# 菜单权限逻辑文档 ## 概述 本项目采用**动态路由注册**方案实现权限控制。用户登录后,系统根据后端返回的菜单权限码动态注册路由,无权限的路由**根本不会注册**到 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 ``` --- ### 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` | 按钮级权限指令 |