feat(iit): Implement event-level QC architecture V3.1 with dynamic rule filtering, report deduplication and AI intent enhancement

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 21:22:11 +08:00
parent 45c7b32dbb
commit 7a299e8562
51 changed files with 10638 additions and 184 deletions

View File

@@ -0,0 +1,327 @@
/**
* 质控详情抽屉组件
*
* 展示受试者某个表单/访视的详细质控信息
* - 左侧:真实数据 (Source of Truth)
* - 右侧AI 诊断报告 + LLM Trace
*/
import React, { useState, useEffect } from 'react';
import { Drawer, Button, Spin, message, Empty, Tag, Space, Tooltip } from 'antd';
import {
CloseOutlined,
ExclamationCircleOutlined,
WarningOutlined,
CheckCircleOutlined,
DatabaseOutlined,
RobotOutlined,
CodeOutlined,
ExportOutlined,
DeleteOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../../api/iitProjectApi';
import type { HeatmapCell, RecordDetail } from '../../types/qcCockpit';
interface QcDetailDrawerProps {
open: boolean;
onClose: () => void;
cell: HeatmapCell | null;
projectId: string;
projectName: string;
}
const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
open,
onClose,
cell,
projectId,
projectName,
}) => {
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<RecordDetail | null>(null);
const [activeTab, setActiveTab] = useState<'report' | 'trace'>('report');
// 加载详情
useEffect(() => {
if (open && cell) {
loadDetail();
}
}, [open, cell]);
const loadDetail = async () => {
if (!cell) return;
setLoading(true);
try {
const data = await iitProjectApi.getQcRecordDetail(
projectId,
cell.recordId,
cell.formName
);
setDetail(data);
} catch (error: any) {
message.error(error.message || '加载详情失败');
} finally {
setLoading(false);
}
};
const handleConfirmViolation = () => {
message.info('确认违规功能开发中...');
};
const handleSendQuery = () => {
message.info('发送 Query 功能开发中...');
};
const handleIgnore = () => {
message.info('忽略功能开发中...');
};
const handleOpenRedcap = () => {
message.info('打开 REDCap 功能开发中...');
};
if (!cell) return null;
// 从 recordId 提取数字部分作为头像
const avatarNum = cell.recordId.replace(/\D/g, '').slice(-2) || '00';
return (
<Drawer
title={null}
placement="right"
width={1000}
open={open}
onClose={onClose}
closable={false}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
>
{/* 抽屉头部 */}
<div style={{
padding: '16px 24px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: '#fafafa',
}}>
<div className="qc-drawer-header">
<div className="qc-drawer-avatar">{avatarNum}</div>
<div className="qc-drawer-info">
<div className="qc-drawer-title"> {cell.recordId}</div>
<div className="qc-drawer-subtitle">
访: {cell.formName} |
: {projectName} |
{detail?.entryTime && ` 录入时间: ${detail.entryTime}`}
</div>
</div>
</div>
<Space>
<Tooltip title="在 REDCap 中查看">
<Button icon={<ExportOutlined />} onClick={handleOpenRedcap}>
REDCap
</Button>
</Tooltip>
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
</Space>
</div>
{/* 抽屉内容 */}
<Spin spinning={loading}>
{detail ? (
<div className="qc-drawer-content">
{/* 左栏:真实数据 */}
<div className="qc-drawer-left">
<div className="qc-drawer-section">
<div className="qc-drawer-section-title">
<DatabaseOutlined />
({cell.formName})
<Tag color="default" style={{ marginLeft: 8 }}>Read Only</Tag>
</div>
</div>
<div style={{ padding: 16 }}>
<div className="qc-field-list">
{Object.entries(detail.data).map(([key, value]) => {
// 查找该字段是否有问题
const issue = detail.issues.find(i => i.field === key);
const fieldMeta = detail.fieldMetadata?.[key];
const label = fieldMeta?.label || key;
return (
<div key={key} className="qc-field-item">
<label className="qc-field-label">{label}</label>
<div className={`qc-field-value ${issue ? (issue.severity === 'critical' ? 'error' : 'warning') : ''}`}>
{value ?? <span style={{ color: '#999' }}></span>}
{issue && (
<ExclamationCircleOutlined
style={{
marginLeft: 8,
color: issue.severity === 'critical' ? '#ff4d4f' : '#faad14'
}}
/>
)}
</div>
{issue && (
<div style={{ fontSize: 12, color: '#ff4d4f', marginTop: 4 }}>
{issue.message}
{fieldMeta?.normalRange && (
<span style={{ marginLeft: 8, color: '#999' }}>
: {fieldMeta.normalRange.min ?? '-'} ~ {fieldMeta.normalRange.max ?? '-'}
</span>
)}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
{/* 右栏AI 诊断 + Trace */}
<div className="qc-drawer-right">
{/* Tab 切换 */}
<div className="qc-drawer-tabs">
<div
className={`qc-drawer-tab ${activeTab === 'report' ? 'active' : ''}`}
onClick={() => setActiveTab('report')}
>
<RobotOutlined style={{ marginRight: 8 }} />
</div>
<div
className={`qc-drawer-tab ${activeTab === 'trace' ? 'active' : ''}`}
onClick={() => setActiveTab('trace')}
>
<CodeOutlined style={{ marginRight: 8 }} />
LLM Trace ()
</div>
</div>
{/* Tab 内容 */}
{activeTab === 'report' ? (
<div style={{ padding: 16, overflowY: 'auto' }}>
{detail.issues.length > 0 ? (
detail.issues.map((issue, idx) => (
<div
key={idx}
className={`qc-issue-card ${issue.severity === 'critical' ? 'critical' : 'warning'}`}
>
<div className={`qc-issue-card-header ${issue.severity === 'critical' ? 'critical' : 'warning'}`}>
<span className={`qc-issue-card-title ${issue.severity === 'critical' ? 'critical' : 'warning'}`}>
{issue.severity === 'critical' ? (
<><ExclamationCircleOutlined /> (Critical)</>
) : (
<><WarningOutlined /> </>
)}
</span>
<span className="qc-issue-card-confidence">
: {issue.confidence || 'High'}
</span>
</div>
<div className="qc-issue-card-body">
<p className="qc-issue-card-description">
<strong>AI </strong>{issue.message}
</p>
<div className="qc-issue-card-evidence">
<p style={{ fontWeight: 600, marginBottom: 8 }}> (Evidence Chain):</p>
<ul style={{ paddingLeft: 16, margin: 0 }}>
<li>Variable: <code>{issue.field}</code> = {String(issue.actualValue)}</li>
{issue.expectedValue && (
<li>Standard: {issue.expectedValue}</li>
)}
<li>Form: {cell.formName}</li>
</ul>
</div>
</div>
<div className="qc-issue-card-footer">
{issue.severity === 'critical' && (
<Button
type="primary"
danger
size="small"
onClick={handleConfirmViolation}
>
(PD)
</Button>
)}
<Button size="small" onClick={handleSendQuery}>
<QuestionCircleOutlined /> Query
</Button>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={handleIgnore}
>
</Button>
</div>
</div>
))
) : (
<div style={{ textAlign: 'center', padding: 40 }}>
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a' }} />
<p style={{ marginTop: 16, color: '#52c41a' }}>
</p>
</div>
)}
</div>
) : (
/* LLM Trace Tab */
<div className="qc-llm-trace">
<div className="qc-llm-trace-header">
<span>Prompt Context (Sent to DeepSeek-V3)</span>
<span style={{ color: '#569cd6' }}>XML Protocol</span>
</div>
<div className="qc-llm-trace-content">
{detail.llmTrace ? (
<>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>
{detail.llmTrace.promptSent}
</pre>
<div style={{
marginTop: 24,
paddingTop: 16,
borderTop: '1px solid #333'
}}>
<div style={{ color: '#6a9955', marginBottom: 8 }}>
// LLM Output (Chain of Thought)
</div>
<pre style={{
whiteSpace: 'pre-wrap',
margin: 0,
color: '#9cdcfe',
fontStyle: 'italic'
}}>
{detail.llmTrace.responseReceived}
</pre>
<div style={{ marginTop: 16, color: '#666' }}>
Model: {detail.llmTrace.model} |
Latency: {detail.llmTrace.latencyMs}ms
</div>
</div>
</>
) : (
<div style={{ color: '#666', textAlign: 'center', padding: 40 }}>
LLM Trace
<br />
<small> trace </small>
</div>
)}
</div>
</div>
)}
</div>
</div>
) : (
<Empty description="暂无数据" style={{ padding: 40 }} />
)}
</Spin>
</Drawer>
);
};
export default QcDetailDrawer;

View File

@@ -0,0 +1,443 @@
/**
* 质控报告抽屉组件
*
* 功能:
* - 展示质控报告摘要
* - 展示严重问题和警告问题列表
* - 展示表单统计
* - 支持导出 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;

View File

@@ -0,0 +1,177 @@
/**
* 质控统计卡片组件
*
* 展示 4 个核心指标:
* - 总体数据质量分
* - 严重违规 (Critical) - 可点击查看详情
* - 待确认 Query (Major) - 可点击查看详情
* - 方案偏离 (PD) - 可点击查看详情
*/
import React from 'react';
import { Progress, Tooltip } from 'antd';
import {
HeartOutlined,
ExclamationCircleOutlined,
QuestionCircleOutlined,
ClockCircleOutlined,
RightOutlined,
} from '@ant-design/icons';
import type { QcStats } from '../../types/qcCockpit';
// 卡片类型
export type StatCardType = 'quality' | 'critical' | 'query' | 'deviation';
interface QcStatCardsProps {
stats: QcStats;
onCardClick?: (type: StatCardType) => void;
}
const QcStatCards: React.FC<QcStatCardsProps> = ({ stats, onCardClick }) => {
// 判断卡片是否可点击
const isClickable = (type: StatCardType): boolean => {
switch (type) {
case 'critical': return stats.criticalCount > 0;
case 'query': return stats.queryCount > 0;
case 'deviation': return stats.deviationCount > 0;
default: return false;
}
};
const handleClick = (type: StatCardType) => {
if (isClickable(type) && onCardClick) {
onCardClick(type);
}
};
return (
<div className="qc-stat-cards">
{/* 卡片 1: 总体数据质量分 */}
<div className="qc-stat-card">
<div className="qc-stat-card-header">
<div className="qc-stat-card-content">
<div className="qc-stat-card-title"></div>
<div className="qc-stat-card-value">
{stats.qualityScore}
<span className="qc-stat-card-suffix">/100</span>
</div>
</div>
<div className="qc-stat-card-icon success">
<HeartOutlined />
</div>
</div>
<div className="qc-stat-card-progress">
<Progress
percent={stats.qualityScore}
strokeColor={getScoreColor(stats.qualityScore)}
showInfo={false}
size="small"
/>
</div>
</div>
{/* 卡片 2: 严重违规 (Critical) - 可点击 */}
<Tooltip title={stats.criticalCount > 0 ? '点击查看详情' : undefined}>
<div
className={`qc-stat-card critical ${isClickable('critical') ? 'clickable' : ''}`}
onClick={() => handleClick('critical')}
>
<div className="qc-stat-card-header">
<div className="qc-stat-card-content">
<div className="qc-stat-card-title"> (Critical)</div>
<div className="qc-stat-card-value critical">
{stats.criticalCount}
<span className="qc-stat-card-suffix"></span>
</div>
</div>
<div className="qc-stat-card-icon critical">
<ExclamationCircleOutlined />
</div>
</div>
<div className="qc-stat-card-footer">
<span className="qc-stat-card-desc">
{stats.criticalCount > 0
? `需立即处理:${getTopIssue(stats, 'critical')}`
: '暂无严重违规'}
</span>
{stats.criticalCount > 0 && <RightOutlined className="qc-stat-card-arrow" />}
</div>
</div>
</Tooltip>
{/* 卡片 3: 待确认 Query (Major) - 可点击 */}
<Tooltip title={stats.queryCount > 0 ? '点击查看详情' : undefined}>
<div
className={`qc-stat-card warning ${isClickable('query') ? 'clickable' : ''}`}
onClick={() => handleClick('query')}
>
<div className="qc-stat-card-header">
<div className="qc-stat-card-content">
<div className="qc-stat-card-title"> Query (Major)</div>
<div className="qc-stat-card-value warning">
{stats.queryCount}
<span className="qc-stat-card-suffix"></span>
</div>
</div>
<div className="qc-stat-card-icon warning">
<QuestionCircleOutlined />
</div>
</div>
<div className="qc-stat-card-footer">
<span className="qc-stat-card-desc">
{stats.queryCount > 0
? getTopIssue(stats, 'warning')
: '暂无待确认问题'}
</span>
{stats.queryCount > 0 && <RightOutlined className="qc-stat-card-arrow" />}
</div>
</div>
</Tooltip>
{/* 卡片 4: 方案偏离 (PD) - 可点击 */}
<Tooltip title={stats.deviationCount > 0 ? '点击查看详情' : undefined}>
<div
className={`qc-stat-card ${isClickable('deviation') ? 'clickable' : ''}`}
onClick={() => handleClick('deviation')}
>
<div className="qc-stat-card-header">
<div className="qc-stat-card-content">
<div className="qc-stat-card-title"> (PD)</div>
<div className="qc-stat-card-value">
{stats.deviationCount}
<span className="qc-stat-card-suffix"></span>
</div>
</div>
<div className="qc-stat-card-icon info">
<ClockCircleOutlined />
</div>
</div>
<div className="qc-stat-card-footer">
<span className="qc-stat-card-desc">
{stats.deviationCount > 0
? '大多为访视超窗'
: '暂无方案偏离'}
</span>
{stats.deviationCount > 0 && <RightOutlined className="qc-stat-card-arrow" />}
</div>
</div>
</Tooltip>
</div>
);
};
// 根据分数获取颜色
function getScoreColor(score: number): string {
if (score >= 90) return '#52c41a';
if (score >= 70) return '#faad14';
return '#ff4d4f';
}
// 获取顶部问题
function getTopIssue(stats: QcStats, severity: 'critical' | 'warning'): string {
const issue = stats.topIssues?.find(i => i.severity === severity);
if (!issue) return '';
return `${issue.issue} (${issue.count})`;
}
export default QcStatCards;

View File

@@ -0,0 +1,149 @@
/**
* 风险热力图组件
*
* 展示受试者 × 表单/访视 矩阵
* - 绿色圆点:通过
* - 黄色图标:警告(可点击)
* - 红色图标:严重(可点击)
* - 灰色圆点:未开始
*/
import React from 'react';
import { Spin, Tag, Tooltip } from 'antd';
import {
CheckOutlined,
ExclamationOutlined,
CloseOutlined,
} from '@ant-design/icons';
import type { HeatmapData, HeatmapCell } from '../../types/qcCockpit';
interface RiskHeatmapProps {
data: HeatmapData;
onCellClick: (cell: HeatmapCell) => void;
loading?: boolean;
}
const RiskHeatmap: React.FC<RiskHeatmapProps> = ({ data, onCellClick, loading }) => {
const getStatusTag = (status: string) => {
switch (status) {
case 'enrolled':
return <Tag color="cyan"></Tag>;
case 'screening':
return <Tag color="blue"></Tag>;
case 'completed':
return <Tag color="green"></Tag>;
case 'withdrawn':
return <Tag color="default">退</Tag>;
default:
return <Tag>{status}</Tag>;
}
};
const renderCell = (cell: HeatmapCell) => {
const hasIssues = cell.status === 'warning' || cell.status === 'fail';
// ✅ 所有单元格都可点击,便于查看详情
const handleClick = () => onCellClick(cell);
// 有问题的单元格:显示可点击图标
if (hasIssues) {
const iconClass = `qc-heatmap-cell-icon ${cell.status}`;
const icon = cell.status === 'fail'
? <CloseOutlined />
: <ExclamationOutlined />;
return (
<Tooltip title={`${cell.issueCount} 个问题,点击查看详情`}>
<div className={iconClass} onClick={handleClick} style={{ cursor: 'pointer' }}>
{icon}
</div>
</Tooltip>
);
}
// 通过或待检查:显示小圆点(也可点击)
const dotClass = `qc-heatmap-cell-dot ${cell.status === 'pass' ? 'pass' : 'pending'}`;
const tooltipText = cell.status === 'pass'
? '已通过,点击查看数据'
: '未质控,点击查看数据';
return (
<Tooltip title={tooltipText}>
<div
className={dotClass}
onClick={handleClick}
style={{
backgroundColor: cell.status === 'pass' ? '#52c41a' : '#d9d9d9',
cursor: 'pointer',
}}
/>
</Tooltip>
);
};
return (
<div className="qc-heatmap">
{/* 头部 */}
<div className="qc-heatmap-header">
<h3 className="qc-heatmap-title"> (Risk Heatmap)</h3>
<div className="qc-heatmap-legend">
<span className="qc-heatmap-legend-item">
<span className="qc-heatmap-legend-dot pass" />
</span>
<span className="qc-heatmap-legend-item">
<span className="qc-heatmap-legend-dot warning" />
(Query)
</span>
<span className="qc-heatmap-legend-item">
<span className="qc-heatmap-legend-dot fail" />
</span>
<span className="qc-heatmap-legend-item">
<span className="qc-heatmap-legend-dot pending" />
</span>
</div>
</div>
{/* 表格内容 */}
<div className="qc-heatmap-body">
<Spin spinning={loading}>
<table className="qc-heatmap-table">
<thead>
<tr>
<th className="subject-header"> ID</th>
<th></th>
{data.columns.map((col, idx) => (
<th key={idx}>
{col.split('(')[0]}<br />
<small style={{ fontWeight: 'normal' }}>
{col.includes('(') ? `(${col.split('(')[1]}` : ''}
</small>
</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, rowIdx) => (
<tr key={rowIdx}>
<td className="subject-cell">{row.recordId}</td>
<td>{getStatusTag(row.status)}</td>
{row.cells.map((cell, cellIdx) => (
<td key={cellIdx}>
<div className="qc-heatmap-cell">
{renderCell(cell)}
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</Spin>
</div>
</div>
);
};
export default RiskHeatmap;

View File

@@ -0,0 +1,8 @@
/**
* 质控驾驶舱组件导出
*/
export { default as QcStatCards } from './QcStatCards';
export type { StatCardType } from './QcStatCards';
export { default as RiskHeatmap } from './RiskHeatmap';
export { default as QcDetailDrawer } from './QcDetailDrawer';