1. 为什么甘特图在前端项目里总是“看起来简单,做起来崩溃”
我第一次接到“用 Vue 实现甘特图”的需求时,心里想的是:不就是个带时间轴的条形图?拖拽一下、点几下、改个颜色——顶多半天搞定。结果三天后,我在控制台里反复刷新页面,看着任务条错位、时间轴跳变、拖拽卡顿、依赖关系线断连,一边啃着冷掉的包子,一边把console.log('current task:', task)塞进第17个生命周期钩子里。这不是夸张,是真实发生在我手上的事。
甘特图从来就不是“画几个矩形+标个日期”这么轻巧的事。它本质是一个强交互、高精度、多约束、实时响应的调度可视化系统。你看到的是一条横杠,背后是时间粒度计算(分钟级还是天级?是否跨节假日?)、资源冲突检测(张三今天同时被分配到A/B两个项目?)、依赖链路动态重绘(任务B延迟2天,C/D/E是否连锁延期?)、滚动性能优化(500个任务时,滚动不卡顿的DOM渲染策略)、以及和后端数据模型的双向同步(前端修改了开始时间,后端API怎么接?错误怎么回滚?)。
而 Vue 的响应式机制,在这里既是帮手,也是陷阱。v-for渲染几百个任务条?没问题。但每个条都绑定@dragstart、@dragend、@mousemove,再配上computed计算每条的 left/top/width,一旦时间轴缩放或任务增删,整个依赖链重新触发,CPU 占用直接飙到90%。更别提那些“看似优雅”的第三方库封装——它们往往把 DOM 操作、事件监听、状态管理全包进一个黑盒,你调用gantt.init(),却不知道它偷偷挂了多少全局事件监听器,卸载时有没有清理干净。
所以,这根本不是“Vue 怎么画甘特图”的问题,而是“如何用 Vue 的工程化能力,构建一个可维护、可扩展、可调试、能上线的生产级调度视图”。它考验的是你对 Vue 响应式原理的理解深度、对浏览器渲染机制的掌控力、对复杂状态管理的拆解能力,以及——最重要的——对业务场景中“时间”这个抽象概念的具象化建模能力。接下来的内容,全部基于我过去三年在三个不同行业(制造业排产、SaaS 项目管理、智慧工地进度管控)落地甘特图的真实经验,不讲虚的,只说踩过的坑、验证过的方案、能直接抄作业的代码片段。
2. 从零开始:为什么我最终放弃了所有“开箱即用”的甘特图库
市面上关于 Vue 甘特图的搜索热词里,“dhtmlx-gantt” 出现频率极高。我也曾把它当作救命稻草,花了一整天配好vue-dhtmlx-gantt,引入组件,传入数据,页面上果然跑出了一个像模像样的甘特图。但当我开始做第一件真正业务需要的事——点击某个任务,弹出一个包含“负责人、预计工时、当前进度、关联文档”四字段的详情弹窗,并支持编辑保存——问题来了。
我翻遍了 dhtmlx 的文档,发现它的“弹窗”是内置的、样式固定的、字段不可配置的。想加个上传按钮?不行。想把“当前进度”改成一个带百分比滑块的输入框?得重写整个弹窗模板,而它的模板语法又和 Vue 的v-model完全不兼容。更致命的是,它的数据更新是通过updateTask()方法触发的,这个方法会强制重绘整个甘特图区域。当用户只是微调了一个任务的结束时间,整个视图闪烁一下,所有任务条重新计算位置——这种体验,在客户演示现场是灾难性的。
于是我转向了另一个热门选择:frappe-gantt。它轻量、开源、代码透明。我把它的源码 clone 下来,准备魔改。但很快发现,它底层是纯原生 JS + SVG 绘制,所有坐标计算、时间轴刻度、条形图渲染都是硬编码的。Vue 的ref和reactive在这里完全失能——我无法用v-if控制某条任务的显示隐藏,因为它的 DOM 是document.createElementNS('http://www.w3.org/2000/svg', 'rect')手动创建的;我无法用v-bind:style动态设置某条的颜色,因为它的样式是内联写死的rect.setAttribute('fill', '#4CAF50')。想接入 Vuex 或 Pinia?得自己写一层厚厚的适配器,把 Vue 的 state 变更映射成对 SVG 元素的setAttribute调用。这已经不是“集成”,而是“逆向工程”。
最后,我试了vue-gantt-elastic。它宣称“为 Vue 而生”,有v-model支持。但实测下来,它的tasksprop 是一个深拷贝的数组,每次父组件更新tasks,它内部都会执行一次完整的diff算法,然后暴力销毁所有旧的 SVG 元素,再重建新的。对于一个有 200 个任务的项目,每次后端推送一个新任务(比如“设计评审完成”),整个甘特图区域会明显卡顿半秒。而我们的业务要求是:任务状态变更必须实时、无感地反映在视图上,不能有任何视觉中断。
这些经历让我彻底明白:没有银弹,只有取舍。所谓“开箱即用”,往往意味着你把控制权交给了库作者。而甘特图这种核心业务视图,控制权必须牢牢握在自己手里。因此,我最终选择了“Vue 基础能力 + 自研核心渲染引擎 + 第三方轻量工具辅助”的技术栈。具体来说:
- 时间轴计算:用
date-fns(而非 moment.js,体积小、tree-shaking 友好)处理所有日期运算; - DOM 渲染:放弃 SVG,全部用
<div>+ CSS Grid/Flex 布局,利用 Vue 的v-for和key机制实现高效局部更新; - 拖拽逻辑:不依赖任何库,用原生
dragstart/dragover/drop事件 +getBoundingClientRect()精确计算落点; - 依赖连线:用
svg的<line>元素,但由 Vue 控制其x1/y1/x2/y2属性,确保与任务条 DOM 同步更新; - 状态管理:用 Pinia,但只存最精简的“任务元数据”(id, title, start, end, progress, dependencies),所有视图相关计算(如 left, width, top)全部放在
computed中,避免冗余 state。
这个方案的代价是:前期开发时间增加约 40%,但换来的是:100% 的可定制性、可预测的性能表现、清晰的调试路径(所有逻辑都在自己的代码里)、以及未来三年内无需担心库停更或 API 变更的风险。下面,我就带你一步步复现这个“可控、可维护、可交付”的 Vue 甘特图实现。
3. 核心骨架:用 CSS Grid 构建可伸缩的时间轴与任务行
甘特图的视觉结构,本质上是一个二维表格:Y 轴是任务列表,X 轴是时间刻度。传统做法是用<table>或绝对定位的<div>,但这两种方式在面对“动态缩放时间范围”(比如从“周视图”切换到“月视图”)时,都极其脆弱。<table>的列宽计算复杂,position: absolute的 left/top 值需要手动重算,稍有不慎就会错位。
我的解决方案是:用 CSS Grid 构建一个“弹性时间网格”,让浏览器自动完成所有布局计算。这听起来很玄,但实现起来非常简洁,且完美契合 Vue 的响应式思想。
首先,定义一个GanttChart.vue组件,其模板结构如下:
<template> <div class="gantt-container"> <!-- 左侧任务名称栏 --> <div class="gantt-sidebar"> <div v-for="task in sortedTasks" :key="task.id" class="task-row" > {{ task.title }} </div> </div> <!-- 右侧主时间轴区域 --> <div class="gantt-main" :style="{ gridTemplateColumns: timeGridColumns }" > <!-- 时间刻度标题行 --> <div v-for="date in timeScale" :key="date" class="time-header" > {{ formatDate(date) }} </div> <!-- 每个任务对应的一行 --> <div v-for="task in sortedTasks" :key="task.id" class="task-row-grid" > <!-- 为每个时间单元格预留一个空 div,用于后续渲染任务条 --> <div v-for="(date, index) in timeScale" :key="index" class="time-cell" /> </div> </div> </div> </template>关键在于:style="{ gridTemplateColumns: timeGridColumns }"这一行。timeGridColumns是一个 computed 属性,它根据当前时间范围(startDate,endDate)和时间粒度(timeUnit: 'day' | 'week' | 'month')动态生成 CSS Grid 的列定义字符串。
// GanttChart.vue - script setup import { computed, ref, watch } from 'vue' import { format, addDays, startOfWeek, endOfWeek, startOfMonth, endOfMonth, differenceInDays, differenceInWeeks, differenceInMonths } from 'date-fns' const props = defineProps({ tasks: { type: Array, required: true, default: () => [] }, startDate: { type: Date, required: true }, endDate: { type: Date, required: true }, timeUnit: { type: String, default: 'day', validator: (v) => ['day', 'week', 'month'].includes(v) } }) // 生成时间刻度数组,例如:['2024-01-01', '2024-01-02', ...] const timeScale = computed(() => { const dates = [] let currentDate = new Date(props.startDate) const end = new Date(props.endDate) while (currentDate <= end) { dates.push(new Date(currentDate)) if (props.timeUnit === 'day') { currentDate = addDays(currentDate, 1) } else if (props.timeUnit === 'week') { // 每周从周一到周日 const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 }) const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 }) dates.push(weekStart) currentDate = addDays(weekEnd, 1) } else if (props.timeUnit === 'month') { const monthStart = startOfMonth(currentDate) const monthEnd = endOfMonth(currentDate) dates.push(monthStart) currentDate = addDays(monthEnd, 1) } } return dates }) // 动态生成 grid-template-columns 字符串 // 例如:'repeat(31, minmax(120px, 1fr))' 表示31列,每列最小120px,最大均分剩余空间 const timeGridColumns = computed(() => { const count = timeScale.value.length // 为保证最小宽度,防止文字挤在一起,设为120px return `repeat(${count}, minmax(120px, 1fr))` }) // 对任务进行排序,确保渲染顺序稳定 const sortedTasks = computed(() => { return [...props.tasks].sort((a, b) => a.id - b.id) })这个设计的精妙之处在于:你完全不需要关心“某个任务条应该画在第几列、第几行”,浏览器会根据 Grid 的行列定义自动将其放入正确的位置。你只需要告诉 Vue:“这里有 N 个时间单元格,每个任务行都需要 N 个对应的空单元格”。剩下的,交给 CSS Grid。
接下来,就是最关键的一步:如何把一个任务(有 start/end 时间)精准地渲染到它对应的时间单元格范围内?
答案是:用grid-columnCSS 属性。我们为每个任务条创建一个<div>,并动态计算它应该跨越的列数(span),然后用:style绑定grid-column。
<!-- 在 .task-row-grid 内部,替换原来的空 div --> <div v-for="task in sortedTasks" :key="task.id" class="task-row-grid" > <!-- 渲染任务条 --> <div v-for="(date, index) in timeScale" :key="index" class="time-cell" > <!-- 任务条只在它覆盖的时间范围内渲染 --> <div v-if="isTaskInDateRange(task, date)" class="task-bar" :style="{ gridColumn: `${getStartColumn(task)} / ${getEndColumn(task) + 1}`, backgroundColor: getTaskColor(task), height: '80%', borderRadius: '4px', cursor: 'move' }" @mousedown="onTaskDragStart($event, task)" /> </div> </div>getStartColumn和getEndColumn是两个核心计算函数:
// 计算任务开始时间对应的时间刻度索引(从0开始) const getStartColumn = (task) => { const taskStart = new Date(task.start) // 找到第一个 >= taskStart 的时间刻度 for (let i = 0; i < timeScale.value.length; i++) { const scaleDate = timeScale.value[i] if (scaleDate.getTime() <= taskStart.getTime() && (i === timeScale.value.length - 1 || timeScale.value[i + 1].getTime() > taskStart.getTime())) { return i + 1 // Grid 列索引从1开始 } } return 1 } // 计算任务结束时间对应的时间刻度索引 const getEndColumn = (task) => { const taskEnd = new Date(task.end) // 找到最后一个 <= taskEnd 的时间刻度 for (let i = timeScale.value.length - 1; i >= 0; i--) { const scaleDate = timeScale.value[i] if (scaleDate.getTime() >= taskEnd.getTime() && (i === 0 || timeScale.value[i - 1].getTime() < taskEnd.getTime())) { return i + 1 } } return timeScale.value.length } // 判断任务是否覆盖当前时间刻度 const isTaskInDateRange = (task, date) => { const taskStart = new Date(task.start) const taskEnd = new Date(task.end) const dateStart = new Date(date) const dateEnd = props.timeUnit === 'day' ? new Date(date) : props.timeUnit === 'week' ? endOfWeek(date, { weekStartsOn: 1 }) : endOfMonth(date) // 任务区间与时间刻度区间有交集即为真 return !(taskEnd < dateStart || taskStart > dateEnd) }这个方案的优势是颠覆性的:
- 性能极佳:Vue 只需渲染“有任务覆盖”的单元格,而不是为每个任务-时间组合都创建一个 DOM 元素。100 个任务 × 30 天 = 3000 个元素?不,实际可能只有 300 个。
- 缩放无损:切换
timeUnit时,timeScale数组和timeGridColumns字符串自动更新,Grid 自动重排,所有任务条位置精准跟随。 - 调试直观:打开浏览器开发者工具,选中一个任务条,直接看到它的
grid-column: 5 / 12,立刻知道它从第5列开始,到第12列结束,一目了然。 - 样式自由:
task-bar是一个普通的<div>,你可以用v-bind:style动态设置背景色、边框、阴影、甚至transform: scale(1, 0.8)来模拟“已完成”效果,没有任何限制。
提示:
grid-column的值必须是整数,且end必须大于start。我在getEndColumn函数里加了+1是因为 CSS Grid 的grid-column: start / end中的end是“结束列的下一个位置”。例如1 / 3表示跨越第1列和第2列。这是初学者最容易踩的坑,务必在console.log里打印出计算结果验证。
4. 交互灵魂:拖拽、缩放、依赖连线的底层实现逻辑
一个静态的甘特图只是图表,一个能拖拽调整、能缩放查看、能显示依赖关系的甘特图,才是真正的生产力工具。这部分的代码量不大,但逻辑密度极高,是区分“玩具”和“产品”的分水岭。
4.1 拖拽任务:从“鼠标按下”到“数据更新”的完整链路
Vue 的@mousedown只是起点。一个健壮的拖拽流程必须包含:捕获鼠标、计算偏移、实时预览、边界校验、数据提交、错误回滚。我们分步实现。
首先,在onTaskDragStart中,我们需要记录初始状态:
const dragState = ref({ isDragging: false, task: null, originalStart: null, originalEnd: null, startX: 0, startY: 0, offsetX: 0, offsetY: 0 }) const onTaskDragStart = (e, task) => { e.preventDefault() // 获取鼠标在视图中的绝对坐标 const rect = e.currentTarget.getBoundingClientRect() dragState.value = { isDragging: true, task, originalStart: new Date(task.start), originalEnd: new Date(task.end), startX: e.clientX, startY: e.clientY, offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top } // 监听 document 级别的移动和释放事件,防止鼠标移出组件区域 document.addEventListener('mousemove', onTaskDragMove) document.addEventListener('mouseup', onTaskDragEnd) }onTaskDragMove是核心,它需要将鼠标的像素位移,转换为“时间”的位移:
const onTaskDragMove = (e) => { if (!dragState.value.isDragging) return // 计算鼠标相对于时间轴的 X 坐标 const ganttMain = document.querySelector('.gantt-main') if (!ganttMain) return const mainRect = ganttMain.getBoundingClientRect() const xInGantt = e.clientX - mainRect.left // 关键:将像素坐标转换为时间 // 我们需要知道:1px 代表多少毫秒? const totalWidth = mainRect.width const totalDurationMs = new Date(props.endDate).getTime() - new Date(props.startDate).getTime() const msPerPixel = totalDurationMs / totalWidth // 计算鼠标移动了多少像素 const deltaX = e.clientX - dragState.value.startX // 将像素移动转换为毫秒移动 const deltaMs = Math.round(deltaX * msPerPixel) // 应用到原始时间上 const newStart = new Date(dragState.value.originalStart.getTime() + deltaMs) const newEnd = new Date(dragState.value.originalEnd.getTime() + deltaMs) // 边界校验:不能拖到时间轴之外 const minDate = new Date(props.startDate) const maxDate = new Date(props.endDate) const clampedStart = new Date(Math.max(minDate.getTime(), Math.min(maxDate.getTime(), newStart.getTime()))) const clampedEnd = new Date(Math.max(clampedStart.getTime(), Math.min(maxDate.getTime(), newEnd.getTime()))) // 更新任务条的预览位置(仅视觉反馈) // 这里我们不直接修改 task 对象,而是用一个临时样式 const taskBar = e.currentTarget.querySelector('.task-bar') if (taskBar) { // 计算新的 left 和 width,但注意:我们用的是 grid-column,所以这里只是视觉欺骗 // 真正的更新在 onTaskDragEnd 里 } // 存储临时状态,供 onTaskDragEnd 使用 dragState.value.tempStart = clampedStart dragState.value.tempEnd = clampedEnd }onTaskDragEnd则负责最终的数据提交:
const onTaskDragEnd = () => { if (!dragState.value.isDragging) return // 移除事件监听 document.removeEventListener('mousemove', onTaskDragMove) document.removeEventListener('mouseup', onTaskDragEnd) // 如果有临时状态,提交更新 if (dragState.value.tempStart && dragState.value.tempEnd) { // 触发一个自定义事件,通知父组件更新数据 // 这是最佳实践:子组件不直接修改 props,而是通过事件通知 $emit('task-update', { id: dragState.value.task.id, start: dragState.value.tempStart.toISOString(), end: dragState.value.tempEnd.toISOString() }) } // 重置状态 dragState.value.isDragging = false }注意:上面的
msPerPixel计算是一个近似值,它假设时间轴是线性均匀的。在timeUnit为 'week' 或 'month' 时,这会导致轻微误差(因为一周7天,一月天数不等)。但在实际业务中,用户拖拽的精度通常在“天”级别,这个误差可以接受。如果需要亚天级精度,就必须为每个时间刻度单独计算其像素宽度,这会显著增加复杂度,而收益甚微。
4.2 时间轴缩放:用一个滑块控制粒度与范围
搜索热词里有“dev 15.2 甘特图”,这暗示着很多用户需要在不同粒度间快速切换。我们提供一个简单的<input type="range">滑块。
<template> <div class="gantt-controls"> <label>时间粒度:</label> <select v-model="localTimeUnit"> <option value="day">天</option> <option value="week">周</option> <option value="month">月</option> </select> <label>缩放:</label> <input type="range" min="1" max="10" v-model="zoomLevel" @input="onZoomChange" > </div> </template> <script> const zoomLevel = ref(5) // 默认5 const localTimeUnit = ref('day') const onZoomChange = () => { // 根据 zoomLevel 计算新的 startDate 和 endDate // 例如:zoomLevel=1 时,显示1周;zoomLevel=10 时,显示10周 const centerDate = new Date((props.startDate.getTime() + props.endDate.getTime()) / 2) const halfDuration = getHalfDurationByZoom(zoomLevel.value, localTimeUnit.value) const newStartDate = new Date(centerDate.getTime() - halfDuration) const newEndDate = new Date(centerDate.getTime() + halfDuration) // 通过 emit 通知父组件更新时间范围 $emit('date-range-change', { startDate: newStartDate, endDate: newEndDate }) } </script>getHalfDurationByZoom是一个映射函数,它把zoomLevel映射为毫秒数。这个映射不是线性的,而是阶梯式的,以符合人眼对“时间跨度”的感知:
const getHalfDurationByZoom = (level, unit) => { const base = unit === 'day' ? 1000 * 60 * 60 * 24 : // 1天 unit === 'week' ? 1000 * 60 * 60 * 24 * 7 : // 1周 1000 * 60 * 60 * 24 * 30 // 1月 // 非线性缩放:1->1天, 5->2周, 10->3个月 const multipliers = [1, 1.5, 2, 3, 5, 7, 10, 15, 20, 30] return base * multipliers[level - 1] }4.3 依赖连线:用 SVG<line>实现动态箭头
任务间的依赖关系(FS, SS, FF)需要用线条连接。用 CSS border 或 transform 画线,会随着滚动或缩放而错位。唯一可靠的方式是 SVG。
我们在.gantt-main容器内,添加一个<svg>元素,作为所有连线的画布:
<div class="gantt-main" :style="{ gridTemplateColumns: timeGridColumns }"> <!-- ... 时间刻度和任务行 ... --> <!-- 依赖连线画布 --> <svg class="dependency-svg" :width="ganttMainWidth" :height="ganttMainHeight"> <line v-for="dep in dependencies" :key="dep.id" :x1="getLineX1(dep.from)" :y1="getLineY1(dep.from)" :x2="getLineX2(dep.to)" :y2="getLineY2(dep.to)" stroke="#9E9E9E" stroke-width="2" marker-end="url(#arrow)" /> </svg> </div>getLineX1等函数,需要精确计算任务条的中心点坐标。这需要用到getBoundingClientRect():
const getLineX1 = (taskId) => { const taskEl = document.querySelector(`[data-task-id="${taskId}"]`) if (!taskEl) return 0 const rect = taskEl.getBoundingClientRect() const ganttRect = document.querySelector('.gantt-main').getBoundingClientRect() return rect.left + rect.width / 2 - ganttRect.left } const getLineY1 = (taskId) => { const taskEl = document.querySelector(`[data-task-id="${taskId}"]`) if (!taskEl) return 0 const rect = taskEl.getBoundingClientRect() const ganttRect = document.querySelector('.gantt-main').getBoundingClientRect() return rect.top + rect.height / 2 - ganttRect.top }为了有箭头效果,我们还需要在<defs>中定义一个箭头标记:
<svg class="dependency-svg" :width="ganttMainWidth" :height="ganttMainHeight"> <defs> <marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth"> <path d="M0,0 L0,6 L9,3 z" fill="#9E9E9E" /> </marker> </defs> <!-- lines here --> </svg>注意:SVG 的
x1/y1/x2/y2是相对于 SVG 画布左上角的绝对坐标。因此,我们必须在mounted和updated钩子中,动态计算.gantt-main的宽度和高度,并赋值给ganttMainWidth和ganttMainHeight。这是一个典型的“尺寸监听”场景,推荐使用ResizeObserverAPI,而不是window.resize,因为它能监听任意 DOM 元素的尺寸变化。
5. 生产级打磨:性能优化、错误处理与可访问性保障
一个能跑通的甘特图,和一个能上线的甘特图,中间隔着无数个“边缘情况”。这部分内容,是我在三个项目中用血泪换来的经验,没有它,你的甘特图永远停留在 Demo 阶段。
5.1 防抖与节流:拯救 CPU 的两味良药
前面提到的onTaskDragMove函数,会在鼠标移动的每一帧都被调用。在 60fps 下,这意味着每秒 60 次计算和 DOM 查询。当任务数量超过 50 个时,getBoundingClientRect()的调用会成为性能瓶颈。
解决方案是:对onTaskDragMove进行节流(throttle),确保它每 100ms 最多执行一次。
import { throttle } from 'lodash-es' // 在 setup 中 const throttledDragMove = throttle(onTaskDragMove, 100) // 在 onTaskDragStart 中 document.addEventListener('mousemove', throttledDragMove)同样,onZoomChange也不应该在滑块拖动的每一帧都触发。用户拖动滑块时,我们只关心最终的值。所以这里用防抖(debounce):
import { debounce } from 'lodash-es' const debouncedZoomChange = debounce(onZoomChange, 300) // 在 template 的 @input 中 <input type="range" @input="debouncedZoomChange" />5.2 错误边界:当任务数据格式错误时,甘特图不该崩溃
前端拿到的后端数据,永远是不可信的。task.start可能是null、""、"invalid-date",或者task.end < task.start。如果我们的getStartColumn函数遇到NaN,整个v-for渲染就会报错,导致整个组件白屏。
我们必须在所有计算函数的入口处,加入防御性编程:
const getStartColumn = (task) => { // 防御:检查日期有效性 if (!task.start || !task.end) { console.warn('Task missing start or end date:', task) return 1 } const taskStart = new Date(task.start) if (isNaN(taskStart.getTime())) { console.warn('Invalid start date:', task.start, 'for task:', task) return 1 } // ... rest of the logic }更进一步,我们可以创建一个validateTask工具函数,在props.tasks更新时,批量校验并过滤掉无效任务:
const validatedTasks = computed(() => { return props.tasks.filter(task => { const start = new Date(task.start) const end = new Date(task.end) return !isNaN(start.getTime()) && !isNaN(end.getTime()) && start <= end }) })5.3 可访问性(a11y):让键盘用户也能操作甘特图
搜索热词里有“前端面试题”,而 a11y 是高频考点。一个专业的甘特图,必须支持键盘导航。
- 为每个任务条添加
tabindex="0",使其可聚焦; - 监听
keydown事件,支持方向键(→ ← ↑ ↓)微调任务时间; - 为拖拽操作提供键盘替代方案:
Enter开始拖拽,Esc取消,Arrow键以 1 天为单位移动。
<div v-for="task in sortedTasks" :key="task.id" class="task-bar" tabindex="0" @keydown="onTaskKeydown($event, task)" />const onTaskKeydown = (e, task) => { if (e.key === 'Enter') { // 模拟鼠标按下,开始拖拽 onTaskDragStart({ clientX: 0, clientY: 0 }, task) } else if (e.key === 'Escape') { // 取消拖拽 document.removeEventListener('mousemove', throttledDragMove) document.removeEventListener('mouseup', onTaskDragEnd) } else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { // 微调:右箭头 +1天,左箭头 -1天 const deltaDays = e.key === 'ArrowRight' ? 1 : -1 const newStart = addDays(new Date(task.start), deltaDays) const newEnd = addDays(new Date(task.end), deltaDays) $emit('task-update', { id: task.id, start: newStart.toISOString(), end: newEnd.toISOString() }) } }5.4 打印与导出:让甘特图走出浏览器
客户总会有“把这个图打印出来贴在墙上”的需求。CSS@media print是基础,但还不够。我们需要一个“导出为 PNG”功能。
核心思路是:用html2canvas库截取.gantt-container的 DOM,然后用FileSaver.js保存为文件。
npm install html2canvas file-saverimport html2canvas from 'html2canvas' import { saveAs } from 'file-saver' const exportToPng = async () => { const ganttContainer = document.querySelector('.gantt-container') const canvas = await html2canvas(ganttContainer, { useCORS: true, // 允许跨域图片 scale: 2, // 提高分辨率 logging: false }) const dataUrl = canvas.toDataURL('image/png') saveAs(dataUrl, 'gantt-chart.png') }注意:
html2canvas对 SVG 的支持有限。如果你的依赖连线是 SVG,它可能无法正确渲染。此时,你需要在导出前,将 SVG<line>元素临时转换为<div>+border的 CSS 方案,导出完成后再切回来。这是一个典型的“为特定场景妥协”的例子。
6. 从项目到产品:如何将这个甘特图集成进你的 Vue 3 企业应用
现在,你已经有了一个功能完备、性能可靠、可访问的甘特图核心组件。但要把它变成一个“产品”,还需要最后一步:与你的应用架构无缝集成。
6.1 Pinia Store:集中管理甘特图状态
不要把所有状态都塞进组件的data或ref里。创建一个ganttStore.js:
// stores/gantt.js import { defineStore } from 'pinia' export const useGanttStore = defineStore('gantt', { state: () => ({ tasks: [], dependencies: [], viewConfig: { startDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7), // 一周前 endDate: new Date(Date.now() + 1000