资讯动态

Vue 3生产级甘特图实现:从CSS Grid渲染到拖拽依赖连线

发布时间:2026/10/1 23:17:51 来源:尧图企业网站定制
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 classgantt-container !-- 左侧任务名称栏 -- div classgantt-sidebar div v-fortask in sortedTasks :keytask.id classtask-row {{ task.title }} /div /div !-- 右侧主时间轴区域 -- div classgantt-main :style{ gridTemplateColumns: timeGridColumns } !-- 时间刻度标题行 -- div v-fordate in timeScale :keydate classtime-header {{ formatDate(date) }} /div !-- 每个任务对应的一行 -- div v-fortask in sortedTasks :keytask.id classtask-row-grid !-- 为每个时间单元格预留一个空 div用于后续渲染任务条 -- div v-for(date, index) in timeScale :keyindex classtime-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-fortask in sortedTasks :keytask.id classtask-row-grid !-- 渲染任务条 -- div v-for(date, index) in timeScale :keyindex classtime-cell !-- 任务条只在它覆盖的时间范围内渲染 -- div v-ifisTaskInDateRange(task, date) classtask-bar :style{ gridColumn: ${getStartColumn(task)} / ${getEndColumn(task) 1}, backgroundColor: getTaskColor(task), height: 80%, borderRadius: 4px, cursor: move } mousedownonTaskDragStart($event, task) / /div /divgetStartColumn和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 typerange滑块。template div classgantt-controls label时间粒度/label select v-modellocalTimeUnit option valueday天/option option valueweek周/option option valuemonth月/option /select label缩放/label input typerange min1 max10 v-modelzoomLevel inputonZoomChange /div /template script const zoomLevel ref(5) // 默认5 const localTimeUnit ref(day) const onZoomChange () { // 根据 zoomLevel 计算新的 startDate 和 endDate // 例如zoomLevel1 时显示1周zoomLevel10 时显示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 }) } /scriptgetHalfDurationByZoom是一个映射函数它把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 依赖连线用 SVGline实现动态箭头任务间的依赖关系FS, SS, FF需要用线条连接。用 CSS border 或 transform 画线会随着滚动或缩放而错位。唯一可靠的方式是 SVG。我们在.gantt-main容器内添加一个svg元素作为所有连线的画布div classgantt-main :style{ gridTemplateColumns: timeGridColumns } !-- ... 时间刻度和任务行 ... -- !-- 依赖连线画布 -- svg classdependency-svg :widthganttMainWidth :heightganttMainHeight line v-fordep in dependencies :keydep.id :x1getLineX1(dep.from) :y1getLineY1(dep.from) :x2getLineX2(dep.to) :y2getLineY2(dep.to) stroke#9E9E9E stroke-width2 marker-endurl(#arrow) / /svg /divgetLineX1等函数需要精确计算任务条的中心点坐标。这需要用到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 classdependency-svg :widthganttMainWidth :heightganttMainHeight defs marker idarrow markerWidth10 markerHeight10 refX9 refY3 orientauto markerUnitsstrokeWidth path dM0,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也不应该在滑块拖动的每一帧都触发。用户拖动滑块时我们只关心最终的值。所以这里用防抖debounceimport { debounce } from lodash-es const debouncedZoomChange debounce(onZoomChange, 300) // 在 template 的 input 中 input typerange inputdebouncedZoomChange /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 是高频考点。一个专业的甘特图必须支持键盘导航。为每个任务条添加tabindex0使其可聚焦监听keydown事件支持方向键→ ← ↑ ↓微调任务时间为拖拽操作提供键盘替代方案Enter开始拖拽Esc取消Arrow键以 1 天为单位移动。div v-fortask in sortedTasks :keytask.id classtask-bar tabindex0 keydownonTaskKeydown($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 打印与导出让甘特图走出浏览器客户总会有“把这个图打印出来贴在墙上”的需求。CSSmedia 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它可能无法正确渲染。此时你需要在导出前将 SVGline元素临时转换为divborder的 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

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑