Files
fund-tracer/frontend/src/pages/workspace/Workspace.tsx
2026-03-14 21:57:07 +08:00

575 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;