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;
|