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

Summary:

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

- Implement pg-boss debounce mechanism in WebhookController

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

- Enhance HardRuleEngine to support form-based rule filtering

- Create QcService for QC data queries

- Optimize ChatService with new intents: query_enrollment, query_qc_status

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

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

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

View File

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