522 lines
18 KiB
TypeScript
522 lines
18 KiB
TypeScript
import React, { useEffect, 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,
|
|
Input,
|
|
InputNumber,
|
|
Dropdown,
|
|
} 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 { fetchImageDetail, 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 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) => getEffectiveMark(t) !== 'duplicate')
|
|
: allTransactions.filter((t) => getEffectiveMark(t) === 'duplicate');
|
|
|
|
const totalOut = allTransactions
|
|
.filter((t) => t.direction === 'out' && getEffectiveMark(t) !== 'duplicate')
|
|
.reduce((s, t) => s + t.amount, 0);
|
|
const totalIn = allTransactions
|
|
.filter((t) => t.direction === 'in' && getEffectiveMark(t) !== 'duplicate')
|
|
.reduce((s, t) => s + t.amount, 0);
|
|
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: 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: 80,
|
|
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',
|
|
width: 120,
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: '备注',
|
|
dataIndex: 'remark',
|
|
width: 120,
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: '标记',
|
|
width: 73,
|
|
render: (_, r) => (
|
|
(() => {
|
|
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>
|
|
);
|
|
})()
|
|
),
|
|
},
|
|
{
|
|
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} 笔重复展示记录`}
|
|
description={'当前仅对“订单号一致”的记录做归并。金额/时间高度相似但订单号不同的交易不会自动排除,将在认定复核中由民警进一步确认。'}
|
|
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}
|
|
scroll={{ x: 'max-content' }}
|
|
pagination={false}
|
|
rowClassName={(r) =>
|
|
getEffectiveMark(r) === 'duplicate'
|
|
? 'row-duplicate'
|
|
: getEffectiveMark(r) === 'transit'
|
|
? 'row-transit'
|
|
: ''
|
|
}
|
|
size="middle"
|
|
/>
|
|
</Card>
|
|
|
|
<Drawer
|
|
title="交易详情"
|
|
placement="right"
|
|
width={780}
|
|
open={!!detail}
|
|
onClose={() => setDetail(null)}
|
|
>
|
|
{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>
|
|
);
|
|
};
|
|
|
|
export default Transactions;
|