|
|
@@ -0,0 +1,683 @@
|
|
|
+# Excel 导入功能前后端代码实现与使用规范
|
|
|
+
|
|
|
+> 本文档基于项目中"个人信息"和"企业信息"模块的导入功能实现进行总结,后续开发新模块的导入功能时,按照本文档的步骤和代码模板照写即可。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 一、整体架构概览
|
|
|
+
|
|
|
+导入功能的整体流程如下:
|
|
|
+
|
|
|
+```
|
|
|
+前端上传 Excel → 后端 Controller 接收 → AutoPoi 解析 Excel → Service 逐行处理
|
|
|
+→ 字典反向翻译 → 唯一键去重 → 必填校验 → 批量保存 → 返回校验结果
|
|
|
+```
|
|
|
+
|
|
|
+涉及的文件分层:
|
|
|
+
|
|
|
+| 层级 | 文件 | 职责 |
|
|
|
+|------|------|------|
|
|
|
+| 前端 - 公共组件 | `src/components/CustomImportModal/src/CustomImportModal.vue` | 通用的导入弹窗组件 |
|
|
|
+| 前端 - API 层 | `src/views/{module}/{Entity}Info.api.ts` | 定义导入/模板下载接口URL |
|
|
|
+| 前端 - 列表页 | `src/views/{module}/{Entity}InfoList.vue` | 集成 CustomImportModal 组件 |
|
|
|
+| 后端 - DTO | `personal/dto/ImportResultItem.java` | 导入校验结果的数据结构(通用) |
|
|
|
+| 后端 - Controller | `{module}/controller/{Entity}InfoController.java` | 接收文件上传、提供模板下载 |
|
|
|
+| 后端 - Service接口 | `{module}/service/I{Entity}InfoService.java` | 声明 importExcelData 方法 |
|
|
|
+| 后端 - Service实现 | `{module}/service/impl/{Entity}InfoServiceImpl.java` | 核心导入逻辑 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 二、后端实现详解
|
|
|
+
|
|
|
+### 2.1 ImportResultItem — 导入校验结果通用 DTO
|
|
|
+
|
|
|
+**文件位置**: `personal/dto/ImportResultItem.java`
|
|
|
+
|
|
|
+```java
|
|
|
+package org.jeecg.modules.zjrs.personal.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 导入结果行数据:包含原始Excel列值 + 错误信息(如有)
|
|
|
+ */
|
|
|
+@Data
|
|
|
+public class ImportResultItem {
|
|
|
+ /** Excel行号 */
|
|
|
+ private int rowNum;
|
|
|
+ /** 该行各列的原始文本值:Excel列名 → 值 */
|
|
|
+ private Map<String, String> rowData;
|
|
|
+ /** 错误信息:null或空表示该行校验通过 */
|
|
|
+ private String errorMsg;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 此 DTO 是通用结构,所有模块共用。`rowData` 存放该行所有 Excel 列的原始值(key 为 Excel 列名 @Excel.name),`errorMsg` 为 null 表示该行校验通过。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2.2 Service 接口 — 声明导入方法
|
|
|
+
|
|
|
+**文件位置**: `{module}/service/I{Entity}InfoService.java`
|
|
|
+
|
|
|
+在接口中新增 `importExcelData` 方法声明:
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 批量导入Excel数据
|
|
|
+ * 对解析后的数据进行:字典文本→字典码反向翻译、唯一键去重、必填字段校验、批量保存
|
|
|
+ *
|
|
|
+ * @param list Excel解析后的数据列表
|
|
|
+ * @return 每行的导入结果(含原始列值 + 错误信息),当全部无错误时内部会执行批量保存
|
|
|
+ */
|
|
|
+List<ImportResultItem> importExcelData(List<{Entity}Info> list);
|
|
|
+```
|
|
|
+
|
|
|
+示例参考:
|
|
|
+- `IPersonalInfoService.java` ([service/IPersonalInfoService.java](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/personal/service/IPersonalInfoService.java#L81))
|
|
|
+- `IEnterpriseInfoService.java` ([service/IEnterpriseInfoService.java](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/enterprise/service/IEnterpriseInfoService.java#L33))
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2.3 Service 实现 — 导入核心逻辑(最重要)
|
|
|
+
|
|
|
+**文件位置**: `{module}/service/impl/{Entity}InfoServiceImpl.java`
|
|
|
+
|
|
|
+核心方法 `importExcelData` 执行以下步骤:
|
|
|
+
|
|
|
+```
|
|
|
+步骤0:定义常量 → 步骤1:字典文本→字典码反向翻译映射 → 步骤2:唯一键去重(Excel内部+数据库)
|
|
|
+→ 步骤3:逐行校验(字典翻译 + 必填校验 + 补充字段)→ 步骤4:有错误则返回错误明细,无错误则批量保存
|
|
|
+```
|
|
|
+
|
|
|
+#### 2.3.1 必须定义的三个常量
|
|
|
+
|
|
|
+在 ServiceImpl 类中定义三个全局常量:
|
|
|
+
|
|
|
+```java
|
|
|
+// 常量1:导入时需要进行 文本→字典码 反向翻译的字段:entity字段名 → 字典编码
|
|
|
+// 只有字典字段(前端是下拉选择而非自由输入的字段)才需要配置此处
|
|
|
+private static final Map<String, String> IMPORT_DICT_FIELD_MAP = new LinkedHashMap<>();
|
|
|
+
|
|
|
+static {
|
|
|
+ IMPORT_DICT_FIELD_MAP.put("gender", "Gender"); // entity字段名 → 字典编码
|
|
|
+ IMPORT_DICT_FIELD_MAP.put("education", "Education");
|
|
|
+ // ... 根据实际业务字段添加
|
|
|
+}
|
|
|
+
|
|
|
+// 常量2:导入模板的字段名列表,顺序必须与 Controller 中 importTemplate 的 fieldNames 一致
|
|
|
+private static final List<String> IMPORT_FIELD_NAMES = Arrays.asList(
|
|
|
+ "idType", "idNumber", "fullName", "gender", "birthDate"
|
|
|
+ // ... 所有模板列字段(包括必填和非必填),顺序与模板导出时的列顺序严格一致
|
|
|
+);
|
|
|
+
|
|
|
+// 常量3:日期格式化器
|
|
|
+private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
|
|
|
+```
|
|
|
+
|
|
|
+> **注意**:`IMPORT_FIELD_NAMES` 的顺序必须与 Controller 中 `importTemplate` 方法里的 `fieldNames` 数组顺序完全一致,否则错误反馈时列名和列值会错位。
|
|
|
+
|
|
|
+#### 2.3.2 核心方法完整代码模板
|
|
|
+
|
|
|
+```java
|
|
|
+@Override
|
|
|
+public List<ImportResultItem> importExcelData(List<{Entity}Info> list) {
|
|
|
+ // ========== 步骤1:批量查询所有字典项,构建 文本→字典码 的反向映射表 ==========
|
|
|
+ Map<String, Map<String, String>> dictTextToCodeMap = new HashMap<>();
|
|
|
+ for (Map.Entry<String, String> entry : IMPORT_DICT_FIELD_MAP.entrySet()) {
|
|
|
+ String dictCode = entry.getValue();
|
|
|
+ if (!dictTextToCodeMap.containsKey(dictCode)) {
|
|
|
+ // 从字典表查询,构建 中文文本→字典码 映射
|
|
|
+ List<DictModel> items = dictionaryItemMapper.queryDictItemsByCode(dictCode);
|
|
|
+ Map<String, String> textToCode = new HashMap<>();
|
|
|
+ for (DictModel item : items) {
|
|
|
+ textToCode.put(item.getText(), item.getValue());
|
|
|
+ }
|
|
|
+ dictTextToCodeMap.put(dictCode, textToCode);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 步骤2:唯一键去重(Excel内部 + 数据库已存在)==========
|
|
|
+ // 以"统一社会信用代码"或"证件号码"作为唯一键,检查Excel中是否有重复、数据库中是否已存在
|
|
|
+ Map<String, List<Integer>> uniqueKeyPositions = new HashMap<>();
|
|
|
+ for (int i = 0; i < list.size(); i++) {
|
|
|
+ String uniqueKey = list.get(i).getUniqueKeyField(); // 替换为你的唯一键getter
|
|
|
+ if (oConvertUtils.isNotEmpty(uniqueKey)) {
|
|
|
+ uniqueKeyPositions.computeIfAbsent(uniqueKey, k -> new ArrayList<>()).add(i);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查Excel内部重复
|
|
|
+ Map<String, String> uniqueKeyDuplicateMsg = new HashMap<>();
|
|
|
+ for (Map.Entry<String, List<Integer>> entry : uniqueKeyPositions.entrySet()) {
|
|
|
+ if (entry.getValue().size() > 1) {
|
|
|
+ String msg = "唯一键【" + entry.getKey() + "】在数据中多次出现(行:"
|
|
|
+ + entry.getValue().stream().map(pos -> String.valueOf(pos + 3))
|
|
|
+ .collect(Collectors.joining("、"))
|
|
|
+ + ")";
|
|
|
+ for (Integer pos : entry.getValue()) {
|
|
|
+ uniqueKeyDuplicateMsg.put(String.valueOf(pos), msg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查数据库是否已存在
|
|
|
+ for (Map.Entry<String, List<Integer>> entry : uniqueKeyPositions.entrySet()) {
|
|
|
+ String uniqueKey = entry.getKey();
|
|
|
+ // 如果Excel内已经重复,不再重复报数据库重复
|
|
|
+ if (uniqueKeyDuplicateMsg.containsKey(String.valueOf(entry.getValue().get(0)))) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ LambdaQueryWrapper<{Entity}Info> queryWrapper = new LambdaQueryWrapper<>();
|
|
|
+ queryWrapper.eq({Entity}Info::getUniqueKeyField, uniqueKey); // 替换
|
|
|
+ long dbCount = this.count(queryWrapper);
|
|
|
+ if (dbCount > 0) {
|
|
|
+ for (Integer pos : entry.getValue()) {
|
|
|
+ uniqueKeyDuplicateMsg.put(String.valueOf(pos),
|
|
|
+ "唯一键【" + uniqueKey + "】已在系统中存在");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 步骤3:逐行校验与字段处理 ==========
|
|
|
+ List<ImportResultItem> resultItems = new ArrayList<>();
|
|
|
+ boolean hasAnyError = false;
|
|
|
+
|
|
|
+ for (int i = 0; i < list.size(); i++) {
|
|
|
+ {Entity}Info item = list.get(i);
|
|
|
+ int rowNum = i + 3; // Excel行号:标题1行 + 表头1行 = 前2行为头,数据从第3行开始
|
|
|
+
|
|
|
+ // 翻译前捕获原始Excel列值,用于错误反馈时展示原数据
|
|
|
+ Map<String, String> rowData = captureOriginalValues(item);
|
|
|
+ List<String> rowErrors = new ArrayList<>();
|
|
|
+
|
|
|
+ // 唯一键重复校验
|
|
|
+ String dupMsg = uniqueKeyDuplicateMsg.get(String.valueOf(i));
|
|
|
+ if (dupMsg != null) {
|
|
|
+ rowErrors.add(dupMsg);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 3.1 字典文本→字典码 反向翻译
|
|
|
+ for (Map.Entry<String, String> fieldEntry : IMPORT_DICT_FIELD_MAP.entrySet()) {
|
|
|
+ String fieldName = fieldEntry.getKey();
|
|
|
+ String dictCode = fieldEntry.getValue();
|
|
|
+ String textValue = (String) getPropertyValue(item, fieldName);
|
|
|
+ if (oConvertUtils.isNotEmpty(textValue)) {
|
|
|
+ Map<String, String> textToCode = dictTextToCodeMap.get(dictCode);
|
|
|
+ String codeValue = textToCode.get(textValue.trim());
|
|
|
+ if (codeValue != null) {
|
|
|
+ setPropertyValue(item, fieldName, codeValue);
|
|
|
+ } else {
|
|
|
+ // 字典中找不到对应值 → 校验失败
|
|
|
+ rowErrors.add("【" + getFieldExcelName(fieldName) + "】值\""
|
|
|
+ + textValue + "\"在字典\"" + dictCode + "\"中不存在");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3.2 校验必填字段(利用已有的 validateRequiredFields 方法)
|
|
|
+ if (rowErrors.isEmpty()) {
|
|
|
+ try {
|
|
|
+ validateRequiredFields(item);
|
|
|
+ } catch (JeecgBootBizTipException e) {
|
|
|
+ rowErrors.add(e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3.3 补充成对字段、设置默认值(根据业务需要)
|
|
|
+ if (rowErrors.isEmpty()) {
|
|
|
+ // 例如:设置数据来源为"导入"
|
|
|
+ item.setDataSource("2");
|
|
|
+ // 其他业务字段补充...
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ rowErrors.add(e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建该行的校验结果
|
|
|
+ ImportResultItem resultItem = new ImportResultItem();
|
|
|
+ resultItem.setRowNum(rowNum);
|
|
|
+ resultItem.setRowData(rowData); // 原始Excel列值
|
|
|
+ if (!rowErrors.isEmpty()) {
|
|
|
+ resultItem.setErrorMsg(String.join(";", rowErrors));
|
|
|
+ hasAnyError = true;
|
|
|
+ resultItems.add(resultItem);
|
|
|
+ }
|
|
|
+ // 没错误的不加入 resultItems(减少返回数据量)
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 步骤4:有错误则不保存,全部通过则批量保存 ==========
|
|
|
+ if (hasAnyError) {
|
|
|
+ return resultItems;
|
|
|
+ }
|
|
|
+
|
|
|
+ long start = System.currentTimeMillis();
|
|
|
+ this.saveBatch(list);
|
|
|
+ log.info("导入消耗时间" + (System.currentTimeMillis() - start) + "毫秒");
|
|
|
+ return resultItems;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 2.3.3 必须实现的三个辅助方法
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 根据字段名获取对应的Excel列名(反射读取 @Excel 注解的 name 属性)
|
|
|
+ * 用于错误提示时展示用户能看懂的列名,而非Java驼峰字段名
|
|
|
+ */
|
|
|
+private String getFieldExcelName(String fieldName) {
|
|
|
+ try {
|
|
|
+ java.lang.reflect.Field field = {Entity}Info.class.getDeclaredField(fieldName);
|
|
|
+ org.jeecgframework.poi.excel.annotation.Excel excel =
|
|
|
+ field.getAnnotation(org.jeecgframework.poi.excel.annotation.Excel.class);
|
|
|
+ if (excel != null) {
|
|
|
+ return excel.name();
|
|
|
+ }
|
|
|
+ } catch (NoSuchFieldException e) {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+ return fieldName; // 兜底:返回字段名
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 捕获原始Excel列值,以Excel列名作为key
|
|
|
+ * 用于校验失败时在错误详情弹窗中展示用户填写的原始数据
|
|
|
+ */
|
|
|
+private Map<String, String> captureOriginalValues({Entity}Info item) {
|
|
|
+ Map<String, String> rowData = new LinkedHashMap<>();
|
|
|
+ for (String fieldName : IMPORT_FIELD_NAMES) {
|
|
|
+ String excelName = getFieldExcelName(fieldName);
|
|
|
+ try {
|
|
|
+ Object value = getPropertyValue(item, fieldName);
|
|
|
+ if (value == null) {
|
|
|
+ rowData.put(excelName, "");
|
|
|
+ } else if (value instanceof java.util.Date) {
|
|
|
+ rowData.put(excelName, DATE_FORMAT.format(value)); // 日期格式化
|
|
|
+ } else {
|
|
|
+ rowData.put(excelName, value.toString());
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ rowData.put(excelName, "");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return rowData;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 通过 getter 方法名反射读取属性值,兼容 @Accessors(chain = true) 的实体
|
|
|
+ * 注意:这两个方法是通用的静态工具方法,可以抽取到公共工具类中复用
|
|
|
+ */
|
|
|
+private static Object getPropertyValue(Object bean, String propertyName) throws Exception {
|
|
|
+ String getterName = "get" + Character.toUpperCase(propertyName.charAt(0))
|
|
|
+ + propertyName.substring(1);
|
|
|
+ return bean.getClass().getMethod(getterName).invoke(bean);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 通过 setter 方法名反射设置属性值,兼容 @Accessors(chain = true) 的实体
|
|
|
+ */
|
|
|
+private static void setPropertyValue(Object bean, String propertyName, Object value)
|
|
|
+ throws Exception {
|
|
|
+ String setterName = "set" + Character.toUpperCase(propertyName.charAt(0))
|
|
|
+ + propertyName.substring(1);
|
|
|
+ bean.getClass().getMethod(setterName, value.getClass()).invoke(bean, value);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2.4 Controller — 接收导入请求与提供模板下载
|
|
|
+
|
|
|
+**文件位置**: `{module}/controller/{Entity}InfoController.java`
|
|
|
+
|
|
|
+#### 2.4.1 导入Excel数据接口
|
|
|
+
|
|
|
+```java
|
|
|
+@RequiresPermissions("{module}:{entityInfo}:importExcel")
|
|
|
+@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
|
|
|
+public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
|
|
|
+ MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
|
|
|
+ Map<String, MultipartFile> fileMap = multipartRequest.getFileMap();
|
|
|
+
|
|
|
+ for (Map.Entry<String, MultipartFile> entity : fileMap.entrySet()) {
|
|
|
+ MultipartFile file = entity.getValue();
|
|
|
+ ImportParams params = new ImportParams();
|
|
|
+ params.setTitleRows(1); // 标题行数(模板第0行为标题行)
|
|
|
+ params.setHeadRows(1); // 表头行数(模板第1行为表头行)
|
|
|
+ try {
|
|
|
+ // AutoPoi 将 Excel 解析为 Entity 列表
|
|
|
+ List<{Entity}Info> list = ExcelImportUtil.importExcel(
|
|
|
+ file.getInputStream(), {Entity}Info.class, params);
|
|
|
+
|
|
|
+ // 调用 Service 进行校验与保存
|
|
|
+ List<ImportResultItem> resultItems = {entity}InfoService.importExcelData(list);
|
|
|
+
|
|
|
+ // 检查是否有行校验失败
|
|
|
+ boolean hasError = false;
|
|
|
+ for (ImportResultItem item : resultItems) {
|
|
|
+ if (item.getErrorMsg() != null && !item.getErrorMsg().isEmpty()) {
|
|
|
+ hasError = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (hasError) {
|
|
|
+ // 返回 code=500 + result=校验错误行列表,前端会展示错误详情弹窗
|
|
|
+ return Result.error("导入数据校验失败", resultItems);
|
|
|
+ }
|
|
|
+ return Result.ok("文件导入成功!数据行数:" + list.size());
|
|
|
+ } catch (Exception e) {
|
|
|
+ String msg = e.getMessage();
|
|
|
+ log.error(msg, e);
|
|
|
+ if (msg != null && msg.contains("Duplicate entry")) {
|
|
|
+ return Result.error("文件导入失败:有重复数据!");
|
|
|
+ } else {
|
|
|
+ return Result.error("文件导入失败:" + e.getMessage());
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ try {
|
|
|
+ file.getInputStream().close();
|
|
|
+ } catch (IOException e) {
|
|
|
+ e.printStackTrace();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return Result.error("文件导入失败!");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **关键点**:
|
|
|
+> - 返回 `Result.error(msg, resultItems)` 时,前端检测到 `code=500` 且 `result` 是数组,就会打开错误详情弹窗
|
|
|
+> - `ImportParams.titleRows` 和 `headRows` 的值必须与导出的模板结构一致
|
|
|
+
|
|
|
+#### 2.4.2 下载导入模板接口
|
|
|
+
|
|
|
+```java
|
|
|
+@RequiresPermissions("{module}:{entityInfo}:importExcel")
|
|
|
+@RequestMapping(value = "/importTemplate")
|
|
|
+public void importTemplate(HttpServletRequest request, HttpServletResponse response)
|
|
|
+ throws Exception {
|
|
|
+ String title = "{业务名}导入模板";
|
|
|
+ ExportParams exportParams = new ExportParams(title, "导入模板", ExcelType.XSSF);
|
|
|
+
|
|
|
+ // 定义模板的列(必填在前,非必填在后)
|
|
|
+ String[] fieldNames = {"idType", "idNumber", "fullName", /* 所有列... */};
|
|
|
+
|
|
|
+ // 构建一条示例数据
|
|
|
+ List<{Entity}Info> dataList = new ArrayList<>(List.of(buildExampleData()));
|
|
|
+
|
|
|
+ // AutoPoi 生成完整 workbook(含列头、样式、示例数据)
|
|
|
+ Workbook workbook = ExcelExportUtil.exportExcel(
|
|
|
+ exportParams, {Entity}Info.class, dataList, fieldNames);
|
|
|
+
|
|
|
+ Sheet sheet = workbook.getSheetAt(0);
|
|
|
+ // AutoPoi 带 ExportParams(title,...) 时:第0行为标题行,第1行为表头行
|
|
|
+ Row headerRow = sheet.getRow(1);
|
|
|
+
|
|
|
+ // 定义必填字段集合(与 Service 中 validateRequiredFields 保持一致)
|
|
|
+ Set<String> requiredFields = new HashSet<>(Arrays.asList(
|
|
|
+ "idType", "idNumber", "fullName" /* 必填字段列表 */
|
|
|
+ ));
|
|
|
+
|
|
|
+ // 必填字段表头加红色加粗样式
|
|
|
+ CellStyle requiredStyle = workbook.createCellStyle();
|
|
|
+ Font requiredFont = workbook.createFont();
|
|
|
+ requiredFont.setColor(IndexedColors.RED.getIndex());
|
|
|
+ requiredFont.setBold(true);
|
|
|
+ requiredStyle.setFont(requiredFont);
|
|
|
+ // 复制 AutoPoi 默认的边框/对齐等样式
|
|
|
+ if (headerRow != null && headerRow.getCell(0) != null) {
|
|
|
+ requiredStyle.cloneStyleFrom(headerRow.getCell(0).getCellStyle());
|
|
|
+ requiredStyle.setFont(requiredFont);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (int i = 0; i < fieldNames.length; i++) {
|
|
|
+ Cell cell = headerRow.getCell(i);
|
|
|
+ if (cell != null && requiredFields.contains(fieldNames[i])) {
|
|
|
+ cell.setCellStyle(requiredStyle); // 必填字段表头标红
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 写出响应
|
|
|
+ response.setContentType(
|
|
|
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
|
|
+ response.setHeader("Content-disposition",
|
|
|
+ "attachment; filename=" + URLEncoder.encode(title + ".xlsx", "UTF-8"));
|
|
|
+ workbook.write(response.getOutputStream());
|
|
|
+ workbook.close();
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 构建模板的示例数据行
|
|
|
+ * 字典字段填写中文文本(与字典表一致),导入时会自动反向翻译为字典码
|
|
|
+ */
|
|
|
+private {Entity}Info buildExampleData() {
|
|
|
+ {Entity}Info example = new {Entity}Info();
|
|
|
+ example.setFullName("张三"); // 自由文本字段
|
|
|
+ example.setGender("男"); // 字典字段 → 填写中文标签文本
|
|
|
+ example.setEducation("大学本科"); // 字典字段 → 填写中文标签文本
|
|
|
+ // ... 所有模板列的示例数据
|
|
|
+ return example;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **注意**:`fieldNames` 数组的顺序必须与 ServiceImpl 中 `IMPORT_FIELD_NAMES` 常量完全一致。必填字段集合 `requiredFields` 要与 `validateRequiredFields` 方法中的校验逻辑一致。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 三、前端实现详解
|
|
|
+
|
|
|
+### 3.1 CustomImportModal 公共组件
|
|
|
+
|
|
|
+**文件位置**: `src/components/CustomImportModal/src/CustomImportModal.vue`
|
|
|
+
|
|
|
+该组件是通用的导入弹窗,所有模块可直接复用,无需修改组件源码。
|
|
|
+
|
|
|
+#### 组件功能
|
|
|
+
|
|
|
+1. **文件选择**:支持 `.xls`、`.xlsx` 格式,限制 2MB
|
|
|
+2. **模板下载**:可配置下载按钮(传入 `downloadUrl`)
|
|
|
+3. **上传导入**:调用 `importUrl` 上传文件
|
|
|
+4. **错误展示**:当后端返回 `code=500` + `result` 数组时,自动弹出错误详情表格,展示每行的原始 Excel 数据和错误原因
|
|
|
+
|
|
|
+#### Props 参数
|
|
|
+
|
|
|
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
|
|
+|------|------|------|--------|------|
|
|
|
+| `auth` | `String` | 否 | `''` | 权限标识,用于 v-auth 控制按钮显示 |
|
|
|
+| `importUrl` | `String` | **是** | — | 导入上传的接口URL |
|
|
|
+| `downloadUrl` | `String` | 否 | `''` | 模板下载的接口URL |
|
|
|
+| `templateName` | `String` | 否 | `'下载导入模板'` | 模板下载按钮文本 |
|
|
|
+| `buttonText` | `String` | 否 | `'导入'` | 触发按钮文本 |
|
|
|
+| `buttonIcon` | `String` | 否 | `'ant-design:import-outlined'` | 触发按钮图标 |
|
|
|
+| `buttonType` | `String` | 否 | `'primary'` | 触发按钮类型 |
|
|
|
+| `success` | `Function` | 否 | — | 导入成功后的回调函数 |
|
|
|
+
|
|
|
+#### Events
|
|
|
+
|
|
|
+| 事件名 | 说明 | 回调参数 |
|
|
|
+|--------|------|----------|
|
|
|
+| `success` | 导入成功后触发 | 无 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3.2 API 层 — 定义导入接口URL
|
|
|
+
|
|
|
+**文件位置**: `src/views/{module}/{Entity}Info.api.ts`
|
|
|
+
|
|
|
+在 API 枚举和导出函数中添加:
|
|
|
+
|
|
|
+```typescript
|
|
|
+enum Api {
|
|
|
+ // ... 其他接口
|
|
|
+ importExcel = '/enterprise/enterpriseInfo/importExcel', // 导入上传URL
|
|
|
+ importTemplate = '/enterprise/enterpriseInfo/importTemplate', // 模板下载URL
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 导入api(传给 CustomImportModal 的 importUrl)
|
|
|
+ */
|
|
|
+export const getImportUrl = Api.importExcel;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 导入模板下载api(传给 CustomImportModal 的 downloadUrl)
|
|
|
+ */
|
|
|
+export const getImportTemplateUrl = Api.importTemplate;
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3.3 列表页 — 集成 CustomImportModal
|
|
|
+
|
|
|
+**文件位置**: `src/views/{module}/{Entity}InfoList.vue`
|
|
|
+
|
|
|
+#### 3.3.1 模板中引入组件
|
|
|
+
|
|
|
+在表格标题区域(`#tableTitle` 插槽)中添加:
|
|
|
+
|
|
|
+```html
|
|
|
+<CustomImportModal
|
|
|
+ auth="{module}:{entityInfo}:importExcel"
|
|
|
+ :importUrl="getImportUrl"
|
|
|
+ :downloadUrl="getImportTemplateUrl"
|
|
|
+ templateName="{业务名}导入模板"
|
|
|
+ buttonText="导入"
|
|
|
+ @success="handleSuccess"
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.3.2 script 中导入
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { CustomImportModal } from '/@/components/CustomImportModal';
|
|
|
+import { getImportUrl, getImportTemplateUrl } from './{Entity}Info.api';
|
|
|
+```
|
|
|
+
|
|
|
+不需要在 `components` 中注册(组件已通过 `withInstall` 全局注册)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 四、新模块接入步骤清单
|
|
|
+
|
|
|
+按以下步骤操作,即可为新模块添加完整的导入功能:
|
|
|
+
|
|
|
+### 后端(5步)
|
|
|
+
|
|
|
+1. **确认 Entity 的 `@Excel` 注解**:实体类中需要用 `@Excel(name = "列名")` 标注字段,这样 AutoPoi 才能按列名解析 Excel,错误提示也能展示中文列名
|
|
|
+
|
|
|
+2. **Service 接口添加方法**:
|
|
|
+ ```java
|
|
|
+ List<ImportResultItem> importExcelData(List<{Entity}Info> list);
|
|
|
+ ```
|
|
|
+
|
|
|
+3. **ServiceImpl 实现 importExcelData**:
|
|
|
+ - 定义 `IMPORT_DICT_FIELD_MAP`(字典字段映射)
|
|
|
+ - 定义 `IMPORT_FIELD_NAMES`(模板列顺序,与第5步模板列顺序一致)
|
|
|
+ - 定义 `DATE_FORMAT`
|
|
|
+ - 照抄 2.3.2 节的代码模板,替换实体类名、唯一键字段
|
|
|
+ - 照抄 2.3.3 节的三个辅助方法
|
|
|
+
|
|
|
+4. **Controller 新增两个接口**:
|
|
|
+ - `POST /importExcel` — 照抄 2.4.1 节代码模板
|
|
|
+ - `GET /importTemplate` — 照抄 2.4.2 节代码模板,编写 `buildExampleData()` 方法
|
|
|
+
|
|
|
+5. **核对一致性**:
|
|
|
+ - Controller 的 `fieldNames` 顺序 = ServiceImpl 的 `IMPORT_FIELD_NAMES` 顺序
|
|
|
+ - Controller 的 `requiredFields` 集合 = ServiceImpl 的 `validateRequiredFields` 逻辑
|
|
|
+
|
|
|
+### 前端(2步)
|
|
|
+
|
|
|
+6. **API 层添加接口URL**:
|
|
|
+ ```typescript
|
|
|
+ export const getImportUrl = Api.importExcel;
|
|
|
+ export const getImportTemplateUrl = Api.importTemplate;
|
|
|
+ ```
|
|
|
+
|
|
|
+7. **列表页引入 CustomImportModal**:
|
|
|
+ ```html
|
|
|
+ <CustomImportModal
|
|
|
+ auth="权限标识"
|
|
|
+ :importUrl="getImportUrl"
|
|
|
+ :downloadUrl="getImportTemplateUrl"
|
|
|
+ templateName="模板名称"
|
|
|
+ @success="handleSuccess"
|
|
|
+ />
|
|
|
+ ```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 五、数据流转示意图
|
|
|
+
|
|
|
+```
|
|
|
+┌──────────────────────────────────────────────────────────────────────────┐
|
|
|
+│ 前端 │
|
|
|
+│ │
|
|
|
+│ [导入按钮] → [选择Excel文件] → [开始导入] │
|
|
|
+│ │ │ │
|
|
|
+│ ▼ ▼ │
|
|
|
+│ [下载模板] POST /importExcel │
|
|
|
+│ GET /importTemplate 上传 MultipartFile │
|
|
|
+│ │
|
|
|
+├──────────────────────────────────────────────────────────────────────────┤
|
|
|
+│ 后端 │
|
|
|
+│ │
|
|
|
+│ Controller.importExcel() │
|
|
|
+│ │ │
|
|
|
+│ ▼ │
|
|
|
+│ ExcelImportUtil.importExcel() → List<Entity>(AutoPoi解析,字典字段仍是中文)│
|
|
|
+│ │ │
|
|
|
+│ ▼ │
|
|
|
+│ Service.importExcelData() │
|
|
|
+│ │ │
|
|
|
+│ ├── 步骤1:批量查字典,构建 中文→字典码 映射 │
|
|
|
+│ ├── 步骤2:唯一键去重(Excel内部 + 数据库存在) │
|
|
|
+│ ├── 步骤3:逐行校验 │
|
|
|
+│ │ ├── 字典文本→字典码 反向翻译 │
|
|
|
+│ │ ├── 必填字段校验(调用 validateRequiredFields) │
|
|
|
+│ │ └── 补充默认值(如 dataSource = "2") │
|
|
|
+│ │ │
|
|
|
+│ └── 步骤4: │
|
|
|
+│ ├── 有错误 → return List<ImportResultItem>(不保存) │
|
|
|
+│ │ 前端展示错误详情表格 │
|
|
|
+│ └── 无错误 → saveBatch() 批量写入数据库 │
|
|
|
+│ │
|
|
|
+└──────────────────────────────────────────────────────────────────────────┘
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 六、注意事项与常见问题
|
|
|
+
|
|
|
+1. **日期格式**:Excel 中日期列请填写 `yyyy-MM-dd` 格式(如 `1990-01-01`),模板示例数据也使用此格式
|
|
|
+
|
|
|
+2. **字典字段**:模板中字典列填写的是**中文标签文本**(如性别填"男"而不是字典码"1"),导入时会自动反向翻译为字典码存入数据库
|
|
|
+
|
|
|
+3. **Excel 行号计算**:数据从第3行开始(第0行标题 + 第1行表头),所以错误提示中的行号 = `列表索引 + 3`
|
|
|
+
|
|
|
+4. **唯一键去重**:如果业务的唯一键不止一个列,需要扩展步骤2的去重逻辑。如果业务没有唯一键约束,可以跳过步骤2
|
|
|
+
|
|
|
+5. **字段顺序一致性**:Controller 的 `fieldNames`、ServiceImpl 的 `IMPORT_FIELD_NAMES`、Entity 的 `@Excel` 注解,三者的列顺序必须完全一致
|
|
|
+
|
|
|
+6. **权限标识**:导入按钮和模板下载使用 `{module}:{entityInfo}:importExcel` 权限,Controller 方法也需要加 `@RequiresPermissions`
|
|
|
+
|
|
|
+7. **大量数据导入**:超过 1000 行数据时,建议使用 `saveBatch(list, 500)` 分批次保存
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 七、涉及的核心文件清单
|
|
|
+
|
|
|
+| 文件 | 作用 |
|
|
|
+|------|------|
|
|
|
+| [CustomImportModal.vue](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecgboot-vue3/src/components/CustomImportModal/src/CustomImportModal.vue) | 前端公共导入弹窗组件 |
|
|
|
+| [ImportResultItem.java](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/personal/dto/ImportResultItem.java) | 导入校验结果通用DTO |
|
|
|
+| [PersonalInfoController.java](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/personal/controller/PersonalInfoController.java#L226-L328) | 个人信息导入Controller(参考实现) |
|
|
|
+| [EnterpriseInfoController.java](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/enterprise/controller/EnterpriseInfoController.java#L216-L360) | 企业信息导入Controller(参考实现) |
|
|
|
+| [PersonalInfoServiceImpl.java](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/personal/service/impl/PersonalInfoServiceImpl.java#L402-L648) | 个人信息导入Service实现(参考实现) |
|
|
|
+| [EnterpriseInfoServiceImpl.java](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/enterprise/service/impl/EnterpriseInfoServiceImpl.java#L151-L351) | 企业信息导入Service实现(参考实现) |
|
|
|
+| [PersonalInfo.api.ts](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecgboot-vue3/src/views/recruitment/personal/PersonalInfo.api.ts) | 个人信息API定义(参考实现) |
|
|
|
+| [EnterpriseInfo.api.ts](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecgboot-vue3/src/views/recruitment/enterprise/EnterpriseInfo.api.ts) | 企业信息API定义(参考实现) |
|
|
|
+| [PersonalInfoList.vue](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecgboot-vue3/src/views/recruitment/personal/PersonalInfoList.vue) | 个人信息列表页(组件集成示例) |
|
|
|
+| [EnterpriseInfoList.vue](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecgboot-vue3/src/views/recruitment/enterprise/EnterpriseInfoList.vue) | 企业信息列表页(组件集成示例) |
|
|
|
+| [IPersonalInfoService.java](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/personal/service/IPersonalInfoService.java) | 个人信息Service接口(接口声明示例) |
|
|
|
+| [IEnterpriseInfoService.java](file:///d:/Code/Project/湛江人社/code/zjrs-jeecgBoot/jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/enterprise/service/IEnterpriseInfoService.java) | 企业信息Service接口(接口声明示例) |
|