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 6b0ad40d..d4330eed 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
@@ -80,6 +80,348 @@ public class ImportFailureExcelUtil {
addValidationError(validationErrors, value != null && value.length() > maxLength, message);
}
+ /**
+ * 把「读取 Excel 阶段」的转换异常转成可读的失败原因。
+ *
+ * 背景:单元格里填了无法转换的内容(如金额列填「一万二千元」)时,EasyExcel 会在
+ * {@code ExcelUtil.read(...)} 阶段就抛 {@link cn.idev.excel.exception.ExcelDataConvertException},
+ * 根本走不到行级校验;若不兜底,用户看到的是
+ * 「Convert data ... to class java.math.BigDecimal error」这类技术文案。
+ *
+ * 本方法据异常里的行列位置与目标类型,生成「第N行「列名」格式不正确:<原值>」这样
+ * 用户能看懂、能定位的文案;列名靠表头反射拿 {@code @ExcelProperty},拿不到则退回列序号。
+ *
+ * @param exception 读取阶段抛出的异常
+ * @param excelClass 导入模型类(用于按列序号反查表头名)
+ * @return 可读的失败原因;非转换类异常返回 null(交由上层按原样处理)
+ */
+ public static String describeReadFailure(Throwable exception, Class> excelClass) {
+ cn.idev.excel.exception.ExcelDataConvertException convertException = findCause(exception,
+ cn.idev.excel.exception.ExcelDataConvertException.class);
+ if (convertException == null) {
+ return null;
+ }
+ Integer rowIndex = convertException.getRowIndex();
+ Integer columnIndex = convertException.getColumnIndex();
+ String columnName = columnName(excelClass, columnIndex);
+ // Excel 行号:EasyExcel 的 rowIndex 为 1 基数据行号(表头占第 1 行),故 Excel 行号 = rowIndex + 1
+ String rowText = rowIndex == null ? "第?行" : "第" + (rowIndex + 1) + "行";
+ String columnText = columnName == null ? ("第" + (columnIndex == null ? "?" : columnIndex + 1) + "列") : "「" + columnName + "」";
+ String value = cellValue(convertException);
+ StringBuilder message = new StringBuilder(rowText).append(columnText).append("格式不正确");
+ if (value != null && !value.isBlank()) {
+ message.append(":").append(value);
+ }
+ return message.toString();
+ }
+
+ /**
+ * 在异常链里查找指定类型的 cause
+ */
+ private static T findCause(Throwable throwable, Class type) {
+ for (Throwable current = throwable; current != null; current = current.getCause()) {
+ if (type.isInstance(current)) {
+ return type.cast(current);
+ }
+ if (current.getCause() == current) {
+ break;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 按列序号反查表头名(@ExcelProperty 的 value,取不到则用字段名)
+ */
+ private static String columnName(Class> excelClass, Integer columnIndex) {
+ if (excelClass == null || columnIndex == null) {
+ return null;
+ }
+ List fields = excelFields(excelClass);
+ if (columnIndex < 0 || columnIndex >= fields.size()) {
+ return null;
+ }
+ Field field = fields.get(columnIndex);
+ ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
+ if (excelProperty == null) {
+ return field.getName();
+ }
+ String[] value = excelProperty.value();
+ String columnName = value.length == 0 ? field.getName() : value[0];
+ return columnName.replace("*", "");
+ }
+
+ /**
+ * 取触发转换失败的单元格原值
+ */
+ private static String cellValue(cn.idev.excel.exception.ExcelDataConvertException exception) {
+ try {
+ cn.idev.excel.metadata.data.CellData> cellData = exception.getCellData();
+ if (cellData == null) {
+ return null;
+ }
+ Object data = cellData.getData();
+ if (data == null) {
+ data = cellData.getStringValue();
+ }
+ return data == null ? null : String.valueOf(data).trim();
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ /**
+ * 以「原始单元格文本」方式重读导入文件,得到的行用于构造读取阶段的失败明细。
+ *
+ * 用途:当 {@code ExcelUtil.read(file, XxxExcel.class)} 因单元格无法转换而失败时,
+ * 已解析的实体列表是不完整的(异常中断了整批读取)。此时用本方法重读一次:
+ * 不做类型转换(直接读成 Map),因此绝不会再抛转换异常,可拿到**原表全部行**,
+ * 供导出「原表全量 + 仅出错行带原因」的失败明细。
+ *
+ * @param file 上传的 Excel 文件
+ * @param excelClass 导入模型类(按列序号反查表头名,用于把列序号映射成列名)
+ * @return 按行组织的明细数据:每行是「表头名 → 单元格文本」的有序 Map;
+ * 读不出内容时返回空列表(调用方按空明细处理)
+ */
+ public static List