feat: 0.2.27 - 抽离数据库基础设施模块

- 新增 ias-db crate,集中管理 PostgreSQL 连接池和 SQLx 迁移
- 迁移 ias-service/migrations 到 ias-db/migrations
- ias-main 改用 ias_db::connect_db(),ias-service 只接 PgPool
- 新增操作日志 HTTP 查询接口 (GET /v1/logs, GET /v1/logs/{id})
- 新增操作日志 Web 前端页面,支持按级别/模块/关键词筛选
- 消息队列关键节点增加操作日志写入
- ias-mail 新增 mark_remote_message_seen,IMAP 已读同步
- 工作流配置加载改为文件级容错 (load_configs_with_warnings)
- ias-context 工具调用说明更新为 query_api_docs + call_http_api
- 新增高德地图地理编码 HTTP 能力配置

模块版本: ias-db 0.1.0, ias-ai 0.1.14, ias-context 0.1.6, ias-http 0.1.15, ias-mail 0.1.16, ias-service 0.1.25, ias-main 0.2.27
This commit is contained in:
2026-07-08 17:44:21 +08:00
parent 7d5140c8d2
commit 7deae286ca
83 changed files with 1440 additions and 107 deletions
+8
View File
@@ -5,6 +5,7 @@ import { MailDetailPage } from "./pages/MailDetailPage";
import { ToolsPage } from "./pages/ToolsPage";
import { ConfigsPage } from "./pages/ConfigsPage";
import { ContextSimulatorPage } from "./pages/ContextSimulatorPage";
import { LogsPage } from "./pages/LogsPage";
import { HomePage } from "./pages/HomePage";
import {
GenericWebPageDetailPage,
@@ -49,6 +50,12 @@ function App() {
>
</Link>
<Link
to="/logs"
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
>
</Link>
<Link
to="/context"
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
@@ -69,6 +76,7 @@ function App() {
<Route path="/web/:pageType/:slug" element={<GenericWebPageDetailPage />} />
<Route path="/tools" element={<ToolsPage />} />
<Route path="/configs" element={<ConfigsPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/context" element={<ContextSimulatorPage />} />
</Routes>
</main>
+265
View File
@@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import { useSearchParams, Link } from "react-router-dom";
import { apiGet } from "../lib/api";
interface OperationLog {
id: number;
level: string;
module: string;
message: string;
metadata: Record<string, unknown>;
created_at: string;
}
interface LogListResponse {
success: boolean;
page: number;
per_page: number;
total: number;
logs: OperationLog[];
}
const LEVELS = ["", "info", "warn", "error", "debug"] as const;
const LEVEL_LABELS: Record<string, string> = {
"": "全部级别",
info: "INFO",
warn: "WARN",
error: "ERROR",
debug: "DEBUG",
};
const LEVEL_COLORS: Record<string, string> = {
info: "bg-blue-50 text-blue-700",
warn: "bg-yellow-50 text-yellow-700",
error: "bg-red-50 text-red-700",
debug: "bg-gray-100 text-gray-600",
};
export function LogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState<LogListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const page = Number(searchParams.get("page") || "1");
const perPage = Number(searchParams.get("per_page") || "20");
const level = searchParams.get("level") || "";
const module = searchParams.get("module") || "";
const q = searchParams.get("q") || "";
useEffect(() => {
setLoading(true);
setError(null);
const params: Record<string, string | number> = { page, per_page: perPage };
if (level) params.level = level;
if (module) params.module = module;
if (q) params.q = q;
apiGet<LogListResponse>("/v1/logs", params)
.then(setData)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [page, perPage, level, module, q]);
const updateParams = (updates: Record<string, string>) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
});
if (
updates.level !== undefined ||
updates.module !== undefined ||
updates.q !== undefined
) {
newParams.set("page", "1");
}
setSearchParams(newParams);
};
const hasFilter = level || module || q;
const totalPages = data ? Math.ceil(data.total / data.per_page) : 0;
const formatTime = (t: string) => {
if (!t) return "-";
const d = new Date(t);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
};
const truncate = (s: string, max: number) => {
if (!s) return "";
return s.length > max ? s.slice(0, max - 1) + "…" : s;
};
return (
<div>
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
📋
</h1>
<p className="mt-1.5 text-sm text-gray-500">
{data
? hasFilter
? `筛选结果:共 ${data.total} 条,当前 ${(page - 1) * perPage + 1}${Math.min(page * perPage, data.total)}`
: `${data.total} 条日志`
: "加载中..."}
</p>
</div>
{/* 筛选栏 */}
<form
onSubmit={(e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
updateParams({
level: (form.get("level") as string) || "",
module: (form.get("module") as string) || "",
q: (form.get("q") as string) || "",
});
}}
className="bg-white rounded-xl border border-gray-200 p-4 md:p-5 mb-5"
>
<div className="flex flex-col md:flex-row gap-3">
<div className="flex-1 min-w-0">
<input
name="q"
defaultValue={q}
placeholder="搜索日志消息或元数据…"
className="w-full border border-gray-200 rounded-lg px-3.5 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
/>
</div>
<div className="flex gap-2">
<select
name="level"
defaultValue={level}
className="w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm bg-white focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
>
{LEVELS.map((lv) => (
<option key={lv} value={lv}>
{LEVEL_LABELS[lv]}
</option>
))}
</select>
<input
name="module"
defaultValue={module}
placeholder="模块"
className="w-32 border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
className="inline-flex items-center justify-center px-5 py-2.5 rounded-lg text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 transition shadow-sm"
>
</button>
{hasFilter && (
<Link
to="/logs"
className="inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-600 transition"
>
</Link>
)}
</div>
</div>
</form>
{loading && (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
)}
{error && (
<div className="text-center py-16 text-red-500">
<p>{error}</p>
</div>
)}
{data && !loading && (
<>
<div className="flex flex-col gap-3 mb-4">
{data.logs.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<div className="text-4xl mb-3">📭</div>
<p className="text-base"></p>
</div>
) : (
data.logs.map((log) => (
<div
key={log.id}
className="block bg-white rounded-xl border border-gray-200 p-4 md:p-5 hover:border-blue-400 hover:shadow-md transition group"
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium uppercase ${LEVEL_COLORS[log.level] || "bg-gray-100 text-gray-600"}`}
>
{log.level}
</span>
{log.module && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-purple-50 text-purple-600">
{log.module}
</span>
)}
<span className="text-xs text-gray-400">#{log.id}</span>
</div>
<span className="shrink-0 text-xs text-gray-400 whitespace-nowrap">
{formatTime(log.created_at)}
</span>
</div>
<p className="text-sm text-gray-700 leading-relaxed break-words">
{truncate(log.message, 200)}
</p>
{log.metadata && Object.keys(log.metadata).length > 0 && (
<pre className="mt-2 text-xs text-gray-400 bg-gray-50 rounded-lg p-2 overflow-x-auto max-h-24">
{JSON.stringify(log.metadata, null, 2)}
</pre>
)}
</div>
))
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<nav className="flex items-center justify-between gap-3 mt-5">
{page > 1 ? (
<Link
to={`/logs?${new URLSearchParams({ ...Object.fromEntries(searchParams), page: String(page - 1) })}`}
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition"
>
</Link>
) : (
<span className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">
</span>
)}
<span className="text-sm text-gray-400">
{page} / {totalPages}
</span>
{page < totalPages ? (
<Link
to={`/logs?${new URLSearchParams({ ...Object.fromEntries(searchParams), page: String(page + 1) })}`}
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition"
>
</Link>
) : (
<span className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">
</span>
)}
</nav>
)}
</>
)}
</div>
);
}