Files
fund-tracer/frontend/src/pages/screenshots/Screenshots.tsx
2026-03-13 09:57:04 +08:00

762 lines
26 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, 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,
DeleteOutlined,
} from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types';
import { deleteCaseImages, fetchImageDetail, fetchImages, startCaseOcr } from '../../services/api';
const appLabel: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' },
alipay: { label: '支付宝', color: 'blue' },
bank: { label: '银行', color: 'purple' },
digital_wallet: { label: '数字钱包', color: 'orange' },
other: { label: '其他', color: 'default' },
};
const pageTypeLabel: Record<PageType, string> = {
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<string, React.ReactNode> = {
done: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
processing: <LoadingOutlined style={{ color: '#1677ff' }} />,
failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
pending: <FileImageOutlined style={{ color: '#d9d9d9' }} />,
};
type EditableTx = {
id: string;
blockId: string;
data: Record<string, unknown>;
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<string, string> = {
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<string, unknown> | null => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
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<string>('all');
const [selectedImage, setSelectedImage] = useState<EvidenceImage | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [rerunTracking, setRerunTracking] = useState<Record<string, { sawProcessing: boolean }>>({});
const [editableTxs, setEditableTxs] = useState<EditableTx[]>([]);
const [lastProcessingCount, setLastProcessingCount] = useState(0);
const { data: allImages = [] } = useQuery({
queryKey: ['images', id],
queryFn: () => fetchImages(id),
refetchInterval: (query) => {
const images = (query.state.data as EvidenceImage[] | undefined) ?? [];
const hasBackendProcessing = images.some((img) => img.ocrStatus === 'processing');
return hasBackendProcessing || Object.keys(rerunTracking).length > 0 ? 2000 : false;
},
});
const deleteMutation = useMutation({
mutationFn: (targetIds: string[]) => deleteCaseImages(id, targetIds),
onMutate: () => {
message.open({
key: 'screenshots-delete',
type: 'loading',
content: `正在删除选中截图(${selectedIds.length}...`,
duration: 0,
});
},
onSuccess: (res, targetIds) => {
message.open({
key: 'screenshots-delete',
type: 'success',
content: res.message,
});
setSelectedIds((prev) => prev.filter((x) => !targetIds.includes(x)));
if (selectedImage && targetIds.includes(selectedImage.id)) {
setDrawerOpen(false);
setSelectedImage(null);
}
queryClient.invalidateQueries({ queryKey: ['images', id] });
queryClient.invalidateQueries({ queryKey: ['transactions', id] });
queryClient.invalidateQueries({ queryKey: ['case', id] });
},
onError: () => {
message.open({
key: 'screenshots-delete',
type: 'error',
content: '删除截图失败',
});
},
});
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<Record<string, number>>((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(() => {
const processingCount = allImages.filter(
(img) => resolveOcrStatus(img.id, img.ocrStatus) === 'processing',
).length;
if (lastProcessingCount > 0 && processingCount === 0) {
message.success('OCR识别已完成');
}
setLastProcessingCount(processingCount);
}, [allImages, rerunTracking, lastProcessingCount, message]);
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<string, { sawProcessing: boolean }> = { ...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<string, unknown>,
};
} 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 (
<div>
<Card
title={
<Space>
<FileImageOutlined />
<span></span>
<Tag>{allImages.length} </Tag>
</Space>
}
extra={
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={triggerOcrMutation.isPending}
disabled={selectedIds.length === 0}
onClick={() => triggerOcrMutation.mutate(selectedIds)}
>
OCR
</Button>
<Button
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
disabled={selectedIds.length === 0}
onClick={() => deleteMutation.mutate(selectedIds)}
>
</Button>
<Button onClick={selectAllFiltered}></Button>
<Button onClick={clearSelection} disabled={selectedIds.length === 0}></Button>
{selectedIds.length > 0 && <Tag color="blue"> {selectedIds.length} </Tag>}
<Select
value={filterApp}
onChange={setFilterApp}
style={{ width: 140 }}
options={[
{ label: '全部来源', value: 'all' },
...Object.entries(appLabel).map(([k, v]) => ({
label: `${v.label} (${appCounts[k] || 0})`,
value: k,
})),
]}
/>
<Segmented
options={[
{ label: '网格', value: 'grid' },
{ label: '列表', value: 'list' },
]}
defaultValue="grid"
/>
</Space>
}
>
<Row gutter={[16, 16]}>
{filtered.map((img) => {
const viewStatus = resolveOcrStatus(img.id, img.ocrStatus);
return (
<Col key={img.id} xs={12} sm={8} md={6} lg={4}>
<Card
hoverable
onClick={() => handleSelect(img)}
styles={{
body: { padding: 12 },
}}
cover={
<div
style={{
height: 200,
background: '#f5f5f5',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
}}
>
{(img.thumbUrl || img.url) ? (
<img
src={img.thumbUrl || img.url}
alt="截图缩略图"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<>
<FileImageOutlined
style={{ fontSize: 40, color: '#bfbfbf' }}
/>
<Typography.Text
type="secondary"
style={{ fontSize: 12, marginTop: 8 }}
>
</Typography.Text>
</>
)}
<div
style={{
position: 'absolute',
top: 8,
right: 8,
}}
>
{ocrStatusIcon[viewStatus]}
</div>
<div
style={{
position: 'absolute',
top: 8,
left: 8,
}}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={selectedIds.includes(img.id)}
onChange={(e) => toggleChecked(img.id, e.target.checked)}
style={{ marginRight: 6 }}
/>
<Tag
color={appLabel[img.sourceApp].color}
style={{ fontSize: 11 }}
>
{appLabel[img.sourceApp].label}
</Tag>
</div>
</div>
}
>
<Typography.Text ellipsis style={{ fontSize: 13 }}>
{pageTypeLabel[img.pageType]}
</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{ocrStatusMeta[viewStatus].text}
</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{img.uploadedAt}
</Typography.Text>
</Card>
</Col>
);
})}
</Row>
{filtered.length === 0 && <Empty description="暂无截图" />}
</Card>
<Drawer
title={
selectedImage ? (
<Space>
<Tag color={appLabel[selectedImage.sourceApp].color}>
{appLabel[selectedImage.sourceApp].label}
</Tag>
<span>{pageTypeLabel[selectedImage.pageType]}</span>
<Badge
status={ocrStatusMeta[
resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].badgeStatus}
text={ocrStatusMeta[
resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].text}
/>
</Space>
) : '截图详情'
}
placement="right"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{selectedImage && (
<>
<div
style={{
background: '#fafafa',
borderRadius: 8,
height: 300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
border: '1px dashed #d9d9d9',
overflow: 'hidden',
}}
>
{(imageDetail?.url || selectedImage.url) ? (
<img
src={imageDetail?.url || selectedImage.url}
alt="原始截图"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<Space direction="vertical" align="center">
<FileImageOutlined style={{ fontSize: 64, color: '#bfbfbf' }} />
<Typography.Text type="secondary">
</Typography.Text>
<Button icon={<ZoomInOutlined />} size="small">
</Button>
</Space>
)}
</div>
<Typography.Title level={5}>OCR </Typography.Title>
<Typography.Text type="secondary" style={{ marginBottom: 16, display: 'block' }}>
OCR
</Typography.Text>
{visibleBlocks.length === 0 && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="暂无 OCR 提取结果"
description={getEmptyReason()}
/>
)}
{editableTxs.length > 0 && (
<Collapse
size="small"
items={editableTxs.map((tx, idx) => ({
key: tx.id,
label: (
<Space>
<Typography.Text strong>{getTxSummary(tx, idx)}</Typography.Text>
<Tag color="blue"> {idx + 1}</Tag>
</Space>
),
children: (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Table
size="small"
pagination={false}
rowKey="key"
dataSource={buildFieldRows(tx)}
columns={[
{
title: '字段',
dataIndex: 'label',
key: 'label',
width: 160,
},
{
title: '值',
key: 'value',
render: (_, row: { key: string; value: unknown }) => {
if (row.key === 'direction') {
return (
<Select
style={{ width: 140 }}
value={String(row.value ?? 'out')}
options={[
{ label: '转入(in)', value: 'in' },
{ label: '转出(out)', value: 'out' },
]}
onChange={(val) => updateTxField(tx.id, row.key, val)}
/>
);
}
if (row.key === 'amount' || row.key === 'confidence') {
const numVal = Number(row.value);
return (
<InputNumber
style={{ width: '100%' }}
value={Number.isFinite(numVal) ? numVal : undefined}
onChange={(val) => updateTxField(tx.id, row.key, val ?? 0)}
/>
);
}
return (
<Input
value={String(row.value ?? '')}
onChange={(e) => updateTxField(tx.id, row.key, e.target.value)}
/>
);
},
},
]}
/>
<div>
<Typography.Text type="secondary">JSON</Typography.Text>
<Input.TextArea
value={tx.jsonText}
rows={8}
onChange={(e) => updateTxJson(tx.id, e.target.value)}
style={{ marginTop: 8, fontFamily: 'monospace' }}
/>
{tx.jsonError && (
<Typography.Text type="danger">{tx.jsonError}</Typography.Text>
)}
</div>
</Space>
),
}))}
/>
)}
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label="图片ID">
{selectedImage.id}
</Descriptions.Item>
<Descriptions.Item label="文件哈希">
{selectedImage.fileHash}
</Descriptions.Item>
<Descriptions.Item label="上传时间">
{selectedImage.uploadedAt}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
</div>
);
};
export default Screenshots;