diff --git a/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgClassMsgController.java b/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgClassMsgController.java index 178c7fcd..47233fe5 100644 --- a/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgClassMsgController.java +++ b/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgClassMsgController.java @@ -1,27 +1,41 @@ package org.dromara.xg.controller; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.constraints.*; import cn.dev33.satoken.annotation.SaCheckPermission; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.validation.annotation.Validated; +import org.springframework.web.multipart.MultipartFile; import org.dromara.common.idempotent.annotation.RepeatSubmit; import org.dromara.common.log.annotation.Log; import org.dromara.common.web.core.BaseController; import org.dromara.common.mybatis.core.page.PageQuery; import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.validate.AddGroup; import org.dromara.common.core.validate.EditGroup; import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.excel.core.DropDownOptions; +import org.dromara.common.excel.core.ExcelResult; import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.xg.domain.vo.XgClassMsgImportVo; import org.dromara.xg.domain.vo.XgClassMsgVo; +import org.dromara.xg.domain.vo.XgSysDeptVo; import org.dromara.xg.domain.bo.XgClassMsgBo; import org.dromara.xg.domain.bo.XgClassStatusBo; import org.dromara.xg.domain.bo.XgClassStatusBatchBo; +import org.dromara.xg.excel.XgClassMsgImportTemplateWriter; +import org.dromara.xg.listener.XgClassMsgImportListener; import org.dromara.xg.service.IXgClassMsgService; +import org.dromara.xg.service.IXgSysDeptService; import org.dromara.common.mybatis.core.page.TableDataInfo; import org.dromara.common.satoken.utils.LoginHelper; @@ -31,6 +45,7 @@ import org.dromara.common.satoken.utils.LoginHelper; * @author zlwu * @date 2025-07-23 */ +@Slf4j @Validated @RequiredArgsConstructor @RestController @@ -38,6 +53,7 @@ import org.dromara.common.satoken.utils.LoginHelper; public class XgClassMsgController extends BaseController { private final IXgClassMsgService xgClassMsgService; + private final IXgSysDeptService xgSysDeptService; /** * 查询班级信息-新版列表 @@ -146,4 +162,91 @@ public class XgClassMsgController extends BaseController { List list = xgClassMsgService.queryList(bo); return R.ok(list); } + + /** + * 下载班级信息导入模板(含字典下拉、学院-专业联动下拉) + */ + // @SaCheckPermission("classMsg:classMsg:import") + @PostMapping("/importTemplate") + public void importTemplate(HttpServletResponse response) { + try { + List templateData = new ArrayList<>(); + XgClassMsgImportVo example = new XgClassMsgImportVo(); + int currentYear = java.time.Year.now().getValue(); + example.setClassName("示例:" + currentYear + "计算机1班"); + example.setClassType("1"); + example.setGrade(String.valueOf(currentYear)); + example.setEnrollmentYear((long) currentYear); + example.setSchoolSystem(3L); + // 毕业年份由模板公式自动计算:入学年份+学制 + example.setGraduationYear(null); + example.setIsGraduation(0L); + example.setClassLocation("教学楼A101"); + example.setClassStatus("1"); + example.setClassDescription("示例数据,导入前请删除或修改"); + example.setRemark("班主任选填:姓名唯一匹配才写入"); + templateData.add(example); + + List options = buildImportDropDownOptions(); + XgClassMsgImportTemplateWriter.write(templateData, response, options); + } catch (Exception e) { + log.error("生成班级信息导入模板失败", e); + throw new ServiceException("生成模板失败: " + e.getMessage()); + } + } + + /** + * 导入班级信息 + */ + // @SaCheckPermission("classMsg:classMsg:import") + @Log(title = "班级信息-新版", businessType = BusinessType.IMPORT) + @PostMapping(value = "/importData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R importData(@RequestPart("file") MultipartFile file) throws Exception { + ExcelResult result = ExcelUtil.importExcel( + file.getInputStream(), + XgClassMsgImportVo.class, + new XgClassMsgImportListener()); + return R.ok(result.getAnalysis()); + } + + /** + * 构建导入模板下拉:学院-专业联动 + 常见学制 + */ + private List buildImportDropDownOptions() { + List collegeList = xgSysDeptService.selectCollegeList(); + List majorList = new ArrayList<>(); + for (XgSysDeptVo college : collegeList) { + List majors = xgSysDeptService.selectMajorListByCollegeId(college.getDeptId()); + if (majors != null) { + majorList.addAll(majors); + } + } + + int collegeIndex = 0; + int majorIndex = 1; + DropDownOptions collegeToMajor = DropDownOptions.buildLinkedOptions( + collegeList, + collegeIndex, + majorList, + majorIndex, + XgSysDeptVo::getDeptId, + XgSysDeptVo::getParentId, + this::buildDeptOptionValue + ); + + // 学制常见可选值 + DropDownOptions schoolSystemOptions = new DropDownOptions(6, Arrays.asList("2", "3", "4", "5", "6")); + + List options = new ArrayList<>(); + options.add(collegeToMajor); + options.add(schoolSystemOptions); + return options; + } + + /** + * 生成学院/专业下拉选项值:仅展示名称(导入时按名称解析为 ID) + */ + private String buildDeptOptionValue(XgSysDeptVo dept) { + return StringUtils.blankToDefault(dept.getDeptName(), "未命名"); + } } diff --git a/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgClassMsgImportVo.java b/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgClassMsgImportVo.java new file mode 100644 index 00000000..11770315 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgClassMsgImportVo.java @@ -0,0 +1,128 @@ +package org.dromara.xg.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.annotation.write.style.ColumnWidth; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.annotation.ExcelNotation; +import org.dromara.common.excel.convert.ExcelDictConvert; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 班级信息导入VO + * + * @author zlwu + */ +@Data +@ExcelIgnoreUnannotated +public class XgClassMsgImportVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 学院(下拉:学院名称,与专业联动) + */ + @ExcelProperty(value = "学院", index = 0) + @ColumnWidth(24) + @NotBlank(message = "学院不能为空") + private String college; + + /** + * 专业(下拉:专业名称,随学院联动) + */ + @ExcelProperty(value = "专业", index = 1) + @ColumnWidth(30) + @NotBlank(message = "专业不能为空") + private String major; + + /** + * 班级名称(同时作为班级代码) + */ + @ExcelProperty(value = "班级名称", index = 2) + @NotBlank(message = "班级名称不能为空") + private String className; + + /** + * 学历层级(字典下拉:zsjy_education_level) + */ + @ExcelProperty(value = "学历层级", index = 3, converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "zsjy_education_level") + @ColumnWidth(20) + @NotBlank(message = "学历层级不能为空") + private String classType; + + /** + * 年级 + */ + @ExcelProperty(value = "年级", index = 4) + @NotBlank(message = "年级不能为空") + private String grade; + + /** + * 入学年份 + */ + @ExcelProperty(value = "入学年份", index = 5) + @NotNull(message = "入学年份不能为空") + private Long enrollmentYear; + + /** + * 学制(年) + */ + @ExcelProperty(value = "学制(年)", index = 6) + @NotNull(message = "学制不能为空") + private Long schoolSystem; + + /** + * 毕业年份(模板内公式:入学年份+学制;可不填,导入时也会自动计算) + */ + @ExcelProperty(value = "毕业年份", index = 7) + @ExcelNotation(value = "自动计算:入学年份+学制,也可留空由系统计算") + private Long graduationYear; + + /** + * 是否已毕业(字典下拉:is_graduation) + */ + @ExcelProperty(value = "是否已毕业", index = 8, converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "is_graduation") + @NotNull(message = "是否已毕业不能为空") + private Long isGraduation; + + /** + * 班级教室位置 + */ + @ExcelProperty(value = "班级教室位置", index = 9) + private String classLocation; + + /** + * 班级状态(字典下拉:xg_class_status) + */ + @ExcelProperty(value = "班级状态", index = 10, converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "xg_class_status") + @NotBlank(message = "班级状态不能为空") + private String classStatus; + + /** + * 班主任(选填,填写姓名;仅唯一匹配时写入,不影响班级保存) + */ + @ExcelProperty(value = "班主任", index = 11) + @ExcelNotation(value = "选填,填写姓名。匹配到唯一人员才写入;未匹配或多人同名则不写入,不影响班级导入") + private String classTeacherName; + + /** + * 班级描述 + */ + @ExcelProperty(value = "班级描述", index = 12) + private String classDescription; + + /** + * 备注信息 + */ + @ExcelProperty(value = "备注信息", index = 13) + private String remark; +} diff --git a/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/listener/XgClassMsgImportListener.java b/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/listener/XgClassMsgImportListener.java new file mode 100644 index 00000000..16020ce6 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/listener/XgClassMsgImportListener.java @@ -0,0 +1,291 @@ +package org.dromara.xg.listener; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.http.HtmlUtil; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.event.AnalysisEventListener; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.excel.core.DropDownOptions; +import org.dromara.common.excel.core.ExcelListener; +import org.dromara.common.excel.core.ExcelResult; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.xg.domain.XgClassMsg; +import org.dromara.xg.domain.XgSysDept; +import org.dromara.xg.domain.XgSysUser; +import org.dromara.xg.domain.bo.XgClassMsgBo; +import org.dromara.xg.domain.bo.XgClassTeacherCounselorRelBo; +import org.dromara.xg.domain.vo.XgClassMsgImportVo; +import org.dromara.xg.domain.vo.XgSysDeptVo; +import org.dromara.xg.mapper.XgClassMsgMapper; +import org.dromara.xg.mapper.XgSysDeptMapper; +import org.dromara.xg.mapper.XgSysUserMapper; +import org.dromara.xg.service.IXgClassMsgService; +import org.dromara.xg.service.IXgClassTeacherCounselorRelService; +import org.dromara.xg.service.IXgSysDeptService; + +import java.util.Date; +import java.util.List; + +/** + * 班级信息导入监听器 + * + * @author zlwu + */ +@Slf4j +public class XgClassMsgImportListener extends AnalysisEventListener + implements ExcelListener { + + private final IXgClassMsgService classMsgService; + private final IXgSysDeptService deptService; + private final IXgClassTeacherCounselorRelService classTeacherCounselorRelService; + private final XgSysDeptMapper deptMapper; + private final XgClassMsgMapper classMsgMapper; + private final XgSysUserMapper sysUserMapper; + private final Long operUserId; + + private int successNum = 0; + private int failureNum = 0; + private final StringBuilder successMsg = new StringBuilder(); + private final StringBuilder failureMsg = new StringBuilder(); + + public XgClassMsgImportListener() { + this.classMsgService = SpringUtils.getBean(IXgClassMsgService.class); + this.deptService = SpringUtils.getBean(IXgSysDeptService.class); + this.classTeacherCounselorRelService = SpringUtils.getBean(IXgClassTeacherCounselorRelService.class); + this.deptMapper = SpringUtils.getBean(XgSysDeptMapper.class); + this.classMsgMapper = SpringUtils.getBean(XgClassMsgMapper.class); + this.sysUserMapper = SpringUtils.getBean(XgSysUserMapper.class); + this.operUserId = LoginHelper.getUserId(); + } + + @Override + public void invoke(XgClassMsgImportVo importVo, AnalysisContext context) { + Integer rowIndex = context.readRowHolder().getRowIndex() + 1; + try { + if (isBlankRow(importVo)) { + return; + } + // 跳过模板自带的示例行 + if (StringUtils.isNotBlank(importVo.getClassName()) && importVo.getClassName().startsWith("示例")) { + return; + } + ValidatorUtils.validate(importVo); + + Long collegeId = resolveCollegeId(importVo.getCollege()); + XgSysDept college = deptMapper.selectById(collegeId); + if (college == null || !"0".equals(college.getDelFlag())) { + throw new ServiceException("学院不存在或已停用:" + importVo.getCollege()); + } + if (college.getParentId() == null || college.getParentId() != 101L) { + throw new ServiceException("所选不是有效学院:" + importVo.getCollege()); + } + + Long majorId = resolveMajorId(importVo.getMajor(), collegeId); + XgSysDept major = deptMapper.selectById(majorId); + if (major == null || !"0".equals(major.getDelFlag())) { + throw new ServiceException("专业不存在或已停用:" + importVo.getMajor()); + } + if (!collegeId.equals(major.getParentId())) { + throw new ServiceException("专业不属于所选学院,请重新从下拉框选择学院和专业"); + } + + String className = StringUtils.trim(importVo.getClassName()); + LambdaQueryWrapper existWrapper = Wrappers.lambdaQuery(); + existWrapper.and(w -> w.eq(XgClassMsg::getClassName, className).or().eq(XgClassMsg::getClassCode, className)); + Long existCount = classMsgMapper.selectCount(existWrapper); + if (existCount != null && existCount > 0) { + throw new ServiceException("班级名称已存在:" + className); + } + + XgClassMsgBo bo = new XgClassMsgBo(); + bo.setCollegeId(collegeId); + bo.setCollegeName(college.getDeptName()); + bo.setMajorId(majorId); + bo.setMajorName(major.getDeptName()); + bo.setClassName(className); + bo.setClassCode(className); + bo.setClassType(importVo.getClassType()); + bo.setGrade(StringUtils.trim(importVo.getGrade())); + bo.setEnrollmentYear(importVo.getEnrollmentYear()); + bo.setSchoolSystem(importVo.getSchoolSystem()); + // 统一按入学年份+学制计算,与模板公式一致 + Long graduationYear = importVo.getEnrollmentYear() + importVo.getSchoolSystem(); + bo.setGraduationYear(graduationYear); + bo.setIsGraduation(importVo.getIsGraduation()); + bo.setClassLocation(importVo.getClassLocation()); + bo.setClassStatus(importVo.getClassStatus()); + bo.setClassDescription(importVo.getClassDescription()); + bo.setRemark(importVo.getRemark()); + bo.setCreateBy(operUserId); + + classMsgService.insertByBo(bo); + + String teacherTip = bindClassTeacherIfUnique(bo.getId(), importVo.getClassTeacherName()); + + successNum++; + successMsg.append("
").append(successNum).append("、第").append(rowIndex) + .append("行班级 ").append(className).append(" 导入成功"); + if (StringUtils.isNotBlank(teacherTip)) { + successMsg.append("(").append(teacherTip).append(")"); + } + } catch (Exception e) { + failureNum++; + String msg = e.getMessage(); + if (e instanceof ConstraintViolationException cvException) { + msg = StreamUtils.join(cvException.getConstraintViolations(), ConstraintViolation::getMessage, ", "); + } + String className = importVo == null ? "" : StringUtils.blankToDefault(importVo.getClassName(), ""); + failureMsg.append("
").append(failureNum).append("、第").append(rowIndex) + .append("行班级 ").append(HtmlUtil.cleanHtmlTag(className)).append(" 导入失败:") + .append(HtmlUtil.cleanHtmlTag(StringUtils.blankToDefault(msg, e.getClass().getSimpleName()))); + log.warn("班级信息导入失败,行号: {}, 原因: {}", rowIndex, msg, e); + } + } + + /** + * 按姓名匹配班主任:仅唯一命中时写入;空值/未匹配/多人同名均跳过,不影响班级保存。 + * + * @return 提示文案,无需提示时返回空 + */ + private String bindClassTeacherIfUnique(Long classId, String teacherNameRaw) { + String teacherName = StringUtils.trim(teacherNameRaw); + if (StringUtils.isBlank(teacherName) || classId == null) { + return null; + } + try { + List users = sysUserMapper.selectActiveStaffByNickName(teacherName); + if (users == null || users.isEmpty()) { + log.info("班级导入班主任未匹配到人员,classId={}, name={}", classId, teacherName); + return "班主任「" + teacherName + "」未匹配到人员,已跳过"; + } + if (users.size() > 1) { + log.info("班级导入班主任姓名匹配到多人,跳过设置,classId={}, name={}, count={}", + classId, teacherName, users.size()); + return "班主任「" + teacherName + "」匹配到多人,已跳过"; + } + XgSysUser teacher = users.get(0); + XgClassTeacherCounselorRelBo relBo = new XgClassTeacherCounselorRelBo(); + relBo.setClassId(classId); + relBo.setTeacherId(teacher.getUserId()); + relBo.setTeacherName(teacher.getNickName()); + relBo.setTeacherType("class_teacher"); + relBo.setStartDate(new Date()); + classTeacherCounselorRelService.insertByBo(relBo); + return "已设置班主任「" + teacher.getNickName() + "」"; + } catch (Exception e) { + // 班主任设置失败不影响班级导入结果 + log.warn("班级导入后设置班主任失败,classId={}, name={}, cause={}", classId, teacherName, e.getMessage(), e); + return "班主任设置失败已跳过"; + } + } + + private boolean isBlankRow(XgClassMsgImportVo importVo) { + if (importVo == null) { + return true; + } + // 毕业年份、班主任为辅助字段,不参与空行判断 + return StringUtils.isAllBlank( + importVo.getCollege(), + importVo.getMajor(), + importVo.getClassName(), + importVo.getClassType(), + importVo.getGrade(), + importVo.getClassStatus(), + importVo.getClassLocation(), + importVo.getClassDescription(), + importVo.getRemark() + ) && importVo.getEnrollmentYear() == null + && importVo.getSchoolSystem() == null + && importVo.getIsGraduation() == null; + } + + private Long resolveCollegeId(String optionValue) { + Long id = parseOptionId(optionValue); + if (id != null) { + return id; + } + String name = StringUtils.trim(optionValue); + return deptService.selectCollegeList().stream() + .filter(item -> name.equals(item.getDeptName())) + .map(XgSysDeptVo::getDeptId) + .findFirst() + .orElseThrow(() -> new ServiceException("未找到学院:" + name + ",请从模板下拉框中选择")); + } + + private Long resolveMajorId(String optionValue, Long collegeId) { + Long id = parseOptionId(optionValue); + if (id != null) { + return id; + } + String name = StringUtils.trim(optionValue); + return deptService.selectMajorListByCollegeId(collegeId).stream() + .filter(item -> name.equals(item.getDeptName())) + .map(XgSysDeptVo::getDeptId) + .findFirst() + .orElseThrow(() -> new ServiceException("未找到专业:" + name + ",请从模板下拉框中选择")); + } + + /** + * 兼容旧模板「名称_ID」格式;新模板仅名称时返回 null,改走名称匹配。 + */ + private Long parseOptionId(String optionValue) { + String value = StringUtils.trim(optionValue); + if (StringUtils.isBlank(value)) { + throw new ServiceException("学院/专业不能为空"); + } + // 纯名称(无下划线)直接走名称匹配,避免误把名称解析为 ID + if (!value.contains("_")) { + return null; + } + List parts = DropDownOptions.analyzeOptionValue(value); + if (!parts.isEmpty()) { + String idStr = parts.get(parts.size() - 1); + if (NumberUtil.isLong(idStr)) { + return Long.parseLong(idStr); + } + } + return null; + } + + @Override + public void doAfterAllAnalysed(AnalysisContext context) { + // no-op + } + + @Override + public ExcelResult getExcelResult() { + return new ExcelResult<>() { + @Override + public List getList() { + return null; + } + + @Override + public List getErrorList() { + return null; + } + + @Override + public String getAnalysis() { + if (failureNum > 0) { + failureMsg.insert(0, "很抱歉,导入失败!共 " + failureNum + " 条数据格式不正确,错误如下:"); + throw new ServiceException(failureMsg.toString()); + } + if (successNum == 0) { + throw new ServiceException("未读取到有效数据,请检查是否填写了班级信息"); + } + successMsg.insert(0, "恭喜您,数据已全部导入成功!共 " + successNum + " 条,数据如下:"); + return successMsg.toString(); + } + }; + } +} diff --git a/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgSysUserMapper.java b/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgSysUserMapper.java index a4d34de6..180419f1 100644 --- a/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgSysUserMapper.java +++ b/backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgSysUserMapper.java @@ -28,4 +28,12 @@ public interface XgSysUserMapper extends BaseMapperPlus { */ List selectActiveUserListByAccountType(String accountType); + /** + * 按昵称精确匹配有效教职工 + * + * @param nickName 用户昵称(姓名) + * @return 匹配用户列表 + */ + List selectActiveStaffByNickName(String nickName); + } diff --git a/backend/ruoyi-modules/ruoyi-xg/src/main/resources/mapper/xg/XgSysUserMapper.xml b/backend/ruoyi-modules/ruoyi-xg/src/main/resources/mapper/xg/XgSysUserMapper.xml index 821baf75..efec5a73 100644 --- a/backend/ruoyi-modules/ruoyi-xg/src/main/resources/mapper/xg/XgSysUserMapper.xml +++ b/backend/ruoyi-modules/ruoyi-xg/src/main/resources/mapper/xg/XgSysUserMapper.xml @@ -34,4 +34,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" order by user_name asc, create_time desc + + + diff --git a/docs/开发文档/API/API文档总览.md b/docs/开发文档/API/API文档总览.md index 2cb88b2d..13bbff0b 100644 --- a/docs/开发文档/API/API文档总览.md +++ b/docs/开发文档/API/API文档总览.md @@ -174,7 +174,7 @@ | [学生信息收集批次管理API](./学生信息收集批次管理API.md) | 学生信息收集批次班级权限管理 | ✅ 完成 | 2025-01-27 | | [学生信息批次收集API](./学生信息批次收集API.md) | 学生信息批次收集管理(新增) | ✅ 完成 | 2025-01-27 | | [学生学籍异动与换班级API](./学生学籍异动与换班级API.md) | 学籍异动与换班级(含复学+换班级事务接口) | ✅ 完成 | 2026-03-13 | -| [班级信息管理API](./班级信息管理API.md) | 班级基础信息增删改查 | ✅ 完成 | 2025-01-27 | +| [班级信息管理API](./班级信息管理API.md) | 班级基础信息增删改查、Excel导入(字典下拉+学院专业联动) | ✅ 完成 | 2026-07-22 | | [二级学院管理API](./二级学院管理API.md) | 二级学院组织信息管理 | ✅ 完成 | 2025-12-26 | | [专业信息管理API](./专业信息管理API.md) | 专业信息管理(树形菜单+列表) | ✅ 完成 | 2025-12-26 | | [班级干部信息管理API](./班级干部信息管理API.md) | 班级干部信息管理(含用户角色关联) | ✅ 完成 | 2025-01-27 | diff --git a/docs/开发文档/API/班级信息管理API.md b/docs/开发文档/API/班级信息管理API.md new file mode 100644 index 00000000..3c8e7b4b --- /dev/null +++ b/docs/开发文档/API/班级信息管理API.md @@ -0,0 +1,73 @@ +# 班级信息管理 API + +**模块名称**:classMsg / 班级信息 +**基础路径**:`/classMsg/classMsg` +**更新时间**:2026-07-22 + +## 模块功能 + +班级基础信息的增删改查、状态批量变更,以及 Excel 批量导入(模板含字典下拉、学院-专业联动下拉)。 + +## 导入相关接口 + +### 1. 下载导入模板 + +- **URL**:`POST /classMsg/classMsg/importTemplate` +- **说明**:生成带下拉校验的 Excel 模板 +- **模板列**: + +| 列名 | 填写方式 | 说明 | +|------|----------|------| +| 学院 | 下拉(联动) | 仅显示学院名称,请从下拉选择;列已加宽 | +| 专业 | 下拉(联动) | 随学院变化,仅显示专业名称;列已加宽 | +| 班级名称 | 文本 | 必填;同时作为班级代码写入系统 | +| 学历层级 | 字典下拉 | `zsjy_education_level` 中文标签;列已加宽 | +| 年级 | 文本 | 如当前年份 | +| 入学年份 | 数字 | 如当前年份 | +| 学制(年) | 下拉 | 常见值 2/3/4/5/6 | +| 毕业年份 | 公式自动计算 | Excel 公式:`入学年份+学制`;导入时同样按此规则计算 | +| 是否已毕业 | 字典下拉 | `is_graduation`(未毕业/已毕业) | +| 班级教室位置 | 文本 | 可选 | +| 班级状态 | 字典下拉 | `xg_class_status`(正常/暂停/毕业/解散) | +| 班主任 | 文本(姓名) | 选填;按教职工姓名精确匹配。唯一命中才写入;未匹配或多人同名则跳过,**不影响班级保存** | +| 班级描述 | 文本 | 可选 | +| 备注信息 | 文本 | 可选 | + +- **注意**: + - 模板首行示例数据以「示例」开头,导入时会自动跳过 + - 学院、专业、字典项请务必从下拉框选择,避免手填错误 + - 班主任匹配失败不会导致该行班级导入失败,可事后在列表中单独设置 + +### 2. 导入班级数据 + +- **URL**:`POST /classMsg/classMsg/importData` +- **Content-Type**:`multipart/form-data` +- **参数**:`file`(Excel 文件) +- **成功响应**:`R`,`msg` 为导入成功明细(HTML,含班主任匹配提示) +- **失败响应**:抛出业务异常,`msg` 含失败行号与原因 +- **处理逻辑**: + 1. 解析学院/专业名称,并校验专业属于所选学院 + 2. 字典中文标签自动转换为字典值 + 3. 班级名称作为 `classCode` / `className`,重复则该行失败 + 4. 毕业年份按 `入学年份 + 学制` 自动计算 + 5. 调用既有新增逻辑,同步创建对应 `sys_dept` 班级节点 + 6. 班主任按姓名匹配有效教职工:0 人或多人 → 不写班主任;1 人 → 写入并同步关联/角色 + +## 其他已有接口(摘要) + +| 接口 | 方法 | 路径 | +|------|------|------| +| 分页列表 | GET | `/classMsg/classMsg/list` | +| 详情 | GET | `/classMsg/classMsg/{id}` | +| 新增 | POST | `/classMsg/classMsg` | +| 修改 | PUT | `/classMsg/classMsg` | +| 删除 | DELETE | `/classMsg/classMsg/{ids}` | +| 更新状态 | PUT | `/classMsg/classMsg/status` | +| 批量更新状态 | PUT | `/classMsg/classMsg/status/batch` | +| 导出 | POST | `/classMsg/classMsg/export` | +| 我作为班主任的班级 | GET | `/classMsg/classMsg/myClassListAsTeacher` | + +## 前端入口 + +- 页面:`web/src/views/xg/xsjcxx/classmsg/classMsg/index.vue` +- 操作:工具栏「导入」→ 下载模板 / 上传 Excel diff --git a/web/src/views/xg/xsjcxx/classmsg/classMsg/index.vue b/web/src/views/xg/xsjcxx/classmsg/classMsg/index.vue index 5bdab9bc..1e439f0f 100644 --- a/web/src/views/xg/xsjcxx/classmsg/classMsg/index.vue +++ b/web/src/views/xg/xsjcxx/classmsg/classMsg/index.vue @@ -49,6 +49,17 @@ 新增 + + + 导入 + + + 修改 - + - + - + @@ -386,6 +397,41 @@ + + + + +
将文件拖到此处,或点击上传
+ +
+ +
+ @@ -446,7 +492,9 @@ import { import { listColleges, collegeTree, listMajorsByCollegeId } from '@/api/xg/xsjcxx/classmsg/college'; import { ClassMsgVO, ClassMsgQuery, ClassMsgForm, SysDeptVo } from '@/api/xg/xsjcxx/classmsg/types'; import { ComponentInternalInstance, ref, reactive, toRefs, onMounted, getCurrentInstance, watch } from 'vue'; -import type { FormInstance as ElFormInstance } from 'element-plus'; +import type { FormInstance as ElFormInstance, UploadFile, UploadInstance } from 'element-plus'; +import { ElMessageBox } from 'element-plus'; +import { globalHeaders } from '@/utils/request'; import { getActiveUserList } from '@/api/xg/sysUser'; // 添加用户API导入 import { addClassTeacherCounselorRel, updateClassTeacherCounselorRel } from '@/api/xg/classTeacherCounselorRel'; // 添加班级辅导员关联API导入 @@ -526,6 +574,16 @@ const collegeTreeData = ref([]); // 学院专业树形数据 const queryFormRef = ref(); const classMsgFormRef = ref(); +const uploadRef = ref(); + +/** 班级导入参数 */ +const upload = reactive({ + open: false, + title: '', + isUploading: false, + headers: globalHeaders(), + url: import.meta.env.VITE_APP_BASE_API + '/classMsg/classMsg/importData' +}); const dialog = reactive({ visible: false, @@ -705,6 +763,15 @@ watch( } ); +/** 根据入学年份 + 学制自动计算毕业年份 */ +const syncGraduationYear = () => { + const enrollmentYear = Number(form.value.enrollmentYear); + const schoolSystem = Number(form.value.schoolSystem); + if (Number.isFinite(enrollmentYear) && Number.isFinite(schoolSystem) && schoolSystem > 0) { + form.value.graduationYear = enrollmentYear + schoolSystem; + } +}; + /** 取消按钮 */ const cancel = () => { reset(); @@ -744,6 +811,46 @@ const handleAdd = () => { dialog.title = '添加班级信息'; }; +/** 导入按钮操作 */ +const handleImport = () => { + upload.title = '班级信息导入'; + upload.open = true; +}; + +/** 下载导入模板 */ +const importTemplate = () => { + proxy?.download('classMsg/classMsg/importTemplate', {}, `班级信息导入模板_${new Date().getTime()}.xlsx`); +}; + +/** 文件上传中处理 */ +const handleFileUploadProgress = () => { + upload.isUploading = true; +}; + +/** 文件上传成功处理 */ +const handleFileSuccess = (response: any, file: UploadFile) => { + upload.open = false; + upload.isUploading = false; + uploadRef.value?.handleRemove(file); + ElMessageBox.alert( + "
" + (response.msg || '导入完成') + '
', + '导入结果', + { dangerouslyUseHTMLString: true } + ); + getList(); +}; + +/** 文件上传失败处理 */ +const handleFileError = () => { + upload.isUploading = false; + proxy?.$modal.msgError('导入失败,请检查文件格式或网络连接'); +}; + +/** 提交上传文件 */ +const submitFileForm = () => { + uploadRef.value?.submit(); +}; + /** 修改按钮操作 */ const handleUpdate = async (row?: ClassMsgVO) => { reset();