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:
262
frontend-v2/src/modules/admin/api/iitProjectApi.ts
Normal file
262
frontend-v2/src/modules/admin/api/iitProjectApi.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
912
frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx
Normal file
912
frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx
Normal 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">
|
||||
上传研究方案、CRF、入排标准等文档到知识库,AI 将基于这些内容回答问题。
|
||||
</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;
|
||||
340
frontend-v2/src/modules/admin/pages/IitProjectListPage.tsx
Normal file
340
frontend-v2/src/modules/admin/pages/IitProjectListPage.tsx
Normal 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;
|
||||
127
frontend-v2/src/modules/admin/types/iitProject.ts
Normal file
127
frontend-v2/src/modules/admin/types/iitProject.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user