news 2026/10/24 1:04:30

LabWindows/CVI基础教程:用C语言轻松开发上位机界面与波形显示

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LabWindows/CVI基础教程:用C语言轻松开发上位机界面与波形显示

简介:这是一份面向测控领域开发者的LabWindows/CVI入门教程,适合具备C语言基础、熟悉Windows操作系统的读者,用于快速掌握该集成开发环境的上位机程序设计思路。教程以实例驱动,从创建用户界面、添加按钮与图形控件,到编写响应回调函数,完整演示了正弦波生成、清除与退出程序的过程;对菜单路径、控件属性修改等关键操作均有明确标注,有助于减少初学者的摸索成本。资源为单个PDF文档,约1.08MB,内容紧凑精炼。已有978人学习下载,适合需要系统入门LabWindows/CVI或快速回顾开发流程的测控相关学生与工程师。

1. 老牌测控 IDE 还值得上手:LabWindows/CVI 与 C 语言上位机开发的现实选择

这两年上位机开发的路子越铺越宽,C#、Python 都能画界面,可真到产线、实验室这种环境,设备厂商给的 SDK 常常只是一个 C 语言 DLL。回头用 LabWindows/CVI,也就是常说的 LabwindowsCVI,反而阻力最小:它以 ANSI C 为内核,把控件拖拽、函数面板、回调机制都封装成 C 语言能直接调用的形式,界面和采集在一个工程里干完。

这套 LabWindows/CVI 基础教程用“按下 Acquire 生成正弦波、按下 Clear 清空图形、按下 Quit 退出”的小程序,把 UIR 设计、自动生成代码框架、回调函数补逻辑整条流程串起来,每章对应一个具体例子。适合没碰过 CVI 但有 C 语言基础的人,也适合想用 C 语言做上位机开发的团队当入职练习。不用啃长篇大论,照着点鼠标就能跟上。

2. 从空工程到能跑的界面:UIR、面板属性与三个按钮的搭建流程

2.1 为什么先拖控件,而不是手写窗口代码

在 CVI 里做界面,第一反应不该是打开编辑器敲窗口类、消息循环这些,而是先建一个 User Interface 文件(*.uir)。CVI 的界面是资源式管理:面板和控件都放在 UIR 文件里,用鼠标拖出来、双击改属性,代码只需要通过常量名去引用控件。

对比 MFC 或者纯 Win32 那套创建窗口、注册类的流程,这套方式最大的好处是界面改动不需要动 C 代码,控件位置调一调,重新生成一次代码框架就能同步。对熟悉 C 语言、但不熟悉 Windows 平台界面编程的开发者来说,这是最平滑的入口,也是这份教程把 UIR 放在第一个实操步骤的原因。

2.2 新建工程与 UIR 文件的正确姿势

启动 CVI 之后,第一步是新建项目,然后从 File>>New 里选 User Interface(*.uir),CVI 会弹出一个带 Untitled Panel 的空白窗口。往面板上放控件,是在面板的灰色区域点右键,选 Command Button>>Square Command Button 创建按钮,选 Graph>>Graph 创建波形显示区。

本示例共需要三个方形按钮和一个 Graph,拖完后用鼠标把它们摆到目标位置。这一步常见问题是很多人会先去写 .c 文件,再回头补 UIR,结果发现控件常量没地方定义。正确顺序是先建 UIR,再让 CVI 生成代码框架,编译器才不会报 PANEL、ACQUIRE 这些宏找不到。

新版 IDE 的菜单入口和旧版略有差异,但右键添加控件这个交互从 8.0 那一代到现在一直没变,跟着教程的截图走也能对得上。

2.3 控件属性里最容易混淆的三组名字

双击面板空白处,会弹出 Edit Panel 窗口。这里有两个属性要改:Panel Title 决定面板顶部标题栏显示的文字;Auto-Center Vertically 和 Auto-Center Horizontally 勾上后,程序运行时面板会自动居中。双击 Graph 控件,把 Label 改成 ShowWave。

真正容易混的是按钮编辑窗里的三处,很多新手在这里踩坑:

属性示例值作用
Constant NameACQUIRE / CLEAR / QUIT代码里引用控件用的宏名,生成后写进头文件
Callback FunctionAcquireCallback / ClearCallback / QuitCallback控件事件触发时被调用的函数名
LabelAcquire / Clear / Quit界面上显示的文字,带下划线的字母支持 Alt+字母热键

Constant Name 是给编译器看的,Label 是给人看的,Callback Function 是 CVI 做事件分发的关键。初学者容易只改 Label 不改 Constant Name,然后代码里继续写旧宏名,编译到一半就报未定义。还有一点:Label 里写成 Acquire 时那个下划线不是装饰,它表示 Alt+A 可以直接触发这个按钮。

2.4 让 CVI 生成代码框架:All Code 到底生成了什么

界面摆好后,让 UIR 窗口处于当前激活状态,选 Code>>Generate>>All Code。弹出的对话框里要注意两个选项:Generate WinMain() instead of Main() 决定入口函数形式,一般选默认的 Main 即可;Select QuitUserInterface Callback 选 QuitCallback,这样程序退出时由 Quit 按钮负责收尾。

点击 OK,CVI 会自动生成一个包含 main 和三个回调函数框架的 .c 文件,以及配套的头文件。生成出来的 main 函数长这样:

#include <cvirte.h> #include <userint.h> #include "FirstSample.h" static int panelHandle; int main (int argc, char *argv[]) { if (InitCVIRTE (0, argv, 0) == 0) return -1; /* out of memory */ if ((panelHandle = LoadPanel (0, "FirstSample.uir", PANEL)) < 0) return -1; DisplayPanel (panelHandle); RunUserInterface (); DiscardPanel (panelHandle); return 0; }

这段代码是 CVI 自动生成的模板,不需要手写,但需要看懂每一行。InitCVIRTE 负责初始化运行时环境,参数 0 表示使用默认线程模式;LoadPanel 把 FirstSample.uir 文件里的 PANEL 面板装载进内存,返回面板句柄,负数代表加载失败。

DisplayPanel 把面板显示出来;RunUserInterface 进入消息循环,程序会一直停在这里,直到某个回调里调用了 QuitUserInterface 才返回;最后 DiscardPanel 释放面板资源。panelHandle 被声明成 static 全局变量,这样 AcquireCallback、ClearCallback 里也能引用它。

第一次看到这段代码的人会好奇为什么没有 while 循环,这正是 CVI 替你包了一层消息泵,你要做的只是往回调函数里填业务逻辑。

2.5 先编译一次:把空框架跑起来再填逻辑

生成完代码先别急着填波形,先按 F7 编译运行一次。这一步能验证三件事:UIR 文件路径是否找得到、头文件里的控件常量是否和 UIR 同步、运行时环境初始化是否正常。如果程序弹出一个带三个按钮的面板,并且点 Quit 能退出,说明工程骨架已经通了。

常见编译失败原因有两个。一是 FirstSample.h 没有自动生成,去工程目录确认头文件存在;二是 .c 里引用的回调函数名和 UIR 里填的名字不一致,编译时报告 unresolved external。处理办法是回 UIR 检查 Callback Function 字段,改完重新 Generate All Code,不要手动去 .c 里改名,两边容易再次脱节。

3. 回调函数与事件驱动:把 Acquire/Clear/Quit 三个动作装进 EVENT_COMMIT

3.1 事件模型:按钮被按下之后发生了什么

CVI 程序跑起来以后,RunUserInterface 不返回,操作系统把鼠标点击、键盘输入、绘制消息不断送进来。CVI 的内部机制会把 Windows 消息翻译成控件事件,再分发给控件注册的回调函数。

对命令按钮来说,用户按下再松开,控件收到的是 EVENT_COMMIT;鼠标右键事件是 EVENT_RIGHT_CLICK,键盘焦点变化是 EVENT_GOT_FOCUS 这类。回调函数原型是固定的,CVI 生成代码时已经给了完整签名:参数 panel 是哪块面板触发的,control 是哪个控件,event 是具体事件值,callbackData 是注册时传入的自定义数据指针,eventData1 / eventData2 是事件附带的数据。

对于按钮的 EVENT_COMMIT,后两个参数一般用不到,保持 0 就行。写回调的第一步就是确认 switch 里有没有 case EVENT_COMMIT 这个分支,以及业务代码是否真的放在了分支里而不是 switch 外面。

3.2 Acquire 回调:把正弦波数据填进去

生成代码时 AcquireCallback 内部只有一个空的 switch 框架,CVI 会把 case EVENT_COMMIT 写出来,光标停在分支下面。这时候不要直接手敲整个 SinePattern 调用,用函数面板插入:菜单 Library>>Advanced Analysis,选 Signal Generation>>Sine Pattern。

函数面板里填好参数,点 Insert into function,CVI 会自动把调用插到光标位置。这个方式的好处是参数名、头文件、库链接都由 IDE 处理,避开了手写时漏包含头文件的问题。填完之后回调是这样的:

int CVICALLBACK AcquireCallback (int panel, int control, int event, void *callbackData, int eventData1, int eventData2) { double wave[1024]; switch (event) { case EVENT_COMMIT: /* 生成1024点正弦波数据 */ SinePattern (1024, 1.0, 0.1, 0.0, wave); /* 把数组绘制到 Graph 控件 */ PlotWaveform (panel, PANEL_GRAPH, wave, 1024, VAL_DOUBLE, VAL_THIN_LINE, VAL_EMPTY, VAL_SOLID, 1, VAL_BLUE); break; } return 0; }

逻辑上这条回调做了两件事:SinePattern 生成 1024 个 double 波形数据,放进 wave 数组;PlotWaveform 把数组画到 PANEL_GRAPH 控件上。数据是先存进数组、再由绘图函数拷贝进控件的,所以 wave 虽然声明成局部变量、回调返回后就被回收,画面并不会消失。

参数方面,SinePattern 五个参数依次是采样点数、幅值、相位增量(弧度/点)、初始相位、输出数组。这里相位增量 0.1 表示每前进一个点相位推 0.1 弧度,因此 1024 点里约有 16 个完整周期;想看两个完整周期,相位增量改成 2π/512 左右。

PlotWaveform 的参数更多:panel 和 PANEL_GRAPH 指明画到哪个控件的哪张图,VAL_DOUBLE 告诉它数组元素是 double,后面的 VAL_THIN_LINE、VAL_EMPTY、VAL_SOLID 分别控制线宽、数据点样式、连线样式,数字 1 表示每隔 1 个点画一次点标记,VAL_BLUE 是曲线颜色。这些枚举在 userint.h 里都有定义,写错不会编译报错,但画出来的样式会不对,先用这套组合最稳。

3.3 Clear 和 Quit:清除波形与退出消息循环

Clear 和 Quit 这两个回调同样是 CVI 生成的框架,区别在于 QuitCallback 会自动带上一句 QuitUserInterface (0),而 Clear 需要自己补:

int CVICALLBACK ClearCallback (int panel, int control, int event, void *callbackData, int eventData1, int eventData2) { switch (event) { case EVENT_COMMIT: DeleteGraphPlot (panel, PANEL_GRAPH, -1, VAL_IMMEDIATE_DRAW); break; } return 0; } int CVICALLBACK QuitCallback (int panel, int control, int event, void *callbackData, int eventData1, int eventData2) { switch (event) { case EVENT_COMMIT: QuitUserInterface (0); break; } return 0; }

DeleteGraphPlot 的第三个参数传 -1,含义是删除该 Graph 上的全部曲线,而不是只删某一条。最后一个参数 VAL_IMMEDIATE_DRAW 要求控件立即重绘,如果不传这个枚举,界面不会马上刷新,看起来就像 Clear 没生效。

QuitUserInterface(0) 的作用是让 RunUserInterface 返回,程序随后走到 main 里的 DiscardPanel 并正常退出。整个退出链路是:按钮事件触发 QuitCallback,消息循环退出,main 收尾。如果 QuitCallback 里少了这一句,界面会一直停在原地。

3.4 eventData 参数什么时候会用到

新手最容易纠结的是回调参数里那四个不知道干嘛用的变量。以命令按钮为例,EVENT_COMMIT 时 eventData1 和 eventData2 没有任何有效信息;换成鼠标事件时,这两个参数会携带屏幕坐标;定时器控件的 EVENT_TIMER 也会带上时间信息。

所以正确姿势是只在确定事件类型的前提下使用它们,不要在 EVENT_COMMIT 分支里打印坐标然后怀疑界面坏了。callbackData 更少用到,它是 LoadPanel 或安装回调时注册的自定义指针,用于把业务结构体传进回调。示例程序里三个按钮都没用到这两个能力,保持框架默认即可。

另外强调一句:回调函数签名里的 CVICALLBACK 不能去掉,它是 CVI 规定的调用约定宏,删了回调就可能收不到事件。

4. 波形数据从哪来:Sine Pattern、PlotWaveform 与定时器联调参数详解

4.1 Sine Pattern 函数面板:搞清楚五个参数再点插入

函数面板是 CVI 里最有特色的东西。Library>>Advanced Analysis>>Signal Generation>>Sine Pattern 打开后,面板上会列出函数原型和每个参数的输入框。关键在于不要只填一个看起来像频率的数,要理解相位增量。

参数含义示例值说明
numberOfPoints生成的点数1024点数决定数组长度和绘图宽度
amplitude幅值1.0波形的峰值
phaseIncrement相位增量(弧度/点)0.1决定一个周期占多少点
phase初始相位(弧度)0.0决定波形从哪个相位开始
patternArray[]输出数组wavedouble 数组,函数往里面填数据

相位增量和波形周期的关系是:周期点数为 2π / phaseIncrement。相位增量 0.1 时一个周期约 62.8 个点,1024 点里能看到约 16 个周期;想看两个完整的,设成 2π/512 约 0.0123。常见误用是把这个值当成频率 Hz,填个 50 进去,结果波形被压成一团,还以为是软件坏了。

4.2 PlotWaveform 的数据类型:这里最容易翻车

函数面板插入 PlotWaveform 时,dataType 参数必须和数组类型一致。double wave[1024] 就传 VAL_DOUBLE,int 数组就传 VAL_INTEGER。不一致时不会编译报错,运行出来却是一堆乱线和竖线,这种问题属于典型的波形玄学,实际是类型不匹配。

点样式和点频率是第二个容易出效果的参数组合:pointFrequency 传 1 表示每个数据点都画点标记,数据密集时图形又粗又糊,改成 0 只画线不画点,数据量大时界面流畅很多。曲线颜色用最后一个参数控制,VAL_BLUE、VAL_RED 这些枚举来自头文件。

还需要注意:同一 Graph 反复 PlotWaveform,新曲线不会自动覆盖旧曲线,而是追加绘制。所以示例里连点三次 Acquire,Graph 上会叠三条正弦波。想要每次只画一条,绘制前先 DeleteGraphPlot 清空,再画新的。

4.3 从手动触发到定时刷新:把示例改造成持续采集显示

示例是个手动触发程序。如果把 SinePattern 换成采集卡的读取函数,再往回调里塞一个定时器,就是最基本的实时采集界面。做法是回到 UIR 编辑器,右键添加一个 Timer 控件,把 Interval 属性设成 100(单位毫秒),Callback Function 填 TimerCallback。

生成代码后,回调框架里会有 case EVENT_TIMER,而不是 EVENT_COMMIT。补上逻辑之后,回调大致长这样:

int CVICALLBACK TimerCallback (int panel, int control, int event, void *callbackData, int eventData1, int eventData2) { double wave[256]; static double phase = 0.0; switch (event) { case EVENT_TIMER: /* 从当前相位继续生成,波形才会滚动 */ SinePattern (256, 1.0, 0.05, phase, wave); PlotWaveform (panel, PANEL_GRAPH, wave, 256, VAL_DOUBLE, VAL_THIN_LINE, VAL_EMPTY, VAL_SOLID, 1, VAL_RED); phase += 0.05 * 256; break; } return 0; }

这里 static double phase 是关键:局部变量在回调返回后销毁,static 让它跨多次回调存活,每次事件触发时相位从上一次的位置继续推进,波形看起来才是连续滚动的。如果每次都用同一个初始相位 0,画出来的每一帧都完全一样,界面上波形就像冻住了。

参数上,定时器周期 100 毫秒对应每秒 10 次回调,点数 256 比较小,绘图开销可控。如果换成真正的采集线程,把 SinePattern 替换成从共享缓冲区拷贝数据即可,界面逻辑不用动。注意 EVENT_TIMER 分支里不要做耗时超过定时周期的操作,否则消息队列积压,界面会一顿一顿的。

4.4 数组边界与动态数据:CVI 对越界不设防

CVI 里的数组就是标准 C 数组,没有越界检查。回调里声明 double wave[256],却在函数面板里填 numberOfPoints=1024,SinePattern 会一路写下去,把栈上相邻变量踩掉,表现多为:回调执行完界面直接闪退,或者另一个控件的值莫名变了。排查手段是先看数组声明再看函数面板参数,两头对齐。

需要生成动态长度波形时,用 malloc 分配,用完 free;不要在回调里频繁分配回收,内存碎片会拖慢界面。

注意:波形“不动”不是数据没更新,先检查相位是否每次回调都从 0 开始,再看数组长度和点数是否一致。

5. 避坑与排查:LoadPanel 失败、回调失灵、波形不刷新等五个翻车现场

下面五条是我在带人过这套教程时反复看到的问题,每一条都按现象、原因、解决三个层面拆开。如果你照着前面章节做下来还是跑不通,先按这五个方向对一遍,基本能覆盖大多数新手期的翻车现场。

5.1 LoadPanel 返回负值:UIR 路径与头文件不同步

现象:运行后黑窗口一闪而过,在 LoadPanel 那一行加断点,看到返回的是负数。

原因:LoadPanel 的第二个参数按相对路径查找 UIR 文件,而程序的工作目录不是工程目录,最常见是直接从文件管理器双击 exe 运行;另一个低频原因是 UIR 文件名改了,代码里没跟上。

解决:把工程的调试工作目录设置为工程目录,或者临时用绝对路径验证。同时打开 FirstSample.h,确认 PANEL 这个宏确实存在,头文件和 UIR 不同步时,LoadPanel 同样会出错。

5.2 回调函数没反应:Constant Name、Callback Function、头文件三处脱节

现象:按钮按下去没有反应,或者在 case EVENT_COMMIT 里设的断点根本没命中。

原因:UIR 里 Callback Function 字段为空;或者改了 Constant Name 后没重新生成代码,.c 里回调引用的还是旧宏名,和头文件定义对不上。

解决:回 UIR 编辑器,双击按钮检查 Callback Function 是否填了 AcquireCallback;改完任何控件属性后重新执行一次 Code>>Generate>>All Code。不要手写回调函数名,靠 IDE 生成,两边保持一致才不容易出问题。

5.3 面板不显示或不在正中间

现象:程序能跑,但屏幕上什么都没有;或者面板出现在左上角,位置别扭。

原因:main 里只 LoadPanel 没调用 DisplayPanel;Auto-Center 两个属性没勾选。

解决:按 InitCVIRTE 到 LoadPanel 到 DisplayPanel 再到 RunUserInterface 的顺序检查 main。居中问题直接在 UIR 的 Edit Panel 里勾选 Auto-Center Vertically 和 Auto-Center Horizontally。

5.4 波形画出来是乱的或者 Graph 没反应

现象:按下 Acquire,Graph 区域空白,或者画出一堆乱线。

原因:数组类型不匹配,int 数组配 VAL_DOUBLE;numberOfPoints 大于数组长度;Graph 控件的常量名写错,比如把 StripChart 的常量当 Graph 用。

解决:断点停在 PlotWaveform 这一行,先看 wave[0]、wave[1] 的值在不在 ±1 范围内,不在说明 SinePattern 参数或数组有问题。再核对数据类型枚举和控件常量名,确认 PANEL_GRAPH 对应的确实是 Graph 而不是其他绘图控件。

5.5 点了 Quit 退不出或者界面卡死

现象:Quit 按钮点了没反应,或者连续采集一段时间后界面无响应。

原因:QuitCallback 里没有 QuitUserInterface(0);回调函数里出现 while(1) 等待数据的循环;定时器回调执行时间超过定时周期,消息越积越多。

解决:QuitCallback 的 EVENT_COMMIT 分支必须调 QuitUserInterface(0)。耗时操作移出回调,放到独立线程或加长定时周期,让界面线程有空处理绘制和输入消息。

6. 进阶:用断点验证回调、管好数组内存,再把 Demo 改造成采集框架

6.1 先用调试器确认事件真的到了

跑起来之前,先在 AcquireCallback 的 case EVENT_COMMIT 里打上断点,点 Acquire 按钮。断点命中说明事件链路通;没命中就别查绘图代码,回头查 UIR 的 Callback Function。

命中后用 Variables 窗口展开 wave 数组,检查数值范围是否合理。按 F5 继续跑,再点 Clear,确认 DeleteGraphPlot 路径。这个习惯能省掉大量瞎猜的时间,尤其是事件驱动类程序,先确认事件有没有到,再谈逻辑对不对。

6.2 内存习惯:谁分配谁释放

示例里 wave 是栈上数组,不需要释放。改成动态数组后,malloc 和 free 要成对出现,回调里频繁分配释放会制造内存碎片,界面会越来越卡。

一个常用做法是 main 里一次性分配一块足够大的缓冲区,把指针存成全局,回调只往里写数据,程序退出前统一释放。如果你是从旧版工程迁到 CVI 2019 这类新环境,打开 UIR 后先重新生成一次代码,对比头文件里的常量有没有变,再编译。

6.3 把 Demo 改造成可复用的上位机框架

示例只有一个面板,真实上位机一般有参数设置面板和数据显示面板。多个 UIR 文件或多个 Panel 时,main 里分别 LoadPanel 再按需 DisplayPanel,回调通过 panel 参数区分来源。业务上把采集线程、数据处理、界面刷新拆开,回调只做最小的事。

验证项做法预期结果
事件链路EVENT_COMMIT 打断点点按钮后命中
数据正确性Variables 查看 wave数值在范围内
退出链路Quit 后跟踪 mainDiscardPanel 后进程结束

教程里的例子虽然简单,但把这套流程跑通后,换成串口、板卡 DLL 的数据源就只是替换数据生成函数的事。从那以后,我每拿到一个示例工程,都先按“数据从哪来、怎么进控件、什么时候被释放”过一遍再往上改,界面层和数据层分开,后面接什么设备都稳。希望帮到你。

本文还有配套的精品资源,点击获取

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

Java笔试高频考点全解析:从重载、InnoDB到多线程与算法

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

作者头像 李华
网站建设 2026/10/24 1:03:45

前端调用本地exe的四种方案与安全实践

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

作者头像 李华
网站建设 2026/10/24 1:03:44

STM32F215RE与PCA9422低功耗电源管理方案设计与实现

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

作者头像 李华
网站建设 2026/10/24 1:03:32

Games101作业3:从Phong光照到法线贴图的Shader渲染实战

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

作者头像 李华
网站建设 2026/10/24 1:03:23

Python类与对象详解:从第二十三课掌握面向对象核心

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

作者头像 李华
网站建设 2026/10/24 1:03:22

ADD属性驱动架构设计:以质量属性为锚点的架构决策方法论

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

作者头像 李华