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:
@@ -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