feat(iit): Implement real-time quality control system

Summary:

- Add 4 new database tables: iit_field_metadata, iit_qc_logs, iit_record_summary, iit_qc_project_stats

- Implement pg-boss debounce mechanism in WebhookController

- Refactor QC Worker for dual output: QC logs + record summary

- Enhance HardRuleEngine to support form-based rule filtering

- Create QcService for QC data queries

- Optimize ChatService with new intents: query_enrollment, query_qc_status

- Add admin batch operations: one-click full QC + one-click full summary

- Create IIT Admin management module: project config, QC rules, user mapping

Status: Code complete, pending end-to-end testing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-07 21:56:11 +08:00
parent 0c590854b5
commit 5db4a7064c
74 changed files with 13383 additions and 2129 deletions

View File

@@ -0,0 +1,262 @@
/**
* IIT 项目管理 API
*/
import apiClient from '@/common/api/axios';
import type {
IitProject,
CreateProjectRequest,
UpdateProjectRequest,
TestConnectionRequest,
TestConnectionResult,
QCRule,
CreateRuleRequest,
RuleStats,
IitUserMapping,
CreateUserMappingRequest,
UpdateUserMappingRequest,
RoleOption,
KnowledgeBaseOption,
} from '../types/iitProject';
const BASE_URL = '/api/v1/admin/iit-projects';
// ==================== 项目 CRUD ====================
/** 获取项目列表 */
export async function listProjects(params?: {
status?: string;
search?: string;
}): Promise<IitProject[]> {
const response = await apiClient.get(BASE_URL, { params });
return response.data.data;
}
/** 获取项目详情 */
export async function getProject(id: string): Promise<IitProject> {
const response = await apiClient.get(`${BASE_URL}/${id}`);
return response.data.data;
}
/** 创建项目 */
export async function createProject(data: CreateProjectRequest): Promise<IitProject> {
const response = await apiClient.post(BASE_URL, data);
return response.data.data;
}
/** 更新项目 */
export async function updateProject(
id: string,
data: UpdateProjectRequest
): Promise<IitProject> {
const response = await apiClient.put(`${BASE_URL}/${id}`, data);
return response.data.data;
}
/** 删除项目 */
export async function deleteProject(id: string): Promise<void> {
await apiClient.delete(`${BASE_URL}/${id}`);
}
// ==================== REDCap 连接 ====================
/** 测试 REDCap 连接(新配置) */
export async function testConnection(
data: TestConnectionRequest
): Promise<TestConnectionResult> {
const response = await apiClient.post(`${BASE_URL}/test-connection`, data);
return response.data.data;
}
/** 测试项目的 REDCap 连接 */
export async function testProjectConnection(
projectId: string
): Promise<TestConnectionResult> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/test-connection`);
return response.data.data;
}
/** 同步 REDCap 元数据 */
export async function syncMetadata(projectId: string): Promise<{
success: boolean;
fieldCount: number;
}> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/sync-metadata`);
return response.data.data;
}
// ==================== 知识库关联 ====================
/** 关联知识库 */
export async function linkKnowledgeBase(
projectId: string,
knowledgeBaseId: string
): Promise<void> {
await apiClient.post(`${BASE_URL}/${projectId}/knowledge-base`, { knowledgeBaseId });
}
/** 解除知识库关联 */
export async function unlinkKnowledgeBase(projectId: string): Promise<void> {
await apiClient.delete(`${BASE_URL}/${projectId}/knowledge-base`);
}
/** 获取可用知识库列表 */
export async function listKnowledgeBases(): Promise<KnowledgeBaseOption[]> {
const response = await apiClient.get('/api/v1/admin/system-kb');
return response.data.data;
}
// ==================== 质控规则 ====================
/** 获取项目的质控规则列表 */
export async function listRules(projectId: string): Promise<QCRule[]> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/rules`);
return response.data.data;
}
/** 获取规则统计 */
export async function getRuleStats(projectId: string): Promise<RuleStats> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/rules/stats`);
return response.data.data;
}
/** 获取单条规则 */
export async function getRule(projectId: string, ruleId: string): Promise<QCRule> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/rules/${ruleId}`);
return response.data.data;
}
/** 添加规则 */
export async function addRule(
projectId: string,
data: CreateRuleRequest
): Promise<QCRule> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/rules`, data);
return response.data.data;
}
/** 更新规则 */
export async function updateRule(
projectId: string,
ruleId: string,
data: Partial<CreateRuleRequest>
): Promise<QCRule> {
const response = await apiClient.put(`${BASE_URL}/${projectId}/rules/${ruleId}`, data);
return response.data.data;
}
/** 删除规则 */
export async function deleteRule(projectId: string, ruleId: string): Promise<void> {
await apiClient.delete(`${BASE_URL}/${projectId}/rules/${ruleId}`);
}
/** 批量导入规则 */
export async function importRules(
projectId: string,
rules: CreateRuleRequest[]
): Promise<{ count: number; rules: QCRule[] }> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/import`, {
rules,
});
return response.data.data;
}
/** 测试规则逻辑 */
export async function testRule(
logic: Record<string, unknown>,
testData: Record<string, unknown>
): Promise<{ passed: boolean; result: unknown }> {
const response = await apiClient.post(`${BASE_URL}/rules/test`, { logic, testData });
return response.data.data;
}
// ==================== 用户映射 ====================
/** 获取角色选项 */
export async function getRoleOptions(): Promise<RoleOption[]> {
const response = await apiClient.get(`${BASE_URL}/roles`);
return response.data.data;
}
/** 获取项目的用户映射列表 */
export async function listUserMappings(
projectId: string,
params?: { role?: string; search?: string }
): Promise<IitUserMapping[]> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/users`, { params });
return response.data.data;
}
/** 获取用户映射统计 */
export async function getUserMappingStats(projectId: string): Promise<{
total: number;
byRole: Record<string, number>;
}> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/users/stats`);
return response.data.data;
}
/** 创建用户映射 */
export async function createUserMapping(
projectId: string,
data: CreateUserMappingRequest
): Promise<IitUserMapping> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/users`, data);
return response.data.data;
}
/** 更新用户映射 */
export async function updateUserMapping(
projectId: string,
mappingId: string,
data: UpdateUserMappingRequest
): Promise<IitUserMapping> {
const response = await apiClient.put(
`${BASE_URL}/${projectId}/users/${mappingId}`,
data
);
return response.data.data;
}
/** 删除用户映射 */
export async function deleteUserMapping(
projectId: string,
mappingId: string
): Promise<void> {
await apiClient.delete(`${BASE_URL}/${projectId}/users/${mappingId}`);
}
// ==================== 批量操作 ====================
/** 一键全量质控 */
export async function batchQualityCheck(projectId: string): Promise<{
success: boolean;
message: string;
stats: {
totalRecords: number;
passed: number;
failed: number;
warnings: number;
passRate: string;
};
durationMs: number;
}> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/batch-qc`);
return response.data;
}
/** 一键全量数据汇总 */
export async function batchSummary(projectId: string): Promise<{
success: boolean;
message: string;
stats: {
totalRecords: number;
summariesUpdated: number;
totalForms: number;
avgCompletionRate: string;
};
durationMs: number;
}> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/batch-summary`);
return response.data;
}

View File

@@ -7,6 +7,7 @@
* - 租户管理(已有)
* - Prompt管理已有
* - 系统知识库管理
* - IIT 项目管理
*/
import React from 'react';
@@ -17,6 +18,8 @@ import UserDetailPage from './pages/UserDetailPage';
import StatsDashboardPage from './pages/StatsDashboardPage';
import SystemKbListPage from './pages/SystemKbListPage';
import SystemKbDetailPage from './pages/SystemKbDetailPage';
import IitProjectListPage from './pages/IitProjectListPage';
import IitProjectDetailPage from './pages/IitProjectDetailPage';
const AdminModule: React.FC = () => {
return (
@@ -35,6 +38,10 @@ const AdminModule: React.FC = () => {
{/* 系统知识库管理 */}
<Route path="system-kb" element={<SystemKbListPage />} />
<Route path="system-kb/:id" element={<SystemKbDetailPage />} />
{/* IIT 项目管理 */}
<Route path="iit-projects" element={<IitProjectListPage />} />
<Route path="iit-projects/:id" element={<IitProjectDetailPage />} />
</Routes>
);
};

View File

@@ -0,0 +1,912 @@
/**
* IIT 项目配置详情页
*
* 包含4个Tab基本配置、质控规则、用户映射、知识库
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card,
Tabs,
Button,
Form,
Input,
Select,
Table,
Modal,
message,
Popconfirm,
Typography,
Space,
Tag,
Spin,
Alert,
Descriptions,
Empty,
Tooltip,
Badge,
} from 'antd';
import {
ArrowLeftOutlined,
SaveOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
LinkOutlined,
DisconnectOutlined,
ExclamationCircleOutlined,
BookOutlined,
ThunderboltOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type {
IitProject,
UpdateProjectRequest,
QCRule,
CreateRuleRequest,
IitUserMapping,
CreateUserMappingRequest,
RoleOption,
KnowledgeBaseOption,
} from '../types/iitProject';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
// ==================== 常量定义 ====================
const SEVERITY_MAP = {
error: { color: 'error', text: '错误' },
warning: { color: 'warning', text: '警告' },
info: { color: 'processing', text: '信息' },
};
const CATEGORY_MAP = {
inclusion: { color: '#52c41a', text: '纳入标准' },
exclusion: { color: '#ff4d4f', text: '排除标准' },
lab_values: { color: '#1890ff', text: '变量范围' },
logic_check: { color: '#722ed1', text: '逻辑检查' },
};
// ==================== 主组件 ====================
const IitProjectDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [project, setProject] = useState<IitProject | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('basic');
const [batchQcLoading, setBatchQcLoading] = useState(false);
const [batchSummaryLoading, setBatchSummaryLoading] = useState(false);
// 加载项目详情
const loadProject = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const data = await iitProjectApi.getProject(id);
setProject(data);
} catch (error) {
message.error('加载项目详情失败');
navigate('/admin/iit-projects');
} finally {
setLoading(false);
}
}, [id, navigate]);
useEffect(() => {
loadProject();
}, [loadProject]);
// ⭐ 一键全量质控
const handleBatchQc = async () => {
if (!id) return;
setBatchQcLoading(true);
try {
const result = await iitProjectApi.batchQualityCheck(id);
message.success(`质控完成!共 ${result.stats.totalRecords} 条记录,通过率 ${result.stats.passRate}`);
} catch (error: any) {
message.error(error.message || '质控失败');
} finally {
setBatchQcLoading(false);
}
};
// ⭐ 一键全量数据汇总
const handleBatchSummary = async () => {
if (!id) return;
setBatchSummaryLoading(true);
try {
const result = await iitProjectApi.batchSummary(id);
message.success(`汇总完成!共 ${result.stats.totalRecords} 条记录,平均完成率 ${result.stats.avgCompletionRate}`);
} catch (error: any) {
message.error(error.message || '汇总失败');
} finally {
setBatchSummaryLoading(false);
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Spin size="large" />
</div>
);
}
if (!project) {
return <Empty description="项目不存在" />;
}
const tabItems = [
{
key: 'basic',
label: 'REDCap 配置',
children: <BasicConfigTab project={project} onUpdate={loadProject} />,
},
{
key: 'rules',
label: '质控规则',
children: <QCRulesTab projectId={project.id} />,
},
{
key: 'users',
label: '通知设置',
children: <UserMappingTab projectId={project.id} />,
},
{
key: 'kb',
label: '知识库',
children: <KnowledgeBaseTab project={project} onUpdate={loadProject} />,
},
];
return (
<div style={{ padding: 24 }}>
{/* 页面标题 */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Space align="center">
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/iit-projects')}>
</Button>
<Title level={3} style={{ margin: 0 }}>
{project.name}
</Title>
<Badge status={project.status === 'active' ? 'success' : 'warning'} />
</Space>
{/* ⭐ 批量操作按钮 */}
<Space>
<Button
type="primary"
icon={<ThunderboltOutlined />}
loading={batchQcLoading}
onClick={handleBatchQc}
>
</Button>
<Button
icon={<BarChartOutlined />}
loading={batchSummaryLoading}
onClick={handleBatchSummary}
>
</Button>
</Space>
</div>
{project.description && (
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
{project.description}
</Paragraph>
)}
</div>
{/* Tab 页 */}
<Card>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</Card>
</div>
);
};
// ==================== Tab 1: 基本配置 ====================
interface BasicConfigTabProps {
project: IitProject;
onUpdate: () => void;
}
const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) => {
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
form.setFieldsValue({
name: project.name,
description: project.description,
redcapUrl: project.redcapUrl,
redcapProjectId: project.redcapProjectId,
redcapApiToken: project.redcapApiToken,
});
}, [form, project]);
const handleSave = async (values: UpdateProjectRequest) => {
setSaving(true);
try {
await iitProjectApi.updateProject(project.id, values);
message.success('保存成功');
onUpdate();
} catch (error) {
message.error('保存失败');
} finally {
setSaving(false);
}
};
const handleTestConnection = async () => {
setTesting(true);
try {
const result = await iitProjectApi.testProjectConnection(project.id);
if (result.success) {
message.success(`连接成功REDCap 版本: ${result.version},记录数: ${result.recordCount}`);
} else {
message.error(`连接失败: ${result.error}`);
}
} catch (error) {
message.error('测试连接失败');
} finally {
setTesting(false);
}
};
const handleSyncMetadata = async () => {
setSyncing(true);
try {
const result = await iitProjectApi.syncMetadata(project.id);
message.success(`同步成功!共 ${result.fieldCount} 个字段`);
} catch (error) {
message.error('同步失败');
} finally {
setSyncing(false);
}
};
return (
<div>
<Form form={form} layout="vertical" onFinish={handleSave} style={{ maxWidth: 600 }}>
<Form.Item name="name" label="项目名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="项目描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="redcapUrl" label="REDCap URL" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="redcapProjectId" label="REDCap 项目 ID (PID)" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="redcapApiToken"
label="API Token"
rules={[{ required: true, message: '请输入 API Token' }]}
>
<Input.Password placeholder="REDCap 项目的 API Token" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
</Button>
<Button icon={<CheckCircleOutlined />} onClick={handleTestConnection} loading={testing}>
</Button>
<Button icon={<SyncOutlined />} onClick={handleSyncMetadata} loading={syncing}>
</Button>
</Space>
</Form.Item>
</Form>
<Descriptions title="项目信息" style={{ marginTop: 24 }} column={1} bordered>
<Descriptions.Item label="项目 ID">{project.id}</Descriptions.Item>
<Descriptions.Item label="创建时间">
{new Date(project.createdAt).toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="最后更新">
{new Date(project.updatedAt).toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="最后同步">
{project.lastSyncAt ? new Date(project.lastSyncAt).toLocaleString() : '从未同步'}
</Descriptions.Item>
</Descriptions>
</div>
);
};
// ==================== Tab 2: 质控规则 ====================
interface QCRulesTabProps {
projectId: string;
}
const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
const [rules, setRules] = useState<QCRule[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<QCRule | null>(null);
const [form] = Form.useForm();
const loadRules = useCallback(async () => {
setLoading(true);
try {
const data = await iitProjectApi.listRules(projectId);
setRules(data);
} catch (error) {
message.error('加载质控规则失败');
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadRules();
}, [loadRules]);
const handleAdd = () => {
setEditingRule(null);
form.resetFields();
setModalOpen(true);
};
const handleEdit = (rule: QCRule) => {
setEditingRule(rule);
form.setFieldsValue({
...rule,
field: Array.isArray(rule.field) ? rule.field.join(', ') : rule.field,
logic: JSON.stringify(rule.logic, null, 2),
});
setModalOpen(true);
};
const handleDelete = async (ruleId: string) => {
try {
await iitProjectApi.deleteRule(projectId, ruleId);
message.success('删除成功');
loadRules();
} catch (error) {
message.error('删除失败');
}
};
const handleSubmit = async (values: Record<string, unknown>) => {
try {
const fieldValue = values.field as string;
const logicValue = values.logic as string;
const ruleData: CreateRuleRequest = {
name: values.name as string,
field: fieldValue.includes(',') ? fieldValue.split(',').map((s) => s.trim()) : fieldValue,
logic: JSON.parse(logicValue),
message: values.message as string,
severity: values.severity as 'error' | 'warning' | 'info',
category: values.category as 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check',
};
if (editingRule) {
await iitProjectApi.updateRule(projectId, editingRule.id, ruleData);
message.success('更新成功');
} else {
await iitProjectApi.addRule(projectId, ruleData);
message.success('添加成功');
}
setModalOpen(false);
loadRules();
} catch (error) {
message.error('操作失败,请检查 JSON Logic 格式');
}
};
const columns = [
{
title: '规则名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 100,
render: (category: keyof typeof CATEGORY_MAP) => {
const cat = CATEGORY_MAP[category];
return <Tag color={cat?.color}>{cat?.text || category}</Tag>;
},
},
{
title: '严重程度',
dataIndex: 'severity',
key: 'severity',
width: 100,
render: (severity: keyof typeof SEVERITY_MAP) => {
const sev = SEVERITY_MAP[severity];
return <Tag color={sev?.color}>{sev?.text || severity}</Tag>;
},
},
{
title: '字段',
dataIndex: 'field',
key: 'field',
width: 150,
render: (field: string | string[]) => (
<Text code>{Array.isArray(field) ? field.join(', ') : field}</Text>
),
},
{
title: '错误信息',
dataIndex: 'message',
key: 'message',
ellipsis: true,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: unknown, record: QCRule) => (
<Space>
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
</Tooltip>
<Popconfirm title="确认删除此规则?" onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Alert
message="质控规则使用 JSON Logic 格式定义,用于数据质量检查"
type="info"
showIcon
style={{ flex: 1, marginRight: 16 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
<Table
columns={columns}
dataSource={rules}
rowKey="id"
loading={loading}
pagination={false}
size="small"
/>
<Modal
title={editingRule ? '编辑规则' : '添加规则'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
footer={null}
width={700}
>
<Form form={form} layout="vertical" onFinish={handleSubmit} style={{ marginTop: 24 }}>
<Form.Item name="name" label="规则名称" rules={[{ required: true }]}>
<Input placeholder="例如:年龄范围检查" />
</Form.Item>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select
options={Object.entries(CATEGORY_MAP).map(([value, { text }]) => ({
value,
label: text,
}))}
/>
</Form.Item>
<Form.Item name="severity" label="严重程度" rules={[{ required: true }]}>
<Select
options={Object.entries(SEVERITY_MAP).map(([value, { text }]) => ({
value,
label: text,
}))}
/>
</Form.Item>
<Form.Item
name="field"
label="字段名"
rules={[{ required: true }]}
extra="多个字段用逗号分隔"
>
<Input placeholder="例如age 或 age, gender" />
</Form.Item>
<Form.Item
name="logic"
label="JSON Logic 表达式"
rules={[
{ required: true },
{
validator: (_, value) => {
try {
JSON.parse(value);
return Promise.resolve();
} catch {
return Promise.reject('请输入有效的 JSON');
}
},
},
]}
>
<TextArea
rows={6}
placeholder={'{\n "and": [\n {">=": [{"var": "age"}, 16]},\n {"<=": [{"var": "age"}, 35]}\n ]\n}'}
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
<Form.Item name="message" label="错误信息" rules={[{ required: true }]}>
<Input placeholder="例如:年龄必须在 16-35 岁之间" />
</Form.Item>
<Form.Item>
<Space>
<Button onClick={() => setModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
{editingRule ? '更新' : '添加'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
// ==================== Tab 3: 用户映射 ====================
interface UserMappingTabProps {
projectId: string;
}
const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
const [mappings, setMappings] = useState<IitUserMapping[]>([]);
const [roleOptions, setRoleOptions] = useState<RoleOption[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingMapping, setEditingMapping] = useState<IitUserMapping | null>(null);
const [form] = Form.useForm();
const loadData = useCallback(async () => {
setLoading(true);
try {
const [mappingsData, rolesData] = await Promise.all([
iitProjectApi.listUserMappings(projectId),
iitProjectApi.getRoleOptions(),
]);
setMappings(mappingsData);
setRoleOptions(rolesData);
} catch (error) {
message.error('加载数据失败');
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadData();
}, [loadData]);
const handleAdd = () => {
setEditingMapping(null);
form.resetFields();
setModalOpen(true);
};
const handleEdit = (mapping: IitUserMapping) => {
setEditingMapping(mapping);
form.setFieldsValue(mapping);
setModalOpen(true);
};
const handleDelete = async (mappingId: string) => {
try {
await iitProjectApi.deleteUserMapping(projectId, mappingId);
message.success('删除成功');
loadData();
} catch (error) {
message.error('删除失败');
}
};
const handleSubmit = async (values: CreateUserMappingRequest) => {
try {
if (editingMapping) {
await iitProjectApi.updateUserMapping(projectId, editingMapping.id, values);
message.success('更新成功');
} else {
await iitProjectApi.createUserMapping(projectId, values);
message.success('添加成功');
}
setModalOpen(false);
loadData();
} catch (error) {
message.error('操作失败');
}
};
const columns = [
{
title: '企业微信用户 ID',
dataIndex: 'wecomUserId',
key: 'wecomUserId',
render: (id: string | undefined) => id || <Text type="secondary"></Text>,
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 120,
render: (role: string) => {
const option = roleOptions.find((r) => r.value === role);
return <Tag>{option?.label || role}</Tag>;
},
},
{
title: '操作',
key: 'action',
width: 100,
render: (_: unknown, record: IitUserMapping) => (
<Space>
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
</Tooltip>
<Popconfirm title="确认删除此通知接收人?" onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
<Table
columns={columns}
dataSource={mappings}
rowKey="id"
loading={loading}
pagination={false}
size="small"
/>
<Modal
title={editingMapping ? '编辑通知接收人' : '添加通知接收人'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
footer={null}
>
<Form form={form} layout="vertical" onFinish={handleSubmit} style={{ marginTop: 24 }}>
<Form.Item
name="wecomUserId"
label="企业微信用户 ID"
rules={[{ required: true, message: '请输入企业微信用户 ID' }]}
extra="用于接收质控通知FengZhiBo"
>
<Input placeholder="企业微信通讯录中的用户 ID" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select options={roleOptions} placeholder="选择角色(可选)" allowClear />
</Form.Item>
<Form.Item
name="systemUserId"
label="系统用户 ID"
extra="可选,用于系统内部标识"
>
<Input placeholder="例如user_001可选" />
</Form.Item>
<Form.Item
name="redcapUsername"
label="REDCap 用户名"
extra="可选REDCap 中的用户名"
>
<Input placeholder="REDCap 用户名(可选)" />
</Form.Item>
<Form.Item>
<Space>
<Button onClick={() => setModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
{editingMapping ? '更新' : '添加'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
// ==================== Tab 4: 知识库 ====================
interface KnowledgeBaseTabProps {
project: IitProject;
onUpdate: () => void;
}
const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }) => {
const [kbList, setKbList] = useState<KnowledgeBaseOption[]>([]);
const [loading, setLoading] = useState(true);
const [linking, setLinking] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const loadKbList = async () => {
setLoading(true);
try {
const data = await iitProjectApi.listKnowledgeBases();
setKbList(data);
} catch (error) {
message.error('加载知识库列表失败');
} finally {
setLoading(false);
}
};
loadKbList();
}, []);
const handleLink = async (kbId: string) => {
setLinking(true);
try {
await iitProjectApi.linkKnowledgeBase(project.id, kbId);
message.success('关联成功');
onUpdate();
} catch (error) {
message.error('关联失败');
} finally {
setLinking(false);
}
};
const handleUnlink = async () => {
setLinking(true);
try {
await iitProjectApi.unlinkKnowledgeBase(project.id);
message.success('解除关联成功');
onUpdate();
} catch (error) {
message.error('操作失败');
} finally {
setLinking(false);
}
};
if (loading) {
return <Spin />;
}
return (
<div>
{project.knowledgeBase ? (
<div>
<Alert
message="已关联知识库"
description={
<div style={{ marginTop: 8 }}>
<Space direction="vertical">
<Text>
<BookOutlined style={{ marginRight: 8 }} />
{project.knowledgeBase.name}
</Text>
<Text type="secondary">
: {project.knowledgeBase.documentCount}
</Text>
</Space>
</div>
}
type="success"
showIcon
action={
<Space direction="vertical">
<Button
size="small"
onClick={() => navigate(`/admin/system-kb/${project.knowledgeBase?.id}`)}
>
</Button>
<Popconfirm title="确认解除关联?" onConfirm={handleUnlink}>
<Button size="small" danger icon={<DisconnectOutlined />}>
</Button>
</Popconfirm>
</Space>
}
/>
<div style={{ marginTop: 24 }}>
<Title level={5}></Title>
<Text type="secondary">
CRFAI
</Text>
<div style={{ marginTop: 16 }}>
<Space wrap>
<Tag color="blue"></Tag>
<Tag color="green">CRF</Tag>
<Tag color="orange"></Tag>
<Tag color="purple"></Tag>
<Tag color="cyan"></Tag>
<Tag color="magenta"></Tag>
<Tag></Tag>
</Space>
</div>
</div>
</div>
) : (
<div>
<Alert
message="未关联知识库"
description="请选择或创建一个知识库,用于存储研究方案、入排标准等文档。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
<Title level={5} style={{ marginTop: 24 }}>
</Title>
{kbList.length === 0 ? (
<Empty description="暂无可用知识库">
<Button type="primary" onClick={() => navigate('/admin/system-kb')}>
</Button>
</Empty>
) : (
<Select
style={{ width: 400 }}
placeholder="选择要关联的知识库"
loading={linking}
onChange={handleLink}
options={kbList.map((kb) => ({
value: kb.id,
label: kb.name,
}))}
/>
)}
</div>
)}
</div>
);
};
export default IitProjectDetailPage;

View File

@@ -0,0 +1,340 @@
/**
* 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,
Tag,
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';
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 IitProjectListPage: 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(`/admin/iit-projects/${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 }}>
{/* 页面标题 */}
<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 IitProjectListPage;

View File

@@ -0,0 +1,127 @@
/**
* IIT 项目管理类型定义
*/
// ==================== 项目相关 ====================
export interface IitProject {
id: string;
name: string;
description?: string;
redcapProjectId: string;
redcapUrl: string;
redcapApiToken?: string;
knowledgeBaseId?: string;
status: string;
lastSyncAt?: string;
createdAt: string;
updatedAt: string;
userMappingCount?: number;
skillCount?: number;
knowledgeBase?: {
id: string;
name: string;
documentCount: number;
};
userMappings?: IitUserMapping[];
}
export interface CreateProjectRequest {
name: string;
description?: string;
redcapUrl: string;
redcapProjectId: string;
redcapApiToken: string;
knowledgeBaseId?: string;
}
export interface UpdateProjectRequest {
name?: string;
description?: string;
redcapUrl?: string;
redcapProjectId?: string;
redcapApiToken?: string;
knowledgeBaseId?: string;
status?: string;
}
export interface TestConnectionRequest {
redcapUrl: string;
redcapApiToken: string;
}
export interface TestConnectionResult {
success: boolean;
version?: string;
recordCount?: number;
error?: string;
}
// ==================== 质控规则相关 ====================
export interface QCRule {
id: string;
name: string;
field: string | string[];
logic: Record<string, unknown>;
message: string;
severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
metadata?: Record<string, unknown>;
}
export interface CreateRuleRequest {
name: string;
field: string | string[];
logic: Record<string, unknown>;
message: string;
severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
metadata?: Record<string, unknown>;
}
export interface RuleStats {
total: number;
byCategory: Record<string, number>;
bySeverity: Record<string, number>;
}
// ==================== 用户映射相关 ====================
export interface IitUserMapping {
id: string;
projectId: string;
systemUserId: string;
redcapUsername: string;
wecomUserId?: string;
role: string;
createdAt: string;
updatedAt: string;
}
export interface CreateUserMappingRequest {
systemUserId: string;
redcapUsername: string;
wecomUserId?: string;
role: string;
}
export interface UpdateUserMappingRequest {
systemUserId?: string;
redcapUsername?: string;
wecomUserId?: string;
role?: string;
}
export interface RoleOption {
value: string;
label: string;
}
// ==================== 知识库相关 ====================
export interface KnowledgeBaseOption {
id: string;
name: string;
documentCount?: number;
}

View File

@@ -987,9 +987,7 @@
.message-bubble .markdown-content h2 {
font-size: 1.2em;
}
.message-bubble .markdown-content h3 {
}.message-bubble .markdown-content h3 {
font-size: 1.1em;
}