news 2026/9/24 3:18:52

Java动态表头Excel导出:告别硬编码,灵活应对需求变更

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java动态表头Excel导出:告别硬编码,灵活应对需求变更

上周运营提了一个新需求,说报表导出的时候要能自己勾选导出哪些列,不同角色看的列还不一样。这已经不是第一次为了导出表头改代码了,以前每次字段调整都要去改实体类上的@ExcelProperty注解,加一个字段、改一个列名都得出一次版本。这次我直接把导出工具类重构了一版,核心思路就一句话:表头不再写死在注解里,而是运行时动态传入。做完之后发现这个改动带来的一连串收益远超预期,今天把整个设计和实现过程完整记录一下。

这篇内容主要面向做 Java 后端、经常要和 Excel 导出打交道的朋友。如果你还在用硬编码表头的方式写导出,每次需求变更都要发版,那这篇文章正好能解决你的问题。我会从头到尾讲清楚为什么表头要动态化、工具类怎么设计、核心代码怎么落地,以及我在实现过程中踩过的各种坑。

1. 为什么表头非要"动态"不可:从一次改到烦的需求说起

1.1 运营一句话,后端改半天

做后台系统的同学应该都有这种经历。运营同学跑过来说:"这个用户列表的导出,能不能把'最后登录时间'放在'手机号'前面?"你看了看代码,实体类上的@ExcelProperty(value = "手机号")@ExcelProperty(value = "最后登录时间"),顺序写死了,得调注解顺序重新发版。好不容易上线了,运营又说:"现在不看手机号了,帮我换成'注册来源'吧。"你又得改注解、提测、发版。一次两次还行,一个月来五次,谁受得了?

后来运营又提了一个更复杂的需求:不同的人打开导出页面,看到的列不一样。管理员要看手机号,普通运营不能看手机号,只让看脱敏后的用户名和注册时间。这种需求靠注解就真的没法实现了,因为同一个实体类不可能同时对应多套表头结构。你得写两个实体类,各写一套注解,加一个场景再加一个类。

1.2 注解写死表头的三个核心痛点

我把用注解方式做导出的痛点梳理了一下,归根结底是这三条。

第一,表头变更必须改代码发版。这是最直接的痛。表头本质上是一个需求层面的东西,业务方改表头跟改页面上的文案一样频繁,把这种高频变化的东西硬编码进代码里,等于每次变更都要走一遍完整的开发流程。

第二,一个实体类只能绑一套表头。你想让同一个查询结果导出成两种不同列组合的 Excel,用注解就非常别扭。要么写两个实体类,要么导出前先做一遍字段映射,把实体转成另一个结构,非常繁琐。

第三,复杂表头实现成本高。像"用户信息"这个大分类下面挂"姓名"和"年龄"两个子列这种多层合并表头,用注解虽然也能做,但写起来很绕,需要额外定义一堆内部类。等你想支持运行时用户自己选列,注解这套玩法就彻底走不通了。

1.3 动态表头解决的其实是"响应变化"的问题

动态表头这个词听起来挺玄乎,本质上就是把"表头长什么样"这个决策时机从"编译期"推迟到"运行时"。你不再告诉代码"表头永远是这三列",而是让调用方在调用导出方法的时候,把想要的表头结构传进来。表头想变?调用方改一下传参就行,导出工具类一行代码都不用改。

这个思路和依赖注入其实是一个道理:把变化的部分从稳定的部分中剥离出来。稳定的部分是"我怎么生成一个 Excel 文件",变化的部分是"表头长什么样、数据有哪些列"。工具类负责处理稳定的部分,动态表头负责承载变化的部分。这样一来,任何表头层面的调整都只是调用方的参数变化,不会再波及导出的底层代码。

2. 需求边界:一个"动态表头导出工具类"到底要做到什么程度

2.1 先列清楚功能需求清单

动手写代码之前,我先把需求边界划清楚。如果只说"支持动态修改表头"很容易做成一个四不像,所以我把工具类需要支持的能力列了个清单。

  • 表头列表由调用方传入,不依赖实体类上的注解,想导哪些列、列名叫什么,全部由参数决定。
  • 支持多层合并表头,即一个父列下挂多个子列,这是实际业务里最常用的复杂表头形态。
  • 数据行和表头列顺序严格对应,调用方按表头顺序逐列给数据。
  • 支持大数据量分批写入,不能因为导出 10 万行数据就把堆内存打满。
  • 不绑定具体业务模型,任何业务都能复用同一个工具类。

有了这份清单,工具类的定位就清楚了:它不是一个"用户导出工具",也不是"订单导出工具",而是一个通用的"Excel 导出底座"。

2.2 技术选型:为什么选 EasyExcel 而不是原生 POI

这个工具类底层我用的是阿里开源的 EasyExcel。没有从零基于 POI 手写,原因很现实:EasyExcel 在 POI 之上封装了很多易用性和内存优化,尤其是SAX模式逐行读写的设计,能显著降低大数据量导出时内存的占用,这在后文踩坑部分我会详细对比数据。

易用性上,EasyExcel 天然支持把表头定义成List<List<String>>传入,这正好是动态表头需要的形态。原生 POI 要自己创建 Row 和 Cell 去拼表头代码量会翻一倍。

我整理了一个简单对比,当时就是基于这个对比锁定的 EasyExcel:

对比项原生 POIEasyExcel
表头动态化便利度需要手动操作 Cell直接传入 List 结构,开箱即用
5 万行数据的粗略内存占用较高,容易 OOM明显更低,实测比较稳
API 易用性代码较多,心智负担重封装度高,配置清晰
社区活跃度稳定但更新慢国内企业使用广泛,资料多

2.3 动态表头模式的边界条件

这里有一点必须先说清楚:动态表头模式下,注解自动映射的能力就废了。你用@ExcelProperty绑定实体类字段时,EasyExcel 会自动把对象属性和列对应起来;但一旦表头改成运行时传入的List<List<String>>,数据部分就必须配套改成List<List<Object>>——外层 List 代表一行记录,内层 List 按表头顺序存放这一行的每个单元格的值。顺序一旦错位,数据就会填到错误的列下面,这在第 5 部分踩坑里我会详细展开。

另外,动态表头模式下,默认的单元格样式会变得比较原始,比如表头没有加粗、内容不做居中。要处理这个问题需要额外的样式 Handler 或者策略类,并不会因为用了 EasyExcel 就自动带过来。

3. 整体设计:先画清楚主流程再写代码

3.1 工具类的核心结构

整个工具类的设计我分成三个部分:一个对外暴露的静态方法入口ExcelExportUtils、一个承载表头结构的元数据模型、以及底层的写入执行流程。没有引入 Spring 依赖,纯 JDK + EasyExcel,这样不管你是 Spring Boot 项目还是普通 Java 项目都能直接用。

对外入口我设计成两个重载方法。第一个方法接收完整的数据集合,适合中小数据量场景,一次性把表头和数据传给工具类;第二个方法接收一个"分批获取数据"的函数式接口,适合大数据量场景,工具类内部按页调用这个接口拿数据并批量写入,避免一次性把全量数据加载进内存。

3.2 表头数据结构怎么定

动态表头的核心是表头的数据结构。我选择了List<List<String>>,这个结构一开始可能有点绕,我举个例子。

假设我要生成一个这样的表头:

| 序号 | 用户信息 | 注册信息 | | | 姓名 | 年龄 | 注册时间 |

对应到List<List<String>>就是:

List<List<String>> headList = new ArrayList<>(); // 第一列:只有一层,占两行 headList.add(Arrays.asList("序号")); // 第二列:父列"用户信息",子列"姓名" headList.add(Arrays.asList("用户信息", "姓名")); // 第三列:父列"用户信息",子列"年龄" headList.add(Arrays.asList("用户信息", "年龄")); // 第四列:父列"注册信息",子列"注册时间" headList.add(Arrays.asList("注册信息", "注册时间"));

内层 List 的长度代表这个列跨几层,同一父列下的子列 EasyExcel 会自动帮你合并单元格。这个数据结构是 EasyExcel 官方支持的动态表头写法,理解起来也不难:外层一个元素是一整列,内层的字符串从上到下就是这一列从父级到子级的表头内容。

3.3 导出主流程

工具类内部的主流程其实很简洁。第一步,根据文件名和响应对象初始化ExcelWriter;第二步,用传入的headList构建WriteSheet;第三步,写入数据;第四步,调用finish方法把缓冲区的内容刷到输出流并释放资源。

整个过程最难的不是代码,而是调参和防坑。在第 4 部分我会先给出完整可用的实现代码,第 5 部分再集中讲我踩过的坑,这样你在使用的时候遇到问题可以回头对照。

4. 动态表头的核心实现:直接给可落地的工具类代码

4.1 基础版本:一次性传入表头和数据

先给一个最直接的版本,适用于大多数中小数据量的导出场景。

package com.example.excel; import com.alibaba.excel.EasyExcel; import com.alibaba.excel.ExcelWriter; import com.alibaba.excel.write.metadata.WriteSheet; import com.alibaba.excel.write.style.HorizontalCellStyleStrategy; import org.apache.poi.ss.usermodel.HorizontalAlignment; import org.apache.poi.ss.usermodel.IndexedColors; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; /** * Excel 导出工具类,核心特性:动态表头。 * 表头结构由调用方传入 List<List<String>>,不绑定任何实体类注解。 */ public final class ExcelExportUtils { private ExcelExportUtils() { } /** * 导出 Excel(一次性写入全部数据,适合中小数据量)。 * * @param response HTTP 响应对象 * @param fileName 导出文件名,不需要带 .xlsx 后缀 * @param sheetName Sheet 名称 * @param headList 动态表头,外层 List 是一列,内层 List 从父级到子级排列 * @param dataList 数据行,每行 List 的元素顺序必须与 headList 的列顺序一致 */ public static void exportWithData(HttpServletResponse response, String fileName, String sheetName, List<List<String>> headList, List<List<Object>> dataList) throws IOException { // 1. 设置 HTTP 响应头,让浏览器识别为下载 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()) .replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + encodedFileName); // 2. 构建样式策略 HorizontalCellStyleStrategy styleStrategy = buildDefaultStyleStrategy(); // 3. 写入并释放资源 ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).build(); WriteSheet writeSheet = EasyExcel.writerSheet(sheetName).head(headList).build(); excelWriter.write(dataList, writeSheet); excelWriter.finish(); } /** * 构建默认样式:表头灰底加粗居中,内容居中。 */ private static HorizontalCellStyleStrategy buildDefaultStyleStrategy() { // 表头样式 WriteCellStyle headStyle = new WriteCellStyle(); headStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); WriteFont headFont = new WriteFont(); headFont.setBold(true); headFont.setFontHeightInPoints((short) 11); headStyle.setWriteFont(headFont); // 数据行样式 WriteCellStyle contentStyle = new WriteCellStyle(); contentStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); return new HorizontalCellStyleStrategy(headStyle, contentStyle); } }

这里有几个细节需要说明一下。响应头里的filename*=utf-8''是解决中文文件名乱码的标准写法,我一开始直接用filename=加 URL 编码后的文件名,发现部分浏览器会把文件名里的%E6%9F%90这种字符直接显示出来,用filename*配合URLEncoder编码后就正常了。另外,ExcelWriterfinish()方法一定要在 finally 或者正常流程末尾调用,它会触发流资源的释放,漏掉的话会导致导出的文件损坏、打不开。

4.2 大数据量版本:分批拉取,边查边写

一次性传入所有数据虽然简单,但假如你要导出的是几十万行用户数据,把所有数据先查出来放到内存里再传给工具类,内存很容易扛不住。EasyExcel 的优势在于它可以分批写入,每批写完就释放,内存压力很小。

我设计了一个函数式接口DataPageProvider,由调用方实现分批查询逻辑:

@FunctionalInterface public interface DataPageProvider { /** * 获取一页数据。 * * @param pageNum 页码,从 1 开始 * @param pageSize 每页行数 * @return 当前页数据列表;如果没有更多数据,返回 null 或空集合 */ List<List<Object>> fetch(int pageNum, int pageSize); }

然后增加一个分批导出的重载方法:

public static void exportByPage(HttpServletResponse response, String fileName, String sheetName, List<List<String>> headList, int pageSize, DataPageProvider dataPageProvider) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()) .replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + encodedFileName); HorizontalCellStyleStrategy styleStrategy = buildDefaultStyleStrategy(); ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()) .registerWriteHandler(styleStrategy) .build(); WriteSheet writeSheet = EasyExcel.writerSheet(sheetName).head(headList).build(); int pageNum = 1; while (true) { List<List<Object>> pageData = dataPageProvider.fetch(pageNum, pageSize); if (pageData == null || pageData.isEmpty()) { break; } excelWriter.write(pageData, writeSheet); pageNum++; // 每批数据用完即可被 GC,内存只保留当前批的数据 } excelWriter.finish(); }

这样调用方就可以用 lambda 把 MyBatis-Plus 的分页查询逻辑传进来,比如:

ExcelExportUtils.exportByPage(response, "用户导出", "用户", headList, 5000, (pageNum, pageSize) -> { Page<UserDO> page = userMapper.selectPage(new Page<>(pageNum, pageSize), null); if (page.getRecords().isEmpty()) { return null; } return page.getRecords().stream() .map(user -> Arrays.asList( user.getId(), user.getName(), user.getPhone() )) .collect(Collectors.toList()); });

这种方式下,无论导出的数据量多大,内存里同一时刻只保留一个 pageSize 批次的数据,极大的降低了 OOM 概率。

4.3 一个复杂的动态表头实际调用示例

为了让你对"动态表头"有一个直观的概念,我放一个真实场景的调用代码。假设运营后台需要导出一份带合并表头的用户分析表:

List<List<String>> headList = new ArrayList<>(); headList.add(Collections.singletonList("序号")); headList.add(Arrays.asList("用户信息", "姓名")); headList.add(Arrays.asList("用户信息", "年龄")); headList.add(Arrays.asList("用户信息", "手机号")); headList.add(Arrays.asList("行为数据", "最近登录时间")); headList.add(Arrays.asList("行为数据", "订单数")); List<List<Object>> dataList = new ArrayList<>(); dataList.add(Arrays.asList(1, "张三", 28, "138****1234", "2025-01-15 10:24:00", 12)); dataList.add(Arrays.asList(2, "李四", 32, "139****5678", "2025-01-14 22:10:00", 5)); ExcelExportUtils.exportWithData(response, "用户分析报表", "用户分析", headList, dataList);

这段代码导出的 Excel 里,"用户信息"和"行为数据"两个父列会自动跨行合并,效果和你在 Excel 里手动合并单元格是一样的。运营再提"我要加一列'注册来源'"这个需求时,你只需要在 headList 和数据列表里各加一个元素,工具类一行都不用改。

5. 踩坑记录:动态表头远没有看起来那么简单

5.1 坑一:动态表头下样式和列宽全部失效

第一次跑通动态表头导出时,导出的文件确实能打开,但表头灰白的底色没了,字体也不加粗了,数据全部挤在左侧,列宽也是默认宽度,列名都显示不全。原因是:当你通过head(List<List<String>>)传入表头时,EasyExcel 不会自动套用基于注解设计的样式策略。

解决方式就是我工具类里那套HorizontalCellStyleStrategy,但这里有一个容易忽略的细节:在分批导出版本里,一定要通过excelWriterregisterWriteHandler(styleStrategy)来注册样式,而不是在writerSheet之后才传。我之前在测试时把样式策略放在writerSheet(...).build()后面传入,结果数据行有样式、表头没有样式,排查了半天才意识到注册顺序会影响样式作用范围。

列宽的坑也值得一提。如果你不对列宽做任何处理,动态表头模式下所有列宽都是默认值,中文表头几乎一定显示不全。最简单的方案是给 excelWriter 注册一个LongestMatchColumnWidthStyleStrategy,它会让列宽自动匹配本列最长的内容(包括表头和数据)。但这个策略在数据量大的时候会遍历每一行的内容来计算宽度,性能有损耗。如果数据量特别大,建议手动按业务场景指定列宽,而不是用自适应策略。

5.2 坑二:表头和数据列顺序错位,数据填到错误的列

动态表头模式下最容易踩的坑就是这个:headList 的顺序和 dataList 内层 List 的顺序必须完全一致,否则数据会错位。这不是工具类能帮你兜底的,因为工具类根本不知道你的业务字段叫什么,它只认顺序。

我自己的排查经验是,先在调用方写一个简单的断言方法:确认 headList 的 size 和 dataList 每一行的 size 相等,如果不相等直接抛异常,不要等导出完才发现。耗时不多但能避免大量低级错误。如果用到分批导出,还需要注意每一批数据行的列数都要一致,否则后续的行会发生错位。

另外,DataPageProvider 分批查询时,每一页返回的数据行数不能超过 pageSize,否则会出现写入乱序的问题。这个在实现查询函数时稍微注意一下就行,分页查询框架一般不会出现这种问题,但如果是自己写 SQL 的就要多留个心眼。

5.3 坑三:单元格内容以 = 或 @ 开头被 Excel 当成公式

导出用户备注时,如果用户备注是"=逾期用户"或者"@所有人",Excel 打开文件的时候会把"=逾期用户"解析成公式,导致单元格报错或显示异常。这个问题不是 EasyExcel 特有的,用 POI 也一样会遇到,属于 Excel 本身的安全机制。

解决方式是在写入数据前对单元格内容做一次前导字符转义。做一个简单的字符串处理:

private static Object safeCellValue(Object value) { if (value instanceof String) { String str = (String) value; if (str.startsWith("=") || str.startsWith("@") || str.startsWith("+")) { return "'" + str; } } return value; }

注意这个方法只对字符串类型做处理,数字、日期等类型直接原样返回。我在工具类的 write 环节之前统一对数据行做一遍清洗,和表头校验放在一起。这个小函数帮我避免了好几次线上数据问题。

5.4 坑四:ExcelWriter 的 finish() 漏调用导致文件损坏

实际生产环境里,如果导出过程中业务代码抛了异常,比如数据库查询超时,ExcelWriter 的输出流没有被正确关闭,用户下载到的就是一个损坏的文件。有人会想"我用 try-with-resources 不就行了",但 ExcelWriter 实现了 Closeable 接口,可以放进 try-with-resources 里,不过 EasyExcel 更推荐在 finally 里调用 finish(),因为 finish() 不仅仅是关闭流,还会做一些收尾工作。

以我的经验,最稳妥的写法是这样:

ExcelWriter excelWriter = null; try { excelWriter = EasyExcel.write(outputStream).build(); // ... 写入逻辑 } finally { if (excelWriter != null) { excelWriter.finish(); } }

如果是在 Web 导出场景,异常情况下还需要在 finally 里调用 response.reset(),防止已经写出去的部分字节导致浏览器解析错误。这个细节我可以说是踩了三次才长记性。

5.5 坑五:动态表头模式下日期格式显示成一串数字

如果你在数据列表里传的是Date类型,默认情况下 EasyExcel 会按内置格式输出,但如果传的是 LocalDateTime 或者时间戳字符串,显示效果就不可控。我在工具类里没有做强制转换,因为要求调用方自己控制数据格式。但为了统一体验,我在实际项目中会让调用方把时间类数据先格式化成字符串,这样最简单可控,也不容易踩"明明传了时间却显示成数字"的坑。

6. 进阶玩法:从"动态表头"走向"表头配置化"

6.1 表头定义搬进 JSON 或 YAML 配置

动态表头解决了"不用改代码改表头"的问题,但如果你还是把 headList 的构建逻辑写死在调用方代码里,本质上也只是把"改注解"变成了"改 Java 代码"。要让表头变更彻底脱离开发,得把表头定义搬到配置文件中。

比如在 resources 目录下维护一个export-head.json,内容大致是:

{ "columns": [ { "title": ["用户信息", "姓名"], "dataIndex": "name" }, { "title": ["用户信息", "年龄"], "dataIndex": "age" }, { "title": ["行为数据", "订单数"], "dataIndex": "orderCount" } ] }

运行时通过 Jackson 把这个文件读成配置对象,再转换成 EasyExcel 需要的List<List<String>>。数据部分也需要配套改造:从"写死每列的值"变成"根据 dataIndex 从 Map 或对象里取对应字段的值",这一步可以结合反射或直接把查询结果转成 Map 来做。

这样做的好处非常明显:运营想加一列,只要告诉运维改一下 JSON 文件,重启都不需要(配合配置中心)就能生效。我见过很多大厂的报表系统就是这么做的——前端配置列,后端按配置导出,两边共用同一套元数据。

6.2 表头存数据库,做成可维护的元数据

比配置文件更进一步的做法是把表头定义存进数据库,做成一张"导出模板表",字段大概包含模板编码、列序号、父表头名称、子表头名称、关联的数据字段名、是否启用等。运营或者管理员可以在后台页面维护这张表,增删列、调整顺序都走可视化界面,后端导出时根据模板编码查出表头定义,再查数据填充。

这种方式配合动态表头工具类非常顺手,因为工具类已经没有"固定结构"了,天然适配"从数据库查出什么表头就导出什么表头"的模式。唯一要注意的是缓存问题:表头配置不要每次都查数据库,可以加一层本地缓存,配置变更时再刷新缓存。我实现的时候用的是 Caffeine 本地缓存,热点数据基本无感知。

6.3 多模板管理:同一份数据多个导出视角

表头配置化之后,很自然就会进化出多模板管理的能力。比如用户数据导出这个场景,可以设计"用户基础信息模板"、"用户行为分析模板"、"运营安全审计模板"三个模板,它们对应不同的列组合、不同的表头层级。

后端导出接口只要多接收一个"模板编码"参数,工具类统一路由到对应模板查配置、取数据、导出。这样做还有一个额外的收益:权限控制变得简单了。管理员模板可以看到手机号和身份证号,普通运营模板看不到,权限校验只需要在返回模板配置之前做一次判断,前端导出按钮的可见列随之变化。整个体系扩展起来非常顺畅。

从我个人的使用体验来看,把表头做成配置、把导出工具类做成通用底座,这个方向是对的。它不仅解决了当时运营提的"选择性导出"需求,还让后续几乎所有报表导出需求变成了配置工作而不是编码工作。

最后再分享一个建议:如果你也在做类似的重构,不用一步到位就把配置中心、数据库模板全上了。先把工具类的动态表头能力做好,保证它可以通过参数控制表头结构,后面再逐步把配置来源从硬编码升级到 JSON、再到数据库,每一步都可以独立上线,风险可控。这套工具类在我这边已经稳定跑了半年多,中间经历了多轮表头调整,一行导出代码都没改过,这也是我特别想把这段经验记录下来的原因。

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

StarRocks 全面支持 Paimon 2.0:构建多模态统一分析与检索

作者&#xff1a;范振&#xff0c;StarRocks TSC Member&#xff1b;阿里云开源 OLAP 负责人StarRocks 对 Lakehouse 的投入已经持续多年。自 2023 年提出“From OLAP to Lakehouse”技术路线以来&#xff0c;社区持续完善对 Delta Lake、Iceberg、Paimon 等开放湖表的支持。数…

作者头像 李华
网站建设 2026/9/24 3:00:01

基于TinyUSB的STM32 U盘实现:从RAM Disk到SPI Flash完整教程

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

作者头像 李华