基于 Element Plus 2.10+ 的
el-cascader,实现一个「前两级单选 + 后续层级同父多选」的教材选择器,涵盖 props 配置、选中逻辑、UI 定制和数据提交。
一、需求背景
在资源上传场景中,教材数据是一个多级树结构:
出版社(第1级)
└── 教材(第2级)
└── 章(第3级)
└── 节(第4级)
业务要求:
- 第1、2级 (出版社、教材):单选,用于定位教材
- 第3级起 (章、节):同父多选,用于关联具体章节
- 点击节点文字即可选中,不需要精确点击 checkbox
- 前两级隐藏 checkbox,UI 更简洁
二、Cascader Props 配置
vue
<el-cascader
v-model="textbookCascaderValue"
:options="textbookCascaderList"
:props="{
expandTrigger: 'click',
checkStrictly: true,
multiple: true,
checkOnClickNode: true
}"
popper-class="textbook-cascader-popper"
clearable
filterable
placeholder="请选择"
@change="onTextbookCascaderChange"
/>
Props 逐项说明
| Prop | 值 | 作用 |
|---|---|---|
expandTrigger |
'click' |
点击节点展开子级(默认是 'hover') |
checkStrictly |
true |
父子节点不关联勾选,任意层级都可选 |
multiple |
true |
开启多选模式,显示 checkbox |
checkOnClickNode |
true |
2.10.5+ 点击节点文字即勾选,不用精确点 checkbox |
checkOnClickNode是 Element Plus 2.10.5 新增的 prop,解决了「必须点击 checkbox 才能选中」的痛点。在此之前需要通过 CSS hack 或 JS 模拟实现。
三、选中逻辑:前两级单选 + 后续同父多选
核心处理函数
typescript
const onTextbookCascaderChange = (meta: any) => {
const val = meta.textbookCascaderValue
if (!val || val.length === 0) {
meta.textbookIds = []
return
}
const lastPath = val[val.length - 1]
// 前两级(出版社、教材):单选,只保留最后选择的项
if (lastPath.length <= 2) {
meta.textbookCascaderValue = [lastPath]
meta.textbookIds = [lastPath[lastPath.length - 1]]
return
}
// 第三级起(章节):同级 + 同父多选
const parentKey = JSON.stringify(lastPath.slice(0, -1))
const sameParent = val.filter(
(path: any[]) => JSON.stringify(path.slice(0, -1)) === parentKey
)
if (sameParent.length !== val.length) {
meta.textbookCascaderValue = sameParent
}
meta.textbookIds = sameParent.map((path: any[]) => path[path.length - 1])
}
数据结构说明
el-cascader 多选模式下,v-model 的值是二维数组,每个元素是一条从根到选中节点的路径:
javascript
// 选中了「北京大学出版社 → 必修一 → 第一章」和「北京大学出版社 → 必修一 → 第二章」
textbookCascaderValue = [
[1, 10, 100], // 路径:出版社ID=1, 教材ID=10, 章ID=100
[1, 10, 101], // 路径:出版社ID=1, 教材ID=10, 章ID=101
]
逻辑分两段
1. 前两级(path.length <= 2):单选
javascript
if (lastPath.length <= 2) {
meta.textbookCascaderValue = [lastPath] // 只保留最后选的
meta.textbookIds = [lastPath[lastPath.length - 1]]
return
}
用户选了出版社A,再选出版社B → 清掉A,只保留B。
2. 第三级起(path.length >= 3):同父多选
javascript
const parentKey = JSON.stringify(lastPath.slice(0, -1))
const sameParent = val.filter(
(path: any[]) => JSON.stringify(path.slice(0, -1)) === parentKey
)
- 取最后一次选择的父路径(去掉最后一个元素)作为基准
- 过滤掉父路径不一致的旧选项
- 例如:选了「必修一 → 第一章」,再选「必修二 → 第三章」→ 清掉旧的,只保留后者
四、UI 定制:隐藏前两级 Checkbox
问题
multiple: true 会在所有层级显示 checkbox,但前两级(出版社、教材)是单选,显示 checkbox 会让用户困惑。
方案
通过 popper-class 给教材级联的下拉面板加一个专属 class,再用 CSS 隐藏前两级的 checkbox:
vue
<el-cascader popper-class="textbook-cascader-popper" ... />
css
/* 教材级联:前两级(出版社、教材)隐藏 checkbox */
.textbook-cascader-popper .el-cascader-menu:nth-child(1) .el-checkbox,
.textbook-cascader-popper .el-cascader-menu:nth-child(2) .el-checkbox {
display: none;
}
为什么用 popper-class?
el-cascader 的下拉面板是 teleport 到 <body> 的,不在组件 DOM 树内。直接用组件的 class(如 .l-cascader)选择器够不到 弹窗内的元素。popper-class 会把自定义 class 加到弹窗根元素上,是定位 teleported 内容的标准做法。
DOM 结构
html
<!-- teleport 到 body 的弹窗 -->
<div class="el-popper textbook-cascader-popper el-cascader__dropdown">
<div class="el-cascader-panel">
<div class="el-cascader-menu"> <!-- :nth-child(1) = 出版社 -->
<ul>
<li class="el-cascader-node">
<span class="el-checkbox">...</span> <!-- 隐藏 -->
<span class="el-cascader-node__label">北京大学出版社</span>
<i class="el-cascader-node__postfix">→</i>
</li>
</ul>
</div>
<div class="el-cascader-menu"> <!-- :nth-child(2) = 教材 -->
<ul>
<li class="el-cascader-node">
<span class="el-checkbox">...</span> <!-- 隐藏 -->
<span class="el-cascader-node__label">必修一</span>
<i class="el-cascader-node__postfix">→</i>
</li>
</ul>
</div>
<div class="el-cascader-menu"> <!-- :nth-child(3) = 章 -->
<!-- checkbox 正常显示 -->
</div>
</div>
</div>
五、数据提交
后端接收 List<Long> 类型,FormData 提交时用重复 key:
typescript
// 教材ID列表
if (meta.textbookIds?.length) {
meta.textbookIds.forEach((id: number) => {
fd.append('textbookIds', String(id))
})
}
Spring Boot 会自动将多个同名参数绑定到 List<Long>:
java
@Schema(description = "教材ID列表(多选)")
private List<Long> textbookIds;
六、完整流程图
用户操作 cascaderValue 变化 提交数据
─────────────────────────────────────────────────────────────────────
点击出版社 A → [[A]] → textbookIds: []
点击教材 B → [[A, B]] → textbookIds: []
点击章节 1 → [[A, B, 1]] → textbookIds: [1]
点击章节 2 → [[A,B,1], [A,B,2]] → textbookIds: [1,2]
点击章节 3(不同教材) → [[A,C,3]] ← 清掉旧的 → textbookIds: [3]
点击出版社 D → [[D]] ← 清掉旧的 → textbookIds: []
七、踩坑记录
| 问题 | 原因 | 解决 |
|---|---|---|
| 点击 label 无法选中 | checkStrictly 模式下点击 label 只高亮不勾选 |
升级 Element Plus 2.10.5+,使用 checkOnClickNode: true |
| 前两级 checkbox 隐藏不生效 | dropdown 被 teleport 到 body,组件 class 选择器够不到 | 用 popper-class 定位弹窗 |
| 跨父级选择时旧数据未清空 | 多选模式下新选择是追加而非替换 | 在 @change 中手动过滤,以最后选择的父路径为基准 |
| 提交时后端收不到 List | FormData 用逗号分隔的字符串 | 改为重复 key 方式 fd.append('textbookIds', id) |
八、版本要求
- Element Plus >= 2.10.5 :
checkOnClickNodeprop - Vue 3 :
v-model响应式绑定 - Spring Boot :
List<Long>自动绑定重复 key 参数