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( export async function importUsers(
request: FastifyRequest<{ request: FastifyRequest<{
Body: { users: ImportUserRow[]; defaultTenantId?: string }; Body: {
users: ImportUserRow[];
defaultTenantId?: string;
autoInheritModules?: boolean;
};
}>, }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const importer = request.user!; const importer = request.user!;
const autoInheritModules = request.body.autoInheritModules ?? true;
// 确定默认租户 // 确定默认租户
let defaultTenantId = request.body.defaultTenantId; let defaultTenantId = request.body.defaultTenantId;
@@ -440,7 +445,8 @@ export async function importUsers(
const result = await userService.importUsers( const result = await userService.importUsers(
request.body.users, request.body.users,
defaultTenantId, defaultTenantId,
importer.userId importer.userId,
autoInheritModules
); );
return reply.send({ return reply.send({

View File

@@ -722,11 +722,13 @@ export async function updateUserModules(
/** /**
* 批量导入用户 * 批量导入用户
* @param autoInheritModules 为 true 时忽略 Excel 中的模块列,用户自动继承租户全部已开通模块
*/ */
export async function importUsers( export async function importUsers(
rows: ImportUserRow[], rows: ImportUserRow[],
defaultTenantId: string, defaultTenantId: string,
importerId: string importerId: string,
autoInheritModules: boolean = true
): Promise<ImportResult> { ): Promise<ImportResult> {
const result: ImportResult = { const result: ImportResult = {
success: 0, success: 0,
@@ -734,25 +736,34 @@ export async function importUsers(
errors: [], 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++) { for (let i = 0; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];
const rowNumber = i + 2; // Excel行号跳过表头 const rowNumber = i + 2;
try { try {
// 验证手机号
if (!row.phone || !/^1[3-9]\d{9}$/.test(row.phone)) { if (!row.phone || !/^1[3-9]\d{9}$/.test(row.phone)) {
throw new Error('手机号格式不正确'); throw new Error('手机号格式不正确');
} }
// 验证姓名
if (!row.name || row.name.trim().length === 0) { if (!row.name || row.name.trim().length === 0) {
throw new Error('姓名不能为空'); throw new Error('姓名不能为空');
} }
// 解析角色
const role = parseRole(row.role); const role = parseRole(row.role);
// 解析租户
let tenantId = defaultTenantId; let tenantId = defaultTenantId;
if (row.tenantCode) { if (row.tenantCode) {
const tenant = await prisma.tenants.findUnique({ const tenant = await prisma.tenants.findUnique({
@@ -764,7 +775,6 @@ export async function importUsers(
tenantId = tenant.id; tenantId = tenant.id;
} }
// 解析科室
let departmentId: string | undefined; let departmentId: string | undefined;
if (row.departmentName) { if (row.departmentName) {
const department = await prisma.departments.findFirst({ const department = await prisma.departments.findFirst({
@@ -773,18 +783,23 @@ export async function importUsers(
tenant_id: tenantId, tenant_id: tenantId,
}, },
}); });
if (!department) { departmentId = department?.id;
throw new Error(`科室 ${row.departmentName} 不存在`);
}
departmentId = department.id;
} }
// 解析模块 let modules: string[] | undefined;
const modules = row.modules if (autoInheritModules) {
? row.modules.split(',').map((m) => m.trim().toUpperCase()) // 不写 user_modules用户自动继承租户全部已开通模块
: undefined; 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( await createUser(
{ {
phone: row.phone, phone: row.phone,
@@ -812,6 +827,7 @@ export async function importUsers(
logger.info('[UserService] Batch import completed', { logger.info('[UserService] Batch import completed', {
success: result.success, success: result.success,
failed: result.failed, failed: result.failed,
autoInheritModules,
importedBy: importerId, importedBy: importerId,
}); });

View File

@@ -54,7 +54,7 @@ export async function legacyBridgeRoutes(fastify: FastifyInstance): Promise<void
logger.error('Legacy auth failed', { error }); logger.error('Legacy auth failed', { error });
return reply.status(500).send({ return reply.status(500).send({
error: 'Internal Server Error', 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( async execute(
profile: JournalProfile, profile: JournalProfile,
@@ -53,71 +85,62 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
const startTime = Date.now(); const startTime = Date.now();
const results: SkillResult[] = []; const results: SkillResult[] = [];
// 构建完整上下文
const context = { const context = {
...initialContext, ...initialContext,
profile, profile,
previousResults: [], previousResults: [],
} as unknown as TContext; } as unknown as TContext;
const stages = this.buildStages(profile.pipeline);
logger.info('[SkillExecutor] Starting pipeline execution', { logger.info('[SkillExecutor] Starting pipeline execution', {
taskId: context.taskId, taskId: context.taskId,
profileId: profile.id, profileId: profile.id,
pipelineLength: profile.pipeline.length, pipelineLength: profile.pipeline.length,
stageCount: stages.length,
parallelStages: stages.filter(s => s.length > 1).length,
}); });
// 遍历 Pipeline let shouldBreak = false;
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;
}
// 获取 Skill for (const stage of stages) {
const skill = SkillRegistry.get(item.skillId); if (shouldBreak) break;
if (!skill) {
logger.warn('[SkillExecutor] Skill not found in registry', { skillId: item.skillId });
results.push(this.createSkippedResult(item.skillId, 'Skill not found'));
continue;
}
// 前置检查 if (stage.length === 1) {
if (skill.canRun && !skill.canRun(context as unknown as SkillContext)) { // 单个 Skill — 串行执行(保持原逻辑)
logger.info('[SkillExecutor] Skill pre-check failed, skipping', { skillId: item.skillId }); const item = stage[0];
results.push(this.createSkippedResult(item.skillId, 'Pre-check failed')); const result = await this.executePipelineItem(item, context, profile);
continue; if (result) {
} results.push(result);
context.previousResults.push(result);
// 执行 Skill const skill = SkillRegistry.get(item.skillId);
const result = await this.executeSkill(skill, context as unknown as SkillContext, item, profile); if (skill) this.updateContextWithResult(context, skill, result);
results.push(result); if (result.status === 'error' && !this.shouldContinue(item, profile)) {
shouldBreak = true;
// 调用完成回调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 });
} }
} } else {
// 多个 Skill — 并行执行
logger.info('[SkillExecutor] Executing parallel stage', {
taskId: context.taskId,
skillIds: stage.map(s => s.skillId),
});
// 更新上下文(传递给后续 Skills const promises = stage.map(item => this.executePipelineItem(item, context, profile));
context.previousResults.push(result); const stageResults = await Promise.all(promises);
// 更新共享数据 for (let i = 0; i < stage.length; i++) {
this.updateContextWithResult(context, skill, result); const result = stageResults[i];
if (result) {
// 检查是否需要中断 results.push(result);
if (result.status === 'error' && !this.shouldContinue(item, profile)) { context.previousResults.push(result);
logger.warn('[SkillExecutor] Skill failed and continueOnError=false, stopping', { skillId: item.skillId }); const skill = SkillRegistry.get(stage[i].skillId);
break; if (skill) this.updateContextWithResult(context, skill, result);
}
}
} }
} }
// 生成汇总
const summary = this.buildSummary(context.taskId, profile.id, results, startTime); const summary = this.buildSummary(context.taskId, profile.id, results, startTime);
logger.info('[SkillExecutor] Pipeline execution completed', { logger.info('[SkillExecutor] Pipeline execution completed', {
@@ -131,6 +154,43 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
return summary; 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带超时和重试 * 执行单个 Skill带超时和重试
*/ */

View File

@@ -17,30 +17,32 @@ export const DEFAULT_PROFILE: JournalProfile = {
id: 'default', id: 'default',
name: '通用期刊配置', name: '通用期刊配置',
description: 'RVW V2.0 默认审稿配置,适用于大多数期刊', description: 'RVW V2.0 默认审稿配置,适用于大多数期刊',
version: '1.0.0', version: '1.1.0',
pipeline: [ pipeline: [
{ {
skillId: 'DataForensicsSkill', skillId: 'DataForensicsSkill',
enabled: true, enabled: true,
optional: true, // 数据侦探失败不影响其他审稿 optional: true,
config: { config: {
checkLevel: 'L1_L2_L25', checkLevel: 'L1_L2_L25',
tolerancePercent: 0.1, tolerancePercent: 0.1,
}, },
timeout: 60000, // 60 秒(需要调用 Python timeout: 60000,
}, },
{ {
skillId: 'EditorialSkill', skillId: 'EditorialSkill',
enabled: true, enabled: true,
optional: false, optional: false,
timeout: 180000, // 180 秒 timeout: 180000,
parallelGroup: 'llm-review',
}, },
{ {
skillId: 'MethodologySkill', skillId: 'MethodologySkill',
enabled: true, enabled: true,
optional: false, optional: false,
timeout: 180000, // 180 秒 timeout: 180000,
parallelGroup: 'llm-review',
}, },
], ],
@@ -58,16 +60,16 @@ export const CHINESE_CORE_PROFILE: JournalProfile = {
id: 'chinese-core', id: 'chinese-core',
name: '中文核心期刊配置', name: '中文核心期刊配置',
description: '适用于中文核心期刊,对数据准确性要求更高', description: '适用于中文核心期刊,对数据准确性要求更高',
version: '1.0.0', version: '1.1.0',
pipeline: [ pipeline: [
{ {
skillId: 'DataForensicsSkill', skillId: 'DataForensicsSkill',
enabled: true, enabled: true,
optional: false, // 中文核心对数据准确性要求高 optional: false,
config: { config: {
checkLevel: 'L1_L2_L25', checkLevel: 'L1_L2_L25',
tolerancePercent: 0.05, // 更严格的容错 tolerancePercent: 0.05,
}, },
timeout: 60000, timeout: 60000,
}, },
@@ -78,13 +80,15 @@ export const CHINESE_CORE_PROFILE: JournalProfile = {
config: { config: {
standard: 'chinese-core', standard: 'chinese-core',
}, },
timeout: 180000, // 180 秒 timeout: 180000,
parallelGroup: 'llm-review',
}, },
{ {
skillId: 'MethodologySkill', skillId: 'MethodologySkill',
enabled: true, enabled: true,
optional: false, optional: false,
timeout: 180000, // 180 秒 timeout: 180000,
parallelGroup: 'llm-review',
}, },
], ],

View File

@@ -239,6 +239,8 @@ export interface PipelineItem {
config?: SkillConfig; config?: SkillConfig;
timeout?: number; timeout?: number;
optional?: boolean; 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 forensicsResult = this.convertResult(result);
// 计算状态和评分 // 计算状态和评分(基于数据质量结论,非执行状态;发现问题不等于执行失败)
const hasErrors = forensicsResult.summary.errorCount > 0; const hasErrors = forensicsResult.summary.errorCount > 0;
const hasWarnings = forensicsResult.summary.warningCount > 0; const hasWarnings = forensicsResult.summary.warningCount > 0;
@@ -160,7 +160,7 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
let score: number; let score: number;
if (hasErrors) { if (hasErrors) {
status = 'error'; status = 'warning';
score = Math.max(0, 100 - forensicsResult.summary.errorCount * 20); score = Math.max(0, 100 - forensicsResult.summary.errorCount * 20);
} else if (hasWarnings) { } else if (hasWarnings) {
status = 'warning'; status = 'warning';

View File

@@ -105,14 +105,12 @@ export class EditorialSkill extends BaseSkill<SkillContext, EditorialConfig> {
// 转换为 SkillResult 格式 // 转换为 SkillResult 格式
const issues = this.convertToIssues(result); const issues = this.convertToIssues(result);
// 计算状态 // 计算状态(基于评审结论,非执行状态;有问题不等于执行失败)
const errorCount = issues.filter(i => i.severity === 'ERROR').length; const errorCount = issues.filter(i => i.severity === 'ERROR').length;
const warningCount = issues.filter(i => i.severity === 'WARNING').length; const warningCount = issues.filter(i => i.severity === 'WARNING').length;
let status: 'success' | 'warning' | 'error'; let status: 'success' | 'warning' | 'error';
if (errorCount > 0) { if (errorCount > 0 || warningCount > 0) {
status = 'error';
} else if (warningCount > 0) {
status = 'warning'; status = 'warning';
} else { } else {
status = 'success'; status = 'success';

View File

@@ -116,14 +116,12 @@ export class MethodologySkill extends BaseSkill<SkillContext, MethodologyConfig>
// 转换为 SkillResult 格式 // 转换为 SkillResult 格式
const issues = this.convertToIssues(result); const issues = this.convertToIssues(result);
// 计算状态 // 计算状态(基于评审结论,非执行状态;有问题不等于执行失败)
const errorCount = issues.filter(i => i.severity === 'ERROR').length; const errorCount = issues.filter(i => i.severity === 'ERROR').length;
const warningCount = issues.filter(i => i.severity === 'WARNING').length; const warningCount = issues.filter(i => i.severity === 'WARNING').length;
let status: 'success' | 'warning' | 'error'; let status: 'success' | 'warning' | 'error';
if (errorCount > 0) { if (errorCount > 0 || warningCount > 0) {
status = 'error';
} else if (warningCount > 0) {
status = 'warning'; status = 'warning';
} else { } else {
status = 'success'; status = 'success';

View File

@@ -1,7 +1,7 @@
# 🚀 AI临床研究平台 - 阿里云SAE最新真实状态记录 # 🚀 AI临床研究平台 - 阿里云SAE最新真实状态记录
> **文档用途**记录阿里云SAE服务器最新真实状态 + 每次部署记录 > **文档用途**记录阿里云SAE服务器最新真实状态 + 每次部署记录
> **最后更新**2026-03-02 > **最后更新**2026-03-05
> **维护人员**:开发团队 > **维护人员**:开发团队
> **说明**本文档准确记录SAE上所有应用的当前状态包括内网地址、镜像版本、用户名密码等关键资源信息 > **说明**本文档准确记录SAE上所有应用的当前状态包括内网地址、镜像版本、用户名密码等关键资源信息
@@ -11,10 +11,10 @@
| 服务名称 | 部署状态 | 镜像版本 | 部署位置 | 最后更新时间 | | 服务名称 | 部署状态 | 镜像版本 | 部署位置 | 最后更新时间 |
|---------|---------|---------|---------|-------------| |---------|---------|---------|---------|-------------|
| **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-03-02 | | **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-03-05 |
| **前端Nginx服务** | ✅ 运行中 | **v2.0** | SAE | 2026-03-02 | | **前端Nginx服务** | ✅ 运行中 | **v2.5** | SAE | 2026-03-05 |
| **Python微服务** | ✅ 运行中 | **v1.2** | SAE | 2026-02-27 | | **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 | | **R统计引擎** | ✅ 运行中 | **v1.0.1** | SAE | 2026-02-27 |
| **Dify AI服务** | ⚠️ 已废弃 | - | - | 使用pgvector替代 | | **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` | | **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` | | **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` | | **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.4** | ~838MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.4` | | **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** | | **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** | | **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** | | **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.197.32:80` | **v2.0** | | **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** **前端Nginxfrontend-nginx-service**
```bash ```bash
BACKEND_SERVICE_HOST=172.17.197.32 BACKEND_SERVICE_HOST=172.17.173.106
BACKEND_SERVICE_PORT=3001 BACKEND_SERVICE_PORT=3001
``` ```
@@ -259,27 +259,24 @@ TEMP_DIR=/tmp/extraction_service
### 3.2 前端Nginx服务 ### 3.2 前端Nginx服务
**当前部署版本**v2.0 **当前部署版本**v2.4
**镜像信息** **镜像信息**
- **仓库名称**`ai-clinical_frontend-nginx` - **仓库名称**`ai-clinical_frontend-nginx`
- **镜像版本**`v2.0` ✅(当前部署版本) - **镜像版本**`v2.4` ✅(当前部署版本)
- **镜像大小**约50MB - **镜像大小**约50MB
- **基础镜像**`nginx:alpine` - **基础镜像**`nginx:alpine`
- **构建时间**2026-03-02 - **构建时间**2026-03-05
- **镜像摘要**sha256:ad24ccde2c1cdf59c07af16a429ce6298ac42d28cd9df73276ab8b653e018d38 - **镜像摘要**sha256:6cb9e8be2bcd21fd8ccfe09dabdbb04d64c252fd9a5b5b3a55d5ba6fb52dcde1
**部署状态** **部署状态**
- ✅ 已成功部署到SAE2026-03-02 - ✅ 已成功部署到SAE2026-03-05
- ✅ 服务运行正常内网地址http://172.17.197.32:80 - ✅ 服务运行正常内网地址http://172.17.173.107:80
- ✅ 企业微信域名验证文件已部署WW_verify_YnhsQBwI0ARnNoG0.txt - ✅ 企业微信域名验证文件已部署WW_verify_YnhsQBwI0ARnNoG0.txt
**v2.0版本更新内容** **v2.5版本更新内容**
-IIT V3.1 Dashboard 健康度评分 + D1-D7 维度条 + 热力图 -前端 bug 修复和 UI 优化基于测试反馈0305 三次迭代)
- ✅ GCP 报表重构为 5 Tab执行摘要 + D1/D2/D3D4/D6 四张报表) - ⚠️ 部署后内网地址变更172.17.173.105 → 172.17.173.107
- ✅ 新增 GCP 组件EligibilityTable / CompletenessTable / EqueryLogTable / DeviationLogTable
- ✅ 管理端 QcDetailDrawer / RiskHeatmap / 方案偏离弹窗升级
- ⚠️ 部署后内网地址变更172.17.197.31 → 172.17.197.32
**Git文件结构** **Git文件结构**
``` ```
@@ -296,16 +293,16 @@ AIclinicalresearch/frontend-v2/
### 3.3 Node.js后端服务 ### 3.3 Node.js后端服务
**当前部署版本**v2.4 **当前部署版本**v2.6
**镜像信息** **镜像信息**
- **仓库名称**`backend-service` - **仓库名称**`backend-service`
- **镜像版本**`v2.4` ✅(已部署) - **镜像版本**`v2.6` ✅(已部署)
- **镜像大小**~838MB - **镜像大小**~838MB
- **基础镜像**`node:alpine` - **基础镜像**`node:alpine`
- **构建时间**2026-03-02 - **构建时间**2026-03-05
- **构建策略**改进版方案B本地编译+Docker打包 - **构建策略**改进版方案B本地编译+Docker打包
- **镜像摘要**sha256:7848b1b590c138a629fcf9036204e8a2663fc653d2347f22b2928df2874a4233 - **镜像摘要**sha256:45886ffd90edbaf6b9a57c1938f14b076fdae175b5d8e53caebabdd8c7ef8b7c
**技术架构** **技术架构**
- **Node.js版本**22.x - **Node.js版本**22.x
@@ -316,8 +313,8 @@ AIclinicalresearch/frontend-v2/
- **缓存系统**PostgreSQL替代Redis - **缓存系统**PostgreSQL替代Redis
**部署状态** **部署状态**
- ✅ 已成功部署到SAE2026-03-02 - ✅ 已成功部署到SAE2026-03-05
- ✅ 服务运行正常内网地址http://172.17.197.32:3001 - ✅ 服务运行正常内网地址http://172.17.173.106:3001
- ✅ 健康检查通过 - ✅ 健康检查通过
**Git文件结构** **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引擎全面升级 ### 2026-03-020302部署 - 数据库迁移6个 + IIT V3.1 QC引擎全面升级
#### 部署概览 #### 部署概览
@@ -637,5 +681,5 @@ AIclinicalresearch/extraction_service/
--- ---
> **提示**本文档记录SAE服务器的最新真实状态每次部署后必须更新 > **提示**本文档记录SAE服务器的最新真实状态每次部署后必须更新
> **最后更新**2026-03-02 > **最后更新**2026-03-05
> **当前版本**前端v2.0 | 后端v2.4 | Python v1.2 | R统计v1.0.1 | PostgreSQL 15 > **当前版本**前端v2.5 | 后端v2.8 | Python v1.2 | R统计v1.0.1 | PostgreSQL 15

View File

@@ -1,7 +1,7 @@
# 日常更新操作手册 # 日常更新操作手册
> 版本: v2.2补充 0302 部署经验 > 版本: v2.40305 二次热修后更新
> 更新日期: 2026-03-02 > 更新日期: 2026-03-05
> 适用: 日常代码更新、功能迭代、配置变更 > 适用: 日常代码更新、功能迭代、配置变更
--- ---
@@ -27,7 +27,7 @@ docker login --username=gofeng117@163.com --password=fengzhibo117 crpi-cd5ij4pjt
## 2. Node.js 后端更新(~25 分钟) ## 2. Node.js 后端更新(~25 分钟)
**当前版本**: v2.4 → 下个版本: v2.5 **当前版本**: v2.8 → 下个版本: v2.9
### 2.1 构建 ### 2.1 构建
@@ -39,7 +39,7 @@ npm run build
# 或: npx tsc --noCheck # 或: npx tsc --noCheck
# 构建 Docker 镜像 # 构建 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 覆盖到。 > **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 推送 ### 2.2 推送
```powershell ```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:...` 表示成功。 推送约 10 分钟(~840MB看到 `digest: sha256:...` 表示成功。
@@ -57,7 +57,7 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica
### 2.3 SAE 部署 ### 2.3 SAE 部署
1. SAE 控制台 → `nodejs-backend-test` → 部署应用 1. SAE 控制台 → `nodejs-backend-test` → 部署应用
2. 选择镜像 `backend-service:v2.5`(与上方构建版本一致) 2. 选择镜像 `backend-service:v2.9`(与上方构建版本一致)
3. 确认部署,等待 5-8 分钟 3. 确认部署,等待 5-8 分钟
### 2.4 验证 ### 2.4 验证
@@ -75,14 +75,14 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica
## 3. 前端 Nginx 更新(~15 分钟) ## 3. 前端 Nginx 更新(~15 分钟)
**当前版本**: v2.0 → 下个版本: v2.1 **当前版本**: v2.5 → 下个版本: v2.6
### 3.1 构建 ### 3.1 构建
```powershell ```powershell
cd D:\MyCursor\AIclinicalresearch\frontend-v2 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 编译)。 构建约 5 分钟(含 React 编译)。
@@ -90,9 +90,9 @@ docker build -t ai-clinical_frontend-nginx:v2.1 .
### 3.2 推送 ### 3.2 推送
```powershell ```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 推送约 2 分钟(~50MB
@@ -100,7 +100,7 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica
### 3.3 SAE 部署 ### 3.3 SAE 部署
1. SAE 控制台 → `frontend-nginx-service` → 部署应用 1. SAE 控制台 → `frontend-nginx-service` → 部署应用
2. 选择镜像版本 `v2.1`(与上方构建版本一致) 2. 选择镜像版本 `v2.6`(与上方构建版本一致)
3. **检查环境变量**: `BACKEND_SERVICE_HOST` 指向最新后端 IP 3. **检查环境变量**: `BACKEND_SERVICE_HOST` 指向最新后端 IP
### 3.4 验证 ### 3.4 验证

View File

@@ -3,7 +3,7 @@
> **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行 > **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行
> **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录 > **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录
> **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒 > **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) ### 后端变更 (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 微服务变更 ### 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 部署已清零项 ### 0302 部署已清零项
| # | 变更内容 | 部署日期 | 结果 | | # | 变更内容 | 部署日期 | 结果 |

View File

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

View File

@@ -30,7 +30,6 @@ const TopNavigation = () => {
// 根据用户模块权限过滤可显示的模块 // 根据用户模块权限过滤可显示的模块
const availableModules = MODULES.filter(module => { const availableModules = MODULES.filter(module => {
if (!module.moduleCode) return false; if (!module.moduleCode) return false;
if (module.isExternal || module.isLegacyEmbed) return true;
return hasModule(module.moduleCode); 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`, { const response = await apiClient.post<{ code: number; data: ImportResult }>(`${BASE_URL}/import`, {
users, users,
defaultTenantId, defaultTenantId,
autoInheritModules,
}); });
return response.data.data; return response.data.data;
} }

View File

@@ -1,8 +1,9 @@
/** /**
* 批量导入用户弹窗 * 批量导入用户弹窗
* 4步流程选择租户 → 下载模板/上传 → 预览确认 → 导入结果
*/ */
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Modal, Modal,
Upload, Upload,
@@ -14,15 +15,39 @@ import {
Typography, Typography,
message, message,
Divider, Divider,
Steps,
Tag,
Checkbox,
Descriptions,
Spin,
} from 'antd'; } 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 type { UploadFile } from 'antd/es/upload/interface';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import * as userApi from '../api/userApi'; 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 { 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 { interface ImportUserModalProps {
visible: boolean; visible: boolean;
@@ -37,29 +62,47 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
onSuccess, onSuccess,
tenantOptions, 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 [fileList, setFileList] = useState<UploadFile[]>([]);
const [parsedData, setParsedData] = useState<ImportUserRow[]>([]); const [parsedData, setParsedData] = useState<ImportUserRow[]>([]);
const [defaultTenantId, setDefaultTenantId] = useState<string>();
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null); 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 = () => { const resetState = () => {
setStep('upload'); setCurrentStep(0);
setSelectedTenantId(undefined);
setTenantModules([]);
setAutoInherit(true);
setFileList([]); setFileList([]);
setParsedData([]); setParsedData([]);
setDefaultTenantId(undefined);
setImportResult(null); setImportResult(null);
}; };
// 关闭弹窗
const handleClose = () => { const handleClose = () => {
resetState(); resetState();
onClose(); onClose();
}; };
// 解析Excel文件
const parseExcel = (file: File): Promise<ImportUserRow[]> => { const parseExcel = (file: File): Promise<ImportUserRow[]> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -69,17 +112,17 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
const workbook = XLSX.read(data, { type: 'binary' }); const workbook = XLSX.read(data, { type: 'binary' });
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName]; 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) => ({ const rows: ImportUserRow[] = jsonData.map((row) => ({
phone: String(row['手机号'] || row['phone'] || '').trim(), phone: String(row['手机号'] || row['phone'] || '').trim(),
name: String(row['姓名'] || row['name'] || '').trim(), name: String(row['姓名'] || row['name'] || '').trim(),
email: row['邮箱'] || row['email'] || undefined, email: (row['邮箱'] || row['email'] || undefined) as string | undefined,
role: row['角色'] || row['role'] || undefined, role: (row['角色'] || row['role'] || undefined) as string | undefined,
tenantCode: row['租户代码'] || row['tenantCode'] || undefined, departmentName: (row['科室'] || row['departmentName'] || undefined) as string | undefined,
departmentName: row['科室'] || row['departmentName'] || undefined, modules: autoInherit
modules: row['模块'] || row['modules'] || undefined, ? undefined
: ((row['模块'] || row['modules'] || undefined) as string | undefined),
})); }));
resolve(rows); resolve(rows);
@@ -92,7 +135,6 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
}); });
}; };
// 处理文件上传
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
try { try {
const rows = await parseExcel(file); const rows = await parseExcel(file);
@@ -101,91 +143,209 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
return false; return false;
} }
setParsedData(rows); setParsedData(rows);
setStep('preview'); setCurrentStep(2);
} catch (error) { } catch {
message.error('解析文件失败'); message.error('解析文件失败,请检查文件格式');
} }
return false; return false;
}; };
// 执行导入
const handleImport = async () => { const handleImport = async () => {
if (!defaultTenantId) { if (!selectedTenantId) return;
message.warning('请选择默认租户');
return;
}
setImporting(true); setImporting(true);
try { try {
const result = await userApi.importUsers(parsedData, defaultTenantId); const result = await userApi.importUsers(parsedData, selectedTenantId, autoInherit);
setImportResult(result); setImportResult(result);
setStep('result'); setCurrentStep(3);
if (result.success > 0) { if (result.success > 0) {
onSuccess(); onSuccess();
} }
} catch (error) { } catch {
message.error('导入失败'); message.error('导入失败');
} finally { } finally {
setImporting(false); setImporting(false);
} }
}; };
// 下载模板
const downloadTemplate = () => { const downloadTemplate = () => {
const template = [ const baseTemplate = [
{ {
'手机号': '13800138000', '手机号': '13800138000',
'姓名': '张三', '姓名': '张三',
'邮箱': 'zhangsan@example.com', '邮箱': 'zhangsan@example.com',
'角色': 'USER', '角色': 'USER',
'租户代码': '',
'科室': '', '科室': '',
'模块': 'AIA,PKB',
}, },
]; ];
const template = autoInherit
? baseTemplate
: baseTemplate.map((row) => ({
...row,
'模块': subscribedModuleCodes,
}));
const ws = XLSX.utils.json_to_sheet(template); 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(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '用户导入模板'); 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 = [ const previewColumns = [
{ title: '行号', key: 'rowNum', width: 60, render: (_: unknown, __: unknown, index: number) => index + 2 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 }, { title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 }, { title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180 }, { title: '邮箱', dataIndex: 'email', key: 'email', width: 180 },
{ title: '角色', dataIndex: 'role', key: 'role', width: 100 }, { title: '角色', dataIndex: 'role', key: 'role', width: 100, render: (v: string) => v || 'USER' },
{ title: '租户代码', dataIndex: 'tenantCode', key: 'tenantCode', width: 100 },
{ title: '科室', dataIndex: 'departmentName', key: 'departmentName', width: 100 }, { title: '科室', dataIndex: 'departmentName', key: 'departmentName', width: 100 },
{ title: '模块', dataIndex: 'modules', key: 'modules', width: 120 }, ...(!autoInherit
? [{ title: '模块', dataIndex: 'modules', key: 'modules', width: 120 }]
: []),
]; ];
// 错误表格列
const errorColumns = [ const errorColumns = [
{ title: '行号', dataIndex: 'row', key: 'row', width: 80 }, { title: '行号', dataIndex: 'row', key: 'row', width: 80 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 }, { title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '错误原因', dataIndex: 'error', key: 'error' }, { title: '错误原因', dataIndex: 'error', key: 'error' },
]; ];
const canGoNext = () => {
if (currentStepKey === 'tenant') return !!selectedTenantId;
if (currentStepKey === 'upload') return parsedData.length > 0;
return true;
};
return ( return (
<Modal <Modal
title="批量导入用户" title="批量导入用户"
open={visible} open={visible}
onCancel={handleClose} onCancel={handleClose}
width={800} width={860}
footer={null} footer={null}
destroyOnClose 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 <Alert
message="导入说明" message="导入说明"
description={ description={
<ul style={{ margin: 0, paddingLeft: 20 }}> <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></li>
<li>SUPER_ADMINHOSPITAL_ADMINPHARMA_ADMINDEPARTMENT_ADMINUSER</li> <li>SUPER_ADMINHOSPITAL_ADMINPHARMA_ADMINDEPARTMENT_ADMINUSER USER</li>
<li>使AIA,PKB,RVW</li> {!autoInherit && (
<li>使{subscribedModuleCodes}</li>
)}
</ul> </ul>
} }
type="info" type="info"
@@ -193,6 +353,20 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
style={{ marginBottom: 16 }} 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 }}> <div style={{ marginBottom: 16 }}>
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}> <Button icon={<DownloadOutlined />} onClick={downloadTemplate}>
@@ -203,7 +377,7 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
accept=".xlsx,.xls" accept=".xlsx,.xls"
fileList={fileList} fileList={fileList}
beforeUpload={handleUpload} beforeUpload={handleUpload}
onChange={({ fileList }) => setFileList(fileList)} onChange={({ fileList: fl }) => setFileList(fl)}
maxCount={1} maxCount={1}
> >
<p className="ant-upload-drag-icon"> <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-text"></p>
<p className="ant-upload-hint"> .xlsx .xls </p> <p className="ant-upload-hint"> .xlsx .xls </p>
</Dragger> </Dragger>
<Divider />
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => { setCurrentStep(0); setFileList([]); setParsedData([]); }}>
</Button>
</Space>
</div>
</> </>
)} )}
{step === 'preview' && ( {/* Step 3: 预览确认 */}
{currentStepKey === 'preview' && (
<> <>
<Alert <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" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} 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 <Table
columns={previewColumns} columns={previewColumns}
dataSource={parsedData.map((row, i) => ({ ...row, key: i }))} dataSource={parsedData.map((row, i) => ({ ...row, key: i }))}
size="small" size="small"
scroll={{ x: 800, y: 300 }} scroll={{ x: 700, y: 300 }}
pagination={false} pagination={false}
/> />
<Divider /> <Divider />
<Space style={{ width: '100%', justifyContent: 'flex-end' }}> <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 type="primary" loading={importing} onClick={handleImport}>
</Button> </Button>
@@ -259,7 +446,8 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
</> </>
)} )}
{step === 'result' && importResult && ( {/* Step 4: 导入结果 */}
{currentStepKey === 'result' && importResult && (
<> <>
<Alert <Alert
message={`导入完成:成功 ${importResult.success} 条,失败 ${importResult.failed}`} message={`导入完成:成功 ${importResult.success} 条,失败 ${importResult.failed}`}
@@ -268,11 +456,18 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
{autoInherit && importResult.success > 0 && (
<Alert
message={`已成功导入的 ${importResult.success} 位用户自动继承租户「${selectedTenant?.name}」的全部模块权限`}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{importResult.errors.length > 0 && ( {importResult.errors.length > 0 && (
<> <>
<Text strong style={{ display: 'block', marginBottom: 8 }}> <Title level={5}></Title>
</Text>
<Table <Table
columns={errorColumns} columns={errorColumns}
dataSource={importResult.errors.map((e, i) => ({ ...e, key: i }))} dataSource={importResult.errors.map((e, i) => ({ ...e, key: i }))}
@@ -298,4 +493,3 @@ const ImportUserModal: React.FC<ImportUserModalProps> = ({
}; };
export default ImportUserModal; export default ImportUserModal;

View File

@@ -10,7 +10,6 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { import {
SearchOutlined, SearchOutlined,
FilterOutlined, FilterOutlined,
FileSearchOutlined,
DatabaseOutlined, DatabaseOutlined,
BarChartOutlined, BarChartOutlined,
SettingOutlined, 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', key: 'extraction',
icon: <DatabaseOutlined />, icon: <DatabaseOutlined />,
label: '4. 全文智能提取', label: '3. 全文智能提取',
children: [ children: [
{ {
key: '/literature/extraction/setup', key: '/literature/extraction/setup',
@@ -99,12 +76,12 @@ const ASLLayout = () => {
{ {
key: '/literature/charting', key: '/literature/charting',
icon: <ApartmentOutlined />, icon: <ApartmentOutlined />,
label: '5. SR 图表生成器', label: '4. SR 图表生成器',
}, },
{ {
key: '/literature/meta-analysis', key: '/literature/meta-analysis',
icon: <BarChartOutlined />, icon: <BarChartOutlined />,
label: '6. Meta 分析引擎', label: '5. Meta 分析引擎',
}, },
]; ];
@@ -122,7 +99,6 @@ const ASLLayout = () => {
// 根据当前路径确定展开的菜单 // 根据当前路径确定展开的菜单
const getOpenKeys = () => { const getOpenKeys = () => {
if (currentPath.includes('screening/title')) return ['title-screening']; 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('/extraction')) return ['extraction'];
if (currentPath.includes('/charting')) return []; if (currentPath.includes('/charting')) return [];
if (currentPath.includes('/meta-analysis')) return []; if (currentPath.includes('/meta-analysis')) return [];

View File

@@ -4,8 +4,9 @@
*/ */
import { Suspense, lazy } from 'react'; import { Suspense, lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate, useNavigate } from 'react-router-dom';
import { Spin } from 'antd'; import { Spin, Result, Button } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
// 懒加载组件 // 懒加载组件
const ASLLayout = lazy(() => import('./components/ASLLayout')); const ASLLayout = lazy(() => import('./components/ASLLayout'));
@@ -36,6 +37,22 @@ const SRChartGenerator = lazy(() => import('./pages/SRChartGenerator'));
// 工具 5Meta 分析引擎 // 工具 5Meta 分析引擎
const MetaAnalysisEngine = lazy(() => import('./pages/MetaAnalysisEngine')); 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 = () => { const ASLModule = () => {
return ( return (
<Suspense <Suspense
@@ -77,6 +94,7 @@ const ASLModule = () => {
<Route index element={<Navigate to="setup" replace />} /> <Route index element={<Navigate to="setup" replace />} />
<Route path="setup" element={<ExtractionSetup />} /> <Route path="setup" element={<ExtractionSetup />} />
<Route path="progress/:taskId" element={<ExtractionProgress />} /> <Route path="progress/:taskId" element={<ExtractionProgress />} />
<Route path="workbench" element={<ExtractionWorkbenchGuide />} />
<Route path="workbench/:taskId" element={<ExtractionWorkbench />} /> <Route path="workbench/:taskId" element={<ExtractionWorkbench />} />
</Route> </Route>

View File

@@ -51,10 +51,9 @@ const LegacySystemPage: React.FC<LegacySystemPageProps> = ({ targetUrl }) => {
authDoneRef.current = true authDoneRef.current = true
setStatus('ready') setStatus('ready')
} catch (err: any) { } catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '服务连接失败,请稍后重试' const msg = err?.response?.data?.message || '网络连接问题,请点击下方按钮重试'
setErrorMsg(msg) setErrorMsg(msg)
setStatus('error') setStatus('error')
message.error(msg)
} }
}, [targetUrl]) }, [targetUrl])