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:
@@ -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 } },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华医学超声杂志的稿约标准对稿件进行评估。
|
你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华脑血管病杂志的稿约标准对稿件进行评估。
|
||||||
|
|
||||||
【你的职责】
|
【你的职责】
|
||||||
1. 仔细阅读稿件的每个部分
|
1. 仔细阅读稿件的每个部分
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. 获取模块详细信息
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
await QcReportService.refreshReport(projectId);
|
const orchResult = await dailyQcOrchestrator.orchestrate(projectId);
|
||||||
logger.info('[V3.1] QcReport cache refreshed after batch QC', { projectId });
|
equeriesCreated = orchResult.equeriesCreated;
|
||||||
} catch (reportErr: any) {
|
logger.info('[V3.1] Orchestration completed after batch QC', {
|
||||||
logger.warn('[V3.1] QcReport refresh failed (non-blocking)', { projectId, error: reportErr.message });
|
projectId,
|
||||||
|
equeriesCreated: orchResult.equeriesCreated,
|
||||||
|
criticalEventsArchived: orchResult.criticalEventsArchived,
|
||||||
|
});
|
||||||
|
} catch (orchErr: any) {
|
||||||
|
logger.warn('[V3.1] Orchestration failed (non-blocking)', { projectId, error: orchErr.message });
|
||||||
|
// fallback: at least refresh report cache
|
||||||
|
try {
|
||||||
|
await QcReportService.refreshReport(projectId);
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
@@ -87,6 +98,7 @@ export class IitBatchController {
|
|||||||
warnings,
|
warnings,
|
||||||
fieldStatusWrites,
|
fieldStatusWrites,
|
||||||
passRate,
|
passRate,
|
||||||
|
equeriesCreated,
|
||||||
},
|
},
|
||||||
durationMs,
|
durationMs,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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 业务报表路由
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!project) throw new Error('No active IIT project found');
|
|
||||||
return project.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton factory — lazily resolves active project
|
export async function getChatOrchestrator(projectId: string): Promise<ChatOrchestrator> {
|
||||||
let orchestratorInstance: ChatOrchestrator | null = null;
|
if (!projectId) {
|
||||||
|
throw new Error('projectId is required for ChatOrchestrator');
|
||||||
export async function getChatOrchestrator(): Promise<ChatOrchestrator> {
|
|
||||||
if (!orchestratorInstance) {
|
|
||||||
const projectId = await resolveActiveProjectId();
|
|
||||||
orchestratorInstance = new ChatOrchestrator(projectId);
|
|
||||||
await orchestratorInstance.initialize();
|
|
||||||
}
|
}
|
||||||
return orchestratorInstance;
|
|
||||||
|
let instance = orchestratorCache.get(projectId);
|
||||||
|
if (!instance) {
|
||||||
|
instance = new ChatOrchestrator(projectId);
|
||||||
|
await instance.initialize();
|
||||||
|
orchestratorCache.set(projectId, instance);
|
||||||
|
logger.info('[ChatOrchestrator] Created new instance', { projectId });
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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-02:QC 数据一致性修复!** AI 时间线 + 警告详情 统一从 qc_field_status(SSOT)读取,与热力图数据一致
|
||||||
|
> - **2026-03-02:字段/事件中文显示名!** LEFT JOIN field_metadata + qc_event_status,消除 REDCap 技术标识符
|
||||||
|
> - **2026-03-02:警告详情可查看!** 新增 field-issues 分页 API + ReportsPage 严重问题/警告数字可点击弹出详情 Modal
|
||||||
|
> - **2026-03-02:AI 对话项目隔离!** ChatOrchestrator 按 projectId 缓存实例 + SessionMemory 按 userId::projectId 隔离
|
||||||
|
> - **2026-03-02:管理端配置流重设计!** 5 个配置 Tab 按依赖关系重排 + AI 自动构建质控规则(D1/D3/D5/D6 四维度)
|
||||||
> - **2026-03-01:GCP 业务端报表全量完成!** 4 张 GCP 标准报表(D1 筛选入选/D2 完整性/D3D4 质疑跟踪/D6 方案偏离)后端 API + 前端组件 + ReportsPage 五 Tab 重构
|
> - **2026-03-01:GCP 业务端报表全量完成!** 4 张 GCP 标准报表(D1 筛选入选/D2 完整性/D3D4 质疑跟踪/D6 方案偏离)后端 API + 前端组件 + ReportsPage 五 Tab 重构
|
||||||
> - **2026-03-01:AI 工作流水时间线增强!** 实际规则数显示(33 条而非 1 条)+ 中文事件名 + 可展开问题详情表格 + severity 映射修复
|
> - **2026-03-01:AI 工作流水时间线增强!** 实际规则数显示(33 条而非 1 条)+ 中文事件名 + 可展开问题详情表格 + severity 映射修复
|
||||||
> - **2026-03-01:业务端一键全量质控!** DashboardPage 新增按钮 + 自动刷新报告缓存 + 事件级通过率修复
|
> - **2026-03-01:业务端一键全量质控!** DashboardPage 新增按钮 + 自动刷新报告缓存 + 事件级通过率修复
|
||||||
@@ -61,7 +66,16 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA
|
|||||||
- AI能力:DeepSeek/Qwen + 自研 RAG(pgvector)+ LLM Tool Use
|
- AI能力:DeepSeek/Qwen + 自研 RAG(pgvector)+ LLM Tool Use
|
||||||
|
|
||||||
### 当前状态
|
### 当前状态
|
||||||
- **开发阶段**:**V3.1 质控引擎 + GCP 业务报表 + AI 时间线增强 + Bug 修复 → 待部署验证**
|
- **开发阶段**:**V3.2 数据一致性 + 项目隔离 + 管理端重设计 + 中文显示名 → 待部署验证**
|
||||||
|
- **V3.2 数据一致性 + 项目隔离已完成**(2026-03-02):
|
||||||
|
- AI 时间线改为从 qc_field_status(SSOT)聚合,与风险热力图数据一致
|
||||||
|
- 新增 field-issues 分页查询 API(支持按维度/严重程度/受试者筛选)
|
||||||
|
- ReportsPage 严重问题/警告数字可点击弹出详情 Modal(分页+按维度筛选)
|
||||||
|
- 字段名/事件名中文显示(LEFT JOIN field_metadata + qc_event_status 替代 REDCap 技术标识符)
|
||||||
|
- AI 对话项目隔离(ChatOrchestrator 按 projectId 缓存 + SessionMemory 按 userId::projectId 隔离 + WeChat 回调自动解析 projectId)
|
||||||
|
- 管理端 IIT 配置流重设计:5 个 Tab 按依赖关系重排(REDCap→变量清单→知识库→质控规则→成员)
|
||||||
|
- AI 自动构建质控规则(D3 程序化 + D1/D5/D6 LLM 生成 + 预览批量导入 + 规则追加而非覆盖)
|
||||||
|
- 批量 QC 后自动调用 DailyQcOrchestrator 派发 eQuery
|
||||||
- **GCP 业务报表 + Bug 修复已完成**(2026-03-01):
|
- **GCP 业务报表 + Bug 修复已完成**(2026-03-01):
|
||||||
- 4 张 GCP 标准报表后端 API(iitQcCockpitService:getEligibilityReport/getCompletenessReport/getEqueryReport/getDeviationReport)
|
- 4 张 GCP 标准报表后端 API(iitQcCockpitService:getEligibilityReport/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 筛选入选表(getEligibilityReport:record_summary 全量 + qc_field_status D1 叠加)
|
- D1 筛选入选表(getEligibilityReport:record_summary 全量 + qc_field_status D1 叠加)
|
||||||
@@ -954,8 +999,8 @@ npx ts-node src/modules/iit-manager/test-wechat-push.ts
|
|||||||
---
|
---
|
||||||
|
|
||||||
> **提示**:本文档反映IIT Manager Agent模块的最新真实状态,每个里程碑完成后必须更新!
|
> **提示**:本文档反映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) ⭐⭐⭐⭐⭐
|
||||||
|
|||||||
@@ -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 模块权限按需分配给对应租户和用户
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 记录模板
|
## 记录模板
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 用户映射 ====================
|
// ==================== 用户映射 ====================
|
||||||
|
|
||||||
/** 获取角色选项 */
|
/** 获取角色选项 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -476,14 +490,14 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
|
|||||||
try {
|
try {
|
||||||
const fieldValue = values.field as string;
|
const fieldValue = values.field as string;
|
||||||
const logicValue = values.logic as string;
|
const logicValue = values.logic as string;
|
||||||
|
|
||||||
const ruleData: CreateRuleRequest = {
|
const ruleData: CreateRuleRequest = {
|
||||||
name: values.name as string,
|
name: values.name as string,
|
||||||
field: fieldValue.includes(',') ? fieldValue.split(',').map((s) => s.trim()) : fieldValue,
|
field: fieldValue.includes(',') ? fieldValue.split(',').map((s) => s.trim()) : fieldValue,
|
||||||
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>
|
||||||
|
|
||||||
<Table
|
{loading ? (
|
||||||
columns={columns}
|
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
|
||||||
dataSource={rules}
|
) : rules.length === 0 ? (
|
||||||
rowKey="id"
|
<Empty description="暂无规则,请使用上方 AI 构建或手动添加" />
|
||||||
loading={loading}
|
) : (
|
||||||
pagination={false}
|
<Collapse
|
||||||
size="small"
|
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
|
||||||
|
columns={ruleColumns}
|
||||||
|
dataSource={catRules}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
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,32 +1512,61 @@ const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
{total === 0 && !loading ? (
|
||||||
<Select
|
<Alert
|
||||||
allowClear
|
message="尚未同步变量清单"
|
||||||
placeholder="按表单筛选"
|
description="点击下方按钮从 REDCap 同步所有数据变量定义。这是后续配置知识库和质控规则的基础。"
|
||||||
style={{ width: 200 }}
|
type="info"
|
||||||
value={formFilter}
|
showIcon
|
||||||
onChange={setFormFilter}
|
icon={<SyncOutlined />}
|
||||||
options={forms.map(f => ({ value: f, label: f }))}
|
style={{ marginBottom: 16 }}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<SyncOutlined spin={syncing} />}
|
||||||
|
onClick={handleSyncMetadata}
|
||||||
|
loading={syncing}
|
||||||
|
>
|
||||||
|
从 REDCap 同步元数据
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input.Search
|
) : (
|
||||||
placeholder="搜索变量名或标签"
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
allowClear
|
<Space>
|
||||||
style={{ width: 250 }}
|
<Select
|
||||||
onSearch={setSearch}
|
allowClear
|
||||||
|
placeholder="按表单筛选"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
value={formFilter}
|
||||||
|
onChange={setFormFilter}
|
||||||
|
options={forms.map(f => ({ value: f, label: f }))}
|
||||||
|
/>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索变量名或标签"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 250 }}
|
||||||
|
onSearch={setSearch}
|
||||||
|
/>
|
||||||
|
<Text type="secondary">共 {total} 个变量</Text>
|
||||||
|
</Space>
|
||||||
|
<Button icon={<SyncOutlined />} onClick={handleSyncMetadata} loading={syncing}>
|
||||||
|
重新同步
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{total > 0 && (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={fields}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 50, showSizeChanger: true, showTotal: t => `共 ${t} 条` }}
|
||||||
|
scroll={{ y: 500 }}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary">共 {total} 个变量</Text>
|
)}
|
||||||
</Space>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={fields}
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
size="small"
|
|
||||||
pagination={{ pageSize: 50, showSizeChanger: true, showTotal: t => `共 ${t} 条` }}
|
|
||||||
scroll={{ y: 500 }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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个卡片)
|
||||||
|
|||||||
@@ -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个)
|
||||||
|
|||||||
@@ -1004,4 +1004,4 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
@@ -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 分析引擎',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,6 +89,59 @@ const AiStreamPage: React.FC = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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: '规则',
|
||||||
|
dataIndex: 'ruleName',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string, r: TimelineIssue) => <Text>{v || r.ruleId || '—'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: '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 ?? '—',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const timelineItems = items.map((item) => {
|
const timelineItems = items.map((item) => {
|
||||||
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined />, label: '未知' };
|
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined />, label: '未知' };
|
||||||
const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy };
|
const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy };
|
||||||
@@ -93,42 +150,18 @@ const AiStreamPage: React.FC = () => {
|
|||||||
const timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
const timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
const dateStr = time.toLocaleDateString('zh-CN');
|
const dateStr = time.toLocaleDateString('zh-CN');
|
||||||
|
|
||||||
const eventLabel = item.eventLabel || '';
|
|
||||||
const issues = item.details.issues || [];
|
const issues = item.details.issues || [];
|
||||||
|
|
||||||
const issueColumns = [
|
// 按维度分组
|
||||||
{
|
const groupedByDimension = issues.reduce<Record<string, TimelineIssue[]>>((acc, iss) => {
|
||||||
title: '规则',
|
const key = iss.ruleCategory || '其他';
|
||||||
dataIndex: 'ruleName',
|
if (!acc[key]) acc[key] = [];
|
||||||
width: 160,
|
acc[key].push(iss);
|
||||||
render: (v: string, r: any) => (
|
return acc;
|
||||||
<Space size={4}>
|
}, {});
|
||||||
<Text>{v || r.ruleId}</Text>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '字段', dataIndex: 'field', width: 110, render: (v: string) => v ? <Text code>{v}</Text> : '—' },
|
|
||||||
{ 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 ?? '—',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user