Files
leaudit-platform-frontend/docs/RBAC路由权限集成说明.md
T
2025-12-05 00:09:32 +08:00

14 KiB
Raw Blame History

RBAC 路由权限集成说明

📋 概述

本文档说明如何将前端 Sidebar 菜单与后端 RBAC(基于角色的访问控制)路由权限系统集成。

前端通过调用后端的 /rbac/user/routes 接口获取当前用户有权限访问的路由列表,然后根据这些路由动态生成侧边栏菜单。


🔄 工作流程

1. 用户登录成功
   ↓
2. 前端存储 JWT token 和用户信息到 localStorage
   ↓
3. Layout 组件从 localStorage 读取 user_role 和 access_token
   ↓
4. 传递给 Sidebar 组件
   ↓
5. Sidebar 调用 getUserRoutesByRole(roleKey, jwt)
   ↓
6. 该函数调用后端接口 GET /rbac/user/routes
   ↓
7. 后端根据 JWT 中的用户信息查询该用户的路由权限
   ↓
8. 返回路由树结构(包含嵌套的 children)
   ↓
9. 前端将后端路由格式转换为 MenuItem 格式
   ↓
10. Sidebar 根据 MenuItem 数组渲染菜单

📁 修改的文件

1. app/api/auth/user-routes.ts

新增接口定义

// 后端返回的路由数据接口
export interface BackendRouteInfo {
  id: number;
  route_path: string;
  route_name: string;
  component: string;
  parent_id: number | null;
  route_title: string;
  icon: string | null;
  sort_order: number;
  is_hidden: boolean;
  is_cache: boolean;
  meta: string;
  children?: BackendRouteInfo[];
}

// 后端API响应接口
export interface BackendRoutesResponse {
  code: number;
  msg: string;
  data: {
    user_id: number;
    username: string;
    routes: BackendRouteInfo[];
  };
}

修改 getUserRoutesByRole 函数

之前:通过多次 PostgREST 查询获取路由

// 查询 roles 表 → role_route 表 → sys_routes 表
// 需要 3 次数据库查询

现在:调用后端统一接口

export async function getUserRoutesByRole(roleKey: string, jwt?: string) {
  // 调用后端统一接口
  const response = await apiRequest<BackendRoutesResponse>({
    method: 'GET',
    url: '/rbac/user/routes',
    headers: {
      'Authorization': `Bearer ${jwt}`
    }
  });

  // 转换后端路由格式为前端 MenuItem 格式
  const menuItems = convertBackendRoutesToMenuItems(response.data.routes);

  return { success: true, data: menuItems };
}

新增转换函数

/**
 * 将后端路由格式转换为前端 MenuItem 格式
 */
function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[]): MenuItem[] {
  return backendRoutes
    .filter(route => !route.is_hidden) // 过滤隐藏的路由
    .map(route => {
      const menuItem: MenuItem = {
        id: route.route_name || `route-${route.id}`,
        title: route.route_title,
        path: route.route_path,
        icon: convertIcon(route.icon),
        order: route.sort_order,
        hideBreadcrumb: route.is_hidden
      };

      // 递归处理子路由
      if (route.children && route.children.length > 0) {
        menuItem.children = convertBackendRoutesToMenuItems(route.children);
      }

      return menuItem;
    })
    .sort((a, b) => a.order - b.order);
}

图标映射

Element UI 图标 → RemixIcon 映射:

const ICON_MAPPING: Record<string, string> = {
  'el-icon-s-home': 'ri-home-line',
  'el-icon-house': 'ri-home-4-line',
  'el-icon-document': 'ri-file-text-line',
  'el-icon-edit': 'ri-edit-line',
  'el-icon-connection': 'ri-links-line',
  'el-icon-setting': 'ri-settings-4-line',
  'el-icon-user': 'ri-user-line',
  'el-icon-tickets': 'ri-ticket-line',
  'el-icon-chat-dot-round': 'ri-chat-smile-2-line',
  'el-icon-s-order': 'ri-list-check',
  'el-icon-s-grid': 'ri-grid-line',
  'el-icon-s-comment': 'ri-chat-1-line',
  'el-icon-files': 'ri-file-copy-line',
  'el-icon-folder': 'ri-folder-line',
  'el-icon-upload': 'ri-upload-cloud-line',
  'el-icon-download': 'ri-download-cloud-line',
  'el-icon-search': 'ri-search-line',
};

角色映射增强

export function mapUserRoleToRoleKey(userRole: string): string {
  const roleMapping: Record<string, string> = {
    'common': 'common',
    'admin': 'admin',
    'deptLeader': 'deptLeader',
    'groupLeader': 'groupLeader',
    // 添加常见的后端角色映射
    'super_admin': 'admin',
    'system_admin': 'admin',
    'user': 'common',
    'developer': 'admin'
  };

  // 如果找不到映射,返回 userRole 本身
  return roleMapping[userRole] || userRole || 'common';
}

2. app/components/layout/Layout.tsx

新增从 localStorage 读取用户信息

export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '' }: LayoutProps) {
  const [effectiveUserRole, setEffectiveUserRole] = useState<UserRole>(userRole);
  const [effectiveFrontendJWT, setEffectiveFrontendJWT] = useState<string>(frontendJWT);

  // 从 localStorage 读取用户信息和 JWT 作为备用方案
  useEffect(() => {
    if (typeof window === 'undefined') return;

    try {
      // 如果服务端没有传递 userRole,从 localStorage 读取
      if (!userRole || userRole === '') {
        const storedUserInfoStr = localStorage.getItem('user_info');
        if (storedUserInfoStr) {
          const storedUserInfo = JSON.parse(storedUserInfoStr);
          const storedUserRole = storedUserInfo.user_role || 'common';
          console.log('📖 [Layout] 从 localStorage 读取用户角色:', storedUserRole);
          setEffectiveUserRole(storedUserRole as UserRole);
        }
      } else {
        setEffectiveUserRole(userRole);
      }

      // 如果服务端没有传递 frontendJWT,从 localStorage 读取
      if (!frontendJWT || frontendJWT === '') {
        const storedToken = localStorage.getItem('access_token');
        if (storedToken) {
          console.log('📖 [Layout] 从 localStorage 读取 JWT token');
          setEffectiveFrontendJWT(storedToken);
        }
      } else {
        setEffectiveFrontendJWT(frontendJWT);
      }
    } catch (error) {
      console.error('❌ [Layout] 读取 localStorage 失败:', error);
    }
  }, [userRole, frontendJWT]);

  // 传递给 Sidebar
  <Sidebar
    collapsed={sidebarCollapsed}
    onToggle={toggleSidebar}
    userRole={effectiveUserRole}
    selectedApp={selectedApp}
    frontendJWT={effectiveFrontendJWT}
  />
}

3. app/components/layout/Sidebar.tsx

修改路由获取逻辑

// 获取用户路由权限
useEffect(() => {
  const fetchUserRoutes = async () => {
    setIsLoadingRoutes(true);
    try {
      // 优先使用传入的 frontendJWT,否则从 localStorage 读取
      let jwt = frontendJWT;

      if (!jwt && typeof window !== 'undefined') {
        jwt = localStorage.getItem('access_token') || '';
        console.log('📖 [Sidebar] 从 localStorage 读取 JWT');
      }

      if (!jwt) {
        console.error('❌ [Sidebar] JWT token 未找到');
        setMenuItems([]);
        setIsLoadingRoutes(false);
        return;
      }

      console.log('🔍 [Sidebar] 当前用户角色:', userRole);
      const roleKey = mapUserRoleToRoleKey(userRole);
      const result = await getUserRoutesByRole(roleKey, jwt);

      if (result.success && result.data) {
        setMenuItems(result.data);
        console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data);
      } else {
        console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error);

        // 如果需要重定向到首页
        if (result.shouldRedirectToHome) {
          console.log('🔄 [Sidebar] 重定向到首页');
          navigate('/');
          return;
        }

        // 其他错误情况,使用空数组
        setMenuItems([]);
      }
    } catch (error) {
      console.error('❌ [Sidebar] 获取用户路由权限时发生错误:', error);
      navigate('/');
      return;
    } finally {
      setIsLoadingRoutes(false);
    }
  };

  fetchUserRoutes();
}, [userRole, frontendJWT, navigate]);

🔍 后端接口

请求

GET /rbac/user/routes
Authorization: Bearer {JWT_TOKEN}

响应示例

{
  "code": 0,
  "msg": "成功",
  "data": {
    "user_id": 5,
    "username": "admin",
    "routes": [
      {
        "id": 30,
        "route_path": "/",
        "route_name": "Index",
        "component": "views/Index.vue",
        "parent_id": null,
        "route_title": "入口页",
        "icon": "el-icon-s-home",
        "sort_order": 0,
        "is_hidden": false,
        "is_cache": false,
        "meta": "{\"requiresAuth\": true}"
      },
      {
        "id": 2,
        "route_path": "/documents",
        "route_name": "Documents",
        "component": "views/documents/Index.vue",
        "parent_id": null,
        "route_title": "文档管理",
        "icon": "el-icon-document",
        "sort_order": 2,
        "is_hidden": false,
        "is_cache": true,
        "meta": "{\"requiresAuth\": true}",
        "children": [
          {
            "id": 32,
            "route_path": "/documents/upload",
            "route_name": "DocumentUpload",
            "component": "views/documents/Upload.vue",
            "parent_id": 2,
            "route_title": "上传文档",
            "icon": null,
            "sort_order": 1,
            "is_hidden": false,
            "is_cache": false,
            "meta": "{\"requiresAuth\": true}"
          }
        ]
      }
    ]
  }
}

🧪 测试步骤

1. 准备测试环境

确保后端接口 /rbac/user/routes 已经实现并正常运行。

2. 清除旧数据

在浏览器开发者工具控制台执行:

localStorage.clear()
location.reload()

3. 登录测试

使用测试账号登录:

用户名 密码 角色 预期路由数
000 admin06111 超级管理员 29
001 gdyc06111 普通用户 19

4. 观察控制台日志

登录成功后应该看到:

📖 [Layout] 从 localStorage 读取用户角色: admin
📖 [Layout] 从 localStorage 读取 JWT token
🔍 [Sidebar] 当前用户角色: admin
🔍 [User Routes] 获取用户路由,角色: admin
🔍 [User Routes] 后端返回: { code: 0, msg: "成功", data: {...} }
✅ [User Routes] 成功获取 29 个路由
📋 [User Routes] 菜单数据: [...]
✅ [Sidebar] 用户路由权限加载成功: [...]

5. 验证菜单显示

  • 侧边栏显示正确的菜单项
  • 菜单项按 sort_order 排序
  • 有子菜单的项可以展开/折叠
  • 图标正确显示(RemixIcon
  • is_hidden: true 的路由不显示在菜单中

6. 测试不同角色

切换不同角色的账号登录,验证:

  • 不同角色看到的菜单项数量不同
  • 权限较低的角色看不到高权限路由

常见问题

Q1: 侧边栏显示空白或加载失败

可能原因

  1. JWT token 未正确传递
  2. 后端接口返回格式不正确
  3. 用户角色映射失败

排查步骤

// 检查 localStorage
console.log('Token:', localStorage.getItem('access_token'));
console.log('User Info:', localStorage.getItem('user_info'));

// 手动测试后端接口
const token = localStorage.getItem('access_token');
fetch('http://172.16.0.55:8073/rbac/user/routes', {
  headers: { 'Authorization': `Bearer ${token}` }
}).then(r => r.json()).then(console.log);

Q2: 菜单图标不显示

可能原因

  • Element UI 图标未映射到 RemixIcon
  • 后端返回的 icon 字段为 null

解决方案ICON_MAPPING 中添加缺失的图标映射。


Q3: 后端返回 401 错误

可能原因

  • JWT token 过期
  • 后端认证中间件配置错误
  • /rbac/user/routes 未添加到白名单

解决方案 确保后端允许 /rbac/user/routes 接口使用 JWT 认证。


Q4: 角色映射失败

可能原因

  • 后端返回的 user_role 字段值与前端映射不一致

解决方案mapUserRoleToRoleKey 中添加新的角色映射。

export function mapUserRoleToRoleKey(userRole: string): string {
  const roleMapping: Record<string, string> = {
    // 添加新的角色映射
    'new_role': 'admin',
  };

  return roleMapping[userRole] || userRole || 'common';
}

📊 数据流图

┌─────────────┐
│   用户登录   │
└──────┬──────┘
       │
       ▼
┌─────────────────────┐
│ 存储到 localStorage │
│ - access_token      │
│ - user_info         │
└──────┬──────────────┘
       │
       ▼
┌─────────────────────┐
│  Layout 组件加载    │
│  读取 localStorage  │
└──────┬──────────────┘
       │
       ▼
┌─────────────────────┐
│  Sidebar 组件加载   │
│  调用路由接口       │
└──────┬──────────────┘
       │
       ▼
┌─────────────────────┐
│ GET /rbac/user/routes│
│ Authorization: Bearer│
└──────┬──────────────┘
       │
       ▼
┌─────────────────────┐
│   后端返回路由树    │
└──────┬──────────────┘
       │
       ▼
┌─────────────────────┐
│  转换为 MenuItem    │
│  - route_path → path│
│  - route_title → title│
│  - icon 映射        │
│  - 递归处理 children│
└──────┬──────────────┘
       │
       ▼
┌─────────────────────┐
│   渲染侧边栏菜单    │
└─────────────────────┘

🎯 总结

核心改动

  1. 调用后端统一接口GET /rbac/user/routes
  2. 数据格式转换:后端格式 → MenuItem 格式
  3. 图标映射Element UI → RemixIcon
  4. 从 localStorage 读取认证信息:作为服务端 session 的备用方案

优势

  • 路由权限由后端统一管理
  • 前端不需要查询多个表
  • 支持动态菜单,后端修改后前端自动更新
  • 更好的安全性和可维护性

文档版本: v1.0 最后更新: 2025-11-17 维护者: DocReview 前端团队