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:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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: '网络连接问题,请点击下方按钮重试',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(带超时和重试)
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -239,6 +239,8 @@ export interface PipelineItem {
|
||||
config?: SkillConfig;
|
||||
timeout?: number;
|
||||
optional?: boolean;
|
||||
/** 并行分组标识:相同 group 的 Skill 并行执行,不同 group 按顺序执行 */
|
||||
parallelGroup?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🚀 AI临床研究平台 - 阿里云SAE最新真实状态记录
|
||||
|
||||
> **文档用途**:记录阿里云SAE服务器最新真实状态 + 每次部署记录
|
||||
> **最后更新**:2026-03-02
|
||||
> **最后更新**:2026-03-05
|
||||
> **维护人员**:开发团队
|
||||
> **说明**:本文档准确记录SAE上所有应用的当前状态,包括内网地址、镜像版本、用户名密码等关键资源信息
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
|
||||
| 服务名称 | 部署状态 | 镜像版本 | 部署位置 | 最后更新时间 |
|
||||
|---------|---------|---------|---------|-------------|
|
||||
| **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-03-02 |
|
||||
| **前端Nginx服务** | ✅ 运行中 | **v2.0** | SAE | 2026-03-02 |
|
||||
| **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-03-05 |
|
||||
| **前端Nginx服务** | ✅ 运行中 | **v2.5** | SAE | 2026-03-05 |
|
||||
| **Python微服务** | ✅ 运行中 | **v1.2** | SAE | 2026-02-27 |
|
||||
| **Node.js后端** | ✅ 运行中 | **v2.4** | SAE | 2026-03-02 |
|
||||
| **Node.js后端** | ✅ 运行中 | **v2.8** | SAE | 2026-03-05 |
|
||||
| **R统计引擎** | ✅ 运行中 | **v1.0.1** | SAE | 2026-02-27 |
|
||||
| **Dify AI服务** | ⚠️ 已废弃 | - | - | 使用pgvector替代 |
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
|---------|---------|---------|---------|
|
||||
| **python-extraction** | **v1.2** | ~1.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/python-extraction:v1.2` |
|
||||
| **ssa-r-statistics** | **v1.0.1** | ~1.8GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ssa-r-statistics:v1.0.1` |
|
||||
| **ai-clinical_frontend-nginx** | **v2.0** | ~50MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.0` |
|
||||
| **backend-service** | **v2.4** | ~838MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.4` |
|
||||
| **ai-clinical_frontend-nginx** | **v2.5** | ~50MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.5` |
|
||||
| **backend-service** | **v2.8** | ~838MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.8` |
|
||||
|
||||
---
|
||||
|
||||
@@ -129,8 +129,8 @@ postgresql://airesearch:Xibahe%40fengzhibo117@pgm-2zex1m2y3r23hdn5.pg.rds.aliyun
|
||||
|---------|------|------|-------|------|---------|---------|
|
||||
| **r-statistics-test** | ✅ 运行中 | 1核2GB | 1 | 8080 | `http://172.17.173.101:8080` | **v1.0.1** |
|
||||
| **python-extraction-test** | ✅ 运行中 | **2核4GB** | 1 | 8000 | `http://172.17.173.102:8000` | **v1.2** |
|
||||
| **nodejs-backend-test** | ✅ 运行中 | **2核4GB** | 1 | 3001 | `http://172.17.197.32:3001` | **v2.4** |
|
||||
| **frontend-nginx-service** | ✅ 运行中 | 0.5核1GB | 1 | 80 | `http://172.17.197.32:80` | **v2.0** |
|
||||
| **nodejs-backend-test** | ✅ 运行中 | **2核4GB** | 1 | 3001 | `http://172.17.173.106:3001` | **v2.8** |
|
||||
| **frontend-nginx-service** | ✅ 运行中 | 0.5核1GB | 1 | 80 | `http://172.17.173.107:80` | **v2.5** |
|
||||
|
||||
**环境变量配置**:
|
||||
|
||||
@@ -191,7 +191,7 @@ LEGACY_MYSQL_DATABASE=xzyx_online
|
||||
|
||||
**前端Nginx(frontend-nginx-service)**:
|
||||
```bash
|
||||
BACKEND_SERVICE_HOST=172.17.197.32
|
||||
BACKEND_SERVICE_HOST=172.17.173.106
|
||||
BACKEND_SERVICE_PORT=3001
|
||||
```
|
||||
|
||||
@@ -259,27 +259,24 @@ TEMP_DIR=/tmp/extraction_service
|
||||
|
||||
### 3.2 前端Nginx服务
|
||||
|
||||
**当前部署版本**:v2.0
|
||||
**当前部署版本**:v2.4
|
||||
|
||||
**镜像信息**:
|
||||
- **仓库名称**:`ai-clinical_frontend-nginx`
|
||||
- **镜像版本**:`v2.0` ✅(当前部署版本)
|
||||
- **镜像版本**:`v2.4` ✅(当前部署版本)
|
||||
- **镜像大小**:约50MB
|
||||
- **基础镜像**:`nginx:alpine`
|
||||
- **构建时间**:2026-03-02
|
||||
- **镜像摘要**:sha256:ad24ccde2c1cdf59c07af16a429ce6298ac42d28cd9df73276ab8b653e018d38
|
||||
- **构建时间**:2026-03-05
|
||||
- **镜像摘要**:sha256:6cb9e8be2bcd21fd8ccfe09dabdbb04d64c252fd9a5b5b3a55d5ba6fb52dcde1
|
||||
|
||||
**部署状态**:
|
||||
- ✅ 已成功部署到SAE(2026-03-02)
|
||||
- ✅ 服务运行正常(内网地址:http://172.17.197.32:80)
|
||||
- ✅ 已成功部署到SAE(2026-03-05)
|
||||
- ✅ 服务运行正常(内网地址:http://172.17.173.107:80)
|
||||
- ✅ 企业微信域名验证文件已部署(WW_verify_YnhsQBwI0ARnNoG0.txt)
|
||||
|
||||
**v2.0版本更新内容**:
|
||||
- ✅ IIT V3.1 Dashboard 健康度评分 + D1-D7 维度条 + 热力图
|
||||
- ✅ GCP 报表重构为 5 Tab(执行摘要 + D1/D2/D3D4/D6 四张报表)
|
||||
- ✅ 新增 GCP 组件:EligibilityTable / CompletenessTable / EqueryLogTable / DeviationLogTable
|
||||
- ✅ 管理端 QcDetailDrawer / RiskHeatmap / 方案偏离弹窗升级
|
||||
- ⚠️ 部署后内网地址变更:172.17.197.31 → 172.17.197.32
|
||||
**v2.5版本更新内容**:
|
||||
- ✅ 前端 bug 修复和 UI 优化(基于测试反馈,0305 三次迭代)
|
||||
- ⚠️ 部署后内网地址变更:172.17.173.105 → 172.17.173.107
|
||||
|
||||
**Git文件结构**:
|
||||
```
|
||||
@@ -296,16 +293,16 @@ AIclinicalresearch/frontend-v2/
|
||||
|
||||
### 3.3 Node.js后端服务
|
||||
|
||||
**当前部署版本**:v2.4
|
||||
**当前部署版本**:v2.6
|
||||
|
||||
**镜像信息**:
|
||||
- **仓库名称**:`backend-service`
|
||||
- **镜像版本**:`v2.4` ✅(已部署)
|
||||
- **镜像版本**:`v2.6` ✅(已部署)
|
||||
- **镜像大小**:~838MB
|
||||
- **基础镜像**:`node:alpine`
|
||||
- **构建时间**:2026-03-02
|
||||
- **构建时间**:2026-03-05
|
||||
- **构建策略**:改进版方案B(本地编译+Docker打包)
|
||||
- **镜像摘要**:sha256:7848b1b590c138a629fcf9036204e8a2663fc653d2347f22b2928df2874a4233
|
||||
- **镜像摘要**:sha256:45886ffd90edbaf6b9a57c1938f14b076fdae175b5d8e53caebabdd8c7ef8b7c
|
||||
|
||||
**技术架构**:
|
||||
- **Node.js版本**:22.x
|
||||
@@ -316,8 +313,8 @@ AIclinicalresearch/frontend-v2/
|
||||
- **缓存系统**:PostgreSQL(替代Redis)
|
||||
|
||||
**部署状态**:
|
||||
- ✅ 已成功部署到SAE(2026-03-02)
|
||||
- ✅ 服务运行正常(内网地址:http://172.17.197.32:3001)
|
||||
- ✅ 已成功部署到SAE(2026-03-05)
|
||||
- ✅ 服务运行正常(内网地址:http://172.17.173.106:3001)
|
||||
- ✅ 健康检查通过
|
||||
|
||||
**Git文件结构**:
|
||||
@@ -367,6 +364,53 @@ AIclinicalresearch/extraction_service/
|
||||
|
||||
## 🔄 四、部署历史记录
|
||||
|
||||
### 2026-03-05(0305部署 - 登录踢人 + 权限体系升级 + SSA双通道 + UI优化)
|
||||
|
||||
#### 部署概览
|
||||
- **部署时间**:2026-03-05
|
||||
- **部署范围**:数据库数据更新(1项) + Node.js后端 + 前端Nginx
|
||||
- **主要变更**:登录踢人机制、模块权限体系升级、SSA Agent双通道、前端UI精简
|
||||
|
||||
#### 数据库数据更新(1项)
|
||||
- ✅ DB-1:modules 表 seed 更新(新增 RM、AIA_PROTOCOL,IIT→CRA质控)
|
||||
- ⏭️ DB-2:RVW Prompt 更新(用户指定不执行)
|
||||
- ⏭️ DB-3:SSA 双通道表结构(待后续部署)
|
||||
|
||||
#### Node.js后端更新(v2.4 → v2.6)
|
||||
- ✅ 登录踢人机制:同一手机号只能一人同时在线(JWT tokenVersion)
|
||||
- ✅ 模块权限一致性修复 + 校验放宽 + user_modules 独立生效
|
||||
- ✅ SSA 双通道架构:Agent 模式 4 服务 + ChatHandler 分流
|
||||
- ✅ 批量导入用户增加 autoInheritModules + 模块校验
|
||||
- ✅ 镜像构建推送:`backend-service:v2.6`(digest: sha256:17dc3b3b...)
|
||||
- ✅ SAE部署成功,内网地址变更:`172.17.197.32` → `172.17.197.36`
|
||||
|
||||
#### 前端Nginx更新(v2.0 → v2.3)
|
||||
- ✅ ASL 模块 UI 精简 + 默认进入智能文献检索
|
||||
- ✅ AIA Protocol Agent 按权限动态显示 + 链接修正
|
||||
- ✅ 首页重定向到 `/ai-qa` + PKB 隐藏科室选择
|
||||
- ✅ 被踢出提示 + 运营端模块权限弹窗 + 批量导入重构
|
||||
- ✅ 镜像构建推送:`ai-clinical_frontend-nginx:v2.3`(digest: sha256:db031053...)
|
||||
- ✅ SAE部署成功,内网地址变更:`172.17.197.32` → `172.17.173.104`
|
||||
|
||||
#### 环境变量同步
|
||||
- ✅ `frontend-nginx-service` 的 `BACKEND_SERVICE_HOST` 更新为 `172.17.197.36`
|
||||
- ℹ️ CLB 由阿里云自动更新,无需手动操作
|
||||
|
||||
#### 二次热修部署(同日)
|
||||
- ✅ SSA 双通道数据库迁移:ssa_sessions 新增 execution_mode + ssa_agent_executions 表
|
||||
- ✅ 前端/后端 bug 修复(基于测试反馈)
|
||||
- ✅ 后端 v2.6 → v2.7 → **v2.8**,前端 v2.3 → v2.4 → **v2.5**
|
||||
- ✅ 后端内网地址最终:`172.17.173.106`
|
||||
- ✅ 前端内网地址最终:`172.17.173.107`
|
||||
|
||||
#### 文档产出
|
||||
- ✅ `0305部署/01-部署完成总结.md`
|
||||
- ✅ `00-阿里云SAE最新真实状态记录.md`(更新)
|
||||
- ✅ `01-日常更新操作手册.md`(更新)
|
||||
- ✅ `03-待部署变更清单.md`(清零移入历史)
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-02(0302部署 - 数据库迁移6个 + IIT V3.1 QC引擎全面升级)
|
||||
|
||||
#### 部署概览
|
||||
@@ -637,5 +681,5 @@ AIclinicalresearch/extraction_service/
|
||||
---
|
||||
|
||||
> **提示**:本文档记录SAE服务器的最新真实状态,每次部署后必须更新!
|
||||
> **最后更新**:2026-03-02
|
||||
> **当前版本**:前端v2.0 | 后端v2.4 | Python v1.2 | R统计v1.0.1 | PostgreSQL 15
|
||||
> **最后更新**:2026-03-05
|
||||
> **当前版本**:前端v2.5 | 后端v2.8 | Python v1.2 | R统计v1.0.1 | PostgreSQL 15
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 日常更新操作手册
|
||||
|
||||
> 版本: v2.2(补充 0302 部署经验)
|
||||
> 更新日期: 2026-03-02
|
||||
> 版本: v2.4(0305 二次热修后更新)
|
||||
> 更新日期: 2026-03-05
|
||||
> 适用: 日常代码更新、功能迭代、配置变更
|
||||
|
||||
---
|
||||
@@ -27,7 +27,7 @@ docker login --username=gofeng117@163.com --password=fengzhibo117 crpi-cd5ij4pjt
|
||||
|
||||
## 2. Node.js 后端更新(~25 分钟)
|
||||
|
||||
**当前版本**: v2.4 → 下个版本: v2.5
|
||||
**当前版本**: v2.8 → 下个版本: v2.9
|
||||
|
||||
### 2.1 构建
|
||||
|
||||
@@ -39,7 +39,7 @@ npm run build
|
||||
# 或: npx tsc --noCheck
|
||||
|
||||
# 构建 Docker 镜像
|
||||
docker build -t backend-service:v2.5 .
|
||||
docker build -t backend-service:v2.9 .
|
||||
```
|
||||
|
||||
> **0227 经验**: `tsc` 不会拷贝 `.json` 配置文件,Dockerfile 中已有 `COPY src/modules/ssa/config/*.json` 等补丁步骤。如新模块有 JSON 配置文件需要确认 Dockerfile 覆盖到。
|
||||
@@ -47,9 +47,9 @@ docker build -t backend-service:v2.5 .
|
||||
### 2.2 推送
|
||||
|
||||
```powershell
|
||||
docker tag backend-service:v2.5 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.5
|
||||
docker tag backend-service:v2.9 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.9
|
||||
|
||||
docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.5
|
||||
docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.9
|
||||
```
|
||||
|
||||
推送约 10 分钟(~840MB),看到 `digest: sha256:...` 表示成功。
|
||||
@@ -57,7 +57,7 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica
|
||||
### 2.3 SAE 部署
|
||||
|
||||
1. SAE 控制台 → `nodejs-backend-test` → 部署应用
|
||||
2. 选择镜像 `backend-service:v2.5`(与上方构建版本一致)
|
||||
2. 选择镜像 `backend-service:v2.9`(与上方构建版本一致)
|
||||
3. 确认部署,等待 5-8 分钟
|
||||
|
||||
### 2.4 验证
|
||||
@@ -75,14 +75,14 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica
|
||||
|
||||
## 3. 前端 Nginx 更新(~15 分钟)
|
||||
|
||||
**当前版本**: v2.0 → 下个版本: v2.1
|
||||
**当前版本**: v2.5 → 下个版本: v2.6
|
||||
|
||||
### 3.1 构建
|
||||
|
||||
```powershell
|
||||
cd D:\MyCursor\AIclinicalresearch\frontend-v2
|
||||
|
||||
docker build -t ai-clinical_frontend-nginx:v2.1 .
|
||||
docker build -t ai-clinical_frontend-nginx:v2.6 .
|
||||
```
|
||||
|
||||
构建约 5 分钟(含 React 编译)。
|
||||
@@ -90,9 +90,9 @@ docker build -t ai-clinical_frontend-nginx:v2.1 .
|
||||
### 3.2 推送
|
||||
|
||||
```powershell
|
||||
docker tag ai-clinical_frontend-nginx:v2.1 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.1
|
||||
docker tag ai-clinical_frontend-nginx:v2.6 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.6
|
||||
|
||||
docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.1
|
||||
docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.6
|
||||
```
|
||||
|
||||
推送约 2 分钟(~50MB)。
|
||||
@@ -100,7 +100,7 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica
|
||||
### 3.3 SAE 部署
|
||||
|
||||
1. SAE 控制台 → `frontend-nginx-service` → 部署应用
|
||||
2. 选择镜像版本 `v2.1`(与上方构建版本一致)
|
||||
2. 选择镜像版本 `v2.6`(与上方构建版本一致)
|
||||
3. **检查环境变量**: `BACKEND_SERVICE_HOST` 指向最新后端 IP
|
||||
|
||||
### 3.4 验证
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行
|
||||
> **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录
|
||||
> **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒
|
||||
> **最后清零**: 2026-03-02(0302 部署完成后清零)
|
||||
> **最后清零**: 2026-03-05(0305 部署完成后清零)
|
||||
|
||||
---
|
||||
|
||||
@@ -15,37 +15,19 @@
|
||||
|
||||
| # | 变更内容 | 迁移文件 | 优先级 | 备注 |
|
||||
|---|---------|---------|--------|------|
|
||||
| DB-1 | modules 表新增 `AIA_PROTOCOL`、`RM` 模块注册,IIT 名称改为「CRA质控」 | 无(运行 seed 脚本) | 高 | 运行 `node scripts/seed-modules.js`(upsert,可重复执行) |
|
||||
| DB-2 | RVW Prompt 更新:期刊名称修正为「中华脑血管病杂志」 | 无(重新运行迁移脚本或后台编辑) | 高 | 运行 `npx tsx scripts/migrate-rvw-prompts.ts` 或在 `/admin/prompts/RVW_EDITORIAL` 手动编辑 |
|
||||
| DB-3 | SSA 双通道:ssa_sessions 新增 execution_mode 列 + 新建 ssa_agent_executions 表 | `manual_migrations/20260223_add_execution_mode_and_agent_tables.sql` | 中 | 手动执行 SQL 或通过 Prisma 迁移 |
|
||||
| — | *暂无* | | | |
|
||||
|
||||
### 后端变更 (Node.js)
|
||||
|
||||
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
||||
|---|---------|---------|---------|------|
|
||||
| BE-1 | 登录踢人机制:同一手机号只能一人同时在线 | `auth.service.ts`, `auth.middleware.ts`, `jwt.service.ts` | 重新构建镜像 | JWT 增加 tokenVersion,登录时递增版本号存入缓存,认证中间件校验版本号 |
|
||||
| BE-2 | 模块权限一致性修复:`/me/modules` API 现在尊重 user_modules 精细化配置 | `module.service.ts` | 重新构建镜像 | 之前 `/me/modules` 只看 tenant_modules,现在与登录逻辑一致 |
|
||||
| BE-3 | getModuleName 补充 RM、AIA_PROTOCOL、IIT 名称修正 | `userService.ts` | 重新构建镜像 | IIT Manager → CRA质控 |
|
||||
| BE-4 | RVW 稿约 Prompt 源文件修正 | `prompts/review_editorial_system.txt` | 重新构建镜像 | 中华医学超声杂志 → 中华脑血管病杂志 |
|
||||
| BE-5 | seed 数据:内部租户补充 RM、AIA_PROTOCOL 模块 | `prisma/seed.ts` | 仅影响新环境初始化 | — |
|
||||
| BE-6 | 用户模块配置校验放宽:不再限制必须在租户订阅范围内 | `userService.ts` | 重新构建镜像 | 校验改为「模块代码必须在 modules 表中存在」,支持给用户单独开通功能模块 |
|
||||
| BE-7 | 用户独立模块生效:user_modules 中的模块即使租户未订阅也纳入权限 | `module.service.ts` | 重新构建镜像 | 如 AIA_PROTOCOL 可单独配给用户 |
|
||||
| BE-8 | SSA 双通道架构:Agent 模式 4 服务 + ChatHandler 分流 + Session PATCH API | `Agent*.ts`, `CodeRunnerService.ts`, `ChatHandlerService.ts`, `chat.routes.ts`, `session.routes.ts` | 重新构建镜像 | 含 PlannerAgent / CoderAgent / ReviewerAgent / CodeRunner |
|
||||
| — | *暂无* | | | |
|
||||
|
||||
### 前端变更
|
||||
|
||||
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
||||
|---|---------|---------|---------|------|
|
||||
| FE-1 | ASL 模块:隐藏数据源/年限/篇数,默认 PubMed | `SetupPanel.tsx` | 重新构建镜像 | 高级筛选面板全部隐藏 |
|
||||
| FE-2 | ASL 模块:去掉「研究方案生成」「文献管理」,重新编号 1~6 | `ASLLayout.tsx` | 重新构建镜像 | — |
|
||||
| FE-3 | ASL 模块:默认进入智能文献检索(不再是标题摘要初筛) | `asl/index.tsx` | 重新构建镜像 | 路由 index → research/deep |
|
||||
| FE-4 | AIA 模块:删除「已接入DeepSeek」和搜索框 | `AgentHub.tsx` | 重新构建镜像 | — |
|
||||
| FE-5 | AIA 模块:Protocol Agent 按用户模块权限动态显示 | `AgentHub.tsx`, `constants.ts` | 重新构建镜像 | 通过 `hasModule('AIA_PROTOCOL')` 判断,需 DB-1 配合 |
|
||||
| FE-6 | AIA 模块:数据评价与预处理 / 智能统计分析链接修正 | `constants.ts` | 重新构建镜像 | `/dc` → `/research-management` |
|
||||
| FE-7 | 首页重定向到 `/ai-qa`,不再显示模块卡片首页 | `App.tsx` | 重新构建镜像 | — |
|
||||
| FE-8 | PKB 模块:创建知识库时隐藏科室选择,默认 General | `DashboardPage.tsx` | 重新构建镜像 | — |
|
||||
| FE-9 | 被踢出时前端提示「账号已在其他设备登录」 | `axios.ts` | 重新构建镜像 | 配合 BE-1 |
|
||||
| FE-10 | 运营端:用户模块权限弹窗显示所有模块(含 RM、AIA_PROTOCOL、CRA质控) | `ModulePermissionModal.tsx` | 重新构建镜像 | 未订阅模块标注"(未订阅)",不阻止配置 |
|
||||
| — | *暂无* | | | |
|
||||
|
||||
### Python 微服务变更
|
||||
|
||||
@@ -71,17 +53,6 @@
|
||||
|---|---------|------|------|
|
||||
| — | *暂无* | | |
|
||||
|
||||
### 部署顺序建议
|
||||
|
||||
1. **后端先部署**:构建并推送新镜像(包含 BE-1 ~ BE-5)
|
||||
2. **运行数据库脚本**:
|
||||
- `node scripts/seed-modules.js`(DB-1:注册 AIA_PROTOCOL、RM 模块)
|
||||
- `npx tsx scripts/migrate-rvw-prompts.ts`(DB-2:更新 RVW 稿约 Prompt)
|
||||
3. **前端部署**:构建并推送新镜像(包含 FE-1 ~ FE-9)
|
||||
4. **后台配置**:
|
||||
- 为内部测试人员所在租户开通 `AIA_PROTOCOL` 模块
|
||||
- 确认 SSA/ST/IIT/RM 模块权限按需分配给对应租户和用户
|
||||
|
||||
---
|
||||
|
||||
## 记录模板
|
||||
@@ -112,6 +83,22 @@
|
||||
|
||||
## 历史(已部署,仅供追溯)
|
||||
|
||||
### 0305 部署已清零项
|
||||
|
||||
| # | 变更内容 | 部署日期 | 结果 |
|
||||
|---|---------|---------|------|
|
||||
| DB | modules 表 seed 更新(新增 RM、AIA_PROTOCOL,IIT→CRA质控) | 2026-03-05 | ✅ |
|
||||
| BE | Node.js v2.4 → v2.6(登录踢人 + 权限体系 + SSA双通道 + 批量导入,9 项变更) | 2026-03-05 | ✅ |
|
||||
| FE | 前端 v2.0 → v2.3(ASL/AIA/PKB UI优化 + 权限适配 + 批量导入重构,11 项变更) | 2026-03-05 | ✅ |
|
||||
| ENV | frontend-nginx-service: BACKEND_SERVICE_HOST → 172.17.197.36 | 2026-03-05 | ✅ |
|
||||
| DB | SSA 双通道:ssa_sessions 新增 execution_mode + ssa_agent_executions 表 | 2026-03-05 | ✅ 热修 |
|
||||
| BE | Node.js v2.6 → v2.7(bug 修复,基于测试反馈) | 2026-03-05 | ✅ 二次部署 |
|
||||
| FE | 前端 v2.3 → v2.4(bug 修复,基于测试反馈) | 2026-03-05 | ✅ 二次部署 |
|
||||
| ENV | frontend-nginx-service: BACKEND_SERVICE_HOST → 172.17.197.37 | 2026-03-05 | ✅ 二次部署 |
|
||||
| BE | Node.js v2.7 → v2.8(bug 修复) | 2026-03-05 | ✅ 三次部署 |
|
||||
| FE | 前端 v2.4 → v2.5(bug 修复) | 2026-03-05 | ✅ 三次部署 |
|
||||
| ENV | frontend-nginx-service: BACKEND_SERVICE_HOST → 172.17.173.106 | 2026-03-05 | ✅ 三次部署 |
|
||||
|
||||
### 0302 部署已清零项
|
||||
|
||||
| # | 变更内容 | 部署日期 | 结果 |
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
目标租户:<Tag color="blue">{selectedTenant?.name}</Tag>
|
||||
</li>
|
||||
<li>支持 .xlsx 或 .xls 格式的 Excel 文件</li>
|
||||
<li>必填字段:手机号、姓名</li>
|
||||
<li>角色可选值:SUPER_ADMIN、HOSPITAL_ADMIN、PHARMA_ADMIN、DEPARTMENT_ADMIN、USER</li>
|
||||
<li>模块字段使用逗号分隔,如:AIA,PKB,RVW</li>
|
||||
<li>角色可选值:SUPER_ADMIN、HOSPITAL_ADMIN、PHARMA_ADMIN、DEPARTMENT_ADMIN、USER(默认 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;
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
*/
|
||||
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
|
||||
// 懒加载组件
|
||||
const ASLLayout = lazy(() => import('./components/ASLLayout'));
|
||||
@@ -36,6 +37,22 @@ const SRChartGenerator = lazy(() => import('./pages/SRChartGenerator'));
|
||||
// 工具 5:Meta 分析引擎
|
||||
const MetaAnalysisEngine = lazy(() => import('./pages/MetaAnalysisEngine'));
|
||||
|
||||
const ExtractionWorkbenchGuide = () => {
|
||||
const nav = useNavigate();
|
||||
return (
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user