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,212 @@
/**
* AI 实时工作流水页 (Level 2)
*
* 以 Timeline 展示 Agent 每次质控的完整动作链AI 白盒化。
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Card,
Timeline,
Empty,
Tag,
Typography,
Space,
DatePicker,
Button,
Badge,
Pagination,
} from 'antd';
import {
ThunderboltOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
WarningOutlined,
SyncOutlined,
ClockCircleOutlined,
RobotOutlined,
ApiOutlined,
BellOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { TimelineItem } from '../api/iitProjectApi';
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 TRIGGER_TAG: Record<string, { color: string; label: string }> = {
webhook: { color: 'blue', label: 'EDC 触发' },
cron: { color: 'purple', label: '定时巡查' },
manual: { color: 'cyan', label: '手动执行' },
batch: { color: 'geekblue', label: '批量质控' },
};
const AiStreamPage: React.FC = () => {
const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId') || '';
const [items, setItems] = useState<TimelineItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [dateFilter, setDateFilter] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async () => {
if (!projectId) return;
setLoading(true);
try {
const result = await iitProjectApi.getTimeline(projectId, {
page,
pageSize: 30,
date: dateFilter,
});
setItems(result.items);
setTotal(result.total);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, [projectId, page, dateFilter]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleRefresh = () => {
setPage(1);
fetchData();
};
if (!projectId) {
return <div style={{ padding: 24 }}><Empty description="请先选择一个项目" /></div>;
}
const timelineItems = items.map((item) => {
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined /> };
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');
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>
<Tag color={triggerCfg.color} style={{ fontSize: 10, lineHeight: '18px', padding: '0 6px' }}>
{triggerCfg.label}
</Tag>
<Tag color={dotCfg.color} style={{ fontSize: 10, lineHeight: '18px', padding: '0 6px' }}>
{item.status}
</Tag>
</div>
{/* Description — the AI action chain */}
<div style={{
background: '#f8fafc',
borderRadius: 8,
padding: '8px 12px',
border: '1px solid #e2e8f0',
fontSize: 13,
lineHeight: 1.6,
}}>
<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>}
</Space>
<div style={{ marginLeft: 20 }}>
<Space size={4}>
<ApiOutlined style={{ color: '#8b5cf6' }} />
<Text> {item.details.rulesEvaluated} </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}>
<BellOutlined style={{ color: red > 0 ? '#ef4444' : '#f59e0b' }} />
{red > 0 && <Badge count={red} style={{ backgroundColor: '#ef4444' }} />}
{red > 0 && <Text type="danger"></Text>}
{yellow > 0 && <Badge count={yellow} style={{ backgroundColor: '#f59e0b' }} />}
{yellow > 0 && <Text style={{ color: '#d97706' }}></Text>}
</Space>
</div>
)}
</div>
</div>
),
};
});
return (
<div>
{/* Header */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Tag icon={<ThunderboltOutlined />} color="processing"></Tag>
<Badge count={total} overflowCount={9999} style={{ backgroundColor: '#3b82f6' }}>
<Text type="secondary"></Text>
</Badge>
</Space>
<Space>
<DatePicker
placeholder="按日期筛选"
onChange={(d) => {
setDateFilter(d ? d.format('YYYY-MM-DD') : undefined);
setPage(1);
}}
allowClear
/>
<Button icon={<SyncOutlined spin={loading} />} onClick={handleRefresh} loading={loading}>
</Button>
</Space>
</div>
{/* Timeline */}
<Card>
{items.length > 0 ? (
<>
<Timeline items={timelineItems} />
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Pagination
current={page}
total={total}
pageSize={30}
onChange={setPage}
showTotal={(t) => `${t}`}
size="small"
/>
</div>
</>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
loading
? '加载中...'
: '定时质控开启后Agent 的每一步推理和操作都将在此透明展示'
}
style={{ padding: '48px 0' }}
/>
)}
</Card>
</div>
);
};
export default AiStreamPage;