feat: 添加线索引擎、NLQ、场景检测、前端界面等核心功能模块
This commit is contained in:
@@ -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>
|
||||
Generated
+1732
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) }),
|
||||
};
|
||||
@@ -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 : "-";
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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; }
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user