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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
/** 统计数据 */
|
||||
|
||||
Reference in New Issue
Block a user