feat(admin,rvw,asl,frontend): Batch import redesign + RVW parallel skills + UI improvements

Backend:
- Redesign batch user import: add autoInheritModules param, users auto-inherit tenant modules when true
- Add module validation: reject modules not subscribed by the tenant
- Soften department validation: skip instead of fail when department name not found
- Fix RVW skill status semantics: review findings (ERROR issues) no longer mark skill as error
- Add parallel execution support to SkillExecutor via parallelGroup
- Configure Editorial + Methodology skills to run in parallel (~240s -> ~130s)
- Update legacy bridge error message to user-friendly text

Frontend:
- Redesign ImportUserModal: 4-step flow (select tenant -> upload -> preview -> result)
- Simplify import template: remove tenant code and module columns
- Show tenant subscribed modules before import with auto-inherit option
- Fix isLegacyEmbed modules bypassing RouteGuard and TopNavigation permission checks
- Hide ASL fulltext screening (step 3), renumber subsequent nav items
- Add ExtractionWorkbenchGuide page when no taskId provided
- Update legacy system error message to network-friendly text

Docs:
- Update deployment changelog with BE-9, FE-11 entries

Made-with: Cursor
This commit is contained in:
2026-03-05 22:04:36 +08:00
parent 0677d42345
commit 91ae80888e
19 changed files with 576 additions and 274 deletions

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,39 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
}
/**
* 执行 Pipeline
* 将 pipeline 按 parallelGroup 分段:无 group 的单独一段,相同 group 的合并为一段
*/
private buildStages(pipeline: PipelineItem[]): PipelineItem[][] {
const stages: PipelineItem[][] = [];
let currentGroup: string | undefined;
let currentBatch: PipelineItem[] = [];
for (const item of pipeline) {
if (!item.parallelGroup) {
if (currentBatch.length > 0) {
stages.push(currentBatch);
currentBatch = [];
currentGroup = undefined;
}
stages.push([item]);
} else if (item.parallelGroup === currentGroup) {
currentBatch.push(item);
} else {
if (currentBatch.length > 0) {
stages.push(currentBatch);
}
currentGroup = item.parallelGroup;
currentBatch = [item];
}
}
if (currentBatch.length > 0) {
stages.push(currentBatch);
}
return stages;
}
/**
* 执行 Pipeline支持 parallelGroup 并行分组)
*/
async execute(
profile: JournalProfile,
@@ -53,71 +85,62 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
const startTime = Date.now();
const results: SkillResult[] = [];
// 构建完整上下文
const context = {
...initialContext,
profile,
previousResults: [],
} as unknown as TContext;
const stages = this.buildStages(profile.pipeline);
logger.info('[SkillExecutor] Starting pipeline execution', {
taskId: context.taskId,
profileId: profile.id,
pipelineLength: profile.pipeline.length,
stageCount: stages.length,
parallelStages: stages.filter(s => s.length > 1).length,
});
// 遍历 Pipeline
for (const item of profile.pipeline) {
// 跳过禁用的 Skill
if (!item.enabled) {
logger.debug('[SkillExecutor] Skill disabled, skipping', { skillId: item.skillId });
results.push(this.createSkippedResult(item.skillId, 'Skill disabled in profile'));
continue;
}
let shouldBreak = false;
// 获取 Skill
const skill = SkillRegistry.get(item.skillId);
if (!skill) {
logger.warn('[SkillExecutor] Skill not found in registry', { skillId: item.skillId });
results.push(this.createSkippedResult(item.skillId, 'Skill not found'));
continue;
}
for (const stage of stages) {
if (shouldBreak) break;
// 前置检查
if (skill.canRun && !skill.canRun(context as unknown as SkillContext)) {
logger.info('[SkillExecutor] Skill pre-check failed, skipping', { skillId: item.skillId });
results.push(this.createSkippedResult(item.skillId, 'Pre-check failed'));
continue;
}
// 执行 Skill
const result = await this.executeSkill(skill, context as unknown as SkillContext, item, profile);
results.push(result);
// 调用完成回调V2.1 扩展点)
if (this.config.onSkillComplete) {
try {
await this.config.onSkillComplete(item.skillId, result, context);
} catch (callbackError: unknown) {
const errorMessage = callbackError instanceof Error ? callbackError.message : String(callbackError);
logger.error('[SkillExecutor] onSkillComplete callback failed', { skillId: item.skillId, error: errorMessage });
if (stage.length === 1) {
// 单个 Skill — 串行执行(保持原逻辑)
const item = stage[0];
const result = await this.executePipelineItem(item, context, profile);
if (result) {
results.push(result);
context.previousResults.push(result);
const skill = SkillRegistry.get(item.skillId);
if (skill) this.updateContextWithResult(context, skill, result);
if (result.status === 'error' && !this.shouldContinue(item, profile)) {
shouldBreak = true;
}
}
}
} else {
// 多个 Skill — 并行执行
logger.info('[SkillExecutor] Executing parallel stage', {
taskId: context.taskId,
skillIds: stage.map(s => s.skillId),
});
// 更新上下文(传递给后续 Skills
context.previousResults.push(result);
const promises = stage.map(item => this.executePipelineItem(item, context, profile));
const stageResults = await Promise.all(promises);
// 更新共享数据
this.updateContextWithResult(context, skill, result);
// 检查是否需要中断
if (result.status === 'error' && !this.shouldContinue(item, profile)) {
logger.warn('[SkillExecutor] Skill failed and continueOnError=false, stopping', { skillId: item.skillId });
break;
for (let i = 0; i < stage.length; i++) {
const result = stageResults[i];
if (result) {
results.push(result);
context.previousResults.push(result);
const skill = SkillRegistry.get(stage[i].skillId);
if (skill) this.updateContextWithResult(context, skill, result);
}
}
}
}
// 生成汇总
const summary = this.buildSummary(context.taskId, profile.id, results, startTime);
logger.info('[SkillExecutor] Pipeline execution completed', {
@@ -131,6 +154,43 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
return summary;
}
/**
* 执行单个 PipelineItem含跳过/校验/回调逻辑),返回 null 表示跳过
*/
private async executePipelineItem(
item: PipelineItem,
context: TContext,
profile: JournalProfile
): Promise<SkillResult | null> {
if (!item.enabled) {
return this.createSkippedResult(item.skillId, 'Skill disabled in profile');
}
const skill = SkillRegistry.get(item.skillId);
if (!skill) {
logger.warn('[SkillExecutor] Skill not found in registry', { skillId: item.skillId });
return this.createSkippedResult(item.skillId, 'Skill not found');
}
if (skill.canRun && !skill.canRun(context as unknown as SkillContext)) {
logger.info('[SkillExecutor] Skill pre-check failed, skipping', { skillId: item.skillId });
return this.createSkippedResult(item.skillId, 'Pre-check failed');
}
const result = await this.executeSkill(skill, context as unknown as SkillContext, item, profile);
if (this.config.onSkillComplete) {
try {
await this.config.onSkillComplete(item.skillId, result, context);
} catch (callbackError: unknown) {
const errorMessage = callbackError instanceof Error ? callbackError.message : String(callbackError);
logger.error('[SkillExecutor] onSkillComplete callback failed', { skillId: item.skillId, error: errorMessage });
}
}
return result;
}
/**
* 执行单个 Skill带超时和重试
*/

View File

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

View File

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

View File

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

View File

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

View File

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