Files
fund-tracer/frontend/src/pages/transactions/Transactions.tsx

336 lines
9.7 KiB
TypeScript
Raw Normal View History

2026-03-11 16:28:04 +08:00
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Card,
Table,
Tag,
Typography,
Space,
Select,
Tooltip,
Badge,
Drawer,
Descriptions,
Button,
Alert,
Row,
Col,
Statistic,
} from 'antd';
import {
SwapOutlined,
WarningOutlined,
LinkOutlined,
EyeOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { TransactionRecord, SourceApp } from '../../types';
import { fetchTransactions } from '../../services/api';
const appTag: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' },
alipay: { label: '支付宝', color: 'blue' },
bank: { label: '银行', color: 'purple' },
digital_wallet: { label: '数字钱包', color: 'orange' },
other: { label: '其他', color: 'default' },
};
const Transactions: React.FC = () => {
const { id = '1' } = useParams();
const [filterDuplicate, setFilterDuplicate] = useState<string>('all');
const [detail, setDetail] = useState<TransactionRecord | null>(null);
const { data: txData } = useQuery({
queryKey: ['transactions', id],
queryFn: () => fetchTransactions(id),
});
const allTransactions = txData?.items ?? [];
const data =
filterDuplicate === 'all'
? allTransactions
: filterDuplicate === 'unique'
? allTransactions.filter((t) => !t.isDuplicate)
: allTransactions.filter((t) => t.isDuplicate);
const totalOut = allTransactions
.filter((t) => t.direction === 'out' && !t.isDuplicate)
.reduce((s, t) => s + t.amount, 0);
const totalIn = allTransactions
.filter((t) => t.direction === 'in' && !t.isDuplicate)
.reduce((s, t) => s + t.amount, 0);
const duplicateCount = allTransactions.filter((t) => t.isDuplicate).length;
const transitCount = allTransactions.filter((t) => t.isTransit).length;
const columns: ColumnsType<TransactionRecord> = [
{
title: '交易时间',
dataIndex: 'tradeTime',
width: 170,
sorter: (a, b) => a.tradeTime.localeCompare(b.tradeTime),
defaultSortOrder: 'ascend',
},
{
title: '来源',
dataIndex: 'sourceApp',
width: 100,
render: (app: SourceApp) => (
<Tag color={appTag[app].color}>{appTag[app].label}</Tag>
),
},
{
title: '金额(元)',
dataIndex: 'amount',
width: 140,
align: 'right',
render: (v: number, r) => (
<Typography.Text
strong
style={{ color: r.direction === 'out' ? '#cf1322' : '#389e0d' }}
>
{r.direction === 'out' ? '-' : '+'}¥{v.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
),
},
{
title: '方向',
dataIndex: 'direction',
width: 70,
align: 'center',
render: (d: string) =>
d === 'out' ? (
<ArrowUpOutlined style={{ color: '#cf1322' }} />
) : (
<ArrowDownOutlined style={{ color: '#389e0d' }} />
),
},
{
title: '对方',
dataIndex: 'counterpartyName',
ellipsis: true,
},
{
title: '备注',
dataIndex: 'remark',
width: 120,
ellipsis: true,
},
{
title: '标记',
width: 130,
render: (_, r) => (
<Space size={4}>
{r.isDuplicate && (
2026-03-12 20:04:27 +08:00
<Tooltip title="该笔与其他记录订单号一致,判定为同一笔展示记录并已归并">
2026-03-11 16:28:04 +08:00
<Tag color="red"></Tag>
</Tooltip>
)}
{r.isTransit && (
<Tooltip title="该笔为本人账户间中转,不直接计入被骗金额">
<Tag color="orange"></Tag>
</Tooltip>
)}
{!r.isDuplicate && !r.isTransit && (
<Tag color="green"></Tag>
)}
</Space>
),
},
{
title: '置信度',
dataIndex: 'confidence',
width: 80,
align: 'center',
render: (v: number) => (
<Badge
color={v >= 0.9 ? '#52c41a' : v >= 0.8 ? '#faad14' : '#ff4d4f'}
text={`${(v * 100).toFixed(0)}%`}
/>
),
},
{
title: '操作',
width: 80,
render: (_, r) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => setDetail(r)}
>
</Button>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="总支出(去重后)"
value={totalOut}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="总收入(去重后)"
value={totalIn}
precision={2}
prefix="¥"
valueStyle={{ color: '#389e0d' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="重复记录"
value={duplicateCount}
suffix="笔"
valueStyle={{ color: '#cf1322' }}
prefix={<WarningOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="中转记录"
value={transitCount}
suffix="笔"
valueStyle={{ color: '#fa8c16' }}
prefix={<LinkOutlined />}
/>
</Card>
</Col>
</Row>
{duplicateCount > 0 && (
<Alert
message={`系统识别出 ${duplicateCount} 笔重复展示记录`}
2026-03-12 20:04:27 +08:00
description={'当前仅对“订单号一致”的记录做归并。金额/时间高度相似但订单号不同的交易不会自动排除,将在认定复核中由民警进一步确认。'}
2026-03-11 16:28:04 +08:00
type="info"
showIcon
closable
style={{ marginBottom: 16 }}
/>
)}
<Card
title={
<Space>
<SwapOutlined />
<span></span>
<Tag>{allTransactions.length} </Tag>
</Space>
}
extra={
<Select
value={filterDuplicate}
onChange={setFilterDuplicate}
style={{ width: 160 }}
options={[
{ label: '全部交易', value: 'all' },
{ label: '仅有效交易', value: 'unique' },
{ label: '仅重复交易', value: 'duplicate' },
]}
/>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
pagination={false}
rowClassName={(r) =>
r.isDuplicate
? 'row-duplicate'
: r.isTransit
? 'row-transit'
: ''
}
size="middle"
/>
</Card>
<Drawer
title="交易详情"
placement="right"
width={480}
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>
)}
</Drawer>
</div>
);
};
export default Transactions;