本文档基于项目中"个人信息"和"企业信息"模块的导入功能实现进行总结,后续开发新模块的导入功能时,按照本文档的步骤和代码模板照写即可。
导入功能的整体流程如下:
前端上传 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 |
核心导入逻辑 |
文件位置: personal/dto/ImportResultItem.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 表示该行校验通过。
文件位置: {module}/service/I{Entity}InfoService.java
在接口中新增 importExcelData 方法声明:
/**
* 批量导入Excel数据
* 对解析后的数据进行:字典文本→字典码反向翻译、唯一键去重、必填字段校验、批量保存
*
* @param list Excel解析后的数据列表
* @return 每行的导入结果(含原始列值 + 错误信息),当全部无错误时内部会执行批量保存
*/
List<ImportResultItem> importExcelData(List<{Entity}Info> list);
示例参考:
IPersonalInfoService.java (service/IPersonalInfoService.java)IEnterpriseInfoService.java (service/IEnterpriseInfoService.java)文件位置: {module}/service/impl/{Entity}InfoServiceImpl.java
核心方法 importExcelData 执行以下步骤:
步骤0:定义常量 → 步骤1:字典文本→字典码反向翻译映射 → 步骤2:唯一键去重(Excel内部+数据库)
→ 步骤3:逐行校验(字典翻译 + 必填校验 + 补充字段)→ 步骤4:有错误则返回错误明细,无错误则批量保存
在 ServiceImpl 类中定义三个全局常量:
// 常量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数组顺序完全一致,否则错误反馈时列名和列值会错位。
@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;
}
/**
* 根据字段名获取对应的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);
}
文件位置: {module}/controller/{Entity}InfoController.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的值必须与导出的模板结构一致
@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方法中的校验逻辑一致。
文件位置: src/components/CustomImportModal/src/CustomImportModal.vue
该组件是通用的导入弹窗,所有模块可直接复用,无需修改组件源码。
.xls、.xlsx 格式,限制 2MBdownloadUrl)importUrl 上传文件code=500 + result 数组时,自动弹出错误详情表格,展示每行的原始 Excel 数据和错误原因| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
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 |
否 | — | 导入成功后的回调函数 |
| 事件名 | 说明 | 回调参数 |
|---|---|---|
success |
导入成功后触发 | 无 |
文件位置: src/views/{module}/{Entity}Info.api.ts
在 API 枚举和导出函数中添加:
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;
文件位置: src/views/{module}/{Entity}InfoList.vue
在表格标题区域(#tableTitle 插槽)中添加:
<CustomImportModal
auth="{module}:{entityInfo}:importExcel"
:importUrl="getImportUrl"
:downloadUrl="getImportTemplateUrl"
templateName="{业务名}导入模板"
buttonText="导入"
@success="handleSuccess"
/>
import { CustomImportModal } from '/@/components/CustomImportModal';
import { getImportUrl, getImportTemplateUrl } from './{Entity}Info.api';
不需要在 components 中注册(组件已通过 withInstall 全局注册)。
按以下步骤操作,即可为新模块添加完整的导入功能:
确认 Entity 的 @Excel 注解:实体类中需要用 @Excel(name = "列名") 标注字段,这样 AutoPoi 才能按列名解析 Excel,错误提示也能展示中文列名
Service 接口添加方法:
List<ImportResultItem> importExcelData(List<{Entity}Info> list);
ServiceImpl 实现 importExcelData:
IMPORT_DICT_FIELD_MAP(字典字段映射)IMPORT_FIELD_NAMES(模板列顺序,与第5步模板列顺序一致)DATE_FORMATController 新增两个接口:
POST /importExcel — 照抄 2.4.1 节代码模板GET /importTemplate — 照抄 2.4.2 节代码模板,编写 buildExampleData() 方法核对一致性:
fieldNames 顺序 = ServiceImpl 的 IMPORT_FIELD_NAMES 顺序requiredFields 集合 = ServiceImpl 的 validateRequiredFields 逻辑API 层添加接口URL:
export const getImportUrl = Api.importExcel;
export const getImportTemplateUrl = Api.importTemplate;
列表页引入 CustomImportModal:
<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() 批量写入数据库 │
│ │
└──────────────────────────────────────────────────────────────────────────┘
日期格式:Excel 中日期列请填写 yyyy-MM-dd 格式(如 1990-01-01),模板示例数据也使用此格式
字典字段:模板中字典列填写的是中文标签文本(如性别填"男"而不是字典码"1"),导入时会自动反向翻译为字典码存入数据库
Excel 行号计算:数据从第3行开始(第0行标题 + 第1行表头),所以错误提示中的行号 = 列表索引 + 3
唯一键去重:如果业务的唯一键不止一个列,需要扩展步骤2的去重逻辑。如果业务没有唯一键约束,可以跳过步骤2
字段顺序一致性:Controller 的 fieldNames、ServiceImpl 的 IMPORT_FIELD_NAMES、Entity 的 @Excel 注解,三者的列顺序必须完全一致
权限标识:导入按钮和模板下载使用 {module}:{entityInfo}:importExcel 权限,Controller 方法也需要加 @RequiresPermissions
大量数据导入:超过 1000 行数据时,建议使用 saveBatch(list, 500) 分批次保存
| 文件 | 作用 |
|---|---|
| CustomImportModal.vue | 前端公共导入弹窗组件 |
| ImportResultItem.java | 导入校验结果通用DTO |
| PersonalInfoController.java | 个人信息导入Controller(参考实现) |
| EnterpriseInfoController.java | 企业信息导入Controller(参考实现) |
| PersonalInfoServiceImpl.java | 个人信息导入Service实现(参考实现) |
| EnterpriseInfoServiceImpl.java | 企业信息导入Service实现(参考实现) |
| PersonalInfo.api.ts | 个人信息API定义(参考实现) |
| EnterpriseInfo.api.ts | 企业信息API定义(参考实现) |
| PersonalInfoList.vue | 个人信息列表页(组件集成示例) |
| EnterpriseInfoList.vue | 企业信息列表页(组件集成示例) |
| IPersonalInfoService.java | 个人信息Service接口(接口声明示例) |
| IEnterpriseInfoService.java | 企业信息Service接口(接口声明示例) |