14 KiB
14 KiB
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: 侧边栏显示空白或加载失败
可能原因:
- JWT token 未正确传递
- 后端接口返回格式不正确
- 用户角色映射失败
排查步骤:
// 检查 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│
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ 渲染侧边栏菜单 │
└─────────────────────┘
🎯 总结
核心改动
- 调用后端统一接口:
GET /rbac/user/routes - 数据格式转换:后端格式 → MenuItem 格式
- 图标映射:Element UI → RemixIcon
- 从 localStorage 读取认证信息:作为服务端 session 的备用方案
优势
- ✅ 路由权限由后端统一管理
- ✅ 前端不需要查询多个表
- ✅ 支持动态菜单,后端修改后前端自动更新
- ✅ 更好的安全性和可维护性
文档版本: v1.0 最后更新: 2025-11-17 维护者: DocReview 前端团队