2026/10/26 4:03:36

基于QGraphicsView的Qt甘特图:排产任务拖拽与时间轴换算

基于QGraphicsView的Qt甘特图:排产任务拖拽与时间轴换算 简介围绕 Qt 甘特图实现的一份可运行工程源码面向具备一定 Qt/C 基础、需要在桌面端项目管理或任务调度界面中嵌入时间轴图表的开发者。工程主体便于研究任务条与时间轴的映射、QGraphicsView/QGraphicsItem 图形组织、MVC 数据绑定、鼠标交互与自定义 paint 绘制也可作为二次开发起点扩展依赖关系、里程碑和滚动布局。压缩包共 27 个文件约 1.69MB以 5 个 cpp 与 5 个 h 源码为核心辅以 ui 界面文件、ini 配置、exe 可执行样例及 sln/vcxproj 等 Visual Studio 工程文件兼顾阅读、编译与对照运行。已有 861 人学习适合希望跳出标准控件、参考一套独立甘特图组件实现思路的开发者查阅与集成。1. 从排产需求说起为什么还要再写一版 qt 甘特图排产模块上线前一周产品提了个需求任务条能拖着改工期改完左边任务树和右边时间轴同时跟着动。我第一反应是找个现成的甘特图库试了两个都不太合适——一个把排产规则写死在控件里另一个要求按它的模板重排整套数据结构。最后还是用 QGraphicsView 自己搭了一套也就是这份 qt 甘特图另一个版本左侧 QTreeView 放任务树右侧 QGraphicsView 画时间刻度和任务条中间一层 GanttModel 做桥。它适合手上有排产、项目进度、施工计划这类需求又不想被第三方控件绑死的开发者。整套代码量不大但时间轴换算、拖拽提交、依赖箭头这几块该有的坑都踩过一遍拿来当二次开发的底子比从零开始省事。2. 整体架构三层拆分与目录结构怎么落地先说我为什么没走纯 QWidget 自绘。QWidget 派生一个甘特区重写 paintEvent 确实能画但命中测试要自己算矩形滚动要自己接 QScrollBar局部刷新要自己算脏区一旦加上“拖边缘改工期”“悬停高亮”“依赖箭头跟着任务条走”这些交互代码会迅速膨胀成一坨 if-else。QGraphicsView 的好处是场景坐标、item 命中、视口裁剪都是现成的你只需要关心“每个任务条在场景里的哪个位置、画成什么样”。代价也很明确item 数量上去以后渲染和布局会变成性能瓶颈这一点后面避坑章会专门讲。常见做法是任务条超过两千条时只给可见区的行创建 item滚出视野的直接 removeItem用一个行号到 item 的缓存池复用。2.1 四层结构数据、模型、图元、视图我把整个控件拆成四层层与层之间只靠信号和常量交互不允许图元直接去读数据文件。层主要类职责数据层GanttTask单条任务的纯数据结构可拷贝、可序列化模型层GanttModel建树、增删改、行号重排、数据变更信号图元层GanttBarItem/GanttLinkItem/GanttHeaderItem在场景坐标里画任务条、箭头、刻度尺视图层GanttView/GanttScene/GanttTreeView坐标换算、缩放、滚动同步、布局重排目录结构按这四层切开好处是新人接手时能一眼看出改哪块gantt/ ├── core/ │ ├── gantttask.h 任务数据单元含依赖 id 列表 │ ├── ganttmodel.h/.cpp 任务树 增删改 变更信号 │ └── ganttdependency.h 依赖关系的轻量描述 ├── items/ │ ├── ganttbaritem.h/.cpp 任务条含拖动与改期交互 │ ├── ganttmilestoneitem.h/.cpp 里程碑菱形 │ ├── ganttlinkitem.h/.cpp 依赖箭头正交折线 │ └── ganttheaderitem.h/.cpp 三级时间刻度尺 ├── view/ │ ├── ganttview.h/.cpp QGraphicsView 派生坐标换算中心 │ ├── ganttscene.h/.cpp 布局重排行号分配 │ └── gantttreeview.h/.cpp 左侧任务树与甘特区滚动同步 └── ganttwidget.h/.cpp 组合控件对外唯一入口对外只暴露GanttWidget一个类构造完塞进GanttModel就能用。内部怎么换图元、怎么改布局调用方不用关心。这一点在多人协作里很关键我见过把GanttView直接暴露出去的项目半年后没人敢动里面的坐标逻辑。2.2 任务数据模型字段怎么定才够用GanttTask是整个项目的底座字段定错了后面全是补丁。下面是我实际用的版本// core/gantttask.h class GanttTask { public: // 任务形态普通条 / 里程碑菱形 / 汇总条父任务滚动计算得到 enum Type { Task, Milestone, Summary }; QString id; // 全局唯一依赖关系靠它关联不要用行号 QString parentId; // 空串表示根级任务 QString name; QDateTime start; // 统一存本地时间跨时区在入口处转换 QDateTime end; int progress 0; // 0~100绘制进度填充用 Type type Task; QColor color; // 空色时按 type 走默认配色 QStringList dependsOn; // 前置任务 id 列表 int row 0; // 布局时写入决定 Y 坐标 bool collapsed false; // 父任务折叠后子任务不分配行号 };几个字段的选择理由值得说一下。id用字符串而不是自增整数是因为数据源往往来自数据库或接口自增 id 在本地新建任务时会产生冲突字符串 id 可以由上层生成 UUID导入导出也不会串位。dependsOn存 id 列表而不是指针避免序列化和删除任务时的悬空指针。row不进数据源它只是布局过程的中间产物每次rebuildLayout都会重算所以不需要持久化。milestone的 start 和 end 通常相等渲染时走菱形分支Summary的起止时间由子任务聚合而来用户不能直接拖这个约束在mousePressEvent里判断。2.3 模型层信号设计决定了刷新效率模型层最重要的不是增删改接口而是信号的粒度。如果只发一个modelReset那每改一条任务工期整个甘特区就要全量重排几百条任务就会明显掉帧。我的做法是拆成三个信号class GanttModel : public QObject { Q_OBJECT public: // 一次性灌入数据内部按 parentId 建树并重算 row void setTasks(const QVectorGanttTask tasks); // 拖拽结束后唯一允许改时间的入口 void updateTaskRange(const QString id, const QDateTime newStart, const QDateTime newEnd); QVectorGanttTask * children(const QString parentId) const; GanttTask *task(const QString id) const; signals: void taskChanged(const QString id); // 单条变更局部重绘 void layoutInvalidated(); // 增删/折叠需要全量重排 void dependencyChanged(const QString id);// 依赖变化重算箭头路径 };updateTaskRange里先改数据再判断新旧时间是否跨行、是否影响父任务的汇总时间最后决定发taskChanged还是layoutInvalidated。这个判断看起来琐碎但它让“拖动一条任务”的平均耗时从几十毫秒降到一两毫秒。参数说明id必须存在找不到时直接 return 并打一条警告不要抛异常——拖动过程中中断会让界面卡在半更新状态。3. 时间轴映射从 QDateTime 到像素坐标的换算链路甘特图所有玄学都集中在这一层。左边树上的行是离散的右边时间轴是连续的两者对齐的唯一依据就是“某个时间点对应场景里的哪个 x 坐标”。这个换算函数写错一个符号表现就是任务条整体偏移半天、跨月时错位、缩放后文字压线。3.1 比例系数 pxPerMs缩放的本质我不推荐用QGraphicsView::scale()来做甘特图的缩放。原因很实在view 缩放会把字体、线宽、圆角一起放大缩到 150% 时任务条上的任务名糊成一团1px 的边框变成 1.5px 显示成灰度。正确做法是保持 view 的变换矩阵为单位阵改“一天占多少像素”这个比例。// view/ganttview.h class GanttView : public QGraphicsView { public: qreal pxPerDay() const { return m_pxPerDay; } void setPxPerDay(qreal v); qreal dateToX(const QDateTime dt) const; QDateTime xToDate(qreal x, bool snapToDay true) const; private: qreal m_pxPerDay 36.0; // 默认一天 36 像素 QDateTime m_timelineStart; // 时间轴左端通常是项目最早开始日 QDateTime m_timelineEnd; };m_pxPerDay的合理区间我一般限制在 8 到 240 之间。低于 8 像素时日期标签根本排不下高于 240 时一屏只能看三四天滚动条会变得非常敏感。缩放操作通常挂在wheelEvent上按住 Ctrl 滚动时按 1.15 的倍率增减同时以鼠标当前位置为锚点让锚点下的日期在缩放前后保持在同一个屏幕 x 上否则用户会觉得图在乱跑。3.2 双向换算dateToX 与 xToDate这两个函数是整个控件的核心写成一行也不为过但细节必须抠// view/ganttview.cpp qreal GanttView::dateToX(const QDateTime dt) const { // 统一转 UTC 再算毫秒差绕开夏令时导致的 23/25 小时制 const qint64 ms m_timelineStart.toUTC().msecsTo(dt.toUTC()); return ms / 86400000.0 * m_pxPerDay; } QDateTime GanttView::xToDate(qreal x, bool snapToDay) const { const qint64 ms qRound64(x / m_pxPerDay * 86400000.0); QDateTime dt m_timelineStart.toUTC().addMSecs(ms).toLocalTime(); if (snapToDay) { // 甘特图基本按天粒度过了中午就算下一天视觉上更符合直觉 if (dt.time() QTime(12, 0)) dt dt.addDays(1); dt.setTime(QTime(0, 0)); } return dt; }逻辑说明dateToX返回的是场景坐标不是屏幕坐标因为场景坐标不随滚动变化任务条的setPos可以直接用。xToDate的snapToDay参数在拖动预览时传 false要跟手在松手提交时传 true要落整日。参数86400000.0是一天的毫秒数写成浮点是为了避免整数除法把小数部分截断这个坑我在早期版本踩过表现是长工期任务条的右边缘总是差一两个像素。中间那个“过了中午算下一天”的规则不是标准是我按使用习惯定的。排产场景里没人会把任务开始时间设成 14:30用户拖动时心里想的是“往后挪一天”如果按四舍五入到最近整点拖动半格不动会让人以为卡了。3.3 刻度尺三级粒度的绘制阈值时间刻度尺是GanttHeaderItem一个横跨整个场景宽度的图元固定贴在视口顶部通过setFlag(ItemIgnoresTransformations)配合手动定位或者干脆放在独立的 header view 里。我选的是后者把刻度尺放在一个和甘特区共享水平滚动条的独立 QWidget 上滚动同步比图元方案稳定得多。绘制时分三层上层年、中层月、下层日。日的标签在pxPerDay 18时自动隐藏改成每 7 天画一条周分隔线否则“1 2 3 4 5”会挤成一团黑块。周末底色用一个浅灰填充矩形判断方式是date.dayOfWeek() 6。这些阈值不是拍的是实测出来的36px 一天时“10-24”这样的五位标签刚好放得下再窄就得截断。// items/ganttheaderitem.cpp void GanttHeaderItem::paint(QPainter *p, const QStyleOptionGraphicsItem *, QWidget *) { const QDate first m_view-xToDate(0).date(); const QDate last m_view-xToDate(m_view-sceneRect().right()).date(); // 底层周末底色先画避免盖住文字 for (QDate d first; d last; d d.addDays(1)) { if (d.dayOfWeek() 6) { const qreal x m_view-dateToX(QDateTime(d, QTime(0, 0))); p-fillRect(QRectF(x, 0, m_view-pxPerDay(), height()), QColor(0, 0, 0, 12)); } } // 中层月分隔线与月名只在每月 1 号画 // 上层日标签pxPerDay 18 才画 }参数说明m_view是弱引用刻度尺不拥有视图height()是刻度尺自身高度通常设 48px上层 20px、中层 16px、下层 12px。QRectF(x, 0, pxPerDay, height)里宽度故意取一整天这样周末底色是连续的色带而不是一格一格的。4. 任务条绘制与拖拽交互一次拖动的完整事件链路这一章讲从鼠标按下到数据落库的完整链路。很多甘特图 demo 能画不能拖或者能拖但撤销栈里塞了上百条中间状态问题都出在事件处理的分段策略上。4.1 GanttBarItem 的 boundingRect 与 paint任务条的几何尺寸由时间换算直接决定boundingRect必须和paint里的绘制范围严格一致否则会出现残影和点击热区错位。// items/ganttbaritem.cpp QRectF GanttBarItem::boundingRect() const { const qreal x1 m_view-dateToX(m_displayStart); const qreal x2 m_view-dateToX(m_displayEnd); // 最小宽度兜底工期为零的里程碑也要能点中 const qreal w qMax(x2 - x1, 6.0); return QRectF(x1, 0, w, kRowHeight - 4); } void GanttBarItem::paint(QPainter *p, const QStyleOptionGraphicsItem *opt, QWidget *) { const QRectF r boundingRect().adjusted(0, 2, 0, -2); // 1. 底色圆角矩形 p-setPen(Qt::NoPen); p-setBrush(m_task-color.isValid() ? m_task-color : defaultColor(m_task-type)); p-drawRoundedRect(r, 3, 3); // 2. 进度内部再叠一层深色宽度按 progress 比例 if (m_task-progress 0) { QRectF pr r; pr.setWidth(r.width() * m_task-progress / 100.0); p-setBrush(QColor(0, 0, 0, 40)); p-drawRoundedRect(pr, 3, 3); } // 3. 文字只在条够宽时画否则画到条的右侧外部 if (r.width() 48) { p-setPen(contrastColor(m_task-color)); p-drawText(r.adjusted(6, 0, -4, 0), Qt::AlignVCenter | Qt::AlignLeft, m_task-name); } else { p-setPen(QColor(60, 60, 60)); p-drawText(QPointF(r.right() 6, r.center().y() 5), m_task-name); } }逻辑说明m_displayStart/m_displayEnd是“当前显示用的时间”拖动预览时它和m_task-start不一致松手后才同步。这个分离设计是拖动流畅的关键。参数kRowHeight是行高常量一般 28 到 32QRectF上下各收 2px 是为了行与行之间留缝。文字颜色用contrastColor按背景亮度取黑或白避免深色任务条上写黑字看不见——这种细节不做用户第一眼就会觉得控件“不专业”。4.2 拖动三段式press 记状态、move 只改显示、release 才提交拖拽逻辑最忌讳在mouseMoveEvent里直接改模型。那样撤销栈会被塞满中间态而且每次移动都触发一次布局重排性能直接崩。// items/ganttbaritem.cpp void GanttBarItem::mousePressEvent(QGraphicsSceneMouseEvent *e) { if (m_task-type GanttTask::Summary) { e-ignore(); return; } // 汇总条不给拖 m_pressX e-pos().x(); m_origStart m_task-start; m_origEnd m_task-end; m_displayStart m_origStart; m_displayEnd m_origEnd; const qreal w boundingRect().width(); if (e-pos().x() kEdgeGrip) m_mode ResizeLeft; // 左边 8px 改开始 else if (e-pos().x() w - kEdgeGrip) m_mode ResizeRight; // 右边 8px 改结束 else m_mode Move; // 中间整体平移 e-accept(); } void GanttBarItem::mouseMoveEvent(QGraphicsSceneMouseEvent *e) { const qreal dxPx e-pos().x() - m_pressX; const int days qRound(dxPx / m_view-pxPerDay()); // 吸附到整天 switch (m_mode) { case Move: m_displayStart m_origStart.addDays(days); m_displayEnd m_origEnd.addDays(days); break; case ResizeLeft: { QDateTime s m_origStart.addDays(days); if (s m_displayEnd) s m_displayEnd.addDays(-1); // 不允许反向 m_displayStart s; break; } case ResizeRight: { QDateTime en m_origEnd.addDays(days); if (en m_displayStart) en m_displayStart.addDays(1); m_displayEnd en; break; }} prepareGeometryChange(); // 尺寸要变必须先通知场景 update(); e-accept(); } void GanttBarItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *) { if (m_displayStart ! m_origStart || m_displayEnd ! m_origEnd) { // 只在这里进模型一次拖动 一条撤销命令 m_model-updateTaskRange(m_task-id, m_displayStart, m_displayEnd); } m_mode None; }逻辑说明m_mode用三个枚举区分平移和两端拉伸判定阈值kEdgeGrip取 8px太窄用户抓不住太宽平移区域就没了。mouseMoveEvent里qRound而不是qFloor是为了让拖动跟手否则用户要拖过半格才动。最关键的prepareGeometryChange()必须在改m_displayStart/End之后、update()之前调用漏了它场景缓存的包围盒不更新快速拖动会留下一条条的残影。松手时只在数值真的变了才提交避免单击也往撤销栈塞一条空命令。4.3 依赖箭头正交折线比贝塞尔好维护依赖箭头的画法有两种贝塞尔曲线和正交折线。曲线好看但两条箭头交叉时很难看清走向而且控制点难调。我用的是正交折线从前置任务右中点向右伸出一小段竖直走到目标行再水平连到后继任务左中点。// items/ganttlinkitem.cpp QPainterPath GanttLinkItem::buildPath() const { const QPointF a m_from; // 前置任务右中点场景坐标 const QPointF b m_to; // 后继任务左中点 const qreal stub 10.0; // 出发后先走一小段避免贴着任务条倒角 QPainterPath p(a); if (b.x() a.x() 2 * stub) { // 常规情况目标在右侧标准 Z 形 const qreal midX a.x() stub; p.lineTo(midX, a.y()); p.lineTo(midX, b.y()); p.lineTo(b.x(), b.y()); } else { // 目标在左侧返工/倒挂依赖走下方的避让折线 const qreal downY qMax(a.y(), b.y()) kRowHeight * 0.5; p.lineTo(a.x() stub, a.y()); p.lineTo(a.x() stub, downY); p.lineTo(b.x() - stub, downY); p.lineTo(b.x() - stub, b.y()); p.lineTo(b.x(), b.y()); } return p; }逻辑说明返回的路径直接交给QGraphicsPathItem::setPath。stub是出发段长度取 10px太小会和任务条圆角重叠。分支判断b.x() a.x() 2 * stub处理的是“后继任务排在前置任务左边”的情况现实中确实会出现数据录入错误或计划倒挂不做兜底的话箭头会直接穿过任务条。箭头端部的三角形在paint里用p-translate到路径终点再旋转绘制角度由最后一段线段的atan2得出。依赖箭头的刷新时机也讲究只在dependencyChanged信号里重建路径不要在paint里现算否则每次重绘都要遍历依赖图。任务条拖动过程中箭头不跟手松手后一起跳这个取舍我认了——跟手版的代价是每帧都要重算所有相关箭头的路径几百条依赖时鼠标直接卡住。5. 避坑记录五个把甘特图拖垮的细节这一章是我在几个项目里踩出来的每一条都对应一个能复现的现象不是理论推演。按渲染、滚动、数据提交三类分开写方便对照排查。5.1 渲染与缩放字糊、线粗、残影踩坑一缩放后文字模糊、边框变粗。现象是用户把甘特图放大到 150% 看细节任务名开始发虚1px 边框变成两三个像素的灰边圆角也毛了。原因很直接用了QGraphicsView::scale()做缩放view 的变换矩阵作用到所有图元上字体和线宽被一起放大Qt 的抗锯齿在非整数缩放下又没法完美渲染。解决方式是把缩放换成正比例系数m_pxPerDayview 始终保持单位矩阵改比例后重新setPos并prepareGeometryChange。这样字体始终按原始 DPI 渲染缩放多少都清晰。踩坑二快速拖动后留下一条条残影。现象是按住任务条猛拖松手后画面上还残留着几个半透明的旧位置切换窗口才消失。原因是改了图元的几何尺寸却没通知场景boundingRect依赖m_displayStart/End这两个值变了但prepareGeometryChange()没调场景索引里还记着旧包围盒局部刷新只刷新了旧区域。解决方式是在任何影响boundingRect的成员变更前调用prepareGeometryChange()位置平移用setPos不用改包围盒。这条我写在代码规范里新人入职必看。5.2 滚动同步与交互树和甘特区打架踩坑三左侧任务树和右侧甘特区滚动不同步或者一滚就抖。现象是拖动左边树的滚动条右边甘特区慢半拍偶尔还会自己弹回去滚动条像在打架。原因是两个滚动条互相connect(valueChanged)A 变化触发 BB 变化又触发 A形成回环浮点误差让值来回震荡。解决方式是用QSignalBlocker包住同步写入的那一侧// view/gantttreeview.cpp void GanttTreeView::syncScrollTo(int value) { // 阻断信号避免我改你、你改我的回环抖动 const QSignalBlocker blocker(verticalScrollBar()); verticalScrollBar()-setValue(value); }参数说明QSignalBlocker是 RAII离开作用域自动恢复信号比手动blockSignals(true/false)安全异常路径也不会漏。另外左侧树的滚动范围要按行数 × 行高算不要用默认的scrollBar()-maximum()因为树的行高可能被样式表改过。踩坑四任务条悬停高亮在滚动后错位。现象是鼠标悬停时高亮的任务条和实际鼠标位置差了一行。原因是用了viewport()-mapToGlobal再去和场景坐标比较滚动条一动全局坐标和场景坐标的偏移就变了但高亮图元没跟着更新。解决方式是全程在场景坐标里算用mapToScene(e-pos())拿鼠标位置再用scene()-items(scenePos)做命中测试彻底不碰全局坐标。这条经验适用于所有 QGraphicsView 项目。5.3 时间与数据提交差一天和撤销栈爆炸踩坑五跨月或跨夏令时的任务工期总是差一天。现象是设了 3 月 1 日到 3 月 31 日渲染出来少一格换了时区或者碰上夏令时切换差值还会变。原因是直接用了QDateTime::daysTo两个本地时间相减时夏令时那天只有 23 小时daysTo返回的整数天就和直觉不一致。解决方式统一在换算层转 UTCtoUTC().msecsTo()拿到精确毫秒再除以 86400000 得到浮点天数需要整数时qRound而不是qFloor。数据入口处也做一次规整把所有时间戳的时分秒归零避免上游传进来带时分的脏数据。踩坑六附带撤销栈里塞了几百条中间状态。现象是拖一次任务条按 CtrlZ 要按几十下才能回到原位。原因是在mouseMoveEvent里直接调用模型接口。解决方式就是第 4 章讲的三段式press 缓存原始值move 只改显示变量release 比对后提交一次。配套地模型的updateTaskRange里用QUndoCommand包一层命令的redo和undo都调用同一个内部设置函数保证来回切换状态一致。6. 二次开发改造加列、换渲染、接数据源拿到这份代码后最常见的三个改造需求是左侧树加自定义列负责人、工时、状态、任务条按类型换渲染样式、从 JSON 或数据库接数据源。这三件事都不需要动核心换算逻辑改的都是外围。加列最直接的方式是给GanttTreeView换一个自定义的QAbstractItemModelcolumnCount返回你需要的列数data()里按role Qt::DisplayRole分发字段。注意行高要和甘特区的kRowHeight保持一致否则左右两边对不齐这个对齐是用户最容易一眼看出来的问题。换渲染有两种做法一是在GanttBarItem::paint里按m_task-type分支加个Bracket类型画成方括号表示汇总二是派生新的图元类在GanttScene::rebuildLayout里按类型工厂创建。前者改动小后者扩展性好任务类型超过五种我就建议走工厂。接数据源时把外部数据先转成QVectorGanttTask再一次性调setTasks不要在setTasks内部逐条发信号。我一般会加一个GanttJsonLoader负责日期字符串解析和dependsOn的合法性校验——引用了不存在的前置任务 id 时直接跳过并记日志不要让界面崩在箭头绘制上。验证改造是否成功我有一套固定动作造一份 500 条任务、20 条依赖的测试数据缩放从最小拉到最大走一遍看文字有没有压线随机拖十条任务确认撤销栈深度是 10再滚动到底部检查有没有残影。这套动作五分钟能跑完能挡掉八成低级问题。从那以后我每次改甘特图的绘制或交互代码都会强制走一遍这套验证再提交血泪经验是有一回只改了个颜色顺手动了boundingRect的收边值结果线上滚动时任务条全部错位半行。希望帮到你。本文还有配套的精品资源点击获取