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, 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([]); 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: , description: `${images.length} 张已上传`, }, { title: 'OCR识别', icon: , description: `${images.filter((i: EvidenceImage) => i.ocrStatus === 'done').length}/${images.length} 已完成`, }, { title: '交易归并', icon: , description: `${txList.length} 笔交易`, }, { title: '资金分析', icon: , description: assessments.length > 0 ? `已完成,${assessments.length} 笔认定` : '待分析', }, { title: '认定复核', icon: , description: `${pendingReview} 笔待复核`, }, { title: '报告导出', icon: , description: '待生成', }, ]; return (
{currentCase.title} 待复核 {currentCase.caseNo} · 承办人:{currentCase.handler} · 受害人:{currentCase.victimName} 支持 JPG/PNG,可批量拖拽 } > { 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; }); setUploadBatchItems((prev) => [ ...prev, { localId, fileName: file.name, fileSize: file.size, status: 'uploading', previewUrl, }, ]); uploadImages(id, [file as 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; }} style={{ padding: '20px 0' }} >

点击或拖拽手机账单截图到此区域

支持微信、支付宝、银行APP、数字钱包等多种来源截图

{uploadingCount > 0 && ( 当前有 {uploadingCount} 张截图正在上传,请稍候... )} 0 ? ( ) : null } > {uploadBatchItems.length === 0 ? ( ) : ( <> 本次上传:共 {uploadBatchItems.length} 张, 成功 {uploadBatchItems.filter((x) => x.status === 'success').length} 张, 失败 {uploadBatchItems.filter((x) => x.status === 'error').length} 张, 上传中 {uploadBatchItems.filter((x) => x.status === 'uploading').length} 张 {uploadBatchItems.map((item, index) => ( #{index + 1}
{item.previewUrl ? ( {item.fileName} ) : null}
{item.fileName} {formatFileSize(item.fileSize)}
{item.status === 'uploading' && 上传中} {item.status === 'success' && 上传成功} {item.status === 'error' && 上传失败}
))}
)}
i.ocrStatus === 'done').length / images.length) * 100 : 0, )} size={80} />
OCR 识别率
!t.isDuplicate).length / txList.length) * 100 : 0, )} size={80} strokeColor="#52c41a" />
去重有效率
高置信占比
{images.length} 张 微信 支付宝 银行 数字钱包 {txList.length} 笔 {txList.filter((t) => !t.isDuplicate).length} 笔 2 个 {pendingReview} 笔 {pendingReview > 0 && ( navigate(`/cases/${id}/review`)} > 立即复核 } /> )}
); }; export default Workspace;