update: fix-02

This commit is contained in:
2026-03-13 09:57:04 +08:00
parent 7cd2a18364
commit e0a40ceff0
10 changed files with 843 additions and 133 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
@@ -17,6 +17,9 @@ import {
Row,
Col,
Statistic,
Input,
InputNumber,
Dropdown,
} from 'antd';
import {
SwapOutlined,
@@ -28,7 +31,7 @@ import {
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { TransactionRecord, SourceApp } from '../../types';
import { fetchTransactions } from '../../services/api';
import { fetchImageDetail, fetchTransactions } from '../../services/api';
const appTag: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' },
@@ -38,45 +41,84 @@ const appTag: Record<SourceApp, { label: string; color: string }> = {
other: { label: '其他', color: 'default' },
};
const splitDateTime = (raw: string): { date: string; time: string } => {
if (!raw) return { date: '-', time: '-' };
const normalized = raw.trim().replace(' ', 'T');
if (!normalized.includes('T')) return { date: normalized, time: '-' };
const [datePart, timePartRaw = ''] = normalized.split('T');
const cleanedTime = timePartRaw.replace('Z', '').split('.')[0] || '-';
return { date: datePart || '-', time: cleanedTime };
};
const Transactions: React.FC = () => {
const { id = '1' } = useParams();
const [filterDuplicate, setFilterDuplicate] = useState<string>('all');
const [detail, setDetail] = useState<TransactionRecord | null>(null);
const [editableDetail, setEditableDetail] = useState<TransactionRecord | null>(null);
const [markOverrides, setMarkOverrides] = useState<Record<string, 'duplicate' | 'transit' | 'valid'>>({});
const { data: txData } = useQuery({
queryKey: ['transactions', id],
queryFn: () => fetchTransactions(id),
});
const allTransactions = txData?.items ?? [];
const { data: detailImage, isFetching: detailImageFetching } = useQuery({
queryKey: ['image-detail', detail?.evidenceImageId],
queryFn: () => fetchImageDetail(detail!.evidenceImageId),
enabled: !!detail?.evidenceImageId,
});
useEffect(() => {
setEditableDetail(detail ? { ...detail } : null);
}, [detail]);
const getEffectiveMark = (tx: TransactionRecord): 'duplicate' | 'transit' | 'valid' => {
if (markOverrides[tx.id]) return markOverrides[tx.id];
if (tx.isDuplicate) return 'duplicate';
if (tx.isTransit) return 'transit';
return 'valid';
};
const data =
filterDuplicate === 'all'
? allTransactions
: filterDuplicate === 'unique'
? allTransactions.filter((t) => !t.isDuplicate)
: allTransactions.filter((t) => t.isDuplicate);
? allTransactions.filter((t) => getEffectiveMark(t) !== 'duplicate')
: allTransactions.filter((t) => getEffectiveMark(t) === 'duplicate');
const totalOut = allTransactions
.filter((t) => t.direction === 'out' && !t.isDuplicate)
.filter((t) => t.direction === 'out' && getEffectiveMark(t) !== 'duplicate')
.reduce((s, t) => s + t.amount, 0);
const totalIn = allTransactions
.filter((t) => t.direction === 'in' && !t.isDuplicate)
.filter((t) => t.direction === 'in' && getEffectiveMark(t) !== 'duplicate')
.reduce((s, t) => s + t.amount, 0);
const duplicateCount = allTransactions.filter((t) => t.isDuplicate).length;
const transitCount = allTransactions.filter((t) => t.isTransit).length;
const duplicateCount = allTransactions.filter((t) => getEffectiveMark(t) === 'duplicate').length;
const transitCount = allTransactions.filter((t) => getEffectiveMark(t) === 'transit').length;
const columns: ColumnsType<TransactionRecord> = [
{
title: '交易时间',
dataIndex: 'tradeTime',
width: 170,
width: 96,
render: (raw: string) => {
const { date, time } = splitDateTime(raw);
return (
<div style={{ lineHeight: 1.2 }}>
<Typography.Text style={{ fontSize: 12 }}>{date}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{time}
</Typography.Text>
</div>
);
},
sorter: (a, b) => a.tradeTime.localeCompare(b.tradeTime),
defaultSortOrder: 'ascend',
},
{
title: '来源',
dataIndex: 'sourceApp',
width: 100,
width: 80,
render: (app: SourceApp) => (
<Tag color={appTag[app].color}>{appTag[app].label}</Tag>
),
@@ -110,6 +152,7 @@ const Transactions: React.FC = () => {
{
title: '对方',
dataIndex: 'counterpartyName',
width: 120,
ellipsis: true,
},
{
@@ -120,23 +163,74 @@ const Transactions: React.FC = () => {
},
{
title: '标记',
width: 130,
width: 73,
render: (_, r) => (
<Space size={4}>
{r.isDuplicate && (
<Tooltip title="该笔与其他记录订单号一致,判定为同一笔展示记录并已归并">
<Tag color="red"></Tag>
</Tooltip>
)}
{r.isTransit && (
<Tooltip title="该笔为本人账户间中转,不直接计入被骗金额">
<Tag color="orange"></Tag>
</Tooltip>
)}
{!r.isDuplicate && !r.isTransit && (
<Tag color="green"></Tag>
)}
</Space>
(() => {
const mark = getEffectiveMark(r);
const styleByMark: Record<'duplicate' | 'transit' | 'valid', { bg: string; border: string; color: string; label: string }> = {
duplicate: { bg: '#fff2e8', border: '#ffbb96', color: '#cf1322', label: '重复' },
transit: { bg: '#fff7e6', border: '#ffd591', color: '#d46b08', label: '中转' },
valid: { bg: '#f6ffed', border: '#b7eb8f', color: '#389e0d', label: '有效' },
};
const cfg = styleByMark[mark];
const options: Array<'duplicate' | 'transit' | 'valid'> = ['duplicate', 'transit', 'valid'];
return (
<Space.Compact size="small" style={{ width: '100%' }}>
<Tooltip
title={
mark === 'duplicate'
? '该笔与其他记录订单号一致,判定为同一笔展示记录并已归并'
: mark === 'transit'
? '该笔为本人账户间中转,不直接计入被骗金额'
: '该笔为有效交易'
}
>
<Button
size="small"
style={{
flex: 1,
background: cfg.bg,
color: cfg.color,
border: `1px solid ${cfg.border}`,
borderRight: 'none',
borderRadius: '6px 0 0 6px',
fontWeight: 600,
cursor: 'default',
}}
>
{cfg.label}
</Button>
</Tooltip>
<Dropdown
menu={{
items: options
.filter((v) => v !== mark)
.map((v) => ({
key: v,
label: v === 'duplicate' ? '重复' : v === 'transit' ? '中转' : '有效',
})),
onClick: ({ key }) =>
setMarkOverrides((prev) => ({ ...prev, [r.id]: key as 'duplicate' | 'transit' | 'valid' })),
}}
trigger={['click']}
>
<Button
size="small"
style={{
background: cfg.bg,
color: cfg.color,
border: `1px solid ${cfg.border}`,
borderLeft: 'none',
borderRadius: '0 6px 6px 0',
padding: '0 6px',
}}
>
</Button>
</Dropdown>
</Space.Compact>
);
})()
),
},
{
@@ -252,11 +346,12 @@ const Transactions: React.FC = () => {
rowKey="id"
columns={columns}
dataSource={data}
scroll={{ x: 'max-content' }}
pagination={false}
rowClassName={(r) =>
r.isDuplicate
getEffectiveMark(r) === 'duplicate'
? 'row-duplicate'
: r.isTransit
: getEffectiveMark(r) === 'transit'
? 'row-transit'
: ''
}
@@ -267,65 +362,156 @@ const Transactions: React.FC = () => {
<Drawer
title="交易详情"
placement="right"
width={480}
width={780}
open={!!detail}
onClose={() => setDetail(null)}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="交易时间">
{detail.tradeTime}
</Descriptions.Item>
<Descriptions.Item label="来源APP">
<Tag color={appTag[detail.sourceApp].color}>
{appTag[detail.sourceApp].label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="金额">
<Typography.Text
strong
style={{
color: detail.direction === 'out' ? '#cf1322' : '#389e0d',
}}
>
{detail.direction === 'out' ? '-' : '+'}¥
{detail.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="对方">
{detail.counterpartyName}
</Descriptions.Item>
<Descriptions.Item label="对方账号">
{detail.counterpartyAccount || '-'}
</Descriptions.Item>
<Descriptions.Item label="本方账户尾号">
{detail.selfAccountTailNo || '-'}
</Descriptions.Item>
<Descriptions.Item label="订单号">
{detail.orderNo}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
<Descriptions.Item label="置信度">
{(detail.confidence * 100).toFixed(0)}%
</Descriptions.Item>
<Descriptions.Item label="证据截图">
<Button type="link" size="small" icon={<EyeOutlined />}>
({detail.evidenceImageId})
</Button>
</Descriptions.Item>
<Descriptions.Item label="归并簇">
{detail.clusterId || '独立交易'}
</Descriptions.Item>
<Descriptions.Item label="标记">
<Space>
{detail.isDuplicate && <Tag color="red"></Tag>}
{detail.isTransit && <Tag color="orange"></Tag>}
{!detail.isDuplicate && !detail.isTransit && (
<Tag color="green"></Tag>
)}
</Space>
</Descriptions.Item>
</Descriptions>
{detail && editableDetail && (
<Row gutter={16} align="top">
<Col span={10}>
<Card size="small" loading={detailImageFetching}>
<Typography.Text strong></Typography.Text>
<div
style={{
marginTop: 10,
height: 430,
background: '#fafafa',
border: '1px dashed #d9d9d9',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
{detailImage?.url ? (
<img
src={detailImage.url}
alt="来源截图"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</Card>
</Col>
<Col span={14}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="交易时间">
<Input
value={editableDetail.tradeTime ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, tradeTime: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="来源APP">
<Select
style={{ width: '100%' }}
value={editableDetail.sourceApp}
onChange={(val) =>
setEditableDetail((prev) => (prev ? { ...prev, sourceApp: val } : prev))
}
options={[
{ label: '微信', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银行', value: 'bank' },
{ label: '数字钱包', value: 'digital_wallet' },
{ label: '其他', value: 'other' },
]}
/>
</Descriptions.Item>
<Descriptions.Item label="金额">
<InputNumber
style={{ width: '100%' }}
value={editableDetail.amount}
onChange={(val) =>
setEditableDetail((prev) => (prev ? { ...prev, amount: Number(val ?? 0) } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="方向">
<Select
style={{ width: '100%' }}
value={editableDetail.direction}
onChange={(val) =>
setEditableDetail((prev) => (prev ? { ...prev, direction: val } : prev))
}
options={[
{ label: '转入', value: 'in' },
{ label: '转出', value: 'out' },
]}
/>
</Descriptions.Item>
<Descriptions.Item label="对方">
<Input
value={editableDetail.counterpartyName ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, counterpartyName: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="对方账号">
<Input
value={editableDetail.counterpartyAccount ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, counterpartyAccount: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="本方账户尾号">
<Input
value={editableDetail.selfAccountTailNo ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, selfAccountTailNo: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="订单号">
<Input
value={editableDetail.orderNo ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, orderNo: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="备注">
<Input.TextArea
rows={2}
value={editableDetail.remark ?? ''}
onChange={(e) =>
setEditableDetail((prev) => (prev ? { ...prev, remark: e.target.value } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="置信度">
<InputNumber
style={{ width: '100%' }}
min={0}
max={1}
step={0.01}
value={editableDetail.confidence}
onChange={(val) =>
setEditableDetail((prev) => (prev ? { ...prev, confidence: Number(val ?? 0) } : prev))
}
/>
</Descriptions.Item>
<Descriptions.Item label="归并簇">
<Input value={editableDetail.clusterId || ''} readOnly />
</Descriptions.Item>
<Descriptions.Item label="标记">
<Space>
{getEffectiveMark(detail) === 'duplicate' && <Tag color="red"></Tag>}
{getEffectiveMark(detail) === 'transit' && <Tag color="orange"></Tag>}
{getEffectiveMark(detail) === 'valid' && (
<Tag color="green"></Tag>
)}
</Space>
</Descriptions.Item>
</Descriptions>
</Col>
</Row>
)}
</Drawer>
</div>