主题
菜单权限控制(一)
本页对应 菜单权限控制,共 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" 和 {{ item.roleName }},右侧 .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,提示描述不能为空
- 现象:只填了角色名称,前端校验全过,点"确认添加"接口报错。
- 原因:接口文档中角色描述是必填项,页面没做对应校验,前后端校验规则不一致。
- 修法:提交前补上描述的非空校验;或在联调阶段就把文档里的必填项同步到表单规则里。
练习
- 把"至少勾选一项"的判断改写为不依赖
flat()的版本(提示:some+length),对比两种写法在"五棵树全空"和"只有最后一棵有值"时的表现差异。 - 给添加角色流程补上"角色描述必填"校验,要求:为空时点"确认添加"给出提示且不发请求;验证与接口文档的必填项一致。
- 实现"删除角色":删除后刷新列表,并处理"被删除的正是当前选中角色"的边界情况(选中态如何重置)。
- 把三棵树的"渲染 / 回填 / 禁用"逻辑抽成一个可复用的组合式逻辑或 mixin,思考编辑态下权限 id 从接口返回时该如何回填。
- 复现实验:故意把
disabledTreeList写成this.disabledTreeList = this.treeList,截图或记录现象,并用三句话说明为什么会联动、如何验证深拷贝已生效。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 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是最低成本的对齐手段。 - 前端校验规则要和接口文档保持一致,否则用户能过前端却过不了后端,体验和联调成本都会翻倍。