Unity UGUI背包系统设计:数据与表现分离的架构实践
1. 背包系统的整体设计思路拆解1.1 为什么选择UGUI来做背包在Unity里做UI能选的方案其实就那么几套NGUI、UGUI、UI Toolkit还有自己撸一套Mesh绘制的极端做法。NGUI是上一代的东西维护成本高新项目基本没人碰了UI Toolkit虽然前景好但运行时那套东西在不少版本上还有坑尤其是跟老项目混用的时候。所以UGUI到现在依然是绝大多数团队做背包、商店、角色面板这类功能的首选。UGUI做背包有几个天然优势。第一是组件化程度高Grid Layout Group直接帮你把格子排好不用自己算坐标。第二是事件系统成熟拖拽、点击、悬停这些交互都有现成的接口。第三是和Unity的序列化系统贴合得好做数据驱动的界面特别顺手。我试过用UI Toolkit重写一个背包光是拖拽逻辑就折腾了大半天最后还是切回了UGUI。这个项目的目标很明确搭建一个结构清晰、可扩展、能直接跑起来的简单背包。所谓“简单”是指不搞装备强化、不搞词条随机、不搞多页签切换但数据层和表现层必须分离不然以后想加功能就得推倒重来。1.2 核心架构数据、逻辑、表现三层分离很多新手做背包习惯把Item数据直接挂在格子Prefab上点一下改一下看着能跑但一旦要存档、要排序、要跨界面同步立刻就崩。我在早期项目里踩过这个坑背包数据和UI绑死之后商店买完东西刷新背包得手动去遍历所有格子代码越写越乱。所以这个背包我采用三层结构数据层ModelItemData定义物品的静态属性ID、名字、图标、描述、堆叠上限InventorySlot表示一个格子里的运行时状态物品引用数量。这一层完全不依赖Unity的UI组件纯C#类方便做存档序列化。逻辑层ControllerInventoryManager负责增删改查、堆叠合并、格子交换。它持有ListInventorySlot对外暴露AddItem、RemoveItem、SwapSlots这些方法。表现层ViewInventoryUI负责生成格子、刷新图标和数量文本、处理拖拽事件。它只跟Manager打交道不直接改数据。这样分层之后存档只需要序列化InventorySlot列表换UI皮肤只需要改View逻辑测试可以脱离Unity编辑器跑单元测试。1.3 格子数量与布局的参数选择背包格子数不是随便定的。常见做法是一行6格或8格总共24到40格。为什么因为屏幕宽度有限格子太小点不准太大又浪费空间。以1920x1080为基准一个格子控制在80到100像素比较舒服6格一行加上间距大概600多像素放在屏幕中间刚好。Grid Layout Group的关键参数我一般这么设参数建议值说明Cell Size80 x 80图标区域留出边框空间Spacing8 x 8格子间距太挤会误触ConstraintFixed Column Count固定列数行数自动Constraint Count6一行6格Child AlignmentUpper Center从上方居中排列注意Cell Size不要设成奇数不然图标缩放时会出现半像素模糊。我一般用偶数80、96、100都行。2. 核心细节解析与实操要点2.1 ItemData用ScriptableObject还是普通类这是被问得最多的问题。我的答案是静态配置用ScriptableObject运行时状态用普通类。ItemData存的是“这把剑叫什么、长什么样、能叠几个”这些是策划配的不会在运行时变所以做成ScriptableObject在Project窗口里右键就能创建策划改起来也方便。而InventorySlot里的数量是会变的做成普通类跟着存档走。[CreateAssetMenu(fileName NewItem, menuName Inventory/ItemData)] public class ItemData : ScriptableObject { public string itemId; public string displayName; [TextArea] public string description; public Sprite icon; public int maxStack 1; public ItemType itemType; } public enum ItemType { Consumable, Weapon, Material, Quest }maxStack这个字段很关键。消耗品一般叠到99装备和任务物品通常不叠maxStack1。堆叠逻辑全靠它来判断。2.2 堆叠合并的边界条件堆叠看着简单实际写起来边界条件一堆。假设背包里已经有一组50个的药水上限99现在要加30个正确结果是第一组变成80剩下0个。如果加60个第一组变99剩下11个需要找新格子。我见过有人写堆叠只判断“有没有相同ID的格子”结果数量超了也不管直接塞进去变成120/99显示就乱了。正确的流程应该是先遍历所有已有该物品的格子能补多少补多少。补完之后如果还有剩余再找空格子。空格子也要按maxStack拆分比如加250个上限99的物品要占3个格子999952。public bool AddItem(ItemData item, int amount) { // 第一步填充已有堆叠 foreach (var slot in slots) { if (slot.item item slot.count item.maxStack) { int canAdd item.maxStack - slot.count; int toAdd Mathf.Min(canAdd, amount); slot.count toAdd; amount - toAdd; if (amount 0) { OnInventoryChanged?.Invoke(); return true; } } } // 第二步找空格子 foreach (var slot in slots) { if (slot.item null) { int toAdd Mathf.Min(item.maxStack, amount); slot.item item; slot.count toAdd; amount - toAdd; if (amount 0) { OnInventoryChanged?.Invoke(); return true; } } } OnInventoryChanged?.Invoke(); return amount 0; // 返回false表示背包满了 }实操心得OnInventoryChanged这个事件一定要在数据改完之后统一触发不要每改一个格子就刷一次UI。格子多了之后频繁刷新会掉帧我实测40格背包每帧刷一次低端机上直接掉到30帧。2.3 拖拽交互的实现方式选择UGUI的拖拽有三套方案IBeginDragHandler/IDragHandler/IEndDragHandler接口、EventTrigger组件、自己写射线检测。强烈推荐用接口EventTrigger在Inspector里配一堆东西维护起来想死而且性能还差。拖拽的核心逻辑是拖起一个格子时把图标临时挂到一个跟随鼠标的“幽灵对象”上原格子图标隐藏放下时判断落点是不是有效格子是就交换数据不是就还原。这里有个细节幽灵对象的raycastTarget必须关掉不然它会挡住鼠标导致IEndDragHandler里的eventData.pointerCurrentRaycast.gameObject永远检测到幽灵自己。我第一次写的时候卡了半小时一直以为是坐标算错了。public void OnBeginDrag(PointerEventData eventData) { if (slot.item null) return; dragGhost Instantiate(iconPrefab, canvas.transform); dragGhost.sprite slot.item.icon; dragGhost.raycastTarget false; // 关键 originalIcon.enabled false; } public void OnDrag(PointerEventData eventData) { if (dragGhost ! null) dragGhost.transform.position eventData.position; } public void OnEndDrag(PointerEventData eventData) { if (dragGhost ! null) Destroy(dragGhost.gameObject); originalIcon.enabled true; // 处理落点逻辑 }2.4 图标与数量的显示细节数量文本只在count 1时显示这是常识但很多人忘了maxStack 1的物品永远不显示数量。另外数量文本要加描边或阴影不然放在浅色图标上看不清。我一般用Outline组件颜色黑色距离1像素。图标本身要开Preserve Aspect不然不同比例的图会被拉伸变形。如果美术给的图尺寸不统一可以在导入设置里统一改成SpritePacking Tag设成同一个打成图集减少DrawCall。3. 实操过程与核心环节实现3.1 场景搭建的完整步骤先把Canvas建起来。Render Mode选Screen Space - OverlayCanvas Scaler的UI Scale Mode选Scale With Screen SizeReference Resolution设1920x1080Match设0.5。这套配置在手机和PC上都能自适应。然后建背包面板结构Canvas └─ InventoryPanel (Image背景) ├─ TitleText (Text) ├─ GridArea (空物体 GridLayoutGroup) └─ CloseButton (Button)GridArea上挂GridLayoutGroup参数按前面表格设。注意GridArea本身要加Content Size Fitter吗不加。因为背包格子数是固定的不需要自适应高度加了反而会跟GridLayoutGroup打架。格子Prefab的结构Slot (Image InventorySlotUI脚本 CanvasGroup) ├─ Icon (Image) ├─ CountText (Text) └─ Highlight (Image, 默认隐藏)Slot本身要加CanvasGroup拖拽时把blocksRaycasts设成false这样鼠标能穿透到下面的格子。这个技巧比手动关raycastTarget方便因为CanvasGroup会递归影响子对象。3.2 InventoryManager的初始化与事件绑定Manager我做成MonoBehaviour挂在场景里Awake时初始化格子列表public class InventoryManager : MonoBehaviour { public static InventoryManager Instance; public int slotCount 24; public ListInventorySlot slots new ListInventorySlot(); public event Action OnInventoryChanged; void Awake() { Instance this; for (int i 0; i slotCount; i) slots.Add(new InventorySlot()); } public InventorySlot GetSlot(int index) { if (index 0 || index slots.Count) return null; return slots[index]; } }UI那边在OnEnable时订阅OnInventoryChangedOnDisable时取消订阅。一定要取消订阅不然场景切换后旧UI还会收到事件报NullReference。void OnEnable() { InventoryManager.Instance.OnInventoryChanged RefreshUI; } void OnDisable() { InventoryManager.Instance.OnInventoryChanged - RefreshUI; }3.3 格子UI的刷新逻辑刷新不是重建所有格子而是复用。初始化时生成slotCount个格子Prefab之后每次刷新只改图标和文本public void RefreshUI() { for (int i 0; i slotUIs.Count; i) { var slotData InventoryManager.Instance.GetSlot(i); slotUIs[i].SetData(slotData, i); } } public void SetData(InventorySlot data, int index) { this.slotIndex index; this.slotData data; if (data.item ! null) { icon.enabled true; icon.sprite data.item.icon; countText.text data.count 1 ? data.count.ToString() : ; } else { icon.enabled false; countText.text ; } }注意icon.enabled false比icon.sprite null好后者在某些Unity版本会触发额外的GC。数量文本用count 1判断别用count ! 0不然单个物品也显示“1”很丑。3.4 拖拽交换的完整实现拖拽交换要处理三种情况拖到空格子、拖到有物品的格子、拖到背包外面。拖到空格子直接把数据搬过去原格子清空。 拖到有物品的格子如果两个物品ID相同且可堆叠合并否则交换。 拖到背包外面什么都不做还原。public void OnDrop(PointerEventData eventData) { var fromSlot eventData.pointerDrag.GetComponentInventorySlotUI(); if (fromSlot null || fromSlot this) return; var fromData fromSlot.slotData; var toData this.slotData; if (toData.item null) { // 拖到空格 toData.item fromData.item; toData.count fromData.count; fromData.item null; fromData.count 0; } else if (toData.item fromData.item toData.item.maxStack 1) { // 合并堆叠 int canAdd toData.item.maxStack - toData.count; int toMove Mathf.Min(canAdd, fromData.count); toData.count toMove; fromData.count - toMove; if (fromData.count 0) { fromData.item null; fromData.count 0; } } else { // 交换 var tempItem toData.item; var tempCount toData.count; toData.item fromData.item; toData.count fromData.count; fromData.item tempItem; fromData.count tempCount; } InventoryManager.Instance.OnInventoryChanged?.Invoke(); }这段代码里fromSlot this的判断很重要不然拖到自己身上会触发交换逻辑数据直接乱掉。3.5 测试数据的注入开发阶段不可能每次都手动捡东西我一般写个测试脚本按键盘数字键往背包里塞物品void Update() { if (Input.GetKeyDown(KeyCode.Alpha1)) InventoryManager.Instance.AddItem(testPotion, 10); if (Input.GetKeyDown(KeyCode.Alpha2)) InventoryManager.Instance.AddItem(testSword, 1); if (Input.GetKeyDown(KeyCode.Alpha3)) InventoryManager.Instance.AddItem(testPotion, 250); // 测试多格堆叠 }testPotion这些是ScriptableObject资源在Inspector里拖进去。测试250个药水能一次性验证堆叠拆分逻辑对不对。4. 常见问题与排查技巧实录4.1 拖拽时图标闪烁或位置偏移这个问题九成是幽灵对象的父节点选错了。如果幽灵的父节点是格子本身而格子又在GridLayoutGroup里幽灵的位置会被布局系统不断修正看起来就是抖。正确做法是把幽灵挂到Canvas根节点下用eventData.position直接设世界坐标。还有一种情况是Canvas的Render Mode不是Overlay而是Screen Space - Camera这时候eventData.position是屏幕坐标要转成世界坐标RectTransformUtility.ScreenPointToWorldPointInRectangle( canvasRect, eventData.position, eventData.pressEventCamera, out Vector3 worldPos); dragGhost.transform.position worldPos;4.2 数量文本显示不全或被裁切Text组件的RectTransform如果太小数字会被裁掉。我一般把CountText的锚点设成右下角宽高各40像素对齐方式Right-Bottom加个1像素的Padding。字体大小24左右太大放不下太小看不清。如果用了TextMeshPro记得把Overflow设成Overflow不然超出部分直接消失。4.3 背包满了之后AddItem返回值不对前面代码里return amount 0意思是“所有物品都放进去了才返回true”。但有些设计希望“放进去一部分也算成功”这时候要改成返回实际放入的数量public int AddItem(ItemData item, int amount) { int original amount; // ... 堆叠逻辑 ... return original - amount; // 返回实际放入的数量 }调用方根据返回值判断要不要弹“背包已满”的提示。我建议用返回数量的方式信息更全调用方想怎么处理都行。4.4 常见问题速查表现象可能原因解决方法拖拽没反应格子没挂EventSystem或没实现IDragHandler检查场景里有没有EventSystem脚本有没有实现接口放下后数据没变OnDrop没触发目标格子要开raycastTarget且不能被幽灵挡住图标模糊Cell Size是奇数或图标没开Preserve Aspect改成偶数勾选Preserve Aspect数量显示“1”判断条件写成count ! 0改成count 1切换场景报空引用事件没取消订阅OnDisable里取消订阅堆叠超过上限没判断maxStack在AddItem里用Mathf.Min限制拖拽时原图标还在没隐藏originalIconOnBeginDrag里enabled false格子点击穿透CanvasGroup没设blocksRaycasts拖拽时设false结束时设true4.5 几个我踩过的坑第一个坑是GridLayoutGroup和ContentSizeFitter同时用。当时想让背包高度自适应结果格子位置全乱了。后来才明白GridLayoutGroup自己会算布局ContentSizeFitter再去改RectTransform两者打架。固定格子数的背包不需要自适应直接给GridArea一个固定大小就行。第二个坑是ScriptableObject在运行时被修改。有次我图省事直接把数量存在ItemData里结果编辑器里跑一次数量就被改了下次运行初始值都不对。记住ScriptableObject是配置不是存档运行时数据一律放普通类。第三个坑是拖拽结束的时机。OnEndDrag在OnDrop之前触发所以如果在OnEndDrag里销毁幽灵OnDrop里就拿不到拖拽源了。正确顺序是OnDrop先处理数据交换OnEndDrag再清理幽灵。或者把拖拽源存在一个静态变量里两边都能访问。第四个坑是图集打包。背包图标如果不在同一个图集里每个图标一个DrawCall40个格子就是40个DrawCall手机上直接跪。把背包相关图标放同一个Sprite AtlasDrawCall能降到个位数。这个优化在格子多的时候效果特别明显。4.6 扩展方向的个人建议这套背包搭好之后往上加功能其实很顺。加装备栏就是再建一个InventoryManager实例用不同的slotCount加商店就是写个ShopManager调用InventoryManager的AddItem和RemoveItem加存档就是序列化slots列表转成JSON。我个人在实际操作中的体会是前期多花半小时把分层做清楚后期能省好几天改bug的时间。我见过太多项目因为背包数据和UI绑死加个排序功能都要重构。这套结构虽然看起来多写了几个类但每个类的职责单一改起来心里有底。最后分享一个小技巧调试背包的时候在Inspector里把InventoryManager的slots列表展开运行时能直接看到每个格子的物品和数量比打Log方便多了。如果嫌不够直观可以写个Editor脚本在Inspector里画出格子预览不过那就是另一个话题了。

相关新闻

5G核心网AMF重选注册流程:5a至7a上下文交接与排障实战

5G核心网AMF重选注册流程:5a至7a上下文交接与排障实战

先说结论:在5G核心网的注册流程里,最容易被忽略、又最容易出问题的,往往不是UE和AMF之间的认证交互,而是新老AMF之间那段静悄悄的上下文交接。带AMF重选的注册流程,尤其值得把Step 5a到7a拿出来单独讲一遍——这三个信…

2026/10/17 14:22:08 阅读更多 →
从零搭建AI知识库:四种类型选型、文档解析分段与智能体挂载全流程

从零搭建AI知识库:四种类型选型、文档解析分段与智能体挂载全流程

1. 为什么我要自己搭一套AI知识库 去年下半年开始,我陆续帮几个团队做内部知识管理系统的改造,需求出奇地一致:把散落在各个文档平台、聊天记录、邮件里的资料整合起来,让新人和业务部门能用一个对话窗口直接问出答案。市面上的Sa…

2026/10/17 14:22:08 阅读更多 →
2025企业AI转型:人才先行与AI力测评才是落地关键

2025企业AI转型:人才先行与AI力测评才是落地关键

简介:面向企业管理层、人力资源专家、部门经理及一线员工的一份PDF资料,聚焦2025年人工智能赋能企业变革中人才先行的核心议题,剖析人工智能渗透率在互联网行业已达百分之八十九、传统服务业却不足百分之五十的显著差异,并针对企业…

2026/10/17 14:21:08 阅读更多 →

最新新闻

MySQL学生成绩管理系统数据库设计:需求分析、建表、视图与存储过程完整方案

MySQL学生成绩管理系统数据库设计:需求分析、建表、视图与存储过程完整方案

简介:这份资源是北邮研一数据库课程大作业的完整详解文档,面向正在修读数据库系统课程、需要完成课程设计的研究生及高年级本科生。内容围绕学生成绩管理系统展开,覆盖需求分析、数据库设计、ER图绘制、逻辑结构设计与建表程序等完整流程&…

2026/10/17 16:15:27 阅读更多 →
Unity运行时导入3D模型:基于TriLib源码的模型加载与替换实践

Unity运行时导入3D模型:基于TriLib源码的模型加载与替换实践

简介:这套基于 Unity3D C# 与 TriLib 插件实现的运行时 3D 模型导入加载源码工程,面向需要为游戏或应用添加实时模型替换、关卡/场景编辑器以及 AR/VR 可视化等功能的 Unity 开发者。压缩包共 879 个文件,约 26.37MB,以 C# 脚本、…

2026/10/17 16:15:27 阅读更多 →
数据库关系代数与SQL实战入门:从课本习题到可执行查询

数据库关系代数与SQL实战入门:从课本习题到可执行查询

简介:本资源是《数据库系统概论(第5版)》王珊、萨师煊主编教材的配套课后习题详细答案,面向高校计算机、软件工程、信息管理等专业本科生及数据库初学者,用于巩固关系模型、关系代数、SQL语言等核心理论知识&#xff0…

2026/10/17 16:15:27 阅读更多 →
Unity3D数字孪生案例:从模型导入到数据驱动的完整落地路径

Unity3D数字孪生案例:从模型导入到数据驱动的完整落地路径

简介:面向数字孪生初学者与全栈开发爱好者的一套 Unity3d(WebGL)小案例代码包,聚焦硬件终端、webGL、Web端与服务端之间的数据交互链路,帮助解决 Unity3d 建模在网页端运行及 IoT 数据回流三维场景的问题。压缩包共3个…

2026/10/17 16:15:27 阅读更多 →
WordPress故障排查实战:从白屏、数据库到迁移陷阱一次说清

WordPress故障排查实战:从白屏、数据库到迁移陷阱一次说清

说起WordPress问题,我这几年遇到最多的不是某个单一故障,而是一堆"莫名其妙"的现象:前台白屏、后台登录死循环、文章保存按钮转圈、图片传不上去、网站被搜索引擎收录了一堆垃圾页面、搬家之后样式全丢。很多朋友的第一反应是"…

2026/10/17 16:15:27 阅读更多 →
Vim+GCC+库文件:打通Linux开发工具链的完整闭环

Vim+GCC+库文件:打通Linux开发工具链的完整闭环

我见过太多刚切到 Linux 的开发者,第一周就被劝退。倒不是 C 语言难,而是卡在了工作流上:在终端里不知道怎么快速改代码,写完程序不知道怎么编译,编译完了遇到各种链接报错又一头雾水。说白了,就是 Vim 操作…

2026/10/17 16:14:27 阅读更多 →

日新闻

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

简介:《DeepSeek使用方法.pdf》是一份面向DeepSeek入门及进阶用户的完整使用指南,重点围绕V3与R1两款模型的能力差异与适用场景展开,帮助读者根据任务类型与成本选择合适的模型。资源共1个PDF文件,压缩包大小约311KB,篇…

2026/10/17 0:00:25 阅读更多 →
200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

简介:这是一份聚焦DeepSeek提示词优化的《200Deepseek润色指令》PDF文档,共收录两百余条可直接套用的润色指令,涵盖语法修正、风格统一、逻辑梳理、表达精炼等维度,并针对商务写作、学术论文、广告文案等场景给出差异化建议。文档…

2026/10/17 0:00:25 阅读更多 →
OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

简介:本资源是一份基于OpenCV的本科课程设计完整报告,面向计算机视觉初学者、图像处理学习者及高校通信/信息类专业学生,解决物体智能计数与检测结果结构化记录的实际问题。项目覆盖图像预处理、轮廓检测、边界框绘制、轨迹栏交互式参数调节、…

2026/10/17 0:00:25 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/16 21:35:51 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/17 1:44:59 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 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/16 21:54:38 阅读更多 →
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/16 3:49:43 阅读更多 →
黑夜航拍船只数据集训练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 阅读更多 →