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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -40,6 +40,7 @@ import {
fetchInquirySuggestions,
triggerAnalysis,
fetchImageDetail,
updateTransaction,
} from '../../services/api';
type ReviewAction = 'confirmed' | 'rejected' | 'needs_info';
@@ -146,6 +147,41 @@ const splitTradeTime = (raw: string): { date: string; time: string } => {
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 { id = '1' } = useParams();
const qc = useQueryClient();
@@ -224,6 +260,20 @@ const Review: React.FC = () => {
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 =
filterLevel === 'all'
@@ -242,6 +292,11 @@ const Review: React.FC = () => {
const confirmedCount = allAssessments.filter(
(a) => a.reviewStatus === 'confirmed',
).length;
const recordQAText = useMemo(() => {
const question = '问:具体的转账信息?';
const answer = `答:${buildRecordAnswer(allAssessments)}`;
return `${question}\n${answer}`;
}, [allAssessments]);
const openSupplementDialog = (assessment: FraudAssessment, initialNote?: string) => {
setSupplementModal(assessment);
@@ -661,6 +716,36 @@ const Review: React.FC = () => {
)}
</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
title="查看 / 复核"
open={!!reviewModal}
@@ -875,6 +960,29 @@ const Review: React.FC = () => {
>
</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
type="primary"
loading={reviewMutation.isPending}

View File

@@ -34,7 +34,7 @@ import {
DeleteOutlined,
} from '@ant-design/icons';
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 }> = {
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({
queryKey: ['image-detail', selectedImage?.id],
queryFn: () => fetchImageDetail(selectedImage!.id),
@@ -659,83 +679,94 @@ const Screenshots: React.FC = () => {
/>
)}
{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 style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<Button
type="primary"
loading={saveOcrMutation.isPending}
onClick={() => saveOcrMutation.mutate()}
>
OCR编辑
</Button>
</div>
<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,
},
},
]}
/>
<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' }}
{
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)}
/>
);
},
},
]}
/>
{tx.jsonError && (
<Typography.Text type="danger">{tx.jsonError}</Typography.Text>
)}
</div>
</Space>
),
}))}
/>
<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 />

View File

@@ -1,7 +1,8 @@
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,
Table,
Tag,
@@ -31,7 +32,7 @@ import {
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
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 }> = {
wechat: { label: '微信', color: 'green' },
@@ -52,6 +53,8 @@ const splitDateTime = (raw: string): { date: string; time: string } => {
const Transactions: React.FC = () => {
const { id = '1' } = useParams();
const qc = useQueryClient();
const { message } = App.useApp();
const [filterDuplicate, setFilterDuplicate] = useState<string>('all');
const [detail, setDetail] = useState<TransactionRecord | null>(null);
const [editableDetail, setEditableDetail] = useState<TransactionRecord | null>(null);
@@ -61,6 +64,20 @@ const Transactions: React.FC = () => {
queryKey: ['transactions', 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 { data: detailImage, isFetching: detailImageFetching } = useQuery({
queryKey: ['image-detail', detail?.evidenceImageId],
@@ -209,8 +226,17 @@ const Transactions: React.FC = () => {
key: v,
label: v === 'duplicate' ? '重复' : v === 'transit' ? '中转' : '有效',
})),
onClick: ({ key }) =>
setMarkOverrides((prev) => ({ ...prev, [r.id]: key as 'duplicate' | 'transit' | 'valid' })),
onClick: ({ key }) => {
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']}
>
@@ -367,6 +393,7 @@ const Transactions: React.FC = () => {
onClose={() => setDetail(null)}
>
{detail && editableDetail && (
<>
<Row gutter={16} align="top">
<Col span={10}>
<Card size="small" loading={detailImageFetching}>
@@ -529,6 +556,36 @@ const Transactions: React.FC = () => {
</Descriptions>
</Col>
</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>
</div>

View File

@@ -143,6 +143,17 @@ export async function fetchImageDetail(imageId: string): Promise<EvidenceImageDe
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(
caseId: string,
imageIds: string[],
@@ -208,6 +219,30 @@ export async function fetchTransactions(
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 ──
export async function fetchFlows(