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

18 KiB
Raw Permalink Blame History

菜单权限逻辑文档

概述

本项目采用动态路由注册方案实现权限控制。用户登录后,系统根据后端返回的菜单权限码动态注册路由,无权限的路由根本不会注册到 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: 页面空白?

检查项:

  1. 是否使用了 JSON.parse(JSON.stringify()) 深拷贝路由(会丢失 component
  2. 路由的 name 是否与权限码一致
  3. 控制台是否有报错

Q2: 菜单不显示?

检查项:

  1. localStorage.menusPermission 是否存在
  2. 路由是否包含 meta.titlemeta.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 按钮级权限指令