Skip to content

菜单权限控制(三) ​

本页对应 菜单权限控制,共 6 段视频,约 49 分钟。

学习目标 ​

  • 能依据 permission 标识将完整路由表拆分为静态路由表与动态路由表,并说明拆分依据。
  • 能编写过滤函数,使用一级、二级权限标识对动态路由表及其 children 进行筛选,得到有资格显示的路由数组。
  • 能调用 router.addRoute 将过滤后的动态路由逐项加入路由系统,并通过直接访问路径验证对应组件可渲染。
  • 能通过 Vuex 的 state 与 mutation 存储静态与动态路由组合结果,替换左侧菜单的静态数据并验证菜单随状态响应式渲染。
  • 能定位并修复切换路由时菜单重复追加的问题,并通过判断用户权限数据是否已存在来避免重复请求。
  • 能按“权限数据生成—获取权限—格式化权限—拆分路由—过滤路由—添加路由与渲染菜单”的顺序复述菜单权限控制完整流程。

知识点 ​

为什么需要先检查权限配置 ​

权限配置至少选择一项示意图

为什么需要:在角色权限配置流程里,“权限信息”不是可填可不填的装饰项,而是后续动态路由过滤、菜单渲染和路由注册的数据来源。结合转写稿中的整体流程看,先新增角色并给角色配置权限数据,再新增员工并分配角色,员工才拥有当前角色下的权限数据;后续还要调用接口获取权限、格式化权限数据、拆分动静路由表、用权限标识过滤动态路由表,最后通过 addRoute 加入路由系统并渲染左侧菜单。如果这一步一个权限点都没选,后面的权限标识就没有来源,过滤动态路由时可能得到空结果,左侧只剩静态路由,动态路由也无法正常加入路由系统。因此,进入下一步前必须先做“至少选择一项”的校验。

怎么想:不要靠页面上的视觉状态猜,而是直接问 Tree 组件要“当前选中的节点数组”。Tree 实例提供 getCheckedKeys(),拿到的是选中节点的 key 数组。页面里如果有多个 Tree 实例,就把每个实例的结果收集起来,最后合并判断总长度是否为零。

怎么做:在 nextStep 中,当 activeStep === 2 时,先清空 roleForm.perms,再遍历 this.$refs.tree,把每个 Tree 的 getCheckedKeys() push 进去,最后用 flat() 拉平后判断长度。长度为零就提示“请至少选择一个权限点”,不进入下一步;不为零才 activeStep++。

易错点:roleForm.perms 必须先重置,否则用户反复点击“下一步”时,旧数据会不断追加,空选也可能被历史数据掩盖;this.$refs.tree 的形态要和代码一致,代码里用 forEach,说明按数组处理,如果本地实际只有一个 Tree,就要检查 ref 注册方式;判断的是 flat() 后的总长度,不是每个 Tree 单独判断,否则可能出现“某一个树为空但另一个树有值”的误判。

怎么做:nextStep 中的判断逻辑 ​

为什么需要:把校验放在“进入下一步”的入口处,可以保证后续流程拿到的 roleForm.perms 一定是非空数据。这样后续做权限标识匹配、动态路由过滤时,不会把空权限继续传递下去。

怎么想:这一步只做两件事:收集选中项、判断是否为空。收集时保留每个 Tree 的结果,便于后续按树结构使用;判断时用 flat() 合并成一维数组,只看总数量。提示后不修改 activeStep,让用户留在当前页继续选择。

怎么做:保留下面这段代码,核心就是“先收集,再判断,空则提示,非空才放行”。

vue
 nextStep() {
  if (this.activeStep === 3) return
  if (this.activeStep === 1) {
    // 当前是角色信息状态
  } else if (this.activeStep === 2) {
    // 当前是权限信息状态
    this.roleForm.perms = []
    this.$refs.tree.forEach(tree => {
      this.roleForm.perms.push(tree.getCheckedKeys())
    })
    // 如果长度为零 没有选中任何东西
    if (this.roleForm.perms.flat().length === 0) {
      this.$message({
        type: 'error',
        message: '请至少选择一个权限点'
      })
    } else {
      // 进入到下一页
      this.activeStep++
    }
  }
}

易错点:提示类型用 error 表示这是阻断性校验,不要提示完还继续 activeStep++;getCheckedKeys() 拿到的是选中节点 key,若业务还需要半选父节点,要按 Tree 组件能力另行处理,不能想当然;如果点击后没有任何反应,先检查 activeStep 是否确实等于 2,再检查 this.$refs.tree 是否已渲染、是否为数组、每个实例是否有 getCheckedKeys 方法。

调试与后续流程衔接 ​

为什么需要:这类校验逻辑不复杂,但很容易被“数据累积”和“ref 形态”坑住。调试时不要一上来改大流程,先看 roleForm.perms 的实际值,再看 flat() 后的长度,最后看 activeStep 是否变化。

怎么想:按转写稿里“一步一验证”的思路,先确认 Tree 能返回选中 keys,再确认收集后的二维数组结构,最后确认空数组时只提示、不跳转。后续流程中,权限数据会参与动态路由过滤;如果这里放过了空数据,后面排查成本更高。因此入口校验越早越稳。

怎么做:可以在 flat() 判断前临时打印 this.roleForm.perms 和 this.roleForm.perms.flat(),确认结构;检查 $refs.tree 时确认它是数组还是单个实例;确认 roleForm.perms 在每次点击时都被清空。通过后再删除调试输出。

易错点:不要把“没选权限”误判成“接口没返回”,本节只处理选择校验;不要用 length 判断二维数组的外层,否则每个 Tree 都会有一个空数组,外层长度可能不为零;提示文案要明确,让用户知道是“至少选择一个权限点”,而不是笼统的“请选择”。

本步要解决什么问题 ​

第三步“检查并完成”不是继续收集新数据,而是把前两步的用户输入和权限选择汇总到页面上,让用户做最后确认。这里最容易出问题的是权限树:roleForm.perms 里已经有选中节点数组,但 el-tree 不会自动根据这个数组勾选,必须拿到树实例后主动调用 setCheckedKeys 回填。否则用户进入第三步会看到一棵“空白树”,误以为前面没选权限。

为什么需要:确认页要可信,展示必须和前面选择一致。
怎么想:第二步结束时收集 perms,第三步只读展示;树实例要另起名字,避免和第二步的 tree 冲突。
怎么做:在 nextStep 中先收集、校验,再进入第三步并回填。
易错点:不要只把 roleForm.perms 打印在页面上,树形控件必须调用实例方法回填。

页面展示:把前两步内容汇总 ​

模板中用 v-show="activeStep === 3" 控制第三步显示,并直接读取 roleForm.roleName、roleForm.remark。权限区域用 v-for 渲染多棵树,每棵树对应一组权限数据。

jsx
<div v-show="activeStep === 3" class="form-container">
  <div class="title">检查并完成</div>
  <div class="form">
    <div class="info">
      <div class="form-item">角色名称:{{ roleForm.roleName }}</div>
      <div class="form-item">角色描述:{{ roleForm.remark }}</div>
      <div class="form-item">角色权限:</div>
      <div class="tree-wrapper">
        <div v-for="item in diabledTree" :key="item.id" class="tree-item">
          <div class="tree-title">{{ item.title }}</div>
          <el-tree
            ref="diabledTree"
            :data="item.children"
            show-checkbox
            default-expand-all
            node-key="id"
            :highlight-current="false"
            :props="{ label: 'title'}"
          />
        </div>
      </div>
    </div>
  </div>
</div>

为什么需要:确认页只展示,不应再让用户改。
怎么想:用 v-show 而不是 v-if,组件实例会保留,回填时更容易拿到树实例。
怎么做:el-tree 必须设置 node-key="id",setCheckedKeys 才能按节点 id 勾选。
易错点:ref="diabledTree" 在 v-for 中会变成数组,后面要用 forEach;命名要和代码里实际变量一致,若项目里是 disabledTree,不要一半写 diabledTree 一半写 disabledTree。

树形权限回填:先收集,再校验,再回填 ​

第二步进入第三步时,先把每棵树的选中节点收集到 roleForm.perms,再判断是否至少选了一个权限点。校验通过后 activeStep++,然后按索引把每一组权限回填到对应的确认页树实例上。

jsx
// 下一步
 nextStep() {
  if (this.activeStep === 3) return
  if (this.activeStep === 1) {
    // 当前是角色信息状态
  } else if (this.activeStep === 2) {
    // 当前是权限信息状态
    this.roleForm.perms = []
    this.$refs.tree.forEach(tree => {
      this.roleForm.perms.push(tree.getCheckedKeys())
    })
    // 如果长度为零 没有选中任何东西
    if (this.roleForm.perms.flat().length === 0) {
      this.$message({
        type: 'error',
        message: '请至少选择一个权限点'
      })
    } else {
      // 如果长度不为零,进入到检查并完成
      this.activeStep++
      // 回填已选择数据
      this.$refs.diabledTree.forEach((tree, index) => {
        tree.setCheckedKeys(this.roleForm.perms[index])
      })
    }
  }
}

为什么需要:perms 是按树分组的二维数组,flat() 后才能判断总选中数。
怎么想:收集时用第二步的 this.$refs.tree,回填时用第三步另起的 this.$refs.diabledTree,两边实例分开,避免冲突。
怎么做:先清空 perms,再逐树 getCheckedKeys();校验通过后 activeStep++,再逐树 setCheckedKeys(perms[index])。
易错点:setCheckedKeys 传的是数组;索引必须和树顺序一致;空数组也会清空勾选;若改成 v-if,要等 DOM 更新后再回填,必要时用 $nextTick。

调试与验证:先打印结构,再按索引回填 ​

转写稿里讲权限路由时反复强调“先打印看数据结构、每一步一验证、独立小功能尽量封装”。这里同样适用:收集完先打印 roleForm.perms,确认它是 [[...], [...]] 这样的二维数组,再检查 this.$refs.diabledTree 是不是数组、长度是否和 perms 对得上。读转写稿时也要注意同音字还原,如“权限、路由表、数组、回填”,不要被“权现、楼由、数祖”带偏。

为什么需要:回填错位通常不是 setCheckedKeys 本身,而是 perms 结构和树实例顺序不一致。
怎么想:先验证数据,再验证实例,最后验证勾选结果。
怎么做:在 setCheckedKeys 前打印 perms 和 this.$refs.diabledTree.length;确认 node-key 与 getCheckedKeys 使用的 key 一致。
易错点:第二步的 tree 和第三步的 diabledTree 重名会导致拿错实例;perms 没有按树分组时,perms[index] 会传错;校验失败时不要先 activeStep++,否则会进入空确认页。

本节要解决的问题 ​

本节要完成一个“禁用 Tree”的检查项:页面上可能有多个 el-tree,并且要求整棵树都不可操作,而不是只禁用某一个节点。笔记给出的结论很关键:

el-tree 节点禁用示意图

el-tree 组件如果想要全部禁用,需要在每一个 Item 中都添加一个 disabled 字段,值为 true。

这句话看起来简单,但真正落地时要解决三个问题:第一,树数据是嵌套结构,不能只改最外层;第二,禁用数据不能直接污染原始数据,否则后续需要正常展示时就麻烦了;第三,模板里是多个树循环渲染,数据结构必须和 v-for 对应上。

转写稿虽然没有直接讨论 el-tree 的 disabled,但其中反复强调“数据要拆分”“过滤前先看原始结构”“组合逻辑不要反复累加”“每个小功能封装成小函数”。这些工程思想正好对应本节的递归处理、深拷贝和检查清单。

为什么不能只给 el-tree 加一个总开关 ​

为什么需要:el-tree 的禁用不是组件级的一个 disabled 属性就能覆盖全部节点。它识别的是节点数据里的 disabled 字段。也就是说,树组件会根据每个节点的数据决定该节点是否禁用。
怎么想:既然组件按“节点”判断,我们就要把 disabled: true 写到每一个节点对象上,而不是只写在外层数组或组件标签上。
怎么做:写一个递归函数 addDisabled(treeList),遍历每个节点,先设置 item.disabled = true,如果还有 children,继续递归。
易错点:只处理第一层,导致子节点仍可勾选;或者把 disabled 写到 props 里,误以为能控制节点禁用。props 只负责字段映射,不负责批量禁用。

用递归覆盖所有层级 ​

树是典型的递归结构:每个节点都可能带 children,而 children 里又是同样结构的节点。因此最稳妥的方式是“遇到节点就禁用,遇到子节点就继续”。
怎么做:函数内部先 forEach 当前层,再判断 item.children && item.children.length > 0,满足条件就递归调用自己。
易错点:递归前忘记判断 children 是否存在;或者把递归写在 if 外面导致空数组也进入。笔记中的写法已经给出了基本骨架。

js
// 处理禁用函数
export function addDisabled(treeList) {
  treeList.forEach(item => {
    item.disabled = true
    // 递归处理
    if (item.children && item.children.length > 0) {
      addDisabled(item.children)
    }
  })
}

深拷贝:禁用数据和原始数据要分开 ​

为什么需要:接口返回的树数据可能还要用于其他展示,如果直接在原数组上递归加 disabled,原始数据就被改掉了。转写稿里讲路由表时也强调“引用类型一个地方变化另一个地方也变”,所以这里必须先复制一份。
怎么想:原始数据保留,复制出来的副本专门做禁用处理。
怎么做:笔记使用 JSON.parse(JSON.stringify(res.data)) 做深拷贝,再对副本调用 addDisabled。
易错点:使用浅拷贝,子数组仍然共享引用;或者拷贝后忘记把处理结果赋给模板使用的 disableTreeList。另外,JSON 深拷贝会丢失函数、undefined、Date 等特殊类型,树数据通常是普通对象,所以本节可以这样用;如果数据复杂,应换更稳妥的深拷贝方式。

模板渲染与数据命名检查 ​

模板中通过 v-for="item in disableTreeList" 渲染多个树容器,每个树使用 item.children 作为数据源,并通过 :props="{ label: 'title'}" 指定标题字段。show-checkbox、default-expand-all、node-key="id" 都是树展示所需配置。

vue
<template>
  <div v-for="item in disableTreeList" :key="item.id" class="tree-item">
    <div class="tree-title">{{ item.title }}</div>
    <el-tree
      ref="tree"
      :data="item.children"
      show-checkbox
      default-expand-all
      node-key="id"
      :highlight-current="false"
      :props="{ label: 'title'}"
    />
  </div>
</template>
<script>
// 处理禁用函数
export function addDisabled(treeList) {
  treeList.forEach(item => {
    item.disabled = true
    // 递归处理
    if (item.children && item.children.length > 0) {
      addDisabled(item.children)
    }
  })
}

export default {
  data() {
    return {
      disableTreeList: [] // 禁用的tree列表
    }
  },
  mounted() {
    this.getTreeList()
  },
  methods: {
    async getTreeList() {
      const res = await getTreeListAPI()
      // 分开管理数据(引用类型 一个地方发生变化另一个地方也变 - 深拷贝)
      this.treeList = res.data
      this.disabledTreeList = JSON.parse(JSON.stringify(res.data))
      // 处理禁用
      addDisabled(this.disabledTreeList)
    }
  }
}
</script>

易错点:这段代码里有一个非常值得检查的命名问题——data 中定义的是 disableTreeList,而方法里赋值写成了 this.disabledTreeList,多了一个 d。这会导致模板使用的 disableTreeList 仍是空数组,页面看起来“没有树”。检查时应统一为同一个名字。另一个点是 v-for 内多个 el-tree 使用相同 ref="tree" 时,在 Vue 2 中会得到数组,后续若通过 ref 操作树,要按数组处理。

调试与验证顺序 ​

建议按转写稿中“一步一验证”的方式检查:先确认接口返回的树结构;再确认深拷贝后的副本没有被原始数据影响;然后单独调用 addDisabled,打印每个节点是否都有 disabled: true;最后看模板中 disableTreeList 是否有值、每个 el-tree 的复选框是否全部不可操作。这样能快速定位问题是在数据层、递归层还是模板命名层。

从“检查”到“完成”:确认添加要解决什么 ​

本节围绕新增角色表单的“确认添加”展开。它的目标不是只把表单填完,而是把角色数据真正提交到后端,并在成功后给出反馈、返回上一页。
创建角色确认添加界面

为什么需要:在权限控制流程里,创建角色是权限数据生成的前置步骤。转写稿中梳理过:先新增角色并给角色配置权限数据,再新增员工并分配角色,员工才会拥有当前角色下的权限数据。因此“确认添加”必须可靠完成,否则后续权限数据无从谈起。

怎么想:把“检查”和“完成”分开。检查是提交前确认 roleForm 数据是否完整;完成是调用创建角色接口,等待结果,成功后提示并返回。请求逻辑不要直接散落在组件里,应先封装成 API 函数。

怎么做:先封装接口,再在组件中 await 调用。
易错点:不要漏掉 await,否则提示可能早于接口结果;不要写错 url、method、data;roleForm 要确认是当前表单数据。

封装创建角色接口:让请求有统一入口 ​

为什么需要:如果每个组件都自己写请求地址和请求方法,后期维护会很散。把创建角色请求放到 api/system 中,可以让接口入口统一,也方便其他页面复用。

怎么想:按“一个业务动作一个函数”的方式封装。函数接收 data,交给统一的 request 发请求。转写稿里也强调过,独立小功能尽量封装成小函数,后期定位和修改更方便。

怎么做:

javascript
/**
 * 创建角色
 * @returns
 */
export function createRoleUserAPI(data) {
  return request({
    url: `/park/sys/role`,
    method: 'POST',
    data
  })
}

易错点:url 不要漏写开头的 /;创建数据一般用 POST;请求体放在 data 中,不要误写成 params;函数要 export 出去,组件里才能按需导入。

确认添加:提交、反馈、返回的闭环 ​

为什么需要:用户点击“确认添加”后,需要知道是否成功,并且成功后回到角色列表,形成完整操作闭环。

怎么想:确认添加方法应写成异步函数。先等待创建角色接口完成,再弹出成功提示,最后返回上一页。这样提示和跳转都发生在请求成功之后。

怎么做:

vue
import { createRoleUserAPI } from '@/api/system'

async confirmAdd() {
  await createRoleUserAPI(
    this.roleForm
  )
  this.$message({
    type: 'success',
    message: '添加角色成功'
  })
  this.$router.back()
}

易错点:第一,忘记 await,接口还没成功就提示;第二,this.roleForm 不是完整表单数据;第三,重复点击可能重复提交,实际项目可考虑提交中禁用按钮;第四,本节代码只展示成功分支,失败分支要结合项目实际补上,不能误以为请求一定成功。

调试检查:按执行顺序验证 ​

提交时可这样检查:先看 roleForm 是否有值;再在 Network 中确认请求是否为 POST /park/sys/role,请求体是否是表单数据;响应成功后看是否出现“添加角色成功”;最后看 $router.back() 是否回到上一页。转写稿中反复提到“权限”“路由”“接口”“封装”等词,注意不要写成同音错字。把创建角色这一步做稳,后面的角色权限配置和员工分配角色才有可靠起点。

本节目标与素材说明 ​

本节要完成的是“准备下拉框”:先把一个操作入口和它的下拉菜单结构搭起来,为后续绑定“编辑角色”“删除”等命令做准备。笔记给出的素材如下:

下拉框结构示意图

html
<div class="more">
  <el-dropdown>
    <span class="el-dropdown-link">
      <svg-icon icon-class="more" />
    </span>
    <el-dropdown-menu slot="dropdown">
      <el-dropdown-item>编辑角色</el-dropdown-item>
      <el-dropdown-item>删除</el-dropdown-item>
    </el-dropdown-menu>
  </el-dropdown>
</div>

需要说明:本节转写稿主要讨论权限菜单控制中的路由拆分、权限过滤、动态添加路由和左侧菜单渲染,并没有出现“下拉框”“编辑角色”“删除”的具体调试记录。因此下面只依据笔记中的图片和代码扩写,不把转写稿里的权限路由流程硬套到下拉框上,也不虚构运行结果。

外层容器:先给操作入口一个稳定位置 ​

为什么需要:下拉框通常出现在列表操作区或卡片右上角,需要一个外层容器控制间距、对齐和定位。没有外层容器,触发图标容易受父级布局影响,后续调整位置也不方便。

怎么想:先不急着写下拉菜单内容,而是先确定“下拉框放在哪里”。笔记用 div.more 包住整个 el-dropdown,说明这个容器承担的是布局职责,而不是下拉逻辑本身。

怎么做:保留 <div class="more"> 作为外层包裹,把 el-dropdown 放在里面。后续如果要设置右浮动、绝对定位或外边距,都优先作用在 .more 上。

易错点:不要随意删掉外层 div,否则原本依赖 .more 的样式可能失效;类名要写成 more,不要拼成 mroe;外层容器只负责布局,不要在里面手写点击事件去控制菜单显示,el-dropdown 自身已经负责交互。

触发区域:用 span 承载图标入口 ​

为什么需要:el-dropdown 需要有一个默认插槽作为触发元素。用户点击这个区域时,下拉菜单才展开。笔记里用 span.el-dropdown-link 包住 svg-icon,既提供可点击区域,也方便加样式。

怎么想:触发元素不一定是按钮,也可以是一个图标。这里用 span 包一层,比直接把图标裸露在外更利于控制尺寸、颜色和鼠标样式。

怎么做:保留:

html
<span class="el-dropdown-link">
  <svg-icon icon-class="more" />
</span>

其中 icon-class="more" 表示使用项目里的 more 图标。本节只准备结构,不额外写点击事件。

易错点:不要把 svg-icon 写成普通文本或图片占位;icon-class 的值要与项目图标库中的名称一致;如果触发区域点不到,先检查 span 是否被样式压成 display:none 或宽高为 0;不要手动加 @click 去切换菜单,除非项目明确要求自定义触发逻辑。

菜单容器:el-dropdown-menu 与 slot="dropdown" ​

为什么需要:下拉菜单的内容必须告诉 el-dropdown“这是菜单区域”。笔记使用 slot="dropdown",这是当前写法中把菜单挂到下拉层的关键。

怎么想:el-dropdown 是外层交互组件,el-dropdown-menu 是菜单容器,二者是父子关系。菜单容器不能放到 el-dropdown 外面,否则不会跟随下拉框展开。

怎么做:保留结构:

html
<el-dropdown-menu slot="dropdown">
  <el-dropdown-item>编辑角色</el-dropdown-item>
  <el-dropdown-item>删除</el-dropdown-item>
</el-dropdown-menu>

易错点:漏写 slot="dropdown" 是常见问题,可能导致菜单不按预期渲染;el-dropdown-menu 必须放在 el-dropdown 内部;如果项目使用不同版本的组件库,插槽写法可能不同,但本节笔记明确使用的是 slot="dropdown",不要擅自改成未给出的写法。

菜单项:先占位“编辑角色”和“删除” ​

为什么需要:下拉框最终要承载具体操作。先把两个菜单项写出来,可以检查菜单层级、文案和展开效果,为后续绑定命令做准备。

怎么想:本节标题是“准备下拉框”,重点在“准备”,不是实现编辑和删除逻辑。因此两个 el-dropdown-item 先作为占位项,不额外编造点击处理、确认弹窗或接口调用。

怎么做:保留两个菜单项:

html
<el-dropdown-item>编辑角色</el-dropdown-item>
<el-dropdown-item>删除</el-dropdown-item>

后续如果要绑定命令,应在明确需求后再给 el-dropdown-item 增加对应事件或命令属性。

易错点:不要把菜单项写成普通 div,否则会失去 el-dropdown-item 的菜单样式和交互;删除类操作通常需要谨慎处理,但本节没有给出确认逻辑,不能自行编造;菜单项文案要和实际业务一致,避免“编辑角色”和“删除”被误写成其他同音词。

结构与验证:只检查笔记能支持的内容 ​

为什么需要:下拉框结构写完后,需要确认容器、触发器、菜单容器、菜单项四层关系是否正确。否则后面绑定事件时很难定位问题。

怎么想:按层级核对:.more 包住 el-dropdown,el-dropdown 内先放触发 span,再放带 slot="dropdown" 的菜单容器,菜单容器内放两个菜单项。

怎么做:对照图片和代码检查标签闭合、类名、slot 和 icon-class。本节转写稿没有提供下拉框的实际调试输出,因此不虚构控制台结果或展开截图。

易错点:不要因为“先准备”就省略菜单容器;不要把所有内容平铺在同一层;图片只用于核对结构示意,不能据此编造未给出的样式值或事件逻辑。

需求背景:编辑角色为什么要携带 roleId ​

为什么需要:角色列表里可能有多条角色,点击“编辑角色”时,编辑页必须知道用户要编辑的是哪一条数据;否则编辑页无法发起查询或回显,只能让用户重新选择。
怎么想:最直接的方式是在跳转时把当前行的 roleId 作为参数带到目标路由 /roleAdd。这里选择 query 参数,形式是 /roleAdd?id=xxx,便于地址栏可见、刷新后仍能保留。
怎么做:在 el-dropdown-item 的点击事件里调用 $router.push,用模板字符串拼接 item.roleId。本节笔记代码为:

html
 <el-dropdown-item 
   @click.native="$router.push(`/roleAdd?id=${item.roleId}`)">
   编辑角色
 </el-dropdown-item>

易错点:不要写成 $router.push('/roleAdd?id=item.roleId'),那样 item.roleId 会被当成普通字符串;必须使用模板字符串 ${item.roleId}。另外要确认当前作用域里确实存在 item,且字段名是 roleId,不是 roleid、id 等。

点击事件:为什么用 @click.native ​

为什么需要:el-dropdown-item 是 Element UI 的组件,不是原生 DOM 元素。直接写 @click 有时绑定到组件自定义事件上,不一定能按预期触发原生点击逻辑。
怎么想:这里的诉求很明确——用户点击下拉菜单项时执行跳转,属于原生点击行为,因此使用 .native 监听组件根元素的原生 click 事件。
怎么做:保留 @click.native,在事件处理函数中写 $router.push(...)。如果项目里已经封装了方法,也可以写成 @click.native="handleEdit(item)",但本节笔记采用内联写法。
易错点:不要漏掉 .native;也不要把它写成 @click.native=" $router.push(...) " 这类带多余空格或引号错位的形式。若后续 Element Plus 版本对 .native 支持变化,应以当前项目版本为准,不能机械照搬。

路由跳转:$router.push 与 query 参数 ​

为什么需要:Vue 项目通过 Vue Router 管理页面切换,$router.push 会向历史栈追加一条记录,使用户从角色列表进入编辑页,并可通过浏览器返回。
怎么想:转写稿里多次出现 router、routes、addRoute 等路由术语,说明项目使用 Vue Router 体系;本节只取“路由跳转”这一层,不把权限菜单过滤流程硬套进来。跳转目标 /roleAdd 是编辑页,?id=${item.roleId} 是 query 参数。
怎么做:点击后地址变为类似 /roleAdd?id=3。编辑页可通过 this.$route.query.id 获取。注意是 $route 取当前路由信息,不是 $router。
易错点:$router 是路由实例,负责跳转;$route 是当前路由对象,负责读取参数。二者不要混。若 roleId 可能为 0 或空值,要确认业务是否允许;不要用简单的 if (!id) 误判数字 0。

目标页接收与调试:如何确认参数带过去了 ​

为什么需要:只写跳转还不够,必须确认编辑页能拿到 id,否则页面可能空白或报错。
怎么想:调试顺序应是“先看地址栏是否出现 ?id=xxx,再看编辑页能否读取 $route.query.id,最后看接口请求是否使用该 id”。转写稿中强调过分步验证、独立小功能封装,这里同样适用:先把跳转和取参跑通,再接入编辑接口。
怎么做:在编辑页创建或挂载阶段读取:

js
const roleId = this.$route.query.id

必要时打印或使用调试工具确认。若编辑页需要请求详情,应使用该 roleId 作为查询条件。
易错点:不要在跳转前就把参数存到组件内部状态然后期待跨页面保留;页面跳转后原组件状态不一定可用。也不要把 query 参数当成路由路径的一部分去匹配。最后检查 item.roleId 是否真实存在,避免拼出 id=undefined。

代码示例 ​

常见错误 ​

1. 左侧菜单每点一次就多出一组

  • 现象:切换几次路由后,侧边栏出现重复的菜单项,越点越多。
  • 原因:mutation 里写成 [...state.menuList, ...payload],把已经被污染过的累积结果当成基底再拼一次,自己叠加自己。
  • 修法:基底永远用那份不变的静态路由表,每次 [...staticRoutes, ...payload] 重新生成整体,而不是在 state.menuList 上追加。

2. 每切一次路由就重发一次权限接口

  • 现象:Network 面板里,点一次菜单就多一条获取权限的请求。
  • 原因:初始化逻辑写在全局前置守卫里,而守卫在每次路由切换都会执行,没有幂等判断。
  • 修法:守卫入口加判断 if (!store.state.user.userProfile.id) { ...原有全部逻辑... },只要已经初始化过就整段跳过。

3. addRoute 之后侧边栏还是空的,或菜单有了但点击白屏

  • 现象:动态路由已经 add 进去了,手动输路径能渲染,左侧却不出现;或者反过来,左侧有菜单但点进去空白。
  • 原因:router.options.routes 是初始化时的快照,addRoute 不会回写它,侧边栏若仍读这个属性就永远是旧的;同时"能不能进"和"看不看得见"是两套数据,只落地了一套。
  • 修法:菜单侧改读 Vuex 里的响应式 menuList,路由侧确保 addRoute 在守卫中执行,两边都要做,缺一不可。

4. 权限校验永远失效(一个没勾也放行 / 勾了还提示未选)

  • 现象:点"下一步"要么直接进,要么明明勾了还弹"请至少选择一个权限点"。
  • 原因:roleForm.perms 是 push(tree.getCheckedKeys()) 得到的二维数组,perms.length 是树的数量而不是节点数;另外判断写在收集选中项之前也会出错。
  • 修法:先收集完所有树的选中项,再 this.roleForm.perms.flat().length === 0 判断。

5. 第三步的权限树回填不上,或勾到了错误的树

  • 现象:进入"检查并完成",勾选状态为空,或分组错位。
  • 原因:第三步的 ref 与第二步同名 tree,$refs.tree 把两批实例混在一起;或 setCheckedKeys 在 v-show 切换、DOM 尚未更新时被调用。
  • 修法:第三步 ref 改名 diabledTree,用 forEach((tree, index) => tree.setCheckedKeys(this.roleForm.perms[index])) 按下标一一对应;必要时包一层 $nextTick。

6. 第二步的权限树也被禁用了

  • 现象:加了 disabled 之后,第二步原本可勾选的树全部变灰。
  • 原因:this.treeList = res.data 和 disabledTreeList 指向同一份引用,addDisabled 递归修改的其实是同一批对象。
  • 修法:JSON.parse(JSON.stringify(res.data)) 深拷贝一份再改,只动副本。

7. 编辑角色跳过去是空白新增页

  • 现象:/roleAdd?id=xx 打开后表单为空,roleId 拿不到。
  • 原因:el-dropdown-item 是组件,直接写 @click 在 Vue2 里不触发;且页面没从 $route.query 取值。
  • 修法:用 @click.native,并在 created/mounted 里读 this.$route.query.id 决定走新增还是回显。

练习 ​

练习 1|过滤函数单测(不跑页面) 准备一份 4 条顶层、其中一条含 4 个 children 的动态路由表,以及一级标识 ['park','parking']、二级标识 ['park:building','park:enterprise']。手写 filterRoutes,用 console.table 输出结果。 验收:顶层剩 2 条;第二条的 children 从 4 条变成 2 条;被过滤掉的分组不再出现。

练习 2|复现并修复菜单重复 bug 先按错误写法实现 mutation,连续切换 5 次路由,观察 Vuex 里 menuList 长度的变化并记录;再改成基于静态表的组合写法,重复同样的操作。 验收:修复后无论点多少次,menuList 长度恒定为「静态条数 + 动态条数」。

练习 3|请求去重 不加判断跑一遍,数出 5 次路由切换产生的权限请求条数;加上 userProfile.id 判断后再数一次。 验收:第一次进入有 1 条请求,后续切换为 0 条,且菜单仍然正常。

练习 4|角色新增全流程自测 勾选 0 个权限点、勾选 1 个、勾选多个分组下的若干节点,各点一次"下一步"。 验收:0 个时弹错误提示且不跳转;非 0 时进入第三步,且回填的勾选状态与第二步完全一致(含跨分组)。

练习 5|引用安全验证 在 addDisabled 调用前后,分别打印 treeList[0].disabled 和 disabledTreeList[0].disabled。 验收:调用后 treeList 侧仍为 undefined,disabledTreeList 侧为 true,第二步的树可正常勾选。

原视频对照 ​

#视频时长
1权限菜单控制-拆分动静路由表07:03
2权限菜单控制-使用权限数据过滤动态路由表11:52
3权限菜单控制-把动态路由表加入到路由系统中05:11
4权限菜单控制-渲染左侧菜单12:35
5权限菜单控制-修改路由组合逻辑优化网络请求05:24
6菜单权限控制流程梳理07:32

小结 ​

  1. 菜单和路由是两套数据:addRoute 决定"能不能进",Vuex 里的菜单决定"看不看得见"。任何一个只做一半,都会出现"能进但看不到"或"看得到但点不动"。
  2. router.options.routes 是快照,不是活数据。凡是依赖它做渲染的地方,动态路由上线后必然失效,改数据源比改渲染逻辑更省事。
  3. 路由守卫天然会被反复执行。凡是"只该做一次"的初始化,必须自带幂等判断,否则请求和状态会双重叠加。
  4. 从接口拿到的树形数据,只要下游要改结构(加 disabled、过滤 children),先深拷贝。不要赌引用安全,这类 bug 排查成本极高。
  5. filter 之后再 map 时,嵌套字段必须显式重建,...item 展开不会帮你处理 children,它只会把旧的整棵子树原样搬过去。
  6. el-tree 的选中值天生是"每棵树一个数组",任何跨树的数量判断之前先 flat(),否则 length 会稳定地骗你。
  7. Vue2 里 ref 同名不会报错,只会静默返回错误的实例集合。遇到"数据明明对但视图不对",先查 ref 是否重名。
  8. 权限标识是前后端的契约,前端只能对齐不能自创。过滤结果为空时,第一件事是逐条比对字符串,而不是怀疑过滤逻辑。