update: mock mode

This commit is contained in:
2026-03-12 20:04:27 +08:00
parent ce537bb3dc
commit 7cd2a18364
12 changed files with 1495 additions and 411 deletions

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
App,
Card,
Row,
Col,
@@ -12,26 +13,27 @@ import {
Badge,
Descriptions,
Empty,
List,
Drawer,
Button,
Form,
Input,
InputNumber,
DatePicker,
Divider,
Segmented,
Checkbox,
Alert,
Collapse,
Table,
Input,
InputNumber,
} from 'antd';
import {
FileImageOutlined,
CheckCircleOutlined,
LoadingOutlined,
CloseCircleOutlined,
EditOutlined,
ZoomInOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types';
import { fetchImages } from '../../services/api';
import { fetchImageDetail, fetchImages, startCaseOcr } from '../../services/api';
const appLabel: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' },
@@ -47,7 +49,17 @@ const pageTypeLabel: Record<PageType, string> = {
transfer_receipt: '转账凭证',
sms_notice: '短信通知',
balance: '余额页',
unknown: '未识别',
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> = {
@@ -57,15 +69,190 @@ const ocrStatusIcon: Record<string, React.ReactNode> = {
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 { 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 =
@@ -82,15 +269,125 @@ const Screenshots: React.FC = () => {
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<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 mockOcrFields = [
{ label: '交易时间', value: '2026-03-06 10:25:00', confidence: 0.97 },
{ label: '交易金额', value: '¥50,000.00', confidence: 0.99 },
{ label: '交易方向', value: '支出', confidence: 0.95 },
{ label: '对方账户', value: '李*华 (138****5678)', confidence: 0.88 },
{ label: '订单号', value: 'AL20260306002', confidence: 0.96 },
{ label: '备注', value: '投资款', confidence: 0.92 },
];
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>
@@ -104,6 +401,18 @@ const Screenshots: React.FC = () => {
}
extra={
<Space>
<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>}
<Select
value={filterApp}
onChange={setFilterApp}
@@ -127,8 +436,10 @@ const Screenshots: React.FC = () => {
}
>
<Row gutter={[16, 16]}>
{filtered.map((img) => (
<Col key={img.id} xs={12} sm={8} md={6} lg={4}>
{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)}
@@ -148,15 +459,25 @@ const Screenshots: React.FC = () => {
overflow: 'hidden',
}}
>
<FileImageOutlined
style={{ fontSize: 40, color: '#bfbfbf' }}
/>
<Typography.Text
type="secondary"
style={{ fontSize: 12, marginTop: 8 }}
>
</Typography.Text>
{(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',
@@ -164,7 +485,7 @@ const Screenshots: React.FC = () => {
right: 8,
}}
>
{ocrStatusIcon[img.ocrStatus]}
{ocrStatusIcon[viewStatus]}
</div>
<div
style={{
@@ -172,7 +493,13 @@ const Screenshots: React.FC = () => {
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 }}
@@ -187,12 +514,17 @@ const Screenshots: React.FC = () => {
{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="暂无截图" />}
@@ -207,8 +539,12 @@ const Screenshots: React.FC = () => {
</Tag>
<span>{pageTypeLabel[selectedImage.pageType]}</span>
<Badge
status={selectedImage.ocrStatus === 'done' ? 'success' : 'processing'}
text={selectedImage.ocrStatus === 'done' ? 'OCR已完成' : '处理中'}
status={ocrStatusMeta[
resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].badgeStatus}
text={ocrStatusMeta[
resolveOcrStatus(selectedImage.id, (imageDetail || selectedImage).ocrStatus)
].text}
/>
</Space>
) : '截图详情'
@@ -230,60 +566,120 @@ const Screenshots: React.FC = () => {
justifyContent: 'center',
marginBottom: 24,
border: '1px dashed #d9d9d9',
overflow: 'hidden',
}}
>
<Space direction="vertical" align="center">
<FileImageOutlined style={{ fontSize: 64, color: '#bfbfbf' }} />
<Typography.Text type="secondary">
</Typography.Text>
<Button icon={<ZoomInOutlined />} size="small">
</Button>
</Space>
{(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>
<List
dataSource={mockOcrFields}
renderItem={(item) => (
<List.Item
extra={
{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>
<Tag
color={
item.confidence >= 0.95
? 'green'
: item.confidence >= 0.85
? 'orange'
: 'red'
}
>
{(item.confidence * 100).toFixed(0)}%
</Tag>
<Button type="link" size="small" icon={<EditOutlined />}>
</Button>
<Typography.Text strong>{getTxSummary(tx, idx)}</Typography.Text>
<Tag color="blue"> {idx + 1}</Tag>
</Space>
}
>
<List.Item.Meta
title={
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{item.label}
</Typography.Text>
}
description={
<Typography.Text strong>{item.value}</Typography.Text>
}
/>
</List.Item>
)}
/>
),
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 />
@@ -292,7 +688,7 @@ const Screenshots: React.FC = () => {
{selectedImage.id}
</Descriptions.Item>
<Descriptions.Item label="文件哈希">
{selectedImage.hash}
{selectedImage.fileHash}
</Descriptions.Item>
<Descriptions.Item label="上传时间">
{selectedImage.uploadedAt}