all in
This commit is contained in:
@@ -0,0 +1,558 @@
|
||||
# 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<BackendRoutesResponse>({
|
||||
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<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',
|
||||
};
|
||||
```
|
||||
|
||||
#### 角色映射增强
|
||||
|
||||
```typescript
|
||||
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 读取用户信息
|
||||
|
||||
```typescript
|
||||
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`
|
||||
|
||||
#### 修改路由获取逻辑
|
||||
|
||||
```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<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 前端团队
|
||||
Reference in New Issue
Block a user