From 91ae80888e1700209e59d3143c06e62203ed2275 Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Thu, 5 Mar 2026 22:04:36 +0800 Subject: [PATCH] feat(admin,rvw,asl,frontend): Batch import redesign + RVW parallel skills + UI improvements Backend: - Redesign batch user import: add autoInheritModules param, users auto-inherit tenant modules when true - Add module validation: reject modules not subscribed by the tenant - Soften department validation: skip instead of fail when department name not found - Fix RVW skill status semantics: review findings (ERROR issues) no longer mark skill as error - Add parallel execution support to SkillExecutor via parallelGroup - Configure Editorial + Methodology skills to run in parallel (~240s -> ~130s) - Update legacy bridge error message to user-friendly text Frontend: - Redesign ImportUserModal: 4-step flow (select tenant -> upload -> preview -> result) - Simplify import template: remove tenant code and module columns - Show tenant subscribed modules before import with auto-inherit option - Fix isLegacyEmbed modules bypassing RouteGuard and TopNavigation permission checks - Hide ASL fulltext screening (step 3), renumber subsequent nav items - Add ExtractionWorkbenchGuide page when no taskId provided - Update legacy system error message to network-friendly text Docs: - Update deployment changelog with BE-9, FE-11 entries Made-with: Cursor --- .../admin/controllers/userController.ts | 10 +- .../src/modules/admin/services/userService.ts | 48 ++- backend/src/modules/legacy-bridge/routes.ts | 2 +- .../src/modules/rvw/skills/core/executor.ts | 152 +++++--- .../src/modules/rvw/skills/core/profile.ts | 24 +- backend/src/modules/rvw/skills/core/types.ts | 2 + .../rvw/skills/library/DataForensicsSkill.ts | 4 +- .../rvw/skills/library/EditorialSkill.ts | 6 +- .../rvw/skills/library/MethodologySkill.ts | 6 +- .../00-阿里云SAE最新真实状态记录.md | 102 ++++-- docs/05-部署文档/01-日常更新操作手册.md | 24 +- docs/05-部署文档/03-待部署变更清单.md | 53 ++- frontend-v2/src/App.tsx | 16 +- .../src/framework/layout/TopNavigation.tsx | 1 - frontend-v2/src/modules/admin/api/userApi.ts | 7 +- .../admin/components/ImportUserModal.tsx | 338 ++++++++++++++---- .../src/modules/asl/components/ASLLayout.tsx | 30 +- frontend-v2/src/modules/asl/index.tsx | 22 +- .../src/modules/legacy/LegacySystemPage.tsx | 3 +- 19 files changed, 576 insertions(+), 274 deletions(-) diff --git a/backend/src/modules/admin/controllers/userController.ts b/backend/src/modules/admin/controllers/userController.ts index 8a9bdafb..8083cd5a 100644 --- a/backend/src/modules/admin/controllers/userController.ts +++ b/backend/src/modules/admin/controllers/userController.ts @@ -407,12 +407,17 @@ export async function updateUserModules( */ export async function importUsers( request: FastifyRequest<{ - Body: { users: ImportUserRow[]; defaultTenantId?: string }; + Body: { + users: ImportUserRow[]; + defaultTenantId?: string; + autoInheritModules?: boolean; + }; }>, reply: FastifyReply ) { try { const importer = request.user!; + const autoInheritModules = request.body.autoInheritModules ?? true; // 确定默认租户 let defaultTenantId = request.body.defaultTenantId; @@ -440,7 +445,8 @@ export async function importUsers( const result = await userService.importUsers( request.body.users, defaultTenantId, - importer.userId + importer.userId, + autoInheritModules ); return reply.send({ diff --git a/backend/src/modules/admin/services/userService.ts b/backend/src/modules/admin/services/userService.ts index 053e1ae1..6ab15ada 100644 --- a/backend/src/modules/admin/services/userService.ts +++ b/backend/src/modules/admin/services/userService.ts @@ -722,11 +722,13 @@ export async function updateUserModules( /** * 批量导入用户 + * @param autoInheritModules 为 true 时忽略 Excel 中的模块列,用户自动继承租户全部已开通模块 */ export async function importUsers( rows: ImportUserRow[], defaultTenantId: string, - importerId: string + importerId: string, + autoInheritModules: boolean = true ): Promise { const result: ImportResult = { success: 0, @@ -734,25 +736,34 @@ export async function importUsers( errors: [], }; + // 预加载租户已开通模块(用于校验手动指定的模块) + const tenantModulesCache = new Map>(); + async function getTenantModuleCodes(tenantId: string): Promise> { + if (!tenantModulesCache.has(tenantId)) { + const tenantModules = await prisma.tenant_modules.findMany({ + where: { tenant_id: tenantId, is_enabled: true }, + select: { module_code: true }, + }); + tenantModulesCache.set(tenantId, new Set(tenantModules.map((m) => m.module_code))); + } + return tenantModulesCache.get(tenantId)!; + } + for (let i = 0; i < rows.length; i++) { const row = rows[i]; - const rowNumber = i + 2; // Excel行号(跳过表头) + const rowNumber = i + 2; try { - // 验证手机号 if (!row.phone || !/^1[3-9]\d{9}$/.test(row.phone)) { throw new Error('手机号格式不正确'); } - // 验证姓名 if (!row.name || row.name.trim().length === 0) { throw new Error('姓名不能为空'); } - // 解析角色 const role = parseRole(row.role); - // 解析租户 let tenantId = defaultTenantId; if (row.tenantCode) { const tenant = await prisma.tenants.findUnique({ @@ -764,7 +775,6 @@ export async function importUsers( tenantId = tenant.id; } - // 解析科室 let departmentId: string | undefined; if (row.departmentName) { const department = await prisma.departments.findFirst({ @@ -773,18 +783,23 @@ export async function importUsers( tenant_id: tenantId, }, }); - if (!department) { - throw new Error(`科室 ${row.departmentName} 不存在`); - } - departmentId = department.id; + departmentId = department?.id; } - // 解析模块 - const modules = row.modules - ? row.modules.split(',').map((m) => m.trim().toUpperCase()) - : undefined; + let modules: string[] | undefined; + if (autoInheritModules) { + // 不写 user_modules,用户自动继承租户全部已开通模块 + modules = undefined; + } else if (row.modules) { + modules = row.modules.split(',').map((m) => m.trim().toUpperCase()); + // 校验模块是否在租户已开通范围内 + const subscribedModules = await getTenantModuleCodes(tenantId); + const invalidModules = modules.filter((m) => !subscribedModules.has(m)); + if (invalidModules.length > 0) { + throw new Error(`模块 ${invalidModules.join(',')} 不在租户已开通范围内`); + } + } - // 创建用户 await createUser( { phone: row.phone, @@ -812,6 +827,7 @@ export async function importUsers( logger.info('[UserService] Batch import completed', { success: result.success, failed: result.failed, + autoInheritModules, importedBy: importerId, }); diff --git a/backend/src/modules/legacy-bridge/routes.ts b/backend/src/modules/legacy-bridge/routes.ts index 8c854e09..a3f0f607 100644 --- a/backend/src/modules/legacy-bridge/routes.ts +++ b/backend/src/modules/legacy-bridge/routes.ts @@ -54,7 +54,7 @@ export async function legacyBridgeRoutes(fastify: FastifyInstance): Promise { } /** - * 执行 Pipeline + * 将 pipeline 按 parallelGroup 分段:无 group 的单独一段,相同 group 的合并为一段 + */ + private buildStages(pipeline: PipelineItem[]): PipelineItem[][] { + const stages: PipelineItem[][] = []; + let currentGroup: string | undefined; + let currentBatch: PipelineItem[] = []; + + for (const item of pipeline) { + if (!item.parallelGroup) { + if (currentBatch.length > 0) { + stages.push(currentBatch); + currentBatch = []; + currentGroup = undefined; + } + stages.push([item]); + } else if (item.parallelGroup === currentGroup) { + currentBatch.push(item); + } else { + if (currentBatch.length > 0) { + stages.push(currentBatch); + } + currentGroup = item.parallelGroup; + currentBatch = [item]; + } + } + if (currentBatch.length > 0) { + stages.push(currentBatch); + } + return stages; + } + + /** + * 执行 Pipeline(支持 parallelGroup 并行分组) */ async execute( profile: JournalProfile, @@ -53,71 +85,62 @@ export class SkillExecutor { const startTime = Date.now(); const results: SkillResult[] = []; - // 构建完整上下文 const context = { ...initialContext, profile, previousResults: [], } as unknown as TContext; + const stages = this.buildStages(profile.pipeline); + logger.info('[SkillExecutor] Starting pipeline execution', { taskId: context.taskId, profileId: profile.id, pipelineLength: profile.pipeline.length, + stageCount: stages.length, + parallelStages: stages.filter(s => s.length > 1).length, }); - // 遍历 Pipeline - for (const item of profile.pipeline) { - // 跳过禁用的 Skill - if (!item.enabled) { - logger.debug('[SkillExecutor] Skill disabled, skipping', { skillId: item.skillId }); - results.push(this.createSkippedResult(item.skillId, 'Skill disabled in profile')); - continue; - } + let shouldBreak = false; - // 获取 Skill - const skill = SkillRegistry.get(item.skillId); - if (!skill) { - logger.warn('[SkillExecutor] Skill not found in registry', { skillId: item.skillId }); - results.push(this.createSkippedResult(item.skillId, 'Skill not found')); - continue; - } + for (const stage of stages) { + if (shouldBreak) break; - // 前置检查 - if (skill.canRun && !skill.canRun(context as unknown as SkillContext)) { - logger.info('[SkillExecutor] Skill pre-check failed, skipping', { skillId: item.skillId }); - results.push(this.createSkippedResult(item.skillId, 'Pre-check failed')); - continue; - } - - // 执行 Skill - const result = await this.executeSkill(skill, context as unknown as SkillContext, item, profile); - results.push(result); - - // 调用完成回调(V2.1 扩展点) - if (this.config.onSkillComplete) { - try { - await this.config.onSkillComplete(item.skillId, result, context); - } catch (callbackError: unknown) { - const errorMessage = callbackError instanceof Error ? callbackError.message : String(callbackError); - logger.error('[SkillExecutor] onSkillComplete callback failed', { skillId: item.skillId, error: errorMessage }); + if (stage.length === 1) { + // 单个 Skill — 串行执行(保持原逻辑) + const item = stage[0]; + const result = await this.executePipelineItem(item, context, profile); + if (result) { + results.push(result); + context.previousResults.push(result); + const skill = SkillRegistry.get(item.skillId); + if (skill) this.updateContextWithResult(context, skill, result); + if (result.status === 'error' && !this.shouldContinue(item, profile)) { + shouldBreak = true; + } } - } + } else { + // 多个 Skill — 并行执行 + logger.info('[SkillExecutor] Executing parallel stage', { + taskId: context.taskId, + skillIds: stage.map(s => s.skillId), + }); - // 更新上下文(传递给后续 Skills) - context.previousResults.push(result); + const promises = stage.map(item => this.executePipelineItem(item, context, profile)); + const stageResults = await Promise.all(promises); - // 更新共享数据 - this.updateContextWithResult(context, skill, result); - - // 检查是否需要中断 - if (result.status === 'error' && !this.shouldContinue(item, profile)) { - logger.warn('[SkillExecutor] Skill failed and continueOnError=false, stopping', { skillId: item.skillId }); - break; + for (let i = 0; i < stage.length; i++) { + const result = stageResults[i]; + if (result) { + results.push(result); + context.previousResults.push(result); + const skill = SkillRegistry.get(stage[i].skillId); + if (skill) this.updateContextWithResult(context, skill, result); + } + } } } - // 生成汇总 const summary = this.buildSummary(context.taskId, profile.id, results, startTime); logger.info('[SkillExecutor] Pipeline execution completed', { @@ -131,6 +154,43 @@ export class SkillExecutor { return summary; } + /** + * 执行单个 PipelineItem(含跳过/校验/回调逻辑),返回 null 表示跳过 + */ + private async executePipelineItem( + item: PipelineItem, + context: TContext, + profile: JournalProfile + ): Promise { + if (!item.enabled) { + return this.createSkippedResult(item.skillId, 'Skill disabled in profile'); + } + + const skill = SkillRegistry.get(item.skillId); + if (!skill) { + logger.warn('[SkillExecutor] Skill not found in registry', { skillId: item.skillId }); + return this.createSkippedResult(item.skillId, 'Skill not found'); + } + + if (skill.canRun && !skill.canRun(context as unknown as SkillContext)) { + logger.info('[SkillExecutor] Skill pre-check failed, skipping', { skillId: item.skillId }); + return this.createSkippedResult(item.skillId, 'Pre-check failed'); + } + + const result = await this.executeSkill(skill, context as unknown as SkillContext, item, profile); + + if (this.config.onSkillComplete) { + try { + await this.config.onSkillComplete(item.skillId, result, context); + } catch (callbackError: unknown) { + const errorMessage = callbackError instanceof Error ? callbackError.message : String(callbackError); + logger.error('[SkillExecutor] onSkillComplete callback failed', { skillId: item.skillId, error: errorMessage }); + } + } + + return result; + } + /** * 执行单个 Skill(带超时和重试) */ diff --git a/backend/src/modules/rvw/skills/core/profile.ts b/backend/src/modules/rvw/skills/core/profile.ts index 31546a46..227ef03c 100644 --- a/backend/src/modules/rvw/skills/core/profile.ts +++ b/backend/src/modules/rvw/skills/core/profile.ts @@ -17,30 +17,32 @@ export const DEFAULT_PROFILE: JournalProfile = { id: 'default', name: '通用期刊配置', description: 'RVW V2.0 默认审稿配置,适用于大多数期刊', - version: '1.0.0', + version: '1.1.0', pipeline: [ { skillId: 'DataForensicsSkill', enabled: true, - optional: true, // 数据侦探失败不影响其他审稿 + optional: true, config: { checkLevel: 'L1_L2_L25', tolerancePercent: 0.1, }, - timeout: 60000, // 60 秒(需要调用 Python) + timeout: 60000, }, { skillId: 'EditorialSkill', enabled: true, optional: false, - timeout: 180000, // 180 秒 + timeout: 180000, + parallelGroup: 'llm-review', }, { skillId: 'MethodologySkill', enabled: true, optional: false, - timeout: 180000, // 180 秒 + timeout: 180000, + parallelGroup: 'llm-review', }, ], @@ -58,16 +60,16 @@ export const CHINESE_CORE_PROFILE: JournalProfile = { id: 'chinese-core', name: '中文核心期刊配置', description: '适用于中文核心期刊,对数据准确性要求更高', - version: '1.0.0', + version: '1.1.0', pipeline: [ { skillId: 'DataForensicsSkill', enabled: true, - optional: false, // 中文核心对数据准确性要求高 + optional: false, config: { checkLevel: 'L1_L2_L25', - tolerancePercent: 0.05, // 更严格的容错 + tolerancePercent: 0.05, }, timeout: 60000, }, @@ -78,13 +80,15 @@ export const CHINESE_CORE_PROFILE: JournalProfile = { config: { standard: 'chinese-core', }, - timeout: 180000, // 180 秒 + timeout: 180000, + parallelGroup: 'llm-review', }, { skillId: 'MethodologySkill', enabled: true, optional: false, - timeout: 180000, // 180 秒 + timeout: 180000, + parallelGroup: 'llm-review', }, ], diff --git a/backend/src/modules/rvw/skills/core/types.ts b/backend/src/modules/rvw/skills/core/types.ts index 67dff451..cb6b4d01 100644 --- a/backend/src/modules/rvw/skills/core/types.ts +++ b/backend/src/modules/rvw/skills/core/types.ts @@ -239,6 +239,8 @@ export interface PipelineItem { config?: SkillConfig; timeout?: number; optional?: boolean; + /** 并行分组标识:相同 group 的 Skill 并行执行,不同 group 按顺序执行 */ + parallelGroup?: string; } /** diff --git a/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts b/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts index de282fcd..077107da 100644 --- a/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts +++ b/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts @@ -152,7 +152,7 @@ export class DataForensicsSkill extends BaseSkill 0; const hasWarnings = forensicsResult.summary.warningCount > 0; @@ -160,7 +160,7 @@ export class DataForensicsSkill extends BaseSkill { // 转换为 SkillResult 格式 const issues = this.convertToIssues(result); - // 计算状态 + // 计算状态(基于评审结论,非执行状态;有问题不等于执行失败) const errorCount = issues.filter(i => i.severity === 'ERROR').length; const warningCount = issues.filter(i => i.severity === 'WARNING').length; let status: 'success' | 'warning' | 'error'; - if (errorCount > 0) { - status = 'error'; - } else if (warningCount > 0) { + if (errorCount > 0 || warningCount > 0) { status = 'warning'; } else { status = 'success'; diff --git a/backend/src/modules/rvw/skills/library/MethodologySkill.ts b/backend/src/modules/rvw/skills/library/MethodologySkill.ts index 75c9ac3a..5c673209 100644 --- a/backend/src/modules/rvw/skills/library/MethodologySkill.ts +++ b/backend/src/modules/rvw/skills/library/MethodologySkill.ts @@ -116,14 +116,12 @@ export class MethodologySkill extends BaseSkill // 转换为 SkillResult 格式 const issues = this.convertToIssues(result); - // 计算状态 + // 计算状态(基于评审结论,非执行状态;有问题不等于执行失败) const errorCount = issues.filter(i => i.severity === 'ERROR').length; const warningCount = issues.filter(i => i.severity === 'WARNING').length; let status: 'success' | 'warning' | 'error'; - if (errorCount > 0) { - status = 'error'; - } else if (warningCount > 0) { + if (errorCount > 0 || warningCount > 0) { status = 'warning'; } else { status = 'success'; diff --git a/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md b/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md index 2d606b5c..12f98f1a 100644 --- a/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md +++ b/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md @@ -1,7 +1,7 @@ # 🚀 AI临床研究平台 - 阿里云SAE最新真实状态记录 > **文档用途**:记录阿里云SAE服务器最新真实状态 + 每次部署记录 -> **最后更新**:2026-03-02 +> **最后更新**:2026-03-05 > **维护人员**:开发团队 > **说明**:本文档准确记录SAE上所有应用的当前状态,包括内网地址、镜像版本、用户名密码等关键资源信息 @@ -11,10 +11,10 @@ | 服务名称 | 部署状态 | 镜像版本 | 部署位置 | 最后更新时间 | |---------|---------|---------|---------|-------------| -| **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-03-02 | -| **前端Nginx服务** | ✅ 运行中 | **v2.0** | SAE | 2026-03-02 | +| **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-03-05 | +| **前端Nginx服务** | ✅ 运行中 | **v2.5** | SAE | 2026-03-05 | | **Python微服务** | ✅ 运行中 | **v1.2** | SAE | 2026-02-27 | -| **Node.js后端** | ✅ 运行中 | **v2.4** | SAE | 2026-03-02 | +| **Node.js后端** | ✅ 运行中 | **v2.8** | SAE | 2026-03-05 | | **R统计引擎** | ✅ 运行中 | **v1.0.1** | SAE | 2026-02-27 | | **Dify AI服务** | ⚠️ 已废弃 | - | - | 使用pgvector替代 | @@ -37,8 +37,8 @@ |---------|---------|---------|---------| | **python-extraction** | **v1.2** | ~1.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/python-extraction:v1.2` | | **ssa-r-statistics** | **v1.0.1** | ~1.8GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ssa-r-statistics:v1.0.1` | -| **ai-clinical_frontend-nginx** | **v2.0** | ~50MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.0` | -| **backend-service** | **v2.4** | ~838MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.4` | +| **ai-clinical_frontend-nginx** | **v2.5** | ~50MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.5` | +| **backend-service** | **v2.8** | ~838MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.8` | --- @@ -129,8 +129,8 @@ postgresql://airesearch:Xibahe%40fengzhibo117@pgm-2zex1m2y3r23hdn5.pg.rds.aliyun |---------|------|------|-------|------|---------|---------| | **r-statistics-test** | ✅ 运行中 | 1核2GB | 1 | 8080 | `http://172.17.173.101:8080` | **v1.0.1** | | **python-extraction-test** | ✅ 运行中 | **2核4GB** | 1 | 8000 | `http://172.17.173.102:8000` | **v1.2** | -| **nodejs-backend-test** | ✅ 运行中 | **2核4GB** | 1 | 3001 | `http://172.17.197.32:3001` | **v2.4** | -| **frontend-nginx-service** | ✅ 运行中 | 0.5核1GB | 1 | 80 | `http://172.17.197.32:80` | **v2.0** | +| **nodejs-backend-test** | ✅ 运行中 | **2核4GB** | 1 | 3001 | `http://172.17.173.106:3001` | **v2.8** | +| **frontend-nginx-service** | ✅ 运行中 | 0.5核1GB | 1 | 80 | `http://172.17.173.107:80` | **v2.5** | **环境变量配置**: @@ -191,7 +191,7 @@ LEGACY_MYSQL_DATABASE=xzyx_online **前端Nginx(frontend-nginx-service)**: ```bash -BACKEND_SERVICE_HOST=172.17.197.32 +BACKEND_SERVICE_HOST=172.17.173.106 BACKEND_SERVICE_PORT=3001 ``` @@ -259,27 +259,24 @@ TEMP_DIR=/tmp/extraction_service ### 3.2 前端Nginx服务 -**当前部署版本**:v2.0 +**当前部署版本**:v2.4 **镜像信息**: - **仓库名称**:`ai-clinical_frontend-nginx` -- **镜像版本**:`v2.0` ✅(当前部署版本) +- **镜像版本**:`v2.4` ✅(当前部署版本) - **镜像大小**:约50MB - **基础镜像**:`nginx:alpine` -- **构建时间**:2026-03-02 -- **镜像摘要**:sha256:ad24ccde2c1cdf59c07af16a429ce6298ac42d28cd9df73276ab8b653e018d38 +- **构建时间**:2026-03-05 +- **镜像摘要**:sha256:6cb9e8be2bcd21fd8ccfe09dabdbb04d64c252fd9a5b5b3a55d5ba6fb52dcde1 **部署状态**: -- ✅ 已成功部署到SAE(2026-03-02) -- ✅ 服务运行正常(内网地址:http://172.17.197.32:80) +- ✅ 已成功部署到SAE(2026-03-05) +- ✅ 服务运行正常(内网地址:http://172.17.173.107:80) - ✅ 企业微信域名验证文件已部署(WW_verify_YnhsQBwI0ARnNoG0.txt) -**v2.0版本更新内容**: -- ✅ IIT V3.1 Dashboard 健康度评分 + D1-D7 维度条 + 热力图 -- ✅ GCP 报表重构为 5 Tab(执行摘要 + D1/D2/D3D4/D6 四张报表) -- ✅ 新增 GCP 组件:EligibilityTable / CompletenessTable / EqueryLogTable / DeviationLogTable -- ✅ 管理端 QcDetailDrawer / RiskHeatmap / 方案偏离弹窗升级 -- ⚠️ 部署后内网地址变更:172.17.197.31 → 172.17.197.32 +**v2.5版本更新内容**: +- ✅ 前端 bug 修复和 UI 优化(基于测试反馈,0305 三次迭代) +- ⚠️ 部署后内网地址变更:172.17.173.105 → 172.17.173.107 **Git文件结构**: ``` @@ -296,16 +293,16 @@ AIclinicalresearch/frontend-v2/ ### 3.3 Node.js后端服务 -**当前部署版本**:v2.4 +**当前部署版本**:v2.6 **镜像信息**: - **仓库名称**:`backend-service` -- **镜像版本**:`v2.4` ✅(已部署) +- **镜像版本**:`v2.6` ✅(已部署) - **镜像大小**:~838MB - **基础镜像**:`node:alpine` -- **构建时间**:2026-03-02 +- **构建时间**:2026-03-05 - **构建策略**:改进版方案B(本地编译+Docker打包) -- **镜像摘要**:sha256:7848b1b590c138a629fcf9036204e8a2663fc653d2347f22b2928df2874a4233 +- **镜像摘要**:sha256:45886ffd90edbaf6b9a57c1938f14b076fdae175b5d8e53caebabdd8c7ef8b7c **技术架构**: - **Node.js版本**:22.x @@ -316,8 +313,8 @@ AIclinicalresearch/frontend-v2/ - **缓存系统**:PostgreSQL(替代Redis) **部署状态**: -- ✅ 已成功部署到SAE(2026-03-02) -- ✅ 服务运行正常(内网地址:http://172.17.197.32:3001) +- ✅ 已成功部署到SAE(2026-03-05) +- ✅ 服务运行正常(内网地址:http://172.17.173.106:3001) - ✅ 健康检查通过 **Git文件结构**: @@ -367,6 +364,53 @@ AIclinicalresearch/extraction_service/ ## 🔄 四、部署历史记录 +### 2026-03-05(0305部署 - 登录踢人 + 权限体系升级 + SSA双通道 + UI优化) + +#### 部署概览 +- **部署时间**:2026-03-05 +- **部署范围**:数据库数据更新(1项) + Node.js后端 + 前端Nginx +- **主要变更**:登录踢人机制、模块权限体系升级、SSA Agent双通道、前端UI精简 + +#### 数据库数据更新(1项) +- ✅ DB-1:modules 表 seed 更新(新增 RM、AIA_PROTOCOL,IIT→CRA质控) +- ⏭️ DB-2:RVW Prompt 更新(用户指定不执行) +- ⏭️ DB-3:SSA 双通道表结构(待后续部署) + +#### Node.js后端更新(v2.4 → v2.6) +- ✅ 登录踢人机制:同一手机号只能一人同时在线(JWT tokenVersion) +- ✅ 模块权限一致性修复 + 校验放宽 + user_modules 独立生效 +- ✅ SSA 双通道架构:Agent 模式 4 服务 + ChatHandler 分流 +- ✅ 批量导入用户增加 autoInheritModules + 模块校验 +- ✅ 镜像构建推送:`backend-service:v2.6`(digest: sha256:17dc3b3b...) +- ✅ SAE部署成功,内网地址变更:`172.17.197.32` → `172.17.197.36` + +#### 前端Nginx更新(v2.0 → v2.3) +- ✅ ASL 模块 UI 精简 + 默认进入智能文献检索 +- ✅ AIA Protocol Agent 按权限动态显示 + 链接修正 +- ✅ 首页重定向到 `/ai-qa` + PKB 隐藏科室选择 +- ✅ 被踢出提示 + 运营端模块权限弹窗 + 批量导入重构 +- ✅ 镜像构建推送:`ai-clinical_frontend-nginx:v2.3`(digest: sha256:db031053...) +- ✅ SAE部署成功,内网地址变更:`172.17.197.32` → `172.17.173.104` + +#### 环境变量同步 +- ✅ `frontend-nginx-service` 的 `BACKEND_SERVICE_HOST` 更新为 `172.17.197.36` +- ℹ️ CLB 由阿里云自动更新,无需手动操作 + +#### 二次热修部署(同日) +- ✅ SSA 双通道数据库迁移:ssa_sessions 新增 execution_mode + ssa_agent_executions 表 +- ✅ 前端/后端 bug 修复(基于测试反馈) +- ✅ 后端 v2.6 → v2.7 → **v2.8**,前端 v2.3 → v2.4 → **v2.5** +- ✅ 后端内网地址最终:`172.17.173.106` +- ✅ 前端内网地址最终:`172.17.173.107` + +#### 文档产出 +- ✅ `0305部署/01-部署完成总结.md` +- ✅ `00-阿里云SAE最新真实状态记录.md`(更新) +- ✅ `01-日常更新操作手册.md`(更新) +- ✅ `03-待部署变更清单.md`(清零移入历史) + +--- + ### 2026-03-02(0302部署 - 数据库迁移6个 + IIT V3.1 QC引擎全面升级) #### 部署概览 @@ -637,5 +681,5 @@ AIclinicalresearch/extraction_service/ --- > **提示**:本文档记录SAE服务器的最新真实状态,每次部署后必须更新! -> **最后更新**:2026-03-02 -> **当前版本**:前端v2.0 | 后端v2.4 | Python v1.2 | R统计v1.0.1 | PostgreSQL 15 +> **最后更新**:2026-03-05 +> **当前版本**:前端v2.5 | 后端v2.8 | Python v1.2 | R统计v1.0.1 | PostgreSQL 15 diff --git a/docs/05-部署文档/01-日常更新操作手册.md b/docs/05-部署文档/01-日常更新操作手册.md index bfcbe8a2..537388ef 100644 --- a/docs/05-部署文档/01-日常更新操作手册.md +++ b/docs/05-部署文档/01-日常更新操作手册.md @@ -1,7 +1,7 @@ # 日常更新操作手册 -> 版本: v2.2(补充 0302 部署经验) -> 更新日期: 2026-03-02 +> 版本: v2.4(0305 二次热修后更新) +> 更新日期: 2026-03-05 > 适用: 日常代码更新、功能迭代、配置变更 --- @@ -27,7 +27,7 @@ docker login --username=gofeng117@163.com --password=fengzhibo117 crpi-cd5ij4pjt ## 2. Node.js 后端更新(~25 分钟) -**当前版本**: v2.4 → 下个版本: v2.5 +**当前版本**: v2.8 → 下个版本: v2.9 ### 2.1 构建 @@ -39,7 +39,7 @@ npm run build # 或: npx tsc --noCheck # 构建 Docker 镜像 -docker build -t backend-service:v2.5 . +docker build -t backend-service:v2.9 . ``` > **0227 经验**: `tsc` 不会拷贝 `.json` 配置文件,Dockerfile 中已有 `COPY src/modules/ssa/config/*.json` 等补丁步骤。如新模块有 JSON 配置文件需要确认 Dockerfile 覆盖到。 @@ -47,9 +47,9 @@ docker build -t backend-service:v2.5 . ### 2.2 推送 ```powershell -docker tag backend-service:v2.5 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.5 +docker tag backend-service:v2.9 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.9 -docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.5 +docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.9 ``` 推送约 10 分钟(~840MB),看到 `digest: sha256:...` 表示成功。 @@ -57,7 +57,7 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica ### 2.3 SAE 部署 1. SAE 控制台 → `nodejs-backend-test` → 部署应用 -2. 选择镜像 `backend-service:v2.5`(与上方构建版本一致) +2. 选择镜像 `backend-service:v2.9`(与上方构建版本一致) 3. 确认部署,等待 5-8 分钟 ### 2.4 验证 @@ -75,14 +75,14 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica ## 3. 前端 Nginx 更新(~15 分钟) -**当前版本**: v2.0 → 下个版本: v2.1 +**当前版本**: v2.5 → 下个版本: v2.6 ### 3.1 构建 ```powershell cd D:\MyCursor\AIclinicalresearch\frontend-v2 -docker build -t ai-clinical_frontend-nginx:v2.1 . +docker build -t ai-clinical_frontend-nginx:v2.6 . ``` 构建约 5 分钟(含 React 编译)。 @@ -90,9 +90,9 @@ docker build -t ai-clinical_frontend-nginx:v2.1 . ### 3.2 推送 ```powershell -docker tag ai-clinical_frontend-nginx:v2.1 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.1 +docker tag ai-clinical_frontend-nginx:v2.6 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.6 -docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.1 +docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.6 ``` 推送约 2 分钟(~50MB)。 @@ -100,7 +100,7 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica ### 3.3 SAE 部署 1. SAE 控制台 → `frontend-nginx-service` → 部署应用 -2. 选择镜像版本 `v2.1`(与上方构建版本一致) +2. 选择镜像版本 `v2.6`(与上方构建版本一致) 3. **检查环境变量**: `BACKEND_SERVICE_HOST` 指向最新后端 IP ### 3.4 验证 diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index 9f45b79c..e56fff17 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -3,7 +3,7 @@ > **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行 > **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录 > **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒 -> **最后清零**: 2026-03-02(0302 部署完成后清零) +> **最后清零**: 2026-03-05(0305 部署完成后清零) --- @@ -15,37 +15,19 @@ | # | 变更内容 | 迁移文件 | 优先级 | 备注 | |---|---------|---------|--------|------| -| 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` 手动编辑 | -| DB-3 | SSA 双通道:ssa_sessions 新增 execution_mode 列 + 新建 ssa_agent_executions 表 | `manual_migrations/20260223_add_execution_mode_and_agent_tables.sql` | 中 | 手动执行 SQL 或通过 Prisma 迁移 | +| — | *暂无* | | | | ### 后端变更 (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 可单独配给用户 | -| BE-8 | SSA 双通道架构:Agent 模式 4 服务 + ChatHandler 分流 + Session PATCH API | `Agent*.ts`, `CodeRunnerService.ts`, `ChatHandlerService.ts`, `chat.routes.ts`, `session.routes.ts` | 重新构建镜像 | 含 PlannerAgent / CoderAgent / ReviewerAgent / CodeRunner | +| — | *暂无* | | | | ### 前端变更 | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| 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 微服务变更 @@ -71,17 +53,6 @@ |---|---------|------|------| | — | *暂无* | | | -### 部署顺序建议 - -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 模块权限按需分配给对应租户和用户 - --- ## 记录模板 @@ -112,6 +83,22 @@ ## 历史(已部署,仅供追溯) +### 0305 部署已清零项 + +| # | 变更内容 | 部署日期 | 结果 | +|---|---------|---------|------| +| DB | modules 表 seed 更新(新增 RM、AIA_PROTOCOL,IIT→CRA质控) | 2026-03-05 | ✅ | +| BE | Node.js v2.4 → v2.6(登录踢人 + 权限体系 + SSA双通道 + 批量导入,9 项变更) | 2026-03-05 | ✅ | +| FE | 前端 v2.0 → v2.3(ASL/AIA/PKB UI优化 + 权限适配 + 批量导入重构,11 项变更) | 2026-03-05 | ✅ | +| ENV | frontend-nginx-service: BACKEND_SERVICE_HOST → 172.17.197.36 | 2026-03-05 | ✅ | +| DB | SSA 双通道:ssa_sessions 新增 execution_mode + ssa_agent_executions 表 | 2026-03-05 | ✅ 热修 | +| BE | Node.js v2.6 → v2.7(bug 修复,基于测试反馈) | 2026-03-05 | ✅ 二次部署 | +| FE | 前端 v2.3 → v2.4(bug 修复,基于测试反馈) | 2026-03-05 | ✅ 二次部署 | +| ENV | frontend-nginx-service: BACKEND_SERVICE_HOST → 172.17.197.37 | 2026-03-05 | ✅ 二次部署 | +| BE | Node.js v2.7 → v2.8(bug 修复) | 2026-03-05 | ✅ 三次部署 | +| FE | 前端 v2.4 → v2.5(bug 修复) | 2026-03-05 | ✅ 三次部署 | +| ENV | frontend-nginx-service: BACKEND_SERVICE_HOST → 172.17.173.106 | 2026-03-05 | ✅ 三次部署 | + ### 0302 部署已清零项 | # | 变更内容 | 部署日期 | 结果 | diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index cb1f6b55..25423584 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -82,22 +82,18 @@ function App() { {/* 首页重定向到 AI 问答 */} } /> - {/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */} + {/* 动态加载模块路由 - 基于模块权限系统 */} {MODULES.filter(m => !m.isExternal).map(module => ( - ) : ( - - - - ) + } /> ))} diff --git a/frontend-v2/src/framework/layout/TopNavigation.tsx b/frontend-v2/src/framework/layout/TopNavigation.tsx index 2184f630..1aa252de 100644 --- a/frontend-v2/src/framework/layout/TopNavigation.tsx +++ b/frontend-v2/src/framework/layout/TopNavigation.tsx @@ -30,7 +30,6 @@ const TopNavigation = () => { // 根据用户模块权限过滤可显示的模块 const availableModules = MODULES.filter(module => { if (!module.moduleCode) return false; - if (module.isExternal || module.isLegacyEmbed) return true; return hasModule(module.moduleCode); }); diff --git a/frontend-v2/src/modules/admin/api/userApi.ts b/frontend-v2/src/modules/admin/api/userApi.ts index 9fa23c4a..79eb6a4b 100644 --- a/frontend-v2/src/modules/admin/api/userApi.ts +++ b/frontend-v2/src/modules/admin/api/userApi.ts @@ -91,10 +91,15 @@ export async function updateUserModules(userId: string, data: UpdateUserModulesR /** * 批量导入用户 */ -export async function importUsers(users: ImportUserRow[], defaultTenantId?: string): Promise { +export async function importUsers( + users: ImportUserRow[], + defaultTenantId: string, + autoInheritModules: boolean = true +): Promise { const response = await apiClient.post<{ code: number; data: ImportResult }>(`${BASE_URL}/import`, { users, defaultTenantId, + autoInheritModules, }); return response.data.data; } diff --git a/frontend-v2/src/modules/admin/components/ImportUserModal.tsx b/frontend-v2/src/modules/admin/components/ImportUserModal.tsx index 22fc0209..e91afdd7 100644 --- a/frontend-v2/src/modules/admin/components/ImportUserModal.tsx +++ b/frontend-v2/src/modules/admin/components/ImportUserModal.tsx @@ -1,8 +1,9 @@ /** * 批量导入用户弹窗 + * 4步流程:选择租户 → 下载模板/上传 → 预览确认 → 导入结果 */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Modal, Upload, @@ -14,15 +15,39 @@ import { Typography, message, Divider, + Steps, + Tag, + Checkbox, + Descriptions, + Spin, } from 'antd'; -import { InboxOutlined, DownloadOutlined } from '@ant-design/icons'; +import { + InboxOutlined, + DownloadOutlined, + TeamOutlined, + AppstoreOutlined, +} from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload/interface'; import * as XLSX from 'xlsx'; import * as userApi from '../api/userApi'; -import type { TenantOption, ImportUserRow, ImportResult } from '../types/user'; +import type { + TenantOption, + ImportUserRow, + ImportResult, + ModuleOption, +} from '../types/user'; const { Dragger } = Upload; -const { Text } = Typography; +const { Text, Title } = Typography; + +type StepKey = 'tenant' | 'upload' | 'preview' | 'result'; + +const STEPS: { key: StepKey; title: string }[] = [ + { key: 'tenant', title: '选择租户' }, + { key: 'upload', title: '上传文件' }, + { key: 'preview', title: '预览确认' }, + { key: 'result', title: '导入结果' }, +]; interface ImportUserModalProps { visible: boolean; @@ -37,29 +62,47 @@ const ImportUserModal: React.FC = ({ onSuccess, tenantOptions, }) => { - const [step, setStep] = useState<'upload' | 'preview' | 'result'>('upload'); + const [currentStep, setCurrentStep] = useState(0); + const [selectedTenantId, setSelectedTenantId] = useState(); + const [tenantModules, setTenantModules] = useState([]); + const [loadingModules, setLoadingModules] = useState(false); + const [autoInherit, setAutoInherit] = useState(true); const [fileList, setFileList] = useState([]); const [parsedData, setParsedData] = useState([]); - const [defaultTenantId, setDefaultTenantId] = useState(); const [importing, setImporting] = useState(false); const [importResult, setImportResult] = useState(null); - // 重置状态 + const currentStepKey = STEPS[currentStep].key; + const selectedTenant = tenantOptions.find((t) => t.id === selectedTenantId); + + useEffect(() => { + if (!selectedTenantId) { + setTenantModules([]); + return; + } + setLoadingModules(true); + userApi + .getModuleOptions(selectedTenantId) + .then((modules) => setTenantModules(modules)) + .catch(() => message.error('获取租户模块配置失败')) + .finally(() => setLoadingModules(false)); + }, [selectedTenantId]); + const resetState = () => { - setStep('upload'); + setCurrentStep(0); + setSelectedTenantId(undefined); + setTenantModules([]); + setAutoInherit(true); setFileList([]); setParsedData([]); - setDefaultTenantId(undefined); setImportResult(null); }; - // 关闭弹窗 const handleClose = () => { resetState(); onClose(); }; - // 解析Excel文件 const parseExcel = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -69,17 +112,17 @@ const ImportUserModal: React.FC = ({ const workbook = XLSX.read(data, { type: 'binary' }); const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; - const jsonData = XLSX.utils.sheet_to_json(worksheet); - - // 映射列名 + const jsonData = XLSX.utils.sheet_to_json>(worksheet); + const rows: ImportUserRow[] = jsonData.map((row) => ({ phone: String(row['手机号'] || row['phone'] || '').trim(), name: String(row['姓名'] || row['name'] || '').trim(), - email: row['邮箱'] || row['email'] || undefined, - role: row['角色'] || row['role'] || undefined, - tenantCode: row['租户代码'] || row['tenantCode'] || undefined, - departmentName: row['科室'] || row['departmentName'] || undefined, - modules: row['模块'] || row['modules'] || undefined, + email: (row['邮箱'] || row['email'] || undefined) as string | undefined, + role: (row['角色'] || row['role'] || undefined) as string | undefined, + departmentName: (row['科室'] || row['departmentName'] || undefined) as string | undefined, + modules: autoInherit + ? undefined + : ((row['模块'] || row['modules'] || undefined) as string | undefined), })); resolve(rows); @@ -92,7 +135,6 @@ const ImportUserModal: React.FC = ({ }); }; - // 处理文件上传 const handleUpload = async (file: File) => { try { const rows = await parseExcel(file); @@ -101,91 +143,209 @@ const ImportUserModal: React.FC = ({ return false; } setParsedData(rows); - setStep('preview'); - } catch (error) { - message.error('解析文件失败'); + setCurrentStep(2); + } catch { + message.error('解析文件失败,请检查文件格式'); } return false; }; - // 执行导入 const handleImport = async () => { - if (!defaultTenantId) { - message.warning('请选择默认租户'); - return; - } + if (!selectedTenantId) return; setImporting(true); try { - const result = await userApi.importUsers(parsedData, defaultTenantId); + const result = await userApi.importUsers(parsedData, selectedTenantId, autoInherit); setImportResult(result); - setStep('result'); + setCurrentStep(3); if (result.success > 0) { onSuccess(); } - } catch (error) { + } catch { message.error('导入失败'); } finally { setImporting(false); } }; - // 下载模板 const downloadTemplate = () => { - const template = [ + const baseTemplate = [ { '手机号': '13800138000', '姓名': '张三', '邮箱': 'zhangsan@example.com', '角色': 'USER', - '租户代码': '', '科室': '', - '模块': 'AIA,PKB', }, ]; + + const template = autoInherit + ? baseTemplate + : baseTemplate.map((row) => ({ + ...row, + '模块': subscribedModuleCodes, + })); + const ws = XLSX.utils.json_to_sheet(template); + + // 设置列宽 + ws['!cols'] = [ + { wch: 15 }, + { wch: 10 }, + { wch: 25 }, + { wch: 10 }, + { wch: 15 }, + ...(autoInherit ? [] : [{ wch: 20 }]), + ]; + const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, '用户导入模板'); - XLSX.writeFile(wb, '用户导入模板.xlsx'); + XLSX.writeFile(wb, `用户导入模板_${selectedTenant?.name || ''}.xlsx`); }; - // 预览表格列 + const subscribedModules = tenantModules.filter((m) => m.isSubscribed); + const subscribedModuleCodes = subscribedModules.map((m) => m.code).join(','); + const previewColumns = [ + { title: '行号', key: 'rowNum', width: 60, render: (_: unknown, __: unknown, index: number) => index + 2 }, { title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 }, { title: '姓名', dataIndex: 'name', key: 'name', width: 100 }, { title: '邮箱', dataIndex: 'email', key: 'email', width: 180 }, - { title: '角色', dataIndex: 'role', key: 'role', width: 100 }, - { title: '租户代码', dataIndex: 'tenantCode', key: 'tenantCode', width: 100 }, + { title: '角色', dataIndex: 'role', key: 'role', width: 100, render: (v: string) => v || 'USER' }, { title: '科室', dataIndex: 'departmentName', key: 'departmentName', width: 100 }, - { title: '模块', dataIndex: 'modules', key: 'modules', width: 120 }, + ...(!autoInherit + ? [{ title: '模块', dataIndex: 'modules', key: 'modules', width: 120 }] + : []), ]; - // 错误表格列 const errorColumns = [ { title: '行号', dataIndex: 'row', key: 'row', width: 80 }, { title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 }, { title: '错误原因', dataIndex: 'error', key: 'error' }, ]; + const canGoNext = () => { + if (currentStepKey === 'tenant') return !!selectedTenantId; + if (currentStepKey === 'upload') return parsedData.length > 0; + return true; + }; + return ( - {step === 'upload' && ( + ({ title: s.title }))} + /> + + {/* Step 1: 选择租户 */} + {currentStepKey === 'tenant' && ( + <> +
+ 选择导入目标租户: + ({ - value: t.id, - label: `${t.name} (${t.code})`, - }))} - /> - -
- ({ ...row, key: i }))} size="small" - scroll={{ x: 800, y: 300 }} + scroll={{ x: 700, y: 300 }} pagination={false} /> - + @@ -259,7 +446,8 @@ const ImportUserModal: React.FC = ({ )} - {step === 'result' && importResult && ( + {/* Step 4: 导入结果 */} + {currentStepKey === 'result' && importResult && ( <> = ({ style={{ marginBottom: 16 }} /> + {autoInherit && importResult.success > 0 && ( + + )} + {importResult.errors.length > 0 && ( <> - - 失败记录: - + 失败记录:
({ ...e, key: i }))} @@ -298,4 +493,3 @@ const ImportUserModal: React.FC = ({ }; export default ImportUserModal; - diff --git a/frontend-v2/src/modules/asl/components/ASLLayout.tsx b/frontend-v2/src/modules/asl/components/ASLLayout.tsx index c1249740..8668d224 100644 --- a/frontend-v2/src/modules/asl/components/ASLLayout.tsx +++ b/frontend-v2/src/modules/asl/components/ASLLayout.tsx @@ -10,7 +10,6 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { SearchOutlined, FilterOutlined, - FileSearchOutlined, DatabaseOutlined, BarChartOutlined, SettingOutlined, @@ -57,32 +56,10 @@ const ASLLayout = () => { }, ], }, - { - key: 'fulltext-screening', - icon: , - label: '3. 全文复筛', - children: [ - { - key: '/literature/screening/fulltext/settings', - icon: , - label: '设置与启动', - }, - { - key: '/literature/screening/fulltext/workbench', - icon: , - label: '审核工作台', - }, - { - key: '/literature/screening/fulltext/results', - icon: , - label: '复筛结果', - }, - ], - }, { key: 'extraction', icon: , - label: '4. 全文智能提取', + label: '3. 全文智能提取', children: [ { key: '/literature/extraction/setup', @@ -99,12 +76,12 @@ const ASLLayout = () => { { key: '/literature/charting', icon: , - label: '5. SR 图表生成器', + label: '4. SR 图表生成器', }, { key: '/literature/meta-analysis', icon: , - label: '6. Meta 分析引擎', + label: '5. Meta 分析引擎', }, ]; @@ -122,7 +99,6 @@ const ASLLayout = () => { // 根据当前路径确定展开的菜单 const getOpenKeys = () => { if (currentPath.includes('screening/title')) return ['title-screening']; - if (currentPath.includes('screening/fulltext')) return ['fulltext-screening']; if (currentPath.includes('/extraction')) return ['extraction']; if (currentPath.includes('/charting')) return []; if (currentPath.includes('/meta-analysis')) return []; diff --git a/frontend-v2/src/modules/asl/index.tsx b/frontend-v2/src/modules/asl/index.tsx index ba1c7928..b18485ce 100644 --- a/frontend-v2/src/modules/asl/index.tsx +++ b/frontend-v2/src/modules/asl/index.tsx @@ -4,8 +4,9 @@ */ import { Suspense, lazy } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; -import { Spin } from 'antd'; +import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'; +import { Spin, Result, Button } from 'antd'; +import { SettingOutlined } from '@ant-design/icons'; // 懒加载组件 const ASLLayout = lazy(() => import('./components/ASLLayout')); @@ -36,6 +37,22 @@ const SRChartGenerator = lazy(() => import('./pages/SRChartGenerator')); // 工具 5:Meta 分析引擎 const MetaAnalysisEngine = lazy(() => import('./pages/MetaAnalysisEngine')); +const ExtractionWorkbenchGuide = () => { + const nav = useNavigate(); + return ( + } + title="请先配置提取任务" + subTitle="审核工作台需要在「配置与启动」中创建并完成提取任务后才可使用。请先前往配置页面上传文献并启动提取。" + extra={ + + } + /> + ); +}; + const ASLModule = () => { return ( { } /> } /> } /> + } /> } /> diff --git a/frontend-v2/src/modules/legacy/LegacySystemPage.tsx b/frontend-v2/src/modules/legacy/LegacySystemPage.tsx index e25f92d8..327279fb 100644 --- a/frontend-v2/src/modules/legacy/LegacySystemPage.tsx +++ b/frontend-v2/src/modules/legacy/LegacySystemPage.tsx @@ -51,10 +51,9 @@ const LegacySystemPage: React.FC = ({ targetUrl }) => { authDoneRef.current = true setStatus('ready') } catch (err: any) { - const msg = err?.response?.data?.message || err?.message || '服务连接失败,请稍后重试' + const msg = err?.response?.data?.message || '网络连接问题,请点击下方按钮重试' setErrorMsg(msg) setStatus('error') - message.error(msg) } }, [targetUrl])