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

209 lines
7.4 KiB
TypeScript

"use client";
import { ChevronDown, ChevronRight, Database, Send, Sparkles } from "lucide-react";
import { useState } from "react";
import { Nl2SqlConsole } from "@/components/admin/sections/nl2sql-console";
import {
Badge,
Button,
Card,
ErrorState,
LoadingBlock,
Select,
cn,
useToast,
} from "@/components/admin/ui";
import { errMessage } from "@/lib/admin-api";
import { nl2sqlApi, type Nl2SqlQueryResult } from "@/lib/nl2sql-api";
/** 示例问题,避免员工面对空白输入框不知道怎么问。 */
const SAMPLES = [
"近一个月成交额排名前十的客户有哪些?",
"统计各风险等级客户的数量",
"查询本月新增签约客户名单",
];
/**
* NL2SQL 功能入口。
* 主界面只做「自然语言问数」这一件事;权限与运维面板默认折叠在下方,
* 需要排查问题时再展开,避免员工被配置项淹没。
*/
export function Nl2SqlSection() {
const toast = useToast();
const [question, setQuestion] = useState("");
const [maxRows, setMaxRows] = useState(200);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<Nl2SqlQueryResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [showConsole, setShowConsole] = useState(false);
const run = async (text: string) => {
const value = text.trim();
if (!value || loading) return;
setLoading(true);
setError(null);
setResult(null);
try {
const data = await nl2sqlApi.query({
question: value,
max_rows: maxRows,
});
setResult(data);
} catch (err) {
setError(errMessage(err, "问数失败,请改写问题后重试"));
} finally {
setLoading(false);
}
};
const columns = result?.columns ?? [];
const rows = result?.rows ?? [];
return (
<div className="space-y-5">
<Card
title="智能问数"
description="用自然语言提问,系统自动生成 SQL 并返回结果(受当前账号的数据权限约束)"
>
<div className="space-y-3">
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
void run(question);
}
}}
rows={3}
placeholder="例如:近一个月成交额排名前十的客户有哪些?"
className="w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm leading-6 text-slate-700 focus:border-indigo-400 focus:outline-none"
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{SAMPLES.map((sample) => (
<button
key={sample}
onClick={() => setQuestion(sample)}
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-500 transition-colors hover:border-indigo-300 hover:text-indigo-600"
>
{sample}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">返回上限</span>
<Select
value={String(maxRows)}
onChange={(event) => setMaxRows(Number(event.target.value))}
className="w-24"
>
<option value="50">50</option>
<option value="200">200</option>
<option value="1000">1000</option>
</Select>
<Button variant="primary" loading={loading} onClick={() => void run(question)}>
<Send className="h-4 w-4" />
开始问数
</Button>
</div>
</div>
<p className="text-xs text-slate-400">快捷键 Ctrl / ⌘ + Enter 直接提交</p>
</div>
</Card>
{loading && <LoadingBlock text="正在生成 SQL 并执行查询..." />}
{error && <ErrorState message={error} onRetry={() => void run(question)} />}
{result && !loading && (
<Card
title="查询结果"
description={
result.summary ??
result.answer ??
`共 ${result.row_count ?? rows.length} 行${result.truncated ? "(已截断)" : ""}`
}
actions={
<div className="flex items-center gap-2">
{result.elapsed_ms != null && (
<Badge tone="info">耗时 {result.elapsed_ms} ms</Badge>
)}
{result.query_id && (
<span className="font-mono text-xs text-slate-400">{result.query_id}</span>
)}
</div>
}
>
<div className="space-y-4">
{rows.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">查询未返回数据</p>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-xs text-slate-500">
<tr>
{columns.map((column) => (
<th key={column} className="whitespace-nowrap px-3 py-2 font-medium">
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row, index) => (
<tr key={index} className="hover:bg-slate-50">
{columns.map((column) => (
<td
key={column}
className="whitespace-nowrap px-3 py-2 text-slate-700"
>
{formatCell(row[column])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Card>
)}
<div className="rounded-xl border border-slate-200 bg-white">
<button
onClick={() => setShowConsole(!showConsole)}
className="flex w-full items-center gap-2 px-5 py-3.5 text-left transition-colors hover:bg-slate-50"
>
{showConsole ? (
<ChevronDown className="h-4 w-4 text-slate-400" />
) : (
<ChevronRight className="h-4 w-4 text-slate-400" />
)}
<Database className="h-4 w-4 text-slate-400" />
<span className="text-sm font-semibold text-slate-800">权限与运维面板</span>
<span className="ml-auto flex items-center gap-2 text-xs text-slate-400">
<Sparkles className="h-3.5 w-3.5" />
只读展示 · 排查问题时展开
</span>
</button>
{showConsole && (
<div className={cn("border-t border-slate-100 p-5")}>
<Nl2SqlConsole />
</div>
)}
</div>
</div>
);
}
function formatCell(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}