update: mock mode
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user