575 lines
19 KiB
TypeScript
575 lines
19 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import {
|
||
App,
|
||
Card,
|
||
Steps,
|
||
Row,
|
||
Col,
|
||
Statistic,
|
||
Typography,
|
||
Upload,
|
||
Button,
|
||
Space,
|
||
Tag,
|
||
Descriptions,
|
||
Progress,
|
||
Alert,
|
||
Empty,
|
||
} from 'antd';
|
||
import {
|
||
CloudUploadOutlined,
|
||
CameraOutlined,
|
||
PictureOutlined,
|
||
ScanOutlined,
|
||
MergeCellsOutlined,
|
||
ApartmentOutlined,
|
||
AuditOutlined,
|
||
FileTextOutlined,
|
||
InboxOutlined,
|
||
RightOutlined,
|
||
PlayCircleOutlined,
|
||
ThunderboltOutlined,
|
||
} from '@ant-design/icons';
|
||
import { fetchCase, fetchImages, fetchTransactions, fetchAssessments, uploadImages, startCaseOcr, triggerAnalysis } from '../../services/api';
|
||
import type { EvidenceImage } from '../../types';
|
||
|
||
const { Dragger } = Upload;
|
||
|
||
type UploadBatchItem = {
|
||
localId: string;
|
||
fileName: string;
|
||
fileSize: number;
|
||
status: 'uploading' | 'success' | 'error';
|
||
uploadedImageId?: string;
|
||
previewUrl?: string;
|
||
};
|
||
|
||
const formatFileSize = (size: number): string => {
|
||
if (!Number.isFinite(size) || size <= 0) return '-';
|
||
if (size < 1024) return `${size} B`;
|
||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||
};
|
||
|
||
const Workspace: React.FC = () => {
|
||
const { id = '1' } = useParams();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const { message } = App.useApp();
|
||
const [uploadingCount, setUploadingCount] = useState(0);
|
||
const [uploadBatchItems, setUploadBatchItems] = useState<UploadBatchItem[]>([]);
|
||
const batchCounterRef = useRef<{ success: number; failed: number }>({ success: 0, failed: 0 });
|
||
const batchActiveRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
uploadBatchItems.forEach((item) => {
|
||
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
||
});
|
||
};
|
||
}, [uploadBatchItems]);
|
||
|
||
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
|
||
const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) });
|
||
const { data: txData } = useQuery({ queryKey: ['transactions', id], queryFn: () => fetchTransactions(id) });
|
||
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
|
||
const ocrMutation = useMutation({
|
||
mutationFn: () => startCaseOcr(id, false),
|
||
onMutate: () => {
|
||
message.open({
|
||
key: 'workspace-ocr',
|
||
type: 'loading',
|
||
content: '正在提交 OCR 任务...',
|
||
duration: 0,
|
||
});
|
||
},
|
||
onSuccess: (res) => {
|
||
message.open({
|
||
key: 'workspace-ocr',
|
||
type: 'success',
|
||
content: res.message,
|
||
});
|
||
queryClient.invalidateQueries({ queryKey: ['images', id] });
|
||
},
|
||
onError: () => {
|
||
message.open({
|
||
key: 'workspace-ocr',
|
||
type: 'error',
|
||
content: 'OCR任务提交失败',
|
||
});
|
||
},
|
||
});
|
||
const analysisMutation = useMutation({
|
||
mutationFn: () => triggerAnalysis(id),
|
||
onMutate: () => {
|
||
message.open({
|
||
key: 'workspace-analysis',
|
||
type: 'loading',
|
||
content: '正在提交案件分析任务...',
|
||
duration: 0,
|
||
});
|
||
},
|
||
onSuccess: (res) => {
|
||
message.open({
|
||
key: 'workspace-analysis',
|
||
type: 'success',
|
||
content: res.message || '分析任务已提交',
|
||
});
|
||
queryClient.invalidateQueries({ queryKey: ['assessments', id] });
|
||
queryClient.invalidateQueries({ queryKey: ['suggestions', id] });
|
||
queryClient.invalidateQueries({ queryKey: ['transactions', id] });
|
||
queryClient.invalidateQueries({ queryKey: ['flows', id] });
|
||
queryClient.invalidateQueries({ queryKey: ['case', id] });
|
||
},
|
||
onError: () => {
|
||
message.open({
|
||
key: 'workspace-analysis',
|
||
type: 'error',
|
||
content: '案件分析提交失败',
|
||
});
|
||
},
|
||
});
|
||
|
||
const images = imagesData ?? [];
|
||
const txList = txData?.items ?? [];
|
||
const assessments = assessData?.items ?? [];
|
||
|
||
const highConfirm = assessments.filter((a) => a.confidenceLevel === 'high').length;
|
||
const pendingReview = assessments.filter((a) => a.reviewStatus === 'pending').length;
|
||
|
||
const currentStep = useMemo(() => {
|
||
if (images.length === 0) return 0;
|
||
const doneCount = images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length;
|
||
if (doneCount < images.length) return 1;
|
||
if (txList.length === 0) return 2;
|
||
if (assessments.length === 0) return 3;
|
||
if (pendingReview > 0) return 4;
|
||
return 5;
|
||
}, [images, txList.length, assessments.length, pendingReview]);
|
||
|
||
if (!currentCase) return null;
|
||
|
||
const steps = [
|
||
{
|
||
title: '上传截图',
|
||
icon: <CloudUploadOutlined />,
|
||
description: `${images.length} 张已上传`,
|
||
},
|
||
{
|
||
title: 'OCR识别',
|
||
icon: <ScanOutlined />,
|
||
description: `${images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length}/${images.length} 已完成`,
|
||
},
|
||
{
|
||
title: '交易归并',
|
||
icon: <MergeCellsOutlined />,
|
||
description: `${txList.length} 笔交易`,
|
||
},
|
||
{
|
||
title: '资金分析',
|
||
icon: <ApartmentOutlined />,
|
||
description: assessments.length > 0 ? `已完成,${assessments.length} 笔认定` : '待分析',
|
||
},
|
||
{
|
||
title: '认定复核',
|
||
icon: <AuditOutlined />,
|
||
description: `${pendingReview} 笔待复核`,
|
||
},
|
||
{
|
||
title: '报告导出',
|
||
icon: <FileTextOutlined />,
|
||
description: '待生成',
|
||
},
|
||
];
|
||
|
||
const handleBeforeUpload = (file: File) => {
|
||
if (!batchActiveRef.current) {
|
||
batchActiveRef.current = true;
|
||
batchCounterRef.current = { success: 0, failed: 0 };
|
||
setUploadBatchItems((prev) => {
|
||
prev.forEach((item) => {
|
||
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
||
});
|
||
return [];
|
||
});
|
||
}
|
||
|
||
const localId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
const previewUrl = URL.createObjectURL(file);
|
||
setUploadingCount((c) => {
|
||
const next = c + 1;
|
||
message.open({
|
||
key: 'img-upload',
|
||
type: 'loading',
|
||
content: `正在上传截图(队列中 ${next} 张):${file.name}`,
|
||
duration: 0,
|
||
});
|
||
return next;
|
||
});
|
||
setUploadBatchItems((prev) => [
|
||
...prev,
|
||
{
|
||
localId,
|
||
fileName: file.name,
|
||
fileSize: file.size,
|
||
status: 'uploading',
|
||
previewUrl,
|
||
},
|
||
]);
|
||
uploadImages(id, [file])
|
||
.then((uploaded) => {
|
||
const uploadedImage = uploaded?.[0];
|
||
setUploadBatchItems((prev) =>
|
||
prev.map((item) =>
|
||
item.localId === localId
|
||
? {
|
||
...item,
|
||
status: 'success',
|
||
uploadedImageId: uploadedImage?.id,
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
batchCounterRef.current.success += 1;
|
||
})
|
||
.then(() => {
|
||
queryClient.invalidateQueries({ queryKey: ['images', id] });
|
||
queryClient.invalidateQueries({ queryKey: ['case', id] });
|
||
})
|
||
.catch(() => {
|
||
setUploadBatchItems((prev) =>
|
||
prev.map((item) =>
|
||
item.localId === localId
|
||
? { ...item, status: 'error' }
|
||
: item,
|
||
),
|
||
);
|
||
batchCounterRef.current.failed += 1;
|
||
})
|
||
.finally(() => {
|
||
setUploadingCount((c) => {
|
||
const next = Math.max(0, c - 1);
|
||
if (next === 0) {
|
||
batchActiveRef.current = false;
|
||
message.destroy('img-upload');
|
||
const summary = {
|
||
success: batchCounterRef.current.success,
|
||
failed: batchCounterRef.current.failed,
|
||
};
|
||
message.success(`本次上传完成:成功 ${summary.success} 张,失败 ${summary.failed} 张`);
|
||
} else {
|
||
message.open({
|
||
key: 'img-upload',
|
||
type: 'loading',
|
||
content: `正在上传截图(队列中 ${next} 张)...`,
|
||
duration: 0,
|
||
});
|
||
}
|
||
return next;
|
||
});
|
||
});
|
||
return false;
|
||
};
|
||
|
||
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">
|
||
支持手机拍照/相册上传,或桌面端批量拖拽
|
||
</Typography.Text>
|
||
}
|
||
>
|
||
<Space wrap style={{ marginBottom: 12 }}>
|
||
<Upload
|
||
accept="image/*"
|
||
capture="environment"
|
||
showUploadList={false}
|
||
beforeUpload={(file) => handleBeforeUpload(file as File)}
|
||
>
|
||
<Button icon={<CameraOutlined />}>手机拍照上传</Button>
|
||
</Upload>
|
||
<Upload
|
||
multiple
|
||
accept="image/*"
|
||
showUploadList={false}
|
||
beforeUpload={(file) => handleBeforeUpload(file as File)}
|
||
>
|
||
<Button icon={<PictureOutlined />}>手机相册上传</Button>
|
||
</Upload>
|
||
</Space>
|
||
<Dragger
|
||
multiple
|
||
accept="image/*"
|
||
showUploadList={false}
|
||
beforeUpload={(file) => handleBeforeUpload(file as File)}
|
||
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>
|
||
{uploadingCount > 0 && (
|
||
<Typography.Text type="secondary">
|
||
当前有 {uploadingCount} 张截图正在上传,请稍候...
|
||
</Typography.Text>
|
||
)}
|
||
<Card
|
||
size="small"
|
||
title="本次上传清单"
|
||
style={{ marginTop: 12 }}
|
||
extra={
|
||
uploadBatchItems.length > 0 ? (
|
||
<Button
|
||
size="small"
|
||
type="link"
|
||
onClick={() => navigate(`/cases/${id}/screenshots`)}
|
||
>
|
||
查看本次上传截图
|
||
</Button>
|
||
) : null
|
||
}
|
||
>
|
||
{uploadBatchItems.length === 0 ? (
|
||
<Empty description="暂无上传记录,请先拖拽截图上传" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||
) : (
|
||
<>
|
||
<Typography.Text type="secondary">
|
||
本次上传:共 {uploadBatchItems.length} 张,
|
||
成功 {uploadBatchItems.filter((x) => x.status === 'success').length} 张,
|
||
失败 {uploadBatchItems.filter((x) => x.status === 'error').length} 张,
|
||
上传中 {uploadBatchItems.filter((x) => x.status === 'uploading').length} 张
|
||
</Typography.Text>
|
||
<Space direction="vertical" style={{ width: '100%', marginTop: 10 }} size={8}>
|
||
{uploadBatchItems.map((item, index) => (
|
||
<Row key={item.localId} justify="space-between" align="middle">
|
||
<Col span={18}>
|
||
<Space size={8}>
|
||
<Typography.Text type="secondary">#{index + 1}</Typography.Text>
|
||
<div
|
||
style={{
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 4,
|
||
overflow: 'hidden',
|
||
border: '1px solid #f0f0f0',
|
||
background: '#fafafa',
|
||
}}
|
||
>
|
||
{item.previewUrl ? (
|
||
<img
|
||
src={item.previewUrl}
|
||
alt={item.fileName}
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<Typography.Text ellipsis style={{ maxWidth: 260 }}>
|
||
{item.fileName}
|
||
</Typography.Text>
|
||
<Typography.Text type="secondary">
|
||
{formatFileSize(item.fileSize)}
|
||
</Typography.Text>
|
||
</Space>
|
||
</Col>
|
||
<Col>
|
||
{item.status === 'uploading' && <Tag color="processing">上传中</Tag>}
|
||
{item.status === 'success' && <Tag color="success">上传成功</Tag>}
|
||
{item.status === 'error' && <Tag color="error">上传失败</Tag>}
|
||
</Col>
|
||
</Row>
|
||
))}
|
||
</Space>
|
||
</>
|
||
)}
|
||
</Card>
|
||
</Card>
|
||
|
||
<Card title="处理进度">
|
||
<Row gutter={[24, 16]}>
|
||
<Col span={8}>
|
||
<div style={{ textAlign: 'center' }}>
|
||
<Progress
|
||
type="circle"
|
||
percent={Math.round(
|
||
images.length ? (images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length /
|
||
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
|
||
type="primary"
|
||
ghost
|
||
loading={ocrMutation.isPending}
|
||
onClick={() => ocrMutation.mutate()}
|
||
icon={<PlayCircleOutlined />}
|
||
>
|
||
开始 OCR 识别
|
||
</Button>
|
||
<Button
|
||
block
|
||
type="primary"
|
||
ghost
|
||
loading={analysisMutation.isPending}
|
||
onClick={() => analysisMutation.mutate()}
|
||
icon={<ThunderboltOutlined />}
|
||
>
|
||
{analysisMutation.isPending ? '分析中...' : '执行案件分析'}
|
||
</Button>
|
||
<Button
|
||
block
|
||
type="primary"
|
||
onClick={() => navigate(`/cases/${id}/review`)}
|
||
icon={<RightOutlined />}
|
||
>
|
||
进入认定复核
|
||
</Button>
|
||
</Space>
|
||
</Card>
|
||
|
||
{pendingReview > 0 && (
|
||
<Alert
|
||
message={`有 ${pendingReview} 笔交易待人工确认`}
|
||
description="系统已完成自动分析,请进入认定复核页面审阅并确认结果。"
|
||
type="warning"
|
||
showIcon
|
||
style={{ marginTop: 24 }}
|
||
action={
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
onClick={() => navigate(`/cases/${id}/review`)}
|
||
>
|
||
立即复核
|
||
</Button>
|
||
}
|
||
/>
|
||
)}
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Workspace;
|