feat(iit): Complete CRA Agent V3.0 P0 milestone - autonomous QC pipeline
P0-1: Variable list sync from REDCap metadata P0-2: QC rule configuration with JSON Logic + AI suggestion P0-3: Scheduled QC + report generation + eQuery closed loop P0-4: Unified dashboard + AI stream timeline + critical events Backend: - Add IitEquery, IitCriticalEvent Prisma models + migration - Add cronEnabled/cronExpression to IitProject - Implement eQuery service/controller/routes (CRUD + respond/review/close) - Implement DailyQcOrchestrator (report -> eQuery -> critical events -> notify) - Add AI rule suggestion service - Register daily QC cron worker and eQuery auto-review worker - Extend QC cockpit with timeline, trend, critical events APIs - Fix timeline issues field compat (object vs array format) Frontend: - Create IIT business module with 6 pages (Dashboard, AI Stream, eQuery, Reports, Variable List + project config pages) - Migrate IIT config from admin panel to business module - Implement health score, risk heatmap, trend chart, critical event alerts - Register IIT module in App router and top navigation Testing: - Add E2E API test script covering 7 modules (46 assertions, all passing) Tested: E2E API tests 46/46 passed, backend and frontend verified Made-with: Cursor
This commit is contained in:
205
frontend-v2/src/modules/iit/IitLayout.tsx
Normal file
205
frontend-v2/src/modules/iit/IitLayout.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Spin, Button, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ThunderboltOutlined,
|
||||
FileSearchOutlined,
|
||||
AlertOutlined,
|
||||
SettingOutlined,
|
||||
LinkOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Sider, Content, Header } = Layout;
|
||||
|
||||
const siderMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
type: 'group',
|
||||
label: '全局与宏观概览',
|
||||
children: [
|
||||
{
|
||||
key: 'dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '项目健康度大盘',
|
||||
},
|
||||
{
|
||||
key: 'variables',
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '变量清单',
|
||||
},
|
||||
{
|
||||
key: 'reports',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: '定期报告与关键事件',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
label: 'AI 监查过程与细节',
|
||||
children: [
|
||||
{
|
||||
key: 'stream',
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: 'AI 实时工作流水',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
label: '人工介入与协作',
|
||||
children: [
|
||||
{
|
||||
key: 'equery',
|
||||
icon: <AlertOutlined />,
|
||||
label: '待处理 eQuery',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const viewTitles: Record<string, string> = {
|
||||
dashboard: '项目健康度大盘',
|
||||
variables: '变量清单',
|
||||
stream: 'AI 实时工作流水',
|
||||
equery: '待处理电子质疑 (eQuery)',
|
||||
reports: '定期报告与关键事件',
|
||||
};
|
||||
|
||||
const IitLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const pathSegments = location.pathname.split('/');
|
||||
const currentView = pathSegments[pathSegments.length - 1] || 'dashboard';
|
||||
const headerTitle = viewTitles[currentView] || 'CRA 质控平台';
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
navigate(`/iit/${key}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100%', background: '#f8fafc' }}>
|
||||
<Sider
|
||||
width={256}
|
||||
theme="dark"
|
||||
style={{
|
||||
background: '#0f172a',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Logo / 系统标题 */}
|
||||
<div style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px',
|
||||
borderBottom: '1px solid #1e293b',
|
||||
background: '#020617',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ fontSize: 22, color: '#3b82f6', marginRight: 8 }} />
|
||||
<span style={{ fontWeight: 700, fontSize: 16, color: '#fff', letterSpacing: 1 }}>
|
||||
AI 监查系统
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 当前项目信息 */}
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
|
||||
当前监控项目
|
||||
</div>
|
||||
<div style={{
|
||||
background: '#1e293b',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
border: '1px solid #334155',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#fff', marginBottom: 4 }}>IIT-2026-001</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>非小细胞肺癌真实世界研究</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<Menu
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
selectedKeys={[currentView]}
|
||||
onClick={handleMenuClick}
|
||||
items={siderMenuItems}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 底部:项目设置入口 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #1e293b',
|
||||
marginTop: 'auto',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}>
|
||||
<Tooltip title="跳转到项目配置界面(REDCap 连接、质控规则、知识库等)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => navigate('/iit/config')}
|
||||
style={{ color: '#94a3b8', width: '100%', textAlign: 'left', padding: '4px 12px' }}
|
||||
>
|
||||
项目设置
|
||||
<LinkOutlined style={{ marginLeft: 4, fontSize: 10 }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div style={{ fontSize: 11, color: '#475569', marginTop: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tag color="success" style={{ margin: 0, fontSize: 10 }}>EDC 直连</Tag>
|
||||
统一视图,全员可见
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
{/* 顶部标题栏 */}
|
||||
<Header style={{
|
||||
background: '#fff',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
padding: '0 24px',
|
||||
height: 64,
|
||||
lineHeight: '64px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: '#1e293b', margin: 0 }}>
|
||||
{headerTitle}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Tag icon={<LinkOutlined />} color="processing">EDC 实时直连中</Tag>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Content style={{
|
||||
overflow: 'auto',
|
||||
padding: 24,
|
||||
background: '#f8fafc',
|
||||
}}>
|
||||
<Suspense fallback={
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh' }}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default IitLayout;
|
||||
564
frontend-v2/src/modules/iit/api/iitProjectApi.ts
Normal file
564
frontend-v2/src/modules/iit/api/iitProjectApi.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* 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';
|
||||
import type {
|
||||
QcCockpitData,
|
||||
RecordDetail,
|
||||
} from '../types/qcCockpit';
|
||||
|
||||
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 interface FieldMetadata {
|
||||
id: string;
|
||||
projectId: string;
|
||||
fieldName: string;
|
||||
fieldLabel: string;
|
||||
fieldType: string;
|
||||
formName: string;
|
||||
sectionHeader?: string | null;
|
||||
validation?: string | null;
|
||||
validationMin?: string | null;
|
||||
validationMax?: string | null;
|
||||
choices?: string | null;
|
||||
required: boolean;
|
||||
branching?: string | null;
|
||||
alias?: string | null;
|
||||
ruleSource?: string | null;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
/** 获取字段元数据列表 */
|
||||
export async function listFieldMetadata(
|
||||
projectId: string,
|
||||
params?: { formName?: string; search?: string }
|
||||
): Promise<{ fields: FieldMetadata[]; total: number; forms: string[] }> {
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/field-metadata`, { params });
|
||||
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;
|
||||
}
|
||||
|
||||
/** AI 规则建议 */
|
||||
export async function suggestRules(
|
||||
projectId: string
|
||||
): Promise<CreateRuleRequest[]> {
|
||||
const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/suggest`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// ==================== eQuery ====================
|
||||
|
||||
export interface Equery {
|
||||
id: string;
|
||||
projectId: string;
|
||||
recordId: string;
|
||||
eventId?: string;
|
||||
formName?: string;
|
||||
fieldName?: string;
|
||||
queryText: string;
|
||||
expectedAction?: string;
|
||||
severity: string;
|
||||
category?: string;
|
||||
status: string;
|
||||
assignedTo?: string;
|
||||
respondedAt?: string;
|
||||
responseText?: string;
|
||||
reviewResult?: string;
|
||||
reviewNote?: string;
|
||||
reviewedAt?: string;
|
||||
closedAt?: string;
|
||||
closedBy?: string;
|
||||
resolution?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EqueryListResult {
|
||||
items: Equery[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface EqueryStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
responded: number;
|
||||
reviewing: number;
|
||||
closed: number;
|
||||
reopened: number;
|
||||
avgResolutionHours: number | null;
|
||||
}
|
||||
|
||||
/** 获取 eQuery 列表 */
|
||||
export async function listEqueries(
|
||||
projectId: string,
|
||||
params?: { status?: string; recordId?: string; severity?: string; page?: number; pageSize?: number }
|
||||
): Promise<EqueryListResult> {
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/equeries`, { params });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** 获取 eQuery 统计 */
|
||||
export async function getEqueryStats(projectId: string): Promise<EqueryStats> {
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/equeries/stats`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** 获取单条 eQuery */
|
||||
export async function getEquery(projectId: string, equeryId: string): Promise<Equery> {
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/equeries/${equeryId}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** CRC 回复 eQuery */
|
||||
export async function respondEquery(
|
||||
projectId: string,
|
||||
equeryId: string,
|
||||
data: { responseText: string }
|
||||
): Promise<Equery> {
|
||||
const response = await apiClient.post(`${BASE_URL}/${projectId}/equeries/${equeryId}/respond`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** 管理员手动复核 eQuery */
|
||||
export async function reviewEquery(
|
||||
projectId: string,
|
||||
equeryId: string,
|
||||
data: { passed: boolean; reviewNote?: string }
|
||||
): Promise<Equery> {
|
||||
const response = await apiClient.post(`${BASE_URL}/${projectId}/equeries/${equeryId}/review`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** 手动关闭 eQuery */
|
||||
export async function closeEquery(
|
||||
projectId: string,
|
||||
equeryId: string,
|
||||
data: { closedBy: string; resolution?: string }
|
||||
): Promise<Equery> {
|
||||
const response = await apiClient.post(`${BASE_URL}/${projectId}/equeries/${equeryId}/close`, data);
|
||||
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;
|
||||
}
|
||||
|
||||
// ==================== 质控驾驶舱 ====================
|
||||
|
||||
/** 获取质控驾驶舱数据 */
|
||||
export async function getQcCockpitData(projectId: string): Promise<QcCockpitData> {
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** 获取记录质控详情 */
|
||||
export async function getQcRecordDetail(
|
||||
projectId: string,
|
||||
recordId: string,
|
||||
formName: string
|
||||
): Promise<RecordDetail> {
|
||||
const response = await apiClient.get(
|
||||
`${BASE_URL}/${projectId}/qc-cockpit/records/${recordId}`,
|
||||
{ params: { formName } }
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// ==================== AI 时间线 ====================
|
||||
|
||||
export interface TimelineItem {
|
||||
id: string;
|
||||
type: 'qc_check';
|
||||
time: string;
|
||||
recordId: string;
|
||||
formName?: string;
|
||||
status: string;
|
||||
triggeredBy: string;
|
||||
description: string;
|
||||
details: {
|
||||
rulesEvaluated: number;
|
||||
rulesPassed: number;
|
||||
rulesFailed: number;
|
||||
issuesSummary: { red: number; yellow: number };
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取 AI 工作时间线 */
|
||||
export async function getTimeline(
|
||||
projectId: string,
|
||||
params?: { page?: number; pageSize?: number; date?: string }
|
||||
): Promise<{ items: TimelineItem[]; total: number; page: number; pageSize: number }> {
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/timeline`, { params });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// ==================== 重大事件 ====================
|
||||
|
||||
export interface CriticalEvent {
|
||||
id: string;
|
||||
projectId: string;
|
||||
recordId: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description: string;
|
||||
detectedAt: string;
|
||||
detectedBy: string;
|
||||
status: string;
|
||||
handledBy?: string;
|
||||
handledAt?: string;
|
||||
handlingNote?: string;
|
||||
reportedToEc: boolean;
|
||||
reportedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 获取重大事件列表 */
|
||||
export async function getCriticalEvents(
|
||||
projectId: string,
|
||||
params?: { status?: string; eventType?: string; page?: number; pageSize?: number }
|
||||
): Promise<{ items: CriticalEvent[]; total: number }> {
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/critical-events`, { params });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// ==================== 趋势 ====================
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
total: number;
|
||||
passed: number;
|
||||
passRate: number;
|
||||
}
|
||||
|
||||
/** 获取质控趋势 */
|
||||
export async function getTrend(projectId: string, days: number = 30): Promise<TrendPoint[]> {
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/trend`, { params: { days } });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** 质控报告类型 */
|
||||
export interface QcReport {
|
||||
projectId: string;
|
||||
reportType: 'daily' | 'weekly' | 'on_demand';
|
||||
generatedAt: string;
|
||||
expiresAt: string | null;
|
||||
summary: {
|
||||
totalRecords: number;
|
||||
completedRecords: number;
|
||||
criticalIssues: number;
|
||||
warningIssues: number;
|
||||
pendingQueries: number;
|
||||
passRate: number;
|
||||
lastQcTime: string | null;
|
||||
};
|
||||
criticalIssues: Array<{
|
||||
recordId: string;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
message: string;
|
||||
field?: string;
|
||||
actualValue?: any;
|
||||
expectedValue?: any;
|
||||
detectedAt: string;
|
||||
}>;
|
||||
warningIssues: Array<{
|
||||
recordId: string;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
message: string;
|
||||
field?: string;
|
||||
detectedAt: string;
|
||||
}>;
|
||||
formStats: Array<{
|
||||
formName: string;
|
||||
formLabel: string;
|
||||
totalChecks: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
passRate: number;
|
||||
}>;
|
||||
llmFriendlyXml: string;
|
||||
}
|
||||
|
||||
/** 获取质控报告 */
|
||||
export async function getQcReport(
|
||||
projectId: string,
|
||||
format: 'json' | 'xml' = 'json'
|
||||
): Promise<QcReport | string> {
|
||||
const response = await apiClient.get(
|
||||
`${BASE_URL}/${projectId}/qc-cockpit/report`,
|
||||
{
|
||||
params: { format },
|
||||
responseType: format === 'xml' ? 'text' : 'json',
|
||||
}
|
||||
);
|
||||
return format === 'xml' ? response.data : response.data.data;
|
||||
}
|
||||
|
||||
/** 刷新质控报告 */
|
||||
export async function refreshQcReport(projectId: string): Promise<QcReport> {
|
||||
const response = await apiClient.post(
|
||||
`${BASE_URL}/${projectId}/qc-cockpit/report/refresh`
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
518
frontend-v2/src/modules/iit/components/RuleTemplateBuilder.tsx
Normal file
518
frontend-v2/src/modules/iit/components/RuleTemplateBuilder.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* RuleTemplateBuilder - 可视化规则构建器
|
||||
*
|
||||
* 根据规则分类提供不同表单模板,自动生成 JSON Logic。
|
||||
* 支持"可视化模式"与"高级 JSON 模式"切换。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Select,
|
||||
InputNumber,
|
||||
Input,
|
||||
Switch,
|
||||
Card,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Alert,
|
||||
Divider,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import { CodeOutlined, FormOutlined } from '@ant-design/icons';
|
||||
import VariablePicker from './VariablePicker';
|
||||
import type { VariableInfo } from './VariablePicker';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
type RuleCategory =
|
||||
| 'lab_values'
|
||||
| 'inclusion'
|
||||
| 'exclusion'
|
||||
| 'logic_check'
|
||||
| 'variable_qc'
|
||||
| 'protocol_deviation'
|
||||
| 'ae_monitoring';
|
||||
|
||||
interface RuleTemplateBuilderProps {
|
||||
projectId: string;
|
||||
category: RuleCategory;
|
||||
/** Current JSON Logic value (object or JSON string) */
|
||||
value?: Record<string, unknown> | string;
|
||||
onChange?: (logic: Record<string, unknown>) => void;
|
||||
/** Current field(s) selection — synced from parent form */
|
||||
fields?: string | string[];
|
||||
onFieldsChange?: (fields: string | string[], variables: VariableInfo[]) => void;
|
||||
/** Auto-generate error message */
|
||||
onMessageSuggest?: (msg: string) => void;
|
||||
}
|
||||
|
||||
// ==================== Check type options per category ====================
|
||||
|
||||
const VARIABLE_CHECK_TYPES = [
|
||||
{ value: 'range', label: '数值范围' },
|
||||
{ value: 'required', label: '必填检查' },
|
||||
{ value: 'format', label: '格式校验' },
|
||||
{ value: 'enum', label: '枚举值检查' },
|
||||
];
|
||||
|
||||
const COMPARISON_OPERATORS = [
|
||||
{ value: '==', label: '等于 (=)' },
|
||||
{ value: '!=', label: '不等于 (!=)' },
|
||||
{ value: '>', label: '大于 (>)' },
|
||||
{ value: '>=', label: '大于等于 (>=)' },
|
||||
{ value: '<', label: '小于 (<)' },
|
||||
{ value: '<=', label: '小于等于 (<=)' },
|
||||
{ value: 'in', label: '属于 (in)' },
|
||||
];
|
||||
|
||||
// ==================== JSON Logic Generators ====================
|
||||
|
||||
function buildRangeLogic(fieldName: string, min?: number, max?: number): Record<string, unknown> {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
if (min !== undefined && min !== null) {
|
||||
conditions.push({ '>=': [{ var: fieldName }, min] });
|
||||
}
|
||||
if (max !== undefined && max !== null) {
|
||||
conditions.push({ '<=': [{ var: fieldName }, max] });
|
||||
}
|
||||
if (conditions.length === 0) return { '!!': [{ var: fieldName }] };
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
return { and: conditions };
|
||||
}
|
||||
|
||||
function buildRequiredLogic(fieldName: string): Record<string, unknown> {
|
||||
return { '!!': [{ var: fieldName }] };
|
||||
}
|
||||
|
||||
function buildEnumLogic(fieldName: string, allowedValues: (string | number)[]): Record<string, unknown> {
|
||||
return { in: [{ var: fieldName }, allowedValues] };
|
||||
}
|
||||
|
||||
function buildComparisonLogic(fieldName: string, operator: string, compareValue: string | number): Record<string, unknown> {
|
||||
if (operator === 'in') {
|
||||
const vals = typeof compareValue === 'string'
|
||||
? compareValue.split(',').map(v => v.trim())
|
||||
: [compareValue];
|
||||
return { in: [{ var: fieldName }, vals] };
|
||||
}
|
||||
return { [operator]: [{ var: fieldName }, compareValue] };
|
||||
}
|
||||
|
||||
function buildWindowLogic(dateField1: string, dateField2: string, maxDays: number): Record<string, unknown> {
|
||||
return {
|
||||
'<=': [
|
||||
{ '-': [{ var: dateField2 }, { var: dateField1 }] },
|
||||
maxDays,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildAeTimeLimitLogic(aeDateField: string, reportDateField: string, limitDays: number): Record<string, unknown> {
|
||||
return {
|
||||
'<=': [
|
||||
{ '-': [{ var: reportDateField }, { var: aeDateField }] },
|
||||
limitDays,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Component ====================
|
||||
|
||||
const RuleTemplateBuilder: React.FC<RuleTemplateBuilderProps> = ({
|
||||
projectId,
|
||||
category,
|
||||
value,
|
||||
onChange,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onMessageSuggest,
|
||||
}) => {
|
||||
const [advancedMode, setAdvancedMode] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
|
||||
// Template state for variable_qc / lab_values
|
||||
const [checkType, setCheckType] = useState<string>('range');
|
||||
const [rangeMin, setRangeMin] = useState<number | undefined>();
|
||||
const [rangeMax, setRangeMax] = useState<number | undefined>();
|
||||
const [enumValues, setEnumValues] = useState<string>('');
|
||||
|
||||
// Template state for inclusion / exclusion
|
||||
const [operator, setOperator] = useState<string>('==');
|
||||
const [compareValue, setCompareValue] = useState<string>('');
|
||||
|
||||
// Template state for protocol_deviation
|
||||
const [windowDays, setWindowDays] = useState<number>(7);
|
||||
|
||||
// Template state for ae_monitoring
|
||||
const [limitDays, setLimitDays] = useState<number>(1);
|
||||
|
||||
// Selected variable info
|
||||
const [selectedVars, setSelectedVars] = useState<VariableInfo[]>([]);
|
||||
|
||||
// Sync external value to jsonText when in advanced mode
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const obj = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
setJsonText(typeof obj === 'string' ? obj : '');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const emitLogic = useCallback(
|
||||
(logic: Record<string, unknown>) => {
|
||||
onChange?.(logic);
|
||||
setJsonText(JSON.stringify(logic, null, 2));
|
||||
setJsonError('');
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// Build logic from visual form whenever inputs change (non-advanced mode)
|
||||
useEffect(() => {
|
||||
if (advancedMode) return;
|
||||
|
||||
const fieldName = Array.isArray(fields) ? fields[0] : fields;
|
||||
if (!fieldName) return;
|
||||
|
||||
let logic: Record<string, unknown> | null = null;
|
||||
let suggestedMessage = '';
|
||||
|
||||
const varLabel = selectedVars[0]?.fieldLabel || fieldName;
|
||||
|
||||
switch (category) {
|
||||
case 'lab_values':
|
||||
case 'variable_qc': {
|
||||
switch (checkType) {
|
||||
case 'range':
|
||||
logic = buildRangeLogic(fieldName, rangeMin, rangeMax);
|
||||
suggestedMessage = rangeMin !== undefined && rangeMax !== undefined
|
||||
? `${varLabel} 必须在 ${rangeMin} ~ ${rangeMax} 之间`
|
||||
: rangeMin !== undefined
|
||||
? `${varLabel} 必须 >= ${rangeMin}`
|
||||
: rangeMax !== undefined
|
||||
? `${varLabel} 必须 <= ${rangeMax}`
|
||||
: `${varLabel} 不可为空`;
|
||||
break;
|
||||
case 'required':
|
||||
logic = buildRequiredLogic(fieldName);
|
||||
suggestedMessage = `${varLabel} 不可为空`;
|
||||
break;
|
||||
case 'enum': {
|
||||
const vals = enumValues.split(',').map((v) => v.trim()).filter(Boolean);
|
||||
logic = buildEnumLogic(fieldName, vals);
|
||||
suggestedMessage = `${varLabel} 必须为以下值之一: ${vals.join(', ')}`;
|
||||
break;
|
||||
}
|
||||
case 'format':
|
||||
logic = buildRequiredLogic(fieldName);
|
||||
suggestedMessage = `${varLabel} 格式不正确`;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'inclusion':
|
||||
case 'exclusion': {
|
||||
logic = buildComparisonLogic(fieldName, operator, compareValue);
|
||||
const opLabel = COMPARISON_OPERATORS.find(o => o.value === operator)?.label || operator;
|
||||
suggestedMessage = category === 'inclusion'
|
||||
? `纳入标准: ${varLabel} ${opLabel} ${compareValue}`
|
||||
: `排除标准: ${varLabel} ${opLabel} ${compareValue}`;
|
||||
break;
|
||||
}
|
||||
case 'protocol_deviation': {
|
||||
const field2 = Array.isArray(fields) && fields[1] ? fields[1] : '';
|
||||
if (field2) {
|
||||
logic = buildWindowLogic(fieldName, field2, windowDays);
|
||||
suggestedMessage = `${fieldName} 与 ${field2} 间隔不得超过 ${windowDays} 天`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ae_monitoring': {
|
||||
const reportField = Array.isArray(fields) && fields[1] ? fields[1] : '';
|
||||
if (reportField) {
|
||||
logic = buildAeTimeLimitLogic(fieldName, reportField, limitDays);
|
||||
suggestedMessage = `AE 发生后需在 ${limitDays} 天内上报`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'logic_check': {
|
||||
// Generic — user should use advanced mode for complex logic
|
||||
logic = buildComparisonLogic(fieldName, operator, compareValue);
|
||||
suggestedMessage = `逻辑检查: ${varLabel} ${operator} ${compareValue}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (logic) {
|
||||
emitLogic(logic);
|
||||
}
|
||||
if (suggestedMessage) {
|
||||
onMessageSuggest?.(suggestedMessage);
|
||||
}
|
||||
// Intentionally not including all deps to avoid infinite loops — rebuild only on user-input changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [category, checkType, rangeMin, rangeMax, enumValues, operator, compareValue, windowDays, limitDays, fields, advancedMode]);
|
||||
|
||||
const handleAdvancedChange = (text: string) => {
|
||||
setJsonText(text);
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
setJsonError('');
|
||||
onChange?.(parsed);
|
||||
} catch {
|
||||
setJsonError('JSON 格式错误');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldsChange = (val: string | string[], vars: VariableInfo[]) => {
|
||||
setSelectedVars(vars);
|
||||
onFieldsChange?.(val, vars);
|
||||
};
|
||||
|
||||
// ==================== Visual templates per category ====================
|
||||
|
||||
const renderVariableQcTemplate = () => (
|
||||
<>
|
||||
<Form.Item label="检查类型" style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
value={checkType}
|
||||
onChange={setCheckType}
|
||||
options={VARIABLE_CHECK_TYPES}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
{checkType === 'range' && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="最小值" style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
value={rangeMin}
|
||||
onChange={(v) => setRangeMin(v ?? undefined)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="不填则无下限"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="最大值" style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
value={rangeMax}
|
||||
onChange={(v) => setRangeMax(v ?? undefined)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="不填则无上限"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{checkType === 'enum' && (
|
||||
<Form.Item label="允许的值" extra="多个值用英文逗号分隔" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
value={enumValues}
|
||||
onChange={(e) => setEnumValues(e.target.value)}
|
||||
placeholder="例如: 1, 2, 3"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderInclusionExclusionTemplate = () => (
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="比较方式" style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
value={operator}
|
||||
onChange={setOperator}
|
||||
options={COMPARISON_OPERATORS}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Form.Item label="比较值" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
value={compareValue}
|
||||
onChange={(e) => setCompareValue(e.target.value)}
|
||||
placeholder="例如: 18 或 男,女"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
|
||||
const renderProtocolDeviationTemplate = () => (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
message="请选择两个日期变量:起始日期和截止日期,系统将检查间隔是否超过窗口期"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Form.Item label="选择变量(需选2个日期字段)" style={{ marginBottom: 12 }}>
|
||||
<VariablePicker
|
||||
projectId={projectId}
|
||||
value={Array.isArray(fields) ? fields : fields ? [fields] : []}
|
||||
onChange={handleFieldsChange}
|
||||
mode="multiple"
|
||||
placeholder="选择起始日期和截止日期..."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="窗口期(天)" style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
value={windowDays}
|
||||
onChange={(v) => setWindowDays(v ?? 7)}
|
||||
min={1}
|
||||
style={{ width: 200 }}
|
||||
addonAfter="天"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderAeMonitoringTemplate = () => (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
message="请选择两个日期变量:AE 发生日期和上报日期,系统将检查上报时限"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Form.Item label="选择变量(AE日期 + 上报日期)" style={{ marginBottom: 12 }}>
|
||||
<VariablePicker
|
||||
projectId={projectId}
|
||||
value={Array.isArray(fields) ? fields : fields ? [fields] : []}
|
||||
onChange={handleFieldsChange}
|
||||
mode="multiple"
|
||||
placeholder="选择 AE 发生日期和上报日期..."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="时限(天)" style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
value={limitDays}
|
||||
onChange={(v) => setLimitDays(v ?? 1)}
|
||||
min={1}
|
||||
style={{ width: 200 }}
|
||||
addonAfter="天"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderVisualTemplate = () => {
|
||||
// For protocol_deviation and ae_monitoring, the variable picker is inside the template
|
||||
const needsExternalPicker = !['protocol_deviation', 'ae_monitoring'].includes(category);
|
||||
|
||||
return (
|
||||
<>
|
||||
{needsExternalPicker && (
|
||||
<Form.Item label="选择变量" style={{ marginBottom: 12 }}>
|
||||
<VariablePicker
|
||||
projectId={projectId}
|
||||
value={fields}
|
||||
onChange={handleFieldsChange}
|
||||
mode={category === 'logic_check' ? 'multiple' : 'single'}
|
||||
placeholder="搜索并选择变量..."
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{selectedVars.length > 0 && needsExternalPicker && (
|
||||
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#f6f8fa', borderRadius: 6 }}>
|
||||
<Space wrap>
|
||||
{selectedVars.map((v) => (
|
||||
<Tag key={v.fieldName}>
|
||||
{v.fieldLabel} <Text type="secondary">({v.fieldType})</Text>
|
||||
{v.validation && <Text type="secondary"> | {v.validation}</Text>}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(category === 'variable_qc' || category === 'lab_values') && renderVariableQcTemplate()}
|
||||
{(category === 'inclusion' || category === 'exclusion') && renderInclusionExclusionTemplate()}
|
||||
{category === 'protocol_deviation' && renderProtocolDeviationTemplate()}
|
||||
{category === 'ae_monitoring' && renderAeMonitoringTemplate()}
|
||||
{category === 'logic_check' && renderInclusionExclusionTemplate()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
{advancedMode ? <CodeOutlined /> : <FormOutlined />}
|
||||
<span>{advancedMode ? '高级模式(JSON Logic)' : '可视化规则配置'}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>高级</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={advancedMode}
|
||||
onChange={setAdvancedMode}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: 16 }}
|
||||
>
|
||||
{advancedMode ? (
|
||||
<>
|
||||
<Form.Item label="选择变量" style={{ marginBottom: 12 }}>
|
||||
<VariablePicker
|
||||
projectId={projectId}
|
||||
value={fields}
|
||||
onChange={handleFieldsChange}
|
||||
mode="multiple"
|
||||
placeholder="搜索并选择变量..."
|
||||
/>
|
||||
</Form.Item>
|
||||
<TextArea
|
||||
rows={8}
|
||||
value={jsonText}
|
||||
onChange={(e) => handleAdvancedChange(e.target.value)}
|
||||
placeholder={'{\n "and": [\n {">=": [{"var": "age"}, 18]},\n {"<=": [{"var": "age"}, 65]}\n ]\n}'}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
||||
/>
|
||||
{jsonError && (
|
||||
<Alert type="error" message={jsonError} showIcon style={{ marginTop: 8 }} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
renderVisualTemplate()
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
<Divider style={{ margin: '12px 0 8px' }} />
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>规则预览(JSON Logic):</Text>
|
||||
<pre
|
||||
style={{
|
||||
background: '#f6f8fa',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
margin: '4px 0 0',
|
||||
}}
|
||||
>
|
||||
{jsonText || '(待生成)'}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleTemplateBuilder;
|
||||
export { RuleTemplateBuilder };
|
||||
export type { RuleCategory };
|
||||
192
frontend-v2/src/modules/iit/components/VariablePicker.tsx
Normal file
192
frontend-v2/src/modules/iit/components/VariablePicker.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* VariablePicker - 变量选择器组件
|
||||
*
|
||||
* 从 iit_field_metadata 加载变量清单,按表单分组展示,
|
||||
* 支持搜索、单选/多选、选中后回填类型信息。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Select, Tag, Tooltip, Space, Typography } from 'antd';
|
||||
import { DatabaseOutlined } from '@ant-design/icons';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type { FieldMetadata } from '../api/iitProjectApi';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: '文本',
|
||||
radio: '单选',
|
||||
dropdown: '下拉',
|
||||
checkbox: '多选',
|
||||
yesno: '是/否',
|
||||
truefalse: '真/假',
|
||||
notes: '备注',
|
||||
descriptive: '描述',
|
||||
calc: '计算',
|
||||
file: '文件',
|
||||
slider: '滑块',
|
||||
};
|
||||
|
||||
export interface VariableInfo {
|
||||
fieldName: string;
|
||||
fieldLabel: string;
|
||||
fieldType: string;
|
||||
formName: string;
|
||||
validation?: string | null;
|
||||
validationMin?: string | null;
|
||||
validationMax?: string | null;
|
||||
choices?: string | null;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface VariablePickerProps {
|
||||
projectId: string;
|
||||
value?: string | string[];
|
||||
onChange?: (value: string | string[], variables: VariableInfo[]) => void;
|
||||
mode?: 'single' | 'multiple';
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const VariablePicker: React.FC<VariablePickerProps> = ({
|
||||
projectId,
|
||||
value,
|
||||
onChange,
|
||||
mode = 'single',
|
||||
placeholder = '搜索并选择变量...',
|
||||
disabled = false,
|
||||
style,
|
||||
}) => {
|
||||
const [fields, setFields] = useState<FieldMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadFields = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await iitProjectApi.listFieldMetadata(projectId);
|
||||
setFields(result.fields);
|
||||
} catch {
|
||||
// silent - component will show empty state
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFields();
|
||||
}, [loadFields]);
|
||||
|
||||
const fieldMap = useMemo(() => {
|
||||
const map = new Map<string, FieldMetadata>();
|
||||
for (const f of fields) {
|
||||
map.set(f.fieldName, f);
|
||||
}
|
||||
return map;
|
||||
}, [fields]);
|
||||
|
||||
const groupedOptions = useMemo(() => {
|
||||
const groups = new Map<string, FieldMetadata[]>();
|
||||
for (const f of fields) {
|
||||
if (f.fieldType === 'descriptive') continue;
|
||||
const group = groups.get(f.formName) || [];
|
||||
group.push(f);
|
||||
groups.set(f.formName, group);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([formName, formFields]) => ({
|
||||
label: (
|
||||
<span>
|
||||
<DatabaseOutlined style={{ marginRight: 4 }} />
|
||||
{formName}
|
||||
<Text type="secondary" style={{ marginLeft: 4, fontSize: 11 }}>({formFields.length})</Text>
|
||||
</span>
|
||||
),
|
||||
options: formFields.map((f) => ({
|
||||
label: (
|
||||
<Space size={4}>
|
||||
<span>{f.fieldLabel || f.fieldName}</span>
|
||||
<Text type="secondary" code style={{ fontSize: 11 }}>{f.fieldName}</Text>
|
||||
<Tag style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', margin: 0 }}>
|
||||
{FIELD_TYPE_LABELS[f.fieldType] || f.fieldType}
|
||||
</Tag>
|
||||
{f.required && <Tag color="error" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', margin: 0 }}>必填</Tag>}
|
||||
</Space>
|
||||
),
|
||||
value: f.fieldName,
|
||||
fieldLabel: f.fieldLabel,
|
||||
fieldType: f.fieldType,
|
||||
})),
|
||||
}));
|
||||
}, [fields]);
|
||||
|
||||
const handleChange = (selected: string | string[]) => {
|
||||
if (!onChange) return;
|
||||
const selectedArray = Array.isArray(selected) ? selected : [selected];
|
||||
const variables: VariableInfo[] = selectedArray
|
||||
.map((name) => fieldMap.get(name))
|
||||
.filter(Boolean)
|
||||
.map((f) => ({
|
||||
fieldName: f!.fieldName,
|
||||
fieldLabel: f!.fieldLabel,
|
||||
fieldType: f!.fieldType,
|
||||
formName: f!.formName,
|
||||
validation: f!.validation,
|
||||
validationMin: f!.validationMin,
|
||||
validationMax: f!.validationMax,
|
||||
choices: f!.choices,
|
||||
required: f!.required,
|
||||
}));
|
||||
onChange(selected, variables);
|
||||
};
|
||||
|
||||
const tagRender = (props: any) => {
|
||||
const { value: val, closable, onClose } = props;
|
||||
const meta = fieldMap.get(val);
|
||||
return (
|
||||
<Tag
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
style={{ marginRight: 3 }}
|
||||
>
|
||||
<Tooltip title={meta?.fieldLabel}>
|
||||
{val}
|
||||
</Tooltip>
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
mode={mode === 'multiple' ? 'multiple' : undefined}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
loading={loading}
|
||||
disabled={disabled || loading}
|
||||
style={{ width: '100%', ...style }}
|
||||
options={groupedOptions}
|
||||
tagRender={mode === 'multiple' ? tagRender : undefined}
|
||||
filterOption={(input, option: any) => {
|
||||
if (!option || !option.value) return false;
|
||||
const searchLower = input.toLowerCase();
|
||||
const optValue = (option.value as string).toLowerCase();
|
||||
const optLabel = (option.fieldLabel as string || '').toLowerCase();
|
||||
return optValue.includes(searchLower) || optLabel.includes(searchLower);
|
||||
}}
|
||||
optionFilterProp="value"
|
||||
notFoundContent={
|
||||
fields.length === 0
|
||||
? '暂无变量数据,请先同步 REDCap 元数据'
|
||||
: '无匹配变量'
|
||||
}
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariablePicker;
|
||||
export { VariablePicker };
|
||||
1048
frontend-v2/src/modules/iit/config/ProjectDetailPage.tsx
Normal file
1048
frontend-v2/src/modules/iit/config/ProjectDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
350
frontend-v2/src/modules/iit/config/ProjectListPage.tsx
Normal file
350
frontend-v2/src/modules/iit/config/ProjectListPage.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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,
|
||||
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';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
|
||||
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 ProjectListPage: 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(`/iit/config/${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 }}>
|
||||
{/* 返回按钮 */}
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/iit/dashboard')}
|
||||
style={{ padding: 0, marginBottom: 16 }}
|
||||
>
|
||||
返回 CRA 质控平台
|
||||
</Button>
|
||||
|
||||
{/* 页面标题 */}
|
||||
<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 ProjectListPage;
|
||||
35
frontend-v2/src/modules/iit/index.tsx
Normal file
35
frontend-v2/src/modules/iit/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import IitLayout from './IitLayout';
|
||||
|
||||
const DashboardPage = React.lazy(() => import('./pages/DashboardPage'));
|
||||
const AiStreamPage = React.lazy(() => import('./pages/AiStreamPage'));
|
||||
const EQueryPage = React.lazy(() => import('./pages/EQueryPage'));
|
||||
const ReportsPage = React.lazy(() => import('./pages/ReportsPage'));
|
||||
|
||||
const VariableListPage = React.lazy(() => import('./pages/VariableListPage'));
|
||||
|
||||
const ConfigProjectListPage = React.lazy(() => import('./config/ProjectListPage'));
|
||||
const ConfigProjectDetailPage = React.lazy(() => import('./config/ProjectDetailPage'));
|
||||
|
||||
const IitModule: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
{/* CRA 质控平台主界面 */}
|
||||
<Route element={<IitLayout />}>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="stream" element={<AiStreamPage />} />
|
||||
<Route path="equery" element={<EQueryPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="variables" element={<VariableListPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 项目配置界面(独立布局,不使用 CRA 质控平台导航) */}
|
||||
<Route path="config" element={<ConfigProjectListPage />} />
|
||||
<Route path="config/:id" element={<ConfigProjectDetailPage />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default IitModule;
|
||||
212
frontend-v2/src/modules/iit/pages/AiStreamPage.tsx
Normal file
212
frontend-v2/src/modules/iit/pages/AiStreamPage.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* AI 实时工作流水页 (Level 2)
|
||||
*
|
||||
* 以 Timeline 展示 Agent 每次质控的完整动作链,AI 白盒化。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Timeline,
|
||||
Empty,
|
||||
Tag,
|
||||
Typography,
|
||||
Space,
|
||||
DatePicker,
|
||||
Button,
|
||||
Badge,
|
||||
Pagination,
|
||||
} from 'antd';
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
WarningOutlined,
|
||||
SyncOutlined,
|
||||
ClockCircleOutlined,
|
||||
RobotOutlined,
|
||||
ApiOutlined,
|
||||
BellOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type { TimelineItem } from '../api/iitProjectApi';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const STATUS_DOT: Record<string, { color: string; icon: React.ReactNode }> = {
|
||||
PASS: { color: 'green', icon: <CheckCircleOutlined /> },
|
||||
FAIL: { color: 'red', icon: <CloseCircleOutlined /> },
|
||||
WARNING: { color: 'orange', icon: <WarningOutlined /> },
|
||||
};
|
||||
|
||||
const TRIGGER_TAG: Record<string, { color: string; label: string }> = {
|
||||
webhook: { color: 'blue', label: 'EDC 触发' },
|
||||
cron: { color: 'purple', label: '定时巡查' },
|
||||
manual: { color: 'cyan', label: '手动执行' },
|
||||
batch: { color: 'geekblue', label: '批量质控' },
|
||||
};
|
||||
|
||||
const AiStreamPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const projectId = searchParams.get('projectId') || '';
|
||||
|
||||
const [items, setItems] = useState<TimelineItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [dateFilter, setDateFilter] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await iitProjectApi.getTimeline(projectId, {
|
||||
page,
|
||||
pageSize: 30,
|
||||
date: dateFilter,
|
||||
});
|
||||
setItems(result.items);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, page, dateFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setPage(1);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
if (!projectId) {
|
||||
return <div style={{ padding: 24 }}><Empty description="请先选择一个项目" /></div>;
|
||||
}
|
||||
|
||||
const timelineItems = items.map((item) => {
|
||||
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined /> };
|
||||
const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy };
|
||||
const { red, yellow } = item.details.issuesSummary;
|
||||
const time = new Date(item.time);
|
||||
const timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const dateStr = time.toLocaleDateString('zh-CN');
|
||||
|
||||
return {
|
||||
color: dotCfg.color as any,
|
||||
dot: dotCfg.icon,
|
||||
children: (
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
{/* Header line */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<Text strong style={{ fontSize: 13, fontFamily: 'monospace' }}>{timeStr}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{dateStr}</Text>
|
||||
<Tag color={triggerCfg.color} style={{ fontSize: 10, lineHeight: '18px', padding: '0 6px' }}>
|
||||
{triggerCfg.label}
|
||||
</Tag>
|
||||
<Tag color={dotCfg.color} style={{ fontSize: 10, lineHeight: '18px', padding: '0 6px' }}>
|
||||
{item.status}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* Description — the AI action chain */}
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
<Space wrap size={4} style={{ marginBottom: 4 }}>
|
||||
<RobotOutlined style={{ color: '#3b82f6' }} />
|
||||
<Text>扫描受试者 <Text code>{item.recordId}</Text></Text>
|
||||
{item.formName && <Text type="secondary">[{item.formName}]</Text>}
|
||||
</Space>
|
||||
<div style={{ marginLeft: 20 }}>
|
||||
<Space size={4}>
|
||||
<ApiOutlined style={{ color: '#8b5cf6' }} />
|
||||
<Text>执行 {item.details.rulesEvaluated} 条规则</Text>
|
||||
<Text type="success">→ {item.details.rulesPassed} 通过</Text>
|
||||
{item.details.rulesFailed > 0 && (
|
||||
<Text type="danger">/ {item.details.rulesFailed} 失败</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
{(red > 0 || yellow > 0) && (
|
||||
<div style={{ marginLeft: 20, marginTop: 2 }}>
|
||||
<Space size={4}>
|
||||
<BellOutlined style={{ color: red > 0 ? '#ef4444' : '#f59e0b' }} />
|
||||
{red > 0 && <Badge count={red} style={{ backgroundColor: '#ef4444' }} />}
|
||||
{red > 0 && <Text type="danger">严重问题</Text>}
|
||||
{yellow > 0 && <Badge count={yellow} style={{ backgroundColor: '#f59e0b' }} />}
|
||||
{yellow > 0 && <Text style={{ color: '#d97706' }}>警告</Text>}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<Tag icon={<ThunderboltOutlined />} color="processing">实时</Tag>
|
||||
<Badge count={total} overflowCount={9999} style={{ backgroundColor: '#3b82f6' }}>
|
||||
<Text type="secondary">条工作记录</Text>
|
||||
</Badge>
|
||||
</Space>
|
||||
<Space>
|
||||
<DatePicker
|
||||
placeholder="按日期筛选"
|
||||
onChange={(d) => {
|
||||
setDateFilter(d ? d.format('YYYY-MM-DD') : undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
<Button icon={<SyncOutlined spin={loading} />} onClick={handleRefresh} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<Card>
|
||||
{items.length > 0 ? (
|
||||
<>
|
||||
<Timeline items={timelineItems} />
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={30}
|
||||
onChange={setPage}
|
||||
showTotal={(t) => `共 ${t} 条`}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
loading
|
||||
? '加载中...'
|
||||
: '定时质控开启后,Agent 的每一步推理和操作都将在此透明展示'
|
||||
}
|
||||
style={{ padding: '48px 0' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiStreamPage;
|
||||
359
frontend-v2/src/modules/iit/pages/DashboardPage.tsx
Normal file
359
frontend-v2/src/modules/iit/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 项目健康度大盘 (Level 1)
|
||||
*
|
||||
* 健康度评分 + 核心数据卡片 + 趋势折线图 + 热力图 + 事件预警
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Progress,
|
||||
Empty,
|
||||
Tag,
|
||||
Typography,
|
||||
Space,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
WarningOutlined,
|
||||
CloseCircleOutlined,
|
||||
TeamOutlined,
|
||||
AlertOutlined,
|
||||
RiseOutlined,
|
||||
ThunderboltOutlined,
|
||||
FileSearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const projectId = searchParams.get('projectId') || '';
|
||||
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [heatmap, setHeatmap] = useState<any>(null);
|
||||
const [trend, setTrend] = useState<iitProjectApi.TrendPoint[]>([]);
|
||||
const [criticalEvents, setCriticalEvents] = useState<iitProjectApi.CriticalEvent[]>([]);
|
||||
const [equeryStats, setEqueryStats] = useState<iitProjectApi.EqueryStats | null>(null);
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const [cockpitData, trendData, eventsData, eqStats] = await Promise.allSettled([
|
||||
iitProjectApi.getQcCockpitData(projectId),
|
||||
iitProjectApi.getTrend(projectId, 30),
|
||||
iitProjectApi.getCriticalEvents(projectId, { status: 'open', pageSize: 5 }),
|
||||
iitProjectApi.getEqueryStats(projectId),
|
||||
]);
|
||||
if (cockpitData.status === 'fulfilled') {
|
||||
setStats(cockpitData.value.stats);
|
||||
setHeatmap(cockpitData.value.heatmap);
|
||||
}
|
||||
if (trendData.status === 'fulfilled') setTrend(trendData.value);
|
||||
if (eventsData.status === 'fulfilled') setCriticalEvents(eventsData.value.items);
|
||||
if (eqStats.status === 'fulfilled') setEqueryStats(eqStats.value);
|
||||
} catch { /* non-fatal */ }
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// Health Score = weighted average of passRate, eQuery backlog, critical events
|
||||
const passRate = stats?.passRate ?? 0;
|
||||
const pendingEq = equeryStats?.pending ?? 0;
|
||||
const criticalCount = criticalEvents.length;
|
||||
|
||||
let healthScore = passRate;
|
||||
if (pendingEq > 10) healthScore -= 10;
|
||||
else if (pendingEq > 5) healthScore -= 5;
|
||||
if (criticalCount > 0) healthScore -= criticalCount * 5;
|
||||
healthScore = Math.max(0, Math.min(100, Math.round(healthScore)));
|
||||
|
||||
const healthColor = healthScore >= 80 ? '#52c41a' : healthScore >= 60 ? '#faad14' : '#ff4d4f';
|
||||
const healthLabel = healthScore >= 80 ? '良好' : healthScore >= 60 ? '需关注' : '风险';
|
||||
|
||||
if (!projectId) {
|
||||
return <div style={{ padding: 24 }}><Empty description="请先选择一个项目" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Health Score */}
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: `linear-gradient(135deg, ${healthColor}10 0%, #ffffff 70%)`,
|
||||
borderColor: `${healthColor}40`,
|
||||
}}
|
||||
>
|
||||
<Row align="middle" gutter={32}>
|
||||
<Col>
|
||||
<Progress
|
||||
type="dashboard"
|
||||
percent={healthScore}
|
||||
strokeColor={healthColor}
|
||||
size={120}
|
||||
format={() => (
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: healthColor }}>{healthScore}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>{healthLabel}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="auto">
|
||||
<Title level={4} style={{ margin: 0, color: '#1e293b' }}>项目健康度评分</Title>
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 4, marginBottom: 12 }}>
|
||||
基于质控通过率、待处理 eQuery、重大事件综合计算
|
||||
</Text>
|
||||
<Space size={24}>
|
||||
<span>质控通过率 <Text strong>{passRate}%</Text></span>
|
||||
<span>待处理 eQuery <Text strong style={{ color: pendingEq > 0 ? '#faad14' : '#52c41a' }}>{pendingEq}</Text></span>
|
||||
<span>活跃重大事件 <Text strong style={{ color: criticalCount > 0 ? '#ff4d4f' : '#52c41a' }}>{criticalCount}</Text></span>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Core Stats Cards */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card hoverable onClick={() => navigate(`/iit/reports?projectId=${projectId}`)}>
|
||||
<Statistic
|
||||
title="整体合规率"
|
||||
value={stats?.passRate ?? 0}
|
||||
suffix="%"
|
||||
prefix={<CheckCircleOutlined style={{ color: '#10b981' }} />}
|
||||
/>
|
||||
<Progress percent={stats?.passRate ?? 0} showInfo={false} strokeColor="#10b981" size="small" style={{ marginTop: 4 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card hoverable onClick={() => navigate(`/iit/equery?projectId=${projectId}`)}>
|
||||
<Statistic
|
||||
title="待处理 eQuery"
|
||||
value={pendingEq}
|
||||
prefix={<WarningOutlined style={{ color: '#f59e0b' }} />}
|
||||
valueStyle={{ color: pendingEq > 0 ? '#f59e0b' : undefined }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
总计 {equeryStats?.total ?? 0} | 已关闭 {equeryStats?.closed ?? 0}
|
||||
</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card hoverable>
|
||||
<Statistic
|
||||
title="累计重大事件"
|
||||
value={criticalCount}
|
||||
prefix={<CloseCircleOutlined style={{ color: '#ef4444' }} />}
|
||||
valueStyle={{ color: criticalCount > 0 ? '#ef4444' : undefined }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
SAE + 重大方案偏离
|
||||
</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card hoverable>
|
||||
<Statistic
|
||||
title="已审查受试者"
|
||||
value={stats?.totalRecords ?? 0}
|
||||
prefix={<TeamOutlined style={{ color: '#3b82f6' }} />}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
通过 {stats?.passedRecords ?? 0} | 失败 {stats?.failedRecords ?? 0}
|
||||
</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Critical Event Alerts */}
|
||||
{criticalEvents.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{criticalEvents.map((evt) => (
|
||||
<Alert
|
||||
key={evt.id}
|
||||
type="error"
|
||||
showIcon
|
||||
icon={<AlertOutlined />}
|
||||
message={
|
||||
<Space>
|
||||
<Tag color="error">{evt.eventType}</Tag>
|
||||
<Text strong>受试者 {evt.recordId}</Text>
|
||||
<Text>{evt.title}</Text>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
检出时间: {new Date(evt.detectedAt).toLocaleString('zh-CN')} | 检出方式: {evt.detectedBy === 'ai' ? 'AI 自动' : '手动'}
|
||||
</Text>
|
||||
}
|
||||
style={{ marginBottom: 8 }}
|
||||
closable
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trend Chart (Simple CSS-based) */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<RiseOutlined />
|
||||
<span>质控通过率趋势(近30天)</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{trend.length > 0 ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'end', gap: 2, height: 120, padding: '0 4px' }}>
|
||||
{trend.map((point, idx) => {
|
||||
const barColor = point.passRate >= 80 ? '#52c41a' : point.passRate >= 60 ? '#faad14' : '#ff4d4f';
|
||||
return (
|
||||
<Tooltip
|
||||
key={idx}
|
||||
title={`${point.date}: ${point.passRate}% (${point.passed}/${point.total})`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: `${Math.max(point.passRate, 2)}%`,
|
||||
backgroundColor: barColor,
|
||||
borderRadius: '2px 2px 0 0',
|
||||
minWidth: 4,
|
||||
transition: 'height 0.3s',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 10 }}>{trend[0]?.date}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 10 }}>{trend[trend.length - 1]?.date}</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无趋势数据,首次质控后将自动生成" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Heatmap */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ThunderboltOutlined />
|
||||
<span>风险热力图(受试者 × 表单)</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{heatmap && heatmap.rows?.length > 0 ? (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ padding: '6px 10px', borderBottom: '2px solid #e2e8f0', textAlign: 'left', color: '#64748b', fontWeight: 600 }}>
|
||||
受试者
|
||||
</th>
|
||||
{(heatmap.columns || []).map((col: string, i: number) => (
|
||||
<th key={i} style={{ padding: '6px 8px', borderBottom: '2px solid #e2e8f0', textAlign: 'center', color: '#64748b', fontWeight: 600, whiteSpace: 'nowrap' }}>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{heatmap.rows.map((row: any, ri: number) => (
|
||||
<tr key={ri}>
|
||||
<td style={{ padding: '4px 10px', borderBottom: '1px solid #f1f5f9', fontWeight: 600 }}>
|
||||
{row.recordId}
|
||||
</td>
|
||||
{(row.cells || []).map((cell: any, ci: number) => {
|
||||
const bg = cell.status === 'pass' ? '#dcfce7' : cell.status === 'warning' ? '#fef3c7' : cell.status === 'fail' ? '#fecaca' : '#f1f5f9';
|
||||
const border = cell.status === 'pass' ? '#86efac' : cell.status === 'warning' ? '#fcd34d' : cell.status === 'fail' ? '#fca5a5' : '#e2e8f0';
|
||||
return (
|
||||
<td key={ci} style={{ padding: 2, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Tooltip title={`${cell.formName}: ${cell.issueCount} 个问题`}>
|
||||
<div style={{
|
||||
background: bg,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 4,
|
||||
padding: '4px 6px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
minWidth: 32,
|
||||
fontSize: 11,
|
||||
fontWeight: cell.issueCount > 0 ? 700 : 400,
|
||||
color: cell.status === 'fail' ? '#dc2626' : cell.status === 'warning' ? '#d97706' : '#16a34a',
|
||||
}}>
|
||||
{cell.issueCount > 0 ? cell.issueCount : '✓'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="质控完成后将自动生成风险热力图" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quick Links */}
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => navigate(`/iit/stream?projectId=${projectId}`)}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<ThunderboltOutlined style={{ fontSize: 24, color: '#3b82f6', marginBottom: 8 }} />
|
||||
<div style={{ fontWeight: 600 }}>AI 实时工作流水</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>查看 Agent 每步推理与操作</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => navigate(`/iit/equery?projectId=${projectId}`)}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<AlertOutlined style={{ fontSize: 24, color: '#f59e0b', marginBottom: 8 }} />
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
eQuery 管理
|
||||
{pendingEq > 0 && <Badge count={pendingEq} offset={[8, -2]} />}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>处理 AI 生成的电子质疑</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => navigate(`/iit/reports?projectId=${projectId}`)}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<FileSearchOutlined style={{ fontSize: 24, color: '#10b981', marginBottom: 8 }} />
|
||||
<div style={{ fontWeight: 600 }}>报告与事件</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>质控报告 + 重大事件归档</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
387
frontend-v2/src/modules/iit/pages/EQueryPage.tsx
Normal file
387
frontend-v2/src/modules/iit/pages/EQueryPage.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* eQuery 管理页面
|
||||
*
|
||||
* 展示 AI 自动生成的电子质疑清单,支持状态过滤、回复、关闭操作。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Button,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
Badge,
|
||||
Modal,
|
||||
Input,
|
||||
message,
|
||||
Statistic,
|
||||
Row,
|
||||
Col,
|
||||
Tooltip,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
AlertOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
CloseCircleOutlined,
|
||||
SendOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type { Equery, EqueryStats } from '../api/iitProjectApi';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
|
||||
pending: { color: 'warning', label: '待处理', icon: <ClockCircleOutlined /> },
|
||||
responded: { color: 'processing', label: '已回复', icon: <SendOutlined /> },
|
||||
reviewing: { color: 'purple', label: 'AI 复核中', icon: <SyncOutlined spin /> },
|
||||
closed: { color: 'success', label: '已关闭', icon: <CheckCircleOutlined /> },
|
||||
reopened: { color: 'error', label: '已重开', icon: <CloseCircleOutlined /> },
|
||||
};
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { color: string; label: string }> = {
|
||||
error: { color: 'error', label: '严重' },
|
||||
warning: { color: 'warning', label: '警告' },
|
||||
info: { color: 'default', label: '信息' },
|
||||
};
|
||||
|
||||
const EQueryPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const projectId = searchParams.get('projectId') || '';
|
||||
|
||||
const [equeries, setEqueries] = useState<Equery[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [stats, setStats] = useState<EqueryStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Respond modal
|
||||
const [respondModal, setRespondModal] = useState(false);
|
||||
const [respondTarget, setRespondTarget] = useState<Equery | null>(null);
|
||||
const [responseText, setResponseText] = useState('');
|
||||
const [responding, setResponding] = useState(false);
|
||||
|
||||
// Detail modal
|
||||
const [detailModal, setDetailModal] = useState(false);
|
||||
const [detailTarget, setDetailTarget] = useState<Equery | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
iitProjectApi.listEqueries(projectId, { status: statusFilter, page, pageSize: 20 }),
|
||||
iitProjectApi.getEqueryStats(projectId),
|
||||
]);
|
||||
setEqueries(listResult.items);
|
||||
setTotal(listResult.total);
|
||||
setStats(statsResult);
|
||||
} catch {
|
||||
message.error('加载 eQuery 数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, statusFilter, page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleRespond = async () => {
|
||||
if (!respondTarget || !responseText.trim()) {
|
||||
message.warning('请输入回复内容');
|
||||
return;
|
||||
}
|
||||
setResponding(true);
|
||||
try {
|
||||
await iitProjectApi.respondEquery(projectId, respondTarget.id, { responseText });
|
||||
message.success('回复成功');
|
||||
setRespondModal(false);
|
||||
setResponseText('');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('回复失败');
|
||||
} finally {
|
||||
setResponding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async (equery: Equery) => {
|
||||
try {
|
||||
await iitProjectApi.closeEquery(projectId, equery.id, { closedBy: 'manual' });
|
||||
message.success('已关闭');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '关闭失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Equery> = [
|
||||
{
|
||||
title: '受试者',
|
||||
dataIndex: 'recordId',
|
||||
key: 'recordId',
|
||||
width: 100,
|
||||
render: (id: string) => <Text strong>{id}</Text>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
render: (status: string) => {
|
||||
const cfg = STATUS_CONFIG[status] || { color: 'default', label: status, icon: null };
|
||||
return <Tag icon={cfg.icon} color={cfg.color}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '严重程度',
|
||||
dataIndex: 'severity',
|
||||
key: 'severity',
|
||||
width: 90,
|
||||
render: (s: string) => {
|
||||
const cfg = SEVERITY_CONFIG[s] || { color: 'default', label: s };
|
||||
return <Tag color={cfg.color}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '质疑内容',
|
||||
dataIndex: 'queryText',
|
||||
key: 'queryText',
|
||||
ellipsis: true,
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text}>
|
||||
<span>{text}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '字段',
|
||||
dataIndex: 'fieldName',
|
||||
key: 'fieldName',
|
||||
width: 130,
|
||||
render: (f: string) => f ? <Text code style={{ fontSize: 11 }}>{f}</Text> : '—',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (d: string) => new Date(d).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_: unknown, record: Equery) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="查看详情">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => { setDetailTarget(record); setDetailModal(true); }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{['pending', 'reopened'].includes(record.status) && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => { setRespondTarget(record); setRespondModal(true); }}
|
||||
>
|
||||
回复
|
||||
</Button>
|
||||
)}
|
||||
{record.status !== 'closed' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleClose(record)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty description="请先选择一个项目" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="总计" value={stats.total} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="待处理" value={stats.pending} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="已回复" value={stats.responded} valueStyle={{ color: '#1677ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="已关闭" value={stats.closed} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="已重开" value={stats.reopened} valueStyle={{ color: '#ff4d4f' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="平均解决时长"
|
||||
value={stats.avgResolutionHours ?? '—'}
|
||||
suffix={stats.avgResolutionHours !== null ? 'h' : ''}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Select
|
||||
placeholder="按状态过滤"
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
value={statusFilter}
|
||||
onChange={(v) => { setStatusFilter(v); setPage(1); }}
|
||||
options={Object.entries(STATUS_CONFIG).map(([value, { label }]) => ({ value, label }))}
|
||||
/>
|
||||
<Badge count={total} overflowCount={9999} style={{ backgroundColor: '#1677ff' }}>
|
||||
<Text type="secondary">条 eQuery</Text>
|
||||
</Badge>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card bodyStyle={{ padding: 0 }}>
|
||||
<Table<Equery>
|
||||
columns={columns}
|
||||
dataSource={equeries}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="暂无 eQuery,质控完成后将自动生成" /> }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Respond Modal */}
|
||||
<Modal
|
||||
title={`回复 eQuery - 受试者 ${respondTarget?.recordId}`}
|
||||
open={respondModal}
|
||||
onCancel={() => setRespondModal(false)}
|
||||
onOk={handleRespond}
|
||||
confirmLoading={responding}
|
||||
okText="提交回复"
|
||||
>
|
||||
{respondTarget && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Paragraph type="secondary">{respondTarget.queryText}</Paragraph>
|
||||
{respondTarget.expectedAction && (
|
||||
<Paragraph type="secondary" italic>期望操作: {respondTarget.expectedAction}</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={responseText}
|
||||
onChange={(e) => setResponseText(e.target.value)}
|
||||
placeholder="请输入回复说明(如:已修正数据 / 数据正确,原因是...)"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
title={`eQuery 详情 - ${detailTarget?.id?.substring(0, 8)}`}
|
||||
open={detailModal}
|
||||
onCancel={() => setDetailModal(false)}
|
||||
footer={null}
|
||||
width={640}
|
||||
>
|
||||
{detailTarget && (
|
||||
<div>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col span={8}><Text type="secondary">受试者:</Text> <Text strong>{detailTarget.recordId}</Text></Col>
|
||||
<Col span={8}><Text type="secondary">字段:</Text> <Text code>{detailTarget.fieldName || '—'}</Text></Col>
|
||||
<Col span={8}><Text type="secondary">表单:</Text> {detailTarget.formName || '—'}</Col>
|
||||
<Col span={8}>
|
||||
<Text type="secondary">状态:</Text>{' '}
|
||||
<Tag color={STATUS_CONFIG[detailTarget.status]?.color}>{STATUS_CONFIG[detailTarget.status]?.label}</Tag>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Text type="secondary">严重程度:</Text>{' '}
|
||||
<Tag color={SEVERITY_CONFIG[detailTarget.severity]?.color}>{SEVERITY_CONFIG[detailTarget.severity]?.label}</Tag>
|
||||
</Col>
|
||||
<Col span={8}><Text type="secondary">创建时间:</Text> {new Date(detailTarget.createdAt).toLocaleString('zh-CN')}</Col>
|
||||
</Row>
|
||||
<Card size="small" title="质疑内容" style={{ marginTop: 16 }}>
|
||||
<Paragraph>{detailTarget.queryText}</Paragraph>
|
||||
{detailTarget.expectedAction && (
|
||||
<Paragraph type="secondary">期望操作: {detailTarget.expectedAction}</Paragraph>
|
||||
)}
|
||||
</Card>
|
||||
{detailTarget.responseText && (
|
||||
<Card size="small" title="CRC 回复" style={{ marginTop: 12 }}>
|
||||
<Paragraph>{detailTarget.responseText}</Paragraph>
|
||||
<Text type="secondary">回复时间: {detailTarget.respondedAt ? new Date(detailTarget.respondedAt).toLocaleString('zh-CN') : '—'}</Text>
|
||||
</Card>
|
||||
)}
|
||||
{detailTarget.reviewResult && (
|
||||
<Card size="small" title="AI 复核结果" style={{ marginTop: 12 }}>
|
||||
<Tag color={detailTarget.reviewResult === 'passed' ? 'success' : 'error'}>
|
||||
{detailTarget.reviewResult === 'passed' ? '复核通过' : '复核不通过'}
|
||||
</Tag>
|
||||
{detailTarget.reviewNote && <Paragraph style={{ marginTop: 8 }}>{detailTarget.reviewNote}</Paragraph>}
|
||||
</Card>
|
||||
)}
|
||||
{detailTarget.resolution && (
|
||||
<Card size="small" title="关闭说明" style={{ marginTop: 12 }}>
|
||||
<Paragraph>{detailTarget.resolution}</Paragraph>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EQueryPage;
|
||||
349
frontend-v2/src/modules/iit/pages/ReportsPage.tsx
Normal file
349
frontend-v2/src/modules/iit/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 报告与关键事件页面
|
||||
*
|
||||
* 展示质控报告列表、报告详情、以及重大事件归档。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Statistic,
|
||||
Row,
|
||||
Col,
|
||||
Empty,
|
||||
message,
|
||||
Descriptions,
|
||||
Progress,
|
||||
Tabs,
|
||||
Select,
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
SyncOutlined,
|
||||
WarningOutlined,
|
||||
CheckCircleOutlined,
|
||||
AlertOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type { QcReport, CriticalEvent } from '../api/iitProjectApi';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const ReportsPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const projectId = searchParams.get('projectId') || '';
|
||||
|
||||
const [report, setReport] = useState<QcReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Critical events
|
||||
const [criticalEvents, setCriticalEvents] = useState<CriticalEvent[]>([]);
|
||||
const [ceTotal, setCeTotal] = useState(0);
|
||||
const [ceStatusFilter, setCeStatusFilter] = useState<string | undefined>(undefined);
|
||||
|
||||
const fetchReport = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [reportData, ceData] = await Promise.allSettled([
|
||||
iitProjectApi.getQcReport(projectId) as Promise<QcReport>,
|
||||
iitProjectApi.getCriticalEvents(projectId, { status: ceStatusFilter, pageSize: 100 }),
|
||||
]);
|
||||
if (reportData.status === 'fulfilled') setReport(reportData.value);
|
||||
else setReport(null);
|
||||
if (ceData.status === 'fulfilled') {
|
||||
setCriticalEvents(ceData.value.items);
|
||||
setCeTotal(ceData.value.total);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, ceStatusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport();
|
||||
}, [fetchReport]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!projectId) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const data = await iitProjectApi.refreshQcReport(projectId);
|
||||
setReport(data);
|
||||
message.success('报告已刷新');
|
||||
} catch {
|
||||
message.error('报告刷新失败');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectId) {
|
||||
return <div style={{ padding: 24 }}><Empty description="请先选择一个项目" /></div>;
|
||||
}
|
||||
|
||||
if (!report && !loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Empty description="暂无质控报告,请先执行全量质控" />
|
||||
<Button type="primary" icon={<SyncOutlined />} onClick={handleRefresh} loading={refreshing} style={{ marginTop: 16 }}>
|
||||
生成报告
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = report?.summary;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
<FileTextOutlined style={{ marginRight: 8 }} />
|
||||
质控报告
|
||||
</Title>
|
||||
{report && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
生成于 {new Date(report.generatedAt).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Button icon={<SyncOutlined spin={refreshing} />} loading={refreshing} onClick={handleRefresh}>
|
||||
刷新报告
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="总受试者" value={summary.totalRecords} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="已完成" value={summary.completedRecords} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="通过率"
|
||||
value={summary.passRate}
|
||||
suffix="%"
|
||||
valueStyle={{ color: summary.passRate >= 80 ? '#52c41a' : summary.passRate >= 60 ? '#faad14' : '#ff4d4f' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="严重问题" value={summary.criticalIssues} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="警告" value={summary.warningIssues} prefix={<AlertOutlined />} valueStyle={{ color: '#faad14' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="待处理 Query" value={summary.pendingQueries} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Pass Rate Visual */}
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row align="middle" gutter={24}>
|
||||
<Col span={6}>
|
||||
<Progress
|
||||
type="dashboard"
|
||||
percent={summary.passRate}
|
||||
status={summary.passRate >= 80 ? 'success' : summary.passRate >= 60 ? 'normal' : 'exception'}
|
||||
size={100}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={18}>
|
||||
<Descriptions size="small" column={3}>
|
||||
<Descriptions.Item label="报告类型">
|
||||
<Tag>{report?.reportType === 'daily' ? '日报' : report?.reportType === 'weekly' ? '周报' : '按需'}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后质控时间">
|
||||
{summary.lastQcTime ? new Date(summary.lastQcTime).toLocaleString('zh-CN') : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="报告有效期至">
|
||||
{report?.expiresAt ? new Date(report.expiresAt).toLocaleString('zh-CN') : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs: Issues & Form Stats */}
|
||||
{report && (
|
||||
<Card>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'critical',
|
||||
label: <span><WarningOutlined /> 严重问题 ({report.criticalIssues.length})</span>,
|
||||
children: (
|
||||
<Table
|
||||
dataSource={report.criticalIssues}
|
||||
rowKey={(_, i) => `c${i}`}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: '受试者', dataIndex: 'recordId', width: 100 },
|
||||
{ title: '规则', dataIndex: 'ruleName', width: 180 },
|
||||
{ title: '描述', dataIndex: 'message', ellipsis: true },
|
||||
{ title: '字段', dataIndex: 'field', width: 130, render: (f: string) => f ? <Text code>{f}</Text> : '—' },
|
||||
{ title: '检出时间', dataIndex: 'detectedAt', width: 160, render: (d: string) => new Date(d).toLocaleString('zh-CN') },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'warning',
|
||||
label: <span><AlertOutlined /> 警告 ({report.warningIssues.length})</span>,
|
||||
children: (
|
||||
<Table
|
||||
dataSource={report.warningIssues}
|
||||
rowKey={(_, i) => `w${i}`}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: '受试者', dataIndex: 'recordId', width: 100 },
|
||||
{ title: '规则', dataIndex: 'ruleName', width: 180 },
|
||||
{ title: '描述', dataIndex: 'message', ellipsis: true },
|
||||
{ title: '字段', dataIndex: 'field', width: 130, render: (f: string) => f ? <Text code>{f}</Text> : '—' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'forms',
|
||||
label: <span><CheckCircleOutlined /> 表单统计 ({report.formStats.length})</span>,
|
||||
children: (
|
||||
<Table
|
||||
dataSource={report.formStats}
|
||||
rowKey="formName"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '表单', dataIndex: 'formName', width: 200 },
|
||||
{ title: '标签', dataIndex: 'formLabel', ellipsis: true },
|
||||
{ title: '检查数', dataIndex: 'totalChecks', width: 100 },
|
||||
{ title: '通过', dataIndex: 'passed', width: 80 },
|
||||
{ title: '失败', dataIndex: 'failed', width: 80 },
|
||||
{
|
||||
title: '通过率',
|
||||
dataIndex: 'passRate',
|
||||
width: 120,
|
||||
render: (rate: number) => (
|
||||
<Progress percent={rate} size="small" status={rate >= 80 ? 'success' : rate >= 60 ? 'normal' : 'exception'} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'critical-events',
|
||||
label: (
|
||||
<span>
|
||||
<SafetyCertificateOutlined /> 重大事件归档
|
||||
{ceTotal > 0 && <Badge count={ceTotal} offset={[8, -2]} style={{ backgroundColor: '#ff4d4f' }} />}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
placeholder="按状态筛选"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
value={ceStatusFilter}
|
||||
onChange={setCeStatusFilter}
|
||||
options={[
|
||||
{ value: 'open', label: '待处理' },
|
||||
{ value: 'handled', label: '已处理' },
|
||||
{ value: 'reported', label: '已上报' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={criticalEvents}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
locale={{ emptyText: <Empty description="暂无重大事件" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
columns={[
|
||||
{
|
||||
title: '受试者',
|
||||
dataIndex: 'recordId',
|
||||
width: 100,
|
||||
render: (id: string) => <Text strong>{id}</Text>,
|
||||
},
|
||||
{
|
||||
title: '事件类型',
|
||||
dataIndex: 'eventType',
|
||||
width: 130,
|
||||
render: (t: string) => (
|
||||
<Tag color={t === 'SAE' ? 'error' : 'warning'}>
|
||||
{t === 'SAE' ? '严重不良事件' : '方案偏离'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => (
|
||||
<Tag color={s === 'open' ? 'error' : s === 'handled' ? 'processing' : 'success'}>
|
||||
{s === 'open' ? '待处理' : s === 'handled' ? '已处理' : '已上报'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '检出时间',
|
||||
dataIndex: 'detectedAt',
|
||||
width: 160,
|
||||
render: (d: string) => new Date(d).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: 'EC 上报',
|
||||
dataIndex: 'reportedToEc',
|
||||
width: 80,
|
||||
render: (v: boolean) => v ? <Tag color="success">已上报</Tag> : <Tag>未上报</Tag>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsPage;
|
||||
404
frontend-v2/src/modules/iit/pages/VariableListPage.tsx
Normal file
404
frontend-v2/src/modules/iit/pages/VariableListPage.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 变量清单页面(Variable List / Field Metadata Browser)
|
||||
*
|
||||
* P0-1: 从 REDCap 同步字段元数据,提供表单过滤、搜索、可视化展示
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Tag,
|
||||
Space,
|
||||
Typography,
|
||||
Badge,
|
||||
Tooltip,
|
||||
Empty,
|
||||
message,
|
||||
Alert,
|
||||
Statistic,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
SyncOutlined,
|
||||
SearchOutlined,
|
||||
ArrowLeftOutlined,
|
||||
DatabaseOutlined,
|
||||
FormOutlined,
|
||||
CheckCircleFilled,
|
||||
FieldStringOutlined,
|
||||
BranchesOutlined,
|
||||
FilterOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type { FieldMetadata } from '../api/iitProjectApi';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const FIELD_TYPE_COLORS: Record<string, string> = {
|
||||
text: 'blue',
|
||||
radio: 'green',
|
||||
dropdown: 'cyan',
|
||||
checkbox: 'orange',
|
||||
yesno: 'purple',
|
||||
truefalse: 'purple',
|
||||
notes: 'geekblue',
|
||||
descriptive: 'default',
|
||||
calc: 'magenta',
|
||||
file: 'volcano',
|
||||
slider: 'lime',
|
||||
sql: 'red',
|
||||
};
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: '文本',
|
||||
radio: '单选',
|
||||
dropdown: '下拉',
|
||||
checkbox: '多选',
|
||||
yesno: '是/否',
|
||||
truefalse: '真/假',
|
||||
notes: '备注',
|
||||
descriptive: '描述',
|
||||
calc: '计算',
|
||||
file: '文件',
|
||||
slider: '滑块',
|
||||
sql: 'SQL',
|
||||
};
|
||||
|
||||
const VariableListPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const projectId = searchParams.get('projectId') || '';
|
||||
|
||||
const [fields, setFields] = useState<FieldMetadata[]>([]);
|
||||
const [forms, setForms] = useState<string[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedForm, setSelectedForm] = useState<string | undefined>(undefined);
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null);
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const project = await iitProjectApi.getProject(projectId);
|
||||
setProjectName(project.name);
|
||||
setLastSyncAt(project.lastSyncAt || null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchMetadata = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await iitProjectApi.listFieldMetadata(projectId, {
|
||||
formName: selectedForm,
|
||||
search: searchText || undefined,
|
||||
});
|
||||
setFields(result.fields);
|
||||
setForms(result.forms);
|
||||
setTotal(result.total);
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '获取变量清单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, selectedForm, searchText]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProject();
|
||||
}, [fetchProject]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetadata();
|
||||
}, [fetchMetadata]);
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!projectId) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const result = await iitProjectApi.syncMetadata(projectId);
|
||||
message.success(`同步完成: ${result.fieldCount} 个字段`);
|
||||
setLastSyncAt(new Date().toISOString());
|
||||
await fetchMetadata();
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '同步失败');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
const typeCount: Record<string, number> = {};
|
||||
let requiredCount = 0;
|
||||
let branchingCount = 0;
|
||||
for (const f of fields) {
|
||||
typeCount[f.fieldType] = (typeCount[f.fieldType] || 0) + 1;
|
||||
if (f.required) requiredCount++;
|
||||
if (f.branching) branchingCount++;
|
||||
}
|
||||
return { typeCount, requiredCount, branchingCount };
|
||||
}, [fields]);
|
||||
|
||||
const columns: ColumnsType<FieldMetadata> = [
|
||||
{
|
||||
title: '字段名',
|
||||
dataIndex: 'fieldName',
|
||||
key: 'fieldName',
|
||||
width: 200,
|
||||
fixed: 'left',
|
||||
render: (name: string) => (
|
||||
<Text copyable code style={{ fontSize: 12 }}>{name}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'fieldLabel',
|
||||
key: 'fieldLabel',
|
||||
width: 260,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (label: string) => (
|
||||
<Tooltip title={label}>
|
||||
<span>{label}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'fieldType',
|
||||
key: 'fieldType',
|
||||
width: 100,
|
||||
filters: Object.entries(stats.typeCount).map(([type, count]) => ({
|
||||
text: `${FIELD_TYPE_LABELS[type] || type} (${count})`,
|
||||
value: type,
|
||||
})),
|
||||
onFilter: (value, record) => record.fieldType === value,
|
||||
render: (type: string) => (
|
||||
<Tag color={FIELD_TYPE_COLORS[type] || 'default'}>
|
||||
{FIELD_TYPE_LABELS[type] || type}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '表单',
|
||||
dataIndex: 'formName',
|
||||
key: 'formName',
|
||||
width: 180,
|
||||
render: (form: string) => <Tag icon={<FormOutlined />}>{form}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '必填',
|
||||
dataIndex: 'required',
|
||||
key: 'required',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
filters: [
|
||||
{ text: '必填', value: true },
|
||||
{ text: '非必填', value: false },
|
||||
],
|
||||
onFilter: (value, record) => record.required === value,
|
||||
render: (req: boolean) =>
|
||||
req ? <CheckCircleFilled style={{ color: '#f5222d' }} /> : <span style={{ color: '#d9d9d9' }}>—</span>,
|
||||
},
|
||||
{
|
||||
title: '验证规则',
|
||||
dataIndex: 'validation',
|
||||
key: 'validation',
|
||||
width: 160,
|
||||
render: (_: any, record: FieldMetadata) => {
|
||||
if (!record.validation) return <span style={{ color: '#d9d9d9' }}>—</span>;
|
||||
const parts = [record.validation];
|
||||
if (record.validationMin || record.validationMax) {
|
||||
parts.push(`[${record.validationMin || ''}~${record.validationMax || ''}]`);
|
||||
}
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>{parts.join(' ')}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '选项',
|
||||
dataIndex: 'choices',
|
||||
key: 'choices',
|
||||
width: 200,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (choices: string | null) => {
|
||||
if (!choices) return <span style={{ color: '#d9d9d9' }}>—</span>;
|
||||
const items = choices.split('|').map(c => c.trim());
|
||||
if (items.length <= 3) {
|
||||
return (
|
||||
<Space size={2} wrap>
|
||||
{items.map((item, i) => <Tag key={i} style={{ fontSize: 11 }}>{item}</Tag>)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip title={choices}>
|
||||
<Space size={2}>
|
||||
{items.slice(0, 2).map((item, i) => <Tag key={i} style={{ fontSize: 11 }}>{item}</Tag>)}
|
||||
<Tag style={{ fontSize: 11 }}>+{items.length - 2}</Tag>
|
||||
</Space>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '分支逻辑',
|
||||
dataIndex: 'branching',
|
||||
key: 'branching',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (b: string | null) =>
|
||||
b ? (
|
||||
<Tooltip title={b}>
|
||||
<BranchesOutlined style={{ color: '#722ed1' }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span style={{ color: '#d9d9d9' }}>—</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Alert type="warning" message="请先选择一个项目" showIcon />
|
||||
<Button style={{ marginTop: 16 }} onClick={() => navigate('/iit/config')}>
|
||||
前往项目列表
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px 24px', height: '100%', overflow: 'auto' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)} style={{ padding: 0 }} />
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<DatabaseOutlined style={{ marginRight: 8 }} />
|
||||
变量清单
|
||||
</Title>
|
||||
{projectName && <Tag color="blue">{projectName}</Tag>}
|
||||
</Space>
|
||||
<Space>
|
||||
{lastSyncAt && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
上次同步: {new Date(lastSyncAt).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined spin={syncing} />}
|
||||
loading={syncing}
|
||||
onClick={handleSync}
|
||||
>
|
||||
从 REDCap 同步
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="总字段数" value={total} prefix={<FieldStringOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="表单数" value={forms.length} prefix={<FormOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="必填字段"
|
||||
value={stats.requiredCount}
|
||||
valueStyle={{ color: '#f5222d' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="含分支逻辑"
|
||||
value={stats.branchingCount}
|
||||
prefix={<BranchesOutlined />}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Filters */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="搜索字段名或标签..."
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: 260 }}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onPressEnter={fetchMetadata}
|
||||
/>
|
||||
<Select
|
||||
placeholder="按表单过滤"
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
value={selectedForm}
|
||||
onChange={val => setSelectedForm(val)}
|
||||
options={forms.map(f => ({ label: f, value: f }))}
|
||||
suffixIcon={<FilterOutlined />}
|
||||
/>
|
||||
<Badge count={fields.length} overflowCount={9999} style={{ backgroundColor: '#1677ff' }}>
|
||||
<Text type="secondary">当前显示</Text>
|
||||
</Badge>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card bodyStyle={{ padding: 0 }}>
|
||||
<Table<FieldMetadata>
|
||||
columns={columns}
|
||||
dataSource={fields}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
total: fields.length,
|
||||
pageSize: 50,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
showTotal: (t) => `共 ${t} 个字段`,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description={
|
||||
total === 0
|
||||
? '暂无字段数据,请先从 REDCap 同步元数据'
|
||||
: '当前过滤条件无匹配结果'
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariableListPage;
|
||||
680
frontend-v2/src/modules/iit/styles/QcCockpitPage.css
Normal file
680
frontend-v2/src/modules/iit/styles/QcCockpitPage.css
Normal file
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* 质控驾驶舱页面样式
|
||||
*/
|
||||
|
||||
.qc-cockpit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.qc-cockpit-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.qc-cockpit-loading,
|
||||
.qc-cockpit-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 顶部导航栏 */
|
||||
.qc-cockpit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qc-cockpit-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.qc-cockpit-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.qc-cockpit-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.qc-cockpit-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 统计卡片容器 */
|
||||
.qc-stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.qc-stat-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.qc-stat-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 单个统计卡片 */
|
||||
.qc-stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e8e8e8;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.qc-stat-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 可点击的卡片样式 */
|
||||
.qc-stat-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qc-stat-card.clickable:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.qc-stat-card.clickable:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.qc-stat-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qc-stat-card-arrow {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.qc-stat-card.clickable:hover .qc-stat-card-arrow {
|
||||
transform: translateX(4px);
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.qc-stat-card.critical {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-stat-card.warning {
|
||||
border-left: 4px solid #faad14;
|
||||
}
|
||||
|
||||
.qc-stat-card.success {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
.qc-stat-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.qc-stat-card-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qc-stat-card-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.qc-stat-card-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.qc-stat-card-value.critical {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-stat-card-value.warning {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.qc-stat-card-value.success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.qc-stat-card-suffix {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon.critical {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon.warning {
|
||||
background: #fffbe6;
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon.success {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.qc-stat-card-icon.info {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.qc-stat-card-footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.qc-stat-card-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.qc-stat-card-progress {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* 热力图容器 */
|
||||
.qc-heatmap {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e8e8e8;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.qc-heatmap-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.qc-heatmap-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot.pass {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot.warning {
|
||||
background: #faad14;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot.fail {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-heatmap-legend-dot.pending {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
|
||||
.qc-heatmap-body {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.qc-heatmap-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qc-heatmap-table th,
|
||||
.qc-heatmap-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.qc-heatmap-table th {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.qc-heatmap-table th.subject-header {
|
||||
text-align: left;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.qc-heatmap-table td.subject-cell {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qc-heatmap-table tr:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 热力图单元格 */
|
||||
.qc-heatmap-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon.pass {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon.warning {
|
||||
background: #faad14;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon.fail {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.qc-heatmap-cell-icon.pending {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.qc-status-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.qc-status-tag.enrolled {
|
||||
background: #e6fffb;
|
||||
color: #13c2c2;
|
||||
}
|
||||
|
||||
.qc-status-tag.screening {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.qc-status-tag.completed {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
/* 抽屉样式 */
|
||||
.qc-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.qc-drawer-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qc-drawer-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qc-drawer-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.qc-drawer-subtitle {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.qc-drawer-content {
|
||||
display: flex;
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.qc-drawer-left {
|
||||
flex: 1;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.qc-drawer-right {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.qc-drawer-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.qc-drawer-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 数据字段展示 */
|
||||
.qc-field-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qc-field-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qc-field-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.qc-field-value {
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.qc-field-value.error {
|
||||
background: #fff1f0;
|
||||
border-color: #ffccc7;
|
||||
color: #cf1322;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qc-field-value.warning {
|
||||
background: #fffbe6;
|
||||
border-color: #ffe58f;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
/* 问题卡片 */
|
||||
.qc-issue-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qc-issue-card.critical {
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.qc-issue-card.warning {
|
||||
border-color: #ffe58f;
|
||||
}
|
||||
|
||||
.qc-issue-card-header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qc-issue-card-header.critical {
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.qc-issue-card-header.warning {
|
||||
background: #fffbe6;
|
||||
}
|
||||
|
||||
.qc-issue-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qc-issue-card-title.critical {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.qc-issue-card-title.warning {
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.qc-issue-card-confidence {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.qc-issue-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.qc-issue-card-description {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.qc-issue-card-evidence {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.qc-issue-card-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Tab 切换 */
|
||||
.qc-drawer-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.qc-drawer-tab {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.qc-drawer-tab:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.qc-drawer-tab.active {
|
||||
color: #1890ff;
|
||||
border-bottom-color: #1890ff;
|
||||
}
|
||||
|
||||
/* LLM Trace 样式 */
|
||||
.qc-llm-trace {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 16px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.qc-llm-trace-header {
|
||||
padding: 12px 16px;
|
||||
background: #252526;
|
||||
color: #999;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.qc-llm-trace-content {
|
||||
padding: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.xml-tag {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.xml-attr {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
.xml-value {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
.xml-content {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.xml-comment {
|
||||
color: #6a9955;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1024px) {
|
||||
.qc-drawer-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.qc-drawer-left,
|
||||
.qc-drawer-right {
|
||||
flex: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.qc-drawer-left {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
max-height: 50%;
|
||||
}
|
||||
}
|
||||
136
frontend-v2/src/modules/iit/types/iitProject.ts
Normal file
136
frontend-v2/src/modules/iit/types/iitProject.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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 type RuleCategory =
|
||||
| 'inclusion'
|
||||
| 'exclusion'
|
||||
| 'lab_values'
|
||||
| 'logic_check'
|
||||
| 'variable_qc'
|
||||
| 'protocol_deviation'
|
||||
| 'ae_monitoring';
|
||||
|
||||
export interface QCRule {
|
||||
id: string;
|
||||
name: string;
|
||||
field: string | string[];
|
||||
logic: Record<string, unknown>;
|
||||
message: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: RuleCategory;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateRuleRequest {
|
||||
name: string;
|
||||
field: string | string[];
|
||||
logic: Record<string, unknown>;
|
||||
message: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: RuleCategory;
|
||||
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;
|
||||
}
|
||||
116
frontend-v2/src/modules/iit/types/qcCockpit.ts
Normal file
116
frontend-v2/src/modules/iit/types/qcCockpit.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 质控驾驶舱相关类型定义
|
||||
*/
|
||||
|
||||
// 统计数据
|
||||
export interface QcStats {
|
||||
/** 总体数据质量分 (0-100) */
|
||||
qualityScore: number;
|
||||
/** 总记录数 */
|
||||
totalRecords: number;
|
||||
/** 通过记录数 */
|
||||
passedRecords: number;
|
||||
/** 失败记录数 */
|
||||
failedRecords: number;
|
||||
/** 警告记录数 */
|
||||
warningRecords: number;
|
||||
/** 待检查记录数 */
|
||||
pendingRecords: number;
|
||||
/** 严重违规数(Critical) */
|
||||
criticalCount: number;
|
||||
/** 待确认 Query 数(Major) */
|
||||
queryCount: number;
|
||||
/** 方案偏离数(PD) */
|
||||
deviationCount: number;
|
||||
/** 通过率 */
|
||||
passRate: number;
|
||||
/** 主要问题 */
|
||||
topIssues?: Array<{
|
||||
issue: string;
|
||||
count: number;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
}>;
|
||||
}
|
||||
|
||||
// 热力图数据
|
||||
export interface HeatmapData {
|
||||
/** 行标题(表单/访视名称) */
|
||||
columns: string[];
|
||||
/** 行数据 */
|
||||
rows: HeatmapRow[];
|
||||
}
|
||||
|
||||
export interface HeatmapRow {
|
||||
/** 受试者 ID */
|
||||
recordId: string;
|
||||
/** 入组状态 */
|
||||
status: 'enrolled' | 'screening' | 'completed' | 'withdrawn';
|
||||
/** 各表单/访视的质控状态 */
|
||||
cells: HeatmapCell[];
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
/** 表单/访视名称 */
|
||||
formName: string;
|
||||
/** 质控状态 */
|
||||
status: 'pass' | 'warning' | 'fail' | 'pending';
|
||||
/** 问题数量 */
|
||||
issueCount: number;
|
||||
/** 受试者 ID(冗余,方便查询) */
|
||||
recordId: string;
|
||||
/** 问题摘要 */
|
||||
issues?: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
}>;
|
||||
}
|
||||
|
||||
// 完整的驾驶舱数据
|
||||
export interface QcCockpitData {
|
||||
/** 统计数据 */
|
||||
stats: QcStats;
|
||||
/** 热力图数据 */
|
||||
heatmap: HeatmapData;
|
||||
/** 最后更新时间 */
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
// 记录详情
|
||||
export interface RecordDetail {
|
||||
recordId: string;
|
||||
formName: string;
|
||||
status: 'pass' | 'warning' | 'fail' | 'pending';
|
||||
/** 表单数据 */
|
||||
data: Record<string, any>;
|
||||
/** 字段元数据 */
|
||||
fieldMetadata?: Record<string, {
|
||||
label: string;
|
||||
type: string;
|
||||
normalRange?: { min?: number; max?: number };
|
||||
}>;
|
||||
/** 质控问题 */
|
||||
issues: Array<{
|
||||
field: string;
|
||||
ruleName: string;
|
||||
message: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
actualValue?: any;
|
||||
expectedValue?: string;
|
||||
confidence?: 'high' | 'medium' | 'low';
|
||||
}>;
|
||||
/** LLM Trace(调试用) */
|
||||
llmTrace?: {
|
||||
promptSent: string;
|
||||
responseReceived: string;
|
||||
model: string;
|
||||
latencyMs: number;
|
||||
};
|
||||
/** 录入时间 */
|
||||
entryTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface QcCockpitResponse extends QcCockpitData {}
|
||||
|
||||
export interface RecordDetailResponse extends RecordDetail {}
|
||||
Reference in New Issue
Block a user