forked from liweijie/education
专业添加导入模板下载和数据上传
This commit is contained in:
+37
@@ -6,14 +6,19 @@ import com.roomroot.jwgl.service.DisciplineService;
|
||||
import com.roomroot.jwgl.unit.PageQuery;
|
||||
import com.roomroot.jwgl.unit.PageResult;
|
||||
import com.roomroot.jwgl.unit.Result;
|
||||
import com.roomroot.jwgl.vo.discipline.MajorImportResultVO;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 学科专业管理控制器
|
||||
@@ -212,4 +217,36 @@ public class DisciplineManagementController {
|
||||
cond.setTy(ty);
|
||||
return Result.success(disciplineService.pageMajor(new PageQuery(pageNum, pageSize), cond));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Excel 导入专业(.xls/.xlsx)。
|
||||
* <p>
|
||||
* 表头按专业导入模板的列名匹配(列顺序不限)。已存在的专业跳过、不覆盖库中已有数据;
|
||||
* 校验不通过的行跳过并在结果明细中给出原因,合格行正常导入。
|
||||
* </p>
|
||||
*
|
||||
* @param file 上传的 Excel 文件(form-data 字段名 file)
|
||||
* @return 导入结果(新增/跳过/失败数量与明细)
|
||||
*/
|
||||
@PostMapping("/major/import")
|
||||
public Result<MajorImportResultVO> importMajor(@RequestParam("file") MultipartFile file) throws Exception {
|
||||
MajorImportResultVO result = disciplineService.importMajorExcel(file);
|
||||
return Result.success(result.getMessage(), result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载专业导入模板(xlsx),列头与导入解析保持一致。
|
||||
*
|
||||
* @param response 响应对象
|
||||
*/
|
||||
@GetMapping("/major/template")
|
||||
public void downloadMajorTemplate(HttpServletResponse response) throws Exception {
|
||||
byte[] bytes = disciplineService.buildMajorImportTemplate();
|
||||
String fileName = "专业导入模板.xlsx";
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setHeader("Content-Disposition", "attachment; filename="
|
||||
+ URLEncoder.encode(fileName, StandardCharsets.UTF_8));
|
||||
response.getOutputStream().write(bytes);
|
||||
response.getOutputStream().flush();
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.roomroot.jwgl.dto.discipline;
|
||||
|
||||
import com.roomroot.jwgl.entity.ZYB;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 专业(专业表)导入行解析结果。
|
||||
* <p>
|
||||
* 保留 Excel 中的原始行号,便于把校验失败原因定位到具体行。
|
||||
* </p>
|
||||
*/
|
||||
@Data
|
||||
public class MajorImportRowDTO {
|
||||
|
||||
/**
|
||||
* Excel 行号(1 起始,含表头行)。
|
||||
*/
|
||||
private int rowNum;
|
||||
|
||||
/**
|
||||
* 该行解析出的专业实体。
|
||||
*/
|
||||
private ZYB entity;
|
||||
|
||||
public MajorImportRowDTO() {
|
||||
}
|
||||
|
||||
public MajorImportRowDTO(int rowNum, ZYB entity) {
|
||||
this.rowNum = rowNum;
|
||||
this.entity = entity;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import com.roomroot.jwgl.entity.XKZYXX;
|
||||
import com.roomroot.jwgl.entity.ZYB;
|
||||
import com.roomroot.jwgl.unit.PageQuery;
|
||||
import com.roomroot.jwgl.unit.PageResult;
|
||||
import com.roomroot.jwgl.vo.discipline.MajorImportResultVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 学科专业管理服务接口
|
||||
@@ -89,4 +91,26 @@ public interface DisciplineService {
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<ZYB> pageMajor(PageQuery query, ZYB cond);
|
||||
|
||||
/**
|
||||
* 从 Excel 导入专业(.xls/.xlsx)。
|
||||
* <p>
|
||||
* 表头按专业导入模板的列名匹配。已存在的专业(专业代号已存在,或未填代号但
|
||||
* 专业名称+专业方向+培训类型已存在)直接跳过,不覆盖库中已有数据;
|
||||
* 校验不通过的行跳过并记录原因,其余合格行正常导入。
|
||||
* </p>
|
||||
*
|
||||
* @param file 上传的 Excel 文件
|
||||
* @return 导入结果(新增/跳过/失败与明细)
|
||||
* @throws Exception 解析异常
|
||||
*/
|
||||
MajorImportResultVO importMajorExcel(MultipartFile file) throws Exception;
|
||||
|
||||
/**
|
||||
* 生成专业导入模板(xlsx 字节流),列头与导入解析保持一致。
|
||||
*
|
||||
* @return xlsx 文件字节数组
|
||||
* @throws Exception 生成异常
|
||||
*/
|
||||
byte[] buildMajorImportTemplate() throws Exception;
|
||||
}
|
||||
|
||||
+142
@@ -11,14 +11,25 @@ import com.roomroot.jwgl.mapper.ZYBMapper;
|
||||
import com.roomroot.jwgl.service.DisciplineService;
|
||||
import com.roomroot.jwgl.unit.PageQuery;
|
||||
import com.roomroot.jwgl.unit.PageResult;
|
||||
import com.roomroot.jwgl.utils.ExcelExportUtil;
|
||||
import com.roomroot.jwgl.utils.ExcelParseUtil;
|
||||
import com.roomroot.jwgl.utils.UuidUtil;
|
||||
import com.roomroot.jwgl.dto.discipline.MajorImportRowDTO;
|
||||
import com.roomroot.jwgl.vo.discipline.MajorImportResultVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import java.io.InputStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.roomroot.jwgl.utils.BasicManagementConstants.BAD_REQUEST;
|
||||
import static com.roomroot.jwgl.utils.BasicManagementConstants.CONFLICT;
|
||||
@@ -167,6 +178,137 @@ public class DisciplineServiceImpl implements DisciplineService {
|
||||
return new PageResult<>(result.getRecords(), result.getTotal(), pageNum, pageSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MajorImportResultVO importMajorExcel(MultipartFile file) throws Exception {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ServiceException("请选择要导入的文件", BAD_REQUEST);
|
||||
}
|
||||
|
||||
List<MajorImportRowDTO> rows;
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
rows = ExcelParseUtil.parseMajorExcel(inputStream, file.getOriginalFilename());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
// 文件格式不支持、表头未识别等:转为业务异常,前端可展示明确提示
|
||||
throw new ServiceException(ex.getMessage(), BAD_REQUEST);
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
throw new ServiceException("Excel 中未解析到有效数据,请使用「专业导入模板」填写", BAD_REQUEST);
|
||||
}
|
||||
|
||||
MajorImportResultVO result = new MajorImportResultVO();
|
||||
result.setTotalCount(rows.size());
|
||||
|
||||
// 库中已有专业:代号集合 + (专业名称|专业方向|培训类型)业务键集合,用于跳过判定
|
||||
List<ZYB> existingMajors = zybMapper.selectList(new LambdaQueryWrapper<ZYB>()
|
||||
.select(ZYB::getZydh, ZYB::getZymc, ZYB::getZyfx, ZYB::getPxlx));
|
||||
Set<String> existingZydhSet = new HashSet<>();
|
||||
Set<String> existingKeySet = new HashSet<>();
|
||||
for (ZYB existing : existingMajors) {
|
||||
if (StringUtils.isNotEmpty(existing.getZydh())) {
|
||||
existingZydhSet.add(existing.getZydh().trim());
|
||||
}
|
||||
existingKeySet.add(buildMajorKey(existing.getZymc(), existing.getZyfx(), existing.getPxlx()));
|
||||
}
|
||||
|
||||
List<ZYB> toInsert = new ArrayList<>();
|
||||
for (MajorImportRowDTO row : rows) {
|
||||
ZYB entity = row.getEntity();
|
||||
// 1. 必填与长度校验(与新增专业的校验口径一致)
|
||||
try {
|
||||
validateMajor(entity, true);
|
||||
} catch (ServiceException ex) {
|
||||
result.addFail("第" + row.getRowNum() + "行:" + ex.getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 已存在则跳过,不覆盖库中数据
|
||||
String zydh = entity.getZydh() == null ? null : entity.getZydh().trim();
|
||||
if (StringUtils.isNotEmpty(zydh) && existingZydhSet.contains(zydh)) {
|
||||
result.addSkip("第" + row.getRowNum() + "行:专业代号 " + zydh + " 已存在,已跳过");
|
||||
continue;
|
||||
}
|
||||
String key = buildMajorKey(entity.getZymc(), entity.getZyfx(), entity.getPxlx());
|
||||
if (existingKeySet.contains(key)) {
|
||||
result.addSkip("第" + row.getRowNum() + "行:" + describeMajor(entity) + " 已存在,已跳过");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 补齐主键与必要字段后入队
|
||||
if (StringUtils.isEmpty(zydh)) {
|
||||
entity.setZydh(UuidUtil.getOriginalUUID());
|
||||
} else {
|
||||
entity.setZydh(zydh);
|
||||
}
|
||||
fillMajorDefaultsForImport(entity);
|
||||
|
||||
existingZydhSet.add(entity.getZydh());
|
||||
existingKeySet.add(key);
|
||||
toInsert.add(entity);
|
||||
}
|
||||
|
||||
for (ZYB entity : toInsert) {
|
||||
zybMapper.insert(entity);
|
||||
result.setInsertCount(result.getInsertCount() + 1);
|
||||
}
|
||||
|
||||
result.setMessage(String.format("共解析 %d 行,新增 %d 条,跳过 %d 条,失败 %d 条",
|
||||
result.getTotalCount(), result.getInsertCount(),
|
||||
result.getSkipCount(), result.getFailCount()));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] buildMajorImportTemplate() throws Exception {
|
||||
return ExcelExportUtil.export("专业导入", ExcelParseUtil.MAJOR_IMPORT_HEADERS,
|
||||
Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入专用兜底。
|
||||
* <p>
|
||||
* 只补"库中没有非空缺省值"或需要按业务推导的字段(专业标识号、规范名称),
|
||||
* 其余空值保持 null,交由数据库缺省值生效(如 节次类别='双节次'、系统模式='军校模式'、
|
||||
* 培训类型2/学员类别='其他'),避免写成空串。
|
||||
* </p>
|
||||
*/
|
||||
private void fillMajorDefaultsForImport(ZYB entity) {
|
||||
entity.setTy(false);
|
||||
entity.setQysj(LocalDateTime.now());
|
||||
if (entity.getZgzy() == null) {
|
||||
entity.setZgzy(false);
|
||||
}
|
||||
if (StringUtils.isEmpty(entity.getZybsh())) {
|
||||
entity.setZybsh(entity.getZydh());
|
||||
}
|
||||
if (StringUtils.isEmpty(entity.getGfmc())) {
|
||||
entity.setGfmc(entity.getZymc());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务键:专业名称 + 专业方向 + 培训类型(多版本通过培训类型区分)。
|
||||
*/
|
||||
private String buildMajorKey(String zymc, String zyfx, String pxlx) {
|
||||
return normalize(zymc) + "|" + normalize(zyfx) + "|" + normalize(pxlx);
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private String describeMajor(ZYB entity) {
|
||||
StringBuilder text = new StringBuilder("专业「");
|
||||
text.append(normalize(entity.getZymc())).append("」");
|
||||
if (StringUtils.isNotEmpty(entity.getZyfx())) {
|
||||
text.append("(方向:").append(entity.getZyfx().trim()).append(")");
|
||||
}
|
||||
if (StringUtils.isNotEmpty(entity.getPxlx())) {
|
||||
text.append("(培训类型:").append(entity.getPxlx().trim()).append(")");
|
||||
}
|
||||
return text.toString();
|
||||
}
|
||||
|
||||
private void fillMajorDefaults(ZYB entity) {
|
||||
if (entity.getTy() == null) {
|
||||
entity.setTy(false);
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.roomroot.jwgl.dto.courserunning.ClassesImportDTO;
|
||||
import com.roomroot.jwgl.dto.courserunning.SemesterImportDTO;
|
||||
import com.roomroot.jwgl.dto.courserunning.SingleCourseImportDTO;
|
||||
import com.roomroot.jwgl.dto.departmentpersonnel.DepartmentPersonnelCreateDTO;
|
||||
import com.roomroot.jwgl.dto.discipline.MajorImportRowDTO;
|
||||
import com.roomroot.jwgl.entity.JXSSJH;
|
||||
import com.roomroot.jwgl.entity.JYB;
|
||||
import com.roomroot.jwgl.entity.JYSB;
|
||||
@@ -25,7 +26,9 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class ExcelParseUtil {
|
||||
|
||||
@@ -479,6 +482,174 @@ public class ExcelParseUtil {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 专业管理导入模板的列头定义。
|
||||
* <p>下载模板与解析共用同一份列头,避免模板与解析器不一致。解析按列名匹配,列顺序可调整。</p>
|
||||
*/
|
||||
public static final String[] MAJOR_IMPORT_HEADERS = {
|
||||
"专业代号", "专业名称", "专业方向", "专业代码", "专业版本", "学年制", "学期数",
|
||||
"培训层次", "培训类型", "培训类型2", "学员类别", "主干专业", "自定义分类",
|
||||
"节次类别", "系统模式", "学科专业信息标识号", "教学管理机构编号", "专业标识号",
|
||||
"简称", "规范名称", "培养目标", "专业备注", "专业规范"
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析专业管理导入 Excel 文件(支持 .xls / .xlsx)。
|
||||
* <p>
|
||||
* 表头按 {@link #MAJOR_IMPORT_HEADERS} 的列名匹配(顺序不限,可少列、可多列);
|
||||
* 未识别的列忽略;空白单元格统一归一为 null,由业务层兜底或使用库缺省值。
|
||||
* 专业代号与专业名称均为空的行视为空行跳过。
|
||||
* </p>
|
||||
*
|
||||
* @param inputStream Excel 文件输入流
|
||||
* @param fileName 文件名(用于判断 .xls/.xlsx)
|
||||
* @return 导入行列表(含 Excel 原始行号)
|
||||
* @throws Exception 解析异常
|
||||
*/
|
||||
public static List<MajorImportRowDTO> parseMajorExcel(InputStream inputStream, String fileName) throws Exception {
|
||||
List<MajorImportRowDTO> result = new ArrayList<>();
|
||||
Workbook workbook = createWorkbook(inputStream, fileName);
|
||||
try {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
int firstRowNum = sheet.getFirstRowNum();
|
||||
Row headerRow = sheet.getRow(firstRowNum);
|
||||
if (headerRow == null) {
|
||||
throw new IllegalArgumentException("导入文件缺少表头行,请使用「专业导入模板」填写");
|
||||
}
|
||||
|
||||
Map<String, Integer> headerIndex = new LinkedHashMap<>();
|
||||
for (int col = headerRow.getFirstCellNum(); col < headerRow.getLastCellNum(); col++) {
|
||||
String title = normalizeHeader(getCellStringValue(headerRow.getCell(col)));
|
||||
if (!title.isEmpty() && !headerIndex.containsKey(title)) {
|
||||
headerIndex.put(title, col);
|
||||
}
|
||||
}
|
||||
|
||||
int matched = 0;
|
||||
for (String header : MAJOR_IMPORT_HEADERS) {
|
||||
if (headerIndex.containsKey(header)) {
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
if (matched == 0) {
|
||||
throw new IllegalArgumentException("未识别到专业导入模板表头,请先下载「专业导入模板」并按表头填写");
|
||||
}
|
||||
|
||||
for (int rowNum = firstRowNum + 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
|
||||
Row row = sheet.getRow(rowNum);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ZYB entity = new ZYB();
|
||||
entity.setZydh(cellText(row, headerIndex, "专业代号"));
|
||||
entity.setZymc(cellText(row, headerIndex, "专业名称"));
|
||||
entity.setZyfx(cellText(row, headerIndex, "专业方向"));
|
||||
entity.setZydm(cellText(row, headerIndex, "专业代码"));
|
||||
entity.setZybb(cellText(row, headerIndex, "专业版本"));
|
||||
entity.setXnz(cellText(row, headerIndex, "学年制"));
|
||||
entity.setXqs(cellInt(row, headerIndex, "学期数"));
|
||||
entity.setPxcc(cellText(row, headerIndex, "培训层次"));
|
||||
entity.setPxlx(cellText(row, headerIndex, "培训类型"));
|
||||
entity.setPxlx2(cellText(row, headerIndex, "培训类型2"));
|
||||
entity.setXylb(cellText(row, headerIndex, "学员类别"));
|
||||
entity.setZgzy(cellBool(row, headerIndex, "主干专业"));
|
||||
entity.setZdyfl(cellText(row, headerIndex, "自定义分类"));
|
||||
entity.setJclb(cellText(row, headerIndex, "节次类别"));
|
||||
entity.setXtms(cellText(row, headerIndex, "系统模式"));
|
||||
entity.setXkzyxxbsh(cellText(row, headerIndex, "学科专业信息标识号"));
|
||||
entity.setJxgljgbh(cellText(row, headerIndex, "教学管理机构编号"));
|
||||
entity.setZybsh(cellText(row, headerIndex, "专业标识号"));
|
||||
entity.setJc(cellText(row, headerIndex, "简称"));
|
||||
entity.setGfmc(cellText(row, headerIndex, "规范名称"));
|
||||
entity.setPymb(cellText(row, headerIndex, "培养目标"));
|
||||
entity.setZybz(cellText(row, headerIndex, "专业备注"));
|
||||
entity.setZygf(cellText(row, headerIndex, "专业规范"));
|
||||
|
||||
// 专业代号与专业名称均为空视为空行
|
||||
if (isNotBlank(entity.getZydh()) || isNotBlank(entity.getZymc())) {
|
||||
result.add(new MajorImportRowDTO(rowNum + 1, entity));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
workbook.close();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表头名归一:去星号标记、括号统一为半角、去空白字符,便于容错匹配。
|
||||
*/
|
||||
private static String normalizeHeader(String title) {
|
||||
if (title == null) {
|
||||
return "";
|
||||
}
|
||||
return title.replace("*", "")
|
||||
.replace("(", "(")
|
||||
.replace(")", ")")
|
||||
.replace(" ", "")
|
||||
.replace("\u00A0", "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按列名取单元格文本,空白统一归一为 null。
|
||||
*/
|
||||
private static String cellText(Row row, Map<String, Integer> headerIndex, String header) {
|
||||
Integer col = headerIndex.get(header);
|
||||
if (col == null) {
|
||||
return null;
|
||||
}
|
||||
String value = getCellStringValue(row.getCell(col));
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按列名取整数值,容错 8 / 8.0 / "8" 等写法。
|
||||
*/
|
||||
private static Integer cellInt(Row row, Map<String, Integer> headerIndex, String header) {
|
||||
String value = cellText(row, headerIndex, header);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(value);
|
||||
} catch (NumberFormatException e) {
|
||||
try {
|
||||
return (int) Double.parseDouble(value);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按列名取布尔值,识别 是/否、true/false、1/0、Y/N、√。
|
||||
*/
|
||||
private static Boolean cellBool(Row row, Map<String, Integer> headerIndex, String header) {
|
||||
String value = cellText(row, headerIndex, header);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if ("是".equals(value) || "true".equalsIgnoreCase(value) || "1".equals(value)
|
||||
|| "Y".equalsIgnoreCase(value) || "√".equals(value)) {
|
||||
return true;
|
||||
}
|
||||
if ("否".equals(value) || "false".equalsIgnoreCase(value) || "0".equals(value)
|
||||
|| "N".equalsIgnoreCase(value)) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isNotBlank(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析专业教学计划表导入 Excel 文件(支持 .xls / .xlsx)。
|
||||
* <p>表头顺序:</p>
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.roomroot.jwgl.vo.discipline;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 专业管理导入结果。
|
||||
* <p>
|
||||
* 用于返回导入操作的执行结果:新增数、跳过数(专业已存在)、失败数(校验不通过)与明细列表。
|
||||
* </p>
|
||||
*/
|
||||
@Data
|
||||
public class MajorImportResultVO {
|
||||
|
||||
/**
|
||||
* 解析出的有效数据行数(不含空行)。
|
||||
*/
|
||||
private Integer totalCount = 0;
|
||||
|
||||
/**
|
||||
* 实际新增的专业数。
|
||||
*/
|
||||
private Integer insertCount = 0;
|
||||
|
||||
/**
|
||||
* 因"已存在"而跳过的专业数(不覆盖库中已有数据)。
|
||||
*/
|
||||
private Integer skipCount = 0;
|
||||
|
||||
/**
|
||||
* 因校验不通过而失败的专业数。
|
||||
*/
|
||||
private Integer failCount = 0;
|
||||
|
||||
/**
|
||||
* 跳过明细(专业代号或名称+方向+培训类型)。
|
||||
*/
|
||||
private List<String> skipList = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 失败明细(第 N 行:原因)。
|
||||
*/
|
||||
private List<String> failList = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 结果提示信息。
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 记录一条跳过明细。
|
||||
*
|
||||
* @param detail 明细文本
|
||||
*/
|
||||
public void addSkip(String detail) {
|
||||
this.skipCount++;
|
||||
if (this.skipList.size() < 50) {
|
||||
this.skipList.add(detail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一条失败明细。
|
||||
*
|
||||
* @param detail 明细文本
|
||||
*/
|
||||
public void addFail(String detail) {
|
||||
this.failCount++;
|
||||
if (this.failList.size() < 50) {
|
||||
this.failList.add(detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,25 @@ export function delMajor(zydh) {
|
||||
})
|
||||
}
|
||||
|
||||
// 下载人才培养方案课程数据文件模板
|
||||
// 下载专业导入模板(xlsx,列头与后端导入解析一致)
|
||||
export function downloadMajorTemplate() {
|
||||
return request({
|
||||
url: '/download/training-program',
|
||||
url: '/discipline/major/template',
|
||||
method: 'get',
|
||||
responseType: 'blob',
|
||||
timeout: 30000
|
||||
})
|
||||
}
|
||||
|
||||
// 导入专业(Excel,form-data 字段名 file)
|
||||
export function importMajor(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request({
|
||||
url: '/discipline/major/import',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
})
|
||||
}
|
||||
@@ -68,6 +68,7 @@
|
||||
<span class="table-title">专业列表:</span>
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" icon="el-icon-download" @click="handleDownloadTemplate">下载模板</el-button>
|
||||
<el-button type="primary" icon="el-icon-upload2" @click="handleOpenImport">导入专业</el-button>
|
||||
<el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增专业</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,17 +385,69 @@
|
||||
<el-button @click="viewDialogVisible = false">关 闭</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ==================== 5. 导入专业对话框 ==================== -->
|
||||
<el-dialog
|
||||
title="导入专业"
|
||||
:visible.sync="importDialogVisible"
|
||||
width="720px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert type="info" :closable="false" show-icon class="import-tip">
|
||||
<template slot="title">导入说明</template>
|
||||
<div class="import-tip-body">
|
||||
<p>1. 请先下载专业导入模板,按表头逐列填写;<b>专业名称、专业代码、学年制、学期数、培训层次、培训类型</b>为必填。</p>
|
||||
<p>2. 专业代号已存在(或未填代号但「专业名称 + 专业方向 + 培训类型」已存在)的记录会被跳过,不会覆盖库中已有数据。</p>
|
||||
<p>3. 单行校验不通过只跳过该行并给出原因,其余合格行正常导入;未填写的可空字段使用系统缺省值。</p>
|
||||
</div>
|
||||
</el-alert>
|
||||
|
||||
<div class="import-toolbar">
|
||||
<el-button icon="el-icon-download" @click="handleDownloadTemplate">下载导入模板</el-button>
|
||||
<el-button icon="el-icon-folder-opened" @click="handleChooseImportFile">选择文件</el-button>
|
||||
<span class="import-file-name" :class="{ 'has-file': importFile }">{{ importFileName }}</span>
|
||||
<input ref="importFileInput" type="file" accept=".xls,.xlsx" style="display: none" @change="handleImportFileChange" />
|
||||
</div>
|
||||
|
||||
<div v-if="importResult" class="import-result">
|
||||
<div class="import-summary">
|
||||
共解析 <b>{{ importResult.totalCount || 0 }}</b> 行:新增
|
||||
<b class="text-success">{{ importResult.insertCount || 0 }}</b> 条,跳过
|
||||
<b class="text-warning">{{ importResult.skipCount || 0 }}</b> 条,失败
|
||||
<b class="text-danger">{{ importResult.failCount || 0 }}</b> 条
|
||||
</div>
|
||||
<div v-if="importResult.skipList && importResult.skipList.length" class="import-detail">
|
||||
<div class="import-detail-title">跳过明细:</div>
|
||||
<ul>
|
||||
<li v-for="(item, idx) in importResult.skipList" :key="'skip' + idx">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="importResult.failList && importResult.failList.length" class="import-detail">
|
||||
<div class="import-detail-title text-danger">失败明细:</div>
|
||||
<ul>
|
||||
<li v-for="(item, idx) in importResult.failList" :key="'fail' + idx">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="importDialogVisible = false">关 闭</el-button>
|
||||
<el-button type="primary" :loading="importing" :disabled="!importFile" @click="handleImportSubmit">开始导入</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { saveAs } from 'file-saver'
|
||||
import {
|
||||
listMajor,
|
||||
getMajor,
|
||||
addMajor,
|
||||
updateMajor,
|
||||
delMajor,
|
||||
downloadMajorTemplate
|
||||
downloadMajorTemplate,
|
||||
importMajor
|
||||
} from "@/api/subjectMajor/major"
|
||||
import { listDiscipline } from '@/api/subjectMajor/discipline'
|
||||
import { getDicts } from '@/api/system/dict/data'
|
||||
@@ -465,7 +518,19 @@ export default {
|
||||
// ==================== 详情 ====================
|
||||
viewDialogVisible: false,
|
||||
viewLoading: false,
|
||||
viewForm: {}
|
||||
viewForm: {},
|
||||
|
||||
// ==================== 导入 ====================
|
||||
importDialogVisible: false,
|
||||
importFile: null,
|
||||
importing: false,
|
||||
importResult: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/** 导入对话框中的文件名展示 */
|
||||
importFileName() {
|
||||
return (this.importFile && this.importFile.name) || '未选择任何文件'
|
||||
}
|
||||
},
|
||||
created() {
|
||||
@@ -766,16 +831,66 @@ export default {
|
||||
// ==================== 下载模板 ====================
|
||||
handleDownloadTemplate() {
|
||||
downloadMajorTemplate().then(res => {
|
||||
const blob = new Blob([res], { type: 'application/vnd.ms-excel;charset=utf-8' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = '人才培养方案课程数据文件模板.xls'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
const blob = new Blob([res], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
saveAs(blob, '专业导入模板.xlsx')
|
||||
this.$message.success('模板下载成功')
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
// ==================== 导入 ====================
|
||||
handleOpenImport() {
|
||||
this.importDialogVisible = true
|
||||
this.importFile = null
|
||||
this.importResult = null
|
||||
if (this.$refs.importFileInput) {
|
||||
this.$refs.importFileInput.value = ''
|
||||
}
|
||||
},
|
||||
|
||||
handleChooseImportFile() {
|
||||
this.$refs.importFileInput && this.$refs.importFileInput.click()
|
||||
},
|
||||
|
||||
handleImportFileChange(e) {
|
||||
const input = e.target
|
||||
this.importResult = null
|
||||
const file = input.files && input.files.length > 0 ? input.files[0] : null
|
||||
// 前端先拦非 Excel 文件,避免提交后只拿到「系统未知错误」级别的提示
|
||||
if (file && !/\.xlsx?$/i.test(file.name)) {
|
||||
this.$message.warning('仅支持 .xls / .xlsx 格式的文件,请重新选择')
|
||||
this.importFile = null
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
this.importFile = file
|
||||
},
|
||||
|
||||
handleImportSubmit() {
|
||||
if (!this.importFile) {
|
||||
this.$message.warning('请先选择要导入的文件')
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
importMajor(this.importFile).then(response => {
|
||||
const result = response.data || {}
|
||||
this.importResult = result
|
||||
const inserted = result.insertCount || 0
|
||||
if (result.failCount) {
|
||||
this.$message.warning(`导入完成:新增 ${inserted} 条,跳过 ${result.skipCount || 0} 条,失败 ${result.failCount} 条`)
|
||||
} else if (result.insertCount) {
|
||||
this.$message.success(`导入成功:新增 ${inserted} 条,跳过 ${result.skipCount || 0} 条`)
|
||||
} else {
|
||||
this.$message.warning(`未新增数据:跳过 ${result.skipCount || 0} 条`)
|
||||
}
|
||||
this.fetchList()
|
||||
}).catch(() => {
|
||||
}).finally(() => {
|
||||
this.importing = false
|
||||
})
|
||||
},
|
||||
|
||||
// ==================== 详情 ====================
|
||||
handleView(row) {
|
||||
this.viewDialogVisible = true
|
||||
@@ -859,6 +974,78 @@ export default {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.import-tip {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.import-tip-body {
|
||||
line-height: 20px;
|
||||
|
||||
p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.import-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.import-file-name {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
|
||||
&.has-file {
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.import-result {
|
||||
margin-top: 16px;
|
||||
padding: 12px 14px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
.import-summary {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.import-detail {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
|
||||
.import-detail-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 20px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ty-tag {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
Reference in New Issue
Block a user