2026-03-12 20:04:27 +08:00
|
|
|
|
import React, { useMemo, useState } from 'react';
|
2026-03-11 16:28:04 +08:00
|
|
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
2026-03-12 20:04:27 +08:00
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
2026-03-11 16:28:04 +08:00
|
|
|
|
import {
|
2026-03-12 20:04:27 +08:00
|
|
|
|
App,
|
2026-03-11 16:28:04 +08:00
|
|
|
|
Card,
|
|
|
|
|
|
Steps,
|
|
|
|
|
|
Row,
|
|
|
|
|
|
Col,
|
|
|
|
|
|
Statistic,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
Upload,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
Descriptions,
|
|
|
|
|
|
Progress,
|
|
|
|
|
|
Alert,
|
|
|
|
|
|
} from 'antd';
|
|
|
|
|
|
import {
|
|
|
|
|
|
CloudUploadOutlined,
|
|
|
|
|
|
ScanOutlined,
|
|
|
|
|
|
MergeCellsOutlined,
|
|
|
|
|
|
ApartmentOutlined,
|
|
|
|
|
|
AuditOutlined,
|
|
|
|
|
|
FileTextOutlined,
|
|
|
|
|
|
InboxOutlined,
|
|
|
|
|
|
RightOutlined,
|
2026-03-12 20:04:27 +08:00
|
|
|
|
PlayCircleOutlined,
|
|
|
|
|
|
ThunderboltOutlined,
|
2026-03-11 16:28:04 +08:00
|
|
|
|
} from '@ant-design/icons';
|
2026-03-12 20:04:27 +08:00
|
|
|
|
import { fetchCase, fetchImages, fetchTransactions, fetchAssessments, uploadImages, startCaseOcr, triggerAnalysis } from '../../services/api';
|
|
|
|
|
|
import type { EvidenceImage } from '../../types';
|
2026-03-11 16:28:04 +08:00
|
|
|
|
|
|
|
|
|
|
const { Dragger } = Upload;
|
|
|
|
|
|
|
|
|
|
|
|
const Workspace: React.FC = () => {
|
|
|
|
|
|
const { id = '1' } = useParams();
|
|
|
|
|
|
const navigate = useNavigate();
|
2026-03-12 20:04:27 +08:00
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
const { message } = App.useApp();
|
|
|
|
|
|
const [uploadingCount, setUploadingCount] = useState(0);
|
2026-03-11 16:28:04 +08:00
|
|
|
|
|
|
|
|
|
|
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
|
|
|
|
|
|
const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) });
|
|
|
|
|
|
const { data: txData } = useQuery({ queryKey: ['transactions', id], queryFn: () => fetchTransactions(id) });
|
|
|
|
|
|
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
|
2026-03-12 20:04:27 +08:00
|
|
|
|
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: '案件分析提交失败',
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-03-11 16:28:04 +08:00
|
|
|
|
|
|
|
|
|
|
const images = imagesData ?? [];
|
|
|
|
|
|
const txList = txData?.items ?? [];
|
|
|
|
|
|
const assessments = assessData?.items ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
const highConfirm = assessments.filter((a) => a.confidenceLevel === 'high').length;
|
|
|
|
|
|
const pendingReview = assessments.filter((a) => a.reviewStatus === 'pending').length;
|
|
|
|
|
|
|
2026-03-12 20:04:27 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-11 16:28:04 +08:00
|
|
|
|
if (!currentCase) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const steps = [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '上传截图',
|
|
|
|
|
|
icon: <CloudUploadOutlined />,
|
|
|
|
|
|
description: `${images.length} 张已上传`,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: 'OCR识别',
|
|
|
|
|
|
icon: <ScanOutlined />,
|
2026-03-12 20:04:27 +08:00
|
|
|
|
description: `${images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length}/${images.length} 已完成`,
|
2026-03-11 16:28:04 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '交易归并',
|
|
|
|
|
|
icon: <MergeCellsOutlined />,
|
|
|
|
|
|
description: `${txList.length} 笔交易`,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '资金分析',
|
|
|
|
|
|
icon: <ApartmentOutlined />,
|
2026-03-12 20:04:27 +08:00
|
|
|
|
description: assessments.length > 0 ? `已完成,${assessments.length} 笔认定` : '待分析',
|
2026-03-11 16:28:04 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '认定复核',
|
|
|
|
|
|
icon: <AuditOutlined />,
|
|
|
|
|
|
description: `${pendingReview} 笔待复核`,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '报告导出',
|
|
|
|
|
|
icon: <FileTextOutlined />,
|
|
|
|
|
|
description: '待生成',
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Card style={{ marginBottom: 24 }}>
|
|
|
|
|
|
<Row justify="space-between" align="middle">
|
|
|
|
|
|
<Col>
|
|
|
|
|
|
<Space direction="vertical" size={4}>
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
|
|
|
|
{currentCase.title}
|
|
|
|
|
|
</Typography.Title>
|
|
|
|
|
|
<Tag color="orange">待复核</Tag>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
<Typography.Text type="secondary">
|
|
|
|
|
|
{currentCase.caseNo} · 承办人:{currentCase.handler} · 受害人:{currentCase.victimName}
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col>
|
|
|
|
|
|
<Statistic
|
|
|
|
|
|
title="当前识别被骗金额"
|
|
|
|
|
|
value={currentCase.totalAmount}
|
|
|
|
|
|
precision={2}
|
|
|
|
|
|
prefix="¥"
|
|
|
|
|
|
valueStyle={{ color: '#cf1322', fontSize: 28 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card style={{ marginBottom: 24 }}>
|
|
|
|
|
|
<Steps
|
|
|
|
|
|
current={currentStep}
|
|
|
|
|
|
items={steps}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Row gutter={24}>
|
|
|
|
|
|
<Col span={16}>
|
|
|
|
|
|
<Card
|
|
|
|
|
|
title="快速上传截图"
|
|
|
|
|
|
style={{ marginBottom: 24 }}
|
|
|
|
|
|
extra={
|
|
|
|
|
|
<Typography.Text type="secondary">
|
|
|
|
|
|
支持 JPG/PNG,可批量拖拽
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Dragger
|
|
|
|
|
|
multiple
|
|
|
|
|
|
accept="image/*"
|
|
|
|
|
|
showUploadList={false}
|
2026-03-12 20:04:27 +08:00
|
|
|
|
beforeUpload={(file) => {
|
|
|
|
|
|
setUploadingCount((c) => c + 1);
|
|
|
|
|
|
message.open({
|
|
|
|
|
|
key: 'img-upload',
|
|
|
|
|
|
type: 'loading',
|
|
|
|
|
|
content: `正在上传截图(队列中 ${uploadingCount + 1} 张)...`,
|
|
|
|
|
|
duration: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
uploadImages(id, [file as File])
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
message.success('截图上传成功');
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['images', id] });
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['case', id] });
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
message.error('上传失败');
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
setUploadingCount((c) => {
|
|
|
|
|
|
const next = Math.max(0, c - 1);
|
|
|
|
|
|
if (next === 0) {
|
|
|
|
|
|
message.destroy('img-upload');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.open({
|
|
|
|
|
|
key: 'img-upload',
|
|
|
|
|
|
type: 'loading',
|
|
|
|
|
|
content: `正在上传截图(队列中 ${next} 张)...`,
|
|
|
|
|
|
duration: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-03-11 16:28:04 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{ padding: '20px 0' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p className="ant-upload-drag-icon">
|
|
|
|
|
|
<InboxOutlined style={{ fontSize: 48, color: '#1677ff' }} />
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="ant-upload-text">
|
|
|
|
|
|
点击或拖拽手机账单截图到此区域
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="ant-upload-hint">
|
|
|
|
|
|
支持微信、支付宝、银行APP、数字钱包等多种来源截图
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</Dragger>
|
2026-03-12 20:04:27 +08:00
|
|
|
|
{uploadingCount > 0 && (
|
|
|
|
|
|
<Typography.Text type="secondary">
|
|
|
|
|
|
当前有 {uploadingCount} 张截图正在上传,请稍候...
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
)}
|
2026-03-11 16:28:04 +08:00
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card title="处理进度">
|
|
|
|
|
|
<Row gutter={[24, 16]}>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<div style={{ textAlign: 'center' }}>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
type="circle"
|
|
|
|
|
|
percent={Math.round(
|
2026-03-12 20:04:27 +08:00
|
|
|
|
images.length ? (images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length /
|
2026-03-11 16:28:04 +08:00
|
|
|
|
images.length) *
|
|
|
|
|
|
100 : 0,
|
|
|
|
|
|
)}
|
|
|
|
|
|
size={80}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ marginTop: 8 }}>
|
|
|
|
|
|
<Typography.Text>OCR 识别率</Typography.Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<div style={{ textAlign: 'center' }}>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
type="circle"
|
|
|
|
|
|
percent={Math.round(
|
|
|
|
|
|
txList.length ? (txList.filter((t) => !t.isDuplicate).length /
|
|
|
|
|
|
txList.length) *
|
|
|
|
|
|
100 : 0,
|
|
|
|
|
|
)}
|
|
|
|
|
|
size={80}
|
|
|
|
|
|
strokeColor="#52c41a"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ marginTop: 8 }}>
|
|
|
|
|
|
<Typography.Text>去重有效率</Typography.Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<div style={{ textAlign: 'center' }}>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
type="circle"
|
|
|
|
|
|
percent={Math.round(
|
|
|
|
|
|
assessments.length ? (highConfirm / assessments.length) * 100 : 0,
|
|
|
|
|
|
)}
|
|
|
|
|
|
size={80}
|
|
|
|
|
|
strokeColor="#fa8c16"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ marginTop: 8 }}>
|
|
|
|
|
|
<Typography.Text>高置信占比</Typography.Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Card title="案件概况" style={{ marginBottom: 24 }}>
|
|
|
|
|
|
<Descriptions column={1} size="small">
|
|
|
|
|
|
<Descriptions.Item label="截图总数">
|
|
|
|
|
|
{images.length} 张
|
|
|
|
|
|
</Descriptions.Item>
|
|
|
|
|
|
<Descriptions.Item label="涉及APP">
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
<Tag color="green">微信</Tag>
|
|
|
|
|
|
<Tag color="blue">支付宝</Tag>
|
|
|
|
|
|
<Tag color="purple">银行</Tag>
|
|
|
|
|
|
<Tag>数字钱包</Tag>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Descriptions.Item>
|
|
|
|
|
|
<Descriptions.Item label="提取交易数">
|
|
|
|
|
|
{txList.length} 笔
|
|
|
|
|
|
</Descriptions.Item>
|
|
|
|
|
|
<Descriptions.Item label="去重后交易">
|
|
|
|
|
|
{txList.filter((t) => !t.isDuplicate).length} 笔
|
|
|
|
|
|
</Descriptions.Item>
|
|
|
|
|
|
<Descriptions.Item label="涉诈对手方">2 个</Descriptions.Item>
|
|
|
|
|
|
<Descriptions.Item label="待复核">
|
|
|
|
|
|
<Typography.Text type="warning">
|
|
|
|
|
|
{pendingReview} 笔
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
</Descriptions.Item>
|
|
|
|
|
|
</Descriptions>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card title="快捷操作">
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
block
|
2026-03-12 20:04:27 +08:00
|
|
|
|
type="primary"
|
|
|
|
|
|
ghost
|
|
|
|
|
|
loading={ocrMutation.isPending}
|
|
|
|
|
|
onClick={() => ocrMutation.mutate()}
|
|
|
|
|
|
icon={<PlayCircleOutlined />}
|
2026-03-11 16:28:04 +08:00
|
|
|
|
>
|
2026-03-12 20:04:27 +08:00
|
|
|
|
开始 OCR 识别
|
2026-03-11 16:28:04 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
block
|
2026-03-12 20:04:27 +08:00
|
|
|
|
type="primary"
|
|
|
|
|
|
ghost
|
|
|
|
|
|
loading={analysisMutation.isPending}
|
|
|
|
|
|
onClick={() => analysisMutation.mutate()}
|
|
|
|
|
|
icon={<ThunderboltOutlined />}
|
2026-03-11 16:28:04 +08:00
|
|
|
|
>
|
2026-03-12 20:04:27 +08:00
|
|
|
|
{analysisMutation.isPending ? '分析中...' : '执行案件分析'}
|
2026-03-11 16:28:04 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
block
|
|
|
|
|
|
type="primary"
|
2026-03-12 20:04:27 +08:00
|
|
|
|
onClick={() => navigate(`/cases/${id}/review`)}
|
2026-03-11 16:28:04 +08:00
|
|
|
|
icon={<RightOutlined />}
|
|
|
|
|
|
>
|
|
|
|
|
|
进入认定复核
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{pendingReview > 0 && (
|
|
|
|
|
|
<Alert
|
|
|
|
|
|
message={`有 ${pendingReview} 笔交易待人工确认`}
|
|
|
|
|
|
description="系统已完成自动分析,请进入认定复核页面审阅并确认结果。"
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
showIcon
|
|
|
|
|
|
style={{ marginTop: 24 }}
|
|
|
|
|
|
action={
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="primary"
|
2026-03-12 20:04:27 +08:00
|
|
|
|
onClick={() => navigate(`/cases/${id}/review`)}
|
2026-03-11 16:28:04 +08:00
|
|
|
|
>
|
|
|
|
|
|
立即复核
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default Workspace;
|