762 lines
26 KiB
TypeScript
762 lines
26 KiB
TypeScript
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;
|