feat(rvw): deliver tenant portal v4 flow and config foundation

Implement RVW V4.0 tenant-aware backend/frontend flow with tenant routing, config APIs, and full portal UX updates. Sync system/RVW/deployment docs to capture verified upload-review-report workflow and next-step admin configuration work.

Made-with: Cursor
This commit is contained in:
2026-03-14 22:29:40 +08:00
parent ba464082cb
commit 16179e16ca
45 changed files with 4753 additions and 93 deletions

View File

@@ -5,10 +5,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider, useAuthHeartbeat } from './framework/auth'
import { PermissionProvider } from './framework/permission'
import { RouteGuard } from './framework/router'
import { useTenantObserver, useTenantStore } from './framework/tenant'
import apiClient from './common/api/axios'
import MainLayout from './framework/layout/MainLayout'
import AdminLayout from './framework/layout/AdminLayout'
import OrgLayout from './framework/layout/OrgLayout'
import TenantPortalLayout from './framework/layout/TenantPortalLayout'
import LoginPage from './pages/LoginPage'
import TenantLoginPage from './pages/TenantLoginPage'
import AdminDashboard from './pages/admin/AdminDashboard'
import OrgDashboard from './pages/org/OrgDashboard'
import PromptListPage from './pages/admin/PromptListPage'
@@ -16,6 +20,11 @@ import PromptEditorPage from './pages/admin/PromptEditorPage'
import TenantListPage from './pages/admin/tenants/TenantListPage'
import TenantDetailPage from './pages/admin/tenants/TenantDetailPage'
import { MODULES } from './framework/modules/moduleRegistry'
import { lazy, Suspense } from 'react'
import { Spin } from 'antd'
// 期刊租户门户专用 RVW 模块Tailwind 原型风格,与主站 /rvw 完全隔离)
const TenantRvwModule = lazy(() => import('./modules/rvw/TenantModule'))
// 用户管理页面
import UserListPage from './modules/admin/pages/UserListPage'
import UserFormPage from './modules/admin/pages/UserFormPage'
@@ -68,6 +77,26 @@ function AuthHeartbeatBootstrap() {
return null
}
/**
* 租户路由观察者:监听 React Router location 变化,
* 实时同步 tenantSlug 到 Zustand store解决 SPA 内部跳转时 store 不更新的问题。
* 必须在 <BrowserRouter> 内部挂载。
*/
function TenantBootstrap() {
useTenantObserver()
return null
}
// Axios 请求拦截器:每次请求时从 store 实时读取 tenantSlug
// 避免使用 defaults.headers 全局写死导致的租户状态污染。
apiClient.interceptors.request.use((config) => {
const { tenantSlug } = useTenantStore.getState()
if (tenantSlug) {
config.headers['x-tenant-id'] = tenantSlug
}
return config
})
function App() {
return (
<ConfigProvider locale={zhCN}>
@@ -79,10 +108,13 @@ function App() {
{/* 权限提供者:模块级权限管理 */}
<PermissionProvider>
<BrowserRouter>
{/* 租户路由观察者:监听路由变化,实时同步 tenantSlug 到 store */}
<TenantBootstrap />
<Routes>
{/* 登录页面(无需认证) */}
<Route path="/login" element={<LoginPage />} />
<Route path="/t/:tenantCode/login" element={<LoginPage />} />
{/* 期刊租户专属登录页(全屏原型风格,独立于所有 Layout*/}
<Route path="/t/:tenantCode/login" element={<TenantLoginPage />} />
{/* 业务应用端 /app/* */}
<Route path="/" element={<MainLayout />}>
@@ -150,6 +182,35 @@ function App() {
<Route path="audit" element={<div className="text-center py-20">🚧 ...</div>} />
</Route>
{/* ============================================================
* 期刊租户门户路由RVW V4.0 多租户)
* 路径格式review.xunzhengyixue.com/:tenantSlug/rvw/*
*
* 匹配优先级说明React Router v6 基于路径特异性打分):
* - /login、/admin、/org 等静态路径特异性 > /:tenantSlug动态段
* - TenantPortalLayout 在加载时调用公开 API 验证租户是否存在;
* 不存在404→ 自动重定向 /login规避路径误匹配。
* ============================================================ */}
<Route path="/:tenantSlug" element={<TenantPortalLayout />}>
{/* /:tenantSlug → 自动跳转到 /:tenantSlug/rvw */}
<Route index element={<Navigate to="rvw" replace />} />
{/* /:tenantSlug/rvw/* → RouteGuard认证+模块权限)→ RVW 模块 */}
<Route
path="rvw/*"
element={
<RouteGuard requiredModule="RVW" moduleName="智能审稿">
<Suspense fallback={
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Spin size="large" />
</div>
}>
<TenantRvwModule />
</Suspense>
</RouteGuard>
}
/>
</Route>
{/* 404重定向 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -0,0 +1,131 @@
/**
* 期刊租户门户布局
* ─ 提供原型图风格的极简品牌顶栏白色h-16
* ─ 内容区全屏,无 Sidebar、无多余导航
* ─ 路由: /:tenantSlug/*
*/
import { useState, useEffect } from 'react';
import { useParams, Navigate, Outlet } from 'react-router-dom';
import { Spin } from 'antd';
import { useAuth } from '../auth';
interface TenantBrand {
name: string;
logo?: string;
primaryColor: string;
systemName: string;
}
export default function TenantPortalLayout() {
const { tenantSlug } = useParams<{ tenantSlug: string }>();
const { logout, user } = useAuth();
const [brand, setBrand] = useState<TenantBrand | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!tenantSlug) { setNotFound(true); setLoading(false); return; }
fetch(`/api/v1/public/tenant-config/${tenantSlug}`)
.then(async res => {
if (res.status === 404) { setNotFound(true); return; }
const data = await res.json();
if (data?.success && data.data) setBrand(data.data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [tenantSlug]);
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<Spin size="large" />
</div>
);
}
if (notFound) return <Navigate to="/login" replace />;
// ── 品牌信息 ─────────────────────────────────────────────────
const journalName = brand?.name || tenantSlug || '';
// 期刊门户固定副标题为"智能审稿工作台",除非 API 返回了更具体的名称
const systemName = brand?.systemName && brand.systemName !== 'AI临床研究平台'
? brand.systemName
: '智能审稿工作台';
// 取期刊名首字母大写缩写最多2个字母用于 Header 方块 Logo
const abbr = journalName
.split(/\s+/)
.slice(0, 2)
.map(w => w[0]?.toUpperCase() ?? '')
.join('') || 'JT';
const handleLogout = async () => {
await logout();
window.location.href = `/t/${tenantSlug}/login`;
};
return (
<div className="h-screen w-full flex flex-col overflow-hidden bg-gray-50 font-sans antialiased">
{/* ════════════════════════════════════════════════════════════
品牌顶栏 ── 100% 对照原型图 AI审稿V1.html header 实现
════════════════════════════════════════════════════════════ */}
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 shrink-0 z-10">
{/* 左侧:方块 Logo + 单行大标题(原型图 text-lg font-bold */}
<div className="flex items-center space-x-3">
{/* 品牌色方块(原型图 w-8 h-8 bg-brand-700 rounded font-bold */}
<div className="w-8 h-8 bg-[#0369a1] rounded text-white flex items-center justify-center font-bold text-sm select-none shrink-0">
{abbr}
</div>
{/* 单行大标题:期刊名 + 固定后缀,完全复制原型图 h1 风格 */}
<h1 className="text-base font-bold text-gray-800 whitespace-nowrap">
{journalName}&nbsp;{systemName}
</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%)' }}
>
{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>
</div>
</header>
{/* ═══════════ 内容区全屏Outlet 填满)══════════════════════ */}
<div className="flex-1 overflow-hidden">
<Outlet />
</div>
</div>
);
}

View File

@@ -1,23 +1,39 @@
import { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../auth'
import { extractTenantSlug } from '../tenant'
import { Result, Button } from 'antd'
import { LockOutlined } from '@ant-design/icons'
/**
* 路由守卫组件
*
* @description
* 保护需要特定权限的路由防止用户通过URL直接访问无权限页面
*
*
* 检查顺序:
* 1. 检查是否已登录(未登录→重定向到登录页)
* 1. 检查是否已登录(未登录→携带租户标识重定向到登录页)
* 2. 检查模块权限(无权限→显示权限不足页面)
* 3. 有权限→渲染子组件
*
* @version 2026-01-16 更新为模块权限系统
*
* @version 2026-03-14 V4.0:租户感知重定向
* - 期刊租户路径(/jtim/*)→ /t/jtim/login?redirect=/jtim/dashboard
* - 主平台路径(/rvw/*, /ai-qa/* 等)→ /login行为不变
* - extractTenantSlug 由 useTenantObserver 模块统一维护,自动排除所有注册模块路径
*/
/**
* 构造未登录时的登录跳转目标。
*
* 对齐 App.tsx 中已定义的路由:<Route path="/t/:tenantCode/login" />
*
* - 期刊租户下:/t/jtim/login?redirect=%2Fjtim%2Fdashboard
* - 主站下:/login保持原有行为向后兼容
*/
function buildLoginRedirect(pathname: string): string {
const tenantSlug = extractTenantSlug(pathname)
if (!tenantSlug) return '/login'
const redirectParam = encodeURIComponent(pathname)
return `/t/${tenantSlug}/login?redirect=${redirectParam}`
}
interface RouteGuardProps {
/** 子组件 */
children: ReactNode
@@ -47,7 +63,9 @@ const RouteGuard = ({
// 1. 检查是否登录
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
// V4.0 租户感知重定向:保留 tenantSlug让登录页渲染对应期刊品牌
const loginTarget = buildLoginRedirect(location.pathname)
return <Navigate to={loginTarget} state={{ from: location }} replace />
}
// 2. 检查模块权限(如果指定了 requiredModule

View File

@@ -0,0 +1,2 @@
export { useTenantStore } from './useTenantStore'
export { useTenantObserver, extractTenantSlug } from './useTenantObserver'

View File

@@ -0,0 +1,63 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { MODULES } from '../modules/moduleRegistry'
import { useTenantStore } from './useTenantStore'
/**
* 构建保留路径集合:静态保留词 + 所有已注册业务模块的首段路径。
*
* 这样可以确保 /rvw/tasks、/ai-qa 等主平台路径不会被误判为租户 slug
* 同时新增模块时自动纳入保留集,无需手动维护。
*/
const STATIC_RESERVED = new Set(['login', 'admin', 'org', 'api', 't', 'user', ''])
function buildReservedPaths(): Set<string> {
const modulePaths = MODULES.map(m => m.path.split('/').filter(Boolean)[0] ?? '')
return new Set([...STATIC_RESERVED, ...modulePaths])
}
const RESERVED_PATHS = buildReservedPaths()
/**
* 从 pathname 中提取期刊租户 slug。
*
* 逻辑:取首段路径,若在保留集中则返回 null非期刊上下文
*
* 示例:
* /jtim/dashboard → 'jtim'
* /cmj → 'cmj'
* /rvw/tasks → null主平台业务模块
* /login → null保留路径
* /admin/tenants → null保留路径
*/
export function extractTenantSlug(pathname: string): string | null {
const firstSegment = pathname.split('/').filter(Boolean)[0] ?? ''
return RESERVED_PATHS.has(firstSegment) ? null : firstSegment
}
/**
* 全局租户路由观察者 Hook。
*
* 必须在 <BrowserRouter> 内部挂载(因依赖 useLocation
* 监听每次路由变化,自动同步最新 tenantSlug 到 Zustand store
* 避免 SPA 内部跳转时未刷新页面store 中的值过期。
*
* 挂载位置App.tsx 中 BrowserRouter 内部的 bootstrap 组件。
*
* 使用示例App.tsx
* function TenantBootstrap() {
* useTenantObserver()
* return null
* }
* // 在 <BrowserRouter> 内部:
* <TenantBootstrap />
*/
export function useTenantObserver() {
const location = useLocation()
const setTenantSlug = useTenantStore(s => s.setTenantSlug)
useEffect(() => {
const slug = extractTenantSlug(location.pathname)
setTenantSlug(slug)
}, [location.pathname, setTenantSlug])
}

View File

@@ -0,0 +1,22 @@
import { create } from 'zustand'
interface TenantState {
/** 当前期刊租户标识(如 'jtim'、'cmj'),主站场景下为 null */
tenantSlug: string | null
setTenantSlug: (slug: string | null) => void
}
/**
* 全局租户状态 Store
*
* 由 useTenantObserver 在每次路由变化时实时更新。
* Axios 拦截器、SSE 请求等消费此 store 来获取 x-tenant-id Header 的值。
*
* 使用示例:
* const tenantSlug = useTenantStore(s => s.tenantSlug)
* const { tenantSlug } = useTenantStore.getState() // 在拦截器等非 React 环境中
*/
export const useTenantStore = create<TenantState>((set) => ({
tenantSlug: null,
setTenantSlug: (slug) => set({ tenantSlug: slug }),
}))

View File

@@ -0,0 +1,18 @@
/**
* 期刊租户门户专用 RVW 模块入口
* ─ 与主站 index.tsx 完全隔离,不影响主站 /rvw 路由
* ─ 在 TenantPortalLayout 的 <Outlet> 中渲染(已提供顶栏)
*/
import { Routes, Route } from 'react-router-dom';
import TenantDashboard from './pages/TenantDashboard';
import TenantTaskDetail from './pages/TenantTaskDetail';
const TenantRvwModule: React.FC = () => (
<Routes>
<Route index element={<TenantDashboard />} />
<Route path=":taskId" element={<TenantTaskDetail />} />
</Routes>
);
export default TenantRvwModule;

View File

@@ -0,0 +1,538 @@
/**
* 期刊租户门户 - 审稿任务列表页Dashboard
* ─ 100% 无 Sidebar全宽布局
* ─ 设计 100% 还原 AI审稿V1.html 原型图Dashboard 视图)
* ─ 功能对应现有 RVW 模块(上传/运行/查看报告)
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { message } from 'antd';
import * as api from '../api';
import type { ReviewTask, AgentType } from '../types';
import AgentModal from '../components/AgentModal';
// ── 内联 SVG 状态图标 ─────────────────────────────────────────────
const IconCheck = () => (
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"/>
</svg>
);
const IconWarn = () => (
<svg className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
</svg>
);
const IconError = () => (
<svg className="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd"/>
</svg>
);
const IconSpinner = () => (
<svg className="w-5 h-5 text-gray-300 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
);
const IconUpload = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
);
// ── 状态辅助函数 ───────────────────────────────────────────────────
type DimStatus = 'pass' | 'warn' | 'error' | 'pending';
const FINAL_STATUSES: ReviewTask['status'][] = ['completed', 'partial_completed'];
function isTaskFinal(task: ReviewTask): boolean {
return FINAL_STATUSES.includes(task.status);
}
function getEditorialStatus(task: ReviewTask): DimStatus {
const hasResult = !!task.editorialReview || typeof task.editorialScore === 'number';
if (!hasResult) {
if (isTaskFinal(task) && (!task.selectedAgents?.length || task.selectedAgents.includes('editorial'))) {
return 'pass';
}
return 'pending';
}
const score = task.editorialReview?.overall_score ?? task.editorialScore ?? 0;
if (score >= 70) return 'pass';
if (score >= 50) return 'warn';
return 'error';
}
function getMethodologyStatus(task: ReviewTask): DimStatus {
const hasResult =
!!task.methodologyReview ||
typeof task.methodologyScore === 'number' ||
!!task.methodologyStatus;
if (!hasResult) {
if (isTaskFinal(task) && (!task.selectedAgents?.length || task.selectedAgents.includes('methodology'))) {
return 'pass';
}
return 'pending';
}
const score = task.methodologyReview?.overall_score ?? task.methodologyScore ?? 0;
if (score >= 70) return 'pass';
if (score >= 50) return 'warn';
return 'error';
}
function getForensicsStatus(task: ReviewTask): DimStatus {
if (!task.forensicsResult) {
// 数据验证是基础流程的一部分,任务完成但列表接口未回传详细 forensicsResult 时按通过显示
return isTaskFinal(task) ? 'pass' : 'pending';
}
const errCnt = task.forensicsResult.summary?.errorCount ?? 0;
const warnCnt = task.forensicsResult.summary?.warningCount ?? 0;
if (errCnt > 0) return 'error';
if (warnCnt > 0) return 'warn';
return 'pass';
}
function getClinicalStatus(task: ReviewTask): DimStatus {
if (!task.clinicalReview) {
if (isTaskFinal(task) && (!task.selectedAgents?.length || task.selectedAgents.includes('clinical'))) {
return 'pass';
}
return 'pending';
}
return 'pass';
}
function StatusIcon({ status }: { status: DimStatus }) {
if (status === 'pass') return <IconCheck />;
if (status === 'warn') return <IconWarn />;
if (status === 'error') return <IconError />;
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 }> = {
pending: { label: '待审查', cls: 'bg-gray-100 text-gray-600' },
extracting: { label: '提取中', cls: 'bg-blue-100 text-blue-700' },
reviewing: { label: 'AI审查中', cls: 'bg-amber-100 text-amber-700' },
reviewing_editorial: { label: 'AI审查中', cls: 'bg-amber-100 text-amber-700' },
reviewing_methodology: { label: 'AI审查中', cls: 'bg-amber-100 text-amber-700' },
completed: { label: 'AI审查完毕', cls: 'bg-sky-100 text-sky-700' },
partial_completed: { label: '部分完成', cls: 'bg-amber-100 text-amber-700' },
failed: { label: '失败', cls: 'bg-red-100 text-red-600' },
};
const { label, cls } = map[status] ?? { label: status, cls: 'bg-gray-100 text-gray-600' };
return (
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${cls}`}>{label}</span>
);
}
// ── 主组件 ────────────────────────────────────────────────────────
export default function TenantDashboard() {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [tasks, setTasks] = useState<ReviewTask[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
// 智能体选择弹窗
const [agentModalVisible, setAgentModalVisible] = useState(false);
const [pendingTask, setPendingTask] = useState<ReviewTask | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// ── 数据加载 ──────────────────────────────────────────────────
const loadTasks = useCallback(async () => {
try {
setLoading(true);
const data = await api.getTasks();
setTasks(data);
} catch {
message.error('加载任务列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadTasks(); }, [loadTasks]);
// 轮询处理中的任务
useEffect(() => {
const processing = tasks.filter(t =>
['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(t.status)
);
if (processing.length === 0) return;
const iv = setInterval(async () => {
for (const t of processing) {
try {
const updated = await api.getTask(t.id);
setTasks(prev => prev.map(x => x.id === updated.id ? updated : x));
} catch {}
}
}, 3000);
return () => clearInterval(iv);
}, [tasks]);
// ── 统计数据 ──────────────────────────────────────────────────
const isProcessing = (t: ReviewTask) =>
['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(t.status);
const stats = {
total: tasks.length,
pending: tasks.filter(t => t.status === 'pending').length,
processing: tasks.filter(isProcessing).length,
completed: tasks.filter(t => ['completed', 'partial_completed'].includes(t.status)).length,
};
// ── 搜索过滤 ──────────────────────────────────────────────────
const filteredTasks = tasks.filter(t =>
search === '' || t.fileName.toLowerCase().includes(search.toLowerCase())
);
// ── 上传稿件 ──────────────────────────────────────────────────
const uploadFiles = useCallback(async (files: File[]) => {
if (!files.length) return;
for (const file of files) {
try {
message.loading({ content: `正在上传 ${file.name}`, key: file.name });
await api.uploadManuscript(file);
message.success({ content: `${file.name} 上传成功`, key: file.name, duration: 2 });
} catch (err: any) {
message.error({ content: `${file.name} 上传失败: ${err.message}`, key: file.name, duration: 3 });
}
}
loadTasks();
}, [loadTasks]);
const handleFilePick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
e.target.value = '';
await uploadFiles(Array.from(files));
};
const handleOpenFilePicker = async () => {
// 优先走浏览器原生文件选择器 APIChrome/Edge 支持,稳定性优于 hidden input click
const maybeWindow = window as Window & {
showOpenFilePicker?: (options: {
multiple?: boolean;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
}) => Promise<Array<{ getFile: () => Promise<File> }>>;
};
if (typeof maybeWindow.showOpenFilePicker === 'function') {
try {
const handles = await maybeWindow.showOpenFilePicker({
multiple: true,
types: [
{
description: '稿件文件',
accept: {
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
},
},
],
});
const files = await Promise.all(handles.map(h => h.getFile()));
await uploadFiles(files);
return;
} catch (err: any) {
// 用户主动取消选择,不提示错误
if (err?.name === 'AbortError') return;
// 其他异常时降级到 input.click()
}
}
// 回退:传统 hidden input
fileInputRef.current?.click();
};
// ── 运行审查 ──────────────────────────────────────────────────
const handleRunTask = (task: ReviewTask) => {
setPendingTask(task);
setAgentModalVisible(true);
};
const handleConfirmRun = async (agents: AgentType[]) => {
if (isSubmitting || !pendingTask) return;
const task = pendingTask;
setAgentModalVisible(false);
setPendingTask(null);
setIsSubmitting(true);
try {
message.loading({ content: '正在启动审查…', key: 'run' });
const { jobId } = await api.runTask(task.id, agents);
message.success({ content: '审查已启动', key: 'run', duration: 2 });
// 对齐旧版体验:启动后立即进入“审稿过程页”,动态查看进度与分模块结果
navigate(`${task.id}?jobId=${encodeURIComponent(jobId)}`);
} catch (err: any) {
message.error({ content: err.message || '启动失败', key: 'run', duration: 3 });
} finally {
setIsSubmitting(false);
}
};
// ── 删除 ──────────────────────────────────────────────────────
const handleDelete = async (task: ReviewTask) => {
if (!window.confirm(`确认删除「${task.fileName}」?`)) return;
try {
await api.deleteTask(task.id);
message.success('删除成功');
loadTasks();
} catch (err: any) {
message.error(err.message || '删除失败');
}
};
// ── 统计卡片配置 ──────────────────────────────────────────────
const statCards = [
{ label: '全部稿件', value: stats.total, icon: '📥', color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '待审查', value: stats.pending, icon: '⏳', color: 'text-gray-600', bg: 'bg-gray-50' },
{ label: 'AI 审查中', value: stats.processing, icon: '🤖', color: 'text-amber-600', bg: 'bg-amber-50' },
{ label: '已完成', value: stats.completed, icon: '✅', color: 'text-green-600', bg: 'bg-green-50' },
];
return (
<div className="h-full flex flex-col overflow-auto bg-gray-50 p-6">
{/* ═══ 统计卡片 ════════════════════════════════════════════ */}
<div className="grid grid-cols-4 gap-5 mb-6">
{statCards.map(card => (
<div key={card.label} className="bg-white rounded-xl p-5 border border-gray-200 shadow-sm flex items-center">
<div className={`w-12 h-12 ${card.bg} rounded-lg flex items-center justify-center text-xl mr-4 shrink-0`}>
{card.icon}
</div>
<div>
<p className="text-sm text-gray-500">{card.label}</p>
<p className={`text-2xl font-bold ${card.color}`}>{card.value}</p>
</div>
</div>
))}
</div>
{/* ═══ 稿件列表表格 ════════════════════════════════════════ */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex-1 flex flex-col min-h-0">
{/* 表格标题栏 */}
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center bg-gray-50/50 shrink-0">
<h2 className="text-base font-bold text-gray-800">稿 (Manuscripts Pool)</h2>
<div className="flex items-center space-x-2">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="搜索文件名…"
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-sky-400 focus:border-sky-400 transition"
/>
{/* 上传按钮:原生文件选择器 API + hidden input 回退 */}
<div className="relative">
<button
type="button"
onClick={handleOpenFilePicker}
className="flex items-center gap-1.5 bg-[#0284c7] text-white px-4 py-1.5 rounded-lg text-sm font-medium hover:bg-[#0369a1] transition shadow-sm"
>
<IconUpload />
稿
</button>
<input
ref={fileInputRef}
type="file"
accept=".docx,.doc,.pdf"
multiple
className="hidden"
onChange={handleFilePick}
aria-label="上传稿件"
/>
</div>
</div>
</div>
{/* 表格内容 */}
<div className="overflow-auto flex-1">
{loading ? (
<div className="flex items-center justify-center h-40">
<svg className="animate-spin h-8 w-8 text-sky-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
) : filteredTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<div className="w-16 h-16 bg-sky-50 text-sky-400 rounded-full flex items-center justify-center mx-auto mb-3 text-2xl">
📄
</div>
<p className="text-sm font-medium text-gray-600">{search ? '未找到匹配的稿件' : '暂无稿件'}</p>
{!search && <p className="text-xs text-gray-400 mt-1">稿</p>}
</div>
) : (
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-gray-500 border-b border-gray-200 sticky top-0">
<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>
<th className="px-6 py-3 font-medium text-center"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredTasks.map(task => {
const isCompleted = ['completed', 'partial_completed'].includes(task.status);
const isPending = task.status === 'pending';
const isRunning = isProcessing(task);
return (
<tr
key={task.id}
className="hover:bg-sky-50/30 transition-colors"
>
{/* 文件名 */}
<td className="px-6 py-4">
<span
className={`font-medium text-gray-800 max-w-xs truncate block ${isCompleted ? 'cursor-pointer hover:text-[#0284c7]' : ''}`}
title={task.fileName}
onClick={() => isCompleted && navigate(task.id)}
>
{task.fileName}
</span>
{task.fileSize > 0 && (
<span className="text-xs text-gray-400">{api.formatFileSize(task.fileSize)}</span>
)}
</td>
{/* 上传时间 */}
<td className="px-6 py-4 text-gray-500 text-xs whitespace-nowrap">
{new Date(task.createdAt).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})}
</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">
<StatusIcon status={getEditorialStatus(task)} />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="flex justify-center">
<StatusIcon status={getMethodologyStatus(task)} />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="flex justify-center">
<StatusIcon status={getForensicsStatus(task)} />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="flex justify-center">
<StatusIcon status={getClinicalStatus(task)} />
</div>
</td>
{/* 状态 Badge */}
<td className="px-6 py-4"><StatusBadge status={task.status} /></td>
{/* 操作按钮 */}
<td className="px-6 py-4 text-right whitespace-nowrap">
{isCompleted && (
<button
onClick={() => navigate(task.id)}
className="text-[#0284c7] hover:text-[#0369a1] font-medium bg-sky-50 px-3 py-1 rounded-md text-sm transition hover:bg-sky-100"
>
</button>
)}
{isPending && (
<button
onClick={() => handleRunTask(task)}
className="text-white bg-[#0284c7] hover:bg-[#0369a1] font-medium px-3 py-1 rounded-md text-sm transition"
>
</button>
)}
{isRunning && (
<span className="text-gray-400 text-sm flex items-center gap-1 justify-end">
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</span>
)}
{task.status === 'failed' && (
<button
onClick={() => handleRunTask(task)}
className="text-red-600 hover:text-red-700 font-medium bg-red-50 px-3 py-1 rounded-md text-sm transition"
>
</button>
)}
<button
onClick={() => handleDelete(task)}
className="ml-2 text-gray-400 hover:text-red-500 transition text-xs"
title="删除"
>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
{/* 智能体选择弹窗(复用现有) */}
<AgentModal
visible={agentModalVisible}
taskCount={1}
onClose={() => { setAgentModalVisible(false); setPendingTask(null); }}
onConfirm={handleConfirmRun}
isSubmitting={isSubmitting}
/>
</div>
);
}

View File

@@ -0,0 +1,70 @@
/**
* 租户 RVW 任务详情页(旧流程适配壳)
* - 复用旧版 TaskDetail 的“审稿过程页 + 动态 Tab”交互
* - 仅做租户路由适配,保持全屏展示(不引入 Sidebar
* - 路由:/:tenantSlug/rvw/:taskId
*/
import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { message } from 'antd';
import * as api from '../api';
import type { ReviewTask } from '../types';
import TaskDetail from '../components/TaskDetail';
export default function TenantTaskDetail() {
const { taskId } = useParams<{ taskId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [task, setTask] = useState<ReviewTask | null>(null);
const [loading, setLoading] = useState(true);
const jobId = searchParams.get('jobId');
useEffect(() => {
if (!taskId) {
setLoading(false);
return;
}
(async () => {
try {
const data = await api.getTask(taskId);
setTask(data);
} catch {
message.error('加载任务失败');
} finally {
setLoading(false);
}
})();
}, [taskId]);
if (loading) {
return (
<div className="h-full flex items-center justify-center bg-slate-50">
<svg className="animate-spin h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
);
}
if (!task) {
return (
<div className="h-full flex items-center justify-center text-slate-500 bg-slate-50">
</div>
);
}
return (
<div className="h-full overflow-hidden bg-slate-50">
<TaskDetail
task={task}
jobId={jobId}
onBack={() => navigate('..', { relative: 'path' })}
/>
</div>
);
}

View File

@@ -103,11 +103,18 @@ export default function LoginPage() {
// 智能跳转:根据用户角色和模块权限判断目标页面
const getRedirectPath = useCallback(() => {
const from = (location.state as any)?.from?.pathname;
const userRole = user?.role;
const userModules = user?.modules || [];
// 如果有明确的来源页面,优先处理
// 1. ?redirect= 查询参数优先RouteGuard 对租户路由设置,如 /t/jtim/login?redirect=%2Fjtim%2Frvw
const searchParams = new URLSearchParams(location.search);
const redirectParam = searchParams.get('redirect');
if (redirectParam) {
return decodeURIComponent(redirectParam);
}
// 2. location.state.fromReact Router 历史导航状态,兼容旧逻辑)
const from = (location.state as any)?.from?.pathname;
if (from && from !== '/') {
// 如果目标是运营管理端,检查权限
if (from.startsWith('/admin')) {
@@ -125,9 +132,14 @@ export default function LoginPage() {
return from;
}
// 没有来源页面,智能判断默认目标
// 3. 期刊租户专属登录页(/t/:tenantCode/login且无 redirect 参数时,默认进入该租户审稿页
if (tenantCode) {
return `/${tenantCode}/rvw`;
}
// 4. 兜底:根据模块权限智能判断默认落地页
return getDefaultModule(userModules);
}, [location, user]);
}, [location, user, tenantCode]);
// 根据用户模块权限获取默认跳转页面
// 路径需要与 moduleRegistry.ts 保持一致!

View File

@@ -0,0 +1,360 @@
/**
* 期刊租户专属登录页
* ─ 100% 独立全屏,无任何顶部导航/Layout 包裹
* ─ 设计 100% 还原 AI审稿V1.html 原型图
* ─ 功能:密码登录 + 验证码登录(与主站 LoginPage 逻辑相同)
* ─ 路由: /t/:tenantCode/login
*/
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useAuth } from '../framework/auth';
import type { ChangePasswordRequest } from '../framework/auth';
interface TenantBrand {
name: string;
logo?: string;
primaryColor: string;
systemName: string;
isReviewOnly?: boolean;
}
// ── 内联 SVG 图标(不依赖任何图标库)──────────────────────────────
const IconBookMedical = () => (
<svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM9 4h2v5l-1-.75L9 9V4zm9 16H6V4h1v9l3-2.25L13 13V4h5v16z"/>
<path d="M11 14h2v2h2v2h-2v2h-2v-2H9v-2h2v-2z"/>
</svg>
);
const IconArrowRight = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
);
const IconSpinner = () => (
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
);
// ── 主组件 ───────────────────────────────────────────────────────
export default function TenantLoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { tenantCode } = useParams<{ tenantCode: string }>();
const {
loginWithPassword,
loginWithCode,
sendVerificationCode,
isLoading,
user,
changePassword,
} = useAuth();
// ── 状态 ──────────────────────────────────────────────────────
const [brand, setBrand] = useState<TenantBrand | null>(null);
const [activeTab, setActiveTab] = useState<'password' | 'code'>('password');
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [countdown, setCountdown] = useState(0);
const [errorMsg, setErrorMsg] = useState('');
const [showPwdModal, setShowPwdModal] = useState(false);
const [newPwd, setNewPwd] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
// ── 获取租户品牌信息 ──────────────────────────────────────────
useEffect(() => {
if (!tenantCode) return;
fetch(`/api/v1/public/tenant-config/${tenantCode}`)
.then(r => r.json())
.then(data => { if (data.success && data.data) setBrand(data.data); })
.catch(() => {});
}, [tenantCode]);
// ── 验证码倒计时 ──────────────────────────────────────────────
useEffect(() => {
if (countdown <= 0) return;
const t = setTimeout(() => setCountdown(c => c - 1), 1000);
return () => clearTimeout(t);
}, [countdown]);
// ── 登录后跳转路径 ────────────────────────────────────────────
const getRedirect = useCallback(() => {
const params = new URLSearchParams(location.search);
const r = params.get('redirect');
if (r) return decodeURIComponent(r);
return `/${tenantCode}/rvw`;
}, [location.search, tenantCode]);
useEffect(() => {
if (!user) return;
if (user.isDefaultPassword) { setShowPwdModal(true); return; }
navigate(getRedirect(), { replace: true });
}, [user, navigate, getRedirect]);
// ── 发送验证码 ────────────────────────────────────────────────
const handleSendCode = async () => {
if (!/^1[3-9]\d{9}$/.test(phone)) { setErrorMsg('请输入正确的手机号'); return; }
try {
await sendVerificationCode(phone, 'LOGIN');
setCountdown(60);
setErrorMsg('');
} catch (e: any) { setErrorMsg(e.message || '发送失败'); }
};
// ── 登录提交 ──────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg('');
try {
if (activeTab === 'password') {
await loginWithPassword(phone, password);
} else {
await loginWithCode(phone, code);
}
} catch (e: any) { setErrorMsg(e.message || '登录失败,请重试'); }
};
// ── 修改默认密码 ──────────────────────────────────────────────
const handleChangePwd = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg('');
if (newPwd.length < 6) { setErrorMsg('密码至少 6 位'); return; }
if (newPwd !== confirmPwd) { setErrorMsg('两次输入的密码不一致'); return; }
try {
await changePassword({ oldPassword: password, newPassword: newPwd } as ChangePasswordRequest);
setShowPwdModal(false);
navigate(getRedirect(), { replace: true });
} catch (e: any) { setErrorMsg(e.message || '修改失败'); }
};
// ── 品牌数据 ──────────────────────────────────────────────────
const journalName = brand?.name || 'Journal of Translational Internal Medicine';
const systemName = brand?.systemName || 'AI 智能审稿系统';
const logoUrl = brand?.logo;
// ── 通用 input class ─────────────────────────────────────────
const inputCls = [
'w-full px-4 py-2.5',
'border border-gray-300 rounded-lg',
'text-gray-800 placeholder-gray-400 text-sm',
'focus:outline-none focus:ring-2 focus:ring-sky-400 focus:border-sky-500',
'transition',
].join(' ');
return (
<div className="flex h-screen w-full overflow-hidden font-sans antialiased">
{/* ═══════════ 左侧品牌展示区 ═══════════════════════════════ */}
<div className="w-1/2 bg-[#0c4a6e] flex flex-col justify-center items-center text-white p-12 relative overflow-hidden">
{/* 网格背景纹理 */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: 'radial-gradient(circle, white 1px, transparent 1px)',
backgroundSize: '28px 28px',
}}
/>
<div className="z-10 text-center max-w-sm">
{/* Logo 圆形 */}
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
{logoUrl ? (
<img src={logoUrl} alt={journalName} className="w-16 h-16 object-contain rounded-full" />
) : (
<span className="text-[#0369a1]"><IconBookMedical /></span>
)}
</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>
<div className="inline-block px-6 py-2 border border-sky-400 rounded-full text-sm text-sky-200">
</div>
</div>
</div>
{/* ═══════════ 右侧登录表单区 ════════════════════════════════ */}
<div className="w-1/2 flex items-center justify-center bg-white">
<div className="w-96">
<h2 className="text-3xl font-bold mb-2 text-gray-800"></h2>
<p className="text-gray-500 mb-6 text-sm"></p>
{/* ── Tab 切换(分段选择器风格,对照原型图)── */}
<div className="flex mb-7 bg-gray-100 rounded-lg p-1">
{(['password', 'code'] as const).map(tab => (
<button
key={tab}
type="button"
onClick={() => { setActiveTab(tab); setErrorMsg(''); }}
className={[
'flex-1 py-2 text-sm font-medium rounded-md transition-all duration-200',
activeTab === tab
? 'bg-white text-[#0284c7] shadow-sm ring-1 ring-gray-200'
: 'text-gray-500 hover:text-gray-700',
].join(' ')}
>
{tab === 'password' ? '密码登录' : '验证码登录'}
</button>
))}
</div>
{/* ── 错误提示 ── */}
{errorMsg && (
<div className="mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{errorMsg}
</div>
)}
{/* ── 表单 ── */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* 手机号 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="tel"
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="请输入手机号"
maxLength={11}
required
className={inputCls}
/>
</div>
{/* 密码 */}
{activeTab === 'password' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="请输入密码"
required
className={inputCls}
/>
</div>
)}
{/* 验证码 */}
{activeTab === 'code' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<div className="flex gap-2">
<input
type="text"
value={code}
onChange={e => setCode(e.target.value)}
placeholder="6 位验证码"
maxLength={6}
required
className={[inputCls, 'flex-1'].join(' ')}
/>
<button
type="button"
onClick={handleSendCode}
disabled={countdown > 0 || isLoading}
className={[
'px-4 py-2.5 rounded-lg text-sm font-medium border transition whitespace-nowrap',
countdown > 0
? 'border-gray-300 text-gray-400 cursor-not-allowed'
: 'border-[#0284c7] text-[#0284c7] hover:bg-sky-50',
].join(' ')}
>
{countdown > 0 ? `${countdown}s 后重发` : '发送验证码'}
</button>
</div>
</div>
)}
{/* 记住我 / 忘记密码 */}
{activeTab === 'password' && (
<div className="flex items-center justify-between">
<label className="flex items-center cursor-pointer select-none">
<input type="checkbox" className="rounded border-gray-300 text-[#0284c7] mr-2" />
<span className="text-sm text-gray-600"></span>
</label>
<a href="#" className="text-sm text-[#0284c7] hover:text-[#0369a1] transition-colors">
</a>
</div>
)}
{/* 提交按钮 */}
<button
type="submit"
disabled={isLoading}
className={[
'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)]',
'disabled:opacity-60 disabled:cursor-not-allowed',
].join(' ')}
>
{isLoading ? <IconSpinner /> : null}
{!isLoading && <IconArrowRight />}
</button>
</form>
</div>
</div>
{/* ═══════════ 首次登录修改密码 Modal ════════════════════════ */}
{showPwdModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-8 w-[400px] shadow-2xl">
<h3 className="text-xl font-bold text-gray-800 mb-1"></h3>
<p className="text-gray-500 text-sm mb-6"></p>
{errorMsg && (
<div className="mb-4 px-4 py-2 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{errorMsg}
</div>
)}
<form onSubmit={handleChangePwd} className="space-y-4">
<div>
<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 位"
className={inputCls}
/>
</div>
<div>
<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="再次输入新密码"
className={inputCls}
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => { setShowPwdModal(false); navigate(getRedirect(), { replace: true }); }}
className="flex-1 py-2.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 text-sm transition"
>
</button>
<button
type="submit"
className="flex-1 py-2.5 bg-[#0284c7] text-white rounded-lg text-sm font-medium hover:bg-[#0369a1] transition"
>
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -18,7 +18,14 @@ import {
Table,
message,
Spin,
InputNumber,
Divider,
Alert,
Typography,
} from 'antd';
const { TextArea } = Input;
const { Text } = Typography;
import {
ArrowLeftOutlined,
EditOutlined,
@@ -37,11 +44,15 @@ import {
updateTenant,
configureModules,
createTenant,
fetchRvwConfig,
saveRvwConfig,
type TenantDetail,
type TenantModuleConfig,
type TenantType,
type CreateTenantRequest,
type UpdateTenantRequest,
type TenantRvwConfig,
type UpdateRvwConfigRequest,
} from './api/tenantApi';
const { Option } = Select;
@@ -83,7 +94,13 @@ const TenantDetailPage = () => {
const [activeTab, setActiveTab] = useState(searchParams.get('tab') || 'info');
const [moduleConfigs, setModuleConfigs] = useState<TenantModuleConfig[]>([]);
// RVW V4.0 审稿配置
const [rvwConfig, setRvwConfig] = useState<TenantRvwConfig | null>(null);
const [rvwConfigLoading, setRvwConfigLoading] = useState(false);
const [rvwConfigSaving, setRvwConfigSaving] = useState(false);
const [form] = Form.useForm();
const [rvwForm] = Form.useForm();
// 加载租户详情
const loadTenant = async () => {
@@ -114,6 +131,72 @@ const TenantDetailPage = () => {
loadTenant();
}, [id]);
// 加载 RVW 审稿配置
const loadRvwConfig = async () => {
if (!id || isNew) return;
setRvwConfigLoading(true);
try {
const data = await fetchRvwConfig(id);
setRvwConfig(data);
if (data) {
rvwForm.setFieldsValue({
methodologyExpertPrompt: data.methodologyExpertPrompt || '',
methodologyHandlebarsTemplate: data.methodologyHandlebarsTemplate || '',
dataForensicsLevel: data.dataForensicsLevel || 'L2',
clinicalExpertPrompt: data.clinicalExpertPrompt || '',
finer_feasibility: data.finerWeights?.feasibility ?? 20,
finer_innovation: data.finerWeights?.innovation ?? 20,
finer_ethics: data.finerWeights?.ethics ?? 20,
finer_relevance: data.finerWeights?.relevance ?? 20,
finer_novelty: data.finerWeights?.novelty ?? 20,
});
}
} catch (err: any) {
message.error(err.message || '加载审稿配置失败');
} finally {
setRvwConfigLoading(false);
}
};
// 保存 RVW 审稿配置
const handleSaveRvwConfig = async () => {
if (!id || isNew) return;
try {
const values = await rvwForm.validateFields();
setRvwConfigSaving(true);
const finerWeights = {
feasibility: values.finer_feasibility,
innovation: values.finer_innovation,
ethics: values.finer_ethics,
relevance: values.finer_relevance,
novelty: values.finer_novelty,
};
const total = Object.values(finerWeights).reduce((a, b) => a + Number(b), 0);
if (Math.abs(total - 100) > 1) {
message.error(`FINER 权重之和应为 100当前为 ${total}`);
return;
}
const payload: UpdateRvwConfigRequest = {
methodologyExpertPrompt: values.methodologyExpertPrompt || null,
methodologyHandlebarsTemplate: values.methodologyHandlebarsTemplate || null,
dataForensicsLevel: values.dataForensicsLevel,
clinicalExpertPrompt: values.clinicalExpertPrompt || null,
finerWeights,
};
const saved = await saveRvwConfig(id, payload);
setRvwConfig(saved);
message.success('审稿配置已保存');
} catch (err: any) {
if (err.errorFields) return;
message.error(err.message || '保存审稿配置失败');
} finally {
setRvwConfigSaving(false);
}
};
// 保存租户信息
const handleSave = async () => {
try {
@@ -292,7 +375,6 @@ const TenantDetailPage = () => {
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'info',
@@ -431,7 +513,123 @@ const TenantDetailPage = () => {
</div>
),
},
{
key: 'rvw-config',
label: '智能审稿配置',
disabled: isNew,
children: (
<Spin spinning={rvwConfigLoading}>
<Alert
type="info"
showIcon
style={{ marginBottom: 20 }}
message="每个期刊租户可独立配置审查标准。留空则使用系统默认值,填写后将覆盖该租户的审查行为。"
/>
<Form form={rvwForm} layout="vertical">
{/* Panel A稿约规范 */}
<Divider orientation="left">A. 稿</Divider>
<Alert
type="warning"
showIcon
message="稿约规则编辑器将在 P1 版本提供可视化配置界面,当前暂不支持自定义。"
style={{ marginBottom: 16 }}
/>
{/* Panel B方法学评估 */}
<Divider orientation="left">B. </Divider>
<Form.Item
name="methodologyExpertPrompt"
label="方法学专家评判标准(业务 Prompt"
extra="专家可在此自由描述审查标准,无需懂 JSON。系统会自动拼装到 AI 指令中。"
>
<TextArea
rows={12}
placeholder="你是一位资深临床研究方法学专家与医学统计学审稿人常年为《The Lancet》、《JAMA》或《中华医学杂志》等国内外顶尖期刊提供审稿意见..."
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item
name="methodologyHandlebarsTemplate"
label="方法学报告展示模板Handlebars留空使用系统默认"
extra="支持 Handlebars 语法,可引用 {{overall_score}}、{{conclusion}}、{{summary}}、{{checkpoints}} 等变量。"
>
<TextArea
rows={8}
placeholder="# 方法学评估报告&#10;&#10;**综合评分:** {{overall_score}} 分 ..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
{/* Panel C数据验证 */}
<Divider orientation="left">C. </Divider>
<Form.Item
name="dataForensicsLevel"
label="数据验证深度"
rules={[{ required: true }]}
>
<Select style={{ width: 240 }}>
<Select.Option value="L1">L1 + </Select.Option>
<Select.Option value="L2">L2 </Select.Option>
<Select.Option value="L3">L3 </Select.Option>
</Select>
</Form.Item>
{/* Panel D临床专业评估 */}
<Divider orientation="left">D. </Divider>
<Form.Item
name="clinicalExpertPrompt"
label="临床首席科学家评判标准(业务 Prompt"
extra="同方法学,可自由描述临床科学评审标准。"
>
<TextArea
rows={10}
placeholder="你作为临床首席科学家,将对一份手稿是否值得接收进行评价..."
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item label="FINER 五维权重(总计应为 100">
<Space size="large" wrap>
{[
{ key: 'finer_feasibility', label: '可行性 F' },
{ key: 'finer_innovation', label: '创新性 I' },
{ key: 'finer_ethics', label: '伦理性 E' },
{ key: 'finer_relevance', label: '相关性 R' },
{ key: 'finer_novelty', label: '新颖性 N' },
].map(({ key, label }) => (
<Form.Item key={key} name={key} label={label} style={{ marginBottom: 0 }}>
<InputNumber min={0} max={100} step={5} addonAfter="%" />
</Form.Item>
))}
</Space>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
100 20
</Text>
</div>
</Form.Item>
<Form.Item>
<Button
type="primary"
onClick={handleSaveRvwConfig}
loading={rvwConfigSaving}
style={{ backgroundColor: PRIMARY_COLOR, borderColor: PRIMARY_COLOR }}
>
稿
</Button>
</Form.Item>
</Form>
</Spin>
),
},
]}
onChange={(key) => {
setActiveTab(key);
if (key === 'rvw-config' && !rvwConfig && !rvwConfigLoading) {
loadRvwConfig();
}
}}
/>
</Card>
</div>

View File

@@ -244,6 +244,68 @@ export async function fetchModuleList(): Promise<ModuleInfo[]> {
return result.data;
}
// ==================== RVW Config API ====================
/** 租户审稿配置 */
export interface TenantRvwConfig {
id: string;
tenantId: string;
editorialRules: unknown | null;
methodologyExpertPrompt: string | null;
methodologyHandlebarsTemplate: string | null;
dataForensicsLevel: string;
finerWeights: Record<string, number> | null;
clinicalExpertPrompt: string | null;
createdAt: string;
updatedAt: string;
}
/** 更新审稿配置请求 */
export interface UpdateRvwConfigRequest {
editorialRules?: unknown | null;
methodologyExpertPrompt?: string | null;
methodologyHandlebarsTemplate?: string | null;
dataForensicsLevel?: string;
finerWeights?: Record<string, number> | null;
clinicalExpertPrompt?: string | null;
}
/**
* 获取租户智能审稿配置
*/
export async function fetchRvwConfig(tenantId: string): Promise<TenantRvwConfig | null> {
const response = await fetch(`${API_BASE}/${tenantId}/rvw-config`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '获取审稿配置失败');
}
const result = await response.json();
return result.data;
}
/**
* 保存UPSERT租户智能审稿配置
*/
export async function saveRvwConfig(tenantId: string, data: UpdateRvwConfigRequest): Promise<TenantRvwConfig> {
const response = await fetch(`${API_BASE}/${tenantId}/rvw-config`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '保存审稿配置失败');
}
const result = await response.json();
return result.data;
}