fix: bugs-03

This commit is contained in:
2026-03-17 22:33:13 +08:00
parent 6ebf03d351
commit 143a1b4507
4 changed files with 312 additions and 81 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
@@ -40,6 +40,7 @@ import {
fetchInquirySuggestions, fetchInquirySuggestions,
triggerAnalysis, triggerAnalysis,
fetchImageDetail, fetchImageDetail,
updateTransaction,
} from '../../services/api'; } from '../../services/api';
type ReviewAction = 'confirmed' | 'rejected' | 'needs_info'; type ReviewAction = 'confirmed' | 'rejected' | 'needs_info';
@@ -146,6 +147,41 @@ const splitTradeTime = (raw: string): { date: string; time: string } => {
return { date: datePart || '-', time: cleanedTime }; return { date: datePart || '-', time: cleanedTime };
}; };
const formatTradeTimeForRecord = (raw: string): string => {
const { date, time } = splitTradeTime(raw);
if (date === '-' && time === '-') return '时间不详';
if (time === '-' || !time) return date;
return `${date} ${time}`;
};
const appTextMap: Record<string, string> = {
wechat: '微信',
alipay: '支付宝',
bank: '银行',
digital_wallet: '数字钱包',
other: '其他',
};
const buildRecordAnswer = (assessments: FraudAssessment[]): string => {
if (!assessments.length) return '目前暂无可确认的转账信息建议先完成截图上传、OCR识别及案件分析。';
const sorted = [...assessments].sort((a, b) =>
a.transaction.tradeTime.localeCompare(b.transaction.tradeTime),
);
const lines = sorted.map((item, idx) => {
const tx = item.transaction;
const tradeTimeText = formatTradeTimeForRecord(tx.tradeTime);
const amount = Number(tx.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 });
const direction = tx.direction === 'out' ? '转出' : '转入';
const appName = appTextMap[tx.sourceApp] || '其他';
const cpName = tx.counterpartyName || '未知对手方';
const cpAccount = tx.counterpartyAccount ? `,对方账号${tx.counterpartyAccount}` : '';
const orderNo = tx.orderNo ? `,订单号${tx.orderNo}` : '';
const remark = tx.remark ? `,备注“${tx.remark}` : '';
return `${idx + 1}笔是${tradeTimeText}通过${appName}${direction}人民币${amount}元至${cpName}${cpAccount}${orderNo}${remark}`;
});
return lines.join('');
};
const Review: React.FC = () => { const Review: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -224,6 +260,20 @@ const Review: React.FC = () => {
content: '案件分析提交失败', content: '案件分析提交失败',
}), }),
}); });
const saveTxMutation = useMutation({
mutationFn: (params: {
txId: string;
body: Parameters<typeof updateTransaction>[1];
}) => updateTransaction(params.txId, params.body),
onSuccess: () => {
message.success('交易详情修改已保存');
qc.invalidateQueries({ queryKey: ['assessments', id] });
qc.invalidateQueries({ queryKey: ['transactions', id] });
qc.invalidateQueries({ queryKey: ['flows', id] });
qc.invalidateQueries({ queryKey: ['case', id] });
},
onError: () => message.error('交易详情保存失败'),
});
const data = const data =
filterLevel === 'all' filterLevel === 'all'
@@ -242,6 +292,11 @@ const Review: React.FC = () => {
const confirmedCount = allAssessments.filter( const confirmedCount = allAssessments.filter(
(a) => a.reviewStatus === 'confirmed', (a) => a.reviewStatus === 'confirmed',
).length; ).length;
const recordQAText = useMemo(() => {
const question = '问:具体的转账信息?';
const answer = `答:${buildRecordAnswer(allAssessments)}`;
return `${question}\n${answer}`;
}, [allAssessments]);
const openSupplementDialog = (assessment: FraudAssessment, initialNote?: string) => { const openSupplementDialog = (assessment: FraudAssessment, initialNote?: string) => {
setSupplementModal(assessment); setSupplementModal(assessment);
@@ -661,6 +716,36 @@ const Review: React.FC = () => {
)} )}
</Card> </Card>
<Card
title={
<Space>
<FileTextOutlined />
<span>稿</span>
</Space>
}
style={{ marginTop: 16 }}
extra={
<Button
type="link"
onClick={async () => {
try {
await navigator.clipboard.writeText(recordQAText);
message.success('笔录问答已复制');
} catch {
message.warning('复制失败,请手动复制文本');
}
}}
>
</Button>
}
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
</Typography.Paragraph>
<Input.TextArea value={recordQAText} autoSize={{ minRows: 5, maxRows: 12 }} readOnly />
</Card>
<Drawer <Drawer
title="查看 / 复核" title="查看 / 复核"
open={!!reviewModal} open={!!reviewModal}
@@ -875,6 +960,29 @@ const Review: React.FC = () => {
> >
</Button> </Button>
<Button
loading={saveTxMutation.isPending}
onClick={() => {
if (!editableTx) return;
saveTxMutation.mutate({
txId: editableTx.id,
body: {
trade_time: editableTx.tradeTime,
source_app: editableTx.sourceApp,
amount: editableTx.amount,
direction: editableTx.direction,
counterparty_name: editableTx.counterpartyName,
counterparty_account: editableTx.counterpartyAccount,
self_account_tail_no: editableTx.selfAccountTailNo,
order_no: editableTx.orderNo,
remark: editableTx.remark,
confidence: editableTx.confidence,
},
});
}}
>
</Button>
<Button <Button
type="primary" type="primary"
loading={reviewMutation.isPending} loading={reviewMutation.isPending}

View File

@@ -34,7 +34,7 @@ import {
DeleteOutlined, DeleteOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types'; import type { EvidenceImage, SourceApp, PageType } from '../../types';
import { deleteCaseImages, fetchImageDetail, fetchImages, startCaseOcr } from '../../services/api'; import { deleteCaseImages, fetchImageDetail, fetchImages, startCaseOcr, updateImageOcrBlocks } from '../../services/api';
const appLabel: Record<SourceApp, { label: string; color: string }> = { const appLabel: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' }, wechat: { label: '微信', color: 'green' },
@@ -288,6 +288,26 @@ const Screenshots: React.FC = () => {
}); });
}, },
}); });
const saveOcrMutation = useMutation({
mutationFn: async () => {
if (!selectedImage) throw new Error('未选择截图');
return updateImageOcrBlocks(
selectedImage.id,
editableTxs.map((tx) => ({
content: tx.jsonText,
confidence: Number(tx.data.confidence ?? 0.5),
})),
);
},
onSuccess: (res) => {
message.success(res.message || 'OCR编辑已保存');
queryClient.invalidateQueries({ queryKey: ['image-detail', selectedImage?.id] });
queryClient.invalidateQueries({ queryKey: ['images', id] });
},
onError: () => {
message.error('OCR编辑保存失败');
},
});
const { data: imageDetail } = useQuery({ const { data: imageDetail } = useQuery({
queryKey: ['image-detail', selectedImage?.id], queryKey: ['image-detail', selectedImage?.id],
queryFn: () => fetchImageDetail(selectedImage!.id), queryFn: () => fetchImageDetail(selectedImage!.id),
@@ -659,83 +679,94 @@ const Screenshots: React.FC = () => {
/> />
)} )}
{editableTxs.length > 0 && ( {editableTxs.length > 0 && (
<Collapse <>
size="small" <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
items={editableTxs.map((tx, idx) => ({ <Button
key: tx.id, type="primary"
label: ( loading={saveOcrMutation.isPending}
<Space> onClick={() => saveOcrMutation.mutate()}
<Typography.Text strong>{getTxSummary(tx, idx)}</Typography.Text> >
<Tag color="blue"> {idx + 1}</Tag> OCR编辑
</Space> </Button>
), </div>
children: ( <Collapse
<Space direction="vertical" style={{ width: '100%' }} size={12}> size="small"
<Table items={editableTxs.map((tx, idx) => ({
size="small" key: tx.id,
pagination={false} label: (
rowKey="key" <Space>
dataSource={buildFieldRows(tx)} <Typography.Text strong>{getTxSummary(tx, idx)}</Typography.Text>
columns={[ <Tag color="blue"> {idx + 1}</Tag>
{ </Space>
title: '字段', ),
dataIndex: 'label', children: (
key: 'label', <Space direction="vertical" style={{ width: '100%' }} size={12}>
width: 160, <Table
}, size="small"
{ pagination={false}
title: '值', rowKey="key"
key: 'value', dataSource={buildFieldRows(tx)}
render: (_, row: { key: string; value: unknown }) => { columns={[
if (row.key === 'direction') { {
return ( title: '字段',
<Select dataIndex: 'label',
style={{ width: 140 }} key: 'label',
value={String(row.value ?? 'out')} width: 160,
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)}
/>
);
}, },
}, {
]} title: '值',
/> key: 'value',
<div> render: (_, row: { key: string; value: unknown }) => {
<Typography.Text type="secondary">JSON</Typography.Text> if (row.key === 'direction') {
<Input.TextArea return (
value={tx.jsonText} <Select
rows={8} style={{ width: 140 }}
onChange={(e) => updateTxJson(tx.id, e.target.value)} value={String(row.value ?? 'out')}
style={{ marginTop: 8, fontFamily: 'monospace' }} 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)}
/>
);
},
},
]}
/> />
{tx.jsonError && ( <div>
<Typography.Text type="danger">{tx.jsonError}</Typography.Text> <Typography.Text type="secondary">JSON</Typography.Text>
)} <Input.TextArea
</div> value={tx.jsonText}
</Space> 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 /> <Divider />

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
App,
Card, Card,
Table, Table,
Tag, Tag,
@@ -31,7 +32,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { TransactionRecord, SourceApp } from '../../types'; import type { TransactionRecord, SourceApp } from '../../types';
import { fetchImageDetail, fetchTransactions } from '../../services/api'; import { fetchImageDetail, fetchTransactions, updateTransaction } from '../../services/api';
const appTag: Record<SourceApp, { label: string; color: string }> = { const appTag: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' }, wechat: { label: '微信', color: 'green' },
@@ -52,6 +53,8 @@ const splitDateTime = (raw: string): { date: string; time: string } => {
const Transactions: React.FC = () => { const Transactions: React.FC = () => {
const { id = '1' } = useParams(); const { id = '1' } = useParams();
const qc = useQueryClient();
const { message } = App.useApp();
const [filterDuplicate, setFilterDuplicate] = useState<string>('all'); const [filterDuplicate, setFilterDuplicate] = useState<string>('all');
const [detail, setDetail] = useState<TransactionRecord | null>(null); const [detail, setDetail] = useState<TransactionRecord | null>(null);
const [editableDetail, setEditableDetail] = useState<TransactionRecord | null>(null); const [editableDetail, setEditableDetail] = useState<TransactionRecord | null>(null);
@@ -61,6 +64,20 @@ const Transactions: React.FC = () => {
queryKey: ['transactions', id], queryKey: ['transactions', id],
queryFn: () => fetchTransactions(id), queryFn: () => fetchTransactions(id),
}); });
const saveTxMutation = useMutation({
mutationFn: (params: {
txId: string;
body: Parameters<typeof updateTransaction>[1];
}) => updateTransaction(params.txId, params.body),
onSuccess: () => {
message.success('交易修改已保存');
qc.invalidateQueries({ queryKey: ['transactions', id] });
qc.invalidateQueries({ queryKey: ['assessments', id] });
qc.invalidateQueries({ queryKey: ['flows', id] });
qc.invalidateQueries({ queryKey: ['case', id] });
},
onError: () => message.error('交易修改保存失败'),
});
const allTransactions = txData?.items ?? []; const allTransactions = txData?.items ?? [];
const { data: detailImage, isFetching: detailImageFetching } = useQuery({ const { data: detailImage, isFetching: detailImageFetching } = useQuery({
queryKey: ['image-detail', detail?.evidenceImageId], queryKey: ['image-detail', detail?.evidenceImageId],
@@ -209,8 +226,17 @@ const Transactions: React.FC = () => {
key: v, key: v,
label: v === 'duplicate' ? '重复' : v === 'transit' ? '中转' : '有效', label: v === 'duplicate' ? '重复' : v === 'transit' ? '中转' : '有效',
})), })),
onClick: ({ key }) => onClick: ({ key }) => {
setMarkOverrides((prev) => ({ ...prev, [r.id]: key as 'duplicate' | 'transit' | 'valid' })), const target = key as 'duplicate' | 'transit' | 'valid';
setMarkOverrides((prev) => ({ ...prev, [r.id]: target }));
saveTxMutation.mutate({
txId: r.id,
body: {
is_duplicate: target === 'duplicate',
is_transit: target === 'transit',
},
});
},
}} }}
trigger={['click']} trigger={['click']}
> >
@@ -367,6 +393,7 @@ const Transactions: React.FC = () => {
onClose={() => setDetail(null)} onClose={() => setDetail(null)}
> >
{detail && editableDetail && ( {detail && editableDetail && (
<>
<Row gutter={16} align="top"> <Row gutter={16} align="top">
<Col span={10}> <Col span={10}>
<Card size="small" loading={detailImageFetching}> <Card size="small" loading={detailImageFetching}>
@@ -529,6 +556,36 @@ const Transactions: React.FC = () => {
</Descriptions> </Descriptions>
</Col> </Col>
</Row> </Row>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<Button
type="primary"
loading={saveTxMutation.isPending}
onClick={() => {
const currentMark = getEffectiveMark(detail);
saveTxMutation.mutate({
txId: detail.id,
body: {
trade_time: editableDetail.tradeTime,
source_app: editableDetail.sourceApp,
amount: editableDetail.amount,
direction: editableDetail.direction,
counterparty_name: editableDetail.counterpartyName,
counterparty_account: editableDetail.counterpartyAccount,
self_account_tail_no: editableDetail.selfAccountTailNo,
order_no: editableDetail.orderNo,
remark: editableDetail.remark,
confidence: editableDetail.confidence,
is_duplicate: currentMark === 'duplicate',
is_transit: currentMark === 'transit',
},
});
setDetail({ ...editableDetail });
}}
>
</Button>
</div>
</>
)} )}
</Drawer> </Drawer>
</div> </div>

View File

@@ -143,6 +143,17 @@ export async function fetchImageDetail(imageId: string): Promise<EvidenceImageDe
return request(`${BASE}/images/${imageId}`); return request(`${BASE}/images/${imageId}`);
} }
export async function updateImageOcrBlocks(
imageId: string,
blocks: Array<{ content: string; confidence?: number }>,
): Promise<{ message: string; updated: number }> {
if (!(await isBackendUp())) throw new Error('Mock 模式不支持保存修改');
return request(`${BASE}/images/${imageId}/ocr-blocks`, {
method: 'PATCH',
body: JSON.stringify(blocks),
});
}
export async function deleteCaseImages( export async function deleteCaseImages(
caseId: string, caseId: string,
imageIds: string[], imageIds: string[],
@@ -208,6 +219,30 @@ export async function fetchTransactions(
return request(`${BASE}/cases/${caseId}/transactions${qs}`); return request(`${BASE}/cases/${caseId}/transactions${qs}`);
} }
export async function updateTransaction(
txId: string,
body: Partial<{
source_app: string;
trade_time: string;
amount: number;
direction: 'in' | 'out';
counterparty_name: string;
counterparty_account: string;
self_account_tail_no: string;
order_no: string;
remark: string;
confidence: number;
is_duplicate: boolean;
is_transit: boolean;
}>,
): Promise<TransactionRecord> {
if (!(await isBackendUp())) throw new Error('Mock 模式不支持保存修改');
return request(`${BASE}/transactions/${txId}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
}
// ── Flows ── // ── Flows ──
export async function fetchFlows( export async function fetchFlows(