news 2026/9/24 12:19:09

DataGridView实现Excel式拖动填充:高亮、预览与循环序列引擎

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DataGridView实现Excel式拖动填充:高亮、预览与循环序列引擎

从收到这个需求到把它真正做成一个能让人"顺手"的功能,我大概折腾了两天。起因是我手上有个WinForm项目,里面大量用到DataGridView,业务上经常需要用户连续录入同一类数据,比如把一批物料的批次号、生产日期、班组信息往下拖。用户直接跟我说:"能不能像Excel那样,选中一格往下拖就自动填好?"

我当时的第一个反应是:这功能听起来简单,真正做起来细节太多了。光是"拖动填充"这四个字,就牵扯到鼠标交互、坐标换算、区域高亮、内容预览、序列识别、循环填充、性能优化这一整条链路。而且WinForm的DataGridView不像WPF或第三方控件库那么"开箱即用",很多效果得自己用GDI+一点一点画出来。

这篇文章就把我完整的实现思路、关键代码、踩坑记录整理出来,给准备在自己项目里做类似功能的同学一个参考。我会重点说清楚三件事:高亮反馈怎么做才不闪烁、内容预览怎么实时显示、循环填充的规则引擎该如何设计。

1. 动手之前,先把Excel的拖动填充行为拆解清楚

很多人在实现的时候栽跟头,根源不是代码能力,而是没想清楚"到底要模仿Excel的哪些行为"。Excel的拖动填充看似简单,实际上是一套完整的交互闭环,你少了任何一环,用户用起来都会觉得别扭。

1.1 完整交互链路拆解

我以Excel里最常见的一个操作来拆:

  • 选中一个包含数值的单元格,比如1
  • 鼠标移动到单元格右下角,光标变成黑色十字
  • 按住左键往下拖,拖过的区域出现一个半透明的边框
  • 松开鼠标后,下面的单元格被依次填上2、3、4、5……

如果当初选中的是两个单元格1、2,往下拖就会变成3、4、5、6。如果选中的是三个单元格1、2、1,且数据呈现循环规律,Excel会用循环的方式重复填充。

所以,要复刻这套体验,至少需要四块能力:

能力模块说明在本文中的实现位置
填充柄命中检测判断鼠标是否落在单元格右下角的小方块区域第2章
拖动高亮反馈实时绘制目标填充区域的高亮边框第3章
内容实时预览拖动过程中提示"松手后会填成什么"第3章
填充规则引擎识别等差、等比、循环、纯复制等不同规律第4章

但具体到一个实际项目里,你不需要一次性把所有行为都做出来。我在动手前给自己划了一条边界,这套功能要覆盖以下几个典型场景:

  • 单值填充:单元格是纯文本或数字,往下拖就直接复制
  • 等差序列填充:识别前两个单元格的差值,继续生成后续值
  • 循环序列填充:比如周一、周二、周一、周二这种规律,拖下去能继续循环
  • 文本+数字混合序列:比如A-001A-002,数字部分递增

预览、高亮、循环这三件事,正好覆盖了标题里提到的核心点,也是我在项目里真正用上了的东西。

1.2 为什么建议用"覆盖层"做高亮,而不是直接改单元格样式

这是整个方案里我踩得最深的一个坑,先讲结论:做拖动高亮反馈时,不要通过修改DataGridView单元格的背景色或选中态来实现。

我最早的原型就是这么干的——MouseMove的时候遍历目标区域,把每个单元格的Style.BackColor改成半透明蓝。结果一拖大面积区域,界面直接卡成PPT,而且DataGridView在刷新时会反复触发CellPainting事件,视觉上会看到明显的闪烁。原因在于:每改一个单元格的Style,控件就要重新执行一次布局和重绘,改动量越大,性能越差。

正确的做法是:在DataGridView上方覆盖一个透明的Panel,所有高亮边框、半透明区域都画在这个Panel上。拖动过程中,你只需要Invalidate这个Panel,让GDI+重新绘制那个高亮矩形即可。这样DataGridView本身完全没有参与重绘,性能开销小一个数量级。

1.3 关于"循环"这个需求的定位

标题里提到了"循环",但在Excel里其实有两种"循环":

  • 一种是等比/等差序列的回绕,比如1、2、3之后继续1、2、3
  • 另一种是自定义列表循环,比如星期、月份、季度这种业务上固定的循环序列

两者在实现上完全不同。等差序列回绕只需要在填充时对序列长度取模,而自定义列表循环需要一个"序列注册表",把周一~周日一月~十二月这类业务序列预先定义好,填充时先在注册表里找有没有匹配的序列,找不到再走数值规律识别。

我在项目里两种都做了,后面第4章会详细讲。

2. 交互框架搭建:鼠标事件与坐标换算

这章是整套功能的地基。拖动填充的交互核心是一个简单的状态机:按下、拖动、释放、取消。但真正写起来,麻烦全在坐标换算上。

2.1 一个可复用的鼠标状态机

我先定义一个简单的枚举来表示当前交互状态:

public enum FillDragState { Idle, // 空闲,未开始拖动 Dragging, // 正在拖动填充 }

然后在DataGridView上挂三个事件:MouseDownMouseMoveMouseUpMouseDown时先判断光标是否落在选中区域的右下角"填充柄"范围内,如果是就进入Dragging状态,并记录起始单元格。

这里有个关键点:填充柄的命中区域不能太小,也不能太大。我实测下来,以单元格右下角为中心,取16x16像素的正方形作为热区,体验最顺手。热区太小用户很难点中,太大又容易在正常选中文本时误触发。

判断光标是否在热区内,可以这样处理:

private bool IsInFillHandle(DataGridView grid, Point clientPoint) { var hit = grid.HitTest(clientPoint.X, clientPoint.Y); if (hit.Type != DataGridViewHitTestType.Cell) return false; var cell = grid.Rows[hit.RowIndex].Cells[hit.ColumnIndex]; var cellRect = grid.GetCellDisplayRectangle(hit.ColumnIndex, hit.RowIndex, false); var handleRect = new Rectangle(cellRect.Right - 16, cellRect.Bottom - 16, 16, 16); return handleRect.Contains(clientPoint); }

注意这里使用的是GetCellDisplayRectangle,它返回的是单元格在控件客户区的实际显示坐标,已经包含了滚动偏移,所以不需要手动额外减滚动条的位置。

2.2 坐标换算的两个大坑

第一个坑:HitTest的坐标必须是控件客户区坐标,而不是屏幕坐标。所以在事件处理里,先调用PointToClient转换,这个我在上面代码里已经体现了。

第二个坑:拖动过程中如果用户滚动鼠标滚轮,坐标会错乱。这是因为DataGridView的显示区域发生了变化,而你之前记录的起点可能已经被滚到可视区域之外。处理办法有两种:

  • 简单粗暴:进入拖动状态后,立刻把Grid.ScrollBars设为None,禁止滚动
  • 高级一点:在滚轮事件里实时调整偏移量,或者Scroll发生时自动取消当前拖动

我实际项目里选了第一种,拖动填充本身是一个短交互,禁止滚动对用户影响很小,但代码稳定性高很多。

2.3 目标区域计算:支持横向和纵向

用户可能往下拖,也可能往右拖,甚至可能斜着拖。Excel对斜着拖的处理是:以起点和终点构建一个矩形区域,然后按行优先或列优先展开填充。

我的计算逻辑是这样的:

private Rectangle GetFillRange(Point startCell, Point endCell) { int r1 = Math.Min(startCell.Row, endCell.Row); int r2 = Math.Max(startCell.Row, endCell.Row); int c1 = Math.Min(startCell.Column, endCell.Column); int c2 = Math.Max(startCell.Column, endCell.Column); return new Rectangle(c1, r1, c2 - c1 + 1, r2 - r1 + 1); }

拿到矩形后,我会把源区域(用户选中的单元格集合)和目标区域(拖出来的矩形范围,要去掉与源区域重叠的部分)分开处理。因为填充的规则是:源区域决定"填什么",目标区域决定"填到哪"。

3. 高亮反馈层:不闪烁、不遮挡的绘制方案

这一章是整个功能视觉体验的关键。很多人做出来的拖动填充功能能用,但看起来特别廉价,原因就是高亮反馈做得粗糙。

3.1 覆盖层的设计与生命周期

我在DataGridView上面叠了一个Panel,设置:

var overlay = new Panel { BackColor = Color.Transparent, AllowDrop = false, TabStop = false, Cursor = Cursors.Default }; grid.Controls.Add(overlay); overlay.BringToFront();

覆盖层需要在DataGridViewScrollResizeCellPaint等事件中同步位置和大小。最简单的做法是在这些事件里把覆盖层的Bounds设为grid.DisplayRectangle,然后Invalidate

多说一句,PanelBackColor = Color.Transparent只是让它不画背景色,真正要做到"只画高亮框、不挡住底下单元格文字",关键在于OnPaint只画边框和半透明色块,不画任何不透明的内容

3.2 高亮区域的绘制细节

覆盖层画的视觉元素有两层:外层是目标区域的半透明填充色,内层是目标区域的虚线边框。我用GDI+实现:

protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_fillRect.IsEmpty) return; using (var fillBrush = new SolidBrush(Color.FromArgb(60, 51, 153, 255))) { e.Graphics.FillRectangle(fillBrush, _fillRect); } using (var pen = new Pen(Color.FromArgb(120, 0, 120, 255), 2f)) { pen.DashStyle = System.Drawing.Drawing2D.DashStyle.Dash; e.Graphics.DrawRectangle(pen, _fillRect); } }

你看这里我用的是FromArgb带透明度的颜色,这样即使有半透明填充,用户依然能隐约看到底下单元格的内容,知道高亮区域覆盖了哪些数据。这个"可见性"是保证拖动过程不迷失的关键。

3.3 内容预览:让用户知道松手后会得到什么

高亮框解决了"拖到哪"的问题,但用户更关心的是"松开后会填成什么"。尤其是循环填充的场景,用户拖过了几个循环周期后,很可能不确定当前鼠标位置对应的是哪个值。这时候就需要内容预览。

我的方案是在覆盖层里跟随鼠标光标位置,绘制一个小的Tooltip气泡,显示目标单元格即将被填入的值:

private void ShowPreview(int rowIndex, int columnIndex) { string value = _fillEngine.GetValueForCell(rowIndex, columnIndex); _previewText = value; _previewLocation = new Point( _lastMouseClient.X + 12, _lastMouseClient.Y - 8 ); Invalidate(); }

这个Tooltip是画在覆盖层上的,跟随鼠标实时移动。文字背景用暗色半透明矩形,文字用白色,这样在任何背景下都清晰可读。

这里有一个很重要的设计决定:预览值必须在填充前就通过填充引擎计算出来。也就是说,填充引擎要提供一个接口:给定一个目标单元格坐标,返回它应该被填的值。这个接口既服务于预览,也服务于最终的落库填充,一套逻辑两处复用。我后面第4章讲的填充引擎,就是围绕这个接口设计的。

4. 填充引擎核心:单值、等差序列与循环序列识别

前面讲的都是"外壳",这一章是真正决定功能上限的"内核"。我把它设计成一个独立的FillEngine类,不依赖UI控件,方便单测和复用。

4.1 数据源模型与序列识别策略

填充引擎接收一个源数据列表,比如用户选中了三行一列,值分别是1、2、3,引擎要先判断这是什么规律。

判断优先级我定成:

  1. 纯文本:直接复制
  2. 自定义循环序列(周一~周日、一月~十二月等):按序列循环
  3. 数值等差:取相邻差值,如果差值一致,按等差生成
  4. 日期等差:按天、月、年递增,比如2025-01-012025-01-03,继续填2025-01-05
  5. 文本+数字混合:如A-001,数字部分递增
  6. 以上都不是:退化为纯文本复制

这里每一步的识别逻辑,都决定了后续生成的正确性。我举一个最常见的坑:用户选中了13往下拖,期望得到5、7、9,也就是公差为2的等差序列。但如果你的引擎只看了第一个值就直接复制文本,那结果就是1、1、1……

所以,源数据至少要有两个值,才能做等差识别;只有一个值时,默认是纯复制。这是Excel的行为,也是我们这套引擎的基线。

4.2 循环填充的取模逻辑

循环填充听起来简单,但在一个跨越多行多列的矩形区域里正确展开,需要处理好"行的边界"。

假设源区域是一列三行,值分别是周一、周二、周三,用户往右拖了五列,那么每一行都应该独立执行循环。如果用户往下拖,那就是整列三行作为一个循环单位,第4行继续填周一

核心代码:

public string GetValueForCell(int row, int col) { if (_sequenceType == SequenceType.CustomLoop) { int sourceLength = _sourceValues.Count; int indexInSource = (row - _sourceStartRow + col - _sourceStartColumn) % sourceLength; // 矩形区域展开时,需要同时考虑行和列相对于起点的偏移 // 更准确的做法是先算出这个单元格在展开序列中的序号 int linearIndex = GetLinearIndex(row, col); return _customSequence[linearIndex % _customSequence.Count]; } if (_sequenceType == SequenceType.Arithmetic) { int delta = _delta; // 步长 int offset = GetLinearIndex(row, col); return (_baseValue + delta * offset).ToString(); } // 默认:复制源值(按一列展开时的逻辑) int sourceRow = _sourceStartRow + (row - _sourceStartRow) % _sourceValues.Count; return _sourceValues[sourceRow - _sourceStartRow]; }

这段代码看起来简单,但GetLinearIndex的计算很容易写错。我的建议是:在矩形展开时,按"先列后行"或"先行后列"选择一个固定顺序,然后所有逻辑都围绕这个线性序号展开。我项目里用的是"先列后行"的顺序,即从左上角开始,先填完第一行,再填第二行,这样更符合用户从左到右、从上到下的认知习惯。

4.3 处理"文本+数字"混合序列的实用案例

业务上经常出现这种值:PO-202501-001PO-202501-002。这种字符串不能直接转成数字做等差,需要先解析出数字部分。

我写了一个正则解析:

private static readonly Regex NumberSuffixRegex = new Regex(@"^(?<prefix>.*?)(?<number>\d+)$"); public static bool TrySplitNumberSuffix(string text, out string prefix, out int number) { var match = NumberSuffixRegex.Match(text); if (match.Success) { prefix = match.Groups["prefix"].Value; number = int.Parse(match.Groups["number"].Value); return true; } prefix = text; number = 0; return false; }

识别逻辑是:如果源区域所有文本的prefix部分相同,且number部分构成等差数列,那么填充后续值时,prefix保持不变,number按等差步长递增。这一步在实际业务里使用频率极高,因为很多单据编号都长这样。

4.4 自定义循环序列注册表

前面提到星期、月份的循环填充,我把它实现成一个静态注册表:

public static class SequenceRegistry { private static readonly List<string[]> CustomSequences = new List<string[]> { new[] { "周一", "周二", "周三", "周四", "周五", "周六", "周日" }, new[] { "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日" }, new[] { "一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月" }, new[] { "Q1", "Q2", "Q3", "Q4" }, }; public static bool TryGetSequence(string[] sourceValues, out string[] sequence) { if (sourceValues == null || sourceValues.Length == 0) { sequence = null; return false; } foreach (var seq in CustomSequences) { // 检查sourceValues是否是该序列的连续一段 for (int start = 0; start < seq.Length; start++) { bool match = true; for (int i = 0; i < sourceValues.Length; i++) { if (!string.Equals(seq[(start + i) % seq.Length], sourceValues[i], StringComparison.OrdinalIgnoreCase)) { match = false; break; } } if (match) { sequence = seq; return true; } } } sequence = null; return false; } }

这样一来,循环填充并不需要写很多分支判断,只需要在识别阶段查一下这个注册表,命中就直接走CustomLoop分支。

5. 填坑实录:性能、边界与交互冲突

这套功能在我项目里从原型到上线,一共修了三轮问题,每一轮都值得拿出来说。

5.1 大范围填充时的界面冻结问题

一开始我在MouseUp里直接循环给目标区域每个单元格赋值。后来测试同事拖了100行x 50列,界面直接卡死好几秒。

原因很简单:每给一个单元格赋值,DataGridView内部都要触发CellValueChangedCellFormatting、重绘等一系列事件。赋5000个值,就是5000次完整的事件风暴。

解决思路分两步:

  • 赋值前调用grid.SuspendLayout(),赋值后调用grid.ResumeLayout(),把布局重排暂停掉
  • 如果数据源是DataTable,可以考虑临时断开绑定或使用DataTable.BeginLoadData/EndLoadData;但这个方案有风险,因为用户可能正在编辑其他列

我最终的方案是用一个批量更新接口:

public void ApplyFill(Dictionary<DataGridViewCell, string> fillMap) { grid.SuspendLayout(); grid.CellValueChanged -= OnCellValueChanged; // 临时解除事件 foreach (var kv in fillMap) { kv.Key.Value = kv.Value; } grid.CellValueChanged += OnCellValueChanged; grid.ResumeLayout(); grid.Invalidate(); }

这种方式不触发单元格级别的事件,填充速度快很多。OnCellValueChanged里如果涉及业务联动,我在填充结束后统一触发一次,而不是每个单元格触发一次,避免重复计算。

5.2 双击与拖动的冲突

这是交互层面的坑。DataGridView默认支持单元格编辑,用户如果在填充柄上按下鼠标,我进入了拖动状态,但如果他立刻松开,会被系统识别为一次单击或双击,可能导致单元格进入编辑模式。

解决方案是:在MouseDown进入拖动状态时,把grid.EditMode临时设为EditProgrammatically,并在MouseUp结束拖动后恢复原值。这样就能阻断双击进入编辑。

5.3 拖动过程中鼠标移出控件边界

用户如果拖得太快,鼠标可能跑出DataGridView的客户区。此时有两种选择:一是直接用Capture强制捕获鼠标,保证即使移出控件也能收到事件;二是移出边界时自动取消拖动。

我建议用Capture方案,因为用户体验更好——用户拖出控件再拖回来,高亮框会实时更新,不会因为鼠标短暂移出就"断掉"。代码上只需要在MouseDown时设置grid.Capture = trueMouseUp时设置grid.Capture = false

5.4 与DataGridView自带选中态的视觉冲突

默认情况下,用户拖出来的目标区域如果覆盖了原来的选中区域,蓝色选中边框和高亮边框会叠在一起,视觉上很乱。

我的解决办法是:在进入拖动状态时,把grid.SelectionMode临时设为CellSelect,并清空选中区域;拖动过程中全部选中态交给覆盖层来表现;拖动结束后,把最后填充的区域重新设为选中区域,让用户知道刚才填了哪些格子。

6. 拿到你的业务项目里,这套功能怎么改

第5章讲的是通用实现,但每个项目的DataGridView数据源绑定方式不同,有的绑DataTable,有的绑自定义BindingList,有的干脆是手写单元格。这一章我就具体说说扩展点。

6.1 数据源解耦:填充引擎不直接碰Cell

我在项目里把填充引擎设计成完全独立于DataGridView的类,它只负责接收"源值数组+目标坐标序号",返回"该填什么值"。UI层才负责把值写到具体单元格。

这样带来一个好处:如果后续你想把这套能力复用到ListView、DataGrid(WPF)或者其他自定义表格控件上,只需要写一个薄薄的适配层,核心引擎一行不用改。

public interface IFillValueProvider { string GetValueForCell(int row, int col); }

6.2 自定义业务序列的注入方式

不同业务有不同的循环序列。比如生产排产可能有"白班、夜班、休息"这种三班倒序列,考勤可能有"出勤、休息、请假"这种状态序列。

我在SequenceRegistry里预留了一个公开的注册方法:

public static void RegisterSequence(string[] sequence) { if (sequence == null || sequence.Length < 2) throw new ArgumentException("序列至少需要两个元素"); CustomSequences.Add(sequence); }

业务系统启动时,就可以把自定义序列注册进去。这样填充引擎不需要知道具体业务,逻辑上完全解耦。

6.3 撤销重做:好消息是可以不自己做

我在设计阶段一直在纠结要不要做撤销。后来想通了:如果项目里已经用了第三方撤销框架,或者数据是即时落库的,撤销重做属于另一个维度的功能,不应该塞进拖动填充这个功能里。

如果你确实需要,一个轻量方案是在ApplyFill前,把目标区域的旧值快照到一个栈里,然后提供Undo方法恢复快照。这个方案实现成本不高,但要注意快照的内存占用,如果一次填充了上万个单元格,每个单元格都存一个旧值字符串,内存会明显膨胀。我建议最多支持5级撤销栈,超过就把最老的丢弃。

6.4 右键拖动菜单的扩展思路

Excel还有一个很强的功能:右键拖动单元格填充柄时,松手会弹出一个菜单,让用户选择"复制单元格""填充序列""仅填充格式"等操作。这个功能在WinForm下实现也不难,思路是右键拖动的流程跟左键完全一致,只在MouseUp时检测到右键,弹出一个ContextMenuStrip,菜单项触发不同的填充模式。

我在项目里暂时没做,因为业务方觉得左键填充已经够了。但如果你的用户群操作水平偏高,这个功能值得考虑,它能明显提升专业感。

7. 运行效果与后续优化方向

到这一步,整套功能已经能稳定运行了。我把三个核心体验再理一遍,看你有没有遗漏:

  • 高亮反馈:拖动时实时显示目标区域边框和半透明色块,用户对填充范围一目了然
  • 内容预览:鼠标所在位置实时显示将要填入的值,循环拖久了也不会发懵
  • 循环填充:支持自定义序列、等差序列、文本+数字混合,基本覆盖了实际业务里九成以上的填充需求

后续如果要继续优化,我有三个方向:

第一个方向是增强序列识别能力,比如识别等比序列2、4、8、16,识别日期序列的更多单位(工作日、季度末等)。识别逻辑本身不难,但要注意不能让识别过程误伤了纯文本复制场景,所以优先级判断非常关键。

第二个方向是把这套能力封装成自定义控件,比如ExcelLikeDataGridView,把填充柄、高亮层、填充引擎全部集成进去。这样新项目里只要拖一个控件进来,功能就自动有了,不用再把几十个事件一个个挂上去。

第三个方向是性能层面,我目前的方案在千级单元格范围内流畅度没问题,但如果要支持万级甚至十万级单元格的填充,得考虑用CellPainting事件代替覆盖层来绘制高亮,同时用并行计算来生成填充值。不过这种规模在WinForm表格里本身就比较罕见,普通业务我建议不要过度设计。

回到最开始的需求:用户想"像Excel一样拖动填充"。做完之后我最大的感受是——这种功能真正的难点不在填充算法本身,而在交互细节的打磨。你有没有在拖动区域时正确显示预览?松开鼠标时有没有用尽全力避免界面卡顿?鼠标移出边界后是高亮消失还是继续跟手?这些细节才是用户觉得"好用"还是"将就"的分水岭。

如果你也在自己的项目里实现了类似功能,或者照着上面代码做完了有什么坑,欢迎在评论区聊一聊。毕竟这类"让控件更像Excel"的需求,在WinForm项目里永远不缺变体和需求方。

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

Keil MDK编译报错根源:ARM Compiler 5未正确安装与配置

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

作者头像 李华
网站建设 2026/9/24 12:18:02

小智源码换板子必读:板级配置适配与避坑指南

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

作者头像 李华
网站建设 2026/9/24 12:17:02

Buck电路尖峰吸收:RC、RCD、TVS方案效率与选型实测对比

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

作者头像 李华
网站建设 2026/9/24 12:15:52

电力监控系统网络安全态势感知:从边界防护到设备级风险识别

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

作者头像 李华
网站建设 2026/9/24 12:14:33

IM社交钱包开发全解析:融合社交网络与数字资产管理的新型Web3应用模式

IM社交钱包开发全解析&#xff1a;融合社交网络与数字资产管理的新型Web3应用模式 燃链科技&#xff1a;随着Web3生态、区块链技术以及数字资产应用的不断发展&#xff0c;传统社交应用正在迎来新的升级方向。IM社交钱包作为“即时通讯数字钱包区块链应用”的融合型产品&#x…

作者头像 李华
网站建设 2026/9/24 12:06:59

2026年真实测评三款手机变声器:无杂音

今天真实无广给大家测评三款变声器&#xff0c;从大家关心的音质&#xff0c;广告&#xff0c;声线&#xff0c;功能等等去测评一、三款手机变声器1、叮咚变声器优势&#xff1a;500声线&#xff0c;男变女&#xff0c;女变男转变自然&#xff0c;御姐&#xff0c;少年&#xf…

作者头像 李华