diff --git a/backend/src/modules/admin/controllers/userController.ts b/backend/src/modules/admin/controllers/userController.ts index 7443cfe5..6d6e7a60 100644 --- a/backend/src/modules/admin/controllers/userController.ts +++ b/backend/src/modules/admin/controllers/userController.ts @@ -288,6 +288,13 @@ export async function assignTenantToUser( }); } + if (error.message.includes('参数格式错误')) { + return reply.status(400).send({ + code: 400, + message: error.message, + }); + } + return reply.status(500).send({ code: 500, message: error.message || '分配租户失败', diff --git a/backend/src/modules/admin/services/userService.ts b/backend/src/modules/admin/services/userService.ts index 2ddc8ef7..db66ce34 100644 --- a/backend/src/modules/admin/services/userService.ts +++ b/backend/src/modules/admin/services/userService.ts @@ -28,6 +28,31 @@ const prisma = new PrismaClient(); // 默认密码 const DEFAULT_PASSWORD = '123456'; +/** + * 将模块参数标准化为字符串数组 + * - undefined/null => undefined + * - string => [string] + * - string[] => string[] + * 其他类型抛错,避免出现 map/filter 运行时错误 + */ +function normalizeModuleCodes( + raw: unknown, + fieldName: string = 'modules' +): string[] | undefined { + if (raw === undefined || raw === null) return undefined; + if (Array.isArray(raw)) { + return raw + .filter((v) => typeof v === 'string') + .map((v) => v.trim()) + .filter(Boolean); + } + if (typeof raw === 'string') { + const code = raw.trim(); + return code ? [code] : []; + } + throw new Error(`${fieldName} 参数格式错误,应为字符串数组`); +} + /** * 根据用户ID获取用户的 departmentId */ @@ -340,6 +365,10 @@ export async function getUserById(userId: string, scope: UserQueryScope): Promis * 创建用户 */ export async function createUser(data: CreateUserRequest, creatorId: string): Promise { + const normalizedAllowedModules = normalizeModuleCodes( + (data as any).allowedModules, + 'allowedModules' + ); // 检查手机号是否已存在 const existingUser = await prisma.user.findUnique({ where: { phone: data.phone }, @@ -411,9 +440,9 @@ export async function createUser(data: CreateUserRequest, creatorId: string): Pr }); // 如果指定了模块权限,创建用户模块记录 - if (data.allowedModules && data.allowedModules.length > 0) { + if (normalizedAllowedModules && normalizedAllowedModules.length > 0) { await tx.user_modules.createMany({ - data: data.allowedModules.map((moduleCode) => ({ + data: normalizedAllowedModules.map((moduleCode) => ({ id: uuidv4(), user_id: userId, tenant_id: data.tenantId, @@ -564,6 +593,10 @@ export async function assignTenantToUser( data: AssignTenantRequest, assignerId: string ): Promise { + const normalizedAllowedModules = normalizeModuleCodes( + (data as any).allowedModules, + 'allowedModules' + ); // 检查用户是否存在 const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { @@ -602,9 +635,9 @@ export async function assignTenantToUser( }); // 如果指定了模块权限,创建用户模块记录 - if (data.allowedModules && data.allowedModules.length > 0) { + if (normalizedAllowedModules && normalizedAllowedModules.length > 0) { await tx.user_modules.createMany({ - data: data.allowedModules.map((moduleCode) => ({ + data: normalizedAllowedModules.map((moduleCode) => ({ id: uuidv4(), user_id: userId, tenant_id: data.tenantId, @@ -684,6 +717,7 @@ export async function updateUserModules( data: UpdateUserModulesRequest, updaterId: string ): Promise { + const normalizedModules = normalizeModuleCodes((data as any).modules, 'modules') || []; // 检查用户是否是该租户成员 const membership = await prisma.tenant_members.findUnique({ where: { @@ -704,7 +738,7 @@ export async function updateUserModules( select: { code: true }, }); const validModuleCodes = allModules.map((m) => m.code); - const invalidModules = data.modules.filter((m) => !validModuleCodes.includes(m)); + const invalidModules = normalizedModules.filter((m) => !validModuleCodes.includes(m)); if (invalidModules.length > 0) { throw new Error(`以下模块代码不存在: ${invalidModules.join(', ')}`); } @@ -720,9 +754,9 @@ export async function updateUserModules( }); // 创建新的模块权限 - if (data.modules.length > 0) { + if (normalizedModules.length > 0) { await tx.user_modules.createMany({ - data: data.modules.map((moduleCode) => ({ + data: normalizedModules.map((moduleCode) => ({ id: uuidv4(), user_id: userId, tenant_id: data.tenantId, @@ -736,7 +770,7 @@ export async function updateUserModules( logger.info('[UserService] User modules updated', { userId, tenantId: data.tenantId, - modules: data.modules, + modules: normalizedModules, updatedBy: updaterId, }); } diff --git a/backend/src/modules/rvw/services/clinicalService.ts b/backend/src/modules/rvw/services/clinicalService.ts index 37726650..31d6557f 100644 --- a/backend/src/modules/rvw/services/clinicalService.ts +++ b/backend/src/modules/rvw/services/clinicalService.ts @@ -11,7 +11,7 @@ import { ModelType } from '../../../common/llm/adapters/types.js'; import { logger } from '../../../common/logging/index.js'; import { prisma } from '../../../config/database.js'; import { getPromptService } from '../../../common/prompt/index.js'; -import { composeRvwSystemPrompt } from './promptProtocols.js'; +import { composeRvwSystemPrompt, sanitizeRvwBusinessPrompt } from './promptProtocols.js'; export interface ClinicalReviewResult { report: string; @@ -40,6 +40,7 @@ export async function reviewClinical( businessPrompt = result.content; isDraft = result.isDraft; } + businessPrompt = sanitizeRvwBusinessPrompt('clinical', businessPrompt); if (isDraft) { logger.info('[RVW:Clinical] 使用 DRAFT 版本 Prompt(调试模式)', { userId }); diff --git a/backend/src/modules/rvw/services/editorialService.ts b/backend/src/modules/rvw/services/editorialService.ts index 4d2b4e69..83e20a0d 100644 --- a/backend/src/modules/rvw/services/editorialService.ts +++ b/backend/src/modules/rvw/services/editorialService.ts @@ -14,7 +14,7 @@ import { prisma } from '../../../config/database.js'; import { getPromptService } from '../../../common/prompt/index.js'; import { EditorialReview } from '../types/index.js'; import { parseJSONFromLLMResponse } from './utils.js'; -import { composeRvwSystemPrompt, getRvwProtocol } from './promptProtocols.js'; +import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js'; function isValidEditorialReview(result: unknown): result is EditorialReview { if (!result || typeof result !== 'object') return false; @@ -88,6 +88,7 @@ export async function reviewEditorialStandards( isDraft = result.isDraft; } } + businessPrompt = sanitizeRvwBusinessPrompt('editorial', businessPrompt); if (isDraft) { logger.info('[RVW:Editorial] 使用 DRAFT 版本 Prompt(调试模式)', { userId }); diff --git a/backend/src/modules/rvw/services/methodologyService.ts b/backend/src/modules/rvw/services/methodologyService.ts index 49a3a332..f6a75457 100644 --- a/backend/src/modules/rvw/services/methodologyService.ts +++ b/backend/src/modules/rvw/services/methodologyService.ts @@ -15,7 +15,7 @@ import { prisma } from '../../../config/database.js'; import { getPromptService } from '../../../common/prompt/index.js'; import { MethodologyCheckpoint, MethodologyIssue, MethodologyPart, MethodologyReview } from '../types/index.js'; import { parseJSONFromLLMResponse } from './utils.js'; -import { composeRvwSystemPrompt, getRvwProtocol } from './promptProtocols.js'; +import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js'; const METHODOLOGY_CONCLUSIONS = ['直接接收', '小修', '大修', '拒稿'] as const; type MethodologyConclusion = typeof METHODOLOGY_CONCLUSIONS[number]; @@ -464,6 +464,7 @@ export async function reviewMethodology( source: 'prompt_service', }); } + businessPrompt = sanitizeRvwBusinessPrompt('methodology', businessPrompt); const llmAdapter = LLMFactory.getAdapter(modelType); logger.info('[RVW:Methodology] 开始分治并行评估', { diff --git a/backend/src/modules/rvw/services/promptProtocols.ts b/backend/src/modules/rvw/services/promptProtocols.ts index edf66b61..81d60a2c 100644 --- a/backend/src/modules/rvw/services/promptProtocols.ts +++ b/backend/src/modules/rvw/services/promptProtocols.ts @@ -107,3 +107,57 @@ export function getRvwProtocol(channel: RvwPromptChannel): string { return RVW_PROTOCOLS[channel]; } +/** + * RVW 业务 Prompt 轻量净化器(止血版) + * 目的:避免运营端业务 Prompt 中“输出格式指令”与研发固化协议冲突 + */ +export function sanitizeRvwBusinessPrompt(channel: RvwPromptChannel, businessPrompt: string): string { + const raw = (businessPrompt || '').trim(); + if (!raw) return ''; + + const lines = raw.split(/\r?\n/); + const out: string[] = []; + + let skipOutputSection = false; + for (const line of lines) { + const normalized = line.trim().toLowerCase(); + + // 进入“输出要求/输出格式”段后,后续内容通常是格式指令,直接截断 + if ( + /^输出要求/.test(line.trim()) || + /^输出格式/.test(line.trim()) || + /^report format/.test(normalized) || + /^output requirements?/.test(normalized) || + /^output format/.test(normalized) + ) { + skipOutputSection = true; + continue; + } + + if (skipOutputSection) continue; + + // 删除高风险格式约束行(保留业务评判标准) + const shouldDrop = + /请按以下格式输出/.test(line) || + /严格仅输出\s*json/i.test(line) || + /仅输出\s*json/i.test(line) || + /不要\s*markdown/i.test(line) || + /不要代码块/i.test(line) || + /返回结构化\s*json/i.test(line) || + /审稿结论/.test(line) || + /总体评价/.test(line) || + /详细问题清单与建议/.test(line); + + // 临床模块本身是 markdown 报告结构,允许保留“总体评价/审稿结论”等业务描述 + if (channel === 'clinical' && /审稿结论|总体评价|详细问题清单与建议/.test(line)) { + out.push(line); + continue; + } + + if (!shouldDrop) out.push(line); + } + + const sanitized = out.join('\n').trim(); + return sanitized || raw; +} + diff --git a/backend/src/modules/rvw/services/utils.ts b/backend/src/modules/rvw/services/utils.ts index 3d1c335f..672c332f 100644 --- a/backend/src/modules/rvw/services/utils.ts +++ b/backend/src/modules/rvw/services/utils.ts @@ -4,6 +4,7 @@ */ import { MethodologyReview, MethodologyStatus } from '../types/index.js'; +import { jsonrepair } from 'jsonrepair'; /** * 从LLM响应中解析JSON @@ -14,13 +15,27 @@ export function parseJSONFromLLMResponse(content: string): T { // 1. 尝试直接解析 return JSON.parse(content) as T; } catch { + // 1.1 先尝试 jsonrepair(处理尾逗号、引号缺失等常见脏 JSON) + try { + const repaired = jsonrepair(content); + return JSON.parse(repaired) as T; + } catch { + // 继续后续提取策略 + } + // 2. 尝试提取```json代码块 const jsonMatch = content.match(/```json\s*\n?([\s\S]*?)\n?```/); if (jsonMatch) { try { return JSON.parse(jsonMatch[1].trim()) as T; } catch { - // 继续尝试其他方法 + // 尝试修复代码块 JSON + try { + const repaired = jsonrepair(jsonMatch[1].trim()); + return JSON.parse(repaired) as T; + } catch { + // 继续尝试其他方法 + } } } @@ -30,7 +45,12 @@ export function parseJSONFromLLMResponse(content: string): T { try { return JSON.parse(objectMatch[1]) as T; } catch { - // 继续尝试其他方法 + try { + const repaired = jsonrepair(objectMatch[1]); + return JSON.parse(repaired) as T; + } catch { + // 继续尝试其他方法 + } } } @@ -39,7 +59,12 @@ export function parseJSONFromLLMResponse(content: string): T { try { return JSON.parse(arrayMatch[1]) as T; } catch { - // 失败 + try { + const repaired = jsonrepair(arrayMatch[1]); + return JSON.parse(repaired) as T; + } catch { + // 失败 + } } } diff --git a/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts b/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts index b00eb93b..e41ba79a 100644 --- a/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts +++ b/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts @@ -31,7 +31,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { randomUUID } from 'crypto'; -import { composeRvwSystemPrompt } from '../../services/promptProtocols.js'; +import { composeRvwSystemPrompt, sanitizeRvwBusinessPrompt } from '../../services/promptProtocols.js'; // 默认关闭规则验证,仅做“表格提取 + LLM 判断” const RULE_BASED_VALIDATION_ENABLED = process.env.RVW_FORENSICS_RULES_ENABLED === 'true'; @@ -304,6 +304,7 @@ export class DataForensicsSkill extends BaseSkill **文档版本:** v6.4 +> **文档版本:** v6.5 > **创建日期:** 2026-01-07 > **最后更新:** 2026-03-15 > **维护者:** 开发团队 -> **当前状态:** 🚀 **V4.0 期刊配置中心 MVP 开发完成(租户门户+配置中心+中英基线+权限兜底)** +> **当前状态:** 🚀 **V4.0 期刊配置中心 MVP 已完成,并完成 V4.0.1 线上可用性修复(租户分配+验证码+部署链路)** > **文档目的:** 快速了解RVW模块状态,为新AI助手提供上下文 > > **🎉 V3.0 进展(2026-03-07):** @@ -41,6 +41,12 @@ > - ✅ **审稿配置补齐**:`tenant_rvw_configs` 支持 4 维 Prompt + Handlebars 模板 + `editorial_base_standard(zh/en)` > - ✅ **执行链路最小适配**:`finalPrompt = tenantCustom ?? systemDefault`,稿约支持中英基线路由 > - ✅ **租户权限兜底**:创建/更新为 `JOURNAL` 时自动开通 `RVW` 模块,避免新用户登录后无模块权限 +> +> **🆕 V4.0.1 可用性修复(2026-03-15):** +> - ✅ **用户分配租户 500 修复**:`allowedModules` 增加后端参数归一化(兼容 string/string[]),避免 `.map is not a function` +> - ✅ **分配租户前端防呆**:提交前统一将 `allowedModules` 规范为数组,异常改为可读错误提示 +> - ✅ **验证码发送交互稳定性增强**:主站与期刊登录页新增“发送中”防重入,避免弱网下重复触发造成“已收到短信但前端提示失败” +> - ✅ **部署链路文档补齐**:新增 SAE 接入 RVW 说明(非 SAE 自动识别,依赖路由+租户中间件+权限链路)与前端镜像构建发布验收清单 > > **V2.0 进展回顾:** > - ✅ L1 算术验证 + L2 统计验证 + L2.5 一致性取证 @@ -61,7 +67,7 @@ | **商业价值** | ⭐⭐⭐⭐⭐ 极高 | | **独立性** | ⭐⭐⭐⭐⭐ 极高(用户群完全不同) | | **目标用户** | 期刊初审编辑 | -| **开发状态** | 🚀 **V4.0 期刊配置中心 MVP 已开发完成:配置中心→租户门户→上传→审稿→报告闭环可用** | +| **开发状态** | 🚀 **V4.0 + V4.0.1 已完成:配置中心→租户门户→上传→审稿→报告闭环可用,含分配租户与验证码可用性修复** | ### 核心目标 @@ -506,7 +512,7 @@ Content-Type: multipart/form-data --- -**文档版本:** v6.4 +**文档版本:** v6.5 **最后更新:** 2026-03-15 -**当前状态:** 🚀 V4.0 期刊配置中心 MVP 开发完成(配置中心 + 租户门户 + 中英基线 + 权限兜底) -**下一步:** 进入生产部署与灰度验证(Nginx 深链回退、前端缓存清理、期刊链路冒烟回归) +**当前状态:** 🚀 V4.0 + V4.0.1 已完成(配置中心 + 租户门户 + 中英基线 + 权限兜底 + 可用性修复) +**下一步:** 进入生产部署与灰度验证(SAE 路由/API 转发校验、Nginx 深链回退、前端缓存清理、期刊链路冒烟回归) diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index b358b227..dc9f8994 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -72,6 +72,32 @@ |---|---------|------|------| | 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 缓存导致仍跳旧路径 | +| INF-3 | SAE 接入 RVW 多租户链路:`review.xunzhengyixue.com` 仅负责流量入口,实际租户/模块判定由应用完成(`/:tenantCode/login`、`/:tenantSlug/rvw` + `x-tenant-id` + `tenant_members` + `tenant_modules`) | frontend SAE + backend SAE + ingress/SLB | ⚠️ SAE 不会自动“识别审稿模块”;必须保证前端路由命中、`/api` 正确转发后端、后端开启 RVW 路由与租户中间件;并确认租户已开通 `RVW` 且用户在该租户下有成员关系 | +| INF-4 | 前端镜像构建发布标准流程(生产):安装依赖→构建 `frontend-v2/dist`→打包 Nginx 镜像→推送镜像仓库→SAE 更新版本→灰度验证→全量发布 | frontend-v2 build/deploy pipeline | 推荐命令:`npm ci && npm run build`(在 `frontend-v2` 目录);镜像发布后必须验证 `review.xunzhengyixue.com/{tenant}/login`、`/{tenant}/rvw`、`/api/v1/auth/verification-code` 三条链路可用 | + +--- + +## 上线后快速验收(运维执行) + +> 目标:确认 `review.xunzhengyixue.com` 已真正接入 RVW 租户审稿链路,而非仅页面可访问。 + +1. **前端深链验证(无缓存)** + - 隐身窗口访问:`https://review.xunzhengyixue.com/{tenantCode}/login` + - 登录后应进入:`https://review.xunzhengyixue.com/{tenantCode}/rvw` + - 直接刷新 `/{tenantCode}/rvw` 不应 404(验证 Nginx SPA 回退生效) + +2. **API 代理验证** + - 在浏览器 Network 确认 `/api/v1/auth/*`、`/api/rvw/*` 均返回后端响应(非前端静态 200 HTML) + - 若返回 HTML 或 404,优先检查 ingress/Nginx `/api` 反代优先级 + +3. **租户权限验证(业务可用性)** + - 运营端确认目标租户已开通 `RVW` 模块(`tenant_modules`) + - 运营端确认用户已分配到该租户(`tenant_members`) + - 若登录后提示“暂无访问智能审稿模块权限”,先查租户模块与用户模块覆盖关系 + +4. **验证码链路验证** + - `验证码发送成功 + 倒计时启动 + 登录成功` 需完整通过 + - 弱网场景若偶发失败,先排查网络/网关,再看后端日志;前端已增加“发送中”防重入保护 --- diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index 83621e51..5eb985c1 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -43,6 +43,7 @@ import JournalConfigListPage from './pages/admin/journal-configs/JournalConfigLi import JournalConfigDetailPage from './pages/admin/journal-configs/JournalConfigDetailPage' // 个人中心页面 import ProfilePage from './pages/user/ProfilePage' +import TenantProfilePage from './pages/tenant/TenantProfilePage' /** * 应用根组件 @@ -199,6 +200,15 @@ function App() { }> {/* /:tenantSlug → 自动跳转到 /:tenantSlug/rvw */} } /> + {/* /:tenantSlug/profile → 期刊租户专属个人中心 */} + + + + } + /> {/* /:tenantSlug/rvw/* → RouteGuard(认证+模块权限)→ RVW 模块 */} (); const { logout, user } = useAuth(); + const navigate = useNavigate(); const [brand, setBrand] = useState(null); const [loading, setLoading] = useState(true); @@ -38,6 +41,16 @@ export default function TenantPortalLayout() { .finally(() => setLoading(false)); }, [tenantSlug]); + // 期刊门户标签页标题:统一显示“AI智能审稿平台” + // 注意:必须放在所有 early return 之前,保持 Hooks 调用顺序稳定 + useEffect(() => { + const prevTitle = document.title; + document.title = 'AI智能审稿平台'; + return () => { + document.title = prevTitle; + }; + }, []); + if (loading) { return (
@@ -51,9 +64,15 @@ export default function TenantPortalLayout() { // ── 品牌信息 ───────────────────────────────────────────────── const journalName = brand?.name || tenantSlug || ''; // 期刊门户固定副标题为"智能审稿工作台",除非 API 返回了更具体的名称 - const systemName = brand?.systemName && brand.systemName !== 'AI临床研究平台' + // 且避免与期刊名重复显示(如“中华脑血管病杂志 中华脑血管病杂志”) + const systemName = brand?.systemName + && brand.systemName !== 'AI临床研究平台' + && brand.systemName !== journalName ? brand.systemName : '智能审稿工作台'; + const headerTitle = journalName.includes(systemName) + ? journalName + : `${journalName} ${systemName}`; // 取期刊名首字母大写缩写(最多2个字母),用于 Header 方块 Logo const abbr = journalName .split(/\s+/) @@ -66,6 +85,32 @@ export default function TenantPortalLayout() { window.location.href = `/${tenantSlug}/login`; }; + const userMenuItems: MenuProps['items'] = [ + { + key: 'profile', + icon: , + label: '个人中心', + }, + { type: 'divider' }, + { + key: 'logout', + icon: , + label: '退出登录', + danger: true, + }, + ]; + + const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => { + if (key === 'logout') { + void handleLogout(); + return; + } + if (key === 'profile') { + navigate(`/${tenantSlug}/profile`); + return; + } + }; + return (
@@ -82,43 +127,32 @@ export default function TenantPortalLayout() {
{/* 单行大标题:期刊名 + 固定后缀,完全复制原型图 h1 风格 */}

- {journalName} {systemName} + {headerTitle}

- {/* 右侧:原型图风格的边框容器(铃铛 + 用户) */} -
- - -
- -
- - {user?.name ?? '用户'} - - - - - + } + size={32} + className="shadow-sm" + style={{ backgroundColor: '#f3f4f6', color: '#6b7280' }} + /> + + {user?.name ?? '责编'} + + + + + +
diff --git a/frontend-v2/src/modules/admin/components/AssignTenantModal.tsx b/frontend-v2/src/modules/admin/components/AssignTenantModal.tsx index 0142c4bc..e01e122f 100644 --- a/frontend-v2/src/modules/admin/components/AssignTenantModal.tsx +++ b/frontend-v2/src/modules/admin/components/AssignTenantModal.tsx @@ -74,10 +74,16 @@ const AssignTenantModal: React.FC = ({ const handleSubmit = async (values: any) => { setSubmitting(true); try { + const selectedModules = Array.isArray(values.allowedModules) + ? values.allowedModules + : (typeof values.allowedModules === 'string' && values.allowedModules.trim() + ? [values.allowedModules.trim()] + : []); + await userApi.assignTenantToUser(userId, { tenantId: values.tenantId, role: values.role, - allowedModules: values.allowedModules?.length > 0 ? values.allowedModules : undefined, + allowedModules: selectedModules.length > 0 ? selectedModules : undefined, }); message.success('分配成功'); onSuccess(); diff --git a/frontend-v2/src/modules/rvw/components/ForensicsReport.tsx b/frontend-v2/src/modules/rvw/components/ForensicsReport.tsx index 7d8ceaa0..51af5885 100644 --- a/frontend-v2/src/modules/rvw/components/ForensicsReport.tsx +++ b/frontend-v2/src/modules/rvw/components/ForensicsReport.tsx @@ -22,23 +22,6 @@ interface ForensicsReportProps { data: ForensicsResult; } -// 统计方法英文 -> 中文映射 -const METHOD_NAMES: Record = { - 'chi-square': '卡方检验', - 'mann-whitney': 'Mann-Whitney U 检验', - 't-test': 'T 检验', - 'anova': '方差分析', - 'fisher': 'Fisher 精确检验', - 'wilcoxon': 'Wilcoxon 检验', - 'kruskal-wallis': 'Kruskal-Wallis 检验', - 'mcnemar': 'McNemar 检验', - 'correlation': '相关性分析', - 'regression': '回归分析', - 'logistic': 'Logistic 回归', - 'cox': 'Cox 回归', - 'kaplan-meier': 'Kaplan-Meier 生存分析', -}; - // 问题类型代码 -> 中文描述映射 const ISSUE_TYPE_LABELS: Record = { // L1 算术验证 @@ -79,7 +62,6 @@ export default function ForensicsReport({ data }: ForensicsReportProps) { // 防御性检查:确保所有数组和对象存在 const tables = data?.tables || []; const issues = data?.issues || []; - const methods = data?.methods || []; const summary = data?.summary || { totalTables: 0, totalIssues: 0, errorCount: 0, warningCount: 0 }; const llmTableReports = data?.llmTableReports || {}; @@ -95,11 +77,6 @@ export default function ForensicsReport({ data }: ForensicsReportProps) { return tableIdToCaption[tableId] || tableId; }; - // 翻译统计方法名称为中文 - const translateMethod = (method: string): string => { - return METHOD_NAMES[method.toLowerCase()] || method; - }; - // 翻译问题类型代码为中文 const translateIssueType = (type: string): string => { return ISSUE_TYPE_LABELS[type] || type; @@ -144,7 +121,7 @@ export default function ForensicsReport({ data }: ForensicsReportProps) { if (summary.warningCount > 0) { return { label: '需关注', color: 'text-amber-600', bg: 'bg-amber-500', icon: AlertTriangle }; } - return { label: '数据正常', color: 'text-green-600', bg: 'bg-green-500', icon: CheckCircle }; + return { label: '分析完成', color: 'text-blue-600', bg: 'bg-blue-500', icon: CheckCircle }; }; const status = getOverallStatus(); @@ -179,8 +156,7 @@ export default function ForensicsReport({ data }: ForensicsReportProps) {

数据验证报告

- 已检测 {summary.totalTables} 张表格,发现 {summary.totalIssues} 个问题 - {methods.length > 0 && `,识别到统计方法:${methods.map(translateMethod).join('、')}`} + 已检测 {summary.totalTables} 张表格,以下展示逐表 AI 分析结果。

{/* 统计指标 */} @@ -201,12 +177,6 @@ export default function ForensicsReport({ data }: ForensicsReportProps) { {summary.warningCount} 个警告 )} - {summary.errorCount === 0 && summary.warningCount === 0 && ( -
- - 未发现问题 -
- )} @@ -351,8 +321,8 @@ function TableCard({ table, expanded, onToggle, highlightedCell, llmTableReport )} {!hasIssues && ( - - 通过 + + 已分析 )} {expanded ? ( diff --git a/frontend-v2/src/modules/rvw/components/ReportDetail.tsx b/frontend-v2/src/modules/rvw/components/ReportDetail.tsx index 4363ed15..6abb85b6 100644 --- a/frontend-v2/src/modules/rvw/components/ReportDetail.tsx +++ b/frontend-v2/src/modules/rvw/components/ReportDetail.tsx @@ -24,7 +24,6 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) { // 检查文件格式:非 .docx 文件无法进行数据验证 const fileName = report.fileName || ''; - const isDocx = fileName.toLowerCase().endsWith('.docx'); const isPdf = fileName.toLowerCase().endsWith('.pdf'); const isDoc = fileName.toLowerCase().endsWith('.doc'); const showNoForensicsTip = !hasForensics && (hasEditorial || hasMethodology) && (isPdf || isDoc); @@ -115,7 +114,7 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) { : 'font-medium text-slate-500 hover:text-slate-700' }`} > - 数据验证 ({report.forensicsResult?.summary.totalIssues || 0}个问题) + 数据验证 )} diff --git a/frontend-v2/src/modules/rvw/components/TaskDetail.tsx b/frontend-v2/src/modules/rvw/components/TaskDetail.tsx index c295c36a..f44e192e 100644 --- a/frontend-v2/src/modules/rvw/components/TaskDetail.tsx +++ b/frontend-v2/src/modules/rvw/components/TaskDetail.tsx @@ -22,6 +22,19 @@ interface TaskDetailProps { onBack: () => void; } +function markdownToPlainLines(markdown: string, maxLines = 80): string[] { + return (markdown || '') + .replace(/```[\s\S]*?```/g, ' ') + .replace(/^#{1,6}\s*/gm, '') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/\[(.*?)\]\((.*?)\)/g, '$1 ($2)') + .split('\n') + .map(line => line.replace(/^\s*[-*+]\s+/, '').trim()) + .filter(Boolean) + .slice(0, maxLines); +} + // 状态信息映射 const STATUS_INFO: Record = { pending: { label: '等待开始', color: 'text-slate-500', icon: Clock }, @@ -415,6 +428,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet // 数据验证 if (report.forensicsResult) { + const forensics = report.forensicsResult; children.push( new Paragraph({ text: nextSectionTitle('数据验证'), @@ -423,17 +437,22 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet }) ); - const summary = report.forensicsResult.summary; + const summary = forensics.summary; children.push( new Paragraph({ - text: `共识别表格 ${summary.totalTables} 个;发现问题 ${summary.totalIssues} 个(错误 ${summary.errorCount},警告 ${summary.warningCount})。`, + text: `共检测到 ${summary.totalTables} 张表格,以下为逐表提取结果与 AI 分析。`, spacing: { after: 160 }, }) ); + const llmFallbackSections = (forensics.llmReport || '') + .split(/(?=^##\s*表\d+[::])/m) + .map(section => section.trim()) + .filter(Boolean); + // 导出表格明细(按 forensicsResult.tables 渲染) - if (report.forensicsResult.tables.length > 0) { - report.forensicsResult.tables.slice(0, 20).forEach((tableItem, tableIndex) => { + if (forensics.tables.length > 0) { + forensics.tables.slice(0, 20).forEach((tableItem, tableIndex) => { children.push( new Paragraph({ text: `表${tableIndex + 1}:${tableItem.caption || tableItem.id || '未命名表格'}`, @@ -483,6 +502,27 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet ); } + const llmFromMap = forensics.llmTableReports?.[tableItem.id]; + const llmFromFallback = llmFallbackSections[tableIndex]; + const llmText = llmFromMap || llmFromFallback; + if (llmText) { + children.push( + new Paragraph({ + children: [new TextRun({ text: 'AI 逐表分析:', bold: true })], + spacing: { before: 80, after: 40 }, + }) + ); + markdownToPlainLines(llmText, 120).forEach((line) => { + children.push( + new Paragraph({ + text: `• ${line}`, + indent: { left: 720 }, + spacing: { after: 40 }, + }) + ); + }); + } + const tableIssues = (tableItem.issues || []).slice(0, 50); if (tableIssues.length > 0) { children.push( @@ -504,17 +544,17 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet } }); - if (report.forensicsResult.tables.length > 20) { + if (forensics.tables.length > 20) { children.push( new Paragraph({ - text: `(其余 ${report.forensicsResult.tables.length - 20} 张表格已省略)`, + text: `(其余 ${forensics.tables.length - 20} 张表格已省略)`, spacing: { before: 80, after: 80 }, }) ); } } - if (report.forensicsResult.issues.length > 0) { + if (forensics.issues.length > 0) { children.push( new Paragraph({ children: [new TextRun({ text: '问题清单:', bold: true })], @@ -522,7 +562,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet }) ); - report.forensicsResult.issues.slice(0, 120).forEach((issue) => { + forensics.issues.slice(0, 120).forEach((issue) => { const level = issue.severity === 'ERROR' ? '错误' : issue.severity === 'WARNING' ? '警告' : '提示'; children.push( new Paragraph({ @@ -533,23 +573,15 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet ); }); - if (report.forensicsResult.issues.length > 120) { + if (forensics.issues.length > 120) { children.push( new Paragraph({ - text: `(其余 ${report.forensicsResult.issues.length - 120} 条问题已省略)`, + text: `(其余 ${forensics.issues.length - 120} 条问题已省略)`, indent: { left: 720 }, spacing: { after: 100 }, }) ); } - } else { - children.push( - new Paragraph({ - children: [new TextRun({ text: '✓ 未发现数据一致性问题', color: '006600' })], - indent: { left: 720 }, - spacing: { after: 100 }, - }) - ); } } @@ -825,7 +857,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet : 'font-medium text-slate-500 hover:text-slate-700' }`} > - 数据验证 ({displayReport.forensicsResult.summary.totalIssues || 0}个问题) + 数据验证 )} {displayReport.clinicalReview && ( diff --git a/frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx b/frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx index 56de7eda..d8251de0 100644 --- a/frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx +++ b/frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx @@ -115,27 +115,6 @@ function StatusIcon({ status }: { status: DimStatus }) { return ; } -// 综合分数圆形徽章 -function ScoreBadge({ score }: { score?: number }) { - if (!score || score === 0) { - return ( -
- - -
- ); - } - const cls = score >= 80 - ? 'border-green-200 text-green-600 bg-green-50' - : score >= 60 - ? 'border-amber-200 text-amber-600 bg-amber-50' - : 'border-red-200 text-red-600 bg-red-50'; - return ( -
- {score} -
- ); -} - // 状态 Badge(胶囊标签) function StatusBadge({ status }: { status: ReviewTask['status'] }) { const map: Record = { @@ -402,7 +381,6 @@ export default function TenantDashboard() { 文件名 上传时间 - 综合评分 稿约规范 方法学 数据验证 @@ -443,11 +421,6 @@ export default function TenantDashboard() { })} - {/* 综合评分圆圈 */} - - - - {/* 四维状态图标 */}
diff --git a/frontend-v2/src/pages/LoginPage.tsx b/frontend-v2/src/pages/LoginPage.tsx index e343d339..3508ffb8 100644 --- a/frontend-v2/src/pages/LoginPage.tsx +++ b/frontend-v2/src/pages/LoginPage.tsx @@ -65,6 +65,7 @@ export default function LoginPage() { const [form] = Form.useForm(); const [activeTab, setActiveTab] = useState<'password' | 'code'>('password'); const [countdown, setCountdown] = useState(0); + const [isSendingCode, setIsSendingCode] = useState(false); const [tenantConfig, setTenantConfig] = useState(DEFAULT_CONFIG); const [showPasswordModal, setShowPasswordModal] = useState(false); const [passwordForm] = Form.useForm(); @@ -199,6 +200,7 @@ export default function LoginPage() { // 发送验证码 const handleSendCode = async () => { + if (isSendingCode || countdown > 0) return; try { const phone = form.getFieldValue('phone'); if (!phone) { @@ -210,11 +212,14 @@ export default function LoginPage() { return; } + setIsSendingCode(true); await sendVerificationCode(phone, 'LOGIN'); message.success('验证码已发送'); setCountdown(60); } catch (err) { message.error(err instanceof Error ? err.message : '发送失败'); + } finally { + setIsSendingCode(false); } }; @@ -381,10 +386,10 @@ export default function LoginPage() { /> diff --git a/frontend-v2/src/pages/TenantLoginPage.tsx b/frontend-v2/src/pages/TenantLoginPage.tsx index 11017ead..f15e9c45 100644 --- a/frontend-v2/src/pages/TenantLoginPage.tsx +++ b/frontend-v2/src/pages/TenantLoginPage.tsx @@ -64,6 +64,7 @@ export default function TenantLoginPage() { const [password, setPassword] = useState(''); const [code, setCode] = useState(''); const [countdown, setCountdown] = useState(0); + const [isSendingCode, setIsSendingCode] = useState(false); const [errorMsg, setErrorMsg] = useState(''); const [showPwdModal, setShowPwdModal] = useState(false); const [newPwd, setNewPwd] = useState(''); @@ -85,6 +86,15 @@ export default function TenantLoginPage() { return () => clearTimeout(t); }, [countdown]); + // 期刊登录页标签页标题:统一显示“AI智能审稿平台” + useEffect(() => { + const prevTitle = document.title; + document.title = 'AI智能审稿平台'; + return () => { + document.title = prevTitle; + }; + }, []); + // ── 登录后跳转路径 ──────────────────────────────────────────── const getRedirect = useCallback(() => { const params = new URLSearchParams(location.search); @@ -101,12 +111,18 @@ export default function TenantLoginPage() { // ── 发送验证码 ──────────────────────────────────────────────── const handleSendCode = async () => { + if (isSendingCode || countdown > 0) return; if (!/^1[3-9]\d{9}$/.test(phone)) { setErrorMsg('请输入正确的手机号'); return; } try { + setIsSendingCode(true); await sendVerificationCode(phone, 'LOGIN'); setCountdown(60); setErrorMsg(''); - } catch (e: any) { setErrorMsg(e.message || '发送失败'); } + } catch (e: any) { + setErrorMsg(e.message || '发送失败'); + } finally { + setIsSendingCode(false); + } }; // ── 登录提交 ────────────────────────────────────────────────── @@ -129,7 +145,11 @@ export default function TenantLoginPage() { if (newPwd.length < 6) { setErrorMsg('密码至少 6 位'); return; } if (newPwd !== confirmPwd) { setErrorMsg('两次输入的密码不一致'); return; } try { - await changePassword({ oldPassword: password, newPassword: newPwd } as ChangePasswordRequest); + await changePassword({ + oldPassword: password, + newPassword: newPwd, + confirmPassword: confirmPwd, + } as ChangePasswordRequest); setShowPwdModal(false); navigate(getRedirect(), { replace: true }); } catch (e: any) { setErrorMsg(e.message || '修改失败'); } @@ -172,10 +192,10 @@ export default function TenantLoginPage() { )}
-

{journalName}

-

{systemName} (Editor Portal)

+

{journalName}

+

{systemName} (Editor Portal)

-
+
专属租户入口
@@ -238,6 +258,7 @@ export default function TenantLoginPage() { value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入密码" + autoComplete="current-password" required className={inputCls} /> @@ -261,15 +282,15 @@ export default function TenantLoginPage() { @@ -296,7 +317,7 @@ export default function TenantLoginPage() { 'w-full flex items-center justify-center gap-2', 'bg-[#0284c7] text-white font-medium py-2.5 rounded-lg', 'hover:bg-[#0369a1] transition-colors', - 'shadow-[0_4px_14px_rgba(2,132,199,0.35)]', + 'shadow-lg shadow-sky-500/30', 'disabled:opacity-60 disabled:cursor-not-allowed', ].join(' ')} > @@ -324,7 +345,7 @@ export default function TenantLoginPage() { setNewPwd(e.target.value)} - required minLength={6} placeholder="至少 6 位" + required minLength={6} placeholder="至少 6 位" autoComplete="new-password" className={inputCls} /> @@ -332,7 +353,7 @@ export default function TenantLoginPage() { setConfirmPwd(e.target.value)} - required placeholder="再次输入新密码" + required placeholder="再次输入新密码" autoComplete="new-password" className={inputCls} /> diff --git a/frontend-v2/src/pages/tenant/TenantProfilePage.tsx b/frontend-v2/src/pages/tenant/TenantProfilePage.tsx new file mode 100644 index 00000000..7b991368 --- /dev/null +++ b/frontend-v2/src/pages/tenant/TenantProfilePage.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { useAuth } from '../../framework/auth'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ArrowLeftOutlined, UserOutlined } from '@ant-design/icons'; +import { Avatar, message } from 'antd'; + +function maskPhone(phone?: string) { + if (!phone || phone.length !== 11) return phone || '-'; + return `${phone.slice(0, 3)}****${phone.slice(7)}`; +} + +export default function TenantProfilePage() { + const { user, changePassword } = useAuth(); + const navigate = useNavigate(); + const { tenantSlug } = useParams<{ tenantSlug: string }>(); + + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [saving, setSaving] = useState(false); + + const handleSubmit = async () => { + if (newPassword.length < 6) { + message.error('新密码至少 6 位'); + return; + } + if (newPassword !== confirmPassword) { + message.error('两次输入的新密码不一致'); + return; + } + + try { + setSaving(true); + await changePassword({ + oldPassword: oldPassword || undefined, + newPassword, + confirmPassword, + }); + message.success('密码修改成功'); + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (error: any) { + message.error(error?.message || '密码修改失败,请重试'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ {/* 顶部导航栏 (模拟原型图详情页头部风格) */} +
+ +

个人中心

+
+ + {/* 卡片容器 */} +
+ {/* 用户信息区 */} +
+ } + className="bg-gray-100 text-gray-400" + /> +
+

{user?.name || '用户'}

+

手机号:{maskPhone(user?.phone)}

+
+
+ + 正式用户 + +
+
+ + {/* 修改密码表单区 */} +
+

修改密码

+ +
+
+ + setOldPassword(e.target.value)} + placeholder="请输入当前密码" + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder="至少 6 位" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="请再次输入新密码" + /> +
+ +
+ +
+
+
+
+
+
+ ); +} +