Files
AIclinicalresearch/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx

922 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
DashboardOutlined,
} 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={<DashboardOutlined />}
onClick={() => navigate(`/admin/iit-projects/${id}/cockpit`)}
style={{ background: '#722ed1' }}
>
</Button>
<Button
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;