当前位置: 首页 > news >正文

若依框架Excel导出进阶:基于注解的跨列动态行合并策略实现

1. 为什么我们需要跨列动态行合并?

在若依前后端分离项目中做报表导出,相信很多朋友都遇到过这样的场景:导出一份订单明细,同一个订单号下有多条商品记录,我们希望“订单号”这一列只显示一次,后面的“订单金额”、“下单日期”等列也能跟着合并起来,这样报表看起来才清爽、专业。但若依框架自带的Excel导出功能,虽然强大,却只支持简单的单列合并,遇到这种需要以某一列为基准,动态合并其他多列的需求,就有点力不从心了。

我最早遇到这个问题是在做一个销售数据看板的时候。业务方要求导出的Excel里,同一个销售员的业绩数据要合并显示。如果只用基础的合并,销售员名字是合并了,但他的“销售额”、“订单数”、“客户数”这几列数据还是分散在多行,看起来非常杂乱,业务方每次都要手动调整,抱怨连连。这时候,一个能够基于注解配置、实现跨列动态合并的方案就显得至关重要了。

这种需求在业务系统中非常普遍,比如:

  • 订单汇总报表:以订单ID为准,合并客户信息、总金额、下单时间。
  • 人员信息统计:以部门为准,合并部门下的多项统计指标。
  • 层级数据展示:比如树形结构的数据,希望父级节点信息在合并行中只显示一次。

原始的若依ExcelUtil类,其合并逻辑是内嵌且相对固定的,扩展性不强。如果我们每遇到一种新的合并需求就去硬编码修改工具类,代码会变得难以维护。因此,一个理想的解决方案是:将合并规则“外置”到注解上,让实体类的字段自己声明“我需要根据哪个基准列进行合并”。这样,不同的导出需求只需要调整实体类的注解配置即可,无需反复修改核心工具类,真正做到灵活、可配置。接下来,我们就一步步看看如何实现这个进阶功能。

2. 核心改造:为Excel注解添加合并能力

要实现注解驱动的合并,第一步就是扩展若依框架的@Excel注解。框架原有的注解已经包含了丰富的导出控制信息,比如列名、类型、顺序等,我们只需要为其增加一个用于指定合并规则的属性。

2.1 理解原有的@Excel注解

在动手之前,我们先快速回顾一下若依的@Excel注解。它通常标注在实体类的字段上,用来定义该字段在Excel中的行为。例如:

public class OrderVO { @Excel(name = "订单编号", sort = 1) private String orderNo; @Excel(name = "商品名称", sort = 2) private String productName; @Excel(name = "金额", sort = 3) private BigDecimal amount; }

这个注解控制了导出的列标题、顺序、格式等。但原生的注解并没有任何关于“行合并”的字段。

2.2 新增mergeLine属性

我们的目标是:在@Excel注解中增加一个mergeLine属性。这个属性的值是一个字符串,用来指定当前字段应该根据哪一列(基准列)的值进行合并。更具体地说,它存储的是基准列的索引号

为什么是索引号而不是字段名?因为在Excel合并的逻辑处理中,我们操作的是单元格的列索引(从0开始),这比解析字段名更直接高效。当然,在注解配置时,我们需要手动计算或知晓目标基准列的索引位置。

修改后的@Excel注解定义(在com.ruoyi.common.annotation包下)需要新增这个属性:

public @interface Excel { // ... 其他原有属性 ... /** * 合并行配置(拓展属性) * 例如:`mergeLine = "0"` 表示此列的值需要根据第0列(索引)的值相同与否进行行合并。 * 支持配置多个基准列索引,用英文逗号分隔,但通常一个字段只参照一个基准列。 * 注意:此功能需要配套的ExcelUtil工具类支持。 */ String mergeLine() default ""; }

这里我将其类型设为String,并用default ""表示默认不合并。这样对原有的所有导出功能零侵入,完全兼容。

2.3 在实体类上如何使用

假设我们有一个OrderExportVO类,用于导出订单明细。我们希望“订单编号”(第0列)相同的行为一组,在这一组内,“订单编号”、“总金额”(第2列)、“下单日期”(第3列)这三列需要分别进行行合并。

那么我们的实体类就可以这样配置:

public class OrderExportVO { // 订单编号是基准列,它自身不需要参照其他列合并,所以 mergeLine 留空或不设置。 // 但为了逻辑清晰,我们可以让它参照自己(索引0),但这在合并算法中通常是起点判断。 @Excel(name = "订单编号", sort = 1, mergeLine = "0") private String orderNo; @Excel(name = "商品名称", sort = 2) private String productName; // 商品名称不需要合并,每行都显示 // 金额列需要根据第0列(订单编号)合并 @Excel(name = "金额", sort = 3, mergeLine = "0") private BigDecimal amount; // 日期列也需要根据第0列(订单编号)合并 @Excel(name = "下单日期", sort = 4, mergeLine = "0", dateFormat = "yyyy-MM-dd") private Date orderTime; }

通过这样的注解配置,我们就清晰地表达了合并意图:amountorderTime字段的值,在导出时,需要参照orderNo字段值的连续性进行行合并。控制器(Controller)层的调用代码完全不需要改变,依然是熟悉的ExcelUtil.exportExcel(list, sheetName, OrderExportVO.class)。所有的魔法都将发生在改造后的工具类里。

3. 算法心脏:改造ExcelUtil的合并逻辑

注解只是定义了规则,真正的合并动作需要在导出数据到Excel单元格时动态执行。这就需要我们深入若依的ExcelUtil工具类(或者我们新建一个如ExcelUtilMerge的扩展类),重写其填充数据的核心方法。

3.1 关键变量:追踪合并区间

合并行的本质是找到连续相同值的行范围。我们需要在填充数据的过程中,动态记录这个“开始行”和“结束行”。在原始文章提供的ExcelUtilMerge类中,使用了三个成员变量来跟踪状态:

  • switchMearge: 一个总开关,理论上可以由某个注解或参数触发,但示例中似乎未完全启用,我们可以忽略或将其逻辑整合到mergeLine判断中。
  • mergeLine_start: 记录当前待合并区间的起始行索引(在sheet中的行号,从0开始计数)。
  • mergeLine_end: 记录当前待合并区间的结束行索引。

关键点:这些变量是针对整个导出过程的。也就是说,当处理完一列数据、准备处理下一列时,mergeLine_startmergeLine_end需要被重置吗?不,它们记录的是上一轮对比(针对某个基准列)得出的合并区间。但我们的算法需要为每一个配置了mergeLine的列独立计算合并区间吗?不一定。更高效的做法是:以基准列为驱动

3.2 核心方法:addCell的重构

原始ExcelUtiladdCell方法负责向一个单元格写入数据。我们需要改造它,使其在写入数据前,能根据@Excel(mergeLine = “X”)的配置,判断是否需要合并,以及何时触发合并操作。

让我们剖析一下示例代码中的关键逻辑(我已将其梳理得更易读):

  1. 读取合并配置String mergeLineStr = attr.mergeLine();获取当前字段注解上配置的基准列索引字符串。
  2. 值比对:在填充单元格时,不仅获取当前行(vo)的本字段值(value),还获取上一行(vo_previous)的同字段值(value_previous)。
  3. 区间记录
    • 如果value等于value_previous,说明当前行与上一行的基准列值相同,属于同一个合并组。如果mergeLine_start为0(表示一个新的合并组刚开始),则将其设置为当前行-1(即上一行的索引),然后将mergeLine_end更新为当前行
    • 如果value不等于value_previous,说明遇到了一个新的值,上一个合并组结束了。此时检查mergeLine_startmergeLine_end,如果它们不相等且不为0,说明存在一个有效的待合并区间(行数大于1)。
  4. 执行合并:当发现一个合并组结束时(值变化或已是最后一行),遍历mergeLine配置的所有列索引(理论上,一个字段可以配置参照多个基准列合并,但通常一个就够了),为每一个需要合并的列,创建一个CellRangeAddress区域(参数为:起始行,结束行,起始列,结束列——这里起始列和结束列是同一个列索引),并使用sheet.addMergedRegion(region)将其合并。
  5. 状态重置:执行完合并后,将mergeLine_startmergeLine_end重置为0,准备记录下一个合并区间。

这里有一个极其重要的细节,也是很多朋友初次实现时容易踩的坑:合并的判断必须基于“基准列”的值,而不是当前列的值。在示例代码的addCell方法中,它比较的是valuevalue_previous,这实际上是当前列的前后值。这在当前列本身就是基准列时是没问题的。但如果当前列是参照基准列合并的其他列(比如“金额”参照“订单编号”),那么比较value(金额)的前后相等性就错了!我们应该比较的是基准列在前后行的值是否相等。

因此,更严谨的逻辑应该在fillExcelData方法中,以行为单位,先确定好所有基准列的合并区间,然后在为每个单元格调用addCell时,传入这个预计算好的合并信息。但示例代码采用了一种更实时、每列独立判断的方式,这就要求在addCell内部,能根据mergeLine属性找到对应的基准列,并获取其前后行的值进行比较。这需要对vovo_previous对象进行反射,根据基准列索引找到对应字段的值,稍微复杂但更解耦。

3.3 处理边界与性能

  • 最后一行合并:上述逻辑在“值发生变化”时触发合并。但如果数据集的最后几行属于同一个合并组,循环结束时不会触发“值变化”。因此,在fillExcelData方法循环结束后,必须再检查一次mergeLine_startmergeLine_end,对最后一个合并组执行合并操作。
  • 性能考虑:合并区域addMergedRegion的调用会有一些开销。如果数据量极大(数万行),需要评估性能。不过对于常规的业务报表,这个开销是可以接受的。
  • 样式继承:合并单元格后,通常只有左上角单元格保留原始数据和样式。POI会自动处理这一点,但如果你对合并后的单元格有特殊样式要求,需要在合并后单独设置。

4. 实战演练:从订单报表到数据看板

理论说得再多,不如看实际怎么用。我们假设两个在若依项目里最常见的场景,把代码跑起来。

4.1 场景一:订单明细跨列合并

这是最经典的需求。OrderItemVO对象列表,每个订单可能有多个商品项。

@Data public class OrderItemVO { // 基准列:订单号。我们希望相同的订单号合并显示。 @Excel(name = "订单号", sort = 1, mergeLine = "0") private String orderId; @Excel(name = "商品SKU", sort = 2) private String sku; // 商品SKU不合并 @Excel(name = "商品名称", sort = 3) private String productName; // 商品名称不合并 // 以下两列需要跟随订单号合并 @Excel(name = "订单总金额", sort = 4, mergeLine = "0") private BigDecimal totalAmount; @Excel(name = "下单客户", sort = 5, mergeLine = "0") private String customerName; @Excel(name = "创建时间", sort = 6, mergeLine = "0", dateFormat = "yyyy-MM-dd HH:mm:ss") private Date createTime; }

在Controller中,我们获取到List<OrderItemVO>数据后,直接使用扩展后的工具类进行导出:

@PostMapping("/export") public void export(HttpServletResponse response, OrderItemVO queryParam) { List<OrderItemVO> list = orderService.selectList(queryParam); // 使用我们改造后的工具类,例如叫 ExcelUtilMerge ExcelUtilMerge<OrderItemVO> util = new ExcelUtilMerge<>(OrderItemVO.class); util.exportExcel(list, "订单明细报表", response); }

导出的Excel中,同一个orderId的所有行,其orderIdtotalAmountcustomerNamecreateTime这几列都会被合并成一个单元格,视觉上立刻规整了许多。

4.2 场景二:部门绩效数据看板

第二个场景是管理报表。我们有一个DepartmentPerformanceVO,按部门统计多项月度指标。

@Data public class DepartmentPerformanceVO { // 基准列:部门名称 @Excel(name = "部门", sort = 1, mergeLine = "0") private String deptName; @Excel(name = "月份", sort = 2) private String month; // 月份不合并,显示每个月的细项 // 以下为绩效指标,需要按部门合并(即部门相同则合并,展示部门总计或部门代表值) @Excel(name = "部门当月销售额", sort = 3, mergeLine = "0") private BigDecimal sales; @Excel(name = "部门目标完成率", sort = 4, mergeLine = "0") private String completionRate; @Excel(name = "部门负责人", sort = 5, mergeLine = "0") private String manager; }

这个数据可能是从数据库聚合查询出来的,每个月每个部门一条记录。导出后,同一个部门在不同月份的数据行,其“部门”、“部门当月销售额”、“部门目标完成率”、“部门负责人”这几列会被合并。这样,在看板中,部门的整体信息得以聚合展示,而“月份”数据则保持展开,便于分析月度趋势。

踩坑提醒:在这个场景下,数据必须按照基准列(deptName)预先排序。如果数据是乱序的,部门A的记录中间插入了部门B的记录,那么合并逻辑就会出错,因为算法只识别连续的相同值。所以,在查询数据库或处理集合时,务必记得ORDER BY deptName

5. 避坑指南与高级技巧

实现过程中,我踩过一些坑,也总结出一些让功能更稳健、更易用的技巧。

5.1 必须注意的排序问题

正如上面提到的,数据源必须按照你希望合并的基准列进行排序。这是动态行合并算法正确工作的前提。这个排序责任最好放在Service层或查询层,确保传入ExcelUtilList已经是排好序的。可以在工具类里加个日志警告,如果检测到未排序的数据,给出提示。

5.2 合并列索引的计算

@Excel(mergeLine = “0”)里的0是Excel列的索引,从0开始计数。这个索引是导出Excel中列的最终顺序,由@Excel(sort)属性决定,而不是实体类中字段的声明顺序。务必在配置时数清楚你的列顺序。一个减少出错的方法是写个简单的工具方法,根据sort值自动计算索引,但通常手动核对一下更直接。

5.3 处理空值与复杂对象

如果基准列的值为null或空字符串,合并逻辑如何处理?通常,我们会将null视为相同的值进行合并,但这也可能不符合业务预期。你需要在addCell的值比较逻辑中,加入对空值的特殊处理,比如使用StringUtils.equals方法,它可以安全处理null的比较。

另外,如果你的字段值是一个复杂对象,需要通过targetAttr链式获取(如user.dept.name),那么在合并判断时,获取vo_previous对应字段值也需要走同样的反射链,确保比较的是同一个属性路径下的值。

5.4 性能优化思路

对于超大数据量(例如10万行以上)的导出,合并操作可能会成为瓶颈。一些优化思路包括:

  • 批量合并:不要在每行每列都调用addMergedRegion,可以先将所有合并区间计算出来,存储在一个列表里,在数据全部填充完毕后,一次性执行所有合并。
  • 使用SXSSFWorkbook:若依默认已经使用了SXSSFWorkbook进行流式导出,这对内存友好。我们的合并逻辑不应破坏这种流式特性,避免在内存中持有过多对象。
  • 算法优化:合并区间的查找算法时间复杂度最好是O(n)。示例中的逐行对比已经是O(n),是高效的。

5.5 扩展思考:多级合并与条件合并

有时业务需求会更复杂:

  • 多级合并:比如先按“大区”合并,再在“大区”内部按“省份”合并。这需要更复杂的注解设计(例如mergeLine = “0,1”表示参照第0列和第1列进行两级合并)和递归合并算法。
  • 条件合并:并非值相同就合并,可能还需要满足其他条件。这可以在注解中增加一个mergeCondition的SpEL表达式属性,在合并前进行表达式求值判断。

这些是更高级的玩法,超出了本文的基础改造范围,但基于我们现有的注解框架,未来进行这样的扩展是完全可行的方向。

http://www.jsqmd.com/news/463929/

相关文章:

  • Elasticsearch聚合查询实战:用汽车销售数据教你玩转aggs(附完整代码)
  • 从零到一:定制高精度相机标定板的实战与避坑指南
  • STM32与ASR01语音模块的串口通信与中断控制实战
  • 上海苏钠米实业发展有限公司电话查询:企业联系方式获取指南 - 品牌推荐
  • 成为Segment Anything核心贡献者的终极指南:从入门到精通
  • 四川建筑资质新办机构排行:成都工程师评审机构推荐/成都建筑职称评审机构推荐/成都建筑资质代办机构推荐/成都建筑资质升级代办机构/选择指南 - 优质品牌商家
  • Neovim-from-scratch主题定制:深色模式与个性化界面配置终极指南
  • 手把手教你用MATLAB设计2.4GHz PCB微带天线(附完整仿真代码)
  • 20260311_165151_几个常见漏洞挖掘案例分享
  • 实测封神!中国辅材集团瓷砖胶测评:全场景适配不踩坑 - 中媒介
  • 深入解析MOS管米勒平台效应与RC吸收电路优化策略
  • 7个实用技巧:用Librosa实现专业级音频数据增强,轻松提升模型鲁棒性
  • Tracks:基于Ruby on Rails构建的GTD™高效任务管理平台完全指南
  • chrome-devtools-mcp的疑难杂症
  • BurpSuite实战:一键生成CSRF Poc页面的高效测试技巧
  • STM32 SPI通信实战:从模式0到模式3的完整代码解析与调试技巧
  • 用STM32F103C8T6+OLED打造智能平衡小车:硬件选型与数据可视化实战
  • WandB数据备份全攻略:离线模式转CSV的3种实用方法
  • 20260311_165219_年薪30W+的秘密:网络安全_挖漏洞_必备的4类工具与漏洞复
  • Briefs未来发展路线图:新功能预测与社区贡献指南
  • 从0到1学习Dropbox (S)CSS Style Guide: spacing与formatting全攻略
  • 被听见的少数:千病智能体如何为罕见病患者重塑 “确诊之路”
  • 开源硬件认证揭秘:Ferris键盘的OSHWA认证之路
  • 【ffmpeg命令】实战指南:UDP推拉流在局域网中的高效应用
  • AI时代,人人都是系统设计工程师
  • PHP-Auth快速入门:10分钟实现用户注册与登录功能
  • 5G NR PBCH中MIB数据解析与UE接入优化
  • SwiftAWSLambdaRuntime核心组件解析:从LambdaRuntime到JSON处理全攻略
  • 优质回忆录品牌推荐:重症家属生命回忆录抢救拍摄/长辈七十大寿回忆录礼物/长辈回忆录采访与录制/高端父母回忆录数字影像全案/选择指南 - 优质品牌商家
  • VMware下ROUTER-OS保姆级安装指南:从镜像下载到Winbox连接全流程