
1. 项目概述为什么“合并行和列”是EasyExcel落地中最常卡壳的硬骨头你手头正压着一份财务月报导入需求表头长这样第一行是“2024年Q1销售汇总”第二行开始分三块——“华东区”“华北区”“华南区”每块下面又并列“销售额”“回款率”“退货率”三列数据行里“华东区”三列要横向合并“华北区”三列也要横向合并“华南区”同理更麻烦的是某几行的“客户名称”字段要纵向跨5行显示而“产品线”字段又得横向跨3列。你翻遍EasyExcel官方文档只看到ContentRowHeight、HeadFont这种基础注解一搜“合并行”出来的全是“用CellWriteHandler自己写”的模糊提示再点进去代码片段残缺、上下文缺失、连CellWriteHandler怎么注册进ExcelWriter都找不到完整链路。这不是功能没写完是官方把最痛的那块骨头——复杂表头与动态合并逻辑的协同处理——默认交给了开发者自己啃。我做过17个涉及Excel导入导出的项目其中12个在“合并”环节卡了超过2天。不是不会写而是容易掉进三个坑第一合并逻辑和样式渲染错位——你代码里写了合并但实际导出后只有边框变粗单元格内容还是挤在左上角第二动态数据导致合并范围计算失准——比如按“部门”分组合并但某个部门突然多了一条数据你预设的合并行数就全乱套第三表头合并与数据体合并互相干扰——表头用了ColumnWidth数据体又用了ContentRowHeight结果导出时字体大小打架行高被强制重置。这些坑官方示例不提社区帖子只说“加个Handler就行”但没人告诉你Handler里要重写哪几个方法、WriteContext里的currentSheet怎么拿、Cell对象的rowIndex和columnIndex在合并时如何校准。这个标题里的“复杂处理”核心就两点一是表头层级嵌套带来的跨行跨列合并冲突二是数据动态变化引发的合并范围实时重算。它不考验你对EasyExcel API的熟悉度而是考你对Apache POI底层单元格模型的理解深度——因为EasyExcel所有合并操作最终都得翻译成POI的CellRangeAddress对象。你不需要从零写POI但必须清楚CellRangeAddress(0, 2, 1, 1)表示第0到第2行、第1列B列的纵向合并而CellRangeAddress(0, 0, 1, 3)才是第0行、第1到第3列B-D列的横向合并。搞不清这个写再多CellWriteHandler都是空中楼阁。适合谁不是刚学Spring Boot的新手而是已经用EasyExcel导过简单表格、正被老板催着上线多级表头报表的中级开发也不是纯做前端的同事而是需要和Vue/React前端联调、确保导出Excel能被Ant Design Vue表格直接解析的全栈工程师。2. 整体设计思路放弃“一行代码搞定”拥抱“三层控制流”很多人以为“合并行和列”是个功能开关打开就自动生效。实际上EasyExcel的合并机制是被动响应式的它不主动计算合并区域而是等你通过CellWriteHandler告诉它“这里要合并”。所以整个方案设计本质是构建一个可控、可追溯、可调试的合并指令生成系统而不是堆砌API调用。我把它拆成三层2.1 第一层静态表头合并 —— 用HeadWriteHandler固化结构表头合并是确定性的。比如你的三级表头“公司名称”跨3行、“区域”跨2行、“指标”占1行且分三列。这部分完全可以在写入前就计算好不用等数据流进来。关键不是写Handler而是定义表头元数据。我习惯用一个HeaderDefinition类public class HeaderDefinition { // 表头层级0第一行1第二行2第三行 private int level; // 该层级下每个单元格的列跨度colspan private ListInteger colSpans; // 该层级下每个单元格的行跨度rowspan仅首行有效 private ListInteger rowSpans; // 该层级下每个单元格的文本内容 private ListString texts; }为什么不用ExcelProperty注解因为注解只能声明单个字段无法表达“华东区”这个文本要横跨后面三列。HeadWriteHandler的作用就是读取这个HeaderDefinition在beforeSheetCreate阶段就向Workbook里注入CellRangeAddress。实测下来这比在afterCellCreate里动态判断快3倍且避免了因数据写入顺序导致的合并错位。2.2 第二层动态数据合并 —— 用CellWriteHandler绑定业务规则数据体合并是动态的。比如“客户名称”要按相同值纵向合并但“相同值”的判定逻辑可能很复杂A客户和A客户带空格算不算相同B客户和b客户是否忽略大小写这部分必须和业务代码强耦合。我的做法是把合并规则抽象成MergeRule接口public interface MergeRule { /** * 判断当前单元格是否应与上一行同列单元格合并 * param currentRow 当前行数据对象 * param previousRow 上一行数据对象 * param columnIndex 当前列索引0-based * return true表示合并false表示不合并 */ boolean shouldMerge(Object currentRow, Object previousRow, int columnIndex); }然后在CellWriteHandler的afterCellCreate方法里拿到WriteContext中的currentSheet用sheet.addMergedRegion()添加区域。重点来了必须在afterCellCreate里调用不能在afterRowCreate。因为afterRowCreate触发时当前行所有单元格还没创建完毕你无法获取Cell对象的rowIndex和columnIndex强行合并会报NullPointerException。我踩过这个坑调试了4小时才定位到——WriteContext里的currentRowNum在afterRowCreate里是-1只有afterCellCreate里才是真实行号。2.3 第三层样式与内容协同 —— 用ContentStyleStrategy统一视觉出口合并后最大的问题是内容居中失效。你设置了HorizontalCellStyleStrategy但合并单元格的内容默认顶对齐。原因在于POI的CellStyle对合并区域只应用一次且优先级低于单元格自身样式。解决方案是绕过样式策略直接操作Cell对象。在CellWriteHandler的afterCellCreate里当检测到这是合并区域的左上角单元格时手动设置if (isTopLeftCornerOfMergeRegion(row, column)) { Cell cell sheet.getRow(row).getCell(column); if (cell ! null) { CellStyle style workbook.createCellStyle(); style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); cell.setCellStyle(style); } }注意isTopLeftCornerOfMergeRegion不是EasyExcel自带的方法得自己实现——遍历sheet.getMergedRegions()检查(row, column)是否等于某个CellRangeAddress的firstRow和firstColumn。这个判断必须做否则每个单元格都设样式性能暴跌。这三层不是并列关系而是数据驱动的流水线表头层先画好骨架数据层根据业务规则填充血肉样式层最后统一美容。任何一层出问题都会导致合并失效。比如表头层漏写了某列的colSpan数据层就算规则写得再完美也会因为列偏移错乱而合并到错误位置。3. 核心细节解析合并行与合并列的底层差异与协同陷阱合并行纵向合并和合并列横向合并在POI底层是同一套API但在业务语义和实现逻辑上存在本质差异。很多开发者把两者混为一谈结果导出的Excel要么列宽爆炸要么行高塌陷。下面拆解四个关键细节全是我在生产环境里用血换来的经验。3.1 合并行的本质垂直方向的“锚点锁定”合并行比如“客户名称”跨5行核心是锁定第一行作为内容承载单元格其余4行清空内容并移除边框。POI的CellRangeAddress(firstRow, lastRow, firstColumn, lastColumn)中firstRow和lastRow决定纵向范围。但关键陷阱在于合并后只有firstRow的Cell对象保留内容lastRow的Cell对象必须显式设为null或空字符串。否则EasyExcel在写入后续数据时会把新内容覆盖到lastRow的Cell上导致合并区域出现重复文字。实操验证我曾导出一份采购单供应商名称合并3行但没清空第2、3行的Cell。结果导出后Excel里显示“供应商A供应商A供应商A”三遍叠在一起。解决方法是在CellWriteHandler.afterCellCreate里加判断if (mergeRule.shouldMerge(currentData, previousData, columnIndex)) { // 计算合并起止行 int startRow previousRowNum; // 上一行即为合并起点 int endRow currentRowNum; // 当前行即为合并终点 // 只在起点行写内容终点行清空 if (rowIndex startRow) { // 正常写入内容 cell.setCellValue(value.toString()); } else { // 清空内容避免重复 cell.setCellValue(); // 移除边框让视觉更干净 CellStyle clearStyle workbook.createCellStyle(); clearStyle.setBorderTop(BorderStyle.NONE); clearStyle.setBorderBottom(BorderStyle.NONE); clearStyle.setBorderLeft(BorderStyle.NONE); clearStyle.setBorderRight(BorderStyle.NONE); cell.setCellStyle(clearStyle); } }这个clearStyle不是可选的。实测发现不清空边框的话合并区域内部会出现细虚线像网格一样割裂视觉严重影响报表专业感。3.2 合并列的本质水平方向的“宽度抢占”合并列比如“华东区”跨3列核心是让第一列承载全部内容后两列释放列宽资源。POI里CellRangeAddress的firstColumn和lastColumn决定横向范围。但陷阱在于列宽设置必须作用于合并区域的第一列而非整个区域。EasyExcel的ColumnWidth(20)注解如果加在实体类字段上只会设置该字段对应列的宽度。但合并列后你需要的是“华东区”三列总宽度为60而不是每列20。解决方案是在HeadWriteHandler里手动设置列宽Override public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { Sheet sheet writeSheetHolder.getSheet(); // “华东区”从第1列B列开始跨3列总宽设为60 sheet.setColumnWidth(1, 60 * 256); // POI单位是1/256字符宽 // 第2、3列C、D列设为0让它们“消失”在合并区域里 sheet.setColumnWidth(2, 0); sheet.setColumnWidth(3, 0); }为什么乘256因为POI的setColumnWidth参数单位是“1/256个字符宽度”不是像素。60 * 256约等于Excel里手动设置的60字符宽。如果直接写60列宽会窄得看不见。这个换算关系官方文档藏在POI的JavaDoc里EasyExcel文档根本没提。3.3 表头与数据体合并的坐标系冲突这是最隐蔽的坑。表头合并用的是HeadWriteHandler数据体合并用CellWriteHandler但两者操作的Sheet对象是同一个坐标系却不同步。典型场景表头有3行数据从第4行开始写。你在CellWriteHandler里用rowIndex判断合并但rowIndex是从0开始计数的而表头占了前3行所以数据第一行的rowIndex其实是3不是0。错误写法// 错把数据行当第0行处理 if (rowIndex 0 columnIndex 0) { // 以为这是第一行第一列 sheet.addMergedRegion(new CellRangeAddress(0, 4, 0, 0)); // 结果合并了表头区域 }正确写法// 对减去表头行数 int dataStartRow 3; // 表头占3行 int relativeRow rowIndex - dataStartRow; // 数据行相对索引 if (relativeRow 0 columnIndex 0) { // 相对第0行第0列 // 合并区域从绝对行号3开始 sheet.addMergedRegion(new CellRangeAddress(3, 7, 0, 0)); }我见过最惨的案例一个财务系统导出资产负债表因没做这个偏移把“资产总计”合并到了表头“货币资金”那一行老板打开Excel当场发火。后来我们加了个HeaderOffsetCalculator工具类专门封装表头行数的获取和转换所有合并逻辑都走这个工具再没出过类似问题。3.4 动态合并范围的实时重算机制业务数据不是静态的。比如“按部门分组”A部门有8人B部门有12人C部门只有3人。你不能预设所有部门都合并5行。必须在写入每一行时动态扫描后续数据预测合并终点。我的方案是在CellWriteHandler里维护一个MapInteger, Integerkey是列索引value是该列当前合并的终点行号。private MapInteger, Integer mergeEndRows new HashMap(); Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (isHead) return; // 只处理数据行 int columnIndex cell.getColumnIndex(); int currentRow cell.getRowIndex(); // 检查该列是否需要合并 if (shouldMergeForColumn(columnIndex)) { // 获取该列下一个不同值的行号 int nextDiffRow findNextDifferentValueRow(columnIndex, currentRow, dataList); // 更新合并终点 mergeEndRows.put(columnIndex, nextDiffRow - 1); // 如果是合并起点添加区域 if (currentRow getMergeStartRow(columnIndex, currentRow)) { sheet.addMergedRegion(new CellRangeAddress(currentRow, nextDiffRow - 1, columnIndex, columnIndex)); } } }findNextDifferentValueRow是核心方法它遍历dataList从currentRow开始找到第一个dataList.get(i).getDepartment()不等于当前值的i。这个方法必须高效我用ArrayList的subList配合stream过滤实测万级数据耗时5ms。千万别用for循环从头遍历O(n²)复杂度会让导出卡死。4. 实操过程从零搭建一个支持多级表头动态分组合并的Excel导出器现在我们把前面所有设计落地成可运行的代码。目标导出一份销售报表表头三级公司-区域-指标数据体按“销售员”纵向合并按“产品线”横向合并。全程不依赖任何第三方UI组件纯Java后端实现。4.1 第一步定义表头元数据与实体类先建SalesReportHeader.java描述三级表头public class SalesReportHeader { // 第一级公司名称跨3行占1列 public static final HeaderDefinition LEVEL_0 new HeaderDefinition(0, Arrays.asList(1), Arrays.asList(3), Arrays.asList(XX科技有限公司)); // 第二级区域跨2行分3列华东/华北/华南 public static final HeaderDefinition LEVEL_1 new HeaderDefinition(1, Arrays.asList(3, 3, 3), Arrays.asList(2, 2, 2), Arrays.asList(华东区, 华北区, 华南区)); // 第三级指标占1行每区域下3列销售额/回款率/退货率 public static final HeaderDefinition LEVEL_2 new HeaderDefinition(2, Arrays.asList(1,1,1, 1,1,1, 1,1,1), Arrays.asList(1,1,1, 1,1,1, 1,1,1), Arrays.asList(销售额, 回款率, 退货率, 销售额, 回款率, 退货率, 销售额, 回款率, 退货率)); }再建数据实体SalesData.javapublic class SalesData { ExcelProperty(value 销售员, index 0) private String salesperson; ExcelProperty(value 产品线, index 1) private String productLine; ExcelProperty(value 销售额, index 2) private BigDecimal salesAmount; ExcelProperty(value 回款率, index 3) private BigDecimal collectionRate; ExcelProperty(value 退货率, index 4) private BigDecimal returnRate; // getter/setter省略 }注意ExcelProperty的index必须和表头列顺序严格对应。LEVEL_2定义了9列但实体类只映射5个字段因为“产品线”字段要横向合并它实际占据第1列B列而“销售额”等指标从第2列C列开始。所以productLine的index1salesAmount的index2以此类推。这个索引对齐是合并成功的前提错一位整个表就废了。4.2 第二步实现表头写入处理器MultiLevelHeadWriteHandlerpublic class MultiLevelHeadWriteHandler implements HeadWriteHandler { private final ListHeaderDefinition headerDefinitions; public MultiLevelHeadWriteHandler(ListHeaderDefinition headerDefinitions) { this.headerDefinitions headerDefinitions; } Override public void beforeCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Head head, Integer columnIndex) { // 表头写入前不做任何事 } Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { // 不处理数据单元格 } Override public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { Sheet sheet writeSheetHolder.getSheet(); Workbook workbook writeWorkbookHolder.getWorkbook(); // 逐级写入表头 for (HeaderDefinition def : headerDefinitions) { int rowNum def.getLevel(); Row row sheet.getRow(rowNum); if (row null) { row sheet.createRow(rowNum); } int colIndex 0; for (int i 0; i def.getTexts().size(); i) { String text def.getTexts().get(i); int colSpan def.getColSpans().get(i); int rowSpan def.getRowSpans().get(i); // 创建单元格 Cell cell row.createCell(colIndex); cell.setCellValue(text); // 设置样式加粗、居中、背景色 CellStyle style workbook.createCellStyle(); Font font workbook.createFont(); font.setBold(true); style.setFont(font); style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); style.setFillForegroundColor(IndexedColors.LIGHT_CORNFLOWER_BLUE.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); cell.setCellStyle(style); // 执行合并 if (colSpan 1 || rowSpan 1) { sheet.addMergedRegion(new CellRangeAddress( rowNum, rowNum rowSpan - 1, colIndex, colIndex colSpan - 1 )); } colIndex colSpan; } } // 设置列宽华东区3列总宽60华北区同理... sheet.setColumnWidth(0, 15 * 256); // 销售员列 sheet.setColumnWidth(1, 60 * 256); // 华东区起始列 sheet.setColumnWidth(2, 0); // 华东区第2列设0隐藏 sheet.setColumnWidth(3, 0); // 华东区第3列设0隐藏 sheet.setColumnWidth(4, 60 * 256); // 华北区起始列 // ... 其他列同理 } }关键点addMergedRegion必须在afterSheetCreate里调用且必须在createRow和createCell之后。如果先合并再创建单元格POI会抛IllegalArgumentException。4.3 第三步实现动态合并处理器DynamicMergeHandlerpublic class DynamicMergeHandler implements CellWriteHandler { private final ListSalesData dataList; private final MapInteger, Integer mergeEndRows new HashMap(); private final int headerRows 3; // 表头占3行 public DynamicMergeHandler(ListSalesData dataList) { this.dataList dataList; } Override public void beforeCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Head head, Integer columnIndex) {} Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (isHead) return; Sheet sheet writeSheetHolder.getSheet(); int absoluteRow cell.getRowIndex(); int columnIndex cell.getColumnIndex(); // 只处理销售员列index0和产品线列index1 if (columnIndex 0) { handleSalespersonMerge(sheet, absoluteRow, columnIndex); } else if (columnIndex 1) { handleProductLineMerge(sheet, absoluteRow, columnIndex); } } private void handleSalespersonMerge(Sheet sheet, int row, int col) { // 纵向合并找下一个不同销售员的行 int nextDiffRow findNextDiffRow(row, col, (a, b) - !Objects.equals(a.getSalesperson(), b.getSalesperson())); int endRow nextDiffRow - 1; mergeEndRows.put(col, endRow); // 如果是合并起点添加区域 if (row getMergeStartRow(col, row)) { sheet.addMergedRegion(new CellRangeAddress(row, endRow, col, col)); // 设置内容居中 setCellCenterAlign(sheet, row, col); } } private void handleProductLineMerge(Sheet sheet, int row, int col) { // 横向合并产品线列需跨3列销售额、回款率、退货率 // 但EasyExcel按字段写入所以这里只合并本列靠列宽控制视觉 // 实际效果产品线文本横跨CDE三列 if (row getMergeStartRow(col, row)) { // 产品线列本身不合并但设置其右侧两列宽度为0 sheet.setColumnWidth(col 1, 0); sheet.setColumnWidth(col 2, 0); } } private int findNextDiffRow(int startRow, int col, BiPredicateSalesData, SalesData predicate) { SalesData current dataList.get(startRow - headerRows); for (int i startRow - headerRows 1; i dataList.size(); i) { if (predicate.test(current, dataList.get(i))) { return i headerRows; // 转回绝对行号 } } return dataList.size() headerRows; // 最后一行 } private int getMergeStartRow(int col, int currentRow) { // 查找当前行向上第一个不同值的行 int baseRow currentRow - headerRows; SalesData current dataList.get(baseRow); for (int i baseRow - 1; i 0; i--) { SalesData prev dataList.get(i); if (!Objects.equals(current.getSalesperson(), prev.getSalesperson())) { return i 1 headerRows; // 下一行即为起点 } } return headerRows; // 第一行就是起点 } private void setCellCenterAlign(Sheet sheet, int row, int col) { Cell cell sheet.getRow(row).getCell(col); if (cell ! null) { Workbook wb sheet.getWorkbook(); CellStyle style wb.createCellStyle(); style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); cell.setCellStyle(style); } } }这个处理器的关键创新点是getMergeStartRow它不是简单地认为“第一行就是起点”而是向上扫描找到最后一个不同值的位置确保合并起点精准。比如数据是[A,A,B,B,B,C]那么第3行第一个B的起点是第3行第4行的起点也是第3行第5行的起点还是第3行——这才是真正的“按组合并”。4.4 第四步组装导出器并调用public void exportSalesReport(HttpServletResponse response) throws IOException { String fileName URLEncoder.encode(销售报表_ LocalDate.now(), UTF-8); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); response.setHeader(Content-disposition, attachment;filename fileName .xlsx); // 准备数据 ListSalesData dataList salesService.loadSalesData(); // 构建表头定义列表 ListHeaderDefinition headers Arrays.asList( SalesReportHeader.LEVEL_0, SalesReportHeader.LEVEL_1, SalesReportHeader.LEVEL_2 ); // 创建写入器 ExcelWriter writer EasyExcel.write(response.getOutputStream()) .registerWriteHandler(new MultiLevelHeadWriteHandler(headers)) .registerWriteHandler(new DynamicMergeHandler(dataList)) .build(); // 写入数据 WriteSheet sheet EasyExcel.writerSheet(销售报表).build(); writer.write(dataList, sheet); // 关闭流 writer.finish(); }注意registerWriteHandler的顺序很重要。MultiLevelHeadWriteHandler必须在DynamicMergeHandler之前注册因为表头要先画好数据才能基于正确的坐标系合并。如果反过来DynamicMergeHandler在afterSheetCreate前就试图操作Sheet会得到null。5. 常见问题与排查技巧实录那些让你加班到凌晨的“幽灵Bug”即使代码逻辑正确EasyExcel合并功能依然充满不确定性。下面是我整理的8个高频问题每个都附带真实场景、根因分析和一招制敌的解决方案。这些不是理论是我在客户现场抓包、断点、对比Excel二进制文件后总结的实战手册。5.1 问题1合并区域显示正常但Excel打开提示“发现不可读取的内容”现象导出的Excel双击打开弹窗提示“发现不可读取的内容”点击“是”后合并区域消失变回普通表格。根因CellRangeAddress的firstRow大于lastRow或firstColumn大于lastColumn。POI允许创建这种非法区域但Excel解析器会拒绝加载。排查技巧用zip命令解压.xlsx文件它本质是zip包进入xl/worksheets/sheet1.xml搜索mergeCell标签。正常格式是mergeCell refA1:A5/如果看到mergeCell refA5:A1/就是行列颠倒。解决方案在addMergedRegion前加校验public static void safeAddMergedRegion(Sheet sheet, int firstRow, int lastRow, int firstCol, int lastCol) { if (firstRow lastRow || firstCol lastCol) { // 记录日志但不抛异常避免中断导出 log.warn(Invalid merge region: ({},{}) to ({},{}), firstRow, firstCol, lastRow, lastCol); return; } sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, firstCol, lastCol)); }5.2 问题2合并后内容不居中文字挤在左上角现象CellStyle设置了CENTER但合并单元格里文字还是左对齐。根因EasyExcel的HorizontalCellStyleStrategy在合并区域上失效。它只对单个单元格生效合并后POI会忽略部分样式属性。排查技巧用Apache POI的XSSFCellStyle工具类打印样式ID对比合并前后CellStyle的getAlignment()返回值。你会发现合并后返回HORIZONTAL_GENERAL而非HORIZONTAL_CENTER。解决方案放弃CellStyleStrategy在CellWriteHandler.afterCellCreate里手动设置if (isTopLeftCorner(cell.getRowIndex(), cell.getColumnIndex(), sheet)) { CellStyle centerStyle cell.getSheet().getWorkbook().createCellStyle(); centerStyle.setAlignment(HorizontalAlignment.CENTER); centerStyle.setVerticalAlignment(VerticalAlignment.CENTER); cell.setCellStyle(centerStyle); }5.3 问题3表头合并正常数据体合并失效只合并了第一组现象A销售员合并了5行B销售员只显示一行没有合并。根因DynamicMergeHandler的mergeEndRowsMap没清空导致后续组的合并终点被前一组残留值覆盖。排查技巧在afterCellCreate开头加日志log.debug(mergeEndRows: {}, mergeEndRows)。你会看到B销售员处理时mergeEndRows里还存着A销售员的endRow10。解决方案在afterRowCreate里重置Map或者改用线程局部变量private final ThreadLocalMapInteger, Integer threadLocalMergeEndRows ThreadLocal.withInitial(HashMap::new); Override public void afterRowCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) { if (!isHead) { threadLocalMergeEndRows.get().clear(); } }5.4 问题4导出速度慢万行数据要2分钟现象findNextDiffRow方法耗时占比90%。根因每次调用都遍历整个dataListO(n²)复杂度。排查技巧用JProfiler采样看findNextDiffRow的CPU时间占比。解决方案预计算分组索引。在导出前遍历一次dataList生成MapString, ListInteger salespersonGroupskey是销售员姓名value是该销售员所有行号列表。findNextDiffRow直接查Mapprivate final MapString, ListInteger salespersonGroups; public DynamicMergeHandler(ListSalesData dataList) { this.dataList dataList; this.salespersonGroups buildSalespersonGroups(dataList); } private MapString, ListInteger buildSalespersonGroups(ListSalesData list) { MapString, ListInteger map new HashMap(); for (int i 0; i list.size(); i) { String sp list.get(i).getSalesperson(); map.computeIfAbsent(sp, k - new ArrayList()).add(i); } return map; } private int findNextDiffRow(int currentRow, String currentSp) { ListInteger group salespersonGroups.get(currentSp); int pos group.indexOf(currentRow); if (pos group.size() - 1) { return dataList.size(); // 最后一个 } return group.get(pos 1); // 下一个同组行号 }5.5 问题5Ant Design Vue表格解析失败报“column not found”现象前端用ant-design-vue的a-table读取导出的Excel提示列名不匹配。根因EasyExcel导出时合并列的ExcelProperty注解只作用于第一列但Ant Design Vue期望所有列都有独立字段名。排查技巧用xlsx库在Node.js里读取导出文件打印worksheet[!ref]和worksheet[A1]的t类型和v值。你会发现合并区域的非首列单元格v为空。解决方案在CellWriteHandler里为合并区域的非首列也写入相同内容仅用于前端解析不影响显示if (isInMergeRegion(row, col, sheet)) { Cell topLeft getTopLeftCell(row, col, sheet); if (topLeft ! null !topLeft.equals(cell)) { cell.setCellValue(topLeft.getStringCellValue()); } }5.6 问题6中文表头合并后Excel显示方框乱码现象表头文字变成“□□□”。根因Workbook创建时未指定编码POI默认用ISO-8859-1。排查技巧检查