普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月10日首页

企业级RBAC 实战(八)手撸后端动态路由,拒绝前端硬编码

作者 小胖霞
2025年12月10日 09:29

在企业级后台中,硬编码路由(写死在 router/index.js)是维护的噩梦。本文将深入讲解如何根据用户权限动态生成侧边栏菜单。我们将构建后端的 getRouters 递归接口,并在前端利用 Vite 的 import.meta.glob 实现组件的动态挂载,最后彻底解决路由守卫中经典的“死循环”和“刷新白屏”问题。

学习之前先浏览 前置专栏文章

一、 引言:为什么要做动态路由?

在简单的后台应用中,我们通常会在前端 router/routes.ts 中写死所有路由。但在 RBAC(基于角色的权限控制)模型下,这种做法有两个致命缺陷:

  1. 安全性低:普通用户虽然看不到菜单,但如果在浏览器地址栏手动输入 URL,依然能进入管理员页面。
  2. 维护成本高:每次新增页面都要修改前端代码并重新打包部署。

目标:前端只保留“登录”和“404”等基础页面,其他所有业务路由由后端根据当前用户的角色权限动态返回。

二、 后端实现:构建路由树 (getRouters)

后端的核心任务是:查询当前用户的菜单 -> 过滤掉隐藏的/无权限的 -> 组装成 Vue Router 需要的 JSON 树。

1. 数据结构转换

我们在上一篇设计了 sys_menus 表。Vue Router 需要的结构包含 path, component, meta 等字段。我们需要一个递归函数将扁平的数据库记录转为树形结构。

文件:routes/menu.js

// 辅助函数:将数据库扁平数据转为树形结构
function buildTree(items, parentId = 0) {
  const result = []
  for (const item of items) {
    // 兼容字符串和数字的 ID 对比
    if (item.parent_id == parentId) {
      // 组装 Vue Router 标准结构
      const route = {
        name: toCamelCase(item.path), // 自动生成驼峰 Name
        path: item.path,
        hidden: item.hidden === 1,    // 数据库 1/0 转布尔
        component: item.component,    // 此时还是字符串,如 "system/user/index"
         // 只有当 redirect 有值时才添加该字段
        ...(item.redirect && { redirect: item.redirect }),
        // 只有当 always_show 为 1 时才添加,并转为布尔
        ...(item.alwaysShow === 1 && { alwaysShow: true }),
        meta: {
          title: item.menu_name,
          icon: item.icon,
          noCache: item.no_cache === 1
        }
      }
      
      const children = buildTree(items, item.id)
      if (children.length > 0) {
        route.children = children
      }
      result.push(route)
    }
  }
  return result
}

2. 接口实现

这里有一个关键逻辑:上帝模式普通模式的区别。

  • Admin:直接查 sys_menus 全表(排除被物理删除的)。
  • 普通用户:通过 sys_users -> sys_roles -> sys_role_menus -> sys_menus 进行四表联查,只获取拥有的权限。

文件 route/menu.js

router.get('/getRouters', authMiddleware, async (req, res, next) => {
  try {
    const userId = req.user.userId
    const { isAdmin } = req.user

    let sql = ''
    let params = []

    const baseFields = `m.id, m.parent_id, m.menu_name, m.path, m.component, m.icon, m.hidden`

    if (isAdmin) {
      // 管理员:看所有非隐藏菜单
      sql = `SELECT ${baseFields} FROM sys_menus m WHERE m.hidden = 0 ORDER BY m.sort ASC`
    } else {
      // 普通用户:通过中间表关联查询
      sql = `
        SELECT ${baseFields} 
        FROM sys_menus m
        LEFT JOIN sys_role_menus rm ON m.id = rm.menu_id
        LEFT JOIN sys_users u ON u.role_id = rm.role_id
        WHERE u.id = ? AND m.hidden = 0
        ORDER BY m.sort ASC
      `
      params.push(userId)
    }

    const [rows] = await pool.query(sql, params)
    const menuTree = buildTree(rows)

    res.json({ code: 200, data: menuTree })
  } catch (err) {
    next(err)
  }
})

三、 前端实现:组件动态加载

前端拿到后端的 JSON 后,最大的难点在于:后端返回的 component 是字符串 "system/user/index",而 Vue Router 需要的是一个 Promise 组件对象 () => import(...)

在 Webpack 时代我们要用 require.context,而在 Vite 中,我们要用 import.meta.glob。

1. Store 逻辑 (store/modules/permission.ts)

import { defineStore } from 'pinia'
import { constantRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout/index.vue'

// 1. Vite 核心:一次性匹配 views 目录下所有 .vue 文件
// 结果类似: { '../../views/system/user.vue': () => import(...) }
const modules = import.meta.glob('../../views/**/*.vue')

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],        // 完整路由(侧边栏用)
    addRoutes: [],     // 动态路由(router.addRoute用)
    sidebarRouters: [] // 侧边栏菜单
  }),
  
  actions: {
    async generateRoutes() {
      // 请求后端
      const res: any = await getRouters()
      const sdata = JSON.parse(JSON.stringify(res.data))
      
      // 转换逻辑
      const rewriteRoutes = filterAsyncRouter(sdata)
      
      this.addRoutes = rewriteRoutes
      this.sidebarRouters = constantRoutes.concat(rewriteRoutes)
      
      return rewriteRoutes
    }
  }
})

// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap: any[]) {
  return asyncRouterMap.filter(route => {
    if (route.component) {
      if (route.component === 'Layout') {
        route.component = Layout
      } else {
        // 核心:根据字符串去 modules 里找对应的 import 函数
        route.component = loadView(route.component)
      }
    }
    // ... 递归处理 children
    if (route.children && route.children.length) {
      route.children = filterAsyncRouter(route.children)
    }
    return true
  })
}

export const loadView = (view: string) => {
  let res
  for (const path in modules) {
    // 路径匹配逻辑:从 ../../views/system/user.vue 中提取 system/user
    const dir = path.split('views/')[1].split('.vue')[0]
    if (dir === view) {
      res = () => modules[path]()
    }
  }
  return res
}

四、 核心难点:路由守卫与“死循环”

在 src/permission.ts 中,我们需要拦截页面跳转,如果是第一次进入,则请求菜单并添加到路由中。

1. 经典死循环问题

很多新手会这样写判断:

// ❌ 错误写法
if (userStore.roles.length === 0) {
  // 去拉取用户信息 -> 生成路由 -> next()
}

Bug 场景:如果新建了一个没有任何角色的用户 user01,后端返回的 roles 是空数组。

  1. roles.length 为 0,进入 if。
  2. 拉取信息,发现还是空数组。
  3. next(to) 重定向,重新进入守卫。
  4. roles.length 依然为 0... 死循环,浏览器崩溃

2. 解决方案:引入 isInfoLoaded 状态位

我们在 userStore 中增加一个 isInfoLoaded 布尔值,专门标记“是否已经尝试过拉取用户信息”

文件:src/permission.ts

import router from '@/router'
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission' // 引入新的 store
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({ showSpinner: false })

const whiteList = ['/login', '/404']

router.beforeEach(async (to, from, next) => {
  NProgress.start()
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()

  if (userStore.token) {
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      // 判断当前用户是否已拉取完 user_info 信息
      // 这里我们可以简单判断:如果 userStore.userInfo.roles.length==0 动态添加的菜单长度为0,说明还没请求菜单,但似乎 这么写  如果 用户没角色  会陷入死循环
      if (!userStore.isInfoLoaded) {
        try {
          // 2. 生成动态路由 (后端请求)
          const userInfo = await userStore.getInfo()
          console.log('userInfo--', userInfo)
          if (userInfo && userInfo.data.id) {
            const accessRoutes = await permissionStore.generateRoutes()
            // // 3. 后端返回的路由
            console.log('accessRoutes', accessRoutes)
            // 4. 动态添加路由
            accessRoutes.forEach((route) => {
              router.addRoute(route)
            })
          }
          // 4. 确保路由添加完成 (Hack方法)
          next({ path: to.path, query: to.query, replace: true })
        } catch (err) {
          console.log('userinfo -err', err)
          userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        next()
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      console.log('to.path', to.path)
      next()
    } else {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done()
})

五、 侧边栏递归渲染

最后一步是将 sidebarRouters 渲染到左侧菜单. 这里需要注意一个细节:el-tree 或 el-menu 的父节点折叠问题
如果一个目录只有一个子菜单(例如“首页”),我们通常希望直接显示子菜单,不显示父目录。

文件 layout/components/slideBar/index.vue

<template>
  <div :class="classObj">
    <Logo v-if="showLogo" :collapse="isCollapse" />
    <el-scrollbar wrap-class="scrollbar-wrapper" :class="sideTheme">
      <el-menu
        router
        :default-active="activeMenu"
        :collapse="isCollapse"
        :background-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuBackground
            : variables.menuLightBackground
        "
        :text-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuColor
            : variables.menuLightColor
        "
        :active-text-color="theme"
        :unique-opened="true"
        :collapse-transition="false"
      >
        <template v-for="item in slidebarRouters" :key="item.path">
          <!-- 如果只有一个子菜单,直接显示子菜单 -->
          <el-menu-item
            v-if="item.children && item.children.length === 1"
            :index="item.path + '/' + item.children[0].path"
          >
            <el-icon v-if="item.children[0].meta?.icon">
              <component :is="item.children[0].meta.icon" />
            </el-icon>
            <span>{{ item.children[0].meta.title }}</span>
          </el-menu-item>

          <!-- 如果有多个子菜单,显示下拉菜单 -->
          <el-sub-menu
            v-else-if="item.children && item.children.length > 1"
            :index="item.path"
          >
            <template #title>
              <el-icon v-if="item.meta?.icon">
                <component :is="item.meta.icon" />
              </el-icon>
              <span>{{ item.meta.title }}</span>
            </template>
            <el-menu-item
              v-for="subItem in item.children"
              :key="subItem.path"
              :index="item.path + '/' + subItem.path"
            >
              <el-icon v-if="subItem.meta?.icon">
                <component :is="subItem.meta.icon" />
              </el-icon>
              <span>{{ subItem.meta.title }}</span>
            </el-menu-item>
          </el-sub-menu>

          <!-- 如果没有子菜单,直接显示当前菜单 -->
          <el-menu-item v-else :index="item.path">
            <el-icon v-if="item.meta?.icon">
              <component :is="item.meta.icon" />
            </el-icon>
            <span>{{ item.meta.title }}</span>
          </el-menu-item>
        </template>
      </el-menu>
    </el-scrollbar>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import Logo from './Logo.vue'
import { useRoute } from 'vue-router'
import variables from '@/assets/styles/var.module.scss'
const route = useRoute()

import { useSettingsStore } from '@/store/modules/settings'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const sideTheme = computed(() => settingsStore.sideTheme)
const theme = computed(() => settingsStore.theme)
const showLogo = computed(() => settingsStore.showLogo)
const isCollapse = computed(() => !appStore.sidebar.opened)
const classObj = computed(() => ({
  dark: sideTheme.value === 'dark',
  light: sideTheme.value === 'light',
  'has-logo': settingsStore.showLogo,
}))

const slidebarRouters = computed(() =>
  permissionStore.sidebarRouters.filter((item) => {
    return !item.hidden
  })
)

console.log('slidebarRouters', slidebarRouters.value)

// 激活菜单
const activeMenu = computed(() => {
  const { path } = route
  return path
})
</script>

<style scoped lang="scss">
#app {
  .main-container {
    height: 100%;
    transition: margin-left 0.28s;
    margin-left: $base-sidebar-width;
    position: relative;
  }

  .sidebarHide {
    margin-left: 0 !important;
  }

  .sidebar-container {
    -webkit-transition: width 0.28s;
    transition: width 0.28s;
    width: $base-sidebar-width !important;
    background-color: $base-menu-background;
    height: 100%;
    position: fixed;
    font-size: 0px;
    top: 0;
    bottom: 0;
    left: 0;
    z-index: 1001;
    overflow: hidden;
    -webkit-box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
    box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);

    // reset element-ui css
    .horizontal-collapse-transition {
      transition:
        0s width ease-in-out,
        0s padding-left ease-in-out,
        0s padding-right ease-in-out;
    }

    .scrollbar-wrapper {
      overflow-x: hidden !important;
    }

    .el-scrollbar__bar.is-vertical {
      right: 0px;
    }

    .el-scrollbar {
      height: 100%;
    }

    &.has-logo {
      .el-scrollbar {
        height: calc(100% - 50px);
      }
    }

    .is-horizontal {
      display: none;
    }

    a {
      display: inline-block;
      width: 100%;
      overflow: hidden;
    }

    .svg-icon {
      margin-right: 16px;
    }

    .el-menu {
      border: none;
      height: 100%;
      width: 100% !important;
    }

    .el-menu-item,
    .menu-title {
      overflow: hidden !important;
      text-overflow: ellipsis !important;
      white-space: nowrap !important;
    }

    .el-menu-item .el-menu-tooltip__trigger {
      display: inline-block !important;
    }

    // menu hover
    .sub-menu-title-noDropdown,
    .el-sub-menu__title {
      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .is-active > .el-sub-menu__title {
      color: $base-menu-color-active !important;
    }

    & .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .el-sub-menu .el-menu-item {
      min-width: $base-sidebar-width !important;

      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .theme-dark .el-sub-menu .el-menu-item {
      background-color: $base-sub-menu-background !important;

      &:hover {
        background-color: $base-sub-menu-hover !important;
      }
    }
  }

  .hideSidebar {
    .sidebar-container {
      width: 54px !important;
    }

    .main-container {
      margin-left: 54px;
    }

    .sub-menu-title-noDropdown {
      padding: 0 !important;
      position: relative;

      .el-tooltip {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-sub-menu {
      overflow: hidden;

      & > .el-sub-menu__title {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-menu--collapse {
      .el-sub-menu {
        & > .el-sub-menu__title {
          & > span {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
          & > i {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
        }
      }
    }
  }

  .el-menu--collapse .el-menu .el-sub-menu {
    min-width: $base-sidebar-width !important;
  }

  // mobile responsive
  .mobile {
    .main-container {
      margin-left: 0px;
    }

    .sidebar-container {
      transition: transform 0.28s;
      width: $base-sidebar-width !important;
    }

    &.hideSidebar {
      .sidebar-container {
        pointer-events: none;
        transition-duration: 0.3s;
        transform: translate3d(-$base-sidebar-width, 0, 0);
      }
    }
  }

  .withoutAnimation {
    .main-container,
    .sidebar-container {
      transition: none;
    }
  }
}

// when menu collapsed
.el-menu--vertical {
  & > .el-menu {
    .svg-icon {
      margin-right: 16px;
    }
  }

  .nest-menu .el-sub-menu > .el-sub-menu__title,
  .el-menu-item {
    &:hover {
      // you can use $sub-menuHover
      background-color: rgba(0, 0, 0, 0.06) !important;
    }
  }

  // the scroll bar appears when the sub-menu is too long
  > .el-menu--popup {
    max-height: 100vh;
    overflow-y: auto;

    &::-webkit-scrollbar-track-piece {
      background: #d3dce6;
    }

    &::-webkit-scrollbar {
      width: 6px;
    }

    &::-webkit-scrollbar-thumb {
      background: #99a9bf;
      border-radius: 20px;
    }
  }
}

.dark {
  background-color: $base-menu-background !important;
}
.light {
  background-color: $base-menu-light-background !important;
}
.scrollbar-wrapper {
  overflow-x: hidden !important;
}
.has-logo {
  .el-scrollbar {
    height: calc(100% - 50px);
  }
}
</style>

六、 总结与下篇预告

通过本篇实战,我们实现了:

  1. 后端:根据角色权限过滤菜单数据。
  2. 前端:利用 Vite 的 glob 导入特性,将后端字符串动态转为前端组件。
  3. 守卫:通过 isInfoLoaded 状态位完美解决了空权限用户的死循环问题。

现在的系统已经具备了动态菜单的能力。但是,如何更方便地管理这些用户和角色呢?  如果用户很多,列表怎么分页?怎么模糊搜索?

下一篇:《企业级全栈 RBAC 实战 (九):用户管理与 SQL 复杂查询优化》,我们将深入 Element Plus 表格组件与 MySQL 分页查询的结合。

❌
❌