Qt 自定义树形菜单插件:后台管理侧边栏菜单的 QWidget 实现

在后台管理类软件中,左侧树形菜单是非常常见的交互形式。它通常具备几个特点:一级菜单纵向排列,父级菜单可以展开或收起,子菜单按层级缩进显示,菜单内容超过容器高度时出现滚动条,点击叶子菜单后再进入具体页面。
Qt 本身有 QTreeWidget、QTreeView、QListView 等控件,但如果我们希望菜单更接近后台管理系统的侧边栏样式,并且希望完全控制绘制、交互、圆角、高亮、滚动条、层级缩进和点击回调,那么直接基于 QWidget + QPainter + QScrollArea 自定义一个树形菜单插件会更灵活。
本文实现的插件叫 MenuPluginTreeWidget,它是一个独立的树形菜单组件。外部只需要传入多维菜单数据,插件内部递归解析数据,并按展开状态动态生成可见菜单项。父节点点击时展开或收起,叶子节点点击时发出完整路径回调。
效果如下:

一、插件目标
这个插件主要解决以下问题:
-
支持理论无限级菜单,只要数据结构可以嵌套,插件就可以递归渲染。 -
父级菜单有子节点时,在菜单项前方绘制三角形箭头。 -
点击父级菜单时向下展开,再次点击时收起。 -
如果父级菜单收起,它下面所有已展开的后代菜单都会一起收起。 -
菜单项高度始终固定,不会因为展开内容过多而被压缩。 -
内容超过容器高度时,使用 QScrollArea自动显示垂直滚动条。 -
点击叶子菜单时发出完整菜单路径,方便业务层定位具体功能。 -
菜单项自绘,视觉状态包括 hover、pressed、selected。 -
插件和业务窗口解耦,可以在不同窗口中复用。
二、为什么不用 QTreeWidget
QTreeWidget 很适合做传统树控件,例如文件树、对象树、属性树。但后台管理侧边栏菜单并不完全等同于普通树控件。
普通树控件更偏数据展示,后台菜单更偏导航交互。它通常要求:
-
菜单项高度固定,视觉更像 ListView。 -
鼠标悬浮和选中状态有明显背景色。 -
箭头、缩进、文字布局完全可控。 -
滚动条样式和整体容器风格统一。 -
只暴露业务需要的叶子菜单点击回调。 -
父节点只负责展开/收起,不直接触发业务页面跳转。
因此这里没有直接套 QTreeWidget,而是把菜单项拆成自绘控件 TreeMenuItemWidget,再由 MenuPluginTreeWidget 管理整棵菜单树。
三、源码
下面直接贴出完整源码,方便复制到 Qt 工程中使用。
1. menuplugintreewidget.h
#pragma once#include<QList>#include<QPointer>#include<QString>#include<QWidget>class QEnterEvent;class QEvent;class QMouseEvent;class QPaintEvent;class QScrollArea;class QVBoxLayout;// 树菜单中的单行菜单项控件。// 负责绘制层级缩进、菜单文字、悬浮/按下/选中状态,以及有子节点时的展开箭头。class TreeMenuItemWidget : public QWidget{Q_OBJECTpublic:// 创建一行菜单项。// depth 用于计算左侧缩进;hasChildren 用于判断是否绘制三角箭头。explicitTreeMenuItemWidget(const QString &text, int depth, bool hasChildren, QWidget *parent = nullptr);// 设置展开状态。// 这里只影响箭头方向,真正的子菜单显示和隐藏由 MenuPluginTreeWidget 负责重建。voidsetExpanded(bool expanded);// 设置选中状态。// 叶子菜单被点击后会保持选中高亮,表示当前激活菜单。voidsetSelected(bool selected);// 返回当前菜单项显示的文字。QString text()const;signals:// 鼠标左键在菜单项内部释放后触发。voidclicked();protected:// 鼠标进入菜单项时打开悬浮状态。voidenterEvent(QEnterEvent *event)override;// 鼠标离开菜单项时清理悬浮和按下状态。voidleaveEvent(QEvent *event)override;// 鼠标左键按下时打开按下状态。voidmousePressEvent(QMouseEvent *event)override;// 鼠标释放时判断是否为有效点击。voidmouseReleaseEvent(QMouseEvent *event)override;// 自绘菜单项背景、文字、缩进和箭头。voidpaintEvent(QPaintEvent *event)override;// 返回菜单项建议尺寸,保证高度固定,宽度随文字和层级变化。QSize sizeHint()constoverride;private:QString m_text; // 菜单显示文字。int m_depth; // 当前菜单层级,顶级为 0。bool m_hasChildren; // 是否有子菜单。bool m_expanded; // 当前菜单项是否展开。bool m_hovered; // 鼠标是否悬浮在当前菜单项上。bool m_pressed; // 鼠标是否按下当前菜单项。bool m_selected; // 当前菜单项是否为选中的叶子菜单。};// 后台管理风格的递归树菜单插件。// 接收多维菜单数据,按展开状态渲染可见菜单项,内容超出容器高度时通过 QScrollArea 显示滚动条。class MenuPluginTreeWidget : public QWidget{Q_OBJECTpublic:// 对外传入的菜单数据结构。// key 是菜单文字;values 是子菜单列表,可以递归嵌套任意层级。struct MenuDef {QString key;QList<MenuDef> values = {};};// 初始化树菜单插件。// 内部会创建滚动区域、内容容器、垂直布局和滚动条样式。explicitMenuPluginTreeWidget(QWidget *parent = nullptr);// 析构插件。// 释放可见菜单项控件和内部菜单树数据。~MenuPluginTreeWidget() override;// 设置菜单数据。// 每次调用都会替换旧菜单树,并重新渲染当前可见菜单项。voidsetMenus(const QList<MenuDef> &menus);signals:// 点击叶子菜单时触发。// path 是完整菜单路径,例如 "系统管理 / 用户中心 / 用户列表"。voidactionTriggered(const QString &path);private:// 插件内部使用的树节点结构。// 只保存数据和展开状态,不承担界面绘制职责。struct TreeNode {QString text;QList<TreeNode *> children;TreeNode *parent = nullptr;bool expanded = false;// 递归释放所有子节点。~TreeNode();};// 将外部 MenuDef 数据递归转换为内部 TreeNode 数据。QList<TreeNode *> buildNodes(const QList<MenuDef> &defs, TreeNode *parent)const;// 根据当前树节点展开状态,重新生成所有可见菜单项。voidrebuildVisibleItems();// 延迟重建可见菜单项。// 避免在 clicked 信号还没返回时同步删除当前 sender,防止频繁点击崩溃。voidscheduleRebuildVisibleItems();// 追加一个可见节点;如果该节点已展开,则继续递归追加它的子节点。voidappendVisibleNode(TreeNode *node, int depth);// 清空当前布局中的所有菜单项控件。voidclearLayout();// 清空可见菜单项和内部菜单树数据。voidclearMenuData();// 递归收起指定节点下的所有后代节点。voidcollapseChildren(TreeNode *node);// 根据节点的 parent 指针向上回溯,生成完整菜单路径。QString buildPath(TreeNode *node)const;// 处理菜单项点击。// 父节点点击时展开/收起;叶子节点点击时选中并发出 actionTriggered。voidhandleNodeClicked(TreeNode *node, TreeMenuItemWidget *itemWidget);// 清除上一个选中菜单项的选中状态。voidclearSelectedItem();QList<TreeNode *> m_roots; // 完整菜单树的根节点列表。QScrollArea *m_scrollArea; // 滚动区域,内容超过高度时显示滚动条。QWidget *m_contentWidget; // 滚动区域内部的内容容器。QVBoxLayout *m_contentLayout; // 承载所有可见菜单项的垂直布局。QPointer<TreeMenuItemWidget> m_selectedItem; // 当前选中的叶子菜单项,QPointer 可避免悬空访问。bool m_rebuildPending; // 是否已有延迟重建任务,防止重复投递。};
2. menuplugintreewidget.cpp
#include"menuplugintreewidget.h"#include<QApplication>#include<QEnterEvent>#include<QEvent>#include<QFontMetrics>#include<QFrame>#include<QLayout>#include<QLayoutItem>#include<QMouseEvent>#include<QPainter>#include<QPainterPath>#include<QScrollArea>#include<QSizePolicy>#include<QStringList>#include<QTimer>#include<QtAlgorithms>#include<QVBoxLayout>// 创建一个自绘菜单项。// 菜单项高度固定为 42,展开更多节点时不会压缩已有菜单项高度。TreeMenuItemWidget::TreeMenuItemWidget(const QString &text, int depth, bool hasChildren, QWidget *parent): QWidget(parent), m_text(text), m_depth(depth), m_hasChildren(hasChildren), m_expanded(false), m_hovered(false), m_pressed(false), m_selected(false){// 鼠标追踪和点击事件处理。setMouseTracking(true);// 设置鼠标手型setCursor(Qt::PointingHandCursor);// 菜单项不接收焦点,避免菜单项内文本框获取焦点导致菜单项高亮。setFocusPolicy(Qt::NoFocus);// 菜单项固定高度setFixedHeight(42);// 菜单项自绘,横向尽量拉伸占满空间,纵向保持固定高度setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);}// 设置展开状态。// 该状态只用于绘制箭头方向,子节点是否显示由插件重建可见列表决定。voidTreeMenuItemWidget::setExpanded(bool expanded){if (m_expanded == expanded) {return;}m_expanded = expanded;update();}// 设置选中状态。// 叶子菜单点击后保持选中,用于显示当前激活菜单。voidTreeMenuItemWidget::setSelected(bool selected){if (m_selected == selected) {return;}m_selected = selected;update();}// 返回菜单项文字。QString TreeMenuItemWidget::text()const{return m_text;}// 鼠标进入时开启悬浮状态并重绘。voidTreeMenuItemWidget::enterEvent(QEnterEvent *event){QWidget::enterEvent(event);m_hovered = true;update();}// 鼠标离开时清理悬浮和按下状态,避免颜色残留。voidTreeMenuItemWidget::leaveEvent(QEvent *event){QWidget::leaveEvent(event);m_hovered = false;m_pressed = false;update();}// 鼠标左键按下时开启按下状态。voidTreeMenuItemWidget::mousePressEvent(QMouseEvent *event){if (event->button() == Qt::LeftButton) {m_pressed = true;update();}QWidget::mousePressEvent(event);}// 鼠标释放时转换成 clicked 信号。// 如果按下后移出菜单项再释放,则不触发点击。voidTreeMenuItemWidget::mouseReleaseEvent(QMouseEvent *event){const bool triggerClick = event->button() == Qt::LeftButton && rect().contains(event->pos());m_pressed = false;update();// 点击信号if (triggerClick) {emit clicked();}QWidget::mouseReleaseEvent(event);}// 绘制菜单项完整界面。// 包括背景高亮、层级缩进、展开箭头和菜单文字。voidTreeMenuItemWidget::paintEvent(QPaintEvent *event){Q_UNUSED(event);QPainter painter(this);painter.setRenderHint(QPainter::Antialiasing, true);// 高亮背景保留左右边距,让视觉效果更接近后台侧边栏菜单。const QRectF bgRect = rect().adjusted(8.0, 3.0, -8.0, -3.0);const bool highlighted = m_selected || m_pressed || m_hovered;if (highlighted) {const QColor bgColor = m_selected? QColor(QStringLiteral("#dbeafe")): (m_pressed ? QColor(QStringLiteral("#e0f2fe")) : QColor(QStringLiteral("#f1f5f9")));QPainterPath bgPath;bgPath.addRoundedRect(bgRect, 10.0, 10.0);painter.fillPath(bgPath, bgColor);}QFont menuFont = font();menuFont.setPointSize(10);menuFont.setWeight(m_selected ? QFont::DemiBold : QFont::Medium);painter.setFont(menuFont);const int baseIndent = 16;// 菜单项左侧缩进const int depthIndent = 20; // 菜单项层级缩进const int arrowSize = 8; // 菜单项箭头大小const int arrowX = baseIndent + m_depth * depthIndent; // 菜单项箭头 X 坐标const int centerY = rect().center().y();const int textX = arrowX + 20;const QColor textColor = m_selected ? QColor(QStringLiteral("#1d4ed8")) : QColor(QStringLiteral("#334155"));// 只有存在子节点时才绘制三角形箭头。// 展开状态箭头向下,收起状态箭头向右。if (m_hasChildren) {QPolygonF arrow;if (m_expanded) {arrow << QPointF(arrowX, centerY - 3)<< QPointF(arrowX + arrowSize, centerY - 3)<< QPointF(arrowX + arrowSize / 2.0, centerY + 4);} else {arrow << QPointF(arrowX + 2, centerY - 5)<< QPointF(arrowX + 2, centerY + 5)<< QPointF(arrowX + arrowSize, centerY);}painter.setBrush(QColor(QStringLiteral("#64748b")));painter.setPen(Qt::NoPen);painter.drawPolygon(arrow);}painter.setPen(textColor);QFontMetrics fm(menuFont);const QRect textRect(textX, 0, qMax(20, width() - textX - 14), height());const QString text = fm.elidedText(m_text, Qt::ElideRight, textRect.width());painter.drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, text);}// 返回菜单项建议尺寸。// 高度固定,宽度根据文字长度和层级缩进计算。QSize TreeMenuItemWidget::sizeHint()const{QFont menuFont = font();menuFont.setPointSize(10);menuFont.setWeight(QFont::Medium);QFontMetrics fm(menuFont);const int width = 52 + m_depth * 20 + fm.horizontalAdvance(m_text);return QSize(qMax(220, width), 42);}// 递归释放所有子节点。MenuPluginTreeWidget::TreeNode::~TreeNode(){qDeleteAll(children);children.clear();}// 构建树菜单插件容器。// 使用 QScrollArea 提供类似 ListView 的滚动能力,同时保持每个菜单项固定高度。MenuPluginTreeWidget::MenuPluginTreeWidget(QWidget *parent): QWidget(parent), m_scrollArea(new QScrollArea(this)), m_contentWidget(new QWidget(m_scrollArea)), m_contentLayout(new QVBoxLayout(m_contentWidget)) //m_contentWidget->setLayout(m_contentLayout);, m_rebuildPending(false){// 根布局auto *rootLayout = new QVBoxLayout(this);rootLayout->setContentsMargins(0, 0, 0, 0);rootLayout->setSpacing(0);m_contentLayout->setContentsMargins(0, 8, 0, 8);m_contentLayout->setSpacing(2);// 强制内容容器按菜单项真实总高度计算尺寸,避免展开后压缩菜单项。m_contentLayout->setSizeConstraint(QLayout::SetMinAndMaxSize);m_contentLayout->addStretch();// 填充剩余空间,让菜单项靠上显示。// contentWidget 是 QScrollArea 内部真正承载菜单项的容器。// 显式设置透明,避免某些平台下显示默认黑色背景。m_contentWidget->setObjectName(QStringLiteral("treeMenuContent"));m_contentWidget->setAutoFillBackground(false);m_contentWidget->setAttribute(Qt::WA_StyledBackground, true);m_contentWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum);m_scrollArea->setWidget(m_contentWidget);m_scrollArea->setWidgetResizable(true);m_scrollArea->setFrameShape(QFrame::NoFrame);m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);// QScrollArea 内部还有 viewport,也需要显式设置透明背景。m_scrollArea->viewport()->setAutoFillBackground(false);m_scrollArea->viewport()->setAttribute(Qt::WA_StyledBackground, true);m_scrollArea->setStyleSheet(QStringLiteral(R"(QScrollArea {background: transparent;border: none;}QScrollArea > QWidget,QWidget#treeMenuContent {background: transparent;border: none;}QScrollBar:vertical {width: 8px;background: transparent;margin: 8px 2px 8px 2px;}QScrollBar::handle:vertical {background: #cbd5e1;border-radius: 4px;min-height: 28px;}QScrollBar::handle:vertical:hover {background: #94a3b8;}QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical {height: 0px;}QScrollBar::add-page:vertical,QScrollBar::sub-page:vertical {background: transparent;})"));rootLayout->addWidget(m_scrollArea);setAutoFillBackground(false); // 避免某些平台下显示默认黑色背景。}// 析构时释放菜单树数据和当前可见菜单项。MenuPluginTreeWidget::~MenuPluginTreeWidget(){clearMenuData();}// 设置外部菜单数据。// 先清理旧数据,再递归构建内部树节点,最后渲染当前可见菜单项。voidMenuPluginTreeWidget::setMenus(const QList<MenuDef> &menus){clearMenuData();m_roots = buildNodes(menus, nullptr);rebuildVisibleItems();}// 将外部 MenuDef 数据递归转换成内部 TreeNode 数据。// parent 指针用于叶子节点点击时向上回溯完整路径。QList<MenuPluginTreeWidget::TreeNode *> MenuPluginTreeWidget::buildNodes(const QList<MenuDef> &defs, TreeNode *parent)const{QList<TreeNode *> nodes;for (const MenuDef &def : defs) {if (def.key.isEmpty()) {continue;}auto *node = new TreeNode;node->text = def.key;node->parent = parent;node->children = buildNodes(def.values, node);nodes.append(node);}return nodes;}// 根据当前 expanded 状态重建可见菜单项。// 未展开的子树不会创建 QWidget,只有当前可见节点会被渲染。voidMenuPluginTreeWidget::rebuildVisibleItems(){m_rebuildPending = false;clearLayout();for (TreeNode *node : m_roots) {appendVisibleNode(node, 0);}m_contentLayout->addStretch();}// 将重建任务延迟到下一轮事件循环。// 这样可以避免在 clicked 信号派发过程中删除当前被点击的菜单项。voidMenuPluginTreeWidget::scheduleRebuildVisibleItems(){if (m_rebuildPending) {return;}m_rebuildPending = true;QTimer::singleShot(0, this, [this]() {rebuildVisibleItems();});}// 追加一个可见节点。// 如果该节点已经展开,则递归追加它的子节点,depth + 1 用于增加缩进。voidMenuPluginTreeWidget::appendVisibleNode(TreeNode *node, int depth){if (node == nullptr) {return;}auto *itemWidget = new TreeMenuItemWidget(node->text, depth, !node->children.isEmpty(), m_contentWidget);itemWidget->setExpanded(node->expanded);m_contentLayout->addWidget(itemWidget);// 菜单项只负责发出 clicked,具体展开/收起或回调由插件统一处理。connect(itemWidget, &TreeMenuItemWidget::clicked, this, [this, node, itemWidget]() {handleNodeClicked(node, itemWidget);});if (!node->expanded) {return;}for (TreeNode *child : node->children) {appendVisibleNode(child, depth + 1);}}// 清空当前布局中的菜单项控件。// 使用 deleteLater,而不是直接 delete,避免事件仍在派发时销毁控件导致崩溃。voidMenuPluginTreeWidget::clearLayout(){clearSelectedItem();while (QLayoutItem *item = m_contentLayout->takeAt(0)) {if (QWidget *widget = item->widget()) {widget->hide();widget->deleteLater();}delete item;}}// 清空可见菜单项和内部菜单树数据。voidMenuPluginTreeWidget::clearMenuData(){clearLayout();qDeleteAll(m_roots);m_roots.clear();}// 递归收起指定节点的所有后代节点。// 父节点收起后,下面所有已展开子节点都要同步复位。voidMenuPluginTreeWidget::collapseChildren(TreeNode *node){if (node == nullptr) {return;}for (TreeNode *child : node->children) {child->expanded = false;collapseChildren(child);}}// 通过 parent 指针向上回溯,生成完整菜单路径。QString MenuPluginTreeWidget::buildPath(TreeNode *node)const{QStringList parts;for (TreeNode *current = node; current != nullptr; current = current->parent) {parts.prepend(current->text);}return parts.join(QStringLiteral(" / "));}// 处理菜单项点击。// 父节点点击时展开或收起;叶子节点点击时选中并发出 actionTriggered。voidMenuPluginTreeWidget::handleNodeClicked(TreeNode *node, TreeMenuItemWidget *itemWidget){if (node == nullptr) {return;}// 有子节点时展开或收起if (!node->children.isEmpty()) {if (node->expanded) {node->expanded = false;// 父节点收起时重置后代展开状态,避免再次展开时残留旧状态。collapseChildren(node);} else {node->expanded = true;}scheduleRebuildVisibleItems();return;}//没有子节点的时候,选中该节点并发出 actionTriggered 信号,携带完整菜单路径。clearSelectedItem();if (itemWidget != nullptr) {itemWidget->setSelected(true);m_selectedItem = itemWidget;}emit actionTriggered(buildPath(node));}// 清除当前选中菜单项。// m_selectedItem 是 QPointer,如果控件已被 deleteLater 释放,会自动变为空指针。voidMenuPluginTreeWidget::clearSelectedItem(){if (m_selectedItem != nullptr) {m_selectedItem->setSelected(false);m_selectedItem.clear();}}
3. mainwindow4.h
#pragma once#include<QMainWindow>class QLabel;class MenuPluginTreeWidget;class MainWindow4 : public QMainWindow{public:explicitMainWindow4(QWidget *parent = nullptr);private:voidbuildUi();voidhandleAction(const QString &path);static QString buildStyleSheet();QLabel *m_titleLabel;QLabel *m_currentPathLabel;MenuPluginTreeWidget *m_treeMenu;};
4. mainwindow4.cpp
#include"mainwindow4.h"#include"menuplugintreewidget.h"#include<QFrame>#include<QHBoxLayout>#include<QLabel>#include<QStatusBar>#include<QVBoxLayout>#include<QWidget>MainWindow4::MainWindow4(QWidget *parent): QMainWindow(parent), m_titleLabel(nullptr), m_currentPathLabel(nullptr), m_treeMenu(nullptr){buildUi();setStyleSheet(buildStyleSheet());statusBar()->showMessage(QStringLiteral("树形菜单演示已准备就绪"), 3000);}voidMainWindow4::buildUi(){resize(1120, 720);setWindowTitle(QStringLiteral("树形菜单演示"));auto *centralWidget = new QWidget(this);auto *rootLayout = new QHBoxLayout(centralWidget);rootLayout->setContentsMargins(22, 22, 22, 22);rootLayout->setSpacing(18);auto *sideBar = new QFrame(centralWidget);sideBar->setObjectName(QStringLiteral("sideBar"));sideBar->setFixedWidth(286);auto *sideLayout = new QVBoxLayout(sideBar);sideLayout->setContentsMargins(12, 14, 12, 14);sideLayout->setSpacing(12);auto *logoLabel = new QLabel(QStringLiteral("树形菜单容器"), sideBar);logoLabel->setObjectName(QStringLiteral("logoLabel"));auto *descLabel = new QLabel(QStringLiteral("自定义递归树菜单"), sideBar);descLabel->setObjectName(QStringLiteral("descLabel"));m_treeMenu = new MenuPluginTreeWidget(sideBar);m_treeMenu->setMenus({{QStringLiteral("Dashboard"), {{QStringLiteral("Overview")},{QStringLiteral("Workplace")},{QStringLiteral("Analysis")}}},{QStringLiteral("System Management"), {{QStringLiteral("User Center"), {{QStringLiteral("User List")},{QStringLiteral("Role List")},{QStringLiteral("Permission Matrix"), {{QStringLiteral("Menu Permission")},{QStringLiteral("Button Permission")},{QStringLiteral("Data Permission")}}}}},{QStringLiteral("Department")},{QStringLiteral("Post Management")},{QStringLiteral("Dictionary"), {{QStringLiteral("Dict Type")},{QStringLiteral("Dict Data")}}}}},{QStringLiteral("Content Center"), {{QStringLiteral("Article"), {{QStringLiteral("Article List")},{QStringLiteral("Category")},{QStringLiteral("Tags")}}},{QStringLiteral("Banner")},{QStringLiteral("Comments")}}},{QStringLiteral("Operations"), {{QStringLiteral("Order Management"), {{QStringLiteral("All Orders")},{QStringLiteral("Refund Orders")},{QStringLiteral("Invoice"), {{QStringLiteral("Invoice List")},{QStringLiteral("Invoice Settings")}}}}},{QStringLiteral("Member Center"), {{QStringLiteral("Member List")},{QStringLiteral("Level Rules")},{QStringLiteral("Growth Value")}}},{QStringLiteral("Marketing"), {{QStringLiteral("Coupon")},{QStringLiteral("Campaign")},{QStringLiteral("Push Task")}}}}},{QStringLiteral("Monitor"), {{QStringLiteral("Online Users")},{QStringLiteral("Server Status")},{QStringLiteral("Job Log")},{QStringLiteral("Login Log")},{QStringLiteral("Operation Log")}}},{QStringLiteral("Settings"), {{QStringLiteral("Basic Settings")},{QStringLiteral("Security Settings")},{QStringLiteral("Notification Settings")}}}});connect(m_treeMenu, &MenuPluginTreeWidget::actionTriggered, this, &MainWindow4::handleAction);sideLayout->addWidget(logoLabel);sideLayout->addWidget(descLabel);sideLayout->addWidget(m_treeMenu, 1);auto *contentCard = new QFrame(centralWidget);contentCard->setObjectName(QStringLiteral("contentCard"));auto *contentLayout = new QVBoxLayout(contentCard);contentLayout->setContentsMargins(34, 30, 34, 30);contentLayout->setSpacing(18);m_titleLabel = new QLabel(QStringLiteral("Tree Menu Plugin"), contentCard);m_titleLabel->setObjectName(QStringLiteral("titleLabel"));auto *introLabel = new QLabel(QStringLiteral("此演示使用了一个基于自定义 QWidget 的树形菜单。父项在点击时展开或折叠,而叶项会发出完整的菜单路径。"),contentCard);introLabel->setObjectName(QStringLiteral("introLabel"));introLabel->setWordWrap(true);m_currentPathLabel = new QLabel(QStringLiteral("当前操作:选择一个菜单项(叶子菜单项)"), contentCard);m_currentPathLabel->setObjectName(QStringLiteral("pathLabel"));m_currentPathLabel->setWordWrap(true);auto *hintLabel = new QLabel(QStringLiteral("提示:展开多个层级,然后再次点击顶部父项。该父项下的所有后代都将折叠。"),contentCard);hintLabel->setObjectName(QStringLiteral("hintLabel"));hintLabel->setWordWrap(true);contentLayout->addWidget(m_titleLabel);contentLayout->addWidget(introLabel);contentLayout->addWidget(m_currentPathLabel);contentLayout->addWidget(hintLabel);contentLayout->addStretch();rootLayout->addWidget(sideBar);rootLayout->addWidget(contentCard, 1);setCentralWidget(centralWidget);}voidMainWindow4::handleAction(const QString &path){m_currentPathLabel->setText(QStringLiteral("当前操作: %1").arg(path));statusBar()->showMessage(QStringLiteral("触发: %1").arg(path), 2600);}QString MainWindow4::buildStyleSheet(){return QStringLiteral(R"(QMainWindow {background: #edf2f7;}QFrame#sideBar {background: #ffffff;border: 1px solid #dbe3ee;border-radius: 18px;}QFrame#contentCard {background: #ffffff;border: 1px solid #dbe3ee;border-radius: 22px;}QLabel#logoLabel {color: #0f172a;font-size: 22px;font-weight: 800;padding: 6px 8px 0px 8px;}QLabel#descLabel {color: #64748b;font-size: 13px;padding: 0px 8px 8px 8px;}QLabel#titleLabel {color: #0f172a;font-size: 30px;font-weight: 800;}QLabel#introLabel,QLabel#hintLabel {color: #475569;font-size: 15px;}QLabel#pathLabel {color: #1d4ed8;font-size: 16px;font-weight: 700;background: #eff6ff;border: 1px solid #bfdbfe;border-radius: 14px;padding: 14px 16px;}QStatusBar {background: #ffffff;color: #64748b;border-top: 1px solid #dbe3ee;})");}
四、函数职责分析
TreeMenuItemWidget::TreeMenuItemWidget
构造单个菜单项控件。这里固定了菜单项高度,并设置鼠标追踪、手型光标、无焦点策略和尺寸策略。固定高度是树菜单体验的关键,否则展开层级变多时,布局可能会压缩 item 高度。
TreeMenuItemWidget::setExpanded
设置菜单项展开状态。这个函数不负责创建或销毁子菜单,只负责影响箭头方向。真正的可见菜单项由 MenuPluginTreeWidget 根据树节点状态统一重建。
TreeMenuItemWidget::setSelected
设置叶子菜单项选中状态。后台管理菜单通常需要显示当前页面对应的菜单项,所以叶子节点点击后会保持选中高亮。
TreeMenuItemWidget::text
返回当前菜单项文字。这个函数目前主要用于扩展和调试,后续如果要做搜索、日志、自动化测试,也可以复用这个接口。
TreeMenuItemWidget::enterEvent
鼠标进入菜单项时设置 hover 状态并触发重绘。由于菜单项是自绘控件,hover 视觉需要自己维护。
TreeMenuItemWidget::leaveEvent
鼠标离开菜单项时清理 hover 和 pressed 状态,避免鼠标移走后高亮或按压颜色残留。
TreeMenuItemWidget::mousePressEvent
鼠标左键按下时设置 pressed 状态,用于绘制按压反馈。这里不直接触发点击,因为有效点击应该在 release 时确认。
TreeMenuItemWidget::mouseReleaseEvent
鼠标释放时判断释放位置是否仍在菜单项内部。如果仍在内部,才发出 clicked 信号。这样可以避免按下后拖出控件再释放时误触发。
TreeMenuItemWidget::paintEvent
菜单项绘制的核心函数。它负责绘制悬浮、按下、选中背景,计算层级缩进,绘制父节点三角箭头,并绘制菜单文字。文字过长时使用 QFontMetrics::elidedText 做省略处理。
TreeMenuItemWidget::sizeHint
返回菜单项建议尺寸。高度固定为 42,宽度根据文字长度和层级缩进计算,保证 item 不被压缩,同时给布局提供合理的尺寸参考。
MenuPluginTreeWidget::TreeNode::~TreeNode
内部树节点析构函数。因为子节点使用裸指针保存,所以析构时用 qDeleteAll 递归释放子节点,避免内存泄漏。
MenuPluginTreeWidget::MenuPluginTreeWidget
插件构造函数。它创建 QScrollArea、内部 contentWidget 和 QVBoxLayout,设置滚动条策略,并处理透明背景问题。这里最重要的是让内容容器按真实高度撑开,超出高度时由滚动条处理,而不是压缩菜单项。
MenuPluginTreeWidget::~MenuPluginTreeWidget
插件析构函数。调用 clearMenuData 清理可见控件和内部树数据。
MenuPluginTreeWidget::setMenus
插件对外的核心入口。业务层通过这个函数传入多维菜单数据。函数内部先清理旧数据,再递归构建新的内部树,最后渲染当前可见菜单项。
MenuPluginTreeWidget::buildNodes
递归解析外部 MenuDef 数据,转换成插件内部的 TreeNode。同时记录每个节点的 parent 指针,方便叶子节点点击时回溯完整菜单路径。
MenuPluginTreeWidget::rebuildVisibleItems
根据当前展开状态重建所有可见菜单项。未展开的子树不会创建 QWidget,这样逻辑简单,也避免隐藏大量无用控件。
MenuPluginTreeWidget::scheduleRebuildVisibleItems
延迟重建菜单项。这个函数是稳定性的关键:如果点击父节点后立即清空布局,可能会在 clicked 信号还没返回时删除当前 sender,频繁点击时容易异常退出。使用 QTimer::singleShot(0, ...) 可以把重建延迟到下一轮事件循环。
MenuPluginTreeWidget::appendVisibleNode
递归追加可见节点。每个可见节点都会创建一个 TreeMenuItemWidget,并连接 clicked 信号。如果当前节点已展开,则继续追加它的子节点。
MenuPluginTreeWidget::clearLayout
清空当前布局中的菜单项控件。这里使用 hide + deleteLater,而不是直接 delete,是为了保证 Qt 事件派发期间不会销毁正在处理事件的控件。
MenuPluginTreeWidget::clearMenuData
清空可见控件和内部树数据。setMenus 重新设置菜单、插件析构时都会走这个函数。
MenuPluginTreeWidget::collapseChildren
递归收起指定节点下的所有后代。比如一级、二级、三级菜单都展开后,点击最顶层菜单收起时,它下面所有子菜单的展开状态都要复位。
MenuPluginTreeWidget::buildPath
从当前节点开始沿 parent 指针向上回溯,生成完整菜单路径。这样业务层拿到的不是单个叶子文字,而是完整路径,例如 System Management / User Center / User List。
MenuPluginTreeWidget::handleNodeClicked
点击处理的核心函数。如果点击的是父节点,就切换展开或收起;如果点击的是叶子节点,就设置选中状态,并发出 actionTriggered 信号。
MenuPluginTreeWidget::clearSelectedItem
清理上一个选中的叶子菜单项。这里使用 QPointer 保存当前选中项,即使控件已经被 deleteLater 释放,指针也会自动变空,避免悬空访问。
MainWindow4::buildUi
Demo 的界面初始化函数。它创建左侧菜单容器和右侧内容区域,初始化多级菜单数据,并连接 actionTriggered 信号。
MainWindow4::handleAction
接收插件发出的叶子菜单路径,并展示到右侧内容区和状态栏中。实际项目中可以在这里切换页面、打开模块或触发业务逻辑。
MainWindow4::buildStyleSheet
Demo 的样式函数,主要用于设置窗口背景、侧边栏、内容卡片、文本和状态栏样式。插件自身的菜单项绘制不依赖这里,因此插件可以独立复用。
五、工程化思考
这个树菜单插件的关键点不是“把菜单画出来”,而是职责拆分:
MenuDef
负责对外表达菜单数据。 TreeNode
负责内部树结构和展开状态。 TreeMenuItemWidget
负责单个菜单项绘制和鼠标状态。 MenuPluginTreeWidget
负责递归解析、可见项重建、滚动容器和点击行为。 MainWindow4
只负责传数据和接收回调。
这样设计后,菜单插件不会和具体业务页面绑定。如果后续菜单来自接口、配置文件、权限系统或插件系统,只需要把数据转换成 MenuDef,再调用 setMenus 即可。
六、后续可扩展方向
当前版本已经满足后台管理树菜单的基本需求,但还可以继续扩展:
-
菜单项增加图标字段。 -
支持禁用状态。 -
支持展开/收起动画。 -
支持只允许同级展开一个菜单。 -
支持搜索过滤菜单。 -
支持快捷键或徽标。 -
支持从 JSON 配置加载菜单。 -
支持右侧页面路由映射。
七、总结
MenuPluginTreeWidget 是一个完全自定义的 Qt 树形菜单插件。它没有依赖 QTreeWidget 的默认样式,而是通过 QWidget 自绘菜单项,通过 QScrollArea 提供滚动能力,通过递归数据结构支持多级菜单。
它更适合需要高度定制 UI 的后台管理系统,尤其是希望菜单样式、交互、滚动条、展开规则和回调路径都由自己掌控的场景。
业务侧使用时只需要两步:
m_treeMenu = new MenuPluginTreeWidget(sideBar);m_treeMenu->setMenus({{QStringLiteral("Dashboard"), {{QStringLiteral("Overview")},{QStringLiteral("Workplace")},{QStringLiteral("Analysis")}}},{QStringLiteral("System Management"), {{QStringLiteral("User Center"), {{QStringLiteral("User List")},{QStringLiteral("Role List")},{QStringLiteral("Permission Matrix"), {{QStringLiteral("Menu Permission")},{QStringLiteral("Button Permission")},{QStringLiteral("Data Permission")}}}}},{QStringLiteral("Department")},{QStringLiteral("Post Management")},{QStringLiteral("Dictionary"), {{QStringLiteral("Dict Type")},{QStringLiteral("Dict Data")}}}}},{QStringLiteral("Content Center"), {{QStringLiteral("Article"), {{QStringLiteral("Article List")},{QStringLiteral("Category")},{QStringLiteral("Tags")}}},{QStringLiteral("Banner")},{QStringLiteral("Comments")}}},{QStringLiteral("Operations"), {{QStringLiteral("Order Management"), {{QStringLiteral("All Orders")},{QStringLiteral("Refund Orders")},{QStringLiteral("Invoice"), {{QStringLiteral("Invoice List")},{QStringLiteral("Invoice Settings")}}}}},{QStringLiteral("Member Center"), {{QStringLiteral("Member List")},{QStringLiteral("Level Rules")},{QStringLiteral("Growth Value")}}},{QStringLiteral("Marketing"), {{QStringLiteral("Coupon")},{QStringLiteral("Campaign")},{QStringLiteral("Push Task")}}}}},{QStringLiteral("Monitor"), {{QStringLiteral("Online Users")},{QStringLiteral("Server Status")},{QStringLiteral("Job Log")},{QStringLiteral("Login Log")},{QStringLiteral("Operation Log")}}},{QStringLiteral("Settings"), {{QStringLiteral("Basic Settings")},{QStringLiteral("Security Settings")},{QStringLiteral("Notification Settings")}}}});connect(m_treeMenu, &MenuPluginTreeWidget::actionTriggered, this, &MainWindow4::handleAction);
这样,一个可复用、可扩展、支持多级展开和滚动的后台树形菜单插件就完成了。

▼点个「
」赞,是我持续更新的动力 ▼
夜雨聆风