fix: bugs-03
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,6 +679,16 @@ const Screenshots: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
size="small"
|
||||
items={editableTxs.map((tx, idx) => ({
|
||||
@@ -736,6 +766,7 @@ const Screenshots: React.FC = () => {
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user