Files
sgxt_web/docs/菜单权限逻辑文档.md
2026-04-28 11:26:26 +08:00

464 lines
18 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.

# 菜单权限逻辑文档
## 概述
本项目采用**动态路由注册**方案实现权限控制。用户登录后,系统根据后端返回的菜单权限码动态注册路由,无权限的路由**根本不会注册**到 Vue Router从根本上杜绝了越权访问的可能。
---
## 整体流程图
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 用户登录 │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ 调用登录接口 │
│ 返回jwtToken、menuList、menuCodeSet、deptList 等 │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ 存储权限数据 │
│ - localStorage.menusPermission = menuCodeSet菜单权限码集合
│ - Vuex: user.userInfo.permission.menus = menuCodeSet │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ 路由守卫permission.js
│ 首次进入时:获取 menusPermission → 调用 filterRoutes 动态注册路由 │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Vuex: permission/filterRoutes │
│ 1. 根据 menusPermission 过滤 privateRoutes │
│ 2. 通过 router.addRoute() 动态注册有权限的路由 │
│ 3. 最后注册 404 兜底路由 │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ SideBarMenu.vue 组件 │
│ 从已注册的路由中筛选并渲染侧边栏菜单 │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 核心文件说明
| 文件路径 | 作用 |
|---------|------|
| `src/router/index.js` | 路由配置,定义 `publicRoutes`(公开路由)和 `privateRoutes`(私有路由) |
| `src/store/modules/permission.js` | 权限模块,处理路由过滤和动态注册逻辑 |
| `src/store/modules/user.js` | 用户模块,处理登录和退出登录 |
| `src/permission.js` | 路由守卫,控制路由初始化时机 |
| `src/utils/route.js` | 路由工具函数,处理菜单生成 |
| `src/layout/components/SideBar/SideBarMenu.vue` | 侧边栏菜单组件,渲染权限菜单 |
| `src/views/error/404.vue` | 无权限/页面不存在页面 |
| `src/directives/permission.js` | 按钮级权限指令 |
---
## 路由分类
### 公开路由publicRoutes
应用启动时静态注册,所有用户都能访问:
| 路由路径 | 说明 |
|---------|------|
| `/login` | 登录页 |
| `/oatuh_login` | OAuth 登录页 |
| `/zeroTrust_login` | 零信任登录页 |
| `/` | 首页 |
| `/401` | 无权限页(保留) |
| `/404` | 页面不存在/无权限页 |
| `/mapNavigation` | 地图导航 |
| `/KeyPopulations` | 重点人详情 |
| `/deploymentApproval` | 布控审核 |
| `/clueVerification` | 线索核实 |
| 其他特殊路由... | 无需菜单权限校验的业务路由 |
### 私有路由privateRoutes
登录后根据权限动态注册,包含所有业务功能页面。
---
## 详细逻辑分析
### 1. 登录阶段 - 获取并存储权限
**文件**: `src/store/modules/user.js`
```javascript
// 登录成功后存储权限数据
this.commit("user/setToken", data.jwtToken);
this.commit("user/setMenuList", data.menuList);
setItem("menusPermission", data.menuCodeSet); // 核心:菜单权限码集合
this.commit("user/setUserInfo", {
token: data.jwtToken,
permission: {
buttonPermission: ["removeTest", "viewTest"],
menus: data.menuCodeSet
},
menuList: data.menuList,
deptList: data.deptList
});
```
**权限码示例**
```javascript
["FourColorWarning", "YjData", "IntelligentControl", "userList", "departmentList", ...]
```
---
### 2. 路由守卫 - 控制初始化
**文件**: `src/permission.js`
```javascript
const whiteList = ['/login', '/oatuh_login', '/404', '/401', '/zeroTrust_login',
'/focusExploration', '/clueVerification', '/deploymentApproval'];
let routesInitialized = false;
router.beforeEach(async (to, from, next) => {
if (store.getters.token) {
if (!routesInitialized) {
// ★ 首次进入:动态注册路由
routesInitialized = true;
const afterMenuList = getItem('menusPermission');
// 根据权限动态注册路由
await store.dispatch('permission/filterRoutes', afterMenuList);
// 重新导航,确保刚注册的路由能正确匹配
next({ ...to, replace: true });
return;
}
next();
} else {
// 未登录:白名单放行,否则跳转登录
if (whiteList.indexOf(to.path) > -1) {
next();
} else {
// 跳转登录逻辑...
}
}
});
```
---
### 3. 权限过滤与动态注册
**文件**: `src/store/modules/permission.js`
```javascript
import router from '@/router'
import { publicRoutes, privateRoutes } from '@/router'
/**
* 递归过滤路由(保留 component 引用)
* 规则:
* 1. 路由有 name 且在权限列表中 → 保留
* 2. 路由无 name 但有子路由 → 检查子路由权限,有权限子路由则保留父路由
*/
function filter(data, menus) {
const result = []
data.forEach(route => {
const newRoute = { ...route } // 浅拷贝,保留 component 引用
if (route.name && menus?.includes(route.name)) {
// 有权限:递归处理子路由
if (route.children && route.children.length > 0) {
newRoute.children = filter(route.children, menus)
}
result.push(newRoute)
} else if (!route.name && route.children && route.children.length > 0) {
// 父路由无 name检查子路由
const filteredChildren = filter(route.children, menus)
if (filteredChildren.length > 0) {
newRoute.children = filteredChildren
result.push(newRoute)
}
}
})
return result
}
actions: {
filterRoutes(context, menus) {
let routes = []
if (menus && menus.length > 0) {
routes = filter(privateRoutes, menus)
}
// ★★★ 关键:动态添加路由到 Vue Router ★★★
routes.forEach(route => {
router.addRoute(route)
})
// 404 兜底路由必须最后添加
router.addRoute({
path: '/:catchAll(.*)',
redirect: '/404'
})
context.commit('setRoutes', routes)
return routes
}
}
```
**重要说明**
- 不能使用 `JSON.parse(JSON.stringify())` 深拷贝,会丢失 `component` 函数引用导致页面空白
- 使用 `{ ...route }` 浅拷贝保留 `component` 引用
---
### 4. 侧边栏菜单渲染
**文件**: `src/layout/components/SideBar/SideBarMenu.vue`
```javascript
// 从已注册的路由中获取并过滤
const routes = computed(() => {
const fRoutes = filterRoutes(router.getRoutes());
const data = fRoutes.filter((item) => !EXCLUDE_NAMES.includes(item.name));
const menusPermission = getItem("menusPermission");
if (menusPermission === null || menusPermission === undefined) {
return [];
}
const menusSet = new Set(menusPermission.map((item) => `${item}`));
const permissionFiltered = menusSet.size
? filterRoutesByMenusPermission(data, menusSet)
: [];
return generateMenus(permissionFiltered);
});
```
---
### 5. 404 无权限页面
**文件**: `src/views/error/404.vue`
无权限访问时统一跳转此页面,显示提示信息和操作按钮:
```vue
<template>
<div class="error-page">
<div class="error-content">
<h1>404</h1>
<h2>无权限访问</h2>
<p>您没有权限访问此页面请联系上级部门添加相关权限</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
<el-button @click="logout">退出登录</el-button>
</div>
</div>
</template>
<script setup>
const logout = () => {
window.opener = null;
window.open('', '_self');
window.close();
store.commit("app/clearTag", null, { immediate: true });
store.commit("permission/deleteRouter", { immediate: true });
store.commit("user/deleteKeepLiiveRoute", "home");
}
</script>
```
---
### 6. 退出登录
**文件**: `src/store/modules/user.js`
```javascript
async logout(ctx) {
const res = await loginOut();
if (res) {
// 重置动态路由
resetRouter();
// 重置路由守卫初始化标记
resetRoutesInit();
// 清除权限模块状态
ctx.dispatch("permission/resetRoutes");
// 清除用户状态
ctx.commit("user/setToken", "");
ctx.commit("user/setUserName", "admin");
ctx.commit("user/setUserInfo", {});
// 清除本地存储
removeAllItem();
// 跳转统一门户
window.location.href = `https://tyyy.lz.dsj.xz/portal/home`;
}
}
```
---
## 权限判断核心
### 匹配公式
```
用户权限码menusPermission = ["FourColorWarning", "YjData", "userList", ...]
路由配置:
{
path: "/FourColorWarning",
name: "FourColorWarning", // ← 必须与权限码一致
meta: { title: "预警中心", icon: "article-ranking" },
children: [...]
}
判断逻辑:
menusPermission.includes(route.name) ? 有权限 : 无权限
```
### 关键点
| 要素 | 说明 |
|-----|------|
| **权限来源** | 后端登录接口返回的 `menuCodeSet` |
| **存储位置** | `localStorage.menusPermission` |
| **匹配字段** | 路由的 `name` 属性 |
| **匹配方式** | 数组 `includes` 检查 |
| **父路由处理** | 无 `name` 时检查子路由,有权限子路由则保留父路由 |
---
## 权限控制层级
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 权限控制层级 │
├─────────────────────────────────────────────────────────────────────────┤
│ 第一层:路由守卫 │
│ - 控制登录状态 │
│ - 未登录跳转登录页 │
│ - 白名单路由直接放行 │
├─────────────────────────────────────────────────────────────────────────┤
│ 第二层:动态路由注册 │
│ - 根据权限码筛选路由 │
│ - 无权限路由不注册 → 用户输入 URL 直接 404 │
│ - 只在登录时计算一次,性能最优 │
├─────────────────────────────────────────────────────────────────────────┤
│ 第三层:侧边栏菜单过滤 │
│ - 从已注册路由中筛选 │
│ - 只显示有权限的菜单项 │
│ - 支持特殊部门/角色的额外过滤 │
├─────────────────────────────────────────────────────────────────────────┤
│ 第四层:按钮级权限 │
│ - 使用 v-permission 指令控制按钮显示 │
│ - 根据用户的功能权限动态移除无权限元素 │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 安全特性
### 动态路由方案的安全优势
| 特性 | 说明 |
|-----|------|
| **路由不存在** | 无权限路由不会注册到 Vue Router从根本上杜绝越权访问 |
| **URL 直接访问** | 用户输入无权限 URL 直接跳转 404 |
| **性能最优** | 只在登录时计算一次,后续跳转无需校验 |
| **业界标准** | Vue 官方推荐的权限控制方案 |
### 无权限访问流程
```
用户访问无权限 URL如 /user/userList
路由是否已注册?
┌─────┴─────┐
│ 否 │ 是
▼ ▼
跳转 404 正常访问(但不会发生,因为无权限路由不会注册)
```
---
## 特殊权限处理
### 部门类型 + 角色组合
系统根据部门类型(`deptBizType`)和角色(`roleList`)进行特殊路由控制:
| 条件 | 排除的路由 |
|-----|----------|
| `deptBizType == '23'` 且有 `JS_666666` 角色 | 不排除任何路由 |
| `deptBizType == '23'` 且有 `JS_999999` 角色 | 排除 `/internalAuditor` |
| 其他情况 | 排除 `/internalAuditor``/auditList` |
---
## 常见问题
### Q1: 页面空白?
检查项:
1. 是否使用了 `JSON.parse(JSON.stringify())` 深拷贝路由(会丢失 component
2. 路由的 `name` 是否与权限码一致
3. 控制台是否有报错
### Q2: 菜单不显示?
检查项:
1. `localStorage.menusPermission` 是否存在
2. 路由是否包含 `meta.title``meta.icon`
3. 路由是否在 `EXCLUDE_NAMES` 列表中
### Q3: 刷新后 404
检查项:
1. `localStorage.menusPermission` 是否存在
2. 路由守卫是否正确重新初始化
### Q4: 退出登录后无法重新登录?
检查项:
1. 是否正确调用了 `resetRoutesInit()` 重置初始化标记
2. 是否正确清除了 localStorage
---
## 相关文件索引
| 文件 | 说明 |
|-----|------|
| `src/router/index.js` | 路由配置publicRoutes / privateRoutes |
| `src/store/modules/permission.js` | 权限路由模块(过滤 + 动态注册) |
| `src/store/modules/user.js` | 用户模块(登录/退出) |
| `src/permission.js` | 路由守卫 |
| `src/utils/route.js` | 路由工具函数 |
| `src/layout/components/SideBar/SideBarMenu.vue` | 侧边栏菜单 |
| `src/layout/components/NavBar.vue` | 导航栏(退出登录) |
| `src/views/error/404.vue` | 无权限/404 页面 |
| `src/directives/permission.js` | 按钮级权限指令 |