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:
@@ -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 || '分配租户失败',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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] 开始分治并行评估', {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
// 失败
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 深链回退、前端缓存清理、期刊链路冒烟回归)
|
||||
|
||||
@@ -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. **验证码链路验证**
|
||||
- `验证码发送成功 + 倒计时启动 + 登录成功` 需完整通过
|
||||
- 弱网场景若偶发失败,先排查网络/网关,再看后端日志;前端已增加“发送中”防重入保护
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/*"
|
||||
|
||||
@@ -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} {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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
139
frontend-v2/src/pages/tenant/TenantProfilePage.tsx
Normal file
139
frontend-v2/src/pages/tenant/TenantProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user