简介:一份面向Qt/C++开发者的分页组件源码,参考了ElementUI分页控件的交互与简洁风格,解决桌面应用内长列表或数据表格的页码导航问题,适合有一定C++/Qt基础、希望快速获得可复用分页组件的开发者。压缩包包含两个文件:一个头文件负责类定义、信号与槽接口声明,一个实现文件承载页面切换、页号跳转等核心逻辑;整体体积仅3KB,轻量紧凑,便于阅读与嵌入项目。该资源已有1080人学习,是小而实用的参考案例。核心价值在于完整展示了自定义分页导航器从属性设计到事件响应的开发流程,包括当前页、总页数等成员变量定义,上一页、下一页、直接跳转、首页末页快捷跳转等槽函数,以及通过QSS样式表模拟ElementUI外观的写法。同时,结构简明,为后续扩展每页显示条数选择、页码按钮组等功能留出了清晰框架。
1. Qt 分页组件:照着 ElementUI 分页把翻页体验搬进桌面端
做客户端数据列表时,表格里数据一过几百条,翻页就成了刚需。这套 Qt 分页组件,是参考 ElementUI 分页组件交互模型实现的一套 QWidget 控件:页码按钮、总条数、跳页输入、每页条数下拉一应俱全,连省略号的折叠策略都跟前端版保持一致。开发语言是 C++/Qt,但设计思路直接移植自前端组件,QSS 书写方式和 CSS 同源,前端经验能无缝带过来。适合做后台管理工具、数据看板、数据导出客户端的同学;新手拿过去能直接编译运行,熟手可以把它当二次开发底盘,改交互、换皮肤都在这一套结构上动刀。
2. 先把交互模型拆干净:ElementUI 属性/事件/布局三层结构与 Qt 选型
2.1 先把前端分页的交互模型抽出来
ElementUI 的分页组件是一个 Vue 组件,逻辑由 javascript 写成,DOM 操作和响应式更新混在一起。直接往 Qt 搬,最忌讳照着源码一行行翻译,因为 DOM 操作在 QWidget 里根本没有对应物。我一般先抽交互模型:对外暴露的属性就五个,total、page-size、current-page、page-count、pager-count;对外发的事件就两个,current-change 和 page-size-change。内部状态只有一个 current,加一个由 total 和 page-size 推导出来的 totalPage。搞清楚这个模型,后面每一步实现都有据可依。
这个模型里有两个模式要分清。total 模式是常规做法,totalPage = ceil(total / pageSize),total 变了总页数跟着变,适合后端告诉你"一共多少条"的接口;page-count 模式不关心总条数,直接给固定页数,适合那种接口只返回页数、不返回条数的场景。前端版还用 layout 字符串控制区域排列顺序,比如 "total, prev, pager, next, jumper" 就按这个顺序摆五个区块,Qt 侧完全可以照搬这个思路,用字符串映射到布局。顺序在真实产品里是有讲究的:有的后台要把总数放最左边强调数据量,有的要把跳页放最后方便快速定位,做成可配置比写死强得多。
移植顺序我建议固定成三层:先写纯函数算页码序列,再写渲染层把序列变成按钮,最后接信号槽。这样每一步都能单独验证,页码算法可以脱离 UI 跑单元测试,比整个控件黑盒测试好排查得多。
2.2 为什么选 QWidget 组合控件,而不是 QML
同类需求常被问到:直接用 QML 写分页器不是更接近前端吗?QML 里写 JavaScript 逻辑确实顺手,代价是工程要引入 QQuickWidget,老项目里一堆 QWidget 页面突然混进一个 QML 渲染上下文,事件穿透、焦点切换都是麻烦。常见的做法是留在 QWidget 体系里,用 QPushButton 做页码按钮、QLabel 做总条数、QLineEdit 做跳页输入、QComboBox 做每页条数选择,外面套一个 QHBoxLayout。接入成本最低,任何 QWidget 容器里塞进去就能用。顺带说一句,QTableView 本身只有滚动没有分页概念,这个组件补的正是这个缺口。
类骨架我一般这样定义,信号命名直接对齐前端事件,方便前后端对需求时不用翻译术语:
class Pagination : public QWidget { Q_OBJECT public: explicit Pagination(QWidget *parent = nullptr); void setTotal(int total); // total 模式:总条数 void setPageSize(int pageSize); // 每页条数,默认 10 void setCurrentPage(int page); // 外部强制指定当前页 void setFixedPageCount(int pages); // page-count 模式:固定页数 void setPagerCount(int count); // 页码位数量,默认 7 void setLayoutSpec(const QString &spec); // "total,prev,pager,next,jumper" int total() const; int pageSize() const; int currentPage() const; int pageCount() const; signals: void currentChanged(int currentPage); // 对齐 current-change void pageSizeChanged(int pageSize); // 对齐 size-change private: void rebuild(); // 状态变化后重建页码按钮 void applyLayout(); // 按布局规格排列子控件 };信号设计上,currentChanged 只在 current 真正变化时发出,pageSizeChanged 在每页条数下拉切换时发出。两个信号和前端组件的两个事件一一对应,后续接数据层只需 connect 这两个信号,不用关心按钮内部怎么画。rebuild 和 applyLayout 是两个私有入口,前者管数据、后者管布局,职责切分清楚之后,避坑章里很多问题都能收敛到 rebuild 一个函数里排查。
2.3 状态收敛:所有变更都走同一个入口
一个会影响后续所有问题的细节:setTotal、setPageSize、setCurrentPage 这三个 setter,内部都要先做边界夹紧,再统一调 rebuild,任何 setter 都不能直接改成员变量就返回。举个例子,total 从 1000 改成 50,pageSize 还是 10,totalPage 从 100 缩成 5,如果 current 还停在 30,页码序列里根本没有 30,高亮就会悬空。所以 setTotal 内部要重新计算 totalPage,再用 qBound 把 current 夹回合法区间,最后走 rebuild。
不收敛会引发一个很隐蔽的现象:页码按钮和实际 current 对不上,active 高亮停在旧页上,点了新页码又跳回去。这种问题靠打断点很难定位,因为每个 setter 看起来都"没错"。把状态收敛到一条路径上之后,只需要在 rebuild 入口加一行日志,看每次重建前的 current 和 totalPage 即可,排查时间能省一大半。
3. 核心实现:页码计算、按钮渲染与信号发射的一条链路
3.1 页码序列算法:pagerCount 与省略号哨兵
分页器最核心的不是按钮,而是页码序列怎么折叠。ElementUI 默认 pagerCount 是 7,意思是包括省略号在内最多显示 7 个页码位。计算出的序列我放在 QVector 里,用 -1 当省略号哨兵,渲染层看到 -1 就摆一个 "···" 按钮。
分三种情况:总页数小于等于 7,直接全量显示;当前页靠左,显示 1 到 5 再跟省略号和最后一页;当前页靠右,显示第一页、省略号加最后 5 页;中间位置则显示第一页、省略号、当前页前后各一页、省略号、最后一页。这个策略和前端组件在相同输入下产出的序列完全一致:
QVector<int> Pagination::calcPageItems(int current, int totalPage, int pagerCount) { QVector<int> items; if (totalPage <= pagerCount) { for (int i = 1; i <= totalPage; ++i) items.append(i); // 全量显示 return items; } if (current < pagerCount - 1) { // 靠左:1 2 3 4 5 ... totalPage for (int i = 1; i <= pagerCount - 1; ++i) items.append(i); items.append(kEllipsis); // -1 哨兵 items.append(totalPage); } else if (current > totalPage - pagerCount + 2) { // 靠右:1 ... totalPage-4 ... totalPage items.append(1); items.append(kEllipsis); for (int i = totalPage - (pagerCount - 3); i <= totalPage; ++i) items.append(i); } else { // 中间:1 ... current-1 current current+1 ... totalPage items.append(1); items.append(kEllipsis); for (int i = current - 1; i <= current + 1; ++i) items.append(i); items.append(kEllipsis); items.append(totalPage); } return items; }参数说明:pagerCount 是含省略号在内的最大页码位数,默认 7,不建议改成偶数,否则中间态左右页码数不对称;current 在进入函数前要夹到 [1, totalPage] 区间;totalPage 最小必须是 1,哪怕 total 传 0 也要显示第一页。这个函数是纯计算,不碰任何控件,可以直接拿去做单元测试,边界用例就是 totalPage=1、current=1、current=totalPage 这三组。
3.2 渲染层:动态按钮的创建与销毁
拿到页码序列后,渲染层把每个数字变成 QPushButton 放进水平布局。这里有一个容易翻车的点:页码按钮不能复用。同一个按钮在不同 current 下可能代表不同页码,与其做原地更新,不如每次 rebuild 时清掉旧的重新创建。销毁用 deleteLater,不要在按钮的 clicked 信号栈里直接 delete,否则触发 use-after-free:
void Pagination::rebuild() { m_totalPage = (m_mode == TotalMode) ? qMax(1, (m_total + m_pageSize - 1) / m_pageSize) : m_fixedPageCount; m_current = qBound(1, m_current, m_totalPage); // 先夹紧再渲染 QVector<int> items = calcPageItems(m_current, m_totalPage, m_pagerCount); for (QPushButton *btn : qAsConst(m_pageButtons)) { m_pagerLayout->removeWidget(btn); btn->deleteLater(); // 延后销毁,避免悬空指针 } m_pageButtons.clear(); for (int item : items) { if (item == kEllipsis) { QPushButton *dot = new QPushButton(QStringLiteral("···"), this); dot->setObjectName(QStringLiteral("pagerEllipsis")); // 省略号也接点击,左右跳 5 页,见避坑章 } else { QPushButton *btn = new QPushButton(QString::number(item), this); btn->setObjectName(QStringLiteral("pagerBtn")); btn->setProperty("page", item); btn->setProperty("active", item == m_current); connect(btn, &QPushButton::clicked, this, [this, item] { setCurrentPage(item); }); m_pageButtons.append(btn); } m_pagerLayout->addWidget(btn); } }提示:按钮销毁用 deleteLater 而不是 delete,否则在点击信号的调用栈里销毁对象会触发 use-after-free,表现时好时坏,很难复现。
重建逻辑里 qMax 的使用值得注意:total = 0 时,(0 + 9) / 10 在整数除法里结果是 0,qMax(1, 0) 兜底成 1,页面至少保留一个占位页码;m_current 用 qBound 夹紧后,active 高亮永远和 current 一致。每次 rebuild 触发一次布局重排,频率完全可控,几千条数据的分页场景下性能没有任何压力。
3.3 交互层:信号只在值真正变化时发射
交互链路集中在 setCurrentPage 一个方法里。点击页码按钮、点击上一页下一页、跳页输入框回车,最终都走这里,保证状态收敛:
void Pagination::setCurrentPage(int page) { int clamped = qBound(1, page, m_totalPage); if (clamped == m_current) return; // 值没变,不发信号 m_current = clamped; rebuild(); emit currentChanged(m_current); }关键约束是"值没变就 return"。如果不做这个判断,跳页输入框输入同一个页码也会触发 currentChanged,数据层会白白多一次网络请求;更严重的是,如果槽函数里回调 setCurrentPage,会形成信号递归。加这一道判断后,组件在任何路径下都不会重复发信号,这个习惯建议在所有自定义控件里保持。
prev 和 next 的禁用态也在这一层维护:current 是第一页时上一页禁用,是最后一页时下一页禁用。别小看这个状态,很多移植版忘了处理,第一页还能往前点,点完又跳回第一页,体验很粗糙。可以在 rebuild 末尾统一刷新两个按钮的 enabled,不要散落在各个 slot 里。
4. 样式适配:用 QSS 复刻 ElementUI 主题色的三个关键点
4.1 颜色规范:默认、背景、禁用三套皮肤
ElementUI 分页的视觉核心是颜色区分。默认模式下页码按钮透明无边框,悬停和当前页都变成主题色文字;背景模式下按钮带白色底和灰色边框,当前页变成主题色填充、白色文字。这三个关键状态的颜色值可以直接对齐,方便后续换主题色:
| 样式项 | 默认模式 | 背景模式 | 禁用态 |
|---|---|---|---|
| 页码文字 | #606266 | #606266 | #C0C4CC |
| 当前页文字 | #409EFF | #FFFFFF | - |
| 悬停文字 | #409EFF | #409EFF | - |
| 按钮背景 | 透明 | #FFFFFF | #F5F7FA |
| 边框 | 无 | #DCDFE6 | #DCDFE6 |
字号统一 13px,页码按钮最小宽度 24px,高度控制在 28px 到 32px,跟跳页输入框、每页条数下拉保持同一高度。QSS 里直接写这些值,和前端组件的 css 变量一一对应。基础规则长这样:
QPushButton#pagerBtn { background: transparent; border: none; color: #606266; font-size: 13px; min-width: 24px; padding: 0 4px; } QPushButton#pagerBtn:hover { color: #409EFF; } QPushButton#pagerBtn[active="true"] { color: #409EFF; font-weight: 500; }当前页高亮用属性选择器 [active="true"] 实现,而不是给每个按钮单独 setStyleSheet。理由很直接:动态属性切换比逐个改样式省事,属性选择器可以让一套样式表统一管理,换主题色只动 QSS 一处,代码里完全不用改。
4.2 动态属性切换 active 态:必须手动刷新样式
QSS 有一个经典坑:setProperty 改完动态属性后,样式不会自动重算。第一次点击页码时 active 高亮没反应,要等鼠标再动一下才生效。解决方案是每次切换属性后主动 unpolish 再 polish:
void Pagination::setActiveButton(QPushButton *btn, bool active) { btn->setProperty("active", active); btn->style()->unpolish(btn); // 清除旧样式匹配 btn->style()->polish(btn); // 重新解析属性选择器 btn->update(); // 触发重绘 }unpolish 清除按钮当前的样式匹配,polish 重新走一遍样式解析,update 触发重绘。这三行缺一不可,只调 update 不行,因为 QSS 的匹配发生在 polish 阶段,重绘阶段不会重新匹配动态属性。这个行为在文档里写得含蓄,实际踩到一次就能记住。
4.3 背景模式与整体尺寸适配
背景模式的差异在边框和填充,单独用一组规则,通过根节点上的属性开关控制整组样式:
Pagination[bgMode="true"] QPushButton#pagerBtn { background: #FFFFFF; border: 1px solid #DCDFE6; border-radius: 4px; } Pagination[bgMode="true"] QPushButton#pagerBtn[active="true"] { background: #409EFF; border-color: #409EFF; color: #FFFFFF; }属性选择器写在 Pagination 根节点上,内部所有按钮自动继承匹配,比在代码里逐个 setStyleSheet 干净。尺寸方面,分页器高度建议固定,不要随内容撑开;跳页输入框宽度 50px 左右,每页条数下拉宽度 110px 左右,这些都在 applyLayout 里用固定 spacing 控制,避免不同系统字体渲染差异导致布局忽宽忽窄。上一页下一页如果要用 ‹ › 这类文本符号,注意不同字体下符号基线不一样,最好用图标资源或者干脆用文字"上一页/下一页"。
5. 避坑与排查:五个翻车场景的现象、根因与对策
下面五条是从几个实际接入项目里攒下来的血泪经验,每条都按现象、原因、解决三个层次记录,排查时直接对号入座。
5.1 快速翻页后表格显示的是旧请求的数据
现象:快速连续点击第 2 页、第 3 页,表格最终显示的是第 2 页的数据,页码却停在 3。
原因:异步请求返回顺序不可控,第 2 页响应比第 3 页晚到,到达时把新结果覆盖了。这是分页接数据层最常见的翻车点,组件本身没问题,问题在调用方没做过期校验。
解决:给每次翻页请求发一个递增序号,响应回来时序号不是最新的直接丢弃:
connect(pagination, &Pagination::currentChanged, this, [this](int page) { const int seq = ++m_requestSeq; // 每次请求自增 fetchPage(page, [this, seq](const QVariantList &rows) { if (seq != m_requestSeq) return; // 过期响应直接丢弃 m_model->setRows(rows); }); });m_requestSeq 是成员变量,lambda 按值捕获 seq,回调时比对。不等说明已有更新的请求发出,这条是旧货。这个套路在 C++ 异步编程里通用,任何"连续操作只认最后一个结果"的场景都能用。
5.2 点击省略号没反应
现象:页码中间出现 "···",点击毫无反馈,用户以为组件坏了。
原因:前端 ElementUI 里省略号按钮默认可点,点击向前或向后跳 5 页。很多移植版把省略号做成了纯展示的 QLabel,交互能力丢掉了。
解决:渲染省略号时也接 clicked,向左的省略号跳 current - 5,向右的跳 current + 5,再用 qBound 夹紧。跳 5 页是产品层面的设计选择,如果 pagerCount 改大了,步长可以对应调整,但不要做成不可点,否则用户只能一页页点过去,体验断崖式下跌。
5.3 QSS 动态属性不刷新
现象:第一次点击页码高亮不移动,再点一次才正常。
原因:setProperty 修改属性后样式表没有重新匹配,按钮外观停留在旧状态。
解决:每次切换 active 属性后调用 style()->unpolish 和 polish,再加 update()。4.2 节给了完整代码,凡是 QSS 用到属性选择器的控件都会遇到,不只分页器。排查这类问题先在代码里搜有没有改完属性忘了刷新样式。
5.4 分页器放在滚动区域里,鼠标滚轮被外层吃掉
现象:鼠标悬停在分页器上方滚轮,滚的是外层 QScrollArea 的内容,分页器没反应。
原因:wheelEvent 默认向上传播,外层滚动区域优先处理了滚轮事件,分页器没有机会响应。
解决:分页器重写 wheelEvent,把滚轮事件接收下来做翻页,再调用 accept 阻断传播。同时用 QElapsedTimer 做节流,500ms 内最多翻一页,防止滚一下连翻好几页。如果产品不接受滚轮翻页,至少 accept 掉事件,避免外层列表跟着滚造成误触。
5.5 total 传 0 或 pageSize 传 0 导致界面异常
现象:total 传 0 时页码区空白;pageSize 传 0 时程序崩溃或显示 0 页。
原因:调用方没做输入合法性检查,组件也没兜底。total=0 时向上取整得到 0 页,pageSize=0 时除法直接溢出。
解决:组件内部做防御,totalPage 用 qMax(1, ...) 保证至少 1 页;pageSize 在 setter 里拦截非法值,回退到默认 10。组件要做可靠的输入过滤,别把希望寄托在调用方守规矩上。
6. 进阶用法:把分页组件接进 QTableView 数据层并固定验证流程
组件稳定之后,真正决定好不好用的是它和数据层的配合方式。我一般把分页器、表格模型、请求回调组织成一条单向链路:分页器发信号,控制器发请求,响应回来填模型,模型刷新表格。关键约束是控制器只管"当前第几页",表格只管"显示哪些行",谁都不直接碰对方的内部状态。
组装时还有一个习惯值得留:把页码状态存下来,页面切换回来时恢复。桌面工具经常有关闭面板再打开的场景,每次打开都回到第 1 页,用户翻了半小时的数据就白翻了。在关闭事件里记录 currentPage 和 pageSize,重建窗口时 setCurrentPage 回去,请求数量也能省一半。
m_pagination = new Pagination(this); connect(m_pagination, &Pagination::currentChanged, this, [this](int page) { fetchPage(page, m_pagination->pageSize()); }); connect(m_pagination, &Pagination::pageSizeChanged, this, [this](int size) { fetchPage(1, size); }); void DemoWindow::fetchPage(int page, int pageSize) { // 按 page/pageSize 拼参数请求,回调里过滤过期序号 }注意:fetchPage 里务必把 pageSize 作为参数传进去,不要在回调里现取分页器对象的值,避免请求发出后 pageSize 已变导致响应错位。
验证方法可以固定一套:准备 101 条数据,pageSize 设 10,总页数 11。依次把 current 设为 1、5、6、7、11,打印页码序列,跟前端版在线 demo 逐一比对;再快速连点 10 次下一页,确认最终数据和页码一致。这套用例跑完,边界基本就稳了。
从那以后我每次接分页组件,第一件事就是把 total=0、currentPage 越界、快速连点翻页这三组用例在测试里跑完,再进 UI 调样式。这个习惯是从翻车记录里一条一条赔出来的,希望帮到你。
本文还有配套的精品资源,点击获取