first commit
This commit is contained in:
6
frontend/src/App.tsx
Normal file
6
frontend/src/App.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useRoutes } from 'react-router-dom';
|
||||
import { routes } from './routes';
|
||||
|
||||
export default function App() {
|
||||
return useRoutes(routes);
|
||||
}
|
||||
26
frontend/src/global.css
Normal file
26
frontend/src/global.css
Normal file
@@ -0,0 +1,26 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, 'Noto Sans SC', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
120
frontend/src/layouts/MainLayout.tsx
Normal file
120
frontend/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, theme, Space } from 'antd';
|
||||
import {
|
||||
FolderOpenOutlined,
|
||||
DashboardOutlined,
|
||||
FileImageOutlined,
|
||||
SwapOutlined,
|
||||
ApartmentOutlined,
|
||||
AuditOutlined,
|
||||
FileTextOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/cases', icon: <FolderOpenOutlined />, label: '案件管理' },
|
||||
{ key: '/cases/1/workspace', icon: <DashboardOutlined />, label: '工作台' },
|
||||
{ key: '/cases/1/screenshots', icon: <FileImageOutlined />, label: '截图管理' },
|
||||
{ key: '/cases/1/transactions', icon: <SwapOutlined />, label: '交易归并' },
|
||||
{ key: '/cases/1/analysis', icon: <ApartmentOutlined />, label: '资金分析' },
|
||||
{ key: '/cases/1/review', icon: <AuditOutlined />, label: '认定复核' },
|
||||
{ key: '/cases/1/reports', icon: <FileTextOutlined />, label: '报告导出' },
|
||||
];
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const selectedKey = menuItems
|
||||
.map((m) => m.key)
|
||||
.filter((k) => location.pathname.startsWith(k))
|
||||
.sort((a, b) => b.length - a.length)[0] || '/cases';
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
theme="light"
|
||||
style={{
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
overflow: 'auto',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<SafetyCertificateOutlined
|
||||
style={{ fontSize: 24, color: token.colorPrimary }}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<Typography.Title
|
||||
level={5}
|
||||
style={{ margin: 0, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
智析反诈
|
||||
</Typography.Title>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'margin-left 0.2s' }}>
|
||||
<Header
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
background: token.colorBgContainer,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 99,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
受害人被骗金额归集智能体
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
演示环境 · v0.1.0
|
||||
</Typography.Text>
|
||||
</Header>
|
||||
<Content
|
||||
style={{
|
||||
margin: 24,
|
||||
minHeight: 'calc(100vh - 64px - 48px)',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
35
frontend/src/main.tsx
Normal file
35
frontend/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ConfigProvider, App as AntApp } from 'antd';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import App from './App';
|
||||
import { queryClient } from './services/queryClient';
|
||||
import './global.css';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntApp>
|
||||
<App />
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
198
frontend/src/mock/data.ts
Normal file
198
frontend/src/mock/data.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type {
|
||||
CaseRecord,
|
||||
EvidenceImage,
|
||||
TransactionRecord,
|
||||
FraudAssessment,
|
||||
FundFlowNode,
|
||||
FundFlowEdge,
|
||||
ExportReport,
|
||||
} from '../types';
|
||||
|
||||
export const mockCases: CaseRecord[] = [
|
||||
{
|
||||
id: '1',
|
||||
caseNo: 'ZA-2026-001538',
|
||||
title: '张某被电信诈骗案',
|
||||
victimName: '张某某',
|
||||
handler: '李警官',
|
||||
status: 'reviewing',
|
||||
imageCount: 24,
|
||||
totalAmount: 186500.0,
|
||||
createdAt: '2026-03-08 09:30:00',
|
||||
updatedAt: '2026-03-10 14:20:00',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
caseNo: 'ZA-2026-001612',
|
||||
title: '王某被投资诈骗案',
|
||||
victimName: '王某',
|
||||
handler: '李警官',
|
||||
status: 'analyzing',
|
||||
imageCount: 18,
|
||||
totalAmount: 0,
|
||||
createdAt: '2026-03-09 11:00:00',
|
||||
updatedAt: '2026-03-10 16:45:00',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
caseNo: 'ZA-2026-001705',
|
||||
title: '刘某某被冒充客服诈骗案',
|
||||
victimName: '刘某某',
|
||||
handler: '陈警官',
|
||||
status: 'completed',
|
||||
imageCount: 12,
|
||||
totalAmount: 53200.0,
|
||||
createdAt: '2026-03-05 15:20:00',
|
||||
updatedAt: '2026-03-07 10:30:00',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
caseNo: 'ZA-2026-001821',
|
||||
title: '赵某被刷单诈骗案',
|
||||
victimName: '赵某',
|
||||
handler: '王警官',
|
||||
status: 'pending',
|
||||
imageCount: 0,
|
||||
totalAmount: 0,
|
||||
createdAt: '2026-03-10 08:15:00',
|
||||
updatedAt: '2026-03-10 08:15:00',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
caseNo: 'ZA-2026-001890',
|
||||
title: '陈某被杀猪盘诈骗案',
|
||||
victimName: '陈某',
|
||||
handler: '李警官',
|
||||
status: 'uploading',
|
||||
imageCount: 8,
|
||||
totalAmount: 0,
|
||||
createdAt: '2026-03-11 07:45:00',
|
||||
updatedAt: '2026-03-11 08:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockImages: EvidenceImage[] = [
|
||||
{ id: 'img-1', caseId: '1', url: '', thumbUrl: '', sourceApp: 'wechat', pageType: 'bill_list', ocrStatus: 'done', hash: 'a1b2', uploadedAt: '2026-03-08 09:35:00' },
|
||||
{ id: 'img-2', caseId: '1', url: '', thumbUrl: '', sourceApp: 'wechat', pageType: 'bill_detail', ocrStatus: 'done', hash: 'c3d4', uploadedAt: '2026-03-08 09:35:00' },
|
||||
{ id: 'img-3', caseId: '1', url: '', thumbUrl: '', sourceApp: 'alipay', pageType: 'bill_list', ocrStatus: 'done', hash: 'e5f6', uploadedAt: '2026-03-08 09:36:00' },
|
||||
{ id: 'img-4', caseId: '1', url: '', thumbUrl: '', sourceApp: 'alipay', pageType: 'transfer_receipt', ocrStatus: 'done', hash: 'g7h8', uploadedAt: '2026-03-08 09:36:00' },
|
||||
{ id: 'img-5', caseId: '1', url: '', thumbUrl: '', sourceApp: 'bank', pageType: 'bill_detail', ocrStatus: 'done', hash: 'i9j0', uploadedAt: '2026-03-08 09:37:00' },
|
||||
{ id: 'img-6', caseId: '1', url: '', thumbUrl: '', sourceApp: 'bank', pageType: 'sms_notice', ocrStatus: 'processing', hash: 'k1l2', uploadedAt: '2026-03-08 09:37:00' },
|
||||
{ id: 'img-7', caseId: '1', url: '', thumbUrl: '', sourceApp: 'digital_wallet', pageType: 'bill_list', ocrStatus: 'done', hash: 'm3n4', uploadedAt: '2026-03-08 09:38:00' },
|
||||
{ id: 'img-8', caseId: '1', url: '', thumbUrl: '', sourceApp: 'wechat', pageType: 'bill_detail', ocrStatus: 'failed', hash: 'o5p6', uploadedAt: '2026-03-08 09:38:00' },
|
||||
];
|
||||
|
||||
export const mockTransactions: TransactionRecord[] = [
|
||||
{
|
||||
id: 'tx-1', caseId: '1', sourceApp: 'bank', tradeTime: '2026-03-06 10:15:00',
|
||||
amount: 50000, direction: 'out', counterpartyName: '支付宝-张某某',
|
||||
counterpartyAccount: '', selfAccountTailNo: '6621', orderNo: 'BK20260306001',
|
||||
remark: '转账至支付宝', evidenceImageId: 'img-5', confidence: 0.95,
|
||||
clusterId: 'cl-1', isDuplicate: false, isTransit: true,
|
||||
},
|
||||
{
|
||||
id: 'tx-2', caseId: '1', sourceApp: 'alipay', tradeTime: '2026-03-06 10:16:00',
|
||||
amount: 50000, direction: 'in', counterpartyName: '银行卡(6621)',
|
||||
counterpartyAccount: '', selfAccountTailNo: '', orderNo: 'AL20260306001',
|
||||
remark: '银行卡转入', evidenceImageId: 'img-3', confidence: 0.92,
|
||||
clusterId: 'cl-1', isDuplicate: true, isTransit: true,
|
||||
},
|
||||
{
|
||||
id: 'tx-3', caseId: '1', sourceApp: 'alipay', tradeTime: '2026-03-06 10:25:00',
|
||||
amount: 50000, direction: 'out', counterpartyName: '李*华',
|
||||
counterpartyAccount: '138****5678', selfAccountTailNo: '', orderNo: 'AL20260306002',
|
||||
remark: '投资款', evidenceImageId: 'img-4', confidence: 0.97,
|
||||
isDuplicate: false, isTransit: false,
|
||||
},
|
||||
{
|
||||
id: 'tx-4', caseId: '1', sourceApp: 'wechat', tradeTime: '2026-03-07 14:30:00',
|
||||
amount: 30000, direction: 'out', counterpartyName: '财富管家-客服',
|
||||
counterpartyAccount: '', selfAccountTailNo: '', orderNo: 'WX20260307001',
|
||||
remark: '手续费', evidenceImageId: 'img-2', confidence: 0.88,
|
||||
isDuplicate: false, isTransit: false,
|
||||
},
|
||||
{
|
||||
id: 'tx-5', caseId: '1', sourceApp: 'wechat', tradeTime: '2026-03-07 16:00:00',
|
||||
amount: 20000, direction: 'out', counterpartyName: '李*华',
|
||||
counterpartyAccount: '138****5678', selfAccountTailNo: '', orderNo: 'WX20260307002',
|
||||
remark: '追加保证金', evidenceImageId: 'img-1', confidence: 0.91,
|
||||
isDuplicate: false, isTransit: false,
|
||||
},
|
||||
{
|
||||
id: 'tx-6', caseId: '1', sourceApp: 'digital_wallet', tradeTime: '2026-03-08 09:00:00',
|
||||
amount: 86500, direction: 'out', counterpartyName: 'USDT-TRC20地址',
|
||||
counterpartyAccount: 'T9yD...Xk3m', selfAccountTailNo: '', orderNo: 'DW20260308001',
|
||||
remark: '提币', evidenceImageId: 'img-7', confidence: 0.85,
|
||||
isDuplicate: false, isTransit: false,
|
||||
},
|
||||
{
|
||||
id: 'tx-7', caseId: '1', sourceApp: 'bank', tradeTime: '2026-03-07 20:00:00',
|
||||
amount: 86500, direction: 'out', counterpartyName: '某数字钱包充值',
|
||||
counterpartyAccount: '', selfAccountTailNo: '6621', orderNo: 'BK20260307002',
|
||||
remark: '充值', evidenceImageId: 'img-5', confidence: 0.90,
|
||||
clusterId: 'cl-2', isDuplicate: false, isTransit: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockAssessments: FraudAssessment[] = [
|
||||
{
|
||||
id: 'fa-1', caseId: '1', transactionId: 'tx-3',
|
||||
transaction: mockTransactions[2],
|
||||
confidenceLevel: 'high', assessedAmount: 50000,
|
||||
reason: '受害人经支付宝向涉诈账户"李*华(138****5678)"转账5万元,标注为"投资款",与笔录中描述的对方诱导投资场景吻合。',
|
||||
reviewStatus: 'confirmed', reviewedBy: '李警官', reviewedAt: '2026-03-10 14:00:00',
|
||||
},
|
||||
{
|
||||
id: 'fa-2', caseId: '1', transactionId: 'tx-4',
|
||||
transaction: mockTransactions[3],
|
||||
confidenceLevel: 'high', assessedAmount: 30000,
|
||||
reason: '受害人经微信向"财富管家-客服"转账3万元,标注为"手续费",属于诈骗常见话术诱导收取的费用。',
|
||||
reviewStatus: 'confirmed', reviewedBy: '李警官', reviewedAt: '2026-03-10 14:05:00',
|
||||
},
|
||||
{
|
||||
id: 'fa-3', caseId: '1', transactionId: 'tx-5',
|
||||
transaction: mockTransactions[4],
|
||||
confidenceLevel: 'high', assessedAmount: 20000,
|
||||
reason: '受害人经微信向同一涉诈账户"李*华"追加转账2万元,标注为"追加保证金",与笔录描述一致。',
|
||||
reviewStatus: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'fa-4', caseId: '1', transactionId: 'tx-6',
|
||||
transaction: mockTransactions[5],
|
||||
confidenceLevel: 'medium', assessedAmount: 86500,
|
||||
reason: '受害人通过数字钱包向链上地址提币86500元,该操作发生在诈骗持续期间。但链上地址与其他已知涉诈线索暂无直接关联,建议结合链上追踪结果确认。',
|
||||
excludeReason: '如经查实该提币为受害人个人操作(如套现自用),应排除。',
|
||||
reviewStatus: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'fa-5', caseId: '1', transactionId: 'tx-1',
|
||||
transaction: mockTransactions[0],
|
||||
confidenceLevel: 'low', assessedAmount: 0,
|
||||
reason: '该笔为受害人本人银行卡向支付宝的转账,属于本人账户间资金中转,不直接计入被骗损失。已在资金路径中标记为中转节点。',
|
||||
excludeReason: '本人账户间互转,仅作为资金路径展示。',
|
||||
reviewStatus: 'confirmed', reviewedBy: '李警官', reviewedAt: '2026-03-10 14:10:00',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockFlowNodes: FundFlowNode[] = [
|
||||
{ id: 'n-bank', label: '银行卡(6621)', type: 'self' },
|
||||
{ id: 'n-alipay', label: '支付宝', type: 'self' },
|
||||
{ id: 'n-wechat', label: '微信支付', type: 'self' },
|
||||
{ id: 'n-wallet', label: '数字钱包', type: 'self' },
|
||||
{ id: 'n-suspect1', label: '李*华\n138****5678', type: 'suspect' },
|
||||
{ id: 'n-suspect2', label: '财富管家-客服', type: 'suspect' },
|
||||
{ id: 'n-chain', label: 'USDT地址\nT9yD...Xk3m', type: 'suspect' },
|
||||
];
|
||||
|
||||
export const mockFlowEdges: FundFlowEdge[] = [
|
||||
{ source: 'n-bank', target: 'n-alipay', amount: 50000, count: 1, tradeTime: '2026-03-06 10:15' },
|
||||
{ source: 'n-alipay', target: 'n-suspect1', amount: 50000, count: 1, tradeTime: '2026-03-06 10:25' },
|
||||
{ source: 'n-wechat', target: 'n-suspect2', amount: 30000, count: 1, tradeTime: '2026-03-07 14:30' },
|
||||
{ source: 'n-wechat', target: 'n-suspect1', amount: 20000, count: 1, tradeTime: '2026-03-07 16:00' },
|
||||
{ source: 'n-bank', target: 'n-wallet', amount: 86500, count: 1, tradeTime: '2026-03-07 20:00' },
|
||||
{ source: 'n-wallet', target: 'n-chain', amount: 86500, count: 1, tradeTime: '2026-03-08 09:00' },
|
||||
];
|
||||
|
||||
export const mockReports: ExportReport[] = [
|
||||
{ id: 'rpt-1', caseId: '1', type: 'excel', url: '#', createdAt: '2026-03-10 15:00:00', version: 1 },
|
||||
];
|
||||
327
frontend/src/pages/analysis/Analysis.tsx
Normal file
327
frontend/src/pages/analysis/Analysis.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, Row, Col, Tag, Typography, Space, Timeline, Statistic, Divider } from 'antd';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
ClockCircleOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { fetchTransactions, fetchFlows } from '../../services/api';
|
||||
|
||||
const nodeColorMap: Record<string, string> = {
|
||||
self: '#1677ff',
|
||||
suspect: '#cf1322',
|
||||
transit: '#fa8c16',
|
||||
unknown: '#8c8c8c',
|
||||
};
|
||||
|
||||
const Analysis: React.FC = () => {
|
||||
const { id = '1' } = useParams();
|
||||
|
||||
const { data: txData } = useQuery({
|
||||
queryKey: ['transactions', id],
|
||||
queryFn: () => fetchTransactions(id),
|
||||
});
|
||||
const { data: flowData } = useQuery({
|
||||
queryKey: ['flows', id],
|
||||
queryFn: () => fetchFlows(id),
|
||||
});
|
||||
|
||||
const mockTransactions = txData?.items ?? [];
|
||||
const mockFlowNodes = flowData?.nodes ?? [];
|
||||
const mockFlowEdges = flowData?.edges ?? [];
|
||||
|
||||
const flowChartOption = useMemo(() => {
|
||||
const nodes = mockFlowNodes.map((n: any) => ({
|
||||
name: n.label,
|
||||
symbolSize: n.type === 'suspect' ? 60 : 50,
|
||||
itemStyle: { color: nodeColorMap[n.type] },
|
||||
label: { show: true, fontSize: 11 },
|
||||
category: n.type === 'self' ? 0 : n.type === 'suspect' ? 1 : 2,
|
||||
}));
|
||||
|
||||
const edges = mockFlowEdges.map((e: any) => {
|
||||
const src = mockFlowNodes.find((n: any) => n.id === e.source);
|
||||
const tgt = mockFlowNodes.find((n: any) => n.id === e.target);
|
||||
return {
|
||||
source: src?.label || '',
|
||||
target: tgt?.label || '',
|
||||
value: e.amount,
|
||||
lineStyle: {
|
||||
width: Math.max(2, Math.min(8, e.amount / 20000)),
|
||||
curveness: 0.2,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: `¥${e.amount.toLocaleString()}`,
|
||||
fontSize: 11,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: {
|
||||
data: ['本人账户', '涉诈账户', '中转账户'],
|
||||
bottom: 10,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
roam: true,
|
||||
draggable: true,
|
||||
force: {
|
||||
repulsion: 400,
|
||||
edgeLength: [120, 200],
|
||||
},
|
||||
categories: [
|
||||
{ name: '本人账户' },
|
||||
{ name: '涉诈账户' },
|
||||
{ name: '中转账户' },
|
||||
],
|
||||
data: nodes,
|
||||
links: edges,
|
||||
edgeSymbol: ['none', 'arrow'],
|
||||
edgeSymbolSize: 10,
|
||||
emphasis: { focus: 'adjacency' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [mockFlowNodes, mockFlowEdges]);
|
||||
|
||||
const timelineChartOption = useMemo(() => {
|
||||
const sorted = [...mockTransactions]
|
||||
.filter((t) => !t.isDuplicate)
|
||||
.sort((a, b) => a.tradeTime.localeCompare(b.tradeTime));
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const p = params[0];
|
||||
return `${p.axisValue}<br/>金额: ¥${Math.abs(p.value).toLocaleString()}`;
|
||||
},
|
||||
},
|
||||
grid: { left: 80, right: 40, top: 40, bottom: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: sorted.map((t) => t.tradeTime.slice(5, 16)),
|
||||
axisLabel: { rotate: 30, fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '金额(元)',
|
||||
axisLabel: {
|
||||
formatter: (v: number) => `¥${(v / 1000).toFixed(0)}K`,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: sorted.map((t) => ({
|
||||
value: t.direction === 'out' ? -t.amount : t.amount,
|
||||
itemStyle: {
|
||||
color: t.direction === 'out' ? '#cf1322' : '#52c41a',
|
||||
},
|
||||
})),
|
||||
barWidth: 30,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [mockTransactions]);
|
||||
|
||||
const validTx = mockTransactions.filter((t) => !t.isDuplicate);
|
||||
const totalFraud = validTx
|
||||
.filter((t) => t.direction === 'out' && !t.isTransit)
|
||||
.reduce((s, t) => s + t.amount, 0);
|
||||
|
||||
const sortedTx = [...validTx].sort((a, b) =>
|
||||
a.tradeTime.localeCompare(b.tradeTime),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="疑似被骗总额(去重去中转)"
|
||||
value={totalFraud}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#cf1322', fontSize: 28 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="涉诈对手方"
|
||||
value={3}
|
||||
suffix="个"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="有效交易"
|
||||
value={validTx.length}
|
||||
suffix={`/ ${mockTransactions.length} 笔`}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={14}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ApartmentOutlined />
|
||||
<span>资金流转关系图</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Tag color="#1677ff">本人账户</Tag>
|
||||
<Tag color="#cf1322">涉诈账户</Tag>
|
||||
<Tag color="#fa8c16">中转账户</Tag>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<ReactECharts
|
||||
option={flowChartOption}
|
||||
style={{ height: 420 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<span>交易时间轴</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<ReactECharts
|
||||
option={timelineChartOption}
|
||||
style={{ height: 300 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
红色柱表示支出,绿色柱表示收入。中转和重复记录已排除。
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={10}>
|
||||
<Card
|
||||
title="交易时间线"
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Timeline
|
||||
items={sortedTx.map((tx) => ({
|
||||
color: tx.direction === 'out'
|
||||
? tx.isTransit
|
||||
? 'orange'
|
||||
: 'red'
|
||||
: 'green',
|
||||
children: (
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{tx.tradeTime}
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<Space>
|
||||
<Tag
|
||||
color={
|
||||
tx.sourceApp === 'wechat'
|
||||
? 'green'
|
||||
: tx.sourceApp === 'alipay'
|
||||
? 'blue'
|
||||
: tx.sourceApp === 'bank'
|
||||
? 'purple'
|
||||
: 'orange'
|
||||
}
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{tx.sourceApp === 'wechat'
|
||||
? '微信'
|
||||
: tx.sourceApp === 'alipay'
|
||||
? '支付宝'
|
||||
: tx.sourceApp === 'bank'
|
||||
? '银行'
|
||||
: '数字钱包'}
|
||||
</Tag>
|
||||
{tx.isTransit && <Tag color="orange">中转</Tag>}
|
||||
</Space>
|
||||
<br />
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{
|
||||
color: tx.direction === 'out' ? '#cf1322' : '#389e0d',
|
||||
}}
|
||||
>
|
||||
{tx.direction === 'out' ? '-' : '+'}¥
|
||||
{tx.amount.toLocaleString()}
|
||||
</Typography.Text>
|
||||
<Typography.Text style={{ marginLeft: 8, fontSize: 13 }}>
|
||||
→ {tx.counterpartyName}
|
||||
</Typography.Text>
|
||||
{tx.remark && (
|
||||
<>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{tx.remark}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="收款方聚合">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{[
|
||||
{ name: '李*华 (138****5678)', amount: 70000, count: 2, risk: 'high' },
|
||||
{ name: 'USDT地址 T9yD...Xk3m', amount: 86500, count: 1, risk: 'medium' },
|
||||
{ name: '财富管家-客服', amount: 30000, count: 1, risk: 'high' },
|
||||
].map((item, idx) => (
|
||||
<Card key={idx} size="small" variant="borderless" style={{ background: '#fafafa' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Typography.Text strong>{item.name}</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.count} 笔交易
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
¥{item.amount.toLocaleString()}
|
||||
</Typography.Text>
|
||||
<Tag color={item.risk === 'high' ? 'red' : 'orange'}>
|
||||
{item.risk === 'high' ? '高风险' : '中风险'}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analysis;
|
||||
256
frontend/src/pages/cases/CaseList.tsx
Normal file
256
frontend/src/pages/cases/CaseList.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Tag,
|
||||
Space,
|
||||
Input,
|
||||
Typography,
|
||||
Modal,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
FolderOpenOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { CaseRecord, CaseStatus } from '../../types';
|
||||
import { fetchCases, createCase } from '../../services/api';
|
||||
|
||||
const statusConfig: Record<CaseStatus, { color: string; label: string }> = {
|
||||
pending: { color: 'default', label: '待处理' },
|
||||
uploading: { color: 'processing', label: '上传中' },
|
||||
analyzing: { color: 'blue', label: '分析中' },
|
||||
reviewing: { color: 'orange', label: '待复核' },
|
||||
completed: { color: 'green', label: '已完成' },
|
||||
};
|
||||
|
||||
const CaseList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['cases', search],
|
||||
queryFn: () => fetchCases({ search: search || undefined }),
|
||||
});
|
||||
|
||||
const cases = data?.items ?? [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createCase,
|
||||
onSuccess: () => {
|
||||
message.success('案件创建成功');
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const totalCases = cases.length;
|
||||
const pendingReview = cases.filter((c) => c.status === 'reviewing').length;
|
||||
const completedCount = cases.filter((c) => c.status === 'completed').length;
|
||||
const analyzingCount = cases.filter(
|
||||
(c) => c.status === 'analyzing' || c.status === 'uploading',
|
||||
).length;
|
||||
|
||||
const columns: ColumnsType<CaseRecord> = [
|
||||
{
|
||||
title: '案件编号',
|
||||
dataIndex: 'caseNo',
|
||||
width: 180,
|
||||
render: (text, record) => (
|
||||
<a onClick={() => navigate(`/cases/${record.id}/workspace`)}>{text}</a>
|
||||
),
|
||||
},
|
||||
{ title: '案件名称', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '受害人', dataIndex: 'victimName', width: 100 },
|
||||
{ title: '承办人', dataIndex: 'handler', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: CaseStatus) => (
|
||||
<Tag color={statusConfig[s].color}>{statusConfig[s].label}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '截图数',
|
||||
dataIndex: 'imageCount',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '识别金额(元)',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (v: number) =>
|
||||
v > 0 ? (
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
¥{v.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/cases/${record.id}/workspace`)}
|
||||
>
|
||||
进入工作台
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="全部案件"
|
||||
value={totalCases}
|
||||
prefix={<FolderOpenOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="处理中"
|
||||
value={analyzingCount}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
valueStyle={{ color: '#1677ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="待复核"
|
||||
value={pendingReview}
|
||||
prefix={<ExclamationCircleOutlined />}
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="已完成"
|
||||
value={completedCount}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="案件列表"
|
||||
extra={
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索案件编号、名称"
|
||||
prefix={<SearchOutlined />}
|
||||
style={{ width: 240 }}
|
||||
allowClear
|
||||
onPressEnter={(e) => setSearch((e.target as HTMLInputElement).value)}
|
||||
onChange={(e) => !e.target.value && setSearch('')}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
新建案件
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={cases}
|
||||
loading={isLoading}
|
||||
pagination={{ pageSize: 10, showSizeChanger: true, showTotal: (t) => `共 ${t} 条` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新建案件"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => {
|
||||
form.validateFields().then((values) => createMutation.mutate(values));
|
||||
}}
|
||||
confirmLoading={createMutation.isPending}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
label="案件编号"
|
||||
name="caseNo"
|
||||
rules={[{ required: true, message: '请输入案件编号' }]}
|
||||
>
|
||||
<Input placeholder="如:ZA-2026-001XXX" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="案件名称"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '请输入案件名称' }]}
|
||||
>
|
||||
<Input placeholder="如:张某被电信诈骗案" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="受害人姓名"
|
||||
name="victimName"
|
||||
rules={[{ required: true, message: '请输入受害人姓名' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="承办人" name="handler">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CaseList;
|
||||
329
frontend/src/pages/reports/Reports.tsx
Normal file
329
frontend/src/pages/reports/Reports.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Divider,
|
||||
Descriptions,
|
||||
Select,
|
||||
Checkbox,
|
||||
message,
|
||||
Steps,
|
||||
Result,
|
||||
} from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
FileExcelOutlined,
|
||||
FilePdfOutlined,
|
||||
FileWordOutlined,
|
||||
DownloadOutlined,
|
||||
PrinterOutlined,
|
||||
HistoryOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { fetchCase, fetchAssessments, fetchReports, generateReport, getReportDownloadUrl } from '../../services/api';
|
||||
|
||||
const Reports: React.FC = () => {
|
||||
const { id = '1' } = useParams();
|
||||
const qc = useQueryClient();
|
||||
const [generated, setGenerated] = useState(false);
|
||||
|
||||
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
|
||||
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
|
||||
const { data: reportsData } = useQuery({ queryKey: ['reports', id], queryFn: () => fetchReports(id) });
|
||||
|
||||
const allAssessments = assessData?.items ?? [];
|
||||
const reportsList = reportsData?.items ?? [];
|
||||
|
||||
const confirmedAssessments = allAssessments.filter(
|
||||
(a) => a.reviewStatus === 'confirmed' && a.assessedAmount > 0,
|
||||
);
|
||||
const totalConfirmed = confirmedAssessments.reduce(
|
||||
(s, a) => s + a.assessedAmount,
|
||||
0,
|
||||
);
|
||||
|
||||
const genMutation = useMutation({
|
||||
mutationFn: (reportType: string) => generateReport(id, { report_type: reportType }),
|
||||
onSuccess: () => {
|
||||
setGenerated(true);
|
||||
qc.invalidateQueries({ queryKey: ['reports', id] });
|
||||
message.success('报告生成成功');
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentCase) return null;
|
||||
|
||||
const historyColumns: ColumnsType<(typeof mockReports)[0]> = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
width: 100,
|
||||
render: (t: string) => {
|
||||
const map: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
|
||||
pdf: { icon: <FilePdfOutlined />, label: 'PDF', color: 'red' },
|
||||
excel: { icon: <FileExcelOutlined />, label: 'Excel', color: 'green' },
|
||||
word: { icon: <FileWordOutlined />, label: 'Word', color: 'blue' },
|
||||
};
|
||||
const cfg = map[t] || map.pdf;
|
||||
return <Tag icon={cfg.icon} color={cfg.color}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '版本',
|
||||
dataIndex: 'version',
|
||||
width: 80,
|
||||
render: (v: number) => `v${v}`,
|
||||
},
|
||||
{
|
||||
title: '生成时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<DownloadOutlined />}>
|
||||
下载
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="已确认被骗金额"
|
||||
value={totalConfirmed}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#cf1322', fontSize: 24 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="已确认交易笔数"
|
||||
value={confirmedAssessments.length}
|
||||
suffix="笔"
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="历史报告"
|
||||
value={reportsList.length}
|
||||
suffix="份"
|
||||
prefix={<HistoryOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={14}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
<span>生成报告</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Descriptions column={2} size="small" style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="案件编号">
|
||||
{currentCase.caseNo}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="案件名称">
|
||||
{currentCase.title}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="受害人">
|
||||
{currentCase.victimName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="承办人">
|
||||
{currentCase.handler}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已确认金额">
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
¥{totalConfirmed.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="确认笔数">
|
||||
{confirmedAssessments.length} 笔
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography.Text strong>导出格式:</Typography.Text>
|
||||
<div style={{ margin: '12px 0 24px' }}>
|
||||
<Space size={16}>
|
||||
<Card
|
||||
hoverable
|
||||
style={{ width: 140, textAlign: 'center' }}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<FileExcelOutlined style={{ fontSize: 32, color: '#52c41a' }} />
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text>Excel 汇总表</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox defaultChecked>选择</Checkbox>
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
hoverable
|
||||
style={{ width: 140, textAlign: 'center' }}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<FilePdfOutlined style={{ fontSize: 32, color: '#cf1322' }} />
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text>PDF 报告</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox defaultChecked>选择</Checkbox>
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
hoverable
|
||||
style={{ width: 140, textAlign: 'center' }}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<FileWordOutlined style={{ fontSize: 32, color: '#1677ff' }} />
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text>Word 文书</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox>选择</Checkbox>
|
||||
</div>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Typography.Text strong>报告内容:</Typography.Text>
|
||||
<div style={{ margin: '12px 0 24px' }}>
|
||||
<Space direction="vertical">
|
||||
<Checkbox defaultChecked>被骗金额汇总表</Checkbox>
|
||||
<Checkbox defaultChecked>交易明细清单(含证据索引)</Checkbox>
|
||||
<Checkbox defaultChecked>资金流转路径图</Checkbox>
|
||||
<Checkbox defaultChecked>交易时间轴</Checkbox>
|
||||
<Checkbox defaultChecked>认定理由与排除说明</Checkbox>
|
||||
<Checkbox>笔录辅助问询建议</Checkbox>
|
||||
<Checkbox>原始截图附件</Checkbox>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{!generated ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<FileTextOutlined />}
|
||||
loading={genMutation.isPending}
|
||||
onClick={() => genMutation.mutate('excel')}
|
||||
block
|
||||
>
|
||||
{genMutation.isPending ? '正在生成报告...' : '生成报告'}
|
||||
</Button>
|
||||
) : (
|
||||
<Result
|
||||
status="success"
|
||||
title="报告已生成"
|
||||
subTitle="您可以下载或打印以下报告文件"
|
||||
extra={[
|
||||
<Button
|
||||
key="excel"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => message.info('演示模式:下载 Excel')}
|
||||
>
|
||||
下载 Excel
|
||||
</Button>,
|
||||
<Button
|
||||
key="pdf"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => message.info('演示模式:下载 PDF')}
|
||||
>
|
||||
下载 PDF
|
||||
</Button>,
|
||||
<Button
|
||||
key="print"
|
||||
icon={<PrinterOutlined />}
|
||||
onClick={() => message.info('演示模式:打印')}
|
||||
>
|
||||
打印
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={10}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HistoryOutlined />
|
||||
<span>历史报告</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={historyColumns}
|
||||
dataSource={reportsList}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="报告预览" style={{ minHeight: 300 }}>
|
||||
<div
|
||||
style={{
|
||||
background: '#fafafa',
|
||||
border: '1px dashed #d9d9d9',
|
||||
borderRadius: 8,
|
||||
padding: 24,
|
||||
textAlign: 'center',
|
||||
minHeight: 240,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<FileTextOutlined style={{ fontSize: 48, color: '#bfbfbf' }} />
|
||||
<Typography.Text type="secondary" style={{ marginTop: 12 }}>
|
||||
{generated
|
||||
? '点击左侧"下载"查看完整报告'
|
||||
: '生成报告后可在此预览'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reports;
|
||||
443
frontend/src/pages/review/Review.tsx
Normal file
443
frontend/src/pages/review/Review.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Space,
|
||||
Button,
|
||||
Modal,
|
||||
Input,
|
||||
Select,
|
||||
Descriptions,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Alert,
|
||||
Segmented,
|
||||
Tooltip,
|
||||
message,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
AuditOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
EyeOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { FraudAssessment, ConfidenceLevel } from '../../types';
|
||||
import { fetchAssessments, submitReview, fetchInquirySuggestions } from '../../services/api';
|
||||
|
||||
const confidenceConfig: Record<
|
||||
ConfidenceLevel,
|
||||
{ color: string; label: string }
|
||||
> = {
|
||||
high: { color: 'green', label: '高置信' },
|
||||
medium: { color: 'orange', label: '中置信' },
|
||||
low: { color: 'default', label: '低置信' },
|
||||
};
|
||||
|
||||
const reviewStatusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
|
||||
pending: { color: 'orange', label: '待复核', icon: <ExclamationCircleOutlined /> },
|
||||
confirmed: { color: 'green', label: '已确认', icon: <CheckCircleOutlined /> },
|
||||
rejected: { color: 'red', label: '已排除', icon: <CloseCircleOutlined /> },
|
||||
needs_info: { color: 'blue', label: '需补充', icon: <QuestionCircleOutlined /> },
|
||||
};
|
||||
|
||||
const Review: React.FC = () => {
|
||||
const { id = '1' } = useParams();
|
||||
const qc = useQueryClient();
|
||||
const [filterLevel, setFilterLevel] = useState<string>('all');
|
||||
const [reviewModal, setReviewModal] = useState<FraudAssessment | null>(null);
|
||||
const [reviewAction, setReviewAction] = useState<string>('confirmed');
|
||||
const [reviewNote, setReviewNote] = useState('');
|
||||
|
||||
const { data: assessData } = useQuery({
|
||||
queryKey: ['assessments', id],
|
||||
queryFn: () => fetchAssessments(id),
|
||||
});
|
||||
const { data: suggestionsData } = useQuery({
|
||||
queryKey: ['suggestions', id],
|
||||
queryFn: () => fetchInquirySuggestions(id),
|
||||
});
|
||||
|
||||
const allAssessments = assessData?.items ?? [];
|
||||
const suggestions = suggestionsData?.suggestions ?? [];
|
||||
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: (params: { assessmentId: string; body: any }) =>
|
||||
submitReview(params.assessmentId, params.body),
|
||||
onSuccess: () => {
|
||||
message.success('复核结果已保存');
|
||||
qc.invalidateQueries({ queryKey: ['assessments', id] });
|
||||
setReviewModal(null);
|
||||
},
|
||||
});
|
||||
|
||||
const data =
|
||||
filterLevel === 'all'
|
||||
? allAssessments
|
||||
: allAssessments.filter((a) => a.confidenceLevel === filterLevel);
|
||||
|
||||
const totalConfirmed = allAssessments
|
||||
.filter((a) => a.reviewStatus === 'confirmed' && a.assessedAmount > 0)
|
||||
.reduce((s, a) => s + a.assessedAmount, 0);
|
||||
const totalPending = allAssessments
|
||||
.filter((a) => a.reviewStatus === 'pending')
|
||||
.reduce((s, a) => s + a.assessedAmount, 0);
|
||||
const pendingCount = allAssessments.filter(
|
||||
(a) => a.reviewStatus === 'pending',
|
||||
).length;
|
||||
const confirmedCount = allAssessments.filter(
|
||||
(a) => a.reviewStatus === 'confirmed',
|
||||
).length;
|
||||
|
||||
const columns: ColumnsType<FraudAssessment> = [
|
||||
{
|
||||
title: '交易时间',
|
||||
width: 170,
|
||||
render: (_, r) => r.transaction.tradeTime,
|
||||
sorter: (a, b) =>
|
||||
a.transaction.tradeTime.localeCompare(b.transaction.tradeTime),
|
||||
defaultSortOrder: 'ascend',
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
width: 90,
|
||||
render: (_, r) => {
|
||||
const app = r.transaction.sourceApp;
|
||||
const m: Record<string, { l: string; c: string }> = {
|
||||
wechat: { l: '微信', c: 'green' },
|
||||
alipay: { l: '支付宝', c: 'blue' },
|
||||
bank: { l: '银行', c: 'purple' },
|
||||
digital_wallet: { l: '钱包', c: 'orange' },
|
||||
other: { l: '其他', c: 'default' },
|
||||
};
|
||||
return <Tag color={m[app].c}>{m[app].l}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '认定金额(元)',
|
||||
dataIndex: 'assessedAmount',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (v: number) =>
|
||||
v > 0 ? (
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
¥{v.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">不计入</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '对方',
|
||||
render: (_, r) => r.transaction.counterpartyName,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidenceLevel',
|
||||
width: 90,
|
||||
render: (level: ConfidenceLevel) => (
|
||||
<Tag color={confidenceConfig[level].color}>
|
||||
{confidenceConfig[level].label}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '认定理由',
|
||||
dataIndex: 'reason',
|
||||
ellipsis: true,
|
||||
width: 280,
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text}>
|
||||
<Typography.Text style={{ fontSize: 13 }}>{text}</Typography.Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '复核状态',
|
||||
dataIndex: 'reviewStatus',
|
||||
width: 100,
|
||||
render: (s: string) => {
|
||||
const cfg = reviewStatusConfig[s];
|
||||
return (
|
||||
<Tag color={cfg.color} icon={cfg.icon}>
|
||||
{cfg.label}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_, r) =>
|
||||
r.reviewStatus === 'pending' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setReviewModal(r);
|
||||
setReviewAction('confirmed');
|
||||
setReviewNote('');
|
||||
}}
|
||||
>
|
||||
复核
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
setReviewModal(r);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="已确认被骗金额"
|
||||
value={totalConfirmed}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#cf1322', fontSize: 24 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="待确认金额"
|
||||
value={totalPending}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#fa8c16', fontSize: 24 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="已复核"
|
||||
value={confirmedCount}
|
||||
suffix={`/ ${allAssessments.length} 笔`}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="待复核"
|
||||
value={pendingCount}
|
||||
suffix="笔"
|
||||
prefix={<ExclamationCircleOutlined />}
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{pendingCount > 0 && (
|
||||
<Alert
|
||||
message={`${pendingCount} 笔交易需要人工复核确认`}
|
||||
description="系统已根据OCR识别、交易归并和规则引擎完成自动分析。高置信项可快速确认,中/低置信项建议仔细核对后决定。"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<AuditOutlined />
|
||||
<span>认定复核</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Segmented
|
||||
value={filterLevel}
|
||||
onChange={(v) => setFilterLevel(v as string)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '高置信', value: 'high' },
|
||||
{ label: '中置信', value: 'medium' },
|
||||
{ label: '低置信', value: 'low' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
<span>笔录辅助问询建议</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ background: '#fffbe6', borderColor: '#ffe58f' }}
|
||||
>
|
||||
<Typography.Paragraph>
|
||||
基于当前识别和分析结果,建议在笔录中向受害人追问以下问题:
|
||||
</Typography.Paragraph>
|
||||
<ol style={{ paddingLeft: 20, lineHeight: 2.2 }}>
|
||||
{suggestions.map((s, idx) => (
|
||||
<li key={idx}>
|
||||
<Typography.Text>{s}</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
reviewModal?.reviewStatus === 'pending'
|
||||
? '复核认定'
|
||||
: '认定详情'
|
||||
}
|
||||
open={!!reviewModal}
|
||||
onCancel={() => setReviewModal(null)}
|
||||
footer={
|
||||
reviewModal?.reviewStatus === 'pending'
|
||||
? [
|
||||
<Button key="cancel" onClick={() => setReviewModal(null)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={reviewMutation.isPending}
|
||||
onClick={() => {
|
||||
reviewMutation.mutate({
|
||||
assessmentId: reviewModal!.id,
|
||||
body: {
|
||||
review_status: reviewAction,
|
||||
review_note: reviewNote,
|
||||
reviewed_by: 'demo_user',
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
提交复核
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="close" onClick={() => setReviewModal(null)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
{reviewModal && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="交易时间">
|
||||
{reviewModal.transaction.tradeTime}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
¥{reviewModal.assessedAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对方">
|
||||
{reviewModal.transaction.counterpartyName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源APP">
|
||||
{reviewModal.transaction.sourceApp}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="置信等级">
|
||||
<Tag color={confidenceConfig[reviewModal.confidenceLevel].color}>
|
||||
{confidenceConfig[reviewModal.confidenceLevel].label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16, background: '#f6ffed', borderColor: '#b7eb8f' }}>
|
||||
<Typography.Text strong>认定理由:</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text>{reviewModal.reason}</Typography.Text>
|
||||
</Card>
|
||||
|
||||
{reviewModal.excludeReason && (
|
||||
<Card size="small" style={{ marginBottom: 16, background: '#fff2e8', borderColor: '#ffbb96' }}>
|
||||
<Typography.Text strong>排除条件:</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text>{reviewModal.excludeReason}</Typography.Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{reviewModal.reviewStatus === 'pending' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Typography.Text strong>复核决定:</Typography.Text>
|
||||
<Select
|
||||
value={reviewAction}
|
||||
onChange={setReviewAction}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ label: '确认 - 该笔计入被骗金额', value: 'confirmed' },
|
||||
{ label: '排除 - 该笔不计入被骗金额', value: 'rejected' },
|
||||
{ label: '需补充 - 需进一步调查确认', value: 'needs_info' },
|
||||
]}
|
||||
/>
|
||||
<Typography.Text strong>备注说明:</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={reviewNote}
|
||||
onChange={(e) => setReviewNote(e.target.value)}
|
||||
placeholder="请输入复核意见或备注..."
|
||||
/>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
|
||||
{reviewModal.reviewStatus !== 'pending' && reviewModal.reviewedBy && (
|
||||
<Descriptions column={2} size="small" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="复核人">
|
||||
{reviewModal.reviewedBy}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="复核时间">
|
||||
{reviewModal.reviewedAt}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Review;
|
||||
308
frontend/src/pages/screenshots/Screenshots.tsx
Normal file
308
frontend/src/pages/screenshots/Screenshots.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Typography,
|
||||
Select,
|
||||
Space,
|
||||
Badge,
|
||||
Descriptions,
|
||||
Empty,
|
||||
List,
|
||||
Drawer,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Segmented,
|
||||
} from 'antd';
|
||||
import {
|
||||
FileImageOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
CloseCircleOutlined,
|
||||
EditOutlined,
|
||||
ZoomInOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { EvidenceImage, SourceApp, PageType } from '../../types';
|
||||
import { fetchImages } from '../../services/api';
|
||||
|
||||
const appLabel: Record<SourceApp, { label: string; color: string }> = {
|
||||
wechat: { label: '微信', color: 'green' },
|
||||
alipay: { label: '支付宝', color: 'blue' },
|
||||
bank: { label: '银行', color: 'purple' },
|
||||
digital_wallet: { label: '数字钱包', color: 'orange' },
|
||||
other: { label: '其他', color: 'default' },
|
||||
};
|
||||
|
||||
const pageTypeLabel: Record<PageType, string> = {
|
||||
bill_list: '账单列表',
|
||||
bill_detail: '账单详情',
|
||||
transfer_receipt: '转账凭证',
|
||||
sms_notice: '短信通知',
|
||||
balance: '余额页',
|
||||
unknown: '未识别',
|
||||
};
|
||||
|
||||
const ocrStatusIcon: Record<string, React.ReactNode> = {
|
||||
done: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
processing: <LoadingOutlined style={{ color: '#1677ff' }} />,
|
||||
failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
|
||||
pending: <FileImageOutlined style={{ color: '#d9d9d9' }} />,
|
||||
};
|
||||
|
||||
const Screenshots: React.FC = () => {
|
||||
const { id = '1' } = useParams();
|
||||
const [filterApp, setFilterApp] = useState<string>('all');
|
||||
const [selectedImage, setSelectedImage] = useState<EvidenceImage | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const { data: allImages = [] } = useQuery({
|
||||
queryKey: ['images', id],
|
||||
queryFn: () => fetchImages(id),
|
||||
});
|
||||
|
||||
const filtered =
|
||||
filterApp === 'all'
|
||||
? allImages
|
||||
: allImages.filter((img: EvidenceImage) => img.sourceApp === filterApp);
|
||||
|
||||
const appCounts = allImages.reduce<Record<string, number>>((acc, img: EvidenceImage) => {
|
||||
acc[img.sourceApp] = (acc[img.sourceApp] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const handleSelect = (img: EvidenceImage) => {
|
||||
setSelectedImage(img);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const mockOcrFields = [
|
||||
{ label: '交易时间', value: '2026-03-06 10:25:00', confidence: 0.97 },
|
||||
{ label: '交易金额', value: '¥50,000.00', confidence: 0.99 },
|
||||
{ label: '交易方向', value: '支出', confidence: 0.95 },
|
||||
{ label: '对方账户', value: '李*华 (138****5678)', confidence: 0.88 },
|
||||
{ label: '订单号', value: 'AL20260306002', confidence: 0.96 },
|
||||
{ label: '备注', value: '投资款', confidence: 0.92 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FileImageOutlined />
|
||||
<span>截图管理</span>
|
||||
<Tag>{allImages.length} 张</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Select
|
||||
value={filterApp}
|
||||
onChange={setFilterApp}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '全部来源', value: 'all' },
|
||||
...Object.entries(appLabel).map(([k, v]) => ({
|
||||
label: `${v.label} (${appCounts[k] || 0})`,
|
||||
value: k,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: '网格', value: 'grid' },
|
||||
{ label: '列表', value: 'list' },
|
||||
]}
|
||||
defaultValue="grid"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{filtered.map((img) => (
|
||||
<Col key={img.id} xs={12} sm={8} md={6} lg={4}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => handleSelect(img)}
|
||||
styles={{
|
||||
body: { padding: 12 },
|
||||
}}
|
||||
cover={
|
||||
<div
|
||||
style={{
|
||||
height: 200,
|
||||
background: '#f5f5f5',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<FileImageOutlined
|
||||
style={{ fontSize: 40, color: '#bfbfbf' }}
|
||||
/>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, marginTop: 8 }}
|
||||
>
|
||||
截图预览区
|
||||
</Typography.Text>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
{ocrStatusIcon[img.ocrStatus]}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
}}
|
||||
>
|
||||
<Tag
|
||||
color={appLabel[img.sourceApp].color}
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{appLabel[img.sourceApp].label}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Typography.Text ellipsis style={{ fontSize: 13 }}>
|
||||
{pageTypeLabel[img.pageType]}
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{img.uploadedAt}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{filtered.length === 0 && <Empty description="暂无截图" />}
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
title={
|
||||
selectedImage ? (
|
||||
<Space>
|
||||
<Tag color={appLabel[selectedImage.sourceApp].color}>
|
||||
{appLabel[selectedImage.sourceApp].label}
|
||||
</Tag>
|
||||
<span>{pageTypeLabel[selectedImage.pageType]}</span>
|
||||
<Badge
|
||||
status={selectedImage.ocrStatus === 'done' ? 'success' : 'processing'}
|
||||
text={selectedImage.ocrStatus === 'done' ? 'OCR已完成' : '处理中'}
|
||||
/>
|
||||
</Space>
|
||||
) : '截图详情'
|
||||
}
|
||||
placement="right"
|
||||
width={560}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
{selectedImage && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
height: 300,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
border: '1px dashed #d9d9d9',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" align="center">
|
||||
<FileImageOutlined style={{ fontSize: 64, color: '#bfbfbf' }} />
|
||||
<Typography.Text type="secondary">
|
||||
原始截图预览
|
||||
</Typography.Text>
|
||||
<Button icon={<ZoomInOutlined />} size="small">
|
||||
放大查看
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Typography.Title level={5}>OCR 提取字段</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ marginBottom: 16, display: 'block' }}>
|
||||
以下为系统自动提取结果,可手工修正
|
||||
</Typography.Text>
|
||||
|
||||
<List
|
||||
dataSource={mockOcrFields}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
extra={
|
||||
<Space>
|
||||
<Tag
|
||||
color={
|
||||
item.confidence >= 0.95
|
||||
? 'green'
|
||||
: item.confidence >= 0.85
|
||||
? 'orange'
|
||||
: 'red'
|
||||
}
|
||||
>
|
||||
{(item.confidence * 100).toFixed(0)}%
|
||||
</Tag>
|
||||
<Button type="link" size="small" icon={<EditOutlined />}>
|
||||
修正
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{item.label}
|
||||
</Typography.Text>
|
||||
}
|
||||
description={
|
||||
<Typography.Text strong>{item.value}</Typography.Text>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="图片ID">
|
||||
{selectedImage.id}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="文件哈希">
|
||||
{selectedImage.hash}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="上传时间">
|
||||
{selectedImage.uploadedAt}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Screenshots;
|
||||
335
frontend/src/pages/transactions/Transactions.tsx
Normal file
335
frontend/src/pages/transactions/Transactions.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Space,
|
||||
Select,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Button,
|
||||
Alert,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
} from 'antd';
|
||||
import {
|
||||
SwapOutlined,
|
||||
WarningOutlined,
|
||||
LinkOutlined,
|
||||
EyeOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { TransactionRecord, SourceApp } from '../../types';
|
||||
import { fetchTransactions } from '../../services/api';
|
||||
|
||||
const appTag: Record<SourceApp, { label: string; color: string }> = {
|
||||
wechat: { label: '微信', color: 'green' },
|
||||
alipay: { label: '支付宝', color: 'blue' },
|
||||
bank: { label: '银行', color: 'purple' },
|
||||
digital_wallet: { label: '数字钱包', color: 'orange' },
|
||||
other: { label: '其他', color: 'default' },
|
||||
};
|
||||
|
||||
const Transactions: React.FC = () => {
|
||||
const { id = '1' } = useParams();
|
||||
const [filterDuplicate, setFilterDuplicate] = useState<string>('all');
|
||||
const [detail, setDetail] = useState<TransactionRecord | null>(null);
|
||||
|
||||
const { data: txData } = useQuery({
|
||||
queryKey: ['transactions', id],
|
||||
queryFn: () => fetchTransactions(id),
|
||||
});
|
||||
const allTransactions = txData?.items ?? [];
|
||||
|
||||
const data =
|
||||
filterDuplicate === 'all'
|
||||
? allTransactions
|
||||
: filterDuplicate === 'unique'
|
||||
? allTransactions.filter((t) => !t.isDuplicate)
|
||||
: allTransactions.filter((t) => t.isDuplicate);
|
||||
|
||||
const totalOut = allTransactions
|
||||
.filter((t) => t.direction === 'out' && !t.isDuplicate)
|
||||
.reduce((s, t) => s + t.amount, 0);
|
||||
const totalIn = allTransactions
|
||||
.filter((t) => t.direction === 'in' && !t.isDuplicate)
|
||||
.reduce((s, t) => s + t.amount, 0);
|
||||
const duplicateCount = allTransactions.filter((t) => t.isDuplicate).length;
|
||||
const transitCount = allTransactions.filter((t) => t.isTransit).length;
|
||||
|
||||
const columns: ColumnsType<TransactionRecord> = [
|
||||
{
|
||||
title: '交易时间',
|
||||
dataIndex: 'tradeTime',
|
||||
width: 170,
|
||||
sorter: (a, b) => a.tradeTime.localeCompare(b.tradeTime),
|
||||
defaultSortOrder: 'ascend',
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'sourceApp',
|
||||
width: 100,
|
||||
render: (app: SourceApp) => (
|
||||
<Tag color={appTag[app].color}>{appTag[app].label}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额(元)',
|
||||
dataIndex: 'amount',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (v: number, r) => (
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{ color: r.direction === 'out' ? '#cf1322' : '#389e0d' }}
|
||||
>
|
||||
{r.direction === 'out' ? '-' : '+'}¥{v.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '方向',
|
||||
dataIndex: 'direction',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (d: string) =>
|
||||
d === 'out' ? (
|
||||
<ArrowUpOutlined style={{ color: '#cf1322' }} />
|
||||
) : (
|
||||
<ArrowDownOutlined style={{ color: '#389e0d' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '对方',
|
||||
dataIndex: 'counterpartyName',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '标记',
|
||||
width: 130,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
{r.isDuplicate && (
|
||||
<Tooltip title="该笔为重复展示记录,已与其他截图中的同笔交易合并">
|
||||
<Tag color="red">重复</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{r.isTransit && (
|
||||
<Tooltip title="该笔为本人账户间中转,不直接计入被骗金额">
|
||||
<Tag color="orange">中转</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!r.isDuplicate && !r.isTransit && (
|
||||
<Tag color="green">有效</Tag>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (v: number) => (
|
||||
<Badge
|
||||
color={v >= 0.9 ? '#52c41a' : v >= 0.8 ? '#faad14' : '#ff4d4f'}
|
||||
text={`${(v * 100).toFixed(0)}%`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, r) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => setDetail(r)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="总支出(去重后)"
|
||||
value={totalOut}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="总收入(去重后)"
|
||||
value={totalIn}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#389e0d' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="重复记录"
|
||||
value={duplicateCount}
|
||||
suffix="笔"
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
prefix={<WarningOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="中转记录"
|
||||
value={transitCount}
|
||||
suffix="笔"
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
prefix={<LinkOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{duplicateCount > 0 && (
|
||||
<Alert
|
||||
message={`系统识别出 ${duplicateCount} 笔重复展示记录`}
|
||||
description={'同一笔交易可能在列表页、详情页、短信通知中多次出现。标记为「重复」的记录已被合并,不会重复计入金额汇总。'}
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<SwapOutlined />
|
||||
<span>交易明细</span>
|
||||
<Tag>{allTransactions.length} 笔</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Select
|
||||
value={filterDuplicate}
|
||||
onChange={setFilterDuplicate}
|
||||
style={{ width: 160 }}
|
||||
options={[
|
||||
{ label: '全部交易', value: 'all' },
|
||||
{ label: '仅有效交易', value: 'unique' },
|
||||
{ label: '仅重复交易', value: 'duplicate' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
rowClassName={(r) =>
|
||||
r.isDuplicate
|
||||
? 'row-duplicate'
|
||||
: r.isTransit
|
||||
? 'row-transit'
|
||||
: ''
|
||||
}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
title="交易详情"
|
||||
placement="right"
|
||||
width={480}
|
||||
open={!!detail}
|
||||
onClose={() => setDetail(null)}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="交易时间">
|
||||
{detail.tradeTime}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源APP">
|
||||
<Tag color={appTag[detail.sourceApp].color}>
|
||||
{appTag[detail.sourceApp].label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{
|
||||
color: detail.direction === 'out' ? '#cf1322' : '#389e0d',
|
||||
}}
|
||||
>
|
||||
{detail.direction === 'out' ? '-' : '+'}¥
|
||||
{detail.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对方">
|
||||
{detail.counterpartyName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对方账号">
|
||||
{detail.counterpartyAccount || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="本方账户尾号">
|
||||
{detail.selfAccountTailNo || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="订单号">
|
||||
{detail.orderNo}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
|
||||
<Descriptions.Item label="置信度">
|
||||
{(detail.confidence * 100).toFixed(0)}%
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="证据截图">
|
||||
<Button type="link" size="small" icon={<EyeOutlined />}>
|
||||
查看原图 ({detail.evidenceImageId})
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="归并簇">
|
||||
{detail.clusterId || '独立交易'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标记">
|
||||
<Space>
|
||||
{detail.isDuplicate && <Tag color="red">重复</Tag>}
|
||||
{detail.isTransit && <Tag color="orange">中转</Tag>}
|
||||
{!detail.isDuplicate && !detail.isTransit && (
|
||||
<Tag color="green">有效</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Transactions;
|
||||
300
frontend/src/pages/workspace/Workspace.tsx
Normal file
300
frontend/src/pages/workspace/Workspace.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Steps,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Typography,
|
||||
Upload,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Progress,
|
||||
Alert,
|
||||
Divider,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
CloudUploadOutlined,
|
||||
ScanOutlined,
|
||||
MergeCellsOutlined,
|
||||
ApartmentOutlined,
|
||||
AuditOutlined,
|
||||
FileTextOutlined,
|
||||
InboxOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { fetchCase, fetchImages, fetchTransactions, fetchAssessments, uploadImages } from '../../services/api';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
const Workspace: React.FC = () => {
|
||||
const { id = '1' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(3);
|
||||
|
||||
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
|
||||
const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) });
|
||||
const { data: txData } = useQuery({ queryKey: ['transactions', id], queryFn: () => fetchTransactions(id) });
|
||||
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
|
||||
|
||||
const images = imagesData ?? [];
|
||||
const txList = txData?.items ?? [];
|
||||
const assessments = assessData?.items ?? [];
|
||||
|
||||
const highConfirm = assessments.filter((a) => a.confidenceLevel === 'high').length;
|
||||
const pendingReview = assessments.filter((a) => a.reviewStatus === 'pending').length;
|
||||
|
||||
if (!currentCase) return null;
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '上传截图',
|
||||
icon: <CloudUploadOutlined />,
|
||||
description: `${images.length} 张已上传`,
|
||||
},
|
||||
{
|
||||
title: 'OCR识别',
|
||||
icon: <ScanOutlined />,
|
||||
description: `${images.filter((i: any) => i.ocrStatus === 'done').length}/${images.length} 已完成`,
|
||||
},
|
||||
{
|
||||
title: '交易归并',
|
||||
icon: <MergeCellsOutlined />,
|
||||
description: `${txList.length} 笔交易`,
|
||||
},
|
||||
{
|
||||
title: '资金分析',
|
||||
icon: <ApartmentOutlined />,
|
||||
description: '已生成路径图',
|
||||
},
|
||||
{
|
||||
title: '认定复核',
|
||||
icon: <AuditOutlined />,
|
||||
description: `${pendingReview} 笔待复核`,
|
||||
},
|
||||
{
|
||||
title: '报告导出',
|
||||
icon: <FileTextOutlined />,
|
||||
description: '待生成',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Space>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{currentCase.title}
|
||||
</Typography.Title>
|
||||
<Tag color="orange">待复核</Tag>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">
|
||||
{currentCase.caseNo} · 承办人:{currentCase.handler} · 受害人:{currentCase.victimName}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Statistic
|
||||
title="当前识别被骗金额"
|
||||
value={currentCase.totalAmount}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#cf1322', fontSize: 28 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
items={steps}
|
||||
onChange={(v) => setCurrentStep(v)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card
|
||||
title="快速上传截图"
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
支持 JPG/PNG,可批量拖拽
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<Dragger
|
||||
multiple
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file, fileList) => {
|
||||
uploadImages(id, fileList as unknown as File[])
|
||||
.then(() => message.success('截图上传成功'))
|
||||
.catch(() => message.error('上传失败'));
|
||||
return false;
|
||||
}}
|
||||
style={{ padding: '20px 0' }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ fontSize: 48, color: '#1677ff' }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
点击或拖拽手机账单截图到此区域
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持微信、支付宝、银行APP、数字钱包等多种来源截图
|
||||
</p>
|
||||
</Dragger>
|
||||
</Card>
|
||||
|
||||
<Card title="处理进度">
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col span={8}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={Math.round(
|
||||
images.length ? (images.filter((i: any) => i.ocrStatus === 'done').length /
|
||||
images.length) *
|
||||
100 : 0,
|
||||
)}
|
||||
size={80}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text>OCR 识别率</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={Math.round(
|
||||
txList.length ? (txList.filter((t) => !t.isDuplicate).length /
|
||||
txList.length) *
|
||||
100 : 0,
|
||||
)}
|
||||
size={80}
|
||||
strokeColor="#52c41a"
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text>去重有效率</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={Math.round(
|
||||
assessments.length ? (highConfirm / assessments.length) * 100 : 0,
|
||||
)}
|
||||
size={80}
|
||||
strokeColor="#fa8c16"
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text>高置信占比</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card title="案件概况" style={{ marginBottom: 24 }}>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="截图总数">
|
||||
{images.length} 张
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="涉及APP">
|
||||
<Space>
|
||||
<Tag color="green">微信</Tag>
|
||||
<Tag color="blue">支付宝</Tag>
|
||||
<Tag color="purple">银行</Tag>
|
||||
<Tag>数字钱包</Tag>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提取交易数">
|
||||
{txList.length} 笔
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="去重后交易">
|
||||
{txList.filter((t) => !t.isDuplicate).length} 笔
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="涉诈对手方">2 个</Descriptions.Item>
|
||||
<Descriptions.Item label="待复核">
|
||||
<Typography.Text type="warning">
|
||||
{pendingReview} 笔
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="快捷操作">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button
|
||||
block
|
||||
onClick={() => navigate('/cases/1/screenshots')}
|
||||
icon={<RightOutlined />}
|
||||
>
|
||||
查看截图与 OCR 结果
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
onClick={() => navigate('/cases/1/transactions')}
|
||||
icon={<RightOutlined />}
|
||||
>
|
||||
查看交易归并
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
onClick={() => navigate('/cases/1/analysis')}
|
||||
icon={<RightOutlined />}
|
||||
>
|
||||
查看资金分析
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
onClick={() => navigate('/cases/1/review')}
|
||||
icon={<RightOutlined />}
|
||||
>
|
||||
进入认定复核
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{pendingReview > 0 && (
|
||||
<Alert
|
||||
message={`有 ${pendingReview} 笔交易待人工确认`}
|
||||
description="系统已完成自动分析,请进入认定复核页面审阅并确认结果。"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 24 }}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => navigate('/cases/1/review')}
|
||||
>
|
||||
立即复核
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workspace;
|
||||
44
frontend/src/routes.tsx
Normal file
44
frontend/src/routes.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
|
||||
const CaseList = lazy(() => import('./pages/cases/CaseList'));
|
||||
const Workspace = lazy(() => import('./pages/workspace/Workspace'));
|
||||
const Screenshots = lazy(() => import('./pages/screenshots/Screenshots'));
|
||||
const Transactions = lazy(() => import('./pages/transactions/Transactions'));
|
||||
const Analysis = lazy(() => import('./pages/analysis/Analysis'));
|
||||
const Review = lazy(() => import('./pages/review/Review'));
|
||||
const Reports = lazy(() => import('./pages/reports/Reports'));
|
||||
|
||||
const Loading = () => (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 120 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
|
||||
function withSuspense(Component: React.LazyExoticComponent<React.ComponentType>) {
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/cases" replace /> },
|
||||
{ path: 'cases', element: withSuspense(CaseList) },
|
||||
{ path: 'cases/:id/workspace', element: withSuspense(Workspace) },
|
||||
{ path: 'cases/:id/screenshots', element: withSuspense(Screenshots) },
|
||||
{ path: 'cases/:id/transactions', element: withSuspense(Transactions) },
|
||||
{ path: 'cases/:id/analysis', element: withSuspense(Analysis) },
|
||||
{ path: 'cases/:id/review', element: withSuspense(Review) },
|
||||
{ path: 'cases/:id/reports', element: withSuspense(Reports) },
|
||||
],
|
||||
},
|
||||
];
|
||||
197
frontend/src/services/api.ts
Normal file
197
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Centralized API client with automatic fallback to mock data.
|
||||
*
|
||||
* When the backend is available, all calls go to /api/v1/...
|
||||
* When the backend is down (e.g. demo-only mode), responses fall back
|
||||
* to the mock data in ../mock/data.ts so the frontend always works.
|
||||
*/
|
||||
import type {
|
||||
CaseRecord,
|
||||
EvidenceImage,
|
||||
TransactionRecord,
|
||||
FraudAssessment,
|
||||
ExportReport,
|
||||
} from '../types';
|
||||
import {
|
||||
mockCases,
|
||||
mockImages,
|
||||
mockTransactions,
|
||||
mockAssessments,
|
||||
mockFlowNodes,
|
||||
mockFlowEdges,
|
||||
mockReports,
|
||||
} from '../mock/data';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const resp = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
...options,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`API Error ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
let backendAlive: boolean | null = null;
|
||||
|
||||
async function isBackendUp(): Promise<boolean> {
|
||||
if (backendAlive !== null) return backendAlive;
|
||||
try {
|
||||
const r = await fetch('/health', { signal: AbortSignal.timeout(2000) });
|
||||
backendAlive = r.ok;
|
||||
} catch {
|
||||
backendAlive = false;
|
||||
}
|
||||
return backendAlive;
|
||||
}
|
||||
|
||||
export function resetBackendCheck() {
|
||||
backendAlive = null;
|
||||
}
|
||||
|
||||
// ── Cases ──
|
||||
|
||||
export async function fetchCases(params?: {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<{ items: CaseRecord[]; total: number }> {
|
||||
if (!(await isBackendUp())) return { items: mockCases, total: mockCases.length };
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.offset) qs.set('offset', String(params.offset));
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
return request(`${BASE}/cases?${qs}`);
|
||||
}
|
||||
|
||||
export async function createCase(body: {
|
||||
case_no: string;
|
||||
title: string;
|
||||
victim_name: string;
|
||||
handler?: string;
|
||||
}): Promise<CaseRecord> {
|
||||
if (!(await isBackendUp())) return mockCases[0];
|
||||
return request(`${BASE}/cases`, { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function fetchCase(id: string): Promise<CaseRecord> {
|
||||
if (!(await isBackendUp())) return mockCases.find((c) => c.id === id) || mockCases[0];
|
||||
return request(`${BASE}/cases/${id}`);
|
||||
}
|
||||
|
||||
// ── Images ──
|
||||
|
||||
export async function fetchImages(
|
||||
caseId: string,
|
||||
params?: { source_app?: string; page_type?: string },
|
||||
): Promise<EvidenceImage[]> {
|
||||
if (!(await isBackendUp())) return mockImages.filter((i) => i.caseId === caseId || caseId === '1');
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.source_app) qs.set('source_app', params.source_app);
|
||||
if (params?.page_type) qs.set('page_type', params.page_type);
|
||||
return request(`${BASE}/cases/${caseId}/images?${qs}`);
|
||||
}
|
||||
|
||||
export async function uploadImages(caseId: string, files: File[]): Promise<EvidenceImage[]> {
|
||||
if (!(await isBackendUp())) return mockImages.slice(0, files.length);
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append('files', f));
|
||||
const resp = await fetch(`${BASE}/cases/${caseId}/images`, { method: 'POST', body: form });
|
||||
if (!resp.ok) throw new Error(`Upload failed ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Analysis ──
|
||||
|
||||
export async function triggerAnalysis(caseId: string): Promise<{ task_id: string; message: string }> {
|
||||
if (!(await isBackendUp())) return { task_id: 'mock', message: '分析任务已提交' };
|
||||
return request(`${BASE}/cases/${caseId}/analyze`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchAnalysisStatus(
|
||||
caseId: string,
|
||||
): Promise<{ status: string; progress: number; current_step: string }> {
|
||||
if (!(await isBackendUp())) return { status: 'reviewing', progress: 85, current_step: '待复核' };
|
||||
return request(`${BASE}/cases/${caseId}/analyze/status`);
|
||||
}
|
||||
|
||||
// ── Transactions ──
|
||||
|
||||
export async function fetchTransactions(
|
||||
caseId: string,
|
||||
filterType?: string,
|
||||
): Promise<{ items: TransactionRecord[]; total: number }> {
|
||||
if (!(await isBackendUp())) return { items: mockTransactions, total: mockTransactions.length };
|
||||
const qs = filterType ? `?filter_type=${filterType}` : '';
|
||||
return request(`${BASE}/cases/${caseId}/transactions${qs}`);
|
||||
}
|
||||
|
||||
// ── Flows ──
|
||||
|
||||
export async function fetchFlows(
|
||||
caseId: string,
|
||||
): Promise<{ nodes: any[]; edges: any[] }> {
|
||||
if (!(await isBackendUp())) return { nodes: mockFlowNodes, edges: mockFlowEdges };
|
||||
return request(`${BASE}/cases/${caseId}/flows`);
|
||||
}
|
||||
|
||||
// ── Assessments ──
|
||||
|
||||
export async function fetchAssessments(
|
||||
caseId: string,
|
||||
confidenceLevel?: string,
|
||||
): Promise<{ items: FraudAssessment[]; total: number }> {
|
||||
if (!(await isBackendUp())) return { items: mockAssessments, total: mockAssessments.length };
|
||||
const qs = confidenceLevel ? `?confidence_level=${confidenceLevel}` : '';
|
||||
return request(`${BASE}/cases/${caseId}/assessments${qs}`);
|
||||
}
|
||||
|
||||
export async function submitReview(
|
||||
assessmentId: string,
|
||||
body: { review_status: string; review_note?: string; reviewed_by?: string },
|
||||
): Promise<FraudAssessment> {
|
||||
if (!(await isBackendUp())) return mockAssessments[0];
|
||||
return request(`${BASE}/assessments/${assessmentId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchInquirySuggestions(caseId: string): Promise<{ suggestions: string[] }> {
|
||||
if (!(await isBackendUp()))
|
||||
return {
|
||||
suggestions: [
|
||||
'请向受害人核实是否受对方诱导操作转账。',
|
||||
'是否还有未截图的转账记录?',
|
||||
'涉案金额中是否有已追回款项?',
|
||||
],
|
||||
};
|
||||
return request(`${BASE}/cases/${caseId}/inquiry-suggestions`);
|
||||
}
|
||||
|
||||
// ── Reports ──
|
||||
|
||||
export async function generateReport(
|
||||
caseId: string,
|
||||
body: { report_type: string },
|
||||
): Promise<ExportReport> {
|
||||
if (!(await isBackendUp())) return mockReports[0];
|
||||
return request(`${BASE}/cases/${caseId}/reports`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchReports(caseId: string): Promise<{ items: ExportReport[]; total: number }> {
|
||||
if (!(await isBackendUp())) return { items: mockReports, total: mockReports.length };
|
||||
return request(`${BASE}/cases/${caseId}/reports`);
|
||||
}
|
||||
|
||||
export function getReportDownloadUrl(reportId: string): string {
|
||||
return `${BASE}/reports/${reportId}/download`;
|
||||
}
|
||||
10
frontend/src/services/queryClient.ts
Normal file
10
frontend/src/services/queryClient.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
12
frontend/src/store/caseStore.ts
Normal file
12
frontend/src/store/caseStore.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CaseRecord } from '../types';
|
||||
|
||||
interface CaseState {
|
||||
currentCase: CaseRecord | null;
|
||||
setCurrentCase: (c: CaseRecord | null) => void;
|
||||
}
|
||||
|
||||
export const useCaseStore = create<CaseState>((set) => ({
|
||||
currentCase: null,
|
||||
setCurrentCase: (c) => set({ currentCase: c }),
|
||||
}));
|
||||
88
frontend/src/types/index.ts
Normal file
88
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type CaseStatus = 'pending' | 'uploading' | 'analyzing' | 'reviewing' | 'completed';
|
||||
|
||||
export interface CaseRecord {
|
||||
id: string;
|
||||
caseNo: string;
|
||||
title: string;
|
||||
victimName: string;
|
||||
handler: string;
|
||||
status: CaseStatus;
|
||||
imageCount: number;
|
||||
totalAmount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type PageType = 'bill_list' | 'bill_detail' | 'transfer_receipt' | 'sms_notice' | 'balance' | 'unknown';
|
||||
export type SourceApp = 'wechat' | 'alipay' | 'bank' | 'digital_wallet' | 'other';
|
||||
|
||||
export interface EvidenceImage {
|
||||
id: string;
|
||||
caseId: string;
|
||||
url: string;
|
||||
thumbUrl: string;
|
||||
sourceApp: SourceApp;
|
||||
pageType: PageType;
|
||||
ocrStatus: 'pending' | 'processing' | 'done' | 'failed';
|
||||
hash: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface TransactionRecord {
|
||||
id: string;
|
||||
caseId: string;
|
||||
sourceApp: SourceApp;
|
||||
tradeTime: string;
|
||||
amount: number;
|
||||
direction: 'in' | 'out';
|
||||
counterpartyName: string;
|
||||
counterpartyAccount: string;
|
||||
selfAccountTailNo: string;
|
||||
orderNo: string;
|
||||
remark: string;
|
||||
evidenceImageId: string;
|
||||
confidence: number;
|
||||
clusterId?: string;
|
||||
isDuplicate: boolean;
|
||||
isTransit: boolean;
|
||||
}
|
||||
|
||||
export type ConfidenceLevel = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface FraudAssessment {
|
||||
id: string;
|
||||
caseId: string;
|
||||
transactionId: string;
|
||||
transaction: TransactionRecord;
|
||||
confidenceLevel: ConfidenceLevel;
|
||||
assessedAmount: number;
|
||||
reason: string;
|
||||
excludeReason?: string;
|
||||
reviewStatus: 'pending' | 'confirmed' | 'rejected' | 'needs_info';
|
||||
reviewNote?: string;
|
||||
reviewedBy?: string;
|
||||
reviewedAt?: string;
|
||||
}
|
||||
|
||||
export interface FundFlowNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'self' | 'suspect' | 'transit' | 'unknown';
|
||||
}
|
||||
|
||||
export interface FundFlowEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
tradeTime: string;
|
||||
}
|
||||
|
||||
export interface ExportReport {
|
||||
id: string;
|
||||
caseId: string;
|
||||
type: 'pdf' | 'excel' | 'word';
|
||||
url: string;
|
||||
createdAt: string;
|
||||
version: number;
|
||||
}
|
||||
Reference in New Issue
Block a user