Element Plus 级联选择器实战:仿学科网教材多级选择的完整方案

基于 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.5checkOnClickNode prop
  • Vue 3v-model 响应式绑定
  • Spring BootList<Long> 自动绑定重复 key 参数