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:
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;
|
||||
Reference in New Issue
Block a user