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