feat(iit): Complete CRA Agent V3.0 P0 milestone - autonomous QC pipeline

P0-1: Variable list sync from REDCap metadata
P0-2: QC rule configuration with JSON Logic + AI suggestion
P0-3: Scheduled QC + report generation + eQuery closed loop
P0-4: Unified dashboard + AI stream timeline + critical events

Backend:
- Add IitEquery, IitCriticalEvent Prisma models + migration
- Add cronEnabled/cronExpression to IitProject
- Implement eQuery service/controller/routes (CRUD + respond/review/close)
- Implement DailyQcOrchestrator (report -> eQuery -> critical events -> notify)
- Add AI rule suggestion service
- Register daily QC cron worker and eQuery auto-review worker
- Extend QC cockpit with timeline, trend, critical events APIs
- Fix timeline issues field compat (object vs array format)

Frontend:
- Create IIT business module with 6 pages (Dashboard, AI Stream, eQuery,
  Reports, Variable List + project config pages)
- Migrate IIT config from admin panel to business module
- Implement health score, risk heatmap, trend chart, critical event alerts
- Register IIT module in App router and top navigation

Testing:
- Add E2E API test script covering 7 modules (46 assertions, all passing)

Tested: E2E API tests 46/46 passed, backend and frontend verified
Made-with: Cursor
This commit is contained in:
2026-02-26 13:28:08 +08:00
parent 31b0433195
commit 203846968c
35 changed files with 7353 additions and 22 deletions

View File

@@ -0,0 +1,359 @@
/**
* 项目健康度大盘 (Level 1)
*
* 健康度评分 + 核心数据卡片 + 趋势折线图 + 热力图 + 事件预警
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
Card,
Row,
Col,
Statistic,
Progress,
Empty,
Tag,
Typography,
Space,
Tooltip,
Badge,
Alert,
} from 'antd';
import {
CheckCircleOutlined,
WarningOutlined,
CloseCircleOutlined,
TeamOutlined,
AlertOutlined,
RiseOutlined,
ThunderboltOutlined,
FileSearchOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
const { Text, Title } = Typography;
const DashboardPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const projectId = searchParams.get('projectId') || '';
const [stats, setStats] = useState<any>(null);
const [heatmap, setHeatmap] = useState<any>(null);
const [trend, setTrend] = useState<iitProjectApi.TrendPoint[]>([]);
const [criticalEvents, setCriticalEvents] = useState<iitProjectApi.CriticalEvent[]>([]);
const [equeryStats, setEqueryStats] = useState<iitProjectApi.EqueryStats | null>(null);
const fetchData = useCallback(async () => {
if (!projectId) return;
try {
const [cockpitData, trendData, eventsData, eqStats] = await Promise.allSettled([
iitProjectApi.getQcCockpitData(projectId),
iitProjectApi.getTrend(projectId, 30),
iitProjectApi.getCriticalEvents(projectId, { status: 'open', pageSize: 5 }),
iitProjectApi.getEqueryStats(projectId),
]);
if (cockpitData.status === 'fulfilled') {
setStats(cockpitData.value.stats);
setHeatmap(cockpitData.value.heatmap);
}
if (trendData.status === 'fulfilled') setTrend(trendData.value);
if (eventsData.status === 'fulfilled') setCriticalEvents(eventsData.value.items);
if (eqStats.status === 'fulfilled') setEqueryStats(eqStats.value);
} catch { /* non-fatal */ }
}, [projectId]);
useEffect(() => { fetchData(); }, [fetchData]);
// Health Score = weighted average of passRate, eQuery backlog, critical events
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 healthColor = healthScore >= 80 ? '#52c41a' : healthScore >= 60 ? '#faad14' : '#ff4d4f';
const healthLabel = healthScore >= 80 ? '良好' : healthScore >= 60 ? '需关注' : '风险';
if (!projectId) {
return <div style={{ padding: 24 }}><Empty description="请先选择一个项目" /></div>;
}
return (
<div>
{/* Health Score */}
<Card
style={{
marginBottom: 16,
background: `linear-gradient(135deg, ${healthColor}10 0%, #ffffff 70%)`,
borderColor: `${healthColor}40`,
}}
>
<Row align="middle" gutter={32}>
<Col>
<Progress
type="dashboard"
percent={healthScore}
strokeColor={healthColor}
size={120}
format={() => (
<div>
<div style={{ fontSize: 28, fontWeight: 800, color: healthColor }}>{healthScore}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>{healthLabel}</div>
</div>
)}
/>
</Col>
<Col flex="auto">
<Title level={4} style={{ margin: 0, color: '#1e293b' }}></Title>
<Text type="secondary" style={{ display: 'block', marginTop: 4, marginBottom: 12 }}>
eQuery
</Text>
<Space size={24}>
<span> <Text strong>{passRate}%</Text></span>
<span> eQuery <Text strong style={{ color: pendingEq > 0 ? '#faad14' : '#52c41a' }}>{pendingEq}</Text></span>
<span> <Text strong style={{ color: criticalCount > 0 ? '#ff4d4f' : '#52c41a' }}>{criticalCount}</Text></span>
</Space>
</Col>
</Row>
</Card>
{/* Core Stats Cards */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card hoverable onClick={() => navigate(`/iit/reports?projectId=${projectId}`)}>
<Statistic
title="整体合规率"
value={stats?.passRate ?? 0}
suffix="%"
prefix={<CheckCircleOutlined style={{ color: '#10b981' }} />}
/>
<Progress percent={stats?.passRate ?? 0} showInfo={false} strokeColor="#10b981" size="small" style={{ marginTop: 4 }} />
</Card>
</Col>
<Col span={6}>
<Card hoverable onClick={() => navigate(`/iit/equery?projectId=${projectId}`)}>
<Statistic
title="待处理 eQuery"
value={pendingEq}
prefix={<WarningOutlined style={{ color: '#f59e0b' }} />}
valueStyle={{ color: pendingEq > 0 ? '#f59e0b' : undefined }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{equeryStats?.total ?? 0} | {equeryStats?.closed ?? 0}
</Text>
</Card>
</Col>
<Col span={6}>
<Card hoverable>
<Statistic
title="累计重大事件"
value={criticalCount}
prefix={<CloseCircleOutlined style={{ color: '#ef4444' }} />}
valueStyle={{ color: criticalCount > 0 ? '#ef4444' : undefined }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
SAE +
</Text>
</Card>
</Col>
<Col span={6}>
<Card hoverable>
<Statistic
title="已审查受试者"
value={stats?.totalRecords ?? 0}
prefix={<TeamOutlined style={{ color: '#3b82f6' }} />}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{stats?.passedRecords ?? 0} | {stats?.failedRecords ?? 0}
</Text>
</Card>
</Col>
</Row>
{/* Critical Event Alerts */}
{criticalEvents.length > 0 && (
<div style={{ marginBottom: 16 }}>
{criticalEvents.map((evt) => (
<Alert
key={evt.id}
type="error"
showIcon
icon={<AlertOutlined />}
message={
<Space>
<Tag color="error">{evt.eventType}</Tag>
<Text strong> {evt.recordId}</Text>
<Text>{evt.title}</Text>
</Space>
}
description={
<Text type="secondary" style={{ fontSize: 12 }}>
: {new Date(evt.detectedAt).toLocaleString('zh-CN')} | : {evt.detectedBy === 'ai' ? 'AI 自动' : '手动'}
</Text>
}
style={{ marginBottom: 8 }}
closable
/>
))}
</div>
)}
{/* Trend Chart (Simple CSS-based) */}
<Card
title={
<Space>
<RiseOutlined />
<span>30</span>
</Space>
}
style={{ marginBottom: 16 }}
>
{trend.length > 0 ? (
<div>
<div style={{ display: 'flex', alignItems: 'end', gap: 2, height: 120, padding: '0 4px' }}>
{trend.map((point, idx) => {
const barColor = point.passRate >= 80 ? '#52c41a' : point.passRate >= 60 ? '#faad14' : '#ff4d4f';
return (
<Tooltip
key={idx}
title={`${point.date}: ${point.passRate}% (${point.passed}/${point.total})`}
>
<div
style={{
flex: 1,
height: `${Math.max(point.passRate, 2)}%`,
backgroundColor: barColor,
borderRadius: '2px 2px 0 0',
minWidth: 4,
transition: 'height 0.3s',
cursor: 'pointer',
}}
/>
</Tooltip>
);
})}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 10 }}>{trend[0]?.date}</Text>
<Text type="secondary" style={{ fontSize: 10 }}>{trend[trend.length - 1]?.date}</Text>
</div>
</div>
) : (
<Empty description="暂无趋势数据,首次质控后将自动生成" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
{/* Heatmap */}
<Card
title={
<Space>
<ThunderboltOutlined />
<span> × </span>
</Space>
}
style={{ marginBottom: 16 }}
>
{heatmap && heatmap.rows?.length > 0 ? (
<div style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 12 }}>
<thead>
<tr>
<th style={{ padding: '6px 10px', borderBottom: '2px solid #e2e8f0', textAlign: 'left', color: '#64748b', fontWeight: 600 }}>
</th>
{(heatmap.columns || []).map((col: string, i: number) => (
<th key={i} style={{ padding: '6px 8px', borderBottom: '2px solid #e2e8f0', textAlign: 'center', color: '#64748b', fontWeight: 600, whiteSpace: 'nowrap' }}>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{heatmap.rows.map((row: any, ri: number) => (
<tr key={ri}>
<td style={{ padding: '4px 10px', borderBottom: '1px solid #f1f5f9', fontWeight: 600 }}>
{row.recordId}
</td>
{(row.cells || []).map((cell: any, ci: number) => {
const bg = cell.status === 'pass' ? '#dcfce7' : cell.status === 'warning' ? '#fef3c7' : cell.status === 'fail' ? '#fecaca' : '#f1f5f9';
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} 个问题`}>
<div style={{
background: bg,
border: `1px solid ${border}`,
borderRadius: 4,
padding: '4px 6px',
textAlign: 'center',
cursor: 'pointer',
minWidth: 32,
fontSize: 11,
fontWeight: cell.issueCount > 0 ? 700 : 400,
color: cell.status === 'fail' ? '#dc2626' : cell.status === 'warning' ? '#d97706' : '#16a34a',
}}>
{cell.issueCount > 0 ? cell.issueCount : '✓'}
</div>
</Tooltip>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<Empty description="质控完成后将自动生成风险热力图" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
{/* Quick Links */}
<Row gutter={16}>
<Col span={8}>
<Card
hoverable
onClick={() => navigate(`/iit/stream?projectId=${projectId}`)}
style={{ textAlign: 'center' }}
>
<ThunderboltOutlined style={{ fontSize: 24, color: '#3b82f6', marginBottom: 8 }} />
<div style={{ fontWeight: 600 }}>AI </div>
<Text type="secondary" style={{ fontSize: 12 }}> Agent </Text>
</Card>
</Col>
<Col span={8}>
<Card
hoverable
onClick={() => navigate(`/iit/equery?projectId=${projectId}`)}
style={{ textAlign: 'center' }}
>
<AlertOutlined style={{ fontSize: 24, color: '#f59e0b', marginBottom: 8 }} />
<div style={{ fontWeight: 600 }}>
eQuery
{pendingEq > 0 && <Badge count={pendingEq} offset={[8, -2]} />}
</div>
<Text type="secondary" style={{ fontSize: 12 }}> AI </Text>
</Card>
</Col>
<Col span={8}>
<Card
hoverable
onClick={() => navigate(`/iit/reports?projectId=${projectId}`)}
style={{ textAlign: 'center' }}
>
<FileSearchOutlined style={{ fontSize: 24, color: '#10b981', marginBottom: 8 }} />
<div style={{ fontWeight: 600 }}></div>
<Text type="secondary" style={{ fontSize: 12 }}> + </Text>
</Card>
</Col>
</Row>
</div>
);
};
export default DashboardPage;