news 2026/10/1 18:56:03

Vue纯前端自研甘特图:零依赖实现拖拽排期与依赖线

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue纯前端自研甘特图:零依赖实现拖拽排期与依赖线

做过排期、进度管理类后台系统的人应该都有这个体感:列表和表格搭起来很快,真正卡住工期的是中间那块甘特图。需求方要拖拽调时间、要显示依赖箭头、要按周月切换粒度,最好还能看到关键路径和基线对比,可预算又卡死在“不采购商业授权”这一条上。这时候摆在面前的选择基本就三个:找一个现成的开源库改,硬啃一套商用方案,或者自己动手在 vue 项目里从零撸一个多功能的甘特图组件。我在两三个版本迭代里把三条路都踩过一遍,最后落地的是一套纯前端自研、零依赖、可完全掌控的甘特图方案,核心逻辑加起来不到两千行,功能却比市面上不少轻量库要全。这篇就按实战的顺序,把选型判断、数据模型、时间刻度换算、拖拽吸附、依赖线、虚拟滚动、打包部署踩坑这些东西全部摊开讲,代码能直接抄,参数能直接改用,适合正在做 vue 项目管理、生产排程、资源调度类模块的开发者参考,前端基础一般也能跟着走完全程。

1. 选型之前先把需求盘清楚

很多人一上来就搜“vue 甘特图组件”,装完发现功能对不上,改源码又改不动,最后时间全耗在适配层上。我的经验是先花两个小时把需求拆成三类:必须有的、最好有的、可以砍的。这个动作看起来浪费时间,实际是省时间最狠的一步,因为它直接决定了你是“改造轮子”还是“造轮子”。

1.1 三条技术路线的横向对比

我把当时评估的三种路线整理成表,你可以直接对照自己的项目打个分。评判维度我选了五个:上手成本、功能覆盖度、定制难度、长期维护、隐性风险。注意“定制难度”和“上手成本”经常是反着走的,上手快的往往改起来最难受。

路线上手成本功能覆盖定制难度长期维护典型隐性风险
商用授权库低全,含关键路径、资源视图低,但受配置项限制依赖厂商版本授权按年计费,人数一多成本陡增
开源库二次开发中中等,基础拖拽基本都有中高,需要读懂内部结构上游停更就砸手里源码风格差异大,升级会覆盖你的改动
纯前端自研高按需,做什么有什么低,代码全在自己手里完全自主前期投入大,边界情况要自己填

我当时的需求里有几条是开源库给不了的:任务自带多段进度、子任务折叠后父任务自动汇总、依赖线要能跨越折叠层级、还要和已有的成本模块联动。硬凑的结果就是在外层套一层转换,数据来回映射,debug 的时候得同时看懂两套模型,非常痛苦。所以最后选了自研,但这里必须说清楚:如果你的需求只是“展示时间条 + 简单拖拽”,千万别自研,直接用成熟库,两天上线,别学我折腾。

1.2 多功能到底该包含哪些功能

标题里说“超多功能”,这个词很容易被误解成功能越多越好。实际做下来,真正被高频使用的功能其实就十来个,其余都是锦上添花。我把最终版本的能力列一下,你可以当作需求清单对照勾选:

  • 多粒度时间轴:日、周、月、季度四档切换,缩放级别连续可调
  • 任务层级:父子嵌套,折叠展开,父任务自动按子任务聚合起止时间和进度
  • 拖拽交互:整体平移改起止日期、拖两侧改工期、拖进度点改进度
  • 依赖关系:FS、SS、FF、SF 四种类型,自动避让,支持跨层级连线
  • 里程碑:零工期的菱形节点,独立渲染
  • 关键路径:按最长路径算法自动高亮
  • 基线对比:保存一版计划作为基线,显示偏差
  • 今日线与节假日底色
  • 泳道分组:按负责人或项目分组显示
  • 右键菜单与快捷键
  • 筛选、搜索、定位到指定任务
  • 导出 CSV / 图片
  • 暗色主题

看着多,其实不少是共享同一套渲染管线的,真正增加复杂度的只有依赖线、关键路径和基线三块。先把主干(时间轴 + 任务条 + 拖拽)做扎实,其余都是往上加图层,这个顺序很重要,反过来做会反复重构。

1.3 数据模型定错,后面改十次

我第一版数据模型是照着后端接口直接用的,字段名、时间格式全是后端那套,结果前端做拖拽计算时到处转换,代码里塞了一堆dayjs的 format 和 parse。第二版我做了彻底分离:后端接口层保持原样,中间加一个适配层,内部统一使用一套自己的结构。内部结构大概是这样:

// 内部任务模型 { id: 't-1001', parentId: null, // 用于构建层级 name: '结构件加工', start: 1735689600000, // 时间戳,毫秒,所有时间统一用时间戳 end: 1736294400000, progress: 0.35, // 0~1 milestone: false, groupId: 'g-1', // 泳道归属 deps: [ // 依赖,指向其他任务 { target: 't-1002', type: 'FS', lag: 0 } // lag 单位:天 ], collapsed: false, baseline: { start: 1735603200000, end: 1736208000000 } // 可空 }

两个决定值得展开说。第一,全部用毫秒时间戳,不用字符串。字符串在跨时区、跨夏令时的环境里做加减法很容易出问题,而甘特图的核心运算就是时间的加减和比较,用数字能省掉大量心智负担。第二,依赖关系挂在源任务上,而不是单独一张边表。单独边表在增删任务时要同步维护,容易漏;挂在任务上虽然查“谁依赖我”要遍历一次,但配合索引缓存完全够用,代码量少一半。

2. 核心算法与参数计算

甘特图看着是画图,本质上是几组数学换算:日期换像素、像素换日期、任务换行号、依赖换折线。这几组换算想清楚了,渲染层就只是把计算结果画出来而已,几乎不需要动脑。这一节把公式和边界情况都写清楚,尤其是缩放时的舍入误差,这是最容易埋雷的地方。

2.1 时间刻度到像素的换算

最基础的公式只有两条:

// 时间戳 -> 横坐标 function dateToX(ts, timelineStart, pxPerDay) { return ((ts - timelineStart) / 86400000) * pxPerDay } // 横坐标 -> 时间戳 function xToDate(x, timelineStart, pxPerDay) { return timelineStart + (x / pxPerDay) * 86400000 }

看着简单,实际有三个坑。坑一是 86400000 这个常数,它只在没有夏令时的地区成立,如果系统要服务多地区用户,稳妥做法是用日期库按自然日累加,或者干脆全部用 UTC 零点对齐。坑二是浮点误差,pxPerDay在连续缩放时会是 37.4129 这种小数,来回转换几次后像素值会出现 0.0001 级别的漂移,表现为任务条边缘偶尔抖动一个像素。解决办法是在最终渲染前统一Math.round,并且拖拽过程中累积的增量用原始时间戳算,不要每帧从像素反推时间。坑三是最小宽度,工期只有一天的任务在月视图下宽度可能小于 4 像素,直接看不见,必须设一个 MIN_BAR_WIDTH,同时通过虚线延伸的方式提示真实跨越范围。

缩放的参数档位我实测下来这套比较顺手,你可以直接拿去用:

视图粒度pxPerDay单列宽度来源适用场景
日48 ~ 80每列一天排产、短期冲刺
周24 ~ 40每列一周常规项目排期
月6 ~ 14每列一月季度级规划
季度2 ~ 5每列一季度年度路线图

缩放不是把 pxPerDay 一改就完事,刻度头也要跟着换组件:日视图画“日 + 周”,周视图画“周 + 月”,月视图画“月 + 年”。我的做法是把刻度头抽成一个独立的TimeScaleHeader组件,接收pxPerDay和timelineStart,内部自己决定渲染几层、每格多宽,主组件完全不用关心它的内部逻辑。这样后面加“工作日模式”(跳过周末导致列宽不均)时,改动只落在这一个文件里。

2.2 拖拽吸附与最小粒度

拖拽的本质是监听指针移动,把像素增量换算成天数增量,再作用到任务的起止时间上。这里面最影响手感的是吸附粒度。完全不吸附的话,指针稍微一动任务就落在 3 点 17 分这种奇怪的时间上,用户会觉得“这东西不准”;吸附太粗(比如一天)又没法做小时级排程。

我最终采用的方案是:吸附粒度跟着视图走,日视图吸附到 1 小时,周视图吸附到 1 天,月视图吸附到 1 天,季度视图吸附到 1 周。实现上就是一个取整:

const SNAP = { day: 3600000, week: 86400000, month: 86400000, quarter: 604800000 } function snapDate(ts, snapMs, baseTs) { // 以时间轴起点为基准做吸附,避免全局时间戳取整导致整列错位 const offset = ts - baseTs return baseTs + Math.round(offset / snapMs) * snapMs }

这里有个细节,吸附基准要用时间轴的起点,不能用时间戳 0。如果直接用Math.round(ts / snapMs) * snapMs,在你想把任务对齐到每天 09:00 开始时就会完全对不上,因为整天的边界是 00:00。用时间轴起点做基准,就能保证所有任务落在同一套网格上。

拖拽还要处理几个边界:不允许结束时间早于开始时间(拖左侧时如果越过了右边界,就把右边界推着走,而不是让工期变负数);不允许任务移出时间轴可视范围(或者允许移出但要自动扩展时间轴,我选的是自动扩展,用户体验更好);按下 Esc 要能取消当前拖拽回到初始状态。这几个逻辑看似琐碎,但缺一个都会被用户当成 bug 报上来,我第一版就漏了 Esc 取消,直接被测试同学挂了三个 issue。

2.3 依赖关系的绘制与避让

依赖线是甘特图里视觉效果最讨喜、实现最烦的部分。四种依赖类型的连接点不一样:

  • FS(完成-开始):从源任务右端连到目标任务左端,最常见
  • SS(开始-开始):源左端连到目标左端
  • FF(完成-完成):源右端连到目标右端
  • SF(开始-完成):源左端连到目标右端

连线本身用 SVG 画折线就行,难点在避让。当两个任务挨得很近时,一根直线穿过任务条会非常难看,也看不清指向。我的做法是统一走“三段式”路径:源点先水平出来一小段(固定 12 像素),再垂直走到目标所在行,最后水平进入目标点。如果目标点在源点左边(回环依赖),就改成五段式,从下方绕行,留出 16 像素的缓冲带。

function buildPath(from, to, isBackward) { const GAP = 12, BEND = 16 if (!isBackward) { const midX = Math.max(from.x + GAP, to.x - GAP) return `M${from.x},${from.y} H${midX} V${to.y} H${to.x}` } // 反向依赖从下方绕 const y = Math.max(from.y, to.y) + BEND return `M${from.x},${from.y} V${y} H${to.x - GAP} V${to.y}` }

箭头用marker元素定义一次,全局复用,不要每根线单独画三角形,几十根线的时候性能差距很明显。另外依赖线要画在任务条下面一层,z-index 顺序是:网格底色 < 依赖线 < 任务条 < 拖拽代理 < 右键菜单。顺序错了会出现线压住任务文字的情况,属于典型的低级视觉 bug,但排查起来要花时间。

3. Vue 组件落地实战

理论讲完,进入动手环节。这一节从创建项目开始,把组件拆分、状态管理、关键交互代码都过一遍,你照着搭能直接跑起来。我用的技术栈是 vue 3 +<script setup>+ pinia,不用 vuex 的原因后面会说。

3.1 项目初始化与依赖取舍

如果是新项目,建项目这一步没什么好犹豫的:

npm create vite@latest gantt-demo -- --template vue cd gantt-demo npm install npm install dayjs pinia npm run dev

老项目里做 vue 安装及环境配置时有几个点值得提醒。Node 版本建议 18 以上,低版本在构建时可能报crypto.hash is not a function之类的错。如果你是从 vue 2 迁移过来,注意setup语法和 Options API 混用时的this指向问题,甘特图这种交互密集的组件建议整体用 Composition API 重写,别混着写,否则响应式丢失会非常难查。

依赖我有意控制得极少,只有 dayjs 和 pinia。不建议引入 UI 框架的表格或树组件来拼甘特图,它们的 DOM 结构和定位方式是为列表优化的,你很难在它们上面叠加自由定位的任务条,最后就是一堆position: absolute和层级打架。自研的好处就在这里,DOM 结构完全按需要设计,一个滚动容器 + 一个内容层 + 若干行,简单直接。

状态管理这块,pinia 和 vuex 的选择在甘特图场景里答案比较明确。甘特图的状态特点是读多写少但写入频繁(拖拽时每帧都在改),pinia 的原子化 store 配合$patch批量更新能明显减少无意义的重渲染,而且去掉 mutation 之后代码短了很多。如果你项目里已经全是 vuex,也别为了甘特图单独引一套,用 vuex 的 module 一样能做,只是拖拽期间记得把更新合并到一个 action 里,别一个字段一个 commit。

3.2 组件拆分与数据流设计

组件我拆成六块,每块职责单一,方便单独调试:

GanttChart.vue 总容器,负责尺寸测量、滚动同步、事件分发 ├── TimeScaleHeader.vue 时间刻度头 ├── TaskTree.vue 左侧任务列表(可独立滚动,与右侧垂直同步) ├── GridCanvas.vue 网格底色 + 今日线 + 节假日 ├── BarLayer.vue 任务条、里程碑、进度、基线 ├── DepLayer.vue SVG 依赖线 └── ContextMenu.vue 右键菜单

数据流是单向的:原始任务数组从 store 进来,经过一个computed转成带x / y / width / rowIndex的渲染对象,各图层只读这个渲染对象。关键原则是渲染层不持有业务状态,拖拽产生的临时位移放在一个独立的dragState里,松手时再一次性提交回 store。这样做的直接好处是撤销重做特别好做,只要记录每次提交前后的快照就行。

// 渲染对象由原始数据派生,不要在原始数据上挂 x/y const renderTasks = computed(() => { const rows = flattenVisibleTasks(tasks.value) // 处理折叠 return rows.map((task, i) => ({ ...task, rowIndex: i, x: dateToX(task.start, timelineStart.value, pxPerDay.value), w: Math.max(MIN_BAR_W, (task.end - task.start) / 86400000 * pxPerDay.value) })) })

这里flattenVisibleTasks要处理折叠逻辑:父节点收起时,其所有后代都不进入渲染数组,同时父任务的start / end / progress用子节点的聚合值覆盖。聚合要递归,不能只算一层,否则多层级项目里折叠后时间条会显示错误。

3.3 拖拽、缩放与进度的关键实现

拖拽我统一用 Pointer Events,不用 mouse + touch 两套。原因是 Pointer Events 天然支持触屏、笔、鼠标,还有setPointerCapture可以避免鼠标移出元素后事件丢失,这一点在做快速拖拽时体验差别很大。

function onBarPointerDown(e, task, mode /* 'move' | 'start' | 'end' | 'progress' */) { e.preventDefault() const el = e.currentTarget el.setPointerCapture(e.pointerId) dragState.value = { id: task.id, mode, startX: e.clientX, originStart: task.start, originEnd: task.end, originProgress: task.progress } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp, { once: true }) } function onMove(e) { const d = dragState.value if (!d) return const deltaPx = e.clientX - d.startX const deltaMs = (deltaPx / pxPerDay.value) * 86400000 if (d.mode === 'move') { const ns = snapDate(d.originStart + deltaMs, snapMs.value, timelineStart.value) preview.value = { start: ns, end: ns + (d.originEnd - d.originStart) } } else if (d.mode === 'end') { const ne = snapDate(d.originEnd + deltaMs, snapMs.value, timelineStart.value) preview.value = { end: Math.max(ne, d.originStart + snapMs.value) } } // start / progress 同理,省略 }

这段代码有三个值得注意的地方。第一,位移全程基于 originStart 计算,而不是累加每帧增量,这样即使中途吸附导致位置跳变,也不会产生累积误差。第二,预览值单独放preview,渲染层优先读 preview,松手时才写回 store,天然支持 Esc 取消。第三,setPointerCapture之后仍然要监听 window,因为某些浏览器在捕获状态下移出窗口边界时事件行为不一致,双保险更稳。

进度拖拽稍微特殊,它不改时间,只改progress。判断落点在进度点附近(比如 8 像素内)就进入进度模式。另外值得加一个双击任务条快速定位并聚焦改名的能力,实际使用频率非常高,用户改名字的次数远超你的想象。

3.4 导出与 Excel 方案的取舍

很多团队真正的落地流程是:前端甘特图用来拖拽排期,最终交付物还是 Excel。所以导出能力别省。我有两种实现,按需求选:

导出格式实现方式优点局限
CSV拼字符串直接下载极简单,Excel 可打开没有图形,只有日期
PNGcanvas 重绘或截图保留视觉效果大图清晰度和字体要处理
Excel 带条形用条件格式或在单元格填色能做出简易甘特效果需要模板,改模板麻烦

CSV 那条路一分钟就能写完,直接Blob+a.download就行。PNG 那条我建议用 canvas 重绘而不是截 DOM,因为截 DOM 要引额外库,还会受字体、缩放、跨域图片影响。canvas 重绘的代码量不小但可控,核心是把前面算好的x / y / w直接搬到 canvas 的fillRect上,网格和文字用fillText,一套逻辑两套渲染,实际维护成本可以接受。

顺带说一句,如果你只是想要个能看的甘特图给领导汇报,别写代码了,直接用表格软件的条件格式做一版,十分钟搞定,效果也不差。工具选对场景比技术先进重要。

4. 构建部署与样式异常排查

功能跑通只是前半程,真正让很多人翻车的是打包上线那一刻。vue 打包后布局异常是搜索量特别高的一个问题,而甘特图恰好是最容易中招的组件类型,因为它依赖大量绝对定位、固定像素宽度和滚动容器。

4.1 打包后错位、抖动、白屏的排查顺序

我遇到过三次不同原因的布局异常,整理成排查顺序,从最常见往下找:

现象大概率原因排查方法
任务条整体偏移容器宽度测量时机太早用 ResizeObserver 而非 onMounted 一次性读取
文字和条错位字体加载导致的宽度变化等document.fonts.ready后再测量
滚动不同步两侧滚动容器高度不一致检查是否有边框、内边距差 1px
打包后白屏资源路径 base 配置不对检查 vite 的base或 webpack 的 publicPath
生产环境条极窄pxPerDay 被某些样式覆盖检查是否用了 rem 换算或全局缩放

其中最隐蔽的是第一条。开发环境因为热更新和数据量小,onMounted里读到的容器宽度往往是正确的;生产环境首屏渲染快、样式未完全应用,读到的可能是 0 或错误值,于是所有坐标全错。改用ResizeObserver监听容器尺寸变化,并且在宽高为 0 时跳过渲染,这个问题基本就绝迹了。

const ro = new ResizeObserver(([entry]) => { const { width } = entry.contentRect if (width > 0) viewportW.value = width }) onMounted(() => ro.observe(containerRef.value)) onBeforeUnmount(() => ro.disconnect())

顺便提醒,如果项目里用了 CSS 缩放或者移动端适配的transform: scale,getBoundingClientRect拿到的是缩放后的值,而clientX也是缩放后的值,两者能对上,但计算出的 pxPerDay 会失真。稳妥做法是记录一个scaleFactor,在换算时统一除一次。

4.2 路由切换与多标签页的状态保留

甘特图通常是详情页里的一个模块,用户点进别的页面再回来,期望是滚动位置和展开状态还在。这里有两件事要做。

一是滚动位置和折叠状态放进 store 而不是组件内部 state,组件卸载时不会丢。二是路由如果开了keep-alive,注意onActivated时重新测量容器尺寸,因为缓存恢复时容器的实际宽高可能和之前不同,尤其是响应式布局下的侧边栏展开收起。如果不加这一步,用户会遇到“切回来图表挤成一团”的问题。

还有一个容易被忽略的点:如果甘特图挂在带参数的动态路由上(比如/project/:id/schedule),切换 id 时组件不会重新挂载,需要在watch里监听路由参数变化,重新拉数据并清空拖拽临时状态。我见过有人在这里忘了清dragState,结果切换项目后残留的预览位移还挂着,显示出一条错位的任务条,排查了半天才定位到。

4.3 大数据量下的性能表现

任务数超过 300 条以后,不做优化的话拖拽会明显掉帧。我的实测数据是:不优化时 500 条任务拖拽在 25fps 上下,优化后稳定 60fps。优化的手段按性价比排序是三件事。

第一件,虚拟滚动。只渲染可视区域加前后各 10 行缓冲,DOM 数量从 500 降到 40 以内,这一步收益最大。

const startRow = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - BUFFER)) const endRow = computed(() => Math.min(totalRows.value, Math.ceil((scrollTop.value + viewportH.value) / ROW_H) + BUFFER))

注意左右两侧必须用同一个滚动源,左侧任务列表和右侧图表共享一个scrollTop,否则虚拟滚动的行号对不上,会出现左树右图错行。我的做法是右侧容器负责滚动,左侧只做transform: translateY(-scrollTop)跟随。

第二件,用 transform 代替 left/top。拖拽时如果每帧改left,会触发重排;改成transform: translate3d只触发重绘和合成,性能差一个量级。

第三件,把依赖线拆成独立 SVG 层并在拖拽期间隐藏。依赖线数量是任务数的好几倍,是纯视觉元素,拖拽过程中隐藏它们用户根本不会注意,但省下的计算量很可观,松手后再统一重算并显示。

5. 常见问题速查与踩坑心得

前面讲的是主干,这一节把散落的坑集中收拢,方便你遇到问题时直接查表。这些都是我在真实项目里被用户和测试同学一遍遍打磨出来的,文档里基本不会写。

5.1 高频问题速查表

问题根因处理方式
拖拽后任务跳动一格吸附基准用了时间戳 0改用时间轴起点做吸附基准
折叠父任务时间不对聚合只算了一层递归聚合所有后代
依赖线穿过任务文字层级顺序错依赖线层压在任务条层下面
缩放后条宽为 0未设最小宽度设 MIN_BAR_W 并加虚线提示
触屏上拖不动用了 mouse 事件换成 Pointer Events
拖拽松手事件丢失没做指针捕获setPointerCapture 加 window 双监听
中文字体下刻度错位字体加载晚于测量等 fonts.ready 后再测量
任务多时拖拽掉帧DOM 未虚拟化虚拟滚动 + transform
切换项目残留位移未清临时状态监听路由参数并重置 dragState
导出的图模糊未做 DPR 缩放canvas 按 devicePixelRatio 放大再缩回

5.2 几条真金白银换来的经验

经验一,别在拖拽过程中写 store。我第一版是pointermove里直接改 store 里的任务时间,结果每帧都触发整棵树重渲染,500 条任务时卡到没法用。改成拖拽期间只更新本地preview,pointerup时一次提交,帧率立刻正常,而且撤销重做变得特别简单,因为一次拖拽就是一次快照。

经验二,给用户留一个“撤销”。排期这类操作误触成本很高,拖错一天可能导致下游计划全乱。我加了一个简单的历史栈,每次提交前压入快照,Ctrl+Z回退,实现不到五十行,但用户满意度提升非常明显。历史栈别用深拷贝整个列表,只存变更的字段就行。

经验三,时间和进度分开存,别用百分比反推日期。有些实现为了省事,只存开始时间和进度百分比,工期和结束时间是算出来的。这在甘特图里是灾难,因为用户会同时拖结束时间和进度点,两者互相影响,最后数据完全不自洽。正确做法是start、end、progress三个字段独立存储,工期用end - start派生。

经验四,节假日和工作日模式要提前想好。如果你的排期需要跳过周末,那列宽就不再均匀,前面所有“日期乘列宽”的公式都会失效,需要改成预计算每列宽度并做前缀和。这个改动如果做完了主干再回头加,几乎等于重写,所以一开始就要确定要不要工作日模式。我的建议是除非业务明确要求,否则别做,均匀时间轴简单太多。

经验五,给渲染层加一个渲染次数统计。我在开发环境加了个简单的计数器,显示当前帧渲染了多少次 BarLayer,一旦发现数字异常增长,基本就是某处响应式依赖写错了,比如在模板里调用了函数导致每次重渲染都重新计算。这个小工具帮我定位了两次很难查的性能问题。

经验六,测试数据要造得极端一点。我自己造了三组数据:一条跨度三年的任务、前后各一个零工期里程碑夹着一条普通任务、以及两个任务开始时间完全相同。这三组数据几乎覆盖了所有边界情况,每天跑一遍,能挡掉大部分视觉 bug。

6. 后续还可以往哪些方向扩展

基础版本稳定之后,我陆续加了几个扩展,都是基于同一套渲染管线,成本不高但价值不小,列出来给有类似需求的同学参考。

资源泳道与负载视图。把任务按负责人分组后,在同一行内可以顺带画一个每日工时柱状图,直观看到谁哪天超载了。实现上就是多算一个按人按天的工时聚合,然后用一层额外的 canvas 或 div 画柱子。这个功能在排产场景里使用频率很高,因为“排得下”和“排得开”是两回事。

基线对比。保存一版计划作为基线后,在任务条下方用半透明的另一条显示基线的起止范围,两者不一致时用户一眼就能看出偏差。数据上就是在任务对象里多存一个baseline字段,渲染上多画一层矩形,几乎零成本,但汇报场景下非常有用。

与后端接口的协作要点。甘特图的数据量大,建议后端提供“按项目 + 时间范围”拉取的接口,别一次性返回全部任务。更新时用批量提交,把一次会话里的多次拖拽合并成一个PATCH请求,否则拖十次发十个请求,网络和事务都会很难受。时间字段统一用时间戳传输,前后端约定清楚时区基准,这个如果不约定,跨时区团队协作时必然出问题。

打印与 PDF。如果最终要输出纸质排期表,印刷前记得把时间轴调整到能完整放下所有任务的粒度,否则打印出来会被裁掉。打印样式里把滚动容器的高度固定、超出部分隐藏,配合@media print单独写一套,比在线样式直接打印效果好很多。

最后分享一个小技巧:如果你发现拖拽在某些笔记本触控板上不跟手,先别怀疑代码,去看看是不是pointermove触发频率太高导致的。加一个基于requestAnimationFrame的节流,把每帧的处理次数限制为一次,手感会立刻顺滑起来,这个改动只有几行,但效果立竿见影。

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

基于阶梯碳交易与P2G-CCS耦合的虚拟电厂燃气掺氢优化调度

前阵子给一个园区级虚拟电厂做优化调度&#xff0c;白天光伏一上来&#xff0c;晚间风电又满发&#xff0c;偏偏深夜负荷往下掉&#xff0c;燃气轮机只能压到最低技术出力甚至停机。起初只看电功率平衡&#xff0c;问题勉强靠弃风解决&#xff0c;可一旦把碳成本、P2G-CCS耦合和…

作者头像 李华
网站建设 2026/10/1 18:55:17

剪映+DeepSeek+即梦:短视频剪辑点选择实战指南

选题其实不用太大&#xff0c;但很多人恰恰就卡在最不起眼的环节上——手上有十几条素材&#xff0c;导入剪映之后就不知道该从哪下刀&#xff0c;一段一段接上去&#xff0c;成品看起来却像“素材堆砌”而不是“一条片子”。这篇我聊的就是《剪映DeepSeek即梦&#xff1a;短视…

作者头像 李华
网站建设 2026/10/1 18:54:58

MobileViG实战:轻量级视觉Transformer在边缘设备的部署与优化

简介&#xff1a;本资源是一份面向深度学习初学者与移动端AI开发者的技术实战包&#xff0c;聚焦轻量级视觉模型MobileViG在图像分类任务中的端到端实现。资源涵盖从环境配置、模型构建、训练调优到TensorFlow Lite移动端部署的完整流程&#xff0c;特别适配算力受限的嵌入式与…

作者头像 李华
网站建设 2026/10/1 18:54:45

基于主从博弈的产消者竞价策略:IEEE33节点复现与KKT转化详解

最近刚把一个EI论文里的"基于主从博弈的新型城镇配电系统产消者竞价策略"在IEEE33节点系统上完整复现了一遍&#xff0c;Matlab代码从双层模型搭建到KKT条件转化&#xff0c;再到CPLEX求解和结果验证&#xff0c;前前后后折腾了不少时间。这个方向确实是当下的热点—…

作者头像 李华
网站建设 2026/10/1 18:54:45

FastAPI爬虫服务化实战:从接口设计到Docker部署

1. 为什么是FastAPI&#xff1a;爬虫工程师做接口时的真实痛点先聊个我自己的经历。之前接了个需求&#xff0c;要把某个公开站点上的数据定时抓下来&#xff0c;整理成标准格式给下游系统调用。一开始的方案非常朴素&#xff1a;爬虫跑完往CSV里写&#xff0c;下游自己去读文件…

作者头像 李华
网站建设 2026/10/1 18:54:45

Windows粘滞键后门原理与防御:从sethc.exe到SYSTEM权限

1. 这不是“黑客教程”&#xff0c;而是一次Windows安全机制的深度解剖 你搜“Windows粘滞键后门”时&#xff0c;大概率正被某篇标题耸动、内容空洞的“一键提权”文章吸引——它可能用加粗字体写着“三步绕过登录密码”&#xff0c;配一张黑底白字的cmd窗口截图&#xff0c;最…

作者头像 李华