☰
Element Plus表格禁用勾选:selectable实现行级批量审批控制
2026/10/13 4:45:51 网站建设 项目流程

做后台管理系统久了你会发现,"表格勾选"绝对是最容易被低估的交互之一。需求方永远能想出比组件文档更复杂的勾选规则。我最近处理一个订单批量审批的需求,客户要求已锁定的订单行不能勾选、不能参与全选,最好鼠标放上去还能知道为什么不能勾。一查文档,Element Plus 的 el-table 确实没有一行式的"禁选"属性,但它留了一个selectable回调口子。把这个口子用明白了,上面的需求基本都能优雅落地。这篇文章把验证过的方案、翻车经历、以及几个容易忽略的边界情况一次说清楚,给正被 el-table 勾选列折腾的朋友做个参考。

1. 先搞清楚"不让勾选"到底是个什么问题

1.1 一个典型到不能再典型的批量审批场景

假设你在做一个订单管理系统,订单列表长这样:编号、客户、金额、状态、操作。后台管理员每天要做的事情很机械——勾选待处理的订单,点批量审批。按下批量审批后,系统会逐单走审核流程。

听起来很简单对吧?但业务规则总会插一脚:

  • 已锁定的订单(比如财务冻结、争议中、已由其他管理员处理)不能参与批量审批;
  • 这些行如果不加限制地勾选,一旦提交,后端接口要么报错,要么静默跳过,让用户以为提交成功了;
  • 更麻烦的是,如果全选按钮把这些锁定行也选上,用户还得手动一个个取消,体验直接崩塌。

这种"列表大部分行可以勾,少数行不能勾"的需求,在权限类后台里极其常见。本质上它不是 UI 问题,而是数据权限问题:当前用户对某一行数据是否有操作权。只是这个权限最终要通过表格的勾选框表达出来。

1.2 为什么不能靠隐藏或拦截点击来"曲线救国"

我在群里见过不少"野路子"实现,先说为什么不推荐:

思路一:把 checkbox 藏掉,锁定的行不渲染勾选列。问题是 el-table 的勾选列是整列统一的,你要单独控制某一行的 checkbox 显隐,只能走自定义列渲染。而一旦绕开内置的 selection 列,表头全选、selection-change事件、半选状态全都得自己重新实现一遍。用不了多久你就会发现,你其实是在重写 el-table 的选中状态管理,工程量比想象中大得多。

思路二:监听点击事件,如果点击了锁定的行,就把它从选中列表里剔除。这个思路做"拦截"是可行的,但有个致命问题:用户看到勾选动作发生了,又被强制取消,整个过程有闪烁感。而且如果用户点击的不是 checkbox 本身而是整行,事件处理会更混乱,还要区分select、select-all、selection-change几个事件。维护成本极高。

思路三:用 CSS 把禁选行的 checkbox 置灰。单纯改样式只能骗过眼睛,骗不过数据层。用户照样能通过全选、命令行、脚本把禁选行塞进选中集合,后端该炸还是炸。

所以正确做法不是"事后拦截",而是让组件自己知道哪些行不可选,从一开始就禁止这些行进入选中状态。el-table 的selectable就是专门干这个的。

2. selectable 就是 el-table 留好的那个"闸口"

2.1 三行代码,先让禁选用例跑起来

selectable是一个配置在el-table-column上的属性,注意不是配置在el-table上的。它的签名很简单:

(row: any, index: number) => boolean

返回true表示这行可以勾选,返回false表示这行禁止勾选。看代码:

<el-table :data="tableData" border> <el-table-column type="selection" width="50" align="center" :selectable="isSelectable" /> <el-table-column prop="orderNo" label="订单编号" /> <el-table-column prop="customer" label="客户" /> <el-table-column prop="amount" label="金额" /> <el-table-column prop="status" label="状态" /> </el-table>
const isSelectable = (row: OrderItem) => { // locked 字段为 true 的订单行不允许勾选 return !row.locked }

就这三行,核心功能已经实现了。锁定的行勾选框会变成灰色禁用状态,鼠标点不动,看起来就像被焊死了一样。

不过这里有个容易踩的点:很多新手会把selectable写到el-table上,结果发现完全没效果,代码也不报错。因为它只对type="selection"的列生效,放错位置等于白写。

2.2 机制层面:它到底是怎么拦住的

要理解selectable为什么靠谱,得稍微看一眼 el-table 的选中状态是怎么维护的。

el-table 内部有一个 table store,维护了一堆状态:selection(当前选中的行数组)、isAllSelected、isIndeterminate(半选状态)等等。type="selection"列渲染的时候,每一行的 checkbox 并不是简单绑定某个数据字段,而是由 store 统一计算出来的:

  • 当前 row 是否存在于selection数组里,决定 checkbox 是否打勾;
  • selectable(row)的返回值,决定 checkbox 的disabled是否为true。

也就是说,selectable是在渲染层就切断了"这行可以被选中"的可能性。禁选行的 checkbox 天然是 disabled 的,用户交互层面根本进不去 store 的选中集合。

全选按钮同理。el-table 在执行"全选"时会遍历当前页数据,逐个调用selectable过滤掉不可选行,然后只把"可勾选"的行批量加入选中集合。所以只要selectable写对了,全选按钮不会把锁定的行选上。

这比我手动拦截点击要干净得多,因为状态管理始终走的是组件自己的统一通道,不会出现"UI 显示禁选但 store 里能选"的状态分裂。

2.3 一个关键认知:返回 false 不等于取消选中

这是最容易被忽略的一点。selectable控制的是"能否勾选",不是"是否勾选"。

如果某行一开始是可勾选的,用户已经把它勾上了。此时表格数据刷新,这行的locked变成了true,selectable返回false。UI 上会发生什么?

  • 勾选框变成灰色禁用;
  • 但这行仍然留在 store 的selection数组里;
  • 于是你可能看到一行"灰色勾选框 + 实心打勾状态",看起来非常诡异。

更麻烦的是,全选状态也可能受影响。某行已经选上了,但新规则下它不可选,如果不清除,selection-change事件返回的数组里依然包含这一行,批量操作接口就得自己再过滤一遍,否则会把不该处理的订单提交上去。

所以要记住一个准则:"可勾选条件"变化时,必须同步清理已经失效的选中行。这也是后面第四节要重点讲的数据刷新问题。

3. 手写一个订单批量操作页面(完整可运行)

3.1 环境准备:用 Vite 把 Vue 3 + Element Plus 跑起来

这一步按部就班来就行。我用的是 Vite + Vue 3 组合式 API + Element Plus 全量引入,做演示最省事:

npm create vite@latest el-table-selectable-demo -- --template vue cd el-table-selectable-demo npm install npm install element-plus

然后把main.js改成全量注册:

import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import App from './App.vue' const app = createApp(App) app.use(ElementPlus) app.mount('#app')

生产环境建议按需引入,用unplugin-vue-components加unplugin-auto-import自动处理,效率更高。这里为了演示清晰,全量引入最快。

3.2 核心代码:selectable + 批量操作按钮

整个页面的核心逻辑就三块:表格数据、isSelectable判断函数、批量操作按钮。

我直接给一份能跑的完整代码。

<template> <div class="order-page"> <div class="toolbar"> <el-button type="primary" :disabled="!selectedOrders.length" @click="handleBatchApprove"> 批量审批({{ selectedOrders.length }}) </el-button> <el-button @click="handleReset"> 清空勾选 </el-button> </div> <el-table ref="tableRef" :data="tableData" border @selection-change="handleSelectionChange" > <el-table-column type="selection" width="50" align="center" :selectable="isSelectable" /> <el-table-column prop="orderNo" label="订单编号" min-width="140" /> <el-table-column prop="customer" label="客户" min-width="120" /> <el-table-column prop="amount" label="金额" min-width="100"> <template #default="{ row }"> ¥{{ row.amount.toFixed(2) }} </template> </el-table-column> <el-table-column prop="status" label="状态" min-width="110"> <template #default="{ row }"> <el-tag :type="row.locked ? 'info' : 'success'"> {{ row.locked ? '已锁定' : '待处理' }} </el-tag> </template> </el-table-column> </el-table> </div> </template> <script setup> import { ref } from 'vue' import { ElMessage } from 'element-plus' const tableRef = ref() const selectedOrders = ref([]) const tableData = ref([ { id: 1, orderNo: 'ORD-20240601-001', customer: 'A公司', amount: 1280, status: '待处理', locked: false }, { id: 2, orderNo: 'ORD-20240601-002', customer: 'B公司', amount: 3560, status: '待处理', locked: false }, { id: 3, orderNo: 'ORD-20240602-011', customer: 'C公司', amount: 899, status: '已锁定', locked: true }, { id: 4, orderNo: 'ORD-20240602-015', customer: 'D公司', amount: 2100, status: '待处理', locked: false }, { id: 5, orderNo: 'ORD-20240603-003', customer: 'E公司', amount: 4520, status: '已锁定', locked: true }, ]) const isSelectable = (row) => { // 锁定的订单不能参与批量审批 return !row.locked } const handleSelectionChange = (rows) => { selectedOrders.value = rows } const handleBatchApprove = () => { // 这里再做一次筛选,防止逻辑出现漏网之鱼 const validOrders = selectedOrders.value.filter((row) => !row.locked) if (!validOrders.length) { ElMessage.warning('请先勾选可审批的订单') return } // 实际项目中,这里会调用批量审批接口 ElMessage.success(`已提交 ${validOrders.length} 个订单审批`) } const handleReset = () => { tableRef.value.clearSelection() } </script>

效果一目了然:第三行和第五行的订单,勾选框是灰色禁用的,用户点不动;表头全选按钮只会选中第一、二、四行。批量审批按钮旁边会实时显示已选数量。

3.3 防呆处理:selection-change 里再过滤一次

有人会问:既然selectable已经禁掉了,为什么handleBatchApprove里还要再 filter 一次?

因为凡是走到接口的边界,永远值得多一道校验。现实项目里,selectedOrders可能来自多个渠道:分页记忆恢复的、搜索切换后残留的、同事改代码时误传的。如果在批量提交前不过滤,某些极端情况下,后端就会收到locked为true的订单 ID。

这道防线不是冗余,而是防御式编程的基本素养。后端当然也要校验,但前端先挡住,用户的报错体验会好得多——至少按钮上显示的数量是真实的。

4. 动态禁选条件与数据刷新后的勾选清理

4.1 用 Set 维护"可勾选白名单"

上面的例子是按行上的locked字段硬判断的,等于规则写死在了isSelectable里。但真实项目里,"哪些行可以勾选"经常是后端动态下发的。

比如后端在订单接口里返回了canApprove字段,当前管理员只能审批自己负责区域的订单。这时候判断逻辑就变成:

const isSelectable = (row) => { return row.canApprove === true }

如果后端不下发字段,而是前端维护一个"当前用户可以处理的订单 ID 集合",那更常见:

const approvableIdSet = ref(new Set<number>()) const isSelectable = (row) => { return approvableIdSet.value.has(row.id) }

用Set而不是数组,是因为has()的时间复杂度是 O(1)。当表格从 10 行变成 1000 行时,这个差异是实打实的。

4.2 数据刷新后,失效的勾选必须手动清掉

这是整个需求里最容易出 bug 的部分。我们说过,selectable返回false只是让 checkbox 变成灰色禁用,它不会自动把这一行从 store 的选中集合里踢出去。

你很可能遇到这种场景:用户在列表页勾选了三个订单,点查询条件,把订单状态从"待处理"切到"已锁定"。表格数据刷新后,刚才勾选的三个订单里有一个变成了locked: true,它的 checkbox 是灰色的,但勾选状态还挂在里面。

正确的做法是在数据变化后,清理所有"不再可勾选"的选中行:

const refreshSelectionAfterDataChange = () => { // 拿到当前所有的选中行 const allSelected = tableRef.value.getSelectionRows() // 找出其中已经被规则排除的行 const expiredRows = allSelected.filter((row) => !isSelectable(row)) // 逐个取消勾选 expiredRows.forEach((row) => { tableRef.value.toggleRowSelection(row, false) }) }

这个函数在表格数据赋值之后调用。如果你的表格是接接口的,通常放在await fetchData()之后,再nextTick里执行。因为表格要先把新数据渲染出来,getSelectionRows()才能拿到最新一行的对象引用。

有人会问:为什么不用clearSelection()全清?因为全清会把用户本来合法勾选的行也清掉,体验不好。精细操作虽然代码多一点,但用户那边没有任何意外。

4.3 selectable 里别写重逻辑:它会被反复调用

selectable的函数体比你想的要"勤劳"。表格渲染每一行时调用,全选计算时调用,行数据更新时也可能调用。如果你在里面写row.items.filter(...).some(...)这种 O(n^2) 逻辑,数据量一大,页面就会明显卡顿。

我的习惯是:把复杂的可勾选判定提前计算好,挂在行数据上,selectable只做一次属性读取。参考这样:

const transformRow = (item) => { const canSelect = !item.locked && item.status === '待处理' && item.paid !== false return { ...item, _canSelect: canSelect, } } const tableData = ref(rawList.map(transformRow)) const isSelectable = (row) => { return row._canSelect }

数据进入表格之前就把"能否勾选"算好,selectable里只读一个布尔字段,性能最稳。这相当于把动态规则的复杂度挡在渲染层之外。

5. 进阶体验:禁选原因提示、跨页记忆与批量操作边界

5.1 给禁选行加一点行级样式

只是置灰 checkbox 的话,用户其实不知道这一行为什么不能勾。业务上最好给整行加一点视觉区分,比如半透明或浅灰底色。el-table 提供了一个row-class-name回调:

<el-table ref="tableRef" :data="tableData" :row-class-name="rowClassName" border >
const rowClassName = ({ row }) => { return isSelectable(row) ? '' : 'row-disabled' }

样式写在全局样式或 scoped 样式带上:deep():

:deep(.row-disabled) { background-color: #fafafa; } :deep(.row-disabled .el-checkbox__input.is-disabled .el-checkbox__inner) { background-color: #f0f0f0; border-color: #dcdcdc; }

需要注意:row-class-name返回的类名会作用在整个<tr>上,但 el-table 默认会给单元格压背景色。所以某些情况下需要给单元格也透传背景,用:deep()配合.el-table .row-disabled td才能覆盖干净。

5.2 自定义勾选列:用 Tooltip 解释原因

视觉上不能让用户知道原因,体验就是不完整的。但内置的type="selection"列不给你插 Tooltip 的口子,它只渲染一个原生的 checkbox。

如果想达到"鼠标悬停在禁选 checkbox 上出现提示"的效果,就得把这个列换成自定义渲染:

<el-table-column width="70" align="center"> <template #header> <el-checkbox :model-value="isAllChecked" :indeterminate="isIndeterminate" @change="handleSelectAll" /> </template> <template #default="{ row }"> <el-tooltip v-if="!isSelectable(row)" content="该订单已锁定,无法参与批量审批" placement="left" > <el-checkbox model-value="false" disabled /> </el-tooltip> <el-checkbox v-else :model-value="selectedIdSet.has(row.id)" @change="(val) => handleSelectRow(row, val)" /> </template> </el-table-column>

注意这里已经完全绕开了内置 selection 列,所以选中状态要自己维护:

const selectedIdSet = ref(new Set<number>()) const handleSelectRow = (row, val) => { if (val) { selectedIdSet.value.add(row.id) } else { selectedIdSet.value.delete(row.id) } // 这里要处理半选、全选状态、selection 事件等其他逻辑 }

倒不是说不能这样做。当业务明确要求"禁选原因必须展示"时,这是最直观的方案。但它带来的额外成本是实打实的:全选按钮、半选状态、跨页选中、与后端交互的选中 ID 列表,全部变成手动维护。

我的建议是分两步走:

  1. 如果只是想解释原因,保留内置 selection 列,在旁边状态列里加一个 Tooltip。比如锁定状态下显示一个问号图标,悬停提示"已锁定无法审批"。成本低,效果也不差。
  2. 如果产品经理坚持"鼠标必须放在 checkbox 上就出现提示",再考虑自定义整列,并且把选中状态管理封装成一个独立的 composable,别写在页面组件里。

5.3 跨页勾选记忆的经典做法

el-table 本身的勾选状态只保留在当前渲染的数据范围内。一旦切页、刷新、切换搜索条件,之前页面的勾选就没了。

要做跨页记忆,常见的模式是:selection-change时把选中的行存到一个全局数组里;切页后数据加载完成,再把当前页里"已经存在于全局数组"的行重新勾上。

const selectedRows = ref([]) const syncingSelection = ref(false) const handleSelectionChange = (rows) => { if (syncingSelection.value) return selectedRows.value = rows } const handlePageChange = async () => { await fetchData() // 加载新一页数据 syncingSelection.value = true nextTick(() => { tableData.value.forEach((row) => { const existed = selectedRows.value.some((r) => r.id === row.id) // 禁选行不做强制勾选,避免把失效行带回来 if (isSelectable(row)) { tableRef.value.toggleRowSelection(row, existed) } }) syncingSelection.value = false }) }

加syncingSelection这个标志位是因为toggleRowSelection会触发selection-change事件,如果不加,恢复勾选的过程中全局数组就会被事件反复覆盖,最后得到一堆脏数据。

还有一个细节:跨页记忆要和禁选清理搭配使用。如果一行的权限被收回(isSelectable变成false),它即便存在selectedRows里,也不该被恢复勾选。上面代码里if (isSelectable(row))这个判断就是在做这道拦截。

5.4 批量操作时再做一次边界过滤

批量操作按钮的逻辑也要多一层考虑。跨页记忆场景下,selectedRows可能包含一些"当前页面不显示"的行。批量提交时,除了过滤掉不可勾选的行,还要注意提交数据要不要去重、要不要只保留行 ID。

我的习惯是:

const submitSelection = () => { const validIds = Array.from( new Set( selectedRows.value .filter(isSelectable) // 规则过滤 .map((row) => row.id) // 只留 ID,避免传整个对象 ) ) if (!validIds.length) { ElMessage.warning('请先勾选可处理的记录') return } // 调接口提交 validIds }

5.5 前端限制只是体验,后端校验才是底线

无论是selectable还是toggleRowSelection,都只是前端层面的交互控制。把它当安全边界就大错特错了。

后端批量审批接口必须逐条校验:当前的审批人是否有权限处理这个订单 ID,订单当前状态是否允许批量审批,是否存在并发冲突。前端的灰色禁用只是帮用户省去一次必然失败的点击,后端校验才是真的在守护数据。别问为什么,问就是迟早会有一次绕过前端的调用出现。

6. 踩坑实录:这些雷能帮你省一下午

6.1 selectable 放错位置,静默失效

第一个坑是纯低级错误,但确实常见。selectable写在<el-table :selectable="fn">上,页面不报错,功能完全没效果,排查半天才发现放错了组件。

这个属性的归属是el-table-column,且该列必须type="selection"。如果你用的是自定义勾选列,那selectable同样不生效,因为根本没有内置的选择逻辑可以挂钩。

6.2 已选中的行突然变成禁选,UI 出现"灰色勾选"

这是 4.2 节提到的经典问题。数据刷新后,一行从可勾选变成不可勾选,但勾选状态还残留在 store 里。表面症状是:checkbox 是灰的,但里面有个勾。

对策就是数据变化后主动清理失效选中行。我踩过这个坑之后,养成了一个习惯:所有改变表格数据的代码,后面都跟着一句refreshSelectionAfterDataChange()。

6.3 toggleRowSelection 与 disabled 之间的版本差异

关于toggleRowSelection(row, true)对禁选行是否有效,不同版本行为不完全一致。有的版本即使selectable返回false,手动调用toggleRowSelection(row, true)也会把它塞进选中集合,造成 UI 无勾选但selection-change里有它的矛盾状态;有的版本则会被拦截。

不要依赖这个行为。规则只有一条:禁止对该行调用 toggle(true)。跨页恢复和批量操作逻辑里,先判断isSelectable再做动作。

6.4 用 index 做禁选条件,排序过滤后全线崩溃

有人会图省事,写(row, index) => index > 3。这个判断在表格第一次渲染时没问题,但一旦表格排序、过滤、分页,index对应的行完全变了,禁选的行会随机漂移。

规则必须绑定在稳定的行标识上,通常是id。用index做条件只适合纯静态的演示数据,生产环境不要用。

6.5 常见问题速查表

症状可能原因对策
selectable不生效属性写在了el-table上,或对应列不是type="selection"把属性移到el-table-column上,并确认列类型
禁选行仍然被全选使用自定义勾选列时没有手动过滤全选逻辑恢复内置 selection 列,或补全自定义全选过滤
灰色复选框显示为勾选状态行变成不可选但旧选中状态未清理数据刷新后调用清理函数,将失效行toggleRowSelection(row, false)
切页后勾选丢失el-table 只维护当前渲染数据的选中状态在selection-change中缓存选中行,页变化后恢复勾选
页面数据量增大后卡顿selectable里有 O(n^2) 的复杂计算预先计算行级布尔字段,selectable只读取属性
固定列与勾选列重叠闪烁selection 列fixed设置与其他固定列冲突保持 selection 列置前且固定方向一致,显式指定width

6.6 嵌套表格的干扰

如果用了树形数据或嵌套子表,selectable仍然逐行生效,不影响父子结构。但要小心:父行不可勾选时,子行的状态不会自动联动;如果你希望父子之间有联动(比如父禁选则子也禁选),得自己在selectable里同时判断两层数据。

7. 最后分享一个实践习惯

这类"部分行禁选"的需求,最怕的就是规则散落在多个地方:selectable里写一份,批量按钮里写一份,样式判断里又写一份。哪天产品说"锁定订单里金额小于 100 的其实可以让财务勾",你得改三个地方,漏一个就是线上事故。

我的做法是把"能否勾选"收敛成一个纯函数:

const canSelect = (row) => { return !row.locked && row.status !== '已撤回' && row.approver === currentUserId }

然后所有地方统一引用它:

const isSelectable = (row) => canSelect(row) const handleBatchApprove = () => { const validOrders = selectedRows.value.filter(canSelect) // ... } const rowClassName = ({ row }) => { return canSelect(row) ? '' : 'row-disabled' }

规则变更时只改canSelect一个函数,配套的单元测试也只需要覆盖它。后来我在别的项目里遇到勾选列需求,基本直接把这套模式搬过去,改改canSelect就能上线。包括标题里说的 Vue 3 + el-table 场景,核心就一句话:selectable是控制"能不能勾"的闸口,而"勾上之后是否有效"是你自己的业务防线,二者缺一不可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询