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,6 +679,16 @@ const Screenshots: React.FC = () => {
/> />
)} )}
{editableTxs.length > 0 && ( {editableTxs.length > 0 && (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<Button
type="primary"
loading={saveOcrMutation.isPending}
onClick={() => saveOcrMutation.mutate()}
>
OCR编辑
</Button>
</div>
<Collapse <Collapse
size="small" size="small"
items={editableTxs.map((tx, idx) => ({ items={editableTxs.map((tx, idx) => ({
@@ -736,6 +766,7 @@ const Screenshots: React.FC = () => {
), ),
}))} }))}
/> />
</>
)} )}
<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(