Files
AIclinicalresearch/frontend-v2/src/modules/admin/components/qc-cockpit/QcReportDrawer.tsx

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;