feat(admin): Complete Phase 3.5.1-3.5.4 Prompt Management System (83%)

Summary:
- Implement Prompt management infrastructure and core services
- Build admin portal frontend with light theme
- Integrate CodeMirror 6 editor for non-technical users

Phase 3.5.1: Infrastructure Setup
- Create capability_schema for Prompt storage
- Add prompt_templates and prompt_versions tables
- Add prompt:view/edit/debug/publish permissions
- Migrate RVW prompts to database (RVW_EDITORIAL, RVW_METHODOLOGY)

Phase 3.5.2: PromptService Core
- Implement gray preview logic (DRAFT for debuggers, ACTIVE for users)
- Module-level debug control (setDebugMode)
- Handlebars template rendering
- Variable extraction and validation (extractVariables, validateVariables)
- Three-level disaster recovery (database -> cache -> hardcoded fallback)

Phase 3.5.3: Management API
- 8 RESTful endpoints (/api/admin/prompts/*)
- Permission control (PROMPT_ENGINEER can edit, SUPER_ADMIN can publish)

Phase 3.5.4: Frontend Management UI
- Build admin portal architecture (AdminLayout, OrgLayout)
- Add route system (/admin/*, /org/*)
- Implement PromptListPage (filter, search, debug switch)
- Implement PromptEditor (CodeMirror 6 simplified for clinical users)
- Implement PromptEditorPage (edit, save, publish, test, version history)

Technical Details:
- Backend: 6 files, ~2044 lines (prompt.service.ts 596 lines)
- Frontend: 9 files, ~1735 lines (PromptEditorPage.tsx 399 lines)
- CodeMirror 6: Line numbers, auto-wrap, variable highlight, search, undo/redo
- Chinese-friendly: 15px font, 1.8 line-height, system fonts

Next Step: Phase 3.5.5 - Integrate RVW module with PromptService

Tested: Backend API tests passed (8/8), Frontend pending user testing
Status: Ready for Phase 3.5.5 RVW integration
This commit is contained in:
2026-01-11 21:25:16 +08:00
parent cdfbc9927a
commit 5523ef36ea
297 changed files with 15914 additions and 1266 deletions

View File

@@ -0,0 +1,206 @@
/**
* 认证上下文
*
* 提供全局认证状态管理
*/
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import type { AuthContextType, AuthUser, UserRole, ChangePasswordRequest } from './types';
import * as authApi from './api';
// 创建上下文
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Provider Props
interface AuthProviderProps {
children: ReactNode;
}
/**
* 认证Provider
*/
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 初始化:检查本地存储的用户信息
useEffect(() => {
const initAuth = async () => {
try {
const savedUser = authApi.getSavedUser();
const token = authApi.getAccessToken();
if (savedUser && token) {
// 检查Token是否过期
if (authApi.isTokenExpired()) {
// 尝试刷新Token
try {
await authApi.refreshAccessToken();
const freshUser = await authApi.getCurrentUser();
setUser(freshUser);
} catch {
// 刷新失败,清除状态
authApi.clearTokens();
setUser(null);
}
} else {
setUser(savedUser);
}
}
} catch (err) {
console.error('Auth init error:', err);
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
/**
* 密码登录
*/
const loginWithPassword = useCallback(async (phone: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const result = await authApi.loginWithPassword({ phone, password });
setUser(result.user);
} catch (err) {
const message = err instanceof Error ? err.message : '登录失败';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 验证码登录
*/
const loginWithCode = useCallback(async (phone: string, code: string) => {
setIsLoading(true);
setError(null);
try {
const result = await authApi.loginWithCode({ phone, code });
setUser(result.user);
} catch (err) {
const message = err instanceof Error ? err.message : '登录失败';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 发送验证码
*/
const sendVerificationCode = useCallback(async (
phone: string,
type: 'LOGIN' | 'RESET_PASSWORD' = 'LOGIN'
) => {
setError(null);
try {
await authApi.sendVerificationCode(phone, type);
} catch (err) {
const message = err instanceof Error ? err.message : '发送验证码失败';
setError(message);
throw err;
}
}, []);
/**
* 登出
*/
const logout = useCallback(() => {
authApi.logout();
setUser(null);
setError(null);
}, []);
/**
* 修改密码
*/
const changePassword = useCallback(async (request: ChangePasswordRequest) => {
setError(null);
try {
await authApi.changePassword(request);
// 修改成功后更新用户状态
if (user) {
setUser({ ...user, isDefaultPassword: false });
authApi.saveUser({ ...user, isDefaultPassword: false });
}
} catch (err) {
const message = err instanceof Error ? err.message : '修改密码失败';
setError(message);
throw err;
}
}, [user]);
/**
* 刷新Token
*/
const refreshToken = useCallback(async () => {
try {
await authApi.refreshAccessToken();
} catch (err) {
logout();
throw err;
}
}, [logout]);
/**
* 检查权限
*/
const hasPermission = useCallback((permission: string): boolean => {
if (!user) return false;
// SUPER_ADMIN拥有所有权限
if (user.role === 'SUPER_ADMIN') return true;
return user.permissions.includes(permission);
}, [user]);
/**
* 检查角色
*/
const hasRole = useCallback((...roles: UserRole[]): boolean => {
if (!user) return false;
return roles.includes(user.role);
}, [user]);
const value: AuthContextType = {
user,
isAuthenticated: !!user,
isLoading,
error,
loginWithPassword,
loginWithCode,
sendVerificationCode,
logout,
changePassword,
refreshToken,
hasPermission,
hasRole,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
/**
* 使用认证上下文的Hook
*/
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export { AuthContext };

View File

@@ -0,0 +1,242 @@
/**
* 认证API模块
*/
import type {
ApiResponse,
LoginResponse,
AuthUser,
TokenInfo,
PasswordLoginRequest,
CodeLoginRequest,
ChangePasswordRequest,
} from './types';
// API基础URL
const API_BASE = '/api/v1/auth';
/**
* 存储Token到localStorage
*/
export function saveTokens(tokens: TokenInfo): void {
localStorage.setItem('accessToken', tokens.accessToken);
localStorage.setItem('refreshToken', tokens.refreshToken);
localStorage.setItem('tokenExpiresAt', String(Date.now() + tokens.expiresIn * 1000));
}
/**
* 从localStorage获取Token
*/
export function getAccessToken(): string | null {
return localStorage.getItem('accessToken');
}
export function getRefreshToken(): string | null {
return localStorage.getItem('refreshToken');
}
/**
* 清除Token
*/
export function clearTokens(): void {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('tokenExpiresAt');
localStorage.removeItem('user');
}
/**
* 存储用户信息
*/
export function saveUser(user: AuthUser): void {
localStorage.setItem('user', JSON.stringify(user));
}
/**
* 获取存储的用户信息
*/
export function getSavedUser(): AuthUser | null {
const userStr = localStorage.getItem('user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
}
/**
* 检查Token是否过期
*/
export function isTokenExpired(): boolean {
const expiresAt = localStorage.getItem('tokenExpiresAt');
if (!expiresAt) return true;
return Date.now() > Number(expiresAt) - 60000; // 提前1分钟判断为过期
}
/**
* 创建带认证的fetch
*/
async function authFetch<T>(
url: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const token = getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
headers,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || '请求失败');
}
return data;
}
/**
* 密码登录
*/
export async function loginWithPassword(request: PasswordLoginRequest): Promise<LoginResponse> {
const response = await authFetch<LoginResponse>(`${API_BASE}/login/password`, {
method: 'POST',
body: JSON.stringify(request),
});
if (!response.success || !response.data) {
throw new Error(response.message || '登录失败');
}
// 保存Token和用户信息
saveTokens(response.data.tokens);
saveUser(response.data.user);
return response.data;
}
/**
* 验证码登录
*/
export async function loginWithCode(request: CodeLoginRequest): Promise<LoginResponse> {
const response = await authFetch<LoginResponse>(`${API_BASE}/login/code`, {
method: 'POST',
body: JSON.stringify(request),
});
if (!response.success || !response.data) {
throw new Error(response.message || '登录失败');
}
// 保存Token和用户信息
saveTokens(response.data.tokens);
saveUser(response.data.user);
return response.data;
}
/**
* 发送验证码
*/
export async function sendVerificationCode(
phone: string,
type: 'LOGIN' | 'RESET_PASSWORD' = 'LOGIN'
): Promise<{ expiresIn: number }> {
const response = await authFetch<{ message: string; expiresIn: number }>(
`${API_BASE}/verification-code`,
{
method: 'POST',
body: JSON.stringify({ phone, type }),
}
);
if (!response.success || !response.data) {
throw new Error(response.message || '发送失败');
}
return { expiresIn: response.data.expiresIn };
}
/**
* 获取当前用户信息
*/
export async function getCurrentUser(): Promise<AuthUser> {
const response = await authFetch<AuthUser>(`${API_BASE}/me`);
if (!response.success || !response.data) {
throw new Error(response.message || '获取用户信息失败');
}
// 更新本地存储
saveUser(response.data);
return response.data;
}
/**
* 修改密码
*/
export async function changePassword(request: ChangePasswordRequest): Promise<void> {
const response = await authFetch<{ message: string }>(`${API_BASE}/change-password`, {
method: 'POST',
body: JSON.stringify(request),
});
if (!response.success) {
throw new Error(response.message || '修改密码失败');
}
}
/**
* 刷新Token
*/
export async function refreshAccessToken(): Promise<TokenInfo> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('无RefreshToken');
}
const response = await fetch(`${API_BASE}/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
const data = await response.json();
if (!response.ok || !data.success) {
clearTokens();
throw new Error(data.message || '刷新Token失败');
}
// 保存新Token
saveTokens(data.data);
return data.data;
}
/**
* 登出
*/
export async function logout(): Promise<void> {
try {
await authFetch(`${API_BASE}/logout`, { method: 'POST' });
} catch {
// 忽略登出API错误
} finally {
clearTokens();
}
}

View File

@@ -0,0 +1,8 @@
/**
* 认证模块导出
*/
export { AuthProvider, useAuth, AuthContext } from './AuthContext';
export * from './types';
export * from './api';

View File

@@ -0,0 +1,101 @@
/**
* 认证模块类型定义
*/
/** 用户角色 */
export type UserRole =
| 'SUPER_ADMIN'
| 'PROMPT_ENGINEER'
| 'HOSPITAL_ADMIN'
| 'PHARMA_ADMIN'
| 'DEPARTMENT_ADMIN'
| 'USER';
/** 租户类型 */
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'INTERNAL' | 'PUBLIC';
/** 用户信息 */
export interface AuthUser {
id: string;
phone: string;
name: string;
email?: string | null;
role: UserRole;
tenantId: string;
tenantCode?: string;
tenantName?: string;
departmentId?: string | null;
departmentName?: string | null;
isDefaultPassword: boolean;
permissions: string[];
}
/** Token信息 */
export interface TokenInfo {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
}
/** 登录响应 */
export interface LoginResponse {
user: AuthUser;
tokens: TokenInfo;
}
/** 密码登录请求 */
export interface PasswordLoginRequest {
phone: string;
password: string;
}
/** 验证码登录请求 */
export interface CodeLoginRequest {
phone: string;
code: string;
}
/** 修改密码请求 */
export interface ChangePasswordRequest {
oldPassword?: string;
newPassword: string;
confirmPassword: string;
}
/** API响应 */
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
/** 认证状态 */
export interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
/** 认证上下文类型 */
export interface AuthContextType extends AuthState {
/** 密码登录 */
loginWithPassword: (phone: string, password: string) => Promise<void>;
/** 验证码登录 */
loginWithCode: (phone: string, code: string) => Promise<void>;
/** 发送验证码 */
sendVerificationCode: (phone: string, type?: 'LOGIN' | 'RESET_PASSWORD') => Promise<void>;
/** 登出 */
logout: () => void;
/** 修改密码 */
changePassword: (request: ChangePasswordRequest) => Promise<void>;
/** 刷新Token */
refreshToken: () => Promise<void>;
/** 检查权限 */
hasPermission: (permission: string) => boolean;
/** 检查角色 */
hasRole: (...roles: UserRole[]) => boolean;
}

View File

@@ -0,0 +1,236 @@
import { Suspense, useState } from 'react'
import { Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { Spin, Layout, Menu, Avatar, Dropdown, Badge } from 'antd'
import {
DashboardOutlined,
CodeOutlined,
TeamOutlined,
SettingOutlined,
UserOutlined,
LogoutOutlined,
SwapOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
BellOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { useAuth } from '../auth'
import ErrorBoundary from '../modules/ErrorBoundary'
const { Header, Sider, Content } = Layout
// 运营管理端主色:翠绿
const PRIMARY_COLOR = '#10b981'
/**
* 运营管理端布局方案A全浅色
*
* @description
* - 白色侧边栏 + 翠绿强调色
* - 浅灰内容区,信息清晰
* - 权限检查SUPER_ADMIN / PROMPT_ENGINEER
*/
const AdminLayout = () => {
const { isAuthenticated, isLoading, user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false)
// 加载中
if (isLoading) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<Spin size="large" tip="加载中..." />
</div>
)
}
// 未登录
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
// 权限检查:只有 SUPER_ADMIN 和 PROMPT_ENGINEER 可访问
const allowedRoles = ['SUPER_ADMIN', 'PROMPT_ENGINEER']
if (!allowedRoles.includes(user?.role || '')) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-6xl mb-4">🚫</div>
<h2 className="text-xl mb-2 text-gray-800">访</h2>
<p className="text-gray-500 mb-4"> SUPER_ADMIN PROMPT_ENGINEER </p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 text-white rounded hover:opacity-90"
style={{ background: PRIMARY_COLOR }}
>
</button>
</div>
</div>
)
}
// 侧边栏菜单
const menuItems: MenuProps['items'] = [
{
key: '/admin/dashboard',
icon: <DashboardOutlined />,
label: '运营概览',
},
{
key: '/admin/prompts',
icon: <CodeOutlined />,
label: 'Prompt管理',
},
{
key: '/admin/tenants',
icon: <TeamOutlined />,
label: '租户管理',
},
{
key: '/admin/users',
icon: <UserOutlined />,
label: '用户管理',
},
{
key: '/admin/system',
icon: <SettingOutlined />,
label: '系统配置',
},
]
// 用户下拉菜单
const userMenuItems: MenuProps['items'] = [
{
key: 'switch-app',
icon: <SwapOutlined />,
label: '切换到业务端',
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
},
]
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
logout()
navigate('/login')
} else if (key === 'switch-app') {
navigate('/')
}
}
const handleMenuClick = ({ key }: { key: string }) => {
navigate(key)
}
// 获取当前选中的菜单项
const selectedKey = menuItems.find(item =>
location.pathname.startsWith(item?.key as string)
)?.key as string || '/admin/dashboard'
return (
<Layout className="h-screen">
{/* 侧边栏 - 白色 */}
<Sider
trigger={null}
collapsible
collapsed={collapsed}
style={{ background: '#fff' }}
width={220}
className="shadow-sm"
>
{/* Logo */}
<div
className="h-16 flex items-center justify-center cursor-pointer border-b border-gray-100"
onClick={() => navigate('/admin/dashboard')}
>
<span className="text-2xl"></span>
{!collapsed && (
<span className="ml-2 font-bold" style={{ color: PRIMARY_COLOR }}>
</span>
)}
</div>
{/* 菜单 */}
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={handleMenuClick}
style={{
borderRight: 'none',
}}
/>
</Sider>
<Layout>
{/* 顶部栏 - 白色 */}
<Header
className="flex items-center justify-between shadow-sm"
style={{ background: '#fff', padding: '0 24px' }}
>
{/* 折叠按钮 */}
<button
onClick={() => setCollapsed(!collapsed)}
className="text-gray-500 hover:text-gray-700 text-xl"
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
{/* 右侧工具栏 */}
<div className="flex items-center gap-4">
{/* 通知 */}
<Badge count={0} size="small">
<BellOutlined className="text-gray-500 hover:text-gray-700 text-lg cursor-pointer" />
</Badge>
{/* 用户 */}
<Dropdown
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
placement="bottomRight"
>
<div className="flex items-center gap-2 cursor-pointer px-3 py-1 rounded hover:bg-gray-50">
<Avatar
size="small"
icon={<UserOutlined />}
style={{ background: PRIMARY_COLOR }}
/>
<div className="flex flex-col">
<span className="text-gray-700 text-sm">{user?.name || '管理员'}</span>
<span className="text-xs" style={{ color: PRIMARY_COLOR }}>{user?.role}</span>
</div>
</div>
</Dropdown>
</div>
</Header>
{/* 主内容区 - 浅灰 */}
<Content
className="overflow-auto p-6"
style={{ background: '#f5f5f5' }}
>
<ErrorBoundary moduleName="运营管理">
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Spin size="large" />
</div>
}
>
<Outlet />
</Suspense>
</ErrorBoundary>
</Content>
</Layout>
</Layout>
)
}
export default AdminLayout

View File

@@ -1,27 +1,44 @@
import { Suspense } from 'react'
import { Outlet } from 'react-router-dom'
import { Outlet, Navigate, useLocation } from 'react-router-dom'
import { Spin } from 'antd'
import TopNavigation from './TopNavigation'
import ErrorBoundary from '../modules/ErrorBoundary'
import { useAuth } from '../auth'
/**
* 主布局组件
*
* @description
* - 认证检查:未登录重定向到登录页
* - 顶部导航栏
* - 错误边界保护 ⭐ Week 2 Day 7 新增
* - 错误边界保护
* - 懒加载支持Suspense
* - 主内容区Outlet
*
* @version Week 2 Day 7 - 任务17集成错误边界
*/
const MainLayout = () => {
const { isAuthenticated, isLoading } = useAuth()
const location = useLocation()
// 加载中:显示全屏加载
if (isLoading) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<Spin size="large" tip="加载中..." />
</div>
)
}
// 未登录:重定向到登录页
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return (
<div className="h-screen flex flex-col overflow-hidden bg-gray-50">
{/* 顶部导航 */}
<TopNavigation />
{/* 主内容区 - 添加错误边界保护 */}
{/* 主内容区 - 添加错误边界保护 */}
<div className="flex-1 flex flex-col overflow-hidden">
<ErrorBoundary moduleName="主应用">
<Suspense

View File

@@ -0,0 +1,259 @@
import { Suspense, useState } from 'react'
import { Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { Spin, Layout, Menu, Avatar, Dropdown, Badge } from 'antd'
import {
DashboardOutlined,
TeamOutlined,
ApartmentOutlined,
BarChartOutlined,
AuditOutlined,
UserOutlined,
LogoutOutlined,
SwapOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
BellOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { useAuth } from '../auth'
import ErrorBoundary from '../modules/ErrorBoundary'
const { Header, Sider, Content } = Layout
// 机构管理端主色:深蓝
const PRIMARY_COLOR = '#003a8c'
/**
* 机构管理端布局方案A全浅色
*
* @description
* - 白色侧边栏 + 深蓝强调色
* - 浅灰内容区,信息清晰
* - 权限检查HOSPITAL_ADMIN / PHARMA_ADMIN / DEPARTMENT_ADMIN
*/
const OrgLayout = () => {
const { isAuthenticated, isLoading, user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false)
// 加载中
if (isLoading) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<Spin size="large" tip="加载中..." />
</div>
)
}
// 未登录
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
// 权限检查:机构管理员
const allowedRoles = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN']
if (!allowedRoles.includes(user?.role || '')) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-6xl mb-4">🚫</div>
<h2 className="text-xl mb-2 text-gray-800">访</h2>
<p className="text-gray-500 mb-4"></p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 text-white rounded hover:opacity-90"
style={{ background: PRIMARY_COLOR }}
>
</button>
</div>
</div>
)
}
// 获取机构类型
const isHospital = user?.role === 'HOSPITAL_ADMIN' || user?.role === 'DEPARTMENT_ADMIN'
// 侧边栏菜单
const menuItems: MenuProps['items'] = [
{
key: '/org/dashboard',
icon: <DashboardOutlined />,
label: '管理概览',
},
{
key: '/org/users',
icon: <TeamOutlined />,
label: '用户管理',
},
{
key: '/org/departments',
icon: <ApartmentOutlined />,
label: isHospital ? '科室管理' : '部门管理',
},
{
key: '/org/usage',
icon: <BarChartOutlined />,
label: '使用统计',
},
{
key: '/org/audit',
icon: <AuditOutlined />,
label: '审计日志',
},
]
// 用户下拉菜单
const userMenuItems: MenuProps['items'] = [
{
key: 'switch-app',
icon: <SwapOutlined />,
label: '切换到业务端',
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
},
]
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
logout()
navigate('/login')
} else if (key === 'switch-app') {
navigate('/')
}
}
const handleMenuClick = ({ key }: { key: string }) => {
navigate(key)
}
// 获取当前选中的菜单项
const selectedKey = menuItems.find(item =>
location.pathname.startsWith(item?.key as string)
)?.key as string || '/org/dashboard'
// 获取机构名称
const orgName = (user as any)?.tenant?.name || (isHospital ? '医院管理中心' : '企业管理中心')
// 角色显示名称映射
const roleMap: Record<string, string> = {
'HOSPITAL_ADMIN': '医院管理员',
'PHARMA_ADMIN': '企业管理员',
'DEPARTMENT_ADMIN': '科室主任',
'SUPER_ADMIN': '超级管理员',
'PROMPT_ENGINEER': 'Prompt工程师',
'USER': '普通用户',
}
const roleDisplayName = roleMap[user?.role || ''] || user?.role
return (
<Layout className="h-screen">
{/* 侧边栏 - 白色 */}
<Sider
trigger={null}
collapsible
collapsed={collapsed}
style={{ background: '#fff' }}
width={220}
className="shadow-sm"
>
{/* Logo */}
<div
className="h-16 flex items-center justify-center cursor-pointer border-b border-gray-100"
onClick={() => navigate('/org/dashboard')}
>
<span className="text-2xl">{isHospital ? '🏥' : '🏢'}</span>
{!collapsed && (
<span
className="ml-2 font-bold text-sm truncate max-w-[140px]"
style={{ color: PRIMARY_COLOR }}
>
{orgName}
</span>
)}
</div>
{/* 菜单 */}
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={handleMenuClick}
style={{
borderRight: 'none',
}}
/>
</Sider>
<Layout>
{/* 顶部栏 - 白色 */}
<Header
className="flex items-center justify-between shadow-sm"
style={{ background: '#fff', padding: '0 24px' }}
>
{/* 折叠按钮 */}
<button
onClick={() => setCollapsed(!collapsed)}
className="text-gray-500 hover:text-gray-700 text-xl"
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
{/* 右侧工具栏 */}
<div className="flex items-center gap-4">
{/* 通知 */}
<Badge count={0} size="small">
<BellOutlined className="text-gray-500 hover:text-gray-700 text-lg cursor-pointer" />
</Badge>
{/* 用户 */}
<Dropdown
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
placement="bottomRight"
>
<div className="flex items-center gap-2 cursor-pointer px-3 py-1 rounded hover:bg-gray-50">
<Avatar
size="small"
icon={<UserOutlined />}
style={{ background: PRIMARY_COLOR }}
/>
<div className="flex flex-col">
<span className="text-gray-700 text-sm">{user?.name || '管理员'}</span>
<span className="text-xs" style={{ color: PRIMARY_COLOR }}>
{roleDisplayName}
</span>
</div>
</div>
</Dropdown>
</div>
</Header>
{/* 主内容区 - 浅灰 */}
<Content
className="overflow-auto p-6"
style={{ background: '#f5f5f5' }}
>
<ErrorBoundary moduleName="机构管理">
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Spin size="large" />
</div>
}
>
<Outlet />
</Suspense>
</ErrorBoundary>
</Content>
</Layout>
</Layout>
)
}
export default OrgLayout

View File

@@ -1,9 +1,17 @@
import { useNavigate, useLocation } from 'react-router-dom'
import { Dropdown, Avatar, Tooltip } from 'antd'
import { UserOutlined, LogoutOutlined, SettingOutlined, LockOutlined } from '@ant-design/icons'
import {
UserOutlined,
LogoutOutlined,
SettingOutlined,
LockOutlined,
ControlOutlined,
BankOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { getAvailableModules } from '../modules/moduleRegistry'
import { usePermission } from '../permission'
import { useAuth } from '../auth'
/**
* 顶部导航栏组件
@@ -18,6 +26,7 @@ import { usePermission } from '../permission'
const TopNavigation = () => {
const navigate = useNavigate()
const location = useLocation()
const { user: authUser, logout: authLogout } = useAuth()
const { user, checkModulePermission, logout } = usePermission()
// 获取用户有权访问的模块列表(权限过滤)⭐ 新增
@@ -28,7 +37,12 @@ const TopNavigation = () => {
location.pathname.startsWith(module.path)
)
// 用户菜单
// 检查用户权限,决定显示哪些切换入口
const userRole = authUser?.role || ''
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER'].includes(userRole)
const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole)
// 用户菜单 - 动态构建
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
@@ -40,6 +54,18 @@ const TopNavigation = () => {
icon: <SettingOutlined />,
label: '设置',
},
// 切换入口 - 根据权限显示
...(canAccessOrg || canAccessAdmin ? [{ type: 'divider' as const }] : []),
...(canAccessOrg ? [{
key: 'switch-org',
icon: <BankOutlined />,
label: '切换到机构管理',
}] : []),
...(canAccessAdmin ? [{
key: 'switch-admin',
icon: <ControlOutlined />,
label: '切换到运营管理',
}] : []),
{
type: 'divider',
},
@@ -54,8 +80,13 @@ const TopNavigation = () => {
// 处理用户菜单点击
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
authLogout()
logout()
navigate('/')
navigate('/login')
} else if (key === 'switch-admin') {
navigate('/admin/dashboard')
} else if (key === 'switch-org') {
navigate('/org/dashboard')
} else {
navigate(`/user/${key}`)
}

View File

@@ -1,34 +1,15 @@
import { createContext, useState, useCallback, ReactNode } from 'react'
import { createContext, useCallback, ReactNode, useMemo } from 'react'
import { useAuth } from '../auth'
import { UserInfo, PermissionContextType, checkVersionLevel, UserVersion } from './types'
/**
* 权限上下文
*
* @description 提供全局权限状态管理
* @version Week 2 Day 7 - 任务17
*
* 注意当前阶段Week 2用户信息为硬编码方便开发测试
* 后续计划Week 2 Day 8-9 对接后端JWT认证
* 已对接 AuthContext从 JWT 认证中获取用户信息
*/
/**
* 模拟用户数据(开发阶段使用)
*
* 🔧 开发说明:
* - 当前硬编码为 premium 权限,可以访问所有模块
* - 方便开发和测试所有功能
* - 后续将从后端 JWT token 中解析真实用户信息
*/
const MOCK_USER: UserInfo = {
id: 'test-user-001',
name: '测试研究员',
email: 'test@example.com',
version: 'premium', // 👈 硬编码为最高权限
avatar: null,
isTrial: false,
trialEndsAt: null,
}
/**
* 创建权限上下文
*/
@@ -42,23 +23,41 @@ interface PermissionProviderProps {
}
export const PermissionProvider = ({ children }: PermissionProviderProps) => {
// 当前用户状态(开发阶段使用模拟数据)
const [user, setUser] = useState<UserInfo | null>(MOCK_USER)
const { user: authUser, isAuthenticated, logout: authLogout } = useAuth()
// 将 AuthUser 转换为 UserInfo兼容原有权限系统
const user: UserInfo | null = useMemo(() => {
if (!authUser) return null
// 根据角色映射到版本等级
// SUPER_ADMIN, PROMPT_ENGINEER → premium
// HOSPITAL_ADMIN, PHARMA_ADMIN → advanced
// USER → professional
let version: UserVersion = 'professional'
if (authUser.role === 'SUPER_ADMIN' || authUser.role === 'PROMPT_ENGINEER') {
version = 'premium'
} else if (authUser.role === 'HOSPITAL_ADMIN' || authUser.role === 'PHARMA_ADMIN' || authUser.role === 'DEPARTMENT_ADMIN') {
version = 'advanced'
}
return {
id: authUser.id,
name: authUser.name,
email: authUser.email || null,
version,
avatar: null,
isTrial: false,
trialEndsAt: null,
}
}, [authUser])
/**
* 检查模块权限
* @param requiredVersion 所需权限等级
* @returns 是否有权限访问
*/
const checkModulePermission = useCallback(
(requiredVersion?: UserVersion): boolean => {
// 未登录用户无权限
if (!user) return false
// 没有权限要求,允许访问
if (!requiredVersion) return true
// 检查权限等级
return checkVersionLevel(user.version, requiredVersion)
},
[user]
@@ -66,19 +65,12 @@ export const PermissionProvider = ({ children }: PermissionProviderProps) => {
/**
* 检查功能权限
* @param feature 功能标识
* @returns 是否有权限使用该功能
*
* TODO: 后续可以基于功能列表进行更细粒度的权限控制
*/
const checkFeaturePermission = useCallback(
(feature: string): boolean => {
if (!user) return false
// 当前简化实现premium用户拥有所有功能
if (user.version === 'premium') return true
// 后续可以扩展为基于功能配置表的权限检查
// 后续可基于功能配置表进行更细粒度的权限控制
console.log('Feature permission check:', feature)
return true
},
@@ -86,17 +78,22 @@ export const PermissionProvider = ({ children }: PermissionProviderProps) => {
)
/**
* 退出登录
* 退出登录委托给AuthContext
*/
const logout = useCallback(() => {
setUser(null)
// TODO: 清除后端session/token
console.log('User logged out')
authLogout()
}, [authLogout])
/**
* setUser兼容性保留实际不使用
*/
const setUser = useCallback(() => {
console.warn('setUser is deprecated, use AuthContext instead')
}, [])
const value: PermissionContextType = {
user,
isAuthenticated: !!user,
isAuthenticated,
checkModulePermission,
checkFeaturePermission,
setUser,

View File

@@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../auth'
import { usePermission } from '../permission'
import PermissionDenied from './PermissionDenied'
import type { UserVersion } from '../permission'
@@ -9,28 +10,11 @@ import type { UserVersion } from '../permission'
*
* @description
* 保护需要特定权限的路由防止用户通过URL直接访问无权限页面
* 这是权限控制的"第二道防线"第一道是TopNavigation的过滤
*
* @version Week 2 Day 7 - 任务17
*
* @example
* ```tsx
* <Route
* path="/literature/*"
* element={
* <RouteGuard requiredVersion="advanced" moduleName="AI智能文献">
* <ASLModule />
* </RouteGuard>
* }
* />
* ```
*
* 工作原理:
* 1. 用户访问 /literature 路由
* 2. RouteGuard 检查用户权限
* 3. 如果有权限 → 渲染子组件
* 4. 如果无权限 → 显示 PermissionDenied 页面
* 5. 如果未登录 → 重定向到登录页(后续实现)
* 检查顺序:
* 1. 检查是否已登录(未登录→重定向到登录页)
* 2. 检查权限等级无权限→显示PermissionDenied
* 3. 有权限→渲染子组件
*/
interface RouteGuardProps {
@@ -50,21 +34,25 @@ const RouteGuard = ({
moduleName,
redirectToHome = false,
}: RouteGuardProps) => {
const { user, isAuthenticated, checkModulePermission } = usePermission()
const location = useLocation()
const { isAuthenticated, isLoading } = useAuth()
const { user, checkModulePermission } = usePermission()
// 1. 检查是否登录(后续实现真实认证
// 加载中显示空白或可以显示loading
if (isLoading) {
return null
}
// 1. 检查是否登录
if (!isAuthenticated) {
// TODO: 后续实现真实的登录流程
// 当前阶段用户默认已登录MOCK_USER
console.warn('用户未登录,应该重定向到登录页')
// return <Navigate to="/login" replace />
// 保存当前路径,登录后跳转回来
return <Navigate to="/login" state={{ from: location }} replace />
}
// 2. 检查权限等级
const hasPermission = checkModulePermission(requiredVersion)
if (!hasPermission) {
// 记录无权限访问尝试(用于后续分析和转化优化)
console.log('🔒 权限不足:', {
module: moduleName,
requiredVersion,
@@ -73,12 +61,10 @@ const RouteGuard = ({
timestamp: new Date().toISOString(),
})
// 如果配置了重定向,直接返回首页
if (redirectToHome) {
return <Navigate to="/" replace />
}
// 显示无权限页面(推荐,引导用户升级)
return (
<PermissionDenied
moduleName={moduleName}