feat(rvw): complete journal config center MVP and tenant login routing
Deliver the RVW V4.0 journal configuration center across backend, frontend, migration, and docs with zh/en editorial baseline support and tenant-level prompt/template overrides. Unify tenant login to /:tenantCode/login and auto-enable RVW module when tenant type is JOURNAL to prevent post-login access gaps. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
-- RVW V4.0 期刊配置中心 MVP
|
||||
-- 一次性补齐 tenants(P0/P1/P2+language) 与 tenant_rvw_configs(4维Prompt+Template)
|
||||
|
||||
-- 1) 枚举:JournalLanguage
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'JournalLanguage'
|
||||
AND n.nspname = 'platform_schema'
|
||||
) THEN
|
||||
CREATE TYPE "platform_schema"."JournalLanguage" AS ENUM ('ZH', 'EN', 'OTHER');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 2) 枚举:TenantType 增加 JOURNAL
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TYPE "platform_schema"."TenantType" ADD VALUE IF NOT EXISTS 'JOURNAL';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- 3) tenants 表新增字段
|
||||
ALTER TABLE "platform_schema"."tenants"
|
||||
ADD COLUMN IF NOT EXISTS "journal_language" "platform_schema"."JournalLanguage",
|
||||
ADD COLUMN IF NOT EXISTS "journal_full_name" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "logo_url" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "brand_color" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "login_background_url" TEXT;
|
||||
|
||||
ALTER TABLE "platform_schema"."tenants"
|
||||
ALTER COLUMN "journal_language" SET DEFAULT 'ZH';
|
||||
|
||||
-- 4) tenant_rvw_configs 表升级为 Prompt/Template 统一结构
|
||||
ALTER TABLE "platform_schema"."tenant_rvw_configs"
|
||||
ADD COLUMN IF NOT EXISTS "editorial_base_standard" TEXT NOT NULL DEFAULT 'en',
|
||||
ADD COLUMN IF NOT EXISTS "editorial_expert_prompt" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "editorial_handlebars_template" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "data_forensics_expert_prompt" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "data_forensics_handlebars_template" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "clinical_handlebars_template" TEXT;
|
||||
|
||||
ALTER TABLE "platform_schema"."tenant_rvw_configs"
|
||||
DROP COLUMN IF EXISTS "editorial_rules",
|
||||
DROP COLUMN IF EXISTS "data_forensics_level",
|
||||
DROP COLUMN IF EXISTS "finer_weights";
|
||||
@@ -1915,6 +1915,11 @@ model tenants {
|
||||
code String @unique
|
||||
name String
|
||||
type TenantType
|
||||
journal_language JournalLanguage? @default(ZH)
|
||||
journal_full_name String?
|
||||
logo_url String?
|
||||
brand_color String?
|
||||
login_background_url String?
|
||||
status TenantStatus @default(ACTIVE)
|
||||
config Json? @default("{}")
|
||||
total_quota BigInt @default(0)
|
||||
@@ -1964,8 +1969,14 @@ model TenantRvwConfig {
|
||||
tenantId String @unique @map("tenant_id")
|
||||
tenant tenants @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// 稿约规范评估:规则数组,每条规则含 code/description/fatal
|
||||
editorialRules Json? @map("editorial_rules")
|
||||
/// 稿约规范评估:默认基线(zh/en),用于继承模式的系统默认 Prompt 路由
|
||||
editorialBaseStandard String @default("en") @map("editorial_base_standard")
|
||||
|
||||
/// 稿约规范评估:租户自定义业务 Prompt(null 表示继承系统默认)
|
||||
editorialExpertPrompt String? @map("editorial_expert_prompt") @db.Text
|
||||
|
||||
/// 稿约规范评估:租户自定义报告模板(null 表示继承系统默认)
|
||||
editorialHandlebarsTemplate String? @map("editorial_handlebars_template") @db.Text
|
||||
|
||||
/// 方法学评估:专家业务评判标准(纯文本,可自由编辑,不需懂JSON)
|
||||
methodologyExpertPrompt String? @map("methodology_expert_prompt") @db.Text
|
||||
@@ -1973,15 +1984,18 @@ model TenantRvwConfig {
|
||||
/// 方法学评估:Handlebars 报告展示模板(可覆盖系统默认模板)
|
||||
methodologyHandlebarsTemplate String? @map("methodology_handlebars_template") @db.Text
|
||||
|
||||
/// 数据验证:验证深度 L1/L2/L3
|
||||
dataForensicsLevel String @default("L2") @map("data_forensics_level")
|
||||
/// 数据验证:租户自定义业务 Prompt(null 表示继承系统默认)
|
||||
dataForensicsExpertPrompt String? @map("data_forensics_expert_prompt") @db.Text
|
||||
|
||||
/// 临床评估:FINER 五维权重 {feasibility, innovation, ethics, relevance, novelty}
|
||||
finerWeights Json? @map("finer_weights")
|
||||
/// 数据验证:租户自定义报告模板(null 表示继承系统默认)
|
||||
dataForensicsHandlebarsTemplate String? @map("data_forensics_handlebars_template") @db.Text
|
||||
|
||||
/// 临床评估:专科特色补充要求(纯文本)
|
||||
clinicalExpertPrompt String? @map("clinical_expert_prompt") @db.Text
|
||||
|
||||
/// 临床评估:租户自定义报告模板(null 表示继承系统默认)
|
||||
clinicalHandlebarsTemplate String? @map("clinical_handlebars_template") @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -2011,12 +2025,21 @@ enum TenantStatus {
|
||||
enum TenantType {
|
||||
HOSPITAL // 医院
|
||||
PHARMA // 药企
|
||||
JOURNAL // 期刊
|
||||
INTERNAL // 内部(公司自己)
|
||||
PUBLIC // 个人用户公共池
|
||||
|
||||
@@schema("platform_schema")
|
||||
}
|
||||
|
||||
enum JournalLanguage {
|
||||
ZH
|
||||
EN
|
||||
OTHER
|
||||
|
||||
@@schema("platform_schema")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
SUPER_ADMIN
|
||||
PROMPT_ENGINEER
|
||||
|
||||
496
backend/scripts/test-rvw-journal-e2e.ts
Normal file
496
backend/scripts/test-rvw-journal-e2e.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* RVW V4.0 期刊配置中心 E2E 脚本
|
||||
*
|
||||
* 覆盖链路:
|
||||
* 1) 登录(密码)
|
||||
* 2) 期刊配置(journalLanguage + editorialBaseStandard)
|
||||
* 3) 业务端上传稿件并执行审评
|
||||
* 4) 中英文场景使用不同 Skills
|
||||
* 5) 轮询任务完成并输出结果快照
|
||||
*
|
||||
* 运行方式(PowerShell):
|
||||
* npx tsx scripts/test-rvw-journal-e2e.ts
|
||||
*
|
||||
* 可选环境变量:
|
||||
* - RVW_E2E_BASE_URL=http://localhost:3001
|
||||
* - RVW_E2E_PHONE=13900139001
|
||||
* - RVW_E2E_PASSWORD=Test@1234
|
||||
* - RVW_E2E_ZH_TENANT_CODE=cmj
|
||||
* - RVW_E2E_EN_TENANT_CODE=jtim
|
||||
* - RVW_E2E_POLL_INTERVAL_MS=8000
|
||||
* - RVW_E2E_MAX_POLLS=180
|
||||
*/
|
||||
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||
import FormData from 'form-data';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
type AgentType = 'editorial' | 'methodology' | 'clinical';
|
||||
type JournalLanguage = 'ZH' | 'EN' | 'OTHER';
|
||||
type EditorialBaseStandard = 'zh' | 'en';
|
||||
type TaskStatus = 'pending' | 'extracting' | 'reviewing' | 'completed' | 'partial_completed' | 'failed';
|
||||
|
||||
interface LoginResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
tokens?: {
|
||||
accessToken?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface JournalItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
journalLanguage?: JournalLanguage | null;
|
||||
}
|
||||
|
||||
interface JournalListResponse {
|
||||
success: boolean;
|
||||
data: JournalItem[];
|
||||
}
|
||||
|
||||
interface CreateTaskResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
taskId: string;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface RunTaskResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
taskId: string;
|
||||
jobId: string;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface TaskDetailResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
id: string;
|
||||
status: TaskStatus;
|
||||
errorMessage?: string | null;
|
||||
overallScore?: number | null;
|
||||
editorialScore?: number | null;
|
||||
methodologyScore?: number | null;
|
||||
modelUsed?: string | null;
|
||||
completedAt?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
tenantCode: string;
|
||||
journalLanguage: JournalLanguage;
|
||||
editorialBaseStandard: EditorialBaseStandard;
|
||||
filePath: string;
|
||||
agents: AgentType[];
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
|
||||
const BASE_URL = process.env.RVW_E2E_BASE_URL || 'http://localhost:3001';
|
||||
const PHONE = process.env.RVW_E2E_PHONE || '13900139001';
|
||||
const PASSWORD = process.env.RVW_E2E_PASSWORD || 'Test@1234';
|
||||
const ZH_TENANT_CODE = (process.env.RVW_E2E_ZH_TENANT_CODE || 'cmj').toLowerCase();
|
||||
const EN_TENANT_CODE = (process.env.RVW_E2E_EN_TENANT_CODE || 'jtim').toLowerCase();
|
||||
const POLL_INTERVAL_MS = Number(process.env.RVW_E2E_POLL_INTERVAL_MS || 8000);
|
||||
const MAX_POLLS = Number(process.env.RVW_E2E_MAX_POLLS || 180);
|
||||
const ALLOW_DB_FALLBACK = (process.env.RVW_E2E_ALLOW_DB_FALLBACK || 'true') === 'true';
|
||||
const SCENARIO_FILTER = (process.env.RVW_E2E_SCENARIOS || '').trim();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
id: 'zh-docx',
|
||||
name: '中文期刊场景(稿约+方法学)',
|
||||
tenantCode: ZH_TENANT_CODE,
|
||||
journalLanguage: 'ZH',
|
||||
editorialBaseStandard: 'zh',
|
||||
filePath: path.resolve(
|
||||
repoRoot,
|
||||
'docs/03-业务模块/RVW-稿件审查系统/05-测试文档/骶骨瘤患者围术期大量输血的术前危险因素分析及输血策略2月27 - 副本.docx'
|
||||
),
|
||||
agents: ['editorial', 'methodology'],
|
||||
},
|
||||
{
|
||||
id: 'en-pdf',
|
||||
name: '英文期刊场景(稿约+临床)',
|
||||
tenantCode: EN_TENANT_CODE,
|
||||
journalLanguage: 'EN',
|
||||
editorialBaseStandard: 'en',
|
||||
filePath: path.resolve(
|
||||
repoRoot,
|
||||
'docs/03-业务模块/RVW-稿件审查系统/05-测试文档/Dongen 2003.pdf'
|
||||
),
|
||||
agents: ['editorial', 'clinical'],
|
||||
},
|
||||
];
|
||||
|
||||
function getEnabledScenarios(): Scenario[] {
|
||||
if (!SCENARIO_FILTER) return scenarios;
|
||||
const ids = new Set(
|
||||
SCENARIO_FILTER
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
return scenarios.filter(s => ids.has(s.id));
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function createClient(token?: string): AxiosInstance {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 120000,
|
||||
headers,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
function unwrapError(error: unknown): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const e = error as AxiosError<any>;
|
||||
const status = e.response?.status;
|
||||
const message = (e.response?.data as any)?.message || e.message;
|
||||
return `[HTTP ${status ?? 'N/A'}] ${message}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function loginByPassword(): Promise<string> {
|
||||
const client = createClient();
|
||||
const resp = await client.post<LoginResponse>('/api/v1/auth/login/password', {
|
||||
phone: PHONE,
|
||||
password: PASSWORD,
|
||||
});
|
||||
if (resp.status !== 200 || !resp.data?.success || !resp.data?.data?.tokens?.accessToken) {
|
||||
throw new Error(`登录失败: ${resp.status} ${resp.data?.message || ''}`.trim());
|
||||
}
|
||||
return resp.data.data.tokens.accessToken;
|
||||
}
|
||||
|
||||
async function getJournalByCode(client: AxiosInstance, code: string): Promise<JournalItem> {
|
||||
const resp = await client.get<JournalListResponse>('/api/admin/journal-configs', {
|
||||
params: { search: code, page: 1, limit: 200 },
|
||||
});
|
||||
if (resp.status !== 200 || !resp.data?.success || !Array.isArray(resp.data.data)) {
|
||||
throw new Error(`获取期刊列表失败: ${resp.status}`);
|
||||
}
|
||||
const exact = resp.data.data.find(item => item.code.toLowerCase() === code.toLowerCase());
|
||||
if (!exact) {
|
||||
throw new Error(`未找到期刊租户 code=${code},请先在管理端创建并启用 JOURNAL 租户`);
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
|
||||
async function getJournalByCodeFromDb(code: string): Promise<JournalItem> {
|
||||
const tenant = await prisma.tenants.findUnique({
|
||||
where: { code },
|
||||
select: { id: true, code: true, name: true, journal_language: true },
|
||||
});
|
||||
if (!tenant) {
|
||||
throw new Error(`DB fallback 失败:未找到租户 code=${code}`);
|
||||
}
|
||||
return {
|
||||
id: tenant.id,
|
||||
code: tenant.code,
|
||||
name: tenant.name,
|
||||
journalLanguage: (tenant.journal_language as JournalLanguage | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function configureJournal(
|
||||
client: AxiosInstance,
|
||||
journalId: string,
|
||||
journalLanguage: JournalLanguage,
|
||||
editorialBaseStandard: EditorialBaseStandard
|
||||
) {
|
||||
const basicResp = await client.put(`/api/admin/journal-configs/${journalId}/basic-info`, {
|
||||
journalLanguage,
|
||||
});
|
||||
if (basicResp.status < 200 || basicResp.status >= 300) {
|
||||
throw new Error(`更新期刊基础信息失败: ${basicResp.status} ${(basicResp.data as any)?.message || ''}`.trim());
|
||||
}
|
||||
|
||||
const rvwResp = await client.put(`/api/admin/journal-configs/${journalId}/rvw-config`, {
|
||||
editorialBaseStandard,
|
||||
// 继承系统默认,验证 null 语义
|
||||
editorialExpertPrompt: null,
|
||||
methodologyExpertPrompt: null,
|
||||
dataForensicsExpertPrompt: null,
|
||||
clinicalExpertPrompt: null,
|
||||
});
|
||||
if (rvwResp.status < 200 || rvwResp.status >= 300) {
|
||||
throw new Error(`更新期刊审稿配置失败: ${rvwResp.status} ${(rvwResp.data as any)?.message || ''}`.trim());
|
||||
}
|
||||
}
|
||||
|
||||
async function configureJournalByDb(
|
||||
journalId: string,
|
||||
journalLanguage: JournalLanguage,
|
||||
editorialBaseStandard: EditorialBaseStandard
|
||||
) {
|
||||
await prisma.tenants.update({
|
||||
where: { id: journalId },
|
||||
data: {
|
||||
journal_language: journalLanguage as any,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.tenantRvwConfig.upsert({
|
||||
where: { tenantId: journalId },
|
||||
create: {
|
||||
tenantId: journalId,
|
||||
editorialBaseStandard,
|
||||
editorialExpertPrompt: null,
|
||||
methodologyExpertPrompt: null,
|
||||
dataForensicsExpertPrompt: null,
|
||||
clinicalExpertPrompt: null,
|
||||
},
|
||||
update: {
|
||||
editorialBaseStandard,
|
||||
editorialExpertPrompt: null,
|
||||
methodologyExpertPrompt: null,
|
||||
dataForensicsExpertPrompt: null,
|
||||
clinicalExpertPrompt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadTask(
|
||||
client: AxiosInstance,
|
||||
tenantCode: string,
|
||||
filePath: string
|
||||
): Promise<string> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`测试文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', fs.createReadStream(filePath), path.basename(filePath));
|
||||
form.append('modelType', 'deepseek-v3');
|
||||
|
||||
const resp = await client.post<CreateTaskResponse>('/api/v1/rvw/tasks', form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
'x-tenant-id': tenantCode,
|
||||
},
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
|
||||
if (resp.status !== 200 || !resp.data?.success || !resp.data?.data?.taskId) {
|
||||
throw new Error(`上传稿件失败: ${resp.status} ${(resp.data as any)?.message || ''}`.trim());
|
||||
}
|
||||
return resp.data.data.taskId;
|
||||
}
|
||||
|
||||
async function runTask(client: AxiosInstance, tenantCode: string, taskId: string, agents: AgentType[]): Promise<string> {
|
||||
// 文档提取为异步,run 可能返回“尚未提取完成”,这里做重试以稳定 E2E
|
||||
for (let i = 1; i <= 30; i += 1) {
|
||||
const resp = await client.post<RunTaskResponse>(
|
||||
`/api/v1/rvw/tasks/${taskId}/run`,
|
||||
{ agents },
|
||||
{ headers: { 'x-tenant-id': tenantCode } }
|
||||
);
|
||||
if (resp.status === 200 && resp.data?.success && resp.data?.data?.jobId) {
|
||||
return resp.data.data.jobId;
|
||||
}
|
||||
|
||||
const message = String((resp.data as any)?.message || '');
|
||||
if (resp.status === 400 && message.includes('文档尚未提取完成')) {
|
||||
await sleep(2000);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`执行审评失败: ${resp.status} ${message}`.trim());
|
||||
}
|
||||
throw new Error('执行审评超时:文档提取长时间未就绪');
|
||||
}
|
||||
|
||||
async function pollTask(client: AxiosInstance, tenantCode: string, taskId: string) {
|
||||
let lastStatus: TaskStatus | null = null;
|
||||
for (let i = 1; i <= MAX_POLLS; i += 1) {
|
||||
const resp = await client.get<TaskDetailResponse>(`/api/v1/rvw/tasks/${taskId}`, {
|
||||
headers: { 'x-tenant-id': tenantCode },
|
||||
});
|
||||
if (resp.status !== 200 || !resp.data?.success || !resp.data?.data) {
|
||||
throw new Error(`查询任务失败: ${resp.status} ${(resp.data as any)?.message || ''}`.trim());
|
||||
}
|
||||
|
||||
const data = resp.data.data;
|
||||
if (data.status !== lastStatus) {
|
||||
lastStatus = data.status;
|
||||
console.log(` - 任务状态: ${data.status}`);
|
||||
}
|
||||
|
||||
if (data.status === 'completed' || data.status === 'partial_completed' || data.status === 'failed') {
|
||||
return data;
|
||||
}
|
||||
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`任务轮询超时(>${MAX_POLLS} 次)`);
|
||||
}
|
||||
|
||||
async function runScenario(client: AxiosInstance, scenario: Scenario) {
|
||||
console.log(`\n▶ 场景: ${scenario.name}`);
|
||||
console.log(` 期刊租户: ${scenario.tenantCode}`);
|
||||
console.log(` 测试文件: ${scenario.filePath}`);
|
||||
console.log(` Skills: ${scenario.agents.join(', ')}`);
|
||||
|
||||
let journal: JournalItem;
|
||||
let configMode: 'api' | 'db-fallback' = 'api';
|
||||
|
||||
try {
|
||||
journal = await getJournalByCode(client, scenario.tenantCode);
|
||||
} catch (error) {
|
||||
const msg = unwrapError(error);
|
||||
if (!ALLOW_DB_FALLBACK || !msg.includes('403')) throw error;
|
||||
journal = await getJournalByCodeFromDb(scenario.tenantCode);
|
||||
configMode = 'db-fallback';
|
||||
}
|
||||
console.log(` 已找到期刊: ${journal.name} (${journal.code})`);
|
||||
|
||||
try {
|
||||
await configureJournal(client, journal.id, scenario.journalLanguage, scenario.editorialBaseStandard);
|
||||
configMode = 'api';
|
||||
} catch (error) {
|
||||
const msg = unwrapError(error);
|
||||
if (!ALLOW_DB_FALLBACK || !msg.includes('403')) throw error;
|
||||
await configureJournalByDb(journal.id, scenario.journalLanguage, scenario.editorialBaseStandard);
|
||||
configMode = 'db-fallback';
|
||||
}
|
||||
console.log(` 已配置期刊(${configMode}): journalLanguage=${scenario.journalLanguage}, editorialBaseStandard=${scenario.editorialBaseStandard}`);
|
||||
|
||||
const taskId = await uploadTask(client, scenario.tenantCode, scenario.filePath);
|
||||
console.log(` 上传成功,taskId=${taskId}`);
|
||||
|
||||
const jobId = await runTask(client, scenario.tenantCode, taskId, scenario.agents);
|
||||
console.log(` 已启动审评,jobId=${jobId}`);
|
||||
|
||||
const finalTask = await pollTask(client, scenario.tenantCode, taskId);
|
||||
console.log(` 场景完成,最终状态=${finalTask.status}`);
|
||||
|
||||
let reportStatus = 'not-requested';
|
||||
if (finalTask.status !== 'failed') {
|
||||
const reportResp = await client.get(`/api/v1/rvw/tasks/${taskId}/report`, {
|
||||
headers: { 'x-tenant-id': scenario.tenantCode },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
reportStatus = `${reportResp.status}`;
|
||||
}
|
||||
|
||||
return {
|
||||
scenarioId: scenario.id,
|
||||
scenarioName: scenario.name,
|
||||
tenantCode: scenario.tenantCode,
|
||||
journalId: journal.id,
|
||||
configMode,
|
||||
journalLanguage: scenario.journalLanguage,
|
||||
editorialBaseStandard: scenario.editorialBaseStandard,
|
||||
filePath: scenario.filePath,
|
||||
agents: scenario.agents,
|
||||
taskId,
|
||||
jobId,
|
||||
finalStatus: finalTask.status,
|
||||
overallScore: finalTask.overallScore ?? null,
|
||||
editorialScore: finalTask.editorialScore ?? null,
|
||||
methodologyScore: finalTask.methodologyScore ?? null,
|
||||
modelUsed: finalTask.modelUsed ?? null,
|
||||
completedAt: finalTask.completedAt ?? null,
|
||||
errorMessage: finalTask.errorMessage ?? null,
|
||||
reportStatus,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('RVW V4.0 期刊配置中心 E2E 测试');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log(`BASE_URL: ${BASE_URL}`);
|
||||
console.log(`登录账号: ${PHONE}`);
|
||||
console.log(`中文租户: ${ZH_TENANT_CODE} | 英文租户: ${EN_TENANT_CODE}`);
|
||||
console.log(`轮询参数: interval=${POLL_INTERVAL_MS}ms, max=${MAX_POLLS}`);
|
||||
console.log(`配置模式: API优先,DB fallback=${ALLOW_DB_FALLBACK ? '启用' : '禁用'}`);
|
||||
console.log(`场景过滤: ${SCENARIO_FILTER || '全部'}`);
|
||||
|
||||
const startedAt = new Date();
|
||||
const token = await loginByPassword();
|
||||
console.log('\n✅ 登录成功');
|
||||
|
||||
const client = createClient(token);
|
||||
const results: any[] = [];
|
||||
|
||||
const enabledScenarios = getEnabledScenarios();
|
||||
for (const scenario of enabledScenarios) {
|
||||
try {
|
||||
const result = await runScenario(client, scenario);
|
||||
results.push({ success: true, ...result });
|
||||
} catch (error) {
|
||||
const message = unwrapError(error);
|
||||
console.error(` ❌ 场景失败: ${message}`);
|
||||
results.push({
|
||||
success: false,
|
||||
scenarioId: scenario.id,
|
||||
scenarioName: scenario.name,
|
||||
tenantCode: scenario.tenantCode,
|
||||
filePath: scenario.filePath,
|
||||
agents: scenario.agents,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const outputDir = path.resolve(repoRoot, 'backend/scripts/output');
|
||||
await fs.promises.mkdir(outputDir, { recursive: true });
|
||||
const outputPath = path.resolve(outputDir, `rvw-journal-e2e-${Date.now()}.json`);
|
||||
|
||||
const summary = {
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
durationSeconds: Math.round((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
baseUrl: BASE_URL,
|
||||
operatorPhone: PHONE,
|
||||
scenarios: results,
|
||||
};
|
||||
|
||||
await fs.promises.writeFile(outputPath, JSON.stringify(summary, null, 2), 'utf-8');
|
||||
|
||||
const passed = results.filter(r => r.success).length;
|
||||
const failed = results.length - passed;
|
||||
console.log('\n═══════════════════════════════════════════════════════');
|
||||
console.log(`完成:通过 ${passed},失败 ${failed}`);
|
||||
console.log(`结果文件:${outputPath}`);
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
|
||||
if (failed > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('\n❌ 脚本执行失败:', unwrapError(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -21,6 +21,52 @@ interface FallbackPrompt {
|
||||
* RVW 模块兜底 Prompt
|
||||
*/
|
||||
const RVW_FALLBACKS: Record<string, FallbackPrompt> = {
|
||||
RVW_EDITORIAL_EN: {
|
||||
content: `You are a rigorous medical journal editor. Review the manuscript against international ICMJE-style editorial compliance.
|
||||
|
||||
Fatal checks (must flag as fatal if violated):
|
||||
1) Ethics and informed consent statements for human/animal research.
|
||||
2) Conflict of interest and funding statements (including "None declared" when absent).
|
||||
3) AI authorship compliance (AI tools must not be listed as authors).
|
||||
4) Blind-review leakage in main text (author/institution/funding identifiers).
|
||||
|
||||
Major checks:
|
||||
5) Abstract structure and length compliance by manuscript type.
|
||||
6) Statistical expression standards (exact P values, notation consistency).
|
||||
7) Citation-reference consistency and formatting integrity.
|
||||
8) Figure/table editability and in-text cross-reference integrity.
|
||||
|
||||
Minor checks:
|
||||
9) Consistent English variant (US or UK).
|
||||
10) Number spelling conventions.
|
||||
11) Scientific term formatting (e.g., italics for species/gene symbols).
|
||||
12) SI unit compliance.
|
||||
|
||||
Return structured JSON only, including overall_score and items.
|
||||
If no violation is found, clearly indicate all checks passed.`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
|
||||
RVW_EDITORIAL_ZH: {
|
||||
content: `你是一位专业的医学期刊编辑,负责评估稿件的规范性。
|
||||
|
||||
【评估标准】
|
||||
1. 文稿科学性与实用性
|
||||
2. 文题(中文不超过20字,英文不超过10实词)
|
||||
3. 作者格式
|
||||
4. 摘要(300-500字,含目的、方法、结果、结论)
|
||||
5. 关键词(2-5个)
|
||||
6. 医学名词和药物名称
|
||||
7. 缩略语
|
||||
8. 计量单位
|
||||
9. 图片格式
|
||||
10. 动态图像
|
||||
11. 参考文献
|
||||
|
||||
请输出JSON格式的评估结果,包含overall_score和items数组。`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
|
||||
RVW_EDITORIAL: {
|
||||
content: `你是一位专业的医学期刊编辑,负责评估稿件的规范性。
|
||||
|
||||
|
||||
@@ -112,9 +112,11 @@ import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRout
|
||||
import { systemKbRoutes } from './modules/admin/system-kb/index.js';
|
||||
import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes, iitQcCockpitRoutes, iitEqueryRoutes } from './modules/admin/iit-projects/index.js';
|
||||
import { rvwConfigRoutes } from './modules/admin/rvw-config/rvwConfigRoutes.js';
|
||||
import { journalConfigRoutes } from './modules/admin/journal-config/journalConfigRoutes.js';
|
||||
import { authenticate, requireRoles } from './common/auth/auth.middleware.js';
|
||||
await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' });
|
||||
await fastify.register(rvwConfigRoutes, { prefix: '/api/admin/tenants' });
|
||||
await fastify.register(journalConfigRoutes, { prefix: '/api/admin/journal-configs' });
|
||||
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
|
||||
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
|
||||
await fastify.register(statsRoutes, { prefix: '/api/admin/stats' });
|
||||
@@ -133,7 +135,7 @@ await fastify.register(async (scope) => {
|
||||
await scope.register(iitEqueryRoutes);
|
||||
}, { prefix: '/api/v1/admin/iit-projects' });
|
||||
|
||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects (authenticated)');
|
||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/journal-configs, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects (authenticated)');
|
||||
|
||||
// ============================================
|
||||
// 【临时】平台基础设施测试API
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import * as service from './journalConfigService.js';
|
||||
import type { TenantListQuery, UpdateTenantRequest } from '../types/tenant.types.js';
|
||||
import type { UpdateRvwConfigDto } from '../rvw-config/rvwConfigService.js';
|
||||
|
||||
interface TenantIdParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export async function listJournalConfigs(
|
||||
request: FastifyRequest<{ Querystring: Omit<TenantListQuery, 'type'> }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const result = await service.listJournalConfigs(request.query);
|
||||
return reply.send({ success: true, ...result });
|
||||
} catch (error: any) {
|
||||
logger.error('[JournalConfig] 获取期刊配置列表失败', { error: error.message });
|
||||
return reply.status(500).send({ success: false, message: error.message || '获取期刊配置列表失败' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getJournalConfigDetail(
|
||||
request: FastifyRequest<{ Params: TenantIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const detail = await service.getJournalConfigDetail(request.params.id);
|
||||
if (!detail) {
|
||||
return reply.status(404).send({ success: false, message: '期刊租户不存在' });
|
||||
}
|
||||
return reply.send({ success: true, data: detail });
|
||||
} catch (error: any) {
|
||||
logger.error('[JournalConfig] 获取详情失败', { error: error.message });
|
||||
return reply.status(500).send({ success: false, message: error.message || '获取详情失败' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateJournalBasicInfo(
|
||||
request: FastifyRequest<{ Params: TenantIdParams; Body: UpdateTenantRequest }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const dto = request.body || {};
|
||||
const updated = await service.updateJournalBasicInfo(request.params.id, dto);
|
||||
return reply.send({ success: true, data: updated, message: '基础信息已保存' });
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('不存在')) {
|
||||
return reply.status(404).send({ success: false, message: error.message });
|
||||
}
|
||||
logger.error('[JournalConfig] 保存基础信息失败', { error: error.message });
|
||||
return reply.status(500).send({ success: false, message: error.message || '保存基础信息失败' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateJournalRvwConfig(
|
||||
request: FastifyRequest<{ Params: TenantIdParams; Body: UpdateRvwConfigDto }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const dto = request.body || {};
|
||||
if (dto.editorialBaseStandard && !['zh', 'en'].includes(dto.editorialBaseStandard)) {
|
||||
return reply.status(400).send({ success: false, message: 'editorialBaseStandard 必须为 zh / en' });
|
||||
}
|
||||
const updated = await service.updateJournalRvwConfig(request.params.id, dto);
|
||||
return reply.send({ success: true, data: updated, message: '审稿配置已保存' });
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('不存在')) {
|
||||
return reply.status(404).send({ success: false, message: error.message });
|
||||
}
|
||||
logger.error('[JournalConfig] 保存审稿配置失败', { error: error.message });
|
||||
return reply.status(500).send({ success: false, message: error.message || '保存审稿配置失败' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { authenticate, requireAnyPermission } from '../../../common/auth/auth.middleware.js';
|
||||
import * as controller from './journalConfigController.js';
|
||||
|
||||
/**
|
||||
* 期刊配置中心路由
|
||||
* 前缀:/api/admin/journal-configs
|
||||
*/
|
||||
export async function journalConfigRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/', {
|
||||
preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')],
|
||||
handler: controller.listJournalConfigs,
|
||||
});
|
||||
|
||||
fastify.get('/:id', {
|
||||
preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')],
|
||||
handler: controller.getJournalConfigDetail,
|
||||
});
|
||||
|
||||
fastify.put('/:id/basic-info', {
|
||||
preHandler: [authenticate, requireAnyPermission('tenant:edit', 'ops:user-ops')],
|
||||
handler: controller.updateJournalBasicInfo,
|
||||
});
|
||||
|
||||
fastify.put('/:id/rvw-config', {
|
||||
preHandler: [authenticate, requireAnyPermission('tenant:edit', 'ops:user-ops')],
|
||||
handler: controller.updateJournalRvwConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { tenantService } from '../services/tenantService.js';
|
||||
import type { TenantListQuery, UpdateTenantRequest } from '../types/tenant.types.js';
|
||||
import type { UpdateRvwConfigDto } from '../rvw-config/rvwConfigService.js';
|
||||
import * as rvwConfigService from '../rvw-config/rvwConfigService.js';
|
||||
|
||||
export async function listJournalConfigs(query: Omit<TenantListQuery, 'type'>) {
|
||||
return tenantService.listTenants({
|
||||
...query,
|
||||
type: 'JOURNAL',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJournalConfigDetail(tenantId: string) {
|
||||
const tenant = await tenantService.getTenantDetail(tenantId);
|
||||
if (!tenant || tenant.type !== 'JOURNAL') return null;
|
||||
|
||||
const rvwConfig = await rvwConfigService.getRvwConfig(tenantId);
|
||||
return { tenant, rvwConfig };
|
||||
}
|
||||
|
||||
export async function updateJournalBasicInfo(tenantId: string, dto: UpdateTenantRequest) {
|
||||
const tenant = await tenantService.getTenantDetail(tenantId);
|
||||
if (!tenant || tenant.type !== 'JOURNAL') {
|
||||
throw new Error('期刊租户不存在');
|
||||
}
|
||||
return tenantService.updateTenant(tenantId, dto);
|
||||
}
|
||||
|
||||
export async function updateJournalRvwConfig(tenantId: string, dto: UpdateRvwConfigDto) {
|
||||
const tenant = await tenantService.getTenantDetail(tenantId);
|
||||
if (!tenant || tenant.type !== 'JOURNAL') {
|
||||
throw new Error('期刊租户不存在');
|
||||
}
|
||||
return rvwConfigService.upsertRvwConfig(tenantId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选原子写接口:同时更新基础信息 + RVW 配置,避免跨表写入不一致
|
||||
*/
|
||||
export async function saveJournalConfigAtomic(
|
||||
tenantId: string,
|
||||
basicInfo: UpdateTenantRequest,
|
||||
rvwConfig: UpdateRvwConfigDto
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const tenant = await tx.tenants.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
name: basicInfo.name,
|
||||
type: basicInfo.type as any,
|
||||
journal_language: basicInfo.journalLanguage === null ? null : (basicInfo.journalLanguage as any),
|
||||
journal_full_name: basicInfo.journalFullName === null ? null : basicInfo.journalFullName,
|
||||
logo_url: basicInfo.logoUrl === null ? null : basicInfo.logoUrl,
|
||||
brand_color: basicInfo.brandColor === null ? null : basicInfo.brandColor,
|
||||
login_background_url: basicInfo.loginBackgroundUrl === null ? null : basicInfo.loginBackgroundUrl,
|
||||
contact_name: basicInfo.contactName,
|
||||
contact_phone: basicInfo.contactPhone,
|
||||
contact_email: basicInfo.contactEmail,
|
||||
expires_at: basicInfo.expiresAt === null ? null : (basicInfo.expiresAt ? new Date(basicInfo.expiresAt) : undefined),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const config = await tx.tenantRvwConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: {
|
||||
tenantId,
|
||||
editorialBaseStandard: rvwConfig.editorialBaseStandard ?? 'en',
|
||||
editorialExpertPrompt: rvwConfig.editorialExpertPrompt ?? null,
|
||||
editorialHandlebarsTemplate: rvwConfig.editorialHandlebarsTemplate ?? null,
|
||||
methodologyExpertPrompt: rvwConfig.methodologyExpertPrompt ?? null,
|
||||
methodologyHandlebarsTemplate: rvwConfig.methodologyHandlebarsTemplate ?? null,
|
||||
dataForensicsExpertPrompt: rvwConfig.dataForensicsExpertPrompt ?? null,
|
||||
dataForensicsHandlebarsTemplate: rvwConfig.dataForensicsHandlebarsTemplate ?? null,
|
||||
clinicalExpertPrompt: rvwConfig.clinicalExpertPrompt ?? null,
|
||||
clinicalHandlebarsTemplate: rvwConfig.clinicalHandlebarsTemplate ?? null,
|
||||
},
|
||||
update: {
|
||||
...(rvwConfig.editorialBaseStandard !== undefined && { editorialBaseStandard: rvwConfig.editorialBaseStandard }),
|
||||
...(rvwConfig.editorialExpertPrompt !== undefined && { editorialExpertPrompt: rvwConfig.editorialExpertPrompt }),
|
||||
...(rvwConfig.editorialHandlebarsTemplate !== undefined && { editorialHandlebarsTemplate: rvwConfig.editorialHandlebarsTemplate }),
|
||||
...(rvwConfig.methodologyExpertPrompt !== undefined && { methodologyExpertPrompt: rvwConfig.methodologyExpertPrompt }),
|
||||
...(rvwConfig.methodologyHandlebarsTemplate !== undefined && { methodologyHandlebarsTemplate: rvwConfig.methodologyHandlebarsTemplate }),
|
||||
...(rvwConfig.dataForensicsExpertPrompt !== undefined && { dataForensicsExpertPrompt: rvwConfig.dataForensicsExpertPrompt }),
|
||||
...(rvwConfig.dataForensicsHandlebarsTemplate !== undefined && { dataForensicsHandlebarsTemplate: rvwConfig.dataForensicsHandlebarsTemplate }),
|
||||
...(rvwConfig.clinicalExpertPrompt !== undefined && { clinicalExpertPrompt: rvwConfig.clinicalExpertPrompt }),
|
||||
...(rvwConfig.clinicalHandlebarsTemplate !== undefined && { clinicalHandlebarsTemplate: rvwConfig.clinicalHandlebarsTemplate }),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { tenant, config };
|
||||
});
|
||||
}
|
||||
@@ -50,9 +50,11 @@ export async function getRvwConfig(
|
||||
*
|
||||
* Body 示例:
|
||||
* {
|
||||
* "editorialBaseStandard": "zh",
|
||||
* "editorialExpertPrompt": null,
|
||||
* "methodologyExpertPrompt": "...",
|
||||
* "dataForensicsLevel": "L2",
|
||||
* "finerWeights": { "feasibility": 20, "innovation": 20, "ethics": 20, "relevance": 20, "novelty": 20 }
|
||||
* "dataForensicsExpertPrompt": "...",
|
||||
* "clinicalExpertPrompt": "..."
|
||||
* }
|
||||
*/
|
||||
export async function upsertRvwConfig(
|
||||
@@ -66,26 +68,14 @@ export async function upsertRvwConfig(
|
||||
const { id: tenantId } = request.params;
|
||||
const dto = request.body;
|
||||
|
||||
// 验证 dataForensicsLevel(若提供)
|
||||
if (dto.dataForensicsLevel && !['L1', 'L2', 'L3'].includes(dto.dataForensicsLevel)) {
|
||||
// 验证 editorialBaseStandard(若提供)
|
||||
if (dto.editorialBaseStandard && !['zh', 'en'].includes(dto.editorialBaseStandard)) {
|
||||
return reply.status(400).send({
|
||||
error: 'BadRequest',
|
||||
message: 'dataForensicsLevel 必须为 L1 / L2 / L3',
|
||||
message: 'editorialBaseStandard 必须为 zh / en',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证 finerWeights(若提供,权重之和应为 100)
|
||||
if (dto.finerWeights && typeof dto.finerWeights === 'object') {
|
||||
const weights = dto.finerWeights as Record<string, number>;
|
||||
const sum = Object.values(weights).reduce((a, b) => a + Number(b), 0);
|
||||
if (Math.abs(sum - 100) > 1) {
|
||||
return reply.status(400).send({
|
||||
error: 'BadRequest',
|
||||
message: `FINER 权重之和应为 100,当前为 ${sum}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const config = await rvwConfigService.upsertRvwConfig(tenantId, dto);
|
||||
|
||||
return reply.status(200).send({
|
||||
|
||||
@@ -9,30 +9,35 @@
|
||||
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
/** 审稿配置响应 DTO */
|
||||
export interface RvwConfigDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
editorialRules: Prisma.JsonValue | null;
|
||||
editorialBaseStandard: string;
|
||||
editorialExpertPrompt: string | null;
|
||||
editorialHandlebarsTemplate: string | null;
|
||||
methodologyExpertPrompt: string | null;
|
||||
methodologyHandlebarsTemplate: string | null;
|
||||
dataForensicsLevel: string;
|
||||
finerWeights: Prisma.JsonValue | null;
|
||||
dataForensicsExpertPrompt: string | null;
|
||||
dataForensicsHandlebarsTemplate: string | null;
|
||||
clinicalExpertPrompt: string | null;
|
||||
clinicalHandlebarsTemplate: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** 审稿配置更新 DTO(所有字段可选,UPSERT 语义) */
|
||||
export interface UpdateRvwConfigDto {
|
||||
editorialRules?: Prisma.InputJsonValue | null;
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
editorialExpertPrompt?: string | null;
|
||||
editorialHandlebarsTemplate?: string | null;
|
||||
methodologyExpertPrompt?: string | null;
|
||||
methodologyHandlebarsTemplate?: string | null;
|
||||
dataForensicsLevel?: string;
|
||||
finerWeights?: Prisma.InputJsonValue | null;
|
||||
dataForensicsExpertPrompt?: string | null;
|
||||
dataForensicsHandlebarsTemplate?: string | null;
|
||||
clinicalExpertPrompt?: string | null;
|
||||
clinicalHandlebarsTemplate?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,21 +79,27 @@ export async function upsertRvwConfig(
|
||||
where: { tenantId },
|
||||
create: {
|
||||
tenantId,
|
||||
editorialRules: dto.editorialRules ?? undefined,
|
||||
editorialBaseStandard: dto.editorialBaseStandard ?? 'en',
|
||||
editorialExpertPrompt: dto.editorialExpertPrompt ?? null,
|
||||
editorialHandlebarsTemplate: dto.editorialHandlebarsTemplate ?? null,
|
||||
methodologyExpertPrompt: dto.methodologyExpertPrompt ?? null,
|
||||
methodologyHandlebarsTemplate: dto.methodologyHandlebarsTemplate ?? null,
|
||||
dataForensicsLevel: dto.dataForensicsLevel ?? 'L2',
|
||||
finerWeights: dto.finerWeights ?? undefined,
|
||||
dataForensicsExpertPrompt: dto.dataForensicsExpertPrompt ?? null,
|
||||
dataForensicsHandlebarsTemplate: dto.dataForensicsHandlebarsTemplate ?? null,
|
||||
clinicalExpertPrompt: dto.clinicalExpertPrompt ?? null,
|
||||
clinicalHandlebarsTemplate: dto.clinicalHandlebarsTemplate ?? null,
|
||||
updatedAt: now,
|
||||
},
|
||||
update: {
|
||||
...(dto.editorialRules !== undefined && { editorialRules: dto.editorialRules }),
|
||||
...(dto.editorialBaseStandard !== undefined && { editorialBaseStandard: dto.editorialBaseStandard }),
|
||||
...(dto.editorialExpertPrompt !== undefined && { editorialExpertPrompt: dto.editorialExpertPrompt }),
|
||||
...(dto.editorialHandlebarsTemplate !== undefined && { editorialHandlebarsTemplate: dto.editorialHandlebarsTemplate }),
|
||||
...(dto.methodologyExpertPrompt !== undefined && { methodologyExpertPrompt: dto.methodologyExpertPrompt }),
|
||||
...(dto.methodologyHandlebarsTemplate !== undefined && { methodologyHandlebarsTemplate: dto.methodologyHandlebarsTemplate }),
|
||||
...(dto.dataForensicsLevel !== undefined && { dataForensicsLevel: dto.dataForensicsLevel }),
|
||||
...(dto.finerWeights !== undefined && { finerWeights: dto.finerWeights }),
|
||||
...(dto.dataForensicsExpertPrompt !== undefined && { dataForensicsExpertPrompt: dto.dataForensicsExpertPrompt }),
|
||||
...(dto.dataForensicsHandlebarsTemplate !== undefined && { dataForensicsHandlebarsTemplate: dto.dataForensicsHandlebarsTemplate }),
|
||||
...(dto.clinicalExpertPrompt !== undefined && { clinicalExpertPrompt: dto.clinicalExpertPrompt }),
|
||||
...(dto.clinicalHandlebarsTemplate !== undefined && { clinicalHandlebarsTemplate: dto.clinicalHandlebarsTemplate }),
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,31 @@ import type {
|
||||
} from '../types/tenant.types.js';
|
||||
|
||||
class TenantService {
|
||||
/**
|
||||
* 期刊租户兜底策略:确保 RVW 模块已开通。
|
||||
* - 创建 JOURNAL 租户时自动开通
|
||||
* - 其他入口将租户改为 JOURNAL 时也自动补齐
|
||||
*/
|
||||
private async ensureJournalRvwModule(tenantId: string): Promise<void> {
|
||||
await prisma.tenant_modules.upsert({
|
||||
where: {
|
||||
tenant_id_module_code: {
|
||||
tenant_id: tenantId,
|
||||
module_code: 'RVW',
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenant_id: tenantId,
|
||||
module_code: 'RVW',
|
||||
is_enabled: true,
|
||||
expires_at: null,
|
||||
},
|
||||
update: {
|
||||
is_enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户列表(分页)
|
||||
*/
|
||||
@@ -51,6 +76,11 @@ class TenantService {
|
||||
code: true,
|
||||
name: true,
|
||||
type: true,
|
||||
journal_language: true,
|
||||
journal_full_name: true,
|
||||
logo_url: true,
|
||||
brand_color: true,
|
||||
login_background_url: true,
|
||||
status: true,
|
||||
contact_name: true,
|
||||
contact_phone: true,
|
||||
@@ -70,6 +100,11 @@ class TenantService {
|
||||
code: t.code,
|
||||
name: t.name,
|
||||
type: t.type as any,
|
||||
journalLanguage: t.journal_language as any,
|
||||
journalFullName: t.journal_full_name,
|
||||
logoUrl: t.logo_url,
|
||||
brandColor: t.brand_color,
|
||||
loginBackgroundUrl: t.login_background_url,
|
||||
status: t.status as any,
|
||||
contactName: t.contact_name,
|
||||
contactPhone: t.contact_phone,
|
||||
@@ -120,6 +155,11 @@ class TenantService {
|
||||
code: tenant.code,
|
||||
name: tenant.name,
|
||||
type: tenant.type as any,
|
||||
journalLanguage: tenant.journal_language as any,
|
||||
journalFullName: tenant.journal_full_name,
|
||||
logoUrl: tenant.logo_url,
|
||||
brandColor: tenant.brand_color,
|
||||
loginBackgroundUrl: tenant.login_background_url,
|
||||
status: tenant.status as any,
|
||||
contactName: tenant.contact_name,
|
||||
contactPhone: tenant.contact_phone,
|
||||
@@ -154,6 +194,11 @@ class TenantService {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
type: data.type as any,
|
||||
journal_language: data.journalLanguage as any,
|
||||
journal_full_name: data.journalFullName,
|
||||
logo_url: data.logoUrl,
|
||||
brand_color: data.brandColor,
|
||||
login_background_url: data.loginBackgroundUrl,
|
||||
status: 'ACTIVE',
|
||||
contact_name: data.contactName,
|
||||
contact_phone: data.contactPhone,
|
||||
@@ -177,6 +222,11 @@ class TenantService {
|
||||
}
|
||||
}
|
||||
|
||||
// JOURNAL 租户自动开通 RVW(避免创建后无模块权限)
|
||||
if (data.type === 'JOURNAL') {
|
||||
await this.ensureJournalRvwModule(tenantId);
|
||||
}
|
||||
|
||||
logger.info('[TenantService] 创建租户', {
|
||||
tenantId,
|
||||
code: data.code,
|
||||
@@ -189,6 +239,11 @@ class TenantService {
|
||||
code: tenant.code,
|
||||
name: tenant.name,
|
||||
type: tenant.type as any,
|
||||
journalLanguage: tenant.journal_language as any,
|
||||
journalFullName: tenant.journal_full_name,
|
||||
logoUrl: tenant.logo_url,
|
||||
brandColor: tenant.brand_color,
|
||||
loginBackgroundUrl: tenant.login_background_url,
|
||||
status: tenant.status as any,
|
||||
contactName: tenant.contact_name,
|
||||
contactPhone: tenant.contact_phone,
|
||||
@@ -207,6 +262,12 @@ class TenantService {
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type as any,
|
||||
journal_language: data.journalLanguage === null ? null : (data.journalLanguage as any),
|
||||
journal_full_name: data.journalFullName === null ? null : data.journalFullName,
|
||||
logo_url: data.logoUrl === null ? null : data.logoUrl,
|
||||
brand_color: data.brandColor === null ? null : data.brandColor,
|
||||
login_background_url: data.loginBackgroundUrl === null ? null : data.loginBackgroundUrl,
|
||||
contact_name: data.contactName,
|
||||
contact_phone: data.contactPhone,
|
||||
contact_email: data.contactEmail,
|
||||
@@ -215,6 +276,11 @@ class TenantService {
|
||||
},
|
||||
});
|
||||
|
||||
// 当租户类型为 JOURNAL 时,自动兜底 RVW 模块
|
||||
if (tenant.type === 'JOURNAL') {
|
||||
await this.ensureJournalRvwModule(tenantId);
|
||||
}
|
||||
|
||||
logger.info('[TenantService] 更新租户', { tenantId, data });
|
||||
|
||||
return {
|
||||
@@ -222,6 +288,11 @@ class TenantService {
|
||||
code: tenant.code,
|
||||
name: tenant.name,
|
||||
type: tenant.type as any,
|
||||
journalLanguage: tenant.journal_language as any,
|
||||
journalFullName: tenant.journal_full_name,
|
||||
logoUrl: tenant.logo_url,
|
||||
brandColor: tenant.brand_color,
|
||||
loginBackgroundUrl: tenant.login_background_url,
|
||||
status: tenant.status as any,
|
||||
contactName: tenant.contact_name,
|
||||
contactPhone: tenant.contact_phone,
|
||||
@@ -311,6 +382,7 @@ class TenantService {
|
||||
logo?: string;
|
||||
primaryColor: string;
|
||||
systemName: string;
|
||||
backgroundImageUrl?: string;
|
||||
modules: string[];
|
||||
isReviewOnly: boolean;
|
||||
} | null> {
|
||||
@@ -335,10 +407,11 @@ class TenantService {
|
||||
const isReviewOnly = modules.length === 1 && modules[0] === 'RVW';
|
||||
|
||||
return {
|
||||
name: tenant.name,
|
||||
logo: undefined, // TODO: 未来可从 tenant 扩展字段获取
|
||||
primaryColor: isReviewOnly ? '#6366f1' : '#1890ff',
|
||||
systemName: isReviewOnly ? '智能审稿系统' : 'AI临床研究平台',
|
||||
name: tenant.journal_full_name || tenant.name,
|
||||
logo: tenant.logo_url || undefined,
|
||||
primaryColor: tenant.brand_color || (isReviewOnly ? '#6366f1' : '#1890ff'),
|
||||
systemName: tenant.journal_full_name || (isReviewOnly ? '智能审稿系统' : 'AI临床研究平台'),
|
||||
backgroundImageUrl: tenant.login_background_url || undefined,
|
||||
modules,
|
||||
isReviewOnly,
|
||||
};
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* 租户管理类型定义
|
||||
*/
|
||||
|
||||
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'INTERNAL' | 'PUBLIC';
|
||||
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'JOURNAL' | 'INTERNAL' | 'PUBLIC';
|
||||
export type TenantStatus = 'ACTIVE' | 'SUSPENDED' | 'EXPIRED';
|
||||
export type JournalLanguage = 'ZH' | 'EN' | 'OTHER';
|
||||
|
||||
/**
|
||||
* 租户基本信息
|
||||
@@ -14,6 +15,11 @@ export interface TenantInfo {
|
||||
name: string;
|
||||
type: TenantType;
|
||||
status: TenantStatus;
|
||||
journalLanguage?: JournalLanguage | null;
|
||||
journalFullName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
loginBackgroundUrl?: string | null;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
@@ -47,6 +53,11 @@ export interface CreateTenantRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
type: TenantType;
|
||||
journalLanguage?: JournalLanguage;
|
||||
journalFullName?: string;
|
||||
logoUrl?: string;
|
||||
brandColor?: string;
|
||||
loginBackgroundUrl?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
@@ -59,6 +70,12 @@ export interface CreateTenantRequest {
|
||||
*/
|
||||
export interface UpdateTenantRequest {
|
||||
name?: string;
|
||||
type?: TenantType;
|
||||
journalLanguage?: JournalLanguage | null;
|
||||
journalFullName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
loginBackgroundUrl?: string | null;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
|
||||
@@ -28,15 +28,18 @@ export interface ClinicalReviewResult {
|
||||
export async function reviewClinical(
|
||||
text: string,
|
||||
modelType: ModelType = 'deepseek-v3',
|
||||
userId?: string
|
||||
userId?: string,
|
||||
tenantExpertPrompt?: string | null
|
||||
): Promise<ClinicalReviewResult> {
|
||||
try {
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: businessPrompt, isDraft } = await promptService.get(
|
||||
'RVW_CLINICAL',
|
||||
{},
|
||||
{ userId }
|
||||
);
|
||||
let businessPrompt = tenantExpertPrompt?.trim() || '';
|
||||
let isDraft = false;
|
||||
if (!businessPrompt) {
|
||||
const promptService = getPromptService(prisma);
|
||||
const result = await promptService.get('RVW_CLINICAL', {}, { userId });
|
||||
businessPrompt = result.content;
|
||||
isDraft = result.isDraft;
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
logger.info('[RVW:Clinical] 使用 DRAFT 版本 Prompt(调试模式)', { userId });
|
||||
|
||||
@@ -65,16 +65,29 @@ async function repairEditorialToJson(
|
||||
export async function reviewEditorialStandards(
|
||||
text: string,
|
||||
modelType: ModelType = 'deepseek-v3',
|
||||
userId?: string
|
||||
userId?: string,
|
||||
tenantExpertPrompt?: string | null,
|
||||
editorialBaseStandard?: 'zh' | 'en'
|
||||
): Promise<EditorialReview> {
|
||||
try {
|
||||
// 1. 从 PromptService 获取系统Prompt(支持灰度预览)
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: businessPrompt, isDraft } = await promptService.get(
|
||||
'RVW_EDITORIAL',
|
||||
{},
|
||||
{ userId }
|
||||
);
|
||||
// 1. 优先使用租户自定义 Prompt;否则按中英基线获取系统默认 Prompt
|
||||
let businessPrompt = tenantExpertPrompt?.trim() || '';
|
||||
let isDraft = false;
|
||||
|
||||
if (!businessPrompt) {
|
||||
const promptService = getPromptService(prisma);
|
||||
const promptCode = editorialBaseStandard === 'zh' ? 'RVW_EDITORIAL_ZH' : 'RVW_EDITORIAL_EN';
|
||||
try {
|
||||
const result = await promptService.get(promptCode, {}, { userId });
|
||||
businessPrompt = result.content;
|
||||
isDraft = result.isDraft;
|
||||
} catch {
|
||||
// 向后兼容:若新 code 尚未入库,降级到旧 code
|
||||
const result = await promptService.get('RVW_EDITORIAL', {}, { userId });
|
||||
businessPrompt = result.content;
|
||||
isDraft = result.isDraft;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
logger.info('[RVW:Editorial] 使用 DRAFT 版本 Prompt(调试模式)', { userId });
|
||||
|
||||
@@ -114,16 +114,24 @@ export interface ForensicsResult {
|
||||
* 字段与 TenantRvwConfig 对应,仅携带运行时所需的轻量子集
|
||||
*/
|
||||
export interface TenantRvwConfigSnapshot {
|
||||
/** 稿约规范评估:继承基线(zh/en) */
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
/** 稿约规范评估:租户自定义 Prompt */
|
||||
editorialExpertPrompt?: string | null;
|
||||
/** 稿约规范评估:租户自定义模板 */
|
||||
editorialHandlebarsTemplate?: string | null;
|
||||
/** 方法学评估:专家业务评判标准(覆盖 PromptService 默认值) */
|
||||
methodologyExpertPrompt?: string | null;
|
||||
/** 方法学评估:租户自定义模板 */
|
||||
methodologyHandlebarsTemplate?: string | null;
|
||||
/** 数据验证:租户自定义 Prompt */
|
||||
dataForensicsExpertPrompt?: string | null;
|
||||
/** 数据验证:租户自定义模板 */
|
||||
dataForensicsHandlebarsTemplate?: string | null;
|
||||
/** 临床评估:专科特色补充要求 */
|
||||
clinicalExpertPrompt?: string | null;
|
||||
/** 稿约规范评估:规则数组 */
|
||||
editorialRules?: unknown;
|
||||
/** 数据验证:深度级别 L1/L2/L3 */
|
||||
dataForensicsLevel?: string;
|
||||
/** 临床评估:FINER 权重 */
|
||||
finerWeights?: unknown;
|
||||
/** 临床评估:租户自定义模板 */
|
||||
clinicalHandlebarsTemplate?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,7 +83,13 @@ export class ClinicalAssessmentSkill extends BaseSkill<SkillContext, ClinicalCon
|
||||
});
|
||||
}
|
||||
|
||||
const result: ClinicalReviewResult = await reviewClinical(content, 'deepseek-v3', context.userId);
|
||||
const tenantClinicalPrompt = context.tenantRvwConfig?.clinicalExpertPrompt ?? null;
|
||||
const result: ClinicalReviewResult = await reviewClinical(
|
||||
content,
|
||||
'deepseek-v3',
|
||||
context.userId,
|
||||
tenantClinicalPrompt
|
||||
);
|
||||
|
||||
logger.info('[ClinicalAssessmentSkill] Evaluation completed', {
|
||||
taskId: context.taskId,
|
||||
|
||||
@@ -294,12 +294,16 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
tableCount: forensicsResult.tables.length,
|
||||
});
|
||||
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: businessPrompt } = await promptService.get(
|
||||
'RVW_DATA_VALIDATION',
|
||||
{},
|
||||
{ userId: context.userId }
|
||||
);
|
||||
let businessPrompt = context.tenantRvwConfig?.dataForensicsExpertPrompt?.trim() || '';
|
||||
if (!businessPrompt) {
|
||||
const promptService = getPromptService(prisma);
|
||||
const promptResult = await promptService.get(
|
||||
'RVW_DATA_VALIDATION',
|
||||
{},
|
||||
{ userId: context.userId }
|
||||
);
|
||||
businessPrompt = promptResult.content;
|
||||
}
|
||||
|
||||
// 拼接所有表格为 user message
|
||||
const tableTexts: string[] = [];
|
||||
|
||||
@@ -99,8 +99,16 @@ export class EditorialSkill extends BaseSkill<SkillContext, EditorialConfig> {
|
||||
});
|
||||
}
|
||||
|
||||
// 调用现有 editorialService
|
||||
const result = await reviewEditorialStandards(content, 'deepseek-v3', context.userId);
|
||||
// V4.0 Hybrid Prompt:优先租户自定义稿约 Prompt;否则按 editorialBaseStandard 路由系统默认中英基线
|
||||
const tenantEditorialPrompt = context.tenantRvwConfig?.editorialExpertPrompt ?? null;
|
||||
const editorialBaseStandard = context.tenantRvwConfig?.editorialBaseStandard ?? 'en';
|
||||
const result = await reviewEditorialStandards(
|
||||
content,
|
||||
'deepseek-v3',
|
||||
context.userId,
|
||||
tenantEditorialPrompt,
|
||||
editorialBaseStandard
|
||||
);
|
||||
|
||||
// 转换为 SkillResult 格式
|
||||
const issues = this.convertToIssues(result);
|
||||
|
||||
@@ -491,31 +491,60 @@ async function executeWithSkills(
|
||||
select: { tenantId: true, contextData: true },
|
||||
});
|
||||
let tenantRvwConfig: {
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
editorialExpertPrompt?: string | null;
|
||||
editorialHandlebarsTemplate?: string | null;
|
||||
methodologyExpertPrompt?: string | null;
|
||||
methodologyHandlebarsTemplate?: string | null;
|
||||
dataForensicsExpertPrompt?: string | null;
|
||||
dataForensicsHandlebarsTemplate?: string | null;
|
||||
clinicalExpertPrompt?: string | null;
|
||||
editorialRules?: unknown;
|
||||
dataForensicsLevel?: string;
|
||||
finerWeights?: unknown;
|
||||
clinicalHandlebarsTemplate?: string | null;
|
||||
} | null = null;
|
||||
if (taskWithTenant?.tenantId) {
|
||||
const cfg = await prisma.tenantRvwConfig.findUnique({
|
||||
const tenantMeta = await (prisma as any).tenants.findUnique({
|
||||
where: { id: taskWithTenant.tenantId },
|
||||
select: { journal_language: true },
|
||||
}) as { journal_language?: 'ZH' | 'EN' | 'OTHER' } | null;
|
||||
const inferredEditorialBase: 'zh' | 'en' =
|
||||
tenantMeta?.journal_language === 'ZH' ? 'zh' : 'en';
|
||||
|
||||
const cfg = await (prisma as any).tenantRvwConfig.findUnique({
|
||||
where: { tenantId: taskWithTenant.tenantId },
|
||||
select: {
|
||||
editorialBaseStandard: true,
|
||||
editorialExpertPrompt: true,
|
||||
editorialHandlebarsTemplate: true,
|
||||
methodologyExpertPrompt: true,
|
||||
methodologyHandlebarsTemplate: true,
|
||||
dataForensicsExpertPrompt: true,
|
||||
dataForensicsHandlebarsTemplate: true,
|
||||
clinicalExpertPrompt: true,
|
||||
editorialRules: true,
|
||||
dataForensicsLevel: true,
|
||||
finerWeights: true,
|
||||
clinicalHandlebarsTemplate: true,
|
||||
},
|
||||
});
|
||||
if (cfg) {
|
||||
tenantRvwConfig = cfg;
|
||||
tenantRvwConfig = {
|
||||
...cfg,
|
||||
editorialBaseStandard: cfg.editorialBaseStandard === 'zh' ? 'zh' : inferredEditorialBase,
|
||||
};
|
||||
logger.info('[reviewWorker] 已加载租户审稿配置', {
|
||||
taskId,
|
||||
tenantId: taskWithTenant.tenantId,
|
||||
hasMethodologyPrompt: !!cfg.methodologyExpertPrompt,
|
||||
hasEditorialPrompt: !!cfg.editorialExpertPrompt,
|
||||
hasDataPrompt: !!cfg.dataForensicsExpertPrompt,
|
||||
hasClinicalPrompt: !!cfg.clinicalExpertPrompt,
|
||||
});
|
||||
} else {
|
||||
tenantRvwConfig = {
|
||||
editorialBaseStandard: inferredEditorialBase,
|
||||
};
|
||||
logger.info('[reviewWorker] 未找到租户审稿配置,回退系统基线', {
|
||||
taskId,
|
||||
tenantId: taskWithTenant.tenantId,
|
||||
editorialBaseStandard: inferredEditorialBase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# AIclinicalresearch 系统当前状态与开发指南
|
||||
|
||||
> **文档版本:** v7.1
|
||||
> **文档版本:** v7.2
|
||||
> **创建日期:** 2025-11-28
|
||||
> **维护者:** 开发团队
|
||||
> **最后更新:** 2026-03-14
|
||||
> **最后更新:** 2026-03-15
|
||||
> **🎉 重大里程碑:**
|
||||
> - **🆕 2026-03-15:RVW V4.0 期刊配置中心 MVP 完成!** 新增 ADMIN 一级菜单「期刊配置中心」+ JOURNAL 租户能力 + 中英稿约基线 + 运行时最小配置适配;租户登录路径统一为 `/:tenantCode/login`,创建/更新 JOURNAL 自动开通 RVW 模块
|
||||
> - **🆕 2026-03-14:RVW V4.0 租户门户 MVP 链路打通!** 期刊专属登录页 + 上传 + 执行审稿 + 过程页动态进度 + 报告查看闭环可用;租户列表状态图标展示修正(完成态不再误显示审稿中)
|
||||
> - **🆕 2026-03-13:RVW 方法学稳定性增强(V3.0.2)!** 方法学 20 检查点结构化增强 + A/B/C 分治并行评估 + 规则汇总器统一结论 + 前端展示口径收敛(按三大项分组展示检查点)
|
||||
> - **🆕 2026-03-09:认证模块接入阿里云短信验证码!** 登录验证码链路支持 `mock/aliyun` 双模式 + 后端短信服务封装 + 独立联调脚本(`npm run test:sms`)+ 实机发送验证通过
|
||||
@@ -40,6 +41,9 @@
|
||||
> - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
|
||||
>
|
||||
> **🆕 最新进展(含认证短信集成 2026-03-09):**
|
||||
> - ✅ **🆕 RVW V4.0 期刊配置中心 MVP 完成** — 运营管理端新增期刊配置中心,支持 JOURNAL 期刊中英基线配置(`editorial_base_standard`)与 4 维 Prompt/Template 管理;执行链路接入 `tenantCustom ?? systemDefault` 最小适配
|
||||
> - ✅ **🆕 RVW 登录路径统一** — 期刊登录统一为 `/:tenantCode/login`(开发/生产同路径,仅域名不同);不再保留 `/t/:tenantCode/login`
|
||||
> - ✅ **🆕 JOURNAL 自动模块兜底** — 创建/更新为 `JOURNAL` 时自动开通 `RVW`,解决“用户能登录但无智能审稿模块权限”问题
|
||||
> - ✅ **🆕 RVW V4.0 租户门户 MVP 联调通过** — `/:tenantSlug/rvw` 链路可用,执行审稿后回归旧版过程页交互(动态进度 + 动态Tab),上传按钮与状态图标问题修复
|
||||
> - ✅ **🆕 RVW 方法学稳定性增强(V3.0.2)** — `checkpoints` 结构化输出(20项)+ 方法学 A/B/C 分治并行评估(1-9/10-14/15-20)+ 规则汇总器统一 `summary/conclusion` + 前端展示按三大项分组
|
||||
> - ✅ **🆕 认证短信验证码接入完成** — `sendVerificationCode` 接入阿里云短信网关(保留 `mock`)+ 发送成功后再落库验证码 + 环境变量校验 + 联调脚本 `test:sms` + 实机发送验证通过
|
||||
@@ -1352,6 +1356,7 @@ AIclinicalresearch/
|
||||
| **2026-03-01** | **IIT GCP报表+Bug修复** 🎉 | ✅ 4张GCP标准报表+AI时间线增强+一键全量质控+6项Bug修复 |
|
||||
| **2026-02-27** | **DB文档+部署体系** 📚 | ✅ 6篇数据库文档+部署归档+统一操作手册+开发规范v3.0+Schema对齐 |
|
||||
| **2026-03-14** | **RVW V4.0 租户门户联调通过** 🎉 | ✅ 上传/审稿/过程页/报告闭环可用,状态图标与交互体验修正 |
|
||||
| **2026-03-15** | **RVW V4.0 期刊配置中心 MVP 完成** 🚀 | ✅ ADMIN 期刊配置中心 + JOURNAL 中英基线 + 登录路径统一 + JOURNAL 自动开通 RVW |
|
||||
| **当前** | **PKB模块生产可用** | ✅ 核心功能全部实现(95%),自研RAG+OSS存储上线 |
|
||||
| **2026-01-07 晚** | **RVW模块开发完成** 🎉 | ✅ Phase 1-3完成(后端迁移+数据库扩展+前端重构) |
|
||||
|
||||
@@ -1689,9 +1694,9 @@ if (items.length >= 50) {
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v7.1
|
||||
**最后更新**:2026-03-14
|
||||
**本次更新**:RVW V4.0 租户门户 MVP 联调通过(期刊专属登录 + 上传 + 执行审稿 + 过程页动态进度 + 报告查看)并修复租户列表维度状态显示误判问题;明日进入运营管理端期刊配置中心完善(中英期刊 Skills 匹配 + 租户登录风格 + URL 配置)
|
||||
**文档版本**:v7.2
|
||||
**最后更新**:2026-03-15
|
||||
**本次更新**:RVW V4.0 期刊配置中心 MVP 完成(JOURNAL 模型与配置能力补齐、执行链路接入中英基线与继承覆盖、登录路径统一为 `/:tenantCode/login`、JOURNAL 自动开通 RVW 模块),进入生产部署与灰度验证阶段
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# RVW稿件审查模块 - 当前状态与开发指南
|
||||
|
||||
> **文档版本:** v6.3
|
||||
> **文档版本:** v6.4
|
||||
> **创建日期:** 2026-01-07
|
||||
> **最后更新:** 2026-03-14
|
||||
> **最后更新:** 2026-03-15
|
||||
> **维护者:** 开发团队
|
||||
> **当前状态:** 🚀 **V4.0 租户门户 MVP 联调通过(上传/审稿/过程页/报告全链路可用)**
|
||||
> **当前状态:** 🚀 **V4.0 期刊配置中心 MVP 开发完成(租户门户+配置中心+中英基线+权限兜底)**
|
||||
> **文档目的:** 快速了解RVW模块状态,为新AI助手提供上下文
|
||||
>
|
||||
> **🎉 V3.0 进展(2026-03-07):**
|
||||
@@ -29,12 +29,19 @@
|
||||
> - ✅ **前端展示口径统一**:方法学报告按“三大项->检查点”展示,去除重复占位文案并显示真实LLM内容
|
||||
>
|
||||
> **🆕 V4.0 租户门户联调收敛(2026-03-14):**
|
||||
> - ✅ **期刊专属登录页可用**:`/t/:tenantCode/login` 支持密码登录 + 验证码登录(与主站能力对齐,UI为期刊门户风格)
|
||||
> - ✅ **期刊专属登录页可用**:`/:tenantCode/login` 支持密码登录 + 验证码登录(与主站能力对齐,UI为期刊门户风格)
|
||||
> - ✅ **上传链路修复**:租户工作台上传按钮稳定触发,`POST /api/v1/rvw/tasks` 创建任务成功
|
||||
> - ✅ **执行审稿体验回归旧流程**:点击“开始审稿”后直接进入过程页(复用 `TaskDetail` 动态进度 + 动态Tab)
|
||||
> - ✅ **租户详情页全屏化复用**:租户路由下适配旧版过程页交互,无 Sidebar 干扰
|
||||
> - ✅ **列表状态图标修复**:审稿完成后维度状态正确展示(绿色对钩/警告/错误),不再误显示“审稿中”
|
||||
>
|
||||
> **🆕 V4.0 期刊配置中心 MVP(2026-03-15):**
|
||||
> - ✅ **运营管理端新增一级菜单「期刊配置中心」**:支持列表/详情/配置保存
|
||||
> - ✅ **JOURNAL 租户模型补齐**:`tenants` 增加 `journal_language/journal_full_name/logo_url/brand_color/login_background_url`
|
||||
> - ✅ **审稿配置补齐**:`tenant_rvw_configs` 支持 4 维 Prompt + Handlebars 模板 + `editorial_base_standard(zh/en)`
|
||||
> - ✅ **执行链路最小适配**:`finalPrompt = tenantCustom ?? systemDefault`,稿约支持中英基线路由
|
||||
> - ✅ **租户权限兜底**:创建/更新为 `JOURNAL` 时自动开通 `RVW` 模块,避免新用户登录后无模块权限
|
||||
>
|
||||
> **V2.0 进展回顾:**
|
||||
> - ✅ L1 算术验证 + L2 统计验证 + L2.5 一致性取证
|
||||
> - ✅ Skills 核心框架(types, registry, executor, profile)
|
||||
@@ -54,7 +61,7 @@
|
||||
| **商业价值** | ⭐⭐⭐⭐⭐ 极高 |
|
||||
| **独立性** | ⭐⭐⭐⭐⭐ 极高(用户群完全不同) |
|
||||
| **目标用户** | 期刊初审编辑 |
|
||||
| **开发状态** | 🚀 **V4.0 租户门户 MVP 已联调通过:上传→审稿→过程页→报告闭环可用** |
|
||||
| **开发状态** | 🚀 **V4.0 期刊配置中心 MVP 已开发完成:配置中心→租户门户→上传→审稿→报告闭环可用** |
|
||||
|
||||
### 核心目标
|
||||
|
||||
@@ -104,7 +111,7 @@
|
||||
| 状态筛选 | ❌ | ✅ | ✅ 已完成 |
|
||||
| 历史归档 | ❌ | ✅ | ⏸️ 数据库已支持,UI暂缓 |
|
||||
| 系统设置 | ❌ | ✅ | ⏸️ 暂不开发 |
|
||||
| 登录页面 | ❌ | ⏸️ | ⏸️ 复用平台登录 |
|
||||
| 期刊登录页面(单一路径) | ❌ | ✅ | ✅ `/:tenantCode/login` 已上线,开发/生产同路径 |
|
||||
| PICO卡片 | ❌ | ✅ | ⏸️ 数据库已支持,UI暂缓 |
|
||||
|
||||
---
|
||||
@@ -499,7 +506,7 @@ Content-Type: multipart/form-data
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** v6.3
|
||||
**最后更新:** 2026-03-14
|
||||
**当前状态:** 🚀 V4.0 租户门户 MVP 联调通过(上传/审稿/过程页/报告闭环)
|
||||
**下一步:** 完善运营管理端期刊配置中心(中英期刊 Skills 匹配 + 租户登录风格 + URL/品牌配置联动)
|
||||
**文档版本:** v6.4
|
||||
**最后更新:** 2026-03-15
|
||||
**当前状态:** 🚀 V4.0 期刊配置中心 MVP 开发完成(配置中心 + 租户门户 + 中英基线 + 权限兜底)
|
||||
**下一步:** 进入生产部署与灰度验证(Nginx 深链回退、前端缓存清理、期刊链路冒烟回归)
|
||||
|
||||
30
docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/JTIM稿约规范与差异分析报告.md
Normal file
30
docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/JTIM稿约规范与差异分析报告.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# **JTIM 期刊稿约规范梳理**
|
||||
|
||||
# **分析对象: *Journal of Translational Internal Medicine* (JTIM) 2026-03-10 最新版稿约**
|
||||
|
||||
【JTIM 期刊全维稿约核查规则】
|
||||
作为严谨的医学期刊审稿人,请对稿件的结构、伦理及排版进行全面深度核查。必须严格遵守以下规则池:
|
||||
|
||||
一、 🔴 致命错误与伦理规范(一票否决项,若违反请标记为 fatal)
|
||||
1\. 双盲审查 (Double-blind):正文 (Main Text/Blinded Article) 中绝对不可出现作者姓名、机构、致谢、资助编号等身份信息。
|
||||
2\. 伦理与知情同意:涉及人类或动物实验必须有 IRB/IACUC 批准说明;涉及人类受试者(含病例报告)必须有知情同意书(Informed Consent)获取声明。
|
||||
3\. 声明完整性:正文或结尾必须包含利益冲突声明 (Conflict of Interest)、资金资助声明 (Funding)。若是临床干预试验必须有注册号。
|
||||
4\. AI 与署名:严禁 AI 工具作为作者署名。若声明使用了 AI 辅助必须予以记录。
|
||||
|
||||
二、 🟡 JTIM 专属体例与排版要求(重点核查项,若违反请标记为 major\_issue)
|
||||
5\. 语言校验:全篇必须严格统一使用美式英语 (American English)。
|
||||
6\. 引言篇幅:Original articles(原创论著)的引言 (Introduction) 必须极其简短,严格校验是否在 75-100 个单词范围内。
|
||||
7\. 摘要合规性:论著必须为 Structured 格式(包含 Objectives, Methods, Results, Conclusions),限 250 字;综述限 200 字;病例限 150 字。
|
||||
8\. 参考文献与引文格式:
|
||||
\- 正文引用:必须使用阿拉伯数字上标,用方括号包裹,且必须置于标点符号【之后】(例:...disease.^\[1\])。
|
||||
\- 列表格式:必须列出前 6 位作者,其余用 et al. 替代。
|
||||
9\. 统计与图表:必须提供 P 值的精确数值,且统计学字母必须为斜体(如:\*P\* \= 0.048);图表图例(Figure legends)不得超过 40 个单词。表格严禁为图片格式。
|
||||
10\. 作者数量警报:论著与综述共同作者(co-authors)超 3 人、其余类型超 2 人时,必须予以警告。
|
||||
11\. 标题要求:章节标题使用 Title case(实词首字母大写),绝对不能全部大写 (Not ALL CAPITALS)。Running title 限制在 50 个字符以内。
|
||||
|
||||
三、 🟢 通用学术写作规范(常规核查项,若违反请标记为 minor\_issue)
|
||||
12\. 数字拼写:句首的数字必须拼写成英文单词(如 "Fifteen patients...");正文中 1-10 的数字通常要求拼写(带有度量衡单位的除外)。
|
||||
13\. 学术专有名词:种属名称(如 \*E. coli\*)、基因符号必须使用斜体。
|
||||
14\. 度量单位:必须使用标准的国际单位制 (SI Units)。
|
||||
|
||||
请系统性地输出上述规则的违反情况,如果没有任何违反,请回复“符合所有规范要求”。
|
||||
@@ -0,0 +1,39 @@
|
||||
# **Medical Review (MR) 期刊专属稿约审查提示词 (Prompt)**
|
||||
|
||||
**使用场景:** 在 ADMIN 配置中心,当运营人员为 Medical Review 期刊配置稿约规范时,填入专家评判标准 (Expert Prompt) 文本框的内容。
|
||||
|
||||
**设计逻辑:** 依据 MR 2025-09-23 最新版 Author Guidelines 深度定制。重点加强了其特有的 Sentence case 标题规范、包容性语言要求、单盲属性(移除双盲核查)以及严格的文章类型硬指标。
|
||||
|
||||
请将以下内容直接复制到 MR 期刊配置的 Expert Prompt 中:
|
||||
|
||||
【Medical Review (MR) 期刊全维稿约核查规则】
|
||||
作为严谨的国际医学期刊《Medical Review》的审稿人,请对稿件的结构、伦理及排版进行全面深度核查。必须严格遵守以下 MR 官方指南要求:
|
||||
|
||||
一、 🔴 致命错误与伦理规范(一票否决项,若违反请标记为 fatal)
|
||||
1\. 伦理与知情同意:涉及人类/动物实验必须有 IRB/IACUC 批准说明;涉及人类受试者必须有知情同意书(Informed consent)获取声明。
|
||||
2\. 声明完整性:正文末尾(或模板中)必须包含利益冲突声明 (Conflict of Interest)、资金资助声明 (Research funding) 以及临床试验注册号 (UTN, 若适用)。
|
||||
3\. 数据共享声明 (Data availability):MR 强制要求符合欧洲 GDPR 规范的数据共享政策,必须说明研究数据在哪里可以获取。
|
||||
4\. AI 与署名合规:严禁将大模型(如 ChatGPT, AI)列为作者。若研究方法或写作过程中使用了 AI 工具,必须在 Methods 或声明模板中清晰披露。
|
||||
\*(注:MR 采取单盲 Peer Review,正文可保留作者与机构信息,无需进行双盲排查)\*
|
||||
|
||||
二、 🟡 MR 专属体例与排版要求(重点核查项,若违反请标记为 major\_issue)
|
||||
5\. 标题与层级要求 (Sentence case):
|
||||
\- 文章标题 (Title) 和所有章节小标题 (Headings) 必须使用 Sentence case(仅首字母大写,冒号后使用小写字母)。
|
||||
\- 短标题 (Running head) 限制在 75 个字符以内(含空格)。
|
||||
\- 章节标题不可带数字编号,最多不超过 4 级。
|
||||
6\. 摘要合规性 (严格限制 200 words 以内):
|
||||
\- Research articles / Short Communications:必须为结构化摘要 (Objectives; Methods; Results; Conclusions)。
|
||||
\- Reviews:可为非结构化摘要,或特定结构的摘要 (Objectives; Content; Summary and Outlook)。
|
||||
7\. 各文章类型硬性指标:
|
||||
\- Research Article:正文字数 3000-5000字;图表总数不超过 6 个;参考文献大于 50 篇。
|
||||
\- Review:正文字数大于 6000 字;图表总数 5-10 个;参考文献大于 80 篇。
|
||||
\- Systematic Review:正文字数小于 6000 字;图表总数 3-8 个;参考文献大于 50 篇。
|
||||
8\. 图表与引用独立性:表格必须是可编辑格式(Word/LaTeX),严禁使用图片格式的表格,避免使用垂直线和底纹;图表必须在正文中按阿拉伯数字顺序交叉引用(如 Figure 1, Table 1)。
|
||||
|
||||
三、 🟢 学术写作规范与细节(常规核查项,若违反请标记为 minor\_issue)
|
||||
9\. 包容性语言 (Inclusive language):检查是否符合包容性原则,建议使用性别中立词汇(如尽量使用复数名词或 "they",避免使用 "he" 或 "she")。
|
||||
10\. 语言与拼写:全篇必须一致地使用美式英语或英式英语。
|
||||
11\. 关键词与缩写:提供 3-8 个全小写的关键词(用分号隔开);所有非常规缩写在摘要和正文首次出现时必须写出全称。
|
||||
12\. 格式规范:数字与国际标准单位 (SI Units) 之间必须保留一个窄空格(°C 和 % 除外);物种拉丁名(如 \*E. coli\*)和基因缩写必须使用斜体。
|
||||
|
||||
请系统性地输出上述规则的违反情况。对于没有明确违反规则的项,无需列出。如果通篇没有任何违反,请回复“符合 Medical Review 所有官方规范要求”。
|
||||
@@ -1,131 +0,0 @@
|
||||
# **RVW V4.0 期刊租户配置中心开发需求**
|
||||
|
||||
**模块定位:** 依托现有 ADMIN 运营端 TenantDetailPage,为每个期刊客户建立独立的配置中枢。
|
||||
|
||||
**核心目标:** 实现期刊 SaaS 的“千刊千面”,包括基础品牌信息定制与底层 AI 审稿 Skills 规则的动态编排。
|
||||
|
||||
## **🎯 一、 前端 UI 与功能分区要求**
|
||||
|
||||
在现有的 ADMIN 租户详情页(TenantDetailPage)中,构建两大核心配置区块(可作为独立的 Tab 或折叠面板):
|
||||
|
||||
### **区块 1:期刊基础信息与门户配置 (Basic Info & Portal)**
|
||||
|
||||
用于定义该期刊的对外面貌和访问入口。
|
||||
|
||||
| 配置项名称 | 字段类型 | UI 组件 | 功能说明 |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| **期刊全称** | String | 文本输入框 | 如:*Journal of Translational Internal Medicine*。显示在登录页及工作台顶部。 |
|
||||
| **访问路径 (URL Slug)** | String | 文本输入框 (需正则校验) | **极重要**:用于生成 review.xunzhengyixue.com/{slug}。仅允许小写字母、数字和连字符。 |
|
||||
| **期刊 Logo** | String | 图片上传组件 | 上传至 OSS 后保存 URL。用于替换默认的系统 Logo。 |
|
||||
| **品牌主色调** | String | 颜色拾取器 (Color Picker) | 支持 HEX 色值(如 \#0284c7)。用于动态渲染按钮和高亮文字。 |
|
||||
| **专属登录页背景图** | String | 图片上传组件 | (可选) 替换登录页的默认背景,增强品牌沉浸感。 |
|
||||
|
||||
### **区块 2:智能审稿配置 (RVW Skills Config)**
|
||||
|
||||
这是该模块的核心,分为 4 个独立的配置面板(Panel):
|
||||
|
||||
#### **Panel A: 稿约规范评估 (Editorial)**
|
||||
|
||||
* **规则列表维护**:支持增删改查。每条规则需包含 规则编号 (Code) 和 规则描述 (Description)。
|
||||
* **致命错误开关 (Fatal Flag)**:每条规则后跟随一个 Switch 开关。开启后,该项缺陷将作为“一票否决”项在前端红底高亮。
|
||||
|
||||
#### **Panel B: 方法学评估 (Methodology) \- 【需支持动静分离与预览】**
|
||||
|
||||
* **专家评判标准 (Prompt)**:大文本框。供运营人员输入该期刊特有的统计学和方法学要求(大模型读取)。
|
||||
* **报告展示模板 (Handlebars)**:大文本框。用于配置最终输出给责编的 Markdown 格式。
|
||||
* ⚠️ **强依赖功能:测试渲染 (Test Render)**:**必须**在模板编辑区提供一个“预览”按钮。点击后,使用系统内置的假 JSON 数据(Mock Data)与当前模板进行结合,在右侧弹出抽屉实时渲染出 Markdown 结果,以防止语法错误。
|
||||
|
||||
#### **Panel C: 数据验证 (Data Forensics)**
|
||||
|
||||
* **验证深度 (Level)**:提供三个单选按钮(Radio):
|
||||
* L1 算术验证:仅核对行列加总、百分比。
|
||||
* L2 统计验证:包含 L1,增加 CI↔P 一致性逆向验证(推荐)。
|
||||
* L3 双通道核查:包含 L2,增加大模型智能深度核查(耗时较长)。
|
||||
|
||||
#### **Panel D: 临床专业评估 (Clinical)**
|
||||
|
||||
* **FINER 权重占比**:提供 5 个数字输入框(或滑块),分别对应 Feasibility, Innovation, Novelty, Ethical, Relevant。前端需实时校验总和是否等于 100%。
|
||||
* **专科特色要求**:文本区,填写该期刊的专科倾向性偏好(如:心血管领域的特殊要求)。
|
||||
|
||||
## **💾 二、 数据库 Schema 设计 (Prisma)**
|
||||
|
||||
本模块涉及两部分数据的存储:基础信息存入已有的 Tenants 表,审稿配置存入新增的 TenantRvwConfig 表。
|
||||
|
||||
### **1\. 基础信息存储 (platform\_schema.tenants)**
|
||||
|
||||
利用现有的租户表。假设现有表结构已有 code (即 URL Slug) 和 name (期刊名称)。对于 Logo、颜色等品牌视觉资产,建议统一存入 config JSON 字段中。
|
||||
|
||||
// 无需新建表,在原 Tenants 表中合理利用字段
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
code String @unique // 对应 URL Slug,如 'jtim'
|
||||
name String // 期刊全称
|
||||
|
||||
// 品牌视觉信息存入此 JSON 字段,避免频繁改表结构
|
||||
config Json? // 格式:{ logoUrl: "...", brandColor: "\#...", bgImgUrl: "..." }
|
||||
|
||||
rvwConfig TenantRvwConfig? // 关联 RVW 配置表
|
||||
}
|
||||
|
||||
### **2\. RVW Skills 配置存储 (新增表)**
|
||||
|
||||
执行 prisma migrate dev 创建以下 1对1 扩展表:
|
||||
|
||||
model TenantRvwConfig {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
tenant Tenant @relation(fields: \[tenantId\], references: \[id\])
|
||||
|
||||
// Panel A: 稿约规范 (存为 JSON 数组)
|
||||
// 结构: \[{ code: 'ED-01', desc: '摘要需控制在250字内', isFatal: true }\]
|
||||
editorialRules Json?
|
||||
|
||||
// Panel B: 方法学评估
|
||||
methodologyExpertPrompt String? @db.Text
|
||||
methodologyHandlebarsTemplate String? @db.Text
|
||||
|
||||
// Panel C: 数据验证
|
||||
dataForensicsLevel String @default("L2") // 'L1', 'L2', 'L3'
|
||||
|
||||
// Panel D: 临床评估 (存为 JSON)
|
||||
// 结构: { f: 20, i: 20, n: 20, e: 20, r: 20 }
|
||||
finerWeights Json?
|
||||
clinicalExpertPrompt String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@schema("platform\_schema")
|
||||
}
|
||||
|
||||
## **🔌 三、 后端 API 接口设计**
|
||||
|
||||
新增统一的租户管理与 RVW 配置接口:
|
||||
|
||||
### **1\. 内部 ADMIN 配置接口 (需要 ops:user-ops 权限)**
|
||||
|
||||
* PUT /api/admin/tenants/:id/basic-info:更新期刊名称、Slug、品牌 Logo 等信息。
|
||||
* GET /api/admin/tenants/:id/rvw-config:获取指定期刊的 Skills 配置。
|
||||
* PUT /api/admin/tenants/:id/rvw-config:UPSERT (更新或创建) 期刊 Skills 配置。
|
||||
|
||||
### **2\. 公开业务接口 (无 Auth,用于动态登录页渲染)**
|
||||
|
||||
* GET /api/v1/tenants/public-info/:slug
|
||||
* **参数**:slug (例如 'jtim')
|
||||
* **返回**:{ name, logoUrl, brandColor, bgImgUrl }
|
||||
* **限流要求**:必须添加 Rate Limiting (防刷防爬)。
|
||||
|
||||
## **👥 四、 实施 SOP:用户与期刊绑定(无需开发)**
|
||||
|
||||
**给开发团队的提示**:业务上,客户(责编)登录 /jtim 后能看到 JTIM 的稿件,依赖于正确的账号绑定。这部分**不需要开发新功能**,但需要通知实施团队遵循以下 SOP:
|
||||
|
||||
1. 客户在主站注册账号(或由超管分配账号)。
|
||||
2. 内部超管在 ADMIN 端的 **【用户管理 \-\> 租户成员管理】** 页面,将该用户的账号添加到目标期刊租户(如 JTIM)下。
|
||||
3. 确保该用户拥有 RVW 模块的使用权限。
|
||||
4. **验证**:此后该用户访问 review.xunzhengyixue.com/jtim 时,JWT 中会自动带上该租户身份,接口方可放行。
|
||||
|
||||
## **✅ 五、 验收标准 (Definition of Done)**
|
||||
|
||||
1. 运营人员可在后台顺滑配置 JTIM 和 CMJ 两套截然不同的方法学 Prompt 和 Handlebars 模板。
|
||||
2. 在 Handlebars 编辑器中点击“预览”,能立刻看到正确渲染的 Markdown 格式报告,不报错。
|
||||
3. 未登录用户访问 review.xunzhengyixue.com/jtim 时,登录页自动变更为 JTIM 的名称、Logo 和主色调。
|
||||
@@ -0,0 +1,161 @@
|
||||
# **RVW V4.0 期刊租户配置中心开发需求**
|
||||
|
||||
**模块定位:** 依托现有 ADMIN 运营端 TenantDetailPage,为每个期刊客户建立独立的配置中枢。
|
||||
|
||||
**核心目标:** 实现期刊 SaaS 的“千刊千面”。
|
||||
|
||||
**架构原则:**
|
||||
|
||||
1. **配置继承模式 (Config Inheritance)**:系统提供【标准默认配置】。期刊默认继承系统标准;需要个性化时,可开启【自定义覆盖】。
|
||||
2. **中英双轨基线 (Dual-Baseline)**:针对稿约规范,系统提供“英文期刊”与“中文期刊”两套底层标准,供租户自主选择绑定。
|
||||
3. **全面 Prompt 化 (All-in-Prompt)**:取消细颗粒度表单,采用“专家评判标准 (Prompt) \+ 报告展示模板 (Handlebars)”的开放式极简架构。
|
||||
|
||||
## **🎯 一、 前端 UI 与功能分区要求**
|
||||
|
||||
在现有的 ADMIN 租户详情页(TenantDetailPage)中,构建两大核心配置区块:
|
||||
|
||||
### **区块 1:期刊基础信息与门户配置 (Basic Info & Portal)**
|
||||
|
||||
用于定义该期刊的对外面貌和访问入口。
|
||||
|
||||
| 配置项名称 | 阶段 | 字段类型 | UI 组件 | 功能说明 |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| **期刊全称** | \[P0\] | String | 文本输入框 | 如:*Journal of Translational Internal Medicine*。显示在登录页及工作台顶部。 |
|
||||
| **访问路径(Slug)** | \[P0\] | String | 文本输入框(需正则) | **极重要**:用于生成 review.xunzhengyixue.com/{slug}。仅允许小写字母、数字和连字符。 |
|
||||
| **期刊 Logo** | \[P1\] | String | 图片上传组件 | 上传至 OSS 后保存 URL。用于替换默认的系统 Logo。 |
|
||||
| **品牌主色调** | \[P1\] | String | 颜色拾取器 | 支持 HEX 色值。用于动态渲染按钮和高亮文字。 |
|
||||
| **登录页背景图** | \[P2\] | String | 图片上传组件 | (暂不开发) 替换登录页的默认背景。 |
|
||||
|
||||
### **区块 2:智能审稿配置 (RVW Skills Config) —— 【全维度 Prompt 架构】**
|
||||
|
||||
这是该模块的核心。对于 4 个配置面板,顶部必须增加一个全局切换组件:
|
||||
|
||||
* 🔘 **选项 A: 继承系统默认配置 (Inherit System Default)** \[P0\] —— 选中时,下方大文本框置灰只读。
|
||||
* 🔘 **选项 B: 启用个性化自定义 (Custom Override)** \[P0\] —— 选中时,解锁下方大文本框,允许大面积修改。
|
||||
|
||||
#### **Panel A: 稿约规范评估 (Editorial) —— 【支持中英标准选择】**
|
||||
|
||||
作为特殊的规范模块,当选择 **“继承系统默认配置”** 时,必须提供一个**下拉单选框**供运营人员选择:
|
||||
|
||||
* 🔘 **标准版英文期刊规范 (English Standard)** \[P0\]:自动绑定英文 SCI 级审查标准(严查伦理声明等)。
|
||||
* 🔘 **标准版中文稿约规范 (Chinese Standard)** \[P0\]:自动绑定中文核心期刊审查标准(查中英文摘要对照等)。
|
||||
|
||||
当选择 **“启用个性化自定义”** 时,展示下方的通用组件。
|
||||
|
||||
#### **统一的面板配置项(适用于 Panel B / C / D 及自定义状态下的 Panel A)**
|
||||
|
||||
* **1\. 专家评判标准 (Expert Prompt)** \[P0\]:大文本框。自由输入该期刊特有的审查要求(大模型读取)。
|
||||
* **2\. 报告展示模板 (Handlebars Template)** \[P0\]:大文本框。用于配置最终输出给责编的 Markdown 格式。
|
||||
* ⚠️ **3\. 测试渲染 (Test Render)** \[P1\]:在模板编辑区提供“预览”按钮。使用假 JSON 数据在右侧实时渲染 Markdown 结果,防语法写错导致线上白屏。(MVP 阶段若来不及,可由运营人员去前台真实传 PDF 测试兜底)。
|
||||
|
||||
## **💾 二、 数据库 Schema 设计 (Prisma) \[P0\]**
|
||||
|
||||
**DB 要求**:即便 UI 是分阶段开发,数据库字段必须在 \[P0\] 阶段一次性建好,避免后续频繁 Migrate。
|
||||
|
||||
利用数据库的 null 语义表达“继承”。取消原有的 JSON 数组,统一为 Text 类型的 Prompt 和 Template 字段。
|
||||
|
||||
重点新增 editorialBaseStandard 字段,用于持久化期刊的中英文基线选择。
|
||||
|
||||
model TenantRvwConfig {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
tenant Tenant @relation(fields: \[tenantId\], references: \[id\])
|
||||
|
||||
// Panel A: 稿约规范评估
|
||||
// 'en' 代表选用标准英文规范,'zh' 代表选用标准中文规范。
|
||||
editorialBaseStandard String @default("en")
|
||||
editorialExpertPrompt String? @db.Text
|
||||
editorialHandlebarsTemplate String? @db.Text
|
||||
|
||||
// Panel B: 方法学评估
|
||||
methodologyExpertPrompt String? @db.Text
|
||||
methodologyHandlebarsTemplate String? @db.Text
|
||||
|
||||
// Panel C: 数据验证评估
|
||||
dataForensicsExpertPrompt String? @db.Text
|
||||
dataForensicsHandlebarsTemplate String? @db.Text
|
||||
|
||||
// Panel D: 临床专业评估
|
||||
clinicalExpertPrompt String? @db.Text
|
||||
clinicalHandlebarsTemplate String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@schema("platform\_schema")
|
||||
}
|
||||
|
||||
## **🔌 三、 后端 API 与底层引擎继承逻辑 (核心)**
|
||||
|
||||
### **1\. 内部 ADMIN 配置接口 (需要 ops:user-ops 权限) \[P0\]**
|
||||
|
||||
* PUT /api/admin/tenants/:id/basic-info:更新期刊名称、Slug (P1阶段再接 Logo 等)。
|
||||
* GET /api/admin/tenants/:id/rvw-config:获取指定期刊的 Skills 配置(若不存在,返回 {})。
|
||||
* PUT /api/admin/tenants/:id/rvw-config:UPSERT 期刊 Skills 配置。**若前端选择“继承系统默认”,对应的 ExpertPrompt 和 Template 传 null,但 editorialBaseStandard 必须传选中的值('en'或'zh')**。
|
||||
|
||||
### **2\. 底层引擎运行时的“降级合并 (Fallback Merge)”逻辑 \[P0\]**
|
||||
|
||||
后端重构 SkillExecutor 时,实施深度合并策略。以 **稿约规范 (EditorialSkill)** 为例:
|
||||
|
||||
async function executeEditorialSkill(manuscript, tenantConfig) {
|
||||
let promptToApply;
|
||||
|
||||
// 1\. 如果租户自定义了稿约 Prompt,直接使用租户的开放式配置 (优先级最高)
|
||||
if (tenantConfig && tenantConfig.editorialExpertPrompt) {
|
||||
promptToApply \= tenantConfig.editorialExpertPrompt;
|
||||
}
|
||||
// 2\. 租户未自定义(为 null),触发系统默认降级逻辑
|
||||
else {
|
||||
// 2.1 读取该期刊在后台配置的“标准语言基线”
|
||||
const baseStandard \= tenantConfig?.editorialBaseStandard || 'en';
|
||||
|
||||
// 2.2 根据基线拉取对应的【系统标准版规范】Prompt
|
||||
if (baseStandard \=== 'en') {
|
||||
promptToApply \= await promptService.get('SYSTEM\_DEFAULT\_EDITORIAL\_EN');
|
||||
} else if (baseStandard \=== 'zh') {
|
||||
promptToApply \= await promptService.get('SYSTEM\_DEFAULT\_EDITORIAL\_ZH');
|
||||
}
|
||||
}
|
||||
|
||||
// 3\. 将 promptToApply 喂给 LLM 执行,并使用对应 Handlebars 模板渲染...
|
||||
}
|
||||
|
||||
方法学、数据验证和临床评估的逻辑同理:最终提示词 \= 租户自定义 Prompt ?? 系统默认 Prompt。
|
||||
|
||||
## **👥 四、 实施 SOP:用户与期刊绑定(无需开发)**
|
||||
|
||||
1. 客户在主站注册账号(或由超管分配账号)。
|
||||
2. 内部超管在 ADMIN 端的 **【用户管理 \-\> 租户成员管理】** 页面,将该用户的账号添加到目标期刊租户(如 JTIM)下。
|
||||
3. 确保该用户拥有 RVW 模块的使用权限。
|
||||
4. **验证**:此后该用户访问 review.xunzhengyixue.com/jtim 时,JWT 中会自动带上该租户身份,接口方可放行。
|
||||
|
||||
## **✅ 五、 验收标准 (Definition of Done \- P0 阶段)**
|
||||
|
||||
1. ADMIN 后台成功渲染出只包含大文本框(Textarea)的极简配置界面。
|
||||
2. **中英基线验证**:
|
||||
* 将 JTIM 的【稿约规范】设置为“继承系统默认”并选择\*\*【标准版英文期刊规范】\*\*,上传稿件时,系统严格核查伦理声明等英文规则。
|
||||
* 将 CMJ 设置为“继承系统默认”并选择\*\*【标准版中文稿约规范】\*\*,系统重点核查中英文摘要对应等中文规则。
|
||||
3. 当某期刊在【临床专业评估】开启自定义后,运营人员在大文本框中修改提示词并保存,新上传的稿件严格按该设定执行评估。
|
||||
|
||||
## **🚀 六、 分阶段实施与 MVP 裁剪指南 (给 PM 与研发团队)**
|
||||
|
||||
为了保证项目快速上线,坚决执行 Managed SaaS (代运营) 和 All-in-Prompt 的裁剪策略。
|
||||
|
||||
### **🔴 第一阶段:MVP 必做清单 (P0) —— 目标:系统能跑通双语及个性化**
|
||||
|
||||
* **数据库**:一次性建好所有 Schema 字段(包含全部 Text 和 BaseStandard 字段)。
|
||||
* **核心引擎**:必须完成 SkillExecutor 的降级合并逻辑(?? 合并)和中英文 Prompt 的自动路由。
|
||||
* **ADMIN 前端**:只要最基础、最“丑”的表单输入框。实现“继承/自定义”的单选切换,并暴露 Textarea。不做任何复杂的花哨控件。
|
||||
* **业务前端**:根据 URL 中的 /:tenantId 成功拦截并获取对应的【期刊名称】(纯文本展示即可)。
|
||||
|
||||
### **🟡 第二阶段:视觉与防呆增强 (P1) —— 目标:上线后 1-2 周内快反**
|
||||
|
||||
* **模板预览 (Test Render)**:复用底层 PromptService 补齐预览按钮,防止运营在纯文本框写错 Handlebars 语法导致线上白屏。
|
||||
* **品牌视觉提升**:补齐期刊 Logo、主色调配置,并在客户端动态渲染,提升 SaaS 专属感。
|
||||
* **接口安全**:为公开的品牌信息拉取接口增加 Redis 缓存与 Rate Limiting 限流防刷。
|
||||
|
||||
### **🟢 第三阶段:暂缓或砍掉的伪需求 (P2) —— 目标:避免过度工程**
|
||||
|
||||
* **细颗粒度表单控件**:既然已经全面 Prompt 化,坚决不再开发滑块(如权重分配)、下拉枚举(如验证级别)等细分 UI 控件。全部用 Prompt 解决。
|
||||
* **客户自助配置后台**:坚决不做。坚持代运营模式,期刊主编提需求,内部运营人员在 ADMIN 端修改,规避复杂的 B 端权限开发。
|
||||
* **登录页背景图定制**:暂不开发,统一使用系统默认干净纯色底即可。
|
||||
44
docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/中文医学期刊2025新国标稿约审查提示词.md
Normal file
44
docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/中文医学期刊2025新国标稿约审查提示词.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# **中国医学期刊2025新国标稿约审查提示词 (Prompt)**
|
||||
|
||||
**使用场景:** 在 ADMIN 配置中心,当运营人员为某中文期刊租户选择 **“继承系统默认配置 \-\> 标准版中文稿约规范”** 时,系统底层默认调用的 Prompt。
|
||||
|
||||
**标准依据:** 深度融合 2025 年发布的最新三大国家标准:GB/T 7714-2025 (参考文献)、GB/T 6447-2025 (文献摘要)、GB/T 7713.1-2025 (学术体例)。
|
||||
|
||||
**设计逻辑:** 由于 LLM 缺乏 2025 年新规的先验知识,本 Prompt 采取“规则穷举显式化”设计,直接将判罚红线硬编码在提示词中。
|
||||
|
||||
请将以下内容直接复制到系统“中文版标准规范”的底层 Prompt 库中:
|
||||
|
||||
【2025最新版中文医学期刊全维稿约核查规则】
|
||||
作为严谨的中国医学核心期刊审稿人,请对稿件的结构、学术规范及排版进行全面深度核查。你必须严格依据中国最新的三大国家标准(GB/T 6447-2025、GB/T 7714-2025、GB/T 7713.1)执行检查。请牢记并严格套用以下规则池:
|
||||
|
||||
一、 🔴 摘要与关键词规范(依据 GB/T 6447-2025,若违反请标记为 major\_issue)
|
||||
1\. 结构式摘要要求:原创论著的摘要必须为“结构式摘要”,强制包含【目的】、【方法】、【结果】、【结论】4个明确要素(或类似等效标识)。
|
||||
2\. 摘要内容禁区:摘要中严禁出现参考文献的引用(即不能带有 \[1\]、\[2\] 等引用角标);应尽量避免采用插图、表格、非公知公用的符号和缩写词。
|
||||
3\. 关键词数量:每篇论文必须提供 3个\~8个 关键词,多个关键词之间通常用分号 (;) 分隔。
|
||||
4\. 中英文一致性:必须同时提供中文摘要与英文摘要(Abstract),且英文摘要的要素和含义应与中文摘要保持对应一致。
|
||||
|
||||
二、 🟡 参考文献著录规范(依据 GB/T 7714-2025,重点核查项,若违反请标记为 major\_issue)
|
||||
5\. 责任者(作者)著录规则:
|
||||
\- 欧美作者名必须“姓在前,名在后”(如:Einstein A,不能写 A Einstein)。
|
||||
\- 中外文作者若不超过 3 个,必须全部照录;若超过 3 个,只著录前 3 个责任者,其后必须加“, 等”或“, et al.”(注意前置逗号)。
|
||||
6\. 文献类型与载体标识代码(新规重点,检查代码是否规范):
|
||||
\- 必须准确标注类型:普通期刊 \[J\]、专著图书 \[M\]、学位论文 \[D\]、报告 \[R\]、会议录 \[C\]。
|
||||
\- \*\*2025新规必须支持并识别\*\*:预印本 \[PP\]、数据集 \[DS\]、网站/网页 \[EB/OL\] 或 \[EB\]。
|
||||
\- 电子资源必须标明载体,如 \[J/OL\]、\[M/OL\]、\[DS/OL\]。
|
||||
7\. 年卷期与页码格式:
|
||||
\- 连续出版物(期刊)的标准格式必须为:\`年, 卷(期): 起讫页码.\`(例如:\`2024, 46(8): 102-111.\`)。
|
||||
8\. 日期格式:引用的电子资源、网站网页的发布日期、更新日期和引用日期,必须采用 \`YYYY-MM-DD\` 格式(如:\[2025-07-15\])。
|
||||
9\. DOI 永久标识符:强烈建议电子文献末尾带有持久标识符(如 DOI 或 CSTR)。格式示例:\`DOI:10.1038/nature13308.\`
|
||||
|
||||
三、 🟢 学术体例与语言规范(依据 GB/T 7713.1-2025,常规核查项,若违反请标记为 minor\_issue)
|
||||
10\. 图表规范:
|
||||
\- 表格:应具有“自明性”,必须采用国际通行的“三线表”格式(避免使用复杂的内部网格线和垂直线)。
|
||||
\- 图表编号与图题:图题置于图的下方,表题置于表的上方。必须按出现顺采用阿拉伯数字连续编号(如:图1、表1)。
|
||||
11\. 计量单位与科学名词:
|
||||
\- 必须严格采用国家法定的国际单位制(SI units)。
|
||||
\- 首次出现外文专业术语、缩略词时,必须用圆括号注明原词语全称。
|
||||
12\. 学术诚信与伦理(一票否决项,若违反请标记为 fatal):
|
||||
\- 涉及人类受试者或动物的临床/实验研究,正文中必须明确提及“伦理委员会批准”以及“知情同意(Informed Consent)”。
|
||||
\- 严禁将 AI(如 ChatGPT 等大模型)列为共同作者。
|
||||
|
||||
请系统性地输出上述规则的违反情况(包含具体的缺陷位置和修改建议)。对于没有明确违反规则的项,无需列出。如果通篇没有任何违反,请回复“完全符合 2025 版中文医学期刊国家标准”。
|
||||
32
docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/通用英文期刊稿约审查提示词.md
Normal file
32
docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/通用英文期刊稿约审查提示词.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# **通用英文医学期刊标准稿约审查提示词 (Prompt)**
|
||||
|
||||
**使用场景:** 在 ADMIN 配置中心,当运营人员为某租户选择 **“继承系统默认配置 \-\> 标准版英文期刊规范”** 时,底层 SkillExecutor 默认调用的 Prompt。
|
||||
|
||||
**设计逻辑:** 覆盖主流 SCI 期刊及 ICMJE 规范的通用要求,作为英文期刊的“最大公约数”基线。
|
||||
|
||||
请将以下内容直接复制到系统的默认 Prompt 库中:
|
||||
|
||||
【标准版英文医学期刊全维稿约核查规则】
|
||||
作为严谨的国际医学期刊审稿人,请对稿件的结构、伦理及学术排版进行全面深度核查。必须严格遵守以下通用国际规则池(ICMJE 规范):
|
||||
|
||||
一、 🔴 致命错误与伦理规范(一票否决项,若违反请标记为 fatal)
|
||||
1\. 伦理与知情同意:涉及人类或动物实验必须有 IRB/IACUC 伦理委员会批准说明;涉及人类受试者(含病例报告)必须有知情同意书(Informed Consent)获取声明。
|
||||
2\. 声明完整性:正文或结尾必须包含利益冲突声明 (Conflict of Interest / Competing Interests) 和资金资助声明 (Funding / Support)。即使没有,也必须声明 "None declared"。若是临床干预试验必须提供注册号。
|
||||
3\. AI 与署名:严禁 AI 工具(如 ChatGPT, LLM)作为作者署名。若声明使用了 AI 辅助写作或数据分析,必须予以清晰记录。
|
||||
4\. 盲审风险提示 (Blinded Article Check):请排查正文 (Main Text) 中是否遗留了作者姓名、具体机构名称、致谢或可溯源的资助编号(若期刊采取单盲则忽略,但通常建议分离至 Title Page)。
|
||||
|
||||
二、 🟡 核心体例与排版要求(重点核查项,若违反请标记为 major\_issue)
|
||||
5\. 摘要合规性:
|
||||
\- 论著 (Original Research) 必须为结构化摘要 (Structured Abstract,通常包含 Background/Objectives, Methods, Results, Conclusions),且字数一般在 200-250 字以内。
|
||||
\- 综述或病例报告通常为非结构化摘要,字数一般在 150-200 字以内。
|
||||
6\. 统计学表述规范:必须提供 P 值的精确数值(如 P \= 0.048),严禁简单表述为 "p \< 0.05"(极小值除外,如 P \< 0.001)。统计学参数字母通常需斜体。
|
||||
7\. 参考文献与引文一致性:检查正文引用(如上标方括号或作者-年份)是否全篇保持高度一致,并检查参考文献列表是否存在明显的格式错乱。
|
||||
8\. 图表独立性:表格严禁为不可编辑的图片格式(应为文本表格,多采用三线表);所有图表必须在正文中被按顺序交叉引用(Cross-referenced)。
|
||||
|
||||
三、 🟢 通用学术写作规范(常规核查项,若违反请标记为 minor\_issue)
|
||||
9\. 语言一致性:全篇必须严格统一使用美式英语 (American English) 或英式英语 (British English),绝不可混用。
|
||||
10\. 数字拼写规则:句首的数字必须拼写成英文单词(如 "Fifteen patients...",严禁写 "15 patients");正文中 1-10 的数字通常要求使用英文拼写(带有度量衡单位的除外,如 5 mg)。
|
||||
11\. 学术专有名词:物种名称(如 \*E. coli\*)、基因符号等非英语的外来拉丁词汇必须使用斜体。
|
||||
12\. 度量单位:必须使用标准的国际单位制 (SI Units),且数字与单位之间通常需要保留一个空格(% 和 °C 除外)。
|
||||
|
||||
请系统性地输出上述规则的违反情况。对于没有明确违反规则的项,无需列出。如果通篇没有任何违反,请回复“符合所有国际通用规范要求”。
|
||||
326
docs/03-业务模块/RVW-稿件审查系统/04-开发计划/RVW V4.0 期刊配置中心MVP开发计划.md
Normal file
326
docs/03-业务模块/RVW-稿件审查系统/04-开发计划/RVW V4.0 期刊配置中心MVP开发计划.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# RVW V4.0 期刊配置中心 MVP 开发计划
|
||||
|
||||
> 文档版本: v1.0
|
||||
> 创建日期: 2026-03-15
|
||||
> 适用范围: RVW 多租户期刊配置中心(ADMIN 运营管理端)
|
||||
> 约束前提: 严格遵循 `docs/04-开发规范/09-数据库开发规范.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
本计划用于落地 RVW V4.0 的「期刊配置中心」MVP,满足以下目标:
|
||||
|
||||
1. 在运营管理端新增独立一级模块「期刊配置中心」(类似 IIT 项目管理入口形态)。
|
||||
2. 系统默认配置支持区分:
|
||||
- 中文-稿约规范性
|
||||
- 英文-稿约规范性
|
||||
- 方法学、数据验证、临床专业评估(通用默认)
|
||||
3. 期刊(如 AAA、BBB)可选择:
|
||||
- 继承系统默认配置
|
||||
- 在默认基础上进行覆盖
|
||||
4. 期刊基础信息新增字段:`期刊语言`(中文/英文/其他)。
|
||||
5. MVP 阶段功能可精简,但数据库字段一次性补齐(覆盖 P0/P1/P2 + 期刊语言)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键输入文档
|
||||
|
||||
1. 需求基线
|
||||
`docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/RVW V4.0 期刊租户配置中心开发需求0315.md`
|
||||
|
||||
2. 英文稿约默认规则(系统默认 EN 基线)
|
||||
`docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/通用英文期刊稿约审查提示词.md`
|
||||
|
||||
3. 数据库规范(强制约束)
|
||||
`docs/04-开发规范/09-数据库开发规范.md`
|
||||
|
||||
4. 既有总计划参考
|
||||
`docs/03-业务模块/RVW-稿件审查系统/04-开发计划/RVW V4.0 期刊SaaS版开发计划.md`
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构策略(MVP 版)
|
||||
|
||||
### 3.1 配置分层
|
||||
|
||||
- 第 1 层(系统默认层)
|
||||
- Editorial: `ZH` / `EN` 双基线
|
||||
- Methodology / DataForensics / Clinical: 通用基线
|
||||
- 第 2 层(期刊继承层)
|
||||
- 默认继承系统配置
|
||||
- 第 3 层(期刊覆盖层)
|
||||
- 租户填入自定义 Prompt/Template 后覆盖系统默认
|
||||
|
||||
### 3.2 运行时合并规则
|
||||
|
||||
对任一技能维度,统一采用:
|
||||
|
||||
`最终配置 = 期刊自定义值 ?? 系统默认值`
|
||||
|
||||
Editorial 额外规则:
|
||||
|
||||
- 若 `editorialExpertPrompt` 为空,则按 `editorialBaseStandard` 路由:
|
||||
- `zh` -> 中文稿约默认 Prompt
|
||||
- `en` -> 英文稿约默认 Prompt
|
||||
- 若期刊未显式设置 `editorialBaseStandard`,按 `期刊语言` 映射默认值:
|
||||
- 中文 -> `zh`
|
||||
- 英文/其他 -> `en`
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型设计(一次性补齐)
|
||||
|
||||
## 4.1 设计原则
|
||||
|
||||
- MVP 功能可分阶段启用,但字段一次性建完,避免重复迁移。
|
||||
- 使用 `null` 表达“继承系统默认”。
|
||||
- 严格使用 Prisma migration,不允许手工 DDL 改库。
|
||||
|
||||
## 4.2 表结构调整
|
||||
|
||||
### A. `platform_schema.tenants`(期刊基础信息)
|
||||
|
||||
新增/补齐字段(若已有同义字段,统一命名后保留兼容映射):
|
||||
|
||||
- `journal_language` (`ZH` | `EN` | `OTHER`) // P0
|
||||
- `journal_full_name` (String) // P0
|
||||
- `logo_url` (String?) // P1
|
||||
- `brand_color` (String?) // P1
|
||||
- `login_background_url` (String?) // P2
|
||||
|
||||
### B. `platform_schema.tenant_rvw_configs`(期刊审稿配置)
|
||||
|
||||
一次性补齐四维 Prompt + Template:
|
||||
|
||||
- `editorial_base_standard` (`zh` | `en`) default `en`
|
||||
- `editorial_expert_prompt` (Text?)
|
||||
- `editorial_handlebars_template` (Text?)
|
||||
- `methodology_expert_prompt` (Text?)
|
||||
- `methodology_handlebars_template` (Text?)
|
||||
- `data_forensics_expert_prompt` (Text?)
|
||||
- `data_forensics_handlebars_template` (Text?)
|
||||
- `clinical_expert_prompt` (Text?)
|
||||
- `clinical_handlebars_template` (Text?)
|
||||
|
||||
> 兼容说明:历史结构化字段(如 `editorialRules`/`dataForensicsLevel`/`finerWeights`)如需保留,先保留读兼容;MVP 写入统一走 Prompt/Template 字段。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据库实施规范(必须执行)
|
||||
|
||||
严格按 `09-数据库开发规范.md` 三条铁律执行:
|
||||
|
||||
1. 所有变更通过 `npx prisma migrate dev --name xxx` 生成迁移。
|
||||
2. 禁止 `prisma db push`(含任何变体)。
|
||||
3. 每次迁移后,立即更新 `docs/05-部署文档/03-待部署变更清单.md`。
|
||||
|
||||
推荐本次迁移命名:
|
||||
|
||||
- `20260315_add_journal_fields_to_tenants`
|
||||
- `20260315_expand_tenant_rvw_configs_for_all_prompt_templates`
|
||||
|
||||
Shadow DB 失败时,按规范第 10 章降级流程处理并 `migrate resolve --applied` 标记。
|
||||
|
||||
---
|
||||
|
||||
## 6. 可执行任务清单
|
||||
|
||||
## 6.1 后端任务
|
||||
|
||||
### BE-1 Schema 与迁移
|
||||
- 修改 `backend/prisma/schema.prisma`
|
||||
- 生成并审查迁移 SQL
|
||||
- 更新 Prisma Client
|
||||
|
||||
交付物:
|
||||
- `backend/prisma/migrations/*/migration.sql`
|
||||
- `backend/prisma/schema.prisma`
|
||||
|
||||
### BE-2 系统默认配置路由
|
||||
- 在 Prompt 默认库中明确:
|
||||
- `SYSTEM_DEFAULT_EDITORIAL_EN` 使用英文默认规则(来源于指定文档)
|
||||
- `SYSTEM_DEFAULT_EDITORIAL_ZH` 使用中文默认规则
|
||||
- 其余 3 个技能保留通用默认
|
||||
|
||||
### BE-3 管理端 API(期刊配置中心)
|
||||
- 新增/扩展 API:
|
||||
- `GET /api/admin/journal-configs`
|
||||
- `GET /api/admin/journal-configs/:tenantId`
|
||||
- `PUT /api/admin/journal-configs/:tenantId/basic-info`
|
||||
- `PUT /api/admin/journal-configs/:tenantId/rvw-config`
|
||||
- 权限:`authenticate + requirePermission('ops:user-ops')`
|
||||
|
||||
### BE-4 RVW 执行链路接线
|
||||
- 在 Worker/Skill 装配处接入期刊配置
|
||||
- 按 `??` 规则完成 4 维配置合并
|
||||
- Editorial 增加 `zh/en` 基线路由
|
||||
|
||||
### BE-5 安全校验
|
||||
- `slug` 正则校验(小写字母/数字/连字符)
|
||||
- `journalLanguage`、`editorialBaseStandard` 枚举校验
|
||||
- 日志记录配置来源(system/custom)
|
||||
|
||||
---
|
||||
|
||||
## 6.2 前端任务
|
||||
|
||||
### FE-1 新增左侧一级模块
|
||||
- ADMIN 左侧导航新增 `期刊配置中心`
|
||||
- 路由挂载与权限控制
|
||||
|
||||
### FE-2 列表页
|
||||
- 期刊列表(名称、slug、语言、状态、更新时间、操作)
|
||||
- 支持搜索/筛选/分页
|
||||
|
||||
### FE-3 详情配置页
|
||||
- 基础信息区:
|
||||
- 期刊全称
|
||||
- 访问路径 slug
|
||||
- 期刊语言(中文/英文/其他)
|
||||
- Logo/主色/背景图字段(先存储,MVP 可不渲染)
|
||||
- 审稿配置区(4 Skills):
|
||||
- 继承/自定义切换
|
||||
- Editorial 继承态支持 `zh/en` 基线选择
|
||||
- 每个维度 Prompt + Handlebars Template 文本框
|
||||
|
||||
### FE-4 API 类型对齐
|
||||
- 更新 TypeScript DTO 与后端一致
|
||||
- `null` 语义统一为“继承默认”
|
||||
|
||||
---
|
||||
|
||||
## 6.3 联调任务
|
||||
|
||||
### INT-1 中英文默认稿约验证
|
||||
- JTIM 配置为 `en` 继承,触发英文稿约规则
|
||||
- CMJ 配置为 `zh` 继承,触发中文稿约规则
|
||||
|
||||
### INT-2 继承与覆盖验证
|
||||
- AAA 继承默认
|
||||
- BBB 在临床维度自定义 Prompt
|
||||
- 对比两者结果差异应符合预期
|
||||
|
||||
### INT-3 兼容性验证
|
||||
- 历史任务查询不受影响
|
||||
- 现有租户登录/访问链路不回归
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准(DoD)
|
||||
|
||||
1. ADMIN 出现独立一级菜单「期刊配置中心」。
|
||||
2. 可在期刊维度配置并保存 `期刊语言`。
|
||||
3. 系统可明确区分中英文稿约默认规则。
|
||||
4. 期刊支持“继承默认 + 局部覆盖”两种模式。
|
||||
5. 数据库字段一次性补齐(P0/P1/P2 + 期刊语言)。
|
||||
6. 所有迁移已入库并追加到待部署变更清单。
|
||||
|
||||
---
|
||||
|
||||
## 8. 排期建议(MVP)
|
||||
|
||||
- Day 1: BE-1 + BE-2(Schema/迁移/默认配置)
|
||||
- Day 2: BE-3 + FE-1/FE-2(管理端骨架)
|
||||
- Day 3: FE-3 + FE-4(配置页面联通)
|
||||
- Day 4: BE-4 + 联调(INT-1/2/3)
|
||||
- Day 5: 验收、文档补充、部署清单复核
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与规避
|
||||
|
||||
1. **风险:数据库 drift**
|
||||
规避:禁止 db push,迁移前后执行 status 与 SQL 审查。
|
||||
|
||||
2. **风险:字段多但 UI 尚未全部启用**
|
||||
规避:字段先入库,前端按阶段开放;未启用字段保持可空。
|
||||
|
||||
3. **风险:中英文规则路由错误**
|
||||
规避:加运行时日志(language/baseStandard/promptSource)与双租户回归用例。
|
||||
|
||||
---
|
||||
|
||||
## 10. 审查报告对齐结论(含 TenantType 现状)
|
||||
|
||||
针对以下两份审查报告:
|
||||
|
||||
- `docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/期刊配置中心MVP计划审查报告.md`
|
||||
- `docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/ADMIN多业态租户架构演进评估 (1).md`
|
||||
|
||||
本计划采用如下对齐结论:
|
||||
|
||||
### 10.1 认可并吸收的观点
|
||||
|
||||
1. **前端独立模块方向正确**:在 ADMIN 左侧建立独立一级菜单“期刊配置中心”是正确演进方向。
|
||||
2. **后端数据层保持统一**:继续复用 `platform_schema.tenants`,不拆分新的“期刊主租户表”。
|
||||
3. **配置中心必须可运营**:Handlebars 模板能力应保留,避免后续“千刊千面”报告格式无法落地。
|
||||
4. **跨表一致性要求**:涉及 tenants 与 tenant_rvw_configs 同步修改的场景,应使用事务保证原子性。
|
||||
|
||||
### 10.2 需要修正的审查结论(以代码现状为准)
|
||||
|
||||
两份报告中“系统当前没有 TenantType”的表述不准确。当前系统**已存在 TenantType**:
|
||||
|
||||
- 后端 `backend/prisma/schema.prisma` 中已有 `enum TenantType` 与 `tenants.type`
|
||||
- 当前枚举值:`HOSPITAL` / `PHARMA` / `INTERNAL` / `PUBLIC`
|
||||
|
||||
因此,本项目不需要“从 0 引入 TenantType 机制”,而是需要在现有机制上评估扩展:
|
||||
|
||||
- 方案 A(推荐):新增 `JOURNAL` 枚举值,用于期刊配置中心过滤与治理
|
||||
- 方案 B(兼容):暂不新增枚举,短期用 `INTERNAL` 或约定标签过渡(不推荐长期使用)
|
||||
|
||||
本计划采用:**方案 A(新增 JOURNAL)**。
|
||||
|
||||
---
|
||||
|
||||
## 11. 最低成本实施策略(先跑通,不做大重构)
|
||||
|
||||
### 11.1 决策原则
|
||||
|
||||
针对“当前 Prompt 是否硬编码、是否立刻大重构”的问题,本计划明确:
|
||||
|
||||
1. **不做大重构**(避免阻塞交付)
|
||||
2. **必须做最小配置适配**(保证配置中心不是“假配置”)
|
||||
3. **Handlebars 保留并最小启用**(保留未来扩展能力,当前成本可控)
|
||||
|
||||
### 11.2 最小配置适配(必须)
|
||||
|
||||
在现有 RVW 链路上增加一层“薄适配”即可,不推翻当前实现:
|
||||
|
||||
- 统一规则:`finalPrompt = tenantCustomPrompt ?? systemDefaultPrompt`
|
||||
- Editorial 特殊规则:
|
||||
- 租户未自定义时,按 `editorialBaseStandard` 选择 `zh/en` 默认 Prompt
|
||||
- 若未显式设置 `editorialBaseStandard`,则按 `journalLanguage` 映射(`ZH -> zh`,其他 -> `en`)
|
||||
- 目标:让“期刊配置中心”的保存结果立即影响审稿行为
|
||||
|
||||
### 11.3 Handlebars 最小启用策略
|
||||
|
||||
当前阶段不做复杂重构,但必须保留模板层:
|
||||
|
||||
1. 字段保留:四维模板字段全部入库(已在第 4 章定义)
|
||||
2. 运行时策略:模板为空时走系统默认模板
|
||||
3. 失败兜底:模板渲染失败时降级返回原始报告内容,防止线上中断
|
||||
4. MVP 可选项:测试渲染可先后置到 P1,但预留接口与校验能力
|
||||
|
||||
### 11.4 为什么不建议“完全按写死先跑”
|
||||
|
||||
若只做 UI 不接运行时,配置中心会变成“可编辑但不生效”,风险更高:
|
||||
|
||||
- 运营以为已生效,实际审稿仍走旧规则
|
||||
- 期刊 AAA/BBB 差异化配置无法验证
|
||||
- 后续回补执行链路时会引入二次返工和信任成本
|
||||
|
||||
因此本计划采用“**小步接线,先真生效**”策略,而非“先写死跑通”。
|
||||
|
||||
---
|
||||
|
||||
## 12. 本计划执行前检查清单
|
||||
|
||||
- [ ] 已确认英文默认稿约规则文本(指定文档)
|
||||
- [ ] 已确认中文默认稿约规则文本(对应文档)
|
||||
- [ ] 已同步研发团队数据库铁律(09 规范)
|
||||
- [ ] 已确认 TenantType 扩展策略(新增 JOURNAL)
|
||||
- [ ] 已确认“先最小适配,不做大重构”的实施口径
|
||||
- [ ] 已创建本次迁移命名并分配负责人
|
||||
- [ ] 已约定联调样例租户(至少 JTIM + CMJ)
|
||||
|
||||
BIN
docs/03-业务模块/RVW-稿件审查系统/05-测试文档/Dongen 2003.pdf
Normal file
BIN
docs/03-业务模块/RVW-稿件审查系统/05-测试文档/Dongen 2003.pdf
Normal file
Binary file not shown.
117
docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/ADMIN多业态租户架构演进评估 (1).md
Normal file
117
docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/ADMIN多业态租户架构演进评估 (1).md
Normal file
@@ -0,0 +1,117 @@
|
||||
# **ADMIN 运营管理端:多业态租户架构演进评估与设计指南**
|
||||
|
||||
**文档定位:** 针对平台未来面临“医院、药企、期刊”三大业态分化的战略级架构指导文件。
|
||||
|
||||
**核心主旨:** 摒弃“上帝级”通用租户配置页,走向\*\*“数据底层大一统,前端展现分而治之”\*\*的 SaaS 演进路线。
|
||||
|
||||
**评估结论:** 强烈建议将“期刊配置中心”在前端作为独立一级菜单和页面独立出来。
|
||||
|
||||
## **🔍 一、 业务形态分化趋势评估**
|
||||
|
||||
平台目前服务的三类客户,其核心诉求与配置维度已经发生根本性分化:
|
||||
|
||||
| 业态类型 | 核心业务模块 | ADMIN 端的配置侧重点 | 运营人员视角 |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| **医院 / 科研机构** | IIT (研究项目), DC (数据清洗) | REDCap 数据源打通、CRA 质控规则、伦理审查合规性、患者隐私脱敏级别。 | 偏向于“项目制”与“数据安全”管理。 |
|
||||
| **药企 / 申办方** | PKB (企业知识库), AIA (智能体) | 专属知识库的向量化参数、RAG 检索阈值、多智能体协作 Prompt、合规审查界限。 | 偏向于“知识资产”与“AI 能力”调优。 |
|
||||
| **医学期刊编辑部** | RVW (智能审稿) | 稿约规范双语基线、方法学/临床评估 Prompt 编排、Handlebars 退修信模板定制。 | 偏向于“审稿流水线”与“千刊千面”排版。 |
|
||||
|
||||
**评估意见**:如果未来继续在同一个 TenantDetailPage.tsx 里通过 if-else 来堆砌这些表单,前端代码将迅速腐化,且运营人员在配置一家期刊时,可能会被医院的 REDCap 配置项干扰。**前端页面的物理拆分势在必行。**
|
||||
|
||||
## **🏛️ 二、 核心架构建议:底层统一,表层分治**
|
||||
|
||||
为了兼顾“开发成本”与“运营体验”,建议采用以下架构模式:
|
||||
|
||||
### **1\. 底层大一统 (Unified Data Model)**
|
||||
|
||||
**不能把租户表拆散**(不能建 journal\_tenants, hospital\_tenants)。
|
||||
|
||||
* **原因**:全平台的统一登录 (SSO)、统一计费 (Quotas)、基础信息 (Logo、名称) 必须保持全局唯一,复用现有的 platform\_schema.tenants。
|
||||
* **改造动作**:在 tenants 表中新增一个核心的枚举分类字段 tenant\_type。
|
||||
|
||||
### **2\. 1对1 扩展表 (1-to-1 Extension Tables)**
|
||||
|
||||
针对不同业态的独特配置,采用垂直分表的模式,保持主表整洁。
|
||||
|
||||
* 期刊配置存入 tenant\_rvw\_configs
|
||||
* 药企配置存入 tenant\_pkb\_configs (未来)
|
||||
* 医院配置存入 tenant\_iit\_configs (未来)
|
||||
|
||||
### **3\. 前端分治 (Decoupled Frontend Views)**
|
||||
|
||||
在 ADMIN 端的左侧菜单,明确区分业务管理入口,为特定业态打造沉浸式的配置体验。
|
||||
|
||||
## **💾 三、 数据库 Schema 调整落地 (Prisma)**
|
||||
|
||||
基于上述思路,在 MVP 阶段的数据库改造应如下进行:
|
||||
|
||||
// 1\. 定义租户业态枚举
|
||||
enum TenantType {
|
||||
CLINICAL // 医院/科研机构 (默认)
|
||||
PHARMA // 药企/申办方
|
||||
JOURNAL // 医学期刊
|
||||
INTERNAL // 内部平台测试
|
||||
}
|
||||
|
||||
// 2\. 改造基础租户表
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
code String @unique // slug
|
||||
name String
|
||||
tenantType TenantType @default(CLINICAL) // 新增:业态分类
|
||||
journalLanguage String? // 'ZH' | 'EN' (仅当 type=JOURNAL 时有效)
|
||||
|
||||
// 关联扩展表
|
||||
rvwConfig TenantRvwConfig?
|
||||
// pkbConfig TenantPkbConfig? (未来扩展)
|
||||
|
||||
@@schema("platform\_schema")
|
||||
}
|
||||
|
||||
// 3\. 期刊专属扩展表 (保持不变)
|
||||
model TenantRvwConfig {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
tenant Tenant @relation(fields: \[tenantId\], references: \[id\])
|
||||
// ... Prompt 和 Template 字段
|
||||
}
|
||||
|
||||
## **🖥️ 四、 前端展现独立化设计方案 (ADMIN UI)**
|
||||
|
||||
在之前的审查中,我曾建议“砍掉独立的期刊配置中心,回归租户详情页”。**在此,基于您的战略预判,我正式推翻该建议,支持您最初的构想:在前端建立独立的《期刊配置中心》。**
|
||||
|
||||
### **1\. 左侧导航重构**
|
||||
|
||||
ADMIN 端的导航栏应当按“业态垂直管理”和“平台横向管理”进行划分:
|
||||
|
||||
▼ 平台横向管理 (横向能力)
|
||||
\- 🏢 基础租户池 (所有租户的大盘,仅管理基础信息和计费)
|
||||
\- 👥 全局用户与权限
|
||||
\- 🎨 Prompt 基础设施
|
||||
|
||||
▼ 垂直业态中心 (深度定制)
|
||||
\- 🏥 临床研究中心配置 (过滤 tenantType=CLINICAL)
|
||||
\- 💊 药企知识库配置 (过滤 tenantType=PHARMA)
|
||||
\- 📖 期刊 SaaS 配置中心 (过滤 tenantType=JOURNAL)
|
||||
|
||||
### **2\. “期刊配置中心”的专属交互设计**
|
||||
|
||||
点击【期刊 SaaS 配置中心】后,前端交互应与普通租户管理区分开:
|
||||
|
||||
* **列表页 (JournalConfigList)**:
|
||||
* 后端 API 依然调用 GET /api/admin/tenants,但前端强制带上参数 ?type=JOURNAL。
|
||||
* 列表直接展示期刊专属的列:期刊名称、访问路径(Slug)、语言基线、AI 审查模块开关状态。
|
||||
* **新建期刊向导 (Creation Wizard)**:
|
||||
* 点击“新增期刊”,不要弹出普通租户那种几十项配置的抽屉。
|
||||
* 弹出专属向导:第一步填名称和 Slug,第二步选“中文核心/英文 SCI”,第三步自动为其绑定 RVW 模块权限,一气呵成。
|
||||
* **沉浸式配置页 (JournalConfigDetail)**:
|
||||
* 左侧是“稿约、方法学、数据、临床” 4 个锚点导航,右侧是您要求的“大文本框 Prompt \+ Handlebars 预览”,完全为期刊实施人员打造,没有任何多余的干扰信息。
|
||||
|
||||
## **🚀 五、 给 MVP 开发计划的修正建议**
|
||||
|
||||
您可以直接把这份文档同步给开发团队,并对之前的《MVP 开发计划》做如下调整指示:
|
||||
|
||||
1. **DB 调整**:必须在 tenants 表新增 tenant\_type (Enum) 字段,以此作为划分业态的唯一真理。
|
||||
2. **前端放行**:**恢复执行 FE-1 (新增左侧一级模块) 和 FE-2 (独立的期刊列表页) 任务!** 3\. **接口复用**:前端虽然拆了页面,但后端**不需要**写两套 CRUD。列表查询依然用 /api/admin/tenants?type=JOURNAL,配置更新依然用 /api/admin/tenants/:id/rvw-config。
|
||||
|
||||
**总结:** 您的这个决定避免了系统在未来半年内演变成一个庞大而混乱的“怪物”。通过**数据层的强内聚**(共享 Tenants 表和 SSO)与**前端页面的强解耦**(独立业务配置中心),平台将具备极强的横向扩展能力!
|
||||
76
docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/期刊配置中心MVP计划审查报告.md
Normal file
76
docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/期刊配置中心MVP计划审查报告.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# **RVW V4.0 期刊配置中心 MVP 开发计划 \- 架构与工程审查报告 (V2.0 战略更新版)**
|
||||
|
||||
**审查目标:** 评估《RVW V4.0 期刊配置中心 MVP 开发计划 v1.0》的技术可行性、与平台多业态(临床/药企/期刊)演进架构的对齐度,以及潜在的生产环境风险。
|
||||
|
||||
**审查结论:** 计划整体逻辑清晰,**前端独立拆分模块(FE-1, FE-2)的战略方向极具前瞻性**。但在 **底层业态标识、数据库平滑迁移、防呆设计缺失** 等方面存在 5 个关键问题,需在进入开发前紧急修正。
|
||||
|
||||
## **🟢 核心架构亮点 (Architecture Gold Standard)**
|
||||
|
||||
### **1\. 优秀的前端解耦:独立的期刊配置中心**
|
||||
|
||||
* **计划现状**:任务 FE-1 和 FE-2 提出要“新增左侧一级模块(期刊配置中心)”和“新增列表页”。
|
||||
* **审查意见**:**极度赞同!** 随着平台演进,医院、药企、期刊的配置诉求将天差地别。将期刊配置从拥挤的通用 TenantDetailPage 中剥离出来,建立独立的沉浸式工作台,是极其健康的 SaaS 演进路线。
|
||||
* **⚠️ 后端防坑提醒**:虽然前端页面拆分了,但**后端绝对不能拆分新建独立的期刊租户表**。接口必须深度复用 GET /api/admin/tenants,通过下文提到的 tenant\_type 参数进行动态过滤。
|
||||
|
||||
## **🔴 高风险项 (P0级 \- 影响系统稳定与架构基线)**
|
||||
|
||||
### **2\. 业态类型缺失:如何从底层区分“期刊”与“医院”?**
|
||||
|
||||
* **计划现状**:将所有配置加在了全局的 tenants 表关联中,但没有明确区分租户种类的字段。
|
||||
* **冲突点**:前端要实现独立的“期刊列表页(FE-2)”,后端数据库中现有的临床租户和期刊租户目前混在一起,无法高效过滤。
|
||||
* **修正建议**:
|
||||
在 platform\_schema.tenants 表中,**必须**新增一个业态分类枚举字段:
|
||||
enum TenantType {
|
||||
CLINICAL
|
||||
PHARMA
|
||||
JOURNAL
|
||||
}
|
||||
// 在 tenants 表中新增:
|
||||
tenantType TenantType @default(CLINICAL)
|
||||
|
||||
只有 tenantType \=== 'JOURNAL' 的租户才会出现在期刊配置中心的列表中。
|
||||
|
||||
### **3\. 数据库迁移雷区:向已有表强加非空字段导致 Migrate 崩溃**
|
||||
|
||||
* **计划现状**:计划 4.2 A 节要求向已有的 platform\_schema.tenants 表新增 journal\_language 和 journal\_full\_name,且要求非空。
|
||||
* **冲突点**:线上数据库中 tenants 表已经存在历史数据(主站租户、IIT租户等)。如果直接执行 ALTER TABLE ADD COLUMN journal\_language TEXT NOT NULL,Prisma 会因为历史记录缺少该字段的默认值而直接报错中断。
|
||||
* **修正建议**:
|
||||
在 BE-1 的迁移策略中必须指明平滑过渡方案,建议二选一:
|
||||
* **方案 A(推荐)**:在 Schema 中将该字段设为必填并给定默认值 @default("ZH"),或对于非期刊租户允许其具有特定默认值。
|
||||
* **方案 B**:设为可选字段 String?,在业务代码(Controller/Zod层)强制要求对于 tenantType \=== JOURNAL 的记录非空拦截。
|
||||
|
||||
## **🟡 中风险项 (P1级 \- 影响开发体验与业务闭环)**
|
||||
|
||||
### **4\. 致命防呆设计丢失:Handlebars“测试渲染”预览功能被遗漏**
|
||||
|
||||
* **计划现状**:任务 FE-3 仅规划了“每个维度 Prompt \+ Handlebars Template 文本框”。
|
||||
* **冲突点**:让运营人员在干巴巴的文本框里盲写 Handlebars 代码是灾难性的。一旦写错括号 {{ 导致线上渲染白屏,客诉极大。
|
||||
* **修正建议**:
|
||||
必须在 FE-3 中加回 **“测试渲染 (Test Render)”** 按钮。要求前端复用 ADMIN 现有的 PromptEditorPage 中的 Mock 预览机制,在右侧提供实时 Markdown 预览面板。(此功能是 All-in-Prompt 架构的风控底线)。
|
||||
|
||||
### **5\. API 事务一致性风险:跨表更新未强调 Transaction**
|
||||
|
||||
* **计划现状**:任务 BE-3 设计了 PUT /.../basic-info 和 PUT /.../rvw-config。
|
||||
* **冲突点**:在“保存并发布”时,如果前端提交了一个大表单,同时修改了主表 tenants(如:期刊名称)和关联表 tenant\_rvw\_configs(如:方法学 Prompt)。若分开调用接口或在 Service 层不使用事务,极易造成数据不一致。
|
||||
* **修正建议**:
|
||||
在 BE-3 任务描述中增加硬性约束:**涉及跨 tenants 与 tenant\_rvw\_configs 表的同时写入操作,必须使用 Prisma $transaction 包裹,确保原子性。**
|
||||
|
||||
## **🟢 低风险项 (P2级 \- 细节补齐)**
|
||||
|
||||
### **6\. OTHER 语言的枚举映射与回退机制**
|
||||
|
||||
* **计划现状**:计划 3.2 提到“英文/其他 \-\> en”。
|
||||
* **建议补充**:在后端 SkillExecutor 的合并逻辑中(BE-4),明确将这一点写进代码注释:
|
||||
// 当 journalLanguage 为 OTHER 时,强制 fallback 到 EN 基线,以通用 SCI 标准兜底
|
||||
const langBase \= tenantConfig.editorialBaseStandard ??
|
||||
(tenant.journalLanguage \=== 'ZH' ? 'zh' : 'en');
|
||||
|
||||
## **🚀 最终行动建议 (Next Steps)**
|
||||
|
||||
请开发团队在实施前对计划文档作如下微调:
|
||||
|
||||
1. **推进独立前端**:落实 FE-1 和 FE-2,确保期刊业务拥有纯净的管理体验。
|
||||
2. **底层数据扩充**:在 Prisma Schema 中补齐 tenant\_type 枚举,并为 journal\_language 设定 @default 兜底(或允许为空)以解决数据迁移报错风险。
|
||||
3. **安全防线**:务必补上前端的 **“Handlebars 实时预览”** 按钮和后端的 **$transaction 事务一致性** 约束。
|
||||
|
||||
完成以上 3 点修正后,该 MVP 计划可立即进入研发阶段。
|
||||
@@ -19,6 +19,7 @@
|
||||
| DB-1 | SSA Agent 执行记录新增分步执行与种子审计字段(`step_results/current_step/seed_audit`) | `20260311_add_ssa_agent_step_seed_fields` | 高 | 按数据库规范生成;Shadow DB 失败后采用降级流程产出 SQL,并已人工收敛为仅本次字段变更 |
|
||||
| DB-2 | RVW V4.0:新增 `platform_schema.tenant_rvw_configs` 表(每期刊独立审稿配置,含4维提示词+Handlebars模板) | `20260314_add_tenant_rvw_configs` | 高 | ⚠️ 部署前无需前置条件;使用降级流程手动创建迁移SQL,需执行 `prisma migrate resolve --applied 20260314_add_tenant_rvw_configs` 标记为已应用 |
|
||||
| DB-3 | RVW V4.0:`rvw_schema.review_tasks` 新增 `tenant_id` 字段 + 索引(历史数据平滑回填两步走) | `20260314_add_tenant_id_to_review_tasks` | 高 | ⚠️ **部署前必须先确认** `platform_schema.tenants` 中存在 `code='yanjiu'` 的主站默认租户;迁移会自动将历史记录回填为该租户ID;需执行 `prisma migrate resolve --applied 20260314_add_tenant_id_to_review_tasks` 标记为已应用 |
|
||||
| DB-4 | RVW V4.0:期刊配置中心 MVP 一次性补齐字段(`tenants` 新增期刊字段;`tenant_rvw_configs` 升级为 4 维 Prompt+Template;`TenantType` 新增 `JOURNAL`) | `20260315_journal_config_center_mvp` | 高 | ⚠️ `prisma migrate dev` 因历史 shadow DB 迁移失败触发降级流程,已按规范收敛手工 SQL;部署后执行 `prisma migrate resolve --applied 20260315_journal_config_center_mvp` |
|
||||
|
||||
### 后端变更 (Node.js)
|
||||
|
||||
@@ -30,7 +31,9 @@
|
||||
| BE-4 | R 代码语法修复器纠正 `} else` 处理策略,避免引入 `unexpected 'else'` | `backend/src/modules/ssa/services/CodeRunnerService.ts` | 重新构建镜像 | 修复线上语法错误噪声,减少重试失败 |
|
||||
| BE-5 | RVW 审稿通道改造:4 通道 Prompt 动静分离(业务提示词可编辑 + 系统协议固化)+ 方法学/稿约 JSON 结构化修复兜底 + DataForensics 默认切换为 LLM-only(规则验证默认关闭) | `backend/src/modules/rvw/services/promptProtocols.ts`, `backend/src/modules/rvw/services/editorialService.ts`, `backend/src/modules/rvw/services/methodologyService.ts`, `backend/src/modules/rvw/services/clinicalService.ts`, `backend/src/modules/rvw/skills/library/DataForensicsSkill.ts`, `backend/src/modules/rvw/skills/core/types.ts`, `backend/src/common/document/ExtractionClient.ts`, `backend/src/common/prompt/prompt.fallbacks.ts` | 重新构建镜像 | 解决运营端改 Prompt 导致 JSON 解析失败;数据侦探默认仅“表格提取+LLM判断”,规则代码保留可回切 |
|
||||
| BE-6 | RVW V4.0 Phase 1:Prisma Schema 新增 TenantRvwConfig 模型 + ReviewTask.tenantId 字段 + RVW租户中间件(rvwTenantMiddleware,slug到UUID解析+tenant_members校验+缓存)+ FastifyRequest 扩展 tenantId/tenant 字段 | `backend/prisma/schema.prisma`, `backend/src/modules/rvw/middleware/rvwTenantMiddleware.ts`, `backend/src/common/auth/auth.middleware.ts` | 重新构建镜像 + 执行 DB-2/DB-3 迁移 | 与 DB-2/DB-3 配套上线;Prisma Client 已重新生成 |
|
||||
|| BE-7 | RVW V4.0 Phase 2:RVW Config CRUD API(GET/PUT `/api/admin/tenants/:id/rvw-config`)+ Handlebars 渲染引擎(Zod 校验 + 默认模板)+ SkillExecutor 按租户装配 Hybrid Prompt | `backend/src/modules/admin/rvw-config/rvwConfigController.ts`, `backend/src/modules/admin/rvw-config/rvwConfigService.ts`, `backend/src/modules/admin/rvw-config/rvwConfigRoutes.ts`, `backend/src/modules/rvw/services/rvwReportRenderer.ts`, `backend/src/modules/rvw/workers/reviewWorker.ts`, `backend/src/index.ts` | 重新构建镜像 | 与 BE-6/DB-2/DB-3 配套上线 |
|
||||
| BE-7 | RVW V4.0 Phase 2:RVW Config CRUD API(GET/PUT `/api/admin/tenants/:id/rvw-config`)+ Handlebars 渲染引擎(Zod 校验 + 默认模板)+ SkillExecutor 按租户装配 Hybrid Prompt | `backend/src/modules/admin/rvw-config/rvwConfigController.ts`, `backend/src/modules/admin/rvw-config/rvwConfigService.ts`, `backend/src/modules/admin/rvw-config/rvwConfigRoutes.ts`, `backend/src/modules/rvw/services/rvwReportRenderer.ts`, `backend/src/modules/rvw/workers/reviewWorker.ts`, `backend/src/index.ts` | 重新构建镜像 | 与 BE-6/DB-2/DB-3 配套上线 |
|
||||
| BE-8 | RVW V4.0 Phase 3:后端 CORS 配置新增 x-tenant-id 白名单(多租户 Header 跨域支持) | `backend/src/index.ts` | 重新构建镜像 | ⚠️ 缺少此项会导致浏览器预检请求拦截 x-tenant-id,整个多租户功能失效 |
|
||||
| BE-9 | RVW V4.0 Phase 4(期刊配置中心 MVP):新增 `/api/admin/journal-configs` 专用 API;租户模型扩展期刊字段;RVW 执行链路接入最小配置适配(`tenantCustom ?? systemDefault`),Editorial 支持 `zh/en` 基线路由;创建/更新为 `JOURNAL` 时自动兜底开通 `RVW` 模块 | `backend/src/modules/admin/journal-config/*`, `backend/src/modules/admin/services/tenantService.ts`, `backend/src/modules/admin/types/tenant.types.ts`, `backend/src/modules/admin/rvw-config/*`, `backend/src/modules/rvw/workers/reviewWorker.ts`, `backend/src/modules/rvw/skills/library/*`, `backend/src/modules/rvw/services/editorialService.ts`, `backend/src/modules/rvw/services/clinicalService.ts`, `backend/src/common/prompt/prompt.fallbacks.ts`, `backend/src/index.ts` | 重新构建镜像 + 执行 DB-4 迁移 | 与 FE-7 联动上线,满足「独立一级菜单 + 中英文基线 + 继承/覆盖」MVP 范围,并避免新期刊用户登录后无 RVW 模块权限 |
|
||||
|
||||
### 前端变更
|
||||
|
||||
@@ -39,10 +42,11 @@
|
||||
| FE-1 | Agent 通道接入 step_* SSE 事件并展示分步执行状态(兼容旧 code_* 事件) | `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts`, `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/types/index.ts`, `frontend-v2/src/modules/ssa/stores/ssaStore.ts` | 重新构建镜像 | 右侧工作区可见每步状态/错误/耗时,便于排障 |
|
||||
| FE-2 | Agent 计划阶段复用 QPER 变量编辑控件(单变量/多变量)并接入保存、确认前自动保存 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx`, `frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx` | 重新构建镜像 | 对接 `PATCH /agent-executions/:executionId/plan-params`,实现 5A.5 前后端闭环 |
|
||||
| FE-3 | Agent 工作区增强:在分步状态下可展开查看每步已生成结果(`reportBlocks`),并兼容严格分步模式下的 `code_pending` 空代码预览 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts` | 重新构建镜像 | 修复“有结果但代码下方不可见”与状态显示误导问题 |
|
||||
|| FE-4 | RVW V4.0 Phase 2:TenantDetailPage 新增「智能审稿配置」Tab(4 Panel:稿约规范占位 / 方法学Prompt+Handlebars模板 / 数据验证深度L1-L3 / 临床FINER权重)+ tenantApi 新增 fetchRvwConfig/saveRvwConfig | `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx`, `frontend-v2/src/pages/admin/tenants/api/tenantApi.ts` | 重新构建镜像 | 与 BE-7/DB-2/DB-3 配套上线 |
|
||||
|| BE-8 | RVW V4.0 Phase 3:后端 CORS 配置新增 x-tenant-id 白名单(多租户 Header 跨域支持) | `backend/src/index.ts` | 重新构建镜像 | ⚠️ 缺少此项会导致浏览器预检请求拦截 x-tenant-id,整个多租户功能失效 |
|
||||
|| FE-5 | RVW V4.0 Phase 3:新增 TenantPortalLayout 极简期刊门户布局 + App.tsx 新增 /:tenantSlug/rvw/* 路由(期刊专属 URL)+ LoginPage.tsx 修复跳转逻辑(读取 ?redirect= 查询参数 + 租户默认落地页 /:tenantSlug/rvw) | `frontend-v2/src/framework/layout/TenantPortalLayout.tsx`, `frontend-v2/src/App.tsx`, `frontend-v2/src/pages/LoginPage.tsx` | 重新构建镜像 | 与 BE-6/BE-7/BE-8 配套上线;实现期刊租户完整访问路径 /jtim → /jtim/rvw |
|
||||
| FE-6 | RVW V4.0 租户门户体验收敛:上传按钮稳定触发(原生文件选择器 API + input 回退)、执行审稿后跳转旧版过程页(复用 `TaskDetail`)、列表四维状态图标修复(完成态正确显示绿勾/警告) | `frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx`, `frontend-v2/src/modules/rvw/pages/TenantTaskDetail.tsx` | 重新构建镜像 | 已本地联调通过;上线后重点回归 `/t/:tenant/login -> /:tenant/rvw` 主流程 |
|
||||
| FE-4 | RVW V4.0 Phase 2:TenantDetailPage 新增「智能审稿配置」Tab(4 Panel:稿约规范占位 / 方法学Prompt+Handlebars模板 / 数据验证深度L1-L3 / 临床FINER权重)+ tenantApi 新增 fetchRvwConfig/saveRvwConfig | `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx`, `frontend-v2/src/pages/admin/tenants/api/tenantApi.ts` | 重新构建镜像 | 与 BE-7/DB-2/DB-3 配套上线 |
|
||||
| FE-5 | RVW V4.0 Phase 3:新增 TenantPortalLayout 极简期刊门户布局 + App.tsx 新增 /:tenantSlug/rvw/* 路由(期刊专属 URL)+ LoginPage.tsx 修复跳转逻辑(读取 ?redirect= 查询参数 + 租户默认落地页 /:tenantSlug/rvw) | `frontend-v2/src/framework/layout/TenantPortalLayout.tsx`, `frontend-v2/src/App.tsx`, `frontend-v2/src/pages/LoginPage.tsx` | 重新构建镜像 | 与 BE-6/BE-7/BE-8 配套上线;实现期刊租户完整访问路径 /jtim → /jtim/rvw |
|
||||
| FE-6 | RVW V4.0 租户门户体验收敛:上传按钮稳定触发(原生文件选择器 API + input 回退)、执行审稿后跳转旧版过程页(复用 `TaskDetail`)、列表四维状态图标修复(完成态正确显示绿勾/警告) | `frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx`, `frontend-v2/src/modules/rvw/pages/TenantTaskDetail.tsx` | 重新构建镜像 | 已本地联调通过;上线后重点回归 `/:tenant/login -> /:tenant/rvw` 主流程 |
|
||||
| FE-7 | RVW V4.0 期刊配置中心 MVP:ADMIN 新增独立一级菜单“期刊配置中心”,新增列表页/详情页(基础信息 + 4 维审稿配置),并对齐新 API DTO(`journalLanguage`、`editorialBaseStandard`、Prompt/Handlebars 字段) | `frontend-v2/src/framework/layout/AdminLayout.tsx`, `frontend-v2/src/App.tsx`, `frontend-v2/src/pages/admin/journal-configs/*`, `frontend-v2/src/pages/admin/tenants/api/tenantApi.ts`, `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx` | 重新构建镜像 | 与 BE-9/DB-4 配套上线;MVP 阶段先跑通配置闭环,品牌视觉字段先存储后逐步渲染 |
|
||||
| FE-8 | 登录路径策略统一为单一路径:仅保留 `/:tenantCode/login`,未登录重定向与退出登录统一 `/:tenant/login`,开发/生产同路径仅域名不同 | `frontend-v2/src/App.tsx`, `frontend-v2/src/framework/router/RouteGuard.tsx`, `frontend-v2/src/framework/layout/TenantPortalLayout.tsx`, `frontend-v2/src/pages/TenantLoginPage.tsx`, `frontend-v2/src/pages/LoginPage.tsx` | 重新构建镜像 | 对齐生产目标链接 `review.xunzhengyixue.com/test-qikan01`;不再保留 `/t/:tenantCode/login` 路径,需同步更新测试文档/书签 |
|
||||
|
||||
### Python 微服务变更
|
||||
|
||||
@@ -66,7 +70,8 @@
|
||||
|
||||
| # | 变更内容 | 范围 | 备注 |
|
||||
|---|---------|------|------|
|
||||
| — | *暂无* | | |
|
||||
| INF-1 | 前端 Nginx 增加 SPA 深链回退(`try_files $uri /index.html`),并确保 `/api/` 路由优先反代后端(不能被 index.html 吞掉) | frontend-nginx-service / ingress | ⚠️ 否则直接访问 `review.xunzhengyixue.com/test-qikan01` 或 `.../test-qikan01/login` 会 404;部署后需在无缓存浏览器验证 |
|
||||
| INF-2 | 前端 Docker 构建与发布注意:确认镜像内 Nginx 配置已包含深链规则、并清理旧静态资源缓存(CDN/浏览器)后再灰度 | frontend-v2 build/deploy pipeline | 建议发布后先执行硬刷新(Ctrl+F5)与隐身窗口验证,避免旧 bundle 缓存导致仍跳旧路径 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ import IitQcCockpitPage from './modules/admin/pages/IitQcCockpitPage'
|
||||
import IitMemberManagePage from './modules/admin/pages/IitMemberManagePage'
|
||||
// 运营日志
|
||||
import ActivityLogsPage from './pages/admin/ActivityLogsPage'
|
||||
import JournalConfigListPage from './pages/admin/journal-configs/JournalConfigListPage'
|
||||
import JournalConfigDetailPage from './pages/admin/journal-configs/JournalConfigDetailPage'
|
||||
// 个人中心页面
|
||||
import ProfilePage from './pages/user/ProfilePage'
|
||||
|
||||
@@ -55,7 +57,7 @@ import ProfilePage from './pages/user/ProfilePage'
|
||||
*
|
||||
* 路由结构:
|
||||
* - /login - 通用登录页(个人用户)
|
||||
* - /t/{tenantCode}/login - 租户专属登录页
|
||||
* - /{tenantCode}/login - 租户专属登录页
|
||||
* - / - 首页(需要认证)
|
||||
* - /{module}/* - 业务模块(需要认证+权限)
|
||||
*/
|
||||
@@ -113,8 +115,8 @@ function App() {
|
||||
<Routes>
|
||||
{/* 登录页面(无需认证) */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
{/* 期刊租户专属登录页(全屏原型风格,独立于所有 Layout)*/}
|
||||
<Route path="/t/:tenantCode/login" element={<TenantLoginPage />} />
|
||||
{/* 期刊租户专属登录页(全屏原型风格,独立于所有 Layout) */}
|
||||
<Route path="/:tenantCode/login" element={<TenantLoginPage />} />
|
||||
|
||||
{/* 业务应用端 /app/* */}
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
@@ -168,6 +170,9 @@ function App() {
|
||||
<Route path="iit-members" element={<IitMemberManagePage />} />
|
||||
{/* 运营日志 */}
|
||||
<Route path="activity-logs" element={<ActivityLogsPage />} />
|
||||
{/* 期刊配置中心 */}
|
||||
<Route path="journal-configs" element={<JournalConfigListPage />} />
|
||||
<Route path="journal-configs/:id" element={<JournalConfigDetailPage />} />
|
||||
{/* 系统配置 */}
|
||||
<Route path="system" element={<div className="text-center py-20">🚧 系统配置页面开发中...</div>} />
|
||||
</Route>
|
||||
|
||||
@@ -101,6 +101,7 @@ const AdminLayout = () => {
|
||||
type: 'group' as const,
|
||||
label: '商务运营',
|
||||
children: [
|
||||
{ key: '/admin/journal-configs', icon: <BookOutlined />, label: '期刊配置中心' },
|
||||
{ key: '/admin/tenants', icon: <TeamOutlined />, label: '租户管理' },
|
||||
{ key: '/admin/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/admin/activity-logs', icon: <FileTextOutlined />, label: '运营日志' },
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function TenantPortalLayout() {
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
window.location.href = `/t/${tenantSlug}/login`;
|
||||
window.location.href = `/${tenantSlug}/login`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,7 @@ import { LockOutlined } from '@ant-design/icons'
|
||||
* 3. 有权限→渲染子组件
|
||||
*
|
||||
* @version 2026-03-14 V4.0:租户感知重定向
|
||||
* - 期刊租户路径(/jtim/*)→ /t/jtim/login?redirect=/jtim/dashboard
|
||||
* - 期刊租户路径(/jtim/*)→ /jtim/login?redirect=/jtim/dashboard
|
||||
* - 主平台路径(/rvw/*, /ai-qa/* 等)→ /login(行为不变)
|
||||
* - extractTenantSlug 由 useTenantObserver 模块统一维护,自动排除所有注册模块路径
|
||||
*/
|
||||
@@ -22,16 +22,16 @@ import { LockOutlined } from '@ant-design/icons'
|
||||
/**
|
||||
* 构造未登录时的登录跳转目标。
|
||||
*
|
||||
* 对齐 App.tsx 中已定义的路由:<Route path="/t/:tenantCode/login" />
|
||||
* 对齐 App.tsx 中已定义的路由:<Route path="/:tenantCode/login" />
|
||||
*
|
||||
* - 期刊租户下:/t/jtim/login?redirect=%2Fjtim%2Fdashboard
|
||||
* - 期刊租户下:/jtim/login?redirect=%2Fjtim%2Fdashboard
|
||||
* - 主站下:/login(保持原有行为,向后兼容)
|
||||
*/
|
||||
function buildLoginRedirect(pathname: string): string {
|
||||
const tenantSlug = extractTenantSlug(pathname)
|
||||
if (!tenantSlug) return '/login'
|
||||
const redirectParam = encodeURIComponent(pathname)
|
||||
return `/t/${tenantSlug}/login?redirect=${redirectParam}`
|
||||
return `/${tenantSlug}/login?redirect=${redirectParam}`
|
||||
}
|
||||
|
||||
interface RouteGuardProps {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* 路由:
|
||||
* - /login - 通用登录(个人用户)
|
||||
* - /t/{tenantCode}/login - 租户专属登录(机构用户)
|
||||
* - /{tenantCode}/login - 租户专属登录(机构用户)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
@@ -106,7 +106,7 @@ export default function LoginPage() {
|
||||
const userRole = user?.role;
|
||||
const userModules = user?.modules || [];
|
||||
|
||||
// 1. ?redirect= 查询参数优先(RouteGuard 对租户路由设置,如 /t/jtim/login?redirect=%2Fjtim%2Frvw)
|
||||
// 1. ?redirect= 查询参数优先(RouteGuard 对租户路由设置,如 /jtim/login?redirect=%2Fjtim%2Frvw)
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const redirectParam = searchParams.get('redirect');
|
||||
if (redirectParam) {
|
||||
@@ -132,7 +132,7 @@ export default function LoginPage() {
|
||||
return from;
|
||||
}
|
||||
|
||||
// 3. 期刊租户专属登录页(/t/:tenantCode/login)且无 redirect 参数时,默认进入该租户审稿页
|
||||
// 3. 期刊租户专属登录页(/:tenantCode/login)且无 redirect 参数时,默认进入该租户审稿页
|
||||
if (tenantCode) {
|
||||
return `/${tenantCode}/rvw`;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* ─ 100% 独立全屏,无任何顶部导航/Layout 包裹
|
||||
* ─ 设计 100% 还原 AI审稿V1.html 原型图
|
||||
* ─ 功能:密码登录 + 验证码登录(与主站 LoginPage 逻辑相同)
|
||||
* ─ 路由: /t/:tenantCode/login
|
||||
* ─ 路由: /:tenantCode/login
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Alert, Button, Card, Divider, Form, Input, Select, Space, Spin, Tabs, message } from 'antd';
|
||||
import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import type { JournalLanguage, UpdateRvwConfigRequest } from '../tenants/api/tenantApi';
|
||||
import {
|
||||
fetchJournalDetail,
|
||||
saveJournalBasicInfo,
|
||||
saveJournalRvwConfig,
|
||||
} from './api/journalConfigApi';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const LANGUAGE_OPTIONS: Array<{ value: JournalLanguage; label: string }> = [
|
||||
{ value: 'ZH', label: '中文' },
|
||||
{ value: 'EN', label: '英文' },
|
||||
{ value: 'OTHER', label: '其他' },
|
||||
];
|
||||
|
||||
export default function JournalConfigDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const [basicForm] = Form.useForm();
|
||||
const [rvwForm] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const detail = await fetchJournalDetail(id);
|
||||
basicForm.setFieldsValue({
|
||||
name: detail.tenant.name,
|
||||
journalFullName: detail.tenant.journalFullName || detail.tenant.name,
|
||||
code: detail.tenant.code,
|
||||
journalLanguage: detail.tenant.journalLanguage || 'ZH',
|
||||
logoUrl: detail.tenant.logoUrl || '',
|
||||
brandColor: detail.tenant.brandColor || '',
|
||||
loginBackgroundUrl: detail.tenant.loginBackgroundUrl || '',
|
||||
});
|
||||
|
||||
rvwForm.setFieldsValue({
|
||||
editorialBaseStandard: detail.rvwConfig?.editorialBaseStandard || 'en',
|
||||
editorialExpertPrompt: detail.rvwConfig?.editorialExpertPrompt || '',
|
||||
editorialHandlebarsTemplate: detail.rvwConfig?.editorialHandlebarsTemplate || '',
|
||||
methodologyExpertPrompt: detail.rvwConfig?.methodologyExpertPrompt || '',
|
||||
methodologyHandlebarsTemplate: detail.rvwConfig?.methodologyHandlebarsTemplate || '',
|
||||
dataForensicsExpertPrompt: detail.rvwConfig?.dataForensicsExpertPrompt || '',
|
||||
dataForensicsHandlebarsTemplate: detail.rvwConfig?.dataForensicsHandlebarsTemplate || '',
|
||||
clinicalExpertPrompt: detail.rvwConfig?.clinicalExpertPrompt || '',
|
||||
clinicalHandlebarsTemplate: detail.rvwConfig?.clinicalHandlebarsTemplate || '',
|
||||
});
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载期刊配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const handleSaveBasic = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const values = await basicForm.validateFields();
|
||||
setSaving(true);
|
||||
await saveJournalBasicInfo(id, values);
|
||||
message.success('基础信息已保存');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRvw = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const values = await rvwForm.validateFields();
|
||||
const payload: UpdateRvwConfigRequest = {
|
||||
editorialBaseStandard: values.editorialBaseStandard,
|
||||
editorialExpertPrompt: values.editorialExpertPrompt || null,
|
||||
editorialHandlebarsTemplate: values.editorialHandlebarsTemplate || null,
|
||||
methodologyExpertPrompt: values.methodologyExpertPrompt || null,
|
||||
methodologyHandlebarsTemplate: values.methodologyHandlebarsTemplate || null,
|
||||
dataForensicsExpertPrompt: values.dataForensicsExpertPrompt || null,
|
||||
dataForensicsHandlebarsTemplate: values.dataForensicsHandlebarsTemplate || null,
|
||||
clinicalExpertPrompt: values.clinicalExpertPrompt || null,
|
||||
clinicalHandlebarsTemplate: values.clinicalHandlebarsTemplate || null,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
await saveJournalRvwConfig(id, payload);
|
||||
message.success('审稿配置已保存');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/journal-configs')} style={{ marginBottom: 16 }}>
|
||||
返回列表
|
||||
</Button>
|
||||
|
||||
<Card title="期刊配置中心">
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '基础信息与门户',
|
||||
children: (
|
||||
<Form form={basicForm} layout="vertical" style={{ maxWidth: 760 }}>
|
||||
<Form.Item name="name" label="租户名称" rules={[{ required: true, message: '请输入租户名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="journalFullName" label="期刊全称" rules={[{ required: true, message: '请输入期刊全称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="访问路径 Slug">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="journalLanguage" label="期刊语言" rules={[{ required: true, message: '请选择期刊语言' }]}>
|
||||
<Select>
|
||||
{LANGUAGE_OPTIONS.map(item => (
|
||||
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="logoUrl" label="期刊 Logo URL(P1 预留)">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
<Form.Item name="brandColor" label="品牌主色(P1 预留)">
|
||||
<Input placeholder="#0284c7" />
|
||||
</Form.Item>
|
||||
<Form.Item name="loginBackgroundUrl" label="登录背景图 URL(P2 预留)">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSaveBasic}>
|
||||
保存基础信息
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rvw',
|
||||
label: '智能审稿配置',
|
||||
children: (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="留空即继承系统默认配置;填写后即覆盖该期刊默认行为。"
|
||||
/>
|
||||
<Form form={rvwForm} layout="vertical">
|
||||
<Divider>A. 稿约规范评估(中英基线)</Divider>
|
||||
<Form.Item name="editorialBaseStandard" label="继承基线">
|
||||
<Select style={{ width: 280 }}>
|
||||
<Select.Option value="en">英文期刊标准(EN)</Select.Option>
|
||||
<Select.Option value="zh">中文期刊标准(ZH)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="editorialExpertPrompt" label="Editorial Prompt(留空继承默认)">
|
||||
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="editorialHandlebarsTemplate" label="Editorial Handlebars 模板(留空继承默认)">
|
||||
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>B. 方法学评估</Divider>
|
||||
<Form.Item name="methodologyExpertPrompt" label="Methodology Prompt(留空继承默认)">
|
||||
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="methodologyHandlebarsTemplate" label="Methodology Handlebars 模板(留空继承默认)">
|
||||
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>C. 数据验证评估</Divider>
|
||||
<Form.Item name="dataForensicsExpertPrompt" label="Data Forensics Prompt(留空继承默认)">
|
||||
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dataForensicsHandlebarsTemplate" label="Data Forensics Handlebars 模板(留空继承默认)">
|
||||
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>D. 临床专业评估</Divider>
|
||||
<Form.Item name="clinicalExpertPrompt" label="Clinical Prompt(留空继承默认)">
|
||||
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="clinicalHandlebarsTemplate" label="Clinical Handlebars 模板(留空继承默认)">
|
||||
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSaveRvw}>
|
||||
保存审稿配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Input, Select, Button, Space, message } from 'antd';
|
||||
import { SearchOutlined, SettingOutlined, BookOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { fetchJournalList, type JournalItem } from './api/journalConfigApi';
|
||||
import type { TenantStatus } from '../tenants/api/tenantApi';
|
||||
|
||||
const { Search } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
const STATUS_LABELS: Record<TenantStatus, { label: string; color: string }> = {
|
||||
ACTIVE: { label: '运营中', color: 'success' },
|
||||
SUSPENDED: { label: '已停用', color: 'error' },
|
||||
EXPIRED: { label: '已过期', color: 'warning' },
|
||||
};
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
ZH: '中文',
|
||||
EN: '英文',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
export default function JournalConfigListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<JournalItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState<TenantStatus | ''>('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchJournalList({
|
||||
page,
|
||||
limit: 20,
|
||||
search: search || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
setList(result.data);
|
||||
setTotal(result.total);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [page, status]);
|
||||
|
||||
const columns: ColumnsType<JournalItem> = [
|
||||
{
|
||||
title: '期刊名称',
|
||||
dataIndex: 'journalFullName',
|
||||
key: 'journalFullName',
|
||||
render: (_, r) => r.journalFullName || r.name,
|
||||
},
|
||||
{
|
||||
title: 'Slug',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: 140,
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace' }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '语言',
|
||||
dataIndex: 'journalLanguage',
|
||||
key: 'journalLanguage',
|
||||
width: 90,
|
||||
render: (v: string | null | undefined) => <Tag>{LANGUAGE_LABELS[v || 'ZH'] || '中文'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (v: TenantStatus) => <Tag color={STATUS_LABELS[v].color}>{STATUS_LABELS[v].label}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 140,
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => navigate(`/admin/journal-configs/${record.id}`)}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<BookOutlined />
|
||||
<span>期刊配置中心</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<Search
|
||||
placeholder="搜索期刊名称/Slug"
|
||||
style={{ width: 280 }}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onSearch={(v) => {
|
||||
setSearch(v);
|
||||
setPage(1);
|
||||
setTimeout(load, 0);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
value={status || undefined}
|
||||
onChange={(v) => {
|
||||
setStatus(v || '');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{Object.entries(STATUS_LABELS).map(([k, cfg]) => (
|
||||
<Option key={k} value={k}>
|
||||
{cfg.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 20,
|
||||
total,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 本期刊`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { getAccessToken } from '../../../../framework/auth/api';
|
||||
import type { JournalLanguage, TenantRvwConfig, TenantStatus, TenantType, UpdateRvwConfigRequest } from '../../tenants/api/tenantApi';
|
||||
|
||||
const API_BASE = '/api/admin/journal-configs';
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = getAccessToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export interface JournalItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: TenantType;
|
||||
status: TenantStatus;
|
||||
journalLanguage?: JournalLanguage | null;
|
||||
journalFullName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
loginBackgroundUrl?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface JournalListResponse {
|
||||
success: boolean;
|
||||
data: JournalItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface JournalBasicInfoRequest {
|
||||
name?: string;
|
||||
journalLanguage?: JournalLanguage | null;
|
||||
journalFullName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
loginBackgroundUrl?: string | null;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface JournalDetailResponse {
|
||||
tenant: JournalItem & {
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
rvwConfig: TenantRvwConfig | null;
|
||||
}
|
||||
|
||||
export async function fetchJournalList(params?: {
|
||||
status?: TenantStatus;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<JournalListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||
|
||||
const response = await fetch(`${API_BASE}?${searchParams.toString()}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '获取期刊列表失败');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchJournalDetail(id: string): Promise<JournalDetailResponse> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, { headers: getAuthHeaders() });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '获取期刊详情失败');
|
||||
}
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function saveJournalBasicInfo(id: string, payload: JournalBasicInfoRequest) {
|
||||
const response = await fetch(`${API_BASE}/${id}/basic-info`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '保存基础信息失败');
|
||||
}
|
||||
return (await response.json()).data;
|
||||
}
|
||||
|
||||
export async function saveJournalRvwConfig(id: string, payload: UpdateRvwConfigRequest) {
|
||||
const response = await fetch(`${API_BASE}/${id}/rvw-config`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '保存审稿配置失败');
|
||||
}
|
||||
return (await response.json()).data as TenantRvwConfig;
|
||||
}
|
||||
@@ -18,19 +18,17 @@ import {
|
||||
Table,
|
||||
message,
|
||||
Spin,
|
||||
InputNumber,
|
||||
Divider,
|
||||
Alert,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
EditOutlined,
|
||||
SaveOutlined,
|
||||
BankOutlined,
|
||||
BookOutlined,
|
||||
MedicineBoxOutlined,
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
@@ -64,6 +62,7 @@ const PRIMARY_COLOR = '#10b981';
|
||||
const TENANT_TYPES: Record<TenantType, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
HOSPITAL: { label: '医院', icon: <BankOutlined />, color: 'blue' },
|
||||
PHARMA: { label: '药企', icon: <MedicineBoxOutlined />, color: 'purple' },
|
||||
JOURNAL: { label: '期刊', icon: <BookOutlined />, color: 'geekblue' },
|
||||
INTERNAL: { label: '内部', icon: <HomeOutlined />, color: 'cyan' },
|
||||
PUBLIC: { label: '公共', icon: <UserOutlined />, color: 'green' },
|
||||
};
|
||||
@@ -140,15 +139,15 @@ const TenantDetailPage = () => {
|
||||
setRvwConfig(data);
|
||||
if (data) {
|
||||
rvwForm.setFieldsValue({
|
||||
editorialBaseStandard: data.editorialBaseStandard || 'en',
|
||||
editorialExpertPrompt: data.editorialExpertPrompt || '',
|
||||
editorialHandlebarsTemplate: data.editorialHandlebarsTemplate || '',
|
||||
methodologyExpertPrompt: data.methodologyExpertPrompt || '',
|
||||
methodologyHandlebarsTemplate: data.methodologyHandlebarsTemplate || '',
|
||||
dataForensicsLevel: data.dataForensicsLevel || 'L2',
|
||||
dataForensicsExpertPrompt: data.dataForensicsExpertPrompt || '',
|
||||
dataForensicsHandlebarsTemplate: data.dataForensicsHandlebarsTemplate || '',
|
||||
clinicalExpertPrompt: data.clinicalExpertPrompt || '',
|
||||
finer_feasibility: data.finerWeights?.feasibility ?? 20,
|
||||
finer_innovation: data.finerWeights?.innovation ?? 20,
|
||||
finer_ethics: data.finerWeights?.ethics ?? 20,
|
||||
finer_relevance: data.finerWeights?.relevance ?? 20,
|
||||
finer_novelty: data.finerWeights?.novelty ?? 20,
|
||||
clinicalHandlebarsTemplate: data.clinicalHandlebarsTemplate || '',
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -165,25 +164,16 @@ const TenantDetailPage = () => {
|
||||
const values = await rvwForm.validateFields();
|
||||
setRvwConfigSaving(true);
|
||||
|
||||
const finerWeights = {
|
||||
feasibility: values.finer_feasibility,
|
||||
innovation: values.finer_innovation,
|
||||
ethics: values.finer_ethics,
|
||||
relevance: values.finer_relevance,
|
||||
novelty: values.finer_novelty,
|
||||
};
|
||||
const total = Object.values(finerWeights).reduce((a, b) => a + Number(b), 0);
|
||||
if (Math.abs(total - 100) > 1) {
|
||||
message.error(`FINER 权重之和应为 100,当前为 ${total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: UpdateRvwConfigRequest = {
|
||||
editorialBaseStandard: values.editorialBaseStandard || 'en',
|
||||
editorialExpertPrompt: values.editorialExpertPrompt || null,
|
||||
editorialHandlebarsTemplate: values.editorialHandlebarsTemplate || null,
|
||||
methodologyExpertPrompt: values.methodologyExpertPrompt || null,
|
||||
methodologyHandlebarsTemplate: values.methodologyHandlebarsTemplate || null,
|
||||
dataForensicsLevel: values.dataForensicsLevel,
|
||||
dataForensicsExpertPrompt: values.dataForensicsExpertPrompt || null,
|
||||
dataForensicsHandlebarsTemplate: values.dataForensicsHandlebarsTemplate || null,
|
||||
clinicalExpertPrompt: values.clinicalExpertPrompt || null,
|
||||
finerWeights,
|
||||
clinicalHandlebarsTemplate: values.clinicalHandlebarsTemplate || null,
|
||||
};
|
||||
|
||||
const saved = await saveRvwConfig(id, payload);
|
||||
@@ -527,16 +517,40 @@ const TenantDetailPage = () => {
|
||||
/>
|
||||
<Form form={rvwForm} layout="vertical">
|
||||
{/* Panel A:稿约规范 */}
|
||||
<Divider orientation="left">A. 稿约规范评估</Divider>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="稿约规则编辑器将在 P1 版本提供可视化配置界面,当前暂不支持自定义。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Divider>A. 稿约规范评估</Divider>
|
||||
<Form.Item
|
||||
name="editorialBaseStandard"
|
||||
label="继承模式的基线标准"
|
||||
extra="当未填写稿约自定义 Prompt 时,系统按此基线选择默认规则。"
|
||||
>
|
||||
<Select style={{ width: 280 }}>
|
||||
<Select.Option value="en">英文期刊标准(EN)</Select.Option>
|
||||
<Select.Option value="zh">中文期刊标准(ZH)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="editorialExpertPrompt"
|
||||
label="稿约规范评估 Prompt(留空继承系统默认)"
|
||||
>
|
||||
<TextArea
|
||||
rows={8}
|
||||
placeholder="自定义该租户的稿约规则描述..."
|
||||
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="editorialHandlebarsTemplate"
|
||||
label="稿约报告模板(Handlebars,留空使用系统默认)"
|
||||
>
|
||||
<TextArea
|
||||
rows={6}
|
||||
placeholder="# 稿约规范报告..."
|
||||
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Panel B:方法学评估 */}
|
||||
<Divider orientation="left">B. 方法学评估</Divider>
|
||||
<Divider>B. 方法学评估</Divider>
|
||||
<Form.Item
|
||||
name="methodologyExpertPrompt"
|
||||
label="方法学专家评判标准(业务 Prompt)"
|
||||
@@ -561,21 +575,30 @@ const TenantDetailPage = () => {
|
||||
</Form.Item>
|
||||
|
||||
{/* Panel C:数据验证 */}
|
||||
<Divider orientation="left">C. 数据验证评估</Divider>
|
||||
<Divider>C. 数据验证评估</Divider>
|
||||
<Form.Item
|
||||
name="dataForensicsLevel"
|
||||
label="数据验证深度"
|
||||
rules={[{ required: true }]}
|
||||
name="dataForensicsExpertPrompt"
|
||||
label="数据验证评估 Prompt(留空继承系统默认)"
|
||||
>
|
||||
<Select style={{ width: 240 }}>
|
||||
<Select.Option value="L1">L1 — 基础核验(格式 + 百分比计算)</Select.Option>
|
||||
<Select.Option value="L2">L2 — 标准核验(含统计方法适配性)</Select.Option>
|
||||
<Select.Option value="L3">L3 — 深度核验(含回归系数一致性)</Select.Option>
|
||||
</Select>
|
||||
<TextArea
|
||||
rows={8}
|
||||
placeholder="自定义该租户的数据验证评估要求..."
|
||||
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="dataForensicsHandlebarsTemplate"
|
||||
label="数据验证报告模板(Handlebars,留空使用系统默认)"
|
||||
>
|
||||
<TextArea
|
||||
rows={6}
|
||||
placeholder="# 数据验证报告..."
|
||||
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Panel D:临床专业评估 */}
|
||||
<Divider orientation="left">D. 临床专业评估</Divider>
|
||||
<Divider>D. 临床专业评估</Divider>
|
||||
<Form.Item
|
||||
name="clinicalExpertPrompt"
|
||||
label="临床首席科学家评判标准(业务 Prompt)"
|
||||
@@ -588,25 +611,15 @@ const TenantDetailPage = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="FINER 五维权重(总计应为 100)">
|
||||
<Space size="large" wrap>
|
||||
{[
|
||||
{ key: 'finer_feasibility', label: '可行性 F' },
|
||||
{ key: 'finer_innovation', label: '创新性 I' },
|
||||
{ key: 'finer_ethics', label: '伦理性 E' },
|
||||
{ key: 'finer_relevance', label: '相关性 R' },
|
||||
{ key: 'finer_novelty', label: '新颖性 N' },
|
||||
].map(({ key, label }) => (
|
||||
<Form.Item key={key} name={key} label={label} style={{ marginBottom: 0 }}>
|
||||
<InputNumber min={0} max={100} step={5} addonAfter="%" />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Space>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
各维度权重之和必须等于 100。默认每项 20 分。
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="clinicalHandlebarsTemplate"
|
||||
label="临床评估报告模板(Handlebars,留空使用系统默认)"
|
||||
>
|
||||
<TextArea
|
||||
rows={6}
|
||||
placeholder="# 临床评估报告..."
|
||||
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
BankOutlined,
|
||||
BookOutlined,
|
||||
MedicineBoxOutlined,
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
@@ -49,6 +50,7 @@ const PRIMARY_COLOR = '#10b981';
|
||||
const TENANT_TYPES: Record<TenantType, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
HOSPITAL: { label: '医院', icon: <BankOutlined />, color: 'blue' },
|
||||
PHARMA: { label: '药企', icon: <MedicineBoxOutlined />, color: 'purple' },
|
||||
JOURNAL: { label: '期刊', icon: <BookOutlined />, color: 'geekblue' },
|
||||
INTERNAL: { label: '内部', icon: <HomeOutlined />, color: 'cyan' },
|
||||
PUBLIC: { label: '公共', icon: <UserOutlined />, color: 'green' },
|
||||
};
|
||||
|
||||
@@ -20,14 +20,20 @@ function getAuthHeaders(): HeadersInit {
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'INTERNAL' | 'PUBLIC';
|
||||
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'JOURNAL' | 'INTERNAL' | 'PUBLIC';
|
||||
export type TenantStatus = 'ACTIVE' | 'SUSPENDED' | 'EXPIRED';
|
||||
export type JournalLanguage = 'ZH' | 'EN' | 'OTHER';
|
||||
|
||||
export interface TenantInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: TenantType;
|
||||
journalLanguage?: JournalLanguage | null;
|
||||
journalFullName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
loginBackgroundUrl?: string | null;
|
||||
status: TenantStatus;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
@@ -60,6 +66,11 @@ export interface CreateTenantRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
type: TenantType;
|
||||
journalLanguage?: JournalLanguage;
|
||||
journalFullName?: string;
|
||||
logoUrl?: string;
|
||||
brandColor?: string;
|
||||
loginBackgroundUrl?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
@@ -69,6 +80,12 @@ export interface CreateTenantRequest {
|
||||
|
||||
export interface UpdateTenantRequest {
|
||||
name?: string;
|
||||
type?: TenantType;
|
||||
journalLanguage?: JournalLanguage | null;
|
||||
journalFullName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
loginBackgroundUrl?: string | null;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
@@ -250,24 +267,30 @@ export async function fetchModuleList(): Promise<ModuleInfo[]> {
|
||||
export interface TenantRvwConfig {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
editorialRules: unknown | null;
|
||||
editorialBaseStandard: 'zh' | 'en';
|
||||
editorialExpertPrompt: string | null;
|
||||
editorialHandlebarsTemplate: string | null;
|
||||
methodologyExpertPrompt: string | null;
|
||||
methodologyHandlebarsTemplate: string | null;
|
||||
dataForensicsLevel: string;
|
||||
finerWeights: Record<string, number> | null;
|
||||
dataForensicsExpertPrompt: string | null;
|
||||
dataForensicsHandlebarsTemplate: string | null;
|
||||
clinicalExpertPrompt: string | null;
|
||||
clinicalHandlebarsTemplate: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 更新审稿配置请求 */
|
||||
export interface UpdateRvwConfigRequest {
|
||||
editorialRules?: unknown | null;
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
editorialExpertPrompt?: string | null;
|
||||
editorialHandlebarsTemplate?: string | null;
|
||||
methodologyExpertPrompt?: string | null;
|
||||
methodologyHandlebarsTemplate?: string | null;
|
||||
dataForensicsLevel?: string;
|
||||
finerWeights?: Record<string, number> | null;
|
||||
dataForensicsExpertPrompt?: string | null;
|
||||
dataForensicsHandlebarsTemplate?: string | null;
|
||||
clinicalExpertPrompt?: string | null;
|
||||
clinicalHandlebarsTemplate?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user