first commit

This commit is contained in:
2026-03-09 14:46:56 +08:00
commit 62236eb80e
63 changed files with 6143 additions and 0 deletions

12
frontend/Dockerfile Normal file
View 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
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>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
View 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

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View 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
View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }}
/>
);
}

View 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
View 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
View 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>
);

View 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>
</>
);
}

View 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>
</>
);
}

View 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="自定义厂商 BaseURLOpenAI兼容"
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>
);
}

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

23
frontend/tsconfig.json Normal file
View 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" }]
}

View 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
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

15
frontend/vite.config.js Normal file
View 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
View 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 },
},
},
});