!53 refactor(excel): 提取Excel工具包为独立模块jeecg-module-tools,支持跨项目复用
* refactor(excel): 提取Excel工具包为独立模块jeecg-module-tools,支持跨项目复用 * fix(excel): 校验错误详情返回前端,ColumnMeta重构为Record,避免字典错后重复报NotBlank * Merge remote-tracking branch 'origin/master' * chore: 添加easyexcel依赖
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>org.jeecgframework.boot</groupId>
|
||||
<artifactId>jeecg-module-tools</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>jeecg-module-tools</name>
|
||||
<description>独立 Excel 导入导出工具包 — @ExcelColumn + DictQuery + EasyExcel,零框架耦合</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<easyexcel.version>4.0.3</easyexcel.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- EasyExcel 核心 -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>easyexcel</artifactId>
|
||||
<version>${easyexcel.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 字符串工具 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>3.12.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Bean Validation API -->
|
||||
<dependency>
|
||||
<groupId>javax.validation</groupId>
|
||||
<artifactId>validation-api</artifactId>
|
||||
<version>2.0.1.Final</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Hibernate Validator(运行时的 JSR-303 实现) -->
|
||||
<dependency>
|
||||
<groupId>org.hibernate.validator</groupId>
|
||||
<artifactId>hibernate-validator</artifactId>
|
||||
<version>6.2.5.Final</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Servlet API(仅导出 write() 方法需要) -->
|
||||
<dependency>
|
||||
<groupId>javax.servlet</groupId>
|
||||
<artifactId>javax.servlet-api</artifactId>
|
||||
<version>4.0.1</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<source>${java.version}</source>
|
||||
<target>${java.version}</target>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package org.jeecg.common.util.excel;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
/**
|
||||
* @Excel 注解解析后的列元数据
|
||||
*/
|
||||
public record ColumnMeta(Field field, String name, double width, String format, String[] replace, boolean multiReplace,
|
||||
String numFormat, String groupName, int orderNum, String suffix, String databaseFormat,
|
||||
boolean columnHidden) {
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package org.jeecg.common.util.excel;
|
||||
|
||||
import com.alibaba.excel.metadata.Head;
|
||||
import com.alibaba.excel.metadata.data.WriteCellData;
|
||||
import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
|
||||
import com.alibaba.excel.write.style.column.AbstractColumnWidthStyleStrategy;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* EasyExcel 列宽处理器,读取 ColumnMeta 中的 width 设置列宽
|
||||
*/
|
||||
public class ColumnWidthHandler extends AbstractColumnWidthStyleStrategy {
|
||||
|
||||
private final List<ColumnMeta> columns;
|
||||
|
||||
public ColumnWidthHandler(List<ColumnMeta> columns) {
|
||||
this.columns = columns;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setColumnWidth(WriteSheetHolder writeSheetHolder,
|
||||
List<WriteCellData<?>> cellDataList,
|
||||
Cell cell, Head head,
|
||||
Integer relativeRowIndex, Boolean isHead) {
|
||||
if (isHead == null || !isHead || columns == null || columns.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int columnIndex = cell.getColumnIndex();
|
||||
if (columnIndex >= columns.size()) {
|
||||
return;
|
||||
}
|
||||
ColumnMeta meta = columns.get(columnIndex);
|
||||
Sheet sheet = cell.getSheet();
|
||||
if (meta.columnHidden()) {
|
||||
sheet.setColumnHidden(columnIndex, true);
|
||||
} else {
|
||||
sheet.setColumnWidth(columnIndex, (int) ((meta.width() + 2) * 256));
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package org.jeecg.common.util.excel;
|
||||
|
||||
/**
|
||||
* 字典查询接口,由调用方实现并注入,
|
||||
* 用于 Excel 导入导出时的字典翻译(如 sex=0 → 男)。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface DictQuery {
|
||||
|
||||
/**
|
||||
* 查询字典键值对映射
|
||||
*
|
||||
* @param dictTable 字典表名(来自 @ExcelColumn.dictTable())
|
||||
* @param dicCode 字典编码字段(来自 @ExcelColumn.dicCode())
|
||||
* @param dicText 字典文本字段(来自 @ExcelColumn.dicText())
|
||||
* @return 格式为 {"text_code", ...} 的数组,如 {"男_0", "女_1"};无结果返回 null 或空数组
|
||||
*/
|
||||
String[] query(String dictTable, String dicCode, String dicText);
|
||||
}
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
package org.jeecg.common.util.excel;
|
||||
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.common.util.excel.annotation.ExcelColumn;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.Validator;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* @ExcelColumn 注解工具类,用于 EasyExcel 导入导出。
|
||||
* 从实体类字段上读取 @ExcelColumn 注解,构建 EasyExcel 表头和数据,无需 @ExcelProperty。
|
||||
* <p>
|
||||
* 已解耦 jeecgframework,可独立打包复用。调用方通过 {@link DictQuery} 注入字典服务。
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ExcelAnnotationUtils {
|
||||
|
||||
private static volatile Validator cachedValidator;
|
||||
|
||||
private ExcelAnnotationUtils() {}
|
||||
|
||||
/**
|
||||
* 校验错误信息载体:行号 + 错误消息列表
|
||||
*/
|
||||
public record ValidationError(int row, List<String> messages) {}
|
||||
|
||||
// ==================== Validator ====================
|
||||
|
||||
/**
|
||||
* 获取 Validator 实例(懒加载缓存)。
|
||||
* 若调用方未通过 {@link #setValidator(Validator)} 注入,则自动创建纯 Java Validator。
|
||||
*/
|
||||
public static Validator getValidator() {
|
||||
if (cachedValidator != null) {
|
||||
return cachedValidator;
|
||||
}
|
||||
synchronized (ExcelAnnotationUtils.class) {
|
||||
if (cachedValidator == null) {
|
||||
cachedValidator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
log.info("【Excel工具】已创建默认 Validator(非 Spring 模式)");
|
||||
}
|
||||
}
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入自定义 Validator(如 Spring 管理的 Validator),需在首次调用前设置。
|
||||
*/
|
||||
public static void setValidator(Validator validator) {
|
||||
cachedValidator = validator;
|
||||
}
|
||||
|
||||
// ==================== 注解解析 ====================
|
||||
|
||||
/**
|
||||
* 解析 Class 上带 @ExcelColumn 注解的字段,按 orderNum 排序返回列元数据
|
||||
*
|
||||
* @param clazz 实体类
|
||||
* @param dictQuery 字典查询接口,可为 null(不启用字典翻译)
|
||||
*/
|
||||
public static List<ColumnMeta> parseColumns(Class<?> clazz, DictQuery dictQuery) {
|
||||
List<ColumnMeta> columns = new ArrayList<>();
|
||||
|
||||
for (Field field : getAllFields(clazz)) {
|
||||
ExcelColumn col = field.getAnnotation(ExcelColumn.class);
|
||||
if (col == null) {
|
||||
continue;
|
||||
}
|
||||
field.setAccessible(true);
|
||||
|
||||
String[] replace = col.replace();
|
||||
if (dictQuery != null && (StringUtils.isNotEmpty(col.dictTable()) || StringUtils.isNotEmpty(col.dicCode()))) {
|
||||
String[] dictReplace = dictQuery.query(col.dictTable(), col.dicCode(), col.dicText());
|
||||
if (dictReplace != null && dictReplace.length > 0) {
|
||||
replace = mergeReplace(replace, dictReplace);
|
||||
}
|
||||
}
|
||||
|
||||
columns.add(new ColumnMeta(
|
||||
field,
|
||||
col.name(),
|
||||
col.width(),
|
||||
StringUtils.isNotEmpty(col.exportFormat()) ? col.exportFormat() : col.format(),
|
||||
replace,
|
||||
col.multiReplace(),
|
||||
col.numFormat(),
|
||||
col.groupName(),
|
||||
parseOrderNum(col.orderNum()),
|
||||
col.suffix(),
|
||||
col.databaseFormat(),
|
||||
col.hidden()
|
||||
));
|
||||
}
|
||||
columns.sort(Comparator.comparingInt(ColumnMeta::orderNum));
|
||||
return columns;
|
||||
}
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
/**
|
||||
* 构建 EasyExcel 多级表头,支持 @ExcelColumn.groupName()
|
||||
*/
|
||||
public static List<List<String>> buildHead(List<ColumnMeta> columns) {
|
||||
boolean hasGroup = columns.stream().anyMatch(c -> StringUtils.isNotEmpty(c.groupName()));
|
||||
List<List<String>> head = new ArrayList<>();
|
||||
for (ColumnMeta col : columns) {
|
||||
if (hasGroup && StringUtils.isNotEmpty(col.groupName())) {
|
||||
head.add(Arrays.asList(col.groupName(), col.name()));
|
||||
} else {
|
||||
head.add(Collections.singletonList(col.name()));
|
||||
}
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据列表按 ColumnMeta 转换为 EasyExcel 的行数据
|
||||
*/
|
||||
public static List<List<Object>> convertData(List<ColumnMeta> columns, List<?> dataList) {
|
||||
List<List<Object>> rows = new ArrayList<>(dataList.size());
|
||||
for (Object row : dataList) {
|
||||
List<Object> rowData = new ArrayList<>(columns.size());
|
||||
for (ColumnMeta col : columns) {
|
||||
rowData.add(getCellValue(col, row));
|
||||
}
|
||||
rows.add(rowData);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写出 Excel 的公共入口:设置响应头 + 构建表头 + 写数据
|
||||
*
|
||||
* @param dictQuery 字典查询接口,可为 null
|
||||
*/
|
||||
public static void write(HttpServletResponse response, Class<?> clazz,
|
||||
String title, List<?> data, DictQuery dictQuery) throws IOException {
|
||||
List<ColumnMeta> columns = parseColumns(clazz, dictQuery);
|
||||
List<List<String>> head = buildHead(columns);
|
||||
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
String fileName = URLEncoder.encode(title, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||
response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
|
||||
|
||||
EasyExcel.write(response.getOutputStream())
|
||||
.head(head)
|
||||
.registerWriteHandler(new ColumnWidthHandler(columns))
|
||||
.sheet(title)
|
||||
.doWrite(convertData(columns, data));
|
||||
}
|
||||
|
||||
// ==================== 导入 ====================
|
||||
|
||||
/**
|
||||
* 从 Excel 输入流分批导入数据,基于 @ExcelColumn 注解读取列配置。
|
||||
*
|
||||
* @param dictQuery 字典查询接口,可为 null(不启用字典翻译)
|
||||
*/
|
||||
public static <T> int read(InputStream inputStream, Class<T> clazz,
|
||||
Consumer<List<T>> batchHandler, int batchSize,
|
||||
Consumer<List<ValidationError>> errorHandler,
|
||||
DictQuery dictQuery) {
|
||||
|
||||
List<ColumnMeta> columns = parseColumns(clazz, dictQuery);
|
||||
|
||||
Map<String, ColumnMeta> nameToCol = new LinkedHashMap<>();
|
||||
Map<String, String> displayToCode = new LinkedHashMap<>();
|
||||
|
||||
for (ColumnMeta col : columns) {
|
||||
nameToCol.put(col.name(), col);
|
||||
buildReverseReplace(col.replace(), displayToCode);
|
||||
}
|
||||
|
||||
ExcelImportListener<T> listener = new ExcelImportListener<>(
|
||||
clazz, batchSize, nameToCol, displayToCode, batchHandler, errorHandler
|
||||
);
|
||||
|
||||
EasyExcel.read(inputStream, listener).sheet().doRead();
|
||||
|
||||
return listener.getSuccessCount();
|
||||
}
|
||||
|
||||
// ==================== 内部辅助方法 ====================
|
||||
|
||||
static boolean isValidDictDisplay(String[] replace, String value) {
|
||||
for (String s : replace) {
|
||||
String[] arr = s.split("_");
|
||||
if (arr.length >= 2 && arr[0].equals(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void setFieldValue(ColumnMeta col, Object obj, String value,
|
||||
Map<String, String> displayToCode) throws Exception {
|
||||
Field field = col.field();
|
||||
Class<?> type = field.getType();
|
||||
|
||||
String resolved = displayToCode.getOrDefault(value, value);
|
||||
|
||||
if (type == String.class) {
|
||||
field.set(obj, resolved);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (type == Integer.class || type == int.class) {
|
||||
field.set(obj, Integer.valueOf(resolved));
|
||||
} else if (type == Long.class || type == long.class) {
|
||||
field.set(obj, Long.valueOf(resolved));
|
||||
} else if (type == Double.class || type == double.class) {
|
||||
field.set(obj, Double.valueOf(resolved));
|
||||
} else if (type == BigDecimal.class) {
|
||||
field.set(obj, new BigDecimal(resolved));
|
||||
} else if (type == Boolean.class || type == boolean.class) {
|
||||
field.set(obj, Boolean.valueOf(resolved));
|
||||
} else if (type == Date.class) {
|
||||
String format = StringUtils.isNotEmpty(col.databaseFormat()) ? col.databaseFormat() : col.format();
|
||||
if (StringUtils.isNotEmpty(format)) {
|
||||
field.set(obj, new SimpleDateFormat(format).parse(resolved));
|
||||
}
|
||||
} else if (type == LocalDateTime.class) {
|
||||
String format = col.format();
|
||||
if (StringUtils.isNotEmpty(format)) {
|
||||
field.set(obj, LocalDateTime.parse(resolved, DateTimeFormatter.ofPattern(format)));
|
||||
}
|
||||
} else if (type == LocalDate.class) {
|
||||
String format = col.format();
|
||||
if (StringUtils.isNotEmpty(format)) {
|
||||
field.set(obj, LocalDate.parse(resolved, DateTimeFormatter.ofPattern(format)));
|
||||
}
|
||||
} else if (type == Float.class || type == float.class) {
|
||||
field.set(obj, Float.valueOf(resolved));
|
||||
} else if (type == Short.class || type == short.class) {
|
||||
field.set(obj, Short.valueOf(resolved));
|
||||
} else {
|
||||
field.set(obj, resolved);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("【Excel导入】字段值类型转换失败, field: {}, value: {}", field.getName(), resolved);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 私有方法 ----
|
||||
|
||||
private static Object getCellValue(ColumnMeta col, Object obj) {
|
||||
try {
|
||||
Object value = col.field().get(obj);
|
||||
if (value == null || "".equals(value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (StringUtils.isNotEmpty(col.format())) {
|
||||
value = formatDate(value, col);
|
||||
}
|
||||
|
||||
String strValue = value.toString();
|
||||
|
||||
if (col.replace() != null && col.replace().length > 0) {
|
||||
strValue = col.multiReplace()
|
||||
? multiReplaceValue(col.replace(), strValue)
|
||||
: replaceValue(col.replace(), strValue);
|
||||
}
|
||||
|
||||
if (StringUtils.isNotEmpty(col.numFormat()) && value instanceof Number) {
|
||||
strValue = new DecimalFormat(col.numFormat()).format(value);
|
||||
}
|
||||
|
||||
if (StringUtils.isNotEmpty(col.suffix())) {
|
||||
strValue = strValue + col.suffix();
|
||||
}
|
||||
|
||||
return strValue;
|
||||
} catch (Exception e) {
|
||||
log.error("获取单元格值失败, field: {}, row: {}", col.field().getName(), obj, e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static Object formatDate(Object value, ColumnMeta col) throws Exception {
|
||||
if (value instanceof Date) {
|
||||
return new SimpleDateFormat(col.format()).format((Date) value);
|
||||
}
|
||||
if (value instanceof LocalDateTime) {
|
||||
return ((LocalDateTime) value).format(DateTimeFormatter.ofPattern(col.format()));
|
||||
}
|
||||
if (value instanceof LocalDate) {
|
||||
return ((LocalDate) value).format(DateTimeFormatter.ofPattern(col.format()));
|
||||
}
|
||||
if (value instanceof String && StringUtils.isNotEmpty(col.databaseFormat())) {
|
||||
Date parsed = new SimpleDateFormat(col.databaseFormat()).parse((String) value);
|
||||
return new SimpleDateFormat(col.format()).format(parsed);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String replaceValue(String[] replace, String value) {
|
||||
for (String s : replace) {
|
||||
String[] arr = s.split("_");
|
||||
if (arr.length == 2 && value.equals(arr[1])) {
|
||||
return arr[0];
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String multiReplaceValue(String[] replace, String value) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String v : value.split(",")) {
|
||||
String replaced = replaceValue(replace, v.trim());
|
||||
if (sb.length() > 0) sb.append(",");
|
||||
sb.append(replaced);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String[] mergeReplace(String[] original, String[] dictReplace) {
|
||||
if (original == null || original.length == 0) return dictReplace;
|
||||
if (dictReplace == null || dictReplace.length == 0) return original;
|
||||
String[] merged = Arrays.copyOf(original, original.length + dictReplace.length);
|
||||
System.arraycopy(dictReplace, 0, merged, original.length, dictReplace.length);
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static int parseOrderNum(String orderNum) {
|
||||
try {
|
||||
return Integer.parseInt(orderNum);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static void buildReverseReplace(String[] replace, Map<String, String> displayToCode) {
|
||||
if (replace == null || replace.length == 0) {
|
||||
return;
|
||||
}
|
||||
for (String s : replace) {
|
||||
String[] arr = s.split("_");
|
||||
if (arr.length >= 2) {
|
||||
displayToCode.put(arr[0], arr[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Field> getAllFields(Class<?> clazz) {
|
||||
List<Field> fields = new ArrayList<>();
|
||||
Class<?> current = clazz;
|
||||
while (current != null && current != Object.class) {
|
||||
Collections.addAll(fields, current.getDeclaredFields());
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package org.jeecg.common.util.excel;
|
||||
|
||||
import com.alibaba.excel.context.AnalysisContext;
|
||||
import com.alibaba.excel.metadata.data.ReadCellData;
|
||||
import com.alibaba.excel.read.listener.ReadListener;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.common.util.excel.ExcelAnnotationUtils.ValidationError;
|
||||
|
||||
import javax.validation.ConstraintViolation;
|
||||
import javax.validation.Validator;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 工业级 Excel 导入流式解析与全防御校验监听器
|
||||
*/
|
||||
@Slf4j
|
||||
public class ExcelImportListener<T> implements ReadListener<Map<Integer, String>> {
|
||||
|
||||
private final Class<T> clazz;
|
||||
private final int batchSize;
|
||||
private final Consumer<List<T>> batchHandler;
|
||||
private final Consumer<List<ValidationError>> errorHandler;
|
||||
|
||||
// 外部传入的数据支撑
|
||||
private final Map<String, ColumnMeta> nameToCol;
|
||||
private final Map<String, String> displayToCode;
|
||||
|
||||
// 内部状态机
|
||||
private final List<T> batch;
|
||||
private final List<ValidationError> errorBatch;
|
||||
private final Map<Integer, ColumnMeta> columnOrder = new LinkedHashMap<>();
|
||||
private final Map<String, String> fieldToColName = new HashMap<>();
|
||||
|
||||
private final int[] headRowIndex = {-1};
|
||||
private final int[] successCount = {0};
|
||||
private final int[] skipCount = {0};
|
||||
private final int[] validationFailCount = {0};
|
||||
|
||||
public ExcelImportListener(Class<T> clazz, int batchSize,
|
||||
Map<String, ColumnMeta> nameToCol, Map<String, String> displayToCode,
|
||||
Consumer<List<T>> batchHandler, Consumer<List<ValidationError>> errorHandler) {
|
||||
this.clazz = clazz;
|
||||
this.batchSize = batchSize;
|
||||
this.nameToCol = nameToCol;
|
||||
this.displayToCode = displayToCode;
|
||||
this.batchHandler = batchHandler;
|
||||
this.errorHandler = errorHandler;
|
||||
this.batch = new ArrayList<>(batchSize);
|
||||
this.errorBatch = new ArrayList<>(batchSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) {
|
||||
if (headRowIndex[0] >= 0) return;
|
||||
|
||||
for (Map.Entry<Integer, ReadCellData<?>> entry : headMap.entrySet()) {
|
||||
String headName = entry.getValue() != null ? entry.getValue().getStringValue() : null;
|
||||
if (headName != null) {
|
||||
ColumnMeta col = nameToCol.get(headName);
|
||||
if (col != null) {
|
||||
columnOrder.put(entry.getKey(), col);
|
||||
}
|
||||
}
|
||||
}
|
||||
headRowIndex[0] = context.readRowHolder().getRowIndex();
|
||||
|
||||
// 缓存 Java 属性名 -> Excel 表头列名的转换映射
|
||||
for (ColumnMeta cm : columnOrder.values()) {
|
||||
if (cm.field() != null) {
|
||||
fieldToColName.put(cm.field().getName(), cm.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invoke(Map<Integer, String> data, AnalysisContext context) {
|
||||
if (context.readRowHolder().getRowIndex() == headRowIndex[0]) return;
|
||||
int rowIndex = context.readRowHolder().getRowIndex();
|
||||
|
||||
try {
|
||||
T obj = clazz.getDeclaredConstructor().newInstance();
|
||||
List<String> rowErrors = new ArrayList<>();
|
||||
// 🆕 新增:用来记录哪些 Java 属性名已经发生了字典错误
|
||||
Set<String> dictFailedFields = new HashSet<>();
|
||||
|
||||
// 1. 赋值与字典校验
|
||||
for (Map.Entry<Integer, String> entry : data.entrySet()) {
|
||||
ColumnMeta col = columnOrder.get(entry.getKey());
|
||||
String cellValue = entry.getValue();
|
||||
|
||||
if (col == null || StringUtils.isEmpty(cellValue)) continue;
|
||||
|
||||
// 获取该列对应的 Java 属性名(字段名),例如 "gender"
|
||||
String propertyName = col.field().getName();
|
||||
|
||||
String[] replace = col.replace();
|
||||
if (replace != null && replace.length > 0) {
|
||||
if (!ExcelAnnotationUtils.isValidDictDisplay(replace, cellValue)) {
|
||||
rowErrors.add("【" + col.name() + "】的值'" + cellValue + "'不在字典范围内");
|
||||
dictFailedFields.add(propertyName); // 🆕 标记这个字段字典错漏
|
||||
continue; // 允许 continue,因为字典错的值无法安全转换
|
||||
}
|
||||
}
|
||||
|
||||
// 字典通过,或者不需要字典校验的,正常赋值
|
||||
ExcelAnnotationUtils.setFieldValue(col, obj, cellValue, displayToCode);
|
||||
}
|
||||
|
||||
// 2. Hibernate JSR-303 进阶校验
|
||||
Validator currentValidator = ExcelAnnotationUtils.getValidator();
|
||||
if (currentValidator != null && errorHandler != null) {
|
||||
Set<ConstraintViolation<T>> violations = currentValidator.validate(obj);
|
||||
if (!violations.isEmpty()) {
|
||||
for (ConstraintViolation<T> v : violations) {
|
||||
String propertyName = v.getPropertyPath().toString();
|
||||
|
||||
// 🆕 核心拦截:如果这个字段刚才已经报了“字典错误”,就别再报“不能为空”了
|
||||
if (dictFailedFields.contains(propertyName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String label = fieldToColName.getOrDefault(propertyName, propertyName);
|
||||
rowErrors.add("【" + label + "】" + v.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 错误统一拦截清算
|
||||
if (!rowErrors.isEmpty()) {
|
||||
handleError(rowIndex + 1, rowErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 成功加入批次
|
||||
batch.add(obj);
|
||||
successCount[0]++;
|
||||
if (batch.size() >= batchSize) {
|
||||
batchHandler.accept(new ArrayList<>(batch));
|
||||
batch.clear();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
skipCount[0]++;
|
||||
log.error("【Excel导入】行数据转换发生 system 异常, row: {}", rowIndex, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleError(int rowNum, List<String> errors) {
|
||||
validationFailCount[0]++;
|
||||
errorBatch.add(new ValidationError(rowNum, errors));
|
||||
if (errorBatch.size() >= batchSize) {
|
||||
errorHandler.accept(new ArrayList<>(errorBatch));
|
||||
errorBatch.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doAfterAllAnalysed(AnalysisContext context) {
|
||||
if (!batch.isEmpty()) {
|
||||
batchHandler.accept(new ArrayList<>(batch));
|
||||
batch.clear();
|
||||
}
|
||||
if (!errorBatch.isEmpty()) {
|
||||
errorHandler.accept(new ArrayList<>(errorBatch));
|
||||
errorBatch.clear();
|
||||
}
|
||||
if (skipCount[0] > 0 || validationFailCount[0] > 0) {
|
||||
log.warn("【Excel导入】导入完成, 成功: {} 行, 转换失败: {} 行, 校验失败: {} 行",
|
||||
successCount[0], skipCount[0], validationFailCount[0]);
|
||||
}
|
||||
}
|
||||
|
||||
public int getSuccessCount() {
|
||||
return successCount[0];
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package org.jeecg.common.util.excel.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Excel 导入导出列注解,替代 jeecgframework 的 @Excel,
|
||||
* 使 excel 工具包可独立打包复用。
|
||||
*/
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ExcelColumn {
|
||||
|
||||
/** 列名(表头),必填 */
|
||||
String name();
|
||||
|
||||
/** 列宽 */
|
||||
double width() default 15;
|
||||
|
||||
/** 日期显示格式(如 yyyy-MM-dd),导出时格式化 Date/LocalDate/LocalDateTime */
|
||||
String format() default "";
|
||||
|
||||
/** 导出专用日期格式,优先于 format */
|
||||
String exportFormat() default "";
|
||||
|
||||
/** 值替换映射(格式:text_code),如 {"男_1", "女_0"} */
|
||||
String[] replace() default {};
|
||||
|
||||
/** 是否多值替换(逗号分隔的多个值分别替换) */
|
||||
boolean multiReplace() default false;
|
||||
|
||||
/** 数字格式(DecimalFormat,如 #.##) */
|
||||
String numFormat() default "";
|
||||
|
||||
/** 分组表头(支持多级表头) */
|
||||
String groupName() default "";
|
||||
|
||||
/** 排序号(越小越靠前),字符串形式便于配置 */
|
||||
String orderNum() default "";
|
||||
|
||||
/** 单元格后缀 */
|
||||
String suffix() default "";
|
||||
|
||||
/** 数据库日期格式(导入时将字符串按此格式解析为 Date) */
|
||||
String databaseFormat() default "";
|
||||
|
||||
/** 是否隐藏该列 */
|
||||
boolean hidden() default false;
|
||||
|
||||
/** 字典表名(需搭配 DictQuery 使用) */
|
||||
String dictTable() default "";
|
||||
|
||||
/** 字典编码字段 */
|
||||
String dicCode() default "";
|
||||
|
||||
/** 字典文本字段 */
|
||||
String dicText() default "";
|
||||
}
|
||||
Reference in New Issue
Block a user