feat(web): 实现前端权限系统基础设施

- 添加 permissionStore 管理用户菜单和权限状态
- 实现 usePermission hook 和权限服务
- 配置路由权限映射
- 添加 403 页面和权限相关组件
- Dashboard layout 集成权限检查

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-17 14:05:26 +08:00
parent 15d6e6e29e
commit b23028615b
12 changed files with 491 additions and 1 deletions

View File

@@ -1,16 +1,68 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Header } from '@/components/layout/Header';
import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar';
import { getRoutePermission, isPublicRoute } from '@/config/route-permissions';
import { getUserMenusAndPermissions } from '@/services/permission.service';
import { useAuthStore, usePermissionStore } from '@/stores';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const { token, isHydrated: authHydrated } = useAuthStore();
const { isLoaded, setPermissionData, hasPermission, clearPermissionData } = usePermissionStore();
// 加载用户权限数据
useEffect(() => {
if (!authHydrated) return;
// 未登录时清空权限数据
if (!token) {
clearPermissionData();
return;
}
// 已登录且权限未加载时,加载权限数据
if (!isLoaded) {
getUserMenusAndPermissions()
.then((data) => {
setPermissionData(data);
})
.catch((error) => {
console.error('加载权限数据失败:', error);
// 加载失败时也标记为已加载,避免无限重试
setPermissionData({ menus: [], permissions: [], isSuperAdmin: false });
});
}
}, [authHydrated, token, isLoaded, setPermissionData, clearPermissionData]);
// 路由权限检查
useEffect(() => {
if (!authHydrated || !isLoaded) return;
// 公开路由不检查权限
if (isPublicRoute(pathname)) return;
// 获取路由所需权限
const requiredPermission = getRoutePermission(pathname);
if (!requiredPermission) return;
// 检查权限
if (!hasPermission(requiredPermission)) {
router.replace('/403');
}
}, [authHydrated, isLoaded, pathname, hasPermission, router]);
return (
<div className="min-h-screen flex">
{/* <EFBFBD><EFBFBD>面端侧边栏 */}
{/* 面端侧边栏 */}
<Sidebar />
{/* 移动端侧边栏 */}

View File

@@ -0,0 +1,42 @@
'use client';
import { ShieldX } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
export default function ForbiddenPage() {
const router = useRouter();
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-6 p-8">
<div className="flex justify-center">
<div className="rounded-full bg-destructive/10 p-6">
<ShieldX className="h-16 w-16 text-destructive" />
</div>
</div>
<div className="space-y-2">
<h1 className="text-4xl font-bold tracking-tight">403</h1>
<h2 className="text-xl font-semibold text-muted-foreground">
访
</h2>
<p className="text-muted-foreground max-w-md mx-auto">
访
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="outline" onClick={() => router.back()}>
</Button>
<Button asChild>
<Link href="/dashboard"></Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import { type ReactNode } from 'react';
import { usePermission } from '@/hooks/usePermission';
export interface PermissionGuardProps {
/** 所需权限单个或多个OR 关系) */
permission: string | string[];
/** 有权限时显示的内容 */
children: ReactNode;
/** 无权限时显示的内容(可选) */
fallback?: ReactNode;
/** 权限检查模式any-任一权限, all-所有权限 */
mode?: 'any' | 'all';
}
/**
* 权限守卫组件
* 根据用户权限决定是否渲染子组件
*
* @example
* // 单个权限
* <PermissionGuard permission="user:create">
* <CreateUserButton />
* </PermissionGuard>
*
* @example
* // 多个权限OR 关系)
* <PermissionGuard permission={['user:create', 'user:update']}>
* <UserForm />
* </PermissionGuard>
*
* @example
* // 多个权限AND 关系)
* <PermissionGuard permission={['user:create', 'user:update']} mode="all">
* <UserForm />
* </PermissionGuard>
*
* @example
* // 带 fallback
* <PermissionGuard permission="user:delete" fallback={<span>无权限</span>}>
* <DeleteButton />
* </PermissionGuard>
*/
export function PermissionGuard({
permission,
children,
fallback = null,
mode = 'any',
}: PermissionGuardProps) {
const { hasPermission, hasAllPermissions } = usePermission();
const hasAccess =
mode === 'all' && Array.isArray(permission)
? hasAllPermissions(permission)
: hasPermission(permission);
if (!hasAccess) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,52 @@
'use client';
import { type ComponentType, type ReactNode } from 'react';
import { usePermission } from '@/hooks/usePermission';
export interface WithPermissionOptions {
/** 所需权限单个或多个OR 关系) */
permission: string | string[];
/** 无权限时显示的内容(可选) */
fallback?: ReactNode;
/** 权限检查模式any-任一权限, all-所有权限 */
mode?: 'any' | 'all';
}
/**
* 权限 HOC
* 为组件添加权限检查功能
*
* @example
* const ProtectedButton = withPermission(Button, {
* permission: 'user:delete',
* fallback: <span>无权限</span>,
* });
*/
export function withPermission<P extends object>(
WrappedComponent: ComponentType<P>,
options: WithPermissionOptions
) {
const { permission, fallback = null, mode = 'any' } = options;
function WithPermissionComponent(props: P) {
const { hasPermission, hasAllPermissions } = usePermission();
const hasAccess =
mode === 'all' && Array.isArray(permission)
? hasAllPermissions(permission)
: hasPermission(permission);
if (!hasAccess) {
return <>{fallback}</>;
}
return <WrappedComponent {...props} />;
}
// 设置组件显示名称
const wrappedName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
WithPermissionComponent.displayName = `WithPermission(${wrappedName})`;
return WithPermissionComponent;
}

View File

@@ -0,0 +1,2 @@
export { PermissionGuard, type PermissionGuardProps } from './PermissionGuard';
export { withPermission, type WithPermissionOptions } from './WithPermission';

View File

@@ -5,10 +5,14 @@ export const API_ENDPOINTS = {
REGISTER: '/auth/register',
REFRESH: '/auth/refresh',
ME: '/auth/me',
MENUS: '/auth/menus',
LOGOUT: '/auth/logout',
},
USERS: '/users',
CAPTCHA: '/captcha',
ROLES: '/roles',
PERMISSIONS: '/permissions',
MENUS: '/menus',
} as const;
// 分页默认值
@@ -22,4 +26,5 @@ export const PAGINATION = {
export const STORAGE_KEYS = {
AUTH: 'auth-storage',
UI: 'ui-storage',
PERMISSION: 'permission-storage',
} as const;

View File

@@ -0,0 +1,66 @@
/**
* 路由权限配置
* 定义每个路由所需的权限
*/
export const routePermissions: Record<string, string | string[]> = {
// 用户管理
'/users': 'user:read',
'/users/create': 'user:create',
'/users/[id]': 'user:read',
'/users/[id]/edit': 'user:update',
// 角色管理
'/roles': 'role:read',
'/roles/create': 'role:create',
'/roles/[id]': 'role:read',
'/roles/[id]/edit': 'role:update',
// 菜单管理
'/menus': 'menu:read',
'/menus/create': 'menu:create',
'/menus/[id]': 'menu:read',
'/menus/[id]/edit': 'menu:update',
// 权限管理
'/permissions': 'permission:read',
};
/**
* 公开路由(无需权限检查)
*/
export const publicRoutes = [
'/dashboard',
'/profile',
'/settings',
];
/**
* 检查路由是否需要权限
*/
export function isPublicRoute(pathname: string): boolean {
return publicRoutes.some((route) => {
// 精确匹配或前缀匹配(带斜杠)
return pathname === route || pathname.startsWith(route + '/');
});
}
/**
* 获取路由所需权限
*/
export function getRoutePermission(pathname: string): string | string[] | undefined {
// 精确匹配
if (routePermissions[pathname]) {
return routePermissions[pathname];
}
// 动态路由匹配
for (const [route, permission] of Object.entries(routePermissions)) {
const pattern = route.replace(/\[.*?\]/g, '[^/]+');
const regex = new RegExp(`^${pattern}$`);
if (regex.test(pathname)) {
return permission;
}
}
return undefined;
}

View File

@@ -7,3 +7,26 @@ export {
useRestoreUser,
userKeys,
} from './useUsers';
export { usePermission } from './usePermission';
export {
useRoles,
useRole,
useAllPermissions,
useCreateRole,
useUpdateRole,
useDeleteRole,
roleKeys,
permissionKeys,
} from './useRoles';
export {
useMenus,
useMenuTree,
useMenu,
useCreateMenu,
useUpdateMenu,
useDeleteMenu,
menuKeys,
} from './useMenus';

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import { usePermissionStore } from '@/stores';
/**
* 权限检查 Hook
* 提供便捷的权限检查方法
*/
export function usePermission() {
const { permissions, isSuperAdmin, hasPermission, hasAllPermissions } = usePermissionStore();
/**
* 检查是否拥有指定权限支持单个或多个OR 关系)
*/
const checkPermission = useCallback(
(permission: string | string[]) => {
return hasPermission(permission);
},
[hasPermission]
);
/**
* 检查是否拥有任一指定权限OR 关系)
*/
const hasAnyPermission = useCallback(
(requiredPermissions: string[]) => {
if (isSuperAdmin) return true;
return requiredPermissions.some((p) => permissions.includes(p));
},
[isSuperAdmin, permissions]
);
/**
* 检查是否拥有所有指定权限AND 关系)
*/
const checkAllPermissions = useCallback(
(requiredPermissions: string[]) => {
return hasAllPermissions(requiredPermissions);
},
[hasAllPermissions]
);
return {
/** 当前用户的权限列表 */
permissions,
/** 是否为超级管理员 */
isSuperAdmin,
/** 检查是否拥有指定权限 */
hasPermission: checkPermission,
/** 检查是否拥有任一指定权限 */
hasAnyPermission,
/** 检查是否拥有所有指定权限 */
hasAllPermissions: checkAllPermissions,
};
}

View File

@@ -0,0 +1,11 @@
import type { UserMenusAndPermissionsResponse } from '@seclusion/shared';
import { API_ENDPOINTS } from '@/config/constants';
import { http } from '@/lib/http';
/**
* 获取当前用户的菜单和权限
*/
export async function getUserMenusAndPermissions(): Promise<UserMenusAndPermissionsResponse> {
return http.get<UserMenusAndPermissionsResponse>(API_ENDPOINTS.AUTH.MENUS);
}

View File

@@ -1,3 +1,13 @@
export { useAuthStore, useToken, useUser, useIsAuthenticated, useIsHydrated } from './authStore';
export { useUIStore, useTheme, useSidebarOpen, useSidebarCollapsed } from './uiStore';
export {
usePermissionStore,
useMenus,
usePermissions,
useIsSuperAdmin,
useIsPermissionLoaded,
useHasPermission,
useHasAllPermissions,
} from './permissionStore';
export type { AuthState, AuthActions, AuthStore, UIState, UIActions, UIStore, Theme } from './types';
export type { PermissionState, PermissionActions, PermissionStore } from './permissionStore';

View File

@@ -0,0 +1,108 @@
import type { MenuTreeNode } from '@seclusion/shared';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { STORAGE_KEYS } from '@/config/constants';
// 权限 Store 状态类型
export interface PermissionState {
/** 用户可访问的菜单树 */
menus: MenuTreeNode[];
/** 用户拥有的权限编码列表 */
permissions: string[];
/** 是否为超级管理员 */
isSuperAdmin: boolean;
/** 权限数据是否已加载 */
isLoaded: boolean;
}
// 权限 Store Actions 类型
export interface PermissionActions {
/** 设置权限数据 */
setPermissionData: (data: { menus: MenuTreeNode[]; permissions: string[]; isSuperAdmin: boolean }) => void;
/** 清空权限数据 */
clearPermissionData: () => void;
/** 检查是否拥有指定权限单个或多个OR 关系) */
hasPermission: (permission: string | string[]) => boolean;
/** 检查是否拥有所有指定权限AND 关系) */
hasAllPermissions: (permissions: string[]) => boolean;
}
// 权限 Store 完整类型
export type PermissionStore = PermissionState & PermissionActions;
// 初始状态
const initialState: PermissionState = {
menus: [],
permissions: [],
isSuperAdmin: false,
isLoaded: false,
};
// 创建权限 Store
export const usePermissionStore = create<PermissionStore>()(
persist(
(set, get) => ({
...initialState,
setPermissionData: (data) => {
set({
menus: data.menus,
permissions: data.permissions,
isSuperAdmin: data.isSuperAdmin,
isLoaded: true,
});
},
clearPermissionData: () => {
set(initialState);
},
hasPermission: (permission) => {
const { isSuperAdmin, permissions } = get();
// 超级管理员拥有所有权限
if (isSuperAdmin) {
return true;
}
// 检查权限支持单个或多个OR 关系)
if (Array.isArray(permission)) {
return permission.some((p) => permissions.includes(p));
}
return permissions.includes(permission);
},
hasAllPermissions: (requiredPermissions) => {
const { isSuperAdmin, permissions } = get();
// 超级管理员拥有所有权限
if (isSuperAdmin) {
return true;
}
// 检查是否拥有所有权限
return requiredPermissions.every((p) => permissions.includes(p));
},
}),
{
name: STORAGE_KEYS.PERMISSION || 'permission-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
menus: state.menus,
permissions: state.permissions,
isSuperAdmin: state.isSuperAdmin,
isLoaded: state.isLoaded,
}),
}
)
);
// 便捷 Hook
export const useMenus = () => usePermissionStore((state) => state.menus);
export const usePermissions = () => usePermissionStore((state) => state.permissions);
export const useIsSuperAdmin = () => usePermissionStore((state) => state.isSuperAdmin);
export const useIsPermissionLoaded = () => usePermissionStore((state) => state.isLoaded);
export const useHasPermission = () => usePermissionStore((state) => state.hasPermission);
export const useHasAllPermissions = () => usePermissionStore((state) => state.hasAllPermissions);