OPC UA Python客户端实战:Linux边缘设备稳定连接PLC指南
简介本资源为一套经过实际测试验证的OPC UA客户端完整实现工程面向工业自动化领域开发者、物联网系统集成工程师及学习工业通信协议的中高级技术人员解决OPC UA客户端开发入门难、安全连接配置复杂、服务调用逻辑不清晰等实践痛点。压缩包共172个文件含58个C#源码文件如UAClient.cs、SimpleClient.cs等核心逻辑、17个DLL依赖库、8个可执行程序含带config配置的vshost调试入口、18个resx本地化资源及22个PNG界面图标整体2.77MB结构清晰便于按模块理解客户端初始化、节点浏览、数据读写、订阅发布与证书认证全流程。已有1154人学习下载代码涵盖DesignTimeResolveAssemblyReferences缓存、.csproj项目配置、.sln解决方案及调试符号pdb等完整VS工程要素特别适合通过源码逆向掌握OPC UA栈调用机制、安全策略配置范式与信息模型解析逻辑。1. OPC UA 客户端实战不是配个 IP 就能连上工业设备的黑匣子很多人第一次接触 OPC UA以为下载个客户端软件、填上服务器地址和端口点连接就完事了——结果卡在“BadTimeout”或“BadNotConnected”连握手都失败。其实 OPC UA 不是 HTTP 那种“发请求→收响应”的简单协议它是一套带安全策略、会话生命周期、节点浏览路径、数据编码规则的完整通信框架。这个OPC_UA_Client资源包不是某个 GUI 工具的安装包而是一套可调试、可嵌入、可二次开发的 Python 实现客户端源码含完整示例脚本、证书生成工具、错误日志分级模板专为需要在 Linux 边缘设备上稳定轮询 PLC 数据、对接自研 SCADA 前端、或做 OPC UA 协议层故障复现的工程师准备。它不依赖 Windows COM 组件不绑定特定厂商 SDK所有加密、UA 编码、会话心跳逻辑都落在asyncua库之上但又绕开了asyncua默认配置里那些容易翻车的默认值。如果你正被“证书拒绝”“匿名登录被禁”“读取空值不报错却返回 BadStatus”折磨这份资源就是你该拆开的第一份后悔药。2. 为什么选 asyncua 而非官方 Stack 或 .NET SDK轻量、可控、调试友好2.1 OPC UA 协议栈选型的三个硬约束工业现场不是实验室设备固件版本老旧如某型号 PLC 只支持 UA 1.02、网络带宽受限4G 边缘网关上行仅 300kbps、证书体系混乱自签名证书未预置信任链。这时候用官方 C Stack 或 .NET SDK看似“原生”实则黑盒太深——连接失败时你既看不到 TLS 握手哪一步断的也抓不到 UA 层的OpenSecureChannelRequest是否被截断。而asyncua是纯 Python 实现所有 UA 报文结构、二进制编码UA Binary、安全策略协商过程全部暴露在源码里。更重要的是它支持异步非阻塞一个进程可同时维护 58 个不同厂商的 OPC UA 服务器会话这对多设备轮询场景是刚需。提示本资源包基于asyncua1.10.52023 Q4 稳定版不推荐升级到 1.11因后者重构了证书验证逻辑与多数老旧 PLC 的证书格式兼容性下降。2.2 客户端核心类结构从 Connection 到 Node 的四层映射本资源包的client_core.py并非简单封装Client类而是构建了四层职责分离结构Layer 1ConnectionManager负责 TLS 通道建立、重连退避指数退避 jitter、证书自动加载/校验。关键参数timeout15避免卡死、security_policySecurityPolicy.Basic256Sha256强制指定不依赖服务器建议值。Layer 2SessionPool每个服务器地址端口对应一个 Session 实例但 Session 复用需手动控制——本包默认启用session_reuseTrue但加了max_session_age36001 小时强制重建防止某些 PLC 在长会话后静默丢弃请求。Layer 3NodeResolver解决“怎么找到我要读的变量”这个高频痛点。不依赖browse()全量遍历慢且易超时而是支持三种解析模式by_path(Objects.Server.Robot.Axis1.Position)标准 UA 路径by_nodeid(ns2;sAxis1_Position)直接 NodeId最快by_displayname(Position, namespace_index2)模糊匹配用于调试Layer 4DataReader封装read_values()和create_subscription()但做了两处关键增强自动处理StatusCode当返回BadWaitingForInitialData时不抛异常而是等待 2 秒后重试支持批量读取时按DataType自动解码Int32→intDouble→floatBoolean→boolByteString→bytes非 base64 字符串。2.3 初始化一个生产级客户端5 行代码背后的 7 个隐式动作from opc_ua_client.client_core import OPCUAConnection client OPCUAConnection( endpointopc.tcp://192.168.1.100:4840, cert_path./certs/client_cert.der, private_key_path./certs/client_key.pem, server_cert_path./certs/server_cert.der, security_modeSignAndEncrypt )这 5 行背后触发的动作远不止“连服务器”证书预检检查client_cert.der是否过期、是否由server_cert.der所在 CA 签发用 OpenSSL 命令行调用验证TLS 参数固化强制使用TLSv1.2禁用TLSv1.3因部分西门子 S7-1500 固件存在 1.3 兼容 BugEndpoint 解析若endpoint含域名自动执行 DNS A 记录查询并缓存 IP 300 秒避免 DNS 波动导致连接抖动安全策略协商向服务器发送GetEndpointsRequest但只接受Basic256Sha256和None两种策略跳过Aes128Sha256RsaOaep旧设备不支持会话参数定制设置requested_publishing_interval500毫秒requested_lifetime_count10000防会话过早失效心跳保活启动后台任务每 30 秒发一次PublishRequest即使无订阅维持会话活跃日志分级注入所有 UA 层报文头MessageHeader、安全信道 ID、会话 ID 自动打到 DEBUG 日志便于抓包比对。这些动作在__init__中顺序执行任一环节失败均抛出带上下文的OPCUAConnectionError异常非原始UaError错误信息包含具体失败步骤编号如Step 4: Security policy negotiation failed with status BadNotSupported。3. 连接、读值、订阅三步走从零跑通第一个数据点3.1 连接阶段三次握手失败的典型路径与日志定位法连接不是原子操作而是分三阶段握手阶段关键动作成功标志失败常见日志关键词1. TCP/TLS 层建立 TCP 连接 → TLS 握手INFO: Connected to 192.168.1.100:4840ConnectionRefused,SSLV3_ALERT_HANDSHAKE_FAILURE2. UA 安全信道发送OpenSecureChannelRequest→ 收ResponseDEBUG: Secure channel ID: 12345BadUnexpectedError,BadCertificateUseNotAllowed3. UA 会话发送CreateSessionRequest→ 收CreateSessionResponseINFO: Session created, session_idns1;i67890BadTooManySessions,BadUserAccessDenied注意若卡在阶段 290% 是证书问题。本包提供./tools/cert_check.py脚本输入server_cert.der和client_cert.der自动输出证书有效期、Issuer/Subject 匹配度、KeyUsage 是否含digitalSignature、是否在系统信任库中。3.2 读取单个变量read_node_value()的四个必设参数不要直接调client.read_node_value(ns2;sMotor_Speed)——这会用默认超时10 秒和默认数据类型猜测极易误判。生产环境必须显式传参value, status_code, data_type client.read_node_value( node_idns2;sMotor_Speed, timeout_ms3000, # 必设老旧 PLC 响应常超 1s data_type_hintDouble, # 必设避免 ua.BinaryVariant 解码失败 raise_on_bad_statusFalse # 必设让业务层决定如何处理 BadStatus )timeout_ms3000asyncua默认 10000ms但工业现场要求快速失败3 秒未响应即标记为“设备离线”而非卡住整个 asyncio event loopdata_type_hintDouble告诉解码器预期类型否则ua.Variant可能返回ua.VariantType.Null导致Noneraise_on_bad_statusFalseBadWaitingForInitialData或BadNotReadable等状态码应由业务逻辑处理如降级为上一次有效值而非炸掉进程。3.3 创建数据订阅为什么create_subscription()后还要subscribe_data_change()OPC UA 订阅是两级结构新手常漏第二级# Step 1: 创建订阅对象定义发布周期、生存期 sub client.create_subscription( publishing_interval1000, # 服务器每 1s 推一次 handlerDataChangeHandler() # 自定义回调类必须实现 datachange_notification() ) # Step 2: 向该订阅添加监控节点这才是真正注册变量 handle sub.subscribe_data_change( nodens2;sMotor_Speed, attrua.AttributeIds.Value, # 固定值读 Value 属性 queuesize10 # 本地队列深度防回调处理慢导致丢数据 )queuesize10是关键若你的datachange_notification()处理耗时 200ms而服务器每 100ms 推一次不设队列会直接丢弃新值attrua.AttributeIds.Value不能省略否则默认读DisplayName返回字符串而非数值DataChangeHandler类必须继承SubscriptionHandler且datachange_notification()方法内禁止做阻塞操作如写文件、发 HTTP 请求应投递到线程池或消息队列。4. 避坑OPC UA 客户端部署中最常踩的 4 个深坑4.1 现象连接成功但browse()返回空列表read_node_value()报BadNodeIdUnknown原因服务器启用了“命名空间隔离”ns2在GetEndpoints响应中未声明或客户端未正确解析NamespaceArray。某些罗克韦尔 CompactLogix 设备默认将自定义变量放在ns3但browse()从ObjectsFolderns0开始遍历无法跨命名空间跳转。解决不用browse()改用client.get_namespace_array()获取所有命名空间 URI再用client.get_node(fns{ns_index};s{var_name})直接构造 NodeId。本包NodeResolver.by_displayname()内部已自动做此映射。4.2 现象证书校验通过但CreateSession返回BadUserAccessDenied原因服务器配置了用户权限但客户端未发送用户名/密码。asyncua默认以匿名Anonymous策略连接而很多 PLC 要求Username策略。解决初始化时传入user_nameadmin和password123456并确保security_modeSignAndEncrypt仅 Sign 不够。本包OPCUAConnection构造函数会自动在CreateSessionRequest中填充userIdentityToken。4.3 现象read_node_value()返回None但status_code是Good原因该节点是VariableType而非Variable或其ValueRank为-1标量但实际值为空。更隐蔽的是某些设备如倍福 CX5140对未初始化的INT变量返回ua.Variant(0)但data_type_hint设为Int32时解码器会尝试.to_int32()而0的VariantType可能是Null。解决先用client.read_node_attribute(node_id, ua.AttributeIds.DataType)确认真实 DataType再设data_type_hint或改用read_node_attributes([node_id], [ua.AttributeIds.Value, ua.AttributeIds.DataType])一次性读多属性。4.4 现象Linux 系统上运行正常Docker 容器内连接超时原因容器内/etc/hosts未映射宿主机 hosts或 DNS 配置错误更常见的是容器未挂载系统证书库/etc/ssl/certs导致 TLS 验证失败。解决启动容器时加参数--dns 8.8.8.8 --add-host plc-server:192.168.1.100并挂载-v /etc/ssl/certs:/etc/ssl/certs:ro。本包cert_check.py在容器内运行时会主动检测/etc/ssl/certs是否可读不可读则降级为verifyFalse并警告。5. 生产环境加固证书自动续期、断线重连策略、日志归档三件套5.1 证书自动续期用 OpenSSL 脚本替代手动操作工业设备证书通常 1 年有效期但人工更新易遗漏。本包提供./tools/renew_cert.sh只需配置 3 个变量# ./tools/renew_cert.sh CA_KEY./ca/private/ca.key.pem CA_CERT./ca/certs/ca.cert.pem CLIENT_CSR./certs/client.csr # 生成新证书365 天保留原私钥 openssl x509 -req -in $CLIENT_CSR -CA $CA_CERT -CAkey $CA_KEY \ -CAcreateserial -out ./certs/client_cert.der -days 365 -sha256 \ -extfile (printf subjectAltNameDNS:plc-server,IP:192.168.1.100\nkeyUsagedigitalSignature\nextendedKeyUsageclientAuth)关键点-extfile用 process substitution 动态生成扩展配置确保subjectAltName包含 PLC 的 DNS 名和 IPUA 协议强制要求keyUsagedigitalSignature和extendedKeyUsageclientAuth是 UA 客户端证书的最小必要扩展缺一则服务器拒绝脚本末尾自动调用openssl x509 -in ./certs/client_cert.der -text -noout输出证书详情到./logs/cert_info.log供 CI/CD 流水线校验。5.2 断线重连指数退避 最大重试次数 业务熔断盲目重连会压垮 PLC。本包ConnectionManager实现三级熔断触发条件行为日志级别连续 3 次BadTimeout启动指数退避第 1 次等 1s第 2 次等 2s第 3 次等 4s…最大 60sWARNING连续 10 次失败标记该服务器为UNREACHABLE停止所有读/订阅进入SLEEP状态ERRORSLEEP状态持续 30 分钟自动唤醒发起一次探测连接不带订阅成功则恢复服务INFO提示SLEEP状态下所有read_node_value()调用立即返回(None, StatusCode.BadNotConnected, None)业务层可据此返回缓存值或告警。5.3 日志归档按会话 ID 切分 UA 报文头过滤默认日志混杂 TCP 层和 UA 层信息排查困难。本包logger_config.py配置两个 Handlerua_debug_handler只记录DEBUG级别日志且仅当 log message 含UA Message或SecureChannel时才写入./logs/ua_debug_session_id.logopcuacore_handler记录INFO及以上按天滚动保留 7 天文件名opcuacore_20240520.log。关键过滤逻辑Python 伪代码class UAMessageFilter(logging.Filter): def filter(self, record): # 只放行含 UA 协议特征的日志 return (UA Message in record.msg or SecureChannel in record.msg or SessionId in record.msg or record.levelno logging.WARNING)这样当你收到BadNotConnected告警可直奔./logs/ua_debug_ns1_i67890.log搜索OpenSecureChannelRequest看是 TLS 握手失败还是 UA 层拒绝。6. 验证客户端健壮性的三个硬核技巧用真实 PLC 做压力测试6.1 技巧一模拟网络抖动验证重连策略有效性别只测“连得上”要测“断得稳”。用tcTraffic Control在 Linux 上制造可控丢包# 对目标 PLC IP 添加 5% 随机丢包模拟弱网 sudo tc qdisc add dev eth0 root netem loss 5% # 运行客户端观察日志中重连间隔是否符合指数退避 python examples/read_motor_speed.py # 清除规则 sudo tc qdisc del dev eth0 root重点验证第 1 次丢包后是否在 1s 内发起重连WARNING: Reconnecting in 1.0s第 3 次连续失败后日志是否出现ERROR: Max retry attempts (10) reached, entering SLEEP stateSLEEP状态是否严格持续 30 分钟用date命令计时6.2 技巧二用 Wireshark 过滤 UA 报文比对客户端日志Wireshark 本身支持 OPC UA 解析需启用UA协议解析器但默认不过滤。在捕获窗口输入显示过滤器tcp.port 4840 (ua.message_header || ua.service_request || ua.service_response)然后对比客户端日志中的SecureChannelId: 12345是否与 Wireshark 中SecureChannelId字段一致CreateSessionRequest的RequestedSessionTimeout是否等于代码中设的3600000毫秒PublishRequest的SubscriptionId是否与create_subscription()返回值匹配若不一致说明客户端配置未生效需检查client_core.py中SessionParameters的赋值位置。6.3 技巧三注入 BadStatus验证业务层容错能力不要等 PLC 真出问题。本包提供./tools/inject_badstatus.py可对任意节点返回指定 StatusCode# 注入 BadWaitingForInitialData模拟 PLC 刚上电未就绪 inject_badstatus( endpointopc.tcp://127.0.0.1:4840, node_idns2;sMotor_Speed, status_codeua.StatusCode(ua.StatusCodes.BadWaitingForInitialData) )此时运行业务脚本应观察到read_node_value()返回(None, StatusCode.BadWaitingForInitialData, None)而非抛异常若启用了auto_retry_on_badstatusTrue本包默认关闭需显式开启则应重试 3 次后放弃订阅回调中datachange_notification()的val参数应为Nonedata_type为Nonestatus_code为注入值。从那以后我每次部署新客户端都强制走一遍这三步tc丢包测试、Wireshark 报文比对、BadStatus 注入验证。少走一次就可能在凌晨三点被产线停机电话叫醒。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

Java Swing黄金矿工实战:抛物线动画与双缓冲渲染

Java Swing黄金矿工实战:抛物线动画与双缓冲渲染

简介:本资源是基于Java语言实现的经典休闲游戏《黄金矿工》的完整可运行项目,面向Java初学者与GUI编程进阶学习者,帮助掌握面向对象设计、Swing图形界面开发、事件驱动机制及基础游戏逻辑实现等核心能力。压缩包共30个文件,包含6个…

2026/10/18 10:48:51 阅读更多 →
PFA算法:基于正交投影一致性的多模态特征空间隐式对齐

PFA算法:基于正交投影一致性的多模态特征空间隐式对齐

简介:本资源为数据挖掘领域Pattern Fusion(PFA)算法的完整实践套件,面向算法研究者、R/ MATLAB开发者及高年级本科生或研究生,聚焦于大规模频繁模式的精炼提取与融合优化问题。资源共23个文件,涵盖9个txt&a…

2026/10/18 10:47:51 阅读更多 →
Flex布局对齐三兄弟:justify-content、align-items、align-content详解

Flex布局对齐三兄弟:justify-content、align-items、align-content详解

Flex 布局这个系列写到第三篇,前面已经把容器的 display、flex-direction、flex-wrap 这些基础概念过了一遍。评论区不少读者留言说,最头疼的就是 justify-content、align-items、align-content 这三个属性,看起来都是“对齐”,但…

2026/10/18 10:47:51 阅读更多 →

最新新闻

Flink实时计算三要素:Window、State与Checkpoint深度解析

Flink实时计算三要素:Window、State与Checkpoint深度解析

做实时计算绕不开这几个词:Window、State、Checkpoint。不管你是刚接触Flink,还是已经写了一段时间作业,总会遇到数据算不准、重启后状态丢了、窗口边界对不齐这类问题。我见过很多新手一上来就写业务代码,结果窗口和状态混在一起…

2026/10/18 11:32:06 阅读更多 →
YOLOv12即插即用模块化改进:注意力机制与跨尺度融合实战

YOLOv12即插即用模块化改进:注意力机制与跨尺度融合实战

1. 模块即插即用的整体设计思路1.1 为什么我坚持用模块替换而不是重构整个网络YOLOv12 的改进工作做到第三部分,先说一下我的总体判断:如果你不是做学术研究、不是想发论文,而是手上有一个实际项目要落地,最好的做法不是把网络结构…

2026/10/18 11:32:06 阅读更多 →
AI信息源清单与100美元低成本训练:一份可落地的技术路径指南

AI信息源清单与100美元低成本训练:一份可落地的技术路径指南

最近在 GitHub 上扒到一份挺有意思的开源收藏:92 个 AI 信息源被整理成一份清单,论文、开源仓库、Newsletter、播客、社区讨论全都收进去了。我第一次刷完这份清单的感觉是“这也太干了”,但真正用了一段时间后,我意识到值钱的不是…

2026/10/18 11:32:06 阅读更多 →
高校统一身份认证系统设计:CAS单点登录与安全实现指南

高校统一身份认证系统设计:CAS单点登录与安全实现指南

1. 为什么要做高校统一身份认证前阵子有个A同学抱着毕设题目来问我:“Java写一个高校统一身份认证系统,到底怎么做才是对的?”我一看题目就明白了,这几乎是每届计算机毕设的常客。做的人多,但真正把“统一身份认证”这…

2026/10/18 11:32:06 阅读更多 →
Java实现TRC20收款系统:从区块扫描到幂等回调的完整实践

Java实现TRC20收款系统:从区块扫描到幂等回调的完整实践

简介:这是一套基于Java开发的TRC20收款系统完整源码包,面向Java后端开发者与区块链应用集成人员,可用于快速搭建支持USDT(TRC20)收款的业务平台。资源共377个文件,压缩包仅6.15MB,结构清晰&…

2026/10/18 11:32:06 阅读更多 →
LangChain SQL Agent实战:构建企业级自然语言查数助手

LangChain SQL Agent实战:构建企业级自然语言查数助手

说实话,直到今天,仍然有大量团队在“让大模型直接写SQL”这件事上反复踩坑。裸调大模型接口,让它根据一句“帮我查一下上个月华东区哪些客户的回款逾期了”生成SQL,然后把结果丢给用户——你很快会发现几类问题:生成的…

2026/10/18 11:31:06 阅读更多 →

日新闻

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

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

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

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

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

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

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

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

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

2026/10/18 0:00:55 阅读更多 →

周新闻

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

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

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

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

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

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

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

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

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

2026/10/18 0:00:55 阅读更多 →

月新闻

我发现了一个新思路:用 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/18 0:55:02 阅读更多 →
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/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练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/16 14:23:36 阅读更多 →