easy-excel fill+模板的情况下 如何合并单元格
文章目录
- 前言
- 一、思路
- 二、使用步骤
- 1.模板
- 2.service方法
- 3.策略
- 4.效果
- 总结
前言
easy-excel 导出excel时,遇到需要保留模板内的格式和表头等,在使用模板+fill模式填充数据的情况下,单元格合并比较麻烦 在easy-excel版本比较老(2.x),升级牵扯到poi升级又涉及到poi-tl等组件也要升级的情况下,使用本文方法可实现单元格合并,如果有更好的方案欢迎交流 AI辅助开发的情况下可以把本问题喂给ai提供思路一、思路
先使用fill导出数据到内存,在内存中使用poi对需要合并的单元格进行操作
二、使用步骤
1.模板
第5行为动态数据行,前后表头表尾都是保留的,为了实现导出所有数据后,A5相同的值合并单元格,
// 两次填充,对固定单元格和动态行list行进行添值处理// 填充单个数据(元数据)excelWriter.fill(batchMetaMap,writeSheet);// 填充列表数据FillConfig fillConfig=FillConfig.builder().forceNewRow(Boolean.TRUE).build();excelWriter.fill(rowList,fillConfig,writeSheet);2.service方法
代码如下(示例):
@Override publicvoidexportYearPlan(Long masterId,HttpServletResponse response,OpsPlanMaster opsPlanMaster){try{// 1. 查询该主表下的所有年计划子表记录List<OpsPlanYear>planList=this.listByMasterId(masterId);if(opsPlanMaster==null||planList==null||planList.isEmpty()){return;}// 2. 设置响应头response.setContentType("application/vnd.ms-excel");response.setCharacterEncoding("utf-8");String fileName=URLEncoder.encode("年度检查巡视计划","UTF-8");response.setHeader("Content-disposition","attachment;filename="+fileName+".xlsx");// 3. 加载模板文件String templatePath="template/excel/OpsPlanYearFillTemplate.xlsx";try(InputStream templateStream=ResourceLoaderUtil.getResourceAsStream(templatePath)){if(templateStream==null){throw newRuntimeException("模板文件不存在:"+templatePath);}// 4. 准备动态行数据List<Map<String,Object>>rowList=new ArrayList<>();for(OpsPlanYear plan:planList){Map<String,Object>row=new HashMap<>();row.put("categoryName",plan.getCategoryName());row.put("subCategoryName",plan.getSubCategoryName());row.put("month01",plan.getMonth01()!=null?plan.getMonth01():"");row.put("month02",plan.getMonth02()!=null?plan.getMonth02():"");row.put("month03",plan.getMonth03()!=null?plan.getMonth03():"");row.put("month04",plan.getMonth04()!=null?plan.getMonth04():"");row.put("month05",plan.getMonth05()!=null?plan.getMonth05():"");row.put("month06",plan.getMonth06()!=null?plan.getMonth06():"");row.put("month07",plan.getMonth07()!=null?plan.getMonth07():"");row.put("month08",plan.getMonth08()!=null?plan.getMonth08():"");row.put("month09",plan.getMonth09()!=null?plan.getMonth09():"");row.put("month10",plan.getMonth10()!=null?plan.getMonth10():"");row.put("month11",plan.getMonth11()!=null?plan.getMonth11():"");row.put("month12",plan.getMonth12()!=null?plan.getMonth12():"");row.put("ownerName",plan.getOwnerName()!=null?plan.getOwnerName():"");row.put("remark",plan.getRemark()!=null?plan.getRemark():"");rowList.add(row);}// 填充批次元数据:年月信息、部门、审核人、制表人等Map<String,Object>batchMetaMap=new HashMap<>();batchMetaMap.put("orgName",opsPlanMaster.getOrgName());// 部门名称batchMetaMap.put("planDate",opsPlanMaster.getPlanDate().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")));// 标题中的年月batchMetaMap.put("approvedBy",opsPlanMaster.getApprovedBy());// 审核人batchMetaMap.put("reviewedBy",opsPlanMaster.getReviewedBy());batchMetaMap.put("preparedBy",opsPlanMaster.getPreparedBy());// 使用自定义合并策略:合并A列(大类名称),从第5行(索引4)开始FillMergeStrategy mergeStrategy=newFillMergeStrategy(4,0);// 先将Excel写入内存ByteArrayOutputStream byteArrayOutputStream=newByteArrayOutputStream();ExcelWriter excelWriter=EasyExcel.write(byteArrayOutputStream).withTemplate(templateStream).build();WriteSheet writeSheet=EasyExcel.writerSheet(0).build();// 填充单个数据(元数据)excelWriter.fill(batchMetaMap,writeSheet);// 填充列表数据FillConfig fillConfig=FillConfig.builder().forceNewRow(Boolean.TRUE).build();excelWriter.fill(rowList,fillConfig,writeSheet);excelWriter.finish();// 用POI打开内存中的Excel,执行合并try(Workbook workbook=WorkbookFactory.create(newByteArrayInputStream(byteArrayOutputStream.toByteArray()))){org.apache.poi.ss.usermodel.Sheet sheet=workbook.getSheetAt(0);mergeStrategy.doMergeWithPoi(sheet);// 将合并后的Excel写入响应workbook.write(response.getOutputStream());}catch(Exception e){throw newRuntimeException("合并单元格失败:"+e.getMessage(),e);}}}catch(IOException e){throw newRuntimeException("导出失败:"+e.getMessage(),e);}}3.策略
代码如下(示例):
package com.bjhz.microservice.assets.server.full.listener;import com.alibaba.excel.ExcelWriter;import com.alibaba.excel.write.metadata.WriteSheet;import lombok.extern.slf4j.Slf4j;import org.apache.poi.ss.usermodel.Cell;import org.apache.poi.ss.usermodel.DateUtil;import org.apache.poi.ss.usermodel.Row;import org.apache.poi.ss.usermodel.Sheet;import org.apache.poi.ss.util.CellRangeAddress;/** * EasyExcel Fill模式动态单元格合并策略 * * <p>功能说明:</p> * <ul> * <li>支持多列独立合并,每列根据单元格内容自动判断是否合并</li> * <li>仅当相邻行的单元格内容完全相同时才执行合并</li> * <li>在ExcelWriter.finish()之前手动调用doMerge执行合并</li> * </ul> * * <p>使用示例:</p> * <pre> * // 创建合并策略 * FillMergeStrategy strategy = new FillMergeStrategy(4, 0); * * // 在所有fill操作完成后,finish之前调用 * strategy.doMerge(excelWriter, writeSheet); * * // 合并A列和B列(索引0和1),从第5行开始 * FillMergeStrategy strategy = new FillMergeStrategy(4, 0, 1); * </pre> * * @author * @date 2026-04-26 */@Slf4j public class FillMergeStrategy{/** 需要合并的列索引数组(从0开始),A列 = 0 */private finalint[]mergeColumnIndexes;/** 数据开始的行索引(从0开始计数),例如第5行传入4 */private finalintstartRowIndex;/** * 构造函数 * * @param startRowIndex 数据开始的行索引(从0开始),例如第5行传入4 * @param columnIndexes 需要合并的列索引数组,可变参数 */publicFillMergeStrategy(intstartRowIndex,int...columnIndexes){this.startRowIndex=startRowIndex;this.mergeColumnIndexes=columnIndexes;}/** * 执行单元格合并 * * <p>调用时机:</p> * <ul> * <li>在所有fill操作完成后</li> * <li>在excelWriter.finish()之前</li> * </ul> * * @param excelWriter EasyExcel写入器 * @param writeSheet 写入的Sheet */publicvoiddoMerge(ExcelWriter excelWriter,WriteSheet writeSheet){Sheet sheet=excelWriter.writeContext().writeSheetHolder().getSheet();log.info("开始执行单元格合并,startRowIndex={}, mergeColumnIndexes={}",startRowIndex,mergeColumnIndexes);log.info("Sheet总行数: {}",sheet.getLastRowNum());// 遍历每一列需要合并的列for(intcolumnIndex:mergeColumnIndexes){mergeColumnByContent(sheet,columnIndex);}log.info("单元格合并完成");}/** * 使用POI Sheet执行单元格合并 * * <p>调用时机:</p> * <ul> * <li>ExcelWriter.finish()之后</li> * <li>使用POI打开Excel后</li> * </ul> * * @param sheet POI的Sheet对象 */publicvoiddoMergeWithPoi(Sheet sheet){log.info("开始执行单元格合并,startRowIndex={}, mergeColumnIndexes={}",startRowIndex,mergeColumnIndexes);log.info("Sheet总行数: {}",sheet.getLastRowNum());// 遍历每一列需要合并的列for(intcolumnIndex:mergeColumnIndexes){mergeColumnByContent(sheet,columnIndex);}log.info("单元格合并完成");}/** * 对指定列执行内容相同的单元格合并 * * @param sheet Excel Sheet对象 * @param columnIndex 列索引(从0开始) */privatevoidmergeColumnByContent(Sheet sheet,intcolumnIndex){intlastRowNum=sheet.getLastRowNum();if(lastRowNum<startRowIndex){log.info("数据行数不足,第{}列无需合并",columnIndex);return;}String lastValue=null;intmergeStartRow=-1;intmergeCount=0;// 从指定行开始遍历for(introwIndex=startRowIndex;rowIndex<=lastRowNum;rowIndex++){Row row=sheet.getRow(rowIndex);if(row==null){continue;}Cell cell=row.getCell(columnIndex);String currentValue=getCellValue(cell);// 当前值与上一行值相同,记录合并起始位置if(currentValue!=null&¤tValue.equals(lastValue)){if(mergeStartRow==-1){mergeStartRow=rowIndex-1;}}else{// 当前值与上一行值不同,执行上一段合并if(mergeStartRow!=-1){intmergeEndRow=rowIndex-1;log.info("合并第{}列,从第{}行到第{}行,值='{}'",columnIndex,mergeStartRow,mergeEndRow,lastValue);CellRangeAddress region=newCellRangeAddress(mergeStartRow,mergeEndRow,columnIndex,columnIndex);try{sheet.addMergedRegion(region);clearMergedCells(sheet,region);mergeCount++;}catch(Exception e){log.warn("合并失败: {}",e.getMessage());}mergeStartRow=-1;}}lastValue=currentValue;}// 处理最后一段合并if(mergeStartRow!=-1&&lastRowNum>mergeStartRow){log.info("合并第{}列,从第{}行到第{}行(最后一行),值='{}'",columnIndex,mergeStartRow,lastRowNum,lastValue);CellRangeAddress region=newCellRangeAddress(mergeStartRow,lastRowNum,columnIndex,columnIndex);try{sheet.addMergedRegion(region);clearMergedCells(sheet,region);mergeCount++;}catch(Exception e){log.warn("合并失败: {}",e.getMessage());}}log.info("第{}列合并完成,共合并{}个区域",columnIndex,mergeCount);}/** * 清空合并区域中除第一个单元格外的其他单元格的值 * * @param sheet Excel Sheet对象 * @param region 合并区域 */privatevoidclearMergedCells(Sheet sheet,CellRangeAddress region){for(introwIndex=region.getFirstRow()+1;rowIndex<=region.getLastRow();rowIndex++){Row row=sheet.getRow(rowIndex);if(row!=null){Cell cell=row.getCell(region.getFirstColumn());if(cell!=null){// POI 3.16兼容:设置单元格类型为空来清空值cell.setCellType(Cell.CELL_TYPE_BLANK);}}}}/** * 安全获取单元格的字符串值 * * @param cell Excel单元格对象 * @return 单元格的字符串值,如果单元格为null则返回null */private StringgetCellValue(Cell cell){if(cell==null){returnnull;}intcellType=cell.getCellType();if(cellType==Cell.CELL_TYPE_STRING){returncell.getStringCellValue();}elseif(cellType==Cell.CELL_TYPE_NUMERIC){if(DateUtil.isCellDateFormatted(cell)){returnString.valueOf(cell.getDateCellValue());}else{returnString.valueOf(cell.getNumericCellValue());}}elseif(cellType==Cell.CELL_TYPE_BOOLEAN){returnString.valueOf(cell.getBooleanCellValue());}elseif(cellType==Cell.CELL_TYPE_FORMULA){returncell.getCellFormula();}returnnull;}}示例代码,思路是这样,代码随便看看
clearMergedCells必须要有,否则虽然看着单元格合并了,但是下面的单元格每个还有自己的值,不像在excel里直接执行合并,只保留上面的值,这样的话在导入的情况下,会导致你按空处理合并单元格的第二行会莫名奇妙读到值,具体解释看最后
4.效果
导出效果(示例):
总结
先使用fill导出数据到内存,在内存中使用poi对需要合并的单元格进行操作
踩坑问题:
导出策略中,如果只合并单元格,不进行置空处理,这样导出的excel,合并的单元格并不像我们在excel中合并单元格一样只保留左上角的值,下面单元格也是有值的,如果是导出的时候当成空置判断就会有问题
在 Excel 中,合并单元格后,只有左上角的单元格保留值,其他被合并的单元格应该被清空。但 POI 的 addMergedRegion() 只是设置合并区域,不会自动清除其他单元格的值。
解决方案:
需要修改 FillMergeStrategy ,在执行合并后,清空被合并区域中除了第一个单元格之外的其他单元格的值:
代码已经修复
