Files
Mutual_Fund/frontend/components/admin/sections/nl2sql-console.tsx
T

531 lines
19 KiB
TypeScript

"use client";
import { Activity, Database, History, KeyRound, PlayCircle, ShieldCheck, Zap } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
LoadingBlock,
Select,
TableWrap,
Td,
Th,
TextInput,
cn,
formatTime,
statusTone,
useToast,
} from "@/components/admin/ui";
import { nl2sqlApi, type MaintenanceTask, type QueryHistoryItem } from "@/lib/nl2sql-api";
type Tab = "query" | "history" | "permission" | "ops";
const TABS: { key: Tab; label: string; icon: typeof Activity }[] = [
{ key: "query", label: "查询与健康", icon: Activity },
{ key: "history", label: "查询历史", icon: History },
{ key: "permission", label: "权限总览", icon: KeyRound },
{ key: "ops", label: "运维面板", icon: ShieldCheck },
];
const JOB_TASKS: { value: MaintenanceTask; label: string }[] = [
{ value: "metadata_sync", label: "元数据同步" },
{ value: "vector_cleanup", label: "向量清理" },
{ value: "consistency_check", label: "一致性校验" },
{ value: "history_cleanup", label: "历史清理" },
];
/** 通用 JSON 折叠查看:管理接口字段不固定,按原样展示避免前端臆造字段。 */
function JsonBlock({ data, empty = "暂无数据" }: { data: unknown; empty?: string }) {
if (data === null || data === undefined) return <EmptyState text={empty} />;
if (Array.isArray(data) && data.length === 0) return <EmptyState text={empty} />;
return (
<pre className="max-h-96 overflow-auto rounded-lg bg-slate-50 p-4 text-xs leading-6 text-slate-600">
{JSON.stringify(data, null, 2)}
</pre>
);
}
export function Nl2SqlConsole() {
const toast = useToast();
const [tab, setTab] = useState<Tab>("query");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [question, setQuestion] = useState("");
const [queryResult, setQueryResult] = useState<Record<string, unknown> | null>(null);
const [health, setHealth] = useState<Record<string, unknown> | null>(null);
const [sql, setSql] = useState("");
const [explain, setExplain] = useState<Record<string, unknown>[] | null>(null);
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
const [running, setRunning] = useState<Record<string, unknown>[]>([]);
const [diagnostics, setDiagnostics] = useState<Record<string, unknown> | null>(null);
const [roles, setRoles] = useState<Record<string, unknown>[]>([]);
const [tables, setTables] = useState<Record<string, unknown>[]>([]);
const [columns, setColumns] = useState<Record<string, unknown>[]>([]);
const [sensitive, setSensitive] = useState<Record<string, unknown>[]>([]);
const [selectedRole, setSelectedRole] = useState<string>("");
const [metrics, setMetrics] = useState<Record<string, unknown> | null>(null);
const [runtimeConfig, setRuntimeConfig] = useState<Record<string, unknown> | null>(null);
const [semantics, setSemantics] = useState<Record<string, unknown> | null>(null);
const [jobHistory, setJobHistory] = useState<Record<string, unknown>[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const loadBase = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [healthData, runningData] = await Promise.all([
nl2sqlApi.health(),
nl2sqlApi.running(),
]);
setHealth(healthData);
setRunning(runningData ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "NL2SQL 状态加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadBase();
}, [loadBase]);
const loadHistory = useCallback(async () => {
setLoading(true);
try {
const page = await nl2sqlApi.history({ page: 1, page_size: 20 });
setHistory(page.items ?? []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "查询历史加载失败");
} finally {
setLoading(false);
}
}, []);
const loadPermission = useCallback(async () => {
setLoading(true);
try {
const roleList = await nl2sqlApi.roles();
setRoles(roleList ?? []);
setSensitive((await nl2sqlApi.sensitiveFields()) ?? []);
if (!selectedRole && roleList?.length) {
const firstId = String((roleList[0] as { id?: number }).id ?? "");
setSelectedRole(firstId);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "权限数据加载失败");
} finally {
setLoading(false);
}
}, [selectedRole]);
const loadOps = useCallback(async () => {
setLoading(true);
try {
const [metricsData, configData, semanticsData, jobs] = await Promise.all([
nl2sqlApi.metrics(),
nl2sqlApi.runtimeConfig(),
nl2sqlApi.semantics(),
nl2sqlApi.jobHistory({ page: 1, page_size: 10 }),
]);
setMetrics(metricsData);
setRuntimeConfig(configData);
setSemantics(semanticsData);
setJobHistory(jobs.items ?? []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "运维数据加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (tab === "history") void loadHistory();
if (tab === "permission") void loadPermission();
if (tab === "ops") void loadOps();
}, [tab, loadHistory, loadPermission, loadOps]);
useEffect(() => {
if (!selectedRole) return;
void (async () => {
try {
const [tableList, columnList] = await Promise.all([
nl2sqlApi.roleTables(Number(selectedRole)),
nl2sqlApi.roleColumns(Number(selectedRole)),
]);
setTables(tableList ?? []);
setColumns(columnList ?? []);
} catch {
setTables([]);
setColumns([]);
}
})();
}, [selectedRole]);
const runQuery = useCallback(async () => {
if (!question.trim()) return;
setBusy("query");
try {
const result = await nl2sqlApi.query({ question, include_sql: true, page_size: 50 });
setQueryResult(result as unknown as Record<string, unknown>);
toast("查询完成", "success");
} catch (err) {
toast(err instanceof Error ? err.message : "查询失败", "error");
} finally {
setBusy(null);
}
}, [question, toast]);
const runExplain = useCallback(async () => {
if (!sql.trim()) return;
setBusy("explain");
try {
setExplain(await nl2sqlApi.explain(sql));
} catch (err) {
toast(err instanceof Error ? err.message : "执行计划解析失败", "error");
} finally {
setBusy(null);
}
}, [sql, toast]);
const trigger = useCallback(
async (key: string, action: () => Promise<unknown>, okText: string) => {
setBusy(key);
try {
await action();
toast(okText, "success");
} catch (err) {
toast(err instanceof Error ? err.message : "操作失败", "error");
} finally {
setBusy(null);
}
},
[toast]
);
return (
<div className="space-y-6">
<Card
title="NL2SQL 数据查询"
description="接口以只读展示为主,运维动作需手动触发"
actions={
<div className="flex flex-wrap gap-2">
{TABS.map((item) => (
<Button
key={item.key}
size="sm"
variant={tab === item.key ? "primary" : "secondary"}
onClick={() => setTab(item.key)}
>
<item.icon className="h-3.5 w-3.5" />
{item.label}
</Button>
))}
</div>
}
>
{error && <ErrorState message={error} onRetry={() => void loadBase()} />}
{loading && <LoadingBlock />}
</Card>
{!loading && !error && tab === "query" && (
<div className="grid gap-6 lg:grid-cols-2">
<Card title="自然语言查询" description="POST /nl2sql/query">
<div className="flex gap-2">
<TextInput
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="例如:查询近一个月申购金额最高的前 10 个客户"
/>
<Button
variant="primary"
loading={busy === "query"}
onClick={() => void runQuery()}
disabled={!question.trim()}
>
<PlayCircle className="h-4 w-4" />
查询
</Button>
</div>
{queryResult && (
<div className="mt-4 space-y-3">
{typeof queryResult.summary === "string" && (
<p className="rounded-lg bg-indigo-50 px-3 py-2 text-sm text-indigo-700">
{queryResult.summary}
</p>
)}
{typeof queryResult.generated_sql === "string" && (
<pre className="overflow-auto rounded-lg bg-slate-900 p-3 text-xs leading-6 text-slate-100">
{queryResult.generated_sql}
</pre>
)}
<JsonBlock data={queryResult.rows ?? queryResult} empty="查询无结果" />
</div>
)}
</Card>
<div className="space-y-6">
<Card title="依赖健康" description="GET /nl2sql/health">
<JsonBlock data={health} empty="未返回健康信息" />
</Card>
<Card title="SQL 执行计划" description="POST /nl2sql/query/explain(仅 SELECT,需通过安全校验)">
<div className="flex gap-2">
<TextInput
value={sql}
onChange={(event) => setSql(event.target.value)}
placeholder="SELECT * FROM ..."
/>
<Button loading={busy === "explain"} onClick={() => void runExplain()} disabled={!sql.trim()}>
解析
</Button>
</div>
{explain && <div className="mt-4">{<JsonBlock data={explain} />}</div>}
</Card>
</div>
</div>
)}
{!loading && !error && tab === "history" && (
<div className="space-y-6">
<Card title="查询历史" description="GET /nl2sql/query-history(仅本人)">
{history.length === 0 ? (
<EmptyState text="暂无查询历史" />
) : (
<TableWrap>
<thead>
<tr>
<Th>问题</Th>
<Th>状态</Th>
<Th>行数</Th>
<Th>耗时</Th>
<Th>时间</Th>
<Th>操作</Th>
</tr>
</thead>
<tbody>
{history.map((item) => (
<tr key={item.query_id} className="border-t border-slate-100">
<Td className="max-w-[320px] truncate" title={item.question}>
{item.question}
</Td>
<Td>
<Badge tone={statusTone(item.status)}>{item.status ?? "--"}</Badge>
</Td>
<Td>{item.row_count ?? "--"}</Td>
<Td>{item.elapsed_ms != null ? `${item.elapsed_ms} ms` : "--"}</Td>
<Td>{formatTime(item.create_time)}</Td>
<Td>
<Button
size="sm"
variant="ghost"
onClick={() => void nl2sqlApi.exportHistory(item.query_id)}
>
导出 CSV
</Button>
</Td>
</tr>
))}
</tbody>
</TableWrap>
)}
</Card>
<Card title="运行中查询" description="GET /nl2sql/query/running(管理员)">
<JsonBlock data={running} empty="当前没有运行中的查询" />
</Card>
</div>
)}
{!loading && !error && tab === "permission" && (
<div className="grid gap-6 lg:grid-cols-2">
<Card title="查询角色" description="GET /nl2sql/admin/roles">
{roles.length === 0 ? (
<EmptyState text="暂无角色配置" />
) : (
<TableWrap>
<thead>
<tr>
<Th>角色</Th>
<Th>最大行数</Th>
<Th>状态</Th>
<Th>操作</Th>
</tr>
</thead>
<tbody>
{roles.map((role, index) => {
const id = String((role as { id?: number }).id ?? index);
return (
<tr key={id} className="border-t border-slate-100">
<Td>{String(role.employee_role ?? "--")}</Td>
<Td>{String(role.max_rows ?? "--")}</Td>
<Td>
<Badge tone={role.active === false ? "neutral" : "success"}>
{role.active === false ? "停用" : "启用"}
</Badge>
</Td>
<Td>
<Button
size="sm"
variant={selectedRole === id ? "primary" : "ghost"}
onClick={() => setSelectedRole(id)}
>
查看权限
</Button>
</Td>
</tr>
);
})}
</tbody>
</TableWrap>
)}
</Card>
<div className="space-y-6">
<Card title="表权限" description={`GET /nl2sql/admin/roles/{id}/tables`}>
<JsonBlock data={tables} empty="该角色暂无表权限" />
</Card>
<Card title="列权限" description={`GET /nl2sql/admin/roles/{id}/columns`}>
<JsonBlock data={columns} empty="该角色暂无列权限" />
</Card>
<Card title="敏感字段" description="GET /nl2sql/admin/sensitive-fields">
<JsonBlock data={sensitive} empty="未配置敏感字段" />
</Card>
</div>
</div>
)}
{!loading && !error && tab === "ops" && (
<div className="space-y-6">
<Card
title="运维任务"
description="POST /nl2sql/admin/jobs(幂等,执行后写入任务历史)"
actions={
<div className="flex flex-wrap gap-2">
{JOB_TASKS.map((job) => (
<Button
key={job.value}
size="sm"
loading={busy === job.value}
onClick={() =>
void trigger(
job.value,
async () => {
await nl2sqlApi.runJob(job.value);
const jobs = await nl2sqlApi.jobHistory({ page: 1, page_size: 10 });
setJobHistory(jobs.items ?? []);
},
`${job.label}已执行`
)
}
>
<Zap className="h-3.5 w-3.5" />
{job.label}
</Button>
))}
</div>
}
>
<JsonBlock data={jobHistory} empty="暂无任务执行历史" />
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<Card title="运行指标" description="GET /nl2sql/admin/metrics">
<JsonBlock data={metrics} empty="暂无指标" />
</Card>
<Card
title="语义目录"
description="GET /nl2sql/admin/semantics"
actions={
<Button
size="sm"
loading={busy === "semantics"}
onClick={() =>
void trigger(
"semantics",
async () => setSemantics(await nl2sqlApi.refreshSemantics()),
"语义目录已刷新"
)
}
>
刷新
</Button>
}
>
<JsonBlock data={semantics} empty="暂无语义目录信息" />
</Card>
<Card title="运行参数" description="GET /nl2sql/admin/runtime-config">
<JsonBlock data={runtimeConfig} empty="暂无运行参数" />
</Card>
</div>
<Card
title="运行诊断"
description="GET /nl2sql/query/diagnostics(健康 + 运行中 + 指标 + 最近诊断)"
actions={
<Button
size="sm"
loading={busy === "diagnostics"}
onClick={() =>
void trigger(
"diagnostics",
async () => setDiagnostics(await nl2sqlApi.diagnostics()),
"诊断信息已刷新"
)
}
>
重新诊断
</Button>
}
>
<JsonBlock data={diagnostics} empty="点击「重新诊断」获取信息" />
</Card>
<Card title="缓存失效" description="POST /nl2sql/cache/invalidate(按表名失效查询结果缓存)">
<div className="flex gap-2">
<TextInput
placeholder="输入表名,多个用逗号分隔"
onKeyDown={(event) => {
if (event.key !== "Enter") return;
const names = event.currentTarget.value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (!names.length) return;
void trigger("cache", () => nl2sqlApi.invalidateCache(names), "缓存已失效");
}}
/>
<Select defaultValue="">
<option value="">选择运行中查询以中止</option>
{running.map((item, index) => (
<option key={index} value={String(item.query_id ?? "")}>
{String(item.query_id ?? `查询 ${index + 1}`)}
</option>
))}
</Select>
</div>
<p className="mt-3 flex items-start gap-2 text-xs text-slate-500">
<Database className="mt-0.5 h-3.5 w-3.5 shrink-0" />
回车提交缓存失效;中止运行中查询请使用上方「运行中查询」返回的 query_id 调用 kill 接口。
</p>
</Card>
</div>
)}
<p className={cn("text-xs text-slate-400")}>
说明:本分区按约定只做只读展示与运维触发,权限的增删改表单不在本轮范围内。
</p>
</div>
);
}