diff --git a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/ZYJXJHBController.java b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/ZYJXJHBController.java index 2a963691..beb75630 100644 --- a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/ZYJXJHBController.java +++ b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/ZYJXJHBController.java @@ -92,13 +92,23 @@ public class ZYJXJHBController { /** * 专业教学计划表数据文件导入(.xls/.xlsx)。 - *

表头顺序:专业代号, 课编号, 学期第次, 课类型, 学时, 学分, 周课时, 课程定位, - * 模块, 成绩分制, 理论学时, 实践学时, 考试课时, 教研室代号, 简称。编号由系统自动生成。

+ *

按表头名匹配列(列位置不限),推荐使用 /template 下载的模板填写。 + * 必填列:专业代号、课编号、学期第次、课类型、学时; + * "专业代号+课编号+学期第次"已存在的行跳过,校验失败行返回行级明细。

*/ @PostMapping("/import") - public Result importData(@RequestParam("file") MultipartFile file) throws Exception { - int count = zyjxjhbService.importZYJXJHBFromExcel(file); - return Result.success("导入成功,共" + count + "条", count); + public Result importData( + @RequestParam("file") MultipartFile file) throws Exception { + com.roomroot.jwgl.vo.syllabus.SyllabusImportResultVO result = zyjxjhbService.importExcel(file); + return Result.success(result.getMessage(), result); + } + + /** + * 下载教学大纲导入模板(数据 sheet + 填写说明 sheet)。 + */ + @GetMapping("/template") + public void downloadTemplate(HttpServletResponse response) throws Exception { + zyjxjhbService.downloadTemplate(response); } /** diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/ZYJXJHBService.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/ZYJXJHBService.java index cf8b96a4..7cd14625 100644 --- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/ZYJXJHBService.java +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/ZYJXJHBService.java @@ -65,8 +65,16 @@ public interface ZYJXJHBService { */ PageResult pageByZydhAndTy(PageQuery query, String zydh, Integer ty); - /** 从 Excel(.xls/.xlsx) 导入专业教学计划 */ - int importZYJXJHBFromExcel(org.springframework.web.multipart.MultipartFile file) throws Exception; + /** + * 从 Excel(.xls/.xlsx) 导入专业教学计划。 + *

按表头名匹配列;必填列:专业代号、课编号、学期第次、课类型、学时; + * "专业代号+课编号+学期第次"已存在(含文件内重复)的行跳过,校验失败的行记入失败明细。

+ */ + com.roomroot.jwgl.vo.syllabus.SyllabusImportResultVO importExcel( + org.springframework.web.multipart.MultipartFile file) throws Exception; + + /** 生成教学大纲导入模板(数据 sheet + 填写说明 sheet) */ + void downloadTemplate(jakarta.servlet.http.HttpServletResponse response) throws Exception; /** * 导出专业教学计划到 Excel(.xlsx)。 diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ZYJXJHBServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ZYJXJHBServiceImpl.java index a7a22fd4..84c373ed 100644 --- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ZYJXJHBServiceImpl.java +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ZYJXJHBServiceImpl.java @@ -24,6 +24,15 @@ public class ZYJXJHBServiceImpl implements ZYJXJHBService { @Resource private ZYJXJHBMapper zyjxjhbMapper; + @Resource + private com.roomroot.jwgl.mapper.ZYBMapper zybMapper; + + @Resource + private com.roomroot.jwgl.mapper.KBMapper kbMapper; + + @Resource + private com.roomroot.jwgl.mapper.JYSBMapper jysbMapper; + @Override public void add(ZYJXJHB zyjxjhb) { zyjxjhb.setBh(UuidUtil.getUUID()); @@ -85,22 +94,211 @@ public class ZYJXJHBServiceImpl implements ZYJXJHBService { @Override @org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class) - public int importZYJXJHBFromExcel(org.springframework.web.multipart.MultipartFile file) throws Exception { + public com.roomroot.jwgl.vo.syllabus.SyllabusImportResultVO importExcel( + org.springframework.web.multipart.MultipartFile file) throws Exception { + com.roomroot.jwgl.vo.syllabus.SyllabusImportResultVO result = + new com.roomroot.jwgl.vo.syllabus.SyllabusImportResultVO(); if (file == null || file.isEmpty()) { throw new RuntimeException("导入文件不能为空"); } - List list = com.roomroot.jwgl.utils.ExcelParseUtil.parseZYJXJHBExcel( - file.getInputStream(), file.getOriginalFilename()); - if (list.isEmpty()) { + java.util.Map rows; + try { + rows = com.roomroot.jwgl.utils.ExcelParseUtil.parseZYJXJHBExcel( + file.getInputStream(), file.getOriginalFilename()); + } catch (IllegalArgumentException e) { + throw new RuntimeException(e.getMessage(), e); + } catch (Exception e) { + throw new RuntimeException("Excel解析失败: " + e.getMessage(), e); + } + if (rows.isEmpty()) { throw new RuntimeException("Excel中无有效数据"); } - for (ZYJXJHB entity : list) { - if (entity.getTy() == null) { - entity.setTy(0); - } - zyjxjhbMapper.insert(entity); + + // 预载引用数据与已有计划键,逐行校验 + java.util.Set zydhSet = new java.util.HashSet<>(); + java.util.Set kbhSet = new java.util.HashSet<>(); + java.util.Set jysdhSet = new java.util.HashSet<>(); + for (ZYJXJHB e : rows.values()) { + if (e.getZydh() != null && !e.getZydh().isEmpty()) zydhSet.add(e.getZydh()); + if (e.getKbh() != null && !e.getKbh().isEmpty()) kbhSet.add(e.getKbh()); + if (e.getJysdh() != null && !e.getJysdh().isEmpty()) jysdhSet.add(e.getJysdh()); + } + java.util.Set validZydh = new java.util.HashSet<>(); + if (!zydhSet.isEmpty()) { + for (com.roomroot.jwgl.entity.ZYB zy : zybMapper.selectList( + new LambdaQueryWrapper() + .in(com.roomroot.jwgl.entity.ZYB::getZydh, zydhSet))) { + validZydh.add(zy.getZydh()); + } + } + java.util.Set validKbh = new java.util.HashSet<>(); + if (!kbhSet.isEmpty()) { + for (com.roomroot.jwgl.entity.KB kb : kbMapper.selectBatchIds(kbhSet)) { + validKbh.add(kb.getKbh()); + } + } + java.util.Set validJysdh = new java.util.HashSet<>(); + if (!jysdhSet.isEmpty()) { + for (com.roomroot.jwgl.entity.JYSB jys : jysbMapper.selectBatchIds(jysdhSet)) { + validJysdh.add(jys.getJysdh()); + } + } + java.util.Set existingKeys = new java.util.HashSet<>(); + if (!zydhSet.isEmpty()) { + for (ZYJXJHB exist : zyjxjhbMapper.selectList( + new LambdaQueryWrapper() + .select(ZYJXJHB::getZydh, ZYJXJHB::getKbh, ZYJXJHB::getXqdc) + .in(ZYJXJHB::getZydh, zydhSet))) { + existingKeys.add(planKey(exist.getZydh(), exist.getKbh(), exist.getXqdc())); + } + } + + java.util.Set fileKeys = new java.util.HashSet<>(); + for (java.util.Map.Entry entry : rows.entrySet()) { + int rowNo = entry.getKey(); + ZYJXJHB entity = entry.getValue(); + String failReason = validateImportRow(entity, validZydh, validKbh, validJysdh); + if (failReason != null) { + result.addFail("第" + rowNo + "行:" + failReason); + continue; + } + String key = planKey(entity.getZydh(), entity.getKbh(), entity.getXqdc()); + if (fileKeys.contains(key) || existingKeys.contains(key)) { + result.addSkip("第" + rowNo + "行:专业代号[" + entity.getZydh() + "] 课编号[" + + entity.getKbh() + "] 学期第次[" + entity.getXqdc() + "] 已存在,跳过"); + continue; + } + if (entity.getTy() == null) entity.setTy(0); + if (entity.getBjrxypjf() == null) entity.setBjrxypjf(0); + if (entity.getKsksbxs() == null) entity.setKsksbxs(0); + if (entity.getDgkc() == null) entity.setDgkc(0); + entity.setBh(UuidUtil.getUUID()); + entity.setQysj(LocalDateTime.now()); + zyjxjhbMapper.insert(entity); + fileKeys.add(key); + existingKeys.add(key); + result.setInsertCount(result.getInsertCount() + 1); + } + result.setMessage(String.format("共导入 %d 条,跳过 %d 条,失败 %d 条", + result.getInsertCount(), result.getSkipCount(), result.getFailCount())); + return result; + } + + private static String planKey(String zydh, String kbh, Integer xqdc) { + return zydh + "|" + kbh + "|" + xqdc; + } + + /** + * 校验导入行:必填项 + 引用数据存在性。 + * + * @return 失败原因,通过返回 null + */ + private static String validateImportRow(ZYJXJHB e, + java.util.Set validZydh, + java.util.Set validKbh, + java.util.Set validJysdh) { + StringBuilder sb = new StringBuilder(); + if (e.getZydh() == null || e.getZydh().isEmpty()) sb.append("专业代号为必填项;"); + if (e.getKbh() == null || e.getKbh().isEmpty()) sb.append("课编号为必填项;"); + if (e.getXqdc() == null) sb.append("学期第次为必填项且需为数字;"); + if (e.getKlx() == null || e.getKlx().isEmpty()) sb.append("课类型为必填项;"); + if (e.getXs() == null) sb.append("学时为必填项且需为数字;"); + if (sb.length() > 0) { + return sb.toString(); + } + if (!validZydh.contains(e.getZydh())) sb.append("专业代号[").append(e.getZydh()).append("]不存在;"); + if (!validKbh.contains(e.getKbh())) sb.append("课编号[").append(e.getKbh()).append("]不存在;"); + if (e.getJysdh() != null && !e.getJysdh().isEmpty() && !validJysdh.contains(e.getJysdh())) { + sb.append("教研室代号[").append(e.getJysdh()).append("]不存在;"); + } + return sb.length() > 0 ? sb.toString() : null; + } + + @Override + public void downloadTemplate(jakarta.servlet.http.HttpServletResponse response) throws Exception { + org.apache.poi.ss.usermodel.Workbook workbook = new org.apache.poi.xssf.usermodel.XSSFWorkbook(); + try { + org.apache.poi.ss.usermodel.CellStyle headerStyle = workbook.createCellStyle(); + org.apache.poi.ss.usermodel.Font headerFont = workbook.createFont(); + headerFont.setBold(true); + headerStyle.setFont(headerFont); + headerStyle.setAlignment(org.apache.poi.ss.usermodel.HorizontalAlignment.CENTER); + org.apache.poi.ss.usermodel.CellStyle requiredStyle = workbook.createCellStyle(); + org.apache.poi.ss.usermodel.Font requiredFont = workbook.createFont(); + requiredFont.setBold(true); + requiredFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.RED.getIndex()); + requiredStyle.setFont(requiredFont); + requiredStyle.setAlignment(org.apache.poi.ss.usermodel.HorizontalAlignment.CENTER); + + // 数据 sheet:列与新增页字段一致,必填列标 * + String[] headers = {"专业代号*", "课编号*", "学期第次*", "课类型*", "学时*", "停用", + "简称", "学分", "课程定位", "模块", "成绩分制", "教研室代号", + "考试课时", "理论学时", "实践学时", "周课时", "不计入平均分", "考试课时不显示", "大纲课程"}; + org.apache.poi.ss.usermodel.Sheet dataSheet = workbook.createSheet("专业教学计划"); + org.apache.poi.ss.usermodel.Row headerRow = dataSheet.createRow(0); + for (int i = 0; i < headers.length; i++) { + org.apache.poi.ss.usermodel.Cell cell = headerRow.createCell(i); + cell.setCellValue(headers[i]); + cell.setCellStyle(headers[i].endsWith("*") ? requiredStyle : headerStyle); + dataSheet.setColumnWidth(i, 14 * 256); + } + + // 填写说明 sheet + String[][] instructions = { + {"专业代号", "必填", "专业表中的专业代号(非专业名称),导入时下拉选择项对应的代号"}, + {"课编号", "必填", "课表中的课编号(非课程名称)"}, + {"学期第次", "必填", "数字,1-20,表示该课程安排在第几个学期"}, + {"课类型", "必填", "课类型字典值,如:必修、选修等(以系统字典 course_type 为准)"}, + {"学时", "必填", "数字,总学时"}, + {"停用", "选填", "是/否,默认否(导入后立即停用才填是)"}, + {"简称", "选填", "课程简称"}, + {"学分", "选填", "数字,可带一位小数"}, + {"课程定位", "选填", "文本"}, + {"模块", "选填", "文本"}, + {"成绩分制", "选填", "文本,如:百分制、五级制"}, + {"教研室代号", "选填", "教研室表中的教研室代号,填了必须存在"}, + {"考试课时", "选填", "数字"}, + {"理论学时", "选填", "数字"}, + {"实践学时", "选填", "数字"}, + {"周课时", "选填", "数字"}, + {"不计入平均分", "选填", "是/否,默认否"}, + {"考试课时不显示", "选填", "是/否,默认否"}, + {"大纲课程", "选填", "是/否,默认否"} + }; + org.apache.poi.ss.usermodel.Sheet helpSheet = workbook.createSheet("填写说明"); + org.apache.poi.ss.usermodel.Row titleRow = helpSheet.createRow(0); + org.apache.poi.ss.usermodel.Cell titleCell = titleRow.createCell(0); + titleCell.setCellValue("教学大纲导入填写说明(红色表头列为必填;专业代号+课编号+学期第次 相同的行视为重复,将跳过不导入)"); + titleCell.setCellStyle(headerStyle); + String[] helpHeaders = {"列名", "是否必填", "填写说明"}; + org.apache.poi.ss.usermodel.Row helpHeaderRow = helpSheet.createRow(1); + for (int i = 0; i < helpHeaders.length; i++) { + org.apache.poi.ss.usermodel.Cell cell = helpHeaderRow.createCell(i); + cell.setCellValue(helpHeaders[i]); + cell.setCellStyle(headerStyle); + } + for (int i = 0; i < instructions.length; i++) { + org.apache.poi.ss.usermodel.Row row = helpSheet.createRow(i + 2); + for (int j = 0; j < 3; j++) { + row.createCell(j).setCellValue(instructions[i][j]); + } + if ("必填".equals(instructions[i][1])) { + row.getCell(1).setCellStyle(requiredStyle); + } + } + helpSheet.setColumnWidth(0, 18 * 256); + helpSheet.setColumnWidth(1, 10 * 256); + helpSheet.setColumnWidth(2, 70 * 256); + + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", + "attachment; filename=" + java.net.URLEncoder.encode( + "教学大纲导入模板.xlsx", java.nio.charset.StandardCharsets.UTF_8)); + workbook.write(response.getOutputStream()); + response.getOutputStream().flush(); + } finally { + workbook.close(); } - return list.size(); } @Override diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/utils/ExcelParseUtil.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/utils/ExcelParseUtil.java index 8eda1646..0b991414 100644 --- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/utils/ExcelParseUtil.java +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/utils/ExcelParseUtil.java @@ -659,37 +659,86 @@ public class ExcelParseUtil { * @return 专业教学计划实体列表 * @throws Exception 解析异常 */ - public static List parseZYJXJHBExcel(InputStream inputStream, String fileName) throws Exception { - List result = new ArrayList<>(); + /** + * 教学大纲导入模板支持的表头名 → 实体字段标识。 + * 兼容导出文件与新增页字段两种叫法。 + */ + private static final Map ZYJXJHB_HEADER_FIELDS; + + static { + Map map = new LinkedHashMap<>(); + map.put("专业代号", "zydh"); + map.put("专业", "zydh"); + map.put("课编号", "kbh"); + map.put("课程", "kbh"); + map.put("学期第次", "xqdc"); + map.put("课类型", "klx"); + map.put("学时", "xs"); + map.put("停用", "ty"); + map.put("简称", "jc"); + map.put("学分", "xf"); + map.put("课程定位", "kcdw"); + map.put("模块", "mk"); + map.put("成绩分制", "cjfz"); + map.put("教研室代号", "jysdh"); + map.put("教研室", "jysdh"); + map.put("考试课时", "ksks"); + map.put("理论学时", "llxs"); + map.put("实践学时", "sjxs"); + map.put("周课时", "zks"); + map.put("不计入平均分", "bjrxypjf"); + map.put("不计入学员平均分", "bjrxypjf"); + map.put("考试课时不显示", "ksksbxs"); + map.put("大纲课程", "dgkc"); + ZYJXJHB_HEADER_FIELDS = java.util.Collections.unmodifiableMap(map); + } + + /** + * 按表头名称解析教学大纲导入 Excel(第一个 sheet)。 + *

列位置不固定,按表头名匹配;必填列「专业代号」「课编号」缺失时报错。 + * 是否类列(停用/不计入平均分/考试课时不显示/大纲课程)支持 是/否/1/0。

+ * + * @return Excel 行号(1-based)→ 实体,跳过整行空白行 + */ + public static Map parseZYJXJHBExcel(InputStream inputStream, String fileName) throws Exception { + Map result = new LinkedHashMap<>(); Workbook workbook = createWorkbook(inputStream, fileName); try { Sheet sheet = workbook.getSheetAt(0); - int lastRowNum = sheet.getLastRowNum(); - for (int rowNum = 1; rowNum <= lastRowNum; rowNum++) { + Row headerRow = sheet.getRow(0); + if (headerRow == null) { + throw new IllegalArgumentException("Excel缺少表头行,请下载导入模板填写"); + } + Map colField = new LinkedHashMap<>(); + for (int c = 0; c < headerRow.getLastCellNum(); c++) { + String header = getCellStringValue(headerRow.getCell(c)); + if (header == null) { + continue; + } + String field = ZYJXJHB_HEADER_FIELDS.get(header.replace("*", "").replace(" ", "").trim()); + if (field != null) { + colField.put(c, field); + } + } + if (!colField.containsValue("zydh") || !colField.containsValue("kbh")) { + throw new IllegalArgumentException("表头缺少必需列「专业代号」「课编号」,请下载导入模板填写"); + } + for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) { Row row = sheet.getRow(rowNum); if (row == null) { continue; } ZYJXJHB entity = new ZYJXJHB(); - entity.setZydh(getCellStringValue(row.getCell(0))); - entity.setKbh(getCellStringValue(row.getCell(1))); - entity.setXqdc(parseIntCell(row.getCell(2))); - entity.setKlx(getCellStringValue(row.getCell(3))); - entity.setXs(parseIntCell(row.getCell(4))); - entity.setXf(parseFloatCell(row.getCell(5))); - entity.setZks(parseIntCell(row.getCell(6))); - entity.setKcdw(getCellStringValue(row.getCell(7))); - entity.setMk(getCellStringValue(row.getCell(8))); - entity.setCjfz(getCellStringValue(row.getCell(9))); - entity.setLlxs(parseIntCell(row.getCell(10))); - entity.setSjxs(parseIntCell(row.getCell(11))); - entity.setKsks(parseIntCell(row.getCell(12))); - entity.setJysdh(getCellStringValue(row.getCell(13))); - entity.setJc(getCellStringValue(row.getCell(14))); - // 至少要有专业代号或课编号才视为有效行 - if (entity.getZydh() != null && !entity.getZydh().isEmpty() - || entity.getKbh() != null && !entity.getKbh().isEmpty()) { - result.add(entity); + boolean hasValue = false; + for (Map.Entry entry : colField.entrySet()) { + Cell cell = row.getCell(entry.getKey()); + if (getCellStringValue(cell) != null) { + hasValue = true; + } + applyZYJXJHBField(entity, entry.getValue(), cell); + } + if (hasValue) { + result.put(rowNum + 1, entity); } } } finally { @@ -698,6 +747,49 @@ public class ExcelParseUtil { return result; } + private static void applyZYJXJHBField(ZYJXJHB entity, String field, Cell cell) { + switch (field) { + case "zydh": entity.setZydh(getCellStringValue(cell)); break; + case "kbh": entity.setKbh(getCellStringValue(cell)); break; + case "xqdc": entity.setXqdc(parseIntCell(cell)); break; + case "klx": entity.setKlx(getCellStringValue(cell)); break; + case "xs": entity.setXs(parseIntCell(cell)); break; + case "ty": entity.setTy(parseYesNoCell(cell)); break; + case "jc": entity.setJc(getCellStringValue(cell)); break; + case "xf": entity.setXf(parseFloatCell(cell)); break; + case "kcdw": entity.setKcdw(getCellStringValue(cell)); break; + case "mk": entity.setMk(getCellStringValue(cell)); break; + case "cjfz": entity.setCjfz(getCellStringValue(cell)); break; + case "jysdh": entity.setJysdh(getCellStringValue(cell)); break; + case "ksks": entity.setKsks(parseIntCell(cell)); break; + case "llxs": entity.setLlxs(parseIntCell(cell)); break; + case "sjxs": entity.setSjxs(parseIntCell(cell)); break; + case "zks": entity.setZks(parseIntCell(cell)); break; + case "bjrxypjf": entity.setBjrxypjf(parseYesNoCell(cell)); break; + case "ksksbxs": entity.setKsksbxs(parseYesNoCell(cell)); break; + case "dgkc": entity.setDgkc(parseYesNoCell(cell)); break; + default: break; + } + } + + /** + * 解析是否类单元格:是/Y/1/true → 1,否/N/0/false → 0,空或无法识别 → null。 + */ + private static Integer parseYesNoCell(Cell cell) { + String value = getCellStringValue(cell); + if (value == null || value.isEmpty()) { + return null; + } + String v = value.trim(); + if ("是".equals(v) || "1".equals(v) || "Y".equalsIgnoreCase(v) || "true".equalsIgnoreCase(v)) { + return 1; + } + if ("否".equals(v) || "0".equals(v) || "N".equalsIgnoreCase(v) || "false".equalsIgnoreCase(v)) { + return 0; + } + return null; + } + private static Workbook createWorkbook(InputStream inputStream, String fileName) throws Exception { String name = fileName == null ? "" : fileName.toLowerCase(); if (!name.endsWith(".xlsx") && !name.endsWith(".xls")) { diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/syllabus/SyllabusImportResultVO.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/syllabus/SyllabusImportResultVO.java new file mode 100644 index 00000000..354b8a54 --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/syllabus/SyllabusImportResultVO.java @@ -0,0 +1,56 @@ +package com.roomroot.jwgl.vo.syllabus; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 教学大纲(专业教学计划表)导入结果。 + *

+ * 返回导入操作的执行结果:新增数、跳过数(重复数据)、失败数与行级明细。 + *

+ */ +@Data +public class SyllabusImportResultVO { + + /** + * 实际新增的教学大纲数。 + */ + private Integer insertCount = 0; + + /** + * 因"专业代号+课编号+学期第次"已存在而跳过的条数。 + */ + private Integer skipCount = 0; + + /** + * 校验失败(必填缺失、引用不存在、格式错误等)而未导入的条数。 + */ + private Integer failCount = 0; + + /** + * 跳过明细。 + */ + private List skipList = new ArrayList<>(); + + /** + * 失败明细(第 N 行:失败原因)。 + */ + private List failList = new ArrayList<>(); + + /** + * 结果提示信息。 + */ + private String message; + + public void addSkip(String detail) { + this.skipList.add(detail); + this.skipCount++; + } + + public void addFail(String detail) { + this.failList.add(detail); + this.failCount++; + } +} diff --git a/frontend/src/api/teachBusiness/syllabus.js b/frontend/src/api/teachBusiness/syllabus.js index a961ebcf..133d7b68 100644 --- a/frontend/src/api/teachBusiness/syllabus.js +++ b/frontend/src/api/teachBusiness/syllabus.js @@ -66,3 +66,25 @@ export function exportSyllabus(query) { responseType: 'blob' }) } + +// 下载教学大纲导入模板(数据 sheet + 填写说明 sheet) +export function downloadSyllabusTemplate() { + return request({ + url: '/zyjxjhb/template', + method: 'get', + responseType: 'blob' + }) +} + +// 导入教学大纲 Excel,返回 { insertCount, skipCount, failCount, skipList, failList, message } +export function importSyllabus(file) { + const formData = new FormData() + formData.append('file', file) + return request({ + url: '/zyjxjhb/import', + method: 'post', + data: formData, + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 60000 + }) +} diff --git a/frontend/src/views/teachBusiness/syllabus/index.vue b/frontend/src/views/teachBusiness/syllabus/index.vue index b335e810..83402cfe 100644 --- a/frontend/src/views/teachBusiness/syllabus/index.vue +++ b/frontend/src/views/teachBusiness/syllabus/index.vue @@ -42,6 +42,8 @@ @click="handleBatchDelete" >停用所选 下载 + 下载模板 + 导入 确 定 + + + + + + + + 下载导入模板 + + 红色表头列为必填,详见模板「填写说明」sheet + + +
+ + 选择文件 + {{ importDialog.fileName || '未选择任何文件' }} + + 清除 + +
+
+ +
+ 共导入 {{ importDialog.result.insertCount }}。 +
+
{{ item }}
+
+
+
{{ item }}
+
+
+
+
+ +
@@ -297,7 +345,9 @@ import { batchDeleteSyllabus, updateSyllabus, listByZydhAndTy, - exportSyllabus + exportSyllabus, + downloadSyllabusTemplate, + importSyllabus } from '@/api/teachBusiness/syllabus' import { listMajor } from '@/api/subjectMajor/major' import { listKb } from '@/api/teachOffice/kb' @@ -334,6 +384,14 @@ export default { submitting: false, form: this.createEmptyForm() }, + importDialog: { + visible: false, + downloading: false, + importing: false, + file: null, + fileName: '', + result: null + }, rules: { zydh: [{ required: true, message: '请选择专业', trigger: 'change' }], kbh: [{ required: true, message: '请选择课程', trigger: 'change' }], @@ -524,6 +582,76 @@ export default { }).catch(() => {}) }, + /* ---------- 模板导入 ---------- */ + openImportDialog() { + this.importDialog.file = null + this.importDialog.fileName = '' + this.importDialog.result = null + this.importDialog.visible = true + if (this.$refs.importFileInput) this.$refs.importFileInput.value = '' + }, + handleDownloadTemplate() { + this.importDialog.downloading = true + downloadSyllabusTemplate().then(blob => { + saveAs(blob, '教学大纲导入模板.xlsx') + this.$message.success('模板下载成功') + }).catch(() => {}).finally(() => { + this.importDialog.downloading = false + }) + }, + handleChooseFile() { + if (this.$refs.importFileInput) this.$refs.importFileInput.click() + }, + handleImportFileChange(e) { + const input = e.target + this.importDialog.result = null + if (!input.files || input.files.length === 0) { + this.importDialog.file = null + this.importDialog.fileName = '' + return + } + const file = input.files[0] + const name = (file.name || '').toLowerCase() + if (!name.endsWith('.xls') && !name.endsWith('.xlsx')) { + this.$message.warning('仅支持 .xls / .xlsx 格式的 Excel 文件') + this.clearImportFile() + input.value = '' + return + } + this.importDialog.file = file + this.importDialog.fileName = file.name + }, + clearImportFile() { + this.importDialog.file = null + this.importDialog.fileName = '' + this.importDialog.result = null + if (this.$refs.importFileInput) this.$refs.importFileInput.value = '' + }, + handleImport() { + if (!this.importDialog.file) { + this.$message.warning('请先选择文件') + return + } + this.importDialog.importing = true + importSyllabus(this.importDialog.file).then(response => { + const data = (response && response.data) || {} + this.importDialog.file = null + this.importDialog.fileName = '' + if (this.$refs.importFileInput) this.$refs.importFileInput.value = '' + this.importDialog.result = { + insertCount: data.insertCount || 0, + skipCount: data.skipCount || 0, + failCount: data.failCount || 0, + skipList: data.skipList || [], + failList: data.failList || [] + } + this.$message.success(data.message || '导入完成') + this.fetchList() + }).catch(() => {}).finally(() => { + this.importDialog.importing = false + }) + }, + /* ---------- 分页 ---------- */ handleSizeChange(size) { this.pageSize = size @@ -629,4 +757,51 @@ export default { color: #f78989; } } + +.import-tip { + margin-bottom: 16px; +} + +.tip-text { + margin-left: 10px; + font-size: 12px; + color: #909399; +} + +.file-input { + display: flex; + align-items: center; + + .file-name { + margin-left: 10px; + font-size: 13px; + color: #606266; + } +} + +.import-result { + font-size: 13px; + color: #303133; + + .num { + font-weight: 600; + color: #409eff; + } + + .num.warn { + color: #e6a23c; + } + + .skip-detail { + margin-top: 8px; + max-height: 180px; + overflow-y: auto; + padding: 8px; + background: #f5f7fa; + border-radius: 4px; + font-size: 12px; + color: #909399; + line-height: 1.8; + } +}