简介面向Qt/C开发者与项目管理可视化学习者的甘特图实现源码可用于理解如何在桌面应用中呈现任务起止时间、优先级与进度依赖并完成拖拽选择、视图缩放等交互。压缩包共12个文件约14KB以5个h头文件、4个cpp源文件为核心配合pro工程配置、txt说明及user本地配置便于直接打开工程梳理类结构与模块划分。已有3112人学习下载适合作为中小型可视化组件的参考样例。源码围绕QGraphicsView/QGraphicsScene组织图形项借助QPainter绘制任务条并通过数据模型、时间计算、事件处理与信号槽刷新界面体现从任务数据存储到视图渲染的完整链路。读者可据此掌握自定义图元、时间轴换算、交互响应和代码分层思路为二次开发或嵌入项目排期模块提供可复用的实现参照。1. 场景切入QT 甘特图拖一下就错位用 QT 甘特图做项目排期面板最大的问题往往不是画不出横条而是拖完一次任务条之后时间对不上、依赖线错位、滚一下满屏残影。这份 qt 实现甘特图源码要解决的就是这件事把时间轴换算、任务条绘制、拖拽改期、依赖连线拆成能单独替换的几层避免把逻辑全塞进一个 paintEvent。它适合已经会用 Qt Widgets、被 QTableView 加委托折腾过一轮、需要在桌面端做一个可交互排期界面的开发者也适合想拿一份能编译、能改参数的骨架先把原型立起来的人。下面按我拆包的顺序走先看骨架再谈坐标最后说那些只有跑起来才会暴露的坑。2. QT 甘特图源码的骨架模型层、时间轴、视图层怎么切拿到一份甘特图源码第一件事不是找 paintEvent 在哪而是看它把「时间」放在哪一层。放错了后面每个交互都要重新算一遍时间改一处崩三处。这份包的切法比较常规也比较好改模型层只存绝对时间和依赖 id时间轴类只负责毫秒和像素的互换视图层只负责把算出来的矩形画出来、把鼠标位移换算回时间再写回模型。三层之间没有互相回头调用所以你能单独把视图换成 QWidget 自绘而不动数据。2.1 为什么不用 QTableView 加委托画很多人第一反应是 QTableView 加 QStyledItemDelegate一列一天任务条靠合并单元格或者让委托画到单元格外面去。这条路我试过卡在三个地方委托的 paint 被裁剪在自己的 cell rect 里,想画一条跨十天的横条就得关掉裁剪,而关掉之后相邻行的重绘会互相干扰;选中态是按单元格走的,框住一条任务会框出一排蓝格子;拖拽拉伸需要自己起 editor,一旦进入编辑态,单元格的几何就接管了鼠标,Snap 逻辑要重写一遍。比较一下三条常见路线路线交互成本万级任务表现适用场景QTableView 委托高选中、拖拽、跨行连线都得自己接管好天然只画可视行表格为主、甘特图只是附属列QGraphicsView 场景低命中测试、z 序、选中、鼠标事件都是现成的中等item 数量上去要裁剪需要拖拽、连线、缩放的排期面板QWidget 纯 paintEvent最高命中测试和选中要自己写最好一帧画完可视区域只读展示、打印预览、超大项目这份源码采用的是第二条每条任务一个 QGraphicsItem时间刻度和网格在 drawBackground 里画依赖连线是独立的 QGraphicsPathItem。好处是拖拽和选中不用自己写命中测试坏处是任务上千之后要做裁剪这条后面在排错章节展开。2.2 任务数据结构与模型层一次改对视图才不抖数据结构里有三个决定后面好不好改的字段时间用 QDateTime 存绝对毫秒、依赖存 id 不存行号、进度用 0 到 100 的整数而不是浮点。时间统一按 UTC 存只在展示的时候转本地这一条能省掉后面一大半的时区问题。依赖存 id是因为行号会随着插入、删除、排序变化一旦存了行号删一行就得全表重修。// gantttask.h #pragma once #include QColor #include QDateTime #include QString #include QVector struct GanttTask { int id -1; int parentId -1; // 摘要任务用于折叠分组 QString name; QDateTime start; // 内部统一存 UTC展示时再 toLocalTime() QDateTime end; int progress 0; // 0~100 QVectorint deps; // 前置任务 id默认 FS完成-开始 bool milestone false; QColor color QColor(#5b8def); }; Q_DECLARE_METATYPE(GanttTask)模型层用 QAbstractTableModel行对应任务列不重要视图不按列取数据只按行。关键在 data() 里把每个字段拆成一个自定义 role视图拿数据时按 role 取值改数据时用带 roles 参数的 dataChanged 精确通知避免一改就整表重绘。class GanttModel : public QAbstractTableModel { Q_OBJECT public: enum Roles { IdRole Qt::UserRole 1, StartRole, EndRole, ProgressRole, DepsRole }; int rowCount(const QModelIndex parent {}) const override { return parent.isValid() ? 0 : m_tasks.size(); } int columnCount(const QModelIndex parent {}) const override { return parent.isValid() ? 0 : 1; } QVariant data(const QModelIndex idx, int role) const override { if (!idx.isValid() || idx.row() m_tasks.size()) return {}; const GanttTask t m_tasks.at(idx.row()); switch (role) { case Qt::DisplayRole: return t.name; case IdRole: return t.id; case StartRole: return t.start; case EndRole: return t.end; case ProgressRole: return t.progress; case DepsRole: return QVariant::fromValue(t.deps); default: return {}; } } // 拖拽结束后唯一的写入口校验 写数据 精准通知 bool setTaskSpan(int row, const QDateTime s, const QDateTime e) { if (row 0 || row m_tasks.size()) return false; if (!s.isValid() || !e.isValid() || s e) return false; m_tasks[row].start s; m_tasks[row].end e; emit dataChanged(index(row, 0), index(row, 0), {StartRole, EndRole}); return true; } int rowOfId(int id) const { return m_idToRow.value(id, -1); } private: QVectorGanttTask m_tasks; // 连续存储遍历快 QHashint, int m_idToRow; // id - 行号依赖连线时 O(1) 定位 };注意 dataChanged 后面那个 role 列表它不是可选的装饰。不带 roles 的话视图会把整行所有 role 都当成脏的行数一多就是全量重绘带上之后只影响起止时间的角色视图只重算这一条的几何。2.3 工程怎么跑起来CMake 与 qmake 两套配置源码包里同时给了 CMake 和 .pro 的比例不高但只要有 Q_OBJECT 的类构建配置就是第一道容易踩的坑。下面这份 CMakeLists 兼容 Qt5 和 Qt6靠find_package(QT NAMES Qt6 Qt5 ...)先探测再决定链接哪个 target不要写死 Qt5::Widgets否则在 Qt6 环境里会直接找不到包。cmake_minimum_required(VERSION 3.16) project(GanttDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 的头文件必须被 moc 扫到 set(CMAKE_AUTORCC ON) find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) add_executable(GanttDemo src/main.cpp src/ganttview.cpp src/ganttmodel.cpp src/timeaxis.cpp ) target_include_directories(GanttDemo PRIVATE src) target_link_libraries(GanttDemo PRIVATE Qt${QT_VERSION_MAJOR}::Widgets)构建和运行cmake -S . -B build -DCMAKE_BUILD_TYPERelease cmake --build build -j 8 ./build/GanttDemo如果是老项目只认 qmake.pro 里至少要有这几行缺一行 moc 就不生成QT widgets CONFIG c17 HEADERS src/gantttask.h src/ganttmodel.h src/timeaxis.h src/ganttview.h SOURCES src/main.cpp src/ganttmodel.cpp src/timeaxis.cpp src/ganttview.cpp参数上只有一处值得改CMAKE_BUILD_TYPE。甘特图在 Debug 下拖拽会明显发涩因为每个 item 的边界检查都带断言Release 下 QPainter 的抗锯齿路径也快一截。第一次跑通建议就用 Release别在 Debug 里判断性能。3. 时间轴换算三个函数把 QDateTime 钉到像素上甘特图的本质是一条数轴横轴是时间纵轴是任务行。所有「任务条画歪了」「拖完差半天」「缩放之后对不上」的问题最后都能追到同一个地方就是时间和像素的换算没有收口到一个类里。这份源码把它收在 TimeAxis 里只有两个成员变量场景 x0 对应的绝对毫秒以及一像素代表多少毫秒。所有几何都从这两个值推出来不缓存第二条时间线。3.1 原点加毫秒每像素两个变量定死整条轴不用QDateTime::secsTo()在 paint 里现算原因是它在每次绘制时都要做一次日期分解而且遇到夏令时切换的日期会算错。改用毫秒差值是纯整数运算稳定且便宜。这里的坑是 fromMSecsSinceEpoch 的时区参数如果不显式传 Qt::UTCQt 会按本地时区解释跟内部存的 UTC 时间差出时区偏移表现出来就是整体平移几个小时。// timeaxis.h #pragma once #include QDateTime #include QtGlobal class TimeAxis { public: void setOrigin(const QDateTime t) { m_originMs t.toMSecsSinceEpoch(); } void setMsPerPixel(double v) { m_msPerPixel qBound(1000.0, v, 86400000.0); } double msPerPixel() const { return m_msPerPixel; } qint64 originMs() const { return m_originMs; } // 时间 - 场景 x 坐标可以超出场景范围负值代表在原点左侧 double x(const QDateTime t) const { return double(t.toMSecsSinceEpoch() - m_originMs) / m_msPerPixel; } // 场景 x 坐标 - 时间四舍五入到毫秒避免累计误差 QDateTime timeAt(double px) const { return QDateTime::fromMSecsSinceEpoch( m_originMs qRound64(px * m_msPerPixel), Qt::UTC); } // 吸附步长屏幕上看大约 3 像素对应的时间量取整到分钟 qint64 snapStep() const { return qMaxqint64(60000, qRound64(m_msPerPixel * 3.0)); } private: qint64 m_originMs 0; double m_msPerPixel 3600000.0; // 默认 1 像素 1 小时 };用法上有一点必须固定原点选定之后缩放只改m_msPerPixel不改原点。原点一变所有任务的绝对位置跟着漂滚动条也会跳。如果希望缩放时视野中心不跑正确做法是缩放后重新 centerOn而不是挪原点。3.2 用 QPainter 画任务条、进度和里程碑任务条不用 QGraphicsRectItem而是继承 QGraphicsItem 自己 paint理由是要画进度叠加层和圆角裁剪用现成的矩形 item 反而要套两层。几个细节adjusted(0.5, ...)是为了让 1 像素的描边落在像素中心配合抗锯齿才不会糊成两像素进度用半透明黑叠加而不是重新算一个颜色这样换主题时不用改代码文字用 elidedText 裁一下否则短任务条上的长名字会溢出到隔壁行。void GanttBarItem::paint(QPainter* p, const QStyleOptionGraphicsItem*, QWidget*) { const QRectF body boundingRect().adjusted(0.5, 3.0, -0.5, -3.0); QPainterPath path; path.addRoundedRect(body, 3, 3); p-setRenderHint(QPainter::Antialiasing, true); p-fillPath(path, m_task.color); // 进度裁剪在圆角内叠加避免超出圆角 if (m_task.progress 0 m_task.progress 100) { p-save(); p-setClipPath(path); QRectF done body; done.setWidth(body.width() * m_task.progress / 100.0); p-fillRect(done, QColor(0, 0, 0, 55)); p-restore(); } // 选中态只画两条竖向手柄比整圈描边安静 if (isSelected()) { p-setPen(QPen(QColor(#1b3a6b), 1)); p-drawLine(body.topLeft(), body.bottomLeft()); p-drawLine(body.topRight(), body.bottomRight()); } p-setPen(Qt::white); const QString text p-fontMetrics().elidedText( m_task.name, Qt::ElideRight, int(body.width()) - 12); p-drawText(body.adjusted(6, 0, -6, 0), Qt::AlignVCenter | Qt::AlignLeft, text); }里程碑是特例画成菱形而不是细条宽度固定 12 像素几何上不要跟着时长走否则一个当天完成的里程碑在放大后会变成一条长条。判断条件建议用start end或者显式的 milestone 标记不要用「时长小于一天」来猜跨天的短任务会被误判。改动几何之后必须调prepareGeometryChange()再setPos()或者改内部宽度。这行看起来多余但 BSP 索引靠它知道旧边界失效了少调一次移动过的任务条就会在原位置留一块脏区。这就是典型的残影来源。3.3 表头、网格与滚动同步三条对齐线一个完整的甘特图界面是三块拼的左边任务列表、上面时间刻度、右下画布。三块必须严格对齐行高一致、垂直滚动同步、水平滚动只影响画布和时间刻度。常见做法是用 QGridLayout 摆三块任务列表和画布各有一个滚动条然后把列表的 verticalScrollBar 的 valueChanged 连到画布的 scroll 上反过来也连一次。不要指望 QScrollArea 自动同步两个不同控件的滚动范围天然不一致。网格画在 drawBackground 里按当前缩放选刻度粒度。步长直接用 TimeAxis 的 snapStep 会让网格太密实用一点的做法是按 msPerPixel 分档小于 2 小时用小时线、小于 3 天用天线、再大用周线然后在循环里对齐到步长整数倍只画可视矩形范围内的线。void GanttView::drawBackground(QPainter* p, const QRectF rect) { p-fillRect(rect, Qt::white); qint64 step m_axis.snapStep(); if (m_axis.msPerPixel() 7200000) step 3600000; // 1 小时 else if (m_axis.msPerPixel() 259200000) step 86400000; // 1 天 else step 604800000; // 1 周 qint64 t0 m_axis.timeAt(rect.left()).toMSecsSinceEpoch(); t0 (t0 / step) * step; // 对齐到刻度起点否则线会随滚动条漂移 p-setPen(QPen(QColor(0, 0, 0, 18), 1)); for (qint64 t t0; ; t step) { const double x m_axis.x(QDateTime::fromMSecsSinceEpoch(t, Qt::UTC)); if (x rect.right()) break; p-drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom())); } }网格线的颜色透明度别调高0.07 到 0.1 之间比较合适再深就盖过任务条。行分隔线同理画在任务条下面用setZValue(-1)放到最底层否则拖动时会跟任务条抢视觉层级。4. 拖拽改期与依赖连线交互事件该拦在哪一层交互是这份源码里最见功力的部分也是最容易改坏的部分。核心原则只有一条视图层可以随意移动 item 的坐标但真正的时间修改必须回到模型层并且要经过一次约束校验。一旦允许视图直接改数据撤销栈、依赖重算、外部数据刷新三条路会立刻互相打架。4.1 移动与拉伸吸附粒度与最小工期移动整个任务条的时候最省事的写法是给 item 设ItemIsMovable但那会让任务条在纵向上也能拖跨行之后时间没错、行号错了用户看起来就是「拖歪了」。正确做法是重写 itemChange在 ItemPositionChange 里把 y 钉死到行顶x 做一次吸附。QVariant GanttBarItem::itemChange(GraphicsItemChange change, const QVariant value) { if (change ItemPositionChange scene()) { QPointF pos value.toPointF(); // 1) 时间吸附把落点时间取整到步长再换回像素 const qint64 step m_axis-snapStep(); qint64 ms m_axis-timeAt(pos.x()).toMSecsSinceEpoch(); ms qRound64(double(ms) / double(step)) * step; pos.setX(m_axis-x(QDateTime::fromMSecsSinceEpoch(ms, Qt::UTC))); // 2) 行锁定y 只认行号算出来的值纵向拖不动 pos.setY(rowsTop(m_row)); return pos; } return QGraphicsItem::itemChange(change, value); }拉伸改工期走另一条路mousePressEvent 里判断落点距离左右边缘是否小于 4 像素是则记下 m_resizeEdge 并换 SizeHorCursormouseMoveEvent 里只改宽度不移动位置。有两个边界要卡住最小工期比如不小于 1 小时对应屏幕上至少 6 像素否则用户抓不住结束时间不能早于开始时间。拉伸结束后统一在 mouseReleaseEvent 里算最终时间调model-setTaskSpan()失败就把 item 的几何还原回去。这个还原步骤别省它就是拖拽的后悔药。还有一种约束是依赖带来的前置任务没结束后继任务不该提前。这类校验放在 setTaskSpan 之前用 id 查前置任务的 end 时间不合规就不提交弹一个状态栏提示比弹模态框体验好。4.2 依赖关系连线路径、箭头与命中区域连线不适合做成一条直线两行之间距离远的时候直线会穿过一堆任务条。用三次贝塞尔控制点水平偏移取两点水平距离的一半、最少 18 像素出来的形状接近常见的排期软件。箭头在终点处按切线角度画一个三角形角度用 atan2 现算别写死朝向。void GanttLinkItem::rebuild() { const QRectF a m_from-sceneBoundingRect(); const QRectF b m_to-sceneBoundingRect(); const double x1 a.right(), y1 a.center().y(); const double x2 b.left(), y2 b.center().y(); const double dx qMax(18.0, (x2 - x1) * 0.5); QPainterPath path(QPointF(x1, y1)); path.cubicTo(QPointF(x1 dx, y1), QPointF(x2 - dx, y2), QPointF(x2, y2)); const double ang std::atan2(y2 - y1, x2 - x1); QPolygonF head; head QPointF(x2, y2) QPointF(x2 - 8 * std::cos(ang - 0.4), y2 - 8 * std::sin(ang - 0.4)) QPointF(x2 - 8 * std::cos(ang 0.4), y2 - 8 * std::sin(ang 0.4)); path.addPolygon(head); prepareGeometryChange(); setPath(path); update(); }两个容易忽略的点。一是 boundingRect 必须把箭头和线宽算进去否则箭头尖会被裁掉一半看上去像画到一半断了。二是点选区域一条 1.4 像素宽的线基本点不中用 QPainterPathStroker 把路径加粗到 8 像素作为 shape命中就舒服了。连线不参与拖动setAcceptedMouseButtons(Qt::NoButton)关掉鼠标事件避免它挡在任务条上面吃掉点击。任务条移动时要通知连线重算做法是给 GanttBarItem 加一个保存关联连线的 QVector在 itemChange 的 ItemPositionHasChanged 分支里遍历调用 rebuild。注意别在 rebuild 里再触发 item 的位置变更会死循环。4.3 缩放与右键菜单视图缩放和场景重建的取舍缩放有两条路各有代价。用QGraphicsView::scale()简单一行代码搞定但文字会跟着放大放大到 3 倍时任务名糊成一片重建场景改 msPerPixel 后重排所有 item 的 x 和宽度文字始终清晰但每次缩放都是 O(n)。我的判断标准是任务数两千条以内直接重建两千条以上用视图缩放再对字体做一次反向补偿。重建路线要处理锚点不然缩放后视野会跑到别的地方。做法是缩放前把鼠标位置换算成时间缩放重排之后再把画布中心挪回这个时间。void GanttView::wheelEvent(QWheelEvent* e) { if (!(e-modifiers() Qt::ControlModifier)) { QGraphicsView::wheelEvent(e); // 不按 Ctrl 就是普通滚动 return; } const QPoint vp e-position().toPoint(); // Qt6Qt5 用 e-pos() const QDateTime anchor m_axis.timeAt(mapToScene(vp).x()); const double factor e-angleDelta().y() 0 ? 0.8 : 1.25; m_axis.setMsPerPixel(m_axis.msPerPixel() * factor); relayoutItems(); // 按新比例重排所有任务条 centerOn(m_axis.x(anchor), mapToScene(vp).y()); // 时间锚点回位 e-accept(); }右键菜单里放三项就够插入任务、删除任务、设为里程碑。删除的时候要同步清理所有指向它的 dep 项否则重算依赖时会拿到不存在的 id连线全断。这一处源码里如果没做级联清理是第一优先要补的。5. 排错清单QT 甘特图跑起来才暴露的 5 个问题这些不是理论问题是编译通过、界面出来之后一条一条撞出来的。每条都按现象、原因、解决写方便对照排查。5.1 拖完任务条时间总是差 8 小时现象界面显示 09:00 开始拖动之后写回模型再看变成了 17:00或者反过来差一整个时区偏移。原因QDateTime 的构造和反解析时区不一致。比如用QDateTime(QDate(2024,1,1), QTime(9,0))构造时默认是 LocalTime而 timeAt 里用fromMSecsSinceEpoch(ms, Qt::UTC)还原两边差一个时区偏移另一种是保存到 JSON 时用了toString()丢了时区信息读回来按本地解释。解决把「内部存 UTC、界面转本地」写成硬约定。构造写QDateTime(QDate(2024,1,1), QTime(9,0), Qt::UTC)解析统一fromMSecsSinceEpoch(ms, Qt::UTC)只在最终显示给用户的字符串上toLocalTime()。序列化用toMSecsSinceEpoch()存整数不要存字符串。改完之后在 setTaskSpan 里加一句断点检查确认写进去的毫秒值和界面上显示的一致。5.2 依赖箭头残影或者箭头尖被切掉一半现象拖动任务条之后原来的位置留了一条灰色的线鼠标划过才消失另一种是箭头只画了大半个三角。原因两种。残影是 item 的几何改了但没调prepareGeometryChange()场景的 BSP 索引还记着旧边界重绘区域没覆盖旧位置箭头被切是boundingRect()里没把箭头长度和线宽penWidth算进去绘制被裁到边界内。解决所有会改变尺寸的操作前都调prepareGeometryChange()包括改宽度、改路径、改文字长度。boundingRect 用path().boundingRect().adjusted(-pw, -pw, pw, pw)pw 取线宽的一半再加 2 像素余量。另外 view 的视口更新模式设成MinimalViewportUpdate让 Qt 按 item 的脏区刷新别用 FullViewportUpdate 硬扛。5.3 两千条任务之后拖动像幻灯片现象几百条时很流畅超过两千条之后拖动一条任务条整个界面卡住滚动也一顿一顿。原因场景里每个任务条都是独立 item加 BSP 索引之后虽然只画可视区但每次拖动都会触发场景的碰撞和边界重算item 数量线性上涨重算也跟着涨。如果还开了抗锯齿画网格每帧的路径光栅化开销也不小。解决分两步。先关掉不需要的索引纯展示时setItemIndexMethod(QGraphicsScene::NoIndex)再把可视区外的 item 用setVisible(false)藏起来在垂直滚动条的 valueChanged 里按行号区间做一次批量开关比让场景自己判断便宜得多。任务上万的话更彻底的做法是只保留选中行和相邻行的 item其余全部在 drawBackground 里用 QPainter 直接画矩形交互层只留一个透明的命中层。5.4 CMake 编译通过运行直接崩溃或报未定义符号现象链接阶段报undefined reference to vtable for GanttView或者编译过去了一运行就崩在构造。原因类里写了 Q_OBJECT 宏但头文件没被 AUTOMOC 扫到或者 Q_OBJECT 写在 .cpp 文件里的类上而没有对应的头文件。另一种是 Qt5 和 Qt6 的库混链同一个工程里既有 Qt5::Widgets 又有 Qt6::Core。解决Q_OBJECT 只放在头文件里的类声明中构建配置打开CMAKE_AUTOMOC且头文件必须在 target 的源码列表或 include 路径内。库版本用find_package(QT NAMES Qt6 Qt5 ...)探测后统一引用Qt${QT_VERSION_MAJOR}::Widgets不要在 target_link_libraries 里手写具体版本。清理构建目录再重配一次moc 的中间文件残留也会导致这个错。5.5 高 DPI 屏上网格线时有时无任务条之间露白缝现象在缩放 150% 的屏幕上竖网格线隔一条丢一条相邻两个任务条之间有一条 1 像素的白线。原因1 像素的线画在整数坐标上正好落在两个物理像素的交界抗锯齿把它摊成两个半透明像素视觉上就「消失」了任务条的矩形边界也是同理右边界的坐标算成了 xwidth和下一个元素没有共享同一个像素中心。解决所有描边坐标加 0.5 偏移QPointF(x 0.5, top)矩形用QRectF而不是QRect绘制前adjusted(0.5, 0.5, -0.5, -0.5)。网格线的笔宽固定为 1且关掉这条线的抗锯齿p-setRenderHint(QPainter::Antialiasing, false)单独对这一遍绘制线条会变实但不会丢。Qt6 默认开启高 DPI 缩放Qt5 要在 main 里设 QCore本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?