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:
@@ -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>
|
||||
|
||||
@@ -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: '运营日志' },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* ─ 100% 独立全屏,无任何顶部导航/Layout 包裹
|
||||
* ─ 设计 100% 还原 AI审稿V1.html 原型图
|
||||
* ─ 功能:密码登录 + 验证码登录(与主站 LoginPage 逻辑相同)
|
||||
* ─ 路由: /t/:tenantCode/login
|
||||
* ─ 路由: /:tenantCode/login
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
@@ -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 URL(P1 预留)">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
<Form.Item name="brandColor" label="品牌主色(P1 预留)">
|
||||
<Input placeholder="#0284c7" />
|
||||
</Form.Item>
|
||||
<Form.Item name="loginBackgroundUrl" label="登录背景图 URL(P2 预留)">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user