Files
fund-tracer/frontend/src/pages/workspace/Workspace.tsx

553 lines
20 KiB
TypeScript
Raw Normal View History

2026-03-13 09:57:04 +08:00
import React, { useEffect, useMemo, useRef, 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,
2026-03-13 09:57:04 +08:00
Empty,
2026-03-11 16:28:04 +08:00
} 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;
2026-03-13 09:57:04 +08:00
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`;
};
2026-03-11 16:28:04 +08:00
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-13 09:57:04 +08:00
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]);
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) => {
2026-03-13 09:57:04 +08:00
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 as File);
setUploadingCount((c) => {
const next = c + 1;
message.open({
key: 'img-upload',
type: 'loading',
content: `正在上传截图(队列中 ${next} 张):${file.name}`,
duration: 0,
});
return next;
2026-03-12 20:04:27 +08:00
});
2026-03-13 09:57:04 +08:00
setUploadBatchItems((prev) => [
...prev,
{
localId,
fileName: file.name,
fileSize: file.size,
status: 'uploading',
previewUrl,
},
]);
2026-03-12 20:04:27 +08:00
uploadImages(id, [file as File])
2026-03-13 09:57:04 +08:00
.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;
2026-03-12 20:04:27 +08:00
})
.then(() => {
queryClient.invalidateQueries({ queryKey: ['images', id] });
queryClient.invalidateQueries({ queryKey: ['case', id] });
})
.catch(() => {
2026-03-13 09:57:04 +08:00
setUploadBatchItems((prev) =>
prev.map((item) =>
item.localId === localId
? { ...item, status: 'error' }
: item,
),
);
batchCounterRef.current.failed += 1;
2026-03-12 20:04:27 +08:00
})
.finally(() => {
setUploadingCount((c) => {
const next = Math.max(0, c - 1);
if (next === 0) {
2026-03-13 09:57:04 +08:00
batchActiveRef.current = false;
2026-03-12 20:04:27 +08:00
message.destroy('img-upload');
2026-03-13 09:57:04 +08:00
const summary = {
success: batchCounterRef.current.success,
failed: batchCounterRef.current.failed,
};
message.success(`本次上传完成:成功 ${summary.success} 张,失败 ${summary.failed}`);
2026-03-12 20:04:27 +08:00
} 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-13 09:57:04 +08:00
<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>
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;