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
This commit is contained in:
2026-03-05 22:04:36 +08:00
parent 0677d42345
commit 91ae80888e
19 changed files with 576 additions and 274 deletions

View File

@@ -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({

View File

@@ -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<ImportResult> {
const result: ImportResult = {
success: 0,
@@ -734,25 +736,34 @@ export async function importUsers(
errors: [],
};
// 预加载租户已开通模块(用于校验手动指定的模块)
const tenantModulesCache = new Map<string, Set<string>>();
async function getTenantModuleCodes(tenantId: string): Promise<Set<string>> {
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,
});

View File

@@ -54,7 +54,7 @@ export async function legacyBridgeRoutes(fastify: FastifyInstance): Promise<void
logger.error('Legacy auth failed', { error });
return reply.status(500).send({
error: 'Internal Server Error',
message: '旧系统认证失败,请稍后重试',
message: '网络连接问题,请点击下方按钮重试',
});
}
});

View File

@@ -44,7 +44,39 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
}
/**
* 执行 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<TContext extends BaseSkillContext = SkillContext> {
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);
if (stage.length === 1) {
// 单个 Skill — 串行执行(保持原逻辑)
const item = stage[0];
const result = await this.executePipelineItem(item, context, profile);
if (result) {
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 });
}
}
// 更新上下文(传递给后续 Skills
context.previousResults.push(result);
// 更新共享数据
this.updateContextWithResult(context, skill, result);
// 检查是否需要中断
const skill = SkillRegistry.get(item.skillId);
if (skill) 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;
shouldBreak = true;
}
}
} else {
// 多个 Skill — 并行执行
logger.info('[SkillExecutor] Executing parallel stage', {
taskId: context.taskId,
skillIds: stage.map(s => s.skillId),
});
const promises = stage.map(item => this.executePipelineItem(item, context, profile));
const stageResults = await Promise.all(promises);
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<TContext extends BaseSkillContext = SkillContext> {
return summary;
}
/**
* 执行单个 PipelineItem含跳过/校验/回调逻辑),返回 null 表示跳过
*/
private async executePipelineItem(
item: PipelineItem,
context: TContext,
profile: JournalProfile
): Promise<SkillResult | null> {
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带超时和重试
*/

View File

@@ -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',
},
],

View File

@@ -239,6 +239,8 @@ export interface PipelineItem {
config?: SkillConfig;
timeout?: number;
optional?: boolean;
/** 并行分组标识:相同 group 的 Skill 并行执行,不同 group 按顺序执行 */
parallelGroup?: string;
}
/**

View File

@@ -152,7 +152,7 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
// 转换为内部格式
const forensicsResult = this.convertResult(result);
// 计算状态和评分
// 计算状态和评分(基于数据质量结论,非执行状态;发现问题不等于执行失败)
const hasErrors = forensicsResult.summary.errorCount > 0;
const hasWarnings = forensicsResult.summary.warningCount > 0;
@@ -160,7 +160,7 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
let score: number;
if (hasErrors) {
status = 'error';
status = 'warning';
score = Math.max(0, 100 - forensicsResult.summary.errorCount * 20);
} else if (hasWarnings) {
status = 'warning';

View File

@@ -105,14 +105,12 @@ export class EditorialSkill extends BaseSkill<SkillContext, EditorialConfig> {
// 转换为 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';

View File

@@ -116,14 +116,12 @@ export class MethodologySkill extends BaseSkill<SkillContext, MethodologyConfig>
// 转换为 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';

View File

@@ -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
**前端Nginxfrontend-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
**部署状态**
- ✅ 已成功部署到SAE2026-03-02
- ✅ 服务运行正常内网地址http://172.17.197.32:80
- ✅ 已成功部署到SAE2026-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
**部署状态**
- ✅ 已成功部署到SAE2026-03-02
- ✅ 服务运行正常内网地址http://172.17.197.32:3001
- ✅ 已成功部署到SAE2026-03-05
- ✅ 服务运行正常内网地址http://172.17.173.106:3001
- ✅ 健康检查通过
**Git文件结构**
@@ -367,6 +364,53 @@ AIclinicalresearch/extraction_service/
## 🔄 四、部署历史记录
### 2026-03-050305部署 - 登录踢人 + 权限体系升级 + SSA双通道 + UI优化
#### 部署概览
- **部署时间**2026-03-05
- **部署范围**数据库数据更新1项 + Node.js后端 + 前端Nginx
- **主要变更**登录踢人机制、模块权限体系升级、SSA Agent双通道、前端UI精简
#### 数据库数据更新1项
- ✅ DB-1modules 表 seed 更新(新增 RM、AIA_PROTOCOLIIT→CRA质控
- ⏭️ DB-2RVW Prompt 更新(用户指定不执行)
- ⏭️ DB-3SSA 双通道表结构(待后续部署)
#### 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-020302部署 - 数据库迁移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

View File

@@ -1,7 +1,7 @@
# 日常更新操作手册
> 版本: v2.2补充 0302 部署经验
> 更新日期: 2026-03-02
> 版本: v2.40305 二次热修后更新
> 更新日期: 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 验证

View File

@@ -3,7 +3,7 @@
> **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行
> **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录
> **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒
> **最后清零**: 2026-03-020302 部署完成后清零)
> **最后清零**: 2026-03-050305 部署完成后清零)
---
@@ -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_PROTOCOLIIT→CRA质控 | 2026-03-05 | ✅ |
| BE | Node.js v2.4 → v2.6(登录踢人 + 权限体系 + SSA双通道 + 批量导入9 项变更) | 2026-03-05 | ✅ |
| FE | 前端 v2.0 → v2.3ASL/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.7bug 修复,基于测试反馈) | 2026-03-05 | ✅ 二次部署 |
| FE | 前端 v2.3 → v2.4bug 修复,基于测试反馈) | 2026-03-05 | ✅ 二次部署 |
| ENV | frontend-nginx-service: BACKEND_SERVICE_HOST → 172.17.197.37 | 2026-03-05 | ✅ 二次部署 |
| BE | Node.js v2.7 → v2.8bug 修复) | 2026-03-05 | ✅ 三次部署 |
| FE | 前端 v2.4 → v2.5bug 修复) | 2026-03-05 | ✅ 三次部署 |
| ENV | frontend-nginx-service: BACKEND_SERVICE_HOST → 172.17.173.106 | 2026-03-05 | ✅ 三次部署 |
### 0302 部署已清零项
| # | 变更内容 | 部署日期 | 结果 |

View File

@@ -82,22 +82,18 @@ function App() {
{/* 首页重定向到 AI 问答 */}
<Route index element={<Navigate to="/ai-qa" replace />} />
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
{/* 动态加载模块路由 - 基于模块权限系统 */}
{MODULES.filter(m => !m.isExternal).map(module => (
<Route
key={module.id}
path={`${module.path}/*`}
element={
module.isLegacyEmbed ? (
<module.component />
) : (
<RouteGuard
requiredModule={module.moduleCode}
moduleName={module.name}
>
<module.component />
</RouteGuard>
)
}
/>
))}

View File

@@ -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);
});

View File

@@ -91,10 +91,15 @@ export async function updateUserModules(userId: string, data: UpdateUserModulesR
/**
* 批量导入用户
*/
export async function importUsers(users: ImportUserRow[], defaultTenantId?: string): Promise<ImportResult> {
export async function importUsers(
users: ImportUserRow[],
defaultTenantId: string,
autoInheritModules: boolean = true
): Promise<ImportResult> {
const response = await apiClient.post<{ code: number; data: ImportResult }>(`${BASE_URL}/import`, {
users,
defaultTenantId,
autoInheritModules,
});
return response.data.data;
}

View File

@@ -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<ImportUserModalProps> = ({
onSuccess,
tenantOptions,
}) => {
const [step, setStep] = useState<'upload' | 'preview' | 'result'>('upload');
const [currentStep, setCurrentStep] = useState(0);
const [selectedTenantId, setSelectedTenantId] = useState<string>();
const [tenantModules, setTenantModules] = useState<ModuleOption[]>([]);
const [loadingModules, setLoadingModules] = useState(false);
const [autoInherit, setAutoInherit] = useState(true);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [parsedData, setParsedData] = useState<ImportUserRow[]>([]);
const [defaultTenantId, setDefaultTenantId] = useState<string>();
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(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<ImportUserRow[]> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -69,17 +112,17 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
const workbook = XLSX.read(data, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json<any>(worksheet);
const jsonData = XLSX.utils.sheet_to_json<Record<string, unknown>>(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<ImportUserModalProps> = ({
});
};
// 处理文件上传
const handleUpload = async (file: File) => {
try {
const rows = await parseExcel(file);
@@ -101,91 +143,209 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
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 (
<Modal
title="批量导入用户"
open={visible}
onCancel={handleClose}
width={800}
width={860}
footer={null}
destroyOnClose
>
{step === 'upload' && (
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={STEPS.map((s) => ({ title: s.title }))}
/>
{/* Step 1: 选择租户 */}
{currentStepKey === 'tenant' && (
<>
<div style={{ marginBottom: 16 }}>
<Text strong></Text>
<Select
placeholder="请选择租户"
value={selectedTenantId}
onChange={setSelectedTenantId}
style={{ width: 360, marginLeft: 12 }}
showSearch
optionFilterProp="label"
options={tenantOptions.map((t) => ({
value: t.id,
label: `${t.name} (${t.code})`,
}))}
/>
</div>
{selectedTenantId && (
<Spin spinning={loadingModules}>
<Descriptions
bordered
size="small"
column={1}
style={{ marginBottom: 16 }}
>
<Descriptions.Item
label={
<Space>
<TeamOutlined />
<span></span>
</Space>
}
>
{selectedTenant?.name}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<AppstoreOutlined />
<span></span>
</Space>
}
>
{subscribedModules.length > 0 ? (
<Space wrap>
{subscribedModules.map((m) => (
<Tag color="blue" key={m.code}>
{m.name}
</Tag>
))}
</Space>
) : (
<Text type="secondary"></Text>
)}
</Descriptions.Item>
</Descriptions>
<Alert
message="模块权限说明"
description="导入的用户将自动获得该租户已开通的全部模块权限。如需为每个用户单独配置模块,可在下一步取消「自动继承」选项。"
type="info"
showIcon
/>
</Spin>
)}
<Divider />
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={handleClose}></Button>
<Button
type="primary"
disabled={!canGoNext()}
onClick={() => setCurrentStep(1)}
>
</Button>
</Space>
</div>
</>
)}
{/* Step 2: 下载模板与上传 */}
{currentStepKey === 'upload' && (
<>
<Alert
message="导入说明"
description={
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li> .xlsx .xls Excel文件</li>
<li>
<Tag color="blue">{selectedTenant?.name}</Tag>
</li>
<li> .xlsx .xls Excel </li>
<li></li>
<li>SUPER_ADMINHOSPITAL_ADMINPHARMA_ADMINDEPARTMENT_ADMINUSER</li>
<li>使AIA,PKB,RVW</li>
<li>SUPER_ADMINHOSPITAL_ADMINPHARMA_ADMINDEPARTMENT_ADMINUSER USER</li>
{!autoInherit && (
<li>使{subscribedModuleCodes}</li>
)}
</ul>
}
type="info"
@@ -193,6 +353,20 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
style={{ marginBottom: 16 }}
/>
<div style={{ marginBottom: 16 }}>
<Checkbox
checked={autoInherit}
onChange={(e) => setAutoInherit(e.target.checked)}
>
</Checkbox>
{!autoInherit && (
<Text type="warning" style={{ display: 'block', marginTop: 4, marginLeft: 24 }}>
Excel
</Text>
)}
</div>
<div style={{ marginBottom: 16 }}>
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>
@@ -203,7 +377,7 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
accept=".xlsx,.xls"
fileList={fileList}
beforeUpload={handleUpload}
onChange={({ fileList }) => setFileList(fileList)}
onChange={({ fileList: fl }) => setFileList(fl)}
maxCount={1}
>
<p className="ant-upload-drag-icon">
@@ -212,46 +386,59 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"> .xlsx .xls </p>
</Dragger>
<Divider />
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => { setCurrentStep(0); setFileList([]); setParsedData([]); }}>
</Button>
</Space>
</div>
</>
)}
{step === 'preview' && (
{/* Step 3: 预览确认 */}
{currentStepKey === 'preview' && (
<>
<Alert
message={`共解析 ${parsedData.length} 条数据,请确认后导入`}
message={
<Space>
<span> <Text strong>{parsedData.length}</Text> </span>
<Divider type="vertical" />
<span>
<Tag color="blue">{selectedTenant?.name}</Tag>
</span>
<Divider type="vertical" />
<span>
{autoInherit ? (
<Tag color="green"></Tag>
) : (
<Tag color="orange"> Excel </Tag>
)}
</span>
</Space>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<div style={{ marginBottom: 16 }}>
<Space>
<Text></Text>
<Select
placeholder="请选择默认租户"
value={defaultTenantId}
onChange={setDefaultTenantId}
style={{ width: 250 }}
options={tenantOptions.map((t) => ({
value: t.id,
label: `${t.name} (${t.code})`,
}))}
/>
</Space>
</div>
<Table
columns={previewColumns}
dataSource={parsedData.map((row, i) => ({ ...row, key: i }))}
size="small"
scroll={{ x: 800, y: 300 }}
scroll={{ x: 700, y: 300 }}
pagination={false}
/>
<Divider />
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setStep('upload')}></Button>
<Button onClick={() => { setCurrentStep(1); setFileList([]); setParsedData([]); }}>
</Button>
<Button type="primary" loading={importing} onClick={handleImport}>
</Button>
@@ -259,7 +446,8 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
</>
)}
{step === 'result' && importResult && (
{/* Step 4: 导入结果 */}
{currentStepKey === 'result' && importResult && (
<>
<Alert
message={`导入完成:成功 ${importResult.success} 条,失败 ${importResult.failed}`}
@@ -268,11 +456,18 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
style={{ marginBottom: 16 }}
/>
{autoInherit && importResult.success > 0 && (
<Alert
message={`已成功导入的 ${importResult.success} 位用户自动继承租户「${selectedTenant?.name}」的全部模块权限`}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{importResult.errors.length > 0 && (
<>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
</Text>
<Title level={5}></Title>
<Table
columns={errorColumns}
dataSource={importResult.errors.map((e, i) => ({ ...e, key: i }))}
@@ -298,4 +493,3 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
};
export default ImportUserModal;

View File

@@ -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: <FileSearchOutlined />,
label: '3. 全文复筛',
children: [
{
key: '/literature/screening/fulltext/settings',
icon: <SettingOutlined />,
label: '设置与启动',
},
{
key: '/literature/screening/fulltext/workbench',
icon: <CheckSquareOutlined />,
label: '审核工作台',
},
{
key: '/literature/screening/fulltext/results',
icon: <UnorderedListOutlined />,
label: '复筛结果',
},
],
},
{
key: 'extraction',
icon: <DatabaseOutlined />,
label: '4. 全文智能提取',
label: '3. 全文智能提取',
children: [
{
key: '/literature/extraction/setup',
@@ -99,12 +76,12 @@ const ASLLayout = () => {
{
key: '/literature/charting',
icon: <ApartmentOutlined />,
label: '5. SR 图表生成器',
label: '4. SR 图表生成器',
},
{
key: '/literature/meta-analysis',
icon: <BarChartOutlined />,
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 [];

View File

@@ -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'));
// 工具 5Meta 分析引擎
const MetaAnalysisEngine = lazy(() => import('./pages/MetaAnalysisEngine'));
const ExtractionWorkbenchGuide = () => {
const nav = useNavigate();
return (
<Result
icon={<SettingOutlined style={{ color: '#1890ff' }} />}
title="请先配置提取任务"
subTitle="审核工作台需要在「配置与启动」中创建并完成提取任务后才可使用。请先前往配置页面上传文献并启动提取。"
extra={
<Button type="primary" onClick={() => nav('/literature/extraction/setup')}>
</Button>
}
/>
);
};
const ASLModule = () => {
return (
<Suspense
@@ -77,6 +94,7 @@ const ASLModule = () => {
<Route index element={<Navigate to="setup" replace />} />
<Route path="setup" element={<ExtractionSetup />} />
<Route path="progress/:taskId" element={<ExtractionProgress />} />
<Route path="workbench" element={<ExtractionWorkbenchGuide />} />
<Route path="workbench/:taskId" element={<ExtractionWorkbench />} />
</Route>

View File

@@ -51,10 +51,9 @@ const LegacySystemPage: React.FC<LegacySystemPageProps> = ({ 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])