简介:面向Vue2开发者的可拖拽甘特图组件源码,基于Element UI实现,专门解决排期、项目管理场景中时间块拖拽调整的交互需求,避免付费插件和英文文档带来的接入成本。压缩包共21个文件,包括7个JS逻辑文件、6个Vue组件、2个JSON配置、SCSS样式及静态资源,整体仅107KB;组件与视图分离,含Gantt.vue主视图、模拟数据、日期格式化工具及构建配置,便于直接运行调试与二次改造。目前已有563人学习下载,适合需要快速集成或深入理解实现原理的中级前端工程师。代码覆盖拖拽、自动排序、时间选择、搜索定位、新建排期、右键菜单、操作撤回、批量保存等8项功能,并附有示例数据与日期工具。可直接在Vue2工程中复用,也可作为阅读源码、掌握甘特图拖拽排序逻辑的参考。
1. 为什么在 vue2 里自己拼甘特图而不是等库
Element UI 官方组件里没有 gantt,这一点很多 vue2 项目立项时才意识到。商业甘特库在功能上确实完整,但接入老项目常遇到打包体积、主题定制和授权问题;自己写一个可拖拽版,核心并不在 DOM 拖来拖去,而在于把“日期”变成“横坐标”,把“鼠标移动像素”变回“日期偏移”。下面以 vue2 + element-ui 为基底,从坐标换算讲到 mousedown/mousemove/mouseup 的拖拽闭环,最后补依赖线和缩放校验。适合那些不能升级 vue3、也不希望为单个页面引入重型依赖的存量项目。这套方案的关键产出是一个数据驱动的甘特图:任务条位置只由start/end两个字段决定,拖拽只是修改这两个字段的交互外壳。
2. 甘特图的数据模型与坐标换算:先把任务条的位置算对
在写任何拖拽代码之前,先要把甘特图当成“时间轴画布”。element-ui 在这里只承担表格骨架、滚动条和行高,真正填充任务的是一层绝对定位的 div。这一层的定位规则统一为:
left = (task.start - timeline.start) 对应的毫秒数 / 一天的毫秒数 * pxPerDay width = (task.end - task.start) 对应的毫秒数 / 一天的毫秒数 * pxPerDaypxPerDay是全局唯一的比例因子,表示一个自然日占据多少个像素。一天对应 24px 还是 48px,直接影响拖拽时鼠标移动 1px 代表 0.5 小时还是 1 小时。这个因子不要散落在组件里,应该放在 Vue 的 data 或 Vuex 中,后续做“日/周/月”缩放也只改这一个变量。
2.1 任务字段只需要 start 和 end 两个日期
一个可拖拽任务条需要的最小数据模型如下:
| 字段 | 类型 | 作用 |
|---|---|---|
| id | string/number | 拖拽回写时定位任务 |
| name | string | 左侧表格显示的任务名 |
| start | Date | 任务开始日期,按天做最小粒度 |
| end | Date | 任务结束日期,含当日 |
| progress | number | 0-100,进度条填充比例,拖拽时保持不变 |
| dependencies | array | 前置任务 id 列表,画依赖线用 |
start和end建议统一用 Date 对象存取,而不是"2024-01-01"这种字符串。字符串在减法运算时会触发隐式转换,容易把 UTC 和本地时区混在一起。end采用“含当天”语义,即 1 月 1 日到 1 月 3 日持续 3 天,而不是 2 天;否则计算宽度总是少一天。
2.2 用天序号做换算,避免时区干扰
把日期转成距离 timeline.start 的天序号,是整套换算最稳妥的写法。不要直接用date.getTime()相减后除以 86400000,因为如果某个日期恰好跨了夏令时,或者后端返回的是 UTC 字符串,除出来的可能是 1.96 天,宽度就会偏。
我一般会把日期先归零到当日 0 点,再做差:
const DAY_MS = 24 * 60 * 60 * 1000; function startOfDay(date) { const d = new Date(date); d.setHours(0, 0, 0, 0); return d; } function daySpan(start, end) { const s = startOfDay(start).getTime(); const e = startOfDay(end).getTime(); return Math.round((e - s) / DAY_MS); } function taskStyle(task, timelineStart, pxPerDay) { const left = daySpan(timelineStart, task.start) * pxPerDay; const width = daySpan(task.start, task.end) * pxPerDay + pxPerDay; return { left: left + 'px', width: width + 'px' }; }task.start到task.end的宽度额外加了一个pxPerDay,是因为end含当天。比如 1 月 1 日到 1 月 1 日代表一天,daySpan返回 0,所以必须补一个pxPerDay才能画出一个日宽矩形。同理,如果后续要做半天粒度的任务,pxPerDay就要支持小数,这里Math.round只用于天序号,不用于像素值。
2.3 时间轴头部和表格行必须共用同一套宽度
element-ui 的固定列(如任务名)和甘特图画布列宽度需要保持比例。常见做法是给画布列一个计算后的总宽度:
const timelineWidth = computed(() => { return timelineDays.value * pxPerDayValue + 'px'; });表头里的年、月、日刻度也要用同样的timelineStart + index * pxPerDay生成,而不是用 flex 均分。flex: 1只会把标签均匀塞进容器,遇到月末周数不整时,表头标签和任务条位置就会错位。这个坑在 el-table 自定义表头里非常常见:看起来是数据错位,其实是头部和内容区宽度来源不一致。
提示:如果任务条是从
task.start渲染,而表头是从“当月第几天”渲染,两边宽度基准不同。把timelineStart定为项目开始那周的周一,所有刻度、任务条、依赖线都参照它计算,错位问题会少很多。
另外,pxPerDay建议按实际业务场景预设几档:
| 视图 | pxPerDay | 适用场景 |
|---|---|---|
| 日视图-紧凑 | 24 | 任务超过 60 条,先看整体排布 |
| 日视图-标准 | 48 | 默认视图,适合拖拽调整 |
| 周视图 | 144 | 查看跨周依赖关系 |
缩放时任务条left会实时变化,但项目开始时间timelineStart不变,只有视觉宽度和横坐标位置改变,这样拖拽逻辑不需要为不同缩放级别写两套。
3. 用 vue2 和 element-ui 渲染出第一版甘特图
坐标换算设计好后,渲染层只是把taskStyle返回值绑定到绝对定位的 div 上。这里的关键是:甘特图整体仍然是一张 el-table,左侧列放任务名,右侧一整列作为时间画布。得益于 el-table 自带的横向滚动,行高、对齐、固定列都不需要自己实现。
3.1 最小模板:一个固定列加一个画布列
以下模板可以直接跑通最小可交互版本:
<template> <el-table :data="tasks" class="gantt-table" :scrollbar-always-on="true" > <el-table-column type="index" width="60" fixed /> <el-table-column prop="name" label="任务" width="220" fixed /> <el-table-column :width="timelineWidth" label="时间"> <template #header> <div class="gantt-header"> <div class="gantt-month" v-for="m in monthLabels" :key="m.key"> {{ m.label }} </div> </div> </template> <template #default="{ row }"> <div class="gantt-row"> <div class="gantt-bar" :style="taskStyle(row)" @mousedown.stop="startDrag(row, $event)" > <span class="gantt-progress" :style="{ width: row.progress + '%' }"></span> </div> </div> </template> </el-table-column> </el-table> </template>el-table-column的:width绑定的是timelineWidth,这个值必须是一个具体的数字,不能是带%的字符串。如果需要总宽 5000px,timelineWidth就应该计算成 5000。fixed列用了两个,左侧索引和任务名在横向滚动时会一直可见。
提示:不要给
.gantt-row设置overflow: hidden,否则任务条拖出右侧时会被裁掉;正确做法是通过最大日期来限制拖拽边界。
如果你的数据有父子层级,建议保持 tasks 一维数组,用parentId在表格外部组织行分组,而不是直接用 el-table 的树形展开。树形展开的行高度不稳定,画布层容易和左侧行错位。
3.2 表头刻度按月分组
表头只显示星期和日期会看不清月份。推荐两层结构:上层是月份,下层是每周一或每天。用v-for生成,代码逻辑集中在数据计算里:
const monthLabels = computed(() => { const labels = []; let cursor = timelineStart.value; const end = new Date(timelineEnd.value.getTime() + DAY_MS); while (cursor < end) { const next = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); const width = daySpan(cursor, next) * pxPerDayValue; labels.push({ key: cursor.getTime(), label: `${cursor.getFullYear()}-${cursor.getMonth() + 1}`, width: width + 'px' }); cursor = next; } return labels; });cursor依次跳到每个月的 1 日,daySpan(cursor, next)算出该月在当前pxPerDay下占多少像素,这样月份块宽度和下面每天的刻度完全对齐。表头样式里,月份和日期要使用同一个padding: 0,否则 element-ui 自带单元格 padding 会影响宽度。
3.3 行内任务条和表格行高的关系
el-table 默认行高由内容撑开。为了让任务条有可点击区域,同时不把行高顶得很奇怪,.gantt-row推荐这样写:
.gantt-row { position: relative; height: 48px; margin: 0; } .gantt-bar { position: absolute; top: 10px; height: 28px; border-radius: 4px; background: #409eff; cursor: grab; user-select: none; z-index: 2; }top: 10px就是为了让任务条上下留白,避免和相邻行紧贴。若想显示更紧凑,可以把行高改成 40px,top: 6px; height: 28px。进度条可以用gantt-progress作为条内的一层浅色背景,宽度绑定progress百分比即可。
如果要标记周末,常见做法是在画布列底部加一个绝对定位的周末层,宽度按pxPerDay累积,背景色用#f5f7fa。周末层同样从timelineStart开始计算,不能依赖 el-table 的行背景,因为表格背景不会跟着横向滚动联动。
到这里,你已经能渲染出静态甘特图了。下一步进入可拖拽部分,也是最容易踩坑的部分。
4. 可拖拽:把“拖动多少像素”换算成“移动多少天”
拖拽任务条并不是操纵 DOM 的left值,而是维护一个dragState对象,在 mousemove 里不断算出新的start/end,再通过响应式数据重渲染。这样做的最大好处是撤销、联动、保存都只需要记录数据变更。
4.1 用 dragState 记录拖拽上下文
mousedown 时至少需要记录以下字段:
| 字段 | 含义 |
|---|---|
| taskId | 正在拖哪条任务 |
| startClientX | 鼠标按下时的 clientX |
| originalStart | 任务原始开始日期 |
| originalDays | 任务原始持续天数 |
| moved | 是否发生过拖动,用于区分 click |
一次拖拽过程中,originalStart不随每次 mousemove 变化,始终以按下瞬间的值为基准。这样鼠标左移右移无论多少次,都不会因为四舍五入误差累积。
data() { return { dragState: null }; }, methods: { startDrag(task, event) { this.dragState = { taskId: task.id, startClientX: event.clientX, originalStart: new Date(task.start), originalDays: daySpan(task.start, task.end), moved: false }; document.addEventListener('mousemove', this.onDragMove); document.addEventListener('mouseup', this.onDragEnd); } }event.clientX是相对浏览器视口的坐标,和页面滚动、元素内滚动都有关。如果甘特图容器在页面中间,clientX里就包含了容器左侧的所有偏移,不能直接当作画布坐标。
4.2 减去滚动偏移后再做除法
在 mousemove 里计算天数差时,需要拿到甘特图画布的当前横向滚动量。element-ui 表格内部的滚动容器是.el-table__body-wrapper,它会把内容向左推,clientX不会自动感知这个偏移。常见做法是给画布容器加一个 ref,在拖拽时读取scrollLeft:
onDragMove(event) { if (!this.dragState) return; const container = this.$refs.ganttContainer; const rect = container.getBoundingClientRect(); const scrollLeft = container.scrollLeft || 0; const pixelX = event.clientX - rect.left + scrollLeft; const dx = pixelX - (this.dragState.startClientX - rect.left + scrollLeft); const deltaDays = Math.round(dx / this.pxPerDay); if (deltaDays !== this.dragState.lastDelta) { this.dragState.lastDelta = deltaDays; this.shiftTask(this.dragState.taskId, deltaDays); } }这里的dx计算用到了按下和移动两次坐标,但两次都减了rect.left、加了scrollLeft。如果两次读取到的scrollLeft一致,减号两边会抵消。更稳妥的写法是:pixelX = event.clientX - rect.left + scrollLeft,再和按下时的pixelX相减。不要把event.clientX - startClientX直接除以pxPerDay,那样当鼠标不动、表格横向滚动时,任务条也会跟着动。
注意:
getBoundingClientRect()返回的left会随页面滚动变化,因此每次 mousemove 都要重新读取。把 rect 放在 mousedown 里存固定值,页面中途滚动后会错位。
4.3 边界吸附和实时回写
deltaDays是四舍五入的结果,所以最小拖动单位是 1 天。如果你需要半天粒度,可以把pxPerDay放大后,再把Math.round改成Math.floor,并在shiftTask里处理半天偏移。
实时回写时,需要同时更新start和end,保证时长不变:
shiftTask(taskId, deltaDays) { const task = this.tasks.find(t => t.id === taskId); const newStart = addDays(task.start, deltaDays); const minLimit = this.minDate ? startOfDay(this.minDate) : null; const maxLimit = this.maxDate ? startOfDay(new Date(this.maxDate.getTime() - this.pxPerDay)) : null; if (minLimit && newStart < minLimit) return; if (maxLimit && newStart > maxLimit) return; task.start = newStart; task.end = addDays(newStart, this.dragState.originalDays - 1); }addDays是用setDate还是毫秒加法?建议毫秒加法,并配合startOfDay归零:
function addDays(date, days) { const d = startOfDay(date); return new Date(d.getTime() + days * DAY_MS); }边界值这里有一个常见误区:maxLimit不应该是最大日期本身,而应该是最大日期 - pxPerDay,否则任务条 start 压线但整条宽度超出画布,视觉上会悬空。originalDays - 1对应的是“end 含当天”的语义,保持拖动前后总天数不变。
拖拽结束后,记得移除全局事件监听:
onDragEnd() { document.removeEventListener('mousemove', this.onDragMove); document.removeEventListener('mouseup', this.onDragEnd); if (this.dragState && this.dragState.moved) { this.saveTask(this.dragState.taskId); } this.dragState = null; }到这里,一个支持左右拖动、边界限制、数据回写的最小甘特图已经完成。多数项目做到这一步就能交付,接下来补充拖拽过程中的依赖线和验证技巧。
5. 把拖拽和验证串成闭环:依赖线、缩放与 debug 校验
最后一个环节不是锦上添花,而是把拖拽从“能拖”变“敢拖”。重点处理两件事:拖拽时后续任务的依赖线要实时变化;拖动结果要有可断言的数据校验。这两点做好,即使甘特图延迟渲染,也不会出现“看着动了,保存后日期不对”的线上事故。
5.1 用 SVG 覆盖层画依赖线
任务条是绝对定位的 div,依赖线最合适放在同一个画布内的 SVG 层中。SVG 覆盖层与任务条共享left公式,路径坐标使用画布坐标系,比如从任务 A 的右侧中点指向任务 B 的左侧中点。
function depPath(fromTask, toTask, pxPerDay, timelineStart) { const fromLeft = daySpan(timelineStart, fromTask.start) * pxPerDay; const fromWidth = daySpan(fromTask.start, fromTask.end) * pxPerDay + pxPerDay; const toLeft = daySpan(timelineStart, toTask.start) * pxPerDay; const x1 = fromLeft + fromWidth; const y1 = 24; const x2 = toLeft; const y2 = 24; const offset = 16; return `M ${x1} ${y1} C ${x1 + offset} ${y1}, ${x2 - offset} ${y2}, ${x2} ${y2}`; }y1和y2写死 24,是因为任务条在 48px 高的行内垂直居中。若要支持不同行高,需要把“任务条所在行在画布中的 y 坐标”也传入,不能再用常量。拖动时,任务数据变化会触发depPath重新计算,SVG 的d属性自动更新。
注意:SVG 必须放在
z-index小于任务条的层,否则线会穿过任务条文字。任务条 z-index 2,SVG z-index 1。
5.2 用 verify 函数校验拖拽结果
肉眼验证拖拽是否正确并不可靠。常见做法是抽一个纯函数,接收任务列表和全部时间参数,返回冲突信息,在拖拽结束时调用:
function verifyGantt(tasks, timelineStart, pxPerDay) { const issues = []; for (const task of tasks) { const days = daySpan(task.start, task.end); if (days <= 0) { issues.push(`${task.name}: end 不大于 start`); } for (const depId of task.dependencies) { const dep = tasks.find(t => t.id === depId); if (dep && dep.end > task.start) { issues.push(`${task.name}: 前置任务 ${dep.name} 未完成`); } } const left = daySpan(timelineStart, task.start) * pxPerDay; const width = daySpan(task.start, task.end) * pxPerDay + pxPerDay; if (left < 0 || left + width > timelineDays * pxPerDay) { issues.push(`${task.name}: 超出画布范围`); } } return issues; }把这个函数在onDragEnd里调用,有issues就 console.error,并回滚到拖拽前的任务快照。回滚时建议记录整份tasks的深拷贝,不要只记录单个任务,因为依赖线冲突可能牵扯到多条任务。
5.3 让 pxPerDay 支持缩放而不破坏拖拽
把pxPerDay从 data 中提升为可切换的值,比如 24(日视图紧凑)、48(日视图标准)、144(周视图,一周约 144px)。缩放后所有计算函数都引用同一个pxPerDay,头部月份分组、任务条样式、依赖线路径会自动更新。
验证缩放是否正确的技巧是:选定一个固定任务,计算它的left + width / 2应该始终落在同一条中轴线上,也就是说任务条中点不因缩放而左右漂移。如果有漂移,优先检查daySpan里的Math.round是否污染了像素计算。最后,建议把verifyGantt挂到window上,开发时在控制台直接调用:window.__verifyGantt && console.table(window.__verifyGantt(tasks, timelineStart, pxPerDay))。拖动完成后看一眼这个表格,再决定是否回滚,比反复拖动鼠标定位问题更快。
本文还有配套的精品资源,点击获取