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:
@@ -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>
|
||||
|
||||
131
frontend-v2/src/framework/layout/TenantPortalLayout.tsx
Normal file
131
frontend-v2/src/framework/layout/TenantPortalLayout.tsx
Normal 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} {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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
2
frontend-v2/src/framework/tenant/index.ts
Normal file
2
frontend-v2/src/framework/tenant/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useTenantStore } from './useTenantStore'
|
||||
export { useTenantObserver, extractTenantSlug } from './useTenantObserver'
|
||||
63
frontend-v2/src/framework/tenant/useTenantObserver.ts
Normal file
63
frontend-v2/src/framework/tenant/useTenantObserver.ts
Normal 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])
|
||||
}
|
||||
22
frontend-v2/src/framework/tenant/useTenantStore.ts
Normal file
22
frontend-v2/src/framework/tenant/useTenantStore.ts
Normal 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 }),
|
||||
}))
|
||||
18
frontend-v2/src/modules/rvw/TenantModule.tsx
Normal file
18
frontend-v2/src/modules/rvw/TenantModule.tsx
Normal 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;
|
||||
538
frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx
Normal file
538
frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx
Normal 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 () => {
|
||||
// 优先走浏览器原生文件选择器 API(Chrome/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>
|
||||
);
|
||||
}
|
||||
70
frontend-v2/src/modules/rvw/pages/TenantTaskDetail.tsx
Normal file
70
frontend-v2/src/modules/rvw/pages/TenantTaskDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.from(React 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 保持一致!
|
||||
|
||||
360
frontend-v2/src/pages/TenantLoginPage.tsx
Normal file
360
frontend-v2/src/pages/TenantLoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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="# 方法学评估报告 **综合评分:** {{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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user