first commit

This commit is contained in:
2026-03-11 16:28:04 +08:00
commit c0f9ddabbf
101 changed files with 11601 additions and 0 deletions

View File

@@ -0,0 +1,327 @@
import React, { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Card, Row, Col, Tag, Typography, Space, Timeline, Statistic, Divider } from 'antd';
import {
ApartmentOutlined,
ClockCircleOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import { fetchTransactions, fetchFlows } from '../../services/api';
const nodeColorMap: Record<string, string> = {
self: '#1677ff',
suspect: '#cf1322',
transit: '#fa8c16',
unknown: '#8c8c8c',
};
const Analysis: React.FC = () => {
const { id = '1' } = useParams();
const { data: txData } = useQuery({
queryKey: ['transactions', id],
queryFn: () => fetchTransactions(id),
});
const { data: flowData } = useQuery({
queryKey: ['flows', id],
queryFn: () => fetchFlows(id),
});
const mockTransactions = txData?.items ?? [];
const mockFlowNodes = flowData?.nodes ?? [];
const mockFlowEdges = flowData?.edges ?? [];
const flowChartOption = useMemo(() => {
const nodes = mockFlowNodes.map((n: any) => ({
name: n.label,
symbolSize: n.type === 'suspect' ? 60 : 50,
itemStyle: { color: nodeColorMap[n.type] },
label: { show: true, fontSize: 11 },
category: n.type === 'self' ? 0 : n.type === 'suspect' ? 1 : 2,
}));
const edges = mockFlowEdges.map((e: any) => {
const src = mockFlowNodes.find((n: any) => n.id === e.source);
const tgt = mockFlowNodes.find((n: any) => n.id === e.target);
return {
source: src?.label || '',
target: tgt?.label || '',
value: e.amount,
lineStyle: {
width: Math.max(2, Math.min(8, e.amount / 20000)),
curveness: 0.2,
},
label: {
show: true,
formatter: `¥${e.amount.toLocaleString()}`,
fontSize: 11,
},
};
});
return {
tooltip: { trigger: 'item' },
legend: {
data: ['本人账户', '涉诈账户', '中转账户'],
bottom: 10,
},
series: [
{
type: 'graph',
layout: 'force',
roam: true,
draggable: true,
force: {
repulsion: 400,
edgeLength: [120, 200],
},
categories: [
{ name: '本人账户' },
{ name: '涉诈账户' },
{ name: '中转账户' },
],
data: nodes,
links: edges,
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: 10,
emphasis: { focus: 'adjacency' },
},
],
};
}, [mockFlowNodes, mockFlowEdges]);
const timelineChartOption = useMemo(() => {
const sorted = [...mockTransactions]
.filter((t) => !t.isDuplicate)
.sort((a, b) => a.tradeTime.localeCompare(b.tradeTime));
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const p = params[0];
return `${p.axisValue}<br/>金额: ¥${Math.abs(p.value).toLocaleString()}`;
},
},
grid: { left: 80, right: 40, top: 40, bottom: 60 },
xAxis: {
type: 'category',
data: sorted.map((t) => t.tradeTime.slice(5, 16)),
axisLabel: { rotate: 30, fontSize: 11 },
},
yAxis: {
type: 'value',
name: '金额(元)',
axisLabel: {
formatter: (v: number) => `¥${(v / 1000).toFixed(0)}K`,
},
},
series: [
{
type: 'bar',
data: sorted.map((t) => ({
value: t.direction === 'out' ? -t.amount : t.amount,
itemStyle: {
color: t.direction === 'out' ? '#cf1322' : '#52c41a',
},
})),
barWidth: 30,
},
],
};
}, [mockTransactions]);
const validTx = mockTransactions.filter((t) => !t.isDuplicate);
const totalFraud = validTx
.filter((t) => t.direction === 'out' && !t.isTransit)
.reduce((s, t) => s + t.amount, 0);
const sortedTx = [...validTx].sort((a, b) =>
a.tradeTime.localeCompare(b.tradeTime),
);
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="疑似被骗总额(去重去中转)"
value={totalFraud}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: 28 }}
/>
</Card>
</Col>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="涉诈对手方"
value={3}
suffix="个"
/>
</Card>
</Col>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="有效交易"
value={validTx.length}
suffix={`/ ${mockTransactions.length}`}
/>
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col span={14}>
<Card
title={
<Space>
<ApartmentOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 24 }}
extra={
<Space>
<Tag color="#1677ff"></Tag>
<Tag color="#cf1322"></Tag>
<Tag color="#fa8c16"></Tag>
</Space>
}
>
<ReactECharts
option={flowChartOption}
style={{ height: 420 }}
/>
</Card>
<Card
title={
<Space>
<ClockCircleOutlined />
<span></span>
</Space>
}
>
<ReactECharts
option={timelineChartOption}
style={{ height: 300 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
绿
</Typography.Text>
</Card>
</Col>
<Col span={10}>
<Card
title="交易时间线"
style={{ marginBottom: 24 }}
>
<Timeline
items={sortedTx.map((tx) => ({
color: tx.direction === 'out'
? tx.isTransit
? 'orange'
: 'red'
: 'green',
children: (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{tx.tradeTime}
</Typography.Text>
<br />
<Space>
<Tag
color={
tx.sourceApp === 'wechat'
? 'green'
: tx.sourceApp === 'alipay'
? 'blue'
: tx.sourceApp === 'bank'
? 'purple'
: 'orange'
}
style={{ fontSize: 11 }}
>
{tx.sourceApp === 'wechat'
? '微信'
: tx.sourceApp === 'alipay'
? '支付宝'
: tx.sourceApp === 'bank'
? '银行'
: '数字钱包'}
</Tag>
{tx.isTransit && <Tag color="orange"></Tag>}
</Space>
<br />
<Typography.Text
strong
style={{
color: tx.direction === 'out' ? '#cf1322' : '#389e0d',
}}
>
{tx.direction === 'out' ? '-' : '+'}¥
{tx.amount.toLocaleString()}
</Typography.Text>
<Typography.Text style={{ marginLeft: 8, fontSize: 13 }}>
{tx.counterpartyName}
</Typography.Text>
{tx.remark && (
<>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{tx.remark}
</Typography.Text>
</>
)}
</div>
),
}))}
/>
</Card>
<Card title="收款方聚合">
<Space direction="vertical" style={{ width: '100%' }}>
{[
{ name: '李*华 (138****5678)', amount: 70000, count: 2, risk: 'high' },
{ name: 'USDT地址 T9yD...Xk3m', amount: 86500, count: 1, risk: 'medium' },
{ name: '财富管家-客服', amount: 30000, count: 1, risk: 'high' },
].map((item, idx) => (
<Card key={idx} size="small" variant="borderless" style={{ background: '#fafafa' }}>
<Row justify="space-between" align="middle">
<Col>
<Typography.Text strong>{item.name}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.count}
</Typography.Text>
</Col>
<Col>
<Space>
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{item.amount.toLocaleString()}
</Typography.Text>
<Tag color={item.risk === 'high' ? 'red' : 'orange'}>
{item.risk === 'high' ? '高风险' : '中风险'}
</Tag>
</Space>
</Col>
</Row>
</Card>
))}
</Space>
</Card>
</Col>
</Row>
</div>
);
};
export default Analysis;

View File

@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card,
Table,
Button,
Tag,
Space,
Input,
Typography,
Modal,
Form,
Row,
Col,
Statistic,
message,
} from 'antd';
import {
PlusOutlined,
SearchOutlined,
FolderOpenOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { CaseRecord, CaseStatus } from '../../types';
import { fetchCases, createCase } from '../../services/api';
const statusConfig: Record<CaseStatus, { color: string; label: string }> = {
pending: { color: 'default', label: '待处理' },
uploading: { color: 'processing', label: '上传中' },
analyzing: { color: 'blue', label: '分析中' },
reviewing: { color: 'orange', label: '待复核' },
completed: { color: 'green', label: '已完成' },
};
const CaseList: React.FC = () => {
const navigate = useNavigate();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [search, setSearch] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['cases', search],
queryFn: () => fetchCases({ search: search || undefined }),
});
const cases = data?.items ?? [];
const createMutation = useMutation({
mutationFn: createCase,
onSuccess: () => {
message.success('案件创建成功');
qc.invalidateQueries({ queryKey: ['cases'] });
setCreateOpen(false);
form.resetFields();
},
});
const totalCases = cases.length;
const pendingReview = cases.filter((c) => c.status === 'reviewing').length;
const completedCount = cases.filter((c) => c.status === 'completed').length;
const analyzingCount = cases.filter(
(c) => c.status === 'analyzing' || c.status === 'uploading',
).length;
const columns: ColumnsType<CaseRecord> = [
{
title: '案件编号',
dataIndex: 'caseNo',
width: 180,
render: (text, record) => (
<a onClick={() => navigate(`/cases/${record.id}/workspace`)}>{text}</a>
),
},
{ title: '案件名称', dataIndex: 'title', ellipsis: true },
{ title: '受害人', dataIndex: 'victimName', width: 100 },
{ title: '承办人', dataIndex: 'handler', width: 100 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: CaseStatus) => (
<Tag color={statusConfig[s].color}>{statusConfig[s].label}</Tag>
),
},
{
title: '截图数',
dataIndex: 'imageCount',
width: 80,
align: 'center',
},
{
title: '识别金额(元)',
dataIndex: 'totalAmount',
width: 140,
align: 'right',
render: (v: number) =>
v > 0 ? (
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{v.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
},
{
title: '操作',
width: 160,
render: (_, record) => (
<Space>
<Button
type="link"
size="small"
onClick={() => navigate(`/cases/${record.id}/workspace`)}
>
</Button>
</Space>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="全部案件"
value={totalCases}
prefix={<FolderOpenOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="处理中"
value={analyzingCount}
prefix={<ClockCircleOutlined />}
valueStyle={{ color: '#1677ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="待复核"
value={pendingReview}
prefix={<ExclamationCircleOutlined />}
valueStyle={{ color: '#fa8c16' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="已完成"
value={completedCount}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
</Row>
<Card
title="案件列表"
extra={
<Space>
<Input
placeholder="搜索案件编号、名称"
prefix={<SearchOutlined />}
style={{ width: 240 }}
allowClear
onPressEnter={(e) => setSearch((e.target as HTMLInputElement).value)}
onChange={(e) => !e.target.value && setSearch('')}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
>
</Button>
</Space>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={cases}
loading={isLoading}
pagination={{ pageSize: 10, showSizeChanger: true, showTotal: (t) => `${t}` }}
/>
</Card>
<Modal
title="新建案件"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => {
form.validateFields().then((values) => createMutation.mutate(values));
}}
confirmLoading={createMutation.isPending}
okText="创建"
cancelText="取消"
destroyOnClose
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
label="案件编号"
name="caseNo"
rules={[{ required: true, message: '请输入案件编号' }]}
>
<Input placeholder="如ZA-2026-001XXX" />
</Form.Item>
<Form.Item
label="案件名称"
name="title"
rules={[{ required: true, message: '请输入案件名称' }]}
>
<Input placeholder="如:张某被电信诈骗案" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="受害人姓名"
name="victimName"
rules={[{ required: true, message: '请输入受害人姓名' }]}
>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="承办人" name="handler">
<Input />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</div>
);
};
export default CaseList;

View File

@@ -0,0 +1,329 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card,
Button,
Space,
Typography,
Row,
Col,
Statistic,
Table,
Tag,
Divider,
Descriptions,
Select,
Checkbox,
message,
Steps,
Result,
} from 'antd';
import {
FileTextOutlined,
FileExcelOutlined,
FilePdfOutlined,
FileWordOutlined,
DownloadOutlined,
PrinterOutlined,
HistoryOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { fetchCase, fetchAssessments, fetchReports, generateReport, getReportDownloadUrl } from '../../services/api';
const Reports: React.FC = () => {
const { id = '1' } = useParams();
const qc = useQueryClient();
const [generated, setGenerated] = useState(false);
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
const { data: reportsData } = useQuery({ queryKey: ['reports', id], queryFn: () => fetchReports(id) });
const allAssessments = assessData?.items ?? [];
const reportsList = reportsData?.items ?? [];
const confirmedAssessments = allAssessments.filter(
(a) => a.reviewStatus === 'confirmed' && a.assessedAmount > 0,
);
const totalConfirmed = confirmedAssessments.reduce(
(s, a) => s + a.assessedAmount,
0,
);
const genMutation = useMutation({
mutationFn: (reportType: string) => generateReport(id, { report_type: reportType }),
onSuccess: () => {
setGenerated(true);
qc.invalidateQueries({ queryKey: ['reports', id] });
message.success('报告生成成功');
},
});
if (!currentCase) return null;
const historyColumns: ColumnsType<(typeof mockReports)[0]> = [
{
title: '类型',
dataIndex: 'type',
width: 100,
render: (t: string) => {
const map: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
pdf: { icon: <FilePdfOutlined />, label: 'PDF', color: 'red' },
excel: { icon: <FileExcelOutlined />, label: 'Excel', color: 'green' },
word: { icon: <FileWordOutlined />, label: 'Word', color: 'blue' },
};
const cfg = map[t] || map.pdf;
return <Tag icon={cfg.icon} color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: '版本',
dataIndex: 'version',
width: 80,
render: (v: number) => `v${v}`,
},
{
title: '生成时间',
dataIndex: 'createdAt',
width: 180,
},
{
title: '操作',
width: 120,
render: () => (
<Space>
<Button type="link" size="small" icon={<DownloadOutlined />}>
</Button>
</Space>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="已确认被骗金额"
value={totalConfirmed}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: 24 }}
/>
</Card>
</Col>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="已确认交易笔数"
value={confirmedAssessments.length}
suffix="笔"
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="历史报告"
value={reportsList.length}
suffix="份"
prefix={<HistoryOutlined />}
/>
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col span={14}>
<Card
title={
<Space>
<FileTextOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 24 }}
>
<Descriptions column={2} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="案件编号">
{currentCase.caseNo}
</Descriptions.Item>
<Descriptions.Item label="案件名称">
{currentCase.title}
</Descriptions.Item>
<Descriptions.Item label="受害人">
{currentCase.victimName}
</Descriptions.Item>
<Descriptions.Item label="承办人">
{currentCase.handler}
</Descriptions.Item>
<Descriptions.Item label="已确认金额">
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{totalConfirmed.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="确认笔数">
{confirmedAssessments.length}
</Descriptions.Item>
</Descriptions>
<Divider />
<Typography.Text strong></Typography.Text>
<div style={{ margin: '12px 0 24px' }}>
<Space size={16}>
<Card
hoverable
style={{ width: 140, textAlign: 'center' }}
styles={{ body: { padding: 16 } }}
>
<FileExcelOutlined style={{ fontSize: 32, color: '#52c41a' }} />
<div style={{ marginTop: 8 }}>
<Typography.Text>Excel </Typography.Text>
</div>
<div>
<Checkbox defaultChecked></Checkbox>
</div>
</Card>
<Card
hoverable
style={{ width: 140, textAlign: 'center' }}
styles={{ body: { padding: 16 } }}
>
<FilePdfOutlined style={{ fontSize: 32, color: '#cf1322' }} />
<div style={{ marginTop: 8 }}>
<Typography.Text>PDF </Typography.Text>
</div>
<div>
<Checkbox defaultChecked></Checkbox>
</div>
</Card>
<Card
hoverable
style={{ width: 140, textAlign: 'center' }}
styles={{ body: { padding: 16 } }}
>
<FileWordOutlined style={{ fontSize: 32, color: '#1677ff' }} />
<div style={{ marginTop: 8 }}>
<Typography.Text>Word </Typography.Text>
</div>
<div>
<Checkbox></Checkbox>
</div>
</Card>
</Space>
</div>
<Typography.Text strong></Typography.Text>
<div style={{ margin: '12px 0 24px' }}>
<Space direction="vertical">
<Checkbox defaultChecked></Checkbox>
<Checkbox defaultChecked></Checkbox>
<Checkbox defaultChecked></Checkbox>
<Checkbox defaultChecked></Checkbox>
<Checkbox defaultChecked></Checkbox>
<Checkbox></Checkbox>
<Checkbox></Checkbox>
</Space>
</div>
{!generated ? (
<Button
type="primary"
size="large"
icon={<FileTextOutlined />}
loading={genMutation.isPending}
onClick={() => genMutation.mutate('excel')}
block
>
{genMutation.isPending ? '正在生成报告...' : '生成报告'}
</Button>
) : (
<Result
status="success"
title="报告已生成"
subTitle="您可以下载或打印以下报告文件"
extra={[
<Button
key="excel"
icon={<DownloadOutlined />}
onClick={() => message.info('演示模式:下载 Excel')}
>
Excel
</Button>,
<Button
key="pdf"
type="primary"
icon={<DownloadOutlined />}
onClick={() => message.info('演示模式:下载 PDF')}
>
PDF
</Button>,
<Button
key="print"
icon={<PrinterOutlined />}
onClick={() => message.info('演示模式:打印')}
>
</Button>,
]}
/>
)}
</Card>
</Col>
<Col span={10}>
<Card
title={
<Space>
<HistoryOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 24 }}
>
<Table
rowKey="id"
columns={historyColumns}
dataSource={reportsList}
pagination={false}
size="small"
/>
</Card>
<Card title="报告预览" style={{ minHeight: 300 }}>
<div
style={{
background: '#fafafa',
border: '1px dashed #d9d9d9',
borderRadius: 8,
padding: 24,
textAlign: 'center',
minHeight: 240,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FileTextOutlined style={{ fontSize: 48, color: '#bfbfbf' }} />
<Typography.Text type="secondary" style={{ marginTop: 12 }}>
{generated
? '点击左侧"下载"查看完整报告'
: '生成报告后可在此预览'}
</Typography.Text>
</div>
</Card>
</Col>
</Row>
</div>
);
};
export default Reports;

View File

@@ -0,0 +1,443 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card,
Table,
Tag,
Typography,
Space,
Button,
Modal,
Input,
Select,
Descriptions,
Row,
Col,
Statistic,
Alert,
Segmented,
Tooltip,
message,
Divider,
} from 'antd';
import {
AuditOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
QuestionCircleOutlined,
ExclamationCircleOutlined,
SafetyCertificateOutlined,
EyeOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { FraudAssessment, ConfidenceLevel } from '../../types';
import { fetchAssessments, submitReview, fetchInquirySuggestions } from '../../services/api';
const confidenceConfig: Record<
ConfidenceLevel,
{ color: string; label: string }
> = {
high: { color: 'green', label: '高置信' },
medium: { color: 'orange', label: '中置信' },
low: { color: 'default', label: '低置信' },
};
const reviewStatusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
pending: { color: 'orange', label: '待复核', icon: <ExclamationCircleOutlined /> },
confirmed: { color: 'green', label: '已确认', icon: <CheckCircleOutlined /> },
rejected: { color: 'red', label: '已排除', icon: <CloseCircleOutlined /> },
needs_info: { color: 'blue', label: '需补充', icon: <QuestionCircleOutlined /> },
};
const Review: React.FC = () => {
const { id = '1' } = useParams();
const qc = useQueryClient();
const [filterLevel, setFilterLevel] = useState<string>('all');
const [reviewModal, setReviewModal] = useState<FraudAssessment | null>(null);
const [reviewAction, setReviewAction] = useState<string>('confirmed');
const [reviewNote, setReviewNote] = useState('');
const { data: assessData } = useQuery({
queryKey: ['assessments', id],
queryFn: () => fetchAssessments(id),
});
const { data: suggestionsData } = useQuery({
queryKey: ['suggestions', id],
queryFn: () => fetchInquirySuggestions(id),
});
const allAssessments = assessData?.items ?? [];
const suggestions = suggestionsData?.suggestions ?? [];
const reviewMutation = useMutation({
mutationFn: (params: { assessmentId: string; body: any }) =>
submitReview(params.assessmentId, params.body),
onSuccess: () => {
message.success('复核结果已保存');
qc.invalidateQueries({ queryKey: ['assessments', id] });
setReviewModal(null);
},
});
const data =
filterLevel === 'all'
? allAssessments
: allAssessments.filter((a) => a.confidenceLevel === filterLevel);
const totalConfirmed = allAssessments
.filter((a) => a.reviewStatus === 'confirmed' && a.assessedAmount > 0)
.reduce((s, a) => s + a.assessedAmount, 0);
const totalPending = allAssessments
.filter((a) => a.reviewStatus === 'pending')
.reduce((s, a) => s + a.assessedAmount, 0);
const pendingCount = allAssessments.filter(
(a) => a.reviewStatus === 'pending',
).length;
const confirmedCount = allAssessments.filter(
(a) => a.reviewStatus === 'confirmed',
).length;
const columns: ColumnsType<FraudAssessment> = [
{
title: '交易时间',
width: 170,
render: (_, r) => r.transaction.tradeTime,
sorter: (a, b) =>
a.transaction.tradeTime.localeCompare(b.transaction.tradeTime),
defaultSortOrder: 'ascend',
},
{
title: '来源',
width: 90,
render: (_, r) => {
const app = r.transaction.sourceApp;
const m: Record<string, { l: string; c: string }> = {
wechat: { l: '微信', c: 'green' },
alipay: { l: '支付宝', c: 'blue' },
bank: { l: '银行', c: 'purple' },
digital_wallet: { l: '钱包', c: 'orange' },
other: { l: '其他', c: 'default' },
};
return <Tag color={m[app].c}>{m[app].l}</Tag>;
},
},
{
title: '认定金额(元)',
dataIndex: 'assessedAmount',
width: 140,
align: 'right',
render: (v: number) =>
v > 0 ? (
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{v.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '对方',
render: (_, r) => r.transaction.counterpartyName,
ellipsis: true,
},
{
title: '置信度',
dataIndex: 'confidenceLevel',
width: 90,
render: (level: ConfidenceLevel) => (
<Tag color={confidenceConfig[level].color}>
{confidenceConfig[level].label}
</Tag>
),
},
{
title: '认定理由',
dataIndex: 'reason',
ellipsis: true,
width: 280,
render: (text: string) => (
<Tooltip title={text}>
<Typography.Text style={{ fontSize: 13 }}>{text}</Typography.Text>
</Tooltip>
),
},
{
title: '复核状态',
dataIndex: 'reviewStatus',
width: 100,
render: (s: string) => {
const cfg = reviewStatusConfig[s];
return (
<Tag color={cfg.color} icon={cfg.icon}>
{cfg.label}
</Tag>
);
},
},
{
title: '操作',
width: 100,
render: (_, r) =>
r.reviewStatus === 'pending' ? (
<Button
type="primary"
size="small"
onClick={() => {
setReviewModal(r);
setReviewAction('confirmed');
setReviewNote('');
}}
>
</Button>
) : (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => {
setReviewModal(r);
}}
>
</Button>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="已确认被骗金额"
value={totalConfirmed}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: 24 }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="待确认金额"
value={totalPending}
precision={2}
prefix="¥"
valueStyle={{ color: '#fa8c16', fontSize: 24 }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="已复核"
value={confirmedCount}
suffix={`/ ${allAssessments.length}`}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="待复核"
value={pendingCount}
suffix="笔"
prefix={<ExclamationCircleOutlined />}
valueStyle={{ color: '#fa8c16' }}
/>
</Card>
</Col>
</Row>
{pendingCount > 0 && (
<Alert
message={`${pendingCount} 笔交易需要人工复核确认`}
description="系统已根据OCR识别、交易归并和规则引擎完成自动分析。高置信项可快速确认中/低置信项建议仔细核对后决定。"
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Card
title={
<Space>
<AuditOutlined />
<span></span>
</Space>
}
extra={
<Segmented
value={filterLevel}
onChange={(v) => setFilterLevel(v as string)}
options={[
{ label: '全部', value: 'all' },
{ label: '高置信', value: 'high' },
{ label: '中置信', value: 'medium' },
{ label: '低置信', value: 'low' },
]}
/>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
pagination={false}
size="middle"
/>
</Card>
<Divider />
<Card
title={
<Space>
<FileTextOutlined />
<span></span>
</Space>
}
style={{ background: '#fffbe6', borderColor: '#ffe58f' }}
>
<Typography.Paragraph>
</Typography.Paragraph>
<ol style={{ paddingLeft: 20, lineHeight: 2.2 }}>
{suggestions.map((s, idx) => (
<li key={idx}>
<Typography.Text>{s}</Typography.Text>
</li>
))}
</ol>
</Card>
<Modal
title={
reviewModal?.reviewStatus === 'pending'
? '复核认定'
: '认定详情'
}
open={!!reviewModal}
onCancel={() => setReviewModal(null)}
footer={
reviewModal?.reviewStatus === 'pending'
? [
<Button key="cancel" onClick={() => setReviewModal(null)}>
</Button>,
<Button
key="submit"
type="primary"
loading={reviewMutation.isPending}
onClick={() => {
reviewMutation.mutate({
assessmentId: reviewModal!.id,
body: {
review_status: reviewAction,
review_note: reviewNote,
reviewed_by: 'demo_user',
},
});
}}
>
</Button>,
]
: [
<Button key="close" onClick={() => setReviewModal(null)}>
</Button>,
]
}
width={600}
destroyOnClose
>
{reviewModal && (
<>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="交易时间">
{reviewModal.transaction.tradeTime}
</Descriptions.Item>
<Descriptions.Item label="金额">
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{reviewModal.assessedAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="对方">
{reviewModal.transaction.counterpartyName}
</Descriptions.Item>
<Descriptions.Item label="来源APP">
{reviewModal.transaction.sourceApp}
</Descriptions.Item>
<Descriptions.Item label="置信等级">
<Tag color={confidenceConfig[reviewModal.confidenceLevel].color}>
{confidenceConfig[reviewModal.confidenceLevel].label}
</Tag>
</Descriptions.Item>
</Descriptions>
<Card size="small" style={{ marginBottom: 16, background: '#f6ffed', borderColor: '#b7eb8f' }}>
<Typography.Text strong></Typography.Text>
<br />
<Typography.Text>{reviewModal.reason}</Typography.Text>
</Card>
{reviewModal.excludeReason && (
<Card size="small" style={{ marginBottom: 16, background: '#fff2e8', borderColor: '#ffbb96' }}>
<Typography.Text strong></Typography.Text>
<br />
<Typography.Text>{reviewModal.excludeReason}</Typography.Text>
</Card>
)}
{reviewModal.reviewStatus === 'pending' && (
<>
<Divider />
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text strong></Typography.Text>
<Select
value={reviewAction}
onChange={setReviewAction}
style={{ width: '100%' }}
options={[
{ label: '确认 - 该笔计入被骗金额', value: 'confirmed' },
{ label: '排除 - 该笔不计入被骗金额', value: 'rejected' },
{ label: '需补充 - 需进一步调查确认', value: 'needs_info' },
]}
/>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={3}
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
placeholder="请输入复核意见或备注..."
/>
</Space>
</>
)}
{reviewModal.reviewStatus !== 'pending' && reviewModal.reviewedBy && (
<Descriptions column={2} size="small" style={{ marginTop: 16 }}>
<Descriptions.Item label="复核人">
{reviewModal.reviewedBy}
</Descriptions.Item>
<Descriptions.Item label="复核时间">
{reviewModal.reviewedAt}
</Descriptions.Item>
</Descriptions>
)}
</>
)}
</Modal>
</div>
);
};
export default Review;

View File

@@ -0,0 +1,308 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Card,
Row,
Col,
Tag,
Typography,
Select,
Space,
Badge,
Descriptions,
Empty,
List,
Drawer,
Button,
Form,
Input,
InputNumber,
DatePicker,
Divider,
Segmented,
} from 'antd';
import {
FileImageOutlined,
CheckCircleOutlined,
LoadingOutlined,
CloseCircleOutlined,
EditOutlined,
ZoomInOutlined,
} from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types';
import { fetchImages } from '../../services/api';
const appLabel: 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 pageTypeLabel: Record<PageType, string> = {
bill_list: '账单列表',
bill_detail: '账单详情',
transfer_receipt: '转账凭证',
sms_notice: '短信通知',
balance: '余额页',
unknown: '未识别',
};
const ocrStatusIcon: Record<string, React.ReactNode> = {
done: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
processing: <LoadingOutlined style={{ color: '#1677ff' }} />,
failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
pending: <FileImageOutlined style={{ color: '#d9d9d9' }} />,
};
const Screenshots: React.FC = () => {
const { id = '1' } = useParams();
const [filterApp, setFilterApp] = useState<string>('all');
const [selectedImage, setSelectedImage] = useState<EvidenceImage | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const { data: allImages = [] } = useQuery({
queryKey: ['images', id],
queryFn: () => fetchImages(id),
});
const filtered =
filterApp === 'all'
? allImages
: allImages.filter((img: EvidenceImage) => img.sourceApp === filterApp);
const appCounts = allImages.reduce<Record<string, number>>((acc, img: EvidenceImage) => {
acc[img.sourceApp] = (acc[img.sourceApp] || 0) + 1;
return acc;
}, {});
const handleSelect = (img: EvidenceImage) => {
setSelectedImage(img);
setDrawerOpen(true);
};
const mockOcrFields = [
{ label: '交易时间', value: '2026-03-06 10:25:00', confidence: 0.97 },
{ label: '交易金额', value: '¥50,000.00', confidence: 0.99 },
{ label: '交易方向', value: '支出', confidence: 0.95 },
{ label: '对方账户', value: '李*华 (138****5678)', confidence: 0.88 },
{ label: '订单号', value: 'AL20260306002', confidence: 0.96 },
{ label: '备注', value: '投资款', confidence: 0.92 },
];
return (
<div>
<Card
title={
<Space>
<FileImageOutlined />
<span></span>
<Tag>{allImages.length} </Tag>
</Space>
}
extra={
<Space>
<Select
value={filterApp}
onChange={setFilterApp}
style={{ width: 140 }}
options={[
{ label: '全部来源', value: 'all' },
...Object.entries(appLabel).map(([k, v]) => ({
label: `${v.label} (${appCounts[k] || 0})`,
value: k,
})),
]}
/>
<Segmented
options={[
{ label: '网格', value: 'grid' },
{ label: '列表', value: 'list' },
]}
defaultValue="grid"
/>
</Space>
}
>
<Row gutter={[16, 16]}>
{filtered.map((img) => (
<Col key={img.id} xs={12} sm={8} md={6} lg={4}>
<Card
hoverable
onClick={() => handleSelect(img)}
styles={{
body: { padding: 12 },
}}
cover={
<div
style={{
height: 200,
background: '#f5f5f5',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
}}
>
<FileImageOutlined
style={{ fontSize: 40, color: '#bfbfbf' }}
/>
<Typography.Text
type="secondary"
style={{ fontSize: 12, marginTop: 8 }}
>
</Typography.Text>
<div
style={{
position: 'absolute',
top: 8,
right: 8,
}}
>
{ocrStatusIcon[img.ocrStatus]}
</div>
<div
style={{
position: 'absolute',
top: 8,
left: 8,
}}
>
<Tag
color={appLabel[img.sourceApp].color}
style={{ fontSize: 11 }}
>
{appLabel[img.sourceApp].label}
</Tag>
</div>
</div>
}
>
<Typography.Text ellipsis style={{ fontSize: 13 }}>
{pageTypeLabel[img.pageType]}
</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{img.uploadedAt}
</Typography.Text>
</Card>
</Col>
))}
</Row>
{filtered.length === 0 && <Empty description="暂无截图" />}
</Card>
<Drawer
title={
selectedImage ? (
<Space>
<Tag color={appLabel[selectedImage.sourceApp].color}>
{appLabel[selectedImage.sourceApp].label}
</Tag>
<span>{pageTypeLabel[selectedImage.pageType]}</span>
<Badge
status={selectedImage.ocrStatus === 'done' ? 'success' : 'processing'}
text={selectedImage.ocrStatus === 'done' ? 'OCR已完成' : '处理中'}
/>
</Space>
) : '截图详情'
}
placement="right"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{selectedImage && (
<>
<div
style={{
background: '#fafafa',
borderRadius: 8,
height: 300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
border: '1px dashed #d9d9d9',
}}
>
<Space direction="vertical" align="center">
<FileImageOutlined style={{ fontSize: 64, color: '#bfbfbf' }} />
<Typography.Text type="secondary">
</Typography.Text>
<Button icon={<ZoomInOutlined />} size="small">
</Button>
</Space>
</div>
<Typography.Title level={5}>OCR </Typography.Title>
<Typography.Text type="secondary" style={{ marginBottom: 16, display: 'block' }}>
</Typography.Text>
<List
dataSource={mockOcrFields}
renderItem={(item) => (
<List.Item
extra={
<Space>
<Tag
color={
item.confidence >= 0.95
? 'green'
: item.confidence >= 0.85
? 'orange'
: 'red'
}
>
{(item.confidence * 100).toFixed(0)}%
</Tag>
<Button type="link" size="small" icon={<EditOutlined />}>
</Button>
</Space>
}
>
<List.Item.Meta
title={
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{item.label}
</Typography.Text>
}
description={
<Typography.Text strong>{item.value}</Typography.Text>
}
/>
</List.Item>
)}
/>
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label="图片ID">
{selectedImage.id}
</Descriptions.Item>
<Descriptions.Item label="文件哈希">
{selectedImage.hash}
</Descriptions.Item>
<Descriptions.Item label="上传时间">
{selectedImage.uploadedAt}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
</div>
);
};
export default Screenshots;

View File

@@ -0,0 +1,335 @@
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 && (
<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>
),
},
{
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}
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;

View File

@@ -0,0 +1,300 @@
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Card,
Steps,
Row,
Col,
Statistic,
Typography,
Upload,
Button,
Space,
Tag,
Descriptions,
Progress,
Alert,
Divider,
message,
} from 'antd';
import {
CloudUploadOutlined,
ScanOutlined,
MergeCellsOutlined,
ApartmentOutlined,
AuditOutlined,
FileTextOutlined,
InboxOutlined,
RightOutlined,
} from '@ant-design/icons';
import { fetchCase, fetchImages, fetchTransactions, fetchAssessments, uploadImages } from '../../services/api';
const { Dragger } = Upload;
const Workspace: React.FC = () => {
const { id = '1' } = useParams();
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(3);
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) });
const { data: txData } = useQuery({ queryKey: ['transactions', id], queryFn: () => fetchTransactions(id) });
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
const images = imagesData ?? [];
const txList = txData?.items ?? [];
const assessments = assessData?.items ?? [];
const highConfirm = assessments.filter((a) => a.confidenceLevel === 'high').length;
const pendingReview = assessments.filter((a) => a.reviewStatus === 'pending').length;
if (!currentCase) return null;
const steps = [
{
title: '上传截图',
icon: <CloudUploadOutlined />,
description: `${images.length} 张已上传`,
},
{
title: 'OCR识别',
icon: <ScanOutlined />,
description: `${images.filter((i: any) => i.ocrStatus === 'done').length}/${images.length} 已完成`,
},
{
title: '交易归并',
icon: <MergeCellsOutlined />,
description: `${txList.length} 笔交易`,
},
{
title: '资金分析',
icon: <ApartmentOutlined />,
description: '已生成路径图',
},
{
title: '认定复核',
icon: <AuditOutlined />,
description: `${pendingReview} 笔待复核`,
},
{
title: '报告导出',
icon: <FileTextOutlined />,
description: '待生成',
},
];
return (
<div>
<Card style={{ marginBottom: 24 }}>
<Row justify="space-between" align="middle">
<Col>
<Space direction="vertical" size={4}>
<Space>
<Typography.Title level={4} style={{ margin: 0 }}>
{currentCase.title}
</Typography.Title>
<Tag color="orange"></Tag>
</Space>
<Typography.Text type="secondary">
{currentCase.caseNo} · {currentCase.handler} · {currentCase.victimName}
</Typography.Text>
</Space>
</Col>
<Col>
<Statistic
title="当前识别被骗金额"
value={currentCase.totalAmount}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: 28 }}
/>
</Col>
</Row>
</Card>
<Card style={{ marginBottom: 24 }}>
<Steps
current={currentStep}
items={steps}
onChange={(v) => setCurrentStep(v)}
/>
</Card>
<Row gutter={24}>
<Col span={16}>
<Card
title="快速上传截图"
style={{ marginBottom: 24 }}
extra={
<Typography.Text type="secondary">
JPG/PNG
</Typography.Text>
}
>
<Dragger
multiple
accept="image/*"
showUploadList={false}
beforeUpload={(file, fileList) => {
uploadImages(id, fileList as unknown as File[])
.then(() => message.success('截图上传成功'))
.catch(() => message.error('上传失败'));
return false;
}}
style={{ padding: '20px 0' }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined style={{ fontSize: 48, color: '#1677ff' }} />
</p>
<p className="ant-upload-text">
</p>
<p className="ant-upload-hint">
APP
</p>
</Dragger>
</Card>
<Card title="处理进度">
<Row gutter={[24, 16]}>
<Col span={8}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={Math.round(
images.length ? (images.filter((i: any) => i.ocrStatus === 'done').length /
images.length) *
100 : 0,
)}
size={80}
/>
<div style={{ marginTop: 8 }}>
<Typography.Text>OCR </Typography.Text>
</div>
</div>
</Col>
<Col span={8}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={Math.round(
txList.length ? (txList.filter((t) => !t.isDuplicate).length /
txList.length) *
100 : 0,
)}
size={80}
strokeColor="#52c41a"
/>
<div style={{ marginTop: 8 }}>
<Typography.Text></Typography.Text>
</div>
</div>
</Col>
<Col span={8}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={Math.round(
assessments.length ? (highConfirm / assessments.length) * 100 : 0,
)}
size={80}
strokeColor="#fa8c16"
/>
<div style={{ marginTop: 8 }}>
<Typography.Text></Typography.Text>
</div>
</div>
</Col>
</Row>
</Card>
</Col>
<Col span={8}>
<Card title="案件概况" style={{ marginBottom: 24 }}>
<Descriptions column={1} size="small">
<Descriptions.Item label="截图总数">
{images.length}
</Descriptions.Item>
<Descriptions.Item label="涉及APP">
<Space>
<Tag color="green"></Tag>
<Tag color="blue"></Tag>
<Tag color="purple"></Tag>
<Tag></Tag>
</Space>
</Descriptions.Item>
<Descriptions.Item label="提取交易数">
{txList.length}
</Descriptions.Item>
<Descriptions.Item label="去重后交易">
{txList.filter((t) => !t.isDuplicate).length}
</Descriptions.Item>
<Descriptions.Item label="涉诈对手方">2 </Descriptions.Item>
<Descriptions.Item label="待复核">
<Typography.Text type="warning">
{pendingReview}
</Typography.Text>
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="快捷操作">
<Space direction="vertical" style={{ width: '100%' }}>
<Button
block
onClick={() => navigate('/cases/1/screenshots')}
icon={<RightOutlined />}
>
OCR
</Button>
<Button
block
onClick={() => navigate('/cases/1/transactions')}
icon={<RightOutlined />}
>
</Button>
<Button
block
onClick={() => navigate('/cases/1/analysis')}
icon={<RightOutlined />}
>
</Button>
<Button
block
type="primary"
onClick={() => navigate('/cases/1/review')}
icon={<RightOutlined />}
>
</Button>
</Space>
</Card>
{pendingReview > 0 && (
<Alert
message={`${pendingReview} 笔交易待人工确认`}
description="系统已完成自动分析,请进入认定复核页面审阅并确认结果。"
type="warning"
showIcon
style={{ marginTop: 24 }}
action={
<Button
size="small"
type="primary"
onClick={() => navigate('/cases/1/review')}
>
</Button>
}
/>
)}
</Col>
</Row>
</div>
);
};
export default Workspace;