news 2026/10/24 5:06:22

K/3 Cloud BOS插件机制深度解析:元数据驱动与表单生命周期

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
K/3 Cloud BOS插件机制深度解析:元数据驱动与表单生命周期

简介:面向初学者的K/3 Cloud BOS插件开发基础文档,聚焦BOSIDE、套打、万能报表等常用功能,讲解如何通过插件开发扩展标准业务,满足企业个性化、非标准化的需求。文档内容以插件开发为主线,先说明Windows平台上的环境配置要求,包括Visual Studio 2012开发工具、IE 8/9与Silverlight 5客户端、SQL Server 2008R2或Oracle 11g数据库,以及IIS 6.0以上Web服务器的最低硬件与软件基准;再梳理BOS架构模型与动态表单机制,介绍平台如何借助动态领域模型建模将业务封装成标准接口和标准服务,并详列动态表单插件、服务插件、校验规则插件、表单构建插件四类插件的特点与适用场景。随后给出从新建插件类、引用组件、重载方法到编译注册、运行测试的完整实施步骤,零基础读者可据此搭建个人开发环境,修改Common.config中的管理中心地址,并实现第一个自定义菜单示例。整份资料为1个docx文档,约1.9MB,结构紧凑、图文配合,既适合实施顾问快速理解BOS扩展机制,也可作为开发人员的入门实战手册。目前已有216人学习下载,适合刚开始接触K/3 Cloud插件开发、希望快速上手BOS定制开发的初学者通读。

1. 插件机制:为什么标准产品非要留一道“后门”

做 K/3 Cloud BOS 二次开发的人,最开始容易把“写插件”理解成“写业务逻辑”:反正继承一个基类、重载几个方法、编译丢进 Bin 目录,事情就完了。但真相是,插件开发从来不是写业务,而是理解一套“元数据解释器”的挂载点。BOS 平台把表单、单据、列表的界面和逻辑全部描述成元数据,运行时由引擎解释执行;插件只是在这条解释链路上插入的钩子。你写的每一个重载方法,本质上都是在告诉引擎“标准流程走到这一步时,先停下来让我处理一下”。搞懂这个底层模型,你才能解释为什么插件能改标准业务、为什么有的事件只在新增时触发、为什么删掉一个已注册插件会让单据数据出错。这篇文章就从架构模型出发,把插件机制一层层拆开。

2. 动态领域模型:View 与 Model 分离背后的元数据驱动设计

2.1 为什么界面和逻辑要拆成两份元数据

BOS 的动态表单模型最反直觉的一点,是把一张单据拆成“外观”和“逻辑”两个对象来管理。外观由 View 控制,逻辑由 Model 控制;而这两者的配置数据,分别存在 LayoutInfo(布局元数据)和 BusinessInfo(业务元数据)里。你在 IDE 设计器里拖一个控件、设一个字段锁定、配一条实体服务规则,保存后全部写入这两类元数据,而不是生成代码。

运行时,客户端发起 HTTP 请求,服务端控制器把请求转给动态表单模型控制器,控制器再分别访问视图和模型:视图加载布局元数据,模型从元数据里解析出数据结构。插件能拿到的两个核心入口——IDynamicFormView 和 IDynamicFormModel——正好对应这一拆分的对外暴露面。

// 视图接口:管外观 public interface IDynamicFormView { BusinessInfo BusinessInfo { get; } // 业务对象逻辑元数据 LayoutInfo LayoutInfo { get; } // 布局元数据 void SetFormTitle(LocaleValue title); T GetControl<T>(string key) where T : Control; } // 模型接口:管数据 public interface IDynamicFormModel { BusinessInfo BusinessInfo { get; } DynamicObject DataObject { get; } // 当前表单的数据对象 void SetValue(string fieldKey, object value, int row = 0); void CreateNewEntryRow(string entityKey); }

把外观和逻辑拆开的好处是:同一个业务数据结构可以套不同的布局,比如采购收料单在录入界面和审核界面可以显示不同字段、不同排版,但底层 BusinessInfo 完全一致。插件在 AfterBindData 里通过 View 的 StyleManager 隐藏字段、禁用按钮,改的只是外观层;通过 Model 的 SetValue 改的才是数据层。两层互不污染,这也是这套模型能支撑“标准产品 + 个性化扩展”的根本前提。

2.2 元数据驱动:插件为什么能不改代码改行为

传统软件开发里,要改一个单据的行为,你得改源码、重新编译、重新发布。BOS 的插件机制把这套流程整个倒了过来:单据的行为由元数据描述,元数据在运行时被引擎解释执行,插件只是元数据解析链路上的一个“可插拔处理器”。

理解这一点的关键在于:IDE 里注册插件,不是把插件“编译进”单据,而是把插件程序集的全名记入单据的元数据。运行时引擎读取元数据,发现这个单据挂了某个插件类,就用反射创建实例,之后按固定时序调用插件里的重载方法。所以“删除插件可能导致业务数据错误”这条注意事项就好解释了——你删掉的不是一个文件的引用,而是元数据里的一段行为定义;如果标准业务流程已经依赖这个插件返回的数据,删除后引擎找不到处理器,数据自然出错。

2.3 DataObject 为什么非要是 DynamicObject

另一个值得深挖的设计点是数据承载类型。BOS 默认用 DynamicObject 作为所有单据的数据对象,而不是为每张单据生成强类型类。这是因为 BOS 的单据结构是设计期在 IDE 里定义的,编译期根本不存在对应的 C# 类。DynamicObject 配合 DynamicProperty 实现“运行期反射式读写”——字段的 Key 存在元数据里,取值赋值的动作通过 DynamicProperty 完成。

// 通过 DynamicProperty 运行时读写字段值 Field seqField = info.GetField("FSeq"); seqField.DynamicProperty.SetValue(rowData, row + 1);

这带来的灵活性是:插件编写的时点,根本不需要知道最终单据上有哪些字段,全部按元数据的 Key 来操作。代价是失去了编译期类型检查,字段 Key 写错了只能在运行时抛异常。BOS 允许通过 DynamicObjectType.ClrType 指定自定义类型,但要求必须派生自 DynamicObject——这是为了保证底层数据容器一致,否则引擎的绑定、序列化、状态管理逻辑全都得重写。

3. 四类插件与继承体系:一次菜单点击是如何穿透三层模型的

3.1 插件分类:从界面到服务的四个切入层次

BOS 把插件按介入深度分成四类:动态表单插件处理单据上的事件、操作和服务,是日常开发最常用的;服务插件运行在服务端,对外提供编程接口,供外部服务直接调用,不经过界面;校验规则插件专门对业务规则做校验,比如单据保存前的必填项检查、唯一性检查;表单构建插件在界面加载控件和元数据时介入,动态调整控件和元数据来满足特殊需求。

这四类插件的分布不是随意的,它对应一条完整请求链:表单构建插件最先介入,决定界面“长什么样”;动态表单插件在界面加载后介入,处理用户在界面上的交互;校验规则插件在数据落地前介入,决定数据“能不能过”;服务插件把能力暴露给外部系统,决定别人“怎么调”。你在 IDE 里注册插件时选择不同类型的基类,其实就是在选择介入这条链路的哪一段。

3.2 单据、列表、账表:子类化背后的职责细分

动态表单插件内部又分了五类:单据插件、列表插件、过滤条件插件、账表插件、动态表单插件。它们的继承关系是:所有插件最终都继承自 AbstractDynamicFormPlugIn,单据插件再继承出 AbstractBillPlugIn,基础资料插件继承出 AbstractBasePlugIn,列表插件继承出 AbstractListPlugIn。

// 继承链:单据插件最终落在 DynamicForm 插件体系下 System.Object └─ AbstractDynamicFormPlugIn ├─ AbstractBillPlugIn // 单据插件 ├─ AbstractBasePlugIn // 基础资料插件 ├─ AbstractListPlugIn // 列表插件 └─ AbstractDynamicFormPlugIn // 动态表单插件

为什么要这么细分?因为单据、列表、基础资料虽然都是“动态表单”的一种,但它们能触发的事件完全不同。列表有列表控件的单击、双击事件,单据有分录行的新增、删除事件,基础资料有 F7 选择返回事件。如果只有一个大而全的插件基类,每个插件都要面对与自己无关的一大堆空方法。子类化之后,AbstractBillPlugIn 直接提供单据相关的接口,AbstractListPlugIn 提供列表相关的接口,事件分发效率更高,插件的语义也更清晰。而且这套继承链让“标准插件可被业务插件继承和替代”成为可能——业务插件继承标准插件的实现,重写需要改变的方法,其余逻辑走父类,这就是标准产品扩展的基本姿势。

3.3 事件如何穿透 View 与 Model

接口层面,BOS 把插件事件也拆成了两组:IDynamicFormViewPlugIn 接收 View 层事件,IDynamicFormModelPlugIn 接收 Model 层事件。View 层事件包括 BarItemClick、ButtonClick、AfterBindData、AfterButtonClick 等界面交互类;Model 层事件包括 DataChanged、CreateNewData、BeforeUpdateValue 等数据操作类。

一个典型的场景:用户点击单据上的菜单“HelloWorld”,触发的是 BarItemClick——这属于 View 层事件,因为它来源于界面操作。插件在这个方法里调用 this.View.ShowMessage 弹窗,本质上是“界面事件 → 界面响应”,全程没有碰数据。但如果用户在菜单里点击的是“审核”,插件在 BeforeDoOperation 里改了审核状态字段,那就进入了 Model 层——因为审核状态是数据的一部分,要被状态管理器记录。

public override void BarItemClick(BarItemClickEventArgs e) { base.BarItemClick(e); if (e.BarItemKey == "HelloWorld") { this.View.ShowMessage("Hello world!", MessageBoxType.Notice); } }

理解 View 层与 Model 层的分界,对排查问题很有用:界面没反应,先看是不是 View 层事件没挂上;数据没落库,再看是不是 Model 层事件里改的值没被状态管理器捕获。很多开发者把两件事混在一个事件里处理,出了问题都不知道该查哪层。

4. 表单生命周期:从 OnInitialize 到 AfterBindData 的加载时序

4.1 一次单据加载到底经历了什么

动态表单的加载不是简单地把元数据读出来画到界面上,而是一个多阶段的初始化过程,每个阶段都提供了插件介入点。官方给出的执行顺序是:OnInitialize(页面初始化)→ CreateNewData(创建数据包)→ AfterCreateNewEntryRow(创建分录行后)→ AfterCreateNewData(数据包创建后)→ BeforeBindData(绑定数据前)→ AfterBindData(绑定数据后)→ BeforeClosed(页面关闭前)。

这个顺序看起来平淡无奇,但里面藏着几个关键设计:OnInitialize 在最前面,此时表单实例刚创建,插件趁这个机会做全局参数初始化、元数据预读取、界面控件数据源准备。接着是 CreateNewData,这个事件只在“新增”时触发,打开已有单据不触发——因为打开不需要新建数据包。数据包创建之后,BeforeBindData 给插件最后一次“改数据”的机会,而且是“不被状态管理器记录”的改。最后 AfterBindData 在数据和控件状态都绑定完成后触发,是处理字段可见性、按钮禁用这类界面样式问题的标准位置。

4.2 BeforeBindData:为什么这里改数据不会被记账

状态管理器是 BOS 里的一个隐性机制:它会记录用户对数据的所有修改,用于保存时对比“原始值”和“当前值”。但 BeforeBindData 阶段比较特殊——此时数据还没绑到界面控件上,插件在此阶段修改数据对象,相当于在“展示之前”就把值定好了。界面一加载显示的就是修改后的值,用户没有主动改过它,状态管理器自然不认为这是脏数据。

public override void BeforeBindData(EventArgs e) { base.BeforeBindData(e); // 根据业务类型动态创建分录行 if (_modelTypeId == ElementType.ELEMENTTYPE_BASE.ToString()) { this.View.Model.CreateNewEntryRow("FSearchControl"); } else if (_modelTypeId == ElementType.ELEMENTTYPE_BILL.ToString()) { this.View.Model.CreateNewEntryRow("FFieldParamControl"); } // 批量新增行 this.Model.BatchCreateNewEntryRow("FPARAMOPERATION", 5); }

这段代码演示了 BeforeBindData 的典型用法:在数据绑定前把需要展示的分录行创建好。如果把这个动作放到 AfterBindData 里,新建的行会被状态管理器记录成“新增数据”,保存时可能出现冗余的空行。理解了状态管理器的记账规则,你就能判断一个数据操作该放哪个事件——想让用户看到但不想被保存为“用户修改”,就放 BeforeBindData;想让修改参与数据变更流程、触发 DataChanged 的,就放正式事件里。

4.3 CreateNewData 与 AfterCreateNewData:新增单据时的数据注入

CreateNewData 是“数据包创建”事件,它只在新增时触发,用途是从外部服务加载初始数据。前面示例里用 BusinessDataServiceHelper.LoadBillTypePara 加载单据类型参数,直接赋给 e.BizDataObject——这等于完全接管了数据包的创建过程,插件说什么数据就是什么数据。

AfterCreateNewData 则在数据包创建完后触发,适合做“按条件填默认值”的轻量操作。示例里根据当前组织去查邮件默认设置,把邮件类型、服务器、端口号填进新单据的字段。

public override void AfterCreateNewData(EventArgs e) { base.AfterCreateNewData(e); OQLFilter ofilter = new OQLFilter(); ofilter.Add(new OQLFilterHeadEntityItem { FilterString = string.Format(" FORGID ={0} ", this.Model.Context.CurrentOrganizationInfo.ID) }); DynamicObject[] obj = BusinessDataServiceHelper.Load( this.View.Context, "BAS_MAILDEFAULTSET", null, ofilter); if (obj != null && obj.Count() > 0) { DynamicObject defaultSet = obj[0]; this.View.Model.SetValue("FMessageType", defaultSet["FMessageType"]); this.View.Model.SetValue("FServer", defaultSet["FOutgoingMailServer"]); this.View.Model.SetValue("FSMTPPort", defaultSet["FSMTPPort"]); } }

注意代码里的注释点:“该插件属于创建数据包,在该插件里设置的值不会加到状态管理器中”。和 BeforeBindData 同理,因为此时数据包还没正式交给界面,值是从服务端直接算好塞进去的,客户端数据可以通过 Http 数据监控工具查到完整的请求报文。实际开发里一个常见误用是:把 AfterCreateNewData 当成每次打开单据都会执行的事件,往里写查询逻辑,结果打开已有单据时死活不触发,浪费一上午排查。

4.4 AfterCreateNewEntryRow:新增分录行的时机陷阱

AfterCreateNewEntryRow 在每次新增分录行后触发,包括手动新增、插入、多行输入三种情况。需要注意:这个事件是按“行”触发的,新增一行就触发一次。如果只是想在新增分录后给整批数据做一次整体处理,应放到 AfterCreateNewData 一次性完成。

public override void AfterCreateNewEntryRow(CreateNewEntryEventArgs e) { base.AfterCreateNewEntryRow(e); if (e.Entity.Key.Equals("FSubEntity")) { IEnumerable<DynamicObject> subEntryDataCol = this.Model.GetEntityDataObject(e.Entity); // 只对新插入的最后一行赋值 if (e.Row == subEntryDataCol.Count() - 1) { int maxOperNumber = subEntryDataCol .Select(w => w.GetDynamicObjectItemValue<int>("FOperNumber")) .Max(); int newOperNumber = (int)Math.Ceiling(((decimal)maxOperNumber + 1) / 10) * 10; this.Model.SetValue("FOperNumber", newOperNumber > 9999 ? 9999 : newOperNumber, e.Row); } } }

这段代码做的事情是:新增工序分录时,把工序号自动设为“上一个工序号向上取整到 10 的倍数”,上限 9999。实现上先取当前实体所有行,判断新行是不是最后一行(排除插入行的场景),再算新值。如果这段逻辑放错位置,比如放在 AfterCreateNewData 里,新增一行时根本不会触发;放在 DataChanged 里,则会因为赋值本身触发 DataChanged 造成循环。

4.5 AfterBindData:界面状态统一的最终调整点

AfterBindData 是加载和界面刷新都会调用的事件,也是处理“字段可见性、菜单禁用、样式调整”的标准位置。它和数据绑定后的控件状态直接相关,此时元数据已经解析完、数据已经展示出来,插件可以安全地访问所有控件。

public override void AfterBindData(EventArgs e) { base.AfterBindData(e); // 隐藏主菜单项 this.View.GetMainBarItem("tbNew").Visible = false; // 启用分录菜单项 this.View.GetBarItem("Fentity", "tbAdd").Enabled = true; // 根据业务类型控制页签可见性 if (_modelTypeId == ElementType.ELEMENTTYPE_BASE.ToString()) { this.View.StyleManager.SetVisible("FTab_Field", null, false); } }

这里要区分两类操作:GetMainBarItem 和 GetBarItem 获取的是菜单对象,改 Visible/Enabled 改变的是控件状态;StyleManager.SetVisible 是样式管理器的批量控制接口,适合按条件统一管理一组字段或页签的可见性。实际开发中,如果只需要“某几个字段在特定条件下隐藏”,用 StyleManager 比逐个控件设置更高效,也更容易维护。

5. 插件开发常见问题:翻车现场与排查思路

5.1 插件注册了却不生效

现象:编译后的 DLL 已放到 K3Cloud\K3CloudServer\Bin,IDE 里也勾选了插件,运行单据时重载方法就是不执行。

原因:最常见的是编译路径配错。插件 Building 路径必须指向 K3Cloud\K3CloudServer\Bin,而不是本地随便一个输出目录。另一个隐蔽原因是 IIS 进程缓存——旧的 DLL 还被 w3wp.exe 进程占用,新编译的文件没被加载。还有一个坑:注册插件时选了错误的基类,比如把单据插件注册到了列表插件的位置,事件根本不会按预期分发。

解决:先确认编译输出目录确实是 Bin 路径;改完 DLL 后重启 IIS 或回收应用池,排除进程锁文件的情况;再打开单据的插件列表,检查插件类名和继承的基类是否与单据类型匹配。

5.2 删除已注册插件导致单据打开报错

现象:在 IDE 设计器里删掉某个插件后,单据一打开就报“对象引用未设置”或找不到方法。

原因:插件列表里可能不只有你注册的那一个,标准产品自带的插件也在列表里。这些插件在运行时动态加载,标准流程可能依赖它们维护的数据。删除后,元数据里挂载的行为定义消失,但依赖此行为的业务流程还在执行,自然报错。

解决:删插件前先确认它是不是标准产品自带的;不确定时不要把插件直接从列表移除,改为在代码里加开关控制是否执行。真需要删除时,备份好原单据元数据,删完立即做一次完整的新增、修改、审核、保存的回归测试。

5.3 新增分录后的自动赋值没生效

现象:写了 AfterCreateNewEntryRow 给新行赋默认值,但实际新增分录时值没填进去。

原因:该事件在“每次新增分录”时触发,但插入行(在中间插入)和附加行(在末尾新增)的触发行为不同。示例代码里判断了 e.Row == subEntryDataCol.Count() - 1,目的是排除插入行场景。如果不加这个判断,插入行时也会执行赋值逻辑,可能把用户正在编辑的行值覆盖掉。

解决:先确认触发时机是新增还是插入;给新行赋值时,判断 e.Row 是否为集合的最后一行。另外注意,AfterCreateNewEntryRow 触发频率高,涉及集合遍历时要控制数据量,不要在事件里写慢查询。

5.4 数据库环境配置不一致导致乱码与排序错乱

现象:本地开发正常,部署到客户环境后,单据编码排序不对,中文出现乱码。

原因:BOS 对数据库环境有硬性要求。SQL Server 的排序规则必须是 Chinese_PRC_CI_AS;Oracle 的数据库字符集必须是 AL32UTF8,国家字符集必须是 AL16UTF16。环境搭建时如果用了默认排序规则或字符集,元数据里的中文标签和业务数据的中文内容在存储和比较时就会出问题。

解决:建库时显式指定排序规则和字符集。团队环境按“服务器配置基准值”搭:数据库服务器 4 核 2.0 GHz 以上,SQL Server 8 GB 内存、Oracle 16 GB;客户端双核 2.0 GHz 以上、4 GB 内存。开发环境装了 K/3Cloud 产品后,修改安装目录 K3Cloud\K3CloudServer\App_Data 下 Common.config 文件里的 managementSiteUrl 地址,指向公共环境的管理中心。

<add key="managementSiteUrl" value="http://192.168.73.40:8000/" />

5.5 插件里改了字段值但保存后没变化

现象:在 BeforeBindData 或 CreateNewData 里用 Model.SetValue 给字段赋了值,保存单据后数据库里的值还是原来的。

原因:这两个事件里设置的值不会被状态管理器记录。状态管理器负责跟踪用户级修改,而插件在这两个阶段的数据操作发生在“用户看到界面之前”,引擎把这次修改视为数据包初始化的一部分,不会生成更新记录。

解决:如果希望修改被保存,放到正式的数据变更事件里,比如 DataChanged 或 BeforeDoOperation。如果只是想让界面首次展示时就带值且不污染脏数据标记,就留在 BeforeBindData。一句话:想让数据落库,走“会记账”的事件;只想改展示,走“不记账”的事件。

6. 设计取舍:为什么是“插件挂载”而不是“直接改标准产品”

回到最开始的问题:BOS 为什么把扩展机制设计成插件挂载,而不是直接提供源码让开发者改标准产品?最直接的原因是:标准产品要同时服务成百上千家客户,如果每家都改源码,产品就分叉了。插件机制让“标准能力”和“客户个性化需求”在同一个运行时里共存——标准逻辑跑在引擎里,个性化逻辑跑在插件里,两边通过事件接口通信。

这个设计换来的代价也很明显:插件开发者始终面对的是一个“黑匣子引擎”,你只能看到事件和接口,看不全引擎内部的完整执行路径。遇到引擎自身的 bug,你没法像改自己代码那样直接修,只能绕。另一个代价是事件时序的心智负担——插件机制把业务逻辑切碎成一个个事件方法,你要在脑子里拼出完整的执行顺序,否则就出现 5.3 那种“在错误的事件里做正确的事”的翻车。

我一般会这么理解这套机制:把 BOS 引擎想象成一个解释器,元数据是脚本,插件是脚本里的回调函数。标准产品定义好了解释器的执行框架,插件只在框架声明的“钩子点”上被调用。你写的每一个重载方法,都不是在“实现功能”,而是在“响应流程”。掌握了这个心智模型,遇到任何奇怪的插件行为,你第一反应就是去查两个问题:这个事件在什么时机触发?我在这个事件里改的东西会不会被状态管理器记录?

从那以后,我每次接入一个新的插件需求,都强制自己先画一遍表单的生命周期时序,标出要介入的事件点,再动手写代码。想清楚“数据从哪里来、经过哪些事件、最后落到哪里去”,比急着写那一百行重载方法重要得多。希望帮到你。

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

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

CAAC无人机地面站考试全解析:超视距飞行指挥核心要点

/* 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 5:06:08

Pandas读写CSV实战:read_csv与to_csv避坑指南

/* 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 5:04:33

HiveWE:现代化魔兽地图编辑器的工程化实践

/* 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 5:04:07

PCA9422与PIC18F4553的嵌入式电源管理完整方案

/* 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 5:03:36

Pandas DataFrame数据选择全攻略:从loc、iloc到布尔索引

/* 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 5:03:03

基于PCA9422与PIC18F45K22的嵌入式电源管理方案设计

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

作者头像 李华