ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue甘特图实现标记删除:基于vxe-gantt的软删除完整方案

Vue甘特图实现标记删除:基于vxe-gantt的软删除完整方案 做 Vue 项目时给甘特图加一个“标记删除”功能的需求我估计不少团队都踩过同一道坑。尤其是排期、项目计划这种业务场景任务一旦误删就很难追回来所以合理的做法不是把整条数据从表里物理删掉而是做一个“软删除”交互——任务看起来是删了灰掉、加删除线可数据还留在表里需要时随时能恢复。这一套逻辑放到甘特图里就要同时处理数据字段、行样式和结果回传远不是写一个filter那么简单。这篇文章就基于 vxe-gantt 组件把标记删除的完整实现路径讲清楚。从组件接入、数据字段设计到行的置灰删除线效果再到把已标记的行数据提取出来提交给后端我都会给出可以直接复制的代码和踩坑记录。无论你是第一次接触 vxe-gantt还是已经在用只是想加一个状态功能看完都能直接落地。1. 需求拆解标记删除的本质是什么1.1 先搞清楚业务上的“删除”和“标记删除”的差别在常规表格里点删除按钮通常会调用接口把这条数据从数据库里删掉前端再从数据源里移除对应行。这种物理删除在甘特图业务里风险很高因为任务之间往往存在父子关系、前置依赖甚至资源分配一旦误删关联数据可能跟着出问题线上环境又不可能靠“撤回数据库”救场。标记删除则是给每一行数据增加一个状态字段比如deleteMark或status。删除时不做接口级物理移除只把该行的状态改成“已删除”前端用样式把行视觉弱化。这样做有三个直接好处第一误操作可以随时恢复第二操作记录和原始数据都留底方便审计第三如果后续要做“回收站”或“查看已删除项”只需要按状态字段过滤不需要额外建表。在 vxe-gantt 这种双区域组件里标记删除还必须处理好一件事左侧表格行和右侧甘特条虽然是同一个数据源但渲染区域是分开的。单纯给左侧表格行加一个类名甘特条区域不一定跟着变化所以实现时要同时考虑两边的显示状态。1.2 vxe-gantt 里做标记删除的三个关键层面我在实际项目里总结了一下一个完整的标记删除功能至少拆成三块第一是数据层。数据源里要有明确的删除标记字段并且这个字段要跟着树的父子关系走。如果某个父任务被标记删除子任务是否也要联动标记需要业务上先定清楚。第二是表现层。标记删除的行需要和正常行有明显区分通常用颜色变灰、透明度降低、文字加删除线来实现。vxe-gantt 的左侧区域走的是 vxe-table 行渲染逻辑右侧甘特区域走的是条状渲染逻辑两者对应不同的样式配置。第三是业务层。用户操作完标记删除后系统要能从当前数据里把被标记的行提取出来再组装成前后端约定好的格式提交。这一步最容易被忽略因为大家往往以为“反正数据还在直接取数组就行”但树形结构、筛选状态、组件内部缓存这些因素都会让取值变得不简单。1.3 先想清楚标记删除之后要不要“消失”有些团队做的所谓“标记删除”其实是把行从当前视图里隐藏掉只在回收站页面显示。这种交互在甘特图里有利也有弊好处是页面干净删除后任务不会干扰排期视图坏处是用户看不到任务占用的时间条原计划可能悄悄被其他任务占用反而不利于追溯。我建议在甘特图里采用“标记但不隐藏”的方案被标记的行仍然显示在原位置只是视觉上置灰同时支持在工具栏里切换“只显示未删除”或“显示全部”。这样既保留了软删除的可恢复性又不会破坏甘特图的时间排布。后面第 5 节我会专门讲这个切换逻辑。2. 环境准备让 vxe-gantt 先跑起来2.1 安装依赖并注册组件vxe-gantt 是 vxe-table 生态里的组件一般不能单独使用需要配合 vxe-pc-ui、vxe-table 和 xe-utils。这里有一个容易踩的坑很多同学只装了vxe-gantt结果运行时报找不到VxeUI或相关样式原因就是缺少基础库。npm install xe-utils vxe-pc-ui vxe-table vxe-gantt以 Vue 3 项目为例在main.js里做全局注册import { createApp } from vue import App from ./App.vue import { VxeUI } from vxe-pc-ui import vxe-pc-ui/lib/style.css import VxeGantt from vxe-gantt import vxe-gantt/lib/style.css const app createApp(App) app.use(VxeUI) app.use(VxeGantt) app.mount(#app)需要注意版本匹配。如果你用的是 Vue 2需要装对应的 vxe-table 2.x 和 vxe-gantt 旧版本Vue 3 项目则统一走 v4 系列。动手前最好去 npm 页面或官方文档确认一下版本对应关系避免装完一堆依赖后因为版本不兼容浪费一个下午。实际项目里如果不想全局注册也可以按组件方式局部引入做法是import { VxeGantt } from vxe-gantt然后放到组件的components选项里这样打包时按需加载体积会更可控。2.2 最小甘特图模板安装完成后先跑一个最小示例能渲染出来再谈标记删除。基础的 vxe-gantt 模板长这样template div vxe-toolbar template #buttons vxe-button标记删除/vxe-button vxe-button恢复/vxe-button /template /vxe-toolbar vxe-gantt refganttRef :datataskList :row-configrowConfig :bar-configbarConfig :timeline-configtimelineConfig vxe-gantt-column fieldname title任务名称 tree-node width240/vxe-gantt-column vxe-gantt-column fieldowner title负责人 width120/vxe-gantt-column vxe-gantt-column fieldstartDate title开始日期 width140/vxe-gantt-column vxe-gantt-column fieldendDate title结束日期 width140/vxe-gantt-column /vxe-gantt /div /template对应的基础数据大概是这样const taskList [ { id: 1, name: 产品需求评审, owner: 张三, startDate: 2025-01-01, endDate: 2025-01-05, deleteMark: false, children: [ { id: 11, name: 用户调研, owner: 李四, startDate: 2025-01-02, endDate: 2025-01-04, deleteMark: false } ] } ]row-config里通常要指定keyField也就是每行数据的唯一标识便于组件做排序、展开、选中回显。没有这个字段树结构和后续的批量操作都容易出问题。2.3 数据结构里预留删除标记字段我给任务数据统一加一个布尔字段deleteMark值为false表示正常true表示已删除。也可以用一个枚举状态字段比如taskStatus: normal | deleted这样以后想扩展“暂停”“归档”等状态时不用再拆字段。标记删除本质上就是状态机里的一次状态流转。这里要特别注意接口返回的数据通常不会自带这个字段前端要在拿到接口数据后做一层归一化处理确保每一行都有deleteMark包括树形结构里的子节点。否则后面判断时有的一行有、一行没有样式和取值都会出现空指针之类的隐性问题。3. 数据层核心逻辑标记、恢复、批量操作3.1 单条数据标记与恢复先实现最基本的单条切换。核心思路就是修改源数据里这一行的deleteMark然后让甘特图重新加载数据刷新渲染。因为 vxe-gantt 是内部维护渲染列表的组件直接改数据不一定会触发视图刷新所以需要调用loadData或重新赋值。import { reactive, ref } from vue const ganttRef ref() const taskList ref([]) const toggleDelete (row) { row.deleteMark !row.deleteMark // 强制刷新甘特图 ganttRef.value.loadData(taskList.value) }如果你不想让整棵甘特图全部重新加载可以考虑只更新单行不过 vxe-gantt 的增量更新接口在不同版本里不太统一。最稳妥的方式就是loadData全量刷新甘特图的数据量一般在几百条以内性能压力并不大。如果超过几千条再考虑局部更新或虚拟滚动。3.2 批量标记选中的行需求里通常还会有“勾选多行一键标记删除”。要支持多选必须在row-config或表格配置里开启多选。vxe-gantt 沿用了 vxe-table 的多选机制一般这样配置const rowConfig { keyField: id, useKey: true, checkboxConfig: { reserve: true } }然后通过组件实例拿到勾选的行const batchDelete () { const selectRecords ganttRef.value.getCheckboxRecords() if (!selectRecords.length) { alert(请先勾选需要标记删除的任务) return } selectRecords.forEach(row { row.deleteMark true }) ganttRef.value.loadData(taskList.value) }恢复操作就是把deleteMark改回false逻辑完全对称。批量恢复时还要注意一个问题如果父任务被勾选恢复但它的子任务仍然是已删除状态那么恢复后这个父任务在“仅显示未删除”视图里会出现但子任务不出现层级看起来很奇怪。所以批量恢复要考虑是只恢复勾选项还是恢复勾选项及其所有子孙节点。3.3 获取已标记行数据的标准姿势获取标记删除的行数据是很多初用 vxe-gantt 的同学最容易卡住的地方。想当然地调用getTableData()拿到的可能只是组件当前渲染的表格数据遇到了筛选、树形展开状态数据不一定完整。更稳妥的做法是维护一份“数据池”所有删除、恢复、编辑操作都直接改数据池提交时也从这个数据池里取。如果你还是想通过组件实例取可以用类似下面的方式const getAllRows (list) { const result [] const walk (rows) { rows.forEach(row { result.push(row) if (row.children row.children.length) { walk(row.children) } }) } walk(list) return result } const getDeletedRows () { return getAllRows(taskList.value).filter(row row.deleteMark) }这里我刻意用了递归遍历因为甘特图基本是树形结构子任务嵌套在children里直接filter只能拿到顶层节点。把这个结果提交给后端后端只需要根据这些行的id更新状态即可。我个人强烈建议使用数据池方案因为你在组件外能完整控制数据的状态不用猜组件内部到底缓存了什么。4. 行效果实现删除状态一眼可辨4.1 左侧表格行的置灰与删除线标记删除之后的视觉反馈最基础的操作就是给行加类名。vxe-gantt 的左侧区域继承了 vxe-table 的row-config通过rowClassName可以动态返回当前行需要追加的类名const rowConfig { keyField: id, useKey: true, checkboxConfig: { reserve: true }, rowClassName: ({ row }) { return row.deleteMark ? gantt-row-deleted : } }配套样式.gantt-row-deleted .vxe-cell { color: #aaa; text-decoration: line-through; } .gantt-row-deleted .vxe-cell--checkbox { opacity: 0.6; }这里要注意vxe-table 的单元格内容是在.vxe-cell里的如果只给td加background或color可能在部分主题下不生效因为单元格内部有自己的层级结构和默认样式。所以优先处理.vxe-cell这一层。想要行背景也置灰可以直接给整个tr加背景色但要注意别把复选框也搞得看不清。4.2 甘特条区域的状态样式联动左侧行置灰只是第一步右侧甘特条如果还是高亮蓝色用户会觉得没删干净。vxe-gantt 的甘特条配置集中在bar-config里具体属性名不同版本略有差异但通常支持动态类名或动态样式。比如const barConfig { barClassName: ({ row }) { return row.deleteMark ? gantt-bar-deleted : } }.gantt-bar-deleted { opacity: 0.45; filter: grayscale(1); text-decoration: line-through; } .gantt-bar-deleted .vxe-gantt-bar-label { color: #999; }如果当前版本的bar-config没有提供barClassName也不用慌退而求其次的方案是在渲染甘特条的内容时做一个自定义插槽根据row.deleteMark动态控制内层元素的 class。还有一个小技巧是给甘特图外层容器设置一个>vxe-gantt-column title操作 width160 aligncenter template #default{ row } vxe-button modetext statusdanger v-if!row.deleteMark clicktoggleDelete(row) 标记删除 /vxe-button vxe-button modetext statussuccess v-else clicktoggleDelete(row) 恢复 /vxe-button /template /vxe-gantt-column有些团队还会在标记删除前加一个二次确认弹窗比如vxe-modal或el-popconfirm。甘特图操作属于重操作我建议加上确认提示尤其是在父任务含多个子任务时提示文案要写清楚“该任务及其子任务会被标记删除”之类的内容。5. 场景落地筛选、回收站与后端保存5.1 显示全部还是只显示未删除标记删除做完了接下来要解决视图筛选。工具栏里如果只有“标记删除”和“恢复”没有“显示全部/只显示未删除”切换用户就永远看不到自己删过哪些内容时间一长容易乱。简单做法是在数据源上做过滤不直接改甘特图的原始数据const filterStatus ref(all) // all | active | deleted const filterData (list) { if (filterStatus.value all) return list const result [] list.forEach(row { const visibleByStatus filterStatus.value deleted ? row.deleteMark : !row.deleteMark if (visibleByStatus) { result.push({ ...row, children: row.children ? filterData(row.children) : [] }) } }) return result }但这里有个坑如果你直接把taskList过滤后再丢给vxe-gantt的:data甘特图会失去和原始数组的引用关系后续恢复操作可能找不到原始行对象。我的做法是保留taskList作为唯一数据源把过滤后的结果传给甘特图但操作时仍然针对taskList里的原始行。这一点尤其要注意否则会出现“界面显示恢复了数据源里还是删除状态”的神奇 Bug。5.2 回收站的恢复与清理“标记删除”的另一层业务含义是回收站。当用户切到“已删除”视图时看到的其实就是一个回收站。此时可以增加两个按钮一个是“恢复选中”一个是“彻底删除”。彻底删除才是真正的物理删除一般要调后端接口并且在本地数据中移除该行。实现物理删除时树结构处理要格外小心。比如要删掉一个父任务那么它的所有子任务都必须从原始数组里移除否则会出现父节点没了、子节点还挂着的脏数据。我建议写一个通用递归移除函数const removeRowsById (list, ids) { return list .filter(row !ids.includes(row.id)) .map(row { if (row.children) { row.children removeRowsById(row.children, ids) } return row }) }如果想让“彻底删除”也能撤回那就需要额外叠加一个操作记录栈插入一条反向操作记录。这个属于扩展功能不展开讲但在回收站场景里很值得投入。5.3 提交后端时的数据格式最后一步是把已标记的行数据同步到后端。这里不需要把整行字段全传过去通常传两个信息就够被删除的行的id列表和操作类型删除/恢复。保存时既要传标记删除的数据也要传恢复的数据否则后端只知道谁被删了不知道谁被恢复了。const submitDeleteStatus async () { const allRows getAllRows(taskList.value) const deletedRows allRows.filter(row row.deleteMark) const restoredRows allRows.filter(row !row.deleteMark) // 这里也可以维护一份“变更记录”只提交发生过变化的行 await request.post(/api/task/update-status, { deletedIds: deletedRows.map(row row.id), activeIds: restoredRows.map(row row.id) }) }如果接口要求整行更新也建议只从原始行里挑出后端需要的字段不要直接把组件返回的包含children、_X_ROW_KEY之类内部字段的行对象整个扔给后端。这个看起来无害实际上很容易造成接口字段冗余甚至因为循环引用导致序列化报错。6. 常见问题与排查技巧实录6.1 rowClassName 不生效或样式被覆盖最常见的现象是配置了rowClassName控制台里也能看到tr上有类名但样式就是没变化。大概率是样式优先级问题。vxe-table 默认主题会定义单元格的背景、字体颜色如果你写在全局样式表的.gantt-row-deleted .vxe-cell优先级不够会被组件内部样式盖掉。解决办法是提高选择器优先级或者用!important。我自己一般这样写.vxe-gantt .gantt-row-deleted .vxe-cell { color: #bbb !important; text-decoration: line-through !important; }另外要确认样式文件到底加在哪个作用域里。如果组件里用了scoped而 vxe-gantt 的 HTML 是动态渲染的可能需要:deep()才能穿透:deep(.gantt-row-deleted .vxe-cell) { color: #bbb; }6.2 甘特条颜色无法联动我遇到过左侧表格已经变灰右侧甘特条还保持原来的蓝色。这个基本是因为barClassName在配置时写成了静态字符串或者没有读取到row参数。看一下控制台里甘特条的类名有没有出现gantt-bar-deleted如果没有优先检查bar-config是否传到了组件上。如果当前版本不支持动态类名还有一个偏方用状态字段给甘特条设置内联样式通过vxe-gantt提供的插槽自定义 bar 内容自己渲染一层带样式的 div。这样虽然代码多一点但可控性最强也不受组件版本限制。6.3 getCheckboxRecords 取不到勾选数据这里九成是忘了在配置里开启多选。光有复选框显示还不够必须确认checkboxConfig里reserve: true是否设置。没有reserve当数据重新加载后勾选状态可能会丢失getCheckboxRecords拿到的是空数组。另外在树形结构中如果勾选了父节点但checkMethod挡住了子节点勾选getCheckboxRecords也不会返回未勾选的子节点。排查技巧在按钮点击事件里先console.log(ganttRef.value.getCheckboxRecords())看看返回结构是什么。确认有数据后再写后续逻辑。6.4 删除后甘特条仍停留在原位置但视图未刷新标记删除后如果只修改了deleteMark没有重新加载数据甘特图的时间条位置不会变因为开始结束日期没变但样式可能不更新。这个问题很隐蔽因为数据变了、视图却“看起来”没变。解决方式就一句话改完deleteMark后一定要调用loadData或重新赋值:data。如果觉得全量刷新太重还有一个技巧是给甘特图组件加一个动态:key通过 key 变化强制重渲染。不过这种做法会丢掉当前的展开、滚动、勾选状态使用前要评估一下代价。6.5 大数据量下批量操作卡顿当甘特图里有上千行数据时批量标记删除如果每一行都触发一次loadData页面会非常卡。我建议把批量操作拆成两步第一步在原始数据池里循环修改deleteMark不调用任何组件方法第二步统一调用一次loadData让组件一次性刷新。这样把多次渲染合并成一次体感会好很多。如果数据量再大就考虑让后端做批量更新前端只传 ids通过接口返回更新结果后再刷新视图避免前端做大量递归计算。最后再分享一个个人的实现心得我在实际项目中把删除状态设计成了枚举字段而不是单纯用布尔值status: normal | deleted这样以后如果再增加“归档”“暂停”等状态只需要扩展枚举值和对应样式不用再回头改所有判断逻辑。核心要点就是始终维护一份“数据池”所有删除、恢复、筛选都围绕这份数据操作组件只负责展示提交后端时从数据池里取状态变化行而不是依赖组件内部方法。这套思路配合 vxe-gantt 的标记删除场景基本能应对大部分排期业务。建议你在自己的项目里先用第 3 节的数据层逻辑跑通再加第 4 节的视觉样式最后补第 5 节的回收站和提交逻辑分层推进会少踩很多坑。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进