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:
1048
frontend-v2/src/modules/iit/config/ProjectDetailPage.tsx
Normal file
1048
frontend-v2/src/modules/iit/config/ProjectDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
350
frontend-v2/src/modules/iit/config/ProjectListPage.tsx
Normal file
350
frontend-v2/src/modules/iit/config/ProjectListPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user