feat(admin): Complete tenant management and module access control system
Major Features: - Tenant management CRUD (list, create, edit, delete, module configuration) - Dynamic module management system (modules table with 8 modules) - Multi-tenant module permission merging (ModuleService) - Module access control middleware (requireModule) - User module permission API (GET /api/v1/auth/me/modules) - Frontend module permission filtering (HomePage + TopNavigation) Module Integration: - RVW module integrated with PromptService (editorial + methodology) - All modules (RVW/PKB/ASL/DC) added authenticate + requireModule middleware - Fixed ReviewTask foreign key constraint (cross-schema issue) - Removed all MOCK_USER_ID, unified to request.user?.userId Prompt Management Enhancements: - Module names displayed in Chinese (RVW -> 智能审稿) - Enhanced version history with view content and rollback features - List page shows both activeVersion and draftVersion columns Database Changes: - Added platform_schema.modules table - Modified tenant_modules table (added index and UUID) - Removed ReviewTask foreign key to public.users (cross-schema fix) - Seeded 8 modules: RVW, PKB, ASL, DC, IIT, AIA, SSA, ST Documentation Updates: - Updated ADMIN module development status - Updated TODO checklist (89% progress) - Updated Prompt management plan (Phase 3.5.5 completed) - Added module authentication specification Files Changed: 80+ Status: All features tested and verified locally Next: User management module development
This commit is contained in:
@@ -14,6 +14,8 @@ import AdminDashboard from './pages/admin/AdminDashboard'
|
||||
import OrgDashboard from './pages/org/OrgDashboard'
|
||||
import PromptListPage from './pages/admin/PromptListPage'
|
||||
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'
|
||||
|
||||
/**
|
||||
@@ -89,8 +91,9 @@ function App() {
|
||||
{/* Prompt 管理 */}
|
||||
<Route path="prompts" element={<PromptListPage />} />
|
||||
<Route path="prompts/:code" element={<PromptEditorPage />} />
|
||||
{/* 其他模块(待开发) */}
|
||||
<Route path="tenants" element={<div className="text-center py-20">🚧 租户管理页面开发中...</div>} />
|
||||
{/* 租户管理 */}
|
||||
<Route path="tenants" element={<TenantListPage />} />
|
||||
<Route path="tenants/:id" element={<TenantDetailPage />} />
|
||||
<Route path="users" element={<div className="text-center py-20">🚧 用户管理页面开发中...</div>} />
|
||||
<Route path="system" element={<div className="text-center py-20">🚧 系统配置页面开发中...</div>} />
|
||||
</Route>
|
||||
|
||||
45
frontend-v2/src/common/api/axios.ts
Normal file
45
frontend-v2/src/common/api/axios.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 带认证的 Axios 实例
|
||||
*
|
||||
* 自动添加 Authorization header
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { getAccessToken } from '../../framework/auth/api';
|
||||
|
||||
// 创建 axios 实例
|
||||
const apiClient = axios.create({
|
||||
timeout: 60000, // 60秒超时
|
||||
});
|
||||
|
||||
// 请求拦截器 - 自动添加 Authorization header
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器 - 处理 401 错误
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token 过期或无效,可以在这里触发登出
|
||||
console.warn('[API] 认证失败,请重新登录');
|
||||
// 可选:跳转到登录页
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
|
||||
|
||||
34
frontend-v2/src/framework/auth/moduleApi.ts
Normal file
34
frontend-v2/src/framework/auth/moduleApi.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 用户模块权限 API
|
||||
*/
|
||||
|
||||
import { getAccessToken } from './api';
|
||||
|
||||
const API_BASE = '/api/v1/auth';
|
||||
|
||||
/**
|
||||
* 获取当前用户可访问的模块
|
||||
*/
|
||||
export async function fetchUserModules(): Promise<string[]> {
|
||||
const token = getAccessToken();
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/me/modules`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '获取模块权限失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Dropdown, Avatar, Tooltip } from 'antd'
|
||||
import {
|
||||
@@ -9,9 +10,11 @@ import {
|
||||
BankOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { getAvailableModules } from '../modules/moduleRegistry'
|
||||
import { getAvailableModulesByCode } from '../modules/moduleRegistry'
|
||||
import { fetchUserModules } from '../auth/moduleApi'
|
||||
import { usePermission } from '../permission'
|
||||
import { useAuth } from '../auth'
|
||||
import type { ModuleDefinition } from '../modules/types'
|
||||
|
||||
/**
|
||||
* 顶部导航栏组件
|
||||
@@ -28,9 +31,22 @@ const TopNavigation = () => {
|
||||
const location = useLocation()
|
||||
const { user: authUser, logout: authLogout } = useAuth()
|
||||
const { user, checkModulePermission, logout } = usePermission()
|
||||
const [availableModules, setAvailableModules] = useState<ModuleDefinition[]>([])
|
||||
|
||||
// 获取用户有权访问的模块列表(权限过滤)⭐ 新增
|
||||
const availableModules = getAvailableModules(user?.version || 'basic')
|
||||
// 加载用户可访问的模块
|
||||
useEffect(() => {
|
||||
const loadModules = async () => {
|
||||
try {
|
||||
const moduleCodes = await fetchUserModules()
|
||||
const modules = getAvailableModulesByCode(moduleCodes)
|
||||
setAvailableModules(modules)
|
||||
} catch (error) {
|
||||
console.error('加载模块权限失败', error)
|
||||
setAvailableModules([])
|
||||
}
|
||||
}
|
||||
loadModules()
|
||||
}, [authUser])
|
||||
|
||||
// 获取当前激活的模块
|
||||
const activeModule = availableModules.find(module =>
|
||||
|
||||
@@ -10,6 +10,19 @@ import {
|
||||
AuditOutlined
|
||||
} from '@ant-design/icons'
|
||||
|
||||
/**
|
||||
* 前端模块ID与后端模块代码的映射
|
||||
*/
|
||||
export const MODULE_CODE_MAP: Record<string, string> = {
|
||||
'ai-qa': 'AIA',
|
||||
'literature-platform': 'ASL',
|
||||
'knowledge-base': 'PKB',
|
||||
'data-cleaning': 'DC',
|
||||
'statistical-analysis': 'SSA', // 暂未实现
|
||||
'statistical-tools': 'ST', // 暂未实现
|
||||
'review-system': 'RVW',
|
||||
};
|
||||
|
||||
/**
|
||||
* 模块注册中心
|
||||
* 按照平台架构文档顺序注册所有业务模块
|
||||
@@ -25,6 +38,7 @@ export const MODULES: ModuleDefinition[] = [
|
||||
placeholder: true, // 后续重写
|
||||
requiredVersion: 'basic',
|
||||
description: '基于LLM的智能问答系统',
|
||||
moduleCode: 'AIA', // 后端模块代码
|
||||
},
|
||||
{
|
||||
id: 'literature-platform',
|
||||
@@ -36,6 +50,7 @@ export const MODULES: ModuleDefinition[] = [
|
||||
requiredVersion: 'advanced',
|
||||
description: 'AI驱动的文献筛选和分析系统',
|
||||
standalone: true, // 支持独立运行
|
||||
moduleCode: 'ASL', // 后端模块代码
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base',
|
||||
@@ -46,6 +61,7 @@ export const MODULES: ModuleDefinition[] = [
|
||||
placeholder: false, // V5.0设计已完成实现 ✅
|
||||
requiredVersion: 'basic',
|
||||
description: '个人知识库管理系统(支持全文阅读、逐篇精读、批处理)',
|
||||
moduleCode: 'PKB', // 后端模块代码
|
||||
},
|
||||
{
|
||||
id: 'data-cleaning',
|
||||
@@ -56,6 +72,7 @@ export const MODULES: ModuleDefinition[] = [
|
||||
placeholder: true, // 占位
|
||||
requiredVersion: 'advanced',
|
||||
description: '智能数据清洗整理工具',
|
||||
moduleCode: 'DC', // 后端模块代码
|
||||
},
|
||||
{
|
||||
id: 'statistical-analysis',
|
||||
@@ -67,6 +84,7 @@ export const MODULES: ModuleDefinition[] = [
|
||||
requiredVersion: 'premium',
|
||||
description: '智能统计分析系统(Java团队开发)',
|
||||
isExternal: true, // 外部模块
|
||||
moduleCode: 'SSA', // 后端模块代码
|
||||
},
|
||||
{
|
||||
id: 'statistical-tools',
|
||||
@@ -78,6 +96,7 @@ export const MODULES: ModuleDefinition[] = [
|
||||
requiredVersion: 'premium',
|
||||
description: '统计分析工具集(Java团队开发)',
|
||||
isExternal: true, // 外部模块
|
||||
moduleCode: 'ST', // 后端模块代码
|
||||
},
|
||||
{
|
||||
id: 'review-system',
|
||||
@@ -88,6 +107,7 @@ export const MODULES: ModuleDefinition[] = [
|
||||
placeholder: false, // RVW模块已开发
|
||||
requiredVersion: 'basic',
|
||||
description: '智能期刊审稿系统(稿约评审+方法学评审)',
|
||||
moduleCode: 'RVW', // 后端模块代码
|
||||
},
|
||||
]
|
||||
|
||||
@@ -112,6 +132,7 @@ export const getModuleByPath = (path: string): ModuleDefinition | undefined => {
|
||||
* @returns 用户有权访问的模块列表
|
||||
*
|
||||
* @version Week 2 Day 7 - 任务17:实现权限过滤逻辑
|
||||
* @deprecated 使用 getAvailableModulesByCode 替代
|
||||
*/
|
||||
export const getAvailableModules = (userVersion: string = 'premium'): ModuleDefinition[] => {
|
||||
// 权限等级映射
|
||||
@@ -134,3 +155,19 @@ export const getAvailableModules = (userVersion: string = 'premium'): ModuleDefi
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户模块权限过滤可访问的模块
|
||||
*
|
||||
* @param userModuleCodes 用户可访问的模块代码列表 (如 ['RVW', 'PKB'])
|
||||
* @returns 用户有权访问的模块列表
|
||||
*/
|
||||
export const getAvailableModulesByCode = (userModuleCodes: string[]): ModuleDefinition[] => {
|
||||
return MODULES.filter(module => {
|
||||
// 如果模块没有 moduleCode,保持兼容(外部模块或占位模块)
|
||||
if (!module.moduleCode) return false;
|
||||
|
||||
// 检查用户是否有该模块的访问权限
|
||||
return userModuleCodes.includes(module.moduleCode);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -60,5 +60,8 @@ export interface ModuleDefinition {
|
||||
|
||||
/** 模块描述 */
|
||||
description?: string
|
||||
|
||||
/** 后端模块代码(用于权限检查) */
|
||||
moduleCode?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import { Card, Row, Col } from 'antd'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Row, Col, Spin } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { MODULES } from '@/framework/modules/moduleRegistry'
|
||||
import { getAvailableModulesByCode } from '@/framework/modules/moduleRegistry'
|
||||
import { fetchUserModules } from '@/framework/auth/moduleApi'
|
||||
import type { ModuleDefinition } from '@/framework/modules/types'
|
||||
|
||||
const HomePage = () => {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [availableModules, setAvailableModules] = useState<ModuleDefinition[]>([])
|
||||
|
||||
// 加载用户可访问的模块
|
||||
useEffect(() => {
|
||||
const loadModules = async () => {
|
||||
try {
|
||||
const moduleCodes = await fetchUserModules()
|
||||
const modules = getAvailableModulesByCode(moduleCodes)
|
||||
setAvailableModules(modules)
|
||||
} catch (error) {
|
||||
console.error('加载模块权限失败', error)
|
||||
setAvailableModules([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadModules()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-8">
|
||||
@@ -19,8 +49,14 @@ const HomePage = () => {
|
||||
</div>
|
||||
|
||||
{/* 模块卡片 */}
|
||||
<Row gutter={[24, 24]}>
|
||||
{MODULES.map(module => (
|
||||
{availableModules.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<p className="text-lg">您暂无可访问的模块</p>
|
||||
<p className="text-sm mt-2">请联系管理员为您的租户开通模块权限</p>
|
||||
</div>
|
||||
) : (
|
||||
<Row gutter={[24, 24]}>
|
||||
{availableModules.map(module => (
|
||||
<Col xs={24} sm={12} lg={8} key={module.id}>
|
||||
<Card
|
||||
hoverable={!module.placeholder}
|
||||
@@ -56,30 +92,9 @@ const HomePage = () => {
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">6</div>
|
||||
<div className="text-gray-600 mt-2">业务模块</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">10</div>
|
||||
<div className="text-gray-600 mt-2">数据库Schema</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">4</div>
|
||||
<div className="text-gray-600 mt-2">集成LLM</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
fetchPromptDetail,
|
||||
saveDraft,
|
||||
publishPrompt,
|
||||
rollbackPrompt,
|
||||
testRender,
|
||||
type PromptDetail,
|
||||
type PromptVersion,
|
||||
} from './api/promptApi'
|
||||
|
||||
const { TextArea } = Input
|
||||
@@ -34,6 +36,18 @@ const { TextArea } = Input
|
||||
// 运营管理端主色
|
||||
const PRIMARY_COLOR = '#10b981'
|
||||
|
||||
// 模块中英文映射
|
||||
const MODULE_NAMES: Record<string, string> = {
|
||||
'RVW': '智能审稿',
|
||||
'PKB': '个人知识库',
|
||||
'ASL': '智能文献',
|
||||
'DC': '数据清洗',
|
||||
'IIT': 'IIT管理',
|
||||
'AIA': '智能问答',
|
||||
'SSA': '智能统计分析',
|
||||
'ST': '统计工具',
|
||||
};
|
||||
|
||||
/**
|
||||
* Prompt 编辑器页面
|
||||
*/
|
||||
@@ -50,6 +64,10 @@ const PromptEditorPage = () => {
|
||||
const [changelogModalVisible, setChangelogModalVisible] = useState(false)
|
||||
const [testVariables, setTestVariables] = useState<Record<string, string>>({})
|
||||
const [testResult, setTestResult] = useState('')
|
||||
const [viewVersionModal, setViewVersionModal] = useState<{ visible: boolean; version: PromptVersion | null }>({
|
||||
visible: false,
|
||||
version: null,
|
||||
})
|
||||
|
||||
// 权限检查
|
||||
const canPublish = user?.role === 'SUPER_ADMIN'
|
||||
@@ -151,6 +169,37 @@ const PromptEditorPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 查看历史版本内容
|
||||
const handleViewVersion = (version: PromptVersion) => {
|
||||
setViewVersionModal({ visible: true, version })
|
||||
}
|
||||
|
||||
// 回滚到指定版本
|
||||
const handleRollback = (version: PromptVersion) => {
|
||||
if (!code) return
|
||||
|
||||
Modal.confirm({
|
||||
title: `确定回滚到 v${version.version}?`,
|
||||
content: (
|
||||
<div>
|
||||
<p>此操作会将该版本设为 ACTIVE(生产版本),当前 ACTIVE 版本会被归档。</p>
|
||||
{version.changelog && <p className="text-gray-600 mt-2">📝 {version.changelog}</p>}
|
||||
</div>
|
||||
),
|
||||
okText: '确定回滚',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await rollbackPrompt(code, version.version)
|
||||
message.success('回滚成功')
|
||||
await loadPromptDetail()
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '回滚失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (loading || !prompt) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
@@ -287,7 +336,7 @@ const PromptEditorPage = () => {
|
||||
<Card title="⚙️ 配置">
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="模块">
|
||||
<Tag color="blue">{prompt.module}</Tag>
|
||||
<Tag color="blue">{MODULE_NAMES[prompt.module] || prompt.module}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={isDraft ? 'warning' : 'success'}>
|
||||
@@ -352,14 +401,42 @@ const PromptEditorPage = () => {
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
v{version.version}
|
||||
<Tag className="ml-2 text-xs">{version.status}</Tag>
|
||||
<Tag className="ml-2 text-xs" color={
|
||||
version.status === 'ACTIVE' ? 'success' :
|
||||
version.status === 'DRAFT' ? 'warning' : 'default'
|
||||
}>
|
||||
{version.status === 'ACTIVE' ? '✅ 生产中' :
|
||||
version.status === 'DRAFT' ? '🔬 调试中' : '已归档'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-1">
|
||||
{new Date(version.createdAt).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
{version.changelog && (
|
||||
<div className="text-gray-600 mt-1">{version.changelog}</div>
|
||||
<div className="text-gray-600 mt-1">📝 {version.changelog}</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleViewVersion(version)}
|
||||
style={{ padding: 0, height: 'auto', fontSize: 12 }}
|
||||
>
|
||||
查看内容
|
||||
</Button>
|
||||
{version.status !== 'ACTIVE' && canPublish && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleRollback(version)}
|
||||
style={{ padding: 0, height: 'auto', fontSize: 12, color: PRIMARY_COLOR }}
|
||||
>
|
||||
回滚到此版本
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
@@ -390,6 +467,70 @@ const PromptEditorPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 查看历史版本内容对话框 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<span>版本内容</span>
|
||||
{viewVersionModal.version && (
|
||||
<>
|
||||
<Tag color={
|
||||
viewVersionModal.version.status === 'ACTIVE' ? 'success' :
|
||||
viewVersionModal.version.status === 'DRAFT' ? 'warning' : 'default'
|
||||
}>
|
||||
v{viewVersionModal.version.version} - {
|
||||
viewVersionModal.version.status === 'ACTIVE' ? '✅ 生产中' :
|
||||
viewVersionModal.version.status === 'DRAFT' ? '🔬 调试中' : '已归档'
|
||||
}
|
||||
</Tag>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
open={viewVersionModal.visible}
|
||||
onCancel={() => setViewVersionModal({ visible: false, version: null })}
|
||||
width={800}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setViewVersionModal({ visible: false, version: null })}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{viewVersionModal.version && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
创建时间: {new Date(viewVersionModal.version.createdAt).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
{viewVersionModal.version.changelog && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
📝 变更说明: {viewVersionModal.version.changelog}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">Prompt 内容:</div>
|
||||
<div className="bg-gray-50 p-4 rounded border max-h-96 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm font-mono">
|
||||
{viewVersionModal.version.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
{viewVersionModal.version.modelConfig && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">模型配置:</div>
|
||||
<div className="bg-blue-50 p-3 rounded">
|
||||
<div className="text-xs space-y-1">
|
||||
<div>Model: {viewVersionModal.version.modelConfig.model}</div>
|
||||
<div>Temperature: {viewVersionModal.version.modelConfig.temperature || 0.3}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ const PromptListPage = () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchPromptList()
|
||||
console.log('📊 Prompt列表数据:', data) // 调试日志
|
||||
if (data.length > 0) {
|
||||
console.log('📝 第一条数据示例:', data[0]) // 查看数据结构
|
||||
console.log('🔍 activeVersion字段:', data[0].activeVersion)
|
||||
console.log('🔍 draftVersion字段:', data[0].draftVersion)
|
||||
console.log('🔍 所有字段:', Object.keys(data[0]))
|
||||
}
|
||||
setPrompts(data)
|
||||
setFilteredPrompts(data)
|
||||
} catch (error: any) {
|
||||
@@ -87,6 +94,18 @@ const PromptListPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 模块中英文映射
|
||||
const moduleNames: Record<string, string> = {
|
||||
'RVW': '智能审稿',
|
||||
'PKB': '个人知识库',
|
||||
'ASL': '智能文献',
|
||||
'DC': '数据清洗',
|
||||
'IIT': 'IIT管理',
|
||||
'AIA': '智能问答',
|
||||
'SSA': '智能统计分析',
|
||||
'ST': '统计工具',
|
||||
};
|
||||
|
||||
// 获取模块列表
|
||||
const modules = ['ALL', ...Array.from(new Set(prompts.map(p => p.module)))]
|
||||
|
||||
@@ -110,38 +129,42 @@ const PromptListPage = () => {
|
||||
title: '模块',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 80,
|
||||
width: 120,
|
||||
render: (module: string) => (
|
||||
<Tag color="blue">{module}</Tag>
|
||||
<Tag color="blue">{moduleNames[module] || module}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
title: '生产版本',
|
||||
key: 'activeVersion',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
const status = record.latestVersion?.status || 'ARCHIVED'
|
||||
const colorMap = {
|
||||
ACTIVE: 'success',
|
||||
DRAFT: 'warning',
|
||||
ARCHIVED: 'default',
|
||||
if (record.activeVersion) {
|
||||
return (
|
||||
<Space>
|
||||
<Tag color="success">v{record.activeVersion.version}</Tag>
|
||||
<span className="text-xs text-green-600">✅ 用户可见</span>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag color={colorMap[status]}>
|
||||
{status}
|
||||
</Tag>
|
||||
)
|
||||
return <span className="text-gray-400">未发布</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '版本',
|
||||
key: 'version',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<span className="text-gray-600">
|
||||
v{record.latestVersion?.version || 0}
|
||||
</span>
|
||||
),
|
||||
title: '草稿版本',
|
||||
key: 'draftVersion',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
if (record.draftVersion) {
|
||||
return (
|
||||
<Space>
|
||||
<Tag color="warning">v{record.draftVersion.version}</Tag>
|
||||
<span className="text-xs text-orange-600">🔬 调试中</span>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-400">-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变量',
|
||||
@@ -191,7 +214,9 @@ const PromptListPage = () => {
|
||||
/>
|
||||
{debugMode && (
|
||||
<Tag color="orange">
|
||||
{debugModules.includes('ALL') ? '全部模块' : debugModules.join(', ')}
|
||||
{debugModules.includes('ALL')
|
||||
? '全部模块'
|
||||
: debugModules.map(m => moduleNames[m] || m).join(', ')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
@@ -208,7 +233,9 @@ const PromptListPage = () => {
|
||||
style={{ width: 150 }}
|
||||
>
|
||||
{modules.map(m => (
|
||||
<Option key={m} value={m}>{m === 'ALL' ? '全部' : m}</Option>
|
||||
<Option key={m} value={m}>
|
||||
{m === 'ALL' ? '全部' : (moduleNames[m] || m)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -251,3 +278,5 @@ const PromptListPage = () => {
|
||||
|
||||
export default PromptListPage
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,24 @@
|
||||
* Prompt 管理 API
|
||||
*/
|
||||
|
||||
import { getAccessToken } from '../../../framework/auth/api'
|
||||
|
||||
const API_BASE = '/api/admin/prompts'
|
||||
|
||||
/**
|
||||
* 获取带认证的请求头
|
||||
*/
|
||||
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 PromptTemplate {
|
||||
id: number
|
||||
code: string
|
||||
@@ -11,6 +27,16 @@ export interface PromptTemplate {
|
||||
module: string
|
||||
description?: string
|
||||
variables?: string[]
|
||||
activeVersion?: {
|
||||
version: number
|
||||
status: 'ACTIVE'
|
||||
createdAt: string
|
||||
} | null
|
||||
draftVersion?: {
|
||||
version: number
|
||||
status: 'DRAFT'
|
||||
createdAt: string
|
||||
} | null
|
||||
latestVersion?: {
|
||||
version: number
|
||||
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED'
|
||||
@@ -51,8 +77,20 @@ export interface PromptDetail {
|
||||
*/
|
||||
export async function fetchPromptList(module?: string): Promise<PromptTemplate[]> {
|
||||
const url = module ? `${API_BASE}?module=${module}` : API_BASE
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
const response = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
|
||||
// 先获取原始文本
|
||||
const text = await response.text()
|
||||
console.log('🌐 原始响应文本:', text.substring(0, 500))
|
||||
|
||||
// 解析JSON
|
||||
const data = JSON.parse(text)
|
||||
|
||||
console.log('🌐 API原始响应:', data)
|
||||
console.log('🌐 data.data第一条:', data.data?.[0])
|
||||
console.log('🌐 data.data第一条的activeVersion:', data.data?.[0]?.activeVersion)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch prompts')
|
||||
@@ -65,7 +103,9 @@ export async function fetchPromptList(module?: string): Promise<PromptTemplate[]
|
||||
* 获取 Prompt 详情
|
||||
*/
|
||||
export async function fetchPromptDetail(code: string): Promise<PromptDetail> {
|
||||
const response = await fetch(`${API_BASE}/${code}`)
|
||||
const response = await fetch(`${API_BASE}/${code}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
@@ -86,7 +126,7 @@ export async function saveDraft(
|
||||
): Promise<any> {
|
||||
const response = await fetch(`${API_BASE}/${code}/draft`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ content, modelConfig, changelog }),
|
||||
})
|
||||
|
||||
@@ -105,7 +145,7 @@ export async function saveDraft(
|
||||
export async function publishPrompt(code: string): Promise<any> {
|
||||
const response = await fetch(`${API_BASE}/${code}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
@@ -117,13 +157,32 @@ export async function publishPrompt(code: string): Promise<any> {
|
||||
return data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚到指定版本
|
||||
*/
|
||||
export async function rollbackPrompt(code: string, version: number): Promise<any> {
|
||||
const response = await fetch(`${API_BASE}/${code}/rollback`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ version }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to rollback prompt')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置调试模式
|
||||
*/
|
||||
export async function setDebugMode(modules: string[], enabled: boolean): Promise<any> {
|
||||
const response = await fetch(`${API_BASE}/debug`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ modules, enabled }),
|
||||
})
|
||||
|
||||
@@ -140,7 +199,9 @@ export async function setDebugMode(modules: string[], enabled: boolean): Promise
|
||||
* 获取调试状态
|
||||
*/
|
||||
export async function getDebugStatus(): Promise<any> {
|
||||
const response = await fetch(`${API_BASE}/debug`)
|
||||
const response = await fetch(`${API_BASE}/debug`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
@@ -156,7 +217,7 @@ export async function getDebugStatus(): Promise<any> {
|
||||
export async function testRender(content: string, variables: Record<string, any>): Promise<any> {
|
||||
const response = await fetch(`${API_BASE}/test-render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ content, variables }),
|
||||
})
|
||||
|
||||
@@ -169,3 +230,4 @@ export async function testRender(content: string, variables: Record<string, any>
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
|
||||
442
frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx
Normal file
442
frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* 租户详情页面(含编辑和模块配置)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Tabs,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
DatePicker,
|
||||
Switch,
|
||||
Table,
|
||||
message,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
EditOutlined,
|
||||
SaveOutlined,
|
||||
BankOutlined,
|
||||
MedicineBoxOutlined,
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchTenantDetail,
|
||||
updateTenant,
|
||||
configureModules,
|
||||
createTenant,
|
||||
type TenantDetail,
|
||||
type TenantModuleConfig,
|
||||
type TenantType,
|
||||
type CreateTenantRequest,
|
||||
type UpdateTenantRequest,
|
||||
} from './api/tenantApi';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
// 运营管理端主色
|
||||
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' },
|
||||
INTERNAL: { label: '内部', icon: <HomeOutlined />, color: 'cyan' },
|
||||
PUBLIC: { label: '公共', icon: <UserOutlined />, color: 'green' },
|
||||
};
|
||||
|
||||
// 状态颜色
|
||||
const STATUS_COLORS = {
|
||||
ACTIVE: 'success',
|
||||
SUSPENDED: 'error',
|
||||
EXPIRED: 'warning',
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
ACTIVE: '运营中',
|
||||
SUSPENDED: '已停用',
|
||||
EXPIRED: '已过期',
|
||||
};
|
||||
|
||||
const TenantDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isNew = id === 'new';
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tenant, setTenant] = useState<TenantDetail | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
const [activeTab, setActiveTab] = useState(searchParams.get('tab') || 'info');
|
||||
const [moduleConfigs, setModuleConfigs] = useState<TenantModuleConfig[]>([]);
|
||||
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 加载租户详情
|
||||
const loadTenant = async () => {
|
||||
if (isNew) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTenantDetail(id!);
|
||||
setTenant(data);
|
||||
setModuleConfigs(data.modules);
|
||||
form.setFieldsValue({
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
contactName: data.contactName,
|
||||
contactPhone: data.contactPhone,
|
||||
contactEmail: data.contactEmail,
|
||||
expiresAt: data.expiresAt ? dayjs(data.expiresAt) : null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTenant();
|
||||
}, [id]);
|
||||
|
||||
// 保存租户信息
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
const data: any = {
|
||||
name: values.name,
|
||||
contactName: values.contactName,
|
||||
contactPhone: values.contactPhone,
|
||||
contactEmail: values.contactEmail,
|
||||
expiresAt: values.expiresAt ? values.expiresAt.format('YYYY-MM-DD') : null,
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
data.code = values.code;
|
||||
data.type = values.type;
|
||||
await createTenant(data as CreateTenantRequest);
|
||||
message.success('创建成功');
|
||||
navigate('/admin/tenants');
|
||||
} else {
|
||||
await updateTenant(id!, data as UpdateTenantRequest);
|
||||
message.success('保存成功');
|
||||
setIsEditing(false);
|
||||
loadTenant();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) return; // 表单验证错误
|
||||
message.error(error.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存模块配置
|
||||
const handleSaveModules = async () => {
|
||||
if (!id || isNew) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await configureModules(
|
||||
id,
|
||||
moduleConfigs.map(m => ({
|
||||
code: m.code,
|
||||
enabled: m.enabled,
|
||||
expiresAt: m.expiresAt,
|
||||
}))
|
||||
);
|
||||
setModuleConfigs(result);
|
||||
message.success('模块配置已保存');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换模块状态
|
||||
const handleToggleModule = (code: string, enabled: boolean) => {
|
||||
setModuleConfigs(prev =>
|
||||
prev.map(m => (m.code === code ? { ...m, enabled } : m))
|
||||
);
|
||||
};
|
||||
|
||||
// 设置模块到期时间
|
||||
const handleSetExpiry = (code: string, date: dayjs.Dayjs | null) => {
|
||||
setModuleConfigs(prev =>
|
||||
prev.map(m => (m.code === code ? {
|
||||
...m,
|
||||
expiresAt: date ? date.format('YYYY-MM-DD') : null
|
||||
} : m))
|
||||
);
|
||||
};
|
||||
|
||||
// 模块配置表格列
|
||||
const moduleColumns: ColumnsType<TenantModuleConfig> = [
|
||||
{
|
||||
title: '模块',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, record) => (
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>{name}</span>
|
||||
<Tag>{record.code}</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 100,
|
||||
render: (enabled: boolean, record) => (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(checked) => handleToggleModule(record.code, checked)}
|
||||
checkedChildren={<CheckCircleOutlined />}
|
||||
unCheckedChildren={<CloseCircleOutlined />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '到期时间',
|
||||
dataIndex: 'expiresAt',
|
||||
key: 'expiresAt',
|
||||
width: 250,
|
||||
render: (date: string | null, record) => {
|
||||
if (!record.enabled) {
|
||||
return <span style={{ color: '#999' }}>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
value={date ? dayjs(date) : null}
|
||||
onChange={(d) => handleSetExpiry(record.code, d)}
|
||||
placeholder="永久有效"
|
||||
style={{ width: '100%' }}
|
||||
format="YYYY-MM-DD"
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 头部 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/admin/tenants')}
|
||||
style={{ marginRight: 16 }}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
{!isNew && tenant && (
|
||||
<Space>
|
||||
<Tag icon={TENANT_TYPES[tenant.type].icon} color={TENANT_TYPES[tenant.type].color}>
|
||||
{TENANT_TYPES[tenant.type].label}
|
||||
</Tag>
|
||||
<Tag color={STATUS_COLORS[tenant.status]}>
|
||||
{STATUS_LABELS[tenant.status]}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<BankOutlined style={{ color: PRIMARY_COLOR }} />
|
||||
<span>{isNew ? '新建租户' : tenant?.name || '租户详情'}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
!isNew && !isEditing ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setIsEditing(true)}
|
||||
style={{ backgroundColor: PRIMARY_COLOR, borderColor: PRIMARY_COLOR }}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
disabled={!isEditing && !isNew}
|
||||
style={{ maxWidth: 600 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="租户代码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入租户代码' },
|
||||
{ pattern: /^[a-z0-9-]+$/, message: '只能包含小写字母、数字和连字符' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="如:beijing-hospital"
|
||||
disabled={!isNew}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="租户名称"
|
||||
rules={[{ required: true, message: '请输入租户名称' }]}
|
||||
>
|
||||
<Input placeholder="如:北京协和医院" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="租户类型"
|
||||
rules={[{ required: true, message: '请选择租户类型' }]}
|
||||
>
|
||||
<Select placeholder="选择类型" disabled={!isNew}>
|
||||
{Object.entries(TENANT_TYPES).map(([key, config]) => (
|
||||
<Option key={key} value={key}>
|
||||
{config.icon} {config.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="contactName" label="联系人">
|
||||
<Input placeholder="联系人姓名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="contactPhone" label="联系电话">
|
||||
<Input placeholder="联系电话" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="contactEmail" label="联系邮箱">
|
||||
<Input placeholder="联系邮箱" type="email" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="expiresAt" label="到期时间">
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
placeholder="留空表示永久有效"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{(isEditing || isNew) && (
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
style={{ backgroundColor: PRIMARY_COLOR, borderColor: PRIMARY_COLOR }}
|
||||
>
|
||||
{isNew ? '创建' : '保存'}
|
||||
</Button>
|
||||
{!isNew && (
|
||||
<Button onClick={() => { setIsEditing(false); loadTenant(); }}>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'modules',
|
||||
label: '模块配置',
|
||||
disabled: isNew,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<span style={{ color: '#666' }}>
|
||||
配置该租户可以访问的功能模块
|
||||
</span>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSaveModules}
|
||||
loading={saving}
|
||||
style={{ backgroundColor: PRIMARY_COLOR, borderColor: PRIMARY_COLOR }}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={moduleColumns}
|
||||
dataSource={moduleConfigs}
|
||||
rowKey="code"
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
|
||||
<p style={{ margin: 0, color: '#666', fontSize: 13 }}>
|
||||
💡 <strong>提示:</strong>关闭模块后,该租户下的用户将无法访问对应功能。
|
||||
模块权限会实时生效,无需重新登录。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: `用户 (${tenant?.userCount || 0})`,
|
||||
disabled: isNew,
|
||||
children: (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||
用户管理功能开发中...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantDetailPage;
|
||||
|
||||
337
frontend-v2/src/pages/admin/tenants/TenantListPage.tsx
Normal file
337
frontend-v2/src/pages/admin/tenants/TenantListPage.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 租户列表页面
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
SettingOutlined,
|
||||
StopOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
BankOutlined,
|
||||
MedicineBoxOutlined,
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
fetchTenantList,
|
||||
updateTenantStatus,
|
||||
deleteTenant,
|
||||
type TenantInfo,
|
||||
type TenantType,
|
||||
type TenantStatus,
|
||||
} from './api/tenantApi';
|
||||
|
||||
const { Search } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
// 运营管理端主色
|
||||
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' },
|
||||
INTERNAL: { label: '内部', icon: <HomeOutlined />, color: 'cyan' },
|
||||
PUBLIC: { label: '公共', icon: <UserOutlined />, color: 'green' },
|
||||
};
|
||||
|
||||
// 租户状态配置
|
||||
const TENANT_STATUS: Record<TenantStatus, { label: string; color: string }> = {
|
||||
ACTIVE: { label: '运营中', color: 'success' },
|
||||
SUSPENDED: { label: '已停用', color: 'error' },
|
||||
EXPIRED: { label: '已过期', color: 'warning' },
|
||||
};
|
||||
|
||||
const TenantListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tenants, setTenants] = useState<TenantInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(20);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<TenantType | ''>('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<TenantStatus | ''>('');
|
||||
|
||||
// 加载租户列表
|
||||
const loadTenants = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchTenantList({
|
||||
type: selectedType || undefined,
|
||||
status: selectedStatus || undefined,
|
||||
search: searchText || undefined,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
setTenants(result.data);
|
||||
setTotal(result.total);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTenants();
|
||||
}, [page, selectedType, selectedStatus]);
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchText(value);
|
||||
setPage(1);
|
||||
loadTenants();
|
||||
};
|
||||
|
||||
// 停用/启用租户
|
||||
const handleToggleStatus = async (tenant: TenantInfo) => {
|
||||
const newStatus: TenantStatus = tenant.status === 'ACTIVE' ? 'SUSPENDED' : 'ACTIVE';
|
||||
try {
|
||||
await updateTenantStatus(tenant.id, newStatus);
|
||||
message.success(newStatus === 'ACTIVE' ? '已启用' : '已停用');
|
||||
loadTenants();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除租户
|
||||
const handleDelete = async (tenant: TenantInfo) => {
|
||||
try {
|
||||
await deleteTenant(tenant.id);
|
||||
message.success('删除成功');
|
||||
loadTenants();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<TenantInfo> = [
|
||||
{
|
||||
title: '租户代码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: 150,
|
||||
render: (code: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontWeight: 500 }}>{code}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '租户名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (type: TenantType) => {
|
||||
const config = TENANT_TYPES[type];
|
||||
return (
|
||||
<Tag icon={config.icon} color={config.color}>
|
||||
{config.label}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: TenantStatus) => {
|
||||
const config = TENANT_STATUS[status];
|
||||
return <Tag color={config.color}>{config.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contactName',
|
||||
key: 'contactName',
|
||||
width: 120,
|
||||
render: (name: string | null) => name || '-',
|
||||
},
|
||||
{
|
||||
title: '联系电话',
|
||||
dataIndex: 'contactPhone',
|
||||
key: 'contactPhone',
|
||||
width: 140,
|
||||
render: (phone: string | null) => phone || '-',
|
||||
},
|
||||
{
|
||||
title: '到期时间',
|
||||
dataIndex: 'expiresAt',
|
||||
key: 'expiresAt',
|
||||
width: 120,
|
||||
render: (date: string | null) => {
|
||||
if (!date) return <span style={{ color: '#999' }}>永久</span>;
|
||||
const d = new Date(date);
|
||||
const isExpired = d < new Date();
|
||||
return (
|
||||
<span style={{ color: isExpired ? '#ff4d4f' : undefined }}>
|
||||
{d.toLocaleDateString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 120,
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/admin/tenants/${record.id}`)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="模块配置">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => navigate(`/admin/tenants/${record.id}?tab=modules`)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={record.status === 'ACTIVE' ? '停用' : '启用'}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={record.status === 'ACTIVE' ? <StopOutlined /> : <CheckCircleOutlined />}
|
||||
onClick={() => handleToggleStatus(record)}
|
||||
style={{ color: record.status === 'ACTIVE' ? '#ff4d4f' : PRIMARY_COLOR }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定要删除这个租户吗?"
|
||||
description="删除后无法恢复"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<BankOutlined style={{ color: PRIMARY_COLOR }} />
|
||||
<span>租户管理</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/admin/tenants/new')}
|
||||
style={{ backgroundColor: PRIMARY_COLOR, borderColor: PRIMARY_COLOR }}
|
||||
>
|
||||
新建租户
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* 筛选栏 */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Search
|
||||
placeholder="搜索租户名称或代码..."
|
||||
allowClear
|
||||
style={{ width: 250 }}
|
||||
prefix={<SearchOutlined />}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="租户类型"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={selectedType || undefined}
|
||||
onChange={(v) => { setSelectedType(v || ''); setPage(1); }}
|
||||
>
|
||||
{Object.entries(TENANT_TYPES).map(([key, config]) => (
|
||||
<Option key={key} value={key}>
|
||||
{config.icon} {config.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={selectedStatus || undefined}
|
||||
onChange={(v) => { setSelectedStatus(v || ''); setPage(1); }}
|
||||
>
|
||||
{Object.entries(TENANT_STATUS).map(([key, config]) => (
|
||||
<Option key={key} value={key}>
|
||||
{config.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tenants}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: limit,
|
||||
total,
|
||||
showTotal: (t) => `共 ${t} 个租户`,
|
||||
onChange: (p) => setPage(p),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantListPage;
|
||||
|
||||
246
frontend-v2/src/pages/admin/tenants/api/tenantApi.ts
Normal file
246
frontend-v2/src/pages/admin/tenants/api/tenantApi.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 租户管理 API
|
||||
*/
|
||||
|
||||
import { getAccessToken } from '../../../../framework/auth/api';
|
||||
|
||||
const API_BASE = '/api/admin/tenants';
|
||||
const MODULES_API = '/api/admin/modules';
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'INTERNAL' | 'PUBLIC';
|
||||
export type TenantStatus = 'ACTIVE' | 'SUSPENDED' | 'EXPIRED';
|
||||
|
||||
export interface TenantInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: TenantType;
|
||||
status: TenantStatus;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
expiresAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TenantModuleConfig {
|
||||
code: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface TenantDetail extends TenantInfo {
|
||||
modules: TenantModuleConfig[];
|
||||
userCount: number;
|
||||
}
|
||||
|
||||
export interface ModuleInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateTenantRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
type: TenantType;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
expiresAt?: string;
|
||||
modules?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateTenantRequest {
|
||||
name?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface TenantListResponse {
|
||||
success: boolean;
|
||||
data: TenantInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ==================== API 函数 ====================
|
||||
|
||||
/**
|
||||
* 获取租户列表
|
||||
*/
|
||||
export async function fetchTenantList(params?: {
|
||||
type?: TenantType;
|
||||
status?: TenantStatus;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<TenantListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.type) searchParams.set('type', params.type);
|
||||
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 url = `${API_BASE}?${searchParams.toString()}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '获取租户列表失败');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户详情
|
||||
*/
|
||||
export async function fetchTenantDetail(id: string): Promise<TenantDetail> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
method: 'GET',
|
||||
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 createTenant(data: CreateTenantRequest): Promise<TenantInfo> {
|
||||
const response = await fetch(API_BASE, {
|
||||
method: 'POST',
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新租户信息
|
||||
*/
|
||||
export async function updateTenant(id: string, data: UpdateTenantRequest): Promise<TenantInfo> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新租户状态
|
||||
*/
|
||||
export async function updateTenantStatus(id: string, status: TenantStatus): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '更新租户状态失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除租户
|
||||
*/
|
||||
export async function deleteTenant(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '删除租户失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置租户模块
|
||||
*/
|
||||
export async function configureModules(
|
||||
tenantId: string,
|
||||
modules: { code: string; enabled: boolean; expiresAt?: string | null }[]
|
||||
): Promise<TenantModuleConfig[]> {
|
||||
const response = await fetch(`${API_BASE}/${tenantId}/modules`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ modules }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '配置租户模块失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用模块列表
|
||||
*/
|
||||
export async function fetchModuleList(): Promise<ModuleInfo[]> {
|
||||
const response = await fetch(MODULES_API, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
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