Skip to content

菜单权限控制(一) ​

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

学习目标 ​

  • 能够调用权限树接口获取数据,并在添加角色组件中渲染树形列表,使用 ref 获取树实例。
  • 能够配置树节点父子不关联,使用 getCheckedKeys 获取各树选中项并通过 flat 合并判断是否至少选择一项,未选择时给出提示。
  • 能够使用 setCheckedKeys 将已选权限数据回填到确认页树中,并在每次收集前清空选中集合,避免重复累积。
  • 能够通过深拷贝准备两棵相互独立的树数据,分别绑定权限选择树和回填展示树,并使用 props.disabled 回调禁用第二棵树。
  • 能够收集角色名称、描述和权限数据,调用新增角色接口完成提交,并在成功后跳转回角色列表。
  • 能够为列表项编辑入口绑定点击事件,携带当前角色 id 通过路由 query 跳转到编辑页面。

知识点 ​

一、先搭角色管理容器:为什么需要 → 怎么想 → 怎么做 → 易错点 ​

为什么需要:角色管理页面后面要放“左侧角色列表 + 右侧权限配置”两栏。先把最外层容器和左右布局搭好,后续填数据、加树形权限时就不会反复改结构。转写稿里也强调先把列表结构拉过来、渲染出来,再去做选中和下一步。

怎么想:最外层用 role-container 做 flex 容器,左侧固定宽度,右侧自适应。左侧放角色项和“添加角色”按钮,右侧先通过样式预留权限区域。

怎么做:模板中先写:

vue
<div class="role-container">
  <div class="left-wrapper">
    ...
  </div>
</div>

样式中:

scss
.role-container {
  display: flex;
  font-size: 14px;
  background-color: #fff;
  padding: 20px;
  .left-wrapper {
    width: 200px;
    border-right: 1px solid #e4e7ec;
    padding: 4px;
    text-align: center;
  }
}

易错点:left-wrapper 宽度不要漏写,否则后续右侧树形区域会被挤乱;border-right 是左右分栏的视觉边界;scoped 下样式只影响当前组件,别把右侧样式写到全局。

二、角色列表项:先占位,再等数据 ​

为什么需要:左侧角色项是列表,不能一开始写死多个 div。用 v-for 占位,后续接口返回角色数据时直接替换数组即可。

怎么想:一个角色项包含角色图标、角色名、更多操作图标。外层用 flex 做两端对齐,左侧 role-info 放图标和文字,右侧 more 放更多按钮。

怎么做:

vue
<div v-for="item in []" :key="item.roleId" class="role-item">
  <div class="role-info">
    <svg-icon icon-class="user" class="icon" />
    管理员
  </div>
  <div class="more">
    <svg-icon icon-class="more"/>
  </div>
</div>

样式中用 display:flex; align-items:center; justify-content:space-between; 让图标文字与更多按钮分开。

易错点:v-for="item in []" 是静态占位,空数组不会渲染任何角色项,页面上暂时看不到列表是正常的;key 用 item.roleId,要和后续接口字段对应。转写稿里“train/吹列表”应纠正为 tree/tree-list,本节先不急着写树,避免结构没搭好就堆逻辑。

三、添加角色按钮:入口先放稳 ​

为什么需要:“添加角色”是后续弹窗和权限配置的入口,静态阶段先把按钮放在左侧列表底部。

怎么想:按钮宽度跟随左侧容器,用 width: 100%,上方留间距。Element UI 的 el-button 用 size="mini" 控制尺寸。

怎么做:

vue
<el-button class="addBtn" size="mini">添加角色</el-button>
scss
.addBtn {
  width: 100%;
  margin-top: 20px;
}

易错点:按钮要放在 left-wrapper 内,不要放到 role-container 外层;否则宽度 100% 会按整页计算,视觉上会跑偏。

四、选中态与右侧样式预留 ​

为什么需要:角色项后续会有选中高亮,右侧会有树形权限、标签页和用户区域。样式先写好,后续补模板时直接套类名。

怎么想:角色项用 &.active 预留高亮;右侧用 flex:1 自适应,树区域用 flex 分栏。转写稿里提醒“第二块内容不要放错位置”,说明后续复制树模板时要放进对应状态的内容区,不能放到外层。

怎么做:保留笔记中的 .role-item.active、.right-wrapper、.tree-wrapper、.tree-title、::v-deep .el-tabs__nav-wrap 等样式。

易错点:当前没有 active 类,所以看不到高亮;::v-deep 是穿透 Element UI 内部样式的写法,别写成普通选择器;后续做 disabledTreeList 时注意“disabled”不是“diable”,setCheckedKeys/getCheckedKeys 不要写成“set check case/get check case”。

五、完整静态代码 ​

vue
<template>
  <div class="role-container">
    <div class="left-wrapper">
      <div v-for="item in []" :key="item.roleId" class="role-item">
        <div class="role-info">
          <svg-icon icon-class="user" class="icon" />
          管理员
        </div>
        <div class="more">
          <svg-icon icon-class="more"/>
        </div>
      </div>
       <el-button class="addBtn" size="mini">添加角色</el-button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Role',
}
</script>

<style scoped lang="scss">
.role-container {
  display: flex;
  font-size: 14px;
  background-color: #fff;
  padding:20px;
  .left-wrapper {
    width: 200px;
    border-right: 1px solid #e4e7ec;
    padding: 4px;
    text-align: center;

    .role-item {
      position: relative;
      height: 56px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0 20px;
      cursor: pointer;

      &.active {
        background: #edf5ff;
        color: #4770ff;
      }
    }

    .role-info {
      display: flex;
      align-items: center;

      .icon {
        margin-right: 10px;
      }
    }

    .more {
      display: flex;
      align-items: center;
    }
    .addBtn{
      width: 100%;
      margin-top: 20px;
    }
  }

  .right-wrapper {
    flex: 1;

    .tree-wrapper {
      display: flex;
      justify-content: space-between;

      .tree-item {
        flex: 1;
        border-right: 1px solid #e4e7ec;
        padding: 0px 4px;
        text-align: center;
        .tree-title {
          background: #f5f7fa;
          text-align: center;
          padding: 10px 0;
          margin-bottom: 12px;
        }
      }
    }

    ::v-deep .el-tabs__nav-wrap {
      padding-left: 20px;
    }

    .user-wrapper{
      padding:20px;
    }
  }
}
</style>

本节先把静态骨架搭好:左侧列表、添加按钮、选中态样式、右侧布局预留。后续再把 tree 数据拉进来渲染,并注意模板放置位置和状态分支,避免把第二块、第三块内容放串。

本节目标:把“获取角色成员列表”封装成可复用接口 ​

本节笔记只给了一个接口封装:

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

它不是页面逻辑,而是把请求细节收口到 API 层。转写稿里多次出现“接口已经用过了,直接导进来用”“封装到 API 下的 system 里”“调完接口看 Network”的做法,思路是一致的:页面只关心“调哪个函数、传什么参数、拿到什么数据”,不关心 URL 怎么拼。

为什么需要封装接口 ​

为什么需要:如果每个页面都直接写 request({ url: ... }),路径、参数、命名会散落在组件里。改路径时要全局搜,调用处也看不出业务语义。

怎么想:先看接口文档或已有约定,确认三件事:请求路径、参数、返回值。本节函数名是 getRoleUserAPI,语义是“获取角色成员列表”,参数是 roleId,说明它按角色 ID 查成员。

怎么做:在 API 模块中 export function,内部 return request({...})。注意必须 return,否则调用处拿不到 Promise。

易错点:不要漏掉 return;不要把 roleId 写死在 URL 里;模板字符串的反引号和 ${} 不要写错;函数名要和业务语义一致。

逐行理解这段封装 ​

为什么需要:这段代码短,但每一行都有作用。JSDoc 注释说明用途,roleId 是入参,request 是项目统一请求工具。

怎么想:url: \/park/sys/roleUser/${roleId}`表示路径由固定前缀和动态角色 ID 组成。这里没有写method,说明项目里的 request已有默认请求方法;不要凭感觉补get或post`,应以项目约定和接口文档为准。

怎么做:调用时传明确的角色 ID,例如 getRoleUserAPI(roleId)。如果页面里拿到的是字符串或数字,先确认后端能接受;为空时不要发请求。

易错点:/roleUser/${roleId} 中斜杠不要漏;roleId 为 undefined 时会请求出错误路径;不要把“获取角色成员列表”误写成“获取角色列表”,两者参数和语义不同。

结合转写稿看接口封装的完整落点 ​

转写稿视频 5 讲的是“添加角色”接口:先看接口文档,确认参数有角色名称、描述、permission,再在 API 的 system 下封装,页面导入后调用,成功后跳回列表,列表因路由切换重新拉取。这个过程可以迁移到本节:

为什么需要:封装接口不是只写一个函数,还要能正确导入、传参、调试。

怎么想:先确认文档,再封装;调用处只传业务参数;调完看 Network。

怎么做:页面导入 getRoleUserAPI,在需要展示成员列表时调用;用 Network 看请求 URL 是否带上了正确 roleId,响应结构是否符合预期。

易错点:转写稿里“train”应还原为 tree,“吹列表”是 tree 列表,“微放”是 v-for,“sister”是 system,“肉 API”是 role API;读稿时要先纠正这些同音/口误,否则容易把接口名和组件名混在一起。还有 get check case、set check case 应理解为 getCheckedKeys、setCheckedKeys,小planer 指数组 flat 展开。它们不是本节接口代码的一部分,但说明调试时要按真实 API 名称核对。

调试:用 Network 验证封装是否真的生效 ​

为什么需要:封装看起来对,不代表请求对。转写稿里反复“过来看一下接口调用情况”“检查 Network”,就是验证 URL、参数、返回值。

怎么想:如果请求 404,先查路径前缀;如果 roleId 是 undefined,查调用处;如果返回数据不对,查接口文档和响应结构。

怎么做:打开开发者工具 Network,触发调用,检查请求地址是否为 /park/sys/roleUser/具体ID,再检查响应。若页面需要渲染成员列表,应在数据返回后再赋值渲染,避免先渲染空列表再闪一下。转写稿视频 1 的经验是“一上来就发起,点过去立刻显示,体验更好”,可迁移为:进入成员区域前尽早拉取。

易错点:不要只看控制台报错,要看 Network 的实际请求;不要在没有 roleId 时硬调;不要把接口封装和页面状态混在一起。

小结检查清单 ​

封装 getRoleUserAPI 后,至少检查:路径是否含正确 roleId;是否 return request;函数名和注释是否一致;调用处是否传参;Network 是否发出正确请求;返回数据是否按列表处理。做到这些,这个接口才算真正封装完成,而不是只写了一个“看起来像接口”的函数。

一、先把角色列表数据拉回来 ​

为什么需要:左侧角色列表不能写死,必须从后端接口拿到当前系统里的角色数据,否则新增、编辑后列表不会变化。
怎么想:先封装/引入接口 getRoleListAPI,在组件挂载时请求一次,把返回数据存到 roleList。转写稿里也强调“接口已经用过了,直接拿过来用”,并在 mounted 里一上来就发起,避免用户点到某一步才请求导致页面“闪一下”。
怎么做:在 data 中声明 roleList: [],在 mounted 调用 this.getRoleList(),方法里 const res = await getRoleListAPI(),再 this.roleList = res.data。
易错点:不要漏掉 await;不要把整个 res 直接赋给 roleList,笔记里取的是 res.data;接口请求要放在合适的生命周期里,本节用 mounted。

二、用 v-for 渲染左侧角色项 ​

为什么需要:角色数量是动态的,必须循环渲染每一项,并给每一项绑定唯一 key。
怎么想:模板里用 v-for="item in roleList",key 用 item.roleId;每项内部展示用户图标、角色名和更多操作图标。转写稿里提到的“微放”就是 v-for,树形列表也是同样思路:循环数据、把子级 children 继续传给组件。
怎么做:外层 div.role-item 循环,内部 .role-info 展示 svg-icon icon-class="user" 和 &#123;&#123; item.roleName &#125;&#125;,右侧 .more 放更多图标;底部放“添加角色”按钮。
易错点:key 不要用索引,优先用业务唯一 id;类名 active 的样式已写好,但本节模板未绑定选中态,后续做选中时再补;图标组件名和属性要跟项目里已有封装一致。

三、样式布局与“添加角色”入口 ​

为什么需要:角色管理通常是左右结构,左侧角色列表固定宽度,右侧内容自适应;添加按钮要占满左侧宽度,方便操作。
怎么想:外层 display:flex,.left-wrapper 宽 200px 并加右边框,.right-wrapper 用 flex:1;角色项用 flex 做左右分布,cursor:pointer 提示可点击。
怎么做:按笔记中的 SCSS 保留 .role-container、.left-wrapper、.role-item、.addBtn 等样式;按钮用 el-button size="mini",类名 addBtn 设 width:100%。
易错点:scoped 下想改 Element UI 内部样式要用 ::v-deep,笔记里已对 .el-tabs__nav-wrap 这样处理;不要把所有样式都堆到全局,避免污染其他页面。

四、用转写稿补后续“添加角色”的渲染推导 ​

为什么需要:点击“添加角色”后会进入权限配置,树形权限列表也要渲染出来,并且要支持选择、判断、回填和禁用。
怎么想:转写稿里说,tree 列表接口“没什么参数,直接调就行”,并且在组件一挂载就请求,这样点过去立刻能显示;模板复制时要从对应 div 整体复制,放到第二个内容区域,不要放错位置。
怎么做:声明 treeList 存树数据,接口返回后赋值 this.treeList = res.data;模板用 v-for 渲染,子级继续传 children。判断是否至少选一项时,遍历树实例,调用 getCheckedKeys(),把结果 push 到 permissionList,每次添加前先清空,再用 flat() 展开,长度为 0 就提示“请至少选择一项”,否则进入下一步。
易错点:permissionList 不清空会因回退反复添加而越积越多;父子节点若要独立选中,要加严格父子不关联配置;第三棵树做回填展示时,数据要和第二棵选择树独立,转写稿提醒数组是引用类型,需深拷贝,否则互相影响;禁用可用 disabled 配置返回 true。提交时按接口要求传角色名称、描述和 permission,成功后 router.push 回列表;下拉菜单里给组件绑点击要用 .native,否则不触发。

完整代码(保留本节笔记代码) ​

vue
<template>
  <div class="role-container">
    <div class="left-wrapper">
      <div v-for="item in roleList" :key="item.roleId" class="role-item">
        <div class="role-info">
          <svg-icon icon-class="user" class="icon" />
          {{ item.roleName }}
        </div>
        <div class="more">
          <svg-icon name="more" />
        </div>
      </div>
       <el-button class="addBtn" size="mini">添加角色</el-button>
    </div>
  </div>
</template>

<script>
import { getRoleListAPI } from '@/apis/system'
export default {
  name: 'Role',
  data() {
    return {
      roleList: []
    }
  },
  mounted() {
    this.getRoleList()
  },
  methods: {
    async  getRoleList() {
      const res = await getRoleListAPI()
      this.roleList = res.data
    }
  }
}
</script>

<style scoped lang="scss">
.role-container {
  display: flex;
  font-size: 14px;
  background-color: #fff;
  padding:20px;
  .left-wrapper {
    width: 200px;
    border-right: 1px solid #e4e7ec;
    padding: 4px;
    text-align: center;

    .role-item {
      position: relative;
      height: 56px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0 20px;
      cursor: pointer;

      &.active {
        background: #edf5ff;
        color: #4770ff;
      }
    }

    .role-info {
      display: flex;
      align-items: center;

      .icon {
        margin-right: 10px;
      }
    }

    .more {
      display: flex;
      align-items: center;
    }
    .addBtn{
      width: 100%;
      margin-top: 20px;
    }
  }

  .right-wrapper {
    flex: 1;

    .tree-wrapper {
      display: flex;
      justify-content: space-between;

      .tree-item {
        flex: 1;
        border-right: 1px solid #e4e7ec;
        padding: 0px 4px;
        text-align: center;
        .tree-title {
          background: #f5f7fa;
          text-align: center;
          padding: 10px 0;
          margin-bottom: 12px;
        }
      }
    }

    ::v-deep .el-tabs__nav-wrap {
      padding-left: 20px;
    }

    .user-wrapper{
      padding:20px;
    }
  }
}
</style>

准备模板与放置位置 ​

为什么需要:添加角色是分步流程,第二步要展示权限树,第三步要做回填确认。模板如果放错层级,后续数据绑定和回填都会乱。
怎么想:先把右侧区域的结构搭好,树列表用 v-for 生成,每棵树内部放 el-tree。转写稿里反复强调“第二块内容”“第三块内容”,就是提醒不要把模板复制到外层。
怎么做:保留本节笔记里的结构代码:

html
<div class="left-wrapper"> 省略代码 </div>

<div class="right-wrapper">
  <div class="tree-wrapper">
    <div v-for="item in []" :key="item.id" class="tree-item">
      <div class="tree-title"> 楼宇管理 </div>
      <el-tree
        :data="[]"
        />
    </div>
  </div>
</div

易错点:复制模板时从对应 div 整体复制,放到第二个状态或第三个状态内部;不要只复制 el-tree,否则标题、循环和布局会丢。

获取并渲染树形数据 ​

为什么需要:权限配置页要先把树列表渲染出来,用户才能勾选。
怎么想:接口已经存在,直接导入并调用;在组件初始化时就请求,而不是点到第二步才请求,避免切换时闪一下。
怎么做:从 API 中导入“获取 tree 权限列表”接口,写方法调用,把返回数据赋给 treeList;模板里 v-for 遍历 treeList,el-tree 的 :data 绑定当前项的 children。转写稿里“train”是同音字,实际应理解为 tree。
易错点:接口返回结构要和之前树渲染的结构对齐;v-for 的 :key 要用稳定 id;数据没回来时先给空数组,避免模板报错。

让父子节点独立选中 ​

为什么需要:权限选择里,选中父节点不应强制选中所有子节点,否则用户无法只选楼宇不选下面的权限。
怎么想:这是 el-tree 的选中联动行为,默认父子关联;需要改成严格独立。
怎么做:给 el-tree 加 check-strictly 属性。转写稿里口述为“是否严格区分父子不关联”,加上后父节点和子节点互不影响。
易错点:这个属性要加在权限选择那棵树;回填树也要保持同样逻辑,否则回填后子节点会被连带选中,出现“怎么多了俩”的现象。

判断至少选中一项 ​

为什么需要:用户一项没选就点下一步,应提示“请至少选择一项”,类似表单校验。
怎么想:先拿到每棵树选中的节点,再判断总数是否大于 0。因为 v-for 里有多棵树,ref 拿到的是数组,要遍历每个树实例调用 getCheckedKeys()。
怎么做:在 permissionList 中收集各树选中项,每次收集前先清空,再 push;用 flat() 展平成一个数组,判断长度是否为 0。为 0 时 this.$message.warning('请至少选择一项'),否则进入下一步。
易错点:不清空 permissionList 会导致回退再下一步时重复累积;转写稿调试时看到从 5 个变多,就是没有先清空。getCheckedKeys() 本身已能拿到当前选中项,不需要重复记录旧数据。

第三棵树回填 ​

为什么需要:第三步要让用户确认前面选过的权限,所以要把第二棵树选中的节点回填到第三棵树。
怎么想:回填用 setCheckedKeys(),数据来源就是上一步收集的 permissionList。两棵树在 v-for 中下标对应,因此回填时用 permissionList[index]。
怎么做:给第三棵树绑定独立 ref,遍历其实例调用 setCheckedKeys(permissionList[index])。同时第三棵树的 :data 要绑定独立数据。
易错点:回填树也要加 check-strictly;如果两棵树共享同一个数组,禁用或修改会互相影响。

两棵树独立与禁用 ​

为什么需要:第三棵树只做展示确认,不能再次操作;第二棵树仍要供用户选择。若共用同一份数据,禁用第二棵会连带第三棵。
怎么想:不要只在状态变化时切换禁用,而要从数据层面准备两份树数据。转写稿指出数组是引用类型,直接赋值两份变量仍指向同一地址。
怎么做:准备 treeList 和 disabledTreeList,用 JSON.parse(JSON.stringify(...)) 深拷贝,保证结构一致但互不影响;第三棵树通过 :props="{ disabled: () => true }" 禁用。转写稿经验是直接布尔值不生效,要用函数返回 true。
易错点:深拷贝不能省;禁用配置要放在第三棵树,不要写错位置。

提交表单与编辑跳转 ​

为什么需要:最后要收集角色名称、描述、权限数据并新增;列表项还要支持编辑跳转。
怎么想:确认按钮直接调创建接口,参数用表单对象展开,再加 permissionList;成功后 this.$router.push 回列表,列表会重新拉接口。编辑用 el-dropdown,点击“编辑角色”携带当前行 id 跳到新增同一路径。
怎么做:接口参数按文档传 name、description、permission;描述在文档中必填,前端最好也补上。编辑时给下拉项绑定 click,若组件未触发 $emit,加 .native 强制原生事件,再 this.$router.push({ path, query: { id } })。
易错点:组件标签上的 click 默认是自定义事件,不生效时先想 .native;路由跳转参数别丢;提交后回列表依赖路由切换重新请求,别手动只改本地列表。

先复用并封装树权限接口 ​

为什么需要:添加角色进入第二步“权限配置”时,需要把权限树列表渲染出来供用户勾选。转写稿里明确说,这个接口前面已经用过,所以不需要重新设计,直接复用即可。

怎么想:接口没有参数,直接调用;先封装成 getTreeListAPI,组件里只关心“调接口、拿数据、赋值”,不要把请求细节散落在组件中。

怎么做:保留笔记中的接口封装:

jsx
/**
 * 获取tree权限列表
 * @returns
 */
export function getTreeListAPI() {
  return request({
    url: '/park/sys/permision/all/tree'
  })
}

易错点:路径不要抄错;接口无参数时不要硬塞参数;从已有 API 模块导入后,组件里要调用封装函数,而不是在组件里重复写 request。

在组件中声明并获取 treeList ​

为什么需要:模板要渲染树,组件里必须先有一个响应式数据存放接口返回的列表。转写稿里强调:在 data 里声明 treeList,用来存放拿到的数据列表,并且它是数组。

怎么想:请求放在 mounted 里发起。转写稿解释了原因:第二步和第一步属于同一个组件,一上来先拉数据,用户点“下一步”时能立刻显示;如果点过去才拉接口,可能会“闪一下”,体验差一些。

怎么做:保留笔记中的写法:

jsx
// 组件中获取功能权限列表
data() {
  return {
    treeList: [], // 权限树形列表
  }
}

async getTreeList() {
  const res = await getTreeListAPI()
  this.treeList = res.data
}

mounted() {
  this.getTreeList()
}

调试时按转写稿流程:刷新页面,打开检查面板看接口调用情况,确认 tree 请求被调起并返回数据,再把返回值赋给 treeList。

易错点:赋值是 res.data,不是整个 res;treeList 初始值要是数组;mounted 里别忘记调用 this.getTreeList();async/await 要配套使用。

用 v-for 和 el-tree 渲染树形数据 ​

为什么需要:接口返回的是多棵树的数据,每棵树下面还有 children,所以外层要遍历,内层用 el-tree 渲染子节点。转写稿说,这种渲染方式与之前讲过的 tree 渲染一样:通过 v-for 遍历,把下面的 children 传给 el-tree 的 data。

怎么想:外层 v-for 负责“有几棵树”,内层 el-tree 负责“每棵树怎么展示”。复制模板时要从外层 div 整体复制,并放到第二步内容区域,不能放错位置。

怎么做:保留笔记中的模板:

jsx
// 组件中渲染模版
<div v-for="item in treeList" :key="item.id" class="tree-item">
  <div class="tree-title">{{ item.title }}</div>
  <el-tree
    :data="item.children"
    show-checkbox
    default-expand-all
    node-key="id"
  />
</div>

易错点:v-for 要加 :key;el-tree 的 data 是 item.children,不是整个 item;模板要放在第二个状态的内容区;show-checkbox、default-expand-all、node-key="id" 不要漏。

本节调试与代码汇总 ​

转写稿里的调试过程可以概括为:先导入接口,写 getTreeList,在 mounted 中发起;刷新后看 Network,确认接口返回;再把模板复制到第二步区域,声明 treeList,赋值 res.data;最后点“下一步”,能看到树形列表正常显示。这里要注意转写稿中的同音字,例如“吹列表”应理解为 tree 列表,“方题”是方法,“钩的函数”是钩子函数,“ESM面的 date”应理解为 res.data。

本节完整代码如下:

jsx
/**
 * 获取tree权限列表
 * @returns
 */
export function getTreeListAPI() {
  return request({
    url: '/park/sys/permision/all/tree'
  })
}

// 组件中获取功能权限列表
data() {
  return {
    treeList: [], // 权限树形列表
  }
}

async getTreeList() {
  const res = await getTreeListAPI()
  this.treeList = res.data
}

mounted() {
  this.getTreeList()
}

// 组件中渲染模版
<div v-for="item in treeList" :key="item.id" class="tree-item">
  <div class="tree-title">{{ item.title }}</div>
  <el-tree
    :data="item.children"
    show-checkbox
    default-expand-all
    node-key="id"
  />
</div>

这一节先把“接口封装—组件取数—模板渲染”这条链路跑通,后续做勾选判断、回填和禁用时,才有稳定的数据基础。

先看问题:Tree 标题为什么显示不出来 ​

为什么需要:el-tree 渲染节点时,并不是随便拿数据里的某个字段当标题。它默认会按自己的配置去读节点文本字段。如果后端返回的树数据里,标题字段叫 title,而组件默认读的不是这个字段,就会出现节点能展开、但标题空白或显示异常的情况。本节笔记里的这行配置,就是用来告诉 el-tree:请把数据项里的 title 当作标题来显示。

怎么想:先不要急着改数据,也不要手动把 title 改名为 label。先确认两件事:第一,接口返回的每个节点里,标题字段到底叫什么;第二,el-tree 是否允许通过 props 指定字段映射。确认字段名后,再把组件配置和数据字段对上。

怎么做:在 el-tree 上绑定 props,把 label 映射到数据里的 title:

html
<el-tree
  :props="{ label: 'title' }"
/>

这里的 label 是 el-tree 配置项里的键,'title' 是数据项里的字段名。实际项目中它通常还会配合 :data 使用,但本节只聚焦“标题字段适配”这一件事。

易错点:不要把 :props 写成普通字符串属性;不要写成 { label: title },因为 title 会被当成变量而不是字段名;也不要把 label 拼错成 lable。如果标题仍然空白,先打印第一条树数据,确认字段名到底是 title、name 还是别的。

在添加角色流程中定位 Tree ​

为什么需要:转写稿里,点击“添加角色”并填写信息后,会点“下一步”进入权限配置。这个区域需要先把树形列表拉出来渲染,用户才能继续选中权限。标题适配不是孤立存在的,它发生在“树数据已经拿到、模板已经渲染”的前提下。

怎么想:先确认接口已经存在,直接复用“获取 tree 权限列表”的接口;然后在组件里写方法调用接口,把返回数据存到响应式变量中。转写稿里特别提到,不要等点到第二步才发请求,而是一上来在 mounted 之类的钩子里先拉数据,这样点过去时能立刻显示,体验更好,避免“闪一下”。

怎么做:先调接口拿到树列表,再把数据绑定到 el-tree。模板结构可以从已有页面复制,但要注意放到第二个内容区域,不要放错位置。数据赋值后,再检查 el-tree 的 props 是否把标题字段映射成 title。

易错点:接口返回后没有赋值给响应式变量,页面不会更新;模板放错区域,树不会出现在权限配置里;变量名写错,比如把树列表变量和后面的权限列表变量混用。转写稿里反复强调“放对位置”,就是避免结构对了但渲染区域不对。

配置 props 的正确写法 ​

为什么需要:el-tree 需要知道“哪个字段是标题”。本节笔记给出的就是最小必要配置。把 label 指向 title 后,组件内部遍历节点时就会读取 title 作为显示文本。

怎么想:props 是一个对象,里面写的是“组件配置键 → 数据字段名”的映射。label 不是数据字段本身,而是 el-tree 认识的配置键;'title' 才是我们数据里的真实字段。理解这一点,就不会把 label 和 title 的关系写反。

怎么做:保留笔记中的写法:

html
<el-tree
  :props="{ label: 'title' }"
/>

如果后续发现子节点也渲染不出来,再按同样思路检查 children 字段是否也需要映射;但本节的核心是标题字段。写完后回到页面,点“下一步”进入权限配置,观察树节点标题是否正常显示。

易错点::props 前的冒号不能漏,否则会传字符串;对象里的 'title' 引号不能漏;如果数据里没有 title 字段,配置再对也没用,要先看接口返回。转写稿中先看接口调用情况、确认返回数据,再回到模板处理,这个顺序值得保留:先确认数据,再调组件配置。

调试与验证:先看数据,再看配置 ​

为什么需要:标题不显示时,问题可能出在数据没拿到、数据字段不对、模板没渲染、props 写错四个环节。盲目改组件容易越改越乱。

怎么想:按链路排查:接口是否成功 → 返回数据里有没有 title → 树列表变量是否赋值 → el-tree 是否拿到数据 → props 是否映射到 title。转写稿里也是先检查接口调用、确认返回数据,再回到模板和配置。

怎么做:在浏览器 Network 里看接口是否返回树数据;在代码里临时打印第一条数据,确认字段名;再检查 el-tree 的 :data 和 :props 是否都写对。验证时点“下一步”进入权限配置,看标题是否正常,而不是只看控制台有没有报错。

易错点:只看控制台不报错就以为配置生效;把“节点存在但标题空白”误判为接口没数据;在多个树之间复制模板时,把 props 或数据绑定复制到错误的树上。转写稿后面区分两棵树时也强调独立绑定,这个思想在标题适配上同样适用:哪棵树用哪份数据、哪套配置,要对应清楚。

小结 ​

本节的核心不是复杂逻辑,而是把数据字段和组件配置对齐:el-tree 通过 :props="{ label: 'title' }" 读取 title 作为节点标题。写的时候记住三点:先确认接口数据字段,再绑定 props,最后到权限配置区域实际验证。遇到标题空白,优先查数据字段和冒号绑定,不要先怀疑样式。

代码示例 ​

示例一:角色列表 + 权限树渲染(完整 SFC) ​

vue
<template>
  <div class="role-container">
    <div class="left-wrapper">
      <div v-for="item in roleList" :key="item.roleId" class="role-item">
        <div class="role-info">
          <svg-icon icon-class="user" class="icon" />
          {{ item.roleName }}
        </div>
        <div class="more">
          <svg-icon name="more" />
        </div>
      </div>
      <el-button class="addBtn" size="mini">添加角色</el-button>
    </div>

    <div class="right-wrapper">
      <div class="tree-wrapper">
        <div v-for="item in treeList" :key="item.id" class="tree-item">
          <div class="tree-title">{{ item.title }}</div>
          <el-tree
            :data="item.children"
            show-checkbox
            default-expand-all
            node-key="id"
            check-strictly
            :props="{ label: 'title' }"
          />
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { getRoleListAPI, getTreeListAPI } from '@/apis/system'

export default {
  name: 'Role',
  data() {
    return {
      roleList: [],
      treeList: [] // 权限树形列表
    }
  },
  mounted() {
    this.getRoleList()
    this.getTreeList()
  },
  methods: {
    async getRoleList() {
      const res = await getRoleListAPI()
      this.roleList = res.data
    },
    async getTreeList() {
      const res = await getTreeListAPI()
      this.treeList = res.data
    }
  }
}
</script>

<style scoped lang="scss">
.role-container {
  display: flex;
  font-size: 14px;
  background-color: #fff;
  padding: 20px;
  .left-wrapper {
    width: 200px;
    border-right: 1px solid #e4e7ec;
    padding: 4px;
    text-align: center;

    .role-item {
      position: relative;
      height: 56px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0 20px;
      cursor: pointer;

      &.active {
        background: #edf5ff;
        color: #4770ff;
      }
    }

    .role-info {
      display: flex;
      align-items: center;

      .icon {
        margin-right: 10px;
      }
    }

    .more {
      display: flex;
      align-items: center;
    }
    .addBtn {
      width: 100%;
      margin-top: 20px;
    }
  }

  .right-wrapper {
    flex: 1;

    .tree-wrapper {
      display: flex;
      justify-content: space-between;

      .tree-item {
        flex: 1;
        border-right: 1px solid #e4e7ec;
        padding: 0px 4px;
        text-align: center;
        .tree-title {
          background: #f5f7fa;
          text-align: center;
          padding: 10px 0;
          margin-bottom: 12px;
        }
      }
    }

    ::v-deep .el-tabs__nav-wrap {
      padding-left: 20px;
    }

    .user-wrapper {
      padding: 20px;
    }
  }
}
</style>
javascript
// @/apis/system.js
/**
 * 获取tree权限列表
 * @returns
 */
export function getTreeListAPI() {
  return request({
    url: '/park/sys/permision/all/tree'
  })
}

关键行与易错点

  • v-for="item in treeList" 外层遍历的是「一棵树一个 tree-item」,el-tree 的 :data 只接 item.children,不要把整份 treeList 传给单棵树。
  • node-key="id" 必须写:后续 getCheckedKeys() / setCheckedKeys() 全靠它做节点唯一标识,漏写会导致取值、回填全空。
  • :props="{ label: 'title' }" 是把节点显示字段映射到后端返回的 title;不配的话 el-tree 默认找 label 字段,页面会一片空白。
  • check-strictly 让父子节点不联动:勾父不勾子、取消子也不影响父。权限场景必须独立选中,否则回填时会"多选一堆子节点"。
  • 两个接口都放在 mounted 里一次性发起(转写稿强调:点"下一步"再拉会闪一下,体验差),页面一进来数据就绪。
  • 样式里 ::v-deep .el-tabs__nav-wrap 用于穿透 scoped 影响 Element UI 内部结构,别写成普通后代选择器。

示例二:点"下一步"时校验「至少选中一项」 ​

vue
<template>
  <div class="role-container">
    <div class="right-wrapper">
      <div class="tree-wrapper">
        <div v-for="item in treeList" :key="item.id" class="tree-item">
          <div class="tree-title">{{ item.title }}</div>
          <el-tree
            ref="treeRef"
            :data="item.children"
            show-checkbox
            default-expand-all
            node-key="id"
            check-strictly
            :props="{ label: 'title' }"
          />
        </div>
      </div>

      <div class="step-bar">
        <el-button size="mini" @click="activeStep--">上一步</el-button>
        <el-button type="primary" size="mini" @click="next">下一步</el-button>
      </div>
    </div>
  </div>
</template>

<script>
import { getTreeListAPI } from '@/apis/system'

export default {
  name: 'Role',
  data() {
    return {
      activeStep: 1, // 1 角色信息 2 权限配置 3 检查并完成
      treeList: [],
      permissionList: [] // 存放所有选中项(每个树一个小数组)
    }
  },
  mounted() {
    this.getTreeList()
  },
  methods: {
    async getTreeList() {
      const res = await getTreeListAPI()
      this.treeList = res.data
    },
    next() {
      // 当前是「选择权限信息」状态时才做校验
      if (this.activeStep === 2) {
        // 先清空,避免回退再前进时数据不断累加
        this.permissionList = []

        this.$refs.treeRef.forEach((item) => {
          // getCheckedKeys() 拿到当前这棵树选中的节点 id 数组
          const keys = item.getCheckedKeys()
          this.permissionList.push(keys)
        })

        // 把 5 个小数组拍平成一个大数组
        const allKeys = this.permissionList.flat()

        if (allKeys.length === 0) {
          this.$message.warning('请至少选择一项')
          return false
        }
      }
      this.activeStep++
    }
  }
}
</script>

<style scoped lang="scss">
.role-container {
  display: flex;
  font-size: 14px;
  background-color: #fff;
  padding: 20px;
  .right-wrapper {
    flex: 1;
    .tree-wrapper {
      display: flex;
      justify-content: space-between;
      .tree-item {
        flex: 1;
        border-right: 1px solid #e4e7ec;
        padding: 0px 4px;
        text-align: center;
        .tree-title {
          background: #f5f7fa;
          text-align: center;
          padding: 10px 0;
          margin-bottom: 12px;
        }
      }
    }
    .step-bar {
      margin-top: 20px;
      text-align: center;
    }
  }
}
</style>

关键行与易错点

  • ref="treeRef" 写在 v-for 内部 → this.$refs.treeRef 拿到的是数组(每棵树一个实例),必须 forEach 逐个取,不能直接当单个组件用。
  • this.permissionList = [] 必须先清空:转写稿里的坑就是"上一步 → 下一步"反复切换时,push 会不断叠加,打印出来 5 → 10 → 15 一直涨。
  • flat() 是原生数组方法,把 [[a,b],[c],[],[]] 展平成 [a,b,c];length === 0 即一项都没选。不拍平直接判断 permissionList.length 永远是 5,判断失效。
  • 校验不通过时 return false 提前结束,别让 activeStep++ 执行,否则会"提示了但还是跳过去"。
  • 判断写 if (this.activeStep === 2) 这个分支,是因为同一个 next 方法要服务多个步骤,每个步骤的校验规则不同。
  • 若没加 check-strictly,父节点会处于"半选"状态,getCheckedKeys() 默认不含半选节点,容易出现"明明勾了却没收集到",需要额外 getHalfCheckedKeys()。

示例三:第三棵树回填 + 整体禁用 ​

vue
<template>
  <div class="role-container">
    <div class="right-wrapper">
      <!-- 第二步:可供选择的权限树 -->
      <div v-if="activeStep === 2" class="tree-wrapper">
        <div v-for="item in treeList" :key="item.id" class="tree-item">
          <div class="tree-title">{{ item.title }}</div>
          <el-tree
            ref="treeRef"
            :data="item.children"
            show-checkbox
            default-expand-all
            node-key="id"
            check-strictly
            :props="{ label: 'title' }"
          />
        </div>
      </div>

      <!-- 第三步:只做回填展示、整体禁用的权限树 -->
      <div v-if="activeStep === 3" class="tree-wrapper">
        <div v-for="item in disabledTreeList" :key="item.id" class="tree-item">
          <div class="tree-title">{{ item.title }}</div>
          <el-tree
            ref="disabledTree"
            :data="item.children"
            show-checkbox
            default-expand-all
            node-key="id"
            check-strictly
            :props="{ label: 'title', disabled: () => true }"
          />
        </div>
      </div>

      <div class="step-bar">
        <el-button size="mini" @click="activeStep--">上一步</el-button>
        <el-button type="primary" size="mini" @click="next">下一步</el-button>
      </div>
    </div>
  </div>
</template>

<script>
import { getTreeListAPI } from '@/apis/system'

export default {
  name: 'Role',
  data() {
    return {
      activeStep: 1,
      treeList: [], // 权限配置用(可选)
      disabledTreeList: [], // 回填展示用(禁用)
      permissionList: []
    }
  },
  mounted() {
    this.getTreeList()
  },
  methods: {
    async getTreeList() {
      const res = await getTreeListAPI()
      this.treeList = res.data
      // 深拷贝:结构一致,但两份数据完全独立
      this.disabledTreeList = JSON.parse(JSON.stringify(res.data))
    },
    next() {
      if (this.activeStep === 2) {
        this.permissionList = []
        this.$refs.treeRef.forEach((item) => {
          this.permissionList.push(item.getCheckedKeys())
        })
        if (this.permissionList.flat().length === 0) {
          this.$message.warning('请至少选择一项')
          return false
        }
      }

      this.activeStep++

      // 进入第三步后回填第二棵树
      if (this.activeStep === 3) {
        this.$nextTick(() => {
          this.$refs.disabledTree.forEach((item, index) => {
            item.setCheckedKeys(this.permissionList[index] || [])
          })
        })
      }
    }
  }
}
</script>

<style scoped lang="scss">
.role-container {
  display: flex;
  font-size: 14px;
  background-color: #fff;
  padding: 20px;
  .right-wrapper {
    flex: 1;
    .tree-wrapper {
      display: flex;
      justify-content: space-between;
      .tree-item {
        flex: 1;
        border-right: 1px solid #e4e7ec;
        padding: 0px 4px;
        text-align: center;
        .tree-title {
          background: #f5f7fa;
          text-align: center;
          padding: 10px 0;
          margin-bottom: 12px;
        }
      }
    }
    .step-bar {
      margin-top: 20px;
      text-align: center;
    }
  }
}
</style>

关键行与易错点

  • 必须准备两份数据:treeList 供用户勾选,disabledTreeList 只做回填展示。共用一份的话,给第三棵树加禁用会连带把第二棵也禁掉。
  • 直接写 this.disabledTreeList = res.data 是错的:数组是引用类型,两个属性指向同一块堆内存,改一个另一个跟着变。必须 JSON.parse(JSON.stringify(...)) 深拷贝。
  • disabled: () => true 必须写成函数返回 true:Element UI 会遍历每个节点调用它,用返回值决定是否禁用。直接写 disabled: true 不生效(转写稿实测)。
  • 回填用 setCheckedKeys(数组),与 getCheckedKeys() 配对;下标 index 依赖两棵树结构完全一致(都是 5 棵、顺序相同),所以深拷贝是前提。
  • setCheckedKeys 要放进 $nextTick,保证 v-if="activeStep === 3" 的树已经渲染出实例,否则 $refs.disabledTree 是 undefined。
  • 回填树同样要加 check-strictly,否则设置父节点 id 时会把其下所有子节点一起勾上,出现"多选了好几个"的现象。

常见错误 ​

1. 勾父节点,子节点全被带上;取消一个子节点,父节点也跟着灭

  • 现象:想单独勾"楼宇管理"这一级,结果下面所有子项全被选中;取消任一子项,父级也自动取消。
  • 原因:el-tree 默认 check-strictly = false,父子节点强关联,只有子节点全选父节点才算选中。
  • 修法:给需要独立勾选的树加 :check-strictly="true";注意回填用的那棵树也要加,否则回填结果和用户选择不一致。

2. 第三棵树设为禁用后,第二棵树的复选框也一起变灰

  • 现象:只给第三步的树加 disabled 配置,结果第二步的树也不能点了。
  • 原因:两棵树绑定的是同一份 treeList,props.disabled 是按节点数据生效的,数据同源必然互相影响。
  • 修法:新增独立的 disabledTreeList,用 JSON.parse(JSON.stringify(treeList)) 深拷贝一份再绑定;直接赋值只是复制引用,仍会联动。

3. 反复"上一步 / 下一步"后,选中数量越算越多

  • 现象:只勾了 3 个节点,但 permissionList 长度是 8、13、18 一路涨;不勾任何项也可能通过校验。
  • 原因:getCheckedKeys() 每次返回的都是当前全量结果,而 permissionList 只 push 从不清空,属于累加式收集。
  • 修法:遍历每棵树之前先 this.permissionList = [],再 push;getCheckedKeys 本身是幂等查询,不需要"记住上次"。

4. 回填时调了 setCheckedKeys,但树上没有任何选中项

  • 现象:第三步界面正常显示名称,但权限树是空的,控制台也没有报错。
  • 原因:调用时机在树渲染之前,或 ref 名字与实际绑定不一致(拿到的是 undefined 或数组)。
  • 修法:核对 ref 名称,把回填放到进入该步骤之后(this.$nextTick 内)执行;同时确认传入的是 id 数组而不是节点对象数组。

5. 给下拉菜单项绑 @click 完全没反应

  • 现象:点"编辑角色"打印不出日志,也没有跳转。
  • 原因:写在组件标签上的 @click 默认是自定义事件,组件内部没有 $emit('click') 就不会触发。
  • 修法:加 .native 修饰符绑定到根元素的原生点击;或改用组件提供的 command 事件统一处理。

6. 树节点标题一片空白 / 显示 undefined

  • 现象:el-tree 有节点但没文字,或者只有展开箭头。
  • 原因:接口返回的字段是 title,而 el-tree 默认读 label。
  • 修法: :props="{ label: 'title' }";同时检查 node-key="id" 是否与数据主键一致,否则选中态无法对应。

7. 提交添加时报 400,提示描述不能为空

  • 现象:只填了角色名称,前端校验全过,点"确认添加"接口报错。
  • 原因:接口文档中角色描述是必填项,页面没做对应校验,前后端校验规则不一致。
  • 修法:提交前补上描述的非空校验;或在联调阶段就把文档里的必填项同步到表单规则里。

练习 ​

  1. 把"至少勾选一项"的判断改写为不依赖 flat() 的版本(提示:some + length),对比两种写法在"五棵树全空"和"只有最后一棵有值"时的表现差异。
  2. 给添加角色流程补上"角色描述必填"校验,要求:为空时点"确认添加"给出提示且不发请求;验证与接口文档的必填项一致。
  3. 实现"删除角色":删除后刷新列表,并处理"被删除的正是当前选中角色"的边界情况(选中态如何重置)。
  4. 把三棵树的"渲染 / 回填 / 禁用"逻辑抽成一个可复用的组合式逻辑或 mixin,思考编辑态下权限 id 从接口返回时该如何回填。
  5. 复现实验:故意把 disabledTreeList 写成 this.disabledTreeList = this.treeList,截图或记录现象,并用三句话说明为什么会联动、如何验证深拷贝已生效。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1添加角色-准备树形数据列表03:50
2添加角色-判断tree是否至少选择了一项16:33
3添加角色-实现基础数据回填05:51
4添加角色-区分两颗树把第二颗树禁用12:12
5添加角色-收集表单数据完成添加06:40
6添加角色-携带id跳转路由05:40

小结 ​

  • 多步表单的核心不是"页面切换",而是每一步的守卫:状态分支 + 数据校验 + 通过后才放行。
  • 树的"可选态"和"只读展示态"在业务上是两棵树,不要用状态开关去复用同一份数据。
  • JS 里数组赋值只传引用,凡是"看起来复制了一份"的地方都要确认是否真的深拷贝。
  • 组件库中以函数形式配置的 prop(如 disabled),本质是把函数返回值当作逐节点的判断条件,理解这一点就不用死记文档。
  • 收集类逻辑(push 进数组)必须配套清空,否则用户每回退一次就多算一遍,这类 bug 只在反复操作后才暴露。
  • 组件上绑事件不生效时,先分清"自定义事件"还是"原生事件",再决定加不加 .native。
  • 依赖 DOM 的 API(setCheckedKeys、ref 取值)都要放在渲染完成之后,$nextTick 是最低成本的对齐手段。
  • 前端校验规则要和接口文档保持一致,否则用户能过前端却过不了后端,体验和联调成本都会翻倍。