Skip to content

按钮权限与 Vue3 入门(一) ​

本页对应 按钮权限与 Vue3 入门,共 6 段视频,约 1 小时 8 分。

学习目标 ​

  • 能够复述基于 RBAC 的菜单权限生成流程,并说明角色管理、员工管理与用户权限数据之间的绑定关系。
  • 能够在退出登录时清空动态路由记录及 Vuex 中的菜单和用户权限数据,并验证切换账号后菜单不再复用上一用户缓存。
  • 能够注册并启用全局按钮权限指令,在指令钩子中获取绑定元素与权限标识,完成按钮显示/隐藏控制。
  • 能够根据当前用户 permissions 判断按钮权限标识是否存在,并验证有权限按钮显示、无权限按钮被移除。
  • 能够使用 render 函数及 h 函数创建基础模板结构,说明标签名、属性配置和子节点三个参数的作用。
  • 能够识别适合使用 render 函数的场景,并用动态标签名示例说明其相比模板枚举的灵活性。

知识点 ​

为什么需要把“获取用户信息”封装成接口 ​

在权限控制流程里,第一步就是拿到当前用户的权限数据。转写稿里反复提到“调接口拿到 permissions”,并强调这个数据会继续参与后面的动态路由过滤、左侧菜单渲染。也就是说,页面组件不应该自己到处写 url、method,否则同一个接口一旦地址或请求方式变化,就要满项目查找修改。

“怎么想”很简单:把接口请求收敛到 API 层。业务代码只关心“我要获取用户信息”,不关心请求细节。这样命名清晰、复用方便,也便于统一调试。转写稿中提到的“调 z 口”应理解为“调接口”,“permission 字段”按项目实际字段应写作 permissions。

“怎么做”:一个接口对应一个导出函数,函数内部返回 request(...) 的结果。注意这里必须 return,因为调用方通常需要拿到 Promise,再通过 await 或 .then 取返回数据。

本节笔记代码如下,保留原样:

javascript
/**
 * @description: 获取用户信息
 * @param {*} data {}
 * @return {*} promise
 */
export function getProfileAPI() {
  return request({
    url: '/park/user/profile',
    method: 'GET'
  })
}

易错点:函数名要能表达业务含义;url 不要拼错;method 用大写 GET;不要漏写 return,否则调用处拿到 undefined,后续取 permissions 就会报错。

怎么在权限流程里理解这个接口 ​

转写稿的调试过程是:切换到刚配置好的员工账号,刷新页面,打开检查工具,查看“当前用户”的数据,在 permissions 字段里看到刚才通过角色分配出来的两个权限数据。这个过程的本质就是:getProfileAPI 负责把后端返回的用户信息取回来,后续逻辑再从返回结果里读 permissions。

“怎么想”:封装接口不是终点,而是数据流的起点。接口返回 Promise,调用方拿到结果后,才进入权限数据过滤。转写稿里说“第一步调用 action 函数获取当前用户权限数据”,这里可以理解为:action 或页面逻辑调用 getProfileAPI(),再把结果保存到用户状态中。注意不要编造具体响应结构,调试时以实际返回为准;转写稿明确看到的是 permissions 字段。

“怎么做”:调用处应检查 Promise 的结果,确认 permissions 是否存在。若接口失败,要有错误处理;若字段为空,要能区分“没有权限”和“接口没返回”。

易错点:不要把接口函数写成组件内部方法,否则复用性差;不要在 API 函数里直接操作 store 或路由,保持职责单一;调试时先看网络请求的 url、method、返回体,再看业务字段。

调试与常见问题 ​

结合转写稿的调试思路,排查顺序应是:先确认请求是否发出,再确认 url 是否为 /park/user/profile,method 是否为 GET,最后看返回对象里有没有 permissions。转写稿里还提到切换用户后左侧菜单仍显示旧数据的问题,那是路由缓存问题,不是 getProfileAPI 本身的问题;但接口返回的数据是否正确,是后续判断的基础。

“怎么想”:接口封装层要稳定,业务层才好排查。若页面拿不到权限,先排除接口层,再排查 store、路由过滤和菜单渲染。

“怎么做”:在调用 getProfileAPI() 的地方打印或断点查看返回结果;确认 request 已正确导入;确认导出函数被正确引入。

易错点:同音字纠正——“接口”不要写成“z 口”,“路由”不要写成“漏有”,“实例对象”不要写成“实力对象”;permissions 不要漏 s。封装接口看似只有几行,但它是权限链路的第一环,写稳了,后面的过滤和渲染才有可靠数据源。

这个 Vuex 模块解决什么问题 ​

为什么需要:登录后要拿到当前用户的权限数据,左侧菜单、按钮权限指令等多个地方都要用。如果每个组件都单独调一次 getProfileAPI,请求会重复,数据也容易不同步。把用户信息放进 Vuex,做成带命名空间的模块,就能集中请求、集中保存、集中清空。

怎么想:state 存当前用户信息 profile;mutations 只做同步赋值;actions 负责异步请求接口,拿到结果后再 commit。加 namespaced: true,避免不同模块的 getProfile、setProfile 重名。

怎么做:本节笔记代码如下:

vue
import { getProfileAPI } from '@/apis/user'
export default {
  namespaced: true,
  state: () => {
    return {
      profile: {}
    }
  },
  mutations: {
    setProfile(state, profile) {
      state.profile = profile
    }
  },
  actions: {
    async getProfile(ctx) {
      const res = await getProfileAPI()
      ctx.commit('setProfile', res.data)
    }
  }
}

易错点:state 要用函数返回对象,避免模块间共享同一份引用;不要在 action 里直接改 state.profile,要走 commit;模块开启命名空间后,组件里通常要 dispatch('user/getProfile'),模块内部 commit('setProfile') 不用再加模块名。

从权限数据流转看 Vuex 的位置 ​

为什么需要:转写稿里反复强调,权限数据不是前端随便写的。先在角色管理里给角色分配权限点,再把角色绑定给员工,登录后接口返回当前用户的 permissions。这个数据要供菜单过滤和按钮权限共同使用。

怎么想:getProfile 拿到的 profile 里包含 permissions。左侧菜单渲染时,从 Vuex 里取筛选后的 menuList;按钮权限指令里,从 store.state.user.profile.permissions 取当前用户权限列表,再用按钮自己的权限标识去匹配。也就是说,Vuex 是“接口数据”和“页面消费”之间的中转站。

怎么做:登录后触发 user/getProfile,把返回数据 commit 到 setProfile。按钮指令中先引入 store,再取 store.state.user.profile.permissions,用 includes 判断当前按钮标识是否存在。

易错点:一级路由权限和按钮权限不是一回事。转写稿中“楼宇、区域”这类一级权限用于控制左侧菜单;下面的“添加、删除”才是按钮权限。匹配按钮时不要拿错层级。

调试:切换用户后为什么还是旧菜单 ​

为什么需要:转写稿演示了一个 bug:从低权限用户切到管理员后,左侧菜单仍然只有两项。原因是切换用户时复用了上一次的路由实例和 Vuex 缓存。

怎么想:只重新登录不够,旧路由记录还在;Vuex 里的 menuList 和 profile 也可能还是上一个用户的。要清两处:路由系统中通过 addRoute 添加的动态记录,以及 Vuex 中保存的菜单和用户信息。

怎么做:项目里封装了 resetRouter。转写稿说明它不是官方方法,而是用新 router 实例的 matcher 覆盖老实例的 matcher,从而清空之前 addRoute 添加的记录。退出登录时调用它,同时在 Vuex 中把 menuList 恢复为静态路由,把 user.profile 清空为空对象。这样新用户进来会重新走添加逻辑。

易错点:只清路由不清 Vuex,左侧菜单可能还读旧数据;只清 Vuex 不清路由,路由匹配仍可能复用旧记录。清空 profile 时保持结构,例如赋值为 {},避免消费处直接报错。

按钮权限中如何消费 Vuex ​

为什么需要:页面上很多按钮都要按权限显示或隐藏,不能每个按钮写一套判断。转写稿采用全局指令方案:按钮标识能在用户 permissions 里找到就显示,找不到就移除。

怎么想:指令的 inserted 钩子能拿到 el 和 binding.value。el 是按钮真实元素,binding.value 是按钮权限标识。再从 Vuex 取当前用户权限列表做 includes 判断。

怎么做:注册全局指令后,在按钮上写类似 v-btn-auth="'enterprise:add'"。指令内部取 store.state.user.profile.permissions,若 !permissions.includes(binding.value),就 el.remove()。转写稿中调试时先打印 el 和 binding.value,确认参数能拿到,再补核心逻辑。

易错点:指令写完要引入注册,否则模板会报“指令没有注册”;binding.value 要传真实权限标识,不能随便传字符串;permissions 初始可能为空,消费时要保证结构存在。转写稿还提到 render 函数和 H 函数,那是模板编写的拓展内容,本节 Vuex 逻辑的重点仍是:接口数据进 store,菜单和按钮从 store 取,退出登录时把 store 与路由缓存一起清干净。

先理解权限数据的来源:角色绑定员工 ​

为什么需要:路由守卫里要触发 getProfile,前提是后端已经能返回当前用户的权限数据。转写稿里明确讲了,权限数据不是凭空写死的,而是先在“角色管理”里给角色分配功能权限点,再到“员工管理”里把角色绑定给员工。这样员工登录后,后端才能把该角色下的权限点返回给前端。这个设计就是 RBAC(基于角色的权限控制):先给角色分配权限,再把角色分配给员工,员工就自动拥有角色下的权限点。

怎么想:不要把 permissions 当成前端临时拼出来的数组。它来自后端接口,前端在登录后通过 getProfile 取回,并写入 Vuex。后续动态路由过滤、左侧菜单渲染、按钮权限判断,都依赖这份数据。所以本节的重点不是“写死权限”,而是“在路由守卫里触发获取权限的 action”。

怎么做:在角色管理里勾选权限点,例如园区管理下的楼宇管理、企业管理下的添加/编辑等;再到员工管理里把角色绑定给员工。切换成该员工账号登录后,刷新页面,在接口返回里查看 permission/permissions 字段。转写稿中调试时就是打开检查面板,找到当前用户数据,确认里面出现了刚才配置的权限点。

易错点:不要把“角色”和“员工”混为一谈;角色改权限后,员工绑定关系还在,但需要重新登录或重新获取用户信息才能拿到新权限;后端返回字段可能是 permission 或 permissions,要以实际接口为准,不能凭感觉写。

在路由守卫中触发 getProfile action ​

为什么需要:页面一跳转,路由守卫就会执行。此时如果只有 token,却还没有当前用户的权限数据,就无法判断动态路由和菜单。所以需要在 permission 文件的路由守卫里触发 user/getProfile,把权限数据取回来。

怎么想:先判断 token。有 token 时,先放行当前导航,再检查 store.state.user.profile.id 是否存在;如果没有,就说明用户信息还没取过,调用 store.dispatch('user/getProfile')。用 profile.id 做判断,是为了避免每次路由跳转都重复请求用户信息。

怎么做:保留本节笔记代码:

javascript
// 权限控制
import router from './router'
import { getCookie } from './utils/auth'
import store from './store'

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

router.beforeEach(async(to, from, next) => {
  const token = getCookie('park_token')
  // 有token
  if (token) {
    next()
    // 获取用户信息
    if (!store.state.user.profile.id) {
      // 获取原始权限列表
      const permissions = await store.dispatch('user/getProfile')
      console.log(permissions)
    }
  } else {
    // 没有token
    if (WHITE_LIST.includes(to.path)) {
      next()
    } else {
      next('/login')
    }
  }
})

易错点:next() 的调用时机要清楚。笔记代码是“有 token 先 next(),再 await 获取用户信息”,它表示先放行导航,再补用户信息;如果你希望“拿到权限后再放行”,就要把 next() 放到 await 之后,但要注意不能重复调用 next()。另外 WHITE_LIST.includes(to.path) 判断的是路径,不要写成 from.path;cookie 名要和登录时写入的一致,转写稿里是 park_token。

getProfile 里写入 store 并返回 permissions ​

为什么需要:路由守卫只负责触发 action,真正发请求、写 store、返回权限列表的是 getProfile。如果这里不 commit,后续菜单和按钮权限就拿不到数据;如果不 return,守卫里 await 得到的就是 undefined,无法打印和继续使用权限列表。

怎么想:getProfileAPI() 返回的 res.data 是用户信息,里面包含 permissions。先把整个用户信息写入 setProfile,再把 res.data.permissions 返回给调用方。这样 store 里有完整用户信息,守卫里也能直接拿到权限数组。

怎么做:保留本节笔记代码:

javascript
async getProfile(ctx) {
  const res = await getProfileAPI()
  ctx.commit('setProfile', res.data)
  return res.data.permissions
}

易错点:ctx.commit('setProfile', res.data) 的模块命名空间要确认,如果 user 模块开启了 namespaced: true,action 里通常写 commit('setProfile', ...) 是可以的,但外部 dispatch 要写 user/getProfile;不要把 res.data.permissions 写成 res.permissions;返回值不要漏写 return。

调试与验证:看请求、看 store、看打印 ​

为什么需要:权限流程链路长,光看代码容易漏。转写稿里反复强调“每一步都看一下数据”,因为 permissions 是否返回、profile.id 是否写入、守卫是否重复触发,都会影响结果。

怎么想:先看接口返回有没有权限点;再看 store.state.user.profile 是否被 setProfile 写入;最后看守卫里的 console.log(permissions) 是否打印出数组。转写稿中就是通过检查面板确认 permission 字段里出现了刚才配置的两个权限点,再回到代码里确认 action 被触发。

怎么做:登录后刷新页面,打开控制台看 permissions;在 Vuex 里看 user/profile;如果发现左侧菜单不对,先确认当前账号的角色和员工绑定是否正确。转写稿里还提到切换用户后左侧仍显示上一个用户的菜单,这是路由缓存问题,后续会用 resetRouter 和清空 menuList 解决;但本节先确保 getProfile 能正确触发并返回权限数据。

易错点:不要只打印 res 不看 res.data;不要在 profile.id 已有值时还重复请求;刷新后如果 profile.id 丢失,要检查 store 是否持久化或是否重新登录;console.log 调试完记得清理。

为什么要把动态路由表拆出来 ​

为什么需要:静态路由是每个用户都能看到的,项目初始化时就该注册;动态路由要跟当前用户的权限走,不同角色看到的菜单数量不一样。转写稿里反复强调:静态路由“一上来就放到 routes 选项位置”,动态路由“个数不一致,需要权限控制”。如果全塞进 routes,低权限用户也会把不该有的路由注册进去。

怎么想:先按“是否需要权限控制”分两桶。静态路由留在 router/index.js 的 routes;动态路由单独放一个文件,用 export const asyncRoutes 导出,当成“权限过滤的数据源”。

怎么做:把下面这份动态路由表从主路由文件里拆出来,单独导出。主路由只保留静态路由;权限流程里再 import { asyncRoutes },拿到当前用户 permissions 后过滤,得到“最终路由表”。

易错点:拆出去后忘记导出/导入,菜单和路由都不生效;不要把动态路由又塞回 routes,否则拆分失去意义。

动态路由表的结构设计 ​

为什么需要:动态路由表不是随便写几个 path,它要同时服务三件事:生成左侧菜单、注册路由、做权限匹配。所以每一项要带 permission、meta.title、meta.icon,一级还要用 Layout 做容器。

怎么想:一级路由代表业务模块,permission: 'park' 这类标识控制模块是否出现;children 里的二级路由用 permission: 'park:building' 控制子菜单和页面。meta.title 给菜单文字,meta.icon 给菜单图标,component 用 () => import(...) 懒加载。

怎么做:保留本节笔记代码:

javascript
import Layout from '@/layout'

// 1. 动态路由: 需要做权限控制 可以根据不同的权限 数量上的变化
// 2. 静态路由: 不需要做权限控制 每一个用户都可以看到 初始化的时候初始化一次

// 动态路由表
export const asyncRoutes = [
  {
    path: '/park',
    component: Layout,
    permission: 'park',
    meta: { title: '园区管理', icon: 'el-icon-office-building' },
    children: [{
      path: 'building',
      permission: 'park:building',
      meta: { title: '楼宇管理' },
      component: () => import('@/views/Park/Building/index')
    },
    {
      path: 'enterprise',
      permission: 'park:enterprise',
      meta: { title: '企业管理' },
      component: () => import('@/views/Park/Enterprise/index')
    }]
  },

  {
    path: '/parking',
    component: Layout,
    permission: 'parking',
    meta: { title: '行车管理', icon: 'el-icon-guide' },
    children: [{
      path: 'area',
      permission: 'parking:area',
      component: () => import('@/views/Car/CarArea'),
      meta: { title: '区域管理' }
    }, {
      path: 'card',
      permission: 'parking:card',
      component: () => import('@/views/Car/CarCard'),
      meta: { title: '月卡管理' }
    }, {
      path: 'pay',
      permission: 'parking:payment',
      component: () => import('@/views/Car/CarPay'),
      meta: { title: '停车缴费管理' }
    },
    {
      path: 'rule',
      permission: 'parking:rule',
      component: () => import('@/views/Car/CarRule'),
      meta: { title: '计费规则管理' }
    }]
  },
  {
    path: '/pole',
    component: Layout,
    permission: 'pole',
    meta: { title: '一体杆管理', icon: 'el-icon-refrigerator' },
    children: [{
      path: 'info',
      permission: 'pole:info',
      component: () => import('@/views/Rod/RodManage'),
      meta: { title: '一体杆管理' }
    }, {
      path: 'waring',
      permission: 'pole:warning',
      component: () => import('@/views/Rod/RodWarn'),
      meta: { title: '告警记录' }
    }]
  },
  {
    path: '/sys',
    component: Layout,
    permission: 'sys',
    meta: { title: '系统管理', icon: 'el-icon-setting' },
    children: [{
      path: 'role',
      permission: 'sys:role',
      component: () => import('@/views/System/Role/index'),
      meta: { title: '角色管理' }
    }, {
      path: 'user',
      permission: 'sys:user',
      component: () => import('@/views/System/Employee/index'),
      meta: { title: '员工管理' }
    }]
  }
]

易错点:permission 要和后端返回的权限标识严格一致,比如 parking:payment 不要写成 parking:pay;父子 path 拼接关系要清楚;component 写成函数返回 import(),不要提前执行。

过滤、注册和菜单渲染的推导 ​

为什么需要:转写稿里说,拿到当前用户权限数据后,“过滤的是动态路由表”。权限数据在 permissions 字段里,可能是 park、parking 以及二级标识;动态路由表只是数据源,不能直接全量用。

怎么想:先调接口拿 permissions,再用一级、二级权限标识去过滤 asyncRoutes,得到“符合要求的最终路由表”。这份最终路由表有两个用途:一是存到 Vuex 的 menuList,供 Layout 里的侧边栏遍历渲染;二是通过 router.addRoute 动态加入路由系统。

怎么做:在权限流程中导入 asyncRoutes,过滤后 addRoute,再 commit 到 menuList。转写稿里调试时打印最终路由表,发现它仍然是标准的 route 配置对象,里面有 path、component 等,只是数量变成了当前用户有权限的两项。

易错点:只渲染菜单不 addRoute,页面刷新或直接访问地址会失效;addRoute 重复添加会出问题;退出登录不清空,会复用上一个用户的路由缓存。转写稿里切换管理员后左侧仍只有两项,就是旧路由实例缓存导致的,解决思路是退出登录时重置路由并清空 menuList 和用户权限数据。

先看本节代码:初始化只挂静态路由 ​

本节的核心不是“把权限做完”,而是先把路由拆清楚:项目初始化时,routes 里只放静态路由;需要按用户权限筛选的动态路由,不在这里直接挂载,而是在后续权限流程里再 addRoutes 进去。这样初始化阶段就不会把无权限的路由提前暴露出来。

javascript
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

/* Layout */
import Layout from '@/layout'

// 俩种路由

export const routes = [
  {
    path: '/login',
    component: () => import('@/views/Login/index'),
    hidden: true
  },
  {
    path: '/addCard',
    component: () => import('@/views/Car/CarCard/add-card'),
    hidden: true
  },
  {
    path: '/addEnterprise',
    component: () => import('@/views/Park/Enterprise/AddEnterprise'),
    hidden: true
  },
  {
    path: '/enterpriseDetail',
    component: () => import('@/views/Park/Enterprise/EnterpriseDetail'),
    hidden: true
  },
  {
    path: '/addRole',
    component: () => import('@/views/System/Role/AddRole'),
    hidden: true
  },
  {
    path: '/',
    component: Layout,
    redirect: '/workbench'
  },
  // 只有一级路由
  // 一级路由:负责把layout架子渲染出来
  // 二级路由:path为空 会作为默认的二级路由一上来就渲染出来
  {
    path: '/workbench',
    component: Layout,
    children: [{
      path: '',
      component: () => import('@/views/Workbench/index'),
      meta: { title: '工作台', icon: 'el-icon-data-board' }
    }]
  },

  {
    path: '/404',
    component: () => import('@/views/404'),
    hidden: true
  }
]

const createRouter = () => new Router({
  // mode: 'history', // require service support
  mode: 'history',
  scrollBehavior: () => ({ y: 0 }),
  routes: [...routes]
})

const router = createRouter()

// 重置路由方法
export function resetRouter() {
  // 得到一个全新的router实例对象
  const newRouter = createRouter()
  // 使用新的路由记录覆盖掉老的路由记录
  router.matcher = newRouter.matcher
}

export default router

为什么初始化只处理静态路由表 ​

为什么需要:转写稿里明确区分了“静态路由”和“动态路由”。静态路由是一上来就放到 routes 选项位置、项目初始化后就能用的路由,不需要权限控制;动态路由表个数不固定,需要按当前用户权限筛选。
怎么想:如果初始化就把所有路由都挂上,低权限用户也可能访问到不该出现的页面;所以初始化只处理登录、404、工作台、隐藏详情页等静态项。
怎么做:把静态路由集中到 routes,createRouter() 只传 routes: [...routes]。
易错点:不要把动态路由直接塞进这个 routes 数组;hidden: true 只是菜单隐藏标记,不等于权限控制。

createRouter 的作用与浅拷贝 ​

为什么需要:resetRouter 要能重新生成一个干净实例,所以先把创建逻辑抽成 createRouter()。
怎么想:初始化时实例化 Router,传入静态路由;后续重置时再调用同一个工厂函数。
怎么做:const router = createRouter() 得到当前路由实例;routes: [...routes] 做一层数组浅拷贝。
易错点:浅拷贝不是深拷贝,路由对象本身仍被引用;不要以为 [...routes] 能替代权限过滤。

resetRouter 为什么要覆盖 matcher ​

为什么需要:转写稿里说,切换用户后左侧菜单仍是上一个用户的缓存,原因是复用了上一次的路由实例;动态添加过的路由记录还在。
怎么想:resetRouter 不是官方 API,而是项目里自己补的方案:创建一个全新 Router,再用新实例的 matcher 覆盖老实例的 matcher,从而清掉之前 addRoutes 添加的记录。
怎么做:const newRouter = createRouter(); router.matcher = newRouter.matcher。
易错点:转写稿里“measure”是同音误写,实际是 matcher;重置路由后还要清 Vuex 里的 menuList 和用户权限数据,否则菜单和按钮权限仍可能残留。

退出登录时的调试流程 ​

为什么需要:只重置路由,左侧菜单仍可能来自 Vuex 缓存。
怎么想:退出登录意味着要切换用户,应把“路由记录、菜单数据、用户权限”一起复原。
怎么做:退出登录时调用 resetRouter(),把 menuList 恢复为静态路由初始值,并清空 userProfile;再登录新用户时重新走权限筛选和 addRoutes。
易错点:不要只清一处;转写稿演示过,切换管理员后左侧仍只有两项,就是旧缓存没清干净。初始化阶段只处理静态路由表,动态路由要等权限数据拿到后再添加。

代码示例 ​

示例一:路由拆分 —— 初始化时只挂静态路由,并导出 resetRouter ​

js
// src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

/* Layout */
import Layout from '@/layout'

// 1. 静态路由:不需要权限控制,每个用户都能看到,初始化时只初始化一次
// 2. 动态路由:需要权限控制,数量随权限变化,放在 asyncRoutes.js 里
export const routes = [
  {
    path: '/login',
    component: () => import('@/views/Login/index'),
    hidden: true
  },
  {
    path: '/addCard',
    component: () => import('@/views/Car/CarCard/add-card'),
    hidden: true
  },
  {
    path: '/addEnterprise',
    component: () => import('@/views/Park/Enterprise/AddEnterprise'),
    hidden: true
  },
  {
    path: '/enterpriseDetail',
    component: () => import('@/views/Park/Enterprise/EnterpriseDetail'),
    hidden: true
  },
  {
    path: '/addRole',
    component: () => import('@/views/System/Role/AddRole'),
    hidden: true
  },
  {
    path: '/',
    component: Layout,
    redirect: '/workbench'
  },
  // 只有一级路由:一级路由负责把 layout 架子渲染出来
  // 二级路由 path 为空,会作为默认的二级路由一上来就渲染
  {
    path: '/workbench',
    component: Layout,
    children: [{
      path: '',
      component: () => import('@/views/Workbench/index'),
      meta: { title: '工作台', icon: 'el-icon-data-board' }
    }]
  },
  {
    path: '/404',
    component: () => import('@/views/404'),
    hidden: true
  }
]

const createRouter = () => new Router({
  mode: 'history', // require service support
  scrollBehavior: () => ({ y: 0 }),
  routes: [...routes]
})

const router = createRouter()

// 重置路由方法(非官方 API,是脚手架作者 hack 出来的)
export function resetRouter() {
  // 得到一个全新的 router 实例对象
  const newRouter = createRouter()
  // 使用新的路由记录覆盖掉老的路由记录
  router.matcher = newRouter.matcher
}

export default router

关键行与易错点:

  • routes: [...routes] 这里必须展开复制一份。如果直接写 routes,后续 addRoute 往实例里加动态路由时容易污染源数组,导致重复登录后路由翻倍。
  • resetRouter 不是 Vue Router 官方方法,原理是:新建一个干净实例,把它的 matcher(内部维护的真实路由记录对象)赋值给老实例的 matcher,一次性抹掉所有 addRoute 加进去的动态记录。
  • 易错点:resetRouter 必须 export 出去,在 store 里 import 调用,不要在业务页面里自己 new Router()。
  • 易错点:只重置 matcher 不够,左侧菜单数据存在 Vuex 里,还要同时把菜单数据也复原成静态路由表,否则页面左侧还是旧菜单。

示例二:Vuex 存用户权限 + 全局前置守卫触发 action ​

js
// src/store/modules/user.js
import { getProfileAPI } from '@/apis/user'

export default {
  namespaced: true,
  state: () => {
    return {
      profile: {}
    }
  },
  mutations: {
    setProfile(state, profile) {
      state.profile = profile
    },
    clearProfile(state) {
      state.profile = {}
    }
  },
  actions: {
    async getProfile(ctx) {
      const res = await getProfileAPI()
      ctx.commit('setProfile', res.data)
      // 把权限标识数组返回给调用方(守卫里要用它过滤动态路由)
      return res.data.permissions
    }
  }
}
js
// src/store/modules/menu.js
import { routes } from '@/router'
import { resetRouter } from '@/router'

export default {
  namespaced: true,
  state: () => {
    return {
      // 初始值就是静态路由表,每个用户都一样
      menuList: [...routes]
    }
  },
  mutations: {
    setMenuList(state, list) {
      state.menuList = list
    },
    // 退出登录时清空路由 + 菜单
    resetMenu(state) {
      resetRouter()
      state.menuList = [...routes]
    }
  }
}
js
// src/permission.js
// 权限控制
import router from './router'
import { getCookie } from './utils/auth'
import store from './store'

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

router.beforeEach(async (to, from, next) => {
  const token = getCookie('park_token')
  // 有 token
  if (token) {
    next()
    // 获取用户信息(profile.id 有值说明已经取过,不用重复请求)
    if (!store.state.user.profile.id) {
      // 获取原始权限列表
      const permissions = await store.dispatch('user/getProfile')
      console.log(permissions)
    }
  } else {
    // 没有 token
    if (WHITE_LIST.includes(to.path)) {
      next()
    } else {
      next('/login')
    }
  }
})
js
// 退出登录处(Header 组件里)
async logout() {
  // 1. 清空用户信息
  this.$store.commit('user/clearProfile')
  // 2. 清空动态路由记录 + 复原菜单数据
  this.$store.commit('menu/resetMenu')
  // 3. 跳回登录页
  this.$router.push('/login')
}

关键行与易错点:

  • namespaced: true 之后,action 必须写全名 'user/getProfile',mutation 必须写全名 'user/setProfile',少写模块名会提示 "unknown action type"。
  • action 里 return res.data.permissions 很关键:dispatch 返回的是 action 的返回值,守卫里 await store.dispatch(...) 才能拿到权限数组,否则拿到的是 undefined。
  • if (!store.state.user.profile.id) 是「是否已获取过用户信息」的判据,不加这个判断每次路由跳转都会重新请求接口。
  • 易错点:next() 只能调用一次,且不要在 next() 之后又写 next('/login')。
  • 易错点:白名单里必须包含 /404,否则无 token 访问 404 页面会被反复重定向到登录页。
  • 易错点:切换账号后菜单不刷新,原因是复用了上一次的 router 实例缓存,解决方式就是上面 resetMenu 里调 resetRouter() + 复原 menuList。

示例三:按钮权限控制 —— 全局指令 v-btn-auth ​

js
// src/directive/btnAuth.js
import store from '@/store'

export default {
  // inserted:指令绑定的元素被插入到 DOM 时自动执行
  inserted(el, binding) {
    // 1. 当前按钮独有的权限标识(指令传进来的值)
    const btnPermission = binding.value
    // 2. 当前用户拥有的权限列表(存在 Vuex 的 user.profile.permissions 里)
    const permissions = store.state.user.profile.permissions || []

    // 3. 找不到就说明没权限,把按钮从 DOM 里移除
    if (!permissions.includes(btnPermission)) {
      el.parentNode && el.parentNode.removeChild(el)
    }
  }
}
js
// src/directive/index.js
import Vue from 'vue'
import btnAuth from './btnAuth'

// 注册全局指令:名称不带 v- 前缀,使用时才写 v-btn-auth
Vue.directive('btn-auth', btnAuth)
js
// src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import '@/directive' // 执行一次,全局指令才生效

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')
vue
<!-- 业务页面中使用 -->
<template>
  <div>
    <!-- 有 park:enterprise:add 权限才显示 -->
    <el-button v-btn-auth="'park:enterprise:add'">添加企业</el-button>
    <!-- 没有 park:enterprise:remove 权限,会被 removeChild 干掉 -->
    <el-button v-btn-auth="'park:enterprise:remove'">删除</el-button>
  </div>
</template>

关键行与易错点:

  • Vue.directive('btn-auth', {...}) 第一个参数不带 v-,模板里用时才写 v-btn-auth。
  • inserted 是自动执行的钩子,不需要手动调用;此时元素已经插入 DOM,el.parentNode 才有值,才能 removeChild。
  • binding.value 拿到的就是等号后面引号里的内容。模板里必须写成 v-btn-auth="'park:enterprise:add'"(内层再套一层单引号),如果写成 v-btn-auth="park:enterprise:add",Vue 会把它当成变量去 data 里找,报错。
  • 易错点:store.state.user.profile.permissions 要加 || [] 兜底,否则用户信息还没请求回来时 .includes 会报 "Cannot read property 'includes' of undefined"。
  • 易错点:@/directive 只在 main.js import 一次即可,不 import 的话模板会报 "Failed to resolve directive: btn-auth"。
  • 易错点:每个按钮都要配一个和后端约定好的独立权限标识(如 park:enterprise:add、park:enterprise:remove),标识必须和角色管理里勾选的权限点保持一致,否则永远匹配不上、按钮永远不显示。

常见错误 ​

  1. 切换账号后左侧菜单仍是上一个用户的权限
    现象:管理员登录后仍只显示低权限菜单。
    原因:退出登录未重置路由记录,且 Vuex 中 menuList/profile 仍复用旧缓存。
    修法:退出时统一调用 resetRouter(),清空 menuList 和 user/profile,再跳转登录页。

  2. 动态路由已添加,但侧边栏不显示对应菜单
    现象:地址栏可直接访问页面,左侧菜单缺失。
    原因:只执行了动态添加路由,未把筛选后的路由存入 menuList 供侧边栏渲染。
    修法:筛选后同时执行 addRoutes 与 commit 保存 menuList。

  3. 刷新页面后动态路由页面 404 或空白
    现象:首次登录正常,刷新后动态页面丢失。
    原因:守卫中先 next() 放行,异步获取权限和添加路由发生在放行之后。
    修法:无 profile.id 时先 await dispatch('user/getProfile')、过滤并添加路由,再 next({ ...to, replace: true })。

  4. 按钮权限指令不生效或控制台报“指令未注册”
    现象:v-btn-auth 无反应,按钮仍显示。
    原因:指令文件未在入口导入执行,或指令名与模板中 v- 后名称不一致。
    修法:在 main.js 中导入并执行注册;确认注册名如 btnAuth 对应 v-btn-auth。

  5. 按钮权限判断永远失败
    现象:有权限的按钮也被移除。
    原因:binding.value 未传真实权限点,或 permissions 取值层级错误。
    修法:模板传后端约定的权限标识;指令内从 store.state.user.profile.permissions 取值并用 includes 判断。

  6. 重复添加动态路由导致重复菜单或警告
    现象:多次进入页面后菜单重复、路由重复。
    原因:每次守卫都执行 addRoutes,未做一次性标记。
    修法:用 profile.id 或独立标记判断是否已添加;退出登录时重置标记与路由。

练习 ​

  1. 在退出登录逻辑中补齐清理流程:调用 resetRouter()、清空 menuList、清空 user/profile,说明为什么三者都要清。
  2. 改造 router.beforeEach:有 token 且无 profile.id 时,先拉取权限、过滤动态路由、添加路由、保存 menuList,最后安全放行。
  3. 编写按钮权限指令核心逻辑:拿到 binding.value 与当前用户 permissions,无权限时从 DOM 移除元素。
  4. 用 render 函数实现:根据 level(1~6)生成 h1~h6,文本为 level 对应值。
  5. 排查“切换管理员后仍显示低权限菜单”的 bug,列出至少 4 个检查点及修复动作。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1菜单权限控制复习14:09
2菜单权限控制-退出登录时清空路由11:38
3按钮权限控制-需求理解和指令基础架子搭建15:18
4按钮权限控制-指令核心逻辑编写10:28
5拓展render-render函数的基础语法09:57
6拓展render函数-适合render的场景06:28

小结 ​

  1. 权限数据来源于“角色分配权限点 + 员工绑定角色”,前端消费的是当前用户 permissions。
  2. 静态路由初始化注册;动态路由按权限过滤后添加,并同步保存给侧边栏渲染。
  3. 路由守卫中必须先完成权限获取和动态路由添加,再放行导航,避免刷新丢失。
  4. 退出登录或切换用户必须重置路由记录并清空 menuList/profile,否则会复用旧缓存。
  5. 按钮权限适合用全局指令统一处理,核心是权限标识匹配与 DOM 移除。
  6. render 函数适合需要 JS 编程灵活性生成结构的场景,常规页面仍优先 template。
  7. 排查权限问题按链路检查:接口 permissions → 路由过滤结果 → addRoutes → menuList → 按钮 binding.value。