update: mock mode

This commit is contained in:
2026-03-12 20:04:27 +08:00
parent ce537bb3dc
commit 7cd2a18364
12 changed files with 1495 additions and 411 deletions

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Typography, theme, Space } from 'antd';
import { App, Layout, Menu, Typography, theme, Space, Tag } from 'antd';
import {
FolderOpenOutlined,
DashboardOutlined,
@@ -11,29 +11,71 @@ import {
FileTextOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import { getDataSourceMode } from '../services/api';
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: '报告导出' },
{ key: 'cases', icon: <FolderOpenOutlined />, label: '案件管理' },
{ key: 'workspace', icon: <DashboardOutlined />, label: '工作台' },
{ key: 'screenshots', icon: <FileImageOutlined />, label: '截图管理' },
{ key: 'transactions', icon: <SwapOutlined />, label: '交易归并' },
{ key: 'analysis', icon: <ApartmentOutlined />, label: '资金分析' },
{ key: 'review', icon: <AuditOutlined />, label: '认定复核' },
{ key: 'reports', icon: <FileTextOutlined />, label: '报告导出' },
];
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const [dataSourceMode, setDataSourceMode] = useState<'mock' | 'api'>('mock');
const navigate = useNavigate();
const location = useLocation();
const { token } = theme.useToken();
const { message } = App.useApp();
const selectedKey = menuItems
.map((m) => m.key)
.filter((k) => location.pathname.startsWith(k))
.sort((a, b) => b.length - a.length)[0] || '/cases';
useEffect(() => {
let mounted = true;
const refreshStatus = async () => {
const mode = await getDataSourceMode();
if (mounted) setDataSourceMode(mode);
};
refreshStatus();
const timer = setInterval(refreshStatus, 10_000);
return () => {
mounted = false;
clearInterval(timer);
};
}, []);
const caseMatch = location.pathname.match(/^\/cases\/([^/]+)/);
const currentCaseId = caseMatch?.[1] ?? null;
const selectedKey = (() => {
if (location.pathname === '/cases' || location.pathname.startsWith('/cases?')) return 'cases';
if (location.pathname.includes('/workspace')) return 'workspace';
if (location.pathname.includes('/screenshots')) return 'screenshots';
if (location.pathname.includes('/transactions')) return 'transactions';
if (location.pathname.includes('/analysis')) return 'analysis';
if (location.pathname.includes('/review')) return 'review';
if (location.pathname.includes('/reports')) return 'reports';
return 'cases';
})();
const handleMenuClick = (key: string) => {
if (key === 'cases') {
navigate('/cases');
return;
}
if (currentCaseId) {
navigate(`/cases/${currentCaseId}/${key}`);
return;
}
message.warning('请先在案件管理中创建或选择一个案件');
navigate('/cases');
};
return (
<Layout style={{ minHeight: '100vh' }}>
@@ -79,7 +121,7 @@ const MainLayout: React.FC = () => {
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => navigate(key)}
onClick={({ key }) => handleMenuClick(String(key))}
style={{ border: 'none' }}
/>
</Sider>
@@ -100,9 +142,16 @@ const MainLayout: React.FC = () => {
<Typography.Text strong style={{ fontSize: 15 }}>
</Typography.Text>
<Space>
{dataSourceMode === 'mock' && (
<Tag color="orange">
Mock
</Tag>
)}
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
· v0.1.0
</Typography.Text>
</Space>
</Header>
<Content
style={{

View File

@@ -72,14 +72,14 @@ export const mockCases: CaseRecord[] = [
];
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' },
{ id: 'img-1', caseId: '1', url: '', thumbUrl: '', sourceApp: 'wechat', pageType: 'bill_list', ocrStatus: 'done', fileHash: 'a1b2', uploadedAt: '2026-03-08 09:35:00' },
{ id: 'img-2', caseId: '1', url: '', thumbUrl: '', sourceApp: 'wechat', pageType: 'bill_detail', ocrStatus: 'done', fileHash: 'c3d4', uploadedAt: '2026-03-08 09:35:00' },
{ id: 'img-3', caseId: '1', url: '', thumbUrl: '', sourceApp: 'alipay', pageType: 'bill_list', ocrStatus: 'done', fileHash: 'e5f6', uploadedAt: '2026-03-08 09:36:00' },
{ id: 'img-4', caseId: '1', url: '', thumbUrl: '', sourceApp: 'alipay', pageType: 'transfer_receipt', ocrStatus: 'done', fileHash: 'g7h8', uploadedAt: '2026-03-08 09:36:00' },
{ id: 'img-5', caseId: '1', url: '', thumbUrl: '', sourceApp: 'bank', pageType: 'bill_detail', ocrStatus: 'done', fileHash: 'i9j0', uploadedAt: '2026-03-08 09:37:00' },
{ id: 'img-6', caseId: '1', url: '', thumbUrl: '', sourceApp: 'bank', pageType: 'sms_notice', ocrStatus: 'processing', fileHash: 'k1l2', uploadedAt: '2026-03-08 09:37:00' },
{ id: 'img-7', caseId: '1', url: '', thumbUrl: '', sourceApp: 'digital_wallet', pageType: 'bill_list', ocrStatus: 'done', fileHash: 'm3n4', uploadedAt: '2026-03-08 09:38:00' },
{ id: 'img-8', caseId: '1', url: '', thumbUrl: '', sourceApp: 'wechat', pageType: 'bill_detail', ocrStatus: 'failed', fileHash: 'o5p6', uploadedAt: '2026-03-08 09:38:00' },
];
export const mockTransactions: TransactionRecord[] = [
@@ -194,5 +194,5 @@ export const mockFlowEdges: FundFlowEdge[] = [
];
export const mockReports: ExportReport[] = [
{ id: 'rpt-1', caseId: '1', type: 'excel', url: '#', createdAt: '2026-03-10 15:00:00', version: 1 },
{ id: 'rpt-1', caseId: '1', reportType: 'excel', filePath: '#', createdAt: '2026-03-10 15:00:00', version: 1 },
];

View File

@@ -1,15 +1,15 @@
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { App, Card, Row, Col, Tag, Typography, Space, Timeline, Statistic, Alert, Button } from 'antd';
import {
ApartmentOutlined,
ClockCircleOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import { fetchTransactions, fetchFlows } from '../../services/api';
import { fetchTransactions, fetchFlows, triggerAnalysis } from '../../services/api';
import type { FundFlowNode, FundFlowEdge, TransactionRecord } from '../../types';
const nodeColorMap: Record<string, string> = {
self: '#1677ff',
@@ -20,6 +20,8 @@ const nodeColorMap: Record<string, string> = {
const Analysis: React.FC = () => {
const { id = '1' } = useParams();
const queryClient = useQueryClient();
const { message } = App.useApp();
const { data: txData } = useQuery({
queryKey: ['transactions', id],
@@ -30,12 +32,42 @@ const Analysis: React.FC = () => {
queryFn: () => fetchFlows(id),
});
const mockTransactions = txData?.items ?? [];
const mockFlowNodes = flowData?.nodes ?? [];
const mockFlowEdges = flowData?.edges ?? [];
const analysisMutation = useMutation({
mutationFn: () => triggerAnalysis(id),
onMutate: () => {
message.open({
key: 'analysis-page-analysis',
type: 'loading',
content: '正在提交案件分析任务...',
duration: 0,
});
},
onSuccess: (res) => {
message.open({
key: 'analysis-page-analysis',
type: 'success',
content: res.message || '分析任务已提交',
});
queryClient.invalidateQueries({ queryKey: ['assessments', id] });
queryClient.invalidateQueries({ queryKey: ['suggestions', id] });
queryClient.invalidateQueries({ queryKey: ['transactions', id] });
queryClient.invalidateQueries({ queryKey: ['flows', id] });
queryClient.invalidateQueries({ queryKey: ['case', id] });
},
onError: () =>
message.open({
key: 'analysis-page-analysis',
type: 'error',
content: '案件分析提交失败',
}),
});
const transactions: TransactionRecord[] = useMemo(() => txData?.items ?? [], [txData?.items]);
const flowNodes: FundFlowNode[] = useMemo(() => flowData?.nodes ?? [], [flowData?.nodes]);
const flowEdges: FundFlowEdge[] = useMemo(() => flowData?.edges ?? [], [flowData?.edges]);
const flowChartOption = useMemo(() => {
const nodes = mockFlowNodes.map((n: any) => ({
const nodes = flowNodes.map((n) => ({
name: n.label,
symbolSize: n.type === 'suspect' ? 60 : 50,
itemStyle: { color: nodeColorMap[n.type] },
@@ -43,9 +75,9 @@ const Analysis: React.FC = () => {
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);
const edges = flowEdges.map((e) => {
const src = flowNodes.find((n) => n.id === e.source);
const tgt = flowNodes.find((n) => n.id === e.target);
return {
source: src?.label || '',
target: tgt?.label || '',
@@ -91,17 +123,17 @@ const Analysis: React.FC = () => {
},
],
};
}, [mockFlowNodes, mockFlowEdges]);
}, [flowNodes, flowEdges]);
const timelineChartOption = useMemo(() => {
const sorted = [...mockTransactions]
const sorted = [...transactions]
.filter((t) => !t.isDuplicate)
.sort((a, b) => a.tradeTime.localeCompare(b.tradeTime));
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
formatter: (params: Array<{ axisValue: string; value: number }>) => {
const p = params[0];
return `${p.axisValue}<br/>金额: ¥${Math.abs(p.value).toLocaleString()}`;
},
@@ -132,9 +164,9 @@ const Analysis: React.FC = () => {
},
],
};
}, [mockTransactions]);
}, [transactions]);
const validTx = mockTransactions.filter((t) => !t.isDuplicate);
const validTx = transactions.filter((t) => !t.isDuplicate);
const totalFraud = validTx
.filter((t) => t.direction === 'out' && !t.isTransit)
.reduce((s, t) => s + t.amount, 0);
@@ -145,6 +177,34 @@ const Analysis: React.FC = () => {
return (
<div>
<Card style={{ marginBottom: 16 }}>
<Row justify="space-between" align="middle">
<Col>
<Typography.Text type="secondary"></Typography.Text>
</Col>
<Col>
<Button
type="primary"
icon={<ThunderboltOutlined />}
loading={analysisMutation.isPending}
onClick={() => analysisMutation.mutate()}
>
{analysisMutation.isPending ? '分析中...' : '执行案件分析'}
</Button>
</Col>
</Row>
</Card>
{transactions.length === 0 && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="暂无交易数据"
description="请先在工作台上传截图并完成 OCR 识别,再执行案件分析。"
/>
)}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card variant="borderless">
@@ -171,7 +231,7 @@ const Analysis: React.FC = () => {
<Statistic
title="有效交易"
value={validTx.length}
suffix={`/ ${mockTransactions.length}`}
suffix={`/ ${transactions.length}`}
/>
</Card>
</Col>
@@ -217,6 +277,38 @@ const Analysis: React.FC = () => {
绿
</Typography.Text>
</Card>
<Card title="收款方聚合" style={{ marginTop: 24 }}>
<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>
<Col span={10}>
@@ -286,38 +378,6 @@ const Analysis: React.FC = () => {
}))}
/>
</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>

View File

@@ -118,8 +118,15 @@ const CaseList: React.FC = () => {
render: (_, record) => (
<Space>
<Button
type="link"
type="default"
size="small"
style={{
background: '#e6f4ff',
borderColor: '#91caff',
color: '#0958d9',
fontWeight: 600,
boxShadow: '0 1px 2px rgba(22, 119, 255, 0.12)',
}}
onClick={() => navigate(`/cases/${record.id}/workspace`)}
>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -13,10 +13,8 @@ import {
Tag,
Divider,
Descriptions,
Select,
Checkbox,
message,
Steps,
Result,
} from 'antd';
import {
@@ -32,10 +30,64 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { fetchCase, fetchAssessments, fetchReports, generateReport, getReportDownloadUrl } from '../../services/api';
type ContentKeys =
| 'include_summary'
| 'include_transactions'
| 'include_flow_chart'
| 'include_timeline'
| 'include_reasons'
| 'include_inquiry'
| 'include_screenshots';
const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolean }> = [
{ key: 'include_summary', label: '被骗金额汇总表', defaultOn: true },
{ key: 'include_transactions', label: '交易明细清单(含证据索引)', defaultOn: true },
{ key: 'include_flow_chart', label: '资金流转路径图', defaultOn: true },
{ key: 'include_timeline', label: '交易时间轴', defaultOn: true },
{ key: 'include_reasons', label: '认定理由与排除说明', defaultOn: true },
{ key: 'include_inquiry', label: '笔录辅助问询建议', defaultOn: false },
{ key: 'include_screenshots', label: '原始截图附件', defaultOn: false },
];
const STORAGE_PREFIX = 'report-content-';
const loadContentSelection = (caseId: string): Record<ContentKeys, boolean> => {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${caseId}`);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
const defaults: Record<string, boolean> = {};
contentOptions.forEach((o) => { defaults[o.key] = o.defaultOn; });
return defaults as Record<ContentKeys, boolean>;
};
const saveContentSelection = (caseId: string, sel: Record<ContentKeys, boolean>) => {
try {
localStorage.setItem(`${STORAGE_PREFIX}${caseId}`, JSON.stringify(sel));
} catch { /* ignore */ }
};
const Reports: React.FC = () => {
const { id = '1' } = useParams();
const qc = useQueryClient();
const [generated, setGenerated] = useState(false);
const [selectedFormats, setSelectedFormats] = useState<Array<'excel' | 'pdf' | 'word'>>([
'excel',
'pdf',
]);
const [contentSel, setContentSel] = useState<Record<ContentKeys, boolean>>(() => loadContentSelection(id));
useEffect(() => {
setContentSel(loadContentSelection(id));
}, [id]);
const toggleContent = useCallback((key: ContentKeys, checked: boolean) => {
setContentSel((prev) => {
const next = { ...prev, [key]: checked };
saveContentSelection(id, next);
return next;
});
}, [id]);
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
@@ -53,20 +105,37 @@ const Reports: React.FC = () => {
);
const genMutation = useMutation({
mutationFn: (reportType: string) => generateReport(id, { report_type: reportType }),
onSuccess: () => {
mutationFn: async (reportTypes: Array<'excel' | 'pdf' | 'word'>) => {
const result = await Promise.all(
reportTypes.map((reportType) =>
generateReport(id, { report_type: reportType, ...contentSel }),
),
);
return result;
},
onSuccess: (_res, vars) => {
setGenerated(true);
qc.invalidateQueries({ queryKey: ['reports', id] });
message.success('报告生成成功');
message.success(`报告生成成功${vars.map((v) => v.toUpperCase()).join(' / ')}`);
},
onError: () => {
message.error('报告生成失败');
},
});
if (!currentCase) return null;
const historyColumns: ColumnsType<(typeof mockReports)[0]> = [
const latestReportByType = reportsList.reduce<Record<string, (typeof reportsList)[0]>>((acc, report) => {
if (!acc[report.reportType]) {
acc[report.reportType] = report;
}
return acc;
}, {});
const historyColumns: ColumnsType<(typeof reportsList)[0]> = [
{
title: '类型',
dataIndex: 'type',
dataIndex: 'reportType',
width: 100,
render: (t: string) => {
const map: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
@@ -92,9 +161,15 @@ const Reports: React.FC = () => {
{
title: '操作',
width: 120,
render: () => (
render: (_, report) => (
<Space>
<Button type="link" size="small" icon={<DownloadOutlined />}>
<Button
type="link"
size="small"
icon={<DownloadOutlined />}
href={getReportDownloadUrl(report.id)}
target="_blank"
>
</Button>
</Space>
@@ -188,7 +263,18 @@ const Reports: React.FC = () => {
<Typography.Text>Excel </Typography.Text>
</div>
<div>
<Checkbox defaultChecked></Checkbox>
<Checkbox
checked={selectedFormats.includes('excel')}
onChange={(e) => {
setSelectedFormats((prev) =>
e.target.checked
? Array.from(new Set([...prev, 'excel']))
: prev.filter((x) => x !== 'excel'),
);
}}
>
</Checkbox>
</div>
</Card>
<Card
@@ -201,7 +287,18 @@ const Reports: React.FC = () => {
<Typography.Text>PDF </Typography.Text>
</div>
<div>
<Checkbox defaultChecked></Checkbox>
<Checkbox
checked={selectedFormats.includes('pdf')}
onChange={(e) => {
setSelectedFormats((prev) =>
e.target.checked
? Array.from(new Set([...prev, 'pdf']))
: prev.filter((x) => x !== 'pdf'),
);
}}
>
</Checkbox>
</div>
</Card>
<Card
@@ -214,7 +311,18 @@ const Reports: React.FC = () => {
<Typography.Text>Word </Typography.Text>
</div>
<div>
<Checkbox></Checkbox>
<Checkbox
checked={selectedFormats.includes('word')}
onChange={(e) => {
setSelectedFormats((prev) =>
e.target.checked
? Array.from(new Set([...prev, 'word']))
: prev.filter((x) => x !== 'word'),
);
}}
>
</Checkbox>
</div>
</Card>
</Space>
@@ -223,13 +331,15 @@ const Reports: React.FC = () => {
<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>
{contentOptions.map((opt) => (
<Checkbox
key={opt.key}
checked={contentSel[opt.key]}
onChange={(e) => toggleContent(opt.key, e.target.checked)}
>
{opt.label}
</Checkbox>
))}
</Space>
</div>
@@ -239,7 +349,13 @@ const Reports: React.FC = () => {
size="large"
icon={<FileTextOutlined />}
loading={genMutation.isPending}
onClick={() => genMutation.mutate('excel')}
onClick={() => {
if (selectedFormats.length === 0) {
message.warning('请至少选择一种导出格式');
return;
}
genMutation.mutate(selectedFormats);
}}
block
>
{genMutation.isPending ? '正在生成报告...' : '生成报告'}
@@ -253,7 +369,9 @@ const Reports: React.FC = () => {
<Button
key="excel"
icon={<DownloadOutlined />}
onClick={() => message.info('演示模式:下载 Excel')}
href={latestReportByType.excel ? getReportDownloadUrl(latestReportByType.excel.id) : undefined}
target="_blank"
disabled={!latestReportByType.excel}
>
Excel
</Button>,
@@ -261,10 +379,21 @@ const Reports: React.FC = () => {
key="pdf"
type="primary"
icon={<DownloadOutlined />}
onClick={() => message.info('演示模式:下载 PDF')}
href={latestReportByType.pdf ? getReportDownloadUrl(latestReportByType.pdf.id) : undefined}
target="_blank"
disabled={!latestReportByType.pdf}
>
PDF
</Button>,
<Button
key="word"
icon={<DownloadOutlined />}
href={latestReportByType.word ? getReportDownloadUrl(latestReportByType.word.id) : undefined}
target="_blank"
disabled={!latestReportByType.word}
>
Word
</Button>,
<Button
key="print"
icon={<PrinterOutlined />}

View File

@@ -2,13 +2,14 @@ import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
App,
Card,
Table,
Tag,
Typography,
Space,
Button,
Modal,
Drawer,
Input,
Select,
Descriptions,
@@ -17,9 +18,8 @@ import {
Statistic,
Alert,
Segmented,
Tooltip,
message,
Divider,
Dropdown,
} from 'antd';
import {
AuditOutlined,
@@ -27,13 +27,25 @@ import {
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';
import {
fetchAssessments,
submitReview,
fetchInquirySuggestions,
triggerAnalysis,
fetchImageDetail,
} from '../../services/api';
type ReviewAction = 'confirmed' | 'rejected' | 'needs_info';
type ReviewPayload = {
review_status: ReviewAction;
review_note?: string;
reviewed_by?: string;
};
const confidenceConfig: Record<
ConfidenceLevel,
@@ -44,35 +56,65 @@ const confidenceConfig: Record<
low: { color: 'default', label: '低置信' },
};
const reviewStatusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
const _reviewStatusConfig = {
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 /> },
};
void _reviewStatusConfig;
const aiSuggestionLabel: Record<ReviewAction, string> = {
confirmed: '确认(计入被骗金额)',
rejected: '排除(不计入)',
needs_info: '需补充调查',
};
const getAiSuggestedAction = (assessment: FraudAssessment): ReviewAction => {
if (assessment.assessedAmount <= 0 || assessment.confidenceLevel === 'low') return 'rejected';
if (assessment.confidenceLevel === 'high') return 'confirmed';
return 'needs_info';
};
const splitTradeTime = (raw: string): { date: string; time: string } => {
if (!raw) return { date: '-', time: '-' };
const normalized = raw.trim().replace(' ', 'T');
if (!normalized.includes('T')) return { date: normalized, time: '-' };
const [datePart, timePartRaw = ''] = normalized.split('T');
const cleanedTime = timePartRaw.replace('Z', '').split('.')[0] || '-';
return { date: datePart || '-', time: cleanedTime };
};
const Review: React.FC = () => {
const { id = '1' } = useParams();
const qc = useQueryClient();
const { message } = App.useApp();
const [filterLevel, setFilterLevel] = useState<string>('all');
const [reviewModal, setReviewModal] = useState<FraudAssessment | null>(null);
const [reviewAction, setReviewAction] = useState<string>('confirmed');
const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed');
const [reviewNote, setReviewNote] = useState('');
const { data: assessData } = useQuery({
queryKey: ['assessments', id],
queryFn: () => fetchAssessments(id),
});
const { data: suggestionsData } = useQuery({
const { data: suggestionsData, isLoading: suggestionsLoading, isFetching: suggestionsFetching } = useQuery({
queryKey: ['suggestions', id],
queryFn: () => fetchInquirySuggestions(id),
});
const { data: evidenceImageDetail, isFetching: evidenceImageFetching } = useQuery({
queryKey: ['image-detail', reviewModal?.transaction.evidenceImageId],
queryFn: () => fetchImageDetail(reviewModal!.transaction.evidenceImageId),
enabled: !!reviewModal?.transaction.evidenceImageId,
});
const allAssessments = assessData?.items ?? [];
const suggestions = suggestionsData?.suggestions ?? [];
const hasNoAnalysisResult =
suggestions.length === 1 && suggestions[0].includes('暂无分析结果');
const reviewMutation = useMutation({
mutationFn: (params: { assessmentId: string; body: any }) =>
mutationFn: (params: { assessmentId: string; body: ReviewPayload }) =>
submitReview(params.assessmentId, params.body),
onSuccess: () => {
message.success('复核结果已保存');
@@ -81,6 +123,36 @@ const Review: React.FC = () => {
},
});
const analysisMutation = useMutation({
mutationFn: () => triggerAnalysis(id),
onMutate: () => {
message.open({
key: 'review-analysis',
type: 'loading',
content: '正在提交案件分析任务...',
duration: 0,
});
},
onSuccess: (res) => {
message.open({
key: 'review-analysis',
type: 'success',
content: res.message || '分析任务已提交',
});
qc.invalidateQueries({ queryKey: ['assessments', id] });
qc.invalidateQueries({ queryKey: ['suggestions', id] });
qc.invalidateQueries({ queryKey: ['transactions', id] });
qc.invalidateQueries({ queryKey: ['flows', id] });
qc.invalidateQueries({ queryKey: ['case', id] });
},
onError: () =>
message.open({
key: 'review-analysis',
type: 'error',
content: '案件分析提交失败',
}),
});
const data =
filterLevel === 'all'
? allAssessments
@@ -102,15 +174,26 @@ const Review: React.FC = () => {
const columns: ColumnsType<FraudAssessment> = [
{
title: '交易时间',
width: 170,
render: (_, r) => r.transaction.tradeTime,
width: 86,
render: (_, r) => {
const { date, time } = splitTradeTime(r.transaction.tradeTime);
return (
<div style={{ lineHeight: 1.2 }}>
<Typography.Text style={{ fontSize: 12 }}>{date}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{time}
</Typography.Text>
</div>
);
},
sorter: (a, b) =>
a.transaction.tradeTime.localeCompare(b.transaction.tradeTime),
defaultSortOrder: 'ascend',
},
{
title: '来源',
width: 90,
width: 60,
render: (_, r) => {
const app = r.transaction.sourceApp;
const m: Record<string, { l: string; c: string }> = {
@@ -126,7 +209,7 @@ const Review: React.FC = () => {
{
title: '认定金额(元)',
dataIndex: 'assessedAmount',
width: 140,
width: 94,
align: 'right',
render: (v: number) =>
v > 0 ? (
@@ -139,13 +222,14 @@ const Review: React.FC = () => {
},
{
title: '对方',
width: 120,
render: (_, r) => r.transaction.counterpartyName,
ellipsis: true,
},
{
title: '置信度',
dataIndex: 'confidenceLevel',
width: 90,
width: 60,
render: (level: ConfidenceLevel) => (
<Tag color={confidenceConfig[level].color}>
{confidenceConfig[level].label}
@@ -153,55 +237,152 @@ const Review: React.FC = () => {
),
},
{
title: '认定理由',
dataIndex: 'reason',
ellipsis: true,
width: 280,
render: (text: string) => (
<Tooltip title={text}>
<Typography.Text style={{ fontSize: 13 }}>{text}</Typography.Text>
</Tooltip>
),
title: '状态',
width: 74,
render: (_, r) => {
const aiAction = getAiSuggestedAction(r);
const isPending = r.reviewStatus === 'pending';
const status = isPending ? aiAction : r.reviewStatus;
const pendingStyle: Record<string, { bg: string; border: string; color: string }> = {
confirmed: { bg: '#f6ffed', border: '#b7eb8f', color: '#389e0d' },
rejected: { bg: '#fff2e8', border: '#ffbb96', color: '#cf1322' },
needs_info:{ bg: '#e6f4ff', border: '#91caff', color: '#1677ff' },
};
const doneStyle: Record<string, { bg: string; color: string }> = {
confirmed: { bg: '#389e0d', color: '#fff' },
rejected: { bg: '#cf1322', color: '#fff' },
needs_info:{ bg: '#1677ff', color: '#fff' },
};
const pendingLabel: Record<string, string> = {
confirmed: '待确认', rejected: '待排除', needs_info: '待补充',
};
const doneLabel: Record<string, string> = {
confirmed: '已确认', rejected: '已排除', needs_info: '已补充',
};
const label = isPending ? (pendingLabel[aiAction] || '待确认') : (doneLabel[status] || status);
const submitReviewAction = (action: ReviewAction) => {
reviewMutation.mutate({
assessmentId: r.id,
body: {
review_status: action,
review_note: `操作:${aiSuggestionLabel[action]}`,
reviewed_by: 'demo_user',
},
{
title: '复核状态',
dataIndex: 'reviewStatus',
width: 100,
render: (s: string) => {
const cfg = reviewStatusConfig[s];
});
};
const otherOptions = (['confirmed', 'rejected', 'needs_info'] as ReviewAction[])
.filter((v) => isPending ? v !== aiAction : v !== r.reviewStatus)
.map((v) => ({
key: v,
label: (
<span style={{
color: v === 'confirmed' ? '#389e0d' : v === 'rejected' ? '#cf1322' : '#1677ff',
}}>
{v === 'confirmed' ? '确认' : v === 'rejected' ? '排除' : '需补充'}
</span>
),
}));
if (isPending) {
const ps = pendingStyle[aiAction] || pendingStyle.confirmed;
return (
<Tag color={cfg.color} icon={cfg.icon}>
{cfg.label}
</Tag>
<Space.Compact size="small" style={{ width: '100%' }}>
<Button
size="small"
onClick={() => submitReviewAction(aiAction)}
loading={reviewMutation.isPending}
style={{
flex: 1,
background: ps.bg, color: ps.color,
border: `1px solid ${ps.border}`, borderRight: 'none',
borderRadius: '6px 0 0 6px',
fontWeight: 600, fontSize: 12,
}}
>
{label}
</Button>
<Dropdown
menu={{
items: otherOptions,
onClick: ({ key }) => submitReviewAction(key as ReviewAction),
}}
trigger={['click']}
>
<Button
size="small"
style={{
background: ps.bg, color: ps.color,
border: `1px solid ${ps.border}`, borderLeft: 'none',
borderRadius: '0 6px 6px 0',
padding: '0 6px',
}}
>
</Button>
</Dropdown>
</Space.Compact>
);
}
const ds = doneStyle[status] || doneStyle.confirmed;
return (
<Space.Compact size="small" style={{ width: '100%' }}>
<Button
size="small"
style={{
flex: 1,
background: ds.bg, color: ds.color,
border: 'none',
borderRadius: '6px 0 0 6px',
fontWeight: 600, fontSize: 12,
cursor: 'default',
}}
>
{label}
</Button>
<Dropdown
menu={{
items: otherOptions,
onClick: ({ key }) => submitReviewAction(key as ReviewAction),
}}
trigger={['click']}
>
<Button
size="small"
style={{
background: ds.bg, color: ds.color,
border: 'none',
borderLeft: '1px solid rgba(255,255,255,0.3)',
borderRadius: '0 6px 6px 0',
padding: '0 6px',
}}
>
</Button>
</Dropdown>
</Space.Compact>
);
},
},
{
title: '操作',
width: 100,
render: (_, r) =>
r.reviewStatus === 'pending' ? (
<Button
type="primary"
size="small"
onClick={() => {
setReviewModal(r);
setReviewAction('confirmed');
setReviewNote('');
}}
>
</Button>
) : (
title: '',
width: 80,
render: (_, r) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => {
setReviewModal(r);
setReviewAction(getAiSuggestedAction(r));
setReviewNote('');
}}
>
</Button>
),
},
@@ -274,6 +455,7 @@ const Review: React.FC = () => {
</Space>
}
extra={
allAssessments.length > 0 ? (
<Segmented
value={filterLevel}
onChange={(v) => setFilterLevel(v as string)}
@@ -284,15 +466,35 @@ const Review: React.FC = () => {
{ label: '低置信', value: 'low' },
]}
/>
) : null
}
>
{allAssessments.length === 0 ? (
<Alert
type="warning"
showIcon
message="暂无认定数据"
description="尚未执行案件分析,无法展示认定结果。请先完成 OCR 识别和交易归并,然后执行案件分析。"
action={
<Button
type="primary"
loading={analysisMutation.isPending}
onClick={() => analysisMutation.mutate()}
>
{analysisMutation.isPending ? '分析中...' : '执行案件分析'}
</Button>
}
/>
) : (
<Table
rowKey="id"
columns={columns}
dataSource={data}
pagination={false}
size="middle"
scroll={{ x: 'max-content' }}
/>
)}
</Card>
<Divider />
@@ -309,6 +511,46 @@ const Review: React.FC = () => {
<Typography.Paragraph>
</Typography.Paragraph>
{suggestionsLoading && suggestions.length === 0 ? (
<Alert
type="info"
showIcon
message="正在加载问询建议..."
description="请稍候,系统正在获取当前案件的问询建议。"
/>
) : hasNoAnalysisResult ? (
<Alert
type="info"
showIcon
message="暂无分析结果"
description="请先执行案件分析,再生成更有针对性的问询建议。"
action={
<Button
type="primary"
loading={analysisMutation.isPending}
onClick={() => analysisMutation.mutate()}
>
{analysisMutation.isPending ? '分析中...' : '执行案件分析'}
</Button>
}
/>
) : suggestions.length === 0 ? (
<Alert
type="info"
showIcon
message="暂无问询建议"
description="当前暂无可展示的问询建议,可尝试重新执行案件分析。"
action={
<Button
type="primary"
loading={analysisMutation.isPending || suggestionsFetching}
onClick={() => analysisMutation.mutate()}
>
{analysisMutation.isPending ? '分析中...' : '执行案件分析'}
</Button>
}
/>
) : (
<ol style={{ paddingLeft: 20, lineHeight: 2.2 }}>
{suggestions.map((s, idx) => (
<li key={idx}>
@@ -316,51 +558,47 @@ const Review: React.FC = () => {
</li>
))}
</ol>
)}
</Card>
<Modal
title={
reviewModal?.reviewStatus === 'pending'
? '复核认定'
: '认定详情'
}
<Drawer
title="查看 / 复核"
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
onClose={() => setReviewModal(null)}
width={720}
>
{reviewModal && (
<>
<Row gutter={16} align="top">
<Col span={10}>
<Card size="small" loading={evidenceImageFetching}>
<Typography.Text strong></Typography.Text>
<div
style={{
marginTop: 10,
height: 420,
background: '#fafafa',
border: '1px dashed #d9d9d9',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
{evidenceImageDetail?.url ? (
<img
src={evidenceImageDetail.url}
alt="来源截图"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</Card>
</Col>
<Col span={14}>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="交易时间">
{reviewModal.transaction.tradeTime}
@@ -381,6 +619,9 @@ const Review: React.FC = () => {
{confidenceConfig[reviewModal.confidenceLevel].label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="AI建议">
<Tag color="geekblue">{aiSuggestionLabel[getAiSuggestedAction(reviewModal)]}</Tag>
</Descriptions.Item>
</Descriptions>
<Card size="small" style={{ marginBottom: 16, background: '#f6ffed', borderColor: '#b7eb8f' }}>
@@ -402,6 +643,9 @@ const Review: React.FC = () => {
<Divider />
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text strong></Typography.Text>
<Typography.Text type="secondary">
AI {aiSuggestionLabel[getAiSuggestedAction(reviewModal)]}
</Typography.Text>
<Select
value={reviewAction}
onChange={setReviewAction}
@@ -433,9 +677,35 @@ const Review: React.FC = () => {
</Descriptions.Item>
</Descriptions>
)}
</Col>
</Row>
<Divider />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setReviewModal(null)}>
</Button>
{reviewModal.reviewStatus === 'pending' && (
<Button
type="primary"
loading={reviewMutation.isPending}
onClick={() => {
reviewMutation.mutate({
assessmentId: reviewModal.id,
body: {
review_status: reviewAction,
review_note: reviewNote,
reviewed_by: 'demo_user',
},
});
}}
>
</Button>
)}
</div>
</>
)}
</Modal>
</Drawer>
</div>
);
};

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
App,
Card,
Row,
Col,
@@ -12,26 +13,27 @@ import {
Badge,
Descriptions,
Empty,
List,
Drawer,
Button,
Form,
Input,
InputNumber,
DatePicker,
Divider,
Segmented,
Checkbox,
Alert,
Collapse,
Table,
Input,
InputNumber,
} from 'antd';
import {
FileImageOutlined,
CheckCircleOutlined,
LoadingOutlined,
CloseCircleOutlined,
EditOutlined,
ZoomInOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types';
import { fetchImages } from '../../services/api';
import { fetchImageDetail, fetchImages, startCaseOcr } from '../../services/api';
const appLabel: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' },
@@ -47,7 +49,17 @@ const pageTypeLabel: Record<PageType, string> = {
transfer_receipt: '转账凭证',
sms_notice: '短信通知',
balance: '余额页',
unknown: '未识别',
unknown: '页面类型未识别',
};
const ocrStatusMeta: Record<
EvidenceImage['ocrStatus'],
{ badgeStatus: 'success' | 'processing' | 'error' | 'default'; text: string }
> = {
done: { badgeStatus: 'success', text: 'OCR已完成' },
processing: { badgeStatus: 'processing', text: 'OCR识别中' },
pending: { badgeStatus: 'default', text: '待识别' },
failed: { badgeStatus: 'error', text: '识别失败' },
};
const ocrStatusIcon: Record<string, React.ReactNode> = {
@@ -57,15 +69,190 @@ const ocrStatusIcon: Record<string, React.ReactNode> = {
pending: <FileImageOutlined style={{ color: '#d9d9d9' }} />,
};
type EditableTx = {
id: string;
blockId: string;
data: Record<string, unknown>;
jsonText: string;
jsonError?: string;
};
const preferredFieldOrder = [
'trade_time',
'amount',
'direction',
'counterparty_name',
'counterparty_account',
'self_account_tail_no',
'order_no',
'remark',
'confidence',
] as const;
const fieldLabelMap: Record<string, string> = {
trade_time: '交易时间',
amount: '金额',
direction: '方向',
counterparty_name: '对方名称',
counterparty_account: '对方账号',
self_account_tail_no: '本方尾号',
order_no: '订单号',
remark: '备注',
confidence: '置信度',
raw_content: '原始文本',
};
const tryParseJson = (text: string): unknown | null => {
try {
return JSON.parse(text);
} catch {
const match = text.match(/(\[[\s\S]*\]|\{[\s\S]*\})/);
if (!match) return null;
try {
return JSON.parse(match[1]);
} catch {
return null;
}
}
};
const normalizeToObject = (value: unknown): Record<string, unknown> | null => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return null;
};
const buildEditableTransactions = (
blocks: Array<{ id: string; content: string }>,
): EditableTx[] => {
const items: EditableTx[] = [];
blocks.forEach((block, blockIdx) => {
const raw = (block.content || '').trim();
const parsed = tryParseJson(raw);
if (Array.isArray(parsed)) {
parsed.forEach((entry, i) => {
const obj = normalizeToObject(entry);
if (!obj) return;
items.push({
id: `${block.id}-${i}`,
blockId: block.id,
data: obj,
jsonText: JSON.stringify(obj, null, 2),
});
});
return;
}
const obj = normalizeToObject(parsed);
if (obj) {
items.push({
id: `${block.id}-0`,
blockId: block.id,
data: obj,
jsonText: JSON.stringify(obj, null, 2),
});
return;
}
items.push({
id: `${block.id}-${blockIdx}`,
blockId: block.id,
data: { raw_content: raw },
jsonText: JSON.stringify({ raw_content: raw }, null, 2),
});
});
return items;
};
const formatMoney = (v: unknown): string => {
const n = Number(v);
if (!Number.isFinite(n)) return '-';
return n.toFixed(2);
};
const Screenshots: React.FC = () => {
const { id = '1' } = useParams();
const queryClient = useQueryClient();
const { message } = App.useApp();
const [filterApp, setFilterApp] = useState<string>('all');
const [selectedImage, setSelectedImage] = useState<EvidenceImage | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [rerunTracking, setRerunTracking] = useState<Record<string, { sawProcessing: boolean }>>({});
const [editableTxs, setEditableTxs] = useState<EditableTx[]>([]);
const { data: allImages = [] } = useQuery({
queryKey: ['images', id],
queryFn: () => fetchImages(id),
refetchInterval: Object.keys(rerunTracking).length > 0 ? 2000 : false,
});
const triggerOcrMutation = useMutation({
mutationFn: (targetIds: string[]) =>
startCaseOcr(
id,
targetIds.length > 0,
targetIds.length > 0 ? targetIds : undefined,
),
onMutate: (targetIds) => {
message.open({
key: 'screenshots-ocr',
type: 'loading',
content: targetIds.length > 0 ? `正在提交选中图片 OCR${targetIds.length}...` : '正在提交 OCR 任务...',
duration: 0,
});
if (targetIds.length > 0) {
setRerunTracking((prev) => {
const next = { ...prev };
targetIds.forEach((imageId) => {
next[imageId] = { sawProcessing: false };
});
return next;
});
}
return { targetIds };
},
onSuccess: (res) => {
const isRerun = selectedIds.length > 0;
if (isRerun) {
setSelectedIds([]);
}
queryClient.invalidateQueries({ queryKey: ['images', id] });
message.open({
key: 'screenshots-ocr',
type: 'success',
content: res.message,
});
},
onError: (_err, _vars, ctx) => {
const rollbackIds = ctx?.targetIds || [];
if (rollbackIds.length > 0) {
setRerunTracking((prev) => {
const next = { ...prev };
rollbackIds.forEach((imageId) => {
delete next[imageId];
});
return next;
});
message.open({
key: 'screenshots-ocr',
type: 'error',
content: '选中图片 OCR 重跑提交失败',
});
return;
}
message.open({
key: 'screenshots-ocr',
type: 'error',
content: 'OCR任务提交失败',
});
},
});
const { data: imageDetail } = useQuery({
queryKey: ['image-detail', selectedImage?.id],
queryFn: () => fetchImageDetail(selectedImage!.id),
enabled: drawerOpen && !!selectedImage?.id,
});
const filtered =
@@ -82,15 +269,125 @@ const Screenshots: React.FC = () => {
setSelectedImage(img);
setDrawerOpen(true);
};
const resolveOcrStatus = (imageId: string, backendStatus: EvidenceImage['ocrStatus']): EvidenceImage['ocrStatus'] => {
const tracking = rerunTracking[imageId];
if (!tracking) return backendStatus;
if (backendStatus === 'failed') return 'failed';
if (backendStatus === 'done' && tracking.sawProcessing) return 'done';
return 'processing';
};
React.useEffect(() => {
if (Object.keys(rerunTracking).length === 0) return;
const statusById = new Map(allImages.map((img) => [img.id, img.ocrStatus] as const));
setRerunTracking((prev) => {
let changed = false;
const next: Record<string, { sawProcessing: boolean }> = { ...prev };
Object.entries(prev).forEach(([imageId, tracking]) => {
const status = statusById.get(imageId);
if (!status) {
delete next[imageId];
changed = true;
return;
}
if (status === 'processing' && !tracking.sawProcessing) {
next[imageId] = { sawProcessing: true };
changed = true;
return;
}
if (status === 'failed' || (status === 'done' && tracking.sawProcessing)) {
delete next[imageId];
changed = true;
}
});
return changed ? next : prev;
});
}, [allImages, rerunTracking]);
const toggleChecked = (imageId: string, checked: boolean) => {
setSelectedIds((prev) => (checked ? Array.from(new Set([...prev, imageId])) : prev.filter((id) => id !== imageId)));
};
const selectAllFiltered = () => {
setSelectedIds(Array.from(new Set([...selectedIds, ...filtered.map((img) => img.id)])));
};
const clearSelection = () => setSelectedIds([]);
const rawBlocks = imageDetail?.ocrBlocks || [];
const visibleBlocks = rawBlocks.filter((b) => {
const txt = (b.content || '').trim();
return txt !== '' && txt !== '{}' && txt !== '[]';
});
const getEmptyReason = () => {
const status = (imageDetail || selectedImage)?.ocrStatus;
if (!status) return '';
if (status === 'pending') return '该截图尚未开始 OCR 识别,请先触发识别。';
if (status === 'processing') return 'OCR 正在进行中,稍后会自动刷新结果。';
if (status === 'failed') return 'OCR 识别失败,请尝试重新识别或检查截图清晰度。';
if (status === 'done' && rawBlocks.length === 0) {
return 'OCR 已执行完成,但未提取到可用文本块。常见原因:截图内容非交易明细、清晰度不足或遮挡。';
}
if (status === 'done' && rawBlocks.length > 0 && visibleBlocks.length === 0) {
return 'OCR 返回了空结构(如 {} / []),未形成可展示字段;可尝试重新识别或更换更清晰截图。';
}
return '';
};
useEffect(() => {
if (!drawerOpen) return;
setEditableTxs(buildEditableTransactions(visibleBlocks.map((b) => ({ id: b.id, content: b.content }))));
}, [drawerOpen, selectedImage?.id, imageDetail?.id, imageDetail?.ocrBlocks]);
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 },
const updateTxField = (txId: string, field: string, value: unknown) => {
setEditableTxs((prev) =>
prev.map((tx) => {
if (tx.id !== txId) return tx;
const nextData = { ...tx.data, [field]: value };
return {
...tx,
data: nextData,
jsonText: JSON.stringify(nextData, null, 2),
jsonError: undefined,
};
}),
);
};
const updateTxJson = (txId: string, text: string) => {
setEditableTxs((prev) =>
prev.map((tx) => {
if (tx.id !== txId) return tx;
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ...tx, jsonText: text, jsonError: 'JSON 顶层必须是对象' };
}
return {
...tx,
jsonText: text,
jsonError: undefined,
data: parsed as Record<string, unknown>,
};
} catch {
return { ...tx, jsonText: text, jsonError: 'JSON 格式错误' };
}
}),
);
};
const getTxSummary = (tx: EditableTx, index: number) => {
const tradeTime = String(tx.data.trade_time ?? tx.data.tradeTime ?? '-');
const amount = tx.data.amount;
return `#${index + 1} 时间:${tradeTime} 金额:${formatMoney(amount)}`;
};
const buildFieldRows = (tx: EditableTx) => {
const keys = Object.keys(tx.data);
const ordered = [
...preferredFieldOrder.filter((k) => keys.includes(k)),
...keys.filter((k) => !preferredFieldOrder.includes(k as (typeof preferredFieldOrder)[number])),
];
return ordered.map((key) => ({
key,
label: fieldLabelMap[key] || key,
value: tx.data[key],
}));
};
return (
<div>
@@ -104,6 +401,18 @@ const Screenshots: React.FC = () => {
}
extra={
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={triggerOcrMutation.isPending}
disabled={selectedIds.length === 0}
onClick={() => triggerOcrMutation.mutate(selectedIds)}
>
{selectedIds.length > 0 ? `对选中图片重新OCR${selectedIds.length}` : '开始 OCR 识别'}
</Button>
<Button onClick={selectAllFiltered}></Button>
<Button onClick={clearSelection} disabled={selectedIds.length === 0}></Button>
{selectedIds.length > 0 && <Tag color="blue"> {selectedIds.length} </Tag>}
<Select
value={filterApp}
onChange={setFilterApp}
@@ -127,7 +436,9 @@ const Screenshots: React.FC = () => {
}
>
<Row gutter={[16, 16]}>
{filtered.map((img) => (
{filtered.map((img) => {
const viewStatus = resolveOcrStatus(img.id, img.ocrStatus);
return (
<Col key={img.id} xs={12} sm={8} md={6} lg={4}>
<Card
hoverable
@@ -148,6 +459,14 @@ const Screenshots: React.FC = () => {
overflow: 'hidden',
}}
>
{(img.thumbUrl || img.url) ? (
<img
src={img.thumbUrl || img.url}
alt="截图缩略图"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<>
<FileImageOutlined
style={{ fontSize: 40, color: '#bfbfbf' }}
/>
@@ -157,6 +476,8 @@ const Screenshots: React.FC = () => {
>
</Typography.Text>
</>
)}
<div
style={{
position: 'absolute',
@@ -164,7 +485,7 @@ const Screenshots: React.FC = () => {
right: 8,
}}
>
{ocrStatusIcon[img.ocrStatus]}
{ocrStatusIcon[viewStatus]}
</div>
<div
style={{
@@ -172,7 +493,13 @@ const Screenshots: React.FC = () => {
top: 8,
left: 8,
}}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={selectedIds.includes(img.id)}
onChange={(e) => toggleChecked(img.id, e.target.checked)}
style={{ marginRight: 6 }}
/>
<Tag
color={appLabel[img.sourceApp].color}
style={{ fontSize: 11 }}
@@ -187,12 +514,17 @@ const Screenshots: React.FC = () => {
{pageTypeLabel[img.pageType]}
</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{ocrStatusMeta[viewStatus].text}
</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{img.uploadedAt}
</Typography.Text>
</Card>
</Col>
))}
);
})}
</Row>
{filtered.length === 0 && <Empty description="暂无截图" />}
@@ -207,8 +539,12 @@ const Screenshots: React.FC = () => {
</Tag>
<span>{pageTypeLabel[selectedImage.pageType]}</span>
<Badge
status={selectedImage.ocrStatus === 'done' ? 'success' : 'processing'}
text={selectedImage.ocrStatus === 'done' ? 'OCR已完成' : '处理中'}
status={ocrStatusMeta[
resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].badgeStatus}
text={ocrStatusMeta[
resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].text}
/>
</Space>
) : '截图详情'
@@ -230,8 +566,16 @@ const Screenshots: React.FC = () => {
justifyContent: 'center',
marginBottom: 24,
border: '1px dashed #d9d9d9',
overflow: 'hidden',
}}
>
{(imageDetail?.url || selectedImage.url) ? (
<img
src={imageDetail?.url || selectedImage.url}
alt="原始截图"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<Space direction="vertical" align="center">
<FileImageOutlined style={{ fontSize: 64, color: '#bfbfbf' }} />
<Typography.Text type="secondary">
@@ -241,49 +585,101 @@ const Screenshots: React.FC = () => {
</Button>
</Space>
)}
</div>
<Typography.Title level={5}>OCR </Typography.Title>
<Typography.Text type="secondary" style={{ marginBottom: 16, display: 'block' }}>
OCR
</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>
}
{visibleBlocks.length === 0 && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="暂无 OCR 提取结果"
description={getEmptyReason()}
/>
</List.Item>
)}
{editableTxs.length > 0 && (
<Collapse
size="small"
items={editableTxs.map((tx, idx) => ({
key: tx.id,
label: (
<Space>
<Typography.Text strong>{getTxSummary(tx, idx)}</Typography.Text>
<Tag color="blue"> {idx + 1}</Tag>
</Space>
),
children: (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Table
size="small"
pagination={false}
rowKey="key"
dataSource={buildFieldRows(tx)}
columns={[
{
title: '字段',
dataIndex: 'label',
key: 'label',
width: 160,
},
{
title: '值',
key: 'value',
render: (_, row: { key: string; value: unknown }) => {
if (row.key === 'direction') {
return (
<Select
style={{ width: 140 }}
value={String(row.value ?? 'out')}
options={[
{ label: '转入(in)', value: 'in' },
{ label: '转出(out)', value: 'out' },
]}
onChange={(val) => updateTxField(tx.id, row.key, val)}
/>
);
}
if (row.key === 'amount' || row.key === 'confidence') {
const numVal = Number(row.value);
return (
<InputNumber
style={{ width: '100%' }}
value={Number.isFinite(numVal) ? numVal : undefined}
onChange={(val) => updateTxField(tx.id, row.key, val ?? 0)}
/>
);
}
return (
<Input
value={String(row.value ?? '')}
onChange={(e) => updateTxField(tx.id, row.key, e.target.value)}
/>
);
},
},
]}
/>
<div>
<Typography.Text type="secondary">JSON</Typography.Text>
<Input.TextArea
value={tx.jsonText}
rows={8}
onChange={(e) => updateTxJson(tx.id, e.target.value)}
style={{ marginTop: 8, fontFamily: 'monospace' }}
/>
{tx.jsonError && (
<Typography.Text type="danger">{tx.jsonError}</Typography.Text>
)}
</div>
</Space>
),
}))}
/>
)}
<Divider />
@@ -292,7 +688,7 @@ const Screenshots: React.FC = () => {
{selectedImage.id}
</Descriptions.Item>
<Descriptions.Item label="文件哈希">
{selectedImage.hash}
{selectedImage.fileHash}
</Descriptions.Item>
<Descriptions.Item label="上传时间">
{selectedImage.uploadedAt}

View File

@@ -124,7 +124,7 @@ const Transactions: React.FC = () => {
render: (_, r) => (
<Space size={4}>
{r.isDuplicate && (
<Tooltip title="该笔为重复展示记录,已与其他截图中的同笔交易合并">
<Tooltip title="该笔与其他记录订单号一致,判定为同一笔展示记录并已归并">
<Tag color="red"></Tag>
</Tooltip>
)}
@@ -219,7 +219,7 @@ const Transactions: React.FC = () => {
{duplicateCount > 0 && (
<Alert
message={`系统识别出 ${duplicateCount} 笔重复展示记录`}
description={'同一笔交易可能在列表页、详情页、短信通知中多次出现。标记为「重复」的记录已被合并,不会重复计入金额汇总。'}
description={'当前仅对“订单号一致”的记录做归并。金额/时间高度相似但订单号不同的交易不会自动排除,将在认定复核中由民警进一步确认。'}
type="info"
showIcon
closable

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
App,
Card,
Steps,
Row,
@@ -15,8 +16,6 @@ import {
Descriptions,
Progress,
Alert,
Divider,
message,
} from 'antd';
import {
CloudUploadOutlined,
@@ -27,20 +26,81 @@ import {
FileTextOutlined,
InboxOutlined,
RightOutlined,
PlayCircleOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { fetchCase, fetchImages, fetchTransactions, fetchAssessments, uploadImages } from '../../services/api';
import { fetchCase, fetchImages, fetchTransactions, fetchAssessments, uploadImages, startCaseOcr, triggerAnalysis } from '../../services/api';
import type { EvidenceImage } from '../../types';
const { Dragger } = Upload;
const Workspace: React.FC = () => {
const { id = '1' } = useParams();
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(3);
const queryClient = useQueryClient();
const { message } = App.useApp();
const [uploadingCount, setUploadingCount] = useState(0);
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 ocrMutation = useMutation({
mutationFn: () => startCaseOcr(id, false),
onMutate: () => {
message.open({
key: 'workspace-ocr',
type: 'loading',
content: '正在提交 OCR 任务...',
duration: 0,
});
},
onSuccess: (res) => {
message.open({
key: 'workspace-ocr',
type: 'success',
content: res.message,
});
queryClient.invalidateQueries({ queryKey: ['images', id] });
},
onError: () => {
message.open({
key: 'workspace-ocr',
type: 'error',
content: 'OCR任务提交失败',
});
},
});
const analysisMutation = useMutation({
mutationFn: () => triggerAnalysis(id),
onMutate: () => {
message.open({
key: 'workspace-analysis',
type: 'loading',
content: '正在提交案件分析任务...',
duration: 0,
});
},
onSuccess: (res) => {
message.open({
key: 'workspace-analysis',
type: 'success',
content: res.message || '分析任务已提交',
});
queryClient.invalidateQueries({ queryKey: ['assessments', id] });
queryClient.invalidateQueries({ queryKey: ['suggestions', id] });
queryClient.invalidateQueries({ queryKey: ['transactions', id] });
queryClient.invalidateQueries({ queryKey: ['flows', id] });
queryClient.invalidateQueries({ queryKey: ['case', id] });
},
onError: () => {
message.open({
key: 'workspace-analysis',
type: 'error',
content: '案件分析提交失败',
});
},
});
const images = imagesData ?? [];
const txList = txData?.items ?? [];
@@ -49,6 +109,16 @@ const Workspace: React.FC = () => {
const highConfirm = assessments.filter((a) => a.confidenceLevel === 'high').length;
const pendingReview = assessments.filter((a) => a.reviewStatus === 'pending').length;
const currentStep = useMemo(() => {
if (images.length === 0) return 0;
const doneCount = images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length;
if (doneCount < images.length) return 1;
if (txList.length === 0) return 2;
if (assessments.length === 0) return 3;
if (pendingReview > 0) return 4;
return 5;
}, [images, txList.length, assessments.length, pendingReview]);
if (!currentCase) return null;
const steps = [
@@ -60,7 +130,7 @@ const Workspace: React.FC = () => {
{
title: 'OCR识别',
icon: <ScanOutlined />,
description: `${images.filter((i: any) => i.ocrStatus === 'done').length}/${images.length} 已完成`,
description: `${images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length}/${images.length} 已完成`,
},
{
title: '交易归并',
@@ -70,7 +140,7 @@ const Workspace: React.FC = () => {
{
title: '资金分析',
icon: <ApartmentOutlined />,
description: '已生成路径图',
description: assessments.length > 0 ? `已完成,${assessments.length} 笔认定` : '待分析',
},
{
title: '认定复核',
@@ -117,7 +187,6 @@ const Workspace: React.FC = () => {
<Steps
current={currentStep}
items={steps}
onChange={(v) => setCurrentStep(v)}
/>
</Card>
@@ -136,10 +205,41 @@ const Workspace: React.FC = () => {
multiple
accept="image/*"
showUploadList={false}
beforeUpload={(file, fileList) => {
uploadImages(id, fileList as unknown as File[])
.then(() => message.success('截图上传成功'))
.catch(() => message.error('上传失败'));
beforeUpload={(file) => {
setUploadingCount((c) => c + 1);
message.open({
key: 'img-upload',
type: 'loading',
content: `正在上传截图(队列中 ${uploadingCount + 1} 张)...`,
duration: 0,
});
uploadImages(id, [file as File])
.then(() => {
message.success('截图上传成功');
})
.then(() => {
queryClient.invalidateQueries({ queryKey: ['images', id] });
queryClient.invalidateQueries({ queryKey: ['case', id] });
})
.catch(() => {
message.error('上传失败');
})
.finally(() => {
setUploadingCount((c) => {
const next = Math.max(0, c - 1);
if (next === 0) {
message.destroy('img-upload');
} else {
message.open({
key: 'img-upload',
type: 'loading',
content: `正在上传截图(队列中 ${next} 张)...`,
duration: 0,
});
}
return next;
});
});
return false;
}}
style={{ padding: '20px 0' }}
@@ -154,6 +254,11 @@ const Workspace: React.FC = () => {
APP
</p>
</Dragger>
{uploadingCount > 0 && (
<Typography.Text type="secondary">
{uploadingCount} ...
</Typography.Text>
)}
</Card>
<Card title="处理进度">
@@ -163,7 +268,7 @@ const Workspace: React.FC = () => {
<Progress
type="circle"
percent={Math.round(
images.length ? (images.filter((i: any) => i.ocrStatus === 'done').length /
images.length ? (images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length /
images.length) *
100 : 0,
)}
@@ -243,29 +348,28 @@ const Workspace: React.FC = () => {
<Space direction="vertical" style={{ width: '100%' }}>
<Button
block
onClick={() => navigate('/cases/1/screenshots')}
icon={<RightOutlined />}
type="primary"
ghost
loading={ocrMutation.isPending}
onClick={() => ocrMutation.mutate()}
icon={<PlayCircleOutlined />}
>
OCR
</Button>
<Button
block
onClick={() => navigate('/cases/1/transactions')}
icon={<RightOutlined />}
>
</Button>
<Button
block
onClick={() => navigate('/cases/1/analysis')}
icon={<RightOutlined />}
>
OCR
</Button>
<Button
block
type="primary"
onClick={() => navigate('/cases/1/review')}
ghost
loading={analysisMutation.isPending}
onClick={() => analysisMutation.mutate()}
icon={<ThunderboltOutlined />}
>
{analysisMutation.isPending ? '分析中...' : '执行案件分析'}
</Button>
<Button
block
type="primary"
onClick={() => navigate(`/cases/${id}/review`)}
icon={<RightOutlined />}
>
@@ -284,7 +388,7 @@ const Workspace: React.FC = () => {
<Button
size="small"
type="primary"
onClick={() => navigate('/cases/1/review')}
onClick={() => navigate(`/cases/${id}/review`)}
>
</Button>

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import React, { lazy, Suspense } from 'react';
import type { RouteObject } from 'react-router-dom';
import { Navigate } from 'react-router-dom';

View File

@@ -8,9 +8,12 @@
import type {
CaseRecord,
EvidenceImage,
EvidenceImageDetail,
TransactionRecord,
FraudAssessment,
ExportReport,
FundFlowNode,
FundFlowEdge,
} from '../types';
import {
mockCases,
@@ -36,20 +39,30 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
// ── helpers ──
let backendAlive: boolean | null = null;
let lastCheckTime = 0;
const CHECK_TTL_MS = 10_000; // re-check every 10s if previously failed
async function isBackendUp(): Promise<boolean> {
if (backendAlive !== null) return backendAlive;
const now = Date.now();
if (backendAlive === true && now - lastCheckTime < 60_000) return true;
if (backendAlive === false && now - lastCheckTime < CHECK_TTL_MS) return false;
try {
const r = await fetch('/health', { signal: AbortSignal.timeout(2000) });
const r = await fetch('/api/v1/cases?limit=1', { signal: AbortSignal.timeout(2000) });
backendAlive = r.ok;
} catch {
backendAlive = false;
}
lastCheckTime = now;
return backendAlive;
}
export function resetBackendCheck() {
backendAlive = null;
lastCheckTime = 0;
}
export async function getDataSourceMode(): Promise<'mock' | 'api'> {
return (await isBackendUp()) ? 'api' : 'mock';
}
// ── Cases ──
@@ -106,17 +119,51 @@ export async function uploadImages(caseId: string, files: File[]): Promise<Evide
return resp.json();
}
export async function fetchImageDetail(imageId: string): Promise<EvidenceImageDetail> {
if (!(await isBackendUp())) {
const fallback = mockImages.find((i) => i.id === imageId) || mockImages[0];
return {
...fallback,
ocrBlocks: [],
};
}
return request(`${BASE}/images/${imageId}`);
}
export async function startCaseOcr(
caseId: string,
includeDone = false,
imageIds?: string[],
): Promise<{ caseId: string; submitted: number; totalCandidates: number; message: string }> {
if (!(await isBackendUp())) {
return {
caseId,
submitted: imageIds?.length || 0,
totalCandidates: imageIds?.length || 0,
message: 'Mock 模式下不执行真实 OCR 任务',
};
}
const result = await request<{ caseId: string; submitted: number; totalCandidates: number; message: string }>(`${BASE}/cases/${caseId}/ocr/start`, {
method: 'POST',
body: JSON.stringify({
include_done: includeDone,
image_ids: imageIds && imageIds.length ? imageIds : undefined,
}),
});
return result;
}
// ── Analysis ──
export async function triggerAnalysis(caseId: string): Promise<{ task_id: string; message: string }> {
if (!(await isBackendUp())) return { task_id: 'mock', message: '分析任务已提交' };
export async function triggerAnalysis(caseId: string): Promise<{ taskId: string; message: string }> {
if (!(await isBackendUp())) return { taskId: '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: '待复核' };
): Promise<{ status: string; progress: number; currentStep: string }> {
if (!(await isBackendUp())) return { status: 'reviewing', progress: 85, currentStep: '待复核' };
return request(`${BASE}/cases/${caseId}/analyze/status`);
}
@@ -135,7 +182,7 @@ export async function fetchTransactions(
export async function fetchFlows(
caseId: string,
): Promise<{ nodes: any[]; edges: any[] }> {
): Promise<{ nodes: FundFlowNode[]; edges: FundFlowEdge[] }> {
if (!(await isBackendUp())) return { nodes: mockFlowNodes, edges: mockFlowEdges };
return request(`${BASE}/cases/${caseId}/flows`);
}
@@ -153,7 +200,7 @@ export async function fetchAssessments(
export async function submitReview(
assessmentId: string,
body: { review_status: string; review_note?: string; reviewed_by?: string },
body: { review_status: 'confirmed' | 'rejected' | 'needs_info'; review_note?: string; reviewed_by?: string },
): Promise<FraudAssessment> {
if (!(await isBackendUp())) return mockAssessments[0];
return request(`${BASE}/assessments/${assessmentId}/review`, {
@@ -178,7 +225,16 @@ export async function fetchInquirySuggestions(caseId: string): Promise<{ suggest
export async function generateReport(
caseId: string,
body: { report_type: string },
body: {
report_type: string;
include_summary?: boolean;
include_transactions?: boolean;
include_flow_chart?: boolean;
include_timeline?: boolean;
include_reasons?: boolean;
include_inquiry?: boolean;
include_screenshots?: boolean;
},
): Promise<ExportReport> {
if (!(await isBackendUp())) return mockReports[0];
return request(`${BASE}/cases/${caseId}/reports`, {

View File

@@ -24,10 +24,22 @@ export interface EvidenceImage {
sourceApp: SourceApp;
pageType: PageType;
ocrStatus: 'pending' | 'processing' | 'done' | 'failed';
hash: string;
fileHash: string;
uploadedAt: string;
}
export interface OcrBlock {
id: string;
content: string;
bbox: Record<string, unknown>;
seqOrder: number;
confidence: number;
}
export interface EvidenceImageDetail extends EvidenceImage {
ocrBlocks: OcrBlock[];
}
export interface TransactionRecord {
id: string;
caseId: string;
@@ -81,8 +93,8 @@ export interface FundFlowEdge {
export interface ExportReport {
id: string;
caseId: string;
type: 'pdf' | 'excel' | 'word';
url: string;
reportType: 'pdf' | 'excel' | 'word';
filePath: string;
createdAt: string;
version: number;
}