import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { App, Card, Row, Col, Tag, Typography, Select, Space, Badge, Descriptions, Empty, Drawer, Button, Divider, Segmented, Checkbox, Alert, Collapse, Table, Input, InputNumber, } from 'antd'; import { FileImageOutlined, CheckCircleOutlined, LoadingOutlined, CloseCircleOutlined, ZoomInOutlined, PlayCircleOutlined, } from '@ant-design/icons'; import type { EvidenceImage, SourceApp, PageType } from '../../types'; import { fetchImageDetail, fetchImages, startCaseOcr } from '../../services/api'; const appLabel: Record = { wechat: { label: '微信', color: 'green' }, alipay: { label: '支付宝', color: 'blue' }, bank: { label: '银行', color: 'purple' }, digital_wallet: { label: '数字钱包', color: 'orange' }, other: { label: '其他', color: 'default' }, }; const pageTypeLabel: Record = { bill_list: '账单列表', bill_detail: '账单详情', transfer_receipt: '转账凭证', sms_notice: '短信通知', balance: '余额页', unknown: '页面类型未识别', }; const ocrStatusMeta: Record< EvidenceImage['ocrStatus'], { badgeStatus: 'success' | 'processing' | 'error' | 'default'; text: string } > = { done: { badgeStatus: 'success', text: 'OCR已完成' }, processing: { badgeStatus: 'processing', text: 'OCR识别中' }, pending: { badgeStatus: 'default', text: '待识别' }, failed: { badgeStatus: 'error', text: '识别失败' }, }; const ocrStatusIcon: Record = { done: , processing: , failed: , pending: , }; type EditableTx = { id: string; blockId: string; data: Record; jsonText: string; jsonError?: string; }; const preferredFieldOrder = [ 'trade_time', 'amount', 'direction', 'counterparty_name', 'counterparty_account', 'self_account_tail_no', 'order_no', 'remark', 'confidence', ] as const; const fieldLabelMap: Record = { trade_time: '交易时间', amount: '金额', direction: '方向', counterparty_name: '对方名称', counterparty_account: '对方账号', self_account_tail_no: '本方尾号', order_no: '订单号', remark: '备注', confidence: '置信度', raw_content: '原始文本', }; const tryParseJson = (text: string): unknown | null => { try { return JSON.parse(text); } catch { const match = text.match(/(\[[\s\S]*\]|\{[\s\S]*\})/); if (!match) return null; try { return JSON.parse(match[1]); } catch { return null; } } }; const normalizeToObject = (value: unknown): Record | null => { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } return null; }; const buildEditableTransactions = ( blocks: Array<{ id: string; content: string }>, ): EditableTx[] => { const items: EditableTx[] = []; blocks.forEach((block, blockIdx) => { const raw = (block.content || '').trim(); const parsed = tryParseJson(raw); if (Array.isArray(parsed)) { parsed.forEach((entry, i) => { const obj = normalizeToObject(entry); if (!obj) return; items.push({ id: `${block.id}-${i}`, blockId: block.id, data: obj, jsonText: JSON.stringify(obj, null, 2), }); }); return; } const obj = normalizeToObject(parsed); if (obj) { items.push({ id: `${block.id}-0`, blockId: block.id, data: obj, jsonText: JSON.stringify(obj, null, 2), }); return; } items.push({ id: `${block.id}-${blockIdx}`, blockId: block.id, data: { raw_content: raw }, jsonText: JSON.stringify({ raw_content: raw }, null, 2), }); }); return items; }; const formatMoney = (v: unknown): string => { const n = Number(v); if (!Number.isFinite(n)) return '-'; return n.toFixed(2); }; const Screenshots: React.FC = () => { const { id = '1' } = useParams(); const queryClient = useQueryClient(); const { message } = App.useApp(); const [filterApp, setFilterApp] = useState('all'); const [selectedImage, setSelectedImage] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [rerunTracking, setRerunTracking] = useState>({}); const [editableTxs, setEditableTxs] = useState([]); const { data: allImages = [] } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id), refetchInterval: Object.keys(rerunTracking).length > 0 ? 2000 : false, }); const triggerOcrMutation = useMutation({ mutationFn: (targetIds: string[]) => startCaseOcr( id, targetIds.length > 0, targetIds.length > 0 ? targetIds : undefined, ), onMutate: (targetIds) => { message.open({ key: 'screenshots-ocr', type: 'loading', content: targetIds.length > 0 ? `正在提交选中图片 OCR(${targetIds.length})...` : '正在提交 OCR 任务...', duration: 0, }); if (targetIds.length > 0) { setRerunTracking((prev) => { const next = { ...prev }; targetIds.forEach((imageId) => { next[imageId] = { sawProcessing: false }; }); return next; }); } return { targetIds }; }, onSuccess: (res) => { const isRerun = selectedIds.length > 0; if (isRerun) { setSelectedIds([]); } queryClient.invalidateQueries({ queryKey: ['images', id] }); message.open({ key: 'screenshots-ocr', type: 'success', content: res.message, }); }, onError: (_err, _vars, ctx) => { const rollbackIds = ctx?.targetIds || []; if (rollbackIds.length > 0) { setRerunTracking((prev) => { const next = { ...prev }; rollbackIds.forEach((imageId) => { delete next[imageId]; }); return next; }); message.open({ key: 'screenshots-ocr', type: 'error', content: '选中图片 OCR 重跑提交失败', }); return; } message.open({ key: 'screenshots-ocr', type: 'error', content: 'OCR任务提交失败', }); }, }); const { data: imageDetail } = useQuery({ queryKey: ['image-detail', selectedImage?.id], queryFn: () => fetchImageDetail(selectedImage!.id), enabled: drawerOpen && !!selectedImage?.id, }); const filtered = filterApp === 'all' ? allImages : allImages.filter((img: EvidenceImage) => img.sourceApp === filterApp); const appCounts = allImages.reduce>((acc, img: EvidenceImage) => { acc[img.sourceApp] = (acc[img.sourceApp] || 0) + 1; return acc; }, {}); const handleSelect = (img: EvidenceImage) => { setSelectedImage(img); setDrawerOpen(true); }; const resolveOcrStatus = (imageId: string, backendStatus: EvidenceImage['ocrStatus']): EvidenceImage['ocrStatus'] => { const tracking = rerunTracking[imageId]; if (!tracking) return backendStatus; if (backendStatus === 'failed') return 'failed'; if (backendStatus === 'done' && tracking.sawProcessing) return 'done'; return 'processing'; }; React.useEffect(() => { if (Object.keys(rerunTracking).length === 0) return; const statusById = new Map(allImages.map((img) => [img.id, img.ocrStatus] as const)); setRerunTracking((prev) => { let changed = false; const next: Record = { ...prev }; Object.entries(prev).forEach(([imageId, tracking]) => { const status = statusById.get(imageId); if (!status) { delete next[imageId]; changed = true; return; } if (status === 'processing' && !tracking.sawProcessing) { next[imageId] = { sawProcessing: true }; changed = true; return; } if (status === 'failed' || (status === 'done' && tracking.sawProcessing)) { delete next[imageId]; changed = true; } }); return changed ? next : prev; }); }, [allImages, rerunTracking]); const toggleChecked = (imageId: string, checked: boolean) => { setSelectedIds((prev) => (checked ? Array.from(new Set([...prev, imageId])) : prev.filter((id) => id !== imageId))); }; const selectAllFiltered = () => { setSelectedIds(Array.from(new Set([...selectedIds, ...filtered.map((img) => img.id)]))); }; const clearSelection = () => setSelectedIds([]); const rawBlocks = imageDetail?.ocrBlocks || []; const visibleBlocks = rawBlocks.filter((b) => { const txt = (b.content || '').trim(); return txt !== '' && txt !== '{}' && txt !== '[]'; }); const getEmptyReason = () => { const status = (imageDetail || selectedImage)?.ocrStatus; if (!status) return ''; if (status === 'pending') return '该截图尚未开始 OCR 识别,请先触发识别。'; if (status === 'processing') return 'OCR 正在进行中,稍后会自动刷新结果。'; if (status === 'failed') return 'OCR 识别失败,请尝试重新识别或检查截图清晰度。'; if (status === 'done' && rawBlocks.length === 0) { return 'OCR 已执行完成,但未提取到可用文本块。常见原因:截图内容非交易明细、清晰度不足或遮挡。'; } if (status === 'done' && rawBlocks.length > 0 && visibleBlocks.length === 0) { return 'OCR 返回了空结构(如 {} / []),未形成可展示字段;可尝试重新识别或更换更清晰截图。'; } return ''; }; useEffect(() => { if (!drawerOpen) return; setEditableTxs(buildEditableTransactions(visibleBlocks.map((b) => ({ id: b.id, content: b.content })))); }, [drawerOpen, selectedImage?.id, imageDetail?.id, imageDetail?.ocrBlocks]); const updateTxField = (txId: string, field: string, value: unknown) => { setEditableTxs((prev) => prev.map((tx) => { if (tx.id !== txId) return tx; const nextData = { ...tx.data, [field]: value }; return { ...tx, data: nextData, jsonText: JSON.stringify(nextData, null, 2), jsonError: undefined, }; }), ); }; const updateTxJson = (txId: string, text: string) => { setEditableTxs((prev) => prev.map((tx) => { if (tx.id !== txId) return tx; try { const parsed = JSON.parse(text); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return { ...tx, jsonText: text, jsonError: 'JSON 顶层必须是对象' }; } return { ...tx, jsonText: text, jsonError: undefined, data: parsed as Record, }; } catch { return { ...tx, jsonText: text, jsonError: 'JSON 格式错误' }; } }), ); }; const getTxSummary = (tx: EditableTx, index: number) => { const tradeTime = String(tx.data.trade_time ?? tx.data.tradeTime ?? '-'); const amount = tx.data.amount; return `#${index + 1} 时间:${tradeTime} 金额:${formatMoney(amount)}`; }; const buildFieldRows = (tx: EditableTx) => { const keys = Object.keys(tx.data); const ordered = [ ...preferredFieldOrder.filter((k) => keys.includes(k)), ...keys.filter((k) => !preferredFieldOrder.includes(k as (typeof preferredFieldOrder)[number])), ]; return ordered.map((key) => ({ key, label: fieldLabelMap[key] || key, value: tx.data[key], })); }; return (
截图管理 {allImages.length} 张 } extra={ {selectedIds.length > 0 && 已选 {selectedIds.length} 张} updateTxField(tx.id, row.key, val)} /> ); } if (row.key === 'amount' || row.key === 'confidence') { const numVal = Number(row.value); return ( updateTxField(tx.id, row.key, val ?? 0)} /> ); } return ( updateTxField(tx.id, row.key, e.target.value)} /> ); }, }, ]} />
JSON(可编辑) updateTxJson(tx.id, e.target.value)} style={{ marginTop: 8, fontFamily: 'monospace' }} /> {tx.jsonError && ( {tx.jsonError} )}
), }))} /> )} {selectedImage.id} {selectedImage.fileHash} {selectedImage.uploadedAt} )}
); }; export default Screenshots;