feat(rvw): complete journal config center MVP and tenant login routing

Deliver the RVW V4.0 journal configuration center across backend, frontend, migration, and docs with zh/en editorial baseline support and tenant-level prompt/template overrides. Unify tenant login to /:tenantCode/login and auto-enable RVW module when tenant type is JOURNAL to prevent post-login access gaps.

Made-with: Cursor
This commit is contained in:
2026-03-15 11:51:35 +08:00
parent 16179e16ca
commit 83e395824b
44 changed files with 2555 additions and 312 deletions

View File

@@ -39,6 +39,8 @@ import IitQcCockpitPage from './modules/admin/pages/IitQcCockpitPage'
import IitMemberManagePage from './modules/admin/pages/IitMemberManagePage'
// 运营日志
import ActivityLogsPage from './pages/admin/ActivityLogsPage'
import JournalConfigListPage from './pages/admin/journal-configs/JournalConfigListPage'
import JournalConfigDetailPage from './pages/admin/journal-configs/JournalConfigDetailPage'
// 个人中心页面
import ProfilePage from './pages/user/ProfilePage'
@@ -55,7 +57,7 @@ import ProfilePage from './pages/user/ProfilePage'
*
* 路由结构:
* - /login - 通用登录页(个人用户)
* - /t/{tenantCode}/login - 租户专属登录页
* - /{tenantCode}/login - 租户专属登录页
* - / - 首页(需要认证)
* - /{module}/* - 业务模块(需要认证+权限)
*/
@@ -113,8 +115,8 @@ function App() {
<Routes>
{/* 登录页面(无需认证) */}
<Route path="/login" element={<LoginPage />} />
{/* 期刊租户专属登录页(全屏原型风格,独立于所有 Layout*/}
<Route path="/t/:tenantCode/login" element={<TenantLoginPage />} />
{/* 期刊租户专属登录页(全屏原型风格,独立于所有 Layout */}
<Route path="/:tenantCode/login" element={<TenantLoginPage />} />
{/* 业务应用端 /app/* */}
<Route path="/" element={<MainLayout />}>
@@ -168,6 +170,9 @@ function App() {
<Route path="iit-members" element={<IitMemberManagePage />} />
{/* 运营日志 */}
<Route path="activity-logs" element={<ActivityLogsPage />} />
{/* 期刊配置中心 */}
<Route path="journal-configs" element={<JournalConfigListPage />} />
<Route path="journal-configs/:id" element={<JournalConfigDetailPage />} />
{/* 系统配置 */}
<Route path="system" element={<div className="text-center py-20">🚧 ...</div>} />
</Route>

View File

@@ -101,6 +101,7 @@ const AdminLayout = () => {
type: 'group' as const,
label: '商务运营',
children: [
{ key: '/admin/journal-configs', icon: <BookOutlined />, label: '期刊配置中心' },
{ key: '/admin/tenants', icon: <TeamOutlined />, label: '租户管理' },
{ key: '/admin/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/admin/activity-logs', icon: <FileTextOutlined />, label: '运营日志' },

View File

@@ -63,7 +63,7 @@ export default function TenantPortalLayout() {
const handleLogout = async () => {
await logout();
window.location.href = `/t/${tenantSlug}/login`;
window.location.href = `/${tenantSlug}/login`;
};
return (

View File

@@ -14,7 +14,7 @@ import { LockOutlined } from '@ant-design/icons'
* 3. 有权限→渲染子组件
*
* @version 2026-03-14 V4.0:租户感知重定向
* - 期刊租户路径(/jtim/*)→ /t/jtim/login?redirect=/jtim/dashboard
* - 期刊租户路径(/jtim/*)→ /jtim/login?redirect=/jtim/dashboard
* - 主平台路径(/rvw/*, /ai-qa/* 等)→ /login行为不变
* - extractTenantSlug 由 useTenantObserver 模块统一维护,自动排除所有注册模块路径
*/
@@ -22,16 +22,16 @@ import { LockOutlined } from '@ant-design/icons'
/**
* 构造未登录时的登录跳转目标。
*
* 对齐 App.tsx 中已定义的路由:<Route path="/t/:tenantCode/login" />
* 对齐 App.tsx 中已定义的路由:<Route path="/:tenantCode/login" />
*
* - 期刊租户下:/t/jtim/login?redirect=%2Fjtim%2Fdashboard
* - 期刊租户下:/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}`
return `/${tenantSlug}/login?redirect=${redirectParam}`
}
interface RouteGuardProps {

View File

@@ -7,7 +7,7 @@
*
* 路由:
* - /login - 通用登录(个人用户)
* - /t/{tenantCode}/login - 租户专属登录(机构用户)
* - /{tenantCode}/login - 租户专属登录(机构用户)
*/
import { useState, useEffect, useCallback } from 'react';
@@ -106,7 +106,7 @@ export default function LoginPage() {
const userRole = user?.role;
const userModules = user?.modules || [];
// 1. ?redirect= 查询参数优先RouteGuard 对租户路由设置,如 /t/jtim/login?redirect=%2Fjtim%2Frvw
// 1. ?redirect= 查询参数优先RouteGuard 对租户路由设置,如 /jtim/login?redirect=%2Fjtim%2Frvw
const searchParams = new URLSearchParams(location.search);
const redirectParam = searchParams.get('redirect');
if (redirectParam) {
@@ -132,7 +132,7 @@ export default function LoginPage() {
return from;
}
// 3. 期刊租户专属登录页(/t/:tenantCode/login且无 redirect 参数时,默认进入该租户审稿页
// 3. 期刊租户专属登录页(/:tenantCode/login且无 redirect 参数时,默认进入该租户审稿页
if (tenantCode) {
return `/${tenantCode}/rvw`;
}

View File

@@ -3,7 +3,7 @@
* ─ 100% 独立全屏,无任何顶部导航/Layout 包裹
* ─ 设计 100% 还原 AI审稿V1.html 原型图
* ─ 功能:密码登录 + 验证码登录(与主站 LoginPage 逻辑相同)
* ─ 路由: /t/:tenantCode/login
* ─ 路由: /:tenantCode/login
*/
import { useState, useEffect, useCallback } from 'react';

View File

@@ -0,0 +1,233 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Alert, Button, Card, Divider, Form, Input, Select, Space, Spin, Tabs, message } from 'antd';
import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons';
import type { JournalLanguage, UpdateRvwConfigRequest } from '../tenants/api/tenantApi';
import {
fetchJournalDetail,
saveJournalBasicInfo,
saveJournalRvwConfig,
} from './api/journalConfigApi';
const { TextArea } = Input;
const LANGUAGE_OPTIONS: Array<{ value: JournalLanguage; label: string }> = [
{ value: 'ZH', label: '中文' },
{ value: 'EN', label: '英文' },
{ value: 'OTHER', label: '其他' },
];
export default function JournalConfigDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState('basic');
const [basicForm] = Form.useForm();
const [rvwForm] = Form.useForm();
const load = async () => {
if (!id) return;
setLoading(true);
try {
const detail = await fetchJournalDetail(id);
basicForm.setFieldsValue({
name: detail.tenant.name,
journalFullName: detail.tenant.journalFullName || detail.tenant.name,
code: detail.tenant.code,
journalLanguage: detail.tenant.journalLanguage || 'ZH',
logoUrl: detail.tenant.logoUrl || '',
brandColor: detail.tenant.brandColor || '',
loginBackgroundUrl: detail.tenant.loginBackgroundUrl || '',
});
rvwForm.setFieldsValue({
editorialBaseStandard: detail.rvwConfig?.editorialBaseStandard || 'en',
editorialExpertPrompt: detail.rvwConfig?.editorialExpertPrompt || '',
editorialHandlebarsTemplate: detail.rvwConfig?.editorialHandlebarsTemplate || '',
methodologyExpertPrompt: detail.rvwConfig?.methodologyExpertPrompt || '',
methodologyHandlebarsTemplate: detail.rvwConfig?.methodologyHandlebarsTemplate || '',
dataForensicsExpertPrompt: detail.rvwConfig?.dataForensicsExpertPrompt || '',
dataForensicsHandlebarsTemplate: detail.rvwConfig?.dataForensicsHandlebarsTemplate || '',
clinicalExpertPrompt: detail.rvwConfig?.clinicalExpertPrompt || '',
clinicalHandlebarsTemplate: detail.rvwConfig?.clinicalHandlebarsTemplate || '',
});
} catch (e: any) {
message.error(e.message || '加载期刊配置失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, [id]);
const handleSaveBasic = async () => {
if (!id) return;
try {
const values = await basicForm.validateFields();
setSaving(true);
await saveJournalBasicInfo(id, values);
message.success('基础信息已保存');
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleSaveRvw = async () => {
if (!id) return;
try {
const values = await rvwForm.validateFields();
const payload: UpdateRvwConfigRequest = {
editorialBaseStandard: values.editorialBaseStandard,
editorialExpertPrompt: values.editorialExpertPrompt || null,
editorialHandlebarsTemplate: values.editorialHandlebarsTemplate || null,
methodologyExpertPrompt: values.methodologyExpertPrompt || null,
methodologyHandlebarsTemplate: values.methodologyHandlebarsTemplate || null,
dataForensicsExpertPrompt: values.dataForensicsExpertPrompt || null,
dataForensicsHandlebarsTemplate: values.dataForensicsHandlebarsTemplate || null,
clinicalExpertPrompt: values.clinicalExpertPrompt || null,
clinicalHandlebarsTemplate: values.clinicalHandlebarsTemplate || null,
};
setSaving(true);
await saveJournalRvwConfig(id, payload);
message.success('审稿配置已保存');
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || '保存失败');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ padding: 24 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/journal-configs')} style={{ marginBottom: 16 }}>
</Button>
<Card title="期刊配置中心">
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'basic',
label: '基础信息与门户',
children: (
<Form form={basicForm} layout="vertical" style={{ maxWidth: 760 }}>
<Form.Item name="name" label="租户名称" rules={[{ required: true, message: '请输入租户名称' }]}>
<Input />
</Form.Item>
<Form.Item name="journalFullName" label="期刊全称" rules={[{ required: true, message: '请输入期刊全称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="访问路径 Slug">
<Input disabled />
</Form.Item>
<Form.Item name="journalLanguage" label="期刊语言" rules={[{ required: true, message: '请选择期刊语言' }]}>
<Select>
{LANGUAGE_OPTIONS.map(item => (
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="logoUrl" label="期刊 Logo URLP1 预留)">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item name="brandColor" label="品牌主色P1 预留)">
<Input placeholder="#0284c7" />
</Form.Item>
<Form.Item name="loginBackgroundUrl" label="登录背景图 URLP2 预留)">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSaveBasic}>
</Button>
</Form.Item>
</Form>
),
},
{
key: 'rvw',
label: '智能审稿配置',
children: (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="留空即继承系统默认配置;填写后即覆盖该期刊默认行为。"
/>
<Form form={rvwForm} layout="vertical">
<Divider>A. 稿线</Divider>
<Form.Item name="editorialBaseStandard" label="继承基线">
<Select style={{ width: 280 }}>
<Select.Option value="en">EN</Select.Option>
<Select.Option value="zh">ZH</Select.Option>
</Select>
</Form.Item>
<Form.Item name="editorialExpertPrompt" label="Editorial Prompt留空继承默认">
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="editorialHandlebarsTemplate" label="Editorial Handlebars 模板(留空继承默认)">
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Divider>B. </Divider>
<Form.Item name="methodologyExpertPrompt" label="Methodology Prompt留空继承默认">
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="methodologyHandlebarsTemplate" label="Methodology Handlebars 模板(留空继承默认)">
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Divider>C. </Divider>
<Form.Item name="dataForensicsExpertPrompt" label="Data Forensics Prompt留空继承默认">
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="dataForensicsHandlebarsTemplate" label="Data Forensics Handlebars 模板(留空继承默认)">
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Divider>D. </Divider>
<Form.Item name="clinicalExpertPrompt" label="Clinical Prompt留空继承默认">
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="clinicalHandlebarsTemplate" label="Clinical Handlebars 模板(留空继承默认)">
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSaveRvw}>
稿
</Button>
</Space>
</Form.Item>
</Form>
</>
),
},
]}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Table, Tag, Input, Select, Button, Space, message } from 'antd';
import { SearchOutlined, SettingOutlined, BookOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { fetchJournalList, type JournalItem } from './api/journalConfigApi';
import type { TenantStatus } from '../tenants/api/tenantApi';
const { Search } = Input;
const { Option } = Select;
const STATUS_LABELS: Record<TenantStatus, { label: string; color: string }> = {
ACTIVE: { label: '运营中', color: 'success' },
SUSPENDED: { label: '已停用', color: 'error' },
EXPIRED: { label: '已过期', color: 'warning' },
};
const LANGUAGE_LABELS: Record<string, string> = {
ZH: '中文',
EN: '英文',
OTHER: '其他',
};
export default function JournalConfigListPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [list, setList] = useState<JournalItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [status, setStatus] = useState<TenantStatus | ''>('');
const load = async () => {
setLoading(true);
try {
const result = await fetchJournalList({
page,
limit: 20,
search: search || undefined,
status: status || undefined,
});
setList(result.data);
setTotal(result.total);
} catch (e: any) {
message.error(e.message || '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, [page, status]);
const columns: ColumnsType<JournalItem> = [
{
title: '期刊名称',
dataIndex: 'journalFullName',
key: 'journalFullName',
render: (_, r) => r.journalFullName || r.name,
},
{
title: 'Slug',
dataIndex: 'code',
key: 'code',
width: 140,
render: (v: string) => <span style={{ fontFamily: 'monospace' }}>{v}</span>,
},
{
title: '语言',
dataIndex: 'journalLanguage',
key: 'journalLanguage',
width: 90,
render: (v: string | null | undefined) => <Tag>{LANGUAGE_LABELS[v || 'ZH'] || '中文'}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (v: TenantStatus) => <Tag color={STATUS_LABELS[v].color}>{STATUS_LABELS[v].label}</Tag>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 140,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => (
<Button
type="link"
icon={<SettingOutlined />}
onClick={() => navigate(`/admin/journal-configs/${record.id}`)}
>
</Button>
),
},
];
return (
<div style={{ padding: 24 }}>
<Card
title={
<Space>
<BookOutlined />
<span></span>
</Space>
}
>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Search
placeholder="搜索期刊名称/Slug"
style={{ width: 280 }}
prefix={<SearchOutlined />}
allowClear
onSearch={(v) => {
setSearch(v);
setPage(1);
setTimeout(load, 0);
}}
/>
<Select
placeholder="状态"
allowClear
style={{ width: 140 }}
value={status || undefined}
onChange={(v) => {
setStatus(v || '');
setPage(1);
}}
>
{Object.entries(STATUS_LABELS).map(([k, cfg]) => (
<Option key={k} value={k}>
{cfg.label}
</Option>
))}
</Select>
</div>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
pagination={{
current: page,
pageSize: 20,
total,
onChange: (p) => setPage(p),
showTotal: (t) => `${t} 本期刊`,
}}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { getAccessToken } from '../../../../framework/auth/api';
import type { JournalLanguage, TenantRvwConfig, TenantStatus, TenantType, UpdateRvwConfigRequest } from '../../tenants/api/tenantApi';
const API_BASE = '/api/admin/journal-configs';
function getAuthHeaders(): HeadersInit {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = getAccessToken();
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
export interface JournalItem {
id: string;
code: string;
name: string;
type: TenantType;
status: TenantStatus;
journalLanguage?: JournalLanguage | null;
journalFullName?: string | null;
logoUrl?: string | null;
brandColor?: string | null;
loginBackgroundUrl?: string | null;
updatedAt: string;
createdAt: string;
}
export interface JournalListResponse {
success: boolean;
data: JournalItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface JournalBasicInfoRequest {
name?: string;
journalLanguage?: JournalLanguage | null;
journalFullName?: string | null;
logoUrl?: string | null;
brandColor?: string | null;
loginBackgroundUrl?: string | null;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
expiresAt?: string | null;
}
export interface JournalDetailResponse {
tenant: JournalItem & {
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
expiresAt?: string | null;
};
rvwConfig: TenantRvwConfig | null;
}
export async function fetchJournalList(params?: {
status?: TenantStatus;
search?: string;
page?: number;
limit?: number;
}): Promise<JournalListResponse> {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set('status', params.status);
if (params?.search) searchParams.set('search', params.search);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.limit) searchParams.set('limit', String(params.limit));
const response = await fetch(`${API_BASE}?${searchParams.toString()}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '获取期刊列表失败');
}
return response.json();
}
export async function fetchJournalDetail(id: string): Promise<JournalDetailResponse> {
const response = await fetch(`${API_BASE}/${id}`, { headers: getAuthHeaders() });
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '获取期刊详情失败');
}
const result = await response.json();
return result.data;
}
export async function saveJournalBasicInfo(id: string, payload: JournalBasicInfoRequest) {
const response = await fetch(`${API_BASE}/${id}/basic-info`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '保存基础信息失败');
}
return (await response.json()).data;
}
export async function saveJournalRvwConfig(id: string, payload: UpdateRvwConfigRequest) {
const response = await fetch(`${API_BASE}/${id}/rvw-config`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '保存审稿配置失败');
}
return (await response.json()).data as TenantRvwConfig;
}

View File

@@ -18,19 +18,17 @@ import {
Table,
message,
Spin,
InputNumber,
Divider,
Alert,
Typography,
} from 'antd';
const { TextArea } = Input;
const { Text } = Typography;
import {
ArrowLeftOutlined,
EditOutlined,
SaveOutlined,
BankOutlined,
BookOutlined,
MedicineBoxOutlined,
HomeOutlined,
UserOutlined,
@@ -64,6 +62,7 @@ const PRIMARY_COLOR = '#10b981';
const TENANT_TYPES: Record<TenantType, { label: string; icon: React.ReactNode; color: string }> = {
HOSPITAL: { label: '医院', icon: <BankOutlined />, color: 'blue' },
PHARMA: { label: '药企', icon: <MedicineBoxOutlined />, color: 'purple' },
JOURNAL: { label: '期刊', icon: <BookOutlined />, color: 'geekblue' },
INTERNAL: { label: '内部', icon: <HomeOutlined />, color: 'cyan' },
PUBLIC: { label: '公共', icon: <UserOutlined />, color: 'green' },
};
@@ -140,15 +139,15 @@ const TenantDetailPage = () => {
setRvwConfig(data);
if (data) {
rvwForm.setFieldsValue({
editorialBaseStandard: data.editorialBaseStandard || 'en',
editorialExpertPrompt: data.editorialExpertPrompt || '',
editorialHandlebarsTemplate: data.editorialHandlebarsTemplate || '',
methodologyExpertPrompt: data.methodologyExpertPrompt || '',
methodologyHandlebarsTemplate: data.methodologyHandlebarsTemplate || '',
dataForensicsLevel: data.dataForensicsLevel || 'L2',
dataForensicsExpertPrompt: data.dataForensicsExpertPrompt || '',
dataForensicsHandlebarsTemplate: data.dataForensicsHandlebarsTemplate || '',
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,
clinicalHandlebarsTemplate: data.clinicalHandlebarsTemplate || '',
});
}
} catch (err: any) {
@@ -165,25 +164,16 @@ const TenantDetailPage = () => {
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 = {
editorialBaseStandard: values.editorialBaseStandard || 'en',
editorialExpertPrompt: values.editorialExpertPrompt || null,
editorialHandlebarsTemplate: values.editorialHandlebarsTemplate || null,
methodologyExpertPrompt: values.methodologyExpertPrompt || null,
methodologyHandlebarsTemplate: values.methodologyHandlebarsTemplate || null,
dataForensicsLevel: values.dataForensicsLevel,
dataForensicsExpertPrompt: values.dataForensicsExpertPrompt || null,
dataForensicsHandlebarsTemplate: values.dataForensicsHandlebarsTemplate || null,
clinicalExpertPrompt: values.clinicalExpertPrompt || null,
finerWeights,
clinicalHandlebarsTemplate: values.clinicalHandlebarsTemplate || null,
};
const saved = await saveRvwConfig(id, payload);
@@ -527,16 +517,40 @@ const TenantDetailPage = () => {
/>
<Form form={rvwForm} layout="vertical">
{/* Panel A稿约规范 */}
<Divider orientation="left">A. 稿</Divider>
<Alert
type="warning"
showIcon
message="稿约规则编辑器将在 P1 版本提供可视化配置界面,当前暂不支持自定义。"
style={{ marginBottom: 16 }}
/>
<Divider>A. 稿</Divider>
<Form.Item
name="editorialBaseStandard"
label="继承模式的基线标准"
extra="当未填写稿约自定义 Prompt 时,系统按此基线选择默认规则。"
>
<Select style={{ width: 280 }}>
<Select.Option value="en">EN</Select.Option>
<Select.Option value="zh">ZH</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="editorialExpertPrompt"
label="稿约规范评估 Prompt留空继承系统默认"
>
<TextArea
rows={8}
placeholder="自定义该租户的稿约规则描述..."
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item
name="editorialHandlebarsTemplate"
label="稿约报告模板Handlebars留空使用系统默认"
>
<TextArea
rows={6}
placeholder="# 稿约规范报告..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
{/* Panel B方法学评估 */}
<Divider orientation="left">B. </Divider>
<Divider>B. </Divider>
<Form.Item
name="methodologyExpertPrompt"
label="方法学专家评判标准(业务 Prompt"
@@ -561,21 +575,30 @@ const TenantDetailPage = () => {
</Form.Item>
{/* Panel C数据验证 */}
<Divider orientation="left">C. </Divider>
<Divider>C. </Divider>
<Form.Item
name="dataForensicsLevel"
label="数据验证深度"
rules={[{ required: true }]}
name="dataForensicsExpertPrompt"
label="数据验证评估 Prompt留空继承系统默认"
>
<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>
<TextArea
rows={8}
placeholder="自定义该租户的数据验证评估要求..."
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item
name="dataForensicsHandlebarsTemplate"
label="数据验证报告模板Handlebars留空使用系统默认"
>
<TextArea
rows={6}
placeholder="# 数据验证报告..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
{/* Panel D临床专业评估 */}
<Divider orientation="left">D. </Divider>
<Divider>D. </Divider>
<Form.Item
name="clinicalExpertPrompt"
label="临床首席科学家评判标准(业务 Prompt"
@@ -588,25 +611,15 @@ const TenantDetailPage = () => {
/>
</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
name="clinicalHandlebarsTemplate"
label="临床评估报告模板Handlebars留空使用系统默认"
>
<TextArea
rows={6}
placeholder="# 临床评估报告..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
<Form.Item>

View File

@@ -25,6 +25,7 @@ import {
CheckCircleOutlined,
DeleteOutlined,
BankOutlined,
BookOutlined,
MedicineBoxOutlined,
HomeOutlined,
UserOutlined,
@@ -49,6 +50,7 @@ const PRIMARY_COLOR = '#10b981';
const TENANT_TYPES: Record<TenantType, { label: string; icon: React.ReactNode; color: string }> = {
HOSPITAL: { label: '医院', icon: <BankOutlined />, color: 'blue' },
PHARMA: { label: '药企', icon: <MedicineBoxOutlined />, color: 'purple' },
JOURNAL: { label: '期刊', icon: <BookOutlined />, color: 'geekblue' },
INTERNAL: { label: '内部', icon: <HomeOutlined />, color: 'cyan' },
PUBLIC: { label: '公共', icon: <UserOutlined />, color: 'green' },
};

View File

@@ -20,14 +20,20 @@ function getAuthHeaders(): HeadersInit {
// ==================== 类型定义 ====================
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'INTERNAL' | 'PUBLIC';
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'JOURNAL' | 'INTERNAL' | 'PUBLIC';
export type TenantStatus = 'ACTIVE' | 'SUSPENDED' | 'EXPIRED';
export type JournalLanguage = 'ZH' | 'EN' | 'OTHER';
export interface TenantInfo {
id: string;
code: string;
name: string;
type: TenantType;
journalLanguage?: JournalLanguage | null;
journalFullName?: string | null;
logoUrl?: string | null;
brandColor?: string | null;
loginBackgroundUrl?: string | null;
status: TenantStatus;
contactName?: string | null;
contactPhone?: string | null;
@@ -60,6 +66,11 @@ export interface CreateTenantRequest {
code: string;
name: string;
type: TenantType;
journalLanguage?: JournalLanguage;
journalFullName?: string;
logoUrl?: string;
brandColor?: string;
loginBackgroundUrl?: string;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
@@ -69,6 +80,12 @@ export interface CreateTenantRequest {
export interface UpdateTenantRequest {
name?: string;
type?: TenantType;
journalLanguage?: JournalLanguage | null;
journalFullName?: string | null;
logoUrl?: string | null;
brandColor?: string | null;
loginBackgroundUrl?: string | null;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
@@ -250,24 +267,30 @@ export async function fetchModuleList(): Promise<ModuleInfo[]> {
export interface TenantRvwConfig {
id: string;
tenantId: string;
editorialRules: unknown | null;
editorialBaseStandard: 'zh' | 'en';
editorialExpertPrompt: string | null;
editorialHandlebarsTemplate: string | null;
methodologyExpertPrompt: string | null;
methodologyHandlebarsTemplate: string | null;
dataForensicsLevel: string;
finerWeights: Record<string, number> | null;
dataForensicsExpertPrompt: string | null;
dataForensicsHandlebarsTemplate: string | null;
clinicalExpertPrompt: string | null;
clinicalHandlebarsTemplate: string | null;
createdAt: string;
updatedAt: string;
}
/** 更新审稿配置请求 */
export interface UpdateRvwConfigRequest {
editorialRules?: unknown | null;
editorialBaseStandard?: 'zh' | 'en';
editorialExpertPrompt?: string | null;
editorialHandlebarsTemplate?: string | null;
methodologyExpertPrompt?: string | null;
methodologyHandlebarsTemplate?: string | null;
dataForensicsLevel?: string;
finerWeights?: Record<string, number> | null;
dataForensicsExpertPrompt?: string | null;
dataForensicsHandlebarsTemplate?: string | null;
clinicalExpertPrompt?: string | null;
clinicalHandlebarsTemplate?: string | null;
}
/**