feat: 添加线索引擎、NLQ、场景检测、前端界面等核心功能模块

This commit is contained in:
freedakgmail
2026-06-16 08:15:15 +08:00
parent 7b1e2b10a8
commit 48340f6011
62 changed files with 6772 additions and 65 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AIAudit · 本地 AI 内审平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1732
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "aiaudit-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { useState } from "react";
import Dashboard from "./Dashboard";
import Clues from "./Clues";
import NLQ from "./NLQ";
type Tab = "dashboard" | "clues" | "nlq";
export default function App() {
const [tab, setTab] = useState<Tab>("dashboard");
return (
<div className="app">
<header>
<h1>AIAudit · AI </h1>
<div className="sub">穿 · </div>
</header>
<div className="tabs">
<button className={tab === "dashboard" ? "active" : ""} onClick={() => setTab("dashboard")}>线</button>
<button className={tab === "clues" ? "active" : ""} onClick={() => setTab("clues")}>线</button>
<button className={tab === "nlq" ? "active" : ""} onClick={() => setTab("nlq")}></button>
</div>
{tab === "dashboard" && <Dashboard />}
{tab === "clues" && <Clues />}
{tab === "nlq" && <NLQ />}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useState } from "react";
import { api, type Clue } from "./api";
import { L, confidenceLabel, feedbackLabel, scenarioLabel, statusLabel } from "./labels";
export default function ClueDetail({ clue, onBack }: { clue: Clue; onBack: () => void }) {
const [c, setC] = useState<Clue>(clue);
const [assignee, setAssignee] = useState("");
const [note, setNote] = useState("");
const [err, setErr] = useState("");
const actor = "demo_user";
const wrap = (p: Promise<Clue>) =>
p.then(setC).catch((e) => setErr(String(e)));
return (
<div>
<button className="ghost" onClick={onBack}> </button>
<div className="panel" style={{ marginTop: 12 }}>
<div className="row" style={{ justifyContent: "space-between" }}>
<h3 style={{ margin: 0 }}>{c.title}</h3>
<span className={`badge ${c.confidence}`}>{L(confidenceLabel, c.confidence)}</span>
</div>
<div className="status" style={{ margin: "8px 0" }}>
{c.scenario_code} {L(scenarioLabel, c.scenario_code)} · {c.risk_domain} · {c.score.toFixed(2)} · {L(statusLabel, c.status)}
{c.assignee && ` · 承办 ${c.assignee}`}
{c.feedback && ` · 反馈 ${L(feedbackLabel, c.feedback)}`}
</div>
<p>{c.rationale}</p>
<h4></h4>
<div className="evidence">{JSON.stringify(c.evidence, null, 2)}</div>
<h4></h4>
<div className="evidence">{JSON.stringify(c.subjects, null, 2)}</div>
</div>
<div className="panel">
<h4></h4>
{err && <div className="error">{err}</div>}
<div className="row" style={{ marginBottom: 10 }}>
<input placeholder="分派给(审计员)" value={assignee} onChange={(e) => setAssignee(e.target.value)} />
<button className="ghost" disabled={!assignee} onClick={() => wrap(api.assign(c.id, assignee, actor))}></button>
</div>
<div className="row">
<input placeholder="研判意见" value={note} onChange={(e) => setNote(e.target.value)} />
<button className="primary" onClick={() => wrap(api.adjudicate(c.id, true, actor, note))}>·</button>
<button className="ghost" onClick={() => wrap(api.adjudicate(c.id, false, actor, note))}>·</button>
</div>
<p className="status" style={{ marginTop: 10 }}>线R19 线</p>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { api, type Clue } from "./api";
import ClueDetail from "./ClueDetail";
import { L, confidenceLabel, scenarioLabel, statusLabel } from "./labels";
export default function Clues() {
const [clues, setClues] = useState<Clue[]>([]);
const [confidence, setConfidence] = useState("");
const [scenario, setScenario] = useState("");
const [selected, setSelected] = useState<Clue | null>(null);
const [err, setErr] = useState("");
const load = () => {
api
.listClues({ confidence: confidence || undefined, scenario_code: scenario || undefined })
.then(setClues)
.catch((e) => setErr(String(e)));
};
useEffect(load, [confidence, scenario]);
if (selected) {
return <ClueDetail clue={selected} onBack={() => { setSelected(null); load(); }} />;
}
return (
<div>
<div className="filters">
<select value={confidence} onChange={(e) => setConfidence(e.target.value)}>
<option value=""></option>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
<select value={scenario} onChange={(e) => setScenario(e.target.value)}>
<option value=""></option>
<option value="R8">R8 </option>
<option value="R9">R9 </option>
</select>
</div>
{err && <div className="error">{err}</div>}
<div className="panel">
<table>
<thead>
<tr><th></th><th></th><th></th><th></th><th></th><th>()</th></tr>
</thead>
<tbody>
{clues.map((c) => (
<tr key={c.id} className="clickable" onClick={() => setSelected(c)}>
<td>{c.title}</td>
<td>{c.scenario_code} {L(scenarioLabel, c.scenario_code)}</td>
<td><span className={`badge ${c.confidence}`}>{L(confidenceLabel, c.confidence)}</span></td>
<td>{c.score.toFixed(2)}</td>
<td className="status">{L(statusLabel, c.status)}</td>
<td>{c.amount_involved ? (c.amount_involved / 10000).toFixed(1) : "-"}</td>
</tr>
))}
{clues.length === 0 && <tr><td colSpan={6} className="status">线</td></tr>}
</tbody>
</table>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { api, type DashboardSummary } from "./api";
import { L, confidenceLabel, scenarioLabel, statusLabel } from "./labels";
export default function Dashboard() {
const [data, setData] = useState<DashboardSummary | null>(null);
const [err, setErr] = useState<string>("");
useEffect(() => {
api.summary().then(setData).catch((e) => setErr(String(e)));
}, []);
if (err) return <div className="error">{err}</div>;
if (!data) return <div className="status"></div>;
const wan = (data.total_amount_involved / 10000).toFixed(1);
return (
<div>
<div className="cards">
<div className="card"><div className="k">线</div><div className="v">{data.total}</div></div>
<div className="card"><div className="k"></div><div className="v" style={{ color: "var(--high)" }}>{data.by_confidence.high ?? 0}</div></div>
<div className="card"><div className="k">()</div><div className="v">{wan}</div></div>
<div className="card"><div className="k"></div><div className="v">{Object.keys(data.by_scenario).length}</div></div>
</div>
<div className="panel">
<h3></h3>
<div className="row">
{Object.entries(data.by_status).map(([k, v]) => (
<span key={k} className="tag">{L(statusLabel, k)}: {v}</span>
))}
</div>
</div>
<div className="panel">
<h3></h3>
<div className="row">
{Object.entries(data.by_scenario).map(([k, v]) => (
<span key={k} className="tag">{k} {L(scenarioLabel, k)}: {v}</span>
))}
</div>
</div>
<div className="panel">
<h3></h3>
<div className="row">
{Object.entries(data.by_confidence).map(([k, v]) => (
<span key={k} className="tag">{L(confidenceLabel, k)}: {v}</span>
))}
</div>
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useState } from "react";
import { api, type NLQResponse } from "./api";
import { L, providerLabel } from "./labels";
export default function NLQ() {
const [q, setQ] = useState("");
const [resp, setResp] = useState<NLQResponse | null>(null);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const ask = () => {
if (!q) return;
setLoading(true);
setErr("");
api
.nlq(q)
.then(setResp)
.catch((e) => setErr(String(e)))
.finally(() => setLoading(false));
};
return (
<div className="panel">
<h3></h3>
<p className="status"> SQL线</p>
<div className="row">
<input
style={{ flex: 1 }}
placeholder="输入你的问题…"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && ask()}
/>
<button className="primary" onClick={ask} disabled={loading}>
{loading ? "查询中…" : "提问"}
</button>
</div>
{err && <div className="error" style={{ marginTop: 10 }}>{err}</div>}
{resp && (
<div className="answer">
<div className="status" style={{ marginBottom: 8 }}>
{resp.model} · {L(providerLabel, resp.provider)} ·{" "}
{resp.egress ? "⚠ 经公网" : "✓ 本地不出域"}
</div>
{resp.answer}
</div>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
// 后端 API 客户端(开发期经 Vite 代理转发到本机后端,数据不出域)
export interface Clue {
id: string;
title: string;
risk_domain: string;
scenario_code: string;
confidence: "high" | "medium" | "low";
score: number;
status: string;
rationale: string;
evidence: Record<string, unknown>;
subjects: Record<string, unknown>;
amount_involved: number | null;
assignee: string | null;
feedback: string | null;
}
export interface DashboardSummary {
total: number;
by_status: Record<string, number>;
by_confidence: Record<string, number>;
by_scenario: Record<string, number>;
total_amount_involved: number;
}
export interface NLQResponse {
question: string;
answer: string;
provider: string;
model: string;
egress: boolean;
}
async function http<T>(url: string, init?: RequestInit): Promise<T> {
const resp = await fetch(url, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`${resp.status}: ${text}`);
}
return resp.json() as Promise<T>;
}
export const api = {
summary: () => http<DashboardSummary>("/clues/summary"),
listClues: (params: { status?: string; scenario_code?: string; confidence?: string }) => {
const q = new URLSearchParams(
Object.entries(params).filter(([, v]) => v) as [string, string][]
).toString();
return http<Clue[]>(`/clues${q ? `?${q}` : ""}`);
},
getClue: (id: string) => http<Clue>(`/clues/${id}`),
assign: (id: string, assignee: string, actor: string) =>
http<Clue>(`/clues/${id}/assign`, {
method: "POST",
body: JSON.stringify({ assignee, actor }),
}),
adjudicate: (id: string, confirmed: boolean, actor: string, note?: string) =>
http<Clue>(`/clues/${id}/adjudicate`, {
method: "POST",
body: JSON.stringify({ confirmed, actor, note }),
}),
nlq: (question: string) =>
http<NLQResponse>("/nlq", { method: "POST", body: JSON.stringify({ question }) }),
};
+38
View File
@@ -0,0 +1,38 @@
// 后端英文枚举值 → 中文显示映射
export const confidenceLabel: Record<string, string> = {
high: "高置信",
medium: "中置信",
low: "低置信",
};
export const statusLabel: Record<string, string> = {
new: "待处理",
assigned: "已分派",
reviewing: "研判中",
confirmed: "已确认(属实)",
dismissed: "已排除(误报)",
rectifying: "整改中",
transferred: "已移交",
closed: "已销项",
};
export const feedbackLabel: Record<string, string> = {
confirmed: "属实",
false_positive: "误报",
};
export const scenarioLabel: Record<string, string> = {
R8: "政企拆单",
R9: "养卡骗补",
};
export const providerLabel: Record<string, string> = {
mock: "本地Mock",
vllm: "本地vLLM",
dashscope: "公网千问",
};
// 带兜底的取值函数
export const L = (map: Record<string, string>, key: string | null | undefined): string =>
key ? map[key] ?? key : "-";
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+63
View File
@@ -0,0 +1,63 @@
:root {
--bg: #0f1419;
--panel: #1a2330;
--border: #2a3646;
--text: #e6edf3;
--muted: #8b98a9;
--high: #e5484d;
--medium: #f5a623;
--low: #3fb950;
--accent: #2f81f7;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
}
.app { max-width: 1200px; margin: 0 auto; padding: 24px; }
header h1 { font-size: 20px; margin: 0 0 4px; }
header .sub { color: var(--muted); font-size: 13px; margin-bottom: 20px; }
.tabs { display: flex; gap: 8px; margin-bottom: 20px; }
.tabs button {
background: var(--panel); color: var(--text); border: 1px solid var(--border);
padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.tabs button.active { border-color: var(--accent); color: var(--accent); }
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.card .k { color: var(--muted); font-size: 12px; }
.card .v { font-size: 26px; font-weight: 600; margin-top: 6px; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 10px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
tr.clickable:hover { background: rgba(47,129,247,0.08); cursor: pointer; }
.badge { padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.badge.high { background: rgba(229,72,77,0.15); color: var(--high); }
.badge.medium { background: rgba(245,166,35,0.15); color: var(--medium); }
.badge.low { background: rgba(63,185,80,0.15); color: var(--low); }
.status { font-size: 12px; color: var(--muted); }
input, select, textarea {
background: #0d1117; color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; font-size: 14px;
}
button.primary { background: var(--accent); color: white; border: none; padding: 8px 16px; border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--text); border: 1px solid var(--border); padding: 8px 16px; border-radius: 8px; cursor: pointer; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.filters { display: flex; gap: 10px; margin-bottom: 14px; }
.evidence { background: #0d1117; border-radius: 8px; padding: 12px; font-family: monospace; font-size: 12px; white-space: pre-wrap; }
.error { color: var(--high); font-size: 13px; }
.answer { background: #0d1117; border-radius: 8px; padding: 14px; margin-top: 12px; line-height: 1.6; }
.tag { font-size: 11px; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; }
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/cluedetail.tsx","./src/clues.tsx","./src/dashboard.tsx","./src/nlq.tsx","./src/api.ts","./src/labels.ts","./src/main.tsx"],"version":"5.9.3"}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// 开发期通过代理转发到本地后端(数据不出域:仅内网/本机)
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/clues": "http://localhost:8000",
"/datahub": "http://localhost:8000",
"/nlq": "http://localhost:8000",
"/health": "http://localhost:8000",
},
},
});