Files
fund-tracer/frontend/src/pages/reports/Reports.tsx
2026-03-12 20:04:27 +08:00

459 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, 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,
Checkbox,
message,
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';
type ContentKeys =
| 'include_summary'
| 'include_transactions'
| 'include_flow_chart'
| 'include_timeline'
| 'include_reasons'
| 'include_inquiry'
| 'include_screenshots';
const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolean }> = [
{ key: 'include_summary', label: '被骗金额汇总表', defaultOn: true },
{ key: 'include_transactions', label: '交易明细清单(含证据索引)', defaultOn: true },
{ key: 'include_flow_chart', label: '资金流转路径图', defaultOn: true },
{ key: 'include_timeline', label: '交易时间轴', defaultOn: true },
{ key: 'include_reasons', label: '认定理由与排除说明', defaultOn: true },
{ key: 'include_inquiry', label: '笔录辅助问询建议', defaultOn: false },
{ key: 'include_screenshots', label: '原始截图附件', defaultOn: false },
];
const STORAGE_PREFIX = 'report-content-';
const loadContentSelection = (caseId: string): Record<ContentKeys, boolean> => {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${caseId}`);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
const defaults: Record<string, boolean> = {};
contentOptions.forEach((o) => { defaults[o.key] = o.defaultOn; });
return defaults as Record<ContentKeys, boolean>;
};
const saveContentSelection = (caseId: string, sel: Record<ContentKeys, boolean>) => {
try {
localStorage.setItem(`${STORAGE_PREFIX}${caseId}`, JSON.stringify(sel));
} catch { /* ignore */ }
};
const Reports: React.FC = () => {
const { id = '1' } = useParams();
const qc = useQueryClient();
const [generated, setGenerated] = useState(false);
const [selectedFormats, setSelectedFormats] = useState<Array<'excel' | 'pdf' | 'word'>>([
'excel',
'pdf',
]);
const [contentSel, setContentSel] = useState<Record<ContentKeys, boolean>>(() => loadContentSelection(id));
useEffect(() => {
setContentSel(loadContentSelection(id));
}, [id]);
const toggleContent = useCallback((key: ContentKeys, checked: boolean) => {
setContentSel((prev) => {
const next = { ...prev, [key]: checked };
saveContentSelection(id, next);
return next;
});
}, [id]);
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: async (reportTypes: Array<'excel' | 'pdf' | 'word'>) => {
const result = await Promise.all(
reportTypes.map((reportType) =>
generateReport(id, { report_type: reportType, ...contentSel }),
),
);
return result;
},
onSuccess: (_res, vars) => {
setGenerated(true);
qc.invalidateQueries({ queryKey: ['reports', id] });
message.success(`报告生成成功:${vars.map((v) => v.toUpperCase()).join(' / ')}`);
},
onError: () => {
message.error('报告生成失败');
},
});
if (!currentCase) return null;
const latestReportByType = reportsList.reduce<Record<string, (typeof reportsList)[0]>>((acc, report) => {
if (!acc[report.reportType]) {
acc[report.reportType] = report;
}
return acc;
}, {});
const historyColumns: ColumnsType<(typeof reportsList)[0]> = [
{
title: '类型',
dataIndex: 'reportType',
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: (_, report) => (
<Space>
<Button
type="link"
size="small"
icon={<DownloadOutlined />}
href={getReportDownloadUrl(report.id)}
target="_blank"
>
</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
checked={selectedFormats.includes('excel')}
onChange={(e) => {
setSelectedFormats((prev) =>
e.target.checked
? Array.from(new Set([...prev, 'excel']))
: prev.filter((x) => x !== 'excel'),
);
}}
>
</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
checked={selectedFormats.includes('pdf')}
onChange={(e) => {
setSelectedFormats((prev) =>
e.target.checked
? Array.from(new Set([...prev, 'pdf']))
: prev.filter((x) => x !== 'pdf'),
);
}}
>
</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
checked={selectedFormats.includes('word')}
onChange={(e) => {
setSelectedFormats((prev) =>
e.target.checked
? Array.from(new Set([...prev, 'word']))
: prev.filter((x) => x !== 'word'),
);
}}
>
</Checkbox>
</div>
</Card>
</Space>
</div>
<Typography.Text strong></Typography.Text>
<div style={{ margin: '12px 0 24px' }}>
<Space direction="vertical">
{contentOptions.map((opt) => (
<Checkbox
key={opt.key}
checked={contentSel[opt.key]}
onChange={(e) => toggleContent(opt.key, e.target.checked)}
>
{opt.label}
</Checkbox>
))}
</Space>
</div>
{!generated ? (
<Button
type="primary"
size="large"
icon={<FileTextOutlined />}
loading={genMutation.isPending}
onClick={() => {
if (selectedFormats.length === 0) {
message.warning('请至少选择一种导出格式');
return;
}
genMutation.mutate(selectedFormats);
}}
block
>
{genMutation.isPending ? '正在生成报告...' : '生成报告'}
</Button>
) : (
<Result
status="success"
title="报告已生成"
subTitle="您可以下载或打印以下报告文件"
extra={[
<Button
key="excel"
icon={<DownloadOutlined />}
href={latestReportByType.excel ? getReportDownloadUrl(latestReportByType.excel.id) : undefined}
target="_blank"
disabled={!latestReportByType.excel}
>
Excel
</Button>,
<Button
key="pdf"
type="primary"
icon={<DownloadOutlined />}
href={latestReportByType.pdf ? getReportDownloadUrl(latestReportByType.pdf.id) : undefined}
target="_blank"
disabled={!latestReportByType.pdf}
>
PDF
</Button>,
<Button
key="word"
icon={<DownloadOutlined />}
href={latestReportByType.word ? getReportDownloadUrl(latestReportByType.word.id) : undefined}
target="_blank"
disabled={!latestReportByType.word}
>
Word
</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;