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 { 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}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user