feat(rvw): complete tenant portal polish and ops assignment fixes

Finalize RVW tenant portal UX and reliability updates by aligning login/profile interactions, stabilizing SMS code sends in weak-network scenarios, and fixing multi-tenant assignment payload handling to prevent runtime errors. Refresh RVW status and deployment checklist docs with SAE routing, frontend image build, and post-release validation guidance.

Made-with: Cursor
This commit is contained in:
2026-03-15 18:22:01 +08:00
parent 83e395824b
commit 707f783229
20 changed files with 498 additions and 153 deletions

View File

@@ -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 || '分配租户失败',

View File

@@ -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<UserDetail> {
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<void> {
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<void> {
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,
});
}

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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] 开始分治并行评估', {

View File

@@ -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;
}

View File

@@ -4,6 +4,7 @@
*/
import { MethodologyReview, MethodologyStatus } from '../types/index.js';
import { jsonrepair } from 'jsonrepair';
/**
* 从LLM响应中解析JSON
@@ -14,13 +15,27 @@ export function parseJSONFromLLMResponse<T>(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<T>(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<T>(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 {
// 失败
}
}
}

View File

@@ -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<SkillContext, DataForensicsCon
);
businessPrompt = promptResult.content;
}
businessPrompt = sanitizeRvwBusinessPrompt('data_validation', businessPrompt);
// 拼接所有表格为 user message
const tableTexts: string[] = [];

View File

@@ -1,10 +1,10 @@
# RVW稿件审查模块 - 当前状态与开发指南
> **文档版本:** 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 深链回退、前端缓存清理、期刊链路冒烟回归)

View File

@@ -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. **验证码链路验证**
- `验证码发送成功 + 倒计时启动 + 登录成功` 需完整通过
- 弱网场景若偶发失败,先排查网络/网关,再看后端日志;前端已增加“发送中”防重入保护
---

View File

@@ -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() {
<Route path="/:tenantSlug" element={<TenantPortalLayout />}>
{/* /:tenantSlug → 自动跳转到 /:tenantSlug/rvw */}
<Route index element={<Navigate to="rvw" replace />} />
{/* /:tenantSlug/profile → 期刊租户专属个人中心 */}
<Route
path="profile"
element={
<RouteGuard requiredModule="RVW" moduleName="个人中心">
<TenantProfilePage />
</RouteGuard>
}
/>
{/* /:tenantSlug/rvw/* → RouteGuard认证+模块权限)→ RVW 模块 */}
<Route
path="rvw/*"

View File

@@ -6,8 +6,10 @@
*/
import { useState, useEffect } from 'react';
import { useParams, Navigate, Outlet } from 'react-router-dom';
import { Spin } from 'antd';
import { useParams, Navigate, Outlet, useNavigate } from 'react-router-dom';
import { Spin, Dropdown, Avatar } from 'antd';
import type { MenuProps } from 'antd';
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
import { useAuth } from '../auth';
interface TenantBrand {
@@ -20,6 +22,7 @@ interface TenantBrand {
export default function TenantPortalLayout() {
const { tenantSlug } = useParams<{ tenantSlug: string }>();
const { logout, user } = useAuth();
const navigate = useNavigate();
const [brand, setBrand] = useState<TenantBrand | null>(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 (
<div className="h-screen flex items-center justify-center bg-gray-50">
@@ -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: <UserOutlined />,
label: '个人中心',
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
},
];
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key === 'logout') {
void handleLogout();
return;
}
if (key === 'profile') {
navigate(`/${tenantSlug}/profile`);
return;
}
};
return (
<div className="h-screen w-full flex flex-col overflow-hidden bg-gray-50 font-sans antialiased">
@@ -82,43 +127,32 @@ export default function TenantPortalLayout() {
</div>
{/* 单行大标题:期刊名 + 固定后缀,完全复制原型图 h1 风格 */}
<h1 className="text-base font-bold text-gray-800 whitespace-nowrap">
{journalName}&nbsp;{systemName}
{headerTitle}
</h1>
</div>
{/* 右侧:原型图风格的边框容器(铃铛 + 用户) */}
<div className="h-10 flex items-center rounded-md border border-gray-200 bg-white px-2 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<button
type="button"
className="w-8 h-8 inline-flex items-center justify-center text-gray-500 hover:text-gray-700 transition-colors rounded-md hover:bg-gray-50"
aria-label="通知"
>
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" strokeWidth={1.9} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
<div className="w-px h-5 bg-gray-200 mx-2" />
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-2 pr-1 hover:opacity-80 transition-opacity"
>
<div
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-xs font-semibold text-white select-none shrink-0 shadow-sm"
style={{ background: 'linear-gradient(135deg, #38bdf8 0%, #0369a1 100%)' }}
{/* 右侧:原型图风格的用户下拉 */}
<div className="h-full flex items-center">
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="bottomRight" trigger={['click']}>
<button
type="button"
className="flex items-center gap-2 px-2 py-1 hover:bg-gray-50 rounded-lg transition-colors border border-transparent hover:border-gray-200"
>
{user?.name?.[0] ?? 'U'}
</div>
<span className="text-sm font-medium text-gray-700 whitespace-nowrap leading-none">
{user?.name ?? '用户'}
</span>
<svg className="w-3 h-3 text-gray-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Avatar
src={user?.avatarUrl}
icon={!user?.avatarUrl && <UserOutlined />}
size={32}
className="shadow-sm"
style={{ backgroundColor: '#f3f4f6', color: '#6b7280' }}
/>
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">
{user?.name ?? '责编'}
</span>
<svg className="w-3 h-3 text-gray-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</Dropdown>
</div>
</header>

View File

@@ -74,10 +74,16 @@ const AssignTenantModal: React.FC<AssignTenantModalProps> = ({
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();

View File

@@ -22,23 +22,6 @@ interface ForensicsReportProps {
data: ForensicsResult;
}
// 统计方法英文 -> 中文映射
const METHOD_NAMES: Record<string, string> = {
'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<string, string> = {
// 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) {
<h3 className="font-bold text-lg text-slate-800"></h3>
</div>
<p className="text-slate-600 text-sm leading-relaxed mb-4">
{summary.totalTables} {summary.totalIssues}
{methods.length > 0 && `,识别到统计方法:${methods.map(translateMethod).join('、')}`}
{summary.totalTables} AI
</p>
{/* 统计指标 */}
@@ -201,12 +177,6 @@ export default function ForensicsReport({ data }: ForensicsReportProps) {
<span className="text-sm font-medium text-amber-700">{summary.warningCount} </span>
</div>
)}
{summary.errorCount === 0 && summary.warningCount === 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 rounded-lg border border-green-100">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-green-700"></span>
</div>
)}
</div>
</div>
</div>
@@ -351,8 +321,8 @@ function TableCard({ table, expanded, onToggle, highlightedCell, llmTableReport
</span>
)}
{!hasIssues && (
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-md font-medium">
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-md font-medium">
</span>
)}
{expanded ? (

View File

@@ -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})
</button>
)}
</div>

View File

@@ -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<TaskStatus, { label: string; color: string; icon: any }> = {
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})
</button>
)}
{displayReport.clinicalReview && (

View File

@@ -115,27 +115,6 @@ function StatusIcon({ status }: { status: DimStatus }) {
return <IconSpinner />;
}
// 综合分数圆形徽章
function ScoreBadge({ score }: { score?: number }) {
if (!score || score === 0) {
return (
<div className="w-9 h-9 rounded-full border-4 border-gray-100 bg-gray-50 flex items-center justify-center">
<span className="text-[10px] text-gray-400 font-bold">-</span>
</div>
);
}
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 (
<div className={`w-9 h-9 rounded-full border-4 flex items-center justify-center font-bold text-xs ${cls}`}>
{score}
</div>
);
}
// 状态 Badge胶囊标签
function StatusBadge({ status }: { status: ReviewTask['status'] }) {
const map: Record<ReviewTask['status'], { label: string; cls: string }> = {
@@ -402,7 +381,6 @@ export default function TenantDashboard() {
<tr>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium text-center">稿</th>
<th className="px-6 py-3 font-medium text-center"></th>
<th className="px-6 py-3 font-medium text-center"></th>
@@ -443,11 +421,6 @@ export default function TenantDashboard() {
})}
</td>
{/* 综合评分圆圈 */}
<td className="px-6 py-4">
<ScoreBadge score={task.overallScore} />
</td>
{/* 四维状态图标 */}
<td className="px-6 py-4 text-center">
<div className="flex justify-center">

View File

@@ -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<TenantConfig>(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() {
/>
<Button
onClick={handleSendCode}
disabled={countdown > 0}
disabled={countdown > 0 || isSendingCode}
style={{ width: 120 }}
>
{countdown > 0 ? `${countdown}s后重发` : '获取验证码'}
{isSendingCode ? '发送中...' : (countdown > 0 ? `${countdown}s后重发` : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>

View File

@@ -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() {
)}
</div>
<h1 className="text-3xl font-bold mb-3 leading-snug">{journalName}</h1>
<p className="text-lg text-sky-200 mb-8">{systemName} (Editor Portal)</p>
<h1 className="text-4xl font-bold mb-4 leading-snug">{journalName}</h1>
<p className="text-xl text-sky-100 mb-8">{systemName} (Editor Portal)</p>
<div className="inline-block px-6 py-2 border border-sky-400 rounded-full text-sm text-sky-200">
<div className="inline-block px-6 py-2 border border-sky-400 rounded-full text-sm text-sky-100 bg-sky-800/30 backdrop-blur-sm">
</div>
</div>
@@ -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() {
<button
type="button"
onClick={handleSendCode}
disabled={countdown > 0 || isLoading}
disabled={countdown > 0 || isLoading || isSendingCode}
className={[
'px-4 py-2.5 rounded-lg text-sm font-medium border transition whitespace-nowrap',
countdown > 0
countdown > 0 || isSendingCode
? 'border-gray-300 text-gray-400 cursor-not-allowed'
: 'border-[#0284c7] text-[#0284c7] hover:bg-sky-50',
].join(' ')}
>
{countdown > 0 ? `${countdown}s 后重发` : '发送验证码'}
{isSendingCode ? '发送中...' : (countdown > 0 ? `${countdown}s 后重发` : '发送验证码')}
</button>
</div>
</div>
@@ -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() {
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)}
required minLength={6} placeholder="至少 6 位"
required minLength={6} placeholder="至少 6 位" autoComplete="new-password"
className={inputCls}
/>
</div>
@@ -332,7 +353,7 @@ export default function TenantLoginPage() {
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)}
required placeholder="再次输入新密码"
required placeholder="再次输入新密码" autoComplete="new-password"
className={inputCls}
/>
</div>

View File

@@ -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 (
<div className="h-full overflow-auto bg-gray-50 p-6">
<div className="max-w-3xl mx-auto">
{/* 顶部导航栏 (模拟原型图详情页头部风格) */}
<div className="flex items-center mb-6">
<button
onClick={() => navigate(`/${tenantSlug}/rvw`)}
className="mr-4 w-8 h-8 rounded-full hover:bg-gray-200 flex items-center justify-center transition text-gray-500 hover:text-gray-800"
>
<ArrowLeftOutlined />
</button>
<h1 className="text-xl font-bold text-gray-800"></h1>
</div>
{/* 卡片容器 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-8">
{/* 用户信息区 */}
<div className="flex items-center mb-8 pb-8 border-b border-gray-100">
<Avatar
size={64}
src={user?.avatarUrl}
icon={<UserOutlined />}
className="bg-gray-100 text-gray-400"
/>
<div className="ml-4">
<h2 className="text-lg font-bold text-gray-900">{user?.name || '用户'}</h2>
<p className="text-gray-500 text-sm mt-1">{maskPhone(user?.phone)}</p>
</div>
<div className="ml-auto">
<span className="px-3 py-1 bg-green-50 text-green-700 text-xs font-medium rounded-full border border-green-200">
</span>
</div>
</div>
{/* 修改密码表单区 */}
<div>
<h3 className="text-base font-bold text-gray-800 mb-6"></h3>
<div className="max-w-md space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5"></label>
<input
type="password"
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 focus:border-sky-500 transition-all"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="请输入当前密码"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5"></label>
<input
type="password"
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 focus:border-sky-500 transition-all"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="至少 6 位"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5"></label>
<input
type="password"
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 focus:border-sky-500 transition-all"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入新密码"
/>
</div>
<div className="pt-2">
<button
onClick={handleSubmit}
disabled={saving}
className="px-6 py-2.5 bg-[#0284c7] hover:bg-[#0369a1] text-white text-sm font-medium rounded-lg shadow-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all"
>
{saving ? '保存中...' : '确认修改'}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}