feat(iit): V3.2 data consistency + project isolation + admin config redesign + Chinese labels

Summary:
- Refactor timeline API to read from qc_field_status (SSOT) instead of qc_logs
- Add field-issues paginated API with severity/dimension/recordId filters
- Add LEFT JOIN field_metadata + qc_event_status for Chinese display names
- Implement per-project ChatOrchestrator cache and SessionMemory isolation
- Redesign admin IIT config tabs (REDCap -> Fields -> KB -> Rules -> Members)
- Add AI-powered QC rule generation (D3 programmatic + D1/D5/D6 LLM-based)
- Add clickable warning/critical detail Modal in ReportsPage
- Auto-dispatch eQuery after batch QC via DailyQcOrchestrator
- Update module status documentation to v3.2

Backend changes:
- iitQcCockpitController: rewrite getTimeline from qc_field_status, add getFieldIssues
- iitQcCockpitRoutes: add field-issues route
- ChatOrchestrator: per-projectId cached instances
- SessionMemory: keyed by userId::projectId
- WechatCallbackController: resolve projectId from iitUserMapping
- iitRuleSuggestionService: dimension-based suggest + generateD3Rules
- iitBatchController: call DailyQcOrchestrator after batch QC

Frontend changes:
- AiStreamPage: adapt to new timeline structure with dimension tags
- ReportsPage: clickable stats cards with issue detail Modal
- IitProjectDetailPage: reorder tabs, add AI rule generation UI
- iitProjectApi: add TimelineIssue, FieldIssueItem types and APIs

Status: TypeScript compilation verified, no new lint errors
Made-with: Cursor
This commit is contained in:
2026-03-02 14:29:59 +08:00
parent 72928d3116
commit 71d32d11ee
38 changed files with 1597 additions and 546 deletions

View File

@@ -40,7 +40,7 @@ async function main() {
console.log(` ✅ 内部租户创建成功: ${internalTenant.name}`); 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) { for (const moduleCode of internalModules) {
await prisma.tenant_modules.upsert({ await prisma.tenant_modules.upsert({
where: { tenant_id_module_code: { tenant_id: internalTenant.id, module_code: moduleCode } }, where: { tenant_id_module_code: { tenant_id: internalTenant.id, module_code: moduleCode } },

View File

@@ -1,4 +1,4 @@
你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华医学超声杂志的稿约标准对稿件进行评估。 你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华脑血管病杂志的稿约标准对稿件进行评估。
【你的职责】 【你的职责】
1. 仔细阅读稿件的每个部分 1. 仔细阅读稿件的每个部分

View File

@@ -43,7 +43,7 @@ const MODULES = [
}, },
{ {
code: 'IIT', code: 'IIT',
name: 'IIT管理', name: 'CRA质控',
description: 'IIT项目管理系统支持REDCap集成和项目协作', description: 'IIT项目管理系统支持REDCap集成和项目协作',
icon: 'ProjectOutlined', icon: 'ProjectOutlined',
is_active: true, is_active: true,
@@ -73,6 +73,22 @@ const MODULES = [
is_active: true, is_active: true,
sort_order: 8, 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() { async function main() {

View File

@@ -14,6 +14,7 @@ import { jwtService } from './jwt.service.js';
import type { DecodedToken } from './jwt.service.js'; import type { DecodedToken } from './jwt.service.js';
import { logger } from '../logging/index.js'; import { logger } from '../logging/index.js';
import { moduleService } from './module.service.js'; import { moduleService } from './module.service.js';
import { cache } from '../cache/index.js';
/** /**
* 扩展 Fastify Request 类型 * 扩展 Fastify Request 类型
@@ -71,6 +72,15 @@ export const authenticate: preHandlerHookHandler = async (
// 2. 验证 Token // 2. 验证 Token
const decoded = jwtService.verifyToken(token); const decoded = jwtService.verifyToken(token);
// 2.5 验证 token 版本号(单设备登录:新登录会踢掉旧会话)
if (decoded.tokenVersion !== undefined) {
const tokenVersionKey = `token_version:${decoded.userId}`;
const currentVersion = await cache.get<number>(tokenVersionKey);
if (currentVersion !== null && decoded.tokenVersion < currentVersion) {
throw new AuthenticationError('您的账号已在其他设备登录,当前会话已失效');
}
}
// 3. 注入用户信息 // 3. 注入用户信息
request.user = decoded; request.user = decoded;

View File

@@ -13,6 +13,7 @@ import { prisma } from '../../config/database.js';
import { jwtService } from './jwt.service.js'; import { jwtService } from './jwt.service.js';
import type { JWTPayload, TokenResponse } from './jwt.service.js'; import type { JWTPayload, TokenResponse } from './jwt.service.js';
import { logger } from '../logging/index.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 permissions = await this.getUserPermissions(user.role);
const modules = await this.getUserModules(user.id); const modules = await this.getUserModules(user.id);
// 5. 生成 JWT // 4.5 递增 token 版本号(实现单设备登录,踢掉旧会话)
const tokenVersionKey = `token_version:${user.id}`;
const currentVersion = await cache.get<number>(tokenVersionKey) || 0;
const newVersion = currentVersion + 1;
await cache.set(tokenVersionKey, newVersion, 30 * 24 * 60 * 60); // 30天有效
// 5. 生成 JWT包含 token 版本号)
const jwtPayload: JWTPayload = { const jwtPayload: JWTPayload = {
userId: user.id, userId: user.id,
phone: user.phone, phone: user.phone,
@@ -123,6 +130,7 @@ export class AuthService {
tenantId: user.tenant_id, tenantId: user.tenant_id,
tenantCode: user.tenants?.code, tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password, isDefaultPassword: user.is_default_password,
tokenVersion: newVersion,
}; };
const tokens = jwtService.generateTokens(jwtPayload); const tokens = jwtService.generateTokens(jwtPayload);
@@ -139,6 +147,7 @@ export class AuthService {
role: user.role, role: user.role,
tenantId: user.tenant_id, tenantId: user.tenant_id,
modules: modules.length, modules: modules.length,
tokenVersion: newVersion,
}); });
return { return {
@@ -214,7 +223,13 @@ export class AuthService {
const permissions = await this.getUserPermissions(user.role); const permissions = await this.getUserPermissions(user.role);
const modules = await this.getUserModules(user.id); const modules = await this.getUserModules(user.id);
// 6. 生成 JWT // 5.5 递增 token 版本号(实现单设备登录,踢掉旧会话)
const tokenVersionKey = `token_version:${user.id}`;
const currentVersion = await cache.get<number>(tokenVersionKey) || 0;
const newVersion = currentVersion + 1;
await cache.set(tokenVersionKey, newVersion, 30 * 24 * 60 * 60);
// 6. 生成 JWT包含 token 版本号)
const jwtPayload: JWTPayload = { const jwtPayload: JWTPayload = {
userId: user.id, userId: user.id,
phone: user.phone, phone: user.phone,
@@ -222,6 +237,7 @@ export class AuthService {
tenantId: user.tenant_id, tenantId: user.tenant_id,
tenantCode: user.tenants?.code, tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password, isDefaultPassword: user.is_default_password,
tokenVersion: newVersion,
}; };
const tokens = jwtService.generateTokens(jwtPayload); const tokens = jwtService.generateTokens(jwtPayload);
@@ -231,6 +247,7 @@ export class AuthService {
phone: user.phone, phone: user.phone,
role: user.role, role: user.role,
modules: modules.length, modules: modules.length,
tokenVersion: newVersion,
}); });
return { return {
@@ -431,6 +448,10 @@ export class AuthService {
return null; return null;
} }
// 获取当前 token 版本号(单设备登录校验)
const tokenVersionKey = `token_version:${user.id}`;
const currentVersion = await cache.get<number>(tokenVersionKey) || 0;
return { return {
userId: user.id, userId: user.id,
phone: user.phone, phone: user.phone,
@@ -438,6 +459,7 @@ export class AuthService {
tenantId: user.tenant_id, tenantId: user.tenant_id,
tenantCode: user.tenants?.code, tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password, isDefaultPassword: user.is_default_password,
tokenVersion: currentVersion,
}; };
}); });
} }

View File

@@ -28,6 +28,8 @@ export interface JWTPayload {
tenantCode?: string; tenantCode?: string;
/** 是否为默认密码 */ /** 是否为默认密码 */
isDefaultPassword?: boolean; isDefaultPassword?: boolean;
/** Token版本号用于单点登录踢人 */
tokenVersion?: number;
} }
/** /**
@@ -83,10 +85,10 @@ export class JWTService {
* 生成 Refresh Token * 生成 Refresh Token
*/ */
generateRefreshToken(payload: JWTPayload): string { generateRefreshToken(payload: JWTPayload): string {
// Refresh Token 只包含必要信息
const refreshPayload = { const refreshPayload = {
userId: payload.userId, userId: payload.userId,
type: 'refresh', type: 'refresh',
tokenVersion: payload.tokenVersion,
}; };
const options: SignOptions = { const options: SignOptions = {
@@ -144,12 +146,19 @@ export class JWTService {
throw new Error('无效的Refresh Token'); throw new Error('无效的Refresh Token');
} }
// 获取用户最新信息 // 获取用户最新信息(包含当前 tokenVersion
const user = await getUserById(decoded.userId); const user = await getUserById(decoded.userId);
if (!user) { if (!user) {
throw new Error('用户不存在'); throw new Error('用户不存在');
} }
// 验证 token 版本号(踢人检查)
const refreshTokenVersion = (decoded as any).tokenVersion;
if (refreshTokenVersion !== undefined && user.tokenVersion !== undefined
&& refreshTokenVersion < user.tokenVersion) {
throw new Error('您的账号已在其他设备登录,当前会话已失效');
}
// 生成新的 Tokens // 生成新的 Tokens
return this.generateTokens(user); return this.generateTokens(user);
} }

View File

@@ -152,8 +152,52 @@ class ModuleService {
}); });
}); });
// 6. 合并所有模块(去重 // 5.5 查询用户级别的模块权限(精细化控制
const moduleSet = new Set(tenantModulesData.map(tm => tm.module_code)); 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<string, Map<string, boolean>>();
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<string>();
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); const allModuleCodes = Array.from(moduleSet);
// 7. 获取模块详细信息 // 7. 获取模块详细信息

View File

@@ -19,6 +19,7 @@ import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js';
import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js'; import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js';
import { QcExecutor } from '../../iit-manager/engines/QcExecutor.js'; import { QcExecutor } from '../../iit-manager/engines/QcExecutor.js';
import { QcReportService } from '../../iit-manager/services/QcReportService.js'; import { QcReportService } from '../../iit-manager/services/QcReportService.js';
import { dailyQcOrchestrator } from '../../iit-manager/services/DailyQcOrchestrator.js';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -63,12 +64,22 @@ export class IitBatchController {
const { totalRecords, totalEvents, passed, failed, warnings, fieldStatusWrites, executionTimeMs } = batchResult; const { totalRecords, totalEvents, passed, failed, warnings, fieldStatusWrites, executionTimeMs } = batchResult;
const passRate = totalEvents > 0 ? `${((passed / totalEvents) * 100).toFixed(1)}%` : '0%'; const passRate = totalEvents > 0 ? `${((passed / totalEvents) * 100).toFixed(1)}%` : '0%';
// 自动刷新 QcReport 缓存,使业务端立即看到最新数据 // 编排后续动作:生成报告 + 创建 eQuery + 归档关键事件 + 推送通知
let equeriesCreated = 0;
try {
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 { try {
await QcReportService.refreshReport(projectId); await QcReportService.refreshReport(projectId);
logger.info('[V3.1] QcReport cache refreshed after batch QC', { projectId }); } catch { /* ignore */ }
} catch (reportErr: any) {
logger.warn('[V3.1] QcReport refresh failed (non-blocking)', { projectId, error: reportErr.message });
} }
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
@@ -87,6 +98,7 @@ export class IitBatchController {
warnings, warnings,
fieldStatusWrites, fieldStatusWrites,
passRate, passRate,
equeriesCreated,
}, },
durationMs, durationMs,
}); });

View File

@@ -189,7 +189,9 @@ class IitQcCockpitController {
} }
/** /**
* 获取 AI 工作时间线(QC 日志 + Agent Trace 合并 * 获取 AI 工作时间线(从 qc_field_status 五级结构读取SSOT
*
* 按受试者分组,展示每个受试者的 FAIL/WARNING 问题列表。
*/ */
async getTimeline( async getTimeline(
request: FastifyRequest<{ request: FastifyRequest<{
@@ -201,81 +203,168 @@ class IitQcCockpitController {
const { projectId } = request.params; const { projectId } = request.params;
const query = request.query as any; const query = request.query as any;
const page = query.page ? parseInt(query.page) : 1; 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; const dateFilter = query.date;
try { try {
const dateWhere: any = {}; let dateClause = '';
if (dateFilter) { if (dateFilter) {
const start = new Date(dateFilter); dateClause = `AND fs.last_qc_at >= '${dateFilter}'::date AND fs.last_qc_at < ('${dateFilter}'::date + INTERVAL '1 day')`;
const end = new Date(dateFilter);
end.setDate(end.getDate() + 1);
dateWhere.createdAt = { gte: start, lt: end };
} }
const [qcLogs, totalLogs] = await Promise.all([ // 1. 获取有问题的受试者摘要(分页)
prisma.iitQcLog.findMany({ const recordSummaries = await prisma.$queryRawUnsafe<Array<{
where: { projectId, ...dateWhere }, record_id: string;
orderBy: { createdAt: 'desc' }, critical_count: bigint;
skip: (page - 1) * pageSize, warning_count: bigint;
take: pageSize, total_issues: bigint;
select: { latest_qc_at: Date;
id: true, triggered_by: string;
recordId: true, }>>(
eventId: true, `SELECT
qcType: true, fs.record_id,
formName: true, COUNT(*) FILTER (WHERE fs.severity = 'critical') AS critical_count,
status: true, COUNT(*) FILTER (WHERE fs.severity != 'critical') AS warning_count,
issues: true, COUNT(*) AS total_issues,
rulesEvaluated: true, MAX(fs.last_qc_at) AS latest_qc_at,
rulesPassed: true, MAX(fs.triggered_by) AS triggered_by
rulesFailed: true, FROM iit_schema.qc_field_status fs
triggeredBy: true, WHERE fs.project_id = $1 AND fs.status IN ('FAIL', 'WARNING')
createdAt: true, ${dateClause}
}, GROUP BY fs.record_id
}), ORDER BY MAX(fs.last_qc_at) DESC
prisma.iitQcLog.count({ where: { projectId, ...dateWhere } }), LIMIT $2 OFFSET $3`,
]); projectId, pageSize, (page - 1) * pageSize
);
const items = qcLogs.map((log) => { // 2. 总受试者数
const rawIssues = log.issues as any; const countResult = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
const issues: any[] = Array.isArray(rawIssues) ? rawIssues : (rawIssues?.items || []); `SELECT COUNT(DISTINCT record_id) AS cnt
const redCount = issues.filter((i: any) => i.severity === 'critical' || i.level === 'RED').length; FROM iit_schema.qc_field_status
const yellowCount = issues.filter((i: any) => i.severity === 'warning' || i.level === 'YELLOW').length; WHERE project_id = $1 AND status IN ('FAIL', 'WARNING')
const eventLabel = rawIssues?.eventLabel || ''; ${dateClause}`,
const totalRules = rawIssues?.summary?.totalRules || log.rulesEvaluated || 0; projectId
);
const totalRecords = Number(countResult[0]?.cnt || 0);
let description = `扫描受试者 ${log.recordId}`; // 3. 获取这些受试者的问题详情LEFT JOIN 获取字段/事件中文名)
if (eventLabel) description += `${eventLabel}`; const recordIds = recordSummaries.map(r => r.record_id);
description += ` → 执行 ${totalRules} 条规则 (${log.rulesPassed} 通过`; let issues: any[] = [];
if (log.rulesFailed > 0) description += `, ${log.rulesFailed} 失败`; if (recordIds.length > 0) {
description += ')'; issues = await prisma.$queryRawUnsafe<any[]>(
if (redCount > 0) description += ` → 发现 ${redCount} 个严重问题`; `SELECT
if (yellowCount > 0) description += `, ${yellowCount} 个警告`; 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<string, any[]>();
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<Array<{
record_id: string;
total_fields: bigint;
latest_qc_at: Date;
triggered_by: string;
}>>(
`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 { return {
id: log.id, id: `fs_${rec.record_id}`,
type: 'qc_check' as const, type: 'qc_check' as const,
time: log.createdAt, time: rec.latest_qc_at,
recordId: log.recordId, recordId: rec.record_id,
eventLabel, status,
formName: log.formName, triggeredBy: rec.triggered_by || 'batch',
status: log.status, description: `受试者 ${rec.record_id} 发现 ${criticalCount + warningCount} 个问题`,
triggeredBy: log.triggeredBy,
description,
details: { details: {
rulesEvaluated: totalRules, issuesSummary: { red: criticalCount, yellow: warningCount },
rulesPassed: log.rulesPassed, issues: recIssues.map((i: any) => ({
rulesFailed: log.rulesFailed, ruleId: i.rule_id || '',
issuesSummary: { red: redCount, yellow: yellowCount }, ruleName: i.rule_name || '',
issues, 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({ return reply.send({
success: true, success: true,
data: { items, total: totalLogs, page, pageSize }, data: { items, total: totalAll, page, pageSize },
}); });
} catch (error: any) { } catch (error: any) {
logger.error('[QcCockpitController] 获取时间线失败', { projectId, error: error.message }); 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<any[]>(
`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<Array<{ cnt: bigint }>>(
`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<Array<{
severity: string;
rule_category: string;
cnt: bigint;
}>>(
`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<string, number>,
byDimension: {} as Record<string, number>,
};
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 // GCP 业务报表 API
// ============================================================ // ============================================================

View File

@@ -292,6 +292,25 @@ export async function iitQcCockpitRoutes(fastify: FastifyInstance) {
// V3.1: D6 方案偏离列表 // V3.1: D6 方案偏离列表
fastify.get('/:projectId/qc-cockpit/deviations', iitQcCockpitController.getDeviations.bind(iitQcCockpitController)); 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 业务报表路由 // GCP 业务报表路由
// ============================================================ // ============================================================

View File

@@ -289,16 +289,20 @@ export async function getRuleStats(
} }
/** /**
* AI 规则建议 * AI 规则建议(支持按维度生成)
*/ */
export async function suggestRules( export async function suggestRules(
request: FastifyRequest<{ Params: ProjectIdParams }>, request: FastifyRequest<{ Params: ProjectIdParams; Querystring: { dimension?: string } }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const { projectId } = request.params; 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 service = getIitRuleSuggestionService(prisma);
const suggestions = await service.suggestRules(projectId); const suggestions = await service.suggestRules(projectId, dim);
return reply.send({ return reply.send({
success: true, success: true,
@@ -306,7 +310,33 @@ export async function suggestRules(
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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({ return reply.status(500).send({
success: false, success: false,
error: message, error: message,

View File

@@ -27,9 +27,12 @@ export async function iitQcRuleRoutes(fastify: FastifyInstance) {
// 批量导入规则 // 批量导入规则
fastify.post('/:projectId/rules/import', controller.importRules); fastify.post('/:projectId/rules/import', controller.importRules);
// AI 规则建议 // AI 规则建议(支持 ?dimension=D1 查询参数)
fastify.post('/:projectId/rules/suggest', controller.suggestRules); fastify.post('/:projectId/rules/suggest', controller.suggestRules);
// D3 规则自动生成(数据驱动,无需 LLM
fastify.post('/:projectId/rules/generate-d3', controller.generateD3Rules);
// 测试规则逻辑(不需要项目 ID // 测试规则逻辑(不需要项目 ID
fastify.post('/rules/test', controller.testRule); fastify.post('/rules/test', controller.testRule);
} }

View File

@@ -171,14 +171,17 @@ export class IitQcRuleService {
async importRules(projectId: string, rules: CreateRuleInput[]): Promise<QCRule[]> { async importRules(projectId: string, rules: CreateRuleInput[]): Promise<QCRule[]> {
const skill = await this.getOrCreateSkill(projectId); 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) => ({ const newRules: QCRule[] = rules.map((input, index) => ({
id: `rule_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`, id: `rule_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`,
...input, ...input,
})); }));
const config: QCRuleConfig = { const config: QCRuleConfig = {
rules: newRules, rules: [...existingRules, ...newRules],
version: 1, version: (existingConfig.version || 0) + 1,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };

View File

@@ -2,6 +2,7 @@
* AI 规则建议服务 * AI 规则建议服务
* *
* 读取项目的变量元数据和知识库文档,调用 LLM 生成质控规则建议。 * 读取项目的变量元数据和知识库文档,调用 LLM 生成质控规则建议。
* 支持按维度(D1-D7)生成,以及纯数据驱动的 D3 规则自动构建。
*/ */
import { PrismaClient } from '@prisma/client'; 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 type { Message } from '../../../common/llm/adapters/types.js';
import { logger } from '../../../common/logging/index.js'; import { logger } from '../../../common/logging/index.js';
export type DimensionCode = 'D1' | 'D2' | 'D3' | 'D4' | 'D5' | 'D6' | 'D7';
export interface RuleSuggestion { export interface RuleSuggestion {
name: string; name: string;
field: string | string[]; field: string | string[];
@@ -16,18 +19,86 @@ export interface RuleSuggestion {
message: string; message: string;
severity: 'error' | 'warning' | 'info'; severity: 'error' | 'warning' | 'info';
category: string; category: string;
applicableEvents?: string[];
}
const DIMENSION_META: Record<DimensionCode, { label: string; description: string; needsKb: boolean }> = {
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 { export class IitRuleSuggestionService {
constructor(private prisma: PrismaClient) {} constructor(private prisma: PrismaClient) {}
async suggestRules(projectId: string): Promise<RuleSuggestion[]> { /**
* AI 生成规则建议(按维度)
*/
async suggestRules(projectId: string, dimension?: DimensionCode): Promise<RuleSuggestion[]> {
const project = await this.prisma.iitProject.findFirst({ const project = await this.prisma.iitProject.findFirst({
where: { id: projectId, deletedAt: null }, where: { id: projectId, deletedAt: null },
}); });
if (!project) throw new Error('项目不存在'); if (!project) throw new Error('项目不存在');
// 1. Gather variable metadata
const fields = await this.prisma.iitFieldMetadata.findMany({ const fields = await this.prisma.iitFieldMetadata.findMany({
where: { projectId }, where: { projectId },
orderBy: [{ formName: 'asc' }, { fieldName: 'asc' }], orderBy: [{ formName: 'asc' }, { fieldName: 'asc' }],
@@ -37,9 +108,11 @@ export class IitRuleSuggestionService {
throw new Error('请先从 REDCap 同步变量元数据'); throw new Error('请先从 REDCap 同步变量元数据');
} }
// 2. Gather knowledge base context (protocol summary)
let protocolContext = ''; let protocolContext = '';
if (project.knowledgeBaseId) { const dimMeta = dimension ? DIMENSION_META[dimension] : null;
const needsKb = dimMeta ? dimMeta.needsKb : true;
if (needsKb && project.knowledgeBaseId) {
try { try {
const docs = await this.prisma.ekbDocument.findMany({ const docs = await this.prisma.ekbDocument.findMany({
where: { kbId: project.knowledgeBaseId, status: 'completed' }, 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 variableSummary = fields.map((f) => {
const parts = [`${f.fieldName} (${f.fieldLabel}): type=${f.fieldType}, form=${f.formName}`]; const parts = [`${f.fieldName} (${f.fieldLabel}): type=${f.fieldType}, form=${f.formName}`];
if (f.validation) parts.push(`validation=${f.validation}`); if (f.validation) parts.push(`validation=${f.validation}`);
@@ -68,35 +140,31 @@ export class IitRuleSuggestionService {
return parts.join(', '); return parts.join(', ');
}).join('\n'); }).join('\n');
// 4. Call LLM const dimensionList = Object.entries(DIMENSION_META)
const systemPrompt = `You are an expert clinical research data manager. You generate quality control (QC) rules for clinical trial data captured in REDCap. .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: 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.
- 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
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 - name (string): short descriptive name in Chinese
- field (string or string[]): REDCap field name(s) - field (string or string[]): REDCap field name(s) — must match actual variable names from the list
- logic (object): JSON Logic expression - logic (object): JSON Logic expression using these field names as {"var": "fieldName"}
- message (string): error message in Chinese - message (string): error/warning message in Chinese
- severity: "error" | "warning" | "info" - severity: "error" | "warning" | "info"
- category: one of the categories listed above - category: one of D1-D7
Generate 5-10 practical rules. Focus on: Generate 5-10 practical, accurate rules. Do NOT invent field names — only use fields from the provided variable list.
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)
Do NOT include explanations, only the JSON array.`; Do NOT include explanations, only the JSON array.`;
const userPrompt = `Project: ${project.name} const userPrompt = `Project: ${project.name}
@@ -105,7 +173,7 @@ Variable List (${fields.length} fields):
${variableSummary} ${variableSummary}
${protocolContext ? `\nProtocol / Study Document Context:\n${protocolContext}` : ''} ${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[] = [ const messages: Message[] = [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
@@ -120,7 +188,6 @@ Generate QC rules for this project:`;
}); });
const content = (response.content ?? '').trim(); const content = (response.content ?? '').trim();
// Extract JSON array from response (handle markdown code fences)
const jsonMatch = content.match(/\[[\s\S]*\]/); const jsonMatch = content.match(/\[[\s\S]*\]/);
if (!jsonMatch) { if (!jsonMatch) {
logger.error('LLM 返回非 JSON 格式', { content: content.substring(0, 200) }); 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]); const rules: RuleSuggestion[] = JSON.parse(jsonMatch[0]);
// Validate structure
const validRules = rules.filter( const validRules = rules.filter(
(r) => r.name && r.field && r.logic && r.message && r.severity && r.category (r) => r.name && r.field && r.logic && r.message && r.severity && r.category
); );
logger.info('AI 规则建议生成成功', { logger.info('AI 规则建议生成成功', {
projectId, projectId,
dimension: dimension || 'all',
total: rules.length, total: rules.length,
valid: validRules.length, valid: validRules.length,
model: response.model, model: response.model,
@@ -149,6 +216,90 @@ Generate QC rules for this project:`;
throw err; throw err;
} }
} }
/**
* 数据驱动的 D3准确性规则自动生成 — 无需 LLM
*/
async generateD3Rules(projectId: string): Promise<RuleSuggestion[]> {
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<string, unknown>[] = [];
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; let serviceInstance: IitRuleSuggestionService | null = null;

View File

@@ -677,16 +677,15 @@ export async function updateUserModules(
throw new Error('用户不是该租户的成员'); throw new Error('用户不是该租户的成员');
} }
// 获取租户订阅的模块 // 验证请求的模块是否在系统模块表中存在
const tenantModules = await prisma.tenant_modules.findMany({ const allModules = await prisma.modules.findMany({
where: { tenant_id: data.tenantId, is_enabled: true }, where: { is_active: true },
select: { code: true },
}); });
const tenantModuleCodes = tenantModules.map((tm) => tm.module_code); const validModuleCodes = allModules.map((m) => m.code);
const invalidModules = data.modules.filter((m) => !validModuleCodes.includes(m));
// 验证请求的模块是否在租户订阅范围内
const invalidModules = data.modules.filter((m) => !tenantModuleCodes.includes(m));
if (invalidModules.length > 0) { if (invalidModules.length > 0) {
throw new Error(`以下模块不在租户订阅范围内: ${invalidModules.join(', ')}`); throw new Error(`以下模块代码不存在: ${invalidModules.join(', ')}`);
} }
// 更新用户模块权限 // 更新用户模块权限
@@ -878,10 +877,12 @@ function getModuleName(code: string): string {
PKB: '个人知识库', PKB: '个人知识库',
ASL: 'AI智能文献', ASL: 'AI智能文献',
DC: '数据清洗整理', DC: '数据清洗整理',
IIT: 'IIT Manager', IIT: 'CRA质控',
RVW: '稿件审查', RVW: '稿件审查',
SSA: '智能统计分析', SSA: '智能统计分析',
ST: '统计分析工具', ST: '统计分析工具',
RM: '研究管理',
AIA_PROTOCOL: '全流程研究方案制定',
}; };
return moduleNames[code] || code; return moduleNames[code] || code;
} }

View File

@@ -42,37 +42,42 @@ export class SessionMemory {
private readonly MAX_HISTORY = 3; // 只保留最近3轮6条消息 private readonly MAX_HISTORY = 3; // 只保留最近3轮6条消息
private readonly SESSION_TIMEOUT = 3600000; // 1小时毫秒 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 { addMessage(userId: string, role: 'user' | 'assistant', content: string, projectId?: string): void {
if (!this.sessions.has(userId)) { const key = this.sessionKey(userId, projectId);
this.sessions.set(userId, { if (!this.sessions.has(key)) {
this.sessions.set(key, {
userId, userId,
messages: [], messages: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: 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({ session.messages.push({
role, role,
content, content,
timestamp: new Date(), timestamp: new Date(),
}); });
// 只保留最近3轮6条消息3个user + 3个assistant
if (session.messages.length > this.MAX_HISTORY * 2) { if (session.messages.length > this.MAX_HISTORY * 2) {
const removed = 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); 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(); session.updatedAt = new Date();
logger.debug('[SessionMemory] 添加消息', { logger.debug('[SessionMemory] 添加消息', {
userId, userId,
projectId,
role, role,
messageLength: content.length, messageLength: content.length,
totalMessages: session.messages.length, totalMessages: session.messages.length,
@@ -82,13 +87,13 @@ export class SessionMemory {
/** /**
* 获取用户对话历史最近N轮 * 获取用户对话历史最近N轮
*/ */
getHistory(userId: string, maxTurns: number = 3): ConversationMessage[] { getHistory(userId: string, maxTurns: number = 3, projectId?: string): ConversationMessage[] {
const session = this.sessions.get(userId); const key = this.sessionKey(userId, projectId);
const session = this.sessions.get(key);
if (!session) { if (!session) {
return []; return [];
} }
// 返回最近N轮2N条消息
const maxMessages = maxTurns * 2; const maxMessages = maxTurns * 2;
return session.messages.length > maxMessages return session.messages.length > maxMessages
? session.messages.slice(-maxMessages) ? session.messages.slice(-maxMessages)
@@ -98,8 +103,8 @@ export class SessionMemory {
/** /**
* 获取用户上下文格式化为字符串用于LLM Prompt * 获取用户上下文格式化为字符串用于LLM Prompt
*/ */
getContext(userId: string): string { getContext(userId: string, projectId?: string): string {
const history = this.getHistory(userId, 2); // 只取最近2轮 const history = this.getHistory(userId, 2, projectId);
if (history.length === 0) { if (history.length === 0) {
return ''; return '';
} }
@@ -112,10 +117,11 @@ export class SessionMemory {
/** /**
* 清除用户会话 * 清除用户会话
*/ */
clearSession(userId: string): void { clearSession(userId: string, projectId?: string): void {
const existed = this.sessions.delete(userId); const key = this.sessionKey(userId, projectId);
const existed = this.sessions.delete(key);
if (existed) { if (existed) {
logger.info('[SessionMemory] 清除会话', { userId }); logger.info('[SessionMemory] 清除会话', { userId, projectId });
} }
} }

View File

@@ -22,7 +22,7 @@ import { PrismaClient } from '@prisma/client';
import { createRequire } from 'module'; import { createRequire } from 'module';
import { logger } from '../../../common/logging/index.js'; import { logger } from '../../../common/logging/index.js';
import { wechatService } from '../services/WechatService.js'; import { wechatService } from '../services/WechatService.js';
import { ChatOrchestrator, getChatOrchestrator } from '../services/ChatOrchestrator.js'; import { getChatOrchestrator } from '../services/ChatOrchestrator.js';
// 使用 createRequire 导入 CommonJS 模块 // 使用 createRequire 导入 CommonJS 模块
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
@@ -75,7 +75,7 @@ export class WechatCallbackController {
private token: string; private token: string;
private encodingAESKey: string; private encodingAESKey: string;
private corpId: string; private corpId: string;
private chatOrchestrator: ChatOrchestrator | null = null; // chatOrchestrator now resolved per-project via getChatOrchestrator(projectId)
constructor() { constructor() {
// 从环境变量读取配置 // 从环境变量读取配置
@@ -322,10 +322,17 @@ export class WechatCallbackController {
'🫡 正在查询,请稍候...' '🫡 正在查询,请稍候...'
); );
if (!this.chatOrchestrator) { const userMapping = await prisma.iitUserMapping.findFirst({
this.chatOrchestrator = await getChatOrchestrator(); 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回复 // 主动推送AI回复
await wechatService.sendTextMessage(fromUser, aiResponse); await wechatService.sendTextMessage(fromUser, aiResponse);

View File

@@ -507,10 +507,11 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
schema: { schema: {
body: { body: {
type: 'object', type: 'object',
required: ['message'], required: ['message', 'projectId'],
properties: { properties: {
message: { type: 'string' }, message: { type: 'string' },
userId: { type: 'string' }, userId: { type: 'string' },
projectId: { type: 'string' },
}, },
}, },
}, },
@@ -518,9 +519,9 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
async (request: any, reply) => { async (request: any, reply) => {
const startTime = Date.now(); const startTime = Date.now();
try { try {
const { message, userId } = request.body; const { message, userId, projectId } = request.body;
const uid = userId || request.user?.id || 'web-user'; 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 rawReply = await orchestrator.handleMessage(uid, message);
const cleanReply = sanitizeLlmReply(rawReply); const cleanReply = sanitizeLlmReply(rawReply);

View File

@@ -78,6 +78,10 @@ export class ChatOrchestrator {
}); });
} }
getProjectId(): string {
return this.projectId;
}
async handleMessage(userId: string, userMessage: string): Promise<string> { async handleMessage(userId: string, userMessage: string): Promise<string> {
const startTime = Date.now(); const startTime = Date.now();
@@ -86,7 +90,7 @@ export class ChatOrchestrator {
} }
try { try {
const history = sessionMemory.getHistory(userId, 2); const history = sessionMemory.getHistory(userId, 2, this.projectId);
const historyMessages: Message[] = history.map((m) => ({ const historyMessages: Message[] = history.map((m) => ({
role: m.role as 'user' | 'assistant', role: m.role as 'user' | 'assistant',
content: m.content, content: m.content,
@@ -180,34 +184,31 @@ export class ChatOrchestrator {
} }
private saveConversation(userId: string, userMsg: string, aiMsg: string, startTime: number): void { private saveConversation(userId: string, userMsg: string, aiMsg: string, startTime: number): void {
sessionMemory.addMessage(userId, 'user', userMsg); sessionMemory.addMessage(userId, 'user', userMsg, this.projectId);
sessionMemory.addMessage(userId, 'assistant', aiMsg); sessionMemory.addMessage(userId, 'assistant', aiMsg, this.projectId);
logger.info('[ChatOrchestrator] Conversation saved', { logger.info('[ChatOrchestrator] Conversation saved', {
userId, userId,
projectId: this.projectId,
duration: `${Date.now() - startTime}ms`, duration: `${Date.now() - startTime}ms`,
}); });
} }
} }
// Resolve the active project ID from DB // Per-project orchestrator cache
async function resolveActiveProjectId(): Promise<string> { const orchestratorCache = new Map<string, ChatOrchestrator>();
const project = await prisma.iitProject.findFirst({
where: { status: 'active' }, export async function getChatOrchestrator(projectId: string): Promise<ChatOrchestrator> {
select: { id: true }, if (!projectId) {
}); throw new Error('projectId is required for ChatOrchestrator');
if (!project) throw new Error('No active IIT project found');
return project.id;
} }
// Singleton factory — lazily resolves active project let instance = orchestratorCache.get(projectId);
let orchestratorInstance: ChatOrchestrator | null = null; if (!instance) {
instance = new ChatOrchestrator(projectId);
export async function getChatOrchestrator(): Promise<ChatOrchestrator> { await instance.initialize();
if (!orchestratorInstance) { orchestratorCache.set(projectId, instance);
const projectId = await resolveActiveProjectId(); logger.info('[ChatOrchestrator] Created new instance', { projectId });
orchestratorInstance = new ChatOrchestrator(projectId);
await orchestratorInstance.initialize();
} }
return orchestratorInstance; return instance;
} }

View File

@@ -84,7 +84,8 @@ async function runTests() {
let orchestrator; let orchestrator;
try { try {
console.log('\n🔧 Initializing ChatOrchestrator...'); 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'); console.log('✅ ChatOrchestrator initialized successfully\n');
} catch (error: any) { } catch (error: any) {
console.error('❌ Failed to initialize ChatOrchestrator:', error.message); console.error('❌ Failed to initialize ChatOrchestrator:', error.message);

View File

@@ -1,10 +1,15 @@
# IIT Manager Agent模块 - 当前状态与开发指南 # IIT Manager Agent模块 - 当前状态与开发指南
> **文档版本:** v3.1 > **文档版本:** v3.2
> **创建日期:** 2026-01-01 > **创建日期:** 2026-01-01
> **维护者:** IIT Manager开发团队 > **维护者:** IIT Manager开发团队
> **最后更新:** 2026-03-01 **GCP 业务报表 + AI 工作流水增强 + 多项 Bug 修复完成** > **最后更新:** 2026-03-02 **数据一致性修复 + 项目隔离 + 管理端配置流重设计 + 中文显示名**
> **重大里程碑:** > **重大里程碑:**
> - **2026-03-02QC 数据一致性修复!** AI 时间线 + 警告详情 统一从 qc_field_statusSSOT读取与热力图数据一致
> - **2026-03-02字段/事件中文显示名!** LEFT JOIN field_metadata + qc_event_status消除 REDCap 技术标识符
> - **2026-03-02警告详情可查看** 新增 field-issues 分页 API + ReportsPage 严重问题/警告数字可点击弹出详情 Modal
> - **2026-03-02AI 对话项目隔离!** ChatOrchestrator 按 projectId 缓存实例 + SessionMemory 按 userId::projectId 隔离
> - **2026-03-02管理端配置流重设计** 5 个配置 Tab 按依赖关系重排 + AI 自动构建质控规则D1/D3/D5/D6 四维度)
> - **2026-03-01GCP 业务端报表全量完成!** 4 张 GCP 标准报表D1 筛选入选/D2 完整性/D3D4 质疑跟踪/D6 方案偏离)后端 API + 前端组件 + ReportsPage 五 Tab 重构 > - **2026-03-01GCP 业务端报表全量完成!** 4 张 GCP 标准报表D1 筛选入选/D2 完整性/D3D4 质疑跟踪/D6 方案偏离)后端 API + 前端组件 + ReportsPage 五 Tab 重构
> - **2026-03-01AI 工作流水时间线增强!** 实际规则数显示33 条而非 1 条)+ 中文事件名 + 可展开问题详情表格 + severity 映射修复 > - **2026-03-01AI 工作流水时间线增强!** 实际规则数显示33 条而非 1 条)+ 中文事件名 + 可展开问题详情表格 + severity 映射修复
> - **2026-03-01业务端一键全量质控** DashboardPage 新增按钮 + 自动刷新报告缓存 + 事件级通过率修复 > - **2026-03-01业务端一键全量质控** DashboardPage 新增按钮 + 自动刷新报告缓存 + 事件级通过率修复
@@ -61,7 +66,16 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA
- AI能力DeepSeek/Qwen + 自研 RAGpgvector+ LLM Tool Use - AI能力DeepSeek/Qwen + 自研 RAGpgvector+ LLM Tool Use
### 当前状态 ### 当前状态
- **开发阶段****V3.1 质控引擎 + GCP 业务报表 + AI 时间线增强 + Bug 修复 → 待部署验证** - **开发阶段****V3.2 数据一致性 + 项目隔离 + 管理端重设计 + 中文显示名 → 待部署验证**
- **V3.2 数据一致性 + 项目隔离已完成**2026-03-02
- AI 时间线改为从 qc_field_statusSSOT聚合与风险热力图数据一致
- 新增 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 - **GCP 业务报表 + Bug 修复已完成**2026-03-01
- 4 张 GCP 标准报表后端 APIiitQcCockpitServicegetEligibilityReport/getCompletenessReport/getEqueryReport/getDeviationReport - 4 张 GCP 标准报表后端 APIiitQcCockpitServicegetEligibilityReport/getCompletenessReport/getEqueryReport/getDeviationReport
- 4 个前端报表组件EligibilityTable/CompletenessTable/EqueryLogTable/DeviationLogTable - 4 个前端报表组件EligibilityTable/CompletenessTable/EqueryLogTable/DeviationLogTable
@@ -107,6 +121,37 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA
-**端到端测试通过**REDCap → Node.js → 企业微信) -**端到端测试通过**REDCap → Node.js → 企业微信)
-~~AI对话集成完成ChatService + SessionMemory~~ → 已替换为 ChatOrchestrator -~~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 业务报表 + AI 时间线 + Bug 修复 - 2026-03-01
-**GCP 标准报表(阶段 A 4 张)** -**GCP 标准报表(阶段 A 4 张)**
- D1 筛选入选表getEligibilityReportrecord_summary 全量 + qc_field_status D1 叠加) - D1 筛选入选表getEligibilityReportrecord_summary 全量 + qc_field_status D1 叠加)
@@ -954,8 +999,8 @@ npx ts-node src/modules/iit-manager/test-wechat-push.ts
--- ---
> **提示**本文档反映IIT Manager Agent模块的最新真实状态每个里程碑完成后必须更新 > **提示**本文档反映IIT Manager Agent模块的最新真实状态每个里程碑完成后必须更新
> **最后更新**2026-03-01 > **最后更新**2026-03-02
> **当前进度**V3.1 QC Engine 完成 | GCP 业务报表 4 张全量完成 | AI Timeline 增强 | 一键全量质控 | 多项 Bug 修复 | Phase 2: LLM 执行摘要待开发 > **当前进度**V3.2 数据一致性修复 | 项目隔离 | 管理端配置流重设计 | AI 规则自动生成 | 中文显示名 | 警告详情 Modal | 待部署验证
> **核心文档** > **核心文档**
> - [CRA Agent V3.0 开发计划](./04-开发计划/V3.0全新开发计划/V3.0全新开发计划.md) ⭐⭐⭐⭐⭐ > - [CRA Agent V3.0 开发计划](./04-开发计划/V3.0全新开发计划/V3.0全新开发计划.md) ⭐⭐⭐⭐⭐
> - [统一数字 CRA 质控平台 PRD](./04-开发计划/V3.0全新开发计划/统一数字%20CRA%20质控平台产品需求文档(PRD).md) ⭐⭐⭐⭐⭐ > - [统一数字 CRA 质控平台 PRD](./04-开发计划/V3.0全新开发计划/统一数字%20CRA%20质控平台产品需求文档(PRD).md) ⭐⭐⭐⭐⭐

View File

@@ -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) ### 后端变更 (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 微服务变更 ### 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 模块权限按需分配给对应租户和用户
--- ---
## 记录模板 ## 记录模板

View File

@@ -8,7 +8,6 @@ import { RouteGuard } from './framework/router'
import MainLayout from './framework/layout/MainLayout' import MainLayout from './framework/layout/MainLayout'
import AdminLayout from './framework/layout/AdminLayout' import AdminLayout from './framework/layout/AdminLayout'
import OrgLayout from './framework/layout/OrgLayout' import OrgLayout from './framework/layout/OrgLayout'
import HomePage from './pages/HomePage'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import AdminDashboard from './pages/admin/AdminDashboard' import AdminDashboard from './pages/admin/AdminDashboard'
import OrgDashboard from './pages/org/OrgDashboard' import OrgDashboard from './pages/org/OrgDashboard'
@@ -80,8 +79,8 @@ function App() {
{/* 业务应用端 /app/* */} {/* 业务应用端 /app/* */}
<Route path="/" element={<MainLayout />}> <Route path="/" element={<MainLayout />}>
{/* 首页 */} {/* 首页重定向到 AI 问答 */}
<Route index element={<HomePage />} /> <Route index element={<Navigate to="/ai-qa" replace />} />
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */} {/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
{MODULES.filter(m => !m.isExternal).map(module => ( {MODULES.filter(m => !m.isExternal).map(module => (

View File

@@ -62,9 +62,16 @@ apiClient.interceptors.response.use(
const hasRefreshToken = !!getRefreshToken(); const hasRefreshToken = !!getRefreshToken();
const alreadyRetried = originalRequest._retry; 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(); clearTokens();
if (isKicked) {
alert('您的账号已在其他设备登录,当前会话已失效,请重新登录');
}
window.location.href = '/login'; window.location.href = '/login';
} }
return Promise.reject(error); return Promise.reject(error);

View File

@@ -195,6 +195,24 @@ export async function testRule(
return response.data.data; return response.data.data;
} }
/** AI 规则建议(支持按维度生成) */
export async function suggestRules(
projectId: string,
dimension?: string
): Promise<import('../types/iitProject').RuleSuggestion[]> {
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<import('../types/iitProject').RuleSuggestion[]> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/generate-d3`);
return response.data.data;
}
// ==================== 用户映射 ==================== // ==================== 用户映射 ====================
/** 获取角色选项 */ /** 获取角色选项 */

View File

@@ -73,9 +73,6 @@ const ModulePermissionModal: React.FC<ModulePermissionModalProps> = ({
} }
}; };
// 可用模块(租户已订阅的)
const subscribedModules = moduleOptions.filter((m) => m.isSubscribed);
return ( return (
<Modal <Modal
title={`配置模块权限 - ${membership.tenantName}`} title={`配置模块权限 - ${membership.tenantName}`}
@@ -88,28 +85,35 @@ const ModulePermissionModal: React.FC<ModulePermissionModalProps> = ({
<Spin spinning={loading}> <Spin spinning={loading}>
<Alert <Alert
message="模块权限说明" message="模块权限说明"
description="选择用户在该租户内可以访问的模块。取消所有选择将默认继承租户的全部模块权限。" description="选择用户在该租户内可以访问的模块。取消所有选择将默认继承租户的全部模块权限。灰色模块表示租户尚未订阅。"
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
{subscribedModules.length > 0 ? ( {moduleOptions.length > 0 ? (
<Checkbox.Group <Checkbox.Group
value={selectedModules} value={selectedModules}
onChange={(values) => setSelectedModules(values as string[])} onChange={(values) => setSelectedModules(values as string[])}
style={{ width: '100%' }} style={{ width: '100%' }}
> >
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{subscribedModules.map((module) => ( {moduleOptions.map((module) => (
<Col span={12} key={module.code}> <Col span={12} key={module.code}>
<Checkbox value={module.code}>{module.name}</Checkbox> <Checkbox value={module.code}>
{module.name}
{!module.isSubscribed && (
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>
()
</Text>
)}
</Checkbox>
</Col> </Col>
))} ))}
</Row> </Row>
</Checkbox.Group> </Checkbox.Group>
) : ( ) : (
<Text type="secondary"></Text> <Text type="secondary"></Text>
)} )}
</Spin> </Spin>
</Modal> </Modal>

View File

@@ -27,6 +27,7 @@ import {
Empty, Empty,
Tooltip, Tooltip,
Badge, Badge,
Collapse,
} from 'antd'; } from 'antd';
import { import {
ArrowLeftOutlined, ArrowLeftOutlined,
@@ -42,6 +43,8 @@ import {
BarChartOutlined, BarChartOutlined,
DashboardOutlined, DashboardOutlined,
ClockCircleOutlined, ClockCircleOutlined,
BulbOutlined,
RobotOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi'; import * as iitProjectApi from '../api/iitProjectApi';
import type { import type {
@@ -49,6 +52,7 @@ import type {
UpdateProjectRequest, UpdateProjectRequest,
QCRule, QCRule,
CreateRuleRequest, CreateRuleRequest,
RuleSuggestion,
IitUserMapping, IitUserMapping,
CreateUserMappingRequest, CreateUserMappingRequest,
RoleOption, RoleOption,
@@ -65,13 +69,27 @@ const SEVERITY_MAP = {
info: { color: 'processing', text: '信息' }, info: { color: 'processing', text: '信息' },
}; };
const CATEGORY_MAP = { const CATEGORY_MAP: Record<string, { color: string; text: string }> = {
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: '纳入标准' }, inclusion: { color: '#52c41a', text: '纳入标准' },
exclusion: { color: '#ff4d4f', text: '排除标准' }, exclusion: { color: '#ff4d4f', text: '排除标准' },
lab_values: { color: '#1890ff', text: '变量范围' }, lab_values: { color: '#1890ff', text: '变量范围' },
logic_check: { color: '#722ed1', text: '逻辑检查' }, logic_check: { color: '#722ed1', text: '逻辑检查' },
}; };
const AI_BUILD_DIMENSIONS = [
{ key: 'D3', label: 'D3 准确性规则', description: '基于变量定义自动生成范围/枚举检查', icon: <BarChartOutlined />, needsKb: false, isD3: true },
{ key: 'D1', label: 'D1 入选/排除规则', description: 'AI 基于知识库生成纳入排除标准检查', icon: <CheckCircleOutlined />, needsKb: true, isD3: false },
{ key: 'D5', label: 'D5 安全性规则', description: 'AI 生成 AE/SAE 报告时限和完整性检查', icon: <ExclamationCircleOutlined />, needsKb: true, isD3: false },
{ key: 'D6', label: 'D6 方案偏离规则', description: 'AI 生成访视窗口期和用药合规性检查', icon: <BookOutlined />, needsKb: true, isD3: false },
];
// ==================== 主组件 ==================== // ==================== 主组件 ====================
const IitProjectDetailPage: React.FC = () => { const IitProjectDetailPage: React.FC = () => {
@@ -143,31 +161,34 @@ const IitProjectDetailPage: React.FC = () => {
return <Empty description="项目不存在" />; return <Empty description="项目不存在" />;
} }
const hasFields = !!project.lastSyncAt;
const hasKb = !!(project.knowledgeBaseId || project.knowledgeBase?.id);
const tabItems = [ const tabItems = [
{ {
key: 'basic', key: 'basic',
label: 'REDCap 配置', label: 'REDCap 配置',
children: <BasicConfigTab project={project} onUpdate={loadProject} />, children: <BasicConfigTab project={project} onUpdate={loadProject} />,
}, },
{ {
key: 'rules', key: 'fields',
label: '质控规则', label: '② 变量清单',
children: <QCRulesTab projectId={project.id} />, children: <FieldMetadataTab projectId={project.id} project={project} onUpdate={loadProject} />,
},
{
key: 'members',
label: '项目成员',
children: <UserMappingTab projectId={project.id} />,
}, },
{ {
key: 'kb', key: 'kb',
label: '知识库', label: '知识库',
children: <KnowledgeBaseTab project={project} onUpdate={loadProject} />, children: <KnowledgeBaseTab project={project} onUpdate={loadProject} hasFields={hasFields} />,
}, },
{ {
key: 'fields', key: 'rules',
label: '变量清单', label: `④ 质控规则`,
children: <FieldMetadataTab projectId={project.id} />, children: <QCRulesTab projectId={project.id} project={project} hasFields={hasFields} hasKb={hasKb} />,
},
{
key: 'members',
label: '⑤ 项目成员',
children: <UserMappingTab projectId={project.id} />,
}, },
]; ];
@@ -238,7 +259,6 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
const [form] = Form.useForm(); const [form] = Form.useForm();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [syncing, setSyncing] = useState(false);
useEffect(() => { useEffect(() => {
form.setFieldsValue({ form.setFieldsValue({
@@ -281,18 +301,6 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ 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 ( return (
<div> <div>
<Form form={form} layout="vertical" onFinish={handleSave} style={{ maxWidth: 600 }}> <Form form={form} layout="vertical" onFinish={handleSave} style={{ maxWidth: 600 }}>
@@ -394,9 +402,6 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
<Button icon={<CheckCircleOutlined />} onClick={handleTestConnection} loading={testing}> <Button icon={<CheckCircleOutlined />} onClick={handleTestConnection} loading={testing}>
</Button> </Button>
<Button icon={<SyncOutlined />} onClick={handleSyncMetadata} loading={syncing}>
</Button>
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
@@ -417,19 +422,28 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
); );
}; };
// ==================== Tab 2: 质控规则 ==================== // ==================== Tab 4: 质控规则 ====================
interface QCRulesTabProps { interface QCRulesTabProps {
projectId: string; projectId: string;
project: IitProject;
hasFields: boolean;
hasKb: boolean;
} }
const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => { const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId, project: _project, hasFields, hasKb }) => {
const [rules, setRules] = useState<QCRule[]>([]); const [rules, setRules] = useState<QCRule[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<QCRule | null>(null); const [editingRule, setEditingRule] = useState<QCRule | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [aiLoading, setAiLoading] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<RuleSuggestion[]>([]);
const [selectedSuggestionKeys, setSelectedSuggestionKeys] = useState<React.Key[]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [importing, setImporting] = useState(false);
const loadRules = useCallback(async () => { const loadRules = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -483,7 +497,7 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
logic: JSON.parse(logicValue), logic: JSON.parse(logicValue),
message: values.message as string, message: values.message as string,
severity: values.severity as 'error' | 'warning' | 'info', 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) { if (editingRule) {
@@ -501,22 +515,73 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ 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: '规则名称', title: '规则名称',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 200, width: 220,
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 100,
render: (category: keyof typeof CATEGORY_MAP) => {
const cat = CATEGORY_MAP[category];
return <Tag color={cat?.color}>{cat?.text || category}</Tag>;
},
}, },
{ {
title: '严重程度', title: '严重程度',
@@ -560,29 +625,149 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
}, },
]; ];
const DIMENSION_ORDER = ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7'];
const groupedRules = React.useMemo(() => {
const groups: Record<string, QCRule[]> = {};
for (const rule of rules) {
const cat = rule.category || 'other';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(rule);
}
const sorted: Record<string, QCRule[]> = {};
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 <Tag color={cat?.color}>{cat?.text || c}</Tag>;
},
},
{
title: '严重程度',
dataIndex: 'severity',
key: 'severity',
width: 100,
render: (s: string) => {
const sev = SEVERITY_MAP[s as keyof typeof SEVERITY_MAP];
return <Tag color={sev?.color}>{sev?.text || s}</Tag>;
},
},
{
title: '字段',
dataIndex: 'field',
key: 'field',
width: 150,
render: (f: string | string[]) => <Text code>{Array.isArray(f) ? f.join(', ') : f}</Text>,
},
{ title: '提示信息', dataIndex: 'message', key: 'message', ellipsis: true },
];
if (!hasFields) {
return (
<Alert
message="请先完成变量清单同步"
description="质控规则依赖变量清单数据。请先在「② 变量清单」Tab 中从 REDCap 同步元数据。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
);
}
return ( return (
<div> <div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}> {/* AI 自动构建规则区域 */}
<Alert <Card
message="质控规则使用 JSON Logic 格式定义,用于数据质量检查" size="small"
type="info" title={<Space><RobotOutlined /> AI </Space>}
showIcon style={{ marginBottom: 16, border: '1px solid #d9d9d9', background: '#fafafa' }}
style={{ flex: 1, marginRight: 16 }} >
/> <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{AI_BUILD_DIMENSIONS.map((dim) => {
const disabled = dim.needsKb && !hasKb;
return (
<Tooltip
key={dim.key}
title={disabled ? '需要先创建知识库并上传文档' : dim.description}
>
<Button
icon={dim.icon}
loading={aiLoading === dim.key}
disabled={disabled || !!aiLoading}
onClick={() => handleAiBuild(dim)}
style={{ height: 'auto', padding: '8px 16px' }}
>
<div style={{ textAlign: 'left' }}>
<div style={{ fontWeight: 500 }}>{dim.label}</div>
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
{dim.isD3 ? '数据驱动(无需 LLM' : 'AI 生成(需要知识库)'}
</div>
</div>
</Button>
</Tooltip>
);
})}
</div>
{!hasKb && (
<Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
D3 D1/D5/D6
</Text>
)}
</Card>
{/* 规则列表(按维度分组) */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong> ({rules.length})</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}> <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button> </Button>
</div> </div>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : rules.length === 0 ? (
<Empty description="暂无规则,请使用上方 AI 构建或手动添加" />
) : (
<Collapse
defaultActiveKey={Object.keys(groupedRules)}
items={Object.entries(groupedRules).map(([cat, catRules]) => {
const catMeta = CATEGORY_MAP[cat];
return {
key: cat,
label: (
<Space>
<Tag color={catMeta?.color}>{catMeta?.text || cat}</Tag>
<Text type="secondary">{catRules.length} </Text>
</Space>
),
children: (
<Table <Table
columns={columns} columns={ruleColumns}
dataSource={rules} dataSource={catRules}
rowKey="id" rowKey="id"
loading={loading}
pagination={false} pagination={false}
size="small" size="small"
/> />
),
};
})}
/>
)}
{/* 手动编辑规则 Modal */}
<Modal <Modal
title={editingRule ? '编辑规则' : '添加规则'} title={editingRule ? '编辑规则' : '添加规则'}
open={modalOpen} open={modalOpen}
@@ -597,10 +782,15 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
<Form.Item name="category" label="分类" rules={[{ required: true }]}> <Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select <Select
options={Object.entries(CATEGORY_MAP).map(([value, { text }]) => ({ options={[
value, { value: 'D1', label: 'D1 入选/排除' },
label: text, { value: 'D2', label: 'D2 完整性' },
}))} { value: 'D3', label: 'D3 准确性' },
{ value: 'D4', label: 'D4 质疑管理' },
{ value: 'D5', label: 'D5 安全性' },
{ value: 'D6', label: 'D6 方案偏离' },
{ value: 'D7', label: 'D7 药物管理' },
]}
/> />
</Form.Item> </Form.Item>
@@ -660,6 +850,55 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
{/* AI 规则预览 + 批量导入 Modal */}
<Modal
title={
<Space>
<BulbOutlined style={{ color: '#faad14' }} />
AI
</Space>
}
open={previewOpen}
onCancel={() => { setPreviewOpen(false); setSuggestions([]); setSelectedSuggestionKeys([]); }}
width={900}
footer={
<Space>
<Text type="secondary"> {selectedSuggestionKeys.length} / {suggestions.length} </Text>
<Button onClick={() => { setPreviewOpen(false); setSuggestions([]); setSelectedSuggestionKeys([]); }}>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
loading={importing}
disabled={selectedSuggestionKeys.length === 0}
onClick={handleImportSelected}
>
</Button>
</Space>
}
>
<Alert
message="以下规则由 AI 自动生成,请检查后勾选需要导入的规则。导入后可随时编辑或删除。"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Table
columns={previewColumns}
dataSource={suggestions.map((s, i) => ({ ...s, _key: i }))}
rowKey="_key"
size="small"
pagination={false}
scroll={{ y: 400 }}
rowSelection={{
selectedRowKeys: selectedSuggestionKeys,
onChange: (keys) => setSelectedSuggestionKeys(keys),
}}
/>
</Modal>
</div> </div>
); );
}; };
@@ -927,9 +1166,10 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
interface KnowledgeBaseTabProps { interface KnowledgeBaseTabProps {
project: IitProject; project: IitProject;
onUpdate: () => void; onUpdate: () => void;
hasFields: boolean;
} }
const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }) => { const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate, hasFields }) => {
const [documents, setDocuments] = useState<any[]>([]); const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@@ -1018,6 +1258,18 @@ const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }
} }
}; };
if (!hasFields) {
return (
<Alert
message="请先完成变量清单同步"
description="建议在「② 变量清单」Tab 中先从 REDCap 同步变量元数据,然后再创建知识库。知识库中的研究方案等文档将与变量数据配合,帮助 AI 生成更精准的质控规则。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
);
}
if (!kbId) { if (!kbId) {
return ( return (
<div> <div>
@@ -1150,20 +1402,25 @@ const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }
); );
}; };
// ==================== Tab 5: 变量清单 ==================== // ==================== Tab 2: 变量清单 ====================
interface FieldMetadataTabProps { interface FieldMetadataTabProps {
projectId: string; projectId: string;
project: IitProject;
onUpdate: () => void;
} }
const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => { const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId, project, onUpdate }) => {
const [fields, setFields] = useState<any[]>([]); const [fields, setFields] = useState<any[]>([]);
const [forms, setForms] = useState<string[]>([]); const [forms, setForms] = useState<string[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [formFilter, setFormFilter] = useState<string | undefined>(undefined); const [formFilter, setFormFilter] = useState<string | undefined>(undefined);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const hasRedcapConfig = !!(project.redcapUrl && project.redcapApiToken);
const fetchFields = useCallback(async () => { const fetchFields = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -1184,6 +1441,32 @@ const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
fetchFields(); fetchFields();
}, [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 (
<Alert
message="请先完成 REDCap 配置"
description="变量清单需要从 REDCap 同步获取。请先在「① REDCap 配置」Tab 中填写 REDCap URL 和 API Token 并保存。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
);
}
const columns = [ const columns = [
{ {
title: '变量名', title: '变量名',
@@ -1229,7 +1512,29 @@ const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
return ( return (
<div> <div>
<Space style={{ marginBottom: 16 }}> {total === 0 && !loading ? (
<Alert
message="尚未同步变量清单"
description="点击下方按钮从 REDCap 同步所有数据变量定义。这是后续配置知识库和质控规则的基础。"
type="info"
showIcon
icon={<SyncOutlined />}
style={{ marginBottom: 16 }}
action={
<Button
type="primary"
size="large"
icon={<SyncOutlined spin={syncing} />}
onClick={handleSyncMetadata}
loading={syncing}
>
REDCap
</Button>
}
/>
) : (
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Select <Select
allowClear allowClear
placeholder="按表单筛选" placeholder="按表单筛选"
@@ -1246,6 +1551,12 @@ const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
/> />
<Text type="secondary"> {total} </Text> <Text type="secondary"> {total} </Text>
</Space> </Space>
<Button icon={<SyncOutlined />} onClick={handleSyncMetadata} loading={syncing}>
</Button>
</div>
)}
{total > 0 && (
<Table <Table
columns={columns} columns={columns}
dataSource={fields} dataSource={fields}
@@ -1255,6 +1566,7 @@ const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
pagination={{ pageSize: 50, showSizeChanger: true, showTotal: t => `${t}` }} pagination={{ pageSize: 50, showSizeChanger: true, showTotal: t => `${t}` }}
scroll={{ y: 500 }} scroll={{ y: 500 }}
/> />
)}
</div> </div>
); );
}; };

View File

@@ -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 { export interface QCRule {
id: string; id: string;
name: string; name: string;
@@ -83,7 +89,7 @@ export interface QCRule {
logic: Record<string, unknown>; logic: Record<string, unknown>;
message: string; message: string;
severity: 'error' | 'warning' | 'info'; severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'; category: RuleCategory;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
@@ -93,10 +99,20 @@ export interface CreateRuleRequest {
logic: Record<string, unknown>; logic: Record<string, unknown>;
message: string; message: string;
severity: 'error' | 'warning' | 'info'; severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'; category: RuleCategory;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
export interface RuleSuggestion {
name: string;
field: string | string[];
logic: Record<string, unknown>;
message: string;
severity: 'error' | 'warning' | 'info';
category: string;
applicableEvents?: string[];
}
export interface RuleStats { export interface RuleStats {
total: number; total: number;
byCategory: Record<string, number>; byCategory: Record<string, number>;

View File

@@ -8,11 +8,12 @@
* - 5个阶段12个智能体卡片 * - 5个阶段12个智能体卡片
*/ */
import React, { useState, useMemo } from 'react'; import React, { useMemo } from 'react';
import { BrainCircuit, Search } from 'lucide-react'; import { BrainCircuit } from 'lucide-react';
import { AgentCard } from './AgentCard'; import { AgentCard } from './AgentCard';
import { AGENTS, PHASES } from '../constants'; import { AGENTS, PHASES } from '../constants';
import type { AgentConfig } from '../types'; import type { AgentConfig } from '../types';
import { useAuth } from '@/framework/auth';
import '../styles/agent-hub.css'; import '../styles/agent-hub.css';
interface AgentHubProps { interface AgentHubProps {
@@ -20,40 +21,32 @@ interface AgentHubProps {
} }
export const AgentHub: React.FC<AgentHubProps> = ({ onAgentSelect }) => { export const AgentHub: React.FC<AgentHubProps> = ({ 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 agentsByPhase = useMemo(() => {
const grouped: Record<number, AgentConfig[]> = {}; const grouped: Record<number, AgentConfig[]> = {};
AGENTS.forEach(agent => { const visibleAgents = showProtocol ? AGENTS : AGENTS.filter(a => !a.isProtocolAgent);
visibleAgents.forEach(agent => {
if (!grouped[agent.phase]) { if (!grouped[agent.phase]) {
grouped[agent.phase] = []; grouped[agent.phase] = [];
} }
grouped[agent.phase].push(agent); grouped[agent.phase].push(agent);
}); });
return grouped; return grouped;
}, []); }, [showProtocol]);
// 搜索提交
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();
}
};
return ( return (
<div className="agent-hub"> <div className="agent-hub">
{/* 主体内容 */} {/* 主体内容 */}
<main className="hub-main"> <main className="hub-main">
{/* 头部搜索区 */} {/* 头部标题区 */}
<div className="hub-header"> <div className="hub-header">
<div className="header-title"> <div className="header-title">
<div className="title-icon"> <div className="title-icon">
@@ -61,29 +54,14 @@ export const AgentHub: React.FC<AgentHubProps> = ({ onAgentSelect }) => {
</div> </div>
<h1 className="title-text"> <h1 className="title-text">
<span className="title-badge">DeepSeek</span>
</h1> </h1>
</div> </div>
<div className="search-box">
<input
type="text"
placeholder="输入研究想法例如SGLT2抑制剂对心衰患者预后的影响..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleKeyDown}
className="search-input"
/>
<button className="search-btn" onClick={handleSearch}>
<Search size={20} />
</button>
</div>
</div> </div>
{/* 流水线模块 */} {/* 流水线模块 */}
<div className="pipeline-container"> <div className="pipeline-container">
{PHASES.map((phase, phaseIndex) => { {visiblePhases.map((phase, phaseIndex) => {
const isLast = phaseIndex === PHASES.length - 1; const isLast = phaseIndex === visiblePhases.length - 1;
const agents = agentsByPhase[phase.phase] || []; const agents = agentsByPhase[phase.phase] || [];
// Protocol Agent 阶段特殊处理phase 0单独显示1个卡片 // Protocol Agent 阶段特殊处理phase 0单独显示1个卡片

View File

@@ -127,7 +127,7 @@ export const AGENTS: AgentConfig[] = [
phase: 4, phase: 4,
order: 9, order: 9,
isTool: true, isTool: true,
toolUrl: '/dc', toolUrl: '/research-management',
}, },
{ {
id: 'TOOL_10', id: 'TOOL_10',
@@ -138,7 +138,7 @@ export const AGENTS: AgentConfig[] = [
phase: 4, phase: 4,
order: 10, order: 10,
isTool: true, isTool: true,
toolUrl: '/dc/analysis', toolUrl: '/research-management',
}, },
// Phase 5: 写作助手 (2个) // Phase 5: 写作助手 (2个)

View File

@@ -8,9 +8,7 @@
import { Layout, Menu } from 'antd'; import { Layout, Menu } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { import {
FileTextOutlined,
SearchOutlined, SearchOutlined,
FolderOpenOutlined,
FilterOutlined, FilterOutlined,
FileSearchOutlined, FileSearchOutlined,
DatabaseOutlined, DatabaseOutlined,
@@ -32,29 +30,15 @@ const ASLLayout = () => {
// 菜单项配置 // 菜单项配置
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{
key: 'research-plan',
icon: <FileTextOutlined />,
label: '1. 研究方案生成',
disabled: true,
title: '敬请期待'
},
{ {
key: '/literature/research/deep', key: '/literature/research/deep',
icon: <SearchOutlined />, icon: <SearchOutlined />,
label: '2. 智能文献检索', label: '1. 智能文献检索',
},
{
key: 'literature-management',
icon: <FolderOpenOutlined />,
label: '3. 文献管理',
disabled: true,
title: '敬请期待'
}, },
{ {
key: 'title-screening', key: 'title-screening',
icon: <FilterOutlined />, icon: <FilterOutlined />,
label: '4. 标题摘要初筛', label: '2. 标题摘要初筛',
children: [ children: [
{ {
key: '/literature/screening/title/settings', key: '/literature/screening/title/settings',
@@ -76,7 +60,7 @@ const ASLLayout = () => {
{ {
key: 'fulltext-screening', key: 'fulltext-screening',
icon: <FileSearchOutlined />, icon: <FileSearchOutlined />,
label: '5. 全文复筛', label: '3. 全文复筛',
children: [ children: [
{ {
key: '/literature/screening/fulltext/settings', key: '/literature/screening/fulltext/settings',
@@ -98,7 +82,7 @@ const ASLLayout = () => {
{ {
key: 'extraction', key: 'extraction',
icon: <DatabaseOutlined />, icon: <DatabaseOutlined />,
label: '6. 全文智能提取', label: '4. 全文智能提取',
children: [ children: [
{ {
key: '/literature/extraction/setup', key: '/literature/extraction/setup',
@@ -115,12 +99,12 @@ const ASLLayout = () => {
{ {
key: '/literature/charting', key: '/literature/charting',
icon: <ApartmentOutlined />, icon: <ApartmentOutlined />,
label: '7. SR 图表生成器', label: '5. SR 图表生成器',
}, },
{ {
key: '/literature/meta-analysis', key: '/literature/meta-analysis',
icon: <BarChartOutlined />, icon: <BarChartOutlined />,
label: '8. Meta 分析引擎', label: '6. Meta 分析引擎',
}, },
]; ];

View File

@@ -6,16 +6,13 @@
*/ */
import { useState, useEffect } from 'react'; 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 { import {
ArrowLeftOutlined, ArrowLeftOutlined,
ThunderboltOutlined, ThunderboltOutlined,
GlobalOutlined,
EditOutlined, EditOutlined,
CheckCircleOutlined, CheckCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { aslApi } from '../../api';
import type { DataSourceConfig } from '../../types/deepResearch';
const { Text } = Typography; const { Text } = Typography;
@@ -43,24 +40,11 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
initialQuery, onSubmit, onBack, loading, collapsed, onExpand, initialQuery, onSubmit, onBack, loading, collapsed, onExpand,
}) => { }) => {
const [query, setQuery] = useState(initialQuery); const [query, setQuery] = useState(initialQuery);
const [dataSources, setDataSources] = useState<DataSourceConfig[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [yearRange, setYearRange] = useState<string>('近5年');
const [targetCount, setTargetCount] = useState<string>('全面检索');
const [loadingSources, setLoadingSources] = useState(true);
const [loadingTextIdx, setLoadingTextIdx] = useState(0); const [loadingTextIdx, setLoadingTextIdx] = useState(0);
useEffect(() => { // 默认使用 PubMed + 近5年 + 全面检索(数据源/年限/篇数 UI 暂时隐藏)
aslApi.getDeepResearchDataSources().then(res => { const yearRange = '近5年';
const sources = res.data || []; const targetCount = '全面检索';
setDataSources(sources);
setSelectedIds(sources.filter((s: DataSourceConfig) => s.defaultChecked).map((s: DataSourceConfig) => s.id));
}).catch(() => {
setDataSources([]);
}).finally(() => {
setLoadingSources(false);
});
}, []);
useEffect(() => { useEffect(() => {
if (!loading) { setLoadingTextIdx(0); return; } if (!loading) { setLoadingTextIdx(0); return; }
@@ -70,21 +54,10 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
return () => clearInterval(timer); return () => clearInterval(timer);
}, [loading]); }, [loading]);
const handleToggle = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
const handleSubmit = () => { const handleSubmit = () => {
const domains = dataSources onSubmit(query, ['PubMed'], { yearRange, targetCount });
.filter(s => selectedIds.includes(s.id))
.map(s => s.domainScope);
onSubmit(query, domains, { yearRange, targetCount });
}; };
const selectedNames = dataSources.filter(s => selectedIds.includes(s.id)).map(s => s.label);
if (collapsed) { if (collapsed) {
return ( return (
<Card size="small" className="!bg-white"> <Card size="small" className="!bg-white">
@@ -94,9 +67,7 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
<div className="min-w-0"> <div className="min-w-0">
<Text strong className="block truncate">{query}</Text> <Text strong className="block truncate">{query}</Text>
<div className="flex items-center gap-2 mt-1 flex-wrap"> <div className="flex items-center gap-2 mt-1 flex-wrap">
{selectedNames.map(name => ( <Tag className="!text-xs !m-0">PubMed</Tag>
<Tag key={name} className="!text-xs !m-0">{name}</Tag>
))}
<Text type="secondary" className="text-xs">{yearRange} · {targetCount}</Text> <Text type="secondary" className="text-xs">{yearRange} · {targetCount}</Text>
</div> </div>
</div> </div>
@@ -111,9 +82,6 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
); );
} }
const englishSources = dataSources.filter(s => s.category === 'english');
const chineseSources = dataSources.filter(s => s.category === 'chinese');
return ( return (
<div> <div>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -133,77 +101,7 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
/> />
</Card> </Card>
<Card className="mb-4" size="small" title={<><GlobalOutlined className="mr-2" /></>}> {/* 数据源/年限/篇数暂时隐藏,默认 PubMed + 近5年 + 全面检索 */}
{loadingSources ? (
<Spin size="small" />
) : (
<>
<Text type="secondary" className="block mb-3 text-xs"></Text>
<div className="flex flex-col gap-2 mb-4">
{englishSources.map(ds => (
<Checkbox
key={ds.id}
checked={selectedIds.includes(ds.id)}
onChange={() => handleToggle(ds.id)}
>
<span className="font-medium">{ds.label}</span>
<span className="text-gray-400 text-xs ml-2">{ds.domainScope}</span>
</Checkbox>
))}
</div>
<Divider className="!my-3" />
<Text type="secondary" className="block mb-3 text-xs"></Text>
<div className="flex flex-col gap-2">
{chineseSources.map(ds => (
<Checkbox
key={ds.id}
checked={selectedIds.includes(ds.id)}
onChange={() => handleToggle(ds.id)}
>
<span className="font-medium">{ds.label}</span>
<span className="text-gray-400 text-xs ml-2">{ds.domainScope}</span>
</Checkbox>
))}
</div>
</>
)}
</Card>
<Card className="mb-6" size="small" title="高级筛选">
<div className="flex gap-4">
<div className="flex-1">
<Text type="secondary" className="block mb-1 text-xs"></Text>
<Select
value={yearRange}
onChange={setYearRange}
className="w-full"
options={[
{ value: '不限', label: '不限' },
{ value: '近1年', label: '近1年' },
{ value: '近3年', label: '近3年' },
{ value: '近5年', label: '近5年' },
{ value: '近10年', label: '近10年' },
]}
/>
</div>
<div className="flex-1">
<Text type="secondary" className="block mb-1 text-xs"></Text>
<Select
value={targetCount}
onChange={setTargetCount}
className="w-full"
options={[
{ value: '全面检索', label: '全面检索' },
{ value: '约20篇', label: '约20篇' },
{ value: '约50篇', label: '约50篇' },
{ value: '约100篇', label: '约100篇' },
]}
/>
</div>
</div>
</Card>
<Button <Button
type="primary" type="primary"
@@ -212,7 +110,7 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
block block
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={!query.trim() || selectedIds.length === 0} disabled={!query.trim()}
> >
{loading ? LOADING_TEXTS[loadingTextIdx] : '生成检索需求书'} {loading ? LOADING_TEXTS[loadingTextIdx] : '生成检索需求书'}
</Button> </Button>

View File

@@ -47,7 +47,7 @@ const ASLModule = () => {
> >
<Routes> <Routes>
<Route path="" element={<ASLLayout />}> <Route path="" element={<ASLLayout />}>
<Route index element={<Navigate to="screening/title/settings" replace />} /> <Route index element={<Navigate to="research/deep" replace />} />
{/* 智能文献检索 V1.x保留兼容 */} {/* 智能文献检索 V1.x保留兼容 */}
<Route path="research/search" element={<ResearchSearch />} /> <Route path="research/search" element={<ResearchSearch />} />

View File

@@ -423,30 +423,32 @@ export async function getQcRecordDetail(
// ==================== AI 时间线 ==================== // ==================== 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 { export interface TimelineItem {
id: string; id: string;
type: 'qc_check'; type: 'qc_check';
time: string; time: string;
recordId: string; recordId: string;
eventLabel?: string;
formName?: string;
status: string; status: string;
triggeredBy: string; triggeredBy: string;
description: string; description: string;
details: { details: {
rulesEvaluated: number;
rulesPassed: number;
rulesFailed: number;
issuesSummary: { red: number; yellow: number }; issuesSummary: { red: number; yellow: number };
issues?: Array<{ issues: TimelineIssue[];
ruleId: string;
ruleName: string;
field?: string;
message: string;
severity: string;
actualValue?: string;
expectedValue?: string;
}>;
}; };
} }
@@ -459,6 +461,49 @@ export async function getTimeline(
return response.data.data; 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<string, number>;
byDimension: Record<string, number>;
}
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<FieldIssuesResponse> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/field-issues`, { params });
return response.data.data;
}
// ==================== 重大事件 ==================== // ==================== 重大事件 ====================
export interface CriticalEvent { export interface CriticalEvent {

View File

@@ -1,8 +1,8 @@
/** /**
* AI 实时工作流水页 (Level 2) * AI 实时工作流水页 (Level 2)
* *
* 以 Timeline 展示 Agent 每次质控的完整动作链AI 白盒化 * 以 Timeline 展示 Agent 质控结果,数据来源: qc_field_status (SSOT)
* 显示中文事件名、实际规则数、五层定位详情、最终判定状态 * 按受试者分组展示问题详情,支持按维度分组查看
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
@@ -28,16 +28,20 @@ import {
SyncOutlined, SyncOutlined,
ClockCircleOutlined, ClockCircleOutlined,
RobotOutlined, RobotOutlined,
ApiOutlined,
BellOutlined, BellOutlined,
FileSearchOutlined, FileSearchOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi'; import * as iitProjectApi from '../api/iitProjectApi';
import type { TimelineItem } from '../api/iitProjectApi'; import type { TimelineItem, TimelineIssue } from '../api/iitProjectApi';
import { useIitProject } from '../context/IitProjectContext'; import { useIitProject } from '../context/IitProjectContext';
const { Text } = Typography; const { Text } = Typography;
const DIMENSION_LABELS: Record<string, string> = {
D1: '入选/排除', D2: '完整性', D3: '准确性', D4: '质疑管理',
D5: '安全性', D6: '方案偏离', D7: '药物管理',
};
const STATUS_DOT: Record<string, { color: string; icon: React.ReactNode; label: string }> = { const STATUS_DOT: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
PASS: { color: 'green', icon: <CheckCircleOutlined />, label: '通过' }, PASS: { color: 'green', icon: <CheckCircleOutlined />, label: '通过' },
FAIL: { color: 'red', icon: <CloseCircleOutlined />, label: '严重' }, FAIL: { color: 'red', icon: <CloseCircleOutlined />, label: '严重' },
@@ -66,7 +70,7 @@ const AiStreamPage: React.FC = () => {
try { try {
const result = await iitProjectApi.getTimeline(projectId, { const result = await iitProjectApi.getTimeline(projectId, {
page, page,
pageSize: 30, pageSize: 20,
date: dateFilter, date: dateFilter,
}); });
setItems(result.items); setItems(result.items);
@@ -85,29 +89,40 @@ const AiStreamPage: React.FC = () => {
fetchData(); fetchData();
}; };
const timelineItems = items.map((item) => {
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined />, label: '未知' };
const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy };
const { red, yellow } = item.details.issuesSummary;
const time = new Date(item.time);
const timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateStr = time.toLocaleDateString('zh-CN');
const eventLabel = item.eventLabel || '';
const issues = item.details.issues || [];
const issueColumns = [ const issueColumns = [
{
title: '维度',
dataIndex: 'ruleCategory',
width: 90,
render: (v: string) => {
const label = DIMENSION_LABELS[v] || v;
return <Tag color="blue">{v ? `${v} ${label}` : '—'}</Tag>;
},
},
{ {
title: '规则', title: '规则',
dataIndex: 'ruleName', dataIndex: 'ruleName',
width: 160, width: 160,
render: (v: string, r: any) => ( render: (v: string, r: TimelineIssue) => <Text>{v || r.ruleId || '—'}</Text>,
<Space size={4}> },
<Text>{v || r.ruleId}</Text> {
</Space> title: '字段',
), dataIndex: 'field',
width: 140,
render: (v: string, r: TimelineIssue) => {
const label = r.fieldLabel || v;
return label ? <Text>{label}</Text> : '—';
},
},
{
title: '事件',
dataIndex: 'eventId',
width: 140,
render: (v: string, r: TimelineIssue) => {
const label = r.eventLabel || v;
return label || '—';
},
}, },
{ title: '字段', dataIndex: 'field', width: 110, render: (v: string) => v ? <Text code>{v}</Text> : '—' },
{ title: '问题描述', dataIndex: 'message', ellipsis: true }, { title: '问题描述', dataIndex: 'message', ellipsis: true },
{ {
title: '严重度', title: '严重度',
@@ -127,8 +142,26 @@ const AiStreamPage: React.FC = () => {
}, },
]; ];
const timelineItems = items.map((item) => {
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined />, label: '未知' };
const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy };
const { red, yellow } = item.details.issuesSummary;
const time = new Date(item.time);
const timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateStr = time.toLocaleDateString('zh-CN');
const issues = item.details.issues || [];
// 按维度分组
const groupedByDimension = issues.reduce<Record<string, TimelineIssue[]>>((acc, iss) => {
const key = iss.ruleCategory || '其他';
if (!acc[key]) acc[key] = [];
acc[key].push(iss);
return acc;
}, {});
return { return {
color: dotCfg.color as any, color: dotCfg.color as string,
dot: dotCfg.icon, dot: dotCfg.icon,
children: ( children: (
<div style={{ paddingBottom: 8 }}> <div style={{ paddingBottom: 8 }}>
@@ -153,29 +186,20 @@ const AiStreamPage: React.FC = () => {
}}> }}>
<Space wrap size={4} style={{ marginBottom: 4 }}> <Space wrap size={4} style={{ marginBottom: 4 }}>
<RobotOutlined style={{ color: '#3b82f6' }} /> <RobotOutlined style={{ color: '#3b82f6' }} />
<Text> <Text code>{item.recordId}</Text></Text> <Text> <Text code>{item.recordId}</Text></Text>
{eventLabel && <Tag color="geekblue">{eventLabel}</Tag>}
</Space> </Space>
<div style={{ marginLeft: 20 }}>
<Space size={4}>
<ApiOutlined style={{ color: '#8b5cf6' }} />
<Text> <Text strong>{item.details.rulesEvaluated}</Text> </Text>
<Text type="success"> {item.details.rulesPassed} </Text>
{item.details.rulesFailed > 0 && (
<Text type="danger">/ {item.details.rulesFailed} </Text>
)}
</Space>
</div>
{(red > 0 || yellow > 0) && ( {(red > 0 || yellow > 0) && (
<div style={{ marginLeft: 20, marginTop: 2 }}> <div style={{ marginLeft: 20, marginTop: 2 }}>
<Space size={4}> <Space size={8}>
<BellOutlined style={{ color: red > 0 ? '#ef4444' : '#f59e0b' }} /> <BellOutlined style={{ color: red > 0 ? '#ef4444' : '#f59e0b' }} />
{red > 0 && <Badge count={red} style={{ backgroundColor: '#ef4444' }} />} {red > 0 && <><Badge count={red} style={{ backgroundColor: '#ef4444' }} /><Text type="danger"></Text></>}
{red > 0 && <Text type="danger"></Text>} {yellow > 0 && <><Badge count={yellow} style={{ backgroundColor: '#f59e0b' }} /><Text style={{ color: '#d97706' }}></Text></>}
{yellow > 0 && <Badge count={yellow} style={{ backgroundColor: '#f59e0b' }} />} {Object.entries(groupedByDimension).map(([dim, dimIssues]) => (
{yellow > 0 && <Text style={{ color: '#d97706' }}></Text>} <Tag key={dim} color="processing" style={{ fontSize: 10 }}>
{dim} {DIMENSION_LABELS[dim] || ''}: {dimIssues.length}
</Tag>
))}
</Space> </Space>
</div> </div>
)} )}
@@ -200,6 +224,7 @@ const AiStreamPage: React.FC = () => {
size="small" size="small"
pagination={false} pagination={false}
columns={issueColumns} columns={issueColumns}
scroll={{ x: 800 }}
/> />
), ),
}]} }]}
@@ -225,7 +250,7 @@ const AiStreamPage: React.FC = () => {
<Space> <Space>
<Tag icon={<ThunderboltOutlined />} color="processing"></Tag> <Tag icon={<ThunderboltOutlined />} color="processing"></Tag>
<Badge count={total} overflowCount={9999} style={{ backgroundColor: '#3b82f6' }}> <Badge count={total} overflowCount={9999} style={{ backgroundColor: '#3b82f6' }}>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</Badge> </Badge>
</Space> </Space>
<Space> <Space>
@@ -251,9 +276,9 @@ const AiStreamPage: React.FC = () => {
<Pagination <Pagination
current={page} current={page}
total={total} total={total}
pageSize={30} pageSize={20}
onChange={setPage} onChange={setPage}
showTotal={(t) => `${t} `} showTotal={(t) => `${t} 位受试者`}
size="small" size="small"
/> />
</div> </div>

View File

@@ -27,6 +27,8 @@ import {
Select, Select,
Badge, Badge,
Spin, Spin,
Modal,
Pagination,
} from 'antd'; } from 'antd';
import { import {
FileTextOutlined, FileTextOutlined,
@@ -38,9 +40,10 @@ import {
DatabaseOutlined, DatabaseOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
ExceptionOutlined, ExceptionOutlined,
EyeOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi'; 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'; import { useIitProject } from '../context/IitProjectContext';
const EligibilityTable = lazy(() => import('../components/reports/EligibilityTable')); const EligibilityTable = lazy(() => import('../components/reports/EligibilityTable'));
@@ -62,6 +65,16 @@ const ReportsPage: React.FC = () => {
const [ceTotal, setCeTotal] = useState(0); const [ceTotal, setCeTotal] = useState(0);
const [ceStatusFilter, setCeStatusFilter] = useState<string | undefined>(undefined); const [ceStatusFilter, setCeStatusFilter] = useState<string | undefined>(undefined);
// 问题详情 Modal
const [issueModalOpen, setIssueModalOpen] = useState(false);
const [issueModalSeverity, setIssueModalSeverity] = useState<string | undefined>(undefined);
const [issueModalDimension, setIssueModalDimension] = useState<string | undefined>(undefined);
const [issueItems, setIssueItems] = useState<FieldIssueItem[]>([]);
const [issueTotal, setIssueTotal] = useState(0);
const [issuePage, setIssuePage] = useState(1);
const [issueSummary, setIssueSummary] = useState<FieldIssuesSummary | null>(null);
const [issueLoading, setIssueLoading] = useState(false);
const fetchReport = useCallback(async () => { const fetchReport = useCallback(async () => {
if (!projectId) return; if (!projectId) return;
setLoading(true); setLoading(true);
@@ -82,6 +95,34 @@ const ReportsPage: React.FC = () => {
useEffect(() => { fetchReport(); }, [fetchReport]); 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 () => { const handleRefresh = async () => {
if (!projectId) return; if (!projectId) return;
setRefreshing(true); setRefreshing(true);
@@ -117,8 +158,12 @@ const ReportsPage: React.FC = () => {
<Col span={4}><Card size="small"> <Col span={4}><Card size="small">
<Statistic title="通过率" value={summary.passRate} suffix="%" valueStyle={{ color: summary.passRate >= 80 ? '#52c41a' : summary.passRate >= 60 ? '#faad14' : '#ff4d4f' }} /> <Statistic title="通过率" value={summary.passRate} suffix="%" valueStyle={{ color: summary.passRate >= 80 ? '#52c41a' : summary.passRate >= 60 ? '#faad14' : '#ff4d4f' }} />
</Card></Col> </Card></Col>
<Col span={4}><Card size="small"><Statistic title="严重问题" value={summary.criticalIssues} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} /></Card></Col> <Col span={4}><Card size="small" hoverable onClick={() => openIssueModal('critical')} style={{ cursor: 'pointer' }}>
<Col span={4}><Card size="small"><Statistic title="警告" value={summary.warningIssues} prefix={<AlertOutlined />} valueStyle={{ color: '#faad14' }} /></Card></Col> <Statistic title={<Space size={4}> <EyeOutlined style={{ fontSize: 12, color: '#999' }} /></Space>} value={summary.criticalIssues} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} />
</Card></Col>
<Col span={4}><Card size="small" hoverable onClick={() => openIssueModal('warning')} style={{ cursor: 'pointer' }}>
<Statistic title={<Space size={4}> <EyeOutlined style={{ fontSize: 12, color: '#999' }} /></Space>} value={summary.warningIssues} prefix={<AlertOutlined />} valueStyle={{ color: '#faad14' }} />
</Card></Col>
<Col span={4}><Card size="small"><Statistic title="待处理 Query" value={summary.pendingQueries} /></Card></Col> <Col span={4}><Card size="small"><Statistic title="待处理 Query" value={summary.pendingQueries} /></Card></Col>
</Row> </Row>
@@ -223,6 +268,117 @@ const ReportsPage: React.FC = () => {
const lazyFallback = <Spin style={{ display: 'block', margin: '40px auto' }} />; const lazyFallback = <Spin style={{ display: 'block', margin: '40px auto' }} />;
const DIMENSION_LABELS: Record<string, string> = {
D1: '入选/排除', D2: '完整性', D3: '准确性', D4: '质疑管理',
D5: '安全性', D6: '方案偏离', D7: '药物管理',
};
const issueDetailColumns = [
{ title: '受试者', dataIndex: 'recordId', width: 80, render: (v: string) => <Text strong>{v}</Text> },
{
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) ? <Text>{v || r.fieldName}</Text> : '—',
},
{
title: '维度',
dataIndex: 'ruleCategory',
width: 100,
render: (v: string) => <Tag color="blue">{v ? `${v} ${DIMENSION_LABELS[v] || ''}` : '—'}</Tag>,
},
{ title: '规则', dataIndex: 'ruleName', width: 140, ellipsis: true },
{ title: '问题描述', dataIndex: 'message', ellipsis: true },
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => <Tag color={s === 'critical' ? 'error' : 'warning'}>{s === 'critical' ? '严重' : '警告'}</Tag>,
},
{ 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 = (
<Modal
title={
<Space>
{issueModalSeverity === 'critical' ? <WarningOutlined style={{ color: '#ff4d4f' }} /> : <AlertOutlined style={{ color: '#faad14' }} />}
{issueModalSeverity === 'critical' ? '严重问题详情' : issueModalSeverity === 'warning' ? '警告详情' : '所有问题详情'}
<Badge count={issueTotal} style={{ backgroundColor: issueModalSeverity === 'critical' ? '#ff4d4f' : '#faad14' }} />
</Space>
}
open={issueModalOpen}
onCancel={() => setIssueModalOpen(false)}
width={1100}
footer={null}
>
<div style={{ marginBottom: 12 }}>
<Space wrap>
<Select
placeholder="按维度筛选"
allowClear
style={{ width: 160 }}
value={issueModalDimension}
onChange={(val) => {
setIssueModalDimension(val);
setIssuePage(1);
fetchIssues(issueModalSeverity, val, 1);
}}
options={
issueSummary
? Object.entries(issueSummary.byDimension).map(([dim, cnt]) => ({
value: dim,
label: `${dim} ${DIMENSION_LABELS[dim] || ''} (${cnt})`,
}))
: []
}
/>
{issueSummary && (
<Space size={12}>
<Text type="secondary">
: <Text type="danger" strong>{issueSummary.bySeverity.critical || 0}</Text>
</Text>
<Text type="secondary">
: <Text style={{ color: '#faad14' }} strong>{issueSummary.bySeverity.warning || 0}</Text>
</Text>
</Space>
)}
</Space>
</div>
<Table
dataSource={issueItems}
rowKey="id"
columns={issueDetailColumns}
size="small"
loading={issueLoading}
pagination={false}
scroll={{ x: 1000, y: 400 }}
/>
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Pagination
current={issuePage}
total={issueTotal}
pageSize={20}
onChange={(pg) => {
setIssuePage(pg);
fetchIssues(issueModalSeverity, issueModalDimension, pg);
}}
showTotal={(t) => `${t}`}
size="small"
/>
</div>
</Modal>
);
return ( return (
<div> <div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -282,6 +438,7 @@ const ReportsPage: React.FC = () => {
]} ]}
/> />
</Card> </Card>
{issueModal}
</div> </div>
); );
}; };

View File

@@ -84,7 +84,7 @@ const DashboardPage: React.FC = () => {
const [selectedTypeId, setSelectedTypeId] = useState<KBType | null>(null); const [selectedTypeId, setSelectedTypeId] = useState<KBType | null>(null);
// 表单数据 // 表单数据
const [formData, setFormData] = useState({ name: '', department: 'Cardiology' }); const [formData, setFormData] = useState({ name: '', department: 'General' });
const [files, setFiles] = useState<any[]>([]); const [files, setFiles] = useState<any[]>([]);
// 新增创建知识库后保存ID用于Step3上传文档 // 新增创建知识库后保存ID用于Step3上传文档
const [createdKbId, setCreatedKbId] = useState<string | null>(null); const [createdKbId, setCreatedKbId] = useState<string | null>(null);
@@ -141,7 +141,7 @@ const DashboardPage: React.FC = () => {
const handleCreateOpen = () => { const handleCreateOpen = () => {
setCreateStep(1); setCreateStep(1);
setSelectedTypeId(null); setSelectedTypeId(null);
setFormData({ name: '', department: 'Cardiology' }); setFormData({ name: '', department: 'General' });
setFiles([]); setFiles([]);
setCreatedKbId(null); setCreatedKbId(null);
setUploadedCount(0); setUploadedCount(0);
@@ -400,19 +400,7 @@ const DashboardPage: React.FC = () => {
/> />
</div> </div>
<div> {/* 科室选择暂时隐藏,默认使用 General */}
<label className="block text-sm font-bold text-slate-700 mb-2"> ( AI )</label>
<select
className="w-full px-4 py-3 border border-gray-300 rounded-lg bg-white outline-none focus:ring-2 focus:ring-blue-500 text-base"
value={formData.department}
onChange={(e) => setFormData({...formData, department: e.target.value})}
>
<option value="Cardiology"></option>
<option value="Neurology"></option>
<option value="Oncology"></option>
<option value="General"></option>
</select>
</div>
</div> </div>
</div> </div>
)} )}