diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 2ee54ba6..017c602e 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -40,7 +40,7 @@ async function main() { console.log(` ✅ 内部租户创建成功: ${internalTenant.name}`); // 为内部租户开放所有模块(超级管理员完整权限) - const internalModules = ['AIA', 'ASL', 'PKB', 'DC', 'SSA', 'ST', 'RVW', 'IIT']; + const internalModules = ['AIA', 'ASL', 'PKB', 'DC', 'SSA', 'ST', 'RVW', 'IIT', 'RM', 'AIA_PROTOCOL']; for (const moduleCode of internalModules) { await prisma.tenant_modules.upsert({ where: { tenant_id_module_code: { tenant_id: internalTenant.id, module_code: moduleCode } }, diff --git a/backend/prompts/review_editorial_system.txt b/backend/prompts/review_editorial_system.txt index 8522ce5c..fcc44c55 100644 --- a/backend/prompts/review_editorial_system.txt +++ b/backend/prompts/review_editorial_system.txt @@ -1,4 +1,4 @@ -你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华医学超声杂志的稿约标准对稿件进行评估。 +你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华脑血管病杂志的稿约标准对稿件进行评估。 【你的职责】 1. 仔细阅读稿件的每个部分 diff --git a/backend/scripts/seed-modules.js b/backend/scripts/seed-modules.js index 82479e0e..c204107d 100644 --- a/backend/scripts/seed-modules.js +++ b/backend/scripts/seed-modules.js @@ -43,7 +43,7 @@ const MODULES = [ }, { code: 'IIT', - name: 'IIT管理', + name: 'CRA质控', description: 'IIT项目管理系统,支持REDCap集成和项目协作', icon: 'ProjectOutlined', is_active: true, @@ -73,6 +73,22 @@ const MODULES = [ is_active: true, sort_order: 8, }, + { + code: 'RM', + name: '研究管理', + description: '研究项目管理系统,支持项目全流程管理', + icon: 'ProjectOutlined', + is_active: true, + sort_order: 9, + }, + { + code: 'AIA_PROTOCOL', + name: '全流程研究方案制定', + description: 'AI问答模块内的Protocol Agent功能,可按用户/租户独立配置开关', + icon: 'ExperimentOutlined', + is_active: true, + sort_order: 100, + }, ]; async function main() { diff --git a/backend/src/common/auth/auth.middleware.ts b/backend/src/common/auth/auth.middleware.ts index 0f4c5a67..e4edce4b 100644 --- a/backend/src/common/auth/auth.middleware.ts +++ b/backend/src/common/auth/auth.middleware.ts @@ -14,6 +14,7 @@ import { jwtService } from './jwt.service.js'; import type { DecodedToken } from './jwt.service.js'; import { logger } from '../logging/index.js'; import { moduleService } from './module.service.js'; +import { cache } from '../cache/index.js'; /** * 扩展 Fastify Request 类型 @@ -71,6 +72,15 @@ export const authenticate: preHandlerHookHandler = async ( // 2. 验证 Token const decoded = jwtService.verifyToken(token); + // 2.5 验证 token 版本号(单设备登录:新登录会踢掉旧会话) + if (decoded.tokenVersion !== undefined) { + const tokenVersionKey = `token_version:${decoded.userId}`; + const currentVersion = await cache.get(tokenVersionKey); + if (currentVersion !== null && decoded.tokenVersion < currentVersion) { + throw new AuthenticationError('您的账号已在其他设备登录,当前会话已失效'); + } + } + // 3. 注入用户信息 request.user = decoded; diff --git a/backend/src/common/auth/auth.service.ts b/backend/src/common/auth/auth.service.ts index 6275ddb6..5b1ea9d4 100644 --- a/backend/src/common/auth/auth.service.ts +++ b/backend/src/common/auth/auth.service.ts @@ -13,6 +13,7 @@ import { prisma } from '../../config/database.js'; import { jwtService } from './jwt.service.js'; import type { JWTPayload, TokenResponse } from './jwt.service.js'; import { logger } from '../logging/index.js'; +import { cache } from '../cache/index.js'; /** * 登录请求 - 密码方式 @@ -115,7 +116,13 @@ export class AuthService { const permissions = await this.getUserPermissions(user.role); const modules = await this.getUserModules(user.id); - // 5. 生成 JWT + // 4.5 递增 token 版本号(实现单设备登录,踢掉旧会话) + const tokenVersionKey = `token_version:${user.id}`; + const currentVersion = await cache.get(tokenVersionKey) || 0; + const newVersion = currentVersion + 1; + await cache.set(tokenVersionKey, newVersion, 30 * 24 * 60 * 60); // 30天有效 + + // 5. 生成 JWT(包含 token 版本号) const jwtPayload: JWTPayload = { userId: user.id, phone: user.phone, @@ -123,6 +130,7 @@ export class AuthService { tenantId: user.tenant_id, tenantCode: user.tenants?.code, isDefaultPassword: user.is_default_password, + tokenVersion: newVersion, }; const tokens = jwtService.generateTokens(jwtPayload); @@ -139,6 +147,7 @@ export class AuthService { role: user.role, tenantId: user.tenant_id, modules: modules.length, + tokenVersion: newVersion, }); return { @@ -214,7 +223,13 @@ export class AuthService { const permissions = await this.getUserPermissions(user.role); const modules = await this.getUserModules(user.id); - // 6. 生成 JWT + // 5.5 递增 token 版本号(实现单设备登录,踢掉旧会话) + const tokenVersionKey = `token_version:${user.id}`; + const currentVersion = await cache.get(tokenVersionKey) || 0; + const newVersion = currentVersion + 1; + await cache.set(tokenVersionKey, newVersion, 30 * 24 * 60 * 60); + + // 6. 生成 JWT(包含 token 版本号) const jwtPayload: JWTPayload = { userId: user.id, phone: user.phone, @@ -222,6 +237,7 @@ export class AuthService { tenantId: user.tenant_id, tenantCode: user.tenants?.code, isDefaultPassword: user.is_default_password, + tokenVersion: newVersion, }; const tokens = jwtService.generateTokens(jwtPayload); @@ -231,6 +247,7 @@ export class AuthService { phone: user.phone, role: user.role, modules: modules.length, + tokenVersion: newVersion, }); return { @@ -431,6 +448,10 @@ export class AuthService { return null; } + // 获取当前 token 版本号(单设备登录校验) + const tokenVersionKey = `token_version:${user.id}`; + const currentVersion = await cache.get(tokenVersionKey) || 0; + return { userId: user.id, phone: user.phone, @@ -438,6 +459,7 @@ export class AuthService { tenantId: user.tenant_id, tenantCode: user.tenants?.code, isDefaultPassword: user.is_default_password, + tokenVersion: currentVersion, }; }); } diff --git a/backend/src/common/auth/jwt.service.ts b/backend/src/common/auth/jwt.service.ts index adf4aa70..05900c55 100644 --- a/backend/src/common/auth/jwt.service.ts +++ b/backend/src/common/auth/jwt.service.ts @@ -28,6 +28,8 @@ export interface JWTPayload { tenantCode?: string; /** 是否为默认密码 */ isDefaultPassword?: boolean; + /** Token版本号(用于单点登录踢人) */ + tokenVersion?: number; } /** @@ -83,10 +85,10 @@ export class JWTService { * 生成 Refresh Token */ generateRefreshToken(payload: JWTPayload): string { - // Refresh Token 只包含必要信息 const refreshPayload = { userId: payload.userId, type: 'refresh', + tokenVersion: payload.tokenVersion, }; const options: SignOptions = { @@ -144,12 +146,19 @@ export class JWTService { throw new Error('无效的Refresh Token'); } - // 获取用户最新信息 + // 获取用户最新信息(包含当前 tokenVersion) const user = await getUserById(decoded.userId); if (!user) { throw new Error('用户不存在'); } + // 验证 token 版本号(踢人检查) + const refreshTokenVersion = (decoded as any).tokenVersion; + if (refreshTokenVersion !== undefined && user.tokenVersion !== undefined + && refreshTokenVersion < user.tokenVersion) { + throw new Error('您的账号已在其他设备登录,当前会话已失效'); + } + // 生成新的 Tokens return this.generateTokens(user); } diff --git a/backend/src/common/auth/module.service.ts b/backend/src/common/auth/module.service.ts index c282aeea..46233f46 100644 --- a/backend/src/common/auth/module.service.ts +++ b/backend/src/common/auth/module.service.ts @@ -152,8 +152,52 @@ class ModuleService { }); }); - // 6. 合并所有模块(去重) - const moduleSet = new Set(tenantModulesData.map(tm => tm.module_code)); + // 5.5 查询用户级别的模块权限(精细化控制) + const userModulesData = await prisma.user_modules.findMany({ + where: { + user_id: userId, + tenant_id: { in: tenantIds }, + }, + select: { + tenant_id: true, + module_code: true, + is_enabled: true, + }, + }); + + // 按租户分组 user_modules + const userModulesByTenant = new Map>(); + for (const um of userModulesData) { + if (!userModulesByTenant.has(um.tenant_id)) { + userModulesByTenant.set(um.tenant_id, new Map()); + } + userModulesByTenant.get(um.tenant_id)!.set(um.module_code, um.is_enabled); + } + + // 6. 合并所有模块(去重),尊重 user_modules 精细化配置 + const moduleSet = new Set(); + + for (const tm of tenantModulesData) { + const userModulesForTenant = userModulesByTenant.get(tm.tenant_id); + if (userModulesForTenant && userModulesForTenant.size > 0) { + const isEnabled = userModulesForTenant.get(tm.module_code); + if (isEnabled) { + moduleSet.add(tm.module_code); + } + } else { + moduleSet.add(tm.module_code); + } + } + + // 6.5 补充用户级独立配置的模块(如 AIA_PROTOCOL,租户未订阅但用户单独开通) + for (const [, userModuleMap] of userModulesByTenant) { + for (const [moduleCode, isEnabled] of userModuleMap) { + if (isEnabled) { + moduleSet.add(moduleCode); + } + } + } + const allModuleCodes = Array.from(moduleSet); // 7. 获取模块详细信息 diff --git a/backend/src/modules/admin/iit-projects/iitBatchController.ts b/backend/src/modules/admin/iit-projects/iitBatchController.ts index 540f8301..33652c10 100644 --- a/backend/src/modules/admin/iit-projects/iitBatchController.ts +++ b/backend/src/modules/admin/iit-projects/iitBatchController.ts @@ -19,6 +19,7 @@ import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js'; import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js'; import { QcExecutor } from '../../iit-manager/engines/QcExecutor.js'; import { QcReportService } from '../../iit-manager/services/QcReportService.js'; +import { dailyQcOrchestrator } from '../../iit-manager/services/DailyQcOrchestrator.js'; const prisma = new PrismaClient(); @@ -63,12 +64,22 @@ export class IitBatchController { const { totalRecords, totalEvents, passed, failed, warnings, fieldStatusWrites, executionTimeMs } = batchResult; const passRate = totalEvents > 0 ? `${((passed / totalEvents) * 100).toFixed(1)}%` : '0%'; - // 自动刷新 QcReport 缓存,使业务端立即看到最新数据 + // 编排后续动作:生成报告 + 创建 eQuery + 归档关键事件 + 推送通知 + let equeriesCreated = 0; try { - await QcReportService.refreshReport(projectId); - logger.info('[V3.1] QcReport cache refreshed after batch QC', { projectId }); - } catch (reportErr: any) { - logger.warn('[V3.1] QcReport refresh failed (non-blocking)', { projectId, error: reportErr.message }); + const orchResult = await dailyQcOrchestrator.orchestrate(projectId); + equeriesCreated = orchResult.equeriesCreated; + logger.info('[V3.1] Orchestration completed after batch QC', { + projectId, + equeriesCreated: orchResult.equeriesCreated, + criticalEventsArchived: orchResult.criticalEventsArchived, + }); + } catch (orchErr: any) { + logger.warn('[V3.1] Orchestration failed (non-blocking)', { projectId, error: orchErr.message }); + // fallback: at least refresh report cache + try { + await QcReportService.refreshReport(projectId); + } catch { /* ignore */ } } const durationMs = Date.now() - startTime; @@ -87,6 +98,7 @@ export class IitBatchController { warnings, fieldStatusWrites, passRate, + equeriesCreated, }, durationMs, }); diff --git a/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts b/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts index f65dc2e9..8178c05b 100644 --- a/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts +++ b/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts @@ -189,7 +189,9 @@ class IitQcCockpitController { } /** - * 获取 AI 工作时间线(QC 日志 + Agent Trace 合并) + * 获取 AI 工作时间线(从 qc_field_status 五级结构读取,SSOT) + * + * 按受试者分组,展示每个受试者的 FAIL/WARNING 问题列表。 */ async getTimeline( request: FastifyRequest<{ @@ -201,81 +203,168 @@ class IitQcCockpitController { const { projectId } = request.params; const query = request.query as any; const page = query.page ? parseInt(query.page) : 1; - const pageSize = query.pageSize ? parseInt(query.pageSize) : 50; + const pageSize = query.pageSize ? parseInt(query.pageSize) : 20; const dateFilter = query.date; try { - const dateWhere: any = {}; + let dateClause = ''; if (dateFilter) { - const start = new Date(dateFilter); - const end = new Date(dateFilter); - end.setDate(end.getDate() + 1); - dateWhere.createdAt = { gte: start, lt: end }; + dateClause = `AND fs.last_qc_at >= '${dateFilter}'::date AND fs.last_qc_at < ('${dateFilter}'::date + INTERVAL '1 day')`; } - const [qcLogs, totalLogs] = await Promise.all([ - prisma.iitQcLog.findMany({ - where: { projectId, ...dateWhere }, - orderBy: { createdAt: 'desc' }, - skip: (page - 1) * pageSize, - take: pageSize, - select: { - id: true, - recordId: true, - eventId: true, - qcType: true, - formName: true, - status: true, - issues: true, - rulesEvaluated: true, - rulesPassed: true, - rulesFailed: true, - triggeredBy: true, - createdAt: true, - }, - }), - prisma.iitQcLog.count({ where: { projectId, ...dateWhere } }), - ]); + // 1. 获取有问题的受试者摘要(分页) + const recordSummaries = await prisma.$queryRawUnsafe>( + `SELECT + fs.record_id, + COUNT(*) FILTER (WHERE fs.severity = 'critical') AS critical_count, + COUNT(*) FILTER (WHERE fs.severity != 'critical') AS warning_count, + COUNT(*) AS total_issues, + MAX(fs.last_qc_at) AS latest_qc_at, + MAX(fs.triggered_by) AS triggered_by + FROM iit_schema.qc_field_status fs + WHERE fs.project_id = $1 AND fs.status IN ('FAIL', 'WARNING') + ${dateClause} + GROUP BY fs.record_id + ORDER BY MAX(fs.last_qc_at) DESC + LIMIT $2 OFFSET $3`, + projectId, pageSize, (page - 1) * pageSize + ); - const items = qcLogs.map((log) => { - const rawIssues = log.issues as any; - const issues: any[] = Array.isArray(rawIssues) ? rawIssues : (rawIssues?.items || []); - const redCount = issues.filter((i: any) => i.severity === 'critical' || i.level === 'RED').length; - const yellowCount = issues.filter((i: any) => i.severity === 'warning' || i.level === 'YELLOW').length; - const eventLabel = rawIssues?.eventLabel || ''; - const totalRules = rawIssues?.summary?.totalRules || log.rulesEvaluated || 0; + // 2. 总受试者数 + const countResult = await prisma.$queryRawUnsafe>( + `SELECT COUNT(DISTINCT record_id) AS cnt + FROM iit_schema.qc_field_status + WHERE project_id = $1 AND status IN ('FAIL', 'WARNING') + ${dateClause}`, + projectId + ); + const totalRecords = Number(countResult[0]?.cnt || 0); - let description = `扫描受试者 ${log.recordId}`; - if (eventLabel) description += `「${eventLabel}」`; - description += ` → 执行 ${totalRules} 条规则 (${log.rulesPassed} 通过`; - if (log.rulesFailed > 0) description += `, ${log.rulesFailed} 失败`; - description += ')'; - if (redCount > 0) description += ` → 发现 ${redCount} 个严重问题`; - if (yellowCount > 0) description += `, ${yellowCount} 个警告`; + // 3. 获取这些受试者的问题详情(LEFT JOIN 获取字段/事件中文名) + const recordIds = recordSummaries.map(r => r.record_id); + let issues: any[] = []; + if (recordIds.length > 0) { + issues = await prisma.$queryRawUnsafe( + `SELECT + fs.record_id, fs.event_id, fs.form_name, fs.field_name, + fm.field_label, + es.event_label, + fs.rule_category, fs.rule_name, fs.rule_id, + fs.severity, fs.status, fs.message, + fs.actual_value, fs.expected_value, fs.last_qc_at + FROM iit_schema.qc_field_status fs + LEFT JOIN iit_schema.field_metadata fm + ON fm.project_id = fs.project_id AND fm.field_name = fs.field_name + LEFT JOIN iit_schema.qc_event_status es + ON es.project_id = fs.project_id AND es.record_id = fs.record_id AND es.event_id = fs.event_id + WHERE fs.project_id = $1 + AND fs.status IN ('FAIL', 'WARNING') + AND fs.record_id = ANY($2) + ORDER BY fs.record_id, fs.last_qc_at DESC`, + projectId, recordIds + ); + } + + // 4. 组装成按受试者分组的 timeline items + const issuesByRecord = new Map(); + for (const issue of issues) { + const key = issue.record_id; + if (!issuesByRecord.has(key)) issuesByRecord.set(key, []); + issuesByRecord.get(key)!.push(issue); + } + + // 5. 同时获取通过的受试者(无问题的),补充到时间线 + const passedRecords = await prisma.$queryRawUnsafe>( + `SELECT + fs.record_id, + COUNT(*) AS total_fields, + MAX(fs.last_qc_at) AS latest_qc_at, + MAX(fs.triggered_by) AS triggered_by + FROM iit_schema.qc_field_status fs + WHERE fs.project_id = $1 + AND fs.record_id NOT IN ( + SELECT DISTINCT record_id FROM iit_schema.qc_field_status + WHERE project_id = $1 AND status IN ('FAIL', 'WARNING') + ) + ${dateClause} + GROUP BY fs.record_id + ORDER BY MAX(fs.last_qc_at) DESC + LIMIT 10`, + projectId + ); + + const items = recordSummaries.map(rec => { + const recIssues = issuesByRecord.get(rec.record_id) || []; + const criticalCount = Number(rec.critical_count); + const warningCount = Number(rec.warning_count); + const status = criticalCount > 0 ? 'FAIL' : 'WARNING'; return { - id: log.id, + id: `fs_${rec.record_id}`, type: 'qc_check' as const, - time: log.createdAt, - recordId: log.recordId, - eventLabel, - formName: log.formName, - status: log.status, - triggeredBy: log.triggeredBy, - description, + time: rec.latest_qc_at, + recordId: rec.record_id, + status, + triggeredBy: rec.triggered_by || 'batch', + description: `受试者 ${rec.record_id} 发现 ${criticalCount + warningCount} 个问题`, details: { - rulesEvaluated: totalRules, - rulesPassed: log.rulesPassed, - rulesFailed: log.rulesFailed, - issuesSummary: { red: redCount, yellow: yellowCount }, - issues, + issuesSummary: { red: criticalCount, yellow: warningCount }, + issues: recIssues.map((i: any) => ({ + ruleId: i.rule_id || '', + ruleName: i.rule_name || '', + ruleCategory: i.rule_category || '', + field: i.field_name || '', + fieldLabel: i.field_label || '', + eventId: i.event_id || '', + eventLabel: i.event_label || '', + formName: i.form_name || '', + message: i.message || '', + severity: i.severity || 'warning', + actualValue: i.actual_value, + expectedValue: i.expected_value, + })), }, }; }); + // 追加通过的受试者 + for (const rec of passedRecords) { + items.push({ + id: `fs_pass_${rec.record_id}`, + type: 'qc_check' as const, + time: rec.latest_qc_at, + recordId: rec.record_id, + status: 'PASS', + triggeredBy: rec.triggered_by || 'batch', + description: `受试者 ${rec.record_id} 全部通过 (${Number(rec.total_fields)} 个字段)`, + details: { + issuesSummary: { red: 0, yellow: 0 }, + issues: [], + }, + }); + } + + // 按时间降序排序 + items.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()); + + // 6. 总数 = 有问题 + 通过 + const totalAll = totalRecords + passedRecords.length; + return reply.send({ success: true, - data: { items, total: totalLogs, page, pageSize }, + data: { items, total: totalAll, page, pageSize }, }); } catch (error: any) { logger.error('[QcCockpitController] 获取时间线失败', { projectId, error: error.message }); @@ -493,6 +582,129 @@ class IitQcCockpitController { } } + /** + * 字段级问题分页查询(从 qc_field_status SSOT) + * 支持按 severity / dimension / recordId 筛选 + */ + async getFieldIssues( + request: FastifyRequest<{ + Params: { projectId: string }; + Querystring: { page?: string; pageSize?: string; severity?: string; dimension?: string; recordId?: string }; + }>, + reply: FastifyReply + ) { + const { projectId } = request.params; + const query = request.query as any; + const page = query.page ? parseInt(query.page) : 1; + const pageSize = query.pageSize ? parseInt(query.pageSize) : 50; + const severity = query.severity; // 'critical' | 'warning' + const dimension = query.dimension; // 'D1' | 'D3' | ... + const recordId = query.recordId; + + try { + const conditions: string[] = [`fs.project_id = $1`, `fs.status IN ('FAIL', 'WARNING')`]; + const params: any[] = [projectId]; + let paramIdx = 2; + + if (severity) { + conditions.push(`fs.severity = $${paramIdx}`); + params.push(severity); + paramIdx++; + } + if (dimension) { + conditions.push(`fs.rule_category = $${paramIdx}`); + params.push(dimension); + paramIdx++; + } + if (recordId) { + conditions.push(`fs.record_id = $${paramIdx}`); + params.push(recordId); + paramIdx++; + } + + const whereClause = conditions.join(' AND '); + + const [rows, countResult] = await Promise.all([ + prisma.$queryRawUnsafe( + `SELECT + fs.id, fs.record_id, fs.event_id, fs.form_name, fs.field_name, + fm.field_label, + es.event_label, + fs.rule_category, fs.rule_name, fs.rule_id, + fs.severity, fs.status, fs.message, + fs.actual_value, fs.expected_value, fs.last_qc_at + FROM iit_schema.qc_field_status fs + LEFT JOIN iit_schema.field_metadata fm + ON fm.project_id = fs.project_id AND fm.field_name = fs.field_name + LEFT JOIN iit_schema.qc_event_status es + ON es.project_id = fs.project_id AND es.record_id = fs.record_id AND es.event_id = fs.event_id + WHERE ${whereClause} + ORDER BY fs.last_qc_at DESC + LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`, + ...params, pageSize, (page - 1) * pageSize + ), + prisma.$queryRawUnsafe>( + `SELECT COUNT(*) AS cnt FROM iit_schema.qc_field_status fs WHERE ${whereClause}`, + ...params + ), + ]); + + const total = Number(countResult[0]?.cnt || 0); + + const items = rows.map((r: any) => ({ + id: r.id, + recordId: r.record_id, + eventId: r.event_id, + eventLabel: r.event_label || '', + formName: r.form_name, + fieldName: r.field_name, + fieldLabel: r.field_label || '', + ruleCategory: r.rule_category, + ruleName: r.rule_name, + ruleId: r.rule_id, + severity: r.severity, + status: r.status, + message: r.message, + actualValue: r.actual_value, + expectedValue: r.expected_value, + lastQcAt: r.last_qc_at, + })); + + // 聚合统计 + const summaryResult = await prisma.$queryRawUnsafe>( + `SELECT fs.severity, COALESCE(fs.rule_category, 'OTHER') AS rule_category, COUNT(*) AS cnt + FROM iit_schema.qc_field_status fs + WHERE fs.project_id = $1 AND fs.status IN ('FAIL', 'WARNING') + GROUP BY fs.severity, fs.rule_category`, + projectId + ); + + const summary = { + totalIssues: total, + bySeverity: { critical: 0, warning: 0, info: 0 } as Record, + byDimension: {} as Record, + }; + for (const row of summaryResult) { + const sev = row.severity || 'warning'; + summary.bySeverity[sev] = (summary.bySeverity[sev] || 0) + Number(row.cnt); + const dim = row.rule_category || 'OTHER'; + summary.byDimension[dim] = (summary.byDimension[dim] || 0) + Number(row.cnt); + } + + return reply.send({ + success: true, + data: { items, total, page, pageSize, summary }, + }); + } catch (error: any) { + logger.error('[QcCockpitController] getFieldIssues failed', { projectId, error: error.message }); + return reply.status(500).send({ success: false, error: error.message }); + } + } + // ============================================================ // GCP 业务报表 API // ============================================================ diff --git a/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts b/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts index 7b9b1790..ff45b3fb 100644 --- a/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts +++ b/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts @@ -292,6 +292,25 @@ export async function iitQcCockpitRoutes(fastify: FastifyInstance) { // V3.1: D6 方案偏离列表 fastify.get('/:projectId/qc-cockpit/deviations', iitQcCockpitController.getDeviations.bind(iitQcCockpitController)); + // 字段级问题分页查询(支持按维度/严重程度筛选) + fastify.get('/:projectId/qc-cockpit/field-issues', { + schema: { + description: '从 qc_field_status 分页查询所有问题字段', + tags: ['IIT Admin - QC 驾驶舱'], + params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] }, + querystring: { + type: 'object', + properties: { + page: { type: 'string' }, + pageSize: { type: 'string' }, + severity: { type: 'string' }, + dimension: { type: 'string' }, + recordId: { type: 'string' }, + }, + }, + }, + }, iitQcCockpitController.getFieldIssues.bind(iitQcCockpitController)); + // ============================================================ // GCP 业务报表路由 // ============================================================ diff --git a/backend/src/modules/admin/iit-projects/iitQcRuleController.ts b/backend/src/modules/admin/iit-projects/iitQcRuleController.ts index a262f3b3..7a6b07ab 100644 --- a/backend/src/modules/admin/iit-projects/iitQcRuleController.ts +++ b/backend/src/modules/admin/iit-projects/iitQcRuleController.ts @@ -289,16 +289,20 @@ export async function getRuleStats( } /** - * AI 规则建议 + * AI 规则建议(支持按维度生成) */ export async function suggestRules( - request: FastifyRequest<{ Params: ProjectIdParams }>, + request: FastifyRequest<{ Params: ProjectIdParams; Querystring: { dimension?: string } }>, reply: FastifyReply ) { try { const { projectId } = request.params; + const dimension = (request.query as any)?.dimension as string | undefined; + const validDimensions = ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7']; + const dim = dimension && validDimensions.includes(dimension) ? dimension as any : undefined; + const service = getIitRuleSuggestionService(prisma); - const suggestions = await service.suggestRules(projectId); + const suggestions = await service.suggestRules(projectId, dim); return reply.send({ success: true, @@ -306,7 +310,33 @@ export async function suggestRules( }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - logger.error('AI 规则建议生成失败', { error: message }); + logger.error('AI 规则建议生成失败', { error: message, dimension: (request.query as any)?.dimension }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * D3 规则自动生成(数据驱动,无需 LLM) + */ +export async function generateD3Rules( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const service = getIitRuleSuggestionService(prisma); + const rules = await service.generateD3Rules(projectId); + + return reply.send({ + success: true, + data: rules, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('D3 规则自动生成失败', { error: message }); return reply.status(500).send({ success: false, error: message, diff --git a/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts b/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts index 27b829a7..5a73aeb0 100644 --- a/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts +++ b/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts @@ -27,9 +27,12 @@ export async function iitQcRuleRoutes(fastify: FastifyInstance) { // 批量导入规则 fastify.post('/:projectId/rules/import', controller.importRules); - // AI 规则建议 + // AI 规则建议(支持 ?dimension=D1 查询参数) fastify.post('/:projectId/rules/suggest', controller.suggestRules); + // D3 规则自动生成(数据驱动,无需 LLM) + fastify.post('/:projectId/rules/generate-d3', controller.generateD3Rules); + // 测试规则逻辑(不需要项目 ID) fastify.post('/rules/test', controller.testRule); } diff --git a/backend/src/modules/admin/iit-projects/iitQcRuleService.ts b/backend/src/modules/admin/iit-projects/iitQcRuleService.ts index b38b3111..dbc6db5c 100644 --- a/backend/src/modules/admin/iit-projects/iitQcRuleService.ts +++ b/backend/src/modules/admin/iit-projects/iitQcRuleService.ts @@ -171,14 +171,17 @@ export class IitQcRuleService { async importRules(projectId: string, rules: CreateRuleInput[]): Promise { const skill = await this.getOrCreateSkill(projectId); + const existingConfig = (skill.config as unknown as QCRuleConfig) || { rules: [], version: 1, updatedAt: '' }; + const existingRules = Array.isArray(existingConfig.rules) ? existingConfig.rules : []; + const newRules: QCRule[] = rules.map((input, index) => ({ id: `rule_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`, ...input, })); const config: QCRuleConfig = { - rules: newRules, - version: 1, + rules: [...existingRules, ...newRules], + version: (existingConfig.version || 0) + 1, updatedAt: new Date().toISOString(), }; diff --git a/backend/src/modules/admin/iit-projects/iitRuleSuggestionService.ts b/backend/src/modules/admin/iit-projects/iitRuleSuggestionService.ts index 6d42dc85..bd988403 100644 --- a/backend/src/modules/admin/iit-projects/iitRuleSuggestionService.ts +++ b/backend/src/modules/admin/iit-projects/iitRuleSuggestionService.ts @@ -2,6 +2,7 @@ * AI 规则建议服务 * * 读取项目的变量元数据和知识库文档,调用 LLM 生成质控规则建议。 + * 支持按维度(D1-D7)生成,以及纯数据驱动的 D3 规则自动构建。 */ import { PrismaClient } from '@prisma/client'; @@ -9,6 +10,8 @@ import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js'; import type { Message } from '../../../common/llm/adapters/types.js'; import { logger } from '../../../common/logging/index.js'; +export type DimensionCode = 'D1' | 'D2' | 'D3' | 'D4' | 'D5' | 'D6' | 'D7'; + export interface RuleSuggestion { name: string; field: string | string[]; @@ -16,18 +19,86 @@ export interface RuleSuggestion { message: string; severity: 'error' | 'warning' | 'info'; category: string; + applicableEvents?: string[]; +} + +const DIMENSION_META: Record = { + D1: { label: '入选/排除', description: '受试者纳入标准和排除标准的合规性检查', needsKb: true }, + D2: { label: '完整性', description: '必填字段、缺失数据、表单完成度检查', needsKb: false }, + D3: { label: '准确性', description: '数值范围、枚举值、数据格式准确性检查', needsKb: false }, + D4: { label: '质疑管理', description: 'Query 响应时限和关闭状态检查', needsKb: false }, + D5: { label: '安全性', description: '不良事件(AE/SAE)报告时限和完整性检查', needsKb: true }, + D6: { label: '方案偏离', description: '访视窗口期、用药方案合规性检查', needsKb: true }, + D7: { label: '药物管理', description: '试验药物接收、分配、回收记录的完整性检查', needsKb: true }, +}; + +function buildDimensionPrompt(dimension: DimensionCode): string { + switch (dimension) { + case 'D1': + return `Focus EXCLUSIVELY on D1 (Eligibility) rules: +- Inclusion criteria: age range, gender, diagnosis, consent date +- Exclusion criteria: contraindicated conditions, prior treatments, lab exclusions +- Generate rules that verify each inclusion criterion is met and no exclusion criterion is triggered +- Use fields related to: demographics, medical_history, consent, eligibility forms`; + + case 'D2': + return `Focus EXCLUSIVELY on D2 (Completeness) rules: +- Required field checks: key CRF fields that must not be empty +- Form completion checks: ensure critical forms have data +- Missing data detection for safety-critical fields +- Use "!!" or "missing" operators to check field presence`; + + case 'D3': + return `Focus EXCLUSIVELY on D3 (Accuracy) rules: +- Numeric range checks: vital signs, lab values, dosing +- Enum/choice validation: field values within allowed options +- Date logic: visit date order, date format validity +- Cross-field consistency: e.g. BMI matches height/weight`; + + case 'D5': + return `Focus EXCLUSIVELY on D5 (Safety/AE) rules: +- AE onset date must be after informed consent date +- SAE must be reported within 24 hours (if reporting date available) +- AE severity and outcome fields must be complete when AE is present +- Relationship to study drug must be documented`; + + case 'D6': + return `Focus EXCLUSIVELY on D6 (Protocol Deviation) rules: +- Visit window checks: actual visit date within allowed window of scheduled date +- Dose modification rules: dose changes must have documented reason +- Prohibited concomitant medication checks +- Procedure timing compliance`; + + case 'D7': + return `Focus EXCLUSIVELY on D7 (Drug Management) rules: +- Drug dispensing records completeness +- Drug accountability: dispensed vs returned quantities +- Storage temperature compliance (if tracked) +- Drug expiry date checks`; + + case 'D4': + return `Focus EXCLUSIVELY on D4 (Query Management) rules: +- Data discrepancy auto-detection +- Cross-form consistency checks that would generate queries +- Logic contradiction checks between related fields`; + + default: + return ''; + } } export class IitRuleSuggestionService { constructor(private prisma: PrismaClient) {} - async suggestRules(projectId: string): Promise { + /** + * AI 生成规则建议(按维度) + */ + async suggestRules(projectId: string, dimension?: DimensionCode): Promise { const project = await this.prisma.iitProject.findFirst({ where: { id: projectId, deletedAt: null }, }); if (!project) throw new Error('项目不存在'); - // 1. Gather variable metadata const fields = await this.prisma.iitFieldMetadata.findMany({ where: { projectId }, orderBy: [{ formName: 'asc' }, { fieldName: 'asc' }], @@ -37,9 +108,11 @@ export class IitRuleSuggestionService { throw new Error('请先从 REDCap 同步变量元数据'); } - // 2. Gather knowledge base context (protocol summary) let protocolContext = ''; - if (project.knowledgeBaseId) { + const dimMeta = dimension ? DIMENSION_META[dimension] : null; + const needsKb = dimMeta ? dimMeta.needsKb : true; + + if (needsKb && project.knowledgeBaseId) { try { const docs = await this.prisma.ekbDocument.findMany({ where: { kbId: project.knowledgeBaseId, status: 'completed' }, @@ -58,7 +131,6 @@ export class IitRuleSuggestionService { } } - // 3. Build variable summary for LLM const variableSummary = fields.map((f) => { const parts = [`${f.fieldName} (${f.fieldLabel}): type=${f.fieldType}, form=${f.formName}`]; if (f.validation) parts.push(`validation=${f.validation}`); @@ -68,35 +140,31 @@ export class IitRuleSuggestionService { return parts.join(', '); }).join('\n'); - // 4. Call LLM - const systemPrompt = `You are an expert clinical research data manager. You generate quality control (QC) rules for clinical trial data captured in REDCap. + const dimensionList = Object.entries(DIMENSION_META) + .map(([code, meta]) => `- ${code}: ${meta.label} — ${meta.description}`) + .join('\n'); -Rules must be in JSON Logic format (https://jsonlogic.com). Each rule checks one or more fields. + const dimensionInstruction = dimension + ? `\n\n*** IMPORTANT: ${buildDimensionPrompt(dimension)} ***\nAll generated rules MUST have category="${dimension}".` + : ''; -Available categories: -- variable_qc: Field-level checks (range, required, format, enum) -- inclusion: Inclusion criteria checks -- exclusion: Exclusion criteria checks -- lab_values: Lab value range checks -- logic_check: Cross-field logic checks -- protocol_deviation: Visit window / time constraint checks -- ae_monitoring: AE reporting timeline checks + const systemPrompt = `You are an expert clinical research data manager (GCP-trained). Generate QC rules in JSON Logic format for clinical trial data from REDCap. -Severity levels: error (blocking), warning (review needed), info (informational) +Available dimension categories (use these as the "category" field): +${dimensionList} -Respond ONLY with a JSON array of rule objects. Each object must have these fields: +Severity levels: error (blocking issue), warning (needs review), info (informational) +${dimensionInstruction} + +Respond ONLY with a JSON array. Each object: - name (string): short descriptive name in Chinese -- field (string or string[]): REDCap field name(s) -- logic (object): JSON Logic expression -- message (string): error message in Chinese -- severity: "error" | "warning" | "info" -- category: one of the categories listed above +- field (string or string[]): REDCap field name(s) — must match actual variable names from the list +- logic (object): JSON Logic expression using these field names as {"var": "fieldName"} +- message (string): error/warning message in Chinese +- severity: "error" | "warning" | "info" +- category: one of D1-D7 -Generate 5-10 practical rules. Focus on: -1. Required field checks for key variables -2. Range checks for numeric fields that have validation ranges -3. Logical consistency checks between related fields -4. Date field checks (visit windows, timelines) +Generate 5-10 practical, accurate rules. Do NOT invent field names — only use fields from the provided variable list. Do NOT include explanations, only the JSON array.`; const userPrompt = `Project: ${project.name} @@ -105,7 +173,7 @@ Variable List (${fields.length} fields): ${variableSummary} ${protocolContext ? `\nProtocol / Study Document Context:\n${protocolContext}` : ''} -Generate QC rules for this project:`; +Generate ${dimension ? `${dimension} (${dimMeta!.label})` : 'QC'} rules for this project:`; const messages: Message[] = [ { role: 'system', content: systemPrompt }, @@ -120,7 +188,6 @@ Generate QC rules for this project:`; }); const content = (response.content ?? '').trim(); - // Extract JSON array from response (handle markdown code fences) const jsonMatch = content.match(/\[[\s\S]*\]/); if (!jsonMatch) { logger.error('LLM 返回非 JSON 格式', { content: content.substring(0, 200) }); @@ -129,13 +196,13 @@ Generate QC rules for this project:`; const rules: RuleSuggestion[] = JSON.parse(jsonMatch[0]); - // Validate structure const validRules = rules.filter( (r) => r.name && r.field && r.logic && r.message && r.severity && r.category ); logger.info('AI 规则建议生成成功', { projectId, + dimension: dimension || 'all', total: rules.length, valid: validRules.length, model: response.model, @@ -149,6 +216,90 @@ Generate QC rules for this project:`; throw err; } } + + /** + * 数据驱动的 D3(准确性)规则自动生成 — 无需 LLM + */ + async generateD3Rules(projectId: string): Promise { + const fields = await this.prisma.iitFieldMetadata.findMany({ + where: { projectId }, + orderBy: [{ formName: 'asc' }, { fieldName: 'asc' }], + }); + + if (fields.length === 0) { + throw new Error('请先从 REDCap 同步变量元数据'); + } + + const rules: RuleSuggestion[] = []; + + for (const f of fields) { + if (f.fieldType === 'descriptive' || f.fieldType === 'section_header') continue; + + const hasMin = f.validationMin !== null && f.validationMin !== ''; + const hasMax = f.validationMax !== null && f.validationMax !== ''; + if (hasMin || hasMax) { + const logic: Record[] = []; + const label = f.fieldLabel || f.fieldName; + const parts: string[] = []; + + if (hasMin) { + const min = Number(f.validationMin); + if (!isNaN(min)) { + logic.push({ '>=': [{ 'var': f.fieldName }, min] }); + parts.push(`≥${min}`); + } + } + if (hasMax) { + const max = Number(f.validationMax); + if (!isNaN(max)) { + logic.push({ '<=': [{ 'var': f.fieldName }, max] }); + parts.push(`≤${max}`); + } + } + + if (logic.length > 0) { + const finalLogic = logic.length === 1 ? logic[0] : { and: logic }; + rules.push({ + name: `${label} 范围检查`, + field: f.fieldName, + logic: finalLogic, + message: `${label} 应在 ${parts.join(' 且 ')} 范围内`, + severity: 'warning', + category: 'D3', + }); + } + } + + if (f.choices && (f.fieldType === 'radio' || f.fieldType === 'dropdown')) { + const choicePairs = f.choices.split('|').map(c => c.trim()); + const validValues = choicePairs + .map(pair => { + const sep = pair.indexOf(','); + return sep > -1 ? pair.substring(0, sep).trim() : pair.trim(); + }) + .filter(v => v !== ''); + + if (validValues.length > 0) { + rules.push({ + name: `${f.fieldLabel || f.fieldName} 有效值检查`, + field: f.fieldName, + logic: { 'in': [{ 'var': f.fieldName }, validValues] }, + message: `${f.fieldLabel || f.fieldName} 取值必须是 [${validValues.join(', ')}] 之一`, + severity: 'warning', + category: 'D3', + }); + } + } + } + + logger.info('D3 规则自动生成完成', { + projectId, + totalFields: fields.length, + rulesGenerated: rules.length, + }); + + return rules; + } } let serviceInstance: IitRuleSuggestionService | null = null; diff --git a/backend/src/modules/admin/services/userService.ts b/backend/src/modules/admin/services/userService.ts index 365ba526..053e1ae1 100644 --- a/backend/src/modules/admin/services/userService.ts +++ b/backend/src/modules/admin/services/userService.ts @@ -677,16 +677,15 @@ export async function updateUserModules( throw new Error('用户不是该租户的成员'); } - // 获取租户订阅的模块 - const tenantModules = await prisma.tenant_modules.findMany({ - where: { tenant_id: data.tenantId, is_enabled: true }, + // 验证请求的模块是否在系统模块表中存在 + const allModules = await prisma.modules.findMany({ + where: { is_active: true }, + select: { code: true }, }); - const tenantModuleCodes = tenantModules.map((tm) => tm.module_code); - - // 验证请求的模块是否在租户订阅范围内 - const invalidModules = data.modules.filter((m) => !tenantModuleCodes.includes(m)); + const validModuleCodes = allModules.map((m) => m.code); + const invalidModules = data.modules.filter((m) => !validModuleCodes.includes(m)); if (invalidModules.length > 0) { - throw new Error(`以下模块不在租户订阅范围内: ${invalidModules.join(', ')}`); + throw new Error(`以下模块代码不存在: ${invalidModules.join(', ')}`); } // 更新用户模块权限 @@ -878,10 +877,12 @@ function getModuleName(code: string): string { PKB: '个人知识库', ASL: 'AI智能文献', DC: '数据清洗整理', - IIT: 'IIT Manager', + IIT: 'CRA质控', RVW: '稿件审查', SSA: '智能统计分析', ST: '统计分析工具', + RM: '研究管理', + AIA_PROTOCOL: '全流程研究方案制定', }; return moduleNames[code] || code; } diff --git a/backend/src/modules/iit-manager/agents/SessionMemory.ts b/backend/src/modules/iit-manager/agents/SessionMemory.ts index 403fa5e0..f99b8228 100644 --- a/backend/src/modules/iit-manager/agents/SessionMemory.ts +++ b/backend/src/modules/iit-manager/agents/SessionMemory.ts @@ -42,37 +42,42 @@ export class SessionMemory { private readonly MAX_HISTORY = 3; // 只保留最近3轮(6条消息) private readonly SESSION_TIMEOUT = 3600000; // 1小时(毫秒) + private sessionKey(userId: string, projectId?: string): string { + return projectId ? `${userId}::${projectId}` : userId; + } + /** * 添加对话记录 */ - addMessage(userId: string, role: 'user' | 'assistant', content: string): void { - if (!this.sessions.has(userId)) { - this.sessions.set(userId, { + addMessage(userId: string, role: 'user' | 'assistant', content: string, projectId?: string): void { + const key = this.sessionKey(userId, projectId); + if (!this.sessions.has(key)) { + this.sessions.set(key, { userId, messages: [], createdAt: new Date(), updatedAt: new Date(), }); - logger.debug('[SessionMemory] 创建新会话', { userId }); + logger.debug('[SessionMemory] 创建新会话', { userId, projectId }); } - const session = this.sessions.get(userId)!; + const session = this.sessions.get(key)!; session.messages.push({ role, content, timestamp: new Date(), }); - // 只保留最近3轮(6条消息:3个user + 3个assistant) if (session.messages.length > this.MAX_HISTORY * 2) { const removed = session.messages.length - this.MAX_HISTORY * 2; session.messages = session.messages.slice(-this.MAX_HISTORY * 2); - logger.debug('[SessionMemory] 清理历史消息', { userId, removedCount: removed }); + logger.debug('[SessionMemory] 清理历史消息', { userId, projectId, removedCount: removed }); } session.updatedAt = new Date(); logger.debug('[SessionMemory] 添加消息', { userId, + projectId, role, messageLength: content.length, totalMessages: session.messages.length, @@ -82,13 +87,13 @@ export class SessionMemory { /** * 获取用户对话历史(最近N轮) */ - getHistory(userId: string, maxTurns: number = 3): ConversationMessage[] { - const session = this.sessions.get(userId); + getHistory(userId: string, maxTurns: number = 3, projectId?: string): ConversationMessage[] { + const key = this.sessionKey(userId, projectId); + const session = this.sessions.get(key); if (!session) { return []; } - // 返回最近N轮(2N条消息) const maxMessages = maxTurns * 2; return session.messages.length > maxMessages ? session.messages.slice(-maxMessages) @@ -98,8 +103,8 @@ export class SessionMemory { /** * 获取用户上下文(格式化为字符串,用于LLM Prompt) */ - getContext(userId: string): string { - const history = this.getHistory(userId, 2); // 只取最近2轮 + getContext(userId: string, projectId?: string): string { + const history = this.getHistory(userId, 2, projectId); if (history.length === 0) { return ''; } @@ -112,10 +117,11 @@ export class SessionMemory { /** * 清除用户会话 */ - clearSession(userId: string): void { - const existed = this.sessions.delete(userId); + clearSession(userId: string, projectId?: string): void { + const key = this.sessionKey(userId, projectId); + const existed = this.sessions.delete(key); if (existed) { - logger.info('[SessionMemory] 清除会话', { userId }); + logger.info('[SessionMemory] 清除会话', { userId, projectId }); } } diff --git a/backend/src/modules/iit-manager/controllers/WechatCallbackController.ts b/backend/src/modules/iit-manager/controllers/WechatCallbackController.ts index 2d3bec42..88091fb7 100644 --- a/backend/src/modules/iit-manager/controllers/WechatCallbackController.ts +++ b/backend/src/modules/iit-manager/controllers/WechatCallbackController.ts @@ -22,7 +22,7 @@ import { PrismaClient } from '@prisma/client'; import { createRequire } from 'module'; import { logger } from '../../../common/logging/index.js'; import { wechatService } from '../services/WechatService.js'; -import { ChatOrchestrator, getChatOrchestrator } from '../services/ChatOrchestrator.js'; +import { getChatOrchestrator } from '../services/ChatOrchestrator.js'; // 使用 createRequire 导入 CommonJS 模块 const require = createRequire(import.meta.url); @@ -75,7 +75,7 @@ export class WechatCallbackController { private token: string; private encodingAESKey: string; private corpId: string; - private chatOrchestrator: ChatOrchestrator | null = null; + // chatOrchestrator now resolved per-project via getChatOrchestrator(projectId) constructor() { // 从环境变量读取配置 @@ -322,10 +322,17 @@ export class WechatCallbackController { '🫡 正在查询,请稍候...' ); - if (!this.chatOrchestrator) { - this.chatOrchestrator = await getChatOrchestrator(); + const userMapping = await prisma.iitUserMapping.findFirst({ + where: { wecomUserId: fromUser }, + select: { projectId: true }, + orderBy: { updatedAt: 'desc' }, + }); + if (!userMapping) { + await wechatService.sendTextMessage(fromUser, '您尚未关联任何 IIT 项目,请联系管理员配置。'); + return; } - const aiResponse = await this.chatOrchestrator.handleMessage(fromUser, content); + const orchestrator = await getChatOrchestrator(userMapping.projectId); + const aiResponse = await orchestrator.handleMessage(fromUser, content); // 主动推送AI回复 await wechatService.sendTextMessage(fromUser, aiResponse); diff --git a/backend/src/modules/iit-manager/routes/index.ts b/backend/src/modules/iit-manager/routes/index.ts index be4135ad..7a8e6ac2 100644 --- a/backend/src/modules/iit-manager/routes/index.ts +++ b/backend/src/modules/iit-manager/routes/index.ts @@ -507,10 +507,11 @@ export async function registerIitRoutes(fastify: FastifyInstance) { schema: { body: { type: 'object', - required: ['message'], + required: ['message', 'projectId'], properties: { message: { type: 'string' }, userId: { type: 'string' }, + projectId: { type: 'string' }, }, }, }, @@ -518,9 +519,9 @@ export async function registerIitRoutes(fastify: FastifyInstance) { async (request: any, reply) => { const startTime = Date.now(); try { - const { message, userId } = request.body; + const { message, userId, projectId } = request.body; const uid = userId || request.user?.id || 'web-user'; - const orchestrator = await getChatOrchestrator(); + const orchestrator = await getChatOrchestrator(projectId); const rawReply = await orchestrator.handleMessage(uid, message); const cleanReply = sanitizeLlmReply(rawReply); diff --git a/backend/src/modules/iit-manager/services/ChatOrchestrator.ts b/backend/src/modules/iit-manager/services/ChatOrchestrator.ts index b39c736e..f3e26bf4 100644 --- a/backend/src/modules/iit-manager/services/ChatOrchestrator.ts +++ b/backend/src/modules/iit-manager/services/ChatOrchestrator.ts @@ -78,6 +78,10 @@ export class ChatOrchestrator { }); } + getProjectId(): string { + return this.projectId; + } + async handleMessage(userId: string, userMessage: string): Promise { const startTime = Date.now(); @@ -86,7 +90,7 @@ export class ChatOrchestrator { } try { - const history = sessionMemory.getHistory(userId, 2); + const history = sessionMemory.getHistory(userId, 2, this.projectId); const historyMessages: Message[] = history.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, @@ -180,34 +184,31 @@ export class ChatOrchestrator { } private saveConversation(userId: string, userMsg: string, aiMsg: string, startTime: number): void { - sessionMemory.addMessage(userId, 'user', userMsg); - sessionMemory.addMessage(userId, 'assistant', aiMsg); + sessionMemory.addMessage(userId, 'user', userMsg, this.projectId); + sessionMemory.addMessage(userId, 'assistant', aiMsg, this.projectId); logger.info('[ChatOrchestrator] Conversation saved', { userId, + projectId: this.projectId, duration: `${Date.now() - startTime}ms`, }); } } -// Resolve the active project ID from DB -async function resolveActiveProjectId(): Promise { - const project = await prisma.iitProject.findFirst({ - where: { status: 'active' }, - select: { id: true }, - }); - if (!project) throw new Error('No active IIT project found'); - return project.id; -} +// Per-project orchestrator cache +const orchestratorCache = new Map(); -// Singleton factory — lazily resolves active project -let orchestratorInstance: ChatOrchestrator | null = null; - -export async function getChatOrchestrator(): Promise { - if (!orchestratorInstance) { - const projectId = await resolveActiveProjectId(); - orchestratorInstance = new ChatOrchestrator(projectId); - await orchestratorInstance.initialize(); +export async function getChatOrchestrator(projectId: string): Promise { + if (!projectId) { + throw new Error('projectId is required for ChatOrchestrator'); } - return orchestratorInstance; + + let instance = orchestratorCache.get(projectId); + if (!instance) { + instance = new ChatOrchestrator(projectId); + await instance.initialize(); + orchestratorCache.set(projectId, instance); + logger.info('[ChatOrchestrator] Created new instance', { projectId }); + } + return instance; } diff --git a/backend/tests/e2e-p1-chat-test.ts b/backend/tests/e2e-p1-chat-test.ts index f03de8f6..e9e0c02b 100644 --- a/backend/tests/e2e-p1-chat-test.ts +++ b/backend/tests/e2e-p1-chat-test.ts @@ -84,7 +84,8 @@ async function runTests() { let orchestrator; try { console.log('\n🔧 Initializing ChatOrchestrator...'); - orchestrator = await getChatOrchestrator(); + const testProjectId = process.env.TEST_PROJECT_ID || 'test0102-pd-study'; + orchestrator = await getChatOrchestrator(testProjectId); console.log('✅ ChatOrchestrator initialized successfully\n'); } catch (error: any) { console.error('❌ Failed to initialize ChatOrchestrator:', error.message); diff --git a/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md b/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md index 5c7dbafb..7c6f81e8 100644 --- a/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md @@ -1,10 +1,15 @@ # IIT Manager Agent模块 - 当前状态与开发指南 -> **文档版本:** v3.1 +> **文档版本:** v3.2 > **创建日期:** 2026-01-01 > **维护者:** IIT Manager开发团队 -> **最后更新:** 2026-03-01 **GCP 业务报表 + AI 工作流水增强 + 多项 Bug 修复完成!** +> **最后更新:** 2026-03-02 **数据一致性修复 + 项目隔离 + 管理端配置流重设计 + 中文显示名!** > **重大里程碑:** +> - **2026-03-02:QC 数据一致性修复!** AI 时间线 + 警告详情 统一从 qc_field_status(SSOT)读取,与热力图数据一致 +> - **2026-03-02:字段/事件中文显示名!** LEFT JOIN field_metadata + qc_event_status,消除 REDCap 技术标识符 +> - **2026-03-02:警告详情可查看!** 新增 field-issues 分页 API + ReportsPage 严重问题/警告数字可点击弹出详情 Modal +> - **2026-03-02:AI 对话项目隔离!** ChatOrchestrator 按 projectId 缓存实例 + SessionMemory 按 userId::projectId 隔离 +> - **2026-03-02:管理端配置流重设计!** 5 个配置 Tab 按依赖关系重排 + AI 自动构建质控规则(D1/D3/D5/D6 四维度) > - **2026-03-01:GCP 业务端报表全量完成!** 4 张 GCP 标准报表(D1 筛选入选/D2 完整性/D3D4 质疑跟踪/D6 方案偏离)后端 API + 前端组件 + ReportsPage 五 Tab 重构 > - **2026-03-01:AI 工作流水时间线增强!** 实际规则数显示(33 条而非 1 条)+ 中文事件名 + 可展开问题详情表格 + severity 映射修复 > - **2026-03-01:业务端一键全量质控!** DashboardPage 新增按钮 + 自动刷新报告缓存 + 事件级通过率修复 @@ -61,7 +66,16 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA - AI能力:DeepSeek/Qwen + 自研 RAG(pgvector)+ LLM Tool Use ### 当前状态 -- **开发阶段**:**V3.1 质控引擎 + GCP 业务报表 + AI 时间线增强 + Bug 修复 → 待部署验证** +- **开发阶段**:**V3.2 数据一致性 + 项目隔离 + 管理端重设计 + 中文显示名 → 待部署验证** +- **V3.2 数据一致性 + 项目隔离已完成**(2026-03-02): + - AI 时间线改为从 qc_field_status(SSOT)聚合,与风险热力图数据一致 + - 新增 field-issues 分页查询 API(支持按维度/严重程度/受试者筛选) + - ReportsPage 严重问题/警告数字可点击弹出详情 Modal(分页+按维度筛选) + - 字段名/事件名中文显示(LEFT JOIN field_metadata + qc_event_status 替代 REDCap 技术标识符) + - AI 对话项目隔离(ChatOrchestrator 按 projectId 缓存 + SessionMemory 按 userId::projectId 隔离 + WeChat 回调自动解析 projectId) + - 管理端 IIT 配置流重设计:5 个 Tab 按依赖关系重排(REDCap→变量清单→知识库→质控规则→成员) + - AI 自动构建质控规则(D3 程序化 + D1/D5/D6 LLM 生成 + 预览批量导入 + 规则追加而非覆盖) + - 批量 QC 后自动调用 DailyQcOrchestrator 派发 eQuery - **GCP 业务报表 + Bug 修复已完成**(2026-03-01): - 4 张 GCP 标准报表后端 API(iitQcCockpitService:getEligibilityReport/getCompletenessReport/getEqueryReport/getDeviationReport) - 4 个前端报表组件(EligibilityTable/CompletenessTable/EqueryLogTable/DeviationLogTable) @@ -107,6 +121,37 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA - ✅ **端到端测试通过**(REDCap → Node.js → 企业微信) - ✅ ~~AI对话集成完成(ChatService + SessionMemory)~~ → 已替换为 ChatOrchestrator +#### ✅ 已完成功能(V3.2 数据一致性 + 项目隔离 + 管理端重设计 - 2026-03-02) +- ✅ **QC 数据一致性修复(qc_field_status 为 SSOT)**: + - AI 时间线 getTimeline 改为从 qc_field_status 按受试者分组聚合(替代 qc_logs) + - 与风险热力图、质控报告数据来源统一,消除数据不一致问题 + - 时间线展示有问题的受试者(FAIL/WARNING)+ 全部通过的受试者(PASS) +- ✅ **字段/事件中文显示名**: + - getTimeline + getFieldIssues SQL 增加 LEFT JOIN field_metadata(字段中文标签)+ qc_event_status(事件中文标签) + - 前端优先显示 fieldLabel / eventLabel,回退到原始技术标识符 + - 消除 `65a64dbbd9_arm_1`、`check_date` 等不可读标识符 +- ✅ **警告详情可查看**: + - 新增 GET /:projectId/qc-cockpit/field-issues 分页查询 API(支持按 severity/dimension/recordId 筛选) + - 返回聚合统计(bySeverity + byDimension) + - ReportsPage "严重问题"/"警告"卡片可点击,弹出详情 Modal(分页+维度下拉筛选) +- ✅ **AI 对话项目隔离**: + - ChatOrchestrator 从单例改为按 projectId 缓存实例(orchestratorCache Map) + - SessionMemory 会话键改为 `userId::projectId`,不同项目聊天历史互不干扰 + - 后端 /api/v1/iit/chat 路由要求传 projectId + - WechatCallbackController 通过 iitUserMapping 自动解析用户 projectId +- ✅ **管理端 IIT 配置流重设计**: + - 5 个配置 Tab 按依赖关系重排:① REDCap 配置 → ② 变量清单 → ③ 知识库 → ④ 质控规则 → ⑤ 项目成员 + - "同步元数据" 按钮从基础配置移至变量清单 Tab + - 各 Tab 增加前置条件检查和提示(如"请先配置 REDCap 并同步变量") +- ✅ **AI 自动构建质控规则**: + - D3 准确性:基于 field_metadata 程序化生成范围/枚举/必填检查(无需 LLM) + - D1 入选排除 / D5 安全性 / D6 方案偏离:LLM 基于变量清单+知识库智能生成 + - 预览 Modal 支持多选批量导入 + 规则追加(不覆盖已有规则) + - 规则按 D1-D7 维度分组展示(Collapse 折叠面板) +- ✅ **批量 QC 后自动派发 eQuery**: + - iitBatchController 执行 QcExecutor.executeBatch 后调用 DailyQcOrchestrator.orchestrate + - 返回 equeriesCreated 计数 + #### ✅ 已完成功能(GCP 业务报表 + AI 时间线 + Bug 修复 - 2026-03-01) - ✅ **GCP 标准报表(阶段 A 4 张)**: - D1 筛选入选表(getEligibilityReport:record_summary 全量 + qc_field_status D1 叠加) @@ -954,8 +999,8 @@ npx ts-node src/modules/iit-manager/test-wechat-push.ts --- > **提示**:本文档反映IIT Manager Agent模块的最新真实状态,每个里程碑完成后必须更新! -> **最后更新**:2026-03-01 -> **当前进度**:V3.1 QC Engine 完成 | GCP 业务报表 4 张全量完成 | AI Timeline 增强 | 一键全量质控 | 多项 Bug 修复 | Phase 2: LLM 执行摘要待开发 +> **最后更新**:2026-03-02 +> **当前进度**:V3.2 数据一致性修复 | 项目隔离 | 管理端配置流重设计 | AI 规则自动生成 | 中文显示名 | 警告详情 Modal | 待部署验证 > **核心文档**: > - [CRA Agent V3.0 开发计划](./04-开发计划/V3.0全新开发计划/V3.0全新开发计划.md) ⭐⭐⭐⭐⭐ > - [统一数字 CRA 质控平台 PRD](./04-开发计划/V3.0全新开发计划/统一数字%20CRA%20质控平台产品需求文档(PRD).md) ⭐⭐⭐⭐⭐ diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index 0b4d83ee..d51647ba 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -15,19 +15,35 @@ | # | 变更内容 | 迁移文件 | 优先级 | 备注 | |---|---------|---------|--------|------| -| — | *暂无* | | | | +| DB-1 | modules 表新增 `AIA_PROTOCOL`、`RM` 模块注册,IIT 名称改为「CRA质控」 | 无(运行 seed 脚本) | 高 | 运行 `node scripts/seed-modules.js`(upsert,可重复执行) | +| DB-2 | RVW Prompt 更新:期刊名称修正为「中华脑血管病杂志」 | 无(重新运行迁移脚本或后台编辑) | 高 | 运行 `npx tsx scripts/migrate-rvw-prompts.ts` 或在 `/admin/prompts/RVW_EDITORIAL` 手动编辑 | ### 后端变更 (Node.js) | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| BE-1 | 登录踢人机制:同一手机号只能一人同时在线 | `auth.service.ts`, `auth.middleware.ts`, `jwt.service.ts` | 重新构建镜像 | JWT 增加 tokenVersion,登录时递增版本号存入缓存,认证中间件校验版本号 | +| BE-2 | 模块权限一致性修复:`/me/modules` API 现在尊重 user_modules 精细化配置 | `module.service.ts` | 重新构建镜像 | 之前 `/me/modules` 只看 tenant_modules,现在与登录逻辑一致 | +| BE-3 | getModuleName 补充 RM、AIA_PROTOCOL、IIT 名称修正 | `userService.ts` | 重新构建镜像 | IIT Manager → CRA质控 | +| BE-4 | RVW 稿约 Prompt 源文件修正 | `prompts/review_editorial_system.txt` | 重新构建镜像 | 中华医学超声杂志 → 中华脑血管病杂志 | +| BE-5 | seed 数据:内部租户补充 RM、AIA_PROTOCOL 模块 | `prisma/seed.ts` | 仅影响新环境初始化 | — | +| BE-6 | 用户模块配置校验放宽:不再限制必须在租户订阅范围内 | `userService.ts` | 重新构建镜像 | 校验改为「模块代码必须在 modules 表中存在」,支持给用户单独开通功能模块 | +| BE-7 | 用户独立模块生效:user_modules 中的模块即使租户未订阅也纳入权限 | `module.service.ts` | 重新构建镜像 | 如 AIA_PROTOCOL 可单独配给用户 | ### 前端变更 | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| FE-1 | ASL 模块:隐藏数据源/年限/篇数,默认 PubMed | `SetupPanel.tsx` | 重新构建镜像 | 高级筛选面板全部隐藏 | +| FE-2 | ASL 模块:去掉「研究方案生成」「文献管理」,重新编号 1~6 | `ASLLayout.tsx` | 重新构建镜像 | — | +| FE-3 | ASL 模块:默认进入智能文献检索(不再是标题摘要初筛) | `asl/index.tsx` | 重新构建镜像 | 路由 index → research/deep | +| FE-4 | AIA 模块:删除「已接入DeepSeek」和搜索框 | `AgentHub.tsx` | 重新构建镜像 | — | +| FE-5 | AIA 模块:Protocol Agent 按用户模块权限动态显示 | `AgentHub.tsx`, `constants.ts` | 重新构建镜像 | 通过 `hasModule('AIA_PROTOCOL')` 判断,需 DB-1 配合 | +| FE-6 | AIA 模块:数据评价与预处理 / 智能统计分析链接修正 | `constants.ts` | 重新构建镜像 | `/dc` → `/research-management` | +| FE-7 | 首页重定向到 `/ai-qa`,不再显示模块卡片首页 | `App.tsx` | 重新构建镜像 | — | +| FE-8 | PKB 模块:创建知识库时隐藏科室选择,默认 General | `DashboardPage.tsx` | 重新构建镜像 | — | +| FE-9 | 被踢出时前端提示「账号已在其他设备登录」 | `axios.ts` | 重新构建镜像 | 配合 BE-1 | +| FE-10 | 运营端:用户模块权限弹窗显示所有模块(含 RM、AIA_PROTOCOL、CRA质控) | `ModulePermissionModal.tsx` | 重新构建镜像 | 未订阅模块标注"(未订阅)",不阻止配置 | ### Python 微服务变更 @@ -53,6 +69,17 @@ |---|---------|------|------| | — | *暂无* | | | +### 部署顺序建议 + +1. **后端先部署**:构建并推送新镜像(包含 BE-1 ~ BE-5) +2. **运行数据库脚本**: + - `node scripts/seed-modules.js`(DB-1:注册 AIA_PROTOCOL、RM 模块) + - `npx tsx scripts/migrate-rvw-prompts.ts`(DB-2:更新 RVW 稿约 Prompt) +3. **前端部署**:构建并推送新镜像(包含 FE-1 ~ FE-9) +4. **后台配置**: + - 为内部测试人员所在租户开通 `AIA_PROTOCOL` 模块 + - 确认 SSA/ST/IIT/RM 模块权限按需分配给对应租户和用户 + --- ## 记录模板 diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index c63e1aa5..cb1f6b55 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -8,7 +8,6 @@ import { RouteGuard } from './framework/router' import MainLayout from './framework/layout/MainLayout' import AdminLayout from './framework/layout/AdminLayout' import OrgLayout from './framework/layout/OrgLayout' -import HomePage from './pages/HomePage' import LoginPage from './pages/LoginPage' import AdminDashboard from './pages/admin/AdminDashboard' import OrgDashboard from './pages/org/OrgDashboard' @@ -80,8 +79,8 @@ function App() { {/* 业务应用端 /app/* */} }> - {/* 首页 */} - } /> + {/* 首页重定向到 AI 问答 */} + } /> {/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */} {MODULES.filter(m => !m.isExternal).map(module => ( diff --git a/frontend-v2/src/common/api/axios.ts b/frontend-v2/src/common/api/axios.ts index 95ce39c8..acd20972 100644 --- a/frontend-v2/src/common/api/axios.ts +++ b/frontend-v2/src/common/api/axios.ts @@ -62,9 +62,16 @@ apiClient.interceptors.response.use( const hasRefreshToken = !!getRefreshToken(); const alreadyRetried = originalRequest._retry; - if (!is401 || !hasRefreshToken || alreadyRetried) { - if (is401 && !hasRefreshToken) { + // 检测是否被踢出(其他设备登录) + const responseMsg = (error.response?.data as any)?.message || ''; + const isKicked = is401 && responseMsg.includes('其他设备'); + + if (!is401 || !hasRefreshToken || alreadyRetried || isKicked) { + if (is401) { clearTokens(); + if (isKicked) { + alert('您的账号已在其他设备登录,当前会话已失效,请重新登录'); + } window.location.href = '/login'; } return Promise.reject(error); diff --git a/frontend-v2/src/modules/admin/api/iitProjectApi.ts b/frontend-v2/src/modules/admin/api/iitProjectApi.ts index c23d6d50..c63ea6cb 100644 --- a/frontend-v2/src/modules/admin/api/iitProjectApi.ts +++ b/frontend-v2/src/modules/admin/api/iitProjectApi.ts @@ -195,6 +195,24 @@ export async function testRule( return response.data.data; } +/** AI 规则建议(支持按维度生成) */ +export async function suggestRules( + projectId: string, + dimension?: string +): Promise { + const params = dimension ? { dimension } : {}; + const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/suggest`, {}, { params }); + return response.data.data; +} + +/** D3 规则自动生成(数据驱动,无需 LLM) */ +export async function generateD3Rules( + projectId: string +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/generate-d3`); + return response.data.data; +} + // ==================== 用户映射 ==================== /** 获取角色选项 */ diff --git a/frontend-v2/src/modules/admin/components/ModulePermissionModal.tsx b/frontend-v2/src/modules/admin/components/ModulePermissionModal.tsx index 88a380e9..9b15cf0a 100644 --- a/frontend-v2/src/modules/admin/components/ModulePermissionModal.tsx +++ b/frontend-v2/src/modules/admin/components/ModulePermissionModal.tsx @@ -73,9 +73,6 @@ const ModulePermissionModal: React.FC = ({ } }; - // 可用模块(租户已订阅的) - const subscribedModules = moduleOptions.filter((m) => m.isSubscribed); - return ( = ({ - {subscribedModules.length > 0 ? ( + {moduleOptions.length > 0 ? ( setSelectedModules(values as string[])} style={{ width: '100%' }} > - {subscribedModules.map((module) => ( + {moduleOptions.map((module) => ( - {module.name} + + {module.name} + {!module.isSubscribed && ( + + (未订阅) + + )} + ))} ) : ( - 该租户暂无可用模块 + 暂无可用模块 )} diff --git a/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx b/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx index 134c3be1..2b9788c2 100644 --- a/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx +++ b/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx @@ -27,6 +27,7 @@ import { Empty, Tooltip, Badge, + Collapse, } from 'antd'; import { ArrowLeftOutlined, @@ -42,6 +43,8 @@ import { BarChartOutlined, DashboardOutlined, ClockCircleOutlined, + BulbOutlined, + RobotOutlined, } from '@ant-design/icons'; import * as iitProjectApi from '../api/iitProjectApi'; import type { @@ -49,6 +52,7 @@ import type { UpdateProjectRequest, QCRule, CreateRuleRequest, + RuleSuggestion, IitUserMapping, CreateUserMappingRequest, RoleOption, @@ -65,13 +69,27 @@ const SEVERITY_MAP = { info: { color: 'processing', text: '信息' }, }; -const CATEGORY_MAP = { +const CATEGORY_MAP: Record = { + D1: { color: '#52c41a', text: 'D1 入选/排除' }, + D2: { color: '#13c2c2', text: 'D2 完整性' }, + D3: { color: '#1890ff', text: 'D3 准确性' }, + D4: { color: '#faad14', text: 'D4 质疑管理' }, + D5: { color: '#ff4d4f', text: 'D5 安全性' }, + D6: { color: '#722ed1', text: 'D6 方案偏离' }, + D7: { color: '#eb2f96', text: 'D7 药物管理' }, inclusion: { color: '#52c41a', text: '纳入标准' }, exclusion: { color: '#ff4d4f', text: '排除标准' }, lab_values: { color: '#1890ff', text: '变量范围' }, logic_check: { color: '#722ed1', text: '逻辑检查' }, }; +const AI_BUILD_DIMENSIONS = [ + { key: 'D3', label: 'D3 准确性规则', description: '基于变量定义自动生成范围/枚举检查', icon: , needsKb: false, isD3: true }, + { key: 'D1', label: 'D1 入选/排除规则', description: 'AI 基于知识库生成纳入排除标准检查', icon: , needsKb: true, isD3: false }, + { key: 'D5', label: 'D5 安全性规则', description: 'AI 生成 AE/SAE 报告时限和完整性检查', icon: , needsKb: true, isD3: false }, + { key: 'D6', label: 'D6 方案偏离规则', description: 'AI 生成访视窗口期和用药合规性检查', icon: , needsKb: true, isD3: false }, +]; + // ==================== 主组件 ==================== const IitProjectDetailPage: React.FC = () => { @@ -143,31 +161,34 @@ const IitProjectDetailPage: React.FC = () => { return ; } + const hasFields = !!project.lastSyncAt; + const hasKb = !!(project.knowledgeBaseId || project.knowledgeBase?.id); + const tabItems = [ { key: 'basic', - label: 'REDCap 配置', + label: '① REDCap 配置', children: , }, { - key: 'rules', - label: '质控规则', - children: , - }, - { - key: 'members', - label: '项目成员', - children: , + key: 'fields', + label: '② 变量清单', + children: , }, { key: 'kb', - label: '知识库', - children: , + label: '③ 知识库', + children: , }, { - key: 'fields', - label: '变量清单', - children: , + key: 'rules', + label: `④ 质控规则`, + children: , + }, + { + key: 'members', + label: '⑤ 项目成员', + children: , }, ]; @@ -238,7 +259,6 @@ const BasicConfigTab: React.FC = ({ project, onUpdate }) => const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); - const [syncing, setSyncing] = useState(false); useEffect(() => { form.setFieldsValue({ @@ -281,18 +301,6 @@ const BasicConfigTab: React.FC = ({ project, onUpdate }) => } }; - const handleSyncMetadata = async () => { - setSyncing(true); - try { - const result = await iitProjectApi.syncMetadata(project.id); - message.success(`同步成功!共 ${result.fieldCount} 个字段`); - } catch (error) { - message.error('同步失败'); - } finally { - setSyncing(false); - } - }; - return (
@@ -394,9 +402,6 @@ const BasicConfigTab: React.FC = ({ project, onUpdate }) => - @@ -417,19 +422,28 @@ const BasicConfigTab: React.FC = ({ project, onUpdate }) => ); }; -// ==================== Tab 2: 质控规则 ==================== +// ==================== Tab 4: 质控规则 ==================== interface QCRulesTabProps { projectId: string; + project: IitProject; + hasFields: boolean; + hasKb: boolean; } -const QCRulesTab: React.FC = ({ projectId }) => { +const QCRulesTab: React.FC = ({ projectId, project: _project, hasFields, hasKb }) => { const [rules, setRules] = useState([]); const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); const [editingRule, setEditingRule] = useState(null); const [form] = Form.useForm(); + const [aiLoading, setAiLoading] = useState(null); + const [suggestions, setSuggestions] = useState([]); + const [selectedSuggestionKeys, setSelectedSuggestionKeys] = useState([]); + const [previewOpen, setPreviewOpen] = useState(false); + const [importing, setImporting] = useState(false); + const loadRules = useCallback(async () => { setLoading(true); try { @@ -476,14 +490,14 @@ const QCRulesTab: React.FC = ({ projectId }) => { try { const fieldValue = values.field as string; const logicValue = values.logic as string; - + const ruleData: CreateRuleRequest = { name: values.name as string, field: fieldValue.includes(',') ? fieldValue.split(',').map((s) => s.trim()) : fieldValue, logic: JSON.parse(logicValue), message: values.message as string, severity: values.severity as 'error' | 'warning' | 'info', - category: values.category as 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check', + category: values.category as CreateRuleRequest['category'], }; if (editingRule) { @@ -501,22 +515,73 @@ const QCRulesTab: React.FC = ({ projectId }) => { } }; - const columns = [ + const handleAiBuild = async (dim: typeof AI_BUILD_DIMENSIONS[number]) => { + if (!hasFields) { + message.warning('请先在「② 变量清单」中同步元数据'); + return; + } + if (dim.needsKb && !hasKb) { + message.warning('此维度需要知识库支持,请先在「③ 知识库」中创建并上传文档'); + return; + } + + setAiLoading(dim.key); + try { + let result: RuleSuggestion[]; + if (dim.isD3) { + result = await iitProjectApi.generateD3Rules(projectId); + } else { + result = await iitProjectApi.suggestRules(projectId, dim.key); + } + if (result.length === 0) { + message.info('未生成规则,请检查变量清单或知识库内容'); + return; + } + setSuggestions(result); + setSelectedSuggestionKeys(result.map((_, i) => i)); + setPreviewOpen(true); + } catch (err: any) { + message.error(err?.response?.data?.error || err?.message || 'AI 生成失败'); + } finally { + setAiLoading(null); + } + }; + + const handleImportSelected = async () => { + const selected = selectedSuggestionKeys.map((k) => suggestions[k as number]).filter(Boolean); + if (selected.length === 0) { + message.warning('请至少选择一条规则'); + return; + } + setImporting(true); + try { + const rulesToImport: CreateRuleRequest[] = selected.map((s) => ({ + name: s.name, + field: s.field, + logic: s.logic, + message: s.message, + severity: s.severity, + category: s.category as CreateRuleRequest['category'], + })); + const result = await iitProjectApi.importRules(projectId, rulesToImport); + message.success(`成功导入 ${result.count} 条规则`); + setPreviewOpen(false); + setSuggestions([]); + setSelectedSuggestionKeys([]); + loadRules(); + } catch (err: any) { + message.error(err?.response?.data?.error || '导入失败'); + } finally { + setImporting(false); + } + }; + + const ruleColumns = [ { title: '规则名称', dataIndex: 'name', key: 'name', - width: 200, - }, - { - title: '分类', - dataIndex: 'category', - key: 'category', - width: 100, - render: (category: keyof typeof CATEGORY_MAP) => { - const cat = CATEGORY_MAP[category]; - return {cat?.text || category}; - }, + width: 220, }, { title: '严重程度', @@ -560,29 +625,149 @@ const QCRulesTab: React.FC = ({ projectId }) => { }, ]; + const DIMENSION_ORDER = ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7']; + const groupedRules = React.useMemo(() => { + const groups: Record = {}; + for (const rule of rules) { + const cat = rule.category || 'other'; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(rule); + } + const sorted: Record = {}; + for (const dim of DIMENSION_ORDER) { + if (groups[dim]) { sorted[dim] = groups[dim]; delete groups[dim]; } + } + for (const [key, val] of Object.entries(groups)) { + sorted[key] = val; + } + return sorted; + }, [rules]); + + const previewColumns = [ + { title: '规则名称', dataIndex: 'name', key: 'name', width: 200 }, + { + title: '分类', + dataIndex: 'category', + key: 'category', + width: 120, + render: (c: string) => { + const cat = CATEGORY_MAP[c]; + return {cat?.text || c}; + }, + }, + { + title: '严重程度', + dataIndex: 'severity', + key: 'severity', + width: 100, + render: (s: string) => { + const sev = SEVERITY_MAP[s as keyof typeof SEVERITY_MAP]; + return {sev?.text || s}; + }, + }, + { + title: '字段', + dataIndex: 'field', + key: 'field', + width: 150, + render: (f: string | string[]) => {Array.isArray(f) ? f.join(', ') : f}, + }, + { title: '提示信息', dataIndex: 'message', key: 'message', ellipsis: true }, + ]; + + if (!hasFields) { + return ( + } + /> + ); + } + return (
-
- + {/* AI 自动构建规则区域 */} + AI 自动构建质控规则} + style={{ marginBottom: 16, border: '1px solid #d9d9d9', background: '#fafafa' }} + > +
+ {AI_BUILD_DIMENSIONS.map((dim) => { + const disabled = dim.needsKb && !hasKb; + return ( + + + + ); + })} +
+ {!hasKb && ( + + 提示:D3 准确性规则可直接生成;D1/D5/D6 需要先在「③ 知识库」中上传研究方案等文档 + + )} +
+ + {/* 规则列表(按维度分组) */} +
+ 已配置规则 ({rules.length})
- + {loading ? ( +
+ ) : rules.length === 0 ? ( + + ) : ( + { + const catMeta = CATEGORY_MAP[cat]; + return { + key: cat, + label: ( + + {catMeta?.text || cat} + {catRules.length} 条规则 + + ), + children: ( +
+ ), + }; + })} + /> + )} + {/* 手动编辑规则 Modal */} = ({ projectId }) => {
({ ...s, _key: i }))} + rowKey="_key" + size="small" + pagination={false} + scroll={{ y: 400 }} + rowSelection={{ + selectedRowKeys: selectedSuggestionKeys, + onChange: (keys) => setSelectedSuggestionKeys(keys), + }} + /> + ); }; @@ -927,9 +1166,10 @@ const UserMappingTab: React.FC = ({ projectId }) => { interface KnowledgeBaseTabProps { project: IitProject; onUpdate: () => void; + hasFields: boolean; } -const KnowledgeBaseTab: React.FC = ({ project, onUpdate }) => { +const KnowledgeBaseTab: React.FC = ({ project, onUpdate, hasFields }) => { const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); @@ -1018,6 +1258,18 @@ const KnowledgeBaseTab: React.FC = ({ project, onUpdate } } }; + if (!hasFields) { + return ( + } + /> + ); + } + if (!kbId) { return (
@@ -1150,20 +1402,25 @@ const KnowledgeBaseTab: React.FC = ({ project, onUpdate } ); }; -// ==================== Tab 5: 变量清单 ==================== +// ==================== Tab 2: 变量清单 ==================== interface FieldMetadataTabProps { projectId: string; + project: IitProject; + onUpdate: () => void; } -const FieldMetadataTab: React.FC = ({ projectId }) => { +const FieldMetadataTab: React.FC = ({ projectId, project, onUpdate }) => { const [fields, setFields] = useState([]); const [forms, setForms] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); const [formFilter, setFormFilter] = useState(undefined); const [search, setSearch] = useState(''); + const hasRedcapConfig = !!(project.redcapUrl && project.redcapApiToken); + const fetchFields = useCallback(async () => { setLoading(true); try { @@ -1184,6 +1441,32 @@ const FieldMetadataTab: React.FC = ({ projectId }) => { fetchFields(); }, [fetchFields]); + const handleSyncMetadata = async () => { + setSyncing(true); + try { + const result = await iitProjectApi.syncMetadata(projectId); + message.success(`同步成功!共 ${result.fieldCount} 个字段`); + fetchFields(); + onUpdate(); + } catch (error) { + message.error('同步元数据失败,请检查 REDCap 配置'); + } finally { + setSyncing(false); + } + }; + + if (!hasRedcapConfig) { + return ( + } + /> + ); + } + const columns = [ { title: '变量名', @@ -1229,32 +1512,61 @@ const FieldMetadataTab: React.FC = ({ projectId }) => { return (
- - ({ value: f, label: f }))} + /> + + 共 {total} 个变量 + + +
+ )} + {total > 0 && ( +
`共 ${t} 条` }} + scroll={{ y: 500 }} /> - 共 {total} 个变量 - -
`共 ${t} 条` }} - scroll={{ y: 500 }} - /> + )} ); }; diff --git a/frontend-v2/src/modules/admin/types/iitProject.ts b/frontend-v2/src/modules/admin/types/iitProject.ts index ec9e2f4e..2b65d364 100644 --- a/frontend-v2/src/modules/admin/types/iitProject.ts +++ b/frontend-v2/src/modules/admin/types/iitProject.ts @@ -76,6 +76,12 @@ export interface TestConnectionResult { // ==================== 质控规则相关 ==================== +export type DimensionCode = 'D1' | 'D2' | 'D3' | 'D4' | 'D5' | 'D6' | 'D7'; + +export type RuleCategory = + | 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check' + | DimensionCode; + export interface QCRule { id: string; name: string; @@ -83,7 +89,7 @@ export interface QCRule { logic: Record; message: string; severity: 'error' | 'warning' | 'info'; - category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'; + category: RuleCategory; metadata?: Record; } @@ -93,10 +99,20 @@ export interface CreateRuleRequest { logic: Record; message: string; severity: 'error' | 'warning' | 'info'; - category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'; + category: RuleCategory; metadata?: Record; } +export interface RuleSuggestion { + name: string; + field: string | string[]; + logic: Record; + message: string; + severity: 'error' | 'warning' | 'info'; + category: string; + applicableEvents?: string[]; +} + export interface RuleStats { total: number; byCategory: Record; diff --git a/frontend-v2/src/modules/aia/components/AgentHub.tsx b/frontend-v2/src/modules/aia/components/AgentHub.tsx index 7eb7710c..f13d17b7 100644 --- a/frontend-v2/src/modules/aia/components/AgentHub.tsx +++ b/frontend-v2/src/modules/aia/components/AgentHub.tsx @@ -8,11 +8,12 @@ * - 5个阶段,12个智能体卡片 */ -import React, { useState, useMemo } from 'react'; -import { BrainCircuit, Search } from 'lucide-react'; +import React, { useMemo } from 'react'; +import { BrainCircuit } from 'lucide-react'; import { AgentCard } from './AgentCard'; import { AGENTS, PHASES } from '../constants'; import type { AgentConfig } from '../types'; +import { useAuth } from '@/framework/auth'; import '../styles/agent-hub.css'; interface AgentHubProps { @@ -20,40 +21,32 @@ interface AgentHubProps { } export const AgentHub: React.FC = ({ onAgentSelect }) => { - const [searchValue, setSearchValue] = useState(''); + const { hasModule } = useAuth(); + + // Protocol Agent 按用户模块权限动态显示(管理后台配置 AIA_PROTOCOL) + const showProtocol = hasModule('AIA_PROTOCOL'); + + const visiblePhases = useMemo(() => { + return showProtocol ? PHASES : PHASES.filter(p => !p.isProtocolAgent); + }, [showProtocol]); - // 按阶段分组智能体 const agentsByPhase = useMemo(() => { const grouped: Record = {}; - AGENTS.forEach(agent => { + const visibleAgents = showProtocol ? AGENTS : AGENTS.filter(a => !a.isProtocolAgent); + visibleAgents.forEach(agent => { if (!grouped[agent.phase]) { grouped[agent.phase] = []; } grouped[agent.phase].push(agent); }); return grouped; - }, []); - - // 搜索提交 - const handleSearch = () => { - if (searchValue.trim()) { - // 默认进入第一个智能体并携带搜索内容 - const firstAgent = AGENTS[0]; - onAgentSelect({ ...firstAgent, initialQuery: searchValue } as any); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch(); - } - }; + }, [showProtocol]); return (
{/* 主体内容 */}
- {/* 头部搜索区 */} + {/* 头部标题区 */}
@@ -61,29 +54,14 @@ export const AgentHub: React.FC = ({ onAgentSelect }) => {

医学研究专属大模型 - 已接入DeepSeek

- -
- setSearchValue(e.target.value)} - onKeyDown={handleKeyDown} - className="search-input" - /> - -
{/* 流水线模块 */}
- {PHASES.map((phase, phaseIndex) => { - const isLast = phaseIndex === PHASES.length - 1; + {visiblePhases.map((phase, phaseIndex) => { + const isLast = phaseIndex === visiblePhases.length - 1; const agents = agentsByPhase[phase.phase] || []; // Protocol Agent 阶段特殊处理(phase 0,单独显示1个卡片) diff --git a/frontend-v2/src/modules/aia/constants.ts b/frontend-v2/src/modules/aia/constants.ts index 65c50552..037eb80d 100644 --- a/frontend-v2/src/modules/aia/constants.ts +++ b/frontend-v2/src/modules/aia/constants.ts @@ -127,7 +127,7 @@ export const AGENTS: AgentConfig[] = [ phase: 4, order: 9, isTool: true, - toolUrl: '/dc', + toolUrl: '/research-management', }, { id: 'TOOL_10', @@ -138,7 +138,7 @@ export const AGENTS: AgentConfig[] = [ phase: 4, order: 10, isTool: true, - toolUrl: '/dc/analysis', + toolUrl: '/research-management', }, // Phase 5: 写作助手 (2个) diff --git a/frontend-v2/src/modules/aia/styles/chat-workspace.css b/frontend-v2/src/modules/aia/styles/chat-workspace.css index e703c1ac..df9bec4f 100644 --- a/frontend-v2/src/modules/aia/styles/chat-workspace.css +++ b/frontend-v2/src/modules/aia/styles/chat-workspace.css @@ -1004,4 +1004,4 @@ border-radius: 4px; font-family: 'Monaco', 'Consolas', 'Courier New', monospace; font-size: 0.9em; -} +} \ No newline at end of file diff --git a/frontend-v2/src/modules/asl/components/ASLLayout.tsx b/frontend-v2/src/modules/asl/components/ASLLayout.tsx index eef9a0c9..c1249740 100644 --- a/frontend-v2/src/modules/asl/components/ASLLayout.tsx +++ b/frontend-v2/src/modules/asl/components/ASLLayout.tsx @@ -8,9 +8,7 @@ import { Layout, Menu } from 'antd'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { - FileTextOutlined, SearchOutlined, - FolderOpenOutlined, FilterOutlined, FileSearchOutlined, DatabaseOutlined, @@ -32,29 +30,15 @@ const ASLLayout = () => { // 菜单项配置 const menuItems: MenuItem[] = [ - { - key: 'research-plan', - icon: , - label: '1. 研究方案生成', - disabled: true, - title: '敬请期待' - }, { key: '/literature/research/deep', icon: , - label: '2. 智能文献检索', - }, - { - key: 'literature-management', - icon: , - label: '3. 文献管理', - disabled: true, - title: '敬请期待' + label: '1. 智能文献检索', }, { key: 'title-screening', icon: , - label: '4. 标题摘要初筛', + label: '2. 标题摘要初筛', children: [ { key: '/literature/screening/title/settings', @@ -76,7 +60,7 @@ const ASLLayout = () => { { key: 'fulltext-screening', icon: , - label: '5. 全文复筛', + label: '3. 全文复筛', children: [ { key: '/literature/screening/fulltext/settings', @@ -98,7 +82,7 @@ const ASLLayout = () => { { key: 'extraction', icon: , - label: '6. 全文智能提取', + label: '4. 全文智能提取', children: [ { key: '/literature/extraction/setup', @@ -115,12 +99,12 @@ const ASLLayout = () => { { key: '/literature/charting', icon: , - label: '7. SR 图表生成器', + label: '5. SR 图表生成器', }, { key: '/literature/meta-analysis', icon: , - label: '8. Meta 分析引擎', + label: '6. Meta 分析引擎', }, ]; diff --git a/frontend-v2/src/modules/asl/components/deep-research/SetupPanel.tsx b/frontend-v2/src/modules/asl/components/deep-research/SetupPanel.tsx index ed15bb97..9a0d48ee 100644 --- a/frontend-v2/src/modules/asl/components/deep-research/SetupPanel.tsx +++ b/frontend-v2/src/modules/asl/components/deep-research/SetupPanel.tsx @@ -6,16 +6,13 @@ */ import { useState, useEffect } from 'react'; -import { Card, Checkbox, Button, Input, Select, Spin, Divider, Typography, Tag } from 'antd'; +import { Card, Button, Input, Typography, Tag } from 'antd'; import { ArrowLeftOutlined, ThunderboltOutlined, - GlobalOutlined, EditOutlined, CheckCircleOutlined, } from '@ant-design/icons'; -import { aslApi } from '../../api'; -import type { DataSourceConfig } from '../../types/deepResearch'; const { Text } = Typography; @@ -43,24 +40,11 @@ const SetupPanel: React.FC = ({ initialQuery, onSubmit, onBack, loading, collapsed, onExpand, }) => { const [query, setQuery] = useState(initialQuery); - const [dataSources, setDataSources] = useState([]); - const [selectedIds, setSelectedIds] = useState([]); - const [yearRange, setYearRange] = useState('近5年'); - const [targetCount, setTargetCount] = useState('全面检索'); - const [loadingSources, setLoadingSources] = useState(true); const [loadingTextIdx, setLoadingTextIdx] = useState(0); - useEffect(() => { - aslApi.getDeepResearchDataSources().then(res => { - const sources = res.data || []; - setDataSources(sources); - setSelectedIds(sources.filter((s: DataSourceConfig) => s.defaultChecked).map((s: DataSourceConfig) => s.id)); - }).catch(() => { - setDataSources([]); - }).finally(() => { - setLoadingSources(false); - }); - }, []); + // 默认使用 PubMed + 近5年 + 全面检索(数据源/年限/篇数 UI 暂时隐藏) + const yearRange = '近5年'; + const targetCount = '全面检索'; useEffect(() => { if (!loading) { setLoadingTextIdx(0); return; } @@ -70,21 +54,10 @@ const SetupPanel: React.FC = ({ return () => clearInterval(timer); }, [loading]); - const handleToggle = (id: string) => { - setSelectedIds(prev => - prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] - ); - }; - const handleSubmit = () => { - const domains = dataSources - .filter(s => selectedIds.includes(s.id)) - .map(s => s.domainScope); - onSubmit(query, domains, { yearRange, targetCount }); + onSubmit(query, ['PubMed'], { yearRange, targetCount }); }; - const selectedNames = dataSources.filter(s => selectedIds.includes(s.id)).map(s => s.label); - if (collapsed) { return ( @@ -94,9 +67,7 @@ const SetupPanel: React.FC = ({
{query}
- {selectedNames.map(name => ( - {name} - ))} + PubMed {yearRange} · {targetCount}
@@ -111,9 +82,6 @@ const SetupPanel: React.FC = ({ ); } - const englishSources = dataSources.filter(s => s.category === 'english'); - const chineseSources = dataSources.filter(s => s.category === 'chinese'); - return (
@@ -133,77 +101,7 @@ const SetupPanel: React.FC = ({ /> - 选择数据源}> - {loadingSources ? ( - - ) : ( - <> - 英文数据库 -
- {englishSources.map(ds => ( - handleToggle(ds.id)} - > - {ds.label} - {ds.domainScope} - - ))} -
- - - - 中文数据库 -
- {chineseSources.map(ds => ( - handleToggle(ds.id)} - > - {ds.label} - {ds.domainScope} - - ))} -
- - )} -
- - -
-
- 时间范围 - -
-
-
+ {/* 数据源/年限/篇数暂时隐藏,默认 PubMed + 近5年 + 全面检索 */} diff --git a/frontend-v2/src/modules/asl/index.tsx b/frontend-v2/src/modules/asl/index.tsx index 1780bd82..ba1c7928 100644 --- a/frontend-v2/src/modules/asl/index.tsx +++ b/frontend-v2/src/modules/asl/index.tsx @@ -47,7 +47,7 @@ const ASLModule = () => { > }> - } /> + } /> {/* 智能文献检索 V1.x(保留兼容) */} } /> diff --git a/frontend-v2/src/modules/iit/api/iitProjectApi.ts b/frontend-v2/src/modules/iit/api/iitProjectApi.ts index a0e56303..9a9ff9a2 100644 --- a/frontend-v2/src/modules/iit/api/iitProjectApi.ts +++ b/frontend-v2/src/modules/iit/api/iitProjectApi.ts @@ -423,30 +423,32 @@ export async function getQcRecordDetail( // ==================== AI 时间线 ==================== +export interface TimelineIssue { + ruleId: string; + ruleName: string; + ruleCategory?: string; + field?: string; + fieldLabel?: string; + eventId?: string; + eventLabel?: string; + formName?: string; + message: string; + severity: string; + actualValue?: string; + expectedValue?: string; +} + export interface TimelineItem { id: string; type: 'qc_check'; time: string; recordId: string; - eventLabel?: string; - formName?: string; status: string; triggeredBy: string; description: string; details: { - rulesEvaluated: number; - rulesPassed: number; - rulesFailed: number; issuesSummary: { red: number; yellow: number }; - issues?: Array<{ - ruleId: string; - ruleName: string; - field?: string; - message: string; - severity: string; - actualValue?: string; - expectedValue?: string; - }>; + issues: TimelineIssue[]; }; } @@ -459,6 +461,49 @@ export async function getTimeline( return response.data.data; } +// ==================== 字段级问题查询 ==================== + +export interface FieldIssueItem { + id: string; + recordId: string; + eventId: string; + eventLabel?: string; + formName: string; + fieldName: string; + fieldLabel?: string; + ruleCategory: string; + ruleName: string; + ruleId: string; + severity: string; + status: string; + message: string; + actualValue?: string; + expectedValue?: string; + lastQcAt: string; +} + +export interface FieldIssuesSummary { + totalIssues: number; + bySeverity: Record; + byDimension: Record; +} + +export interface FieldIssuesResponse { + items: FieldIssueItem[]; + total: number; + page: number; + pageSize: number; + summary: FieldIssuesSummary; +} + +export async function getFieldIssues( + projectId: string, + params?: { page?: number; pageSize?: number; severity?: string; dimension?: string; recordId?: string } +): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/field-issues`, { params }); + return response.data.data; +} + // ==================== 重大事件 ==================== export interface CriticalEvent { diff --git a/frontend-v2/src/modules/iit/pages/AiStreamPage.tsx b/frontend-v2/src/modules/iit/pages/AiStreamPage.tsx index 8c1aa21f..1d52f92a 100644 --- a/frontend-v2/src/modules/iit/pages/AiStreamPage.tsx +++ b/frontend-v2/src/modules/iit/pages/AiStreamPage.tsx @@ -1,8 +1,8 @@ /** * AI 实时工作流水页 (Level 2) * - * 以 Timeline 展示 Agent 每次质控的完整动作链,AI 白盒化。 - * 显示中文事件名、实际规则数、五层定位详情、最终判定状态。 + * 以 Timeline 展示 Agent 质控结果,数据来源: qc_field_status (SSOT)。 + * 按受试者分组展示问题详情,支持按维度分组查看。 */ import React, { useState, useEffect, useCallback } from 'react'; @@ -28,16 +28,20 @@ import { SyncOutlined, ClockCircleOutlined, RobotOutlined, - ApiOutlined, BellOutlined, FileSearchOutlined, } from '@ant-design/icons'; import * as iitProjectApi from '../api/iitProjectApi'; -import type { TimelineItem } from '../api/iitProjectApi'; +import type { TimelineItem, TimelineIssue } from '../api/iitProjectApi'; import { useIitProject } from '../context/IitProjectContext'; const { Text } = Typography; +const DIMENSION_LABELS: Record = { + D1: '入选/排除', D2: '完整性', D3: '准确性', D4: '质疑管理', + D5: '安全性', D6: '方案偏离', D7: '药物管理', +}; + const STATUS_DOT: Record = { PASS: { color: 'green', icon: , label: '通过' }, FAIL: { color: 'red', icon: , label: '严重' }, @@ -66,7 +70,7 @@ const AiStreamPage: React.FC = () => { try { const result = await iitProjectApi.getTimeline(projectId, { page, - pageSize: 30, + pageSize: 20, date: dateFilter, }); setItems(result.items); @@ -85,6 +89,59 @@ const AiStreamPage: React.FC = () => { fetchData(); }; + const issueColumns = [ + { + title: '维度', + dataIndex: 'ruleCategory', + width: 90, + render: (v: string) => { + const label = DIMENSION_LABELS[v] || v; + return {v ? `${v} ${label}` : '—'}; + }, + }, + { + title: '规则', + dataIndex: 'ruleName', + width: 160, + render: (v: string, r: TimelineIssue) => {v || r.ruleId || '—'}, + }, + { + title: '字段', + dataIndex: 'field', + width: 140, + render: (v: string, r: TimelineIssue) => { + const label = r.fieldLabel || v; + return label ? {label} : '—'; + }, + }, + { + title: '事件', + dataIndex: 'eventId', + width: 140, + render: (v: string, r: TimelineIssue) => { + const label = r.eventLabel || v; + return label || '—'; + }, + }, + { title: '问题描述', dataIndex: 'message', ellipsis: true }, + { + title: '严重度', + dataIndex: 'severity', + width: 80, + render: (s: string) => ( + + {s === 'critical' ? '严重' : '警告'} + + ), + }, + { + title: '实际值', + dataIndex: 'actualValue', + width: 90, + render: (v: string) => v ?? '—', + }, + ]; + const timelineItems = items.map((item) => { const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: , label: '未知' }; const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy }; @@ -93,42 +150,18 @@ const AiStreamPage: React.FC = () => { const timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const dateStr = time.toLocaleDateString('zh-CN'); - const eventLabel = item.eventLabel || ''; const issues = item.details.issues || []; - const issueColumns = [ - { - title: '规则', - dataIndex: 'ruleName', - width: 160, - render: (v: string, r: any) => ( - - {v || r.ruleId} - - ), - }, - { title: '字段', dataIndex: 'field', width: 110, render: (v: string) => v ? {v} : '—' }, - { title: '问题描述', dataIndex: 'message', ellipsis: true }, - { - title: '严重度', - dataIndex: 'severity', - width: 80, - render: (s: string) => ( - - {s === 'critical' ? '严重' : '警告'} - - ), - }, - { - title: '实际值', - dataIndex: 'actualValue', - width: 90, - render: (v: string) => v ?? '—', - }, - ]; + // 按维度分组 + const groupedByDimension = issues.reduce>((acc, iss) => { + const key = iss.ruleCategory || '其他'; + if (!acc[key]) acc[key] = []; + acc[key].push(iss); + return acc; + }, {}); return { - color: dotCfg.color as any, + color: dotCfg.color as string, dot: dotCfg.icon, children: (
@@ -153,29 +186,20 @@ const AiStreamPage: React.FC = () => { }}> - 扫描受试者 {item.recordId} - {eventLabel && {eventLabel}} + 受试者 {item.recordId} -
- - - 执行 {item.details.rulesEvaluated} 条规则 - → {item.details.rulesPassed} 通过 - {item.details.rulesFailed > 0 && ( - / {item.details.rulesFailed} 失败 - )} - -
- {(red > 0 || yellow > 0) && (
- + 0 ? '#ef4444' : '#f59e0b' }} /> - {red > 0 && } - {red > 0 && 严重问题} - {yellow > 0 && } - {yellow > 0 && 警告} + {red > 0 && <>严重问题} + {yellow > 0 && <>警告} + {Object.entries(groupedByDimension).map(([dim, dimIssues]) => ( + + {dim} {DIMENSION_LABELS[dim] || ''}: {dimIssues.length} + + ))}
)} @@ -200,6 +224,7 @@ const AiStreamPage: React.FC = () => { size="small" pagination={false} columns={issueColumns} + scroll={{ x: 800 }} /> ), }]} @@ -225,7 +250,7 @@ const AiStreamPage: React.FC = () => { } color="processing">实时 - 条工作记录 + 位受试者 @@ -251,9 +276,9 @@ const AiStreamPage: React.FC = () => { `共 ${t} 条`} + showTotal={(t) => `共 ${t} 位受试者`} size="small" />
diff --git a/frontend-v2/src/modules/iit/pages/ReportsPage.tsx b/frontend-v2/src/modules/iit/pages/ReportsPage.tsx index 52fd2715..b2959d86 100644 --- a/frontend-v2/src/modules/iit/pages/ReportsPage.tsx +++ b/frontend-v2/src/modules/iit/pages/ReportsPage.tsx @@ -27,6 +27,8 @@ import { Select, Badge, Spin, + Modal, + Pagination, } from 'antd'; import { FileTextOutlined, @@ -38,9 +40,10 @@ import { DatabaseOutlined, QuestionCircleOutlined, ExceptionOutlined, + EyeOutlined, } from '@ant-design/icons'; import * as iitProjectApi from '../api/iitProjectApi'; -import type { QcReport, CriticalEvent } from '../api/iitProjectApi'; +import type { QcReport, CriticalEvent, FieldIssueItem, FieldIssuesSummary } from '../api/iitProjectApi'; import { useIitProject } from '../context/IitProjectContext'; const EligibilityTable = lazy(() => import('../components/reports/EligibilityTable')); @@ -62,6 +65,16 @@ const ReportsPage: React.FC = () => { const [ceTotal, setCeTotal] = useState(0); const [ceStatusFilter, setCeStatusFilter] = useState(undefined); + // 问题详情 Modal + const [issueModalOpen, setIssueModalOpen] = useState(false); + const [issueModalSeverity, setIssueModalSeverity] = useState(undefined); + const [issueModalDimension, setIssueModalDimension] = useState(undefined); + const [issueItems, setIssueItems] = useState([]); + const [issueTotal, setIssueTotal] = useState(0); + const [issuePage, setIssuePage] = useState(1); + const [issueSummary, setIssueSummary] = useState(null); + const [issueLoading, setIssueLoading] = useState(false); + const fetchReport = useCallback(async () => { if (!projectId) return; setLoading(true); @@ -82,6 +95,34 @@ const ReportsPage: React.FC = () => { useEffect(() => { fetchReport(); }, [fetchReport]); + const fetchIssues = useCallback(async (severity?: string, dimension?: string, pg = 1) => { + if (!projectId) return; + setIssueLoading(true); + try { + const data = await iitProjectApi.getFieldIssues(projectId, { + page: pg, + pageSize: 20, + severity, + dimension, + }); + setIssueItems(data.items); + setIssueTotal(data.total); + setIssueSummary(data.summary); + } catch { + setIssueItems([]); + } finally { + setIssueLoading(false); + } + }, [projectId]); + + const openIssueModal = (severity?: string) => { + setIssueModalSeverity(severity); + setIssueModalDimension(undefined); + setIssuePage(1); + setIssueModalOpen(true); + fetchIssues(severity, undefined, 1); + }; + const handleRefresh = async () => { if (!projectId) return; setRefreshing(true); @@ -117,8 +158,12 @@ const ReportsPage: React.FC = () => {
= 80 ? '#52c41a' : summary.passRate >= 60 ? '#faad14' : '#ff4d4f' }} /> - } valueStyle={{ color: '#ff4d4f' }} /> - } valueStyle={{ color: '#faad14' }} /> + openIssueModal('critical')} style={{ cursor: 'pointer' }}> + 严重问题 } value={summary.criticalIssues} prefix={} valueStyle={{ color: '#ff4d4f' }} /> + + openIssueModal('warning')} style={{ cursor: 'pointer' }}> + 警告 } value={summary.warningIssues} prefix={} valueStyle={{ color: '#faad14' }} /> + @@ -223,6 +268,117 @@ const ReportsPage: React.FC = () => { const lazyFallback = ; + const DIMENSION_LABELS: Record = { + D1: '入选/排除', D2: '完整性', D3: '准确性', D4: '质疑管理', + D5: '安全性', D6: '方案偏离', D7: '药物管理', + }; + + const issueDetailColumns = [ + { title: '受试者', dataIndex: 'recordId', width: 80, render: (v: string) => {v} }, + { + title: '事件', + dataIndex: 'eventLabel', + width: 130, + render: (v: string, r: FieldIssueItem) => v || r.eventId || '—', + }, + { + title: '字段', + dataIndex: 'fieldLabel', + width: 130, + render: (v: string, r: FieldIssueItem) => (v || r.fieldName) ? {v || r.fieldName} : '—', + }, + { + title: '维度', + dataIndex: 'ruleCategory', + width: 100, + render: (v: string) => {v ? `${v} ${DIMENSION_LABELS[v] || ''}` : '—'}, + }, + { title: '规则', dataIndex: 'ruleName', width: 140, ellipsis: true }, + { title: '问题描述', dataIndex: 'message', ellipsis: true }, + { + title: '严重度', + dataIndex: 'severity', + width: 80, + render: (s: string) => {s === 'critical' ? '严重' : '警告'}, + }, + { title: '实际值', dataIndex: 'actualValue', width: 90, render: (v: string) => v ?? '—' }, + { title: '检出时间', dataIndex: 'lastQcAt', width: 150, render: (d: string) => d ? new Date(d).toLocaleString('zh-CN') : '—' }, + ]; + + const issueModal = ( + + {issueModalSeverity === 'critical' ? : } + {issueModalSeverity === 'critical' ? '严重问题详情' : issueModalSeverity === 'warning' ? '警告详情' : '所有问题详情'} + + + } + open={issueModalOpen} + onCancel={() => setIssueModalOpen(false)} + width={1100} + footer={null} + > +
+ +
+ +
+ { + setIssuePage(pg); + fetchIssues(issueModalSeverity, issueModalDimension, pg); + }} + showTotal={(t) => `共 ${t} 条`} + size="small" + /> +
+ + ); + return (
@@ -282,6 +438,7 @@ const ReportsPage: React.FC = () => { ]} /> + {issueModal}
); }; diff --git a/frontend-v2/src/modules/pkb/pages/DashboardPage.tsx b/frontend-v2/src/modules/pkb/pages/DashboardPage.tsx index 8b82e32f..cda33810 100644 --- a/frontend-v2/src/modules/pkb/pages/DashboardPage.tsx +++ b/frontend-v2/src/modules/pkb/pages/DashboardPage.tsx @@ -84,7 +84,7 @@ const DashboardPage: React.FC = () => { const [selectedTypeId, setSelectedTypeId] = useState(null); // 表单数据 - const [formData, setFormData] = useState({ name: '', department: 'Cardiology' }); + const [formData, setFormData] = useState({ name: '', department: 'General' }); const [files, setFiles] = useState([]); // 新增:创建知识库后保存ID,用于Step3上传文档 const [createdKbId, setCreatedKbId] = useState(null); @@ -141,7 +141,7 @@ const DashboardPage: React.FC = () => { const handleCreateOpen = () => { setCreateStep(1); setSelectedTypeId(null); - setFormData({ name: '', department: 'Cardiology' }); + setFormData({ name: '', department: 'General' }); setFiles([]); setCreatedKbId(null); setUploadedCount(0); @@ -400,19 +400,7 @@ const DashboardPage: React.FC = () => { />
-
- - -
+ {/* 科室选择暂时隐藏,默认使用 General */} )}