444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
/**
|
|
* 质控报告抽屉组件
|
|
*
|
|
* 功能:
|
|
* - 展示质控报告摘要
|
|
* - 展示严重问题和警告问题列表
|
|
* - 展示表单统计
|
|
* - 支持导出 XML 格式报告
|
|
*/
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Drawer,
|
|
Tabs,
|
|
Statistic,
|
|
Row,
|
|
Col,
|
|
Card,
|
|
Table,
|
|
Tag,
|
|
Space,
|
|
Button,
|
|
Spin,
|
|
Empty,
|
|
Typography,
|
|
message,
|
|
Progress,
|
|
Tooltip,
|
|
} from 'antd';
|
|
import {
|
|
DownloadOutlined,
|
|
ReloadOutlined,
|
|
ExclamationCircleOutlined,
|
|
WarningOutlined,
|
|
CheckCircleOutlined,
|
|
FileTextOutlined,
|
|
BarChartOutlined,
|
|
} from '@ant-design/icons';
|
|
import type { QcReport } from '../../api/iitProjectApi';
|
|
import * as iitProjectApi from '../../api/iitProjectApi';
|
|
|
|
const { Text } = Typography;
|
|
const { TabPane } = Tabs;
|
|
|
|
interface QcReportDrawerProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
projectId: string;
|
|
projectName: string;
|
|
}
|
|
|
|
const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
|
|
open,
|
|
onClose,
|
|
projectId,
|
|
projectName,
|
|
}) => {
|
|
const [loading, setLoading] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [report, setReport] = useState<QcReport | null>(null);
|
|
|
|
// 加载报告
|
|
const loadReport = async (forceRefresh = false) => {
|
|
if (forceRefresh) {
|
|
setRefreshing(true);
|
|
} else {
|
|
setLoading(true);
|
|
}
|
|
|
|
try {
|
|
let data: QcReport;
|
|
if (forceRefresh) {
|
|
data = await iitProjectApi.refreshQcReport(projectId);
|
|
message.success('报告已刷新');
|
|
} else {
|
|
data = await iitProjectApi.getQcReport(projectId, 'json') as QcReport;
|
|
}
|
|
setReport(data);
|
|
} catch (error: any) {
|
|
message.error(error.message || '加载报告失败');
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
// 导出 XML 报告(导出前自动刷新,确保获取最新数据)
|
|
const handleExportXml = async () => {
|
|
try {
|
|
message.loading({ content: '正在生成最新报告...', key: 'export' });
|
|
|
|
// 1. 先刷新报告(确保获取最新质控结果)
|
|
await iitProjectApi.refreshQcReport(projectId);
|
|
|
|
// 2. 获取刷新后的 XML 报告
|
|
const xmlData = await iitProjectApi.getQcReport(projectId, 'xml') as string;
|
|
|
|
// 3. 创建 Blob 并下载
|
|
const blob = new Blob([xmlData], { type: 'application/xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
const now = new Date();
|
|
const dateStr = now.toISOString().split('T')[0];
|
|
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM 格式
|
|
link.download = `qc-report-${projectId}-${dateStr}-${timeStr}.xml`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
message.success({ content: '报告已导出', key: 'export' });
|
|
} catch (error: any) {
|
|
message.error({ content: error.message || '导出失败', key: 'export' });
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
loadReport();
|
|
}
|
|
}, [open, projectId]);
|
|
|
|
// 渲染摘要
|
|
const renderSummary = () => {
|
|
if (!report) return null;
|
|
const { summary } = report;
|
|
|
|
// V2.1: 防护空值
|
|
if (!summary) {
|
|
return (
|
|
<Empty
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
description="暂无摘要数据,请尝试刷新报告"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 安全获取数值
|
|
const totalRecords = summary.totalRecords ?? 0;
|
|
const passRate = summary.passRate ?? 0;
|
|
const criticalIssues = summary.criticalIssues ?? 0;
|
|
const warningIssues = summary.warningIssues ?? 0;
|
|
const completedRecords = summary.completedRecords ?? 0;
|
|
|
|
return (
|
|
<div className="qc-report-summary">
|
|
<Row gutter={[16, 16]}>
|
|
<Col span={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="总记录数"
|
|
value={totalRecords}
|
|
valueStyle={{ color: '#1890ff' }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="通过率"
|
|
value={passRate}
|
|
suffix="%"
|
|
valueStyle={{ color: passRate >= 80 ? '#52c41a' : '#ff4d4f' }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="严重问题"
|
|
value={criticalIssues}
|
|
valueStyle={{ color: '#ff4d4f' }}
|
|
prefix={<ExclamationCircleOutlined />}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="警告问题"
|
|
value={warningIssues}
|
|
valueStyle={{ color: '#faad14' }}
|
|
prefix={<WarningOutlined />}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Card size="small" style={{ marginTop: 16 }}>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Text type="secondary">完成记录</Text>
|
|
<Progress
|
|
percent={totalRecords > 0 ? Math.round((completedRecords / totalRecords) * 100) : 0}
|
|
status="active"
|
|
/>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Text type="secondary">待确认</Text>
|
|
<Statistic value={summary.pendingQueries ?? 0} valueStyle={{ fontSize: 20 }} />
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
|
|
<Card size="small" style={{ marginTop: 16 }}>
|
|
<Text type="secondary">报告生成时间: </Text>
|
|
<Text>{report.generatedAt ? new Date(report.generatedAt).toLocaleString() : '-'}</Text>
|
|
<br />
|
|
<Text type="secondary">最后质控时间: </Text>
|
|
<Text>{summary.lastQcTime ? new Date(summary.lastQcTime).toLocaleString() : '-'}</Text>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 渲染问题列表
|
|
const renderIssues = (issues: QcReport['criticalIssues'] | QcReport['warningIssues'], type: 'critical' | 'warning') => {
|
|
const columns = [
|
|
{
|
|
title: '记录 ID',
|
|
dataIndex: 'recordId',
|
|
key: 'recordId',
|
|
width: 100,
|
|
render: (text: string) => <Text code>{text}</Text>,
|
|
},
|
|
{
|
|
title: '规则',
|
|
dataIndex: 'ruleName',
|
|
key: 'ruleName',
|
|
width: 150,
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: '问题描述',
|
|
dataIndex: 'message',
|
|
key: 'message',
|
|
ellipsis: true,
|
|
render: (text: string) => (
|
|
<Tooltip title={text}>
|
|
<span>{text}</span>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
title: '字段',
|
|
dataIndex: 'field',
|
|
key: 'field',
|
|
width: 120,
|
|
render: (text: string) => text ? <Text code>{text}</Text> : '-',
|
|
},
|
|
{
|
|
title: '检测时间',
|
|
dataIndex: 'detectedAt',
|
|
key: 'detectedAt',
|
|
width: 140,
|
|
render: (text: string) => text ? new Date(text).toLocaleDateString() : '-',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Table
|
|
dataSource={issues}
|
|
columns={columns}
|
|
rowKey={(record, index) => `${record.recordId}-${record.ruleId}-${index}`}
|
|
size="small"
|
|
pagination={{ pageSize: 10 }}
|
|
locale={{
|
|
emptyText: (
|
|
<Empty
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
description={type === 'critical' ? '无严重问题' : '无警告问题'}
|
|
/>
|
|
),
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
// 渲染表单统计
|
|
const renderFormStats = () => {
|
|
if (!report?.formStats?.length) {
|
|
return <Empty description="暂无表单统计数据" />;
|
|
}
|
|
|
|
const columns = [
|
|
{
|
|
title: '表单',
|
|
dataIndex: 'formLabel',
|
|
key: 'formLabel',
|
|
},
|
|
{
|
|
title: '检查数',
|
|
dataIndex: 'totalChecks',
|
|
key: 'totalChecks',
|
|
width: 80,
|
|
align: 'center' as const,
|
|
},
|
|
{
|
|
title: '通过',
|
|
dataIndex: 'passed',
|
|
key: 'passed',
|
|
width: 80,
|
|
align: 'center' as const,
|
|
render: (text: number) => <Text type="success">{text}</Text>,
|
|
},
|
|
{
|
|
title: '失败',
|
|
dataIndex: 'failed',
|
|
key: 'failed',
|
|
width: 80,
|
|
align: 'center' as const,
|
|
render: (text: number) => <Text type="danger">{text}</Text>,
|
|
},
|
|
{
|
|
title: '通过率',
|
|
dataIndex: 'passRate',
|
|
key: 'passRate',
|
|
width: 120,
|
|
render: (rate: number) => (
|
|
<Progress
|
|
percent={rate}
|
|
size="small"
|
|
status={rate >= 80 ? 'success' : rate >= 60 ? 'normal' : 'exception'}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Table
|
|
dataSource={report?.formStats ?? []}
|
|
columns={columns}
|
|
rowKey="formName"
|
|
size="small"
|
|
pagination={false}
|
|
/>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Drawer
|
|
title={
|
|
<Space>
|
|
<FileTextOutlined />
|
|
<span>质控报告 - {projectName}</span>
|
|
</Space>
|
|
}
|
|
placement="right"
|
|
width={800}
|
|
open={open}
|
|
onClose={onClose}
|
|
extra={
|
|
<Space>
|
|
<Button
|
|
icon={<ReloadOutlined spin={refreshing} />}
|
|
onClick={() => loadReport(true)}
|
|
loading={refreshing}
|
|
>
|
|
刷新报告
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
icon={<DownloadOutlined />}
|
|
onClick={handleExportXml}
|
|
>
|
|
导出 XML
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
{loading ? (
|
|
<div style={{ textAlign: 'center', padding: 50 }}>
|
|
<Spin size="large" />
|
|
<div style={{ marginTop: 16 }}>
|
|
<Text type="secondary">正在生成报告...</Text>
|
|
</div>
|
|
</div>
|
|
) : report ? (
|
|
<Tabs defaultActiveKey="summary">
|
|
<TabPane
|
|
tab={
|
|
<span>
|
|
<BarChartOutlined />
|
|
摘要
|
|
</span>
|
|
}
|
|
key="summary"
|
|
>
|
|
{renderSummary()}
|
|
</TabPane>
|
|
<TabPane
|
|
tab={
|
|
<span>
|
|
<ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />
|
|
严重问题
|
|
{(report.criticalIssues?.length ?? 0) > 0 && (
|
|
<Tag color="red" style={{ marginLeft: 8 }}>
|
|
{report.criticalIssues?.length ?? 0}
|
|
</Tag>
|
|
)}
|
|
</span>
|
|
}
|
|
key="critical"
|
|
>
|
|
{renderIssues(report.criticalIssues ?? [], 'critical')}
|
|
</TabPane>
|
|
<TabPane
|
|
tab={
|
|
<span>
|
|
<WarningOutlined style={{ color: '#faad14' }} />
|
|
警告问题
|
|
{(report.warningIssues?.length ?? 0) > 0 && (
|
|
<Tag color="orange" style={{ marginLeft: 8 }}>
|
|
{report.warningIssues?.length ?? 0}
|
|
</Tag>
|
|
)}
|
|
</span>
|
|
}
|
|
key="warning"
|
|
>
|
|
{renderIssues(report.warningIssues ?? [], 'warning')}
|
|
</TabPane>
|
|
<TabPane
|
|
tab={
|
|
<span>
|
|
<CheckCircleOutlined />
|
|
表单统计
|
|
</span>
|
|
}
|
|
key="forms"
|
|
>
|
|
{renderFormStats()}
|
|
</TabPane>
|
|
</Tabs>
|
|
) : (
|
|
<Empty description="暂无报告数据" />
|
|
)}
|
|
</Drawer>
|
|
);
|
|
};
|
|
|
|
export default QcReportDrawer;
|