# 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` #### 新增接口定义 ```typescript // 后端返回的路由数据接口 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 查询获取路由 ```typescript // 查询 roles 表 → role_route 表 → sys_routes 表 // 需要 3 次数据库查询 ``` **现在**:调用后端统一接口 ```typescript export async function getUserRoutesByRole(roleKey: string, jwt?: string) { // 调用后端统一接口 const response = await apiRequest({ method: 'GET', url: '/rbac/user/routes', headers: { 'Authorization': `Bearer ${jwt}` } }); // 转换后端路由格式为前端 MenuItem 格式 const menuItems = convertBackendRoutesToMenuItems(response.data.routes); return { success: true, data: menuItems }; } ``` #### 新增转换函数 ```typescript /** * 将后端路由格式转换为前端 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 映射: ```typescript const ICON_MAPPING: Record = { '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', }; ``` #### 角色映射增强 ```typescript export function mapUserRoleToRoleKey(userRole: string): string { const roleMapping: Record = { '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 读取用户信息 ```typescript export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '' }: LayoutProps) { const [effectiveUserRole, setEffectiveUserRole] = useState(userRole); const [effectiveFrontendJWT, setEffectiveFrontendJWT] = useState(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 } ``` --- ### 3. `app/components/layout/Sidebar.tsx` #### 修改路由获取逻辑 ```typescript // 获取用户路由权限 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]); ``` --- ## 🔍 后端接口 ### 请求 ```http GET /rbac/user/routes Authorization: Bearer {JWT_TOKEN} ``` ### 响应示例 ```json { "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. 清除旧数据 在浏览器开发者工具控制台执行: ```javascript 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. 用户角色映射失败 **排查步骤**: ```javascript // 检查 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` 中添加新的角色映射。 ```typescript export function mapUserRoleToRoleKey(userRole: string): string { const roleMapping: Record = { // 添加新的角色映射 '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 前端团队