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:
359
frontend-v2/src/modules/iit/pages/DashboardPage.tsx
Normal file
359
frontend-v2/src/modules/iit/pages/DashboardPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user