feat(rbac): F3 前端 RBAC 按钮级控制

后端:
- 新增 GET /user/permissions 端点返回当前用户权限码
- Login 响应附带 permissions 数组
- collectUserPermissionCodes 用 raw SQL JOIN 避免 GORM many2many 表名问题
- admin 用户返回 ['*'] 通配符

前端:
- usePermission hook (hasPermission/hasAnyPermission/hasAllPermissions)
- <HasPermission> 组件 (permission/anyOf/allOf + fallback)
- zustand store 新增 permissions 字段
- Sidebar 菜单递归过滤 (42 菜单项全部标注 permission)
- ServerManager: 添加/导入/批量删除按钮按权限显隐
- ServerTableRow: 终端/编辑/文件/删除按权限显隐
- SystemUserTab: 新建/导入/编辑/删除按钮按权限显隐
- MainLayout: 页面刷新自动拉取权限
- Login: 登录时存储 permissions

验证:
- admin: permissions=['*'] 通配符
- viewer: 7 个只读权限 (dashboard:view/server:view/...)
- viewer 无 server:create/delete
- 10/10 回归测试通过
- npm run build 零错误
This commit is contained in:
attacker committed 2026-06-25 22:51:25 +08:00
1 parent 77c234b060
commit be2a377f01
30 files changed
+2328 -462

No files matched your search

+26 -1
View File
@@ -1,9 +1,11 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useThemeStore } from '@/store';
import { useThemeStore, useUserStore } from '@/store';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { Breadcrumb } from './Breadcrumb';
import { userApi } from '@/services/api';
/**
* MainLayout - 优化后的主布局组件
@@ -14,9 +16,32 @@ import { Breadcrumb } from './Breadcrumb';
* 3. 添加 Breadcrumb 面包屑导航
* 4. 布局过渡动画平滑
* 5. 支持响应式布局
* 6. F3: 页面刷新后自动拉取当前用户权限(RBAC 按钮级控制)
*/
export function MainLayout() {
const { sidebarCollapsed } = useThemeStore();
const token = useUserStore((s) => s.token);
const permissions = useUserStore((s) => s.permissions);
const setPermissions = useUserStore((s) => s.setPermissions);
// 页面刷新后 zustand persist 会恢复 token/user,但 permissions 可能丢失或过期
// 在 MainLayout 挂载时如果 permissions 为 null 则拉取一次
useEffect(() => {
if (!token || permissions !== null) return;
let cancelled = false;
userApi.getMyPermissions()
.then((data: any) => {
if (cancelled) return;
const perms: string[] = data?.permissions ?? [];
setPermissions(Array.isArray(perms) ? perms : []);
})
.catch(() => {
// 拉取失败不阻塞页面,权限保持 null(usePermission 默认放行)
if (!cancelled) setPermissions([]);
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<div