feat(iit): Complete V3.1 QC engine + GCP business reports + AI timeline + bug fixes

V3.1 QC Engine:
- QcExecutor unified entry + D1-D7 dimension engines + three-level aggregation
- HealthScoreEngine + CompletenessEngine + ProtocolDeviationEngine + QcAggregator
- B4 flexible cron scheduling (project-level cronExpression + pg-boss dispatcher)
- Prisma migrations for qc_field_status, event_status, project_stats

GCP Business Reports (Phase A - 4 reports):
- D1 Eligibility: record_summary full list + qc_field_status D1 overlay
- D2 Completeness: data entry rate and missing rate aggregation
- D3/D4 Query Tracking: severity distribution from qc_field_status
- D6 Protocol Deviation: D6 dimension filtering
- 4 frontend table components + ReportsPage 5-tab restructure

AI Timeline Enhancement:
- SkillRunner outputs totalRules (33 actual rules vs 1 skill)
- iitQcCockpitController severity mapping fix (critical->red, warning->yellow)
- AiStreamPage expandable issue detail table with Chinese labels
- Event label localization (eventLabel from backend)

Business-side One-click Batch QC:
- DashboardPage batch QC button with SyncOutlined icon
- Auto-refresh QcReport cache after batch execution

Bug Fixes:
- dimension_code -> rule_category in 4 SQL queries
- D1 eligibility data source: record_summary full + qc_field_status overlay
- Timezone UTC -> Asia/Shanghai (QcReportService toBeijingTime helper)
- Pass rate calculation: passed/totalEvents instead of passed/totalRecords

Docs:
- Update IIT module status with GCP reports and bug fix milestones
- Update system status doc v6.6 with IIT progress

Tested: Backend compiles, frontend linter clean, batch QC verified
Made-with: Cursor
This commit is contained in:
2026-03-01 22:49:49 +08:00
parent 0b29fe88b5
commit 2030ebe28f
50 changed files with 8687 additions and 1492 deletions

View File

@@ -318,19 +318,55 @@ export async function getQcCockpitData(projectId: string): Promise<QcCockpitData
return response.data.data;
}
/** 获取记录质控详情 */
/** 获取记录质控详情 (V3.1: 支持 eventId) */
export async function getQcRecordDetail(
projectId: string,
recordId: string,
formName: string
eventId?: string
): Promise<RecordDetail> {
const response = await apiClient.get(
`${BASE_URL}/${projectId}/qc-cockpit/records/${recordId}`,
{ params: { formName } }
{ params: eventId ? { eventId } : {} }
);
return response.data.data;
}
/** V3.1: 获取 D6 方案偏离列表 */
export async function getDeviations(projectId: string): Promise<any[]> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/deviations`);
return response.data.data;
}
/** V3.1: 获取 D1-D7 维度分析 */
export async function getDimensions(projectId: string): Promise<{
healthScore: number;
healthGrade: string;
dimensions: Array<{ code: string; label: string; passRate: number }>;
}> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/dimensions`);
return response.data.data;
}
/** V3.1: 获取按受试者完整性 */
export async function getCompleteness(projectId: string): Promise<Array<{
recordId: string;
fieldsTotal: number;
fieldsFilled: number;
fieldsMissing: number;
missingRate: number;
}>> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/completeness`);
return response.data.data;
}
/** V3.1: 获取字段级质控结果 */
export async function getFieldStatus(projectId: string, params?: {
recordId?: string; eventId?: string; status?: string; page?: number; pageSize?: number;
}): Promise<{ items: any[]; total: number; page: number; pageSize: number }> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/field-status`, { params });
return response.data.data;
}
/** 质控报告类型 */
export interface QcReport {
projectId: string;

View File

@@ -1,7 +1,7 @@
/**
* 质控详情抽屉组件
* 质控详情抽屉组件 (V3.1)
*
* 展示受试者某个表单/访视的详细质控信息
* 展示受试者某个事件的详细质控信息
* - 左侧:真实数据 (Source of Truth)
* - 右侧AI 诊断报告 + LLM Trace
*/
@@ -29,6 +29,7 @@ interface QcDetailDrawerProps {
cell: HeatmapCell | null;
projectId: string;
projectName: string;
eventLabel?: string;
}
const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
@@ -37,12 +38,12 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
cell,
projectId,
projectName,
eventLabel,
}) => {
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<RecordDetail | null>(null);
const [activeTab, setActiveTab] = useState<'report' | 'trace'>('report');
// 加载详情
useEffect(() => {
if (open && cell) {
loadDetail();
@@ -56,7 +57,7 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
const data = await iitProjectApi.getQcRecordDetail(
projectId,
cell.recordId,
cell.formName
cell.eventId || cell.formName
);
setDetail(data);
} catch (error: any) {
@@ -84,8 +85,19 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
if (!cell) return null;
// 从 recordId 提取数字部分作为头像
const avatarNum = cell.recordId.replace(/\D/g, '').slice(-2) || '00';
const displayEventName = eventLabel || cell.eventId || cell.formName || '-';
const DIMENSION_NAMES: Record<string, string> = {
D1: '入排一致性', D2: '数据完整性', D3: '数据准确性',
D4: 'Query 响应', D5: '时效性', D6: '方案依从', D7: '安全性',
};
const getIssueTitle = (issue: RecordDetail['issues'][0]) => {
const dim = issue.dimensionCode ? `[${issue.dimensionCode}] ${DIMENSION_NAMES[issue.dimensionCode] || ''}` : '';
if (issue.severity === 'critical') return <><ExclamationCircleOutlined /> {dim || '严重违规'} (Critical)</>;
return <><WarningOutlined /> {dim || '数据质量警告'}</>;
};
return (
<Drawer
@@ -97,7 +109,6 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
closable={false}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
>
{/* 抽屉头部 */}
<div style={{
padding: '16px 24px',
borderBottom: '1px solid #f0f0f0',
@@ -111,13 +122,16 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
<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}`}
访: {displayEventName} |
: {projectName}
{detail?.entryTime && ` | 录入时间: ${new Date(detail.entryTime).toLocaleString()}`}
</div>
</div>
</div>
<Space>
<Tag color={cell.issueCount > 0 ? 'red' : 'green'}>
{cell.issueCount > 0 ? `${cell.issueCount} 个问题` : '通过'}
</Tag>
<Tooltip title="在 REDCap 中查看">
<Button icon={<ExportOutlined />} onClick={handleOpenRedcap}>
REDCap
@@ -127,26 +141,23 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
</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})
({displayEventName})
<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;
const label = issue?.fieldLabel || fieldMeta?.label || key;
return (
<div key={key} className="qc-field-item">
@@ -164,12 +175,8 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
</div>
{issue && (
<div style={{ fontSize: 12, color: '#ff4d4f', marginTop: 4 }}>
{issue.dimensionCode && <Tag color="blue" style={{ fontSize: 10, marginRight: 4 }}>{issue.dimensionCode}</Tag>}
{issue.message}
{fieldMeta?.normalRange && (
<span style={{ marginLeft: 8, color: '#999' }}>
: {fieldMeta.normalRange.min ?? '-'} ~ {fieldMeta.normalRange.max ?? '-'}
</span>
)}
</div>
)}
</div>
@@ -179,9 +186,7 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
</div>
</div>
{/* 右栏AI 诊断 + Trace */}
<div className="qc-drawer-right">
{/* Tab 切换 */}
<div className="qc-drawer-tabs">
<div
className={`qc-drawer-tab ${activeTab === 'report' ? 'active' : ''}`}
@@ -199,7 +204,6 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
</div>
</div>
{/* Tab 内容 */}
{activeTab === 'report' ? (
<div style={{ padding: 16, overflowY: 'auto' }}>
{detail.issues.length > 0 ? (
@@ -210,11 +214,7 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
>
<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 /> </>
)}
{getIssueTitle(issue)}
</span>
<span className="qc-issue-card-confidence">
: {issue.confidence || 'High'}
@@ -227,11 +227,12 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
<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>
<li>Variable: <code>{issue.fieldLabel || issue.field}</code> = {String(issue.actualValue ?? '(空)')}</li>
{issue.expectedValue && (
<li>Standard: {issue.expectedValue}</li>
)}
<li>Form: {cell.formName}</li>
<li>Event: {displayEventName}</li>
{issue.dimensionCode && <li>Dimension: {issue.dimensionCode} ({DIMENSION_NAMES[issue.dimensionCode] || ''})</li>}
</ul>
</div>
</div>
@@ -270,7 +271,6 @@ const QcDetailDrawer: React.FC<QcDetailDrawerProps> = ({
)}
</div>
) : (
/* LLM Trace Tab */
<div className="qc-llm-trace">
<div className="qc-llm-trace-header">
<span>Prompt Context (Sent to DeepSeek-V3)</span>

View File

@@ -1,11 +1,13 @@
/**
* 质控报告抽屉组件
* 质控报告抽屉组件 (V3.1)
*
* 功能:
* - 展示质控报告摘要
* - 展示严重问题和警告问题列表
* - 展示表单统计
* - 支持导出 XML 格式报告
* Tabs:
* 1. 摘要 — 总记录/通过率/问题数
* 2. 维度分析 (D1-D7) — 各维度通过率 + 健康度
* 3. 事件概览 — 按受试者缺失率
* 4. 严重问题
* 5. 警告问题
* 6. 表单统计
*/
import React, { useState, useEffect } from 'react';
@@ -35,6 +37,8 @@ import {
CheckCircleOutlined,
FileTextOutlined,
BarChartOutlined,
DashboardOutlined,
TeamOutlined,
} from '@ant-design/icons';
import type { QcReport } from '../../api/iitProjectApi';
import * as iitProjectApi from '../../api/iitProjectApi';
@@ -49,6 +53,11 @@ interface QcReportDrawerProps {
projectName: string;
}
const DIMENSION_NAMES: Record<string, string> = {
D1: '入排一致性', D2: '数据完整性', D3: '数据准确性',
D4: 'Query 响应', D5: '时效性', D6: '方案依从', D7: '安全性',
};
const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
open,
onClose,
@@ -59,7 +68,17 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
const [refreshing, setRefreshing] = useState(false);
const [report, setReport] = useState<QcReport | null>(null);
// 加载报告
const [dimensionsLoading, setDimensionsLoading] = useState(false);
const [dimensions, setDimensions] = useState<{
healthScore: number; healthGrade: string;
dimensions: Array<{ code: string; label: string; passRate: number }>;
} | null>(null);
const [completenessLoading, setCompletenessLoading] = useState(false);
const [completeness, setCompleteness] = useState<Array<{
recordId: string; fieldsTotal: number; fieldsFilled: number; fieldsMissing: number; missingRate: number;
}>>([]);
const loadReport = async (forceRefresh = false) => {
if (forceRefresh) {
setRefreshing(true);
@@ -84,31 +103,47 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
}
};
// 导出 XML 报告(导出前自动刷新,确保获取最新数据)
const loadDimensions = async () => {
setDimensionsLoading(true);
try {
const data = await iitProjectApi.getDimensions(projectId);
setDimensions(data);
} catch {
setDimensions(null);
} finally {
setDimensionsLoading(false);
}
};
const loadCompleteness = async () => {
setCompletenessLoading(true);
try {
const data = await iitProjectApi.getCompleteness(projectId);
setCompleteness(data || []);
} catch {
setCompleteness([]);
} finally {
setCompletenessLoading(false);
}
};
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 格式
const timeStr = now.toTimeString().slice(0, 5).replace(':', '');
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' });
@@ -118,25 +153,18 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
useEffect(() => {
if (open) {
loadReport();
loadDimensions();
loadCompleteness();
}
}, [open, projectId]);
// 渲染摘要
const renderSummary = () => {
if (!report) return null;
const { summary } = report;
// V2.1: 防护空值
if (!summary) {
return (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无摘要数据,请尝试刷新报告"
/>
);
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无摘要数据,请尝试刷新报告" />;
}
// 安全获取数值
const totalRecords = summary.totalRecords ?? 0;
const passRate = summary.passRate ?? 0;
const criticalIssues = summary.criticalIssues ?? 0;
@@ -148,11 +176,7 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
<Row gutter={[16, 16]}>
<Col span={6}>
<Card size="small">
<Statistic
title="总记录数"
value={totalRecords}
valueStyle={{ color: '#1890ff' }}
/>
<Statistic title="总记录数" value={totalRecords} valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
<Col span={6}>
@@ -214,7 +238,110 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
);
};
// 渲染问题列表
const renderDimensions = () => {
if (dimensionsLoading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
if (!dimensions) return <Empty description="暂无维度分析数据" />;
const getGradeColor = (grade: string) => {
if (grade === 'A') return '#52c41a';
if (grade === 'B') return '#1890ff';
if (grade === 'C') return '#faad14';
return '#ff4d4f';
};
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={12}>
<Card size="small">
<Statistic
title="健康度评分"
value={dimensions.healthScore}
suffix="/ 100"
valueStyle={{ color: getGradeColor(dimensions.healthGrade) }}
/>
</Card>
</Col>
<Col span={12}>
<Card size="small">
<Statistic
title="健康等级"
value={dimensions.healthGrade}
valueStyle={{
color: getGradeColor(dimensions.healthGrade),
fontSize: 36,
fontWeight: 'bold',
}}
/>
</Card>
</Col>
</Row>
<Card title="D1-D7 维度通过率" size="small">
{(dimensions.dimensions || []).map(d => {
const label = DIMENSION_NAMES[d.code] || d.label || d.code;
const color = d.passRate >= 90 ? '#52c41a' : d.passRate >= 70 ? '#faad14' : '#ff4d4f';
return (
<div key={d.code} style={{ marginBottom: 12 }}>
<Row justify="space-between">
<Col><Text strong>{d.code} {label}</Text></Col>
<Col><Text style={{ color }}>{d.passRate.toFixed(1)}%</Text></Col>
</Row>
<Progress
percent={d.passRate}
showInfo={false}
strokeColor={color}
size="small"
/>
</div>
);
})}
{(!dimensions.dimensions || dimensions.dimensions.length === 0) && (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无维度数据" />
)}
</Card>
</div>
);
};
const renderCompleteness = () => {
if (completenessLoading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
if (!completeness.length) return <Empty description="暂无完整性数据" />;
const columns = [
{ title: '受试者 ID', dataIndex: 'recordId', key: 'recordId', width: 120 },
{ title: '总字段数', dataIndex: 'fieldsTotal', key: 'fieldsTotal', width: 100, align: 'center' as const },
{ title: '已填写', dataIndex: 'fieldsFilled', key: 'fieldsFilled', width: 80, align: 'center' as const,
render: (v: number) => <Text type="success">{v}</Text> },
{ title: '缺失', dataIndex: 'fieldsMissing', key: 'fieldsMissing', width: 80, align: 'center' as const,
render: (v: number) => v > 0 ? <Text type="danger">{v}</Text> : <Text>{v}</Text> },
{
title: '缺失率',
dataIndex: 'missingRate',
key: 'missingRate',
width: 160,
render: (rate: number) => (
<Progress
percent={100 - rate}
size="small"
format={() => `${rate}%`}
status={rate > 20 ? 'exception' : rate > 10 ? 'normal' : 'success'}
/>
),
},
];
return (
<Table
dataSource={completeness}
columns={columns}
rowKey="recordId"
size="small"
pagination={{ pageSize: 15, showTotal: (t) => `${t}` }}
/>
);
};
const renderIssues = (issues: QcReport['criticalIssues'] | QcReport['warningIssues'], type: 'critical' | 'warning') => {
const columns = [
{
@@ -237,9 +364,7 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
key: 'message',
ellipsis: true,
render: (text: string) => (
<Tooltip title={text}>
<span>{text}</span>
</Tooltip>
<Tooltip title={text}><span>{text}</span></Tooltip>
),
},
{
@@ -277,39 +402,20 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
);
};
// 渲染表单统计
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: '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,
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,
title: '失败', dataIndex: 'failed', key: 'failed', width: 80, align: 'center' as const,
render: (text: number) => <Text type="danger">{text}</Text>,
},
{
@@ -347,7 +453,7 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
</Space>
}
placement="right"
width={800}
width={850}
open={open}
onClose={onClose}
extra={
@@ -379,16 +485,23 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
) : report ? (
<Tabs defaultActiveKey="summary">
<TabPane
tab={
<span>
<BarChartOutlined />
</span>
}
tab={<span><BarChartOutlined /> </span>}
key="summary"
>
{renderSummary()}
</TabPane>
<TabPane
tab={<span><DashboardOutlined /> </span>}
key="dimensions"
>
{renderDimensions()}
</TabPane>
<TabPane
tab={<span><TeamOutlined /> </span>}
key="completeness"
>
{renderCompleteness()}
</TabPane>
<TabPane
tab={
<span>
@@ -422,12 +535,7 @@ const QcReportDrawer: React.FC<QcReportDrawerProps> = ({
{renderIssues(report.warningIssues ?? [], 'warning')}
</TabPane>
<TabPane
tab={
<span>
<CheckCircleOutlined />
</span>
}
tab={<span><CheckCircleOutlined /> </span>}
key="forms"
>
{renderFormStats()}

View File

@@ -1,7 +1,7 @@
/**
* 风险热力图组件
* 风险热力图组件 (V3.1)
*
* 展示受试者 × 表单/访视 矩阵
* 展示受试者 × 事件 矩阵
* - 绿色圆点:通过
* - 黄色图标:警告(可点击)
* - 红色图标:严重(可点击)
@@ -11,7 +11,6 @@
import React from 'react';
import { Spin, Tag, Tooltip } from 'antd';
import {
CheckOutlined,
ExclamationOutlined,
CloseOutlined,
} from '@ant-design/icons';
@@ -24,6 +23,8 @@ interface RiskHeatmapProps {
}
const RiskHeatmap: React.FC<RiskHeatmapProps> = ({ data, onCellClick, loading }) => {
const columnLabels = data.columnLabels || {};
const getStatusTag = (status: string) => {
switch (status) {
case 'enrolled':
@@ -39,13 +40,16 @@ const RiskHeatmap: React.FC<RiskHeatmapProps> = ({ data, onCellClick, loading })
}
};
const getColumnLabel = (col: string): string => {
if (columnLabels[col]) return columnLabels[col];
if (col.includes('(')) return col.split('(')[0];
return col;
};
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'
@@ -61,7 +65,6 @@ const RiskHeatmap: React.FC<RiskHeatmapProps> = ({ data, onCellClick, loading })
);
}
// 通过或待检查:显示小圆点(也可点击)
const dotClass = `qc-heatmap-cell-dot ${cell.status === 'pass' ? 'pass' : 'pending'}`;
const tooltipText = cell.status === 'pass'
? '已通过,点击查看数据'
@@ -83,7 +86,6 @@ const RiskHeatmap: React.FC<RiskHeatmapProps> = ({ data, onCellClick, loading })
return (
<div className="qc-heatmap">
{/* 头部 */}
<div className="qc-heatmap-header">
<h3 className="qc-heatmap-title"> (Risk Heatmap)</h3>
<div className="qc-heatmap-legend">
@@ -106,7 +108,6 @@ const RiskHeatmap: React.FC<RiskHeatmapProps> = ({ data, onCellClick, loading })
</div>
</div>
{/* 表格内容 */}
<div className="qc-heatmap-body">
<Spin spinning={loading}>
<table className="qc-heatmap-table">
@@ -114,14 +115,16 @@ const RiskHeatmap: React.FC<RiskHeatmapProps> = ({ data, onCellClick, loading })
<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>
))}
{data.columns.map((col, idx) => {
const label = getColumnLabel(col);
return (
<th key={idx}>
<Tooltip title={col !== label ? col : undefined}>
<span>{label}</span>
</Tooltip>
</th>
);
})}
</tr>
</thead>
<tbody>

View File

@@ -247,8 +247,8 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
redcapUrl: project.redcapUrl,
redcapProjectId: project.redcapProjectId,
redcapApiToken: project.redcapApiToken,
cronEnabled: (project as any).cronEnabled ?? false,
cronExpression: (project as any).cronExpression ?? '0 8 * * *',
cronEnabled: project.cronEnabled ?? false,
cronExpression: project.cronExpression ?? '0 8 * * *',
});
}, [form, project]);
@@ -331,20 +331,40 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
<Form.Item name="cronEnabled" valuePropName="checked" noStyle>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.cronEnabled !== cur.cronEnabled}>
{() => form.getFieldValue('cronEnabled') ? (
<Form.Item name="cronExpression" style={{ marginBottom: 0, marginTop: 8 }}>
<Select
style={{ width: 300 }}
options={[
{ value: '0 8 * * *', label: '每天 08:00' },
{ value: '0 9 * * 1', label: '每周一 09:00' },
{ value: '0 8 * * 1,3,5', label: '每周一三五 08:00' },
{ value: 'custom', label: '自定义 Cron 表达式' },
]}
/>
</Form.Item>
) : null}
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.cronEnabled !== cur.cronEnabled || prev.cronExpression !== cur.cronExpression}>
{() => {
if (!form.getFieldValue('cronEnabled')) return null;
const cronVal = form.getFieldValue('cronExpression') || '0 8 * * *';
const presets = ['0 8 * * *', '0 9 * * 1', '0 8 * * 1,3,5', '0 20 * * *', '0 8,14 * * *'];
const isCustom = !presets.includes(cronVal) && cronVal !== 'custom';
return (
<Space direction="vertical" style={{ width: '100%', marginTop: 8 }}>
<Form.Item name="cronExpression" style={{ marginBottom: 0 }}>
<Select
style={{ width: 360 }}
options={[
{ value: '0 8 * * *', label: '🕗 每天 08:00' },
{ value: '0 20 * * *', label: '🕗 每天 20:00' },
{ value: '0 8,14 * * *', label: '🕗 每天 08:00 + 14:00两次' },
{ value: '0 9 * * 1', label: '📅 每周一 09:00' },
{ value: '0 8 * * 1,3,5', label: '📅 每周一三五 08:00' },
{ value: 'custom', label: '✏️ 自定义 Cron 表达式...' },
]}
/>
</Form.Item>
{(cronVal === 'custom' || isCustom) && (
<Form.Item
name="cronExpression"
style={{ marginBottom: 0 }}
rules={[{ pattern: /^[\d,\-\*\/\s]+$/, message: 'Cron 格式不正确' }]}
extra="格式:分 时 日 月 周(如 0 8 * * 1-5 = 工作日 08:00"
>
<Input placeholder="0 8 * * *" style={{ width: 360 }} />
</Form.Item>
)}
</Space>
);
}}
</Form.Item>
</Space>
</Form.Item>

View File

@@ -1,13 +1,5 @@
/**
* IIT 质控驾驶舱页面 (QC Cockpit)
*
* 功能:
* - 可视化质控全景图
* - 统计卡片展示关键指标(可点击查看详情)
* - 风险热力图(受试者 × 表单/访视 矩阵)
* - 点击单元格查看详情(侧滑抽屉)
*
* @see docs/03-业务模块/IIT Manager Agent/01-需求分析/质控管理原型图.html
* IIT 质控驾驶舱页面 (QC Cockpit) — V3.1
*/
import React, { useState, useEffect, useCallback } from 'react';
@@ -39,7 +31,7 @@ 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 type { QcCockpitData, HeatmapCell, DeviationItem } from '../types/qcCockpit';
import './IitQcCockpitPage.css';
const { Title, Text } = Typography;
@@ -48,25 +40,23 @@ 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 [deviations, setDeviations] = useState<DeviationItem[]>([]);
const [deviationsLoading, setDeviationsLoading] = useState(false);
const [reportDrawerOpen, setReportDrawerOpen] = useState(false);
// 加载项目信息和质控数据
const loadData = useCallback(async (showLoading = true) => {
if (!id) return;
@@ -74,7 +64,6 @@ const IitQcCockpitPage: React.FC = () => {
else setRefreshing(true);
try {
// 并行加载项目信息和驾驶舱数据
const [projectData, cockpitResponse] = await Promise.all([
iitProjectApi.getProject(id),
iitProjectApi.getQcCockpitData(id),
@@ -94,29 +83,38 @@ const IitQcCockpitPage: React.FC = () => {
loadData();
}, [loadData]);
// 处理热力图单元格点击
const handleCellClick = (cell: HeatmapCell) => {
setSelectedCell(cell);
setDrawerOpen(true);
};
// 处理统计卡片点击
const handleStatCardClick = (type: StatCardType) => {
const handleStatCardClick = async (type: StatCardType) => {
setIssueModalType(type);
setIssueModalOpen(true);
if (type === 'deviation' && id) {
setDeviationsLoading(true);
try {
const data = await iitProjectApi.getDeviations(id);
setDeviations(data || []);
} catch (e: any) {
message.error('加载方案偏离数据失败');
setDeviations([]);
} finally {
setDeviationsLoading(false);
}
}
};
// 获取问题列表模态框标题
const getIssueModalTitle = (): string => {
switch (issueModalType) {
case 'critical': return '严重违规问题列表';
case 'query': return '待确认问题列表';
case 'deviation': return '方案偏离列表';
case 'deviation': return '方案偏离列表 (D6)';
default: return '问题列表';
}
};
// 获取问题列表数据
const getIssueListData = () => {
if (!cockpitData?.stats.topIssues) return [];
@@ -132,16 +130,20 @@ const IitQcCockpitPage: React.FC = () => {
}
};
// 打开报告抽屉
const handleViewReport = () => {
setReportDrawerOpen(true);
};
// 切换全屏
const toggleFullscreen = () => {
setFullscreen(!fullscreen);
};
const selectedEventLabel = (() => {
if (!selectedCell || !cockpitData?.heatmap.columnLabels) return undefined;
const eventId = selectedCell.eventId || selectedCell.formName || '';
return eventId ? cockpitData.heatmap.columnLabels[eventId] : undefined;
})();
if (loading) {
return (
<div className="qc-cockpit-loading">
@@ -161,9 +163,56 @@ const IitQcCockpitPage: React.FC = () => {
);
}
const deviationColumns = [
{
title: '受试者 ID',
dataIndex: 'recordId',
key: 'recordId',
width: 100,
},
{
title: '事件',
dataIndex: 'eventId',
key: 'eventId',
width: 150,
render: (eventId: string) => {
const label = cockpitData?.heatmap.columnLabels?.[eventId];
return label || eventId;
},
},
{
title: '字段',
key: 'field',
width: 150,
render: (_: any, record: DeviationItem) => record.fieldLabel || record.fieldName,
},
{
title: '问题描述',
dataIndex: 'message',
key: 'message',
ellipsis: true,
},
{
title: '严重程度',
dataIndex: 'severity',
key: 'severity',
width: 90,
render: (severity: string) => {
const color = severity === 'critical' ? 'red' : severity === 'warning' ? 'orange' : 'blue';
return <Tag color={color}>{severity}</Tag>;
},
},
{
title: '发现时间',
dataIndex: 'detectedAt',
key: 'detectedAt',
width: 160,
render: (t: string | null) => t ? new Date(t).toLocaleString() : '-',
},
];
return (
<div className={`qc-cockpit ${fullscreen ? 'qc-cockpit-fullscreen' : ''}`}>
{/* 顶部导航栏 */}
<header className="qc-cockpit-header">
<div className="qc-cockpit-header-left">
<Button
@@ -202,15 +251,12 @@ const IitQcCockpitPage: React.FC = () => {
</div>
</header>
{/* 主内容区 */}
<main className="qc-cockpit-content">
{/* 统计卡片 - 可点击查看详情 */}
<QcStatCards
stats={cockpitData.stats}
onCardClick={handleStatCardClick}
/>
{/* 风险热力图 */}
<RiskHeatmap
data={cockpitData.heatmap}
onCellClick={handleCellClick}
@@ -218,16 +264,15 @@ const IitQcCockpitPage: React.FC = () => {
/>
</main>
{/* 详情抽屉 */}
<QcDetailDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
cell={selectedCell}
projectId={id!}
projectName={project.name}
eventLabel={selectedEventLabel}
/>
{/* 报告抽屉 */}
<QcReportDrawer
open={reportDrawerOpen}
onClose={() => setReportDrawerOpen(false)}
@@ -235,60 +280,71 @@ const IitQcCockpitPage: React.FC = () => {
projectName={project.name}
/>
{/* 问题列表模态框 */}
<Modal
title={getIssueModalTitle()}
open={issueModalOpen}
onCancel={() => setIssueModalOpen(false)}
onCancel={() => { setIssueModalOpen(false); setDeviations([]); }}
footer={null}
width={700}
width={issueModalType === 'deviation' ? 900 : 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: '暂无问题' }}
/>
{issueModalType === 'deviation' ? (
<Table
dataSource={deviations}
rowKey={(r, i) => `${r.recordId}-${r.eventId}-${r.fieldName}-${i}`}
loading={deviationsLoading}
pagination={{ pageSize: 20, showTotal: (t) => `${t} 条方案偏离` }}
columns={deviationColumns}
size="small"
locale={{ emptyText: '暂无方案偏离' }}
/>
) : (
<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>
);

View File

@@ -16,6 +16,8 @@ export interface IitProject {
tenantName?: string;
tenantCode?: string;
isDemo?: boolean;
cronEnabled?: boolean;
cronExpression?: string;
status: string;
lastSyncAt?: string;
createdAt: string;
@@ -56,6 +58,8 @@ export interface UpdateProjectRequest {
knowledgeBaseId?: string;
status?: string;
isDemo?: boolean;
cronEnabled?: boolean;
cronExpression?: string;
}
export interface TestConnectionRequest {

View File

@@ -1,11 +1,22 @@
/**
* 质控驾驶舱相关类型定义
* 质控驾驶舱相关类型定义 (V3.1)
*/
// 维度分解
export interface DimensionBreakdown {
code: string;
label: string;
passRate: number;
}
// 统计数据
export interface QcStats {
/** 总体数据质量分 (0-100) */
qualityScore: number;
/** V3.1 健康度评分 */
healthScore?: number;
/** V3.1 健康度等级 */
healthGrade?: string;
/** 总记录数 */
totalRecords: number;
/** 通过记录数 */
@@ -20,10 +31,12 @@ export interface QcStats {
criticalCount: number;
/** 待确认 Query 数Major */
queryCount: number;
/** 方案偏离数(PD */
/** 方案偏离数D6 */
deviationCount: number;
/** 通过率 */
passRate: number;
/** V3.1 D1-D7 维度分解 */
dimensionBreakdown?: DimensionBreakdown[];
/** 主要问题 */
topIssues?: Array<{
issue: string;
@@ -34,8 +47,10 @@ export interface QcStats {
// 热力图数据
export interface HeatmapData {
/** 标题(表单/访视名称 */
/** 标题(事件 ID */
columns: string[];
/** V3.1 事件语义标签映射 */
columnLabels?: Record<string, string>;
/** 行数据 */
rows: HeatmapRow[];
}
@@ -45,13 +60,15 @@ export interface HeatmapRow {
recordId: string;
/** 入组状态 */
status: 'enrolled' | 'screening' | 'completed' | 'withdrawn';
/** 各表单/访视的质控状态 */
/** 各事件的质控状态 */
cells: HeatmapCell[];
}
export interface HeatmapCell {
/** 表单/访视名称 */
formName: string;
/** V3.1 事件 ID */
eventId: string;
/** 向后兼容 */
formName?: string;
/** 质控状态 */
status: 'pass' | 'warning' | 'fail' | 'pending';
/** 问题数量 */
@@ -76,9 +93,10 @@ export interface QcCockpitData {
lastUpdatedAt: string;
}
// 记录详情
// 记录详情 (V3.1)
export interface RecordDetail {
recordId: string;
eventId?: string;
formName: string;
status: 'pass' | 'warning' | 'fail' | 'pending';
/** 表单数据 */
@@ -89,17 +107,20 @@ export interface RecordDetail {
type: string;
normalRange?: { min?: number; max?: number };
}>;
/** 质控问题 */
/** 质控问题 (V3.1 含五级坐标) */
issues: Array<{
field: string;
fieldLabel?: string;
ruleName: string;
message: string;
severity: 'critical' | 'warning' | 'info';
dimensionCode?: string;
eventId?: string;
actualValue?: any;
expectedValue?: string;
confidence?: 'high' | 'medium' | 'low';
}>;
/** LLM Trace(调试用) */
/** LLM Trace */
llmTrace?: {
promptSent: string;
responseReceived: string;
@@ -110,7 +131,18 @@ export interface RecordDetail {
entryTime?: string;
}
// D6 方案偏离条目
export interface DeviationItem {
recordId: string;
eventId: string;
fieldName: string;
fieldLabel: string;
message: string;
severity: string;
dimensionCode: string;
detectedAt: string | null;
}
// API 响应类型
export interface QcCockpitResponse extends QcCockpitData {}
export interface RecordDetailResponse extends RecordDetail {}

View File

@@ -372,6 +372,7 @@ export async function batchQualityCheck(projectId: string): Promise<{
message: string;
stats: {
totalRecords: number;
totalEventCombinations: number;
passed: number;
failed: number;
warnings: number;
@@ -427,6 +428,7 @@ export interface TimelineItem {
type: 'qc_check';
time: string;
recordId: string;
eventLabel?: string;
formName?: string;
status: string;
triggeredBy: string;
@@ -436,6 +438,15 @@ export interface TimelineItem {
rulesPassed: number;
rulesFailed: number;
issuesSummary: { red: number; yellow: number };
issues?: Array<{
ruleId: string;
ruleName: string;
field?: string;
message: string;
severity: string;
actualValue?: string;
expectedValue?: string;
}>;
};
}
@@ -562,3 +573,229 @@ export async function refreshQcReport(projectId: string): Promise<QcReport> {
);
return response.data.data;
}
// ==================== V3.1 新增端点 ====================
/** 获取 D1-D7 维度统计 */
export async function getDimensions(projectId: string): Promise<{
healthScore: number;
healthGrade: string;
dimensions: Array<{ code: string; label: string; passRate: number }>;
}> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/dimensions`);
return response.data.data;
}
/** 获取按受试者缺失率 */
export async function getCompleteness(projectId: string): Promise<Array<{
recordId: string;
fieldsTotal: number;
fieldsFilled: number;
fieldsMissing: number;
missingRate: number;
}>> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/completeness`);
return response.data.data;
}
/** 获取字段级质控结果(分页) */
export async function getFieldStatus(
projectId: string,
params?: { recordId?: string; eventId?: string; status?: string; page?: number; pageSize?: number }
): Promise<{ items: any[]; total: number; page: number; pageSize: number }> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/field-status`, { params });
return response.data.data;
}
// ==================== GCP 业务报表 ====================
export interface EligibilityCriterion {
ruleId: string;
ruleName: string;
type: 'inclusion' | 'exclusion';
status: 'PASS' | 'FAIL' | 'NOT_CHECKED';
actualValue: string | null;
expectedValue: string | null;
message: string | null;
}
export interface EligibilitySubject {
recordId: string;
overallStatus: 'eligible' | 'ineligible' | 'incomplete';
failedCriteria: string[];
criteriaResults: EligibilityCriterion[];
}
export interface EligibilityReport {
summary: {
totalScreened: number;
eligible: number;
ineligible: number;
incomplete: number;
eligibilityRate: number;
};
criteria: Array<{
ruleId: string;
ruleName: string;
type: 'inclusion' | 'exclusion';
fieldName: string;
fieldLabel: string;
passCount: number;
failCount: number;
}>;
subjects: EligibilitySubject[];
}
export interface CompletenessEventStat {
eventId: string;
eventLabel: string;
fieldsTotal: number;
fieldsMissing: number;
missingRate: number;
}
export interface CompletenessSubject {
recordId: string;
fieldsTotal: number;
fieldsFilled: number;
fieldsMissing: number;
missingRate: number;
activeEvents: number;
byEvent: CompletenessEventStat[];
}
export interface CompletenessReport {
summary: {
totalRequiredFields: number;
totalFilledFields: number;
totalMissingFields: number;
overallMissingRate: number;
subjectsChecked: number;
eventsChecked: number;
isStale: boolean;
};
bySubject: CompletenessSubject[];
}
export interface CompletenessFieldDetail {
recordId: string;
eventId: string;
eventLabel: string;
byForm: Array<{
formName: string;
formLabel: string;
fieldsTotal: number;
fieldsMissing: number;
missingFields: Array<{
fieldName: string;
fieldLabel: string;
fieldType: string;
}>;
}>;
}
export interface EqueryLogEntry {
id: string;
recordId: string;
eventId: string | null;
formName: string | null;
fieldName: string | null;
fieldLabel: string | null;
queryText: string;
expectedAction: string | null;
severity: string;
category: string | null;
status: string;
createdAt: string;
respondedAt: string | null;
responseText: string | null;
reviewResult: string | null;
reviewNote: string | null;
reviewedAt: string | null;
closedAt: string | null;
closedBy: string | null;
resolution: string | null;
resolutionHours: number | null;
}
export interface EqueryLogReport {
summary: {
total: number;
pending: number;
responded: number;
reviewing: number;
closed: number;
reopened: number;
autoClosed: number;
avgResolutionHours: number;
};
bySubject: Array<{ recordId: string; total: number; pending: number; closed: number }>;
byRule: Array<{ category: string; ruleTrigger: string; count: number }>;
entries: EqueryLogEntry[];
}
export interface DeviationEntry {
id: string;
recordId: string;
eventId: string;
eventLabel: string;
fieldName: string;
fieldLabel: string;
deviationType: string;
message: string | null;
severity: string;
deviationDays: number | null;
direction: 'early' | 'late' | null;
actualDate: string | null;
expectedDate: string | null;
windowBefore: number | null;
windowAfter: number | null;
detectedAt: string | null;
impactAssessment: string | null;
capa: string | null;
}
export interface DeviationReport {
summary: {
totalDeviations: number;
byType: Record<string, number>;
bySeverity: { critical: number; warning: number };
subjectsAffected: number;
};
entries: DeviationEntry[];
}
/** D1 筛选入选表 */
export async function getEligibilityReport(projectId: string): Promise<EligibilityReport> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/report/eligibility`);
return response.data.data;
}
/** D2 数据完整性总览 */
export async function getCompletenessReport(projectId: string): Promise<CompletenessReport> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/report/completeness`);
return response.data.data;
}
/** D2 字段级懒加载 */
export async function getCompletenessFields(
projectId: string, recordId: string, eventId: string
): Promise<CompletenessFieldDetail> {
const response = await apiClient.get(
`${BASE_URL}/${projectId}/qc-cockpit/report/completeness/fields`,
{ params: { recordId, eventId } },
);
return response.data.data;
}
/** D3/D4 eQuery 全生命周期 */
export async function getEqueryLogReport(projectId: string): Promise<EqueryLogReport> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/report/equery-log`);
return response.data.data;
}
/** D6 方案偏离报表 */
export async function getDeviationReport(projectId: string): Promise<DeviationReport> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/report/deviations`);
return response.data.data;
}

View File

@@ -0,0 +1,169 @@
/**
* D2 数据完整性记录表 — 五层缺失率分析
*
* L2 受试者行 → L3 事件展开 → L4/L5 懒加载字段明细
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Card, Table, Tag, Statistic, Row, Col, Empty, Spin, Typography, Space, Alert, Progress,
} from 'antd';
import { WarningOutlined } from '@ant-design/icons';
import * as iitProjectApi from '../../api/iitProjectApi';
import type {
CompletenessReport, CompletenessSubject, CompletenessEventStat, CompletenessFieldDetail,
} from '../../api/iitProjectApi';
const { Text } = Typography;
interface Props {
projectId: string;
}
const rateColor = (rate: number) =>
rate >= 20 ? '#ff4d4f' : rate >= 10 ? '#faad14' : '#52c41a';
const CompletenessTable: React.FC<Props> = ({ projectId }) => {
const [data, setData] = useState<CompletenessReport | null>(null);
const [loading, setLoading] = useState(true);
const [fieldCache, setFieldCache] = useState<Record<string, CompletenessFieldDetail>>({});
const [fieldLoading, setFieldLoading] = useState<Record<string, boolean>>({});
useEffect(() => {
(async () => {
setLoading(true);
try { setData(await iitProjectApi.getCompletenessReport(projectId)); } catch { /* ignore */ }
setLoading(false);
})();
}, [projectId]);
const loadFields = useCallback(async (recordId: string, eventId: string) => {
const key = `${recordId}__${eventId}`;
if (fieldCache[key] || fieldLoading[key]) return;
setFieldLoading(prev => ({ ...prev, [key]: true }));
try {
const detail = await iitProjectApi.getCompletenessFields(projectId, recordId, eventId);
setFieldCache(prev => ({ ...prev, [key]: detail }));
} catch { /* ignore */ }
setFieldLoading(prev => ({ ...prev, [key]: false }));
}, [projectId, fieldCache, fieldLoading]);
if (loading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
if (!data) return <Empty description="暂无 D2 完整性数据" />;
const { summary, bySubject } = data;
const eventExpandRender = (record: CompletenessSubject) => (
<Table
dataSource={record.byEvent}
rowKey="eventId"
size="small"
pagination={false}
expandable={{
onExpand: (expanded, evt: CompletenessEventStat) => {
if (expanded) loadFields(record.recordId, evt.eventId);
},
expandedRowRender: (evt: CompletenessEventStat) => {
const key = `${record.recordId}__${evt.eventId}`;
const detail = fieldCache[key];
if (fieldLoading[key]) return <Spin size="small" />;
if (!detail || detail.byForm.length === 0)
return <Text type="secondary"></Text>;
return (
<div>
{detail.byForm.map(form => (
<div key={form.formName} style={{ marginBottom: 12 }}>
<Text strong>{form.formLabel}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>
({form.fieldsMissing}/{form.fieldsTotal} )
</Text>
<div style={{ marginTop: 4, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{form.missingFields.map(f => (
<Tag key={f.fieldName} color="orange">{f.fieldLabel}</Tag>
))}
</div>
</div>
))}
</div>
);
},
}}
columns={[
{ title: '事件', dataIndex: 'eventLabel', ellipsis: true },
{ title: '总字段', dataIndex: 'fieldsTotal', width: 80 },
{ title: '缺失', dataIndex: 'fieldsMissing', width: 80, render: (n: number) => <Text type={n > 0 ? 'danger' : undefined}>{n}</Text> },
{
title: '缺失率',
dataIndex: 'missingRate',
width: 120,
render: (rate: number) => (
<Progress percent={rate} size="small" strokeColor={rateColor(rate)} format={p => `${p}%`} />
),
},
]}
/>
);
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{summary.isStale && (
<Alert
type="warning"
showIcon
icon={<WarningOutlined />}
message="数据可能过期,建议重新执行全量质控"
/>
)}
<Row gutter={16}>
<Col span={4}>
<Card size="small"><Statistic title="应填字段" value={summary.totalRequiredFields} /></Card>
</Col>
<Col span={4}>
<Card size="small"><Statistic title="已填字段" value={summary.totalFilledFields} valueStyle={{ color: '#52c41a' }} /></Card>
</Col>
<Col span={4}>
<Card size="small"><Statistic title="缺失字段" value={summary.totalMissingFields} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
<Col span={4}>
<Card size="small"><Statistic title="缺失率" value={summary.overallMissingRate} suffix="%" valueStyle={{ color: rateColor(summary.overallMissingRate) }} /></Card>
</Col>
<Col span={4}>
<Card size="small"><Statistic title="受试者" value={summary.subjectsChecked} /></Card>
</Col>
<Col span={4}>
<Card size="small"><Statistic title="事件数" value={summary.eventsChecked} /></Card>
</Col>
</Row>
<Card title="受试者完整性总览" size="small">
<Table
dataSource={bySubject}
rowKey="recordId"
size="small"
pagination={{ pageSize: 20 }}
expandable={{ expandedRowRender: eventExpandRender }}
columns={[
{ title: '受试者 ID', dataIndex: 'recordId', width: 110 },
{ title: '活跃事件', dataIndex: 'activeEvents', width: 90 },
{ title: '总字段', dataIndex: 'fieldsTotal', width: 80 },
{ title: '已填', dataIndex: 'fieldsFilled', width: 80 },
{ title: '缺失', dataIndex: 'fieldsMissing', width: 80, render: (n: number) => <Text type={n > 0 ? 'danger' : undefined}>{n}</Text> },
{
title: '缺失率',
dataIndex: 'missingRate',
width: 130,
sorter: (a: CompletenessSubject, b: CompletenessSubject) => a.missingRate - b.missingRate,
defaultSortOrder: 'descend' as const,
render: (rate: number) => (
<Progress percent={rate} size="small" strokeColor={rateColor(rate)} format={p => `${p}%`} />
),
},
]}
/>
</Card>
</Space>
);
};
export default CompletenessTable;

View File

@@ -0,0 +1,148 @@
/**
* D6 方案偏离记录表 — PD Log
*/
import React, { useState, useEffect } from 'react';
import {
Card, Table, Tag, Statistic, Row, Col, Empty, Spin, Typography, Space, Tooltip,
} from 'antd';
import { WarningOutlined } from '@ant-design/icons';
import * as iitProjectApi from '../../api/iitProjectApi';
import type { DeviationReport, DeviationEntry } from '../../api/iitProjectApi';
const { Text } = Typography;
interface Props {
projectId: string;
}
const DeviationLogTable: React.FC<Props> = ({ projectId }) => {
const [data, setData] = useState<DeviationReport | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
setLoading(true);
try { setData(await iitProjectApi.getDeviationReport(projectId)); } catch { /* ignore */ }
setLoading(false);
})();
}, [projectId]);
if (loading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
if (!data || data.entries.length === 0) return <Empty description="暂无方案偏离记录" />;
const { summary, entries } = data;
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Row gutter={16}>
<Col span={6}>
<Card size="small">
<Statistic title="总偏离数" value={summary.totalDeviations} prefix={<WarningOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="严重" value={summary.bySeverity.critical} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="警告" value={summary.bySeverity.warning} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="受影响受试者" value={summary.subjectsAffected} />
</Card>
</Col>
</Row>
{Object.keys(summary.byType).length > 0 && (
<Card size="small" title="按偏离类型分布">
<Space>
{Object.entries(summary.byType).map(([type, count]) => (
<Tag key={type} color="volcano">{type}: {count}</Tag>
))}
</Space>
</Card>
)}
<Card title="偏离明细" size="small">
<Table
dataSource={entries}
rowKey="id"
size="small"
pagination={{ pageSize: 20 }}
columns={[
{ title: '受试者', dataIndex: 'recordId', width: 80 },
{ title: '事件', dataIndex: 'eventLabel', width: 130, ellipsis: true },
{ title: '字段', dataIndex: 'fieldLabel', width: 110, ellipsis: true },
{
title: '偏离类型',
dataIndex: 'deviationType',
width: 100,
render: (t: string) => <Tag color="volcano">{t}</Tag>,
},
{
title: '偏离天数',
dataIndex: 'deviationDays',
width: 90,
sorter: (a: DeviationEntry, b: DeviationEntry) => (a.deviationDays ?? 0) - (b.deviationDays ?? 0),
render: (d: number | null, r: DeviationEntry) =>
d != null ? (
<Text type={d > 7 ? 'danger' : 'warning'}>
{r.direction === 'late' ? `迟到 ${d}` : `提前 ${d}`}
</Text>
) : '—',
},
{
title: '窗口期',
width: 100,
render: (_: unknown, r: DeviationEntry) =>
r.windowBefore != null ? `${r.windowBefore} / +${r.windowAfter}` : '—',
},
{
title: '实际日期',
dataIndex: 'actualDate',
width: 100,
render: (d: string | null) => d || '—',
},
{
title: '预期日期',
dataIndex: 'expectedDate',
width: 100,
render: (d: string | null) => d || '—',
},
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => <Tag color={s === 'critical' ? 'error' : 'warning'}>{s === 'critical' ? '严重' : '警告'}</Tag>,
},
{
title: '描述',
dataIndex: 'message',
ellipsis: true,
render: (m: string | null) => m ? <Tooltip title={m}><Text>{m}</Text></Tooltip> : '—',
},
{
title: '影响评估',
dataIndex: 'impactAssessment',
width: 100,
render: () => <Text type="secondary" italic></Text>,
},
{
title: 'CAPA',
dataIndex: 'capa',
width: 80,
render: () => <Text type="secondary" italic></Text>,
},
]}
/>
</Card>
</Space>
);
};
export default DeviationLogTable;

View File

@@ -0,0 +1,174 @@
/**
* D1 筛选入选表 — 入排合规性评估
*/
import React, { useState, useEffect } from 'react';
import {
Card, Table, Tag, Statistic, Row, Col, Empty, Spin, Typography, Tooltip, Space,
} from 'antd';
import {
CheckCircleOutlined, CloseCircleOutlined, QuestionCircleOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../../api/iitProjectApi';
import type { EligibilityReport, EligibilitySubject } from '../../api/iitProjectApi';
const { Text } = Typography;
interface Props {
projectId: string;
}
const statusColor: Record<string, string> = {
eligible: '#52c41a',
ineligible: '#ff4d4f',
incomplete: '#faad14',
};
const statusLabel: Record<string, string> = {
eligible: '合规',
ineligible: '不合规',
incomplete: '未完成',
};
const EligibilityTable: React.FC<Props> = ({ projectId }) => {
const [data, setData] = useState<EligibilityReport | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
setLoading(true);
try {
const res = await iitProjectApi.getEligibilityReport(projectId);
setData(res);
} catch { /* ignore */ }
setLoading(false);
})();
}, [projectId]);
if (loading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
if (!data || data.subjects.length === 0) return <Empty description="暂无 D1 筛选入选数据" />;
const { summary, criteria, subjects } = data;
const criteriaColumns = [
{ title: '规则 ID', dataIndex: 'ruleId', width: 100 },
{ title: '标准名称', dataIndex: 'ruleName', ellipsis: true },
{
title: '类型',
dataIndex: 'type',
width: 80,
render: (t: string) => <Tag color={t === 'inclusion' ? 'green' : 'red'}>{t === 'inclusion' ? '纳入' : '排除'}</Tag>,
},
{ title: '字段', dataIndex: 'fieldLabel', width: 130, ellipsis: true },
{ title: '通过', dataIndex: 'passCount', width: 60 },
{ title: '失败', dataIndex: 'failCount', width: 60, render: (n: number) => <Text type={n > 0 ? 'danger' : undefined}>{n}</Text> },
{
title: '通过率',
width: 80,
render: (_: unknown, r: (typeof criteria)[0]) => {
const total = r.passCount + r.failCount;
return total > 0 ? `${Math.round((r.passCount / total) * 100)}%` : '—';
},
},
];
const subjectColumns = [
{ title: '受试者 ID', dataIndex: 'recordId', width: 110 },
{
title: '总体判定',
dataIndex: 'overallStatus',
width: 100,
render: (s: string) => (
<Tag color={statusColor[s] || '#999'}>{statusLabel[s] || s}</Tag>
),
},
{
title: '不合规条目',
dataIndex: 'failedCriteria',
render: (arr: string[]) =>
arr.length > 0 ? arr.map(id => <Tag key={id} color="error">{id}</Tag>) : <Text type="secondary"></Text>,
},
];
const expandedRowRender = (record: EligibilitySubject) => (
<Table
dataSource={record.criteriaResults}
rowKey="ruleId"
size="small"
pagination={false}
columns={[
{ title: '规则', dataIndex: 'ruleName', width: 160 },
{
title: '类型',
dataIndex: 'type',
width: 70,
render: (t: string) => <Tag color={t === 'inclusion' ? 'green' : 'red'}>{t === 'inclusion' ? '纳入' : '排除'}</Tag>,
},
{
title: '判定',
dataIndex: 'status',
width: 80,
render: (s: string) => {
if (s === 'PASS') return <Tag icon={<CheckCircleOutlined />} color="success">PASS</Tag>;
if (s === 'FAIL') return <Tag icon={<CloseCircleOutlined />} color="error">FAIL</Tag>;
return <Tag icon={<QuestionCircleOutlined />} color="default"></Tag>;
},
},
{
title: '实际值',
dataIndex: 'actualValue',
width: 120,
render: (v: string | null) => v ?? <Text type="secondary"></Text>,
},
{
title: '期望值',
dataIndex: 'expectedValue',
width: 120,
render: (v: string | null) => v ?? <Text type="secondary"></Text>,
},
{
title: '说明',
dataIndex: 'message',
ellipsis: true,
render: (m: string | null) => m ? <Tooltip title={m}><Text>{m}</Text></Tooltip> : '—',
},
]}
/>
);
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Row gutter={16}>
<Col span={6}>
<Card size="small"><Statistic title="总筛选人数" value={summary.totalScreened} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="合规" value={summary.eligible} valueStyle={{ color: '#52c41a' }} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="不合规" value={summary.ineligible} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="合规率" value={summary.eligibilityRate} suffix="%" /></Card>
</Col>
</Row>
<Card title="标准通过率汇总" size="small">
<Table dataSource={criteria} rowKey="ruleId" size="small" pagination={false} columns={criteriaColumns} />
</Card>
<Card title="受试者逐条判定" size="small">
<Table
dataSource={subjects}
rowKey="recordId"
size="small"
pagination={{ pageSize: 20 }}
columns={subjectColumns}
expandable={{ expandedRowRender }}
/>
</Card>
</Space>
);
};
export default EligibilityTable;

View File

@@ -0,0 +1,198 @@
/**
* D3/D4 质疑跟踪表 — eQuery 全生命周期
*/
import React, { useState, useEffect } from 'react';
import {
Card, Table, Tag, Statistic, Row, Col, Empty, Spin, Typography, Space, Input, Select, Timeline,
} from 'antd';
import {
ClockCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined,
SyncOutlined, SearchOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../../api/iitProjectApi';
import type { EqueryLogReport, EqueryLogEntry } from '../../api/iitProjectApi';
const { Text } = Typography;
interface Props {
projectId: string;
}
const statusConfig: Record<string, { color: string; label: string }> = {
pending: { color: 'orange', label: '待处理' },
responded: { color: 'blue', label: '已回复' },
reviewing: { color: 'purple', label: '复核中' },
closed: { color: 'green', label: '已关闭' },
reopened: { color: 'red', label: '已重开' },
auto_closed: { color: 'cyan', label: '自动关闭' },
};
const EqueryLogTable: React.FC<Props> = ({ projectId }) => {
const [data, setData] = useState<EqueryLogReport | null>(null);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [searchText, setSearchText] = useState('');
useEffect(() => {
(async () => {
setLoading(true);
try { setData(await iitProjectApi.getEqueryLogReport(projectId)); } catch { /* ignore */ }
setLoading(false);
})();
}, [projectId]);
if (loading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
if (!data) return <Empty description="暂无 eQuery 数据" />;
const { summary, entries } = data;
const filtered = entries.filter(e => {
if (statusFilter && e.status !== statusFilter) return false;
if (searchText) {
const lower = searchText.toLowerCase();
return (
e.recordId.toLowerCase().includes(lower) ||
e.queryText.toLowerCase().includes(lower) ||
(e.fieldLabel || '').toLowerCase().includes(lower)
);
}
return true;
});
const expandedRow = (record: EqueryLogEntry) => (
<div style={{ padding: '8px 24px' }}>
<Timeline
items={[
{
color: 'blue',
dot: <ClockCircleOutlined />,
children: (
<div>
<Text strong></Text>
<Text type="secondary" style={{ marginLeft: 8 }}>{new Date(record.createdAt).toLocaleString('zh-CN')}</Text>
<div style={{ marginTop: 4 }}>{record.queryText}</div>
{record.expectedAction && <div><Text type="secondary">: {record.expectedAction}</Text></div>}
</div>
),
},
...(record.respondedAt ? [{
color: 'green' as const,
dot: <CheckCircleOutlined />,
children: (
<div>
<Text strong>CRC </Text>
<Text type="secondary" style={{ marginLeft: 8 }}>{new Date(record.respondedAt).toLocaleString('zh-CN')}</Text>
<div style={{ marginTop: 4 }}>{record.responseText || '—'}</div>
</div>
),
}] : []),
...(record.reviewedAt ? [{
color: 'purple' as const,
dot: <ExclamationCircleOutlined />,
children: (
<div>
<Text strong>AI </Text>
<Text type="secondary" style={{ marginLeft: 8 }}>{new Date(record.reviewedAt).toLocaleString('zh-CN')}</Text>
<div style={{ marginTop: 4 }}>
<Tag color={record.reviewResult === 'pass' ? 'success' : 'warning'}>{record.reviewResult}</Tag>
{record.reviewNote && <Text>{record.reviewNote}</Text>}
</div>
</div>
),
}] : []),
...(record.closedAt ? [{
color: 'gray' as const,
dot: <CheckCircleOutlined />,
children: (
<div>
<Text strong></Text>
<Text type="secondary" style={{ marginLeft: 8 }}>{new Date(record.closedAt).toLocaleString('zh-CN')}</Text>
{record.closedBy && <Text type="secondary" style={{ marginLeft: 8 }}>by {record.closedBy}</Text>}
{record.resolution && <div style={{ marginTop: 4 }}>{record.resolution}</div>}
{record.resolutionHours != null && <div><Text type="secondary">: {record.resolutionHours} </Text></div>}
</div>
),
}] : []),
]}
/>
</div>
);
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Row gutter={[12, 12]}>
<Col span={3}><Card size="small"><Statistic title="总数" value={summary.total} /></Card></Col>
<Col span={3}><Card size="small"><Statistic title="待处理" value={summary.pending} valueStyle={{ color: '#faad14' }} /></Card></Col>
<Col span={3}><Card size="small"><Statistic title="已回复" value={summary.responded} valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={3}><Card size="small"><Statistic title="复核中" value={summary.reviewing} valueStyle={{ color: '#722ed1' }} /></Card></Col>
<Col span={3}><Card size="small"><Statistic title="已关闭" value={summary.closed} valueStyle={{ color: '#52c41a' }} /></Card></Col>
<Col span={3}><Card size="small"><Statistic title="已重开" value={summary.reopened} valueStyle={{ color: '#ff4d4f' }} /></Card></Col>
<Col span={3}><Card size="small"><Statistic title="自动关闭" value={summary.autoClosed} /></Card></Col>
<Col span={3}><Card size="small"><Statistic title="平均时长" value={summary.avgResolutionHours} suffix="h" prefix={<SyncOutlined />} /></Card></Col>
</Row>
<Card
title="eQuery 明细"
size="small"
extra={
<Space>
<Input
placeholder="搜索受试者/字段/内容"
prefix={<SearchOutlined />}
allowClear
style={{ width: 200 }}
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={statusFilter}
onChange={setStatusFilter}
options={Object.entries(statusConfig).map(([k, v]) => ({ value: k, label: v.label }))}
/>
</Space>
}
>
<Table
dataSource={filtered}
rowKey="id"
size="small"
pagination={{ pageSize: 15 }}
expandable={{ expandedRowRender: expandedRow }}
columns={[
{ title: '受试者', dataIndex: 'recordId', width: 80 },
{ title: '事件', dataIndex: 'eventId', width: 120, ellipsis: true, render: (v: string | null) => v || '—' },
{ title: '字段', dataIndex: 'fieldLabel', width: 120, ellipsis: true, render: (v: string | null) => v || '—' },
{ title: '质疑内容', dataIndex: 'queryText', ellipsis: true },
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => <Tag color={s === 'critical' ? 'error' : s === 'warning' ? 'warning' : 'default'}>{s}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => {
const cfg = statusConfig[s] || { color: 'default', label: s };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 140,
render: (d: string) => new Date(d).toLocaleString('zh-CN'),
},
]}
/>
</Card>
</Space>
);
};
export default EqueryLogTable;

View File

@@ -2,6 +2,7 @@
* AI 实时工作流水页 (Level 2)
*
* 以 Timeline 展示 Agent 每次质控的完整动作链AI 白盒化。
* 显示中文事件名、实际规则数、五层定位详情、最终判定状态。
*/
import React, { useState, useEffect, useCallback } from 'react';
@@ -16,6 +17,8 @@ import {
Button,
Badge,
Pagination,
Collapse,
Table,
} from 'antd';
import {
ThunderboltOutlined,
@@ -27,6 +30,7 @@ import {
RobotOutlined,
ApiOutlined,
BellOutlined,
FileSearchOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { TimelineItem } from '../api/iitProjectApi';
@@ -34,10 +38,10 @@ import { useIitProject } from '../context/IitProjectContext';
const { Text } = Typography;
const STATUS_DOT: Record<string, { color: string; icon: React.ReactNode }> = {
PASS: { color: 'green', icon: <CheckCircleOutlined /> },
FAIL: { color: 'red', icon: <CloseCircleOutlined /> },
WARNING: { color: 'orange', icon: <WarningOutlined /> },
const STATUS_DOT: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
PASS: { color: 'green', icon: <CheckCircleOutlined />, label: '通过' },
FAIL: { color: 'red', icon: <CloseCircleOutlined />, label: '严重' },
WARNING: { color: 'orange', icon: <WarningOutlined />, label: '警告' },
};
const TRIGGER_TAG: Record<string, { color: string; label: string }> = {
@@ -82,19 +86,52 @@ const AiStreamPage: React.FC = () => {
};
const timelineItems = items.map((item) => {
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined /> };
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined />, label: '未知' };
const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy };
const { red, yellow } = item.details.issuesSummary;
const time = new Date(item.time);
const timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateStr = time.toLocaleDateString('zh-CN');
const eventLabel = item.eventLabel || '';
const issues = item.details.issues || [];
const issueColumns = [
{
title: '规则',
dataIndex: 'ruleName',
width: 160,
render: (v: string, r: any) => (
<Space size={4}>
<Text>{v || r.ruleId}</Text>
</Space>
),
},
{ title: '字段', dataIndex: 'field', width: 110, render: (v: string) => v ? <Text code>{v}</Text> : '—' },
{ title: '问题描述', dataIndex: 'message', ellipsis: true },
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => (
<Tag color={s === 'critical' ? 'error' : 'warning'}>
{s === 'critical' ? '严重' : '警告'}
</Tag>
),
},
{
title: '实际值',
dataIndex: 'actualValue',
width: 90,
render: (v: string) => v ?? '—',
},
];
return {
color: dotCfg.color as any,
dot: dotCfg.icon,
children: (
<div style={{ paddingBottom: 8 }}>
{/* Header line */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text strong style={{ fontSize: 13, fontFamily: 'monospace' }}>{timeStr}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>{dateStr}</Text>
@@ -102,11 +139,10 @@ const AiStreamPage: React.FC = () => {
{triggerCfg.label}
</Tag>
<Tag color={dotCfg.color} style={{ fontSize: 10, lineHeight: '18px', padding: '0 6px' }}>
{item.status}
{dotCfg.label}
</Tag>
</div>
{/* Description — the AI action chain */}
<div style={{
background: '#f8fafc',
borderRadius: 8,
@@ -118,18 +154,20 @@ const AiStreamPage: React.FC = () => {
<Space wrap size={4} style={{ marginBottom: 4 }}>
<RobotOutlined style={{ color: '#3b82f6' }} />
<Text> <Text code>{item.recordId}</Text></Text>
{item.formName && <Text type="secondary">[{item.formName}]</Text>}
{eventLabel && <Tag color="geekblue">{eventLabel}</Tag>}
</Space>
<div style={{ marginLeft: 20 }}>
<Space size={4}>
<ApiOutlined style={{ color: '#8b5cf6' }} />
<Text> {item.details.rulesEvaluated} </Text>
<Text> <Text strong>{item.details.rulesEvaluated}</Text> </Text>
<Text type="success"> {item.details.rulesPassed} </Text>
{item.details.rulesFailed > 0 && (
<Text type="danger">/ {item.details.rulesFailed} </Text>
)}
</Space>
</div>
{(red > 0 || yellow > 0) && (
<div style={{ marginLeft: 20, marginTop: 2 }}>
<Space size={4}>
@@ -141,6 +179,40 @@ const AiStreamPage: React.FC = () => {
</Space>
</div>
)}
{issues.length > 0 && (
<Collapse
ghost
size="small"
style={{ marginTop: 4 }}
items={[{
key: '1',
label: (
<Space size={4}>
<FileSearchOutlined style={{ color: '#64748b' }} />
<Text type="secondary"> {issues.length} </Text>
</Space>
),
children: (
<Table
dataSource={issues}
rowKey={(_, i) => `issue-${i}`}
size="small"
pagination={false}
columns={issueColumns}
/>
),
}]}
/>
)}
{issues.length === 0 && item.status === 'PASS' && (
<div style={{ marginLeft: 20, marginTop: 2 }}>
<Text type="success" style={{ fontSize: 12 }}>
<CheckCircleOutlined />
</Text>
</div>
)}
</div>
</div>
),
@@ -149,7 +221,6 @@ const AiStreamPage: React.FC = () => {
return (
<div>
{/* Header */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Tag icon={<ThunderboltOutlined />} color="processing"></Tag>
@@ -172,7 +243,6 @@ const AiStreamPage: React.FC = () => {
</Space>
</div>
{/* Timeline */}
<Card>
{items.length > 0 ? (
<>

View File

@@ -19,6 +19,8 @@ import {
Tooltip,
Badge,
Alert,
Button,
message,
} from 'antd';
import {
CheckCircleOutlined,
@@ -29,6 +31,7 @@ import {
RiseOutlined,
ThunderboltOutlined,
FileSearchOutlined,
SyncOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import { useIitProject } from '../context/IitProjectContext';
@@ -65,22 +68,47 @@ const DashboardPage: React.FC = () => {
useEffect(() => { fetchData(); }, [fetchData]);
// Health Score = weighted average of passRate, eQuery backlog, critical events
const [batchRunning, setBatchRunning] = useState(false);
const handleBatchQc = async () => {
if (!projectId) return;
setBatchRunning(true);
try {
const res = await iitProjectApi.batchQualityCheck(projectId);
message.success(`全量质控完成:${res.stats.totalRecords} 个受试者,${res.stats.totalEventCombinations} 个事件,事件通过率 ${res.stats.passRate},耗时 ${(res.durationMs / 1000).toFixed(1)}s`);
fetchData();
} catch {
message.error('全量质控执行失败');
} finally {
setBatchRunning(false);
}
};
const passRate = stats?.passRate ?? 0;
const pendingEq = equeryStats?.pending ?? 0;
const criticalCount = criticalEvents.length;
let healthScore = passRate;
if (pendingEq > 10) healthScore -= 10;
else if (pendingEq > 5) healthScore -= 5;
if (criticalCount > 0) healthScore -= criticalCount * 5;
healthScore = Math.max(0, Math.min(100, Math.round(healthScore)));
const healthScore = stats?.healthScore ?? Math.max(0, Math.min(100, Math.round(passRate)));
const healthGrade = stats?.healthGrade ?? '';
const healthColor = healthScore >= 80 ? '#52c41a' : healthScore >= 60 ? '#faad14' : '#ff4d4f';
const healthLabel = healthScore >= 80 ? '良好' : healthScore >= 60 ? '需关注' : '风险';
const healthLabel = healthGrade || (healthScore >= 80 ? '良好' : healthScore >= 60 ? '需关注' : '风险');
const dimensions = stats?.dimensionBreakdown ?? [];
return (
<div>
{/* 操作栏 */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
icon={<SyncOutlined spin={batchRunning} />}
loading={batchRunning}
onClick={handleBatchQc}
size="large"
>
</Button>
</div>
{/* Health Score */}
<Card
style={{
@@ -107,7 +135,7 @@ const DashboardPage: React.FC = () => {
<Col flex="auto">
<Title level={4} style={{ margin: 0, color: '#1e293b' }}></Title>
<Text type="secondary" style={{ display: 'block', marginTop: 4, marginBottom: 12 }}>
eQuery
D1-D7 {healthLabel}
</Text>
<Space size={24}>
<span> <Text strong>{passRate}%</Text></span>
@@ -171,6 +199,32 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
{/* D1-D7 Dimension Breakdown */}
{dimensions.length > 0 && (
<Card title="D1-D7 维度通过率" style={{ marginBottom: 16 }}>
<Row gutter={[16, 8]}>
{dimensions.map((d: any) => {
const dimColor = d.passRate >= 90 ? '#52c41a' : d.passRate >= 70 ? '#faad14' : '#ff4d4f';
return (
<Col span={8} key={d.code}>
<div style={{ marginBottom: 4 }}>
<Text strong>{d.code}</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>{d.label}</Text>
<Text strong style={{ float: 'right', color: dimColor }}>{d.passRate.toFixed(1)}%</Text>
</div>
<Progress
percent={d.passRate}
showInfo={false}
strokeColor={dimColor}
size="small"
/>
</Col>
);
})}
</Row>
</Card>
)}
{/* Critical Event Alerts */}
{criticalEvents.length > 0 && (
<div style={{ marginBottom: 16 }}>
@@ -249,7 +303,7 @@ const DashboardPage: React.FC = () => {
title={
<Space>
<ThunderboltOutlined />
<span> × </span>
<span> × </span>
</Space>
}
style={{ marginBottom: 16 }}
@@ -280,7 +334,7 @@ const DashboardPage: React.FC = () => {
const border = cell.status === 'pass' ? '#86efac' : cell.status === 'warning' ? '#fcd34d' : cell.status === 'fail' ? '#fca5a5' : '#e2e8f0';
return (
<td key={ci} style={{ padding: 2, borderBottom: '1px solid #f1f5f9' }}>
<Tooltip title={`${cell.formName}: ${cell.issueCount} 个问题`}>
<Tooltip title={`${cell.eventId || cell.formName || ''}: ${cell.issueCount} 个问题`}>
<div style={{
background: bg,
border: `1px solid ${border}`,

View File

@@ -1,10 +1,14 @@
/**
* 报告与关键事件页面
* 质控报告与 GCP 业务报表
*
* 展示质控报告列表、报告详情、以及重大事件归档。
* Tab 0: 执行摘要(原有报告 + 维度分析 + 重大事件
* Tab 1: D1 筛选入选表
* Tab 2: D2 数据完整性
* Tab 3: D3/D4 质疑跟踪表
* Tab 4: D6 方案偏离表
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, lazy, Suspense } from 'react';
import {
Card,
Table,
@@ -22,19 +26,28 @@ import {
Tabs,
Select,
Badge,
Spin,
} from 'antd';
import {
FileTextOutlined,
SyncOutlined,
WarningOutlined,
CheckCircleOutlined,
AlertOutlined,
SafetyCertificateOutlined,
AuditOutlined,
DatabaseOutlined,
QuestionCircleOutlined,
ExceptionOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { QcReport, CriticalEvent } from '../api/iitProjectApi';
import { useIitProject } from '../context/IitProjectContext';
const EligibilityTable = lazy(() => import('../components/reports/EligibilityTable'));
const CompletenessTable = lazy(() => import('../components/reports/CompletenessTable'));
const EqueryLogTable = lazy(() => import('../components/reports/EqueryLogTable'));
const DeviationLogTable = lazy(() => import('../components/reports/DeviationLogTable'));
const { Text, Title } = Typography;
const ReportsPage: React.FC = () => {
@@ -43,8 +56,8 @@ const ReportsPage: React.FC = () => {
const [report, setReport] = useState<QcReport | null>(null);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [dimensions, setDimensions] = useState<any>(null);
// Critical events
const [criticalEvents, setCriticalEvents] = useState<CriticalEvent[]>([]);
const [ceTotal, setCeTotal] = useState(0);
const [ceStatusFilter, setCeStatusFilter] = useState<string | undefined>(undefined);
@@ -53,24 +66,21 @@ const ReportsPage: React.FC = () => {
if (!projectId) return;
setLoading(true);
try {
const [reportData, ceData] = await Promise.allSettled([
const [reportData, ceData, dimData] = await Promise.allSettled([
iitProjectApi.getQcReport(projectId) as Promise<QcReport>,
iitProjectApi.getCriticalEvents(projectId, { status: ceStatusFilter, pageSize: 100 }),
iitProjectApi.getDimensions(projectId),
]);
if (reportData.status === 'fulfilled') setReport(reportData.value);
else setReport(null);
if (ceData.status === 'fulfilled') {
setCriticalEvents(ceData.value.items);
setCeTotal(ceData.value.total);
}
if (ceData.status === 'fulfilled') { setCriticalEvents(ceData.value.items); setCeTotal(ceData.value.total); }
if (dimData.status === 'fulfilled') setDimensions(dimData.value);
} finally {
setLoading(false);
}
}, [projectId, ceStatusFilter]);
useEffect(() => {
fetchReport();
}, [fetchReport]);
useEffect(() => { fetchReport(); }, [fetchReport]);
const handleRefresh = async () => {
if (!projectId) return;
@@ -86,22 +96,135 @@ const ReportsPage: React.FC = () => {
}
};
if (!report && !loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Empty description="暂无质控报告,请先执行全量质控" />
<Button type="primary" icon={<SyncOutlined />} onClick={handleRefresh} loading={refreshing} style={{ marginTop: 16 }}>
</Button>
</div>
);
}
const summary = report?.summary;
const executiveSummaryTab = (
<div>
{!report && !loading && (
<div style={{ textAlign: 'center', padding: 24 }}>
<Empty description="暂无质控报告,请先执行全量质控" />
<Button type="primary" icon={<SyncOutlined />} onClick={handleRefresh} loading={refreshing} style={{ marginTop: 16 }}>
</Button>
</div>
)}
{summary && (
<>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={4}><Card size="small"><Statistic title="总受试者" value={summary.totalRecords} /></Card></Col>
<Col span={4}><Card size="small"><Statistic title="已完成" value={summary.completedRecords} /></Card></Col>
<Col span={4}><Card size="small">
<Statistic title="通过率" value={summary.passRate} suffix="%" valueStyle={{ color: summary.passRate >= 80 ? '#52c41a' : summary.passRate >= 60 ? '#faad14' : '#ff4d4f' }} />
</Card></Col>
<Col span={4}><Card size="small"><Statistic title="严重问题" value={summary.criticalIssues} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} /></Card></Col>
<Col span={4}><Card size="small"><Statistic title="警告" value={summary.warningIssues} prefix={<AlertOutlined />} valueStyle={{ color: '#faad14' }} /></Card></Col>
<Col span={4}><Card size="small"><Statistic title="待处理 Query" value={summary.pendingQueries} /></Card></Col>
</Row>
<Card size="small" style={{ marginBottom: 16 }}>
<Row align="middle" gutter={24}>
<Col span={6}>
<Progress
type="dashboard"
percent={summary.passRate}
status={summary.passRate >= 80 ? 'success' : summary.passRate >= 60 ? 'normal' : 'exception'}
size={100}
/>
</Col>
<Col span={18}>
<Descriptions size="small" column={3}>
<Descriptions.Item label="报告类型">
<Tag>{report?.reportType === 'daily' ? '日报' : report?.reportType === 'weekly' ? '周报' : '按需'}</Tag>
</Descriptions.Item>
<Descriptions.Item label="最后质控时间">
{summary.lastQcTime ? new Date(summary.lastQcTime).toLocaleString('zh-CN') : '—'}
</Descriptions.Item>
<Descriptions.Item label="报告有效期至">
{report?.expiresAt ? new Date(report.expiresAt).toLocaleString('zh-CN') : '—'}
</Descriptions.Item>
</Descriptions>
</Col>
</Row>
</Card>
</>
)}
{dimensions && (
<Card size="small" title="D1-D7 维度分析" style={{ marginBottom: 16 }}>
<Row gutter={16} style={{ marginBottom: 12 }}>
<Col span={6}>
<Statistic title="健康度评分" value={dimensions.healthScore ?? 0} suffix={`/ 100 (${dimensions.healthGrade ?? '-'})`} />
</Col>
</Row>
<Table
dataSource={dimensions.dimensions || []}
rowKey="code"
size="small"
pagination={false}
columns={[
{ title: '代码', dataIndex: 'code', width: 80, render: (c: string) => <Tag color="blue">{c}</Tag> },
{ title: '维度', dataIndex: 'label', width: 150 },
{
title: '通过率',
dataIndex: 'passRate',
render: (rate: number) => <Progress percent={rate} size="small" status={rate >= 90 ? 'success' : rate >= 70 ? 'normal' : 'exception'} />,
},
]}
/>
</Card>
)}
<Card size="small" title={
<Space>
<SafetyCertificateOutlined />
{ceTotal > 0 && <Badge count={ceTotal} style={{ backgroundColor: '#ff4d4f' }} />}
</Space>
}>
<div style={{ marginBottom: 12 }}>
<Select
placeholder="按状态筛选"
allowClear
style={{ width: 140 }}
value={ceStatusFilter}
onChange={setCeStatusFilter}
options={[
{ value: 'open', label: '待处理' },
{ value: 'handled', label: '已处理' },
{ value: 'reported', label: '已上报' },
]}
/>
</div>
<Table
dataSource={criticalEvents}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
locale={{ emptyText: <Empty description="暂无重大事件" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
columns={[
{ title: '受试者', dataIndex: 'recordId', width: 100, render: (id: string) => <Text strong>{id}</Text> },
{
title: '事件类型', dataIndex: 'eventType', width: 130,
render: (t: string) => <Tag color={t === 'SAE' ? 'error' : 'warning'}>{t === 'SAE' ? '严重不良事件' : '方案偏离'}</Tag>,
},
{ title: '标题', dataIndex: 'title', ellipsis: true },
{
title: '状态', dataIndex: 'status', width: 90,
render: (s: string) => <Tag color={s === 'open' ? 'error' : s === 'handled' ? 'processing' : 'success'}>{s === 'open' ? '待处理' : s === 'handled' ? '已处理' : '已上报'}</Tag>,
},
{ title: '检出时间', dataIndex: 'detectedAt', width: 160, render: (d: string) => new Date(d).toLocaleString('zh-CN') },
{ title: 'EC 上报', dataIndex: 'reportedToEc', width: 80, render: (v: boolean) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
]}
/>
</Card>
</div>
);
const lazyFallback = <Spin style={{ display: 'block', margin: '40px auto' }} />;
return (
<div>
{/* Header */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Title level={5} style={{ margin: 0 }}>
@@ -119,224 +242,46 @@ const ReportsPage: React.FC = () => {
</Button>
</div>
{/* Summary Cards */}
{summary && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={4}>
<Card size="small">
<Statistic title="总受试者" value={summary.totalRecords} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="已完成" value={summary.completedRecords} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic
title="通过率"
value={summary.passRate}
suffix="%"
valueStyle={{ color: summary.passRate >= 80 ? '#52c41a' : summary.passRate >= 60 ? '#faad14' : '#ff4d4f' }}
/>
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="严重问题" value={summary.criticalIssues} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="警告" value={summary.warningIssues} prefix={<AlertOutlined />} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="待处理 Query" value={summary.pendingQueries} />
</Card>
</Col>
</Row>
)}
{/* Pass Rate Visual */}
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Row align="middle" gutter={24}>
<Col span={6}>
<Progress
type="dashboard"
percent={summary.passRate}
status={summary.passRate >= 80 ? 'success' : summary.passRate >= 60 ? 'normal' : 'exception'}
size={100}
/>
</Col>
<Col span={18}>
<Descriptions size="small" column={3}>
<Descriptions.Item label="报告类型">
<Tag>{report?.reportType === 'daily' ? '日报' : report?.reportType === 'weekly' ? '周报' : '按需'}</Tag>
</Descriptions.Item>
<Descriptions.Item label="最后质控时间">
{summary.lastQcTime ? new Date(summary.lastQcTime).toLocaleString('zh-CN') : '—'}
</Descriptions.Item>
<Descriptions.Item label="报告有效期至">
{report?.expiresAt ? new Date(report.expiresAt).toLocaleString('zh-CN') : '—'}
</Descriptions.Item>
</Descriptions>
</Col>
</Row>
</Card>
)}
{/* Tabs: Issues & Form Stats */}
{report && (
<Card>
<Tabs
items={[
{
key: 'critical',
label: <span><WarningOutlined /> ({report.criticalIssues.length})</span>,
children: (
<Table
dataSource={report.criticalIssues}
rowKey={(_, i) => `c${i}`}
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '受试者', dataIndex: 'recordId', width: 100 },
{ title: '规则', dataIndex: 'ruleName', width: 180 },
{ title: '描述', dataIndex: 'message', ellipsis: true },
{ title: '字段', dataIndex: 'field', width: 130, render: (f: string) => f ? <Text code>{f}</Text> : '—' },
{ title: '检出时间', dataIndex: 'detectedAt', width: 160, render: (d: string) => new Date(d).toLocaleString('zh-CN') },
]}
/>
),
},
{
key: 'warning',
label: <span><AlertOutlined /> ({report.warningIssues.length})</span>,
children: (
<Table
dataSource={report.warningIssues}
rowKey={(_, i) => `w${i}`}
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '受试者', dataIndex: 'recordId', width: 100 },
{ title: '规则', dataIndex: 'ruleName', width: 180 },
{ title: '描述', dataIndex: 'message', ellipsis: true },
{ title: '字段', dataIndex: 'field', width: 130, render: (f: string) => f ? <Text code>{f}</Text> : '—' },
]}
/>
),
},
{
key: 'forms',
label: <span><CheckCircleOutlined /> ({report.formStats.length})</span>,
children: (
<Table
dataSource={report.formStats}
rowKey="formName"
size="small"
pagination={false}
columns={[
{ title: '表单', dataIndex: 'formName', width: 200 },
{ title: '标签', dataIndex: 'formLabel', ellipsis: true },
{ title: '检查数', dataIndex: 'totalChecks', width: 100 },
{ title: '通过', dataIndex: 'passed', width: 80 },
{ title: '失败', dataIndex: 'failed', width: 80 },
{
title: '通过率',
dataIndex: 'passRate',
width: 120,
render: (rate: number) => (
<Progress percent={rate} size="small" status={rate >= 80 ? 'success' : rate >= 60 ? 'normal' : 'exception'} />
),
},
]}
/>
),
},
{
key: 'critical-events',
label: (
<span>
<SafetyCertificateOutlined />
{ceTotal > 0 && <Badge count={ceTotal} offset={[8, -2]} style={{ backgroundColor: '#ff4d4f' }} />}
</span>
),
children: (
<div>
<div style={{ marginBottom: 12 }}>
<Select
placeholder="按状态筛选"
allowClear
style={{ width: 140 }}
value={ceStatusFilter}
onChange={setCeStatusFilter}
options={[
{ value: 'open', label: '待处理' },
{ value: 'handled', label: '已处理' },
{ value: 'reported', label: '已上报' },
]}
/>
</div>
<Table
dataSource={criticalEvents}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
locale={{ emptyText: <Empty description="暂无重大事件" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
columns={[
{
title: '受试者',
dataIndex: 'recordId',
width: 100,
render: (id: string) => <Text strong>{id}</Text>,
},
{
title: '事件类型',
dataIndex: 'eventType',
width: 130,
render: (t: string) => (
<Tag color={t === 'SAE' ? 'error' : 'warning'}>
{t === 'SAE' ? '严重不良事件' : '方案偏离'}
</Tag>
),
},
{ title: '标题', dataIndex: 'title', ellipsis: true },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => (
<Tag color={s === 'open' ? 'error' : s === 'handled' ? 'processing' : 'success'}>
{s === 'open' ? '待处理' : s === 'handled' ? '已处理' : '已上报'}
</Tag>
),
},
{
title: '检出时间',
dataIndex: 'detectedAt',
width: 160,
render: (d: string) => new Date(d).toLocaleString('zh-CN'),
},
{
title: 'EC 上报',
dataIndex: 'reportedToEc',
width: 80,
render: (v: boolean) => v ? <Tag color="success"></Tag> : <Tag></Tag>,
},
]}
/>
</div>
),
},
]}
/>
</Card>
)}
<Card>
<Tabs
defaultActiveKey="summary"
items={[
{
key: 'summary',
label: <span><FileTextOutlined /> </span>,
children: executiveSummaryTab,
},
{
key: 'd1',
label: <span><AuditOutlined /> (D1)</span>,
children: projectId ? (
<Suspense fallback={lazyFallback}><EligibilityTable projectId={projectId} /></Suspense>
) : <Empty />,
},
{
key: 'd2',
label: <span><DatabaseOutlined /> (D2)</span>,
children: projectId ? (
<Suspense fallback={lazyFallback}><CompletenessTable projectId={projectId} /></Suspense>
) : <Empty />,
},
{
key: 'd3d4',
label: <span><QuestionCircleOutlined /> (D3/D4)</span>,
children: projectId ? (
<Suspense fallback={lazyFallback}><EqueryLogTable projectId={projectId} /></Suspense>
) : <Empty />,
},
{
key: 'd6',
label: <span><ExceptionOutlined /> (D6)</span>,
children: projectId ? (
<Suspense fallback={lazyFallback}><DeviationLogTable projectId={projectId} /></Suspense>
) : <Empty />,
},
]}
/>
</Card>
</div>
);
};

View File

@@ -2,29 +2,26 @@
* 质控驾驶舱相关类型定义
*/
// 统计数据
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;
/** 通过率 */
export interface DimensionBreakdown {
code: string;
label: string;
passRate: number;
/** 主要问题 */
}
export interface QcStats {
qualityScore: number;
healthScore: number;
healthGrade: string;
totalRecords: number;
passedRecords: number;
failedRecords: number;
warningRecords: number;
pendingRecords: number;
criticalCount: number;
queryCount: number;
deviationCount: number;
passRate: number;
dimensionBreakdown: DimensionBreakdown[];
topIssues?: Array<{
issue: string;
count: number;
@@ -50,15 +47,10 @@ export interface HeatmapRow {
}
export interface HeatmapCell {
/** 表单/访视名称 */
formName: string;
/** 质控状态 */
eventId: string;
status: 'pass' | 'warning' | 'fail' | 'pending';
/** 问题数量 */
issueCount: number;
/** 受试者 ID冗余方便查询 */
recordId: string;
/** 问题摘要 */
issues?: Array<{
field: string;
message: string;
@@ -66,6 +58,33 @@ export interface HeatmapCell {
}>;
}
export interface FieldStatusRow {
id: string;
projectId: string;
recordId: string;
eventId: string;
formName: string;
instanceId: number;
fieldName: string;
status: string;
ruleId?: string;
ruleName?: string;
ruleCategory?: string;
severity?: string;
message?: string;
actualValue?: string;
expectedValue?: string;
lastQcAt: string;
}
export interface CompletenessRow {
recordId: string;
fieldsTotal: number;
fieldsFilled: number;
fieldsMissing: number;
missingRate: number;
}
// 完整的驾驶舱数据
export interface QcCockpitData {
/** 统计数据 */