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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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