diff --git a/backend/src/main/java/com/yfd/platform/utils/ExportZipUtil.java b/backend/src/main/java/com/yfd/platform/utils/ExportZipUtil.java new file mode 100644 index 00000000..ad3214d8 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/utils/ExportZipUtil.java @@ -0,0 +1,521 @@ +package com.yfd.platform.utils; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import java.io.*; +import java.net.URLEncoder; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 综合导出工具类 — 将多维度数据导出为 ZIP 压缩包,内含若干 Excel (.xlsx) 文件,每个 Excel 支持多 Sheet 页。 + * + *

使用示例

+ *
{@code
+ * // 1. 构建 Sheet 数据
+ * SheetData stationSheet = new SheetData("电站基础信息",
+ *         Arrays.asList("测站编码", "测站名称", "经度", "纬度"),
+ *         stationRows);  // List>
+ *
+ * SheetData monitorSheet = new SheetData("监测数据",
+ *         Arrays.asList("时间", "水位", "流量"),
+ *         monitorRows);
+ *
+ * // 2. 构建 Excel 数据(一个 Excel = 多个 Sheet)
+ * ExcelData excel1 = new ExcelData("电站数据", Arrays.asList(stationSheet, monitorSheet));
+ * ExcelData excel2 = new ExcelData("流域数据", Arrays.asList(basinSheet));
+ *
+ * // 3. 导出到 HttpServletResponse
+ * ExportZipUtil.exportToResponse(response, "数据导出_20260731.zip", Arrays.asList(excel1, excel2));
+ *
+ * // 或导出到文件
+ * ExportZipUtil.exportToFile(new File("D:/export.zip"), Arrays.asList(excel1, excel2));
+ *
+ * // 类型安全的构建方式
+ * SheetData sheet = ExportZipUtil.fromTypedData("电站信息",
+ *         Arrays.asList("编码", "名称", "经度"),
+ *         Arrays.asList(Station::getStcd, Station::getStnm, Station::getLgtd),
+ *         stationList);
+ * }
+ * + * @author system + * @since 2026-07-31 + */ +public class ExportZipUtil { + + private static final Logger log = LoggerFactory.getLogger(ExportZipUtil.class); + + private ExportZipUtil() { + // 工具类,禁止实例化 + } + + // ======================== 数据模型 ======================== + + /** + * 单个 Sheet 页数据定义 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SheetData { + /** Sheet 页名称 */ + private String sheetName; + /** 表头列名集合 */ + private List headers; + /** 数据行集合(每行是一个 Object 列表,与 headers 一一对应) */ + private List> rows; + } + + /** + * 单个 Excel 文件数据定义(一个 Excel = 多个 Sheet) + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ExcelData { + /** Excel 文件名(不含 .xlsx 扩展名,在 ZIP 中自动添加) */ + private String fileName; + /** 该 Excel 包含的所有 Sheet 页 */ + private List sheets; + } + + // ======================== 核心导出方法 ======================== + + /** + * 导出 ZIP 压缩包到 HttpServletResponse(浏览器下载) + * + * @param response HttpServletResponse + * @param zipFileName 下载文件名(含 .zip 扩展名) + * @param excelList 要导出的 Excel 数据集合 + */ + public static void exportToResponse(HttpServletResponse response, String zipFileName, List excelList) { + response.setContentType("application/zip"); + response.setCharacterEncoding("UTF-8"); + try { + response.setHeader("Content-Disposition", + "attachment; filename=" + URLEncoder.encode(zipFileName, "UTF-8")); + } catch (UnsupportedEncodingException e) { + response.setHeader("Content-Disposition", "attachment; filename=export.zip"); + } + + try (ServletOutputStream os = response.getOutputStream()) { + exportToStream(os, excelList); + os.flush(); + } catch (IOException e) { + log.error("导出 ZIP 到 Response 失败", e); + throw new RuntimeException("导出失败: " + e.getMessage(), e); + } + } + + /** + * 导出 ZIP 压缩包到本地文件 + * + * @param targetFile 目标文件 + * @param excelList 要导出的 Excel 数据集合 + */ + public static void exportToFile(File targetFile, List excelList) { + try (FileOutputStream fos = new FileOutputStream(targetFile)) { + exportToStream(fos, excelList); + } catch (IOException e) { + log.error("导出 ZIP 到文件失败: {}", targetFile.getAbsolutePath(), e); + throw new RuntimeException("导出失败: " + e.getMessage(), e); + } + } + + /** + * 导出 ZIP 压缩包到任意 OutputStream + *

+ * 实现原理:先将每个 Excel 写入临时字节数组,再用内存 ZIP 打包,避免磁盘 IO。 + * + * @param os 目标输出流 + * @param excelList 要导出的 Excel 数据集合 + */ + public static void exportToStream(OutputStream os, List excelList) throws IOException { + if (excelList == null || excelList.isEmpty()) { + log.warn("导出数据为空,跳过 ZIP 生成"); + return; + } + + // 延迟创建 ZIP,累计所有 Excel 字节数组 + ByteArrayOutputStream zipBaos = new ByteArrayOutputStream(); + try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(zipBaos)) { + for (ExcelData excelData : excelList) { + if (excelData.getSheets() == null || excelData.getSheets().isEmpty()) { + continue; + } + byte[] excelBytes = workbookToBytes(createWorkbook(excelData)); + String entryName = sanitizeFileName(excelData.getFileName()) + ".xlsx"; + + java.util.zip.ZipEntry entry = new java.util.zip.ZipEntry(entryName); + zos.putNextEntry(entry); + zos.write(excelBytes); + zos.closeEntry(); + } + zos.finish(); + } + + os.write(zipBaos.toByteArray()); + } + + // ======================== Excel 生成方法 ======================== + + /** + * 根据 ExcelData 创建一个完整的 Excel Workbook(含所有 Sheet) + *

+ * 使用 SXSSFWorkbook(流式写入),支持大数据量导出,内存占用可控。 + * + * @param excelData 单个 Excel 的数据定义 + * @return 填充完毕的 Workbook(调用方负责关闭以释放临时文件) + */ + public static Workbook createWorkbook(ExcelData excelData) { + SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 内存中保留 100 行,其余写临时文件 + CellStyle headerStyle = createHeaderStyle(workbook); + CellStyle dataStyle = createDataStyle(workbook); + + for (SheetData sheetData : excelData.getSheets()) { + if (sheetData == null) { + continue; + } + fillSheet(workbook, sheetData, headerStyle, dataStyle); + } + + return workbook; + } + + /** + * 填充单个 Sheet 页的数据 + * + * @param workbook 目标 Workbook + * @param sheetData Sheet 数据定义 + * @param headerStyle 表头样式 + * @param dataStyle 数据行样式 + */ + public static void fillSheet(Workbook workbook, SheetData sheetData, CellStyle headerStyle, CellStyle dataStyle) { + String name = safeName(sheetData.getSheetName()); + Sheet sheet = workbook.createSheet(name); + + // SXSSFSheet 必须在写入数据前开启列追踪,否则 autoSizeColumn 会报错 + if (sheet instanceof SXSSFSheet) { + ((SXSSFSheet) sheet).trackAllColumnsForAutoSizing(); + } + + List headers = sheetData.getHeaders(); + List> rows = sheetData.getRows(); + int colCount = headers != null ? headers.size() : 0; + + // 设置列宽默认值(后续在数据写入后统一自动调整) + int currentRow = 0; + + // ---- 写表头 ---- + if (headers != null && !headers.isEmpty()) { + Row headerRow = sheet.createRow(currentRow++); + headerRow.setHeightInPoints(22); + for (int i = 0; i < colCount; i++) { + Cell cell = headerRow.createCell(i); + cell.setCellValue(headers.get(i)); + cell.setCellStyle(headerStyle); + } + } + + // ---- 写数据行 ---- + if (rows != null) { + for (List rowData : rows) { + Row dataRow = sheet.createRow(currentRow++); + for (int i = 0; i < Math.min(rowData.size(), colCount); i++) { + Cell cell = dataRow.createCell(i); + Object value = rowData.get(i); + setCellValue(cell, value); + cell.setCellStyle(dataStyle); + } + } + } + + // ---- 自动调整列宽 ---- + autoSizeColumns(sheet, colCount); + + // 冻结首行(表头固定) + if (currentRow > 1) { + sheet.createFreezePane(0, 1); + } + } + + // ======================== 样式定义 ======================== + + /** + * 创建表头样式(蓝色背景、白色加粗字体、居中对齐、带边框) + */ + public static CellStyle createHeaderStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + + // 背景色:深蓝 + style.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + // 字体:白色加粗 + Font font = workbook.createFont(); + font.setBold(true); + font.setColor(IndexedColors.WHITE.getIndex()); + font.setFontHeightInPoints((short) 11); + style.setFont(font); + + // 对齐:居中 + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + + // 边框 + setThinBorder(style); + + return style; + } + + /** + * 创建数据行样式(无背景、正常字体、左对齐、带边框) + */ + public static CellStyle createDataStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + + style.setAlignment(HorizontalAlignment.LEFT); + style.setVerticalAlignment(VerticalAlignment.CENTER); + + // 自动换行 + style.setWrapText(true); + + setThinBorder(style); + + return style; + } + + /** + * 设置细线边框 + */ + private static void setThinBorder(CellStyle style) { + style.setBorderTop(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + } + + // ======================== 列宽自动调整 ======================== + + /** + * 自动调整列宽(根据内容宽度) + *

+ * 遍历每一列,取表头和所有数据行中最宽的内容作为列宽基准。 + * + * @param sheet Sheet 对象 + * @param colCount 列数 + */ + public static void autoSizeColumns(Sheet sheet, int colCount) { + for (int i = 0; i < colCount; i++) { + sheet.autoSizeColumn(i); + // 自动列宽后留一点余量,避免内容紧贴边框 + int currentWidth = sheet.getColumnWidth(i); + if (currentWidth > 0) { + sheet.setColumnWidth(i, Math.min(currentWidth + 512, 255 * 256)); // 最大 255 字符宽 + } + } + } + + // ======================== 辅助方法 ======================== + + /** + * 类型安全方式构建 SheetData + *

+ * 适用于已有实体类 List 的场景,通过方法引用指定字段取值,避免手动逐字段提取。 + * + *

{@code
+     * SheetData data = ExportZipUtil.fromTypedData("电站信息",
+     *         Arrays.asList("编码", "名称", "经度", "纬度"),
+     *         Arrays.asList(Station::getStcd, Station::getStnm, Station::getLgtd, Station::getLttd),
+     *         stationList);
+     * }
+ * + * @param sheetName Sheet 名称 + * @param headers 表头列表 + * @param extractors 字段取值函数(与 headers 一一对应) + * @param data 实体数据集合 + * @param 实体类型 + * @return 构建好的 SheetData + */ + public static SheetData fromTypedData(String sheetName, + List headers, + List> extractors, + List data) { + List> rows = data.stream() + .map(entity -> extractors.stream() + .map(ext -> ext.apply(entity)) + .collect(Collectors.toList())) + .collect(Collectors.toList()); + return new SheetData(sheetName, headers, rows); + } + + /** + * 将 Workbook 写入字节数组并关闭 Workbook + */ + private static byte[] workbookToBytes(Workbook workbook) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + workbook.write(baos); + } finally { + workbook.close(); + // SXSSFWorkbook 会清理临时文件 + if (workbook instanceof SXSSFWorkbook) { + ((SXSSFWorkbook) workbook).dispose(); + } + } + return baos.toByteArray(); + } + + /** + * 文件名非法字符过滤(Windows + 通用) + */ + private static String sanitizeFileName(String fileName) { + if (fileName == null || fileName.isEmpty()) { + return "sheet"; + } + return fileName.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + /** + * Sheet 名称安全处理(Excel Sheet 名最长 31 字符) + */ + private static String safeName(String name) { + if (name == null || name.isEmpty()) { + return "Sheet1"; + } + // Excel Sheet 名禁止包含 [ ] : * ? / \ + String safe = name.replaceAll("[\\[\\]:*?/\\\\]", "_"); + return safe.length() > 31 ? safe.substring(0, 31) : safe; + } + + /** + * 设置单元格值(自动适配类型) + */ + private static void setCellValue(Cell cell, Object value) { + if (value == null) { + cell.setCellValue(""); + } else if (value instanceof Number) { + cell.setCellValue(((Number) value).doubleValue()); + } else if (value instanceof Boolean) { + cell.setCellValue((Boolean) value); + } else if (value instanceof java.util.Date) { + cell.setCellValue((java.util.Date) value); + } else { + cell.setCellValue(String.valueOf(value)); + } + } + + // ======================== 测试方法 ======================== + + /** + * 本地运行测试,生成模拟数据 ZIP 文件到 D:/export_sample.zip + */ + public static void main(String[] args) { + // ---------- Excel 1:电站基础数据(1个Sheet:基础信息) ---------- + List> stationRows = new ArrayList<>(); + for (int i = 1; i <= 50; i++) { + stationRows.add(Arrays.asList( + "STCD" + String.format("%05d", i), // 测站编码 + "XX水电站" + i + "号", // 测站名称 + "长江流域", // 所在流域 + 110.123456 + Math.random() * 10, // 经度 + 30.654321 + Math.random() * 10, // 纬度 + "坝式水电站", // 工程类型 + Math.random() > 0.5 ? "运行中" : "在建", // 建设状态 + "省级", // 级别 + 2000 + (i % 24) // 建成时间 + )); + } + SheetData stationSheet = new SheetData("电站基础信息", + Arrays.asList("测站编码", "测站名称", "所在流域", "经度", "纬度", + "工程类型", "建设状态", "级别", "建成时间"), + stationRows); + + // ---------- Sheet 2:水位监测数据 ---------- + List> monitorRows = new ArrayList<>(); + String[] months = {"2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"}; + for (int i = 1; i <= 30; i++) { + for (String month : months) { + monitorRows.add(Arrays.asList( + "STCD" + String.format("%05d", i), + "XX水电站" + i + "号", + month, + String.format("%.2f", 100 + Math.random() * 50), // 水位(m) + String.format("%.2f", 500 + Math.random() * 2000), // 流量(m³/s) + String.format("%.1f", 15 + Math.random() * 20), // 水温(℃) + Math.random() > 0.8 ? "超标" : "正常" // 水质状态 + )); + } + } + SheetData monitorSheet = new SheetData("水位监测数据", + Arrays.asList("测站编码", "测站名称", "监测月份", "水位(m)", "流量(m³/s)", + "水温(℃)", "水质状态"), + monitorRows); + + // ---------- Excel 2:流域概况数据 ---------- + List> basinRows = new ArrayList<>(); + String[] basinNames = {"长江流域", "黄河流域", "珠江流域", "淮河流域", "松花江流域", "海河流域"}; + String[] provinces = {"湖北/湖南/四川", "河南/山东/陕西", "广东/广西", "安徽/江苏", "黑龙江/吉林", "河北/天津"}; + for (int i = 0; i < basinNames.length; i++) { + basinRows.add(Arrays.asList( + "RV" + String.format("%03d", i + 1), // 流域编码 + basinNames[i], // 流域名称 + i == 0 ? "一级" : "二级", // 级别 + provinces[i], // 覆盖省份 + 100 + Math.random() * 900, // 流域面积(万km²) + 500 + Math.random() * 5000, // 年径流量(亿m³) + (int) (1000 + Math.random() * 2000), // 电站数量 + "良好" // 生态状态 + )); + } + SheetData basinSheet = new SheetData("流域概况", + Arrays.asList("流域编码", "流域名称", "级别", "覆盖省份", "流域面积(万km²)", + "年径流量(亿m³)", "电站数量(个)", "生态状态"), + basinRows); + + // ---------- Sheet 4:鱼道设施数据 ---------- + List> fishwayRows = new ArrayList<>(); + String[] types = {"竖缝式鱼道", "池堰式鱼道", "升鱼机", "集运鱼船"}; + for (int i = 1; i <= 20; i++) { + fishwayRows.add(Arrays.asList( + "STCD" + String.format("%05d", i), + "XX水电站" + i + "号", + types[i % types.length], // 鱼道类型 + String.format("%.1f", 5 + Math.random() * 95), // 过鱼效率(%) + String.format("%.2f", 50 + Math.random() * 200), // 鱼道长度(m) + String.format("%.1f", 2 + Math.random() * 8), // 鱼道宽度(m) + Math.random() > 0.3 ? "运行中" : "检修中", // 运行状态 + "2025-0" + (1 + i % 9) + "-01" // 投运日期 + )); + } + SheetData fishwaySheet = new SheetData("鱼道设施", + Arrays.asList("电站编码", "电站名称", "鱼道类型", "过鱼效率(%)", + "鱼道长度(m)", "鱼道宽度(m)", "运行状态", "投运日期"), + fishwayRows); + + // ---------- 组装导出 ---------- + ExcelData excel1 = new ExcelData("电站综合数据", Arrays.asList(stationSheet, monitorSheet)); + ExcelData excel2 = new ExcelData("流域与鱼道数据", Arrays.asList(basinSheet, fishwaySheet)); + + String outputPath = "D:/export_sample.zip"; + try { + log.info("开始生成测试数据..."); + exportToFile(new File(outputPath), Arrays.asList(excel1, excel2)); + log.info("导出成功!文件路径: {}", outputPath); + System.out.println("导出成功!文件路径: " + outputPath); + } catch (Exception e) { + log.error("导出失败", e); + System.err.println("导出失败: " + e.getMessage()); + } + } +}