C# OPC UA 客户端实战:订阅、回调与 SQL Server 批量落库

简介:这是一份面向工业自动化、物联网及企业级数据集成方向开发者的C#实战项目源码,核心演示如何基于OpcUaHelper开源库构建OPC UA客户端,连接服务器读写数据,并将采集结果以字符串形式存入SQL Server关系型数据库,适合具备一定C#基础、希望打通设备通信与数据落库链路的进阶学习者参考。资源包共144个文件,以56个dll依赖库、54个xml配置文档、9个cs源码文件为主,另含config、resx、exe等运行与资源文件,整体约5.51MB,项目结构完整可直接编译调试。目前已有3240人学习下载。通过该实例,读者可掌握OPC UA连接建立、数据格式转换、数据库表设计与INSERT写入等关键环节,并借鉴其工程组织方式与排错思路,快速迁移到自身的数据采集与存储场景中。

1. 从一次产线数据断流说起:这套 C# OPC UA 客户端到底能解决什么

去年帮一个做离散制造的朋友排查问题,他们车间一台老上位机每隔两三天就丢一批工艺参数,操作工只能拿 U 盘去机台手动拷。现场用的是某品牌 PLC,走 OPC UA 协议对外暴露节点,但没人会写客户端去稳定订阅,数据就一直躺在设备里。这类场景其实很典型:设备侧协议是通的,缺的是中间那层「把 OPC UA 节点值持续、可靠地落到关系库」的采集程序。这份资源给的就是这么一套 C# 实现——用 .NET 的 OPC UA 客户端库连上服务端,订阅你关心的变量,再把变化的数据写进 SQL Server。它适合三类人:做工业数据采集的 .NET 开发者、需要把设备数据接进 MES 或报表系统的集成工程师、以及想拿一个能跑通的例子入门 OPC UA 编程的新手。下面我按「先跑通、再讲参数、最后说坑」的顺序拆一遍,中间会给出可直接抄的代码骨架和连接串、订阅参数的具体写法。

2. OPC UA 客户端选型与连接建立:从 Session 到订阅的完整链路

2.1 为什么用 C# 和官方栈而不是自己撸协议

OPC UA 不是简单的请求-响应,它自带一套二进制协议、安全通道、会话管理和订阅模型。自己从 socket 开始写,光是把 SecureChannel 的握手和证书校验跑通就够喝一壶。C# 这边常见做法是用 OPC Foundation 官方提供的 .NET Standard 库,它把 Session、Subscription、MonitoredItem 这些概念都封装好了,你只需要关心「连哪个端点、订阅哪些节点、变化了怎么处理」。选它的理由很直接:协议栈成熟、社区示例多、和 .NET 生态的异步模型贴合。要注意的是这个库对 .NET 版本有要求,老项目如果还停在 .NET Framework 4.0,得先确认包能不能装,别到编译期才发现。

2.2 建立连接:端点、安全策略与证书

连接的第一步是拿到服务端的端点描述。现场设备通常暴露一个opc.tcp://ip:port的地址,但同一个地址下可能有多种安全策略(None、Sign、SignAndEncrypt)。很多新手直接选 None,本地测试没问题,一上生产就被安全策略拒掉。稳妥的做法是先Discover出所有端点,再按服务端实际启用的策略去匹配。

// 发现端点并挑选一个可用的安全策略 var endpointUrl = "opc.tcp://192.168.1.10:4840"; var config = new ApplicationConfiguration { ApplicationName = "DataCollector", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { // 客户端自己的证书,首次运行会自动生成 ApplicationCertificate = new CertificateIdentifier { StoreType = "Directory", StorePath = "OPC Foundation/CertificateStores/MachineDefault", SubjectName = "CN=DataCollector" }, AutoAcceptUntrustedCertificates = false // 生产环境别开 true }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 } }; await config.Validate(ApplicationType.Client); var endpointDescription = CoreClientUtils.SelectEndpoint(endpointUrl, useSecurity: true); var endpointConfiguration = EndpointConfiguration.Create(config); var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); // 建立会话 var session = await Session.Create( config, endpoint, false, "DataCollectorSession", 60000, new UserIdentity(new AnonymousIdentityToken()), null);

这段代码里几个参数值得盯一下。DefaultSessionTimeout是会话保活时间,设太短网络一抖就断,设太长服务端资源回收慢,60000 毫秒是常见起点。AutoAcceptUntrustedCertificates在调试期可以临时开,但生产环境必须关掉,否则等于把证书校验这道门拆了。UserIdentity用匿名还是用户名密码,取决于服务端配置,很多现场设备默认允许匿名,但正规项目会要求带凭证。

2.3 订阅模型:为什么不用轮询 Read

连上之后有两种拿数据的方式:定时Read轮询,或者Subscription订阅。轮询实现简单,但节点一多、频率一高,服务端压力陡增,而且你拿到的永远是采样时刻的值,中间跳变会丢。订阅模型是 OPC UA 的原生玩法:你告诉服务端「我关心这几个节点,值变了或者到时间了就推给我」,服务端负责监控,客户端只管收通知。对于产线数据采集,订阅几乎是唯一正确选择。

// 创建订阅,发布间隔 1000ms var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 1000, // 服务端推送周期 KeepAliveCount = 10, // 10 个周期没数据就发心跳 LifetimeCount = 30, // 30 个周期没收到就认为订阅失效 MaxNotificationsPerPublish = 1000, PublishingEnabled = true, Priority = 100 }; session.AddSubscription(subscription); await subscription.Create(); // 添加监控项 var item = new MonitoredItem(subscription.DefaultItem) { DisplayName = "Temperature", StartNodeId = new NodeId("ns=2;s=Machine1.Temperature"), AttributeId = Attributes.Value, SamplingInterval = 500, // 服务端采样间隔 QueueSize = 10, // 本地队列,防突发 DiscardOldest = true }; item.Notification += OnDataChanged; // 数据变化回调 subscription.AddItem(item); await subscription.ApplyChanges();

PublishingInterval和SamplingInterval是两个最容易搞混的参数。前者是服务端多久打包推一次,后者是服务端多久去读一次节点值。采样可以比发布快,这样即使推送周期长,也不会漏掉中间的变化(前提是 QueueSize 够)。QueueSize配合DiscardOldest决定突发数据怎么处理:队列满了是丢最旧的还是最新的,采集场景一般丢最旧,保证拿到最新值。

3. 数据落库:从 MonitoredItem 回调到 SQL Server 批量写入

3.1 回调里别做重活:生产者-消费者解耦

Notification回调是在库的内部线程上触发的,如果你在里面直接开数据库连接、执行 insert,一旦数据库慢或者网络卡,回调线程被堵住,后续通知就会堆积甚至丢。血泪经验是:回调里只做一件事——把数据塞进一个线程安全的队列,另起一个消费者线程专门写库。

// 线程安全队列做缓冲 private static readonly BlockingCollection<DataPoint> _queue = new BlockingCollection<DataPoint>(boundedCapacity: 10000); private static void OnDataChanged(MonitoredItem item, MonitoredItemNotificationEventArgs e) { foreach (var value in item.DequeueValues()) { var point = new DataPoint { NodeId = item.StartNodeId.ToString(), Value = value.Value?.ToString(), SourceTimestamp = value.SourceTimestamp, ServerTimestamp = value.ServerTimestamp, StatusCode = value.StatusCode.Code }; // 队列满时 TryAdd 返回 false,这里要记日志而不是死等 if (!_queue.TryAdd(point)) { // 落一条告警日志,说明消费跟不上生产 } } }

boundedCapacity是队列上限,设 10000 是个经验值,太小容易丢,太大内存吃不消。TryAdd而不是Add,是为了在队列满时不让回调线程阻塞——宁可丢一条并告警,也不能把整个订阅拖垮。SourceTimestamp是设备侧的时间戳,ServerTimestamp是 OPC UA 服务端收到的时间,落库时两个都存,后面排查「数据延迟到底是设备慢还是网络慢」时非常有用。

3.2 批量写入:SqlBulkCopy 还是表值参数

单条 insert 在每秒几百点的场景下会把数据库打爆。常见做法是攒一批再写,两种方案:SqlBulkCopy适合大批量、结构固定的场景,吞吐最高;表值参数(TVP)适合中等批量、需要事务控制的场景。采集程序一般用SqlBulkCopy配一个 DataTable,每攒够 500 条或者每 2 秒触发一次写入。

private static void ConsumeLoop() { var batch = new List<DataPoint>(500); foreach (var point in _queue.GetConsumingEnumerable()) { batch.Add(point); if (batch.Count >= 500) { WriteBatch(batch); batch.Clear(); } } } private static void WriteBatch(List<DataPoint> batch) { var table = new DataTable(); table.Columns.Add("NodeId", typeof(string)); table.Columns.Add("Value", typeof(string)); table.Columns.Add("SourceTimestamp", typeof(DateTime)); table.Columns.Add("ServerTimestamp", typeof(DateTime)); table.Columns.Add("StatusCode", typeof(int)); foreach (var p in batch) table.Rows.Add(p.NodeId, p.Value, p.SourceTimestamp, p.ServerTimestamp, p.StatusCode); using var conn = new SqlConnection( "Server=.;Database=OpcData;Integrated Security=true;TrustServerCertificate=true"); conn.Open(); using var bulk = new SqlBulkCopy(conn) { DestinationTableName = "dbo.TagValues", BatchSize = batch.Count, BulkCopyTimeout = 30 }; bulk.WriteToServer(table); }

连接串里TrustServerCertificate=true在本地开发方便,生产环境如果 SQL Server 配了正规证书,应该去掉并让客户端信任 CA。BulkCopyTimeout设 30 秒,避免大批量写入时超时中断。目标表建议按时间做分区或者定期归档,否则几千万行之后查询会明显变慢。

3.3 建表与索引:别让写入变成全表扫描

落库的表结构直接影响后续查询效率。一个够用的最小结构是节点 ID、值、两个时间戳、状态码,再加一个自增主键。索引建在NodeId + SourceTimestamp上,覆盖「查某个测点某段时间的数据」这个最高频的查询。

CREATE TABLE dbo.TagValues ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, NodeId NVARCHAR(200) NOT NULL, Value NVARCHAR(500) NULL, SourceTimestamp DATETIME2 NOT NULL, ServerTimestamp DATETIME2 NOT NULL, StatusCode INT NOT NULL ); CREATE NONCLUSTERED INDEX IX_TagValues_Node_Time ON dbo.TagValues (NodeId, SourceTimestamp) INCLUDE (Value, StatusCode);

Value用NVARCHAR(500)是个折中,OPC UA 的值可能是数字、布尔、字符串甚至数组,统一转成字符串存最省事,代价是查询时要做类型转换。如果测点类型固定,更好的做法是按类型分表或者加一列ValueType。INCLUDE把Value和StatusCode带进索引,查询时不用回表,这个细节在数据量大时差别很明显。

4. 避坑与排查:连接、订阅、落库三段的常见翻车点

4.1 连接阶段:证书被拒和端点选错

现象是Session.Create抛异常,提示证书不受信任或者安全策略不匹配。原因通常是客户端证书没被服务端信任,或者SelectEndpoint选了一个服务端没启用的策略。解决办法是先把客户端证书导出,导入到服务端的信任列表;如果现场允许,临时用AutoAcceptUntrustedCertificates = true验证是不是证书问题,确认后再关掉走正规流程。端点选错的话,打印出Discover返回的所有端点,看服务端实际开了哪个SecurityPolicy。

4.2 订阅阶段:收不到通知和队列溢出

现象是订阅建好了但回调一直不触发。先查StartNodeId对不对——命名空间索引ns=2在不同服务端可能不一样,用 UaExpert 之类的工具确认节点 ID。再查PublishingInterval是不是设得比服务端允许的最小值还小,有些设备会拒绝过短的周期。队列溢出的表现是日志里频繁出现TryAdd失败,说明消费速度跟不上生产,要么加大boundedCapacity,要么优化写库逻辑,要么降低采样频率。

4.3 落库阶段:时间戳时区和批量超时

现象是查出来的数据时间对不上。OPC UA 的时间戳是 UTC,SQL Server 的DATETIME2不带时区,直接存进去再按本地时间查就会差几个小时。解决办法是统一存 UTC,查询时再转换,或者落库前就转成本地时间并明确约定。批量超时一般是单批太大或者数据库有锁,把BatchSize调小、BulkCopyTimeout调大,同时检查目标表有没有触发器或外键拖慢写入。

4.4 断线重连:别指望库自动帮你兜底

现象是网络抖动后订阅静默失效,程序还在跑但数据不再更新。OPC UA 库有KeepAlive机制,但重连逻辑通常要自己写。常见做法是监听Session.SessionClosing事件,在里面做退避重连,重连成功后重建订阅和监控项。退避策略用指数增长,比如 1 秒、2 秒、4 秒、8 秒,避免服务端刚恢复就被重连风暴打垮。

4.5 内存与句柄:长时间运行的隐形杀手

现象是程序跑几天后内存涨到几个 G。排查方向是MonitoredItem有没有重复添加、Notification事件有没有重复订阅、DataTable有没有及时释放。每次重建订阅前先把旧的RemoveSubscription掉,事件回调在不需要时解绑。这类问题不会立刻暴露,但产线程序一跑就是几个月,不处理迟早翻车。

5. 进阶技巧:用配置驱动节点清单和写入策略

把节点 ID、采样间隔、目标表这些硬编码在代码里,每加一个测点就要改代码重新部署,这在现场是灾难。我后来的习惯是把节点清单抽到一个配置文件里,程序启动时读配置动态建订阅。下面是一个简化的 JSON 结构,每个测点带自己的采样周期和目标表。

{ "endpoint": "opc.tcp://192.168.1.10:4840", "publishingInterval": 1000, "items": [ { "nodeId": "ns=2;s=Machine1.Temperature", "sampling": 500, "table": "TagValues" }, { "nodeId": "ns=2;s=Machine1.Pressure", "sampling": 1000, "table": "TagValues" }, { "nodeId": "ns=2;s=Machine1.Status", "sampling": 2000, "table": "StatusLog" } ] }

读取配置后按table分组,每组一个消费者和一条写入通道,这样不同表之间互不阻塞。采样周期也按测点走,温度这种快变量给 500 毫秒,状态这种慢变量给 2 秒,避免无谓的服务端压力。验证配置有没有生效,最直接的办法是看数据库里各节点的写入频率是否符合预期,再对照 OPC UA 服务端的诊断信息看订阅是否正常。

还有一个容易被忽略的点:StatusCode不总是Good。设备可能返回Bad或者Uncertain,这些值要不要落库、怎么标记,得在配置里说清楚。我的做法是全部落库但加一列标记质量,查询时按需过滤,这样既保留了原始信息,又不影响正常分析。从那以后我每次上线采集程序,都会先拿一个测点跑满 24 小时,确认断线重连、队列水位、时间戳都对得上,再批量加节点。希望帮到你。

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

相关新闻

用perf精准定位Linux音频卡顿:调度延迟与中断排查实战

用perf精准定位Linux音频卡顿:调度延迟与中断排查实战

1. 为什么音频性能问题总让人抓狂&#xff1a;从一次爆音排查说起先描述一个场景。某嵌入式音频设备的应用层跑着播放任务&#xff0c;平时一切正常&#xff0c;但只要系统负载一上来&#xff0c;音频就开始出现间歇性爆音&#xff0c;偶尔还会整段卡顿几百毫秒。翻应用日志一无…

2026/10/20 10:31:12 阅读更多 →
FPGA音频回环设计:WM8731的I2S时序详解与踩坑指南

FPGA音频回环设计:WM8731的I2S时序详解与踩坑指南

简介&#xff1a;一份面向FPGA数字音频设计学习者的工程资料&#xff0c;以Altera DE2-EP2C35F672C6开发板为平台&#xff0c;借助WM8731立体声编解码器实现音频输入经ADC数字化后送入FPGA处理&#xff0c;再通过DAC还原输出的完整信号链。工程包含WM8731从模式配置、I2S串行音…

2026/10/20 10:31:12 阅读更多 →
OpenProject 开源项目管理快速上手:免费自托管,甘特图、看板、时间跟踪全都有

OpenProject 开源项目管理快速上手:免费自托管,甘特图、看板、时间跟踪全都有

OpenProject 开源项目管理快速上手&#xff1a;免费自托管&#xff0c;甘特图、看板、时间跟踪全都有 【免费下载链接】openproject OpenProject is the leading open source project management software for product, project and portfolio management. A powerful Jira alt…

2026/10/20 10:31:12 阅读更多 →

最新新闻

51单片机贪吃蛇实战:12864驱动、EEPROM掉电保存与嵌入式分层设计

51单片机贪吃蛇实战:12864驱动、EEPROM掉电保存与嵌入式分层设计

简介&#xff1a;本资源是一套面向大学本科课程设计的51单片机贪吃蛇游戏完整开发方案&#xff0c;适用于单片机原理与应用、嵌入式系统实践等课程学习与项目实训。资源包含Proteus仿真工程&#xff08;.pdsprj&#xff09;、Keil C51源码&#xff08;.c/.a51&#xff09;、编译…

2026/10/21 3:32:25 阅读更多 →
图书推荐系统实战:Python协同过滤从原理到代码避坑指南

图书推荐系统实战:Python协同过滤从原理到代码避坑指南

我记得第一次带学生跑这个图书推荐系统项目时&#xff0c;好几个人卡在了同一个地方&#xff1a;代码能跑通&#xff0c;但推荐结果全是乱的。后来查了半天&#xff0c;问题出在一个被注释掉的函数上。这个项目在中文互联网上流传了很多版本&#xff0c;绝大多数人拿到的都是简…

2026/10/21 3:32:25 阅读更多 →
空间注意力模块实战指南:原理、代码与调参经验

空间注意力模块实战指南:原理、代码与调参经验

做视觉任务这些年&#xff0c;大家都会遇到一个很实际的需求&#xff1a;网络深了、宽了&#xff0c;但该学的东西没学会。SpatialAttention空间注意力模块&#xff0c;是我调过的结构里性价比最高的一个——代码就几十行、参数量几乎可以忽略&#xff0c;却能明显改变网络的关…

2026/10/21 3:32:25 阅读更多 →
awesome-shizuku vs Dhizuku:两大Android权限提升工具终极对比指南

awesome-shizuku vs Dhizuku:两大Android权限提升工具终极对比指南

awesome-shizuku vs Dhizuku&#xff1a;两大Android权限提升工具终极对比指南 在Android开发的世界中&#xff0c;awesome-shizuku和Dhizuku是两个备受关注的高权限工具&#xff0c;它们都致力于让普通应用获得系统级权限&#xff0c;从而突破Android系统的限制。无论你是普通…

2026/10/21 3:32:25 阅读更多 →
CANN内测实战:从环境初始化到模型推理的完整指南

CANN内测实战:从环境初始化到模型推理的完整指南

前阵子看到某社区的“内测招募&#xff0c;免费体验CANN开发环境”活动&#xff0c;我第一时间就报名了。说实话&#xff0c;当时打动我的不只是“免费”两个字&#xff0c;而是想看看这个面向昇腾AI处理器的异构计算架构&#xff0c;在过去一年里到底进化到了什么程度。CANN这…

2026/10/21 3:32:25 阅读更多 →
Redis单线程还是多线程?从4.0到7.2的线程模型演进真相

Redis单线程还是多线程?从4.0到7.2的线程模型演进真相

1. 这个问题为什么值得花十分钟认真搞懂“Redis 到底是单线程还是多线程&#xff1f;”——这句提问在技术群、面试现场、甚至线上课程评论区里&#xff0c;几乎每周都会高频出现。它表面看是个“是非题”&#xff0c;但背后牵扯的是你对 Redis 性能本质、架构演进逻辑、以及高…

2026/10/21 3:31:24 阅读更多 →

日新闻

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事&#xff1a;AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里&#xff0c;按宣传口径&#xff0c;本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”&#xff0c;而是这话背后的含金量——它把过去只能…

2026/10/21 0:00:11 阅读更多 →
AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

1. 从“封号重开”这个行为说起&#xff1a;用户到底在追求什么“封号重开”这四个字&#xff0c;放在任何产品语境里都带着一股决绝的味道。一个账号从零开始积累使用记录、调教偏好、沉淀工作流&#xff0c;背后是大量的时间和精力成本。正常情况下&#xff0c;没有人愿意主动…

2026/10/21 0:00:11 阅读更多 →
ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

1. 为什么在ROS里硬刚YOLOv3不是“炫技”&#xff0c;而是工程落地的必经之路我第一次在某高校机器人实验室看到那台搭载Jetson TX2的移动底盘原地打转、反复识别错垃圾桶时&#xff0c;就意识到&#xff1a;单纯跑通darknet_ros demo和真正让YOLOv3在ROS里“干活”&#xff0c…

2026/10/21 0:00:11 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/20 9:50:45 阅读更多 →