1.这篇文章写什么?
在工作中碰到了这样一个需求,要从数据库里面读取一段时间内,每天某些时刻对应的温度数据,一天可能会设置有多个时间点,导出到excel中的模板需要支持多种格式的,比如一个月,一个季度,上半年,下半年等,并且需要支持灵活的配置导出的时间段范围。超过温度的点需要用红色来进行标识
2.为什么写这篇文章
在工作中碰到了需要导出excel报表的需求,写这篇文章记录从需求到实现的过程,供后续参考和改进
3.如何实现?
要实现导出excel,首先就想到了easyexcel,它可以支持读、写、填充excel,针对现在这个需求,如果需要自定义时间的话可以通过配置字典,前端读取字典用dayjs来进行解析,把解析到的时间范围给到后端,后端再根据时间范围去导出数据,至于excel方面,想到了先用占位符在excel模板中预设好位置,然后用代码进行填充,主要的实现流程就大概有了
4.具体实现
根据上面的主要流程,下面就来进行每一步的细化,首先是前端
4.1 解析时间
通过字典来读取时间范围的代码,至于为什么不用后端来实现,之前也考虑过,不过因为无法确定具体的时间,就没用后端去实现,因为一个月不固定有多少天,不固定有多少周,如果用后端去实现的话可能会得不到准确的时间,反观前端的dayjs,就可以很简单的实现这个功能,通过解析dayjs代码来实现时间的获取,代码如下:
TS
<template>
<div>
{{getDates()}}
</div>
</template>
<script lang="ts" setup>
import dayjs from "dayjs";
const getDates = () =>{
const str = "const startTime = dayjs().startOf('year').format('YYYY-MM-DD HH:mm:ss');const endTime = dayjs().endOf('year').format('YYYY-MM-DD HH:mm:ss');return [startTime, endTime]"
const timeFunc = new Function('dayjs', str);
const data = timeFunc(dayjs)
console.log(data[0])
console.log(data[1])
return timeFunc(dayjs)
}
</script>
用str来模拟从字段中读取到的时间范围代码,用dayjs解析出来,这样页面上就会直接打印出当前的时间: [ "2023-01-01 00:00:00", "2023-12-31 23:59:59" ]
到此,前端解析时间的任务就完成了
4.2 导出excel
万丈高台起于垒土,要实现本次需求,首先要能导出excel,然后再考虑样式,最后再考虑现有可行方案的兼容性,能否兼容多种格式的模板。经过考察官网,发现了两种看起来比较可行和符合目前需求的导出方式: 首先把依赖导入先:
XML
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.2.1</version>
</dependency>
方式1: 定义一个模板,里面就搞几个占位符,每个占位符替换一个内容
直接上代码:
JAVA
@Test
public void simpleFill() {
// 模板注意 用{} 来表示你要用的变量 如果本来就有"{","}" 特殊字符 用"\{","\}"代替
String templateFileName = "F:/excel/" + "simple.xlsx";
// 方案1 根据对象填充
String fileName = "F:/excel/" + System.currentTimeMillis() + ".xlsx";
// 这里会填充到第一个sheet, 然后文件流会自动关闭
Map<String, Object> map = new HashMap<>();
map.put("name", "张三");
map.put("number", 5.2);
map.put("month", 5);
EasyExcel.write(fileName).withTemplate(templateFileName)
.sheet().doFill(map);
}
看效果,替换成功了,能正常替换,这种方式能跑:
方式2 定义一个模板,里面搞几个占位符,以.
开头的为循环的占位符
直接上代码:
JAVA
@Test
public void horizontalFill() {
String templateFileName = "F:/excel/" + "simpleHeng.xlsx";
String fileName = "F:/excel/" + System.currentTimeMillis() + "heng" + ".xlsx";
// 方案1
try (ExcelWriter excelWriter = EasyExcel.write(fileName).withTemplate(templateFileName)
.build()) {
WriteSheet writeSheet = EasyExcel.writerSheet().build();
excelWriter.write(data(),writeSheet);
}
}
JAVA
private List<FillData> data() {
List<FillData> list = ListUtils.newArrayList();
for (int i = 0; i < 10; i++) {
FillData fillData = new FillData();
list.add(fillData);
fillData.setName("张三" + i);
fillData.setNumber(5.2);
fillData.setDate(new Date());
}
return list;
}
JAVA
@Getter
@Setter
@EqualsAndHashCode
public class FillData {
private String name;
private double number;
private Date date;
}
看效果,替换成功(虽然哪里感觉不对劲):
现在基本的替换模板实现了,那么就进一步深入,给表格里面的内容添加颜色样式
经过网上搜索了一下,发现自定义样式需要写一个类去实现CellWriteHandler
,从而自定义样式
JAVA
@Slf4j
public class CustomCellWriteHandler implements CellWriteHandler {
@Override
public void afterCellDispose(CellWriteHandlerContext context) {
Cell cell = context.getCell();
Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
CellStyle cellStyle = workbook.createCellStyle();
Font writeFont = workbook.createFont();
writeFont.setColor(IndexedColors.RED.getIndex());
writeFont.setBold(true);
cellStyle.setFont(writeFont);
cell.setCellStyle(cellStyle);
context.getFirstCellData().setWriteCellStyle(null);
}
}
然后在原有导出代码的基础上加个registerWriteHandler(new CustomCellWriteHandler())
JAVA
EasyExcel.write(fileName).withTemplate(templateFileName)
.registerWriteHandler(new CustomCellWriteHandler())
.sheet().doFill(map);
然后就可以看到导出的效果了
再试试第二种方式,也就是循环写入的,加个样式:
JAVA
WriteSheet writeSheet = EasyExcel.writerSheet().registerWriteHandler(new CustomCellWriteHandler()).build();
然后导出,发现这种方式的导出的excel未能成功设置颜色 ,后面也尝试了很多次,确定了这种方式没法带上样式,所以就放弃这种方式了
到这里就只剩下excel中每个位置的都用一个占位符来占位替换的方式了
4.3 实现思路
确定了可行的实现方式,现在就要在这种方式上思考如何实现兼容多种时间段,多种模板的导出。需求中每天可能会设置若干个点,也可能需要导出不同时间段的温度数据,例如月、季度、半年等,或者是其他更细的时间点,每次的模板可能也不大一样,例如下面的几种:
老王的模板
老张的模板
老李的模板
可以看出需要兼容不同的模板确实是个伤脑筋的事情,后面发现了个比较折中的办法:因为目前能实现excel上导出带样式的就只有替换占位符的方法,如何在不改代码的前提下适配多种模板呢,就直接把模板上每个格子都对应一个占位符,只要后端每次都是按照固定规则去生成数据的,那么只要设置好模板里每个格子的占位符就可以解决问题了。只要每次生成数据的顺序都是按照{t1}、{t2}、{t3}这样,依次类推下去,只要模板配置好占位符即可。例如每天一个监测点,那{t1}就对应第一天监测点的温度,{t2}就对应第二天监测点的温度,如果每天两个监测点,那么{t1}和{t2}就分别对应第一天的第一、第二个监测点的温度。这样就可以实现导出月、季度、半年的数据而不用改代码了,如果要导出某个时间段的,例如1月15日到2月15日的,这种时间段的话就使用大一级的模板,使用季度的模板即可兼容。
4.4 代码实现
有了上面的思路,大致实现流程就出来了
4.4.1 前端解析字符串,获取时间范围
这个上面有提及,这里就不再赘述,跳过
4.4.2 后端获取每天配置的时间点
由于配置的时间点是12、13、15这种,并不是具体的一个时间,因此需要结合时间范围来解析成具体的时间,首先需要获取时间段里面的每一天,然后再根据配置的时间点得到每个具体的时间点,获取到了时间点之后就可以去数据库中查询这个时间点对应的数据,模拟代码如下: 首先说明,这里引入了hutool
来处理时间
XML
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.18</version>
</dependency>
JAVA
public static void main(String[] args) {
DateTime startTime = DateUtil.parse("2023-07-01 00:00:00");
DateTime endTime = DateUtil.parse("2023-10-31 23:59:59");
List<Date> timeRangeDates = getTimeRangeDates(startTime, endTime);
List<String> times = Arrays.asList("12");
Map<DateTime, Float> resultMap = getTemperature(timeRangeDates, times);
}
/**
* 获取要导出数据的Map, key为时间,value为时间对应的值
*
* @param timeRangeDates 时间范围
* @param times 每天定义的时间点
* @return 导出的map
*/
private static Map<DateTime, Float> getTemperature(List<Date> timeRangeDates, List<String> times) {
Map<DateTime, Float> resultMap = new HashMap<>();
for (Date timeRangeDate : timeRangeDates) {
for (String time : times) {
// eg: 12 ==> 2023-11-07 12:00:00
String tempTime = DateUtil.formatDateTime(timeRangeDate).substring(0, 11) + time + ":00:00";
DateTime recordTime = DateUtil.parse(tempTime);
// 如果是将来的时间,就不执行查询,认为是没有数据的
if (recordTime.isAfter(new Date())) {
resultMap.put(recordTime, null);
continue;
}
// 模拟从数据库拿到的温度,10.5 °C
resultMap.put(recordTime, selectTemperature());
}
}
return resultMap;
}
/**
* 模拟从数据库查询数据,随机生成2到10之间的一个数字,保留一位小数
*
* @return 2到10之间的一个数字, 保留一位小数
*/
private static float selectTemperature() {
Random random = new Random();
float minValue = 2.0f;
float maxValue = 10.0f;
float range = maxValue - minValue;
float randomFloat = (random.nextFloat() * range) + minValue;
DecimalFormat df = new DecimalFormat("#.0");
String formattedFloat = df.format(randomFloat);
return Float.parseFloat(formattedFloat);
}
/**
* 获取起止时间内的每一天
*
* @param start 开始时间
* @param end 结束时间
* @return 每天
*/
private static List<Date> getTimeRangeDates(Date start, Date end) {
List<Date> dates = new ArrayList<>();
DateTime current = DateUtil.date(start);
DateTime endTime = DateUtil.date(end);
while (current.isBefore(endTime)) {
dates.add(current.toJdkDate());
current = current.offset(DateField.DAY_OF_MONTH, 1);
}
return dates;
}
经过上面一通操作,就可以得到了一个Map
,里面的key
是时间点,value
是时间点对应的温度。 按道理来说,map已经构造好了,直接编写模板导出就完事了,就在这时,又碰到了一个问题,map上的{t1}、{t2}等占位符如何跟时间匹配的问题,模板上肯定是每个月都以最大的时间来定义,也就是31天,但并不是每个月都有31天,因此要在导出数据的时候把那些应该为空的占位符也计算出来,拿老王的模板来举例子:
今年是2023年,9月只有30天,那么模板上31号的占位符应该导出的时候就应该设置个空值,也就是在碰到{t93}的时候需要把值设置为空,然后再导出excel
完整代码如下:
JAVA
package com.example.demo.test;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import com.alibaba.excel.EasyExcel;
import com.example.demo.handler.CustomCellWriteHandler;
import java.text.DecimalFormat;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
public class ExportTest {
public static void main(String[] args) {
DateTime startTime = DateUtil.parse("2023-07-01 00:00:00");
DateTime endTime = DateUtil.parse("2023-12-31 23:59:59");
List<Date> timeRangeDates = getTimeRangeDates(startTime, endTime);
List<String> times = Collections.singletonList("12");
Map<DateTime, Float> resultMap = getTemperature(timeRangeDates, times);
// 获取日期范围内的月份
List<Integer> distinctMonths = getDistinctMonths(timeRangeDates);
// 获取月份对应的天数
Map<Integer, Integer> daysInMonths = getDaysInMonths(timeRangeDates);
// 获取应该置空的下标
List<Integer> emptyIndexList = getEmptyIndexList(daysInMonths, times.size());
// 获取理论上一共有多少天,按照一个月31天算
int totalDaysCount = getTotalDaysCount(distinctMonths, timeRangeDates.size());
Map<String, Object> exportDataMap = new HashMap<>();
// i快 j慢
for (int i = 0, j = 0; i < totalDaysCount; i++) {
int currentDateIndex = j;
int currentCount = (i + 1) * times.size() - (times.size() - 1);
for (String time : times) {
// 本月不足31天的填充null
if (emptyIndexList.contains(currentCount)) {
exportDataMap.put("t" + currentCount++, null);
continue;
}
// 12 ==> 2023-10-25 12:00:00
String tempTime = DateUtil.formatDateTime(timeRangeDates.get(currentDateIndex))
.substring(0, 11) + time + ":00:00";
if (currentDateIndex == j) {
j++;
}
// 根据date查询数据
DateTime recordTime = DateUtil.parse(tempTime);
exportDataMap.put("t" + currentCount++, resultMap.get(recordTime));
}
}
// 模板注意 用{} 来表示你要用的变量 如果本来就有"{","}" 特殊字符 用"\{","\}"代替
String templateFileName = "F:/excel/" + "老王.xlsx";
// 方案1 根据对象填充
String fileName = "F:/excel/" + System.currentTimeMillis() + "老王.xlsx";
EasyExcel.write(fileName).withTemplate(templateFileName)
.registerWriteHandler(new CustomCellWriteHandler())
.sheet().doFill(exportDataMap);
}
private static int getTotalDaysCount(List<Integer> distinctMonths, int timeRangeSize) {
if (timeRangeSize <= 31) {
return Math.min(distinctMonths.size() * 31, timeRangeSize);
}
return distinctMonths.size() * 31;
}
private static List<Integer> getEmptyIndexList(Map<Integer, Integer> daysInMonths, int pointCount) {
List<Integer> list = new ArrayList<>();
AtomicInteger count = new AtomicInteger();
daysInMonths.forEach((key, value) -> {
// 本月的开始长度
int monthLength = count.get();
// 总本月虚拟长度
count.addAndGet(31 * pointCount);
// 本月实际长度
int realLength = value * pointCount;
// 本月开始下标
int startIndex = monthLength + realLength;
// 多出的下标
int extraCount = count.get() - realLength - monthLength;
// 记录需要存空值的占位符位置
for (int i = startIndex + 1; i <= startIndex + extraCount; i++) {
list.add(i);
}
});
return list;
}
/**
* 获取今年的某些月份的天数
*
* @param timeRangeDates 时间范围
* @return 该月份对应的年
*/
private static Map<Integer, Integer> getDaysInMonths(List<Date> timeRangeDates) {
List<Integer> distinctMonths = getDistinctMonths(timeRangeDates);
Date firstDate = timeRangeDates.get(0);
Calendar calendar = Calendar.getInstance();
calendar.setTime(firstDate);
int currentYear = calendar.get(Calendar.YEAR);
return distinctMonths.stream()
.collect(Collectors.toMap(
month -> month,
month -> YearMonth.of(currentYear, month).lengthOfMonth()
));
}
/**
* 获取时间范围内的所有月份、去重、转成String类型
*
* @param timeRangeDates 时间范围集合
* @return 月份集合
*/
public static List<Integer> getDistinctMonths(List<Date> timeRangeDates) {
return timeRangeDates.stream()
.map(date -> date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().getMonthValue())
.distinct()
.sorted()
.collect(Collectors.toList());
}
/**
* 获取要导出数据的Map, key为时间,value为时间对应的值
*
* @param timeRangeDates 时间范围
* @param times 每天定义的时间点
* @return 导出的map
*/
private static Map<DateTime, Float> getTemperature(List<Date> timeRangeDates, List<String> times) {
Map<DateTime, Float> resultMap = new HashMap<>();
for (Date timeRangeDate : timeRangeDates) {
for (String time : times) {
// eg: 12 ==> 2023-11-07 12:00:00
String tempTime = DateUtil.formatDateTime(timeRangeDate).substring(0, 11) + time + ":00:00";
DateTime recordTime = DateUtil.parse(tempTime);
// 如果是将来的时间,就不执行查询,认为是没有数据的
if (recordTime.isAfter(new Date())) {
resultMap.put(recordTime, null);
continue;
}
// 模拟从数据库拿到的温度,10.5 °C
resultMap.put(recordTime, selectTemperature());
}
}
return resultMap;
}
/**
* 模拟从数据库查询数据,随机生成2到10之间的一个数字,保留一位小数
*
* @return 2到10之间的一个数字, 保留一位小数
*/
private static float selectTemperature() {
Random random = new Random();
float minValue = 2.0f;
float maxValue = 10.0f;
float range = maxValue - minValue;
float randomFloat = (random.nextFloat() * range) + minValue;
DecimalFormat df = new DecimalFormat("#.0");
String formattedFloat = df.format(randomFloat);
return Float.parseFloat(formattedFloat);
}
/**
* 获取起止时间内的每一天
*
* @param start 开始时间
* @param end 结束时间
* @return 每天
*/
private static List<Date> getTimeRangeDates(Date start, Date end) {
List<Date> dates = new ArrayList<>();
DateTime current = DateUtil.date(start);
DateTime endTime = DateUtil.date(end);
while (current.isBefore(endTime)) {
dates.add(current.toJdkDate());
current = current.offset(DateField.DAY_OF_MONTH, 1);
}
return dates;
}
}
自定义设置样式
JAVA
package com.example.demo.handler;
import com.alibaba.excel.write.handler.CellWriteHandler;
import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
@Slf4j
public class CustomCellWriteHandler implements CellWriteHandler {
@Override
public void afterCellDispose(CellWriteHandlerContext context) {
Cell cell = context.getCell();
double numericCellValue;
try {
numericCellValue = cell.getNumericCellValue();
} catch (Exception e) {
log.error("解析到了非数值类型,直接返回");
return;
}
if (numericCellValue >= 2 && numericCellValue <= 8) {
return;
}
Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
CellStyle cellStyle = workbook.createCellStyle();
Font writeFont = workbook.createFont();
writeFont.setColor(IndexedColors.RED.getIndex());
writeFont.setBold(true);
cellStyle.setFont(writeFont);
cell.setCellStyle(cellStyle);
context.getFirstCellData().setWriteCellStyle(null);
}
}
运行效果如下: 9月31日有占位符,但是9月只有30日,因此31日无数据,并且数据不在2和8范围之间的数据都可以用红色字体展示
至于返回数据给前端这里就省略了,毕竟文件都能生成了,其他的都是小问题了
5. 小结
本次的设计实现了支持不同模板,按照月(月初到月末)、季度、半年的数据进行导出,初步实现了功能,但是面对更复杂的场景,例如需要支持不同模板在某一段时间的导出功能暂时没法实现。不过也算是积累了一次使用easyexcel导出报表的经验,以此来记录本次开发的过程,为后续类似功能的开发提供借鉴
代码存放链接: gitee.com/szq2021/eas... (主要是怕找不着了,先存着)