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

559 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 前端团队