
做中后台项目做到甘特图十个里有八个绕不过“行内显示子任务、拆分任务展示、拖拽调整任务日期”这三大坎。我这边在跑一个排产类系统时就被这三个需求轮番折磨过最后在 vue3 技术栈上基于 vxe-table 的行列渲染能力封装了一套轻量组件团队内部就叫它 vxe-gantt。这篇文章就从这套组件的实际使用出发讲讲怎么把行内子任务、拆分任务条和拖拽改期一次性做干净还会把我在排期换算、滚动同步、性能处理上踩过的坑一并交代清楚。如果你正打算在 Vue3 项目里上甘特图又不想被笨重的商业控件绑死那这篇应该能帮你省掉不少排查时间。1. 需求拆解为什么这三件事必须同时做1.1 典型业务场景长什么样排产系统里最常见的结构就是“订单 - 工单 - 工序”。订单下面挂多个工单工单下面又拆成焊接、装配、检测等工序每个工序都有自己的开始时间和结束时间。传统做法是给订单单独开辟一个详情页点进去才能看子任务但一线排产员根本受不了这种跳转他们希望在一个表格里把整棵任务树展开子任务行直接显示在父任务下方右侧甘特区对应行同步出现一条条色块。这就是“行内显示子任务”最真实的需求来源。拆分任务也好理解。生产计划经常遇到设备检修、物料暂停、跨周末停线一条连续任务会被硬生生切成几段。比如“装配工序”从 6 月 10 号到 6 月 15 号中间 12 号检修停一天那么这条任务在甘特图上就不能是一整根条而应该是 10 号到 11 号一段、13 号到 15 号一段中间留出空档。这种分段不影响任务本身的开始结束约束只影响可视化呈现和排期调整时的边界判断。1.2 需求关键词逐个拆需求点业务含义技术在实现时要解决的难点行内子任务树形结构在当前表格中展开子任务与父任务共用同一行区域树形数据渲染、行高自适应、展开收起联动右侧甘特条拆分任务展示一个任务拆成多个不连续的时间段每个时间段显示独立条形数据模型需要存分段数组渲染时需要循环生成多个条拖拽调整任务日期直接拖甘特条改变开始结束时间拖完回写数据像素与天数的换算、边界约束、父子任务联动修改这三个需求不是割裂的。树形结构决定了拖拽一个子任务时要不要联动父任务拆分结构决定了拖拽一段时要不要挤压后面几段。所以数据模型、渲染逻辑、交互逻辑必须放在一起设计任何一环单独做都会在联调阶段返工。2. vxe-gantt 选型逻辑与渲染原理2.1 为什么不自研也不直接搬重型商用控件自研甘特图的最大问题是时间轴和表格联动太麻烦。左侧是任务信息表格右侧是时间轴画布两者必须保持行高一致、滚动同步、展开收起联动。如果从零开始实现光是行高测量和虚拟滚动就要写掉两周。直接上重型商业控件又有许可成本和学习成本而且很多业务定制需求不好放开。所以折中方案是依赖一个成熟表格库的渲染能力只把“时间条绘制”和“拖拽换算”自己做。vxe-table 本身在 Vue3 项目里很能打树形展开、单元格插槽、虚拟滚动、以及行列固定都做得比较完整。vxe-gantt 这套实现就是围绕 vxe-table 的行列结构做扩展左侧继续走表格的列配置和树形展开右侧每行在单元格内渲染绝对定位的时间条。这样表格有的能力甘特图直接继承甘特图需要的时间条渲染和拖拽事件则在单元格内部自己控制。2.2 整体渲染模型看这张简化结构就能明白它的工作原理页面分成两大部分左侧是固定列区域显示任务名称、进度百分比右侧是时间轴区域。右侧的每一个单元格其实是对应某一个时间刻度但大多数实现不会真的把一天一列渲染而是把整个时间轴做成一个横跨所有列的单元格在里面用 absolute 定位每一个任务条。这样做的好处很明显时间条的位置不受列宽限制可以精确到任意像素。比如把一天的宽度设为 24px那么任务条从 6 月 10 号到 6 月 12 号就是 left0、width72px 的一个块。子任务树展开时表格行自然撑开右侧对应行的单元格同样变高时间条就在这个变高的空间里垂直居中。2.3 插槽是整个灵活性的关键vxe-gantt 在行级暴露了一个任务条渲染插槽我传入的每一行数据经过内部处理后会交给插槽自行渲染。这意味着拆分任务展示不用改组件源码只需要在插槽里遍历 row.segments 数组把每一段算出一个绝对定位的 div 即可。父级行和子级行的区别只在于数据里有没有 children 字段。树形展开时子任务行也是普通的 row照样走同一个插槽所以“行内显示子任务”在这个模型里是天然成立的。父任务行如果需要在视觉上显示汇总条也可以在插槽里额外判断我后面会详细说。3. 数据模型设计先想清楚再写代码3.1 树形任务结构任务数据在 vxe-gantt 里沿用了 vxe-table 的树形结构约定核心字段为 id、children其他业务字段自己放。我习惯定义为const taskTree [ { id: order-001, name: 订单A, start: 2025-06-10, end: 2025-06-20, expanded: true, children: [ { id: work-001, name: 工单A-装配工序, start: 2025-06-10, end: 2025-06-15, children: [ { id: task-001, name: 装配-前段, start: 2025-06-10, end: 2025-06-11 }, { id: task-002, name: 装配-后段, start: 2025-06-13, end: 2025-06-15 } ] } ] } ];这里要特别说一点父级任务的 start 和 end 在数据层面并不需要手工维护它应该是子任务范围的汇总。我建议在拉取接口返回后做一次归并计算父级 start 等于所有子任务最早开始end 等于子任务最晚结束。拖拽子任务改期后再触发一次父级汇总刷新这样父级任务条始终反映真实范围。3.2 拆分任务的字段设计一个任务被拆成多段时我在任务节点上增加一个 segments 数组{ id: task-003, name: 设备组装, start: 2025-06-10, end: 2025-06-16, segments: [ { id: seg-1, start: 2025-06-10, end: 2025-06-11 }, { id: seg-2, start: 2025-06-13, end: 2025-06-16 } ] }top 层级的 start 和 end 此时表示任务的整体最早和最晚边界。渲染时只遍历 segments 画条拖拽时也要针对 segments 内部元素做处理。如果没有 segments就把整个 start 到 end 当成一段处理这样代码逻辑可以统一。3.3 日期单位与换算约定日期处理我直接用了 dayjs并且在项目里做了两个全局约定一是所有日期统一为 YYYY-MM-DD 字符串不带时分秒二是 task 的 duration 用 end 减 start 再加 1 计算因为甘特图里的任务条必须包含开始那天和结束那天否则视觉上会少一天。import dayjs from dayjs; function getDurationDays(start, end) { return dayjs(end).diff(dayjs(start), day) 1; }这个小小的公式是后面所有像素换算、拖拽边界判断、条宽计算的根基。很多新手写甘特图日期偏移问题就出在对“按天加 1”的处理上。4. 行内子任务与拆分任务展示的实现4.1 表格列配置实操vxe-gantt 左侧任务表直接复用 vxe-table 列配置。第一列是任务名称开启 treeNode 属性让组件渲染出展开箭头template vxe-gantt :datataskTree :row-keyid :tree-config{ children: children } :start-dateprojectStartDate :end-dateprojectEndDate :day-widthdayWidth :columnscolumns template #ganttBar{ row } div classgantt-row template v-ifrow.segments row.segments.length div v-forseg in row.segments :keyseg.id classgantt-bar :classgetBarClass(row, seg) :stylegetBarStyle(seg) /div /template div v-else classgantt-bar :classgetBarClass(row) :stylegetBarStyle(row) /div /div /template /vxe-gantt /templatecolumns 数组大概长这样const columns [ { field: id, title: ID, width: 80 }, { field: name, title: 任务名称, treeNode: true, minWidth: 220 }, { field: progress, title: 进度, width: 90 } ];treeNode 放在任务名称列上树形展开箭头和缩进就自动出来了。子任务行在左侧表格里自然显示右侧甘特区同一行的高度也跟着对齐。4.2 时间条位置计算这是拆分任务展示最核心的一段逻辑。计算之前需要知道项目总跨度也就是整个甘特图最左边的日期和最右边的日期const projectStartDate dayjs(2025-06-01); const projectEndDate dayjs(2025-06-30); const dayWidthPx 24; // 一天 24 像素 function getBarStyle(row) { const startOffset dayjs(row.start).diff(projectStartDate, day); const durationDays getDurationDays(row.start, row.end); return { left: startOffset * dayWidthPx px, width: durationDays * dayWidthPx - 2 px // 减 2px 用来做出条与条之间的间隔 }; }这里千万不要用百分比。跨度为 30 天的时候一个 2 天的任务条宽度大概是 6.7%勉强能看跨度为 365 天时同一条任务宽度只剩 0.55%直接变成一条细线。固定像素宽度才能保证不管项目跨度多大条的可读性都不变。针对拆分段遍历 segments 时对每一段分别调用 getBarStyle就能在同一行里画出多个时间段条。两个分段之间的空隙不需要额外处理因为第二段的 left 是从项目起点算起的天然落在正确位置。4.3 多段任务的行内排布细节当同一行需要显示多段时行高必须留够。我把 vxe-gantt 的行高设为 40px时间条高度用 16px垂直居中。凑巧一段任务只有一条的时候视觉上比较松散但拆成三四段时不会觉得拥挤。如果一段数据里还有资源维度比如同一工序两个人分段做每段时间不连续但互有重叠可以用行内小布局实现上中下三条.gantt-row { position: relative; width: 100%; height: 100%; } .gantt-bar { position: absolute; top: 50%; height: 16px; transform: translateY(-50%); background: #2f7afe; border-radius: 3px; cursor: grab; }多资源时改成纵向堆叠每个条指定 top 为不同百分比。这个小改动成本很低但能让同一行的信息密度明显提升。5. 拖拽调整任务日期的完整实现5.1 拖拽交互框架拖拽我建议用 Pointer Events 而不是原生 drag 事件。原生拖拽在 Safari 上容易触发图片预览、闪烁之类的问题而 Pointer Events 对鼠标和触摸统一处理代码也更容易控制。组件内部暴露了三个事件drag-start、dragging、drag-end。业务层主要就监听这三个事件完成数据回写。核心手指标只有一个鼠标横向位移了多少像素。5.2 像素与日期换算拖拽的第一步是把像素位移换算成天数偏移const pixelsToDays (pixels) { return Math.round(pixels / dayWidthPx); };有的场景想要更细的粒度比如拖动半天、四分之一天那就不取整把 projectStartDate 的最小单位从“天”降到“小时”同时 dayWidthPx 也要等比放大到一个小时内能拖动的最小视觉距离。我这边业务上最小单位是天所以直接 Math.round 就好。第二步是计算新日期并做边界约束。约束包括三部分不能早于项目开始日不能晚于项目结束日任务本身至少要有一天长度import dayjs from dayjs; function handleDragEnd({ row, offsetPixels, dragMode }) { const deltaDays pixelsToDays(offsetPixels); let newStart dayjs(row.start).add(deltaDays, day); // 不能超出项目范围 if (newStart.isBefore(projectStartDate)) { newStart dayjs(projectStartDate); } if (newStart.isAfter(projectEndDate)) { newStart dayjs(projectEndDate); } const newEnd newStart.add(getDurationDays(row.start, row.end) - 1, day); if (newEnd.isAfter(projectEndDate)) { // 如果结束日期超界整体往回退保证任务长度不变 newEnd dayjs(projectEndDate); newStart newEnd.subtract(getDurationDays(row.start, row.end) - 1, day); } updateTaskDates(row, newStart.format(YYYY-MM-DD), newEnd.format(YYYY-MM-DD)); refreshParentDates(row.id); refreshGantt(); }5.3 拖拽头和尾调整结束日期除了整条平移业务上经常还要“只拖头部改变开始日期或只拖尾部改变结束日期”。我做的实现是在时间条左右各留一个 6px 的拖拽手柄鼠标落在手柄区间时就进入 resize 模式function handleResizeEnd({ row, edge, offsetPixels }) { const deltaDays pixelsToDays(offsetPixels); if (edge left) { const newStart dayjs(row.start).add(deltaDays, day); // 新的开始日期不能晚于原结束日期 const maxStart dayjs(row.end).add(-1, day); row.start clampDate(newStart, projectStartDate, maxStart); } else { const newEnd dayjs(row.end).add(deltaDays, day); // 新的结束日期不能早于原开始日期 const minEnd dayjs(row.start).add(1, day); row.end clampDate(newEnd, minEnd, projectEndDate); } }resize 模式下 tasks 的 start 或 end 可能会改变跨度拖拽结束后需要全局重新计算一次 left 和 width。5.4 父子任务联动拖拽一条子任务时父任务的范围汇总需要跟着变。我的做法是拖拽结束后循环向上找父节点function refreshParentDates(taskId) { const findNode (list, id) { for (const item of list) { if (item.id id) return item; if (item.children) { const child findNode(item.children, id); if (child) return child; } } }; const node findNode(taskTree, taskId); if (node node.children node.children.length) { const allStart node.children.map(c dayjs(c.start)); const allEnd node.children.map(c dayjs(c.end)); node.start dayjs.min(allStart).format(YYYY-MM-DD); node.end dayjs.max(allEnd).format(YYYY-MM-DD); } }另外我加了一个规则拖拽子任务时不允许拖出父任务范围。父任务本身被拖动时整棵子树整体平移保持相对偏移不变。整树平移实现起来最简单因为所有子任务同时加上同一个 deltaDays 就行。5.5 拆分段拖拽的特殊处理拆分段拖拽和平移整条任务在边界判断上有一个重要区别段与段之间不能把前一段拖到后一段的位置重叠。我做的处理是在拖拽结束后做一次重叠修正如果段 A 的新结束日期大于段 B 的开始日期强制把段 A 结束日期压到段 B 开始日期的前一天。如果是拆分段之间本来就是连续的比如一段从 10 号到 12 号另一段从 13 号到 15 号拖前一段导致 12 号变成 14 号后一段的 start 也应该跟着顺延。这个顺延逻辑业务含义很强建议用一个 service 函数集中处理不要散落在视图代码里。6. 常见问题排查与避坑实录6.1 甘特条偏移半格症状是任务条左边总是对不准时间轴的刻度中线看起来像偏了半个格子。原因基本都出在 dayWidthPx 与刻度渲染不一致。vxe-gantt 的时间轴刻度是按日期字符串循环生成的如果 dayWidthPx 设成小数比如 23.5px像素换算时取整就会出现偏差。解决方法是把 dayWidthPx 固定为整数比如 24 或 32并且甘特区总宽度 总天数 * dayWidthPx不要额外加减边框宽度。6.2 左侧表格和右侧甘特滚动不同步树形表格和甘特区域是两个独立滚动容器展开子任务后左侧行变高而右侧不同步常见原因是两个容器没有监听同一个 scroll 事件。我的做法是把甘特区放在一个 vxe-table 的单元格里让表格本身作为统一滚动容器这样行高天然一致滚动也天然同步。如果组件结构上必须拆成左右两个容器就手动监听 scroll 并同步 scrollLeft。6.3 拖动时卡顿卡顿有两个常见诱因一是拖拽过程中直接去操作整个 taskTree 数据并触发全量渲染二是甘特条数量上千时没做虚拟滚动。我优化时把拖拽中的临时状态放在一个独立的 reactive 对象里只在 drag-end 时才写入正式数据同时打开 vxe-table 的虚拟滚动配置把可视区外的行跳过渲染。这两步改了之后两千行任务仍然保持流畅。6.4 常见问题速查表问题现象排查方向解决参考任务条显示为细线使用了百分比宽度改用 dayWidthPx 乘天数算像素宽度拖拽后数据变了但画面没变没有调用刷新甘特区域方法拖拽结束统一触发 refreshGantt父任务条范围不更新子任务改期后没有向上归并在回写函数末尾调用 refreshParentDates拆分任务段重叠缺少段间边界约束增加重叠检测强制修正结束日期时间轴刻度对不上dayWidthPx 取整问题保证 dayWidthPx 为整数且甘特区宽度等于天数乘日宽大量数据下滚动卡顿未开启虚拟滚动开启 scroll-x / scroll-y 的虚拟滚动配置7. 性能优化与后续扩展7.1 大数据量虚拟滚动配置vxe-table 的虚拟滚动是内置能力但需要主动打开。我在用到两千行以上任务树时给组件传递了 scroll-y 的配置:scroll-y{ gt: 100, oSize: 40 }这里的 gt 表示超过 100 行时启用虚拟滚动oSize 是预渲染的行缓冲。右侧甘特条的插槽此时会跟着虚拟滚动切换内容所以插槽里的计算逻辑一定要快不要在 getBarStyle 里做复杂的循环。7.2 可以继续加的能力这套方案跑稳之后扩展空间还很大。比如在甘特条上画依赖箭头前置任务结束后后置任务才能开始拖拽时自动吸附到周末、节假日边界或者把拆分段的不同颜色对应不同状态检修段灰色、生产段蓝色、延迟段红色。这些都不需要动组件核心只需要在 getBarClass 里根据业务字段返回不同 class再在插槽外层加一条 SVG 覆盖层画连线即可。我在实际项目中最后还做了一层“拖拽预览”拖拽过程中不直接改正式任务数据而是把预览样式存在一个高亮变量里松手时再统一提交。这样即使拖拽过程中算错了偏移用户也只是看到预览条跳了一下不会污染已经排好的计划数据。这个改动看起来小但真实使用下来误操作恢复的成功率直线上升。如果你也要在这个方案上继续加交互务必先守住“预览与提交分离”这一条。