18 KiB
18 KiB
菜单权限逻辑文档
概述
本项目采用动态路由注册方案实现权限控制。用户登录后,系统根据后端返回的菜单权限码动态注册路由,无权限的路由根本不会注册到 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
// 登录成功后存储权限数据
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
});
权限码示例:
["FourColorWarning", "YjData", "IntelligentControl", "userList", "departmentList", ...]
2. 路由守卫 - 控制初始化
文件: src/permission.js
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
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
// 从已注册的路由中获取并过滤
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
无权限访问时统一跳转此页面,显示提示信息和操作按钮:
<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
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: 页面空白?
检查项:
- 是否使用了
JSON.parse(JSON.stringify())深拷贝路由(会丢失 component) - 路由的
name是否与权限码一致 - 控制台是否有报错
Q2: 菜单不显示?
检查项:
localStorage.menusPermission是否存在- 路由是否包含
meta.title和meta.icon - 路由是否在
EXCLUDE_NAMES列表中
Q3: 刷新后 404?
检查项:
localStorage.menusPermission是否存在- 路由守卫是否正确重新初始化
Q4: 退出登录后无法重新登录?
检查项:
- 是否正确调用了
resetRoutesInit()重置初始化标记 - 是否正确清除了 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 |
按钮级权限指令 |