news 2026/9/9 5:17:15

QTestLib实战:Qt项目单元测试从入门到CI集成

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
QTestLib实战:Qt项目单元测试从入门到CI集成

搞Qt项目做了这么多年,我的测试方案其实换过三轮:最早是printf大法,后来短暂尝试过Google Test,最后才真正把QTestLib用进主力工程。绕了一大圈才发现,QTestLib并不是“Qt官方顺便给的阉割测试框架”,而是唯一能和QObject元对象、信号槽、重载的GUI事件机制无缝咬合的测试工具。这篇内容是我从“只知道QTestLib可以写断言”到能把它实际落地到日常工程维护的完整学习复盘,适合两类人:一类是在Qt 5/Qt 6项目里想补测试但不知道怎么起步的C++开发者,另一类是已经会用Google Test但总被Qt特有的私有槽、GUI事件信号搞得不太顺手的测试进阶者。想一次性说清楚QTestLib怎么入门、怎么避坑、怎么把它接进CI,在真正动手前不被劝退。

1. 选型时的纠结:为什么Qt项目里QTestLib反而是最稳的起点

1.1 测试Qt应用最难的不是断言,而是对象生命周期和事件循环

很多人写Qt测试遇到的第一道坎,并不是不会写QVERIFY,而是被测代码只要一涉及信号槽、定时器、界面刷新,就发现自己完全没法控制执行时机。Google Test这类通用C++测试框架擅长的是“给定输入、调用函数、比较结果”,可一旦被测对象内部藏着QTimer::singleShot、跨线程信号连接或者QWidget重绘,测试函数早就返回了,异步回调还没跑完,测试结果自然一团糟。

QTestLib真正的核心价值,是它把Qt的事件循环直接纳入了测试执行模型。QSignalSpy可以同步等待信号出现,QTest::qWaitQTRY_*宏能在等待条件满足时继续驱动事件循环,让槽函数有机会被调用。这个能力不是靠外部框架打补丁就能做好的——它需要和Qt元对象系统深度绑定,只有Qt自家维护的框架能做到这么顺。

1.2 三个框架放一起对比,才算把选型理由看明白

我在纸上做过一次很实在的横向比较,不吹不黑,每种框架都有自己最合适的场景:

对比维度QTestLibGoogle TestCatch2
Qt类型与信号槽支持原生支持,QSignalSpy直接监听需自己写辅助封装需自己处理事件循环
测试类发现机制QObject的private slots + moc自动注册宏注册,链接期自动展开宏 + 编译期注册
GUI事件模拟QTest::keyClick / mouseClick内置无内置能力无内置能力
输出格式文本、XML、JUnit等XML报告多靠第三方转换需借助第三方插件
对Qt版本兼容性与Qt同步发布,基本无兼容成本需要自己控制Qt版本适配需自行适配
纯C++逻辑测试体验稍显冗长,防御性强非常成熟顺手表达式拆分很好用

如果被测代码是脱离Qt的纯算法模块,我完全不反对继续用Google Test,它的参数化和死亡测试在纯C++领域确实成熟。可对于一线Qt客户端项目,被测对象十个里有八个都继承自QObject,那为每个小模块引入两套测试框架、维护两套CI命令,代价远超过收益。QTestLib最大的优势从来不是“最强”,而是“最贴合”。

1.3 Qt官方维护带来的另一个隐性好处:版本迁移成本低

还有一个容易被忽略的点:QTestLib是Qt官方模块的一部分,Qt 5到Qt 6迁移时,测试代码跟着同步迁移就好,接口大方向保持一致。我之前维护过一个遗留Qt Widgets程序,从Qt 5.12升到Qt 6.5时,业务代码改了一堆弃用API,但测试工程差不多只改了CMake的find_package写法和少量头文件包含路径。对比某第三方测试框架因为RTTI开关、异常策略和Qt的定制构建选项冲突时的整改成本,官方维护这个“名分”在选型时确实是重要加分项。

2. 从空项目开始:CMake接入QTestLib并跑通第一个用例

2.1 测试模块的依赖关系:不要忘了Test组件的显式链接

无论你用Qt 5还是Qt 6,QTestLib都不会被自动带进.exe。CMake里除了最基础的Qt模块之外,还必须单独声明Test组件。Qt 6的target_link_libraries一般要写成Qt6::Test,Qt 5则对应Qt5::Test,项目迁移时这一点很容易漏。

我习惯把测试目标和产品代码完全分开目录,测试可执行文件不参与安装,方便CI按需过滤。一个最小工程骨架长这样:

cmake_minimum_required(VERSION 3.21) project(qt_test_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 REQUIRED COMPONENTS Test) include(CTest) if(BUILD_TESTING) add_executable(tst_qstring testqstring.cpp) target_link_libraries(tst_qstring PRIVATE Qt6::Test) add_test(NAME tst_qstring COMMAND tst_qstring) endif()

CMAKE_AUTOMOC必须打开,否则测试类里的Q_OBJECT宏不会被处理,最终链接时会报类似“vtable for TestQString not found”的错误。这一步基本算是QTestLib新手最容易踩的编译坑。

2.2 测试类骨架与入口宏的选择

QTestLib的测试类本身就是一个QObject子类,这一点和Google Test完全不同。被测用例不是普通函数,而是类里的private slots成员函数。框架通过moc生成的元对象信息,在启动时遍历这些槽函数并依次执行。

一个最简单的字符串测试类是这样的:

#include <QtTest> class TestQString : public QObject { Q_OBJECT private slots: void toUpper(); }; void TestQString::toUpper() { QString str = "hello"; QCOMPARE(str.toUpper(), QString("HELLO")); } QTEST_APPLESS_MAIN(TestQString) #include "testqstring.moc"

文件末尾的#include "testqstring.moc"是个非常容易忽略的细节。当类的定义和实现全部放在.cpp里时,moc生成的内容也要随着这个编译单元一起展开,少了这行,链接阶段会直接报错。如果你更习惯把类声明放在头文件里,那么cmake会自动让moc处理头文件,但务必在头文件里包含Q_OBJECT,并且记得在CMake中开启AUTOMOC。

2.3 三种入口宏,别等运行时报错了才去查

  • QTEST_APPLESS_MAIN:不创建任何QApplication/QGuiApplication,只测纯逻辑类,链接依赖最轻。
  • QTEST_GUILESS_MAIN:会创建一个QGuiApplication,适合不是Widgets却有QGuiApplication需求的场景,比如依赖QWindow的测试。
  • QTEST_MAIN:创建完整的QApplication,测试QWidget派生的控件时基本首选。

我之前犯过一个低级错误:测试一个普通QLineEdit的子类时用了QTEST_GUILESS_MAIN,结果在Linux桌面上跑起来怪怪的,部分依赖窗口系统的事件根本发不到控件里,换成QTEST_MAIN并链接Qt6::Widgets后一切正常。如果测试代码不碰GUI,尽量用QTEST_APPLESS_MAIN,执行更快,也不容易在无头CI环境里卡住。

2.4 命令行参数是初期调试的好朋友

QTestLib默认入口宏已经帮你解析了常见命令行参数。跑测试时加上-functions,可以先把框架识别到的全部测试槽函数列出来,确认函数有没有被成功注册:

./tst_qstring -functions

想单独跑某一个测试函数,直接把它当参数传进去:

./tst_qstring toUpper

这个习惯对排查“为什么测试数量不对”非常有效——很多时候不是用例没写,而是函数没放在private slots:下面,或者访问权限不是private。槽函数放错位置不会被QTest发现,你不会看到任何警告,只有用例数量异常。

3. 断言体系的实战子集:别把所有检查都写成一个巨大的QVERIFY

3.1 基础断言和“失败后怎么办”的语义差异

QTestLib的断言宏最常用的是QVERIFYQCOMPAREQVERIFY(condition)只判断条件真假,失败后会在日志里打印出代码位置,然后立刻返回当前测试函数。QCOMPARE(actual, expected)则额外负责打印两边的实际值,失败信息直观很多,能用它的时候尽量别用裸的QVERIFY

这里必须理解一个容易误解的地方:断言失败不会导致整个测试程序崩溃,也不会让后面的其他测试函数停止。每次QVERIFY失败,该测试函数直接返回,但下一个测试槽函数照常执行。所以一个用例内部最好只验证一个行为主体,否则前面失败跳到函数末尾,后面几个断言其实根本不会跑到,误以为“全过了”或者“挂了”。

我在实际工程中常见的对比体验是这样:

QCOMPARE(obj.value(), 42); // 失败能看到实际值 QVERIFY(obj.value() == 42); // 失败信息里只有true/false QVERIFY2(obj.isReady(), "模块未初始化完成"); // 带自定义信息的断言

3.2 浮点比较别直接用QCOMPARE

表面上看,QCOMPARE(1.23456, 1.23456)好像没问题,但只要两个浮点数来自不同计算路径,哪怕误差只有1e-15,QCOMPARE也会报失败,因为它的实现基于类型自身的operator==,而这种比较对浮点来说本身就是不稳定的。

我处理浮点断言的默认写法是:

QVERIFY(qAbs(actual - expected) < 1e-9);

如果整个项目对浮点误差有自己的容差策略,推荐封装一个expectNear辅助函数,统一维护容差。否则测试里散落着各种1e-61e-8,出问题后检查的人根本不知道当初为什么选这个量级。

3.3 失败信息与日志结合:让问题不是“红了”而是“看得懂”

QTestLib执行时可以用-v1-v2控制输出等级。-v1会打印每个测试函数是否通过,-v2会连QVERIFY里的具体比较信息都带出来。平时全绿的时候默认输出干净清爽,一旦用例失败,就需要有足够上下文判断问题在哪个环节。

我的习惯是在测试类里按模块加QTest::qWait观察异步行为,或者用QWARN输出关键路径信息:

void TestDemo::init() { // 每条测试运行前执行,输出一些辅助信息 QWARN("initTestCase can not use QVERIFY"); }

QTestLib里有个特殊细节:initTestCase()是整个测试类的第一个执行函数,cleanupTestCase()是最后一个,而init()cleanup()则会在每个槽函数前后执行。如果需要在每条用例前重置某个系统状态,不要放在构造函数里写,而是放init(),这样才能保证测试间的独立。

4. 数据驱动测试:同一段逻辑用一张表测透

4.1 从手写循环到数据驱动,变化的不只是代码量

测试解析函数或者状态机时,经常同一套逻辑要覆盖几十组输入。最容易想到的写法是在测试函数里写一个数组,然后for循环挨个断言。这确实能跑,但问题是:一旦第3条用例挂了,整个函数立刻返回,后面第4到第20条用例全部被跳过,可你根本不知道后面还有多少坏数据。

QTestLib的数据驱动机制完美解决了这个问题。核心约定是:如果测试槽函数叫columnTest,那就在同一个类里再写一个columnTest_data()函数,用addColumn声明要传入的列,然后用newRow一行一行添加数据。QTest会自动把每一行当成一次独立的用例执行,任何一行失败都不影响其他行。

4.2 addColumn、newRow、QFETCH三者怎么配合

来看一个实际例子,假设有段代码负责把十六进制字符串转成字节数组:

class TestHexParser : public QObject { Q_OBJECT private slots: void parse_data(); void parse(); }; void TestHexParser::parse_data() { QTest::addColumn<QString>("hexInput"); QTest::addColumn<QByteArray>("expectBytes"); QTest::newRow("empty string") << QString("") << QByteArray(); QTest::newRow("single byte") << QString("0A") << QByteArray::fromHex("0A"); QTest::newRow("multi bytes") << QString("001122FF") << QByteArray::fromHex("001122FF"); QTest::newRow("lower case") << QString("ff") << QByteArray::fromHex("ff"); QTest::newRow("odd length") << QString("abc") << QByteArray(); } void TestHexParser::parse() { QFETCH(QString, hexInput); QFETCH(QByteArray, expectBytes); QCOMPARE(parseHexString(hexInput), expectBytes); }

QFETCH的作用是把当前行对应的数据取出来声明成一个局部变量,类型必须和addColumn里声明的一致,否则运行时会报错。newRow后面传入的字符串是对这条数据的描述,它会在失败日志中展示,命名的时候一定要让人一眼看出“这行到底在测什么情况”,不要写row0row1这种没意义的名称。

4.3 数据函数里能放的不只是输入和期望值

数据驱动不仅可以传输入输出,还能传测试行为开关、错误类型枚举,甚至超时时间。比如测网络组件时,把“期望是否成功”和“等待毫秒数”放进表里,一行代表一种网络延迟场景,可读性比在函数体内堆if-else好太多。

唯一要注意的是,数据函数每行的列数、顺序必须一致,否则运行时会警告或直接读取错误数据。还要注意,如果你用QSKIP跳过某些情形,一定要在测试函数里检查数据条件,比如:

QSKIP("该分支只在Windows下测试");

QSKIP会跳过当前这条用例,但仍算“符合预期”,非常适合跨平台差异场景。

5. 打开Qt项目的护城河:私有槽、信号监听与GUI事件注入

5.1 测试私有成员的经典取舍

QTestLib的“private slots”经常被误读成“用来自动测试被测类的私有成员”,这是个大坑。它实际上只是说测试类自身的用例函数是私有槽;至于被测类的private方法,框架并不会帮你突破C++访问控制。

不过QTestLib背后有moc能力加持,让被测类把测试类声明为friend是最直接的方案。我在工程里的做法是给被测类加一行前置声明:

class TestBusinessCore; // 测试类前置声明 class BusinessCore { friend class TestBusinessCore; private: int calcInternal(const QByteArray &rawData); };

这种方式有一点模式侵入,但代价远低于#define private public这种危险写法。后者会影响包含顺序,一旦产品代码和第三方头文件混在一起,宏展开后可能把别人的private都变成public,造成的链接问题诡异到让人怀疑人生。真要测试私有逻辑,优先考虑让被测类提供一个testOnly接口前缀的公开方法,或者干脆把复杂逻辑抽出来放到一个不依赖GUI的纯C++类里,这样对测试最友好。

5.2 QSignalSpy是Qt测试的顶级福利

在非Qt的C++测试里,想验证“某个信号被发出”需要手动埋桩,工程量大且容易污染生产代码。QTestLib里一个QSignalSpy就可以解决一切:

class TestLoginController : public QObject { Q_OBJECT private slots: void loginFailedShouldEmitSignal(); }; void TestLoginController::loginFailedShouldEmitSignal() { LoginController controller; QSignalSpy spy(&controller, &LoginController::loginFailed); controller.login("tester", "wrong-password"); QCOMPARE(spy.count(), 1); QList<QVariant> arguments = spy.takeFirst(); QString errorMessage = arguments.at(0).toString(); QCOMPARE(errorMessage, QString("invalid credential")); }

QSignalSpy会在创建后自动监听目标信号,信号一旦触发就会把参数解析成QVariant列表存起来。如果测试里涉及异步登录,可以用spy.wait(3000)等最多3秒,而不是裸用QThread::sleep去卡住整个测试线程。

这里有个容易踩的细节:用信号函数指针初始化QSignalSpy时,如果信号有重载,编译器可能不知道你指的是哪个。需要强转为对应成员函数指针,或者用QOverload指明:

QSignalSpy spy(&controller, QOverload<const QString &>::of(&LoginController::loginFailed));

5.3 模拟键盘鼠标事件:QTest::keyClick与mouseClick

测试QWidget时,直接调用控件的public方法常常绕过了用户真实操作链路。要验证快捷键、焦点切换、输入法事件这类交互逻辑,最好直接注入QEvent。QTestLib为此提供了QTest::keyClickQTest::keyClicksQTest::mouseClick等方法。

一个典型的行编辑输入用例:

void TestInputWidget::singleLineTextInput() { QLineEdit edit; edit.show(); QVERIFY(QTest::qWaitForWindowExposed(&edit)); QTest::keyClicks(&edit, "Qt Test"); QCOMPARE(edit.text(), QString("Qt Test")); }

写GUI测试时有个通用认知:必须先让窗口显示并等待它真正暴露,很多控件在未显示状态下,事件分发路径和正常使用不一样。edit.show()之后立刻keyClicks并不是100%可靠的,稳妥写法是QTest::qWaitForWindowExposed

无显示器CI环境里跑GUI测试,需要设置离屏渲染平台:

QT_QPA_PLATFORM=offscreen ./tst_inputwidget

不然Qt会去连X11或Wayland,而CI容器里通常什么都没有,测试直接启动失败。

6. 实战中的硬骨头:资源路径、异步等待与测试隔离

6.1 QRC资源和相对路径在不同工作目录下的差异

QTestLib测试默认的工作目录通常不是源码目录,而是CMake配置的构建目录。如果产品代码用相对路径去读配置文件,比如QFile("config/app.ini"),测试时大概率会找错位置。这个不是框架问题,却是我见过最常见的Qt测试挂掉原因。

Qt为解决这个问题提供了QFINDTESTDATA宏,它会依次在多个常用位置寻找给定文件,适合定位源码树里的测试数据:

QString iniPath = QFINDTESTDATA("data/app.ini"); QVERIFY2(!iniPath.isEmpty(), "找不到测试数据文件"); QSettings settings(iniPath, QSettings::IniFormat);

至于编译进可执行文件的qrc资源,用":/scheme/app.ini"访问基本不会受工作目录影响,因为资源始终固定在二进制内部。如果测试时发现qrc里的资源找不到,第一反应应该去看.qrc文件是否正确列入了编译目标,而不是怀疑路径。

6.2 不要用sleep去等异步结果

新手很容易写出这样的代码:

controller.startAsyncTask(); QThread::msleep(2000); QCOMPARE(controller.result(), 1);

这种代码在本地开发机上可能碰巧能过,换到CI上要么太慢、要么竞态导致偶发失败。QTestLib的正确思路是“事件驱动等待”,用QSignalSpy::wait或者QTRY_*宏轮询条件。

举个例子:

QSignalSpy finishedSpy(&controller, &AsyncController::finished); controller.start(); QVERIFY(finishedSpy.wait(5000));

如果不方便监听信号,也可以用带条件判断的宏:

QTRY_COMPARE_WITH_TIMEOUT(controller.progress(), 100, 3000);

QTRY_*宏的底层会在等待期间持续处理事件循环,这比QThread::msleep高明得多,也不会让自己的UI主线程假死。涉及定时器、网络、线程池的测试一定要特意想到这一点。

6.3 测试状态污染:init和cleanup要覆盖的资源

QTestLib里每个测试槽函数会按升序执行,但对象成员生命周期贯穿整个测试类。如果一个测试修改了某个成员变量而没有还原,后一个测试读到的就是脏数据。为了防止此类问题,我通常在init()里重新构建被测对象,在cleanup()里销毁并检查残留:

void TestManager::init() { manager = new Manager(); } void TestManager::cleanup() { delete manager; manager = nullptr; }

另外还需要留意全局单例污染。Qt本身大量使用单例,比如QNetworkAccessManager的缓存、全局消息处理器。跑到多个测试类之间,这些单例状态并不会自动清空。建议在所有测试用例集中在同一个进程执行时,把特别容易互踩状态的测试单独拆成一个可执行文件,而不是试图在单个进程里做完美清场——后者的维护成本通常比收益高。

7. 把测试变成日常流程:CTest聚合、报告输出与后续加分项

7.1 用include(CTest)把所有测试目标聚合起来

随手写单文件测试很容易,难的是让整个团队的测试都能一键跑起来。我在工程根CMake里会统一开启include(CTest),每一个QTestLib测试目标都紧跟一句add_test。这样在构建目录里只要执行:

ctest --output-on-failure

就会自动发现所有已注册的测试目标,并且按失败返回值汇总。

对稍微大型一点的Qt工程,多个测试可执行文件并行跑能显著减少等待时间:

ctest -j4 --output-on-failure

但要注意,如果多个GUI测试放在不同进程里并行执行,恰好又共享同一个临时目录、同一个资源文件端口,数据竞争依然存在。所以在设计测试时,凡是写临时文件最好都使用QTemporaryDir,让每个进程拿到独立目录。

7.2 通过add_test参数把JUnit XML交给CI

很多CI平台天生擅长解析JUnit格式的XML。QTestLib执行时可执行文件可以自己输出结构化报告。我的用法不是改产品代码,而是在CMake里给测试命令追加参数:

add_test( NAME tst_demo COMMAND tst_demo -o ${CMAKE_BINARY_DIR}/testresults/tst_demo.xml,xunitxml )

这样跑完ctest后,对应目录下就留下标准化结果文件。CI面板上每个测试函数都会单独展示,定位哪个数据行失败会快很多。

需要提醒的是,如果同时还要把原生文本日志保留下来,可以再输出一份txt格式:

./tst_demo -o result.txt,txt -o result.xml,xunitxml

7.3 更进一步:覆盖率统计与故障定位习惯

测试的最终目的不是堆函数数量,而是让重构有安全网。QTestLib没有额外绑定覆盖率工具,但Qt项目的代码覆盖率统计沿用编译器工具链就好:编译时加--coverage,跑完测试后用gcov或lcov生成报告。我自己不会把覆盖率指标当成KPI教条,但会特别关注核心解析、状态转换、协议编解码这几类模块的命中率,因为它们的回归成本往往最高。

除此之外,团队协作时一定要有一个约定:任何bug修复都必须至少补一条对应这个bug的QTestLib用例,并且要先写用例,再修代码。用QTestLib执行一个原本会失败的用例,确认它红了,再让被测代码变绿,这个流程能有效防止“随手改了代码但测试根本没覆盖到”的情况。

我自己的个人体会是,如果只想临时验证点东西,那用QTestLib的输出宏临时打印也足够,但真正想让它发挥作用,一定要尽早把测试写进CMake和CI流程。测试框架的学习不是背几个断言宏,而是建立起“哪些场景适合纯逻辑测试、哪些场景必须用信号等待、哪些场景干脆要用离屏GUI”的判断力。把这几个问题想清楚了,QTestLib在你手上会变得非常顺手。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 5:17:10

MicroPython驱动MY18E20:单总线温度传感器时序解析与可靠实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 5:15:49

三维A*路径规划算法详解:从栅格地图构建到Matlab实现

1. 三维路径规划的开局&#xff1a;为什么我从二维改写到了三维做飞行器路径规划的朋友应该都有体会&#xff0c;入门时接触的A*教程十个里有九个是二维地图上的方格寻路&#xff0c;剩下那一个是二维栅格地图加了个高度伪影。真正到了无人机、导弹或者低空飞行器的任务场景里&…

作者头像 李华
网站建设 2026/9/9 5:14:52

Claude Code本地化实战:CC Switch协议适配与Codex代理链路解析

1. “ruflo”不是工具名&#xff0c;而是被误传的开发代号与社区黑话 最近在多个技术社区、AI开发者群和VS Code插件讨论区里&#xff0c;“ruflo”这个词高频出现&#xff0c;但几乎没人能说清它到底指什么——有人把它当做一个新发布的CLI工具&#xff0c;有人以为是Claude C…

作者头像 李华
网站建设 2026/9/9 5:14:29

D-H算法详解:密钥交换原理、安全缺陷与工程实践

1. 先搞清楚D-H算法到底解决什么问题1.1 没有它之前&#xff0c;密钥分发是个死结很多人第一次接触信息安全里的密钥交换&#xff0c;都会觉得D-H算法像个魔术&#xff1a;两个人明明没有提前约定任何秘密&#xff0c;甚至中间还隔着一个什么都听得见的窃听者&#xff0c;最后却…

作者头像 李华
网站建设 2026/9/9 5:12:29

Rust+Tauri打造的开源本地视频剪辑器WolfCut实测与解析

从“剪映”跳到“本地剪辑”&#xff0c;我在GitHub周榜上蹲到了这个叫 WolfCut 的项目。标题很直白&#xff1a;Rust Tauri 打造的开源本地视频剪辑器&#xff0c;定位是免费无水印的 CapCut&#xff08;剪映海外版&#xff09;替代方案。作为一个常年被剪映的云素材、会员模…

作者头像 李华
网站建设 2026/9/9 5:10:47

基于SpringBoot+Vue的瑜伽馆会员预约管理系统设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华