diff --git a/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java index 70d7183..e297cb5 100644 --- a/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java +++ b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java @@ -35,6 +35,7 @@ import cn.idev.excel.write.metadata.holder.WriteTableHolder; import jakarta.servlet.http.HttpServletResponse; 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.IndexedColors; import org.apache.poi.ss.usermodel.Workbook; @@ -46,9 +47,11 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** * 导入失败明细 Excel 导出工具类 @@ -95,7 +98,7 @@ public class ImportFailureExcelUtil { * @param excelClass 原导入 Excel 类型 */ public static void export(HttpServletResponse response, String fileName, String sheetName, List data, Class excelClass) { - exportFailureReasonOnly(response, fileName, sheetName, data, excelClass); + export(response, fileName, sheetName, data, excelClass, true); } /** @@ -109,6 +112,16 @@ public class ImportFailureExcelUtil { */ public static void exportFailureReasonOnly(HttpServletResponse response, String fileName, String sheetName, List data, Class excelClass) { + export(response, fileName, sheetName, data, excelClass, false); + } + + /** + * 导出导入失败明细 + * + * @param markErrorColumns 是否按失败原因里出现的列名,标红对应字段单元格 + */ + private static void export(HttpServletResponse response, String fileName, String sheetName, + List data, Class excelClass, boolean markErrorColumns) { response.setContentType("application/vnd.ms-excel"); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); String encodeFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8); @@ -118,7 +131,7 @@ public class ImportFailureExcelUtil { List> rows = buildRows(data, excelFields); try { FastExcel.write(response.getOutputStream()) - .registerWriteHandler(new ImportFailureCellStyleHandler(excelFields.size())) + .registerWriteHandler(new ImportFailureCellStyleHandler(excelFields, rows, markErrorColumns)) .head(head) .sheet(sheetName) .doWrite(rows); @@ -204,15 +217,60 @@ public class ImportFailureExcelUtil { throw new NoSuchFieldException(String.join(",", fieldNames)); } + /** + * 文本归一化:去掉空白、星号与中英文标点并统一小写 + *

+ * 目的是让「失败原因里写的列名」与「表头列名」能直接做包含匹配, + * 不受「*」「/」「:」等写法差异影响。 + */ + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[\\s*_::,,。;;()()\\[\\]【】<>《》//、-]", "").toLowerCase(); + } + + /** + * 取某一列用于定位的候选关键词 + *

+ * 包含表头列名、字段名,以及列名按分隔符拆出的片段 + * (如「车牌号/船号」拆出「车牌号」「船号」,失败原因只写其中一段时也能定位)。 + */ + private static List columnKeywords(Field field) { + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + String[] value = excelProperty.value(); + String columnName = value.length == 0 ? field.getName() : value[0]; + List keywords = new ArrayList<>(); + keywords.add(columnName); + keywords.add(field.getName()); + keywords.addAll(Arrays.asList(columnName.replace("*", "").split("[//、()()\\s]+"))); + return keywords.stream() + .map(ImportFailureExcelUtil::normalize) + .filter(keyword -> keyword.length() >= 2) + .distinct() + .toList(); + } + private static class ImportFailureCellStyleHandler implements CellWriteHandler { private final int failureReasonColumnIndex; + private final boolean markErrorColumns; + private final List> rows; + private final Map> redColumnsByRow = new HashMap<>(); private final Map redStyleCache = new HashMap<>(); + private final Map redFillStyleCache = new HashMap<>(); private final Map noWrapStyleCache = new HashMap<>(); private final Map columnWidthCache = new HashMap<>(); - private ImportFailureCellStyleHandler(int failureReasonColumnIndex) { - this.failureReasonColumnIndex = failureReasonColumnIndex; + private ImportFailureCellStyleHandler(List excelFields, List> rows, boolean markErrorColumns) { + this.failureReasonColumnIndex = excelFields.size(); + this.markErrorColumns = markErrorColumns; + this.rows = rows; + if (markErrorColumns) { + List> columnKeywords = excelFields.stream() + .map(ImportFailureExcelUtil::columnKeywords) + .toList(); + for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) { + redColumnsByRow.put(rowIndex, resolveRedColumns(rows.get(rowIndex), columnKeywords)); + } + } } @Override @@ -230,14 +288,95 @@ public class ImportFailureExcelUtil { return; } adjustColumnWidth(cell); - if (cell.getColumnIndex() == failureReasonColumnIndex) { - markRed(cell); + if (relativeRowIndex == null || relativeRowIndex < 0 || relativeRowIndex >= rows.size()) { + return; + } + if (shouldMarkRed(relativeRowIndex, cell.getColumnIndex())) { + markRed(cell, isBlankValue(rows.get(relativeRowIndex), cell.getColumnIndex())); } } - private void markRed(Cell cell) { + /** + * 计算某一行需要标红的列 + *

+ * 失败原因里出现的列名即视为出错列;但当某个命中片段被另一列更长的命中 + * 片段完全覆盖时(如原因「车船类型不能为空」同时命中「车船类型」和它的 + * 子串「类型」),只保留更长的那一列,避免把无关列一起标红。 + */ + private Set resolveRedColumns(List row, List> columnKeywords) { + Set redColumns = new LinkedHashSet<>(); + redColumns.add(failureReasonColumnIndex); + String failureReason = normalize(String.valueOf(row.get(failureReasonColumnIndex))); + if (failureReason.isEmpty()) { + return redColumns; + } + List matches = new ArrayList<>(); + for (int column = 0; column < columnKeywords.size(); column++) { + for (String keyword : columnKeywords.get(column)) { + int fromIndex = 0; + while (true) { + int index = failureReason.indexOf(keyword, fromIndex); + if (index < 0) { + break; + } + matches.add(new int[]{index, index + keyword.length(), column}); + fromIndex = index + 1; + } + } + } + for (int[] match : matches) { + if (!isSubsumed(match, matches)) { + redColumns.add(match[2]); + } + } + return redColumns; + } + + /** + * 判断该命中片段是否被另一列更长的命中片段完全覆盖 + */ + private boolean isSubsumed(int[] match, List matches) { + int matchLength = match[1] - match[0]; + for (int[] other : matches) { + if (other[2] == match[2]) { + continue; + } + if ((other[1] - other[0]) > matchLength && other[0] <= match[0] && other[1] >= match[1]) { + return true; + } + } + return false; + } + + private boolean shouldMarkRed(int rowIndex, int columnIndex) { + if (columnIndex == failureReasonColumnIndex) { + return true; + } + if (!markErrorColumns) { + return false; + } + Set redColumns = redColumnsByRow.get(rowIndex); + return redColumns != null && redColumns.contains(columnIndex); + } + + private boolean isBlankValue(List row, int columnIndex) { + if (columnIndex < 0 || columnIndex >= row.size()) { + return true; + } + Object value = row.get(columnIndex); + return value == null || String.valueOf(value).isBlank(); + } + + /** + * 标红单元格 + *

+ * 单元格为空时只改字体颜色屏幕上什么也看不到(「必填项为空」正是这种情况), + * 因此对空单元格额外加浅红底纹,保证用户能定位到是哪一列出错。 + */ + private void markRed(Cell cell, boolean blankValue) { CellStyle currentStyle = cell.getCellStyle(); - CellStyle redStyle = redStyleCache.computeIfAbsent(currentStyle.getIndex(), styleIndex -> { + Map styleCache = blankValue ? redFillStyleCache : redStyleCache; + CellStyle redStyle = styleCache.computeIfAbsent(currentStyle.getIndex(), styleIndex -> { Workbook workbook = cell.getSheet().getWorkbook(); CellStyle newStyle = workbook.createCellStyle(); newStyle.cloneStyleFrom(currentStyle); @@ -245,6 +384,10 @@ public class ImportFailureExcelUtil { font.setColor(IndexedColors.RED.getIndex()); newStyle.setFont(font); newStyle.setWrapText(true); + if (blankValue) { + newStyle.setFillForegroundColor(IndexedColors.ROSE.getIndex()); + newStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + } return newStyle; }); cell.setCellStyle(redStyle);