Files
AIclinicalresearch/frontend-v2/src/modules/iit/pages/DashboardPage.tsx
HaHafeng 0b29fe88b5 feat(iit): QC deep fix + V3.1 architecture plan + project member management
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
2026-03-01 15:27:05 +08:00

356 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 项目健康度大盘 (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;