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:
2026-02-26 13:28:08 +08:00
parent 31b0433195
commit 203846968c
35 changed files with 7353 additions and 22 deletions

View 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;

View 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;
}

View 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 };

View 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 };

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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%;
}
}

View 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;
}

View 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 {}