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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,350 @@
/**
* IIT 项目列表页
*
* 卡片式展示所有 IIT 项目
*/
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Card,
Button,
Modal,
Form,
Input,
message,
Popconfirm,
Typography,
Empty,
Spin,
Row,
Col,
Badge,
Space,
Alert,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
SettingOutlined,
LinkOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ReloadOutlined,
DatabaseOutlined,
TeamOutlined,
BookOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { IitProject, CreateProjectRequest, TestConnectionResult } from '../types/iitProject';
import { ArrowLeftOutlined } from '@ant-design/icons';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
/** 项目状态标签 */
const STATUS_MAP: Record<string, { color: string; text: string }> = {
active: { color: 'success', text: '运行中' },
paused: { color: 'warning', text: '已暂停' },
deleted: { color: 'error', text: '已删除' },
};
const ProjectListPage: React.FC = () => {
const navigate = useNavigate();
const [projects, setProjects] = useState<IitProject[]>([]);
const [loading, setLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [connectionResult, setConnectionResult] = useState<TestConnectionResult | null>(null);
const [form] = Form.useForm();
// 加载项目列表
const loadProjects = async () => {
setLoading(true);
try {
const data = await iitProjectApi.listProjects();
setProjects(data);
} catch (error) {
message.error('加载项目列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadProjects();
}, []);
// 测试连接
const handleTestConnection = async () => {
try {
const values = await form.validateFields(['redcapUrl', 'redcapApiToken']);
setTestingConnection(true);
setConnectionResult(null);
const result = await iitProjectApi.testConnection({
redcapUrl: values.redcapUrl,
redcapApiToken: values.redcapApiToken,
});
setConnectionResult(result);
if (result.success) {
message.success(`连接成功REDCap 版本: ${result.version},记录数: ${result.recordCount}`);
} else {
message.error(`连接失败: ${result.error}`);
}
} catch (error) {
message.error('请先填写 REDCap URL 和 API Token');
} finally {
setTestingConnection(false);
}
};
// 创建项目
const handleCreate = async (values: CreateProjectRequest) => {
try {
await iitProjectApi.createProject(values);
message.success('创建项目成功');
setCreateModalOpen(false);
form.resetFields();
setConnectionResult(null);
loadProjects();
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '创建项目失败';
message.error(errorMessage);
}
};
// 删除项目
const handleDelete = async (id: string) => {
try {
await iitProjectApi.deleteProject(id);
message.success('删除项目成功');
loadProjects();
} catch (error) {
message.error('删除项目失败');
}
};
// 渲染项目卡片
const renderProjectCard = (project: IitProject) => {
const status = STATUS_MAP[project.status] || STATUS_MAP.active;
return (
<Col xs={24} sm={12} lg={8} xl={6} key={project.id}>
<Card
hoverable
style={{ height: '100%' }}
actions={[
<SettingOutlined
key="config"
onClick={() => navigate(`/iit/config/${project.id}`)}
/>,
<Popconfirm
key="delete"
title="确认删除此项目?"
description="删除后项目数据将无法恢复"
onConfirm={() => handleDelete(project.id)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<DeleteOutlined style={{ color: '#ff4d4f' }} />
</Popconfirm>,
]}
>
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={5} style={{ margin: 0 }}>
{project.name}
</Title>
<Badge status={status.color as 'success' | 'warning' | 'error'} text={status.text} />
</div>
{project.description && (
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{ marginTop: 8, marginBottom: 0 }}
>
{project.description}
</Paragraph>
)}
</div>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<LinkOutlined style={{ color: '#1890ff' }} />
<Text type="secondary" ellipsis style={{ flex: 1 }}>
{project.redcapUrl}
</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<DatabaseOutlined style={{ color: '#52c41a' }} />
<Text type="secondary"> ID: {project.redcapProjectId}</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<TeamOutlined style={{ color: '#722ed1' }} />
<Text type="secondary">: {project.userMappingCount || 0}</Text>
</div>
{project.knowledgeBaseId && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BookOutlined style={{ color: '#faad14' }} />
<Text type="secondary"></Text>
</div>
)}
</Space>
<div style={{ marginTop: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
: {new Date(project.createdAt).toLocaleDateString()}
</Text>
</div>
</Card>
</Col>
);
};
return (
<div style={{ padding: 24 }}>
{/* 返回按钮 */}
<Button
type="link"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/iit/dashboard')}
style={{ padding: 0, marginBottom: 16 }}
>
CRA
</Button>
{/* 页面标题 */}
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ margin: 0 }}>IIT </Title>
<Text type="secondary"> REDCap </Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
</Button>
</Space>
</div>
{/* 项目列表 */}
<Spin spinning={loading}>
{projects.length === 0 ? (
<Empty
description="暂无项目"
style={{ marginTop: 100 }}
>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
</Button>
</Empty>
) : (
<Row gutter={[16, 16]}>
{projects.map(renderProjectCard)}
</Row>
)}
</Spin>
{/* 创建项目弹窗 */}
<Modal
title="新建 IIT 项目"
open={createModalOpen}
onCancel={() => {
setCreateModalOpen(false);
form.resetFields();
setConnectionResult(null);
}}
footer={null}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleCreate}
style={{ marginTop: 24 }}
>
<Form.Item
name="name"
label="项目名称"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="例如:原发性痛经队列研究" />
</Form.Item>
<Form.Item name="description" label="项目描述">
<TextArea rows={2} placeholder="项目简要描述" />
</Form.Item>
<Form.Item
name="redcapUrl"
label="REDCap URL"
rules={[{ required: true, message: '请输入 REDCap URL' }]}
extra="例如http://localhost:8080 或 https://redcap.example.com"
>
<Input placeholder="http://localhost:8080" />
</Form.Item>
<Form.Item
name="redcapProjectId"
label="REDCap 项目 ID (PID)"
rules={[{ required: true, message: '请输入项目 ID' }]}
>
<Input placeholder="例如17" />
</Form.Item>
<Form.Item
name="redcapApiToken"
label="API Token"
rules={[{ required: true, message: '请输入 API Token' }]}
>
<Input.Password placeholder="REDCap 项目的 API Token" />
</Form.Item>
{/* 连接测试结果 */}
{connectionResult && (
<Form.Item>
<Alert
type={connectionResult.success ? 'success' : 'error'}
message={connectionResult.success ? '连接成功' : '连接失败'}
description={
connectionResult.success
? `REDCap 版本: ${connectionResult.version},当前记录数: ${connectionResult.recordCount}`
: connectionResult.error
}
showIcon
icon={connectionResult.success ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
/>
</Form.Item>
)}
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button onClick={handleTestConnection} loading={testingConnection}>
</Button>
<Space>
<Button onClick={() => {
setCreateModalOpen(false);
form.resetFields();
setConnectionResult(null);
}}>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ProjectListPage;