news 2026/9/14 5:53:03

Vue2+Element UI实现可拖拽甘特图:日期坐标换算与拖拽闭环

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue2+Element UI实现可拖拽甘特图:日期坐标换算与拖拽闭环

简介:面向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) 对应的毫秒数 / 一天的毫秒数 * pxPerDay

pxPerDay是全局唯一的比例因子,表示一个自然日占据多少个像素。一天对应 24px 还是 48px,直接影响拖拽时鼠标移动 1px 代表 0.5 小时还是 1 小时。这个因子不要散落在组件里,应该放在 Vue 的 data 或 Vuex 中,后续做“日/周/月”缩放也只改这一个变量。

2.1 任务字段只需要 start 和 end 两个日期

一个可拖拽任务条需要的最小数据模型如下:

字段类型作用
idstring/number拖拽回写时定位任务
namestring左侧表格显示的任务名
startDate任务开始日期,按天做最小粒度
endDate任务结束日期,含当日
progressnumber0-100,进度条填充比例,拖拽时保持不变
dependenciesarray前置任务 id 列表,画依赖线用

startend建议统一用 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.starttask.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里处理半天偏移。

实时回写时,需要同时更新startend,保证时长不变:

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}`; }

y1y2写死 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))。拖动完成后看一眼这个表格,再决定是否回滚,比反复拖动鼠标定位问题更快。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 5:52:51

Rust函数编程:从基础到高级特性解析

1. Rust函数基础概念与核心特性Rust作为一门现代系统编程语言&#xff0c;其函数设计融合了安全性、性能与表达力三大核心优势。与C/C等传统系统语言不同&#xff0c;Rust函数在编译阶段就通过所有权机制消除了数据竞争和内存安全问题。一个基础的Rust函数定义如下&#xff1a;…

作者头像 李华
网站建设 2026/9/14 5:51:49

四模态融合课堂感知系统:情绪+表情+姿态+人脸协同分析

简介&#xff1a;本资源是一个基于多模态AI技术的智能教室系统实现方案&#xff0c;面向教育信息化开发者、计算机视觉方向学习者及智慧校园建设实践者&#xff0c;聚焦课堂行为分析与考试监管场景&#xff0c;解决学生专注度量化、动态考勤、情绪状态识别、异常姿态监测及作弊…

作者头像 李华
网站建设 2026/9/14 5:51:16

字符级RNN古诗生成系统:可复现、可调试、可部署

简介&#xff1a;本资源是一个基于循环神经网络&#xff08;RNN&#xff09;实现的古诗自动生成系统&#xff0c;面向人工智能初学者与机器学习实践者&#xff0c;聚焦诗词文本生成这一典型NLP任务&#xff0c;帮助学习者掌握数据预处理、RNN建模、TensorFlow训练及轻量前端部署…

作者头像 李华
网站建设 2026/9/14 5:51:11

微信存储空间清理实战:搞懂缓存与聊天文件,从40GB到6GB

手机相册里全是截图、聊天记录里的视频还没看完、微信动不动就“存储空间不足”——这个场景我太熟悉了。我自己常年要处理各种文件往来&#xff0c;微信里塞满了合同、产品图、项目视频&#xff0c;峰值时占用超过 40GB。这两年我把清理微信大文件这件事彻底研究了一遍&#x…

作者头像 李华