简介本资源是一份面向WPF中高级开发者的TreeView拖拽功能实战示例聚焦解决层次化数据节点的自由重排、移动与复制需求适用于桌面管理类、配置树编辑、组织架构调整等典型业务场景。压缩包共12个文件含6个C#逻辑文件实现DragDrop事件处理、数据模型操作及TreeView扩展、2个XAML界面文件定义主窗口与样式、1个SLN解决方案及配套CSProj、RESX和Settings配置文件整体仅11KB轻量易集成。已有855人学习下载代码结构清晰包含完整可运行的TestDragDropTreeView项目涵盖DragStarted/DragEnter/DragOver/Drop等全生命周期事件处理逻辑并封装了基于DataObject的数据传递与节点位置计算机制附带App.xaml与Window1.xaml.cs等关键模块便于开发者快速理解WPF拖放原理并迁移至自有项目。1. WPF TreeView 拖拽为什么拖着节点松手后它既没消失也没跳到目标位置而是悬在半空“卡住”了这是 WPF 开发者在实现 TreeView 拖拽时最典型的玄学现场——鼠标松开光标变回箭头但被拖的节点影子还黏在指针下方目标节点不展开、不高亮、不接收、不插入整个 UI 像被按了暂停键。更诡异的是如果此时快速点一下空白处或切到其他窗口再切回来那个悬浮影子才“啪”地一声消失仿佛系统终于想起来“哦你刚才松手了”。这不是 Bug而是 WPF 拖放DragDrop机制里一个被严重低估的状态同步断层DragDrop.DoDragDrop启动后UI 线程进入一种特殊的“拖拽模态”它会劫持鼠标消息、冻结部分渲染逻辑并依赖开发者手动、精确、及时地响应一系列特定事件才能完成闭环。一旦PreviewMouseMove、PreviewMouseLeftButtonUp、DragEnter、DragOver、Drop这五者中任意一环的时机、条件或返回值出错整个拖拽链就断在半路变成那个悬停不落的“幽灵节点”。搞懂这个断层在哪、怎么缝合比背十遍 MSDN 示例代码都管用——因为所有翻车案例根源都在这五个事件的协作契约被悄悄破坏了。2. 拖拽不是“拖放”而是一场由 DragDrop API 主导的四阶段状态机WPF 的拖放不是简单的鼠标按下→移动→松开而是一个由DragDrop类强制驱动的状态机其生命周期被严格划分为四个不可跳过的阶段启动Initiate→ 预览Preview→ 悬停Hover→ 落地Commit。每个阶段对应一组必须被正确处理的事件且事件触发顺序、参数含义、返回值语义都有硬性约定。忽略任一阶段或在错误时机调用DoDragDrop都会导致拖拽“卡住”。2.1 启动阶段DoDragDrop 不是“开始拖”而是“交出控制权”DragDrop.DoDragDrop是整个流程的闸门但它不负责绘制拖拽影子也不监听鼠标移动。它的真正作用是将当前线程置入“拖拽模态”接管鼠标捕获Mouse.Capture启动内部计时器持续向鼠标当前位置的 UI 元素发送DragEnter/DragOver事件等待某个元素在DragOver中设置e.Effects DragDropEffects.Move或 Copy并在Drop中完成数据写入若超时默认 30 秒或无元素接受则自动取消。关键陷阱在于DoDragDrop必须在PreviewMouseLeftButtonUp中调用而非MouseDown或MouseMove。原因在于MouseDown时鼠标尚未“稳定”MouseMove则可能因抖动误触发。正确的启动逻辑如下private void TreeViewItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { // 记录起始节点但不启动拖拽 _draggedItem GetTreeViewItemFromEvent(e); } private void TreeViewItem_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { // 仅当鼠标在起始节点上松开且已移动过一定距离防点击误触 if (_draggedItem ! null IsMouseMovedEnough(e.GetPosition(this))) { // ⚠️ 核心此处必须传入 DataObject且数据格式需与 Drop 目标匹配 var data new DataObject(TreeViewItemData, _draggedItem); DragDrop.DoDragDrop(this, data, DragDropEffects.Move); _draggedItem null; // 清理状态 } }提示IsMouseMovedEnough通常判断Math.Abs(e.GetPosition(this).X - _mouseDownPoint.X) SystemParameters.MinimumHorizontalDragDistance这是 Windows 系统级拖拽阈值硬编码5会导致高 DPI 下失效。2.2 预览阶段PreviewMouseMove 是唯一能“感知拖拽意图”的窗口PreviewMouseMove是启动拖拽前的最后决策点。它被频繁触发每毫秒数次但WPF 不会在该事件中自动触发DoDragDrop。开发者必须在此处检测鼠标是否已离开起始节点边界并主动调用DoDragDrop—— 这正是多数教程遗漏的关键PreviewMouseMove是拖拽“意图确认”的唯一可靠时机。private Point _mouseDownPoint; private TreeViewItem _draggedItem; private void TreeViewItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { _mouseDownPoint e.GetPosition(this); _draggedItem sender as TreeViewItem; } private void TreeViewItem_PreviewMouseMove(object sender, MouseEventArgs e) { if (_draggedItem null || e.LeftButton ! MouseButtonState.Pressed) return; // 计算位移超过阈值则启动拖拽 var currentPos e.GetPosition(this); var distance Math.Sqrt( Math.Pow(currentPos.X - _mouseDownPoint.X, 2) Math.Pow(currentPos.Y - _mouseDownPoint.Y, 2)); if (distance SystemParameters.MinimumHorizontalDragDistance) { // ✅ 此刻启动确保拖拽影子与鼠标同步 var data new DataObject(TreeViewItemData, _draggedItem); DragDrop.DoDragDrop(_draggedItem, data, DragDropEffects.Move); _draggedItem null; } }注意PreviewMouseMove必须绑定到TreeView或TreeViewItem容器而非TextBlock等子控件否则事件无法冒泡到容器层。2.3 悬停阶段DragOver 不是“提示用户”而是“协商协议”DragOver事件每 50ms 触发一次其核心职责是动态协商拖拽效果。目标元素必须在DragOver中显式设置e.Effects否则Drop永远不会触发。常见错误是只在DragEnter中设Effects却忽略DragOver的持续更新private void TreeView_DragOver(object sender, DragEventArgs e) { // ⚠️ 必须检查数据格式否则即使有数据也会被拒绝 if (!e.Data.GetDataPresent(TreeViewItemData)) { e.Effects DragDropEffects.None; return; } // 获取鼠标下的目标节点关键 var targetItem GetTreeViewItemUnderMouse(e.GetPosition(this)); if (targetItem null) { e.Effects DragDropEffects.None; return; } // ✅ 动态判断是否允许插入不能拖到自己或子节点 if (IsAncestorOrSelf(_draggedItem, targetItem)) { e.Effects DragDropEffects.None; return; } // 允许移动显示插入线或高亮 e.Effects DragDropEffects.Move; e.Handled true; // ⚠️ 必须设为 true否则事件继续冒泡 }提示e.Handled true是悬停阶段的生死线。若未设事件会向上冒泡到父容器导致Effects被重置为NoneDrop永远不触发。2.4 落地阶段Drop 是唯一能修改数据模型的地方且必须同步刷新 UIDrop事件是拖拽的终点也是唯一合法修改业务逻辑的位置。但开发者常犯两个致命错误在Drop中只修改数据却不通知 UI 更新如未调用Items.Refresh()或未触发INotifyCollectionChanged在Drop中执行耗时操作如数据库写入导致 UI 线程卡死拖拽影子“僵住”。正确做法是先完成数据模型变更再立即触发 UI 刷新private void TreeView_Drop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent(TreeViewItemData)) return; var draggedItem e.Data.GetData(TreeViewItemData) as TreeViewItem; var targetItem GetTreeViewItemUnderMouse(e.GetPosition(this)); if (draggedItem null || targetItem null) return; // ✅ 1. 从原父容器移除 var oldParent VisualTreeHelper.GetParent(draggedItem) as ItemsControl; if (oldParent ! null oldParent.Items.Contains(draggedItem)) { oldParent.Items.Remove(draggedItem); } // ✅ 2. 插入到新位置此处简化为追加实际需计算索引 if (targetItem.Items ! null) { targetItem.Items.Add(draggedItem); } // ✅ 3. 强制刷新确保 TreeView 重绘插入线和节点状态 this.Items.Refresh(); // 若绑定到 ObservableCollection则自动触发 e.Handled true; }注意GetTreeViewItemUnderMouse的实现必须穿透TreeViewItem的HeaderTemplate直接获取VisualTreeHelper.GetChild的真实TreeViewItem实例否则返回null。3. 避坑那些让拖拽“悬在半空”的 5 个血泪经验WPF TreeView 拖拽的失败模式高度集中。以下是某开发者在模拟项目 X 中踩过的 5 个典型坑每一条都对应一个可复现的现象、明确的底层原因以及一行代码级的修复方案。3.1 现象拖拽影子出现后鼠标移动时影子完全不动像粘在初始位置原因DoDragDrop被错误地绑定在TextBlock或Label等子控件上而非TreeViewItem容器本身。DoDragDrop需要容器级的VisualTree上下文来定位鼠标子控件坐标系与TreeView不一致导致影子位置计算失效。解决将PreviewMouseMove和PreviewMouseLeftButtonUp事件处理器绑定到TreeViewItem的Template中的Border或Grid根元素或直接绑定到TreeView并用VisualTreeHelper反向查找TreeViewItem。3.2 现象松开鼠标后Drop事件完全不触发DragOver也停止调用原因DragOver事件处理器中未设置e.Effects或设置了DragDropEffects.None后未设e.Handled true导致事件冒泡到TreeView父容器而父容器的DragOver又未处理最终Effects被重置为NoneDrop被系统静默拒绝。解决在TreeView级别的DragOver中添加兜底逻辑private void TreeView_DragOver(object sender, DragEventArgs e) { // 即使子项未处理也确保 Effects 有值 e.Effects e.AllowedEffects; e.Handled true; }3.3 现象拖拽到目标节点时目标节点不展开Expand无法插入到子节点原因TreeViewItem的IsExpanded属性默认为false且DragOver中未主动调用item.IsExpanded true。WPF 不会自动展开目标节点需开发者手动触发。解决在DragOver中检测到鼠标悬停在折叠节点上时强制展开if (targetItem ! null !targetItem.IsExpanded targetItem.Items.Count 0) { targetItem.IsExpanded true; }3.4 现象拖拽后源节点在 UI 上消失但数据模型中仍存在或反之原因Drop事件中只修改了Items集合但未同步更新绑定的数据源如ObservableCollectionT。TreeView的ItemsSource与Items是两个独立集合直接操作Items会破坏绑定一致性。解决始终通过数据源操作而非 UI 集合// ❌ 错误操作 UI 层 targetItem.Items.Add(draggedItem); // ✅ 正确操作数据源UI 自动同步 var targetData targetItem.DataContext as MyTreeNode; targetData.Children.Add(draggedItem.DataContext as MyTreeNode);3.5 现象拖拽过程中鼠标右键弹出菜单或按 Alt 键切换焦点拖拽影子立即消失原因DoDragDrop进入模态后会拦截大部分输入事件但ContextMenu和Alt焦点切换属于系统级事件会强制退出拖拽模态。WPF 无内置防御机制。解决在PreviewMouseRightButtonDown中取消上下文菜单并禁用Alt键private void TreeViewItem_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) { e.Handled true; // 阻止 ContextMenu 弹出 } private void TreeView_KeyDown(object sender, KeyEventArgs e) { if (e.Key Key.LeftAlt || e.Key Key.RightAlt) { e.Handled true; // 阻止 Alt 切换焦点中断拖拽 } }4. 源码级验证从 DragDrop.DoDragDrop 到 DragEventArgs.Effects 的完整调用链要真正信服“为什么必须那样写”得看 WPF 源码如何把DoDragDrop变成屏幕上那个移动的影子。虽然 .NET Framework 源码不完全开源但通过 Reflector 反编译PresentationCore.dll和PresentationFramework.dll可还原出关键路径。以下是从DoDragDrop调用到DragOver事件触发的精简链路每一步都解释其对开发者代码的约束。4.1 DoDragDrop 的三重校验为什么你的 DataObject 必须带格式名DragDrop.DoDragDrop内部首先执行三重校验数据校验检查dataObject是否为DataObject实例且至少包含一个GetDataPresent(format)为true的格式目标校验调用VisualTreeHelper.HitTest获取鼠标当前位置的Visual并沿VisualTree向上遍历寻找注册了DragEnter事件的UIElement权限校验检查调用线程是否为 UI 线程且DragDropEffects是否在AllowedEffects范围内。关键约束若DataObject构造时不指定格式名如new DataObject(myData)则GetDataPresent(System.String)可能返回true但目标DragOver中e.Data.GetDataPresent(TreeViewItemData)为false导致Effects无法设置。格式名必须精确匹配。4.2 DragEventArgs 的 Effects 如何被“动态覆盖”DragEventArgs的Effects属性并非只读而是由 WPF 内部一个EffectStack维护。每次DragOver触发时WPF 执行// 伪代码WPF 内部 EffectStack.Push() _effectStack.Push(e.Effects); // 当前元素设置的 Effects // 然后检查栈顶若为 None则终止后续事件 if (_effectStack.Peek() DragDropEffects.None) { // 取消 Drop 事件影子消失 }因此DragOver中若忘记设e.Effects栈顶为0NoneDrop永远不触发。而e.Handled true的作用是阻止事件冒泡到父元素避免父元素的DragOver覆盖当前Effects。4.3 拖拽影子的绘制时机为什么 PreviewMouseMove 必须早于 DoDragDropWPF 的拖拽影子Drag Adorner绘制逻辑位于DragDrop类的私有方法StartDragAdorner中其触发条件是DoDragDrop被调用当前线程处于DragDrop.DoDragDrop的try块内Mouse.PrimaryDevice.GetPosition(null)返回有效坐标。但影子绘制需要VisualTree上下文来确定AdornerLayer。若DoDragDrop在PreviewMouseMove之前调用如在MouseDown中此时Mouse.GetPosition可能返回(0,0)导致影子绘制在左上角且无法跟随鼠标。PreviewMouseMove提供了第一个可靠的、非零的鼠标坐标是影子初始化的唯一安全时机。4.4 Drop 事件的线程安全性为什么不能在 Drop 中 awaitDrop事件运行在 UI 线程且DoDragDrop内部使用Dispatcher.Invoke同步调用。若在Drop中await一个TaskDoDragDrop的try块会提前退出导致拖拽模态被强制关闭影子立即消失而await后续代码如数据保存在后台线程执行UI 无法更新。验证方式在Drop中写await Task.Delay(100);观察影子是否在Delay开始时就消失。修复所有异步操作必须在Drop外部触发或使用Dispatcher.BeginInvoke延迟执行private async void TreeView_Drop(object sender, DragEventArgs e) { // ✅ 同步完成 UI 更新 MoveItemInModel(e); this.Items.Refresh(); // ✅ 异步操作移出 Drop 事件 Dispatcher.BeginInvoke(new Action(async () { await SaveToDatabaseAsync(); })); }5. 设计取舍为什么 WPF 不提供“开箱即用”的 TreeView 拖拽当你翻遍TreeView的所有属性会发现没有AllowDragDropTrue这样的开关。这不是疏忽而是 WPF 团队在DragDropAPI 设计时做出的显式优于隐式的坚定取舍。他们宁愿让开发者写 200 行事件处理代码也不愿封装一个“智能拖拽”组件原因有三5.1 数据模型与 UI 的强耦合风险TreeView的数据源可能是ObservableCollectionT、DataTable、甚至自定义ICollectionView。若框架自动实现拖拽就必须假设所有数据源都支持Insert(int index, object item)和Remove(object item)而这在ICollectionView中根本不存在。强制统一接口等于阉割了 WPF 的数据绑定灵活性。因此WPF 把“数据移动”逻辑完全交给开发者只提供“拖拽状态传递”的管道。5.2 插入位置的语义不可自动化“拖到节点 A 上方”、“拖到节点 A 下方”、“拖到节点 A 子节点中”——这三种插入语义在 UI 上仅靠鼠标坐标无法区分。DragOver事件中开发者必须根据e.GetPosition(targetItem)计算 Y 坐标与targetItem边界的关系才能决定插入位置。框架无法猜出你的业务规则所以把坐标计算和位置判断的权力留给你。5.3 性能与可预测性的权衡一个“全自动拖拽”组件需要在MouseMove中持续 HitTest、计算层级关系、预判插入点、渲染插入线……这对复杂 TreeView上千节点是灾难性的。WPF 选择将性能控制权交给开发者你可以用VirtualizingStackPanel优化渲染可以用IsExpanded控制展开粒度可以在DragOver中加缓存避免重复计算。框架只保证DragEventArgs的参数可靠其余皆可裁剪。从那以后我每次实现 TreeView 拖拽都强制走一遍这四阶段验证PreviewMouseMove是否拿到有效位移DragOver是否设置了e.Effects且e.Handled trueDrop是否只操作数据源不碰Items集合是否禁用了ContextMenu和Alt键干扰这四步走完那个悬在半空的幽灵节点就再也没出现过。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
Fabricated RRO 实战:运行时动态资源叠加与换肤 1. 从一次资源替换的踩坑说起:RRO 到底解决了什么问题第一次接触 RRO 是在一个定制化项目里,客户要求同一套系统固件在不同渠道版本上呈现完全不同的开机动画、默认壁纸和预置应用名称,但又不允许我们改动系统分区里的原始资源文件。当时团队… · 2026/11/6 2:47:07
企业存储云原生化的核心价值与实践路径 1. 为什么企业存储组件不上云原生?现状与痛点解析在大数据领域摸爬滚打多年,我发现一个有趣现象:90%的企业仍将HDFS/Kafka部署在物理机,Spark/Flink继续跑在YARN上。这并非技术落后,而是经过实践验证的稳定架构。物理机… · 2026/11/6 2:47:07
蓝牙协议四层详解:从物理层到GATT的初学者入门指南 简介:这是一份专为蓝牙技术初学者打造的协议原理入门资料,系统梳理蓝牙协议栈各层核心机制与协同逻辑,帮助读者建立从物理层到应用层的完整认知框架,解决入门者对无线通信协议抽象难懂、层次不清的常见痛点。资源包共3个文件&… · 2026/11/6 2:47:07
苏州相城电工证报名多久拿证,工地太忙怎么高效备考 苏州相城电工证报名多久拿证,工地太忙怎么高效备考 工地太忙根本没时间复习考试,这几乎是每个特种作业人员的真实写照,尤其是电工证这种必须通过理论和实操双重考核的证件,复习难度和时间成本都比较高。你是不是也经常因为项目赶工期,连系统复习的时间都… · 2026/11/7 0:31:51
福建如何查电工证网上查询最全攻略,外省证能转吗 福建如何查电工证网上查询最全攻略,外省证能转吗 之前考的证在外省能不能转过来,这个问题真的折磨了不少人,特别是像我这样在漯河工地干了五年的电工,手里拿着河南的低压电工证,想在福建干项目,却发现证件查不到,连项目负责人都拦着不让上。这事儿不是… · 2026/11/7 0:31:51
电工证在哪里刷视频网上查询方便吗 电工证在哪里刷视频网上查询方便吗 之前考的证在外省能不能转过来?这可能是很多刚毕业的应届生、正在换工作或想转行从事电工行业的朋友最关心的问题。特别是像漯河这样的地方,很多同学毕业后在本地找工作时发现, 电工证在哪里刷视频… · 2026/11/7 0:31:51
磐安考电工证在哪里通过率揭秘 磐安考电工证在哪里通过率揭秘 不知道去哪报名怕被中介坑,尤其是像磐安这样的地方,信息闭塞、渠道杂乱,很多想考证的人容易被“包过班”“代考”这些话术忽悠,最终花了钱还拿不到证,耽误了工作和前途。其实,电工证是国家强制认证的特种作业操作证,… · 2026/11/7 0:31:51
70岁电工考一个特种作业操作证还来得及?官方报名入口详解 70岁电工考一个特种作业操作证还来得及?官方报名入口详解 实操考试心里没底怕挂科,这是我去年在漯河帮一个70岁老电工考试时最真实的状态。那天他手抖得厉害,连螺丝刀都拿不稳,但最终还是考过了。为什么?因为他知道【官方报名入口】在哪里,也清楚该… · 2026/11/7 0:31:51
普洱电工证培训机构怎么选?考前押题帮你高效通关 普洱电工证培训机构怎么选?考前押题帮你高效通关 工地太忙根本没时间复习考试,但电工证考试又不能拖,否则影响上岗。现在市面上的【普洱电工证培训机构】太多,很多人不知道怎么选。别急,看完这篇,你就知道怎么高效拿到证书,还能通过【考前押题】轻松过… · 2026/11/7 0:30:50
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 10:29:23
澳洲水电工证考试:证书过期了不知道怎么复审延期?电子证书怎么弄? 澳洲水电工证考试:证书过期了不知道怎么复审延期?电子证书怎么弄? 证书过期了不知道怎么复审延期?别慌,很多人在拿到【澳洲水电工证考试】的电子证书后,一不小心就忽略了复审时间,结果证书失效,工作受阻,甚至被单位追责。今天就从【澳洲水电工证考试… · 2026/11/7 0:00:41
电工作业提示版通过率揭秘:怎么考才能一次过? 电工作业提示版通过率揭秘:怎么考才能一次过? 怕考不过白交培训费?别急,今天我就从驻马店应届工程类毕业生的视角,带你全面了解电工作业提示版的考试流程、备考要点和真实通过率,帮你少走弯路、提高一次通过率。… · 2026/11/7 0:00:41
电工证年审报名千万别踩坑 外省证转过来要搞清这几点 电工证年审报名千万别踩坑 外省证转过来要搞清这几点 之前考的证在外省能不能转过来?这个问题是很多电工从业者最关心的,特别是像你这样在商丘从事公路工程的人员,经常需要跨省流动,证书的 有效性 和 可迁移性… · 2026/11/7 0:00:41
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 10:29:23