news 2026/9/10 1:21:41

基于Unity的汽车零部件产线数字孪生方案设计与落地实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Unity的汽车零部件产线数字孪生方案设计与落地实践

做汽车零部件产线的数字孪生,最怕什么?最怕做出来一个好看但没用的“数字展厅”。产线数据接不进来、模型动不起来、设备状态对不上、产线一抖动就崩溃,这套东西就算画面再炫,车间主任也不会打开第二回。

我过去大半年一直在折腾一套面向汽车零部件产线的 Unity 数字孪生方案,技术栈就是 C# + Unity,目标是真正落地到车间现场,而不是停留在演示 Demo。从 PLC 数据采集、消息中间件、MES 对接,到孪生体建模、动画驱动、UI 呈现,再到性能优化和部署运维,整套链路都跑通了。这篇文章把这套方案的完整设计思路、核心代码、踩过的坑一次性整理出来,给正在做同类项目的朋友一份可以直接参考的底稿。

整套方案适合这几类人来读:已经在用 Unity 做工业可视化、但不知道怎么把数据链路跑通的人;做上位机或者 SCADA 出身、想往数字孪生方向转的人;以及被领导安排“搞一个车间数字孪生”但完全没头绪的朋友。下面按我落地时的推进顺序来讲。

1. 方案整体设计与技术选型思路

1.1 先搞清楚数字孪生到底要解决产线什么问题

汽车零部件产线有个典型特点:设备种类多、节拍快、工序耦合紧密,而且一旦某台设备停机,影响会沿着产线快速传导。传统 MES 和 SCADA 是二维报表和趋势图,想看“整条产线现在的真实物理状态”非常费劲。数字孪生在这里的价值不是“好看”,而是把产线在三维空间里实时映射出来,让管理者一眼看懂当前设备状态、在制品的实时位置、有没有异常报警,以及瓶颈工位在哪。

我在这套方案里把需求拆成了四层:

  • 数据层:从 PLC、传感器、MES 定时或实时采集数据,统一格式后进入系统。
  • 模型层:把产线设备、输送线、在制品建立成三维模型,并建立与物理设备的对应关系。
  • 驱动层:用实时数据驱动模型的动作、颜色、文字状态,实现“数字体跟随物理体”的效果。
  • 展示层:车间全局漫游、设备详情、报警列表、产量统计等交互界面。

这四层不是独立的,数据层是底座,模型层是载体,驱动层是灵魂,展示层是出口。很多团队一开始就把精力放在展示层,把模型做得极其精细,结果数据接不进来,模型动都不动,这是最大的坑。

1.2 为什么选 Unity + C# 而不是纯 Web 或其它引擎

很多做工业的人会问,现在 WebGL 和 Three.js 不是也能做三维可视化吗?为什么还要用 Unity?我的回答是:看你要做到什么深度。

汽车零部件产线的数字孪生,往往需要处理几万甚至几十万个零部件对象,还要实时响应毫秒级的设备状态变化。纯 Web 方案在场景规模一大之后,帧率会很难看。Unity 的优势在于:

  • 成熟的渲染管线和 Level of Detail 机制,能撑住大规模场景。
  • C# 写业务逻辑非常顺手,和上位机、MES 后端开发语言栈一致,团队上手成本低。
  • 有完整的 UI 系统(UGUI)、动画系统(Animator)、物理系统,做交互和仿真都很方便。
  • 如果要接 VR/AR 设备做巡检或培训,Unity 生态也最成熟。

有一点需要提前说清楚:Unity 本身不是实时数据库,也不是消息中间件。数字孪生的“实时性”来自背后的数据链路。Unity 更像是一个强大的三维呈现和交互客户端,你要给它喂数据,它才能“活”起来。别指望 Unity 自己能把产线数据拉过来,中间一定需要一套通信和数据处理层,这就是 C# 的用武之地。

1.3 系统架构分层与数据流方向

我的最终架构是这样设计的:

物理层(PLC/传感器/设备) -> 边缘采集层(Modbus TCP/OPC UA) -> 消息中间件(MQTT/Redis) -> 数据服务层(C# 服务端解析、清洗、存储) -> Unity 客户端(C# 脚本驱动孪生体)

每一层的职责很清楚:

  • 边缘采集层:负责和 PLC 通信,把寄存器地址里的原始值读出来。
  • 消息中间件:把采集到的数据丢到 MQTT 主题或 Redis 里,起到削峰填谷的作用,避免 PLC 端到端直连 Unity 导致耦合过紧。
  • 数据服务层:负责协议解析、数据过滤、单位换算、阈值判断等。这一步必须在服务端做,别塞到 Unity 里。
  • Unity 客户端:通过 WebSocket 或 MQTT 订阅数据,把数据绑定到孪生体上,驱动动作、颜色、UI 更新。

为什么要加一层消息中间件,而不是让 Unity 直接去连 PLC?原因有三个:第一,PLC 的通信接口通常只支持少量并发连接,Unity 反复断连会干扰现场控制;第二,中间件可以做数据缓存,Unity 断线重连后能快速拿到历史快照;第三,多个客户端(比如中控大屏、办公室看板、手机小程序)可以共用一份数据,而不是各连一遍 PLC。

2. 数据链路设计与通信层实现

2.1 PLC 数据采集:Modbus TCP 和 OPC UA 怎么选

汽车零部件产线的 PLC 品牌很多:西门子 S7 系列、三菱 FX/Q 系列、欧姆龙 NJ/NX 系列,还有大量第三方传感器和仪表。选通信协议的原则是:能走 OPC UA 就走 OPC UA,走不了再退而求其次用 Modbus TCP。

OPC UA 是工业互联的标准协议,胜在自描述能力强——节点里有完整的地址、类型、单位信息,你不需要对着 PLC 点位表翻半天才知道那个地址是干嘛的。缺点是部分老设备的 PLC 不支持,需要额外加网关。Modbus TCP 则几乎是所有 PLC 的标配,实现简单,但需要手动维护点位映射表,点位一多就容易乱。

我这次用的方案是:西门子 S7-1200 走 S7 协议直采,第三方设备用 Modbus TCP 采集,统一封装成一个硬件访问服务。如果现场没有 OPC UA 服务器,可以考虑用 KEPServerEX 这类网关软件先做协议转换,把所有的数据统一成 OPC UA 节点,这样上层代码只用面对一种协议,会省非常多的麻烦。

2.2 数据服务层:C# 实现设备状态轮询与数据推送

数据服务层我用了 .NET 6 的 BackgroundService 来写后台轮询任务。核心思路是:开一个定时器,每 200ms 扫一遍所有需要采集的点位,把读到的原始值标准化成统一的设备状态模型,再发布到 MQTT。

这里有一个很关键的架构决策:不要在 Unity 里做数据采集。原因很简单,Unity 的主循环是渲染循环,你要是把 PLC 通信放在主线程里,一旦 PLC 响应慢,画面就会卡死;放在子线程里,又涉及跨线程访问 Unity API 的各种限制。把采集放到独立服务里,Unity 只负责订阅干净的数据,两边职责清楚,问题也容易排查。

采集服务的核心结构大概是这样:

public class PlcCollectWorker : BackgroundService { private readonly IPlcClient _plcClient; private readonly IMessagePublisher _publisher; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200)); while (await timer.WaitForNextTickAsync(stoppingToken)) { var snapshot = await _plcClient.ReadAllAsync(); var payload = JsonSerializer.Serialize(snapshot); await _publisher.PublishAsync("plant/production/state", payload); } } }

注意这里用了PeriodicTimer,它比Thread.Sleep更准,也不会在长时间运行后产生时间漂移。点位读取频率不需要一味求快,一般 200ms 到 500ms 足够满足画面平滑的需求,太快的频率不是增加 PLC 负担就是增加网络负担。

2.3 Unity 客户端通过 MQTT 订阅数据并驱动孪生体

Unity 端订阅数据我用了 MQTTnet 库。这是一个纯 C# 实现的 MQTT 客户端库,用起来很简单。

在 Unity 里订阅 MQTT 需要注意一个核心问题:MQTTnet 的ApplicationMessageReceived事件是在后台线程触发的,不能直接在回调里操作 Unity 的 GameObject。正确做法是把数据放进一个线程安全的队列或者用锁保护的内存变量,然后在 Unity 的Update里取出来用。

public class MqttDataBridge : MonoBehaviour { private MqttClientOptions _options; private IMqttClient _mqttClient; private readonly object _lock = new object(); private DeviceStateData _latestData; private void Start() { _options = new MqttClientOptionsBuilder() .WithTcpServer("192.168.1.100", 1883) .Build(); _mqttClient = new MqttFactory().CreateMqttClient(); _mqttClient.ApplicationMessageReceived += OnMessageReceived; _mqttClient.ConnectAsync(_options).Wait(); _mqttClient.SubscribeAsync("plant/production/state"); } private void OnMessageReceived(MqttApplicationMessageReceivedEventArgs e) { var payload = e.ApplicationMessage.ConvertPayloadToString(); var data = JsonConvert.DeserializeObject<DeviceStateData>(payload); lock (_lock) { _latestData = data; } } private void Update() { DeviceStateData snapshot; lock (_lock) { snapshot = _latestData; } if (snapshot != null) { TwinManager.Instance.ApplySnapshot(snapshot); } } }

在这个例子里,TwinManager是一个单例管理类,负责把数据分发到对应的设备孪生体。这里强调一下lock的使用:虽然_latestData只是单个引用赋值,从内存模型角度直接读也许不会出错,但加了锁之后语义更明确,后续如果改成同时更新多个字段,也不容易踩坑。

3. 数字孪生体建模、坐标统一与实时驱动

3.1 模型规范:工业场景不需要堆多边形面数

很多团队拿到 CAD 模型就直接往 Unity 里塞,结果就是场景卡成幻灯片。汽车零部件产线设备模型动辄几百个零件,加上在制品、输送线、工装夹具,一个场景几十上百万个三角面太常见了。

这里需要明确一个观念:数字孪生模型分为“展示模型”和“仿真模型”两套。展示模型要有一定的真实感,但不需要精细到每个螺栓。仿真模型则以几何体为主,主要用于碰撞检测和空间占位判断。在 Unity 里,我建议两套模型配合使用——远看用展示模型,近看切换到精细细节,运行时用仿真模型做逻辑判断。

面数控制经验值:

  • 单个设备(如一台加工中心):一万到五万个三角面足够,再多就要考虑简化。
  • 整条产线场景:总体面数控制在五十万到一百万以内,这样主流显卡跑 60 帧没压力。
  • 在制品零件:一个零件几百到一千个面就够了,反正会被夹具挡住大半。

建模工具方面,3ds Max 做工业模型是老本行,Blender 也能胜任。导出的格式我推荐 FBX,因为它对 Unity 的支持最好,能保留层级结构、动画、材质信息。

3.2 坐标系与尺度统一:一个小数点毁掉一条产线

模型导入后第一件事就是确认单位。CAD 软件里经常用毫米,Unity 默认用米,如果你直接把 CAD 模型导出 FBX 而不做单位转换,导入 Unity 后模型会大 1000 倍——你的产线视角直接怼到机床内部去了。

我的做法是:在建模时统一约定以毫米为单位建模,然后导出 FBX 时设置单位换算,让 Unity 识别为正确的米制尺寸。导入之后再用一个基准点(比如厂房立柱或产线起点)校准所有设备的位置,确保整个场景的绝对坐标和实际产线布局一致。

坐标统一还涉及另一个问题:Unity 的坐标系是左手系,且 Y 轴朝上;CAD 软件里 Z 轴朝上的居多。导入后要检查设备朝向是否正常。最简单的方法是以设备的“上料口方向”为标准,手动旋转校准,然后把校准结果保存为 prefab,后面对应的设备直接用同一个 prefab 实例化。

3.3 设备状态驱动的三层实现:颜色、动画、数字面板

设备孪生体的驱动我分三层实现,每层对应的数据类型和刷新频率都不同:

底层是状态色映射。设备状态来自 PLC 的数据块,通常有:运行(绿色)、待机(黄色)、故障(红色)、离线(灰色)。每种状态对应材质颜色的切换。这里要注意,直接改Material.color会导致材质实例化,增加 DrawCall。更好的做法是多套材质轮换,或者用 Shader 的 Color 属性配合 MaterialPropertyBlock 来控制颜色。

中间层是动画驱动。比如机械臂的关节旋转、输送带的循环运动、机床的开关门。这些动作不是传统美术动画,而是由实时数据驱动的。我的做法是:在 C# 脚本里计算每个运动轴的实时目标角度,然后在 Update 里做插值平滑。比如一台六轴机器人的各关节角度,需要从 PLC 读取,然后通过逆运动学或者直接角度映射驱动模型。

public class RobotArmController : MonoBehaviour { public Transform[] Joints; public float smoothTime = 0.1f; private Vector3[] _currentVelocity = new Vector3[6]; public void UpdateJointAngles(float[] targetAnglesDegrees) { for (int i = 0; i < Joints.Length && i < targetAnglesDegrees.Length; i++) { var targetRot = Quaternion.Euler(0, targetAnglesDegrees[i], 0); Joints[i].localRotation = DampAngle( Joints[i].localRotation, targetRot, ref _currentVelocity[i], smoothTime ); } } private Quaternion DampAngle(Quaternion current, Quaternion target, ref Vector3 velocity, float smoothTime) { float angle = Quaternion.Angle(current, target); if (angle < 0.01f) return target; return Quaternion.RotateTowards(current, target, angle * Time.deltaTime * 10f); } }

这里用了Quaternion.RotateTowards而不是直接赋值,目的是让模型动作平滑过渡,看起来像真实的物理运动,而不是瞬间跳变。平滑时间常数可以根据设备类型调整:机械臂可以快一点,输送线慢一点,模拟出惯性感。

最上层是数字面板。每个设备上方用 UGUI 创建一个悬浮面板,显示设备编号、当前状态、节拍数、故障代码等信息。数字面板的信息更新频率不需要太高,每秒更新一次就足够,避免 UI 频繁重建导致 Canvas 性能下降。

4. 工业级 UI 设计与场景可视化呈现

4.1 全局概览:一张图看懂产线状态

数字孪生项目的 UI 设计逻辑和普通游戏完全不同。游戏追求沉浸感,工业场景追求信息获取效率。所以我的 UI 方案是:整条产线的俯视视角作为主界面,设备按实际布局在三维场景中摆放,状态色直接可视化。管理者在 10 秒内就能判断:哪些设备在正常生产,哪些在报警,线体瓶颈在哪个位置。

我加了一张二维俯视小地图作为辅助导航——用 RenderTexture 叠加一个正交相机从顶部往下渲染,画面上同时标出设备的 ID。这个方法比单独画二维 SV G 图简单得多,而且天然和三维场景保持一致。

4.2 设备详情弹窗与实时参数曲线

点击某个设备模型后,弹出设备详情面板。我用的事件是IPointerClickHandler,通过射线检测点击到的物体,再回溯到对应的设备孪生体。弹窗里展示的信息包括:

  • 设备基本信息:编号、名称、所属工位、投产日期。
  • 实时状态:当前加工工序、主轴转速、进给速度、温度、振动等参数。
  • 产量统计:今日计划数、已完成数、合格率、OEE。
  • 实时曲线:最近的几个关键参数随时间的趋势曲线。

实时曲线我用的是 XCharts 插件,它是 UGUI 上的一个图表库,做折线图、柱状图很方便。数据源就是我在数据服务层缓存的最近 200 个时间点的参数快照,通过接口提供给 UI 层。

4.3 报警提示与事件追踪

设备报警是产线数字孪生最重要的功能之一。我的方案是:在场景中把报警设备高亮闪烁,同时在屏幕右上角弹出报警队列,按时间排序,显示报警时间、设备编号、报警代码、报警描述。点击报警条目可以定位到对应设备并自动聚焦。

报警级别我分了三级:

  • 一级报警:设备故障停机,需要立即处理。红色,闪频较高。
  • 二级报警:设备参数异常,但还能继续运行。橙色,常亮或低频闪烁。
  • 三级报警:提示性信息,如物料不足、维护到期。黄色,不闪烁。

这个分级直接在数据服务层判断,Unity 端只接收结果。判断逻辑放到服务端有个好处:后续如果要增加联动规则(比如某台设备报警后自动触发相邻设备减速),不需要重新发布 Unity 客户端。

5. 性能优化与工业场景落地的关键细节

5.1 大规模场景的渲染优化,别让帧率毁掉项目

工业级数字孪生最容易被吐槽的问题就是卡顿。在产线场景里,几十台设备、上千个零部件同时可见,如果每个物体都用独立材质和独立网格,帧率崩盘是分分钟的事。

我的优化策略按优先级排列:

  • 静态合批:把传输链、防护栏、地面等不会动的物体标记为 Static,让 Unity 自动进行静态合批。
  • 纹理图集:把多个小纹理拼成一张图集,减少材质切换。
  • LOD 组:每个设备做三档 LOD,近距离用高模,远距离切换低模。
  • 遮挡剔除:开启 Occlusion Culling,Unity 会提前计算哪些物体被墙挡住,不渲染它们。
  • 减少实时光源:固定场景用烘焙光照,只保留必要的动态光源(比如报警时的闪光灯效果)。

还有一点容易被忽略:脚本里的 Update 循环数量。场景里几十个设备,每个设备一个 Update,每帧跑几十个空循环,对性能影响不大,但如果每个 Update 里都做字符串拼接、GameObject.FindGetComponent,性能立刻下降。我的做法是:所有设备数据的应用统一由TwinManager每帧调用一次,内部用并行遍历的方式批量处理,避免每个物体单独驱动。

5.2 断线重连与数据异常场景的容错设计

工业现场的网路质量永远没有开发环境那么好。交换机重启、网线松动、PLC 固件升级,都会导致数据链路中断。如果不做容错,Unity 客户端一断线就白屏或者模型停在最后状态,会让现场人员对整个系统丧失信任。

我做的容错机制包括:

  • MQTT 客户端断开后自动重连,指数退避策略,从 1 秒开始逐次翻倍,最多不超过 30 秒。
  • 数据超过 3 秒没更新,设备状态强制显示为“灰色离线”,而不是保留最后的绿色运行状态。
  • 离线设备在 UI 面板上显示最后更新时间,方便定位是采集服务挂了还是 PLC 本身通信中断。
  • 整个 Unity 客户端提供“数据自检”模式:可以在 UI 上打开调试面板,实时显示收到的原始 JSON 数据,方便现场排查问题。

这些功能听着琐碎,但恰恰是“能落地”和“只能演示”的分水岭。我见过太多项目,演示时一切正常,一接入车间真实网络环境就暴露问题——问题往往不在 3D 效果上,而是在这些边缘情况上。

5.3 部署与交付:工控机 or 工作站?屏幕怎么配?

Unity 数字孪生客户端最终跑在什么硬件上,直接决定了你做的画面能开几档特效。我的建议方案:

  • 中控室大屏:Windows 工作站,独立显卡(如 RTX 3060 以上),4K 分辨率输出。
  • 车间现场看板:工控机加 1080P 或 2K 屏,显卡至少 GTX 1660 以上。
  • 办公室浏览器看板:用 Unity 打包 WebGL,降低特效,保证能跑。

尤其注意,车间环境常有粉尘和震动,工控机最好选无风扇或带工业级散热的产品,硬盘用固态,内存至少 16G。屏幕建议用工业显示器,亮度要高,因为车间照明通常很亮,普通显示器的画面会显得灰暗。

另一个部署细节是:Unity 客户端要支持开机自启和无人值守运行。我打包了 Windows Standalone 版本,用任务计划程序设置为开机启动,配合一个简单的看门狗脚本——如果进程意外退出,自动重新拉起。这个看似不起眼的小功能,实际使用中救了我很多次。

6. 常见问题与排障技巧实录

6.1 模型导入后出现比例、轴朝向异常

这是我被问得最多的问题,几乎每个新人都踩过。模型导入后,要么大得离谱,要么整个设备是横躺的。

  • 症状一:模型巨大/微小。原因是 CAD 单位是毫米,Unity 默认单位是米,导入时没设置 File Scale。
  • 症状二:设备朝向不对,Y 轴变成了 Z 轴。解决方法是:在建模软件里手动旋转对齐,导出前重新设置坐标系。
  • 症状三:贴图丢失,模型呈现粉红色。原因是 FBX 里引用的贴图路径是绝对路径,换电脑后找不到。解决方法是:把贴图和 FBX 放在同一个文件夹下重新导入。

排查技巧:导入模型后立即检查 Inspector 面板里的 Scale Factor 和 Mesh 尺寸,如果尺寸和预期差几个数量级,优先检查单位设置。

6.2 MQTT 数据能收到,但画面不动

这个问题八成出在数据解析上。我调试时踩过的坑是:服务端发的是float[]数组,到了 Unity 端用JsonConvert.DeserializeObject<Dictionary<string, object>>解析,结果数字全部被解析成了 Int64,导致后续类型转换异常。后来统一用强类型 DTO 类,问题消失。

另一个原因是:驱动脚本在Update里把数据应用到了模型上,但模型本身没有发生任何变化——比如输送带是传送带动画,但 PLC 给的信号是“当前有无工件”,而不是“输送带速度”,所以看起来像是画面不动。排查思路是用调试面板看数据和模型引用是否对应一致。

6.3 场景卡顿,帧率上不去

先看 Stats 面板(Game 视图右上角):

  • 如果 Batches 数量特别大,优先合并材质、启用静态合批。
  • 如果 Tris 数量特别大,优先做 LOD 和简化模型。
  • 如果 CPU 耗时高,打开 Profiler 看有没有哪个 Update 方法耗时异常,通常是字符串操作或Find调用导致的。

还有一招比较有用:把设备的实时状态更新拆分到多个帧执行,比如每帧只更新 10 台设备的状态,而不是一次更新全部。这样可以把每帧的耗时峰值摊平,对整体帧率稳定有明显帮助。

6.4 报警闪烁做得太生硬

闪烁效果建议用 Shader 实现发光强度脉冲,而不是不断切换颜色的材质。用MaterialPropertyBlock.SetFloat("_EmissionIntensity", value)配合正弦曲线做脉冲,效果自然且性能开销小。

7. 实操心得与后续扩展方向

这套方案实际跑下来,我的体会是:数字孪生项目真正的难度不在 3D 技术,而在于对产线业务的深刻理解,以及把 IT 和 OT 两个领域衔接起来的能力。很多项目失败,都是因为做 IT 的不懂 PLC,做自动化的人不熟悉 Unity 和 C#。如果你有志于深耕这个方向,建议两条线都抓:一是把工业通信协议(Modbus、OPC UA、S7)吃透,二是把 Unity 的渲染和交互真正搞清楚。

最后分享一个我自己长期受益的工作习惯:整个方案严格采用“数据访问层 + 业务逻辑层 + 表现层”的三层架构,所有数据交换都用明确的 DTO 类定义。前期会感觉多写了很多代码,但后期维护和扩展时,你会发现改动一个字段、增加一种设备类型、接入一条新产线,都变得非常轻松。尤其是当客户从一条产线扩展到多条产线时,这种架构带来的收益是指数级的。

这套方案目前已经支撑了三条汽车零部件产线的日常运行,下一步我准备把设备健康预测和 AR 巡检加进去。如果你们也正在做类似的项目,欢迎多交流,尤其是数据采集和孪生体驱动这块的坑,绝对比想象中多得多。

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

机器学习课程资料怎么学?一份完整的学习路线与避坑指南

简介&#xff1a;唐宇迪机器学习课程资料是一套面向机器学习初学者与进阶者的系统入门资源&#xff0c;覆盖数据预处理、特征工程、模型构建与评估等内容&#xff0c;适合计划进入数据分析或人工智能领域的学习者使用。资源包为RAR压缩格式&#xff0c;整体约66.11MB&#xff0…

作者头像 李华
网站建设 2026/9/10 1:20:08

Run:ai如何重构AI算力调度:从K8s到AI原生调度器

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

作者头像 李华
网站建设 2026/9/10 1:18:33

CMSIS-5深度解析:嵌入式系统架构决策与硬件抽象层契约

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

作者头像 李华