C#调用摄像头拍照:AForge.NET轻量方案实战与避坑指南
1. 为什么在C#里调用摄像头我最终选了AForge1.1 从一次实际需求说起前阵子帮一个做产线质检的朋友处理一个小工具需求很朴素工位上有一台普通USB摄像头操作员按一下按钮软件自动抓一张当前画面的照片存到指定目录同时把工单号、时间戳一起写进文件名。听起来是不是特别简单但真动手的时候问题就来了——项目是WinForm的.NET Framework 4.7.2客户机器上不允许装额外的商业SDK也不希望引入体积庞大的第三方库。我一开始试过几种方案。WPF的MediaCapture在WinForm里用起来别扭而且对老版本系统的兼容性一般DirectShow自己封装FilterGraph代码量大得离谱调试成本高OpenCVSharp功能强但为了拍一张照片引入上百MB的依赖实在不划算。最后绕了一圈还是回到了AForge.NET这个老牌库上。AForge.NET是一个开源的C#图像处理和计算机视觉框架里面的AForge.Video和AForge.Video.DirectShow两个程序集专门用来做摄像头的枚举、预览和帧捕获。它底层封装的是DirectShow但把那些复杂的COM接口调用都藏起来了对外暴露的API非常干净。对于“调用摄像头拍照”这种需求它几乎是量身定做的。1.2 AForge方案的核心优势与适用边界先说优势。第一轻量。AForge.Video相关的几个DLL加起来也就几百KB跟OpenCVSharp那种动辄上百MB的库完全不是一个量级。第二API简单。枚举设备、启动预览、订阅NewFrame事件、抓帧保存核心逻辑不到五十行代码就能跑通。第三兼容性好。它基于DirectShow在Windows 7到Windows 11上都能正常工作对老设备的支持也到位。但我也得把话说清楚AForge不是万能的。它已经很多年没有大版本更新了社区活跃度不高遇到一些新摄像头或者特殊编码格式可能会翻车。另外它本身不提供录像编码功能只能抓帧如果你要录视频还得自己接FFmpeg之类的工具。所以我的判断是如果你的需求就是“预览抓拍单帧”AForge是最省事的选择如果要做实时视频处理、人脸识别、录像编码那还是老老实实上OpenCVSharp或者MediaFoundation。1.3 整体设计思路这个项目的结构我拆成了三层。最底层是设备管理层负责枚举摄像头、选择设备、启动和停止视频源中间是预览层把摄像头的实时画面渲染到PictureBox上最上层是抓拍层在用户点击按钮时从当前帧复制一份Bitmap出来做保存和后续处理。为什么要分层因为摄像头操作有个坑视频源启动后是在独立线程里回调NewFrame事件的如果你直接在事件里操作UI控件会抛跨线程异常。分层之后预览层负责用Invoke把画面丢回UI线程抓拍层则用一个标志位来控制“下一帧到来时执行抓拍”这样逻辑清晰也不容易出bug。2. 环境准备与依赖配置的实操细节2.1 安装AForge相关的NuGet包现在装AForge最省事的方式就是走NuGet。在Visual Studio里右键项目选择“管理NuGet程序包”搜索AForge你会看到几个相关的包。这里要注意别只装一个AForge就完事了实际需要的是这三个包名作用是否必需AForge核心库提供基础类型和图像处理必需AForge.Video视频源抽象层定义IVideoSource接口必需AForge.Video.DirectShowDirectShow实现枚举和操作摄像头必需如果你用的是Package Manager Console直接敲这三条命令就行Install-Package AForge Install-Package AForge.Video Install-Package AForge.Video.DirectShow装完之后检查一下项目的引用列表应该能看到AForge.dll、AForge.Video.dll、AForge.Video.DirectShow.dll三个引用。有时候NuGet会自动带上AForge.Math和AForge.Imaging这两个在这个项目里用不到但留着也不碍事。提示AForge的NuGet包版本号停留在2.2.5已经很多年了这是正常的不用怀疑自己装错了。这个版本足够稳定覆盖绝大多数USB摄像头场景。2.2 项目配置的几个关键点第一个要注意的是目标框架。AForge是基于.NET Framework开发的虽然理论上.NET Core和.NET 5也能通过兼容包引用但实际测试下来在.NET Framework 4.5以上版本最稳。我一般建议用4.7.2或4.8这两个版本在Win10和Win11上兼容性最好。第二个是平台目标。AForge.Video.DirectShow底层调用的是32位的DirectShow组件所以你的项目平台目标最好设成x86或者Any CPU但取消“首选32位”的勾选。我踩过一次坑项目设成x64结果枚举摄像头的时候返回空列表排查了半天才发现是平台不匹配。第三个是UI框架的选择。WinForm和WPF都能用但WinForm的PictureBox配合AForge最顺手因为AForge输出的就是System.Drawing.Bitmap直接赋值给PictureBox.Image就行。WPF的话需要额外做Bitmap到BitmapSource的转换多一道工序。2.3 摄像头设备的枚举逻辑枚举摄像头用的是FilterInfoCollection类构造的时候传入FilterCategory.VideoInputDevice。这个类会返回当前系统上所有可用的视频输入设备每个设备是一个FilterInfo对象包含Name和MonikerString两个关键属性。using AForge.Video.DirectShow; FilterInfoCollection videoDevices; try { videoDevices new FilterInfoCollection(FilterCategory.VideoInputDevice); } catch (Exception ex) { MessageBox.Show(枚举摄像头失败 ex.Message); return; } if (videoDevices.Count 0) { MessageBox.Show(没有检测到任何摄像头设备); return; } foreach (FilterInfo device in videoDevices) { comboBoxDevices.Items.Add(device.Name); } comboBoxDevices.SelectedIndex 0;这里有个细节值得说一下MonikerString是设备的唯一标识符启动视频源的时候用的就是这个字符串而不是设备名称。因为设备名称可能重复比如插了两个同型号的摄像头但MonikerString是唯一的。注意枚举操作最好放在try-catch里。某些情况下比如摄像头驱动异常构造函数会直接抛异常不捕获的话程序就崩了。3. 核心实现从预览到抓拍的完整流程3.1 启动视频源与实时预览启动视频源的核心是VideoCaptureDevice类。构造的时候传入选中设备的MonikerString然后订阅NewFrame事件最后调用Start()方法。using AForge.Video; using AForge.Video.DirectShow; private VideoCaptureDevice videoSource; private void StartPreview() { if (videoDevices.Count 0) return; // 如果已有视频源在运行先停止 if (videoSource ! null videoSource.IsRunning) { videoSource.SignalToStop(); videoSource.WaitForStop(); } var selectedDevice videoDevices[comboBoxDevices.SelectedIndex]; videoSource new VideoCaptureDevice(selectedDevice.MonikerString); videoSource.NewFrame VideoSource_NewFrame; videoSource.Start(); } private void VideoSource_NewFrame(object sender, NewFrameEventArgs eventArgs) { // 注意这里是在非UI线程回调的 Bitmap frame (Bitmap)eventArgs.Frame.Clone(); if (pictureBoxPreview.InvokeRequired) { pictureBoxPreview.Invoke(new Action(() { var old pictureBoxPreview.Image; pictureBoxPreview.Image frame; old?.Dispose(); })); } else { var old pictureBoxPreview.Image; pictureBoxPreview.Image frame; old?.Dispose(); } }这段代码有几个关键点必须说清楚。第一eventArgs.Frame是一个复用的Bitmap对象AForge每次回调都会往同一个对象里写新数据。所以你绝对不能直接把Frame赋值给PictureBox必须Clone一份出来否则画面会闪烁甚至出现撕裂。第二Clone出来的Bitmap要记得释放不然内存会一路涨上去。我在PictureBox赋值前先把旧的Image Dispose掉就是这个道理。第三InvokeRequired的判断不能省。NewFrame事件是在AForge的内部线程触发的直接操作PictureBox会抛“跨线程操作无效”的异常。用Invoke把赋值操作丢回UI线程是标准做法。3.2 抓拍单帧的实现方式抓拍有两种思路。一种是在NewFrame事件里设一个标志位下一帧到来时顺便保存另一种是直接从PictureBox.Image里拿当前显示的帧。我推荐第一种因为PictureBox里的图像可能因为缩放而失真而NewFrame里的Frame是原始分辨率。private bool captureRequested false; private void btnCapture_Click(object sender, EventArgs e) { if (videoSource null || !videoSource.IsRunning) { MessageBox.Show(请先启动摄像头预览); return; } captureRequested true; } private void VideoSource_NewFrame(object sender, NewFrameEventArgs eventArgs) { Bitmap frame (Bitmap)eventArgs.Frame.Clone(); // 处理抓拍请求 if (captureRequested) { captureRequested false; SaveFrame(frame); } // 更新预览同上略 } private void SaveFrame(Bitmap frame) { string dir Path.Combine(Application.StartupPath, Captures); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); string fileName $IMG_{DateTime.Now:yyyyMMdd_HHmmss_fff}.jpg; string fullPath Path.Combine(dir, fileName); // 用JPEG编码器保存质量设为90 var encoder GetEncoder(ImageFormat.Jpeg); var encoderParams new EncoderParameters(1); encoderParams.Param[0] new EncoderParameter(Encoder.Quality, 90L); frame.Save(fullPath, encoder, encoderParams); frame.Dispose(); } private ImageCodecInfo GetEncoder(ImageFormat format) { return ImageCodecInfo.GetImageDecoders() .FirstOrDefault(c c.FormatID format.Guid); }这里用EncoderParameters来控制JPEG质量是有讲究的。Bitmap默认的Save方法保存JPEG时用的是75%质量对于质检场景来说细节损失有点大。调到90%之后文件大小增加不多但清晰度明显提升。如果你需要无损保存那就用PNG格式但文件会大好几倍。3.3 分辨率与帧率的设置AForge允许你查询摄像头支持的所有视频能力并从中选择合适的分辨率。VideoCaptureDevice有个VideoCapabilities属性返回VideoCapabilities对象的数组每个对象包含FrameSize、AverageFrameRate等信息。private void SetupCameraCapabilities() { var caps videoSource.VideoCapabilities; if (caps null || caps.Length 0) return; // 按分辨率从高到低排序选一个合适的 var sorted caps.OrderByDescending(c c.FrameSize.Width * c.FrameSize.Height).ToArray(); foreach (var cap in sorted) { // 选一个不超过1920x1080的最高分辨率 if (cap.FrameSize.Width 1920 cap.FrameSize.Height 1080) { videoSource.VideoResolution cap; break; } } }为什么要限制在1920x1080因为很多工业摄像头标称支持4K但实际USB带宽根本跑不满强行设置高分辨率会导致帧率暴跌甚至画面卡死。选一个带宽能承受的分辨率比盲目追求高像素实用得多。实操心得设置VideoResolution必须在Start()之前调用Start之后再改是不生效的。这个顺序千万别搞反。4. 踩坑记录与常见问题排查4.1 摄像头被占用导致启动失败这是最常见的问题。摄像头是独占设备同一时间只能被一个程序使用。如果你的程序启动预览失败先检查是不是有其他软件比如系统自带的相机应用、会议软件正在占用摄像头。AForge在设备被占用时Start()方法不会抛异常但NewFrame事件永远不会触发界面上就是一片黑。这种情况很难排查因为没有任何错误提示。我的做法是在Start()之后加一个超时检测如果3秒内没有收到任何帧就提示用户检查摄像头占用情况。private DateTime lastFrameTime DateTime.MinValue; private void VideoSource_NewFrame(object sender, NewFrameEventArgs eventArgs) { lastFrameTime DateTime.Now; // ... 其他处理 } // 在启动后启动一个定时器检查 private void CheckCameraAlive() { if (videoSource ! null videoSource.IsRunning) { if ((DateTime.Now - lastFrameTime).TotalSeconds 3) { MessageBox.Show(摄像头已启动但未收到画面请检查设备是否被其他程序占用); } } }4.2 内存泄漏与资源释放AForge用不好最容易出的问题就是内存泄漏。每次NewFrame回调都Clone一个Bitmap如果忘记Dispose几分钟下来内存就能涨到几个GB。我总结了几条释放资源的规矩PictureBox.Image赋值前先Dispose旧的Image抓拍保存完成后立即Dispose抓拍用的Bitmap程序关闭时调用videoSource.SignalToStop()和WaitForStop()确保视频源完全停止在FormClosing事件里做清理并且要判断videoSource是否为nullprivate void MainForm_FormClosing(object sender, FormClosingEventArgs e) { if (videoSource ! null videoSource.IsRunning) { videoSource.SignalToStop(); videoSource.WaitForStop(); videoSource.NewFrame - VideoSource_NewFrame; videoSource null; } }WaitForStop()会阻塞当前线程直到视频源完全停止一般也就几百毫秒。如果不调用它直接关程序有时候会残留后台线程导致进程无法完全退出。4.3 常见问题速查表问题现象可能原因解决方法枚举不到摄像头平台目标不匹配改为x86或取消首选32位预览黑屏无画面设备被占用关闭其他使用摄像头的程序画面卡顿严重分辨率设置过高降低到1280x720或更低内存持续增长Bitmap未释放检查所有Clone处是否Dispose关闭程序报错视频源未停止FormClosing里调用WaitForStop抓拍图片模糊JPEG质量过低设置Encoder.Quality为90以上跨线程异常直接操作UI控件用Invoke回UI线程4.4 几个容易被忽略的细节第一个是摄像头预热。有些USB摄像头刚启动的前几帧是黑的或者曝光不正常如果你在启动后立刻抓拍拿到的可能是一张废片。我的做法是启动后延迟500毫秒再允许抓拍或者丢弃前10帧。第二个是文件名冲突。如果你用秒级时间戳快速连拍时可能生成同名文件。加上毫秒fff基本就能避免但更稳妥的做法是再加一个自增序号。第三个是异常处理。SaveFrame里的文件保存操作一定要包在try-catch里因为磁盘满、权限不足、路径非法这些情况都可能导致保存失败。保存失败时给用户一个明确提示比默默失败要好得多。5. 功能扩展与个人经验总结5.1 可以继续扩展的方向这套基础框架搭好之后往上加功能其实很顺手。比如加一个自动抓拍模式每隔N秒抓一张用于定时巡检比如加一个运动检测用AForge.Imaging里的帧差算法判断画面是否有变化有变化才抓拍能大幅减少无效图片再比如加一个简单的图像预处理抓拍后自动做灰度化或者对比度增强方便后续的OCR识别。AForge本身还带了AForge.Imaging库里面有滤波、边缘检测、形态学操作等常用算法。如果你要做简单的视觉检测不用引入OpenCVAForge自带的功能够用了。当然复杂场景还是得上OpenCVSharp这个要看具体需求。5.2 我在实际使用中的几点体会用了几年AForge做摄像头相关的工具最大的感受是它不完美但在“简单抓拍”这个场景下没有比它更省事的选择。你不需要理解DirectShow的FilterGraph不需要处理COM接口的引用计数几十行代码就能跑起来。但也要认清它的局限。AForge的NewFrame回调频率是跟着摄像头走的如果你在回调里做耗时操作比如图像处理、文件IO会阻塞后续帧的回调导致预览卡顿。正确的做法是在回调里只做最轻量的操作——Clone一帧丢给后台线程去处理。另外AForge对高分辨率高帧率的支持确实一般。我试过用4K摄像头跑30帧CPU占用直接飙到50%以上换成1080p就降到10%左右。所以如果你的场景对性能要求高还是得考虑MediaFoundation或者直接上OpenCVSharp。最后分享一个小技巧如果你需要在多台机器上部署建议把AForge的三个DLL直接放在程序目录下不要依赖GAC。这样拷贝过去就能跑不用在每台机器上装NuGet包。发布的时候用Release模式编译Debug模式下AForge的性能会差不少。

相关新闻

SNL教学编译器实战:从环境搭建到AST生成与汇编输出

SNL教学编译器实战:从环境搭建到AST生成与汇编输出

简介:这是一份面向编译原理课程学习者与实践者的SNL语言编译器完整源码实现,适用于高校计算机专业高年级学生及对编译前端技术(词法、语法、语义分析)有深入实践需求的开发者。资源基于C/C开发,包含可直接编译运行的工…

2026/10/12 18:04:32 阅读更多 →
福昕高级PDF编辑器9.5补丁部署指南:版本识别、静默安装与批量维护

福昕高级PDF编辑器9.5补丁部署指南:版本识别、静默安装与批量维护

简介:这份资源是福昕高级PDF编辑器v9.5版本的补丁文件,面向正在使用该版本、希望修复已知问题或提升软件稳定性的办公用户与文档处理人员。补丁以单个docx文档形式打包,压缩包仅13KB,文档内提供了百度网盘下载链接与提取码&#x…

2026/10/12 18:03:31 阅读更多 →
NSL-KDD文本转图像:CNN用于网络入侵检测的特征空间映射方法

NSL-KDD文本转图像:CNN用于网络入侵检测的特征空间映射方法

简介:本资源是一套基于卷积神经网络(CNN)实现的网络入侵检测系统完整Python项目,面向计算机、信息安全、人工智能等专业的在校学生及初学者,适用于毕业设计、课程设计、期末大作业与项目入门实践。项目将NSL-KDD数据集…

2026/10/12 18:03:31 阅读更多 →

最新新闻

Pyre Code扩散模型专题:Noise Schedule、DDIM、Flow Matching与adaLN-Zero从零实现指南

Pyre Code扩散模型专题:Noise Schedule、DDIM、Flow Matching与adaLN-Zero从零实现指南

教育人工智能深度学习 【免费下载链接】pyre-code A self-hosted ML coding practice platform. 68 problems from ReLU to flow matching — attention, training, RLHF, diffusion, and more. Instant feedback in the browser. 项目地址: https://gitcode.com/gh…

2026/10/13 2:23:28 阅读更多 →
PSO优化SVR参数:从手动调参到智能搜索的工程实践

PSO优化SVR参数:从手动调参到智能搜索的工程实践

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

2026/10/13 2:23:28 阅读更多 →
ChatGPT之外,OpenAI还有DALL-E、Whisper和Codex:TaoToken统一Key接入多模型工具链

ChatGPT之外,OpenAI还有DALL-E、Whisper和Codex: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/13 2:23:28 阅读更多 →
微信小程序订餐系统毕设全攻略:从数据库设计到答辩

微信小程序订餐系统毕设全攻略:从数据库设计到答辩

去年带的一批学生里,有个选题占比高得惊人,十个人里四个都选了“基于微信小程序实现订餐管理系统”。说实话,我最初觉得这题目都快被人做烂了,网上随便一搜就是大把源码。可真等他们逐个动手、我一个个排查问题才发现,…

2026/10/13 2:23:28 阅读更多 →
ANSYS模态分析实现汉字形变:对称边界与刚度映射技术

ANSYS模态分析实现汉字形变:对称边界与刚度映射技术

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

2026/10/13 2:23:28 阅读更多 →
基于 Prometheus Operator 的 Kubernetes 集群监控栈部署与排错指南

基于 Prometheus Operator 的 Kubernetes 集群监控栈部署与排错指南

云原生教程 【免费下载链接】kubernetes-handbook Kubernetes Handbook (Kubernetes指南) https://kubernetes.feisky.xyz 项目地址: https://gitcode.com/gh_mirrors/kub/kubernetes-handbook 点击查看 免费下载 Kubernetes 社区围绕 Prome…

2026/10/13 2:22:28 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

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

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

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

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

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

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

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

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

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

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 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/12 21:05:09 阅读更多 →
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/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练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/12 18:11:17 阅读更多 →