QC System Deep Fix: - HardRuleEngine: add null tolerance + field availability pre-check (skipped status) - SkillRunner: baseline data merge for follow-up events + field availability check - QcReportService: record-level pass rate calculation + accurate LLM XML report - iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary - seed-iit-qc-rules: null/empty string tolerance + applicableEvents config V3.1 Architecture Design (docs only, no code changes): - QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions - Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines) - Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions - CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts Project Member Management: - Cross-tenant member search and assignment (remove tenant restriction) - IIT project detail page enhancement with tabbed layout (KB + members) - IitProjectContext for business-side project selection - System-KB route access control adjustment for project operators Frontend: - AdminLayout sidebar menu restructure - IitLayout with project context provider - IitMemberManagePage new component - Business-side pages adapt to project context Prisma: - 2 new migrations (user-project RBAC + is_demo flag) - Schema updates for project member management Made-with: Cursor
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
/**
|
||
* 项目健康度大盘 (Level 1)
|
||
*
|
||
* 健康度评分 + 核心数据卡片 + 趋势折线图 + 热力图 + 事件预警
|
||
*/
|
||
|
||
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { 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';
|
||
import { useIitProject } from '../context/IitProjectContext';
|
||
|
||
const { Text, Title } = Typography;
|
||
|
||
const DashboardPage: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const { projectId } = useIitProject();
|
||
|
||
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 ? '需关注' : '风险';
|
||
|
||
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;
|