Skip to content

菜单权限控制(二) ​

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

学习目标 ​

  • 能够通过角色详情接口获取指定角色数据,并完成角色名称、备注及权限树节点的数据回填,其中权限树需使用组件实例方法完成勾选回填。
  • 能够说明菜单路由权限控制的效果与 RBAC 方案的核心思路,并能描述“角色分配权限、员工绑定角色、登录后按权限显示菜单”的完整链路。
  • 能够封装获取当前用户权限数据的接口,在 Vuex 中编写 action 与 mutation 完成权限数据存储,并在路由权限控制模块中触发获取。
  • 能够使用原生 JavaScript 将后端返回的权限标识数组处理为一级菜单权限标识数组和二级菜单权限标识数组,并验证拆分结果正确。
  • 能够使用 Set 与展开运算符对一级、二级权限标识数组分别去重,确保最终权限数据中不存在重复项。

知识点 ​

先明确本节目标:用 roleId 换回当前角色的权限点 ​

为什么需要:编辑角色时,页面左侧可能切换不同角色,右侧权限树要显示“当前角色已经拥有的权限点”。如果只拿列表行里的 row 数据,字段可能不全,而且不是实时详情;转写里明确提到,row 字段和详情字段不一定完全匹配,详情接口拿到的数据更实时、更通用。
怎么想:把 roleId 当作唯一查询依据,调详情接口,从返回值里取权限点列表,存到组件自己的 perms 中。
怎么做:保留笔记中的 data、getRoleDetail、changeRole 结构。
易错点:不要图省事直接拿列表行数据回填;字段名要以接口为准,笔记里写 res.data.perms,转写中详情接口的权限字段口述为 permissions,落地时要对齐,避免取到 undefined。

javascript
data() {
  return {
    perms: [] // 当前角色权限点列表
  }
}

// 封装请求方法
async getRoleDetail(roleId) {
  const res = await getRoleDetailAPI(roleId)
  this.perms = res.data.perms
}

// 切换角色
changeRole(idx,roleId) {
  this.activeIndex = idx
  // 获取当前角色下的权限点数据列表
  this.getRoleDetail(roleId)
}

请求封装:路径参数 roleId 要传对 ​

为什么需要:详情接口通常要求把角色 ID 放在路径参数里,例如“查看当前角色详情”需要 roleId。如果参数没拼进去,接口可能 404 或返回错误角色。
怎么想:先确认接口文档/现有 API 文件里是否已有 getRoleDetailAPI;转写里提到该接口之前可能已经写过,先检查再新增,避免重复导出。
怎么做:在 API 层按项目已有写法导出请求方法,调用时把当前 roleId 传进去。
易错点:拼接路径时不要漏 /;不要把 roleId 写成固定值;路由跳转方法转写里提醒“写 router.push,不要写错”,同理接口名也不要拼错。

切换角色:先更新 activeIndex,再拉详情 ​

为什么需要:用户点击不同角色时,界面高亮要立刻变化,同时权限树数据也要跟着换。
怎么想:activeIndex 只负责“当前选中哪一项”,perms 负责“当前角色的权限点”。两者由同一个 roleId 驱动。
怎么做:在 changeRole 中先 this.activeIndex = idx,再调用 this.getRoleDetail(roleId)。
易错点:切换角色时最好先把 perms 置空或加载态,防止旧角色权限短暂残留;快速连续切换时,异步响应可能乱序,实际项目可用请求序号或取消请求处理。

调试与验证:看 Network,也看 perms ​

为什么需要:权限点是否真的拿到,不能只看页面“像不像”,要看请求和状态。
怎么想:转写里的调试过程是:切换/进入页面后,在 Network 里观察是否多出详情请求;返回数据中应有角色名称、备注、权限点等字段;再在控制台或 Vue 调试工具里看 perms 是否被赋值。
怎么做:切换角色后检查请求参数是否为当前 roleId;打印 this.perms;确认 res.data 层级。
易错点:有的接口返回 res.data.permissions,不是 res.data.perms;有的返回在 res.data.data 里,必须以实际响应为准。

与后续回填的关系:perms 是权限树回填的数据源 ​

为什么需要:本节拿到的 perms 不只用于展示,后续还要把权限树中该角色已选的节点勾上。转写里提到详情接口返回角色名称、备注、权限点,表单简单字段可直接回填,权限树要调用树实例的勾选设置方法。
怎么想:详情接口一次拿全,表单字段和权限点分开处理;权限树回填时按 perms 对应项设置勾选。
怎么做:例如表单侧把 roleName、remark 赋给 form;权限树侧按所用树组件的勾选 API 传入当前角色的权限点数据。转写中口述为 setChecked 一类方法,实际以项目树组件 API 为准。
易错点:不要只回填表单忘记权限树;不要在初始化时重复写已经写过的“下一步”回填逻辑;树组件方法名和参数格式要核对,避免把整个数组传错层级。

先明确:这里说的“高亮”是让树节点被勾选 ​

为什么需要:编辑角色时,页面上要立刻看出这个角色已经拥有哪些权限。权限通常用 el-tree 展示,所以“高亮”不是单纯改颜色,而是把已有权限对应的节点勾选出来。

怎么想:el-tree 提供了实例方法 setCheckedKeys,传入一组 key,就能把对应节点设置为选中状态。因此思路是:先拿到当前角色的权限 key 列表,再找到树组件实例,最后调用方法回填。

怎么做:模板里给树加 ref,JS 里通过 this.$refs.tree 拿到组件实例,再调用 setCheckedKeys。

易错点:setCheckedKeys 接收的是 key 数组,不是节点对象;不要把它当成普通样式方法。

jsx
<el-tree ref="tree"/>

从详情接口拿到权限数据 ​

为什么需要:转写稿里提到,回填时可以用列表的 row 数据,也可以调详情接口。用 row 有两个问题:字段可能不全;数据不一定是实时的。角色权限这种回填数据,调详情接口更稳妥。

怎么想:先根据角色 ID 请求详情,把返回中的权限数据保存到当前组件,例如 this.perms。笔记代码里字段是 res.data.perms,后续就按这个字段使用。

怎么做:在 getRoleDetail 中 await getRoleDetailAPI(roleId),然后保存权限数据。

易错点:请求是异步的,必须 await;字段名要对准接口返回,不要一会儿写 perms、一会儿写别的名字。

jsx
// 获取当前角色详情
async getRoleDetail(roleId) {
  const res = await getRoleDetailAPI(roleId)
  this.perms = res.data.perms
}

用 ref 找到 el-tree 实例 ​

为什么需要:setCheckedKeys 是树组件实例上的方法,不是普通函数,必须先拿到组件实例。

怎么想:Vue 里通过 ref 获取组件实例。模板中写 ref="tree",JS 中写 this.$refs.tree,名字必须一致。

怎么做:笔记代码中先取 const treeComponentList = this.$refs.tree,再遍历调用。

易错点:如果 ref 名字写错,this.$refs.tree 就是 undefined;如果树还没渲染完就调用,也可能拿不到实例。实际调试时可在调用前打印 this.$refs.tree 确认结构。

循环调用 setCheckedKeys 完成回填 ​

为什么需要:页面里可能不止一棵权限树,每棵树对应一组权限数据,所以要按索引一一回填。

怎么想:this.perms[index] 对应第 index 棵树的权限 key 数组,把它传给 tree.setCheckedKeys(...)。

怎么做:在拿到详情数据后,取树实例列表,forEach 遍历,每棵树调用一次 setCheckedKeys。

易错点:perms 的顺序要和树的数量、顺序对应;setCheckedKeys 的参数应是数组;如果某棵树没有权限数据,要确认接口是否返回空数组,避免传 undefined。

jsx
// 高亮权限列表
const treeComponentList = this.$refs.tree
// 调用setCheckedKeys方法
treeComponentList.forEach((tree, index) => {
  tree.setCheckedKeys(this.perms[index])
})

调试与收尾 ​

转写稿的调试过程是:调详情接口后,在 Network 里能看到新增请求,返回数据中有权限列表;再到页面里点“下一步”或刷新查看,之前选中的权限节点应当仍然处于勾选状态。这里的关键不是重新写一套回填逻辑,而是用命令式方式调用树组件方法。

完整写法可理解为:先请求详情,保存 this.perms,再通过 ref 拿树实例,最后逐棵调用 setCheckedKeys。易错点再强调一次:如果页面只有一棵 el-tree,this.$refs.tree 通常不是数组,直接 forEach 会报错;本节代码按多棵树场景写,使用前要先确认 $refs.tree 的实际结构。确认结构后,再按索引把 perms[index] 回填进去即可。

本节要解决的问题 ​

这一节要完成的是:进入角色编辑/查看页面时,先把角色列表和权限树准备好,再根据当前角色 ID 获取该角色的详情与权限点,最后做数据回填。笔记里特别强调:

注意事项:确保 roleList 和 TreeList 完成之后,再请求 perms。

这句话是本节的核心。因为后面要用 roleList[0].roleId 去请求详情,如果角色列表还没回来,roleList[0] 可能不存在;如果权限树还没渲染好,即使拿到了 permissions,也无法正确勾选。

为什么必须先等角色列表和权限树 ​

为什么需要:初始化页面时通常有三类数据:角色列表、可选权限树、当前角色详情/权限点。它们之间有依赖关系。角色列表决定“用哪个 roleId 请求详情”,权限树决定“拿到权限点后往哪棵树上回填”。

怎么想:不要一进 mounted 就同时发三个请求。虽然并行更快,但详情请求依赖 roleList,权限回填依赖 TreeList。因此这里应采用串行等待:先 await getRoleList(),再 await getTreeList(),最后再 getRoleDetail(...)。

怎么做:在 mounted 中写成 async 函数,用 await 保证顺序:

jsx
// 生命周期里先以第一项roleId获取
async mounted() {
  // 先获取角色列表和可选权限列表
  await this.getRoleList()
  await this.getTreeList()
  // 再获取当前角色下的权限列表
  this.getRoleDetail(this.roleList[0].roleId)   
}

易错点:不要写成 this.getRoleList(); this.getTreeList(); this.getRoleDetail(this.roleList[0].roleId) 这种没有 await 的并行写法,否则 roleList[0] 可能报错。也不要在 roleList 为空时直接取第一项,实际项目里应确认列表有数据。

详情接口为什么比列表行数据更可靠 ​

转写稿里提到一个关键对比:回填时是用列表里的行数据(row),还是重新调详情接口?结论是详情接口更通用。

为什么需要:列表行数据字段不一定和详情字段完全匹配,可能字段更少;而且列表数据是上一次请求拿到的,不一定是实时数据。如果别人刚修改过这条数据,直接用行数据回填可能不是最新值。

怎么想:详情接口按 ID 查询,返回字段更完整,也能保证实时性。因此初始化回填应优先调详情接口。

怎么做:封装/复用 getDetailAPI 一类接口,传入当前 roleId,拿到 roleName、remark、permissions 等字段后回填表单。转写稿中的调试过程是:刷新页面后 Network 里多出一个详情接口请求,返回数据里能看到角色名称、备注和 permissions,随后把这些数据赋给表单。

易错点:不要图省事直接拿列表 row 回填所有字段;详情接口如果之前已经封装过,不要重复造一份,先检查 API 文件里是否已有 getDetail。

权限点回填要等树渲染完成 ​

为什么需要:详情接口返回的 permissions 是权限点数组,需要让权限树对应节点处于勾选状态。如果树组件还没拿到数据或还没渲染,直接调用勾选方法可能无效。

怎么想:权限树数据由 getTreeList() 准备,所以必须先 await 它;详情数据回来后再调用树实例的 setCheckedKeys 一类方法。

怎么做:拿到 permissions 后,对权限树实例逐项设置勾选。转写稿里提到“调 set 方法”“每一个树对应一个权限数据”,本质是把详情里的权限点回填到树上。调试时点“下一步”,能看到之前选中的权限点仍然在。

易错点:setCheckedKeys 不是 setche case;要确认树实例已经存在;权限点数组要和树节点 key 对应;如果树是懒加载或多棵树,要按下标或节点分别处理。

易错点与调试检查 ​

  1. 顺序问题:roleList 和 TreeList 未完成就请求详情,是本节最典型的错误。检查 mounted 里是否有 await。
  2. 空列表问题:this.roleList[0] 前要确保列表非空。
  3. 重复接口:详情接口可能已存在,先搜索 API 文件,避免重复定义。
  4. 回填不完整:基础字段如角色名称、备注要回填;权限树勾选也要回填。
  5. 调试方法:刷新页面看 Network 是否按顺序出现列表、树、详情请求;看详情返回里是否有 permissions;再看页面权限树是否勾选正确。

小结 ​

本节的关键不是“多写一个请求”,而是处理初始化依赖:先准备角色列表和权限树,再按当前 roleId 获取详情与权限点,最后完成表单和权限树回填。记住顺序、等待和回填三个动作,就能避免大部分初始化问题。

改造目标:把“功能权限”和“成员列表”拆成页签 ​

功能权限与成员列表页签结构

角色详情页里既有功能权限树,又有成员列表。如果全部纵向堆在一个页面,内容会很长,用户也不好定位。因此这里用 el-tabs 把两块内容拆成页签:默认展示“功能权限”,需要时再切到“成员列表”。

怎么想:页签容器负责“切换”,每个 el-tab-pane 负责一块内容。当前页签由 activeName 控制,默认值设为 'tree',所以进入页面先看到功能权限。

怎么做:外层写 el-tabs v-model="activeName",里面放两个 el-tab-pane,分别用 label 显示标题、用 name 作为页签标识。注意 name 必须和 activeName 的取值对应。

易错点:label 是给人看的文字,name 是给程序用的标识,不要混用;默认页签不是写死显示,而是靠 activeName 的初始值控制。

vue
<el-tabs v-model="activeName">
  <el-tab-pane label="功能权限" name="tree">
    <div class="tree-wrapper">
      <div v-for="item in treeList" :key="item.id" class="tree-item">
        <!-- title -->
        <div class="tree-title">{{ item.title }}</div>
        <!-- 树形结构 -->
        <el-tree
          ref="tree"
          :props="{label:'title'}"
          :data="item.children"
          show-checkbox
          :default-expand-all="true"
          node-key="id"
        />
      </div>
    </div>
  </el-tab-pane>
<el-tab-pane label="成员列表(100)" name="member">成员管理</el-tab-pane>
</el-tabs>

data() {
  return {
    activeName: 'tree' // 默认打开tree
  }
}

功能权限区域:用 v-for 渲染多棵权限树 ​

为什么需要:功能权限不是只有一棵树,而是按分组展示多棵树。每棵树有自己的标题和子节点数据,所以不能只写一个固定的 el-tree。

怎么想:把 treeList 当成“树配置数组”,每一项包含 id、title、children。外层循环生成标题和树,树的数据绑定到 item.children。

怎么做:v-for="item in treeList" :key="item.id",标题渲染 item.title,el-tree 的 :data 绑定 item.children。props 里用 {label:'title'},表示节点文字取数据里的 title 字段;node-key="id" 保证每个节点有唯一标识,后续勾选和回填都靠它。

易错点:v-for 的 key 要用稳定唯一的 id,不要用数组下标;el-tree 的 props 是字段映射,不是直接写 label 属性;node-key 不设置,后续 setCheckedKeys 回填会不稳定。

默认页签:activeName 控制当前显示 ​

为什么需要:进入详情页时,用户最常看的是功能权限,所以默认打开 tree 页签,避免每次都手动切换。

怎么想:el-tabs 通过 v-model 双向绑定当前页签,activeName 初始值就是默认页签的 name。

怎么做:在 data() 中返回 activeName: 'tree'。如果以后要默认打开成员列表,只改初始值即可。

易错点:activeName 的值必须和某个 el-tab-pane 的 name 完全一致;不要写成中文标题“功能权限”,否则页签匹配不上。

与详情回填的衔接:ref 拿到树实例再调方法 ​

为什么需要:权限详情接口返回 permissions 后,需要把已选权限回填到树上。只改 data 不够,因为树组件有内部选中状态,必须命令式调用树实例的方法。

怎么想:转写稿里强调,回填树要“调 setCheckedKeys 方法”,并且 ref="tree" 写在 v-for 里时,拿到的不是单棵树,而是树实例数组。每棵树对应一份权限数据,所以要用下标 index 取 permissions[index]。

怎么做:详情数据拿到后,遍历 this.$refs.tree,对每个树实例调用 setCheckedKeys(permissions[index])。注意要在树渲染完成后执行,通常放在 nextTick 里更稳。

易错点:ref 同名且在 v-for 中会得到数组,不能直接 this.$refs.tree.setCheckedKeys(...);permissions 的顺序要和 treeList 一一对应;转写稿里提到的“去重”也要注意,重复权限点先去重,避免回填数据异常。成员列表页签目前只是占位,等成员列表逻辑接入后再替换“成员管理”文本即可。

先明确:接口封装解决什么问题 ​

为什么需要:页面里如果到处手写 url、参数和请求方法,一旦路径、请求头、token 或拦截器规则变化,就要改很多地方。封装接口的本质,是把“请求细节”收口到 API 层,让业务组件只关心“我要什么数据”。转写稿里提到一个很关键的对比:回填时可以直接用列表的 row 数据,也可以重新调详情接口。前者的字段不一定和详情字段完全匹配,可能变少;后者拿到的是实时数据。实际开发中调详情接口更通用,不要图省事。这里的“肉数据”应纠正为 row 数据,“pass”应理解为 path。

怎么想:先判断这个函数是“一个接口”还是“一段业务”。getRoleUserAPI 只负责根据 roleId 发请求,不负责页面回填、不负责弹窗状态,也不负责把返回数据拆成表单字段。这样它才能被多个页面复用。

怎么做:在 API 模块中导出函数,函数名体现资源和动作,参数只保留接口真正需要的值,返回值直接交给调用方。

易错点:不要把业务逻辑塞进 API 层;不要重复封装已有接口。转写稿里就出现过“这个接口已经写过了,报错证明之前写过”的情况,写之前先搜一遍已有 API。

以 getRoleUserAPI 为例写路径参数接口 ​

为什么需要:角色成员列表通常依赖角色 ID。把 roleId 作为路径参数拼进 URL,可以让接口语义清晰:请求的是某个角色下的成员列表,而不是所有成员。

怎么想:先看接口要求。给定代码的注释是“获取角色成员列表”,URL 是 /park/sys/roleUser/${roleId},说明 roleId 是路径的一部分。此时不要把它写成 query 参数,也不要在组件里手拼完整地址。

怎么做:保留本节代码,写法如下:

javascript
/**
 * 获取角色成员列表
 * @returns
 */
export function getRoleUserAPI(roleId) {
  return request({
    url: `/park/sys/roleUser/${roleId}`
  })
}

这里的 request 应是项目统一的请求方法或实例,具体拦截器不在本节展开。调用方只需要:

javascript
const data = await getRoleUserAPI(roleId)

易错点:第一,roleId 为空时会拼出 /undefined 或 /,调用前要保证 ID 已取到;第二,模板字符串要用反引号,不要写成普通引号;第三,路径参数不要漏斜杠或重复斜杠;第四,不要把“角色成员列表”和“角色详情”混用,二者语义不同,不能因为都在角色模块就互相替代。

接口层与业务层分工:封装不等于回填 ​

为什么需要:转写稿里花了很多篇幅讲“数据回填”:拿到详情后给角色名称、备注赋值,权限树还要调用 setChecked 之类的方法回填。这说明接口封装和页面回填是两件事。接口层只负责取数,回填是业务组件的事。

怎么想:先问“这个函数返回后,谁来消费数据?”如果是表单,就在组件里解构并赋值;如果是权限树,就在组件里拿到树实例后调用设置方法。不要指望 getRoleUserAPI 自己知道页面结构。

怎么做:调用接口拿到结果后,在业务层做赋值。转写稿中的推导是:先调接口,确认返回数据里有目标字段,再做回填;权限树回填要拿到每一棵树的实例,再逐项设置选中状态。调试时先看接口是否真的被调用,再看返回值是否完整。

易错点:不要用列表 row 数据代替详情数据做正式回填;不要在没有拿到树实例时调用回填方法;不要把回填逻辑写进 API 函数里,否则接口无法复用。

调用与调试:从 Network 和返回值验证 ​

为什么需要:封装完并不等于接通。转写稿里通过刷新页面观察 Network 是否多出请求、查看返回数据中是否有目标字段,这是很实用的验证方式。

怎么想:先验证“函数有没有被调用”,再验证“参数对不对”,最后验证“返回值能不能用”。如果 Network 没有请求,优先查调用处;如果 URL 不对,查模板字符串和 roleId;如果返回为空,查 ID 是否有效。

怎么做:在调用处打印或断点 roleId,在 Network 看请求地址是否为 /park/sys/roleUser/真实ID,再检查返回结构。转写稿中提到的 permissions、mutation、dispatch 等词,实际应分别理解为权限数据、mutation 提交和 dispatch 触发,调试时按 Vuex 的 action → 接口 → mutation → state 链路排查。

易错点:只写了导出函数但没有在页面调用;路由参数名取错;把 path 写成 pass 的口误带入代码;重复定义同名接口导致引用混乱;请求成功但没处理异常和空数据。

本节收束 ​

本节的核心不是“多写一个函数”,而是建立边界:API 层负责请求,业务层负责回填和交互。给定代码已经给出了路径参数接口的标准形态;转写稿则补上了“为什么调详情更可靠”“接口调用后如何观察请求”“回填为什么要拿实例”这些推导。后续再封装类似接口时,先确认语义、参数位置和复用点,再写函数,最后用 Network 和返回值验证。

本组件的目标与数据入口 ​

为什么需要:角色管理页面不仅要展示角色本身,还要展示“当前角色下的成员”。成员数量、成员姓名、登录账号都来自另一个接口,因此组件里必须单独维护一份成员列表数据。
怎么想:先把成员列表作为组件状态放进 data,初始为空数组,等接口返回后再赋值。这样模板里可以直接用 roleUserList.length 和 :data="roleUserList"。
怎么做:

jsx
data() {
    return {
      roleUserList: [] // 当前角色下的成员列表
    }
}

易错点:初始值不要写成 null,否则模板里 roleUserList.length 会报错;成员数据不要直接塞到角色列表的 row 里,因为列表字段和成员字段不一定匹配,而且详情/成员数据应以接口实时返回为准。

封装 getRoleUserList:成员数据单独取 ​

为什么需要:初始化时要取成员,切换角色时也要取成员,逻辑相同,应该封装成一个方法复用。
怎么想:成员接口依赖 roleId,返回结构里成员在 res.data.rows,所以方法接收 roleId,请求成功后把 rows 赋给 roleUserList。
怎么做:

jsx
// 封装获取当前角色下的成员的方法
async getRoleUserList(roleId) {
  const res = await getRoleUserAPI(roleId)
  this.roleUserList = res.data.rows
}

易错点:第一,await 不能漏,否则 res 是 Promise,取不到 res.data.rows;第二,接口返回空时 rows 可能是空数组,赋值前要确认接口约定;第三,切换角色时传的必须是当前角色的 roleId,不能传索引 idx。

mounted 初始化:顺序和 await 是关键 ​

为什么需要:页面刚进入时还没有“当前角色”,必须先拿到角色列表,才能用第一项角色的 roleId 去取角色详情和成员列表。
怎么想:初始化存在依赖关系:getRoleList() 完成后才有 this.roleList[0];getTreeList() 是树数据;然后分别调用 getRoleDetail 和 getRoleUserList。转写稿里也强调“获取角色列表必须加 await”,否则下一行拿不到角色列表。
怎么做:

jsx
async mounted() {
  // 获取角色列表(必须加await)
  await this.getRoleList()
  await this.getTreeList()
  this.getRoleDetail(this.roleList[0].roleId)
  this.getRoleUserList(this.roleList[0].roleId)
}

易错点:如果 getRoleList() 不加 await,this.roleList[0] 可能还是 undefined,页面直接报错;如果角色列表为空,也要避免直接取 [0]。转写稿提到详情接口比直接用列表 row 更通用、更实时,因此角色详情和成员列表都应通过接口获取,不要图省事复用列表行数据。

changeRole:切换角色刷新两块数据 ​

为什么需要:不同角色下的权限点和成员都不同,切换角色后必须重新请求,否则页面还显示上一个角色的数据。
怎么想:先更新激活项 activeIndex,再根据 idx 从 roleList 里取出 roleId,然后分别刷新角色详情(权限点)和成员列表。
怎么做:

jsx
// 切换角色
changeRole(idx) {
    this.activeIndex = idx
    // 1. 获取当前角色下的权限点数据列表
    const roleId = this.roleList[idx].roleId
    this.getRoleDetail(roleId)
    // 2. 获取当前角色下的成员数据列表
    this.getRoleUserList(roleId)
}

易错点:idx 是选项卡索引,roleId 才是接口参数,二者不能混;切换角色后如果成员数量没变,先检查是否传错 roleId,或者 getRoleUserList 是否真的重新执行并重新赋值了 roleUserList。转写稿里的调试思路是看 Network 中详情接口和成员接口是否重新请求、返回数据是否落到对应字段。

模板渲染:成员数量和表格 ​

为什么需要:成员页签要显示“成员(数量)”,表格要展示序号、员工姓名、登录账号。
怎么想:页签 label 用模板字符串拼接 roleUserList.length;表格 :data 绑定成员列表;列 prop 对应接口字段 name、userName。
怎么做:

jsx
// 模版渲染
<el-tab-pane :label="`成员(${roleUserList.length})`" name="user">
  <div class="user-wrapper">
    <el-table
      :data="roleUserList"
      style="width: 100%"
    >
      <el-table-column
        type="index"
        width="250"
        label="序号"
      />
      <el-table-column
        prop="name"
        label="员工姓名"
      />
      <el-table-column
        prop="userName"
        label="登录账号"
      />
    </el-table>
  </div>
</el-tab-pane>

易错点:label 前必须加 :,否则 ${roleUserList.length} 不会按表达式解析;prop 字段名要和接口返回一致,name、userName 大小写不能写错;序号列用 type="index",不要自己再维护一份序号数组。

代码示例 ​

html
<!-- 角色权限管理页:切换角色 → 获取权限点 → 高亮 Tree + 渲染成员列表 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>角色权限管理</title>
  <link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css" />
  <style>
    body { margin: 0; padding: 20px; font-family: "Microsoft YaHei", sans-serif; }
    .role-wrapper { display: flex; }
    .role-list { width: 180px; border-right: 1px solid #eee; }
    .role-item { height: 40px; line-height: 40px; padding-left: 16px; cursor: pointer; }
    .role-item.active { background: #e6f7ff; color: #1890ff; }
    .role-content { flex: 1; padding-left: 20px; }
    .tree-item { margin-bottom: 16px; }
    .tree-title { font-weight: 700; margin-bottom: 8px; }
  </style>
</head>
<body>
  <div id="app">
    <div class="role-wrapper">
      <!-- 左侧角色列表 -->
      <div class="role-list">
        <div
          v-for="(item, idx) in roleList"
          :key="item.roleId"
          class="role-item"
          :class="{ active: activeIndex === idx }"
          @click="changeRole(idx)"
        >
          {{ item.roleName }}
        </div>
      </div>

      <!-- 右侧 tabs -->
      <div class="role-content">
        <el-tabs v-model="activeName">
          <el-tab-pane label="功能权限" name="tree">
            <div class="tree-wrapper">
              <div v-for="item in treeList" :key="item.id" class="tree-item">
                <!-- title -->
                <div class="tree-title">{{ item.title }}</div>
                <!-- 树形结构:ref 在 v-for 里,$refs.tree 是数组 -->
                <el-tree
                  ref="tree"
                  :props="{ label: 'title' }"
                  :data="item.children"
                  show-checkbox
                  :default-expand-all="true"
                  node-key="id"
                />
              </div>
            </div>
          </el-tab-pane>

          <el-tab-pane :label="`成员(${roleUserList.length})`" name="user">
            <div class="user-wrapper">
              <el-table :data="roleUserList" style="width: 100%">
                <el-table-column type="index" width="250" label="序号" />
                <el-table-column prop="name" label="员工姓名" />
                <el-table-column prop="userName" label="登录账号" />
              </el-table>
            </div>
          </el-tab-pane>
        </el-tabs>
      </div>
    </div>
  </div>

  <script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>
  <script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
  <script>
    /* ---------- 以下为模拟接口,真实项目里来自 @/api/role ---------- */
    const mockRoleList = [
      { roleId: 1, roleName: '管理员' },
      { roleId: 2, roleName: '前端讲师' }
    ]
    // 可选权限树(两份,与 ref="tree" 的数组一一对应)
    const mockTreeList = [
      {
        id: 1,
        title: '园区管理',
        children: [{ id: 11, title: '楼宇管理' }, { id: 12, title: '区域管理' }]
      },
      {
        id: 2,
        title: '行车管理',
        children: [{ id: 21, title: '月卡管理' }, { id: 22, title: '车辆管理' }]
      }
    ]
    // 每个角色的权限点:二维数组,元素是 node-key(id),与 treeList 下标对应
    const mockPermsMap = {
      1: [[11, 12], [21]],
      2: [[11], []]
    }
    const mockRoleUserMap = {
      1: [{ name: '张三', userName: 'zhangsan' }],
      2: [{ name: '李四', userName: 'lisi' }, { name: '王五', userName: 'wangwu' }]
    }

    const getRoleListAPI   = () => new Promise(r => setTimeout(() => r({ data: mockRoleList }), 100))
    const getTreeListAPI   = () => new Promise(r => setTimeout(() => r({ data: mockTreeList }), 100))
    const getRoleDetailAPI = (roleId) =>
      new Promise(r => setTimeout(() => r({ data: { perms: mockPermsMap[roleId] } }), 100))
    const getRoleUserAPI   = (roleId) =>
      new Promise(r => setTimeout(() => r({ data: { rows: mockRoleUserMap[roleId] } }), 100))

    new Vue({
      el: '#app',
      data() {
        return {
          roleList: [],      // 角色列表
          treeList: [],      // 可选权限树
          perms: [],         // 当前角色权限点列表(二维数组)
          roleUserList: [],  // 当前角色下的成员列表
          activeIndex: 0,
          activeName: 'tree' // 默认打开 tree
        }
      },
      async mounted() {
        // 先获取角色列表和可选权限列表
        await this.getRoleList()
        await this.getTreeList()
        // 再获取当前角色下的权限列表 + 成员列表
        const roleId = this.roleList[0].roleId
        this.getRoleDetail(roleId)
        this.getRoleUserList(roleId)
      },
      methods: {
        async getRoleList() {
          const res = await getRoleListAPI()
          this.roleList = res.data
        },
        async getTreeList() {
          const res = await getTreeListAPI()
          this.treeList = res.data
        },
        // 获取当前角色详情 + 高亮 Tree 节点
        async getRoleDetail(roleId) {
          const res = await getRoleDetailAPI(roleId)
          this.perms = res.data.perms
          // 等树渲染完成,保证 this.$refs.tree 里是真实组件实例
          await this.$nextTick()
          const treeComponentList = this.$refs.tree // 数组!
          treeComponentList.forEach((tree, index) => {
            tree.setCheckedKeys(this.perms[index] || [])
          })
        },
        // 封装获取当前角色下的成员
        async getRoleUserList(roleId) {
          const res = await getRoleUserAPI(roleId)
          this.roleUserList = res.data.rows
        },
        // 切换角色
        changeRole(idx) {
          this.activeIndex = idx
          const roleId = this.roleList[idx].roleId
          // 1. 获取当前角色下的权限点数据列表
          this.getRoleDetail(roleId)
          // 2. 获取当前角色下的成员数据列表
          this.getRoleUserList(roleId)
        }
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • mounted 里必须先 await this.getRoleList()、await this.getTreeList() 再调 getRoleDetail,否则 this.roleList[0] 是 undefined、this.$refs.tree 是空数组,直接报错(笔记中特别标注了这条注意事项)。
  • ref="tree" 写在 v-for 内部,this.$refs.tree 拿到的是数组,所以要用 forEach 逐个调用,不能写成 this.$refs.tree.setCheckedKeys()。
  • setCheckedKeys 是「覆盖式」勾选,切换角色时不用先清空;但它依赖 node-key="id",并且传入的必须是数组(this.perms[index] 可能为 undefined,要兜底 || [])。
  • 高亮时 this.perms[index] 与树的下标一一对应,靠的是 perms 本身是二维数组;如果后端返回一维,就需要先按树结构分组。
  • changeRole(idx) 用下标取 roleId,不要用 roleId 当索引;activeIndex 只负责高亮左侧菜单。
html
<!-- 权限数据处理:拆分出一级/二级权限标识,并用 Set 去重 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><title>权限数据处理</title></head>
<body>
  <pre id="out"></pre>
  <script>
    // 后端返回的 permissions:一维数组,一级/二级权限标识混在一起,且可能重复
    const permissions = [
      'park',
      'parking:building',
      'park',
      'parking:area',
      'parking:building'
    ]

    // 处理一级路由权限标识:取冒号前面的部分
    function getRouterFirstPermission(permissions) {
      const firstList = permissions.map(item => item.split(':')[0])
      // Set 天然去重,再用展开运算符还原成数组
      return [...new Set(firstList)]
    }

    // 处理二级路由权限标识:取冒号前两段拼接
    function getRouterSecondPermission(permissions) {
      const secondList = permissions
        .filter(item => item.includes(':')) // 只有一级的项(如 'park')不参与拼接
        .map(item => {
          const arr = item.split(':')
          return arr[0] + ':' + arr[1]
        })
      return [...new Set(secondList)]
    }

    const firstPerms = getRouterFirstPermission(permissions)
    const secondPerms = getRouterSecondPermission(permissions)

    document.getElementById('out').textContent =
      '原始权限数据:' + JSON.stringify(permissions) + '\n' +
      '一级权限标识:' + JSON.stringify(firstPerms) + '\n' +
      '二级权限标识:' + JSON.stringify(secondPerms)
  </script>
</body>
</html>

关键行与易错点

  • item.split(':')[0] 取到冒号前的一段,即一级菜单标识(park、parking),用 map 映射出同长度的新数组。
  • 二级标识是「前两段拼接」:arr[0] + ':' + arr[1],得到 parking:building 这种能直接和路由 name 比对的值。
  • 去重写法:[...new Set(list)]。new Set(arr) 得到的是 Set 实例,不能直接当数组用(没有 length、不能下标取值),必须用 ... 展开成新数组。
  • 易错:如果二级处理时不加 filter(item => item.includes(':')),像 'park' 这种只有一级的项会拼成 'park:undefined',多出一个脏标识。
  • 易错:map 一定要有返回值,写成 { arr[0] + ':' + arr[1] } 会得到一串 undefined。
  • 拆成一级/二级两份数据的目的是:后续用它们对本地动态路由表做过滤,一级管一级菜单、二级管二级菜单,各管各的。
html
<!-- 获取当前用户权限数据:Vuex action + 模拟路由守卫 dispatch -->
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><title>获取用户权限数据</title></head>
<body>
  <div id="app"><pre>{{ log }}</pre></div>

  <script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>
  <script src="https://unpkg.com/vuex@3.6.2/dist/vuex.js"></script>
  <script>
    // 模拟接口:真实项目里是 request({ url: '/park/user/profile' }),靠请求头里的 token 识别用户
    const getUserProfileAPI = () =>
      new Promise(resolve =>
        setTimeout(() => resolve({
          data: {
            userId: 1,
            userName: 'CP123',
            permissions: ['park', 'parking:building', 'park', 'parking:area']
          }
        }), 100)
      )

    // store/modules/user.js
    const userModule = {
      namespaced: true,
      state: {
        token: 'mock-token',
        userProfile: {} // 个人信息
      },
      mutations: {
        setUserProfile(state, userProfile) {
          state.userProfile = userProfile
        }
      },
      actions: {
        async getUserProfile(store) {
          const res = await getUserProfileAPI()
          // 通过 mutation 存到 state(Vuex 里只有这一种方式)
          store.commit('setUserProfile', res.data)
          // 把权限数据 return 出去,供 permission.js 使用
          return res.data.permissions
        }
      }
    }

    const store = new Vuex.Store({
      modules: { user: userModule }
    })

    // 模拟 src/permission.js 里的前置守卫
    async function beforeEach() {
      const token = store.state.user.token
      if (!token) return '/login' // 没有 token 直接去登录页
      // 有 token 才请求用户权限数据;await 才能拿到 action 的返回值
      const permissions = await store.dispatch('user/getUserProfile')
      return permissions
    }

    new Vue({
      el: '#app',
      store,
      data() {
        return { log: '' }
      },
      async mounted() {
        const permissions = await beforeEach()
        this.log =
          '当前的权限数据为:' + JSON.stringify(permissions) + '\n' +
          'store 中存的 userProfile:' + JSON.stringify(store.state.user.userProfile)
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • action 里做了两件事:store.commit('setUserProfile', res.data) 把数据存进 state,return res.data.permissions 把数据交给别的模块用——这是 Vuex 支持的写法,笔记里 permissions 就是后续做菜单过滤的依据。
  • 请求要放在 permission.js(路由守卫)里,并且放在「token 已存在」的分支中,因为该接口靠 token 识别用户;没有 token 时调它没有意义。
  • 易错:store.dispatch(...)

常见错误 ​

  1. 切换角色后树上的勾选“串台”或停在旧角色 现象:点第 2 个角色,树里显示的却是第 1 个角色的权限点;快速连点时更明显。 原因:getRoleDetail 是异步的,多个请求并发返回顺序不保证;且回填时用 this.perms[index] 裸下标绑定,perms 与 treeList 的索引并不天然同序。 修法:请求发出前记录 roleId(或自增 token),响应回来后比对当前 activeIndex 对应的 roleId 不一致就丢弃;回填时以 treeList 的 key 去 perms 里取,而不是靠数组下标对齐。

  2. 首次进入页面 setCheckedKeys 报 Cannot read properties of undefined 现象:控制台在 tree.setCheckedKeys 处报错,树一个都不勾。 原因:mounted 里 getTreeList() 没 await,或 $refs.tree 在 el-tabs 未激活的 pane 里还没渲染;v-for 出来的 ref 在列表为空时是 undefined 而非数组。 修法:mounted 里严格按 await getRoleList() → await getTreeList() → getRoleDetail() 顺序;取 ref 后先判空并统一成数组([].concat(this.$refs.tree || [])),必要时放到 $nextTick 里执行。

  3. 空权限角色切换后,树仍保留上一个角色的勾选 现象:切到没有权限点的角色,树上的勾没被清掉。 原因:接口返回 perms 为 undefined 或 null,setCheckedKeys(undefined) 不会清空,等于什么都没做。 修法:赋值处兜底 this.perms = res.data.perms || [],回填时传空数组而不是 undefined。

  4. 用表格行数据(row)回填详情,字段缺失或不是最新值 现象:编辑页备注为空、权限点少几项;别人改过数据后本页显示的还是旧值。 原因:列表接口字段与详情字段不完全一致,且 row 是列表请求那一刻的快照。 修法:进入详情统一调详情接口取全量字段,row 只用来传 id(路由参数)。

  5. 权限数据在 permission.js 里拿到 undefined 现象:const perms = await store.dispatch('user/getUserProfile') 得到 undefined,后续 split 直接崩。 原因:Vuex 的 action 内部调完接口只做了 commit,没有 return 目标数据。 修法:action 末尾 return res.data.perms;调用方 await 后判空再处理。

  6. 去重/切分后二级菜单整体不生效 现象:一级菜单能过滤,二级菜单全没了,或反过来一级里混进了二级标识。 原因:一级只取 split(':')[0],二级也照抄成 split(':')[0],两份数据变成一样的;或去重时把顺序打乱后与路由顺序对不上。 修法:一级取第一段、二级取前两段拼接,两个函数各自独立;去重放在切分之后、返回之前,只对最终数组做 [...new Set(arr)]。

练习 ​

  1. 诊断题:把 changeRole 连续快速点 5 次,观察 Network 面板与树勾选结果,写出一段能复现“串台”的操作步骤,并给出至少两种修复思路(提示:请求序号、丢弃过期响应)。

  2. 改造题:现在回填依赖 perms[index] 与 $refs.tree[index] 的下标对齐。请改成不依赖下标的写法,使得 treeList 顺序变化时回填仍然正确。写出你的数据结构假设与关键代码。

  3. 健壮性题:给 getRoleDetail 补上三处兜底——接口失败、perms 为空、组件已销毁(用户切走页面)。分别说明兜底后用户看到什么。

  4. 编码题:写一个纯函数 splitPerms(perms),一次返回 { firstLevel, secondLevel } 两份已去重的数组,要求:park:building:add 只进二级不进一级;同一层级内不得重复;输入为空数组时返回两个空数组。再用 3 条测试数据自测。

  5. 联动题:成员列表 tab 的 label 依赖 roleUserList.length。请说明在“切换角色 → 立刻切到成员 tab”这一操作下可能出现什么显示问题,并给出修复方案。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1添加角色-完成数据回填08:37
2菜单权限控制-功能演示和方案介绍06:24
3菜单权限控制-整体流程梳理04:12
4菜单权限控制-获取用户权限数据10:33
5菜单权限控制-处理权限数据得到一级和二级权限数据12:18
6权限菜单控制-对权限数据做去重03:56

小结 ​

  1. 详情回填一律走详情接口,row 只负责携带 id——字段完整性和实时性这两点,row 都给不了。
  2. 初始化必须保证“角色列表 → 权限树数据 → 权限点”的串行顺序,任何一步少 await,后面就是 undefined 报错或空树。
  3. 树高亮是命令式操作,依赖组件实例已渲染;ref 拿不到时先怀疑渲染时机,而不是怀疑方法名。
  4. 多角色切换天然存在并发请求,回填结果必须和“当前选中角色”做一致性校验,否则早晚出现串台。
  5. 权限控制的主链路是:拿用户权限 → 拆成一级/二级标识 → 动静路由分离 → 按标识过滤动态路由 → 渲染菜单并动态注册路由。
  6. 权限数据来自 token 对应的用户,所以获取时机必须放在“token 已就绪”的前置守卫里,而不是随便一个生命周期。
  7. 去重、切分这类纯数据处理应抽成独立函数,输入输出明确,才能被复用和单独验证。
  8. 凡是接口返回值参与渲染的地方(perms、roleUserList),都要给空值兜底——空数组和 undefined 在 UI 上完全是两种结果。