zlwu hai 3 meses
pai
achega
4ada3c6f01

+ 103 - 0
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<XgClassMsgVo> list = xgClassMsgService.queryList(bo);
         return R.ok(list);
     }
+
+    /**
+     * 下载班级信息导入模板(含字典下拉、学院-专业联动下拉)
+     */
+    // @SaCheckPermission("classMsg:classMsg:import")
+    @PostMapping("/importTemplate")
+    public void importTemplate(HttpServletResponse response) {
+        try {
+            List<XgClassMsgImportVo> 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<DropDownOptions> 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<Void> importData(@RequestPart("file") MultipartFile file) throws Exception {
+        ExcelResult<XgClassMsgImportVo> result = ExcelUtil.importExcel(
+            file.getInputStream(),
+            XgClassMsgImportVo.class,
+            new XgClassMsgImportListener());
+        return R.ok(result.getAnalysis());
+    }
+
+    /**
+     * 构建导入模板下拉:学院-专业联动 + 常见学制
+     */
+    private List<DropDownOptions> buildImportDropDownOptions() {
+        List<XgSysDeptVo> collegeList = xgSysDeptService.selectCollegeList();
+        List<XgSysDeptVo> majorList = new ArrayList<>();
+        for (XgSysDeptVo college : collegeList) {
+            List<XgSysDeptVo> 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<DropDownOptions> options = new ArrayList<>();
+        options.add(collegeToMajor);
+        options.add(schoolSystemOptions);
+        return options;
+    }
+
+    /**
+     * 生成学院/专业下拉选项值:仅展示名称(导入时按名称解析为 ID)
+     */
+    private String buildDeptOptionValue(XgSysDeptVo dept) {
+        return StringUtils.blankToDefault(dept.getDeptName(), "未命名");
+    }
 }

+ 128 - 0
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;
+}

+ 291 - 0
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<XgClassMsgImportVo>
+    implements ExcelListener<XgClassMsgImportVo> {
+
+    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<XgClassMsg> 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("<br/>").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("<br/>").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<XgSysUser> 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<String> 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<XgClassMsgImportVo> getExcelResult() {
+        return new ExcelResult<>() {
+            @Override
+            public List<XgClassMsgImportVo> getList() {
+                return null;
+            }
+
+            @Override
+            public List<String> 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();
+            }
+        };
+    }
+}

+ 8 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgSysUserMapper.java

@@ -28,4 +28,12 @@ public interface XgSysUserMapper extends BaseMapperPlus<XgSysUser, XgSysUser> {
      */
     List<XgSysUser> selectActiveUserListByAccountType(String accountType);
 
+    /**
+     * 按昵称精确匹配有效教职工
+     *
+     * @param nickName 用户昵称(姓名)
+     * @return 匹配用户列表
+     */
+    List<XgSysUser> selectActiveStaffByNickName(String nickName);
+
 }

+ 9 - 0
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
     </select>
 
+    <!-- 按昵称精确匹配有效教职工(account_type=1) -->
+    <select id="selectActiveStaffByNickName" parameterType="String" resultMap="XgSysUserResult">
+        select user_id, user_name, nick_name, phonenumber, account_type
+        from sys_user
+        where status = '0' and del_flag = '0' and account_type = '1'
+          and nick_name = #{nickName}
+        order by user_id asc
+    </select>
+
 </mapper>

+ 1 - 1
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 |

+ 73 - 0
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

+ 111 - 4
web/src/views/xg/xsjcxx/classmsg/classMsg/index.vue

@@ -49,6 +49,17 @@
           <el-col :span="1.5">
             <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['classMsg:classMsg:add']">新增</el-button>
           </el-col>
+          <el-col :span="1.5">
+            <el-dropdown v-hasPermi="['classMsg:classMsg:add']" split-button type="info" plain @click="handleImport">
+              导入
+              <template #dropdown>
+                <el-dropdown-menu>
+                  <el-dropdown-item icon="Download" @click="importTemplate">下载模板</el-dropdown-item>
+                  <el-dropdown-item icon="Upload" @click="handleImport">导入数据</el-dropdown-item>
+                </el-dropdown-menu>
+              </template>
+            </el-dropdown>
+          </el-col>
           <el-col :span="1.5">
             <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate()" v-hasPermi="['classMsg:classMsg:edit']"
               >修改</el-button
@@ -194,13 +205,13 @@
           <el-input v-model="form.grade" placeholder="请输入年级" />
         </el-form-item>
         <el-form-item label="入学年份" prop="enrollmentYear">
-          <el-input v-model="form.enrollmentYear" placeholder="请输入入学年份" />
+          <el-input v-model="form.enrollmentYear" placeholder="请输入入学年份" @input="syncGraduationYear" />
         </el-form-item>
         <el-form-item label="学制(年)" prop="schoolSystem">
-          <el-input v-model="form.schoolSystem" placeholder="请输入学制(年)" />
+          <el-input v-model="form.schoolSystem" placeholder="请输入学制(年)" @input="syncGraduationYear" />
         </el-form-item>
         <el-form-item label="毕业年份" prop="graduationYear">
-          <el-input v-model="form.graduationYear" placeholder="请输入毕业年份" />
+          <el-input v-model="form.graduationYear" placeholder="入学年份+学制自动计算,可修改" />
         </el-form-item>
         <el-form-item label="辅导员ID" prop="counselorId" v-if="false">
           <el-input v-model="form.counselorId" placeholder="请输入辅导员ID" />
@@ -386,6 +397,41 @@
       </template>
     </el-dialog>
 
+    <!-- 班级信息导入对话框 -->
+    <el-dialog v-model="upload.open" :title="upload.title" width="420px" append-to-body>
+      <el-upload
+        ref="uploadRef"
+        :limit="1"
+        accept=".xlsx, .xls"
+        :headers="upload.headers"
+        :action="upload.url"
+        :disabled="upload.isUploading"
+        :on-progress="handleFileUploadProgress"
+        :on-success="handleFileSuccess"
+        :on-error="handleFileError"
+        :auto-upload="false"
+        drag
+      >
+        <el-icon class="el-icon--upload"><i-ep-upload-filled /></el-icon>
+        <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
+        <template #tip>
+          <div class="el-upload__tip text-center">
+            <div>请先下载模板,通过下拉框选择学院、专业及字典项后上传。</div>
+            <span>仅允许导入 xls、xlsx 格式文件。</span>
+            <el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline" @click="importTemplate">
+              下载模板
+            </el-link>
+          </div>
+        </template>
+      </el-upload>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button type="primary" :loading="upload.isUploading" @click="submitFileForm">确 定</el-button>
+          <el-button @click="upload.open = false">取 消</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
     <!-- 设置辅导员对话框 -->
     <el-dialog title="设置辅导员" v-model="counselorDialog.visible" width="500px" append-to-body>
       <el-descriptions title="班级信息" :column="1" border>
@@ -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<SysDeptVo[]>([]); // 学院专业树形数据
 
 const queryFormRef = ref<ElFormInstance>();
 const classMsgFormRef = ref<ElFormInstance>();
+const uploadRef = ref<UploadInstance>();
+
+/** 班级导入参数 */
+const upload = reactive({
+  open: false,
+  title: '',
+  isUploading: false,
+  headers: globalHeaders(),
+  url: import.meta.env.VITE_APP_BASE_API + '/classMsg/classMsg/importData'
+});
 
 const dialog = reactive<DialogOption>({
   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(
+    "<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + (response.msg || '导入完成') + '</div>',
+    '导入结果',
+    { dangerouslyUseHTMLString: true }
+  );
+  getList();
+};
+
+/** 文件上传失败处理 */
+const handleFileError = () => {
+  upload.isUploading = false;
+  proxy?.$modal.msgError('导入失败,请检查文件格式或网络连接');
+};
+
+/** 提交上传文件 */
+const submitFileForm = () => {
+  uploadRef.value?.submit();
+};
+
 /** 修改按钮操作 */
 const handleUpdate = async (row?: ClassMsgVO) => {
   reset();