update: mock mode
This commit is contained in:
@@ -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>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
演示环境 · v0.1.0
|
||||
</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={{
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)}
|
||||
>
|
||||
进入工作台
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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,57 +237,154 @@ 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: '复核状态',
|
||||
dataIndex: 'reviewStatus',
|
||||
width: 100,
|
||||
render: (s: string) => {
|
||||
const cfg = reviewStatusConfig[s];
|
||||
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',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
<Tag color={cfg.color} icon={cfg.icon}>
|
||||
{cfg.label}
|
||||
</Tag>
|
||||
<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>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
setReviewModal(r);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
title: '',
|
||||
width: 80,
|
||||
render: (_, r) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
setReviewModal(r);
|
||||
setReviewAction(getAiSuggestedAction(r));
|
||||
setReviewNote('');
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -274,25 +455,46 @@ const Review: React.FC = () => {
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Segmented
|
||||
value={filterLevel}
|
||||
onChange={(v) => setFilterLevel(v as string)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '高置信', value: 'high' },
|
||||
{ label: '中置信', value: 'medium' },
|
||||
{ label: '低置信', value: 'low' },
|
||||
]}
|
||||
/>
|
||||
allAssessments.length > 0 ? (
|
||||
<Segmented
|
||||
value={filterLevel}
|
||||
onChange={(v) => setFilterLevel(v as string)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '高置信', value: 'high' },
|
||||
{ label: '中置信', value: 'medium' },
|
||||
{ label: '低置信', value: 'low' },
|
||||
]}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
{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,36 +511,186 @@ const Review: React.FC = () => {
|
||||
<Typography.Paragraph>
|
||||
基于当前识别和分析结果,建议在笔录中向受害人追问以下问题:
|
||||
</Typography.Paragraph>
|
||||
<ol style={{ paddingLeft: 20, lineHeight: 2.2 }}>
|
||||
{suggestions.map((s, idx) => (
|
||||
<li key={idx}>
|
||||
<Typography.Text>{s}</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{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}>
|
||||
<Typography.Text>{s}</Typography.Text>
|
||||
</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>,
|
||||
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}
|
||||
</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
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={reviewMutation.isPending}
|
||||
onClick={() => {
|
||||
reviewMutation.mutate({
|
||||
assessmentId: reviewModal!.id,
|
||||
assessmentId: reviewModal.id,
|
||||
body: {
|
||||
review_status: reviewAction,
|
||||
review_note: reviewNote,
|
||||
@@ -348,94 +700,12 @@ const Review: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
提交复核
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="close" onClick={() => setReviewModal(null)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
{reviewModal && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="交易时间">
|
||||
{reviewModal.transaction.tradeTime}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
¥{reviewModal.assessedAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对方">
|
||||
{reviewModal.transaction.counterpartyName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源APP">
|
||||
{reviewModal.transaction.sourceApp}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="置信等级">
|
||||
<Tag color={confidenceConfig[reviewModal.confidenceLevel].color}>
|
||||
{confidenceConfig[reviewModal.confidenceLevel].label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16, background: '#f6ffed', borderColor: '#b7eb8f' }}>
|
||||
<Typography.Text strong>认定理由:</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text>{reviewModal.reason}</Typography.Text>
|
||||
</Card>
|
||||
|
||||
{reviewModal.excludeReason && (
|
||||
<Card size="small" style={{ marginBottom: 16, background: '#fff2e8', borderColor: '#ffbb96' }}>
|
||||
<Typography.Text strong>排除条件:</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text>{reviewModal.excludeReason}</Typography.Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{reviewModal.reviewStatus === 'pending' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Typography.Text strong>复核决定:</Typography.Text>
|
||||
<Select
|
||||
value={reviewAction}
|
||||
onChange={setReviewAction}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ label: '确认 - 该笔计入被骗金额', value: 'confirmed' },
|
||||
{ label: '排除 - 该笔不计入被骗金额', value: 'rejected' },
|
||||
{ label: '需补充 - 需进一步调查确认', value: 'needs_info' },
|
||||
]}
|
||||
/>
|
||||
<Typography.Text strong>备注说明:</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={reviewNote}
|
||||
onChange={(e) => setReviewNote(e.target.value)}
|
||||
placeholder="请输入复核意见或备注..."
|
||||
/>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
|
||||
{reviewModal.reviewStatus !== 'pending' && reviewModal.reviewedBy && (
|
||||
<Descriptions column={2} size="small" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="复核人">
|
||||
{reviewModal.reviewedBy}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="复核时间">
|
||||
{reviewModal.reviewedAt}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,8 +436,10 @@ const Screenshots: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{filtered.map((img) => (
|
||||
<Col key={img.id} xs={12} sm={8} md={6} lg={4}>
|
||||
{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
|
||||
onClick={() => handleSelect(img)}
|
||||
@@ -148,15 +459,25 @@ const Screenshots: React.FC = () => {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<FileImageOutlined
|
||||
style={{ fontSize: 40, color: '#bfbfbf' }}
|
||||
/>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, marginTop: 8 }}
|
||||
>
|
||||
截图预览区
|
||||
</Typography.Text>
|
||||
{(img.thumbUrl || img.url) ? (
|
||||
<img
|
||||
src={img.thumbUrl || img.url}
|
||||
alt="截图缩略图"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FileImageOutlined
|
||||
style={{ fontSize: 40, color: '#bfbfbf' }}
|
||||
/>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, marginTop: 8 }}
|
||||
>
|
||||
截图预览区
|
||||
</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,60 +566,120 @@ const Screenshots: React.FC = () => {
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
border: '1px dashed #d9d9d9',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" align="center">
|
||||
<FileImageOutlined style={{ fontSize: 64, color: '#bfbfbf' }} />
|
||||
<Typography.Text type="secondary">
|
||||
原始截图预览
|
||||
</Typography.Text>
|
||||
<Button icon={<ZoomInOutlined />} size="small">
|
||||
放大查看
|
||||
</Button>
|
||||
</Space>
|
||||
{(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">
|
||||
原始截图预览
|
||||
</Typography.Text>
|
||||
<Button icon={<ZoomInOutlined />} size="small">
|
||||
放大查看
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Typography.Title level={5}>OCR 提取字段</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ marginBottom: 16, display: 'block' }}>
|
||||
以下为系统自动提取结果,可手工修正
|
||||
以下为 OCR 文本块(实时读取后端识别结果)
|
||||
</Typography.Text>
|
||||
|
||||
<List
|
||||
dataSource={mockOcrFields}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
extra={
|
||||
{visibleBlocks.length === 0 && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="暂无 OCR 提取结果"
|
||||
description={getEmptyReason()}
|
||||
/>
|
||||
)}
|
||||
{editableTxs.length > 0 && (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={editableTxs.map((tx, idx) => ({
|
||||
key: tx.id,
|
||||
label: (
|
||||
<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>
|
||||
<Typography.Text strong>{getTxSummary(tx, idx)}</Typography.Text>
|
||||
<Tag color="blue">来源块 {idx + 1}</Tag>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{item.label}
|
||||
</Typography.Text>
|
||||
}
|
||||
description={
|
||||
<Typography.Text strong>{item.value}</Typography.Text>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user