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 { 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={{
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`)}
|
||||||
>
|
>
|
||||||
进入工作台
|
进入工作台
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user