first commit
This commit is contained in:
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal 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>Fund Tracer - 电信诈骗资金追踪</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
frontend/nginx.conf
Normal file
16
frontend/nginx.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
3625
frontend/package-lock.json
generated
Normal file
3625
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "fund-tracer-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"antd": "^5.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"recharts": "^2.10.3",
|
||||
"@xyflow/react": "^12.0.0",
|
||||
"zustand": "^4.4.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"axios": "^1.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "~5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
24
frontend/src/App.tsx
Normal file
24
frontend/src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import AppLayout from "./components/Layout";
|
||||
import CaseList from "./pages/CaseList";
|
||||
import CaseDetail from "./pages/CaseDetail";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<CaseList />} />
|
||||
<Route path="/cases/:caseId" element={<CaseDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
87
frontend/src/components/FundFlowGraph.tsx
Normal file
87
frontend/src/components/FundFlowGraph.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import type { FlowGraph } from "../services/api";
|
||||
|
||||
interface Props {
|
||||
graph: FlowGraph | null;
|
||||
}
|
||||
|
||||
function buildNodesAndEdges(graph: FlowGraph | null): { nodes: Node[]; edges: Edge[] } {
|
||||
if (!graph || !graph.nodes.length) return { nodes: [], edges: [] };
|
||||
const nodeMap = new Map<string | number, { x: number; y: number }>();
|
||||
const cols = Math.ceil(Math.sqrt(graph.nodes.length));
|
||||
graph.nodes.forEach((n, i) => {
|
||||
const row = Math.floor(i / cols);
|
||||
const col = i % cols;
|
||||
nodeMap.set(n.id, { x: col * 220, y: row * 120 });
|
||||
});
|
||||
const nodes: Node[] = graph.nodes.map((n, i) => {
|
||||
const pos = nodeMap.get(n.id) ?? { x: (i % 3) * 220, y: Math.floor(i / 3) * 120 };
|
||||
return {
|
||||
id: n.id,
|
||||
type: "default",
|
||||
position: pos,
|
||||
data: { label: n.label },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
};
|
||||
});
|
||||
const edges: Edge[] = graph.edges.map((e, i) => ({
|
||||
id: `e-${e.source}-${e.target}-${i}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: `¥${Number(e.amount).toFixed(2)}`,
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
type: "smoothstep",
|
||||
}));
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export default function FundFlowGraph({ graph }: Props) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = buildNodesAndEdges(graph);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
const onInit = useCallback(() => {
|
||||
const { nodes: n, edges: e } = buildNodesAndEdges(graph);
|
||||
setNodes(n);
|
||||
setEdges(e);
|
||||
}, [graph, setNodes, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
const { nodes: n, edges: e } = buildNodesAndEdges(graph);
|
||||
setNodes(n);
|
||||
setEdges(e);
|
||||
}, [graph, setNodes, setEdges]);
|
||||
|
||||
if (!graph?.nodes?.length) {
|
||||
return <div style={{ padding: 24, color: "#999" }}>暂无资金流向数据,请先上传截图并识别交易</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: 500 }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onInit={onInit}
|
||||
fitView
|
||||
>
|
||||
<Controls />
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/Layout.tsx
Normal file
32
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Outlet, Link, useLocation } from "react-router-dom";
|
||||
import { Layout, Menu } from "antd";
|
||||
import { UnorderedListOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
export default function AppLayout() {
|
||||
const loc = useLocation();
|
||||
const selected = loc.pathname === "/settings" ? "settings" : "cases";
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Header style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
||||
<Link to="/" style={{ color: "#fff", fontWeight: 600, fontSize: 18 }}>
|
||||
Fund Tracer
|
||||
</Link>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
selectedKeys={[selected]}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
items={[
|
||||
{ key: "cases", label: <Link to="/">案件列表</Link>, icon: <UnorderedListOutlined /> },
|
||||
{ key: "settings", label: <Link to="/settings">设置</Link>, icon: <SettingOutlined /> },
|
||||
]}
|
||||
/>
|
||||
</Header>
|
||||
<Content style={{ padding: 24 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ReportSummary.tsx
Normal file
35
frontend/src/components/ReportSummary.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Descriptions, Card } from "antd";
|
||||
import type { AnalysisSummary } from "../services/api";
|
||||
|
||||
interface Props {
|
||||
summary: AnalysisSummary | null;
|
||||
}
|
||||
|
||||
export default function ReportSummary({ summary }: Props) {
|
||||
if (!summary) {
|
||||
return <div style={{ color: "#999" }}>暂无汇总数据,请先上传截图并识别交易</div>;
|
||||
}
|
||||
const byApp = summary.by_app || {};
|
||||
return (
|
||||
<div>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="转出合计">¥{Number(summary.total_out).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="转入合计">¥{Number(summary.total_in).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="净损失">¥{Number(summary.net_loss).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="涉及对方数">{summary.counterparty_count}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{Object.keys(byApp).length > 0 && (
|
||||
<Card title="按APP统计" style={{ marginTop: 16 }}>
|
||||
<Descriptions column={1} size="small">
|
||||
{Object.entries(byApp).map(([app, s]) => (
|
||||
<Descriptions.Item key={app} label={app}>
|
||||
转入 ¥{Number((s as { in_amount: number }).in_amount).toFixed(2)} / 转出 ¥
|
||||
{Number((s as { out_amount: number }).out_amount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/ScreenshotUploader.tsx
Normal file
107
frontend/src/components/ScreenshotUploader.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Upload, List, Button, Card, Tag, message } from "antd";
|
||||
import { InboxOutlined, ThunderboltOutlined } from "@ant-design/icons";
|
||||
import { api, type ScreenshotItem } from "../services/api";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
interface Props {
|
||||
caseId: number;
|
||||
onExtracted?: () => void;
|
||||
}
|
||||
|
||||
export default function ScreenshotUploader({ caseId, onExtracted }: Props) {
|
||||
const [screenshots, setScreenshots] = useState<ScreenshotItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [extractingId, setExtractingId] = useState<number | null>(null);
|
||||
|
||||
const loadScreenshots = async () => {
|
||||
try {
|
||||
const items = await api.screenshots.list(caseId);
|
||||
setScreenshots(items);
|
||||
} catch {
|
||||
message.error("加载截图列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.screenshots.upload(caseId, [file]);
|
||||
await loadScreenshots();
|
||||
message.success("上传成功");
|
||||
} catch {
|
||||
message.error("上传失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return false; // prevent default upload
|
||||
};
|
||||
|
||||
const handleExtract = async (screenshotId: number) => {
|
||||
setExtractingId(screenshotId);
|
||||
try {
|
||||
await api.screenshots.extract(caseId, screenshotId);
|
||||
message.success("识别完成");
|
||||
await loadScreenshots();
|
||||
onExtracted?.();
|
||||
} catch (e: unknown) {
|
||||
const msg = e && typeof e === "object" && "response" in e
|
||||
? (e as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: "识别失败";
|
||||
message.error(msg || "识别失败");
|
||||
} finally {
|
||||
setExtractingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (caseId) loadScreenshots();
|
||||
}, [caseId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dragger
|
||||
multiple
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => { handleUpload(file as File); return false; }}
|
||||
disabled={loading}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽账单截图到此处上传</p>
|
||||
<p className="ant-upload-hint">支持 png / jpg / webp,单次可多选</p>
|
||||
</Dragger>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="link" onClick={loadScreenshots} style={{ padding: 0 }}>刷新截图列表</Button>
|
||||
<List
|
||||
style={{ marginTop: 8 }}
|
||||
grid={{ gutter: 16, column: 4 }}
|
||||
dataSource={screenshots}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Card size="small" title={item.filename}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Tag color={item.status === "extracted" ? "green" : item.status === "failed" ? "red" : "default"}>
|
||||
{item.status === "extracted" ? "已识别" : item.status === "failed" ? "失败" : "待识别"}
|
||||
</Tag>
|
||||
</div>
|
||||
{item.status === "pending" && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={extractingId === item.id}
|
||||
onClick={() => handleExtract(item.id)}
|
||||
>
|
||||
识别交易
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/TransactionTable.tsx
Normal file
59
frontend/src/components/TransactionTable.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Table, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { api, type Transaction } from "../services/api";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
interface Props {
|
||||
caseId: number | undefined;
|
||||
}
|
||||
|
||||
export default function TransactionTable({ caseId }: Props) {
|
||||
const [list, setList] = useState<Transaction[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!caseId) return;
|
||||
setLoading(true);
|
||||
api.transactions
|
||||
.list(caseId)
|
||||
.then(setList)
|
||||
.finally(() => setLoading(false));
|
||||
}, [caseId]);
|
||||
|
||||
const columns: ColumnsType<Transaction> = [
|
||||
{ title: "APP", dataIndex: "app_source", key: "app_source", width: 100 },
|
||||
{ title: "类型", dataIndex: "transaction_type", key: "transaction_type", width: 80 },
|
||||
{ title: "金额", dataIndex: "amount", key: "amount", width: 100, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: "币种", dataIndex: "currency", key: "currency", width: 70 },
|
||||
{ title: "对方名称", dataIndex: "counterparty_name", key: "counterparty_name", ellipsis: true },
|
||||
{ title: "对方账号", dataIndex: "counterparty_account", key: "counterparty_account", ellipsis: true },
|
||||
{ title: "订单号", dataIndex: "order_number", key: "order_number", ellipsis: true },
|
||||
{
|
||||
title: "交易时间",
|
||||
dataIndex: "transaction_time",
|
||||
key: "transaction_time",
|
||||
width: 160,
|
||||
render: (v: string | null) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm") : "-"),
|
||||
},
|
||||
{
|
||||
title: "置信度",
|
||||
dataIndex: "confidence",
|
||||
key: "confidence",
|
||||
width: 80,
|
||||
render: (v: string) =>
|
||||
v === "low" ? <Tag color="orange">低</Tag> : v === "high" ? <Tag color="green">高</Tag> : <Tag>中</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/TransactionTimeline.tsx
Normal file
42
frontend/src/components/TransactionTimeline.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Timeline, Spin } from "antd";
|
||||
import { api, type Transaction } from "../services/api";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
interface Props {
|
||||
caseId: number | undefined;
|
||||
}
|
||||
|
||||
export default function TransactionTimeline({ caseId }: Props) {
|
||||
const [list, setList] = useState<Transaction[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!caseId) return;
|
||||
setLoading(true);
|
||||
api.transactions
|
||||
.list(caseId)
|
||||
.then(setList)
|
||||
.finally(() => setLoading(false));
|
||||
}, [caseId]);
|
||||
|
||||
if (loading) return <Spin />;
|
||||
if (!list.length) return <div style={{ color: "#999" }}>暂无交易记录</div>;
|
||||
|
||||
const items = list
|
||||
.map((t) => ({
|
||||
color: t.transaction_type?.includes("转出") || t.transaction_type?.includes("消费") ? "red" : "green",
|
||||
children: (
|
||||
<div key={t.id}>
|
||||
<strong>{t.app_source}</strong> · {t.transaction_type} ¥{Number(t.amount).toFixed(2)}
|
||||
{t.counterparty_name && ` → ${t.counterparty_name}`}
|
||||
<div style={{ fontSize: 12, color: "#888" }}>
|
||||
{t.transaction_time ? dayjs(t.transaction_time).format("YYYY-MM-DD HH:mm") : "-"}
|
||||
{t.confidence === "low" && <span style={{ marginLeft: 8, color: "orange" }}>低置信度</span>}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return <Timeline items={items} />;
|
||||
}
|
||||
7
frontend/src/index.css
Normal file
7
frontend/src/index.css
Normal file
@@ -0,0 +1,7 @@
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
119
frontend/src/pages/CaseDetail.tsx
Normal file
119
frontend/src/pages/CaseDetail.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Card, Descriptions, Button, Tabs, message, Space, Spin } from "antd";
|
||||
import { ArrowLeftOutlined, DownloadOutlined } from "@ant-design/icons";
|
||||
import { api, type CaseItem, type AnalysisSummary, type FlowGraph } from "../services/api";
|
||||
import ScreenshotUploader from "../components/ScreenshotUploader";
|
||||
import FundFlowGraph from "../components/FundFlowGraph";
|
||||
import TransactionTimeline from "../components/TransactionTimeline";
|
||||
import TransactionTable from "../components/TransactionTable";
|
||||
import ReportSummary from "../components/ReportSummary";
|
||||
|
||||
export default function CaseDetail() {
|
||||
const { caseId } = useParams<{ caseId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const id = caseId ? parseInt(caseId, 10) : 0;
|
||||
const [caseData, setCaseData] = useState<CaseItem | null>(null);
|
||||
const [summary, setSummary] = useState<AnalysisSummary | null>(null);
|
||||
const [graph, setGraph] = useState<FlowGraph | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadCase = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const c = await api.cases.get(id);
|
||||
setCaseData(c);
|
||||
} catch {
|
||||
message.error("加载案件失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAnalysis = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { summary: s, graph: g } = await api.analysis.get(id);
|
||||
setSummary(s);
|
||||
setGraph(g);
|
||||
} catch {
|
||||
setSummary(null);
|
||||
setGraph(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCase();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadAnalysis();
|
||||
}, [id]);
|
||||
|
||||
const refreshAnalysis = () => {
|
||||
loadAnalysis();
|
||||
};
|
||||
|
||||
if (loading) return <Spin />;
|
||||
if (!caseData) return null;
|
||||
const excelUrl = id ? api.export.excelUrl(id) : "";
|
||||
const pdfUrl = id ? api.export.pdfUrl(id) : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/")} style={{ marginBottom: 16 }}>
|
||||
返回列表
|
||||
</Button>
|
||||
{caseData && (
|
||||
<Card title={`案件:${caseData.case_number}`}>
|
||||
<Descriptions column={2}>
|
||||
<Descriptions.Item label="受害人">{caseData.victim_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="总损失">¥{Number(caseData.total_loss).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{caseData.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" span={2}>{caseData.description || "-"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
<Card title="截图上传与识别" style={{ marginTop: 16 }}>
|
||||
<ScreenshotUploader caseId={id} onExtracted={refreshAnalysis} />
|
||||
</Card>
|
||||
<Card
|
||||
title="资金分析"
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={refreshAnalysis}>刷新分析</Button>
|
||||
<Button href={excelUrl} download target="_blank" icon={<DownloadOutlined />}>导出 Excel</Button>
|
||||
<Button href={pdfUrl} download target="_blank" icon={<DownloadOutlined />}>导出 PDF</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: "summary",
|
||||
label: "汇总",
|
||||
children: <ReportSummary summary={summary} />,
|
||||
},
|
||||
{
|
||||
key: "graph",
|
||||
label: "资金流向图",
|
||||
children: <FundFlowGraph graph={graph} />,
|
||||
},
|
||||
{
|
||||
key: "timeline",
|
||||
label: "时间线",
|
||||
children: <TransactionTimeline caseId={id} />,
|
||||
},
|
||||
{
|
||||
key: "table",
|
||||
label: "交易明细表",
|
||||
children: <TransactionTable caseId={id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
frontend/src/pages/CaseList.tsx
Normal file
104
frontend/src/pages/CaseList.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button, Card, Table, Space, Modal, Form, Input, message } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { api, type CaseItem } from "../services/api";
|
||||
|
||||
export default function CaseList() {
|
||||
const [list, setList] = useState<CaseItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const items = await api.cases.list();
|
||||
setList(items);
|
||||
} catch (e) {
|
||||
message.error("加载案件列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const onFinish = async (v: { case_number: string; victim_name: string; description?: string }) => {
|
||||
try {
|
||||
const c = await api.cases.create(v);
|
||||
message.success("案件已创建");
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
setList((prev) => [c, ...prev]);
|
||||
} catch (e) {
|
||||
message.error("创建失败");
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: "案件编号", dataIndex: "case_number", key: "case_number", width: 140 },
|
||||
{ title: "受害人", dataIndex: "victim_name", key: "victim_name", width: 120 },
|
||||
{ title: "总损失", dataIndex: "total_loss", key: "total_loss", width: 100, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: "状态", dataIndex: "status", key: "status", width: 90 },
|
||||
{ title: "创建时间", dataIndex: "created_at", key: "created_at", render: (v: string) => v?.slice(0, 19).replace("T", " ") },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
render: (_: unknown, r: CaseItem) => (
|
||||
<Space>
|
||||
<Link to={`/cases/${r.id}`}>
|
||||
<Button type="link" size="small">查看</Button>
|
||||
</Link>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title="案件列表"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||
新建案件
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
</Card>
|
||||
<Modal
|
||||
title="新建案件"
|
||||
open={modalOpen}
|
||||
onCancel={() => { setModalOpen(false); form.resetFields(); }}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item name="case_number" label="案件编号" rules={[{ required: true }]}>
|
||||
<Input placeholder="如:2024-001" />
|
||||
</Form.Item>
|
||||
<Form.Item name="victim_name" label="受害人姓名" rules={[{ required: true }]}>
|
||||
<Input placeholder="受害人姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="案件描述">
|
||||
<Input.TextArea rows={2} placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">创建</Button>
|
||||
<Button onClick={() => setModalOpen(false)}>取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
143
frontend/src/pages/Settings.tsx
Normal file
143
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Form, Input, Select, Button, Alert, Space, message } from "antd";
|
||||
import {
|
||||
api,
|
||||
type RuntimeSettings,
|
||||
getApiBaseUrl,
|
||||
setApiBaseUrl,
|
||||
} from "../services/api";
|
||||
|
||||
export default function Settings() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [runtime, setRuntime] = useState<RuntimeSettings | null>(null);
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.settings.get();
|
||||
setRuntime(data);
|
||||
form.setFieldsValue({
|
||||
system_api_base_url: getApiBaseUrl(),
|
||||
llm_provider: data.llm_provider,
|
||||
custom_openai_base_url: data.base_urls?.custom_openai || "",
|
||||
custom_openai_model: data.models?.custom_openai || "gpt-4o-mini",
|
||||
});
|
||||
} catch {
|
||||
message.error("加载设置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const onFinish = async (values: {
|
||||
system_api_base_url?: string;
|
||||
llm_provider: "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
openai_api_key?: string;
|
||||
anthropic_api_key?: string;
|
||||
deepseek_api_key?: string;
|
||||
custom_openai_api_key?: string;
|
||||
custom_openai_base_url?: string;
|
||||
custom_openai_model?: string;
|
||||
}) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
setApiBaseUrl(values.system_api_base_url || "");
|
||||
const payload = {
|
||||
llm_provider: values.llm_provider,
|
||||
openai_api_key: values.openai_api_key?.trim() || undefined,
|
||||
anthropic_api_key: values.anthropic_api_key?.trim() || undefined,
|
||||
deepseek_api_key: values.deepseek_api_key?.trim() || undefined,
|
||||
custom_openai_api_key: values.custom_openai_api_key?.trim() || undefined,
|
||||
custom_openai_base_url: values.custom_openai_base_url?.trim() || undefined,
|
||||
custom_openai_model: values.custom_openai_model?.trim() || undefined,
|
||||
};
|
||||
const data = await api.settings.update(payload);
|
||||
setRuntime(data);
|
||||
message.success("设置已保存并生效(含系统 API BaseURL)");
|
||||
} catch {
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="LLM 设置" loading={loading}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="LLM API Key 仅在当前服务进程运行期内生效,不会自动写入磁盘。"
|
||||
/>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
label="系统 API BaseURL(前端请求后端)"
|
||||
name="system_api_base_url"
|
||||
extra="默认 /api;若前后端分离部署,可填如 http://127.0.0.1:8000/api"
|
||||
>
|
||||
<Input placeholder="/api 或 http://127.0.0.1:8000/api" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="默认模型提供商"
|
||||
name="llm_provider"
|
||||
rules={[{ required: true, message: "请选择提供商" }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "OpenAI", value: "openai" },
|
||||
{ label: "Anthropic", value: "anthropic" },
|
||||
{ label: "DeepSeek", value: "deepseek" },
|
||||
{ label: "自定义(OpenAI兼容)", value: "custom_openai" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="OpenAI API Key" name="openai_api_key">
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="Anthropic API Key" name="anthropic_api_key">
|
||||
<Input.Password placeholder="sk-ant-..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="DeepSeek API Key" name="deepseek_api_key">
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="自定义厂商 BaseURL(OpenAI兼容)"
|
||||
name="custom_openai_base_url"
|
||||
extra="例如 https://api.xxx.com/v1"
|
||||
>
|
||||
<Input placeholder="https://api.xxx.com/v1" />
|
||||
</Form.Item>
|
||||
<Form.Item label="自定义厂商 Model" name="custom_openai_model">
|
||||
<Input placeholder="gpt-4o-mini / qwen-vl-plus / ..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="自定义厂商 API Key" name="custom_openai_api_key">
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
<Button onClick={loadSettings}>刷新</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
{runtime && (
|
||||
<Card title="当前状态" size="small" style={{ marginTop: 16 }}>
|
||||
<div>系统 API BaseURL: {getApiBaseUrl()}</div>
|
||||
<div>当前提供商: {runtime.llm_provider}</div>
|
||||
<div>OpenAI Key: {runtime.has_keys.openai ? "已配置" : "未配置"}</div>
|
||||
<div>Anthropic Key: {runtime.has_keys.anthropic ? "已配置" : "未配置"}</div>
|
||||
<div>DeepSeek Key: {runtime.has_keys.deepseek ? "已配置" : "未配置"}</div>
|
||||
<div>自定义厂商 Key: {runtime.has_keys.custom_openai ? "已配置" : "未配置"}</div>
|
||||
<div>自定义厂商 BaseURL: {runtime.base_urls.custom_openai || "-"}</div>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
134
frontend/src/services/api.ts
Normal file
134
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const API_BASE_DEFAULT = "/api";
|
||||
const API_BASE_STORAGE_KEY = "fund_tracer_api_base_url";
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return localStorage.getItem(API_BASE_STORAGE_KEY) || API_BASE_DEFAULT;
|
||||
}
|
||||
|
||||
export function setApiBaseUrl(url: string): void {
|
||||
const value = (url || "").trim() || API_BASE_DEFAULT;
|
||||
localStorage.setItem(API_BASE_STORAGE_KEY, value);
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
timeout: 60000,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
config.baseURL = getApiBaseUrl();
|
||||
return config;
|
||||
});
|
||||
|
||||
export interface CaseItem {
|
||||
id: number;
|
||||
case_number: string;
|
||||
victim_name: string;
|
||||
description: string;
|
||||
total_loss: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
case_id: number;
|
||||
screenshot_id: number;
|
||||
app_source: string;
|
||||
transaction_type: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
counterparty_name: string | null;
|
||||
counterparty_account: string | null;
|
||||
order_number: string | null;
|
||||
transaction_time: string | null;
|
||||
remark: string | null;
|
||||
confidence: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotItem {
|
||||
id: number;
|
||||
case_id: number;
|
||||
filename: string;
|
||||
file_path: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AnalysisSummary {
|
||||
total_out: number;
|
||||
total_in: number;
|
||||
net_loss: number;
|
||||
by_app: Record<string, { in_amount: number; out_amount: number }>;
|
||||
counterparty_count: number;
|
||||
}
|
||||
|
||||
export interface FlowGraph {
|
||||
nodes: Array<{ id: string; label: string; type?: string }>;
|
||||
edges: Array<{ source: string; target: string; amount: number; count?: number }>;
|
||||
}
|
||||
|
||||
export interface RuntimeSettings {
|
||||
llm_provider: "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
providers: Array<"openai" | "anthropic" | "deepseek" | "custom_openai">;
|
||||
models: Record<string, string>;
|
||||
base_urls: Record<string, string>;
|
||||
has_keys: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface RuntimeSettingsUpdate {
|
||||
llm_provider?: "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
openai_api_key?: string;
|
||||
anthropic_api_key?: string;
|
||||
deepseek_api_key?: string;
|
||||
custom_openai_api_key?: string;
|
||||
custom_openai_base_url?: string;
|
||||
custom_openai_model?: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
cases: {
|
||||
list: () => client.get<{ items: CaseItem[] }>("/cases").then((r) => r.data.items),
|
||||
get: (id: number) => client.get<CaseItem>(`/cases/${id}`).then((r) => r.data),
|
||||
create: (data: { case_number: string; victim_name: string; description?: string }) =>
|
||||
client.post<CaseItem>("/cases", data).then((r) => r.data),
|
||||
update: (id: number, data: Partial<CaseItem>) =>
|
||||
client.put<CaseItem>(`/cases/${id}`, data).then((r) => r.data),
|
||||
delete: (id: number) => client.delete(`/cases/${id}`),
|
||||
},
|
||||
screenshots: {
|
||||
list: (caseId: number) =>
|
||||
client.get<{ items: ScreenshotItem[] }>(`/cases/${caseId}/screenshots`).then((r) => r.data.items),
|
||||
upload: (caseId: number, files: File[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append("files", f));
|
||||
return client.post<{ items: ScreenshotItem[] }>(`/cases/${caseId}/screenshots`, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}).then((r) => r.data.items);
|
||||
},
|
||||
extract: (caseId: number, screenshotId: number) =>
|
||||
client.post<{ items: Transaction[] }>(`/cases/${caseId}/screenshots/${screenshotId}/extract`).then((r) => r.data.items),
|
||||
},
|
||||
transactions: {
|
||||
list: (caseId: number) => client.get<Transaction[]>(`/cases/${caseId}/transactions`).then((r) => r.data),
|
||||
},
|
||||
analysis: {
|
||||
get: (caseId: number) =>
|
||||
client.get<{ summary: AnalysisSummary; graph: FlowGraph }>(`/cases/${caseId}/analysis`).then((r) => r.data),
|
||||
},
|
||||
export: {
|
||||
excelUrl: (caseId: number) => `${getApiBaseUrl()}/cases/${caseId}/export/excel`,
|
||||
pdfUrl: (caseId: number) => `${getApiBaseUrl()}/cases/${caseId}/export/pdf`,
|
||||
},
|
||||
settings: {
|
||||
get: () => client.get<RuntimeSettings>("/settings").then((r) => r.data),
|
||||
update: (payload: RuntimeSettingsUpdate) =>
|
||||
client.put<RuntimeSettings>("/settings", payload).then((r) => r.data),
|
||||
},
|
||||
};
|
||||
|
||||
export default client;
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
2
frontend/vite.config.d.ts
vendored
Normal file
2
frontend/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8000", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8000", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user