恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Element Plus表格禁用勾选:selectable实现行级批量审批控制
首页
资讯中心
/
Element Plus表格禁用勾选:selectable实现行级批量审批控制
Element Plus表格禁用勾选:selectable实现行级批量审批控制
发布时间:2026/10/13 4:45:53
做后台管理系统久了你会发现表格勾选绝对是最容易被低估的交互之一。需求方永远能想出比组件文档更复杂的勾选规则。我最近处理一个订单批量审批的需求客户要求已锁定的订单行不能勾选、不能参与全选最好鼠标放上去还能知道为什么不能勾。一查文档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 :datatableData border el-table-column typeselection width50 aligncenter :selectableisSelectable / el-table-column proporderNo label订单编号 / el-table-column propcustomer label客户 / el-table-column propamount label金额 / el-table-column propstatus label状态 / /el-tableconst isSelectable (row: OrderItem) { // locked 字段为 true 的订单行不允许勾选 return !row.locked }就这三行核心功能已经实现了。锁定的行勾选框会变成灰色禁用状态鼠标点不动看起来就像被焊死了一样。不过这里有个容易踩的点很多新手会把selectable写到el-table上结果发现完全没效果代码也不报错。因为它只对typeselection的列生效放错位置等于白写。2.2 机制层面它到底是怎么拦住的要理解selectable为什么靠谱得稍微看一眼 el-table 的选中状态是怎么维护的。el-table 内部有一个 table store维护了一堆状态selection当前选中的行数组、isAllSelected、isIndeterminate半选状态等等。typeselection列渲染的时候每一行的 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变成了trueselectable返回false。UI 上会发生什么勾选框变成灰色禁用但这行仍然留在 store 的selection数组里于是你可能看到一行灰色勾选框 实心打勾状态看起来非常诡异。更麻烦的是全选状态也可能受影响。某行已经选上了但新规则下它不可选如果不清除selection-change事件返回的数组里依然包含这一行批量操作接口就得自己再过滤一遍否则会把不该处理的订单提交上去。所以要记住一个准则可勾选条件变化时必须同步清理已经失效的选中行。这也是后面第四节要重点讲的数据刷新问题。3. 手写一个订单批量操作页面完整可运行3.1 环境准备用 Vite 把 Vue 3 Element Plus 跑起来这一步按部就班来就行。我用的是 Vite Vue 3 组合式 API Element Plus 全量引入做演示最省事npm create vitelatest 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 classorder-page div classtoolbar el-button typeprimary :disabled!selectedOrders.length clickhandleBatchApprove 批量审批{{ selectedOrders.length }} /el-button el-button clickhandleReset 清空勾选 /el-button /div el-table reftableRef :datatableData border selection-changehandleSelectionChange el-table-column typeselection width50 aligncenter :selectableisSelectable / el-table-column proporderNo label订单编号 min-width140 / el-table-column propcustomer label客户 min-width120 / el-table-column propamount label金额 min-width100 template #default{ row } ¥{{ row.amount.toFixed(2) }} /template /el-table-column el-table-column propstatus label状态 min-width110 template #default{ row } el-tag :typerow.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 Setnumber()) 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 reftableRef :datatableData :row-class-namerowClassName 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 解释原因视觉上不能让用户知道原因体验就是不完整的。但内置的typeselection列不给你插 Tooltip 的口子它只渲染一个原生的 checkbox。如果想达到鼠标悬停在禁选 checkbox 上出现提示的效果就得把这个列换成自定义渲染el-table-column width70 aligncenter template #header el-checkbox :model-valueisAllChecked :indeterminateisIndeterminate changehandleSelectAll / /template template #default{ row } el-tooltip v-if!isSelectable(row) content该订单已锁定无法参与批量审批 placementleft el-checkbox model-valuefalse disabled / /el-tooltip el-checkbox v-else :model-valueselectedIdSet.has(row.id) change(val) handleSelectRow(row, val) / /template /el-table-column注意这里已经完全绕开了内置 selection 列所以选中状态要自己维护const selectedIdSet ref(new Setnumber()) const handleSelectRow (row, val) { if (val) { selectedIdSet.value.add(row.id) } else { selectedIdSet.value.delete(row.id) } // 这里要处理半选、全选状态、selection 事件等其他逻辑 }倒不是说不能这样做。当业务明确要求禁选原因必须展示时这是最直观的方案。但它带来的额外成本是实打实的全选按钮、半选状态、跨页选中、与后端交互的选中 ID 列表全部变成手动维护。我的建议是分两步走如果只是想解释原因保留内置 selection 列在旁边状态列里加一个 Tooltip。比如锁定状态下显示一个问号图标悬停提示已锁定无法审批。成本低效果也不差。如果产品经理坚持鼠标必须放在 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 :selectablefn上页面不报错功能完全没效果排查半天才发现放错了组件。这个属性的归属是el-table-column且该列必须typeselection。如果你用的是自定义勾选列那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上或对应列不是typeselection把属性移到el-table-column上并确认列类型禁选行仍然被全选使用自定义勾选列时没有手动过滤全选逻辑恢复内置 selection 列或补全自定义全选过滤灰色复选框显示为勾选状态行变成不可选但旧选中状态未清理数据刷新后调用清理函数将失效行toggleRowSelection(row, false)切页后勾选丢失el-table 只维护当前渲染数据的选中状态在selection-change中缓存选中行页变化后恢复勾选页面数据量增大后卡顿selectable里有 O(n^2) 的复杂计算预先计算行级布尔字段selectable只读取属性固定列与勾选列重叠闪烁selection 列fixed设置与其他固定列冲突保持 selection 列置前且固定方向一致显式指定width6.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是控制能不能勾的闸口而勾上之后是否有效是你自己的业务防线二者缺一不可。