diff --git a/backend/src/main/java/com/yfd/platform/annotation/Excel.java b/backend/src/main/java/com/yfd/platform/annotation/Excel.java new file mode 100644 index 00000000..f01e0792 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/annotation/Excel.java @@ -0,0 +1,82 @@ +package com.yfd.platform.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 通用 Excel 导出列注解(参考若依 RuoYi 的 @Excel 设计) + *

标注在实体字段上,配合 {@code ExcelUtil} 即可实现该字段的导出。 + * 支持的转换能力:字典翻译(dictType)、静态值转换(readConverterExp)、日期格式化。

+ *
+ * @Excel(name = "性别", dictType = "sys_user_sex", sort = 3)
+ * private Integer gender;
+ *
+ * @Excel(name = "状态", readConverterExp = "0=停用,1=正常", sort = 5)
+ * private Integer status;
+ *
+ * @Excel(name = "创建时间", dateFormat = "yyyy-MM-dd HH:mm:ss", sort = 6)
+ * private LocalDateTime createTime;
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Excel { + + /** + * 导出列名(Excel 表头) + */ + String name(); + + /** + * 列排序(数值越小越靠前;未标注 sort 的字段按声明顺序排在其后) + */ + int sort() default Integer.MAX_VALUE; + + /** + * 字典类型:如 "sys_user_sex",导出时自动将字典值翻译为字典标签 + * (复用项目 DictCache 缓存,无需额外查询) + */ + String dictType() default ""; + + /** + * 静态转换表达式:如 "0=正常,1=停用"(逗号分隔,等号前为值、后为标签) + * 优先级高于 {@link #dictType()} + */ + String readConverterExp() default ""; + + /** + * 日期格式(支持 java.util.Date、LocalDate、LocalDateTime) + */ + String dateFormat() default "yyyy-MM-dd HH:mm:ss"; + + /** + * 列宽(字符数),0 表示根据表头与内容自动计算 + */ + int width() default 0; + + /** + * 是否导出该列 + */ + boolean isExport() default true; + + /** + * 对齐方式 + */ + Align align() default Align.AUTO; + + /** + * 单元格对齐 + */ + enum Align { + /** 自动(文本左对齐、数值右对齐) */ + AUTO, + /** 左对齐 */ + LEFT, + /** 居中 */ + CENTER, + /** 右对齐 */ + RIGHT + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_base/controller/SdWtBHController.java b/backend/src/main/java/com/yfd/platform/qgc_base/controller/SdWtBHController.java index 2eb6d5c9..f0eed18b 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_base/controller/SdWtBHController.java +++ b/backend/src/main/java/com/yfd/platform/qgc_base/controller/SdWtBHController.java @@ -7,11 +7,15 @@ import com.yfd.platform.qgc_base.domain.SdWtBH; import com.yfd.platform.qgc_base.domain.SdEngInfoBHOperateRequest; import com.yfd.platform.qgc_base.service.ISdWtBHService; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yfd.platform.utils.ExcelUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.bind.annotation.*; +import java.io.IOException; + @RestController @RequestMapping("/base/wt") @Tag(name = "水温站管理") @@ -51,4 +55,11 @@ public class SdWtBHController { boolean result = service.delete(request == null ? null : request.getIds(), request == null ? null : request.getSource()); return result ? ResponseResult.success("删除成功") : ResponseResult.error("删除失败"); } + + @PostMapping("/export") + @Operation(summary = "水温站导出") + public void export(@RequestBody DataSourceRequest request, HttpServletResponse response) throws IOException { + Page page = service.queryPageList(request); + new ExcelUtil<>(SdWtBH.class).exportExcel(response, page.getRecords(), "水温站数据", "水温站导出"); + } } diff --git a/backend/src/main/java/com/yfd/platform/qgc_base/domain/SdWtBH.java b/backend/src/main/java/com/yfd/platform/qgc_base/domain/SdWtBH.java index 7f18dd9a..8a6151d2 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_base/domain/SdWtBH.java +++ b/backend/src/main/java/com/yfd/platform/qgc_base/domain/SdWtBH.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonFormat; +import com.yfd.platform.annotation.Excel; import com.yfd.platform.annotation.FieldChinese; import lombok.Data; @@ -23,15 +24,18 @@ public class SdWtBH implements Serializable { /** 水温站站码 */ @TableId(type = IdType.INPUT) @FieldChinese("水温站站码") + @Excel(name = "水温站站码") private String stcd; /** 数据时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") @FieldChinese("数据时间") + @Excel(name="数据时间") private Date tm; /** 水温站站名 */ @FieldChinese("水温站站名") + @Excel(name="水温站站名") private String stnm; /** 站类 */ @@ -39,38 +43,47 @@ public class SdWtBH implements Serializable { private String sttp; @TableField(exist = false) + @Excel(name="站类") private String sttpName; /** 经度 */ @FieldChinese("经度") + @Excel(name="经度") private BigDecimal lgtd; /** 纬度 */ @FieldChinese("纬度") + @Excel(name="纬度") private BigDecimal lttd; /** 高程 */ @FieldChinese("高程") + @Excel(name = "高程") private BigDecimal elev; /** 站址 */ @FieldChinese("站址") + @Excel(name="站址") private String stlc; /** 测站来源 */ @FieldChinese("测站来源") + @Excel(name="测站来源") private String stsr; /** 水温监测断面设备类型:1=浮动式 2=固定式 */ @FieldChinese("水温监测断面设备类型") + @Excel(name="水温监测断面设备类型") private Integer wtDeviceType; /** 水温监测断面类型 */ @FieldChinese("水温监测断面类型") + @Excel(name="水温监测断面类型") private String wtType; /** 电站水温结构类型 */ @FieldChinese("电站水温结构类型") + @Excel(name="电站水温结构类型") private String wts; /** 固定垂向水温类型:1=相对位置 2=高程 */ diff --git a/backend/src/main/java/com/yfd/platform/qgc_env/warn/service/impl/WarnDataServiceImpl.java b/backend/src/main/java/com/yfd/platform/qgc_env/warn/service/impl/WarnDataServiceImpl.java index fc896698..583515f6 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_env/warn/service/impl/WarnDataServiceImpl.java +++ b/backend/src/main/java/com/yfd/platform/qgc_env/warn/service/impl/WarnDataServiceImpl.java @@ -2620,7 +2620,7 @@ public class WarnDataServiceImpl implements WarnDataService { } String stcd = ao.getDvcd().trim(); String tm = DateUtil.formatDateTime(ao.getAitime()); - String remarkJson = JSONUtil.toJsonStr(ao.getAidata()); +// String remarkJson = JSONUtil.toJsonStr(ao.getAidata()); switch (ao.getType()) { case "0000": // ai心跳数据 @@ -2634,12 +2634,12 @@ public class WarnDataServiceImpl implements WarnDataService { aicomRows.add(new Object[]{ stcd, tm, "AI_5001", ao.getAidata().getArea().toPlainString(), - ao.getFiles(), remarkJson + ao.getFiles() }); break; case "5011": // 漂浮物清理 - aicomRows.add(new Object[]{stcd, tm, "AI_5011", null, ao.getFiles(), remarkJson}); + aicomRows.add(new Object[]{stcd, tm, "AI_5011", null, ao.getFiles()}); break; case "5021": // 视频流量 @@ -2647,7 +2647,7 @@ public class WarnDataServiceImpl implements WarnDataService { stcd, tm, "AI_5014", ao.getAidata() != null && ao.getAidata().getFlow() != null ? ao.getAidata().getFlow().toPlainString() : null, - ao.getFiles(), remarkJson + ao.getFiles() }); break; case "AI_RIV": @@ -2656,7 +2656,7 @@ public class WarnDataServiceImpl implements WarnDataService { aicomRows.add(new Object[]{ stcd, tm, ao.getType(), ao.getAidata() != null ? ao.getAidata().getValue() : null, - ao.getFiles(), remarkJson + ao.getFiles() }); break; default: @@ -2678,7 +2678,7 @@ public class WarnDataServiceImpl implements WarnDataService { sql.append(" UNION ALL "); } sql.append("SELECT ? AS STCD, TO_DATE(?, 'YYYY-MM-DD HH24:MI:SS') AS TM, ") - .append("? AS TYPE, ? AS AI_VAL, ? AS FID, ? AS REMARK FROM DUAL"); + .append("? AS TYPE, ? AS AI_VAL, ? AS FID AS REMARK FROM DUAL"); } sql.append(") source ON (target.STCD = source.STCD AND target.TM = source.TM AND target.TYPE = source.TYPE) ") .append("WHEN NOT MATCHED THEN INSERT (STCD, TM, TYPE, AI_VAL, FID, REMARK, RECORD_TIME, IS_DELETED) ") diff --git a/backend/src/main/java/com/yfd/platform/utils/ExcelUtil.java b/backend/src/main/java/com/yfd/platform/utils/ExcelUtil.java new file mode 100644 index 00000000..e2b10b19 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/utils/ExcelUtil.java @@ -0,0 +1,288 @@ +package com.yfd.platform.utils; + +import com.yfd.platform.annotation.Excel; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.apache.poi.xssf.usermodel.XSSFCellStyle; +import org.apache.poi.xssf.usermodel.XSSFColor; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 通用 Excel 导出工具(参考若依 RuoYi 的 ExcelUtil 设计) + *

+ * 使用方法: + *

    + *
  1. 在实体字段上标注 {@link Excel} 注解,声明列名、排序、字典翻译、日期格式等;
  2. + *
  3. Controller 中按需查询数据后调用:
    + *       List<SysUser> list = sysUserService.list(wrapper);
    + *       new ExcelUtil<>(SysUser.class).exportExcel(response, list, "用户数据", "用户数据");
    + *   
  4. + *
+ * 特性:流式写出(SXSSF,适合大数据量)、表头冻结与自动筛选、列宽自适应、 + * 字典翻译(复用 DictCache)、静态值转换(readConverterExp)、日期格式化、Long 转字符串避免精度丢失。 + */ +@Slf4j +public class ExcelUtil { + + /** 表头背景色(中蓝色 RGB) */ + private static final byte[] HEADER_BG = {(byte) 0x4A, (byte) 0x86, (byte) 0xC8}; + /** 列宽上限(字符数),防止超长内容撑爆表格 */ + private static final int MAX_COLUMN_WIDTH = 50; + + private final Class clazz; + /** 参与导出的字段列表(已按 @Excel.sort 升序排列,仅保留 isExport=true) */ + private final List exportFields; + + public ExcelUtil(Class clazz) { + this.clazz = clazz; + this.exportFields = Arrays.stream(clazz.getDeclaredFields()) + .filter(f -> f.isAnnotationPresent(Excel.class)) + .filter(f -> f.getAnnotation(Excel.class).isExport()) + .sorted(Comparator.comparingInt(f -> f.getAnnotation(Excel.class).sort())) + .collect(Collectors.toList()); + if (exportFields.isEmpty()) { + log.warn("ExcelUtil: 类 {} 未配置任何 @Excel 导出字段", clazz.getName()); + } + } + + // ==================== 对外 API ==================== + + /** + * 导出 Excel 到 HTTP 响应(自动设置 Content-Type 与文件名编码) + * + * @param response HTTP 响应 + * @param list 待导出数据 + * @param sheetName 工作表名称 + * @param fileName 下载文件名(不含扩展名) + */ + public void exportExcel(HttpServletResponse response, List list, String sheetName, String fileName) throws IOException { + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + encoded + ".xlsx"); + exportExcel(response.getOutputStream(), list, sheetName); + } + + /** + * 导出 Excel 到输出流(xlsx 格式) + * + * @param out 输出流 + * @param list 待导出数据 + * @param sheetName 工作表名称 + */ + public void exportExcel(OutputStream out, List list, String sheetName) throws IOException { + // SXSSF 流式写出:内存中只保留 100 行,适合大批量导出 + try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { + writeSheet(workbook, sheetName, list); + workbook.write(out); + workbook.dispose(); + } + } + + // ==================== 内部实现 ==================== + + private void writeSheet(SXSSFWorkbook workbook, String sheetName, List list) { + String safeName = sanitizeSheetName(sheetName); + Sheet sheet = workbook.createSheet(safeName); + int columnCount = exportFields.size(); + + CellStyle headerStyle = buildHeaderStyle(workbook); + CellStyle dataLeft = buildDataStyle(workbook, HorizontalAlignment.LEFT); + CellStyle dataCenter = buildDataStyle(workbook, HorizontalAlignment.CENTER); + CellStyle dataRight = buildDataStyle(workbook, HorizontalAlignment.RIGHT); + + int[] colMaxLen = new int[columnCount]; + + // 1. 表头行 + Row headerRow = sheet.createRow(0); + for (int i = 0; i < columnCount; i++) { + Excel excel = exportFields.get(i).getAnnotation(Excel.class); + Cell cell = headerRow.createCell(i); + cell.setCellValue(excel.name()); + cell.setCellStyle(headerStyle); + colMaxLen[i] = displayLength(excel.name()); + } + // 冻结表头,滚动时保持可见 + sheet.createFreezePane(0, 1); + + // 2. 数据行 + int dataRows = list == null ? 0 : list.size(); + for (int r = 0; r < dataRows; r++) { + Row row = sheet.createRow(r + 1); + T entity = list.get(r); + for (int i = 0; i < columnCount; i++) { + Field field = exportFields.get(i); + String text = convertToString(field, entity); + Cell cell = row.createCell(i); + cell.setCellValue(text); + cell.setCellStyle(chooseDataStyle(field, dataLeft, dataCenter, dataRight)); + int len = displayLength(text); + if (len > colMaxLen[i]) { + colMaxLen[i] = len; + } + } + } + + // 3. 列宽:注解指定优先,否则按内容自适应 + for (int i = 0; i < columnCount; i++) { + Excel excel = exportFields.get(i).getAnnotation(Excel.class); + int width = excel.width() > 0 ? excel.width() : Math.min(colMaxLen[i] + 2, MAX_COLUMN_WIDTH); + sheet.setColumnWidth(i, width * 256); + } + + // 4. 自动筛选(表头 + 数据区) + if (columnCount > 0) { + sheet.setAutoFilter(new CellRangeAddress(0, Math.max(dataRows, 0), 0, columnCount - 1)); + } + } + + /** + * 将实体字段值转换为 Excel 单元格字符串 + */ + private String convertToString(Field field, Object entity) { + Excel excel = field.getAnnotation(Excel.class); + Object value = getFieldValue(field, entity); + if (value == null) { + return ""; + } + String strValue = String.valueOf(value); + + // 1. 静态转换表达式优先:如 "0=停用,1=正常" + if (!excel.readConverterExp().isEmpty()) { + for (String item : excel.readConverterExp().split(",")) { + String[] kv = item.split("="); + if (kv.length == 2 && kv[0].trim().equals(strValue)) { + return kv[1].trim(); + } + } + return strValue; + } + + // 2. 字典翻译:如 dictType="sys_user_sex" → 字典标签 +// if (!excel.dictType().isEmpty()) { +// try { +// String label = SpringUtils.getBean(DictCache.class).getLabel(excel.dictType(), value); +// if (label != null) { +// return label; +// } +// } catch (Exception e) { +// log.warn("Excel 字典翻译失败: dictType={}, value={}, err={}", excel.dictType(), value, e.getMessage()); +// } +// } + + // 3. 日期格式化 + if (value instanceof LocalDateTime localDateTime) { + return localDateTime.format(DateTimeFormatter.ofPattern(excel.dateFormat())); + } + if (value instanceof LocalDate localDate) { + return localDate.format(DateTimeFormatter.ofPattern(excel.dateFormat())); + } + if (value instanceof Date date) { + return new SimpleDateFormat(excel.dateFormat()).format(date); + } + + // 4. 默认(Long 直接转字符串,避免 Excel 科学计数法与前端精度丢失) + return strValue; + } + + private Object getFieldValue(Field field, Object entity) { + try { + field.setAccessible(true); + return field.get(entity); + } catch (IllegalAccessException e) { + log.warn("Excel 读取字段值失败: field={}, err={}", field.getName(), e.getMessage()); + return null; + } + } + + /** + * 根据注解对齐方式选择数据单元格样式 + */ + private CellStyle chooseDataStyle(Field field, CellStyle left, CellStyle center, CellStyle right) { + Excel.Align align = field.getAnnotation(Excel.class).align(); + return switch (align) { + case CENTER -> center; + case RIGHT -> right; + case AUTO, LEFT -> left; + }; + } + + private CellStyle buildHeaderStyle(SXSSFWorkbook workbook) { + // SXSSF 底层为 XSSFWorkbook,样式均为 XSSFCellStyle,可直接设置自定义 RGB 颜色 + XSSFCellStyle style = (XSSFCellStyle) workbook.createCellStyle(); + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + setBorders(style); + style.setFillForegroundColor(new XSSFColor(HEADER_BG, null)); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Font font = workbook.createFont(); + font.setBold(true); + font.setColor(IndexedColors.WHITE.getIndex()); + font.setFontHeightInPoints((short) 11); + style.setFont(font); + return style; + } + + private CellStyle buildDataStyle(SXSSFWorkbook workbook, HorizontalAlignment alignment) { + CellStyle style = workbook.createCellStyle(); + style.setAlignment(alignment); + style.setVerticalAlignment(VerticalAlignment.CENTER); + setBorders(style); + return style; + } + + private void setBorders(CellStyle style) { + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + } + + /** + * 计算字符串显示宽度(中文字符按 2 个字符宽度计) + */ + private int displayLength(String s) { + if (s == null || s.isEmpty()) { + return 0; + } + int len = 0; + for (int i = 0; i < s.length(); i++) { + len += s.charAt(i) > 0xFF ? 2 : 1; + } + return len; + } + + /** + * 过滤 Excel 工作表名非法字符([]:*?/\ 等) + */ + private String sanitizeSheetName(String name) { + String safe = name == null || name.isBlank() ? "Sheet1" : name.replaceAll("[\\[\\]:*?/\\\\]", "_"); + return safe.length() > 31 ? safe.substring(0, 31) : safe; + } +}