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:
@@ -27,6 +27,7 @@ import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage'
|
||||
// IIT 项目管理
|
||||
import IitProjectListPage from './modules/admin/pages/IitProjectListPage'
|
||||
import IitProjectDetailPage from './modules/admin/pages/IitProjectDetailPage'
|
||||
import IitQcCockpitPage from './modules/admin/pages/IitQcCockpitPage'
|
||||
// 运营日志
|
||||
import ActivityLogsPage from './pages/admin/ActivityLogsPage'
|
||||
// 个人中心页面
|
||||
@@ -123,6 +124,7 @@ function App() {
|
||||
{/* IIT 项目管理 */}
|
||||
<Route path="iit-projects" element={<IitProjectListPage />} />
|
||||
<Route path="iit-projects/:id" element={<IitProjectDetailPage />} />
|
||||
<Route path="iit-projects/:id/cockpit" element={<IitQcCockpitPage />} />
|
||||
{/* 运营日志 */}
|
||||
<Route path="activity-logs" element={<ActivityLogsPage />} />
|
||||
{/* 系统配置 */}
|
||||
|
||||
@@ -18,6 +18,10 @@ import type {
|
||||
RoleOption,
|
||||
KnowledgeBaseOption,
|
||||
} from '../types/iitProject';
|
||||
import type {
|
||||
QcCockpitData,
|
||||
RecordDetail,
|
||||
} from '../types/qcCockpit';
|
||||
|
||||
const BASE_URL = '/api/v1/admin/iit-projects';
|
||||
|
||||
@@ -260,3 +264,93 @@ export async function batchSummary(projectId: string): Promise<{
|
||||
const response = await apiClient.post(`${BASE_URL}/${projectId}/batch-summary`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ==================== 质控驾驶舱 ====================
|
||||
|
||||
/** 获取质控驾驶舱数据 */
|
||||
export async function getQcCockpitData(projectId: string): Promise<QcCockpitData> {
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** 获取记录质控详情 */
|
||||
export async function getQcRecordDetail(
|
||||
projectId: string,
|
||||
recordId: string,
|
||||
formName: string
|
||||
): Promise<RecordDetail> {
|
||||
const response = await apiClient.get(
|
||||
`${BASE_URL}/${projectId}/qc-cockpit/records/${recordId}`,
|
||||
{ params: { formName } }
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** 质控报告类型 */
|
||||
export interface QcReport {
|
||||
projectId: string;
|
||||
reportType: 'daily' | 'weekly' | 'on_demand';
|
||||
generatedAt: string;
|
||||
expiresAt: string | null;
|
||||
summary: {
|
||||
totalRecords: number;
|
||||
completedRecords: number;
|
||||
criticalIssues: number;
|
||||
warningIssues: number;
|
||||
pendingQueries: number;
|
||||
passRate: number;
|
||||
lastQcTime: string | null;
|
||||
};
|
||||
criticalIssues: Array<{
|
||||
recordId: string;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
message: string;
|
||||
field?: string;
|
||||
actualValue?: any;
|
||||
expectedValue?: any;
|
||||
detectedAt: string;
|
||||
}>;
|
||||
warningIssues: Array<{
|
||||
recordId: string;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
message: string;
|
||||
field?: string;
|
||||
detectedAt: string;
|
||||
}>;
|
||||
formStats: Array<{
|
||||
formName: string;
|
||||
formLabel: string;
|
||||
totalChecks: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
passRate: number;
|
||||
}>;
|
||||
llmFriendlyXml: string;
|
||||
}
|
||||
|
||||
/** 获取质控报告 */
|
||||
export async function getQcReport(
|
||||
projectId: string,
|
||||
format: 'json' | 'xml' = 'json'
|
||||
): Promise<QcReport | string> {
|
||||
const response = await apiClient.get(
|
||||
`${BASE_URL}/${projectId}/qc-cockpit/report`,
|
||||
{
|
||||
params: { format },
|
||||
responseType: format === 'xml' ? 'text' : 'json',
|
||||
}
|
||||
);
|
||||
return format === 'xml' ? response.data : response.data.data;
|
||||
}
|
||||
|
||||
/** 刷新质控报告 */
|
||||
export async function refreshQcReport(projectId: string): Promise<QcReport> {
|
||||
const response = await apiClient.post(
|
||||
`${BASE_URL}/${projectId}/qc-cockpit/report/refresh`
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -20,6 +20,7 @@ import SystemKbListPage from './pages/SystemKbListPage';
|
||||
import SystemKbDetailPage from './pages/SystemKbDetailPage';
|
||||
import IitProjectListPage from './pages/IitProjectListPage';
|
||||
import IitProjectDetailPage from './pages/IitProjectDetailPage';
|
||||
import IitQcCockpitPage from './pages/IitQcCockpitPage';
|
||||
|
||||
const AdminModule: React.FC = () => {
|
||||
return (
|
||||
@@ -42,6 +43,7 @@ const AdminModule: React.FC = () => {
|
||||
{/* IIT 项目管理 */}
|
||||
<Route path="iit-projects" element={<IitProjectListPage />} />
|
||||
<Route path="iit-projects/:id" element={<IitProjectDetailPage />} />
|
||||
<Route path="iit-projects/:id/cockpit" element={<IitQcCockpitPage />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
BookOutlined,
|
||||
ThunderboltOutlined,
|
||||
BarChartOutlined,
|
||||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type {
|
||||
@@ -183,8 +184,16 @@ const IitProjectDetailPage: React.FC = () => {
|
||||
</Space>
|
||||
{/* ⭐ 批量操作按钮 */}
|
||||
<Space>
|
||||
{/* ⭐ 质控全览图按钮 - 导航到驾驶舱页面 */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DashboardOutlined />}
|
||||
onClick={() => navigate(`/admin/iit-projects/${id}/cockpit`)}
|
||||
style={{ background: '#722ed1' }}
|
||||
>
|
||||
质控全览图
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={batchQcLoading}
|
||||
onClick={handleBatchQc}
|
||||
|
||||
680
frontend-v2/src/modules/admin/pages/IitQcCockpitPage.css
Normal file
680
frontend-v2/src/modules/admin/pages/IitQcCockpitPage.css
Normal file
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* 质控驾驶舱页面样式
|
||||
*/
|
||||
|
||||
.qc-cockpit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.qc-cockpit-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.qc-cockpit-loading,
|
||||
.qc-cockpit-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 顶部导航栏 */
|
||||
.qc-cockpit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qc-cockpit-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.qc-cockpit-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.qc-cockpit-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.qc-cockpit-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 统计卡片容器 */
|
||||
.qc-stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.qc-stat-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.qc-stat-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 单个统计卡片 */
|
||||
.qc-stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e8e8e8;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.qc-stat-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 可点击的卡片样式 */
|
||||
.qc-stat-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qc-stat-card.clickable:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.qc-stat-card.clickable:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.qc-stat-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qc-stat-card-arrow {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.qc-stat-card.clickable:hover .qc-stat-card-arrow {
|
||||
transform: translateX(4px);
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.qc-stat-card.critical {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-stat-card.warning {
|
||||
border-left: 4px solid #faad14;
|
||||
}
|
||||
|
||||
.qc-stat-card.success {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
.qc-stat-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.qc-stat-card-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qc-stat-card-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.qc-stat-card-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.qc-stat-card-value.critical {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-stat-card-value.warning {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.qc-stat-card-value.success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.qc-stat-card-suffix {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon.critical {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon.warning {
|
||||
background: #fffbe6;
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon.success {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon.info {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.qc-stat-card-footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.qc-stat-card-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.qc-stat-card-progress {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* 热力图容器 */
|
||||
.qc-heatmap {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e8e8e8;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.qc-heatmap-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.qc-heatmap-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot.pass {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot.warning {
|
||||
background: #faad14;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot.fail {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot.pending {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
|
||||
.qc-heatmap-body {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.qc-heatmap-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qc-heatmap-table th,
|
||||
.qc-heatmap-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.qc-heatmap-table th {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.qc-heatmap-table th.subject-header {
|
||||
text-align: left;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.qc-heatmap-table td.subject-cell {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qc-heatmap-table tr:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 热力图单元格 */
|
||||
.qc-heatmap-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon.pass {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon.warning {
|
||||
background: #faad14;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon.fail {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon.pending {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.qc-status-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.qc-status-tag.enrolled {
|
||||
background: #e6fffb;
|
||||
color: #13c2c2;
|
||||
}
|
||||
|
||||
.qc-status-tag.screening {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.qc-status-tag.completed {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
/* 抽屉样式 */
|
||||
.qc-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.qc-drawer-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qc-drawer-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qc-drawer-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.qc-drawer-subtitle {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.qc-drawer-content {
|
||||
display: flex;
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.qc-drawer-left {
|
||||
flex: 1;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.qc-drawer-right {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.qc-drawer-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.qc-drawer-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 数据字段展示 */
|
||||
.qc-field-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qc-field-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qc-field-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.qc-field-value {
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.qc-field-value.error {
|
||||
background: #fff1f0;
|
||||
border-color: #ffccc7;
|
||||
color: #cf1322;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qc-field-value.warning {
|
||||
background: #fffbe6;
|
||||
border-color: #ffe58f;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
/* 问题卡片 */
|
||||
.qc-issue-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qc-issue-card.critical {
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.qc-issue-card.warning {
|
||||
border-color: #ffe58f;
|
||||
}
|
||||
|
||||
.qc-issue-card-header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qc-issue-card-header.critical {
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.qc-issue-card-header.warning {
|
||||
background: #fffbe6;
|
||||
}
|
||||
|
||||
.qc-issue-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qc-issue-card-title.critical {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.qc-issue-card-title.warning {
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.qc-issue-card-confidence {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.qc-issue-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.qc-issue-card-description {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.qc-issue-card-evidence {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.qc-issue-card-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Tab 切换 */
|
||||
.qc-drawer-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.qc-drawer-tab {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.qc-drawer-tab:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.qc-drawer-tab.active {
|
||||
color: #1890ff;
|
||||
border-bottom-color: #1890ff;
|
||||
}
|
||||
|
||||
/* LLM Trace 样式 */
|
||||
.qc-llm-trace {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 16px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.qc-llm-trace-header {
|
||||
padding: 12px 16px;
|
||||
background: #252526;
|
||||
color: #999;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.qc-llm-trace-content {
|
||||
padding: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.xml-tag {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.xml-attr {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
.xml-value {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
.xml-content {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.xml-comment {
|
||||
color: #6a9955;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1024px) {
|
||||
.qc-drawer-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.qc-drawer-left,
|
||||
.qc-drawer-right {
|
||||
flex: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.qc-drawer-left {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
max-height: 50%;
|
||||
}
|
||||
}
|
||||
297
frontend-v2/src/modules/admin/pages/IitQcCockpitPage.tsx
Normal file
297
frontend-v2/src/modules/admin/pages/IitQcCockpitPage.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* IIT 质控驾驶舱页面 (QC Cockpit)
|
||||
*
|
||||
* 功能:
|
||||
* - 可视化质控全景图
|
||||
* - 统计卡片展示关键指标(可点击查看详情)
|
||||
* - 风险热力图(受试者 × 表单/访视 矩阵)
|
||||
* - 点击单元格查看详情(侧滑抽屉)
|
||||
*
|
||||
* @see docs/03-业务模块/IIT Manager Agent/01-需求分析/质控管理原型图.html
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Space,
|
||||
Spin,
|
||||
Empty,
|
||||
message,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Modal,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileExcelOutlined,
|
||||
FullscreenOutlined,
|
||||
FullscreenExitOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import QcStatCards from '../components/qc-cockpit/QcStatCards';
|
||||
import type { StatCardType } from '../components/qc-cockpit/QcStatCards';
|
||||
import RiskHeatmap from '../components/qc-cockpit/RiskHeatmap';
|
||||
import QcDetailDrawer from '../components/qc-cockpit/QcDetailDrawer';
|
||||
import QcReportDrawer from '../components/qc-cockpit/QcReportDrawer';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type { IitProject } from '../types/iitProject';
|
||||
import type { QcCockpitData, HeatmapCell } from '../types/qcCockpit';
|
||||
import './IitQcCockpitPage.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const IitQcCockpitPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 状态
|
||||
const [project, setProject] = useState<IitProject | null>(null);
|
||||
const [cockpitData, setCockpitData] = useState<QcCockpitData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
// 抽屉状态
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [selectedCell, setSelectedCell] = useState<HeatmapCell | null>(null);
|
||||
|
||||
// 问题列表模态框状态
|
||||
const [issueModalOpen, setIssueModalOpen] = useState(false);
|
||||
const [issueModalType, setIssueModalType] = useState<StatCardType | null>(null);
|
||||
|
||||
// 报告抽屉状态
|
||||
const [reportDrawerOpen, setReportDrawerOpen] = useState(false);
|
||||
|
||||
// 加载项目信息和质控数据
|
||||
const loadData = useCallback(async (showLoading = true) => {
|
||||
if (!id) return;
|
||||
|
||||
if (showLoading) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
|
||||
try {
|
||||
// 并行加载项目信息和驾驶舱数据
|
||||
const [projectData, cockpitResponse] = await Promise.all([
|
||||
iitProjectApi.getProject(id),
|
||||
iitProjectApi.getQcCockpitData(id),
|
||||
]);
|
||||
|
||||
setProject(projectData);
|
||||
setCockpitData(cockpitResponse);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 处理热力图单元格点击
|
||||
const handleCellClick = (cell: HeatmapCell) => {
|
||||
setSelectedCell(cell);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
// 处理统计卡片点击
|
||||
const handleStatCardClick = (type: StatCardType) => {
|
||||
setIssueModalType(type);
|
||||
setIssueModalOpen(true);
|
||||
};
|
||||
|
||||
// 获取问题列表模态框标题
|
||||
const getIssueModalTitle = (): string => {
|
||||
switch (issueModalType) {
|
||||
case 'critical': return '严重违规问题列表';
|
||||
case 'query': return '待确认问题列表';
|
||||
case 'deviation': return '方案偏离列表';
|
||||
default: return '问题列表';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取问题列表数据
|
||||
const getIssueListData = () => {
|
||||
if (!cockpitData?.stats.topIssues) return [];
|
||||
|
||||
type TopIssue = { issue: string; count: number; severity: 'critical' | 'warning' | 'info' };
|
||||
|
||||
switch (issueModalType) {
|
||||
case 'critical':
|
||||
return cockpitData.stats.topIssues.filter((i: TopIssue) => i.severity === 'critical');
|
||||
case 'query':
|
||||
return cockpitData.stats.topIssues.filter((i: TopIssue) => i.severity === 'warning');
|
||||
default:
|
||||
return cockpitData.stats.topIssues;
|
||||
}
|
||||
};
|
||||
|
||||
// 打开报告抽屉
|
||||
const handleViewReport = () => {
|
||||
setReportDrawerOpen(true);
|
||||
};
|
||||
|
||||
// 切换全屏
|
||||
const toggleFullscreen = () => {
|
||||
setFullscreen(!fullscreen);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="qc-cockpit-loading">
|
||||
<Spin size="large" />
|
||||
<Text type="secondary" style={{ marginTop: 16 }}>加载质控数据中...</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project || !cockpitData) {
|
||||
return (
|
||||
<div className="qc-cockpit-empty">
|
||||
<Empty description="项目不存在或暂无质控数据">
|
||||
<Button onClick={() => navigate('/admin/iit-projects')}>返回列表</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`qc-cockpit ${fullscreen ? 'qc-cockpit-fullscreen' : ''}`}>
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="qc-cockpit-header">
|
||||
<div className="qc-cockpit-header-left">
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate(`/admin/iit-projects/${id}`)}
|
||||
>
|
||||
返回配置
|
||||
</Button>
|
||||
<div className="qc-cockpit-title">
|
||||
<Title level={4} style={{ margin: 0 }}>CRA 智能监查驾驶舱</Title>
|
||||
<Text type="secondary">
|
||||
项目: {project.name} | 最后同步: {
|
||||
project.lastSyncAt
|
||||
? new Date(project.lastSyncAt).toLocaleString()
|
||||
: '从未同步'
|
||||
}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="qc-cockpit-header-right">
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FileExcelOutlined />}
|
||||
onClick={handleViewReport}
|
||||
>
|
||||
查看质控报告
|
||||
</Button>
|
||||
<Tooltip title={fullscreen ? '退出全屏' : '全屏模式'}>
|
||||
<Button
|
||||
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="qc-cockpit-content">
|
||||
{/* 统计卡片 - 可点击查看详情 */}
|
||||
<QcStatCards
|
||||
stats={cockpitData.stats}
|
||||
onCardClick={handleStatCardClick}
|
||||
/>
|
||||
|
||||
{/* 风险热力图 */}
|
||||
<RiskHeatmap
|
||||
data={cockpitData.heatmap}
|
||||
onCellClick={handleCellClick}
|
||||
loading={refreshing}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* 详情抽屉 */}
|
||||
<QcDetailDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
cell={selectedCell}
|
||||
projectId={id!}
|
||||
projectName={project.name}
|
||||
/>
|
||||
|
||||
{/* 报告抽屉 */}
|
||||
<QcReportDrawer
|
||||
open={reportDrawerOpen}
|
||||
onClose={() => setReportDrawerOpen(false)}
|
||||
projectId={id!}
|
||||
projectName={project.name}
|
||||
/>
|
||||
|
||||
{/* 问题列表模态框 */}
|
||||
<Modal
|
||||
title={getIssueModalTitle()}
|
||||
open={issueModalOpen}
|
||||
onCancel={() => setIssueModalOpen(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
<Table
|
||||
dataSource={getIssueListData()}
|
||||
rowKey="issue"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '问题描述',
|
||||
dataIndex: 'issue',
|
||||
key: 'issue',
|
||||
render: (text: string, record: any) => (
|
||||
<Space>
|
||||
{record.severity === 'critical' ? (
|
||||
<ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||
) : (
|
||||
<WarningOutlined style={{ color: '#faad14' }} />
|
||||
)}
|
||||
<span>{text}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (count: number) => (
|
||||
<Tag color={count > 10 ? 'red' : count > 5 ? 'orange' : 'default'}>
|
||||
{count}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '严重程度',
|
||||
dataIndex: 'severity',
|
||||
key: 'severity',
|
||||
width: 100,
|
||||
render: (severity: string) => (
|
||||
<Tag color={severity === 'critical' ? 'red' : 'orange'}>
|
||||
{severity === 'critical' ? '严重' : '警告'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
locale={{ emptyText: '暂无问题' }}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IitQcCockpitPage;
|
||||
116
frontend-v2/src/modules/admin/types/qcCockpit.ts
Normal file
116
frontend-v2/src/modules/admin/types/qcCockpit.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 质控驾驶舱相关类型定义
|
||||
*/
|
||||
|
||||
// 统计数据
|
||||
export interface QcStats {
|
||||
/** 总体数据质量分 (0-100) */
|
||||
qualityScore: number;
|
||||
/** 总记录数 */
|
||||
totalRecords: number;
|
||||
/** 通过记录数 */
|
||||
passedRecords: number;
|
||||
/** 失败记录数 */
|
||||
failedRecords: number;
|
||||
/** 警告记录数 */
|
||||
warningRecords: number;
|
||||
/** 待检查记录数 */
|
||||
pendingRecords: number;
|
||||
/** 严重违规数(Critical) */
|
||||
criticalCount: number;
|
||||
/** 待确认 Query 数(Major) */
|
||||
queryCount: number;
|
||||
/** 方案偏离数(PD) */
|
||||
deviationCount: number;
|
||||
/** 通过率 */
|
||||
passRate: number;
|
||||
/** 主要问题 */
|
||||
topIssues?: Array<{
|
||||
issue: string;
|
||||
count: number;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
}>;
|
||||
}
|
||||
|
||||
// 热力图数据
|
||||
export interface HeatmapData {
|
||||
/** 行标题(表单/访视名称) */
|
||||
columns: string[];
|
||||
/** 行数据 */
|
||||
rows: HeatmapRow[];
|
||||
}
|
||||
|
||||
export interface HeatmapRow {
|
||||
/** 受试者 ID */
|
||||
recordId: string;
|
||||
/** 入组状态 */
|
||||
status: 'enrolled' | 'screening' | 'completed' | 'withdrawn';
|
||||
/** 各表单/访视的质控状态 */
|
||||
cells: HeatmapCell[];
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
/** 表单/访视名称 */
|
||||
formName: string;
|
||||
/** 质控状态 */
|
||||
status: 'pass' | 'warning' | 'fail' | 'pending';
|
||||
/** 问题数量 */
|
||||
issueCount: number;
|
||||
/** 受试者 ID(冗余,方便查询) */
|
||||
recordId: string;
|
||||
/** 问题摘要 */
|
||||
issues?: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
}>;
|
||||
}
|
||||
|
||||
// 完整的驾驶舱数据
|
||||
export interface QcCockpitData {
|
||||
/** 统计数据 */
|
||||
stats: QcStats;
|
||||
/** 热力图数据 */
|
||||
heatmap: HeatmapData;
|
||||
/** 最后更新时间 */
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
// 记录详情
|
||||
export interface RecordDetail {
|
||||
recordId: string;
|
||||
formName: string;
|
||||
status: 'pass' | 'warning' | 'fail' | 'pending';
|
||||
/** 表单数据 */
|
||||
data: Record<string, any>;
|
||||
/** 字段元数据 */
|
||||
fieldMetadata?: Record<string, {
|
||||
label: string;
|
||||
type: string;
|
||||
normalRange?: { min?: number; max?: number };
|
||||
}>;
|
||||
/** 质控问题 */
|
||||
issues: Array<{
|
||||
field: string;
|
||||
ruleName: string;
|
||||
message: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
actualValue?: any;
|
||||
expectedValue?: string;
|
||||
confidence?: 'high' | 'medium' | 'low';
|
||||
}>;
|
||||
/** LLM Trace(调试用) */
|
||||
llmTrace?: {
|
||||
promptSent: string;
|
||||
responseReceived: string;
|
||||
model: string;
|
||||
latencyMs: number;
|
||||
};
|
||||
/** 录入时间 */
|
||||
entryTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface QcCockpitResponse extends QcCockpitData {}
|
||||
|
||||
export interface RecordDetailResponse extends RecordDetail {}
|
||||
Reference in New Issue
Block a user