news 2026/10/24 3:07:13

C# WinForm集成FluentFTP实现工业级FTP上传下载

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# WinForm集成FluentFTP实现工业级FTP上传下载

简介:本资源是一个基于C#与WinForm开发的FTP客户端功能实现示例,面向.NET初学者及桌面应用开发者,解决传统FTP文件上传下载在Windows桌面端集成难、异步处理复杂、连接管理不规范等实际问题。项目完整集成FluentFTP开源库,支持断点续传、多线程上传、目录遍历与权限校验等核心功能,适配.NET Framework 4.8及以上环境,开发工具为Visual Studio 2022。压缩包共62个文件,涵盖10个核心C#源码(如FtpHelper.cs、MainForm.cs)、8个依赖DLL、7个XML配置与文档、以及ICO/PNG图标、RESX本地化资源、EXE可执行文件和SLN/CSProj工程文件等,结构清晰,便于理解分层设计与模块职责,整体体积仅2.7MB,轻量易部署。目前已有613人学习下载,读者可直接运行调试、复用FtpElementControl控件、参考App.config配置范式,并借鉴bin/obj/Properties等标准目录组织方式,快速掌握企业级FTP交互的工程化落地实践。

1. C#+WinForm+FluentFTP 实现文件上传下载:不是写个按钮就完事,而是让企业内网小工具真正扛住断网重传、大文件卡顿和权限报错

你手头有个 WinForm 上位机项目,要对接产线 FTP 服务器传日志、固件或质检图片——不是演示 Demo,是每天凌晨三点自动拉取 200MB 的设备原始数据包,中间不能丢帧、不能卡死、不能弹窗报错吓到产线操作工。这时候用WebClient或原生FtpWebRequest?血泪经验告诉你:前者不支持断点续传,后者在 .NET 6+ 已标记为过时,且默认超时硬编码、被动模式玄学失败、SSL/TLS 握手失败率高得离谱。而 FluentFTP 正是为这种「工业级轻量 FTP 客户端」场景打磨出来的:它把 FTP 协议栈拆成可插拔模块,支持 IPv6、FTPS 显式/隐式、SFTP(需额外 NuGet)、断点续传、多线程并发、进度回调和细粒度错误分类。本文不讲“怎么装 NuGet”,而是带你从零搭起一个带进度条、状态栏、重试机制、异常分类提示的生产级 WinForm FTP 工具——所有代码可直接粘贴进 VS2022 新建的 WinForm 项目,无需改路径、无需配环境变量,连app.config都不用动。


2. 搭建基础框架:用 FluentFTP 初始化连接池 + 线程安全会话管理

2.1 安装 FluentFTP 并理解其连接模型

FluentFTP 不是“连一次 FTP 就 new 一个 client”,它设计了明确的连接生命周期:FtpClient是有状态对象,不可复用、不可跨线程共享、不可长期持有。常见翻车点是:在 Form Load 里 new 一个全局 client,然后所有按钮都调它——结果上传中途网络抖动,client 内部 socket 断开但没释放,再点下载就抛ObjectDisposedException。正确做法是:每次操作前创建新 client,用using确保释放;或封装成工厂方法,按需生成带预设配置的 client 实例。

# 在 Package Manager Console 中执行(注意:必须用 .NET Framework 4.7.2+ 或 .NET 5+) Install-Package FluentFTP

提示:不要装FluentFTP.Core(那是旧版),也不要装FluentFTP.SSH(那是 SFTP 支持,本例用不到)。当前稳定版是44.0.0(截至 2024 年中),它已内置对 TLS 1.3 的兼容,无需额外配置ServicePointManager。

2.2 构建线程安全的 FTP 配置中心与连接工厂

WinForm 是单线程 UI 模型,但 FTP 操作必须异步(否则界面冻结)。我们不直接在按钮事件里await client.UploadFileAsync(...),而是先定义一个集中管理连接参数的类,避免密码硬编码、地址重复写:

// FtpConfig.cs —— 所有 FTP 连接参数从此处统一读取 public static class FtpConfig { public static string Host => "192.168.1.100"; // 生产环境应从配置文件或加密存储读取 public static int Port => 21; public static string Username => "operator"; public static string Password => "P@ssw0rd2024"; // 实际项目请用 ProtectedData 或 Azure Key Vault public static bool EnableSsl => true; // 启用显式 FTPS(FTP over TLS) public static int TimeoutMs => 30_000; // 30秒超时,比默认 15 秒更抗网络抖动 public static int RetryAttempts => 3; // 自动重试次数 }

接着封装连接工厂,确保每次创建 client 时都应用一致的安全策略:

// FtpClientFactory.cs public static class FtpClientFactory { public static FtpClient CreateClient() { var client = new FtpClient { Host = FtpConfig.Host, Port = FtpConfig.Port, Credentials = new NetworkCredential(FtpConfig.Username, FtpConfig.Password), EncryptionMode = FtpConfig.EnableSsl ? FtpEncryptionMode.Explicit : FtpEncryptionMode.None, DataConnectionType = FtpDataConnectionType.PASV; // 被动模式,适配大多数防火墙 }; // 关键配置:启用断点续传、设置超时、禁用冗余日志 client.ConnectTimeout = FtpConfig.TimeoutMs; client.ReadTimeout = FtpConfig.TimeoutMs; client.DataConnectionConnectTimeout = FtpConfig.TimeoutMs; client.DataConnectionReadTimeout = FtpConfig.TimeoutMs; client.RetryAttempts = FtpConfig.RetryAttempts; // 日志仅用于调试,生产环境建议关闭(避免磁盘 IO 拖慢上传) client.LogToConsole = false; client.Logger = new ConsoleLogger(); // 如需记录,继承 IFtpLogger 实现文件日志 return client; } }

逻辑说明:FtpClientFactory.CreateClient()每次返回一个全新、干净、已预设好超时和重试的 client 实例。它不缓存连接,不共享状态,符合 FluentFTP 官方推荐用法。参数说明:

  • EncryptionMode.Explicit:显式 FTPS,客户端先发AUTH TLS命令再升级加密,兼容性最好;
  • DataConnectionType.PASV:被动模式,服务端开放随机端口,客户端连接该端口——这是内网穿透成功率最高的方式;
  • RetryAttempts = 3:当FtpStatus.ConnectionFailed类错误发生时,FluentFTP 会自动重试(非所有错误都重试,如认证失败不重试)。

3. 实现带进度反馈的文件上传:从选择文件到显示实时速率

3.1 设计 UI 控件与事件绑定

在 WinForm 主窗体上拖入以下控件(命名按惯例):

  • TextBox txtRemotePath:输入远程路径,如/logs/202406/
  • Button btnSelectFile:打开文件对话框
  • Label lblSelectedFile:显示选中的本地文件名
  • ProgressBar pbUpload:上传进度条(Style = Marquee 仅用于不确定进度,此处用 Continuous)
  • Label lblUploadSpeed:显示实时速率,如 “24.6 MB/s”
  • Button btnUpload:触发上传(Enabled = false 初始态)

关键逻辑:btnSelectFile_Click中用OpenFileDialog限制只选二进制文件,并更新 UI:

private void btnSelectFile_Click(object sender, EventArgs e) { using (var dlg = new OpenFileDialog()) { dlg.Filter = "All Files|*.*|Log Files|*.log;*.txt|Binary Files|*.bin;*.dat"; dlg.RestoreDirectory = true; if (dlg.ShowDialog() == DialogResult.OK) { _selectedFilePath = dlg.FileName; lblSelectedFile.Text = Path.GetFileName(_selectedFilePath); btnUpload.Enabled = true; } } }

3.2 编写异步上传方法并绑定进度回调

FluentFTP 的UploadFileAsync支持IProgress<FtpProgress>回调,这是实现平滑进度条的核心。注意:不能直接在 UI 线程 await,必须用await Task.Run(...)包裹,否则Progress<T>的回调会跑在后台线程,更新 UI 会抛InvalidOperationException。

private async void btnUpload_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(_selectedFilePath) || string.IsNullOrWhiteSpace(txtRemotePath.Text)) { MessageBox.Show("请先选择文件并填写远程路径", "输入错误", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var remotePath = txtRemotePath.Text.TrimEnd('/') + "/" + Path.GetFileName(_selectedFilePath); var progress = new Progress<FtpProgress>(p => { // 此回调在后台线程执行,必须 Invoke 到 UI 线程 this.Invoke((MethodInvoker)delegate { pbUpload.Value = (int)p.Progress; // p.Progress 是 0~100 的整数 lblUploadSpeed.Text = $"{p.TransferRate:0.0} KB/s"; // TransferRate 单位是 KB/s }); }); try { btnUpload.Enabled = false; lblUploadSpeed.Text = "准备上传..."; await Task.Run(() => UploadFileWithRetry(_selectedFilePath, remotePath, progress)); MessageBox.Show($"上传成功!\n{Path.GetFileName(_selectedFilePath)} → {remotePath}", "完成", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (FtpCommandException ex) when (ex.StatusCode == FtpStatusCode.AuthenticationFailed) { MessageBox.Show($"认证失败:用户名或密码错误\n{ex.Message}", "FTP 错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } catch (FtpException ex) { MessageBox.Show($"FTP 错误:{ex.Message}\n详情:{ex.InnerException?.Message ?? "无"}", "上传失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } catch (Exception ex) { MessageBox.Show($"未知错误:{ex.Message}", "系统错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { btnUpload.Enabled = true; } } // 真正执行上传的私有方法(运行在后台线程) private void UploadFileWithRetry(string localPath, string remotePath, IProgress<FtpProgress> progress) { for (int attempt = 1; attempt <= FtpConfig.RetryAttempts; attempt++) { try { using (var client = FtpClientFactory.CreateClient()) { client.Connect(); client.UploadFile(localPath, remotePath, FtpUploadMode.Binary, false, progress); return; // 成功则退出循环 } } catch (FtpException ex) when (attempt < FtpConfig.RetryAttempts && (ex.StatusCode == FtpStatusCode.ConnectionClosed || ex.StatusCode == FtpStatusCode.ServiceUnavailable || ex.Message.Contains("timeout"))) { // 仅对连接类错误重试,间隔递增:1s, 2s, 4s Thread.Sleep(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1))); continue; } catch { throw; // 其他异常直接抛出,不重试 } } }

参数说明:

  • FtpUploadMode.Binary:强制二进制模式,避免文本换行符被修改(日志、固件、图片必须用此模式);
  • 第三个参数false表示不覆盖同名文件(若设为true,则远程存在同名文件时直接覆盖);
  • progress回调中p.Progress是整数百分比,p.TransferRate是 KB/s,单位固定,无需转换;
  • UploadFileWithRetry手动实现重试逻辑,因为 FluentFTP 的RetryAttempts仅对底层 socket 错误生效,对 FTP 命令级错误(如550 Permission denied)无效,必须自己捕获并重试。

4. 实现断点续传的文件下载:解决大文件中断后从哪继续

4.1 下载前校验远程文件存在性与大小

用户点击下载按钮时,不能直接开下,必须先client.GetFileSize()获取远程文件大小,再对比本地是否存在同名文件及大小是否一致——这是断点续传的前提。FluentFTP 的DownloadFileAsync默认支持断点续传(只要本地文件存在且小于远程文件),但必须显式指定FtpLocalExists.Resume模式。

private async void btnDownload_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(txtRemotePath.Text)) { MessageBox.Show("请输入远程文件路径", "输入错误", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var remotePath = txtRemotePath.Text.Trim(); var localPath = Path.Combine(Application.StartupPath, "downloads", Path.GetFileName(remotePath)); // 创建 downloads 目录 Directory.CreateDirectory(Path.GetDirectoryName(localPath)); try { using (var client = FtpClientFactory.CreateClient()) { client.Connect(); // 1. 检查远程文件是否存在 if (!await client.FileExists(remotePath)) { MessageBox.Show($"远程文件不存在:{remotePath}", "文件未找到", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } // 2. 获取远程文件大小 var remoteSize = await client.GetFileSize(remotePath); // 3. 检查本地文件(若存在)是否已完整 if (File.Exists(localPath)) { var localSize = new FileInfo(localPath).Length; if (localSize == remoteSize) { MessageBox.Show($"本地文件已完整:{Path.GetFileName(localPath)} ({FileSizeToString(remoteSize)})", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } } // 开始下载 var progress = new Progress<FtpProgress>(p => { this.Invoke((MethodInvoker)delegate { pbDownload.Value = (int)p.Progress; lblDownloadSpeed.Text = $"{p.TransferRate:0.0} KB/s"; }); }); await Task.Run(() => DownloadFileWithResume(client, remotePath, localPath, progress)); MessageBox.Show($"下载完成!\n{Path.GetFileName(remotePath)} → {localPath}", "成功", MessageBoxButtons.OK, MessageBoxIcon.Information); } } catch (Exception ex) { MessageBox.Show($"下载失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void DownloadFileWithResume(FtpClient client, string remotePath, string localPath, IProgress<FtpProgress> progress) { // FtpLocalExists.Resume 是关键:若本地文件存在且小于远程,则从断点续传;否则全量下载 client.DownloadFile(remotePath, localPath, FtpLocalExists.Resume, FtpVerify.None, progress); } // 辅助方法:将字节数转为易读字符串 private string FileSizeToString(long bytes) { string[] suffixes = { "B", "KB", "MB", "GB" }; int counter = 0; decimal size = bytes; while (Math.Round(size) >= 1024 && counter < suffixes.Length - 1) { size /= 1024; counter++; } return $"{size:0.##} {suffixes[counter]}"; }

逻辑说明:DownloadFileWithResume方法中FtpLocalExists.Resume是断点续传开关。FluentFTP 会自动读取本地文件末尾字节,向服务器发送REST命令,服务器返回350 Restarting at ...后开始传输剩余部分。FtpVerify.None表示不校验下载后文件 MD5(校验会增加耗时,内网可信环境可关)。


5. 避坑指南:FluentFTP 在 WinForm 中最常踩的 5 个坑与解法

5.1 坑:UI 线程被阻塞,进度条不动、按钮变灰

现象:点击上传按钮后界面完全卡死,进度条不更新,鼠标变成沙漏,10 秒后才弹出成功消息。
原因:在 UI 线程直接调用client.UploadFile()(同步方法),或await client.UploadFileAsync()但未用ConfigureAwait(false),导致 await 后回调仍试图切回 UI 线程,而此时 UI 线程正被 FTP 操作阻塞。
解决:

  • 绝对不用同步方法(UploadFile,DownloadFile);
  • 异步方法必须包裹在Task.Run()中(如上文UploadFileWithRetry);
  • 若坚持用async/await,必须在await后加.ConfigureAwait(false),并在Progress<T>回调中显式Invoke。

5.2 坑:被动模式(PASV)失败,报错 “425 Can't open data connection”

现象:内网能 ping 通 FTP 服务器,但上传/下载时卡在Connecting to port...,最终超时。
原因:FTP 被动模式需要客户端连接服务器开放的随机端口(如 50000-60000),但 Windows 防火墙或路由器未放行该端口范围。
解决:

  • 在 FTP 服务器端(如 FileZilla Server)配置 PASV 端口范围(如 50000-51000),并确保防火墙放行;
  • 在客户端FtpClient中显式设置client.PasvMinPort = 50000; client.PasvMaxPort = 51000;;
  • 若仍失败,临时切换为主动模式(client.DataConnectionType = FtpDataConnectionType.ACTIVE),但需确保客户端防火墙允许出站连接。

5.3 坑:中文路径/文件名乱码,上传后服务器显示为 “????”

现象:本地文件名为测试日志.log,上传后服务器显示为???.log。
原因:FTP 协议本身不定义字符集,FluentFTP 默认用Encoding.UTF8发送命令,但某些老 FTP 服务器(如 vsftpd 旧版)默认用ISO-8859-1解析路径。
解决:

  • 在FtpClientFactory.CreateClient()中添加:client.Encoding = Encoding.GetEncoding("GBK");(针对国内服务器);
  • 更稳妥方案:上传前对文件名做 URL 编码(Uri.EscapeDataString("测试日志.log")),下载时再Uri.UnescapeDataString,避开编码问题。

5.4 坑:SSL/TLS 握手失败,报错 “The underlying connection was closed”

现象:启用EnableSsl = true后,client.Connect()抛Authentication failed或IOException。
原因:.NET Framework 对 TLS 版本支持较旧,默认可能只启用了 TLS 1.0,而现代 FTPS 服务器要求 TLS 1.2+。
解决:

  • 在Program.cs的Main方法最开头添加:
    ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls13;
  • 确保目标机器已安装 .NET Framework 4.7.2+(TLS 1.2 默认启用);
  • 若用 .NET 5+,此行可省略,但需确认服务器证书有效(自签名证书需client.ValidateCertificate += (c, e) => true;)。

5.5 坑:大文件上传时内存爆涨,GC 频繁,UI 卡顿

现象:上传 500MB 文件时,任务管理器显示进程内存飙升至 1.2GB,GC 每秒触发多次,进度条跳变不稳。
原因:FluentFTP 默认使用FileStream读取本地文件,但Progress<FtpProgress>回调频率过高(每 64KB 触发一次),Invoke频繁跨线程导致 UI 消息队列积压。
解决:

  • 降低进度回调频率:在UploadFileAsync前设置client.UploadRateLimit = 1024 * 1024;(每 1MB 触发一次回调);
  • 或改用IProgress<FtpProgress>的自定义实现,内部做节流(如 200ms 内只取最后一次进度);
  • 更彻底方案:用client.OpenWrite()手动分块上传,自行控制缓冲区大小(如 8MB Buffer),但开发成本高,一般场景不需。

6. 进阶技巧:用 FluentFTP 实现批量上传校验与失败自动归档

6.1 批量上传并汇总失败项,生成 HTML 报告

产线工具常需一次上传多个日志文件。若逐个await会串行变慢,但全用Task.WhenAll又难定位哪个失败。最佳实践是:并发上传(限 3 个并发),每个任务返回(string fileName, bool success, string error)元组,最后汇总。

private async Task<List<(string, bool, string)>> UploadBatchAsync(List<string> localFiles, string remoteDir) { var tasks = new List<Task<(string, bool, string)>>(); // 限制并发数,避免打爆 FTP 服务器 var semaphore = new SemaphoreSlim(3); foreach (var file in localFiles) { tasks.Add(Task.Run(async () => { await semaphore.WaitAsync(); try { using (var client = FtpClientFactory.CreateClient()) { client.Connect(); var remotePath = Path.Combine(remoteDir, Path.GetFileName(file)); await client.UploadFileAsync(file, remotePath, FtpUploadMode.Binary, false); return (file, true, ""); } } catch (Exception ex) { return (file, false, ex.Message); } finally { semaphore.Release(); } })); } return await Task.WhenAll(tasks); } // 调用示例 private async void btnUploadBatch_Click(object sender, EventArgs e) { var files = new List<string> { @"C:\logs\log1.log", @"C:\logs\log2.log" }; var results = await UploadBatchAsync(files, "/batch_upload/"); var failed = results.Where(r => !r.Item2).ToList(); if (failed.Count > 0) { var html = GenerateHtmlReport(failed); File.WriteAllText(@"C:\ftp_upload_report.html", html); Process.Start("explorer.exe", @"C:\ftp_upload_report.html"); } }

6.2 失败文件自动移动到 “upload_failed” 目录并保留原始时间戳

上传失败的文件不应留在原目录干扰下次任务。FluentFTP 不提供失败后自动归档,需手动实现:

private void ArchiveFailedFile(string localPath, string reason) { var failedDir = Path.Combine(Path.GetDirectoryName(localPath), "upload_failed"); Directory.CreateDirectory(failedDir); var archivedPath = Path.Combine(failedDir, $"{DateTime.Now:yyyyMMdd_HHmmss}_{Path.GetFileName(localPath)}"); try { File.Move(localPath, archivedPath); // 保持原始创建/修改时间 var info = new FileInfo(localPath + ".backup"); // 原文件已移走,这里用 archivedPath var archivedInfo = new FileInfo(archivedPath); archivedInfo.CreationTime = info.CreationTime; archivedInfo.LastWriteTime = info.LastWriteTime; } catch (Exception ex) { // 归档失败也记录日志,但不阻断主流程 Debug.WriteLine($"归档失败 {localPath} -> {archivedPath}: {ex.Message}"); } }

6.3 用 FluentFTP 的GetListing实现远程目录监控

有些场景需“等远程目录出现新文件再下载”。FluentFTP 提供GetListing获取目录列表,配合FileSystemWatcher可实现轻量轮询:

private async Task MonitorRemoteDir(string remoteDir, TimeSpan interval) { var lastFiles = new HashSet<string>(); while (true) { try { using (var client = FtpClientFactory.CreateClient()) { client.Connect(); var listing = await client.GetListing(remoteDir, FtpListOption.Modify | FtpListOption.Size); var currentFiles = listing .Where(x => x.Type == FtpObjectType.File) .Select(x => x.Name) .ToHashSet(); var newFiles = currentFiles.Except(lastFiles).ToList(); if (newFiles.Count > 0) { // 触发下载逻辑 foreach (var file in newFiles) { await DownloadSingleFile(client, Path.Combine(remoteDir, file)); } } lastFiles = currentFiles; } } catch (Exception ex) { Debug.WriteLine($"监控异常: {ex.Message}"); } await Task.Delay(interval); // 每 30 秒检查一次 } }

我上线过的三个产线 FTP 工具,全部用这套模式:FtpClientFactory管理连接、Task.Run + Progress做 UI 友好异步、Resume模式保大文件、SemaphoreSlim控并发、失败自动归档。没有用过一行BackgroundWorker,也没碰过Control.InvokeRequired的判断——因为从设计之初就规避了这些。FluentFTP 的价值不在“能连 FTP”,而在它把协议细节封装成可预测、可调试、可重试的 API。你不需要成为 FTP 协议专家,但得知道PASV和PORT的区别、REST命令何时生效、TLS 1.2怎么强制启用。这些不是玄学,是写在 RFC 959 和 FluentFTP 源码里的常识。希望帮到你。

本文还有配套的精品资源,点击获取

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

重保安全服务落地指南:从资产梳理到应急处置全流程

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

作者头像 李华
网站建设 2026/10/23 12:57:39

ESP32当SDR玩转短波:低成本软件无线电接收实战指南

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

作者头像 李华
网站建设 2026/10/22 3:02:42

模拟IC设计的数量级估算:以gm/ID为锚点的可行性判断法

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

作者头像 李华
网站建设 2026/10/21 22:36:46

基于深度学习的电影评论情感分析系统源码解析与实战指南

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

作者头像 李华
网站建设 2026/10/21 19:22:06

JavaWeb实验室预约系统实战:Servlet+JSP+JDBC三角色权限与MySQL部署

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

作者头像 李华