Vue 3 动态路由 + v-permission 指令:菜单权限 + 按钮权限完整方案

作者:忆笙智云官方 | 发布时间:2026-04-29 08:30 | 更新时间:2026-06-29 08:30

前端动态路由与权限按钮实现方案

引言

在企业级后台管理系统中,权限控制是核心需求之一。传统的静态路由方案将所有路由写死在前端代码中,通过 v-if 控制页面显示,这种方式存在两个问题:一是路由信息暴露在前端代码中,存在安全隐患;二是每次新增菜单都需要前端发版。

动态路由方案则由后端返回菜单数据,前端根据菜单数据动态生成路由,实现了菜单与权限的完全后端管控。本文将分享 Vue 3 + Vue Router 中动态路由的实现方案,以及按钮级权限控制 v-permission 指令的实现。

一、整体架构

flowchart TD
    A[用户登录] --> B[获取Token]
    B --> C[请求菜单接口]
    C --> D[后端返回菜单树]
    D --> E[前端解析菜单数据]
    E --> F[动态注册路由]
    F --> G[渲染侧边栏菜单]
    G --> H[用户访问页面]
    H --> I{路由守卫校验}
    I -->|有权限| J[正常访问]
    I -->|无权限| K[跳转403页面]

    style F fill:#e1f5fe,stroke:#0288d1
    style I fill:#fff9c4,stroke:#f9a825

二、后端菜单数据结构

2.1 菜单实体设计

/**
 * 菜单实体
 */
public class SysMenu {
    private Long id;
    private String menuName;      // 菜单名称
    private String path;          // 路由路径
    private String component;     // 组件路径
    private String icon;          // 图标
    private Integer menuType;     // 类型:1-目录 2-菜单 3-按钮
    private String permission;    // 权限标识
    private Long parentId;        // 父菜单ID
    private Integer sortOrder;    // 排序
    private Integer visible;      // 是否可见
}

2.2 菜单接口返回格式

后端接口返回树形结构的菜单数据:

{
  "code": 200,
  "data": [
    {
      "id": 1,
      "menuName": "系统管理",
      "path": "/system",
      "component": "LAYOUT",
      "icon": "setting",
      "menuType": 1,
      "children": [
        {
          "id": 2,
          "menuName": "用户管理",
          "path": "/system/user",
          "component": "system/user/index",
          "icon": "user",
          "menuType": 2,
          "children": [
            {
              "id": 10,
              "menuName": "新增用户",
              "menuType": 3,
              "permission": "system:user:add"
            },
            {
              "id": 11,
              "menuName": "删除用户",
              "menuType": 3,
              "permission": "system:user:delete"
            }
          ]
        }
      ]
    }
  ]
}

2.3 权限标识格式设计

权限标识采用 模块:资源:操作 三段式格式:

格式:module:resource:action

示例:
system:user:add       - 系统模块-用户-新增
system:user:edit      - 系统模块-用户-编辑
system:user:delete    - 系统模块-用户-删除
system:user:query     - 系统模块-用户-查询
system:role:add       - 系统模块-角色-新增
ai:chat:send          - AI模块-对话-发送
ai:chat:export        - AI模块-对话-导出

设计原则

三、前端动态路由实现

3.1 路由生成工具函数

/**
 * 路由工具 - 将后端菜单数据转换为Vue Router路由配置
 */
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue'

/** 后端菜单数据类型 */
interface MenuData {
  id: number
  menuName: string
  path: string
  component: string
  icon: string
  menuType: number  // 1-目录 2-菜单 3-按钮
  permission: string
  children?: MenuData[]
}

/**
 * 将后端菜单数据转换为路由配置
 * @param menus 后端菜单数据
 * @returns Vue Router路由配置数组
 */
export function generateRoutes(menus: MenuData[]): RouteRecordRaw[] {
  const routes: RouteRecordRaw[] = []

  for (const menu of menus) {
    // 按钮类型不生成路由
    if (menu.menuType === 3) continue

    const route: RouteRecordRaw = {
      path: menu.path,
      name: menu.path,
      meta: {
        title: menu.menuName,
        icon: menu.icon,
        permission: menu.permission,
      },
    }

    // 处理组件
    if (menu.component === 'LAYOUT') {
      // 目录类型,使用Layout组件
      route.component = Layout
    } else {
      // 菜单类型,动态导入组件
      route.component = loadView(menu.component)
    }

    // 递归处理子菜单
    if (menu.children && menu.children.length > 0) {
      route.children = generateRoutes(menu.children)
    }

    routes.push(route)
  }

  return routes
}

/**
 * 动态导入视图组件
 * 使用Vite的import.meta.glob实现
 */
const views = import.meta.glob('@/views/**/*.vue')

function loadView(component: string) {
  // component格式: system/user/index
  const path = `/src/views/${component}.vue`
  return views[path] || (() => import('@/views/error/404.vue'))
}

3.2 路由守卫中动态注册

/**
 * 路由守卫 - 登录后动态注册路由
 */
import { createRouter, createWebHistory } from 'vue-router'
import { generateRoutes } from './routeUtils'

// 静态路由(不需要权限的页面)
const constantRoutes: RouteRecordRaw[] = [
  { path: '/login', component: () => import('@/views/login/index.vue') },
  { path: '/403', component: () => import('@/views/error/403.vue') },
  { path: '/404', component: () => import('@/views/error/404.vue') },
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
})

// 标记是否已加载动态路由
let isRoutesLoaded = false

router.beforeEach(async (to, from, next) => {
  const token = localStorage.getItem('token')

  // 未登录,跳转登录页
  if (!token) {
    if (to.path === '/login') return next()
    return next('/login')
  }

  // 已登录但访问登录页,跳转首页
  if (to.path === '/login') return next('/')

  // 动态路由已加载,直接放行
  if (isRoutesLoaded) return next()

  try {
    // 1. 获取后端菜单数据
    const menus = await fetchMenuData()

    // 2. 生成路由配置
    const dynamicRoutes = generateRoutes(menus)

    // 3. 动态注册路由
    dynamicRoutes.forEach(route => {
      router.addRoute(route)
    })

    // 4. 添加兜底路由
    router.addRoute({
      path: '/:pathMatch(.*)*',
      redirect: '/404',
    })

    // 5. 标记已加载
    isRoutesLoaded = true

    // 6. 重新导航到目标路由(确保动态路由生效)
    next({ ...to, replace: true })
  } catch (error) {
    // 获取菜单失败,清除Token跳转登录
    localStorage.removeItem('token')
    next('/login')
  }
})

export default router

3.3 动态路由注册流程

sequenceDiagram
    participant U as 用户
    participant G as 路由守卫
    participant API as 后端接口
    participant R as Vue Router

    U->>G: 访问 /system/user
    G->>G: 检查Token ✓
    G->>G: 检查路由是否已加载 ✗
    G->>API: GET /api/menus
    API-->>G: 返回菜单树数据
    G->>G: generateRoutes() 转换路由
    loop 每条路由
        G->>R: router.addRoute(route)
    end
    G->>R: router.addRoute(404兜底)
    G->>U: next({...to, replace: true})
    U->>R: 重新导航 /system/user
    R-->>U: 渲染用户管理页面

四、按钮级权限控制

4.1 权限状态管理

/**
 * 权限Store - 管理用户权限标识列表
 */
import { defineStore } from 'pinia'

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    /** 用户拥有的权限标识集合 */
    permissions: new Set<string>(),
  }),

  actions: {
    /**
     * 设置权限列表
     * 从菜单数据中提取所有按钮权限标识
     */
    setPermissions(menus: MenuData[]) {
      const perms = new Set<string>()
      this.extractPermissions(menus, perms)
      this.permissions = perms
    },

    /**
     * 递归提取权限标识
     */
    extractPermissions(menus: MenuData[], perms: Set<string>) {
      menus.forEach(menu => {
        if (menu.menuType === 3 && menu.permission) {
          perms.add(menu.permission)
        }
        if (menu.children) {
          this.extractPermissions(menu.children, perms)
        }
      })
    },

    /**
     * 判断是否拥有指定权限
     */
    hasPermission(permission: string): boolean {
      return this.permissions.has(permission)
    },
  },
})

4.2 v-permission 指令实现

/**
 * v-permission 自定义指令
 * 用于按钮级别的权限控制
 *
 * 使用方式:
 * <el-button v-permission="'system:user:add'">新增</el-button>
 * <el-button v-permission="['system:user:edit', 'system:user:delete']">操作</el-button>
 */
import type { Directive, DirectiveBinding } from 'vue'
import { usePermissionStore } from '@/stores/permission'

export const vPermission: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
    const permissionStore = usePermissionStore()
    const { value } = binding

    if (!value) return

    const permissions = Array.isArray(value) ? value : [value]

    // 检查是否拥有任一权限
    const hasPermission = permissions.some(
      perm => permissionStore.hasPermission(perm)
    )

    // 无权限则移除元素
    if (!hasPermission) {
      el.parentNode?.removeChild(el)
    }
  },
}

4.3 全局注册指令

// main.ts
import { createApp } from 'vue'
import { vPermission } from './directives/permission'
import App from './App.vue'

const app = createApp(App)

// 注册全局自定义指令
app.directive('permission', vPermission)

app.mount('#app')

4.4 使用示例

<template>
  <div class="user-management">
    <!-- 工具栏 -->
    <div class="toolbar">
      <!-- 只有拥有新增权限的用户才能看到此按钮 -->
      <el-button v-permission="'system:user:add'" type="primary" @click="handleAdd">
        新增用户
      </el-button>

      <!-- 只有拥有导出权限的用户才能看到此按钮 -->
      <el-button v-permission="'system:user:export'" @click="handleExport">
        导出
      </el-button>
    </div>

    <!-- 表格操作列 -->
    <el-table :data="tableData">
      <el-table-column label="操作" width="200">
        <template #default="{ row }">
          <el-button v-permission="'system:user:edit'" link @click="handleEdit(row)">
            编辑
          </el-button>
          <el-button v-permission="'system:user:delete'" link type="danger" @click="handleDelete(row)">
            删除
          </el-button>
          <!-- 多权限:拥有重置密码或分配角色任一权限即可看到 -->
          <el-button v-permission="['system:user:resetPwd', 'system:user:assignRole']"
                     link @click="handleMore(row)">
            更多
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

五、权限判断函数(编程式使用)

除了指令方式,有时需要在逻辑代码中判断权限:

/**
 * 权限判断组合式函数
 */
import { usePermissionStore } from '@/stores/permission'

export function usePermission() {
  const permissionStore = usePermissionStore()

  /**
   * 判断是否拥有指定权限
   */
  function hasPermission(permission: string | string[]): boolean {
    const perms = Array.isArray(permission) ? permission : [permission]
    return perms.some(p => permissionStore.hasPermission(p))
  }

  return { hasPermission }
}

在逻辑代码中使用:

// 在组合式函数中使用
const { hasPermission } = usePermission()

// 控制导出逻辑
function handleExport() {
  if (!hasPermission('system:user:export')) {
    ElMessage.warning('您没有导出权限')
    return
  }
  // 执行导出逻辑
  doExport()
}

六、权限控制流程总览

flowchart TD
    A[用户登录成功] --> B[请求菜单接口]
    B --> C[后端根据角色返回菜单]
    C --> D[前端解析菜单数据]
    D --> E[提取路由信息 → 动态注册路由]
    D --> F[提取权限标识 → 存入PermissionStore]
    E --> G[侧边栏渲染菜单]
    F --> H[v-permission指令控制按钮]
    F --> I[hasPermission函数控制逻辑]

    style E fill:#e1f5fe,stroke:#0288d1
    style F fill:#f3e5f5,stroke:#7b1fa2
    style H fill:#fff9c4,stroke:#f9a825
    style I fill:#fff9c4,stroke:#f9a825

结论与建议

核心要点

  1. 动态路由:后端返回菜单数据,前端动态注册路由,实现菜单的完全后端管控
  2. 权限标识三段式模块:资源:操作 格式清晰、可扩展、便于管理
  3. v-permission 指令:声明式权限控制,使用简洁,与模板代码无缝集成
  4. 路由守卫:在 beforeEach 中统一处理动态路由加载,避免白屏问题

注意事项

相关资源