Files
fund-tracer/frontend/src/pages/screenshots/Screenshots.tsx

705 lines
24 KiB
TypeScript
Raw Normal View History

2026-03-12 20:04:27 +08:00
import React, { useEffect, useState } from 'react';
2026-03-11 16:28:04 +08:00
import { 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,
Row,
Col,
Tag,
Typography,
Select,
Space,
Badge,
Descriptions,
Empty,
Drawer,
Button,
Divider,
Segmented,
2026-03-12 20:04:27 +08:00
Checkbox,
Alert,
Collapse,
Table,
Input,
InputNumber,
2026-03-11 16:28:04 +08:00
} from 'antd';
import {
FileImageOutlined,
CheckCircleOutlined,
LoadingOutlined,
CloseCircleOutlined,
ZoomInOutlined,
2026-03-12 20:04:27 +08:00
PlayCircleOutlined,
2026-03-11 16:28:04 +08:00
} from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types';
2026-03-12 20:04:27 +08:00
import { fetchImageDetail, fetchImages, startCaseOcr } from '../../services/api';
2026-03-11 16:28:04 +08:00
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: '余额页',
2026-03-12 20:04:27 +08:00
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: '识别失败' },
2026-03-11 16:28:04 +08:00
};
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' }} />,
};
2026-03-12 20:04:27 +08:00
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);
};
2026-03-11 16:28:04 +08:00
const Screenshots: React.FC = () => {
const { id = '1' } = useParams();
2026-03-12 20:04:27 +08:00
const queryClient = useQueryClient();
const { message } = App.useApp();
2026-03-11 16:28:04 +08:00
const [filterApp, setFilterApp] = useState<string>('all');
const [selectedImage, setSelectedImage] = useState<EvidenceImage | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
2026-03-12 20:04:27 +08:00
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [rerunTracking, setRerunTracking] = useState<Record<string, { sawProcessing: boolean }>>({});
const [editableTxs, setEditableTxs] = useState<EditableTx[]>([]);
2026-03-11 16:28:04 +08:00
const { data: allImages = [] } = useQuery({
queryKey: ['images', id],
queryFn: () => fetchImages(id),
2026-03-12 20:04:27 +08:00
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,
2026-03-11 16:28:04 +08:00
});
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);
};
2026-03-12 20:04:27 +08:00
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<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,
};
}),
);
};
2026-03-11 16:28:04 +08:00
2026-03-12 20:04:27 +08:00
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],
}));
};
2026-03-11 16:28:04 +08:00
return (
<div>
<Card
title={
<Space>
<FileImageOutlined />
<span></span>
<Tag>{allImages.length} </Tag>
</Space>
}
extra={
<Space>
2026-03-12 20:04:27 +08:00
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={triggerOcrMutation.isPending}
disabled={selectedIds.length === 0}
onClick={() => triggerOcrMutation.mutate(selectedIds)}
>
{selectedIds.length > 0 ? `对选中图片重新OCR${selectedIds.length}` : '开始 OCR 识别'}
</Button>
<Button onClick={selectAllFiltered}></Button>
<Button onClick={clearSelection} disabled={selectedIds.length === 0}></Button>
{selectedIds.length > 0 && <Tag color="blue"> {selectedIds.length} </Tag>}
2026-03-11 16:28:04 +08:00
<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]}>
2026-03-12 20:04:27 +08:00
{filtered.map((img) => {
const viewStatus = resolveOcrStatus(img.id, img.ocrStatus);
return (
<Col key={img.id} xs={12} sm={8} md={6} lg={4}>
2026-03-11 16:28:04 +08:00
<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',
}}
>
2026-03-12 20:04:27 +08:00
{(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>
</>
)}
2026-03-11 16:28:04 +08:00
<div
style={{
position: 'absolute',
top: 8,
right: 8,
}}
>
2026-03-12 20:04:27 +08:00
{ocrStatusIcon[viewStatus]}
2026-03-11 16:28:04 +08:00
</div>
<div
style={{
position: 'absolute',
top: 8,
left: 8,
}}
2026-03-12 20:04:27 +08:00
onClick={(e) => e.stopPropagation()}
2026-03-11 16:28:04 +08:00
>
2026-03-12 20:04:27 +08:00
<Checkbox
checked={selectedIds.includes(img.id)}
onChange={(e) => toggleChecked(img.id, e.target.checked)}
style={{ marginRight: 6 }}
/>
2026-03-11 16:28:04 +08:00
<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 />
2026-03-12 20:04:27 +08:00
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{ocrStatusMeta[viewStatus].text}
</Typography.Text>
<br />
2026-03-11 16:28:04 +08:00
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{img.uploadedAt}
</Typography.Text>
</Card>
</Col>
2026-03-12 20:04:27 +08:00
);
})}
2026-03-11 16:28:04 +08:00
</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
2026-03-12 20:04:27 +08:00
status={ocrStatusMeta[
resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].badgeStatus}
text={ocrStatusMeta[
resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].text}
2026-03-11 16:28:04 +08:00
/>
</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',
2026-03-12 20:04:27 +08:00
overflow: 'hidden',
2026-03-11 16:28:04 +08:00
}}
>
2026-03-12 20:04:27 +08:00
{(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>
)}
2026-03-11 16:28:04 +08:00
</div>
<Typography.Title level={5}>OCR </Typography.Title>
<Typography.Text type="secondary" style={{ marginBottom: 16, display: 'block' }}>
2026-03-12 20:04:27 +08:00
OCR
2026-03-11 16:28:04 +08:00
</Typography.Text>
2026-03-12 20:04:27 +08:00
{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: (
2026-03-11 16:28:04 +08:00
<Space>
2026-03-12 20:04:27 +08:00
<Typography.Text strong>{getTxSummary(tx, idx)}</Typography.Text>
<Tag color="blue"> {idx + 1}</Tag>
2026-03-11 16:28:04 +08:00
</Space>
2026-03-12 20:04:27 +08:00
),
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>
),
}))}
/>
)}
2026-03-11 16:28:04 +08:00
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label="图片ID">
{selectedImage.id}
</Descriptions.Item>
<Descriptions.Item label="文件哈希">
2026-03-12 20:04:27 +08:00
{selectedImage.fileHash}
2026-03-11 16:28:04 +08:00
</Descriptions.Item>
<Descriptions.Item label="上传时间">
{selectedImage.uploadedAt}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
</div>
);
};
export default Screenshots;