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

View File

@@ -72,14 +72,14 @@ export const mockCases: CaseRecord[] = [
]; ];
export const mockImages: EvidenceImage[] = [ 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-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', hash: 'c3d4', 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', hash: 'e5f6', uploadedAt: '2026-03-08 09:36: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', hash: 'g7h8', 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', hash: 'i9j0', uploadedAt: '2026-03-08 09:37: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', hash: 'k1l2', 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', hash: 'm3n4', uploadedAt: '2026-03-08 09:38: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', hash: 'o5p6', 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[] = [ export const mockTransactions: TransactionRecord[] = [
@@ -194,5 +194,5 @@ export const mockFlowEdges: FundFlowEdge[] = [
]; ];
export const mockReports: ExportReport[] = [ 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 React, { useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Card, Row, Col, Tag, Typography, Space, Timeline, Statistic, Divider } from 'antd'; import { App, Card, Row, Col, Tag, Typography, Space, Timeline, Statistic, Alert, Button } from 'antd';
import { import {
ApartmentOutlined, ApartmentOutlined,
ClockCircleOutlined, ClockCircleOutlined,
ArrowUpOutlined, ThunderboltOutlined,
ArrowDownOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react'; 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> = { const nodeColorMap: Record<string, string> = {
self: '#1677ff', self: '#1677ff',
@@ -20,6 +20,8 @@ const nodeColorMap: Record<string, string> = {
const Analysis: React.FC = () => { const Analysis: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const queryClient = useQueryClient();
const { message } = App.useApp();
const { data: txData } = useQuery({ const { data: txData } = useQuery({
queryKey: ['transactions', id], queryKey: ['transactions', id],
@@ -30,12 +32,42 @@ const Analysis: React.FC = () => {
queryFn: () => fetchFlows(id), queryFn: () => fetchFlows(id),
}); });
const mockTransactions = txData?.items ?? []; const analysisMutation = useMutation({
const mockFlowNodes = flowData?.nodes ?? []; mutationFn: () => triggerAnalysis(id),
const mockFlowEdges = flowData?.edges ?? []; 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 flowChartOption = useMemo(() => {
const nodes = mockFlowNodes.map((n: any) => ({ const nodes = flowNodes.map((n) => ({
name: n.label, name: n.label,
symbolSize: n.type === 'suspect' ? 60 : 50, symbolSize: n.type === 'suspect' ? 60 : 50,
itemStyle: { color: nodeColorMap[n.type] }, itemStyle: { color: nodeColorMap[n.type] },
@@ -43,9 +75,9 @@ const Analysis: React.FC = () => {
category: n.type === 'self' ? 0 : n.type === 'suspect' ? 1 : 2, category: n.type === 'self' ? 0 : n.type === 'suspect' ? 1 : 2,
})); }));
const edges = mockFlowEdges.map((e: any) => { const edges = flowEdges.map((e) => {
const src = mockFlowNodes.find((n: any) => n.id === e.source); const src = flowNodes.find((n) => n.id === e.source);
const tgt = mockFlowNodes.find((n: any) => n.id === e.target); const tgt = flowNodes.find((n) => n.id === e.target);
return { return {
source: src?.label || '', source: src?.label || '',
target: tgt?.label || '', target: tgt?.label || '',
@@ -91,17 +123,17 @@ const Analysis: React.FC = () => {
}, },
], ],
}; };
}, [mockFlowNodes, mockFlowEdges]); }, [flowNodes, flowEdges]);
const timelineChartOption = useMemo(() => { const timelineChartOption = useMemo(() => {
const sorted = [...mockTransactions] const sorted = [...transactions]
.filter((t) => !t.isDuplicate) .filter((t) => !t.isDuplicate)
.sort((a, b) => a.tradeTime.localeCompare(b.tradeTime)); .sort((a, b) => a.tradeTime.localeCompare(b.tradeTime));
return { return {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
formatter: (params: any) => { formatter: (params: Array<{ axisValue: string; value: number }>) => {
const p = params[0]; const p = params[0];
return `${p.axisValue}<br/>金额: ¥${Math.abs(p.value).toLocaleString()}`; 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 const totalFraud = validTx
.filter((t) => t.direction === 'out' && !t.isTransit) .filter((t) => t.direction === 'out' && !t.isTransit)
.reduce((s, t) => s + t.amount, 0); .reduce((s, t) => s + t.amount, 0);
@@ -145,6 +177,34 @@ const Analysis: React.FC = () => {
return ( return (
<div> <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 }}> <Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}> <Col span={8}>
<Card variant="borderless"> <Card variant="borderless">
@@ -171,7 +231,7 @@ const Analysis: React.FC = () => {
<Statistic <Statistic
title="有效交易" title="有效交易"
value={validTx.length} value={validTx.length}
suffix={`/ ${mockTransactions.length}`} suffix={`/ ${transactions.length}`}
/> />
</Card> </Card>
</Col> </Col>
@@ -217,6 +277,38 @@ const Analysis: React.FC = () => {
绿 绿
</Typography.Text> </Typography.Text>
</Card> </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>
<Col span={10}> <Col span={10}>
@@ -286,38 +378,6 @@ const Analysis: React.FC = () => {
}))} }))}
/> />
</Card> </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> </Col>
</Row> </Row>
</div> </div>

View File

@@ -118,8 +118,15 @@ const CaseList: React.FC = () => {
render: (_, record) => ( render: (_, record) => (
<Space> <Space>
<Button <Button
type="link" type="default"
size="small" 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`)} 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 { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
@@ -13,10 +13,8 @@ import {
Tag, Tag,
Divider, Divider,
Descriptions, Descriptions,
Select,
Checkbox, Checkbox,
message, message,
Steps,
Result, Result,
} from 'antd'; } from 'antd';
import { import {
@@ -32,10 +30,64 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { fetchCase, fetchAssessments, fetchReports, generateReport, getReportDownloadUrl } from '../../services/api'; 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 Reports: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const qc = useQueryClient(); const qc = useQueryClient();
const [generated, setGenerated] = useState(false); 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: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) }); const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
@@ -53,20 +105,37 @@ const Reports: React.FC = () => {
); );
const genMutation = useMutation({ const genMutation = useMutation({
mutationFn: (reportType: string) => generateReport(id, { report_type: reportType }), mutationFn: async (reportTypes: Array<'excel' | 'pdf' | 'word'>) => {
onSuccess: () => { const result = await Promise.all(
reportTypes.map((reportType) =>
generateReport(id, { report_type: reportType, ...contentSel }),
),
);
return result;
},
onSuccess: (_res, vars) => {
setGenerated(true); setGenerated(true);
qc.invalidateQueries({ queryKey: ['reports', id] }); qc.invalidateQueries({ queryKey: ['reports', id] });
message.success('报告生成成功'); message.success(`报告生成成功${vars.map((v) => v.toUpperCase()).join(' / ')}`);
},
onError: () => {
message.error('报告生成失败');
}, },
}); });
if (!currentCase) return null; 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: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'reportType',
width: 100, width: 100,
render: (t: string) => { render: (t: string) => {
const map: Record<string, { icon: React.ReactNode; label: string; color: string }> = { const map: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
@@ -92,9 +161,15 @@ const Reports: React.FC = () => {
{ {
title: '操作', title: '操作',
width: 120, width: 120,
render: () => ( render: (_, report) => (
<Space> <Space>
<Button type="link" size="small" icon={<DownloadOutlined />}> <Button
type="link"
size="small"
icon={<DownloadOutlined />}
href={getReportDownloadUrl(report.id)}
target="_blank"
>
</Button> </Button>
</Space> </Space>
@@ -188,7 +263,18 @@ const Reports: React.FC = () => {
<Typography.Text>Excel </Typography.Text> <Typography.Text>Excel </Typography.Text>
</div> </div>
<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> </div>
</Card> </Card>
<Card <Card
@@ -201,7 +287,18 @@ const Reports: React.FC = () => {
<Typography.Text>PDF </Typography.Text> <Typography.Text>PDF </Typography.Text>
</div> </div>
<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> </div>
</Card> </Card>
<Card <Card
@@ -214,7 +311,18 @@ const Reports: React.FC = () => {
<Typography.Text>Word </Typography.Text> <Typography.Text>Word </Typography.Text>
</div> </div>
<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> </div>
</Card> </Card>
</Space> </Space>
@@ -223,13 +331,15 @@ const Reports: React.FC = () => {
<Typography.Text strong></Typography.Text> <Typography.Text strong></Typography.Text>
<div style={{ margin: '12px 0 24px' }}> <div style={{ margin: '12px 0 24px' }}>
<Space direction="vertical"> <Space direction="vertical">
<Checkbox defaultChecked></Checkbox> {contentOptions.map((opt) => (
<Checkbox defaultChecked></Checkbox> <Checkbox
<Checkbox defaultChecked></Checkbox> key={opt.key}
<Checkbox defaultChecked></Checkbox> checked={contentSel[opt.key]}
<Checkbox defaultChecked></Checkbox> onChange={(e) => toggleContent(opt.key, e.target.checked)}
<Checkbox></Checkbox> >
<Checkbox></Checkbox> {opt.label}
</Checkbox>
))}
</Space> </Space>
</div> </div>
@@ -239,7 +349,13 @@ const Reports: React.FC = () => {
size="large" size="large"
icon={<FileTextOutlined />} icon={<FileTextOutlined />}
loading={genMutation.isPending} loading={genMutation.isPending}
onClick={() => genMutation.mutate('excel')} onClick={() => {
if (selectedFormats.length === 0) {
message.warning('请至少选择一种导出格式');
return;
}
genMutation.mutate(selectedFormats);
}}
block block
> >
{genMutation.isPending ? '正在生成报告...' : '生成报告'} {genMutation.isPending ? '正在生成报告...' : '生成报告'}
@@ -253,7 +369,9 @@ const Reports: React.FC = () => {
<Button <Button
key="excel" key="excel"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
onClick={() => message.info('演示模式:下载 Excel')} href={latestReportByType.excel ? getReportDownloadUrl(latestReportByType.excel.id) : undefined}
target="_blank"
disabled={!latestReportByType.excel}
> >
Excel Excel
</Button>, </Button>,
@@ -261,10 +379,21 @@ const Reports: React.FC = () => {
key="pdf" key="pdf"
type="primary" type="primary"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
onClick={() => message.info('演示模式:下载 PDF')} href={latestReportByType.pdf ? getReportDownloadUrl(latestReportByType.pdf.id) : undefined}
target="_blank"
disabled={!latestReportByType.pdf}
> >
PDF PDF
</Button>, </Button>,
<Button
key="word"
icon={<DownloadOutlined />}
href={latestReportByType.word ? getReportDownloadUrl(latestReportByType.word.id) : undefined}
target="_blank"
disabled={!latestReportByType.word}
>
Word
</Button>,
<Button <Button
key="print" key="print"
icon={<PrinterOutlined />} icon={<PrinterOutlined />}

View File

@@ -2,13 +2,14 @@ import React, { useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
App,
Card, Card,
Table, Table,
Tag, Tag,
Typography, Typography,
Space, Space,
Button, Button,
Modal, Drawer,
Input, Input,
Select, Select,
Descriptions, Descriptions,
@@ -17,9 +18,8 @@ import {
Statistic, Statistic,
Alert, Alert,
Segmented, Segmented,
Tooltip,
message,
Divider, Divider,
Dropdown,
} from 'antd'; } from 'antd';
import { import {
AuditOutlined, AuditOutlined,
@@ -27,13 +27,25 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
SafetyCertificateOutlined,
EyeOutlined, EyeOutlined,
FileTextOutlined, FileTextOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { FraudAssessment, ConfidenceLevel } from '../../types'; 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< const confidenceConfig: Record<
ConfidenceLevel, ConfidenceLevel,
@@ -44,35 +56,65 @@ const confidenceConfig: Record<
low: { color: 'default', label: '低置信' }, low: { color: 'default', label: '低置信' },
}; };
const reviewStatusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = { const _reviewStatusConfig = {
pending: { color: 'orange', label: '待复核', icon: <ExclamationCircleOutlined /> }, pending: { color: 'orange', label: '待复核', icon: <ExclamationCircleOutlined /> },
confirmed: { color: 'green', label: '已确认', icon: <CheckCircleOutlined /> }, confirmed: { color: 'green', label: '已确认', icon: <CheckCircleOutlined /> },
rejected: { color: 'red', label: '已排除', icon: <CloseCircleOutlined /> }, rejected: { color: 'red', label: '已排除', icon: <CloseCircleOutlined /> },
needs_info: { color: 'blue', label: '需补充', icon: <QuestionCircleOutlined /> }, 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 Review: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const qc = useQueryClient(); const qc = useQueryClient();
const { message } = App.useApp();
const [filterLevel, setFilterLevel] = useState<string>('all'); const [filterLevel, setFilterLevel] = useState<string>('all');
const [reviewModal, setReviewModal] = useState<FraudAssessment | null>(null); const [reviewModal, setReviewModal] = useState<FraudAssessment | null>(null);
const [reviewAction, setReviewAction] = useState<string>('confirmed'); const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed');
const [reviewNote, setReviewNote] = useState(''); const [reviewNote, setReviewNote] = useState('');
const { data: assessData } = useQuery({ const { data: assessData } = useQuery({
queryKey: ['assessments', id], queryKey: ['assessments', id],
queryFn: () => fetchAssessments(id), queryFn: () => fetchAssessments(id),
}); });
const { data: suggestionsData } = useQuery({ const { data: suggestionsData, isLoading: suggestionsLoading, isFetching: suggestionsFetching } = useQuery({
queryKey: ['suggestions', id], queryKey: ['suggestions', id],
queryFn: () => fetchInquirySuggestions(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 allAssessments = assessData?.items ?? [];
const suggestions = suggestionsData?.suggestions ?? []; const suggestions = suggestionsData?.suggestions ?? [];
const hasNoAnalysisResult =
suggestions.length === 1 && suggestions[0].includes('暂无分析结果');
const reviewMutation = useMutation({ const reviewMutation = useMutation({
mutationFn: (params: { assessmentId: string; body: any }) => mutationFn: (params: { assessmentId: string; body: ReviewPayload }) =>
submitReview(params.assessmentId, params.body), submitReview(params.assessmentId, params.body),
onSuccess: () => { onSuccess: () => {
message.success('复核结果已保存'); 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 = const data =
filterLevel === 'all' filterLevel === 'all'
? allAssessments ? allAssessments
@@ -102,15 +174,26 @@ const Review: React.FC = () => {
const columns: ColumnsType<FraudAssessment> = [ const columns: ColumnsType<FraudAssessment> = [
{ {
title: '交易时间', title: '交易时间',
width: 170, width: 86,
render: (_, r) => r.transaction.tradeTime, 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) => sorter: (a, b) =>
a.transaction.tradeTime.localeCompare(b.transaction.tradeTime), a.transaction.tradeTime.localeCompare(b.transaction.tradeTime),
defaultSortOrder: 'ascend', defaultSortOrder: 'ascend',
}, },
{ {
title: '来源', title: '来源',
width: 90, width: 60,
render: (_, r) => { render: (_, r) => {
const app = r.transaction.sourceApp; const app = r.transaction.sourceApp;
const m: Record<string, { l: string; c: string }> = { const m: Record<string, { l: string; c: string }> = {
@@ -126,7 +209,7 @@ const Review: React.FC = () => {
{ {
title: '认定金额(元)', title: '认定金额(元)',
dataIndex: 'assessedAmount', dataIndex: 'assessedAmount',
width: 140, width: 94,
align: 'right', align: 'right',
render: (v: number) => render: (v: number) =>
v > 0 ? ( v > 0 ? (
@@ -139,13 +222,14 @@ const Review: React.FC = () => {
}, },
{ {
title: '对方', title: '对方',
width: 120,
render: (_, r) => r.transaction.counterpartyName, render: (_, r) => r.transaction.counterpartyName,
ellipsis: true, ellipsis: true,
}, },
{ {
title: '置信度', title: '置信度',
dataIndex: 'confidenceLevel', dataIndex: 'confidenceLevel',
width: 90, width: 60,
render: (level: ConfidenceLevel) => ( render: (level: ConfidenceLevel) => (
<Tag color={confidenceConfig[level].color}> <Tag color={confidenceConfig[level].color}>
{confidenceConfig[level].label} {confidenceConfig[level].label}
@@ -153,57 +237,154 @@ const Review: React.FC = () => {
), ),
}, },
{ {
title: '认定理由', title: '状态',
dataIndex: 'reason', width: 74,
ellipsis: true, render: (_, r) => {
width: 280, const aiAction = getAiSuggestedAction(r);
render: (text: string) => ( const isPending = r.reviewStatus === 'pending';
<Tooltip title={text}> const status = isPending ? aiAction : r.reviewStatus;
<Typography.Text style={{ fontSize: 13 }}>{text}</Typography.Text>
</Tooltip> 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' },
title: '复核状态', };
dataIndex: 'reviewStatus', const doneStyle: Record<string, { bg: string; color: string }> = {
width: 100, confirmed: { bg: '#389e0d', color: '#fff' },
render: (s: string) => { rejected: { bg: '#cf1322', color: '#fff' },
const cfg = reviewStatusConfig[s]; 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',
},
});
};
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 (
<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 ( return (
<Tag color={cfg.color} icon={cfg.icon}> <Space.Compact size="small" style={{ width: '100%' }}>
{cfg.label} <Button
</Tag> 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: '操作', title: '',
width: 100, width: 80,
render: (_, r) => render: (_, r) => (
r.reviewStatus === 'pending' ? ( <Button
<Button type="link"
type="primary" size="small"
size="small" icon={<EyeOutlined />}
onClick={() => { onClick={() => {
setReviewModal(r); setReviewModal(r);
setReviewAction('confirmed'); setReviewAction(getAiSuggestedAction(r));
setReviewNote(''); setReviewNote('');
}} }}
> >
</Button> </Button>
) : ( ),
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => {
setReviewModal(r);
}}
>
</Button>
),
}, },
]; ];
@@ -274,25 +455,46 @@ const Review: React.FC = () => {
</Space> </Space>
} }
extra={ extra={
<Segmented allAssessments.length > 0 ? (
value={filterLevel} <Segmented
onChange={(v) => setFilterLevel(v as string)} value={filterLevel}
options={[ onChange={(v) => setFilterLevel(v as string)}
{ label: '全部', value: 'all' }, options={[
{ label: '高置信', value: 'high' }, { label: '全部', value: 'all' },
{ label: '置信', value: 'medium' }, { label: '置信', value: 'high' },
{ label: '置信', value: 'low' }, { label: '置信', value: 'medium' },
]} { label: '低置信', value: 'low' },
/> ]}
/>
) : null
} }
> >
<Table {allAssessments.length === 0 ? (
rowKey="id" <Alert
columns={columns} type="warning"
dataSource={data} showIcon
pagination={false} message="暂无认定数据"
size="middle" 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> </Card>
<Divider /> <Divider />
@@ -309,36 +511,186 @@ const Review: React.FC = () => {
<Typography.Paragraph> <Typography.Paragraph>
</Typography.Paragraph> </Typography.Paragraph>
<ol style={{ paddingLeft: 20, lineHeight: 2.2 }}> {suggestionsLoading && suggestions.length === 0 ? (
{suggestions.map((s, idx) => ( <Alert
<li key={idx}> type="info"
<Typography.Text>{s}</Typography.Text> showIcon
</li> message="正在加载问询建议..."
))} description="请稍候,系统正在获取当前案件的问询建议。"
</ol> />
) : 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}>
<Typography.Text>{s}</Typography.Text>
</li>
))}
</ol>
)}
</Card> </Card>
<Modal <Drawer
title={ title="查看 / 复核"
reviewModal?.reviewStatus === 'pending'
? '复核认定'
: '认定详情'
}
open={!!reviewModal} open={!!reviewModal}
onCancel={() => setReviewModal(null)} onClose={() => setReviewModal(null)}
footer={ width={720}
reviewModal?.reviewStatus === 'pending' >
? [ {reviewModal && (
<Button key="cancel" onClick={() => setReviewModal(null)}> <>
<Row gutter={16} align="top">
</Button>, <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}
</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.Item label="AI建议">
<Tag color="geekblue">{aiSuggestionLabel[getAiSuggestedAction(reviewModal)]}</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>
<Typography.Text type="secondary">
AI {aiSuggestionLabel[getAiSuggestedAction(reviewModal)]}
</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>
)}
</Col>
</Row>
<Divider />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setReviewModal(null)}>
</Button>
{reviewModal.reviewStatus === 'pending' && (
<Button <Button
key="submit"
type="primary" type="primary"
loading={reviewMutation.isPending} loading={reviewMutation.isPending}
onClick={() => { onClick={() => {
reviewMutation.mutate({ reviewMutation.mutate({
assessmentId: reviewModal!.id, assessmentId: reviewModal.id,
body: { body: {
review_status: reviewAction, review_status: reviewAction,
review_note: reviewNote, review_note: reviewNote,
@@ -348,94 +700,12 @@ const Review: React.FC = () => {
}} }}
> >
</Button>, </Button>
] )}
: [ </div>
<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> </Drawer>
</div> </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 { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
App,
Card, Card,
Row, Row,
Col, Col,
@@ -12,26 +13,27 @@ import {
Badge, Badge,
Descriptions, Descriptions,
Empty, Empty,
List,
Drawer, Drawer,
Button, Button,
Form,
Input,
InputNumber,
DatePicker,
Divider, Divider,
Segmented, Segmented,
Checkbox,
Alert,
Collapse,
Table,
Input,
InputNumber,
} from 'antd'; } from 'antd';
import { import {
FileImageOutlined, FileImageOutlined,
CheckCircleOutlined, CheckCircleOutlined,
LoadingOutlined, LoadingOutlined,
CloseCircleOutlined, CloseCircleOutlined,
EditOutlined,
ZoomInOutlined, ZoomInOutlined,
PlayCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types'; 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 }> = { const appLabel: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' }, wechat: { label: '微信', color: 'green' },
@@ -47,7 +49,17 @@ const pageTypeLabel: Record<PageType, string> = {
transfer_receipt: '转账凭证', transfer_receipt: '转账凭证',
sms_notice: '短信通知', sms_notice: '短信通知',
balance: '余额页', 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> = { const ocrStatusIcon: Record<string, React.ReactNode> = {
@@ -57,15 +69,190 @@ const ocrStatusIcon: Record<string, React.ReactNode> = {
pending: <FileImageOutlined style={{ color: '#d9d9d9' }} />, 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 Screenshots: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const queryClient = useQueryClient();
const { message } = App.useApp();
const [filterApp, setFilterApp] = useState<string>('all'); const [filterApp, setFilterApp] = useState<string>('all');
const [selectedImage, setSelectedImage] = useState<EvidenceImage | null>(null); const [selectedImage, setSelectedImage] = useState<EvidenceImage | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); 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({ const { data: allImages = [] } = useQuery({
queryKey: ['images', id], queryKey: ['images', id],
queryFn: () => fetchImages(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 = const filtered =
@@ -82,15 +269,125 @@ const Screenshots: React.FC = () => {
setSelectedImage(img); setSelectedImage(img);
setDrawerOpen(true); 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 = [ const updateTxField = (txId: string, field: string, value: unknown) => {
{ label: '交易时间', value: '2026-03-06 10:25:00', confidence: 0.97 }, setEditableTxs((prev) =>
{ label: '交易金额', value: '¥50,000.00', confidence: 0.99 }, prev.map((tx) => {
{ label: '交易方向', value: '支出', confidence: 0.95 }, if (tx.id !== txId) return tx;
{ label: '对方账户', value: '李*华 (138****5678)', confidence: 0.88 }, const nextData = { ...tx.data, [field]: value };
{ label: '订单号', value: 'AL20260306002', confidence: 0.96 }, return {
{ label: '备注', value: '投资款', confidence: 0.92 }, ...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 ( return (
<div> <div>
@@ -104,6 +401,18 @@ const Screenshots: React.FC = () => {
} }
extra={ extra={
<Space> <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 <Select
value={filterApp} value={filterApp}
onChange={setFilterApp} onChange={setFilterApp}
@@ -127,8 +436,10 @@ const Screenshots: React.FC = () => {
} }
> >
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{filtered.map((img) => ( {filtered.map((img) => {
<Col key={img.id} xs={12} sm={8} md={6} lg={4}> const viewStatus = resolveOcrStatus(img.id, img.ocrStatus);
return (
<Col key={img.id} xs={12} sm={8} md={6} lg={4}>
<Card <Card
hoverable hoverable
onClick={() => handleSelect(img)} onClick={() => handleSelect(img)}
@@ -148,15 +459,25 @@ const Screenshots: React.FC = () => {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<FileImageOutlined {(img.thumbUrl || img.url) ? (
style={{ fontSize: 40, color: '#bfbfbf' }} <img
/> src={img.thumbUrl || img.url}
<Typography.Text alt="截图缩略图"
type="secondary" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
style={{ fontSize: 12, marginTop: 8 }} />
> ) : (
<>
</Typography.Text> <FileImageOutlined
style={{ fontSize: 40, color: '#bfbfbf' }}
/>
<Typography.Text
type="secondary"
style={{ fontSize: 12, marginTop: 8 }}
>
</Typography.Text>
</>
)}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -164,7 +485,7 @@ const Screenshots: React.FC = () => {
right: 8, right: 8,
}} }}
> >
{ocrStatusIcon[img.ocrStatus]} {ocrStatusIcon[viewStatus]}
</div> </div>
<div <div
style={{ style={{
@@ -172,7 +493,13 @@ const Screenshots: React.FC = () => {
top: 8, top: 8,
left: 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 <Tag
color={appLabel[img.sourceApp].color} color={appLabel[img.sourceApp].color}
style={{ fontSize: 11 }} style={{ fontSize: 11 }}
@@ -187,12 +514,17 @@ const Screenshots: React.FC = () => {
{pageTypeLabel[img.pageType]} {pageTypeLabel[img.pageType]}
</Typography.Text> </Typography.Text>
<br /> <br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{ocrStatusMeta[viewStatus].text}
</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}> <Typography.Text type="secondary" style={{ fontSize: 11 }}>
{img.uploadedAt} {img.uploadedAt}
</Typography.Text> </Typography.Text>
</Card> </Card>
</Col> </Col>
))} );
})}
</Row> </Row>
{filtered.length === 0 && <Empty description="暂无截图" />} {filtered.length === 0 && <Empty description="暂无截图" />}
@@ -207,8 +539,12 @@ const Screenshots: React.FC = () => {
</Tag> </Tag>
<span>{pageTypeLabel[selectedImage.pageType]}</span> <span>{pageTypeLabel[selectedImage.pageType]}</span>
<Badge <Badge
status={selectedImage.ocrStatus === 'done' ? 'success' : 'processing'} status={ocrStatusMeta[
text={selectedImage.ocrStatus === 'done' ? 'OCR已完成' : '处理中'} resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].badgeStatus}
text={ocrStatusMeta[
resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].text}
/> />
</Space> </Space>
) : '截图详情' ) : '截图详情'
@@ -230,60 +566,120 @@ const Screenshots: React.FC = () => {
justifyContent: 'center', justifyContent: 'center',
marginBottom: 24, marginBottom: 24,
border: '1px dashed #d9d9d9', border: '1px dashed #d9d9d9',
overflow: 'hidden',
}} }}
> >
<Space direction="vertical" align="center"> {(imageDetail?.url || selectedImage.url) ? (
<FileImageOutlined style={{ fontSize: 64, color: '#bfbfbf' }} /> <img
<Typography.Text type="secondary"> src={imageDetail?.url || selectedImage.url}
alt="原始截图"
</Typography.Text> style={{ width: '100%', height: '100%', objectFit: 'contain' }}
<Button icon={<ZoomInOutlined />} size="small"> />
) : (
</Button> <Space direction="vertical" align="center">
</Space> <FileImageOutlined style={{ fontSize: 64, color: '#bfbfbf' }} />
<Typography.Text type="secondary">
</Typography.Text>
<Button icon={<ZoomInOutlined />} size="small">
</Button>
</Space>
)}
</div> </div>
<Typography.Title level={5}>OCR </Typography.Title> <Typography.Title level={5}>OCR </Typography.Title>
<Typography.Text type="secondary" style={{ marginBottom: 16, display: 'block' }}> <Typography.Text type="secondary" style={{ marginBottom: 16, display: 'block' }}>
OCR
</Typography.Text> </Typography.Text>
{visibleBlocks.length === 0 && (
<List <Alert
dataSource={mockOcrFields} type="info"
renderItem={(item) => ( showIcon
<List.Item style={{ marginBottom: 12 }}
extra={ message="暂无 OCR 提取结果"
description={getEmptyReason()}
/>
)}
{editableTxs.length > 0 && (
<Collapse
size="small"
items={editableTxs.map((tx, idx) => ({
key: tx.id,
label: (
<Space> <Space>
<Tag <Typography.Text strong>{getTxSummary(tx, idx)}</Typography.Text>
color={ <Tag color="blue"> {idx + 1}</Tag>
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> </Space>
} ),
> children: (
<List.Item.Meta <Space direction="vertical" style={{ width: '100%' }} size={12}>
title={ <Table
<Typography.Text type="secondary" style={{ fontSize: 13 }}> size="small"
{item.label} pagination={false}
</Typography.Text> rowKey="key"
} dataSource={buildFieldRows(tx)}
description={ columns={[
<Typography.Text strong>{item.value}</Typography.Text> {
} title: '字段',
/> dataIndex: 'label',
</List.Item> 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 /> <Divider />
@@ -292,7 +688,7 @@ const Screenshots: React.FC = () => {
{selectedImage.id} {selectedImage.id}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="文件哈希"> <Descriptions.Item label="文件哈希">
{selectedImage.hash} {selectedImage.fileHash}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="上传时间"> <Descriptions.Item label="上传时间">
{selectedImage.uploadedAt} {selectedImage.uploadedAt}

View File

@@ -124,7 +124,7 @@ const Transactions: React.FC = () => {
render: (_, r) => ( render: (_, r) => (
<Space size={4}> <Space size={4}>
{r.isDuplicate && ( {r.isDuplicate && (
<Tooltip title="该笔为重复展示记录,已与其他截图中的同笔交易合并"> <Tooltip title="该笔与其他记录订单号一致,判定为同一笔展示记录并已归并">
<Tag color="red"></Tag> <Tag color="red"></Tag>
</Tooltip> </Tooltip>
)} )}
@@ -219,7 +219,7 @@ const Transactions: React.FC = () => {
{duplicateCount > 0 && ( {duplicateCount > 0 && (
<Alert <Alert
message={`系统识别出 ${duplicateCount} 笔重复展示记录`} message={`系统识别出 ${duplicateCount} 笔重复展示记录`}
description={'同一笔交易可能在列表页、详情页、短信通知中多次出现。标记为「重复」的记录已被合并,不会重复计入金额汇总。'} description={'当前仅对“订单号一致”的记录做归并。金额/时间高度相似但订单号不同的交易不会自动排除,将在认定复核中由民警进一步确认。'}
type="info" type="info"
showIcon showIcon
closable 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 { useNavigate, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
App,
Card, Card,
Steps, Steps,
Row, Row,
@@ -15,8 +16,6 @@ import {
Descriptions, Descriptions,
Progress, Progress,
Alert, Alert,
Divider,
message,
} from 'antd'; } from 'antd';
import { import {
CloudUploadOutlined, CloudUploadOutlined,
@@ -27,20 +26,81 @@ import {
FileTextOutlined, FileTextOutlined,
InboxOutlined, InboxOutlined,
RightOutlined, RightOutlined,
PlayCircleOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'; } 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 { Dragger } = Upload;
const Workspace: React.FC = () => { const Workspace: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const navigate = useNavigate(); 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: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) }); const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) });
const { data: txData } = useQuery({ queryKey: ['transactions', id], queryFn: () => fetchTransactions(id) }); const { data: txData } = useQuery({ queryKey: ['transactions', id], queryFn: () => fetchTransactions(id) });
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(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 images = imagesData ?? [];
const txList = txData?.items ?? []; const txList = txData?.items ?? [];
@@ -49,6 +109,16 @@ const Workspace: React.FC = () => {
const highConfirm = assessments.filter((a) => a.confidenceLevel === 'high').length; const highConfirm = assessments.filter((a) => a.confidenceLevel === 'high').length;
const pendingReview = assessments.filter((a) => a.reviewStatus === 'pending').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; if (!currentCase) return null;
const steps = [ const steps = [
@@ -60,7 +130,7 @@ const Workspace: React.FC = () => {
{ {
title: 'OCR识别', title: 'OCR识别',
icon: <ScanOutlined />, 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: '交易归并', title: '交易归并',
@@ -70,7 +140,7 @@ const Workspace: React.FC = () => {
{ {
title: '资金分析', title: '资金分析',
icon: <ApartmentOutlined />, icon: <ApartmentOutlined />,
description: '已生成路径图', description: assessments.length > 0 ? `已完成,${assessments.length} 笔认定` : '待分析',
}, },
{ {
title: '认定复核', title: '认定复核',
@@ -117,7 +187,6 @@ const Workspace: React.FC = () => {
<Steps <Steps
current={currentStep} current={currentStep}
items={steps} items={steps}
onChange={(v) => setCurrentStep(v)}
/> />
</Card> </Card>
@@ -136,10 +205,41 @@ const Workspace: React.FC = () => {
multiple multiple
accept="image/*" accept="image/*"
showUploadList={false} showUploadList={false}
beforeUpload={(file, fileList) => { beforeUpload={(file) => {
uploadImages(id, fileList as unknown as File[]) setUploadingCount((c) => c + 1);
.then(() => message.success('截图上传成功')) message.open({
.catch(() => message.error('上传失败')); 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; return false;
}} }}
style={{ padding: '20px 0' }} style={{ padding: '20px 0' }}
@@ -154,6 +254,11 @@ const Workspace: React.FC = () => {
APP APP
</p> </p>
</Dragger> </Dragger>
{uploadingCount > 0 && (
<Typography.Text type="secondary">
{uploadingCount} ...
</Typography.Text>
)}
</Card> </Card>
<Card title="处理进度"> <Card title="处理进度">
@@ -163,7 +268,7 @@ const Workspace: React.FC = () => {
<Progress <Progress
type="circle" type="circle"
percent={Math.round( 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) * images.length) *
100 : 0, 100 : 0,
)} )}
@@ -243,29 +348,28 @@ const Workspace: React.FC = () => {
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
<Button <Button
block block
onClick={() => navigate('/cases/1/screenshots')} type="primary"
icon={<RightOutlined />} ghost
loading={ocrMutation.isPending}
onClick={() => ocrMutation.mutate()}
icon={<PlayCircleOutlined />}
> >
OCR OCR
</Button>
<Button
block
onClick={() => navigate('/cases/1/transactions')}
icon={<RightOutlined />}
>
</Button>
<Button
block
onClick={() => navigate('/cases/1/analysis')}
icon={<RightOutlined />}
>
</Button> </Button>
<Button <Button
block block
type="primary" 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 />} icon={<RightOutlined />}
> >
@@ -284,7 +388,7 @@ const Workspace: React.FC = () => {
<Button <Button
size="small" size="small"
type="primary" type="primary"
onClick={() => navigate('/cases/1/review')} onClick={() => navigate(`/cases/${id}/review`)}
> >
</Button> </Button>

View File

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

View File

@@ -8,9 +8,12 @@
import type { import type {
CaseRecord, CaseRecord,
EvidenceImage, EvidenceImage,
EvidenceImageDetail,
TransactionRecord, TransactionRecord,
FraudAssessment, FraudAssessment,
ExportReport, ExportReport,
FundFlowNode,
FundFlowEdge,
} from '../types'; } from '../types';
import { import {
mockCases, mockCases,
@@ -36,20 +39,30 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
// ── helpers ── // ── helpers ──
let backendAlive: boolean | null = null; 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> { 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 { 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; backendAlive = r.ok;
} catch { } catch {
backendAlive = false; backendAlive = false;
} }
lastCheckTime = now;
return backendAlive; return backendAlive;
} }
export function resetBackendCheck() { export function resetBackendCheck() {
backendAlive = null; backendAlive = null;
lastCheckTime = 0;
}
export async function getDataSourceMode(): Promise<'mock' | 'api'> {
return (await isBackendUp()) ? 'api' : 'mock';
} }
// ── Cases ── // ── Cases ──
@@ -106,17 +119,51 @@ export async function uploadImages(caseId: string, files: File[]): Promise<Evide
return resp.json(); 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 ── // ── Analysis ──
export async function triggerAnalysis(caseId: string): Promise<{ task_id: string; message: string }> { export async function triggerAnalysis(caseId: string): Promise<{ taskId: string; message: string }> {
if (!(await isBackendUp())) return { task_id: 'mock', message: '分析任务已提交' }; if (!(await isBackendUp())) return { taskId: 'mock', message: '分析任务已提交' };
return request(`${BASE}/cases/${caseId}/analyze`, { method: 'POST' }); return request(`${BASE}/cases/${caseId}/analyze`, { method: 'POST' });
} }
export async function fetchAnalysisStatus( export async function fetchAnalysisStatus(
caseId: string, caseId: string,
): Promise<{ status: string; progress: number; current_step: string }> { ): Promise<{ status: string; progress: number; currentStep: string }> {
if (!(await isBackendUp())) return { status: 'reviewing', progress: 85, current_step: '待复核' }; if (!(await isBackendUp())) return { status: 'reviewing', progress: 85, currentStep: '待复核' };
return request(`${BASE}/cases/${caseId}/analyze/status`); return request(`${BASE}/cases/${caseId}/analyze/status`);
} }
@@ -135,7 +182,7 @@ export async function fetchTransactions(
export async function fetchFlows( export async function fetchFlows(
caseId: string, caseId: string,
): Promise<{ nodes: any[]; edges: any[] }> { ): Promise<{ nodes: FundFlowNode[]; edges: FundFlowEdge[] }> {
if (!(await isBackendUp())) return { nodes: mockFlowNodes, edges: mockFlowEdges }; if (!(await isBackendUp())) return { nodes: mockFlowNodes, edges: mockFlowEdges };
return request(`${BASE}/cases/${caseId}/flows`); return request(`${BASE}/cases/${caseId}/flows`);
} }
@@ -153,7 +200,7 @@ export async function fetchAssessments(
export async function submitReview( export async function submitReview(
assessmentId: string, 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> { ): Promise<FraudAssessment> {
if (!(await isBackendUp())) return mockAssessments[0]; if (!(await isBackendUp())) return mockAssessments[0];
return request(`${BASE}/assessments/${assessmentId}/review`, { return request(`${BASE}/assessments/${assessmentId}/review`, {
@@ -178,7 +225,16 @@ export async function fetchInquirySuggestions(caseId: string): Promise<{ suggest
export async function generateReport( export async function generateReport(
caseId: string, 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> { ): Promise<ExportReport> {
if (!(await isBackendUp())) return mockReports[0]; if (!(await isBackendUp())) return mockReports[0];
return request(`${BASE}/cases/${caseId}/reports`, { return request(`${BASE}/cases/${caseId}/reports`, {

View File

@@ -24,10 +24,22 @@ export interface EvidenceImage {
sourceApp: SourceApp; sourceApp: SourceApp;
pageType: PageType; pageType: PageType;
ocrStatus: 'pending' | 'processing' | 'done' | 'failed'; ocrStatus: 'pending' | 'processing' | 'done' | 'failed';
hash: string; fileHash: string;
uploadedAt: 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 { export interface TransactionRecord {
id: string; id: string;
caseId: string; caseId: string;
@@ -81,8 +93,8 @@ export interface FundFlowEdge {
export interface ExportReport { export interface ExportReport {
id: string; id: string;
caseId: string; caseId: string;
type: 'pdf' | 'excel' | 'word'; reportType: 'pdf' | 'excel' | 'word';
url: string; filePath: string;
createdAt: string; createdAt: string;
version: number; version: number;
} }