☰
原生小程序 Canvas 实现可拖拽任务进度甘特图
2026/11/5 4:34:21 网站建设 项目流程

简介:这份「任务进度甘特图电脑端小程序」面向需要规划个人任务与协同项目进度的职场人士,以条形甘特图直观呈现任务的起止时间、持续时长与完成状态,帮助缓解多任务并行时的排期混乱。压缩包共3个文件,约16.16MB,包含Python脚本、JSON配置数据与TTC中文字体,分别承担甘特图生成逻辑、任务数据组织和界面文字显示,结构轻量,便于在电脑端运行与二次调整。目前已有231人学习下载。程序支持拖拽调整任务起止时间并同步更新图表,适合识别任务冲突、优先处理影响整体进度的关键节点;共享甘特图可为团队提供统一进度视图。JSON配置便于维护任务清单,Python脚本保留扩展空间,字体文件保障中文标注清晰,整体侧重小巧易上手,可作为项目排期、里程碑跟踪和进度提醒的辅助工具。

1. 排期一改就乱套:一个用 Canvas 画甘特图的原生小程序解决了什么

排期表摊在表格软件里还能看,一旦当着评审会的面改一句话——“这个任务能不能提前两天”——三行公式改完,整张表的色块全乱了,依赖箭头指向了错误的位置。这个项目解决的就是这件事:一个用原生小程序写成的任务进度甘特图,时间轴、任务条、进度、依赖关系全部由 Canvas 绘制,在电脑端打开就是一块横向铺开的宽屏画布,改期靠拖拽,不用再跟单元格较劲。

它适合手上有十几到上百条任务、需要按天对齐排期、又不想为一张图引入整套前端工程的人:项目助理、产品经理、小团队的技术负责人。整个东西没有后端依赖,数据落在本地,打开即用,这也是它能在电脑端跑得轻的原因——没有首屏拉接口的等待,也没有登录态要处理。

2. 技术选型与工程结构:为什么放弃图表库、改用原生 Canvas 自己画

2.1 现成图表库在小程序里的三个硬伤

第一反应肯定是找现成的图表库,毕竟柱状图折线图都有人封装好了。真上手才发现甘特图不在标准图表类型里:它的横轴是时间、纵轴是离散的任务行,每个图形是一条有起止点的矩形,还要带进度覆盖和依赖连线。主流图表库要画这个,得走自定义系列(custom series)那条路,等于你仍然要自己算坐标、自己画矩形,只是额外多背了一份库的坐标系和事件体系。

第二个问题是体积。小程序主包有硬上限,一个全量图表库塞进来,还没写业务逻辑,包就先报警了。而甘特图真正需要的绘制原语就那么几个:矩形、直线、文字、裁剪区域,Canvas 2D 原生全有。

第三个,也是最要命的,是交互。图表库的定位是“展示”,它的触摸事件是为 tooltip 和点选服务的。而甘特图的核心交互是按住一条任务条横向拖动改期,这个动作要自己接管 touchstart / touchmove / touchend,还要跟库内部的缩放、点击高亮抢事件。两层事件打架,调试起来是纯玄学。我现在遇到“交互复杂度高于展示复杂度”的图表,一律自己画,血泪经验。

所以最终的技术栈很朴素:原生小程序框架(WXML / WXSS / JS),绘制层用 Canvas 2D 接口(canvas type="2d"),没有任何第三方绘图依赖。

2.2 四层分工的目录结构与组件接口

结构上按“页面管数据、组件管绘制、工具管换算”切,不搞大杂烩。

├── app.js ├── app.json ├── pages/ │ ├── index/ 任务清单入口,增删任务 │ └── gantt/ 甘特图主页面,持有全部任务数据 ├── components/ │ ├── gantt-chart/ Canvas 绘制组件(核心,纯展示 + 抛事件) │ ├── task-editor/ 任务新增/编辑弹层 │ └── time-ruler/ 顶部日期刻度条 ├── utils/ │ ├── date.js 日期与天序号的互转,全部按本地时区 │ ├── scale.js 时间轴换算:日期 <-> 像素 │ ├── layout.js 依赖层级与行号计算 │ └── storage.js 本地读写封装 └── models/ └── task.js 任务字段定义与默认值

关键约定是:gantt-chart组件不碰数据源,它只认properties里的任务数组,画完就完了;用户拖动任务条,它只triggerEvent抛出去,改不改、怎么存,是页面的事。这样组件可以单独抽出来放到任何页面里复用。

组件对外的接口就五个参数,不要贪多:

// components/gantt-chart/gantt-chart.js Component({ properties: { tasks: { type: Array, value: [] }, // 任务列表,字段见 models/task.js rangeStart: { type: String, value: '' }, // 视窗起始日 YYYY-MM-DD rangeEnd: { type: String, value: '' }, // 视窗结束日 YYYY-MM-DD dayWidth: { type: Number, value: 28 }, // 一天占多少 CSS 像素,缩放就改它 rowHeight: { type: Number, value: 36 } // 单行高度(任务条 + 上下留白) }, methods: { // 对外暴露:父页面 setData 之后主动要求重绘 refresh() { this.draw(); } } });

dayWidth是整套渲染里最重要的一个数。时间轴、网格间距、任务条长度、拖拽时“一天等于多少像素”的换算,全部由它派生。缩放功能本质上就是把dayWidth从一个档位切到另一个档位,然后整图重绘一次,不需要改任何其他逻辑。rowHeight决定纵向密度,任务多的时候把它压到 28 甚至 24,一屏能多塞七八行。

2.3 电脑端小程序的运行形态与尺寸约束

在电脑端跑,跟手机上有几处实打实的差别,不说清楚后面全是坑。

一是窗口宽度。手机上windowWidth大概 375 到 430,电脑端可以拉到 1000 以上,甘特图恰好是越宽越好用。但rpx的换算基准是“750rpx 等于窗口宽度”,意味着同一个20rpx在手机上约 10px,在电脑端可能变成 25px 以上。所以绘制相关的尺寸我一律用px算,只在页面布局(padding、按钮大小)上用rpx。混着用是模糊和错位的源头。

二是输入设备。电脑端虽然有鼠标,但小程序的事件模型仍以 touch 事件暴露给业务代码,e.touches[0]里的x、y就是相对页面的坐标。滚轮缩放别指望,稳妥做法是顶部放一对“+ / −”按钮做缩放,双指手势作为移动端的补充。

三是尺寸获取。别去主动设置窗口大小,那能力不是所有版本都稳。正确姿势是渲染前用wx.getWindowInfo()(老版本回落到wx.getSystemInfoSync())拿一次windowWidth/pixelRatio,窗口变化时重新取、重新算 canvas 尺寸。

3. 数据模型与时间刻度:任务条的位置是怎么一步步算出来的

3.1 一个任务对象的最小字段集

数据结构定错了,后面全是补丁。这个项目里一个任务就这么多字段,够用且不多:

字段类型说明
idString唯一标识,拖拽和依赖都用它,不用数组下标
nameString任务名,画在任务条上
startString开始日,固定YYYY-MM-DD,不带时间
endString结束日,含当天(画的时候要 +1 天)
progressNumber0 到 1 的小数,不是百分数
depsArray前置任务 id 列表,画依赖连线和算层级都靠它
colorString可选,不填走默认色
collapsedBoolean分组折叠状态

两个约定值得单独强调。第一,start/end只到“天”,不存时分秒。甘特图的粒度就是天,一旦混进时间,拖拽吸附、跨天判断、进度百分比全要重算。第二,end是含当天的,所以一条 3 月 3 日到 3 月 7 日的任务实际占 5 个格子,代码里算宽度必须是diffDays(start, end) + 1。这个+1我漏过一次,导致所有单日任务宽度为 0,画出来一条线都看不见,排查了半小时。

// models/task.js —— 新增任务时统一走这里,避免各处字段不一致 const DEFAULT_COLOR = '#4c8dff'; function createTask(patch = {}) { const today = formatDate(Date.now()); return { id: 'T-' + Date.now().toString(36), // 时间戳转 36 进制,够短且不重复 name: '未命名任务', start: today, end: today, progress: 0, deps: [], color: DEFAULT_COLOR, collapsed: false, ...patch }; }

id用时间戳的 36 进制而不是自增序号,是因为要支持导入外部数据——自增序号一导入就撞车,撞了之后依赖关系全乱。用时间戳虽然理论上也可能重复(同一毫秒创建两条),实际业务里几乎不会发生,真要做严谨点就在后面再拼两位随机数。

3.2 日期换像素:把所有时区问题掐死在工具层

这一层是整个项目的地基,写完就不再改。核心思路是:永远不要用new Date('2025-03-03')这种字符串构造。按规范,纯日期字符串会被当成 UTC 零点解析,在东八区就会变成前一天早上 8 点,画出来整条图偏移一天,而且只在部分环境复现,非常像玄学。

// utils/date.js const MS_PER_DAY = 86400000; // 解析 'YYYY-MM-DD' 为本地零点的 Date,绕开 UTC 解析 function parseDay(str) { const [y, m, d] = String(str).split('-').map(Number); return new Date(y, m - 1, d); // 注意是 (年, 月-1, 日),不是字符串 } // 相差天数,四舍五入兜住夏令时导致的 23/25 小时 function diffDays(from, to) { const a = parseDay(from).getTime(); const b = parseDay(to).getTime(); return Math.round((b - a) / MS_PER_DAY); } // 日期平移 n 天,用 setDate 而不是加毫秒 function shiftDate(str, n) { const d = parseDay(str); d.setDate(d.getDate() + n); return formatDate(d.getTime()); } function formatDate(ts) { const d = new Date(ts); const p = n => (n < 10 ? '0' + n : String(n)); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; }

shiftDate里用setDate(getDate() + n)而不是ts + n * MS_PER_DAY,是因为后者在有时区夏令时的地区会漂移一小时,累积几天之后日期就错位了。虽然国内不用夏令时,但这份代码大概率会被复制到别的场景里,一开始就写对省事。

有了这三个函数,像素换算就是纯算术:

// utils/scale.js const { parseDay, diffDays } = require('./date'); // 日期 -> 画布 x 坐标。offsetX 是横向滚动的偏移量 function dayToX(dateStr, rangeStart, dayWidth, offsetX = 0) { return diffDays(rangeStart, dateStr) * dayWidth - offsetX; } // 画布 x -> 日期。拖拽结束时用它算新日期 function xToDate(x, rangeStart, dayWidth, offsetX = 0) { const dayIndex = Math.round((x + offsetX) / dayWidth); // round 就是天然吸附 const base = parseDay(rangeStart); base.setDate(base.getDate() + dayIndex); return formatDate(base.getTime()); }

xToDate里那个Math.round是免费的吸附功能——鼠标停在两天之间,它会自动选更近的那一天,不需要额外写“判断距离取最近”的逻辑。反过来如果产品要求半天精度,把round改成floor(x / (dayWidth / 2)) / 2之类的公式即可,改一行。

3.3 依赖层级与行号排布

行号(第几行画)直接由任务在数组里的位置决定,简单直接,因为甘特图的纵向顺序本身是用户手工排的,不该被算法重排。但依赖关系要单独算,用来画连线,也用来给任务条加一点缩进表示层级。

// utils/layout.js function attachLevel(tasks) { const byId = new Map(tasks.map(t => [t.id, t])); const cache = new Map(); // 顺着 deps 往上找层级,带环检测 function levelOf(task, stack) { if (cache.has(task.id)) return cache.get(task.id); if (stack.has(task.id)) return 0; // 有环就降级为 0 层,不死循环 stack.add(task.id); const parents = (task.deps || []).map(id => byId.get(id)).filter(Boolean); const lv = parents.length ? Math.max(...parents.map(p => levelOf(p, stack))) + 1 : 0; stack.delete(task.id); cache.set(task.id, lv); return lv; } return tasks.map(t => ({ ...t, level: levelOf(t, new Set()) })); }

这段的价值全在防环。用户手工填依赖,A 依赖 B、B 又依赖 C、C 依赖 A 这种事一定会发生,没有stack判断就是栈溢出,小程序直接白屏。检测到环之后降级到 0 层而不是抛错,是因为画图这种事“画得丑”远好过“整个崩掉”,用户还能自己把错误依赖删掉。

3.4 本地存储与批量导入

数据落地就两层:页面data.tasks是唯一真相,每次变更后异步写一次本地缓存。写入做节流,拖拽过程中不写,只在touchend之后写一次,否则一次拖动能触发几十次 IO。

// utils/storage.js const KEY = 'gantt_tasks_v1'; let timer = null; // 节流写:拖拽结束时调用,300ms 内的多次调用合并成一次 function saveTasks(tasks) { clearTimeout(timer); timer = setTimeout(() => { wx.setStorage({ key: KEY, data: tasks }); }, 300); } function loadTasks() { try { return wx.getStorageSync(KEY) || []; } catch (e) { return []; // 缓存被清或者格式坏掉,返回空数组,不要 throw 打断渲染 } }

导入这块我踩过坑:直接粘贴一段 JSON 进来,字段名对不上就整批废掉。现在导入前统一过一遍createTask()做字段兜底,缺progress补 0、缺color补默认色,start比end晚的自动交换。宁可静默修正,也不要弹一堆校验错误吓退用户。

4. 渲染与交互链路:从 onReady 到拖拽落地的完整实现

4.1 Canvas 2D 初始化与高清屏适配

Canvas 2D 的初始化只有一次机会,写错了后面全是空白和模糊。要点是:canvas的width/height属性必须是 CSS 尺寸乘pixelRatio,而所有绘制坐标仍按 CSS 像素算,靠一次ctx.scale(dpr, dpr)把两套坐标系统一。

// components/gantt-chart/gantt-chart.js initCanvas() { this.createSelectorQuery() .select('#gantt-canvas') .fields({ node: true, size: true }) .exec(res => { const info = res && res[0]; if (!info || !info.node || !info.width || !info.height) { // 尺寸还没算出来,100ms 后重试一次,最多三次 this.retry = (this.retry || 0) + 1; if (this.retry <= 3) setTimeout(() => this.initCanvas(), 100); return; } const win = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const dpr = win.pixelRatio || 2; const canvas = info.node; canvas.width = info.width * dpr; canvas.height = info.height * dpr; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); // 之后一律按 CSS 像素画 this.canvasNode = canvas; // 导出图片时要用 this.ctx = ctx; this.viewport = { width: info.width, height: info.height }; this.draw(); }); }

this.canvasNode一定要存下来。后面调wx.canvasToTempFilePath导出图片时,Canvas 2D 模式必须传canvas节点而不是canvasId,很多人卡在这一步。

重试那段看着多余,但我确实在电脑端遇到过首次width为 0——父容器还在 flex 布局计算中,onReady时尺寸尚未定下来。

4.2 分层绘制:网格、任务条、进度、文字四层顺序

绘制顺序是固定的,因为后画的会盖住先画的,顺序错了就是一团糊。

draw() { const { ctx, viewport } = this; if (!ctx) return; const { tasks, rangeStart, rangeEnd, dayWidth, rowHeight } = this.properties; const offsetX = this.data.offsetX || 0; const totalDays = diffDays(rangeStart, rangeEnd) + 1; // 拖拽中的任务用草稿覆盖,避免每个 touchmove 都 setData const merged = tasks.map(t => this.draft && this.draft[t.id] ? { ...t, ...this.draft[t.id] } : t ); ctx.clearRect(0, 0, viewport.width, viewport.height); // 第 1 层:网格竖线。天太密就按周画,不然 1px 线糊成灰块 const step = dayWidth < 12 ? 7 : 1; ctx.strokeStyle = '#eceff3'; ctx.lineWidth = 1; for (let i = 0; i <= totalDays; i += step) { const x = Math.round(i * dayWidth - offsetX) + 0.5; // +0.5 让线落在像素中心 if (x < 0 || x > viewport.width) continue; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, viewport.height); ctx.stroke(); } // 第 2 层:任务条 + 进度 + 文字 merged.forEach((task, row) => { const x = dayToX(task.start, rangeStart, dayWidth, offsetX); const w = Math.max((diffDays(task.start, task.end) + 1) * dayWidth, dayWidth * 0.6); const y = row * rowHeight + 8; const h = rowHeight - 16; if (x + w < 0 || x > viewport.width) return; // 完全在视野外,跳过 ctx.fillStyle = task.color || '#4c8dff'; ctx.fillRect(x, y, w, h); if (task.progress > 0) { ctx.fillStyle = 'rgba(0,0,0,0.18)'; ctx.fillRect(x, y, w * Math.min(task.progress, 1), h); } if (w > 40) { ctx.fillStyle = '#fff'; ctx.font = '12px sans-serif'; ctx.textBaseline = 'middle'; ctx.fillText(task.name, x + 6, y + h / 2, w - 12); // 末参是最大宽度,自动截断 } }); }

几个细节值得说。Math.round(i * dayWidth) + 0.5里的+0.5是 Canvas 画 1px 线的经典处理——线宽 1 的线如果落在整数坐标上,会跨两个像素各画一半,看起来是灰色虚影;对齐到 0.5 才是实线。

step那个判断是防糊的:当dayWidth小于 12 像素(缩到最小时),每天画一条线,整片区域会变成一块均匀的灰色,什么信息都看不出来,所以直接降到按周画。

fillText的第四个参数是最大宽度,超出会自动压缩字距,配合w > 40的判断,宽度不够的任务条干脆不画字,比挤成一坨要好看。

4.3 命中检测:点中的到底是哪一条任务

拖拽的第一步是判断手指落在哪条任务条上,以及在条的哪个部位。先锁行再锁横向区间,两步就能定位,不用遍历所有任务做矩形判断。

hitTest(x, y) { const { tasks, rangeStart, dayWidth, rowHeight } = this.properties; const offsetX = this.data.offsetX || 0; const row = Math.floor(y / rowHeight); const task = tasks[row]; if (!task) return null; const barX = dayToX(task.start, rangeStart, dayWidth, offsetX); const barW = (diffDays(task.start, task.end) + 1) * dayWidth; const EDGE = 8; // 左右各 8px 算拉伸区 if (x < barX - EDGE || x > barX + barW + EDGE) return null; if (x - barX <= EDGE) return { task, mode: 'resize-start' }; if (barX + barW - x <= EDGE) return { task, mode: 'resize-end' }; return { task, mode: 'move' }; }

EDGE取 8 是个经验值。太小了在电脑端用鼠标点不准,太大了短任务条整个都是拉伸区、没法平移。任务条宽度小于EDGE * 3的时候,建议直接把左右拉伸区关掉,只留平移,否则用户想挪动一条一天的任务,怎么点都是在拉伸。

4.4 拖拽吸附与缩放档位

拖拽的核心技巧是草稿覆盖:整个拖动过程不动properties,不改data.tasks,只在组件内部维护一个this.draft,绘制时合并上去。松手才triggerEvent提交。这样拖 100 像素只重绘一次,setData只调用一次,不会卡。

onTouchStart(e) { const t = e.touches[0]; const hit = this.hitTest(t.x, t.y); if (!hit) return; this.drag = { id: hit.task.id, mode: hit.mode, startX: t.x, task: hit.task, moved: false }; }, onTouchMove(e) { if (!this.drag) return; const t = e.touches[0]; const dx = t.x - this.drag.startX; if (!this.drag.moved && Math.abs(dx) < 6) return; // 6px 死区,点一下不算拖 this.drag.moved = true; const { dayWidth } = this.properties; const days = Math.round(dx / dayWidth); // 吸附到整天 const { mode, task } = this.drag; const next = { start: task.start, end: task.end }; if (mode === 'move') { next.start = shiftDate(task.start, days); next.end = shiftDate(task.end, days); } else if (mode === 'resize-start') { next.start = shiftDate(task.start, days); } else { next.end = shiftDate(task.end, days); } if (diffDays(next.start, next.end) < 0) return; // 起止不能反,反了直接忽略 this.draft = { [task.id]: next }; this.draw(); // 只重绘,不 setData }, onTouchEnd() { if (!this.drag) return; const changed = this.draft && this.draft[this.drag.id]; this.draft = null; if (changed && this.drag.moved) { this.triggerEvent('taskchange', { id: this.drag.id, ...changed }); } this.draw(); }

6px 死区是必须的。没有它,用户想滚动页面、手指稍微蹭了一下,任务就被挪走一天,这种“我什么都没干它就变了”的体验最劝退。

缩放就是切dayWidth的档位,我用[12, 18, 28, 40, 56]五档,点“+”往右走一格,点“−”往左走一格。切换时要做一件容易被忽略的事:以视口中心为锚点,把offsetX重新算一遍,否则一放大,用户正在看的那一段就跑到屏幕外面去了。

zoom(dir) { const STEPS = [12, 18, 28, 40, 56]; const cur = STEPS.indexOf(this.properties.dayWidth); const next = STEPS[Math.min(Math.max(cur + dir, 0), STEPS.length - 1)]; if (next === this.properties.dayWidth) return; const centerX = this.viewport.width / 2; const centerDay = (centerX + (this.data.offsetX || 0)) / this.properties.dayWidth; const offsetX = Math.max(centerDay * next - centerX, 0); this.setData({ offsetX }); this.triggerEvent('zoomchange', { dayWidth: next }); // 由页面改 properties }

5. 避坑与排查:电脑端打开、时区、手势冲突的 5 个真实翻车点

5.1 首屏一片空白,没有任何报错

现象:手机端正常,电脑端第一次打开时画布区域全白,手动拖一下窗口大小又出来了。

原因:电脑端窗口大,父容器的 flex 布局算得慢,ready阶段查询到的节点width为 0,canvas.width = 0 * dpr还是 0,后面所有绘制都画在一个零尺寸画布上,ctx不报错,只是什么都看不见。

解决:初始化时校验width和height都不为 0,否则延时重试,最多三次。同时在页面onResize里重新走一遍初始化,把canvas.width重设一次。

5.2 电脑端整张图糊成一片马赛克

现象:手机上文字清晰,电脑端任务条边缘发虚,任务名看着像加了高斯模糊。

原因:canvas 的 CSS 尺寸用了rpx,而canvas.width用size.width * dpr算。电脑端rpx换算比例和手机完全不同,CSS 尺寸和实际位图尺寸对不上,浏览器就做了一次非整数缩放。

解决:给 canvas 的 CSS 尺寸写成width: 100%; height: 100%,实际像素从fields({ size: true })里拿,全部用 px 参与运算。绘制坐标系里一个rpx都不要出现。

5.3 所有任务条整体偏移一天

现象:数据里明明写的是 3 月 3 日开始,画出来从 3 月 2 日的位置起。

原因:某处用了new Date('2025-03-03')或者new Date(task.start)。纯日期字符串按 UTC 解析,东八区减 8 小时就退到前一天。这个 bug 还特别会藏——在部分环境上恰好不出问题,很容易被当成玄学。

解决:全项目搜一遍new Date(加字符串的组合,统统换成parseDay()拆年月日再构造。工具层只留date.js一个入口,谁都不许绕过去自己写。

5.4 拖任务条时整页跟着上下滚

现象:横向拖任务,页面却纵向滑动,拖到一半图跑了。

原因:touchmove用了bind而不是catch,事件冒泡到页面,滚动容器接手。

解决:canvas 节点上写成catchtouchmove="onTouchMove",把冒泡掐断。同时在拖动开始时把外层scroll-view的scroll-y置为false,touchend再恢复——只靠catch在部分机型上仍然拦不住惯性滚动。

5.5 任务过百就开始掉帧

现象:20 条任务很顺,加到 150 条,拖动时明显卡顿,手指离开后画面还在追。

原因:每一帧clearRect全量重绘,每个任务都重新fillText,文字排版是最贵的;再加上视野外的任务也在画,纯属浪费。

解决:一是x + w < 0 || x > viewport.width的视野裁剪,这是收益最大的一行;二是只重绘变化的那一行,把画布按行切成独立区域,用ctx.save / clip / restore圈定范围(任务量大且行高固定时很划算);三是拖动结束后 200ms 内的重绘合并成一次,用requestAnimationFrame在 Canvas 2D 的 node 上调度。

6. 二次开发:换数据源、加基线对比、导出图片的三条改造路径

拿到这个项目,最常见的第一个需求是“接我们自己的后端”。改造成本很低,因为数据入口只有storage.js一个文件——把loadTasks/saveTasks换成wx.request或云开发调用就行,只要保证返回的数组结构跟models/task.js对齐,画出事就是不会有的。记得在页面onLoad里加一个 loading 态,接口慢的时候画布是空的,用户会以为崩了。

第二个常见需求是加“基线对比条”——计划是一版,实际是另一版,两条上下并排。这个不用改数据结构,在任务上加baselineStart/baselineEnd两个可选字段,然后在draw()的任务循环里,先画基线(半透明灰)、再画实际条,实际条高度收窄到h * 0.6,纵向居中对齐即可。视觉上很直观,改造成本不到二十行。

第三个是导出图片,方便贴到周报里给不装小程序的人看:

// 把当前视口导出成二倍图,Canvas 2D 必须传 node 而不是 canvasId exportImage() { this.draw(); wx.canvasToTempFilePath({ canvas: this.canvasNode, x: 0, y: 0, width: this.viewport.width, height: this.viewport.height, destWidth: this.viewport.width * 2, // 二倍图,贴进文档不糊 destHeight: this.viewport.height * 2, success: res => { wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath }); }, fail: err => { // 电脑端保存到相册可能不可用,回落到预览让用户自己另存 wx.previewImage({ urls: [] }); console.error('导出失败', err); } }, this); }

这里有个适配问题:电脑端不一定有“相册”这个概念,saveImageToPhotosAlbum可能直接失败。我现在一律先弹一个预览层让用户自己右键另存,移动端才走保存相册,两条路都留着。

验证改造是否正确,我有一个固定动作:造一份 100 条任务的随机数据,起止日期跨越半年,依赖关系随机连几条,跑一遍全流程,然后核对三件事——拖拽后storage里的日期和画面上的是否一致、缩放三次之后视口中心的任务有没有跑偏、导出的图片比例是否正确。这三条过了,基本就不会有回归。

从那以后我每次改渲染逻辑,都强制先用这份随机数据跑一遍再上真机。甘特图这种“坐标算错一位、整张图就歪一片”的东西,肉眼看是不靠谱的,后悔药没有,只能靠数据先兜住。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询