209 lines
7.4 KiB
TypeScript
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);
|
|
}
|