资讯动态

Qt甘特图源码:基于QGraphicsView的可拖拽排期面板

发布时间:2026/10/26 11:07:53 来源:尧图企业网站定制
简介面向Qt/C开发者与项目管理可视化学习者的甘特图实现源码围绕任务时间线、进度展示与交互操作提供一套可参考的自定义图表实现思路适合初中级开发者练习桌面端可视化组件。压缩包共12个文件包含5个头文件、4个cpp源文件、1个pro工程文件以及说明文本、本地配置等整体约14KB头文件用于定义任务结构与类接口源文件承载绘制、事件响应和界面更新逻辑工程文件便于在Qt环境中组织编译。已有3112人学习下载说明其作为小型示例具备一定参考热度。源码涉及图形视图框架、画笔矩形绘制、日期时间计算、鼠标事件处理与信号槽刷新等要点从预览还可见任务约束、新增和修改对话框等模块体现数据处理、视图渲染与交互逻辑的分离可用于项目管理类工具的原型参考与二次开发学习。1. Qt 甘特图源码能直接省掉哪半张排期界面上次接一个排产模块我原以为难点在后端接口结果真正把进度拖了三天的是中间那块横向滚动的甘特图任务条、依赖箭头、今日线、拖拽改期一个都不少。后端的排期表是现成的画不出来才卡住这正是这份 Qt 甘特图源码的价值所在——它把 QGraphicsView 时间轴、任务条绘制、依赖连线、缩放滚动这套容易做得一团乱的东西整理成一套能直接编译运行的结构让你在 Qt 里快速拿到一个可看图、可拖拽的排期面板。适合正在做 MES、项目管理、任务调度这类桌面端应用的开发者尤其是团队里没人专门做过图形视图、又不想为了一个甘特图塞进整个 WebEngine 的那种场景。下面按我拆源码的顺序讲整体架子怎么搭、数据怎么放、绘制链路怎么走、跑起来会踩哪些坑、最后怎么改成自己的。2. 技术选型与整体架构场景图撑起时间轴和任务条2.1 为什么用 QGraphicsView 拼格子先说要避开的做法。很多人第一反应是拿 QTableWidget 或 QTreeWidget 做一行一个任务用 cell 的宽度表示工期再用 QLabel 贴进度条。这种方式在任务数小于 50 的时候看着还行一旦到几百个任务、工期跨度拉到一两年就会卡得厉害。原因是每个 cell 都是独立的 QWidget样式、重绘、布局全部走 widget 栈滚动时整片重建滚动条会明显卡顿。QGraphicsView 走的是另一条路所有图元都放在一个 QGraphicsScene 里由场景维护一棵带空间索引的图元树视图只绘制当前可见矩形内的图元。缩放通过视图自身的变换矩阵实现坐标系统一命中测试、选中、悬停高亮这些交互能力都是框架自带的。Qt 甘特图这类“大画布 大量小矩形”的界面天生适合放在场景图里。常见的边界是单场景几千个图元再往上就要自己做分页或按时间窗口裁剪那些属于后续优化。2.2 四层图元划分与刷新边界源码里的场景不是把所有东西堆在一层而是按 zValue 分了四层。这个分层很关键它决定了后面每次数据变更时你要刷新的范围图层zValue负责内容刷新频率GridLayer0周末底色、月/周分隔线、横向行线缩放或滚动时整层重绘TaskItem10任务条、进度填充、任务名单个任务变更时只重绘自己DependencyItem20前驱后继之间的折线箭头两端任务移动时重算OverlayLayer30今日竖线、框选矩形、拖拽预览交互过程中高频重绘把“周末底色”这种静态内容单独放一层是为了避开一种常见做法把网格画在 TaskItem 的 paint 里结果每次拖动任务条都要顺带把背景重画一遍滚动起来就是一片重影。分层之后任务条移动只需要调用update()更新自己的 boundingRect背景层保持在场景里不动。2.3 时间转像素一条公式统一全部绘制整个项目里最应该抽出来复用的是这个把 QDateTime 映射成横向像素的坐标轴。任务条宽度、今日线位置、刻度标签、拖拽换算全部依赖它一旦这里出现两套算法后面必然会出现“线画在格子中间偏两像素”这种小问题// core/timeaxis.h class TimeAxis { public: explicit TimeAxis(const QDateTime origin, int pxPerDay 32) : m_origin(origin), m_pxPerDay(pxPerDay) {} // 时间 → 像素。用毫秒差做浮点除法避免按整日取整后长周期累积误差 qreal xOf(const QDateTime t) const { const qint64 ms m_origin.msecsTo(t); return ms / 86400000.0 * m_pxPerDay; } // 像素 → 时间拖拽回写时用这一条保证和绘制完全互逆 QDateTime timeOf(qreal x) const { return m_origin.addMSecs(static_castqint64(x / m_pxPerDay * 86400000.0)); } // 缩放时只改这个值所有图元通过信号重算位置不碰场景变换矩阵 void setPxPerDay(int v) { m_pxPerDay qBound(4, v, 200); } int pxPerDay() const { return m_pxPerDay; } private: QDateTime m_origin; // 建议固定为项目最早任务的前一天零点 int m_pxPerDay; };m_origin要选成所有任务里最早开始时间的前一天零点不要用“今天”否则用户往回翻历史排期时坐标会是负数虽然不影响绘制但排查问题时看着难受。pxPerDay的上下限 4 和 200 是我自己压出来的低于 4 像素一天任务名已经完全放不下再缩没有意义高于 200 像素一天一天占掉大半屏用户拖滚动条比直接改期还累。这里有一个容易忽略的点缩放只改pxPerDay不要改 QGraphicsView 的scale()。用视图矩阵缩放会把字体、线宽一起放大1 像素的边框变成 3 像素整个界面像是被拉变形了。改用坐标轴重算位置字体和线宽保持恒定视觉上更接近成熟排期软件的手感。2.4 视图联动左侧任务树与右侧画布的行高对齐排版上通常是左边一棵任务树、右边一块画布两边的行必须严丝合缝。做法是共用一个常量并且让任务树每行的固定高度等于它constexpr int kRowHeight 28; // 任务树行高与画布行高唯一来源 constexpr int kRowGap 4; // 行与行之间留白画布用树里靠 padding 表现 qreal yOfRow(int row) { return row * kRowHeight kRowGap; } int rowAtY(qreal y) { return static_castint(y / kRowHeight); }任务树不用 QTreeWidget 的默认行高而是setUniformRowHeights(true)加自定义 delegate 固定高度画布这边则把行号转成 y 坐标。两处只要引用同一个kRowHeight就不会出现“点左边第三行、右边高亮第四行”的错位。滚动同步放在视图的verticalScrollBar()-valueChanged里把任务树的滚动条跟着走反过来任务树滚轮滚过之后要回写画布。这里得加一个布尔开关防止互相触发形成死循环常见的写法是connect(view-verticalScrollBar(), QScrollBar::valueChanged, this, [this](int v){ if (m_syncing) return; m_syncing true; m_tree-verticalScrollBar()-setValue(v); m_syncing false; });横向滚动只需要同步给顶部的时间刻度头任务树不参与。顶部刻度头用的是一个自绘的 QWidget重写paintEvent按TimeAxis画月线和日期宽度跟随场景sceneRect().width()否则滚到后面刻度就和画布脱节了。3. 目录结构与数据模型任务、依赖、日历怎么存3.1 动手之前先认清哪几个文件是骨架这份源码的目录不复杂但要知道哪些是真正的骨架、哪些只是演示外壳改起来才不会白白折腾半天GanttDemo/ ├── CMakeLists.txt / gantt.pro # 两套构建脚本优先用 CMake ├── main.cpp ├── core/ │ ├── taskmodel.h/.cpp # 任务模型增删改 信号 │ ├── taskloader.h/.cpp # 从 JSON / SQL 装载任务 │ └── dependencychecker.h/.cpp # 依赖环检测、关键路径 ├── view/ │ ├── ganttview.h/.cpp # QGraphicsView 子类缩放与滚动 │ ├── ganttscene.h/.cpp # 场景管理四个图层 │ ├── timeaxis.h/.cpp # 时间 ↔ 像素换算 │ └── items/ │ ├── taskitem.h/.cpp │ ├── dependencyitem.h/.cpp │ └── gridlayer.h/.cpp └── widgets/ ├── tasktreewidget.h/.cpp # 左侧任务树 └── timelineheader.h/.cpp # 顶部时间刻度要接自己的后端动的是core/taskloader加一个从数据库读的版本要改样式动的是view/items/taskitem的 paint要做交互动ganttview的鼠标事件。widgets/下面那两个属于界面拼装换掉也不影响核心绘图逻辑二次开发时我一般先不管它。3.2 三个核心结构体任务、依赖、日历任务结构体是整份代码的地基字段设计直接决定了后面能不能支撑拖拽改期和依赖计算。下面这版我在原基础上收紧了两个地方// core/taskmodel.h struct Task { int id -1; QString name; QDateTime start; // 统一存 UTC展示时再按本地时区转换 QDateTime finish; // 含当天比较时用 而不是 int progress 0; // 0 ~ 100 QColor color; QListint predecessors; // 前驱任务 id 列表 QString group; // 分组/阶段树形结构靠这个平铺展开 bool isValid() const { return id 0 start.isValid() finish.isValid() finish start; } qint64 durationDays() const { return start.date().daysTo(finish.date()) 1; } };durationDays()这里用的是1因为甘特图里一个任务当天开始当天结束视觉上也要占一格。如果按daysTo直接算会得出 0任务条宽度变成一条线这是数据模型和绘制约定必须一致的地方。日历结构体负责处理非工作日。真实的排期系统里周末和节假日不能算工期所以工期计算不能直接用自然日做差struct Calendar { QSetQDate holidays; // 法定假日从配置或数据库加载 bool isWorkday(const QDate d) const { return d.dayOfWeek() 5 !holidays.contains(d); } // 从 start 起累计 workDays 个工作日返回结束日期 QDate addWorkdays(const QDate start, int workDays) const { QDate d start; int n 0; while (n workDays) { if (isWorkday(d)) n; if (n workDays) d d.addDays(1); } return d; } };要不要把工作日算进工期取决于你的业务。如果只是展示自然日更直观如果是产能排程必须走addWorkdays否则拖出来的工期跟车间算的对不上。源码里两种模式都给了开关默认自然日。3.3 装载与依赖环检测任务通常来自 JSON 文件或者数据库查询。装载函数没什么花活重点是对脏数据要容错不能因为一条finish start的脏记录就让整个场景画歪QListTask TaskLoader::fromJson(const QJsonArray arr, QStringList* errors) { QListTask out; for (const QJsonValue v : arr) { const QJsonObject o v.toObject(); Task t; t.id o.value(id).toInt(-1); t.name o.value(name).toString(); t.start QDateTime::fromString(o.value(start).toString(), Qt::ISODate); t.finish QDateTime::fromString(o.value(finish).toString(), Qt::ISODate); t.progress qBound(0, o.value(progress).toInt(), 100); for (const QJsonValue p : o.value(predecessors).toArray()) t.predecessors p.toInt(); if (!t.isValid()) { // 脏数据跳过并记录不中断装载 if (errors) errors-append(QString(task %1 invalid).arg(t.id)); continue; } out.append(t); } return out; }依赖关系加载完必须做一次环检测否则后面算关键路径或者做自动排期时会进入死循环程序直接卡住。用三色标记做深度优先就够了bool DependencyChecker::hasCycle(const QHashint, QListint deps) { enum Color { White, Gray, Black }; QHashint, Color color; std::functionbool(int) dfs [](int u) - bool { color[u] Gray; for (int v : deps.value(u)) { if (color[v] Gray) return true; // 回到灰色节点 有环 if (color[v] White dfs(v)) return true; } color[u] Black; return false; }; for (auto it deps.constBegin(); it ! deps.constEnd(); it) if (color.value(it.key(), White) White dfs(it.key())) return true; return false; }检测到环不要只弹一个QMessageBox了事最好把构成环的那几条依赖标红显示在图里。用户改数据的时候根本不记得自己连了哪几条线只知道“图坏了”把环路径还给他比一句“存在循环依赖”有用得多。3.4 模型到场景的落地新增一条任务要动哪几处TaskModel继承 QAbstractTableModel暴露taskInserted(int row)、taskChanged(int row)、taskRemoved(int row)三个信号。GanttScene连上之后做三件事新建或更新对应的 TaskItem、重算受影响的 DependencyItem、调整sceneRect。void GanttScene::onTaskChanged(int row) { const Task t m_model-taskAt(row); auto* item m_taskItems.value(t.id, nullptr); if (!item) { item new TaskItem(t, m_axis); item-setPos(0, yOfRow(row)); addItem(item); m_taskItems.insert(t.id, item); } else { item-setTask(t); // 内部会 update(boundingRect()) } updateSceneRect(); // 右边界跟着最长任务走 m_depLayer-rebuildFor(t.id); // 只重建和这个任务相关的连线 }注意rebuildFor只重算相关连线不是整个依赖层清空重画。早期图省事写成全量重建任务一多拖一下卡一下这是很典型的性能问题后面还会提到。updateSceneRect()里把左边界固定为 0右边界取所有任务xOf(finish)的最大值再加两天的余量同时要乘上当前的pxPerDay。少加这个余量的话最后一个任务条会贴着滚动条边缘看着很难受。4. 核心绘制链路从 paint 到依赖箭头4.1 TaskItem 的 boundingRect 与 paint任务条是整个界面里被绘制次数最多的图元它的boundingRect和paint写得好不好直接决定高 DPI 下会不会出现描边被裁一半的问题// view/items/taskitem.cpp QRectF TaskItem::boundingRect() const { // 四周各留 2px给圆角描边和 hover 外发光用漏掉就会被场景裁掉 return QRectF(-2, -2, m_width 4, m_height 4); } void TaskItem::paint(QPainter* p, const QStyleOptionGraphicsItem*, QWidget*) { p-setRenderHint(QPainter::Antialiasing, true); const QRectF bar(0, 0, m_width, m_height); p-setPen(QPen(m_border, 1)); p-setBrush(m_base); p-drawRoundedRect(bar, 3, 3); // 进度用裁剪路径画否则矩形会盖掉圆角 if (m_progress 0) { p-save(); QPainterPath clip; clip.addRoundedRect(bar, 3, 3); p-setClipPath(clip); p-setPen(Qt::NoPen); p-setBrush(m_progressColor); p-drawRect(QRectF(0, 0, bar.width() * m_progress / 100.0, bar.height())); p-restore(); } // 文字放不下就不画宁可留白也别让省略号糊成一片 const QFontMetricsF fm(m_font); if (fm.horizontalAdvance(m_name) 12 bar.width()) { p-setFont(m_font); p-setPen(m_textColor); p-drawText(bar.adjusted(6, 0, -6, 0), Qt::AlignVCenter | Qt::AlignLeft, m_name); } }几个参数的含义m_width由m_axis-xOf(finish) - m_axis-xOf(start)算出最小宽度限制在 4 像素防止工期为零的任务完全看不见m_height是kRowHeight - kRowGap两行之间留缝视觉上才有分隔。p-save()和p-restore()必须成对漏掉restore会把裁剪切到后面的文字绘制上表现就是“任务名字只显示了左半边”这个错误我第一次拆的时候就遇到过。boundingRect的 2 像素外扩是个细节但很关键。不开抗锯齿时边框正好压在矩形边上不会被裁一旦开了抗锯齿边缘像素会溢出半像素高 DPI 屏上放大 1.5 倍后肉眼可见被切。这类像素对齐问题没有太多逻辑可言靠反复试错调出来我一般把它归入像素对齐这类靠经验试错的问题。4.2 依赖连线折线优于贝塞尔锚点要算准依赖连线有两种造型贝塞尔曲线好看但两个任务上下错位很大时曲线会横穿其他任务条折线右 → 竖 → 右视觉上更硬朗也更容易对齐。源码用的是折线QPainterPath DependencyItem::buildPath(const QRectF from, const QRectF to) { QPainterPath path; const QPointF a(from.right(), from.center().y()); // 前驱右边缘中点 const QPointF b(to.left(), to.center().y()); // 后继左边缘中点 if (b.x() a.x() 16) { const qreal midX a.x() 16; // 先右出 16px 再竖直走 path.moveTo(a); path.lineTo(midX, a.y()); path.lineTo(midX, b.y()); path.lineTo(b); } else { // 后继在前驱左侧绕行下方避免和任务条重叠 const qreal by qMax(a.y(), b.y()) kRowHeight * 0.6; path.moveTo(a); path.lineTo(a.x() 8, a.y()); path.lineTo(a.x() 8, by); path.lineTo(b.x() - 8, by); path.lineTo(b.x() - 8, b.y()); path.lineTo(b); } return path; }箭头头部在终点处用一个小三角补齐方向取路径最后一段的方向向量。这里有个细节midX固定向右延伸 16 像素而不是取两点的中点。取中点的问题在于当前驱任务很短、后继很远时竖线会落在离前驱很远的位置视觉上像是从别的地方连过来的固定短距离出线读起来更符合“从这条任务的尾部拉出去”的直觉。连线层要开setFlag(QGraphicsItem::ItemUsesExtendedStyleOption)吗不需要它本身不参与精细命中。但要注意连线不适合用setAcceptHoverEvents(true)做高亮路径细悬停判定范围极小用户基本点不中。如果确实要高亮某条依赖做法是给它加一个透明的点击区域或者干脆在任务条上右键选“查看前置/后置”用列表呈现比在画布上瞄准一条 1 像素的线靠谱。4.3 背景层批量绘制只画可见区域背景层的paint如果按整个时间跨度一天一天画跨三年的项目就是一千多次drawLine滚动时明显掉帧。正确做法是用option-exposedRect拿到当前可见矩形只画这一段void GridLayer::paint(QPainter* p, const QStyleOptionGraphicsItem* opt, QWidget*) { const QRectF vis opt-exposedRect; // 先画周末底色按天扫但只扫可见范围 const QDate d0 m_axis-timeOf(vis.left()).date().addDays(-1); const QDate d1 m_axis-timeOf(vis.right()).date().addDays(1); for (QDate d d0; d d1; d d.addDays(1)) { if (d.dayOfWeek() 5) { const qreal x0 m_axis-xOf(QDateTime(d, QTime(0, 0))); const qreal x1 m_axis-xOf(QDateTime(d.addDays(1), QTime(0, 0))); p-fillRect(QRectF(x0, vis.top(), x1 - x0, vis.height()), m_weekendColor); } } // 再画月份分隔线一屏最多十几条成本可以忽略 for (QDate d(d0.year(), d0.month(), 1); d d1; d d.addMonths(1)) { const qreal x m_axis-xOf(QDateTime(d, QTime(0, 0))); p-setPen(QPen(m_monthLineColor, 1, Qt::DashLine)); p-drawLine(QPointF(x, vis.top()), QPointF(x, vis.bottom())); } }exposedRect是场景坐标下的可见矩形视图缩放或滚动时它会变图元因此被重绘。这里左右各扩一天做缓冲是因为timeOf(vis.left())落在某天中间不扩的话最左边那天可能被漏画。周末底色和月份线之外还有一条今日竖线。它属于覆盖层位置随QDate::currentDate()变化所以不能只画一次得挂在一个 QTimer 上每天零点更新一次或者干脆在打开界面时算一次就行。为了省电省事我一般启动时算一次跨零点不刷新用户第二天打开时自然就对了。4.4 命中测试与 hoverboundingRect 不等于点击区域boundingRect决定重绘范围shape()决定能不能被点中。默认实现是拿 boundingRect 当形状这对矩形任务条基本够用但如果你在任务条右下角画了一个拖拽用的手柄又不希望用户点空白处也触发拖拽那就得重写shape()把可点击区域收紧。QPainterPath TaskItem::shape() const { QPainterPath path; path.addRoundedRect(QRectF(0, 0, m_width, m_height), 3, 3); return path; }悬停高亮用一个m_hovered布尔量在hoverEnterEvent/hoverLeaveEvent里改值并调用update()不要在 paint 里直接读isUnderMouse()。后者在快速移动时会有一帧的延迟表现为高亮闪烁看着不太舒服。右键菜单、双击编辑这些交互都是在GanttView里把scenePos转成行列号再派发回模型图元本身不直接改数据。这条单向链路视图 → 模型 → 场景 → 图元要保持住否则改着改着就会出现“界面已经改了、数据还没变、再刷新又弹回去”这类混乱情况。5. 常见问题排查编译、错位与卡顿的排查记录5.1 起不来的三种情况编译与首次运行排查现象一CMake 配置通过编译时报QGraphicsItem: No such file or directory。原因Qt 6 之后部分模块拆得更细widgets 相关头文件需要显式链接Qt6::Widgets只写Qt6::Core是不够的。解决find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets)target_link_libraries里把 Widgets 加上。源码里带了.pro和CMakeLists.txt两套用 qmake 的话记得QT core gui widgets。现象二程序能启动窗口一片灰什么都不显示。原因main.cpp里没有调用view-setScene(scene)或者场景的sceneRect没设默认是空的视图什么也不画。解决先确认setScene再在场景初始化完成后调用一次updateSceneRect()把左边界设为 0、右边界按最长任务算出来。现象三用 Qt 5 编译报horizontalAdvance不存在。原因这个接口是 Qt 5.11 才加的更早版本只有width()。解决源码里其实已经包了一层宏如果自己改代码时删了就得补回来或者统一升到 Qt 5.15 以上。这类版本兼容问题能提前统一版本就别到处打补丁。5.2 画出来不对坐标、刷新与高 DPI 的常见问题现象任务条位置正确但今天线总是偏左几个像素。原因今日线的 x 是用QDate::currentDate()单独算的和任务条用的TimeAxis不是同一套坐标两边的m_origin差了几天。解决所有横向坐标一律走m_axis-xOf()不允许任何地方自己拿日期算像素。这一条我在第二个项目里做成了硬规矩好处是后来加缩放功能时只改了一处。现象拖动任务后任务条消失了滚一下又出现。原因boundingRect没有跟着数据变化更新场景认为旧区域已擦除新区域不在重绘范围内。解决setTask()里先prepareGeometryChange()再改m_width然后update()。漏掉prepareGeometryChange是血泪经验级别的错误表现出来就是各种“偶尔消失”“缩放后才出现”的诡异现象。现象4K 屏上文字模糊、边框粗细不均。原因用了视图scale()做缩放或者没开高 DPI 缩放策略。解决缩放改走pxPerDay启动时设置QGuiApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough)让系统负责缩放绘制代码里再用devicePixelRatio换算线宽。这两件事不做界面在高分屏上就是糊的。现象改完任务数据界面显示的还是旧日期。原因模型发了dataChanged但场景里连的是taskChanged(row)自定义信号两边没对上。解决要么统一走dataChanged并在槽里取 index 转行号要么在模型里显式发自定义信号。混合用两种通知机制迟早会漏。5.3 数据量上来之后卡顿与内存的排查路径现象200 个任务时流畅800 个任务时拖动明显掉帧。原因依赖连线层每次任务移动都全量重建即 N 个任务 × 平均 2 条依赖 1600 个图元反复 new/delete。解决改成按任务 id 增量更新只重建和移动任务相关的连线其余图元复用。用QElapsedTimer打在paintEvent前后能很直观地看出是绘制慢还是重建慢。现象内存随缩放操作缓慢上涨。原因每次缩放新建了一整套 TaskItem 却没删旧的或者依赖图元用了裸指针没delete。解决removeItem之后要么delete要么设置setParent(this)交给场景托管。如果一个场景里图元的生命周期比较乱可以给每个图层加一个clear()切数据源时统一清空。现象横向滚动到底部时越来越卡。原因sceneRect右边界一直在长大滚动条按比例计算滚到后段时一像素对应几十天背景层按天扫描的循环次数暴涨。解决updateSceneRect算右边界时只取所有任务的最大结束时间加两天余量不要按“未来十年”预留另外背景层必须用exposedRect限制扫描范围。提示排查渲染类问题时先把QGraphicsView::setViewportUpdateMode临时改成FullViewportUpdate。如果改成全量刷新之后问题消失说明问题出在增量刷新的边界计算上范围就缩小到boundingRect和prepareGeometryChange这两处了。6. 二次开发把只读甘特图改成可拖拽编辑的排期面板只读的甘特图没什么门槛真正有价值的改造是让用户直接在图上拖任务条改期。这条链路要改三个地方鼠标按下时记录起始状态、移动时把像素增量换算成时间、松开时写回模型并触发重排。拖拽改期的核心在mouseMoveEvent里关键是别直接改QDateTime而是先算像素增量void TaskItem::mouseMoveEvent(QGraphicsSceneMouseEvent* e) { if (!m_dragging) return QGraphicsItem::mouseMoveEvent(e); const qreal dx e-scenePos().x() - e-lastScenePos().x(); if (qFuzzyIsNull(dx)) return; // 像素 → 毫秒用坐标轴换算和绘制共用同一套公式 const qint64 deltaMs static_castqint64(dx / m_axis-pxPerDay() * 86400000.0); m_previewStart m_previewStart.addMSecs(deltaMs); m_previewFinish m_previewFinish.addMSecs(deltaMs); setPos(m_axis-xOf(m_previewStart), y()); // 先动画面不碰数据 e-accept(); } void TaskItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* e) { if (m_dragging m_previewStart ! m_task.start) { emit requestReschedule(m_task.id, m_previewStart, m_previewFinish); // 交给模型 } m_dragging false; QGraphicsItem::mouseReleaseEvent(e); }这里有个设计选择拖拽过程中只动pos不写模型。松开时才发信号。好处是拖动 100 次只触发一次重排和依赖重算性能稳坏处是拖拽过程中依赖线不会跟手需要在一个定时器里对相关连线做轻量预览。如果要做到跟手就得在移动时同步更新连线路径代价是每次移动都要重算几条线的buildPath任务依赖多的场景下要限流。我一般会给拖拽加几条约束写成一个小的配置表约束项做法说明最小粒度拖拽结束后按 0.5 天取整排产不需要精确到小时取整后界面更整齐最长工期拖动右边缘超过 365 天时截断防止误拖出跨越几年的任务条非工作日结束时用Calendar::addWorkdays校正只在不允许周末开工时启用前置依赖开始时间早于前驱结束时弹提示不强制拦截只提示避免打断用户操作节奏验证改动有没有画错我常用的办法是准备三份 JSON一份跨年的长跨度任务、一份全部同一天开始的并发任务、一份存在循环依赖的脏数据。三份都能正常加载、缩放、拖拽基本就能确认坐标换算和刷新逻辑没问题。改完paint之后尤其要把pxPerDay拉到最小值 4 和最大值 200 各看一遍因为极端缩放下最容易出现文字溢出、圆角变形、边框被裁这几类问题。从那以后我每次动坐标相关的代码都会强制走一遍“改一个常量 → 三份数据各缩放一次 → 截图比对”的流程哪怕只是把 16 改成 8。甘特图这种一屏几十个矩形的界面肉眼看不出的偏移在用户反复拖动两小时后会变成一次明确的投诉。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑