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:
206
frontend-v2/src/framework/auth/AuthContext.tsx
Normal file
206
frontend-v2/src/framework/auth/AuthContext.tsx
Normal 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 };
|
||||
|
||||
242
frontend-v2/src/framework/auth/api.ts
Normal file
242
frontend-v2/src/framework/auth/api.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
8
frontend-v2/src/framework/auth/index.ts
Normal file
8
frontend-v2/src/framework/auth/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 认证模块导出
|
||||
*/
|
||||
|
||||
export { AuthProvider, useAuth, AuthContext } from './AuthContext';
|
||||
export * from './types';
|
||||
export * from './api';
|
||||
|
||||
101
frontend-v2/src/framework/auth/types.ts
Normal file
101
frontend-v2/src/framework/auth/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
236
frontend-v2/src/framework/layout/AdminLayout.tsx
Normal file
236
frontend-v2/src/framework/layout/AdminLayout.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
259
frontend-v2/src/framework/layout/OrgLayout.tsx
Normal file
259
frontend-v2/src/framework/layout/OrgLayout.tsx
Normal 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
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user