diff --git a/src/main/java/com/yunhai/acasyllabu/controller/api/SettingController.java b/src/main/java/com/yunhai/acasyllabu/controller/api/SettingController.java index 504e088..bff06a7 100644 --- a/src/main/java/com/yunhai/acasyllabu/controller/api/SettingController.java +++ b/src/main/java/com/yunhai/acasyllabu/controller/api/SettingController.java @@ -2,6 +2,7 @@ package com.yunhai.acasyllabu.controller.api; import com.yunhai.acasyllabu.config.Config; import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; +import com.yunhai.acasyllabu.model.syllabus.GradeSettingVo; import com.yunhai.acasyllabu.model.syllabus.SettingGetVo; import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo; @@ -14,6 +15,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * 应用设置接口(setting/)。 *

时间设置(时段判定边界 + 节次/课间时长)与节次模板(绑定学期×多年级,行含节次与课间/午休空白行) @@ -50,10 +53,36 @@ public class SettingController { settingService.deleteTemplate(id); } + /** 保存课表页「显示全部/只显示节次」偏好到指定模板(绑定模板记忆,预览课表/我的课表同随此值) */ + @PostMapping("SaveTemplateShowBreakRows") + public void saveTemplateShowBreakRows(@RequestParam("id") Integer id, + @RequestParam("showBreakRows") Boolean showBreakRows) { + settingService.saveTemplateShowBreakRows(id, showBreakRows); + } + + /** 保存课表页「节次名称 显示/隐藏」偏好到指定模板(绑定模板记忆,预览课表/我的课表同随此值) */ + @PostMapping("SaveTemplateShowPeriodName") + public void saveTemplateShowPeriodName(@RequestParam("id") Integer id, + @RequestParam("showPeriodName") Boolean showPeriodName) { + settingService.saveTemplateShowPeriodName(id, showPeriodName); + } + /** 课表编辑页生效设置:时间边界 + 当前学期下指定年级启用模板的行(gradeName 如 高一,term 1/2) */ @GetMapping("ForGrade") public EffectiveSettingVo forGrade(@RequestParam(value = "gradeName", required = false) String gradeName, @RequestParam(value = "term", required = false) Integer term) { return settingService.effective(gradeName, term); } + + /** 年级设置列表(启用 + 使用电子版/照片版;班级树年级与已存设置合并,未配置默认全启用) */ + @GetMapping("Grades") + public List grades() { + return settingService.grades(); + } + + /** 批量保存年级设置(按年级名 upsert;作用于「我的课表」电子/照片视图切换) */ + @PostMapping("SaveGrades") + public void saveGrades(@RequestBody List grades) { + settingService.saveGrades(grades); + } } diff --git a/src/main/java/com/yunhai/acasyllabu/controller/api/TimetableController.java b/src/main/java/com/yunhai/acasyllabu/controller/api/TimetableController.java index 7138492..2558b75 100644 --- a/src/main/java/com/yunhai/acasyllabu/controller/api/TimetableController.java +++ b/src/main/java/com/yunhai/acasyllabu/controller/api/TimetableController.java @@ -3,8 +3,9 @@ package com.yunhai.acasyllabu.controller.api; import com.yunhai.acasyllabu.config.Config; import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; import com.yunhai.acasyllabu.model.bsch.SubjectVo; -import com.yunhai.acasyllabu.model.syllabus.MyClassVo; +import com.yunhai.acasyllabu.model.syllabus.MyClassesVo; import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo; +import com.yunhai.acasyllabu.model.syllabus.TimetableOverviewVo; import com.yunhai.acasyllabu.model.syllabus.TimetableSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TimetableVo; import com.yunhai.acasyllabu.service.IClassService; @@ -17,7 +18,9 @@ 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 javax.servlet.http.HttpServletResponse; import java.util.List; /** @@ -74,15 +77,49 @@ public class TimetableController { return classService.getNowYearTerm(); } + /** 排课情况汇总(本校全部班级的电子版 / 照片版编排状态,按学生树年级→班级顺序) */ + @GetMapping("Overview") + public List overview() { + return timetableService.overview(); + } + + /** 打包下载某年级全部照片课表(ZIP,条目按年级+班级名命名;仅当前学年学期,直接写响应流) */ + @GetMapping("DownloadGradePhotos") + public void downloadGradePhotos(@RequestParam("gradeName") String gradeName, HttpServletResponse response) { + timetableService.downloadGradePhotos(gradeName, response); + } + + // ============================ 导入导出(Excel) ============================ + + /** 下载课表导入模板(按班级年级生效节次模板生成;直接写响应流) */ + @GetMapping("ImportTemplate") + public void importTemplate(@RequestParam("classId") Integer classId, HttpServletResponse response) { + timetableService.writeImportTemplate(classId, response); + } + + /** 导入课表(xlsx 模板整表替换该班当前课表),返回课表ID */ + @PostMapping("Import") + public Integer importTimetable(@RequestParam("classId") Integer classId, + @RequestParam("file") MultipartFile file) { + return timetableService.importTimetable(classId, file); + } + + /** 导出班级电子课表(xlsx,当前学年学期;直接写响应流) */ + @GetMapping("Export") + public void exportTimetable(@RequestParam("classId") Integer classId, HttpServletResponse response) { + timetableService.exportTimetable(classId, response); + } + // ============================ 我的课表 ============================ /** - * 「我的课表」可查看班级(管理员/校领导=全校班级全部科目;班主任=所带班级全部科目; + * 「我的课表」可查看班级 + 当前用户身份标识(管理员/班主任/任教老师可叠加; + * 范围:管理员/校领导=全校班级全部科目;班主任=所带班级全部科目; * 任教老师=任教班级任教科目)。班级排序按学生树年级→班级顺序。 */ @GetMapping("MyClasses") - public List myClasses() { - return timetableService.myClasses(); + public MyClassesVo myClasses() { + return timetableService.myClassesVo(); } /** 「我的课表」班级课表(先校验访问权限;非班主任的任教老师只返回任教科目格) */ diff --git a/src/main/java/com/yunhai/acasyllabu/entity/GradeSetting.java b/src/main/java/com/yunhai/acasyllabu/entity/GradeSetting.java new file mode 100644 index 0000000..d2a5cd0 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/entity/GradeSetting.java @@ -0,0 +1,48 @@ +package com.yunhai.acasyllabu.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * 年级应用设置(每校×年级一行)。 + *

统一控制整个年级的课表查看:enabled 关闭后「我的课表」不再展示该年级课表; + * useElectronic / usePhoto 分别控制「我的课表」电子课表、照片课表两个视图是否开放 + * (都开放时页内 tab 切换,只开一个则直接展示该版)。未配置的年级视为全开, + * 与历史行为(电子版恒展示、照片版随上传展示)保持一致。

+ */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "timetable_grade_setting", + indexes = { + @Index(name = "uk_grade_sch_name", columnList = "sch_id, grade_name", unique = true) + }) +public class GradeSetting extends BaseEntity { + + /** 学校ID */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 年级名称(与班级服务学生树年级节点名一致,如 高一) */ + @Column(name = "grade_name", length = 16, nullable = false) + private String gradeName; + + /** 是否启用该年级课表查看(我的课表) */ + @Column(name = "enabled", nullable = false) + private Boolean enabled = Boolean.TRUE; + + /** 是否使用电子课表(我的课表电子视图) */ + @Column(name = "use_electronic", nullable = false) + private Boolean useElectronic = Boolean.TRUE; + + /** 是否使用照片课表(我的课表照片视图) */ + @Column(name = "use_photo", nullable = false) + private Boolean usePhoto = Boolean.TRUE; +} diff --git a/src/main/java/com/yunhai/acasyllabu/entity/PeriodTemplate.java b/src/main/java/com/yunhai/acasyllabu/entity/PeriodTemplate.java index 1aa97a9..5e558dc 100644 --- a/src/main/java/com/yunhai/acasyllabu/entity/PeriodTemplate.java +++ b/src/main/java/com/yunhai/acasyllabu/entity/PeriodTemplate.java @@ -67,4 +67,20 @@ public class PeriodTemplate extends BaseEntity { /** 课间时长(分钟):模板编辑器「+空白行」自动推算结束时间的默认时长,默认 10 */ @Column(name = "break_minutes", nullable = false) private Integer breakMinutes = 10; + + /** + * 课表页是否显示空白行(课间/午休等):true=显示全部(默认),false=只显示节次(表格更紧凑)。 + * 绑定模板记忆:课表编排页底部开关切换后落库到生效模板,预览课表与我的课表同随此值。 + * 可空:ddl-auto 给存量库加列时旧行为 NULL,按显示全部处理(读取侧 !Boolean.FALSE.equals 归一)。 + */ + @Column(name = "show_break_rows") + private Boolean showBreakRows = Boolean.TRUE; + + /** + * 课表页是否显示「节次」列(第一节/课间等名称):true=显示(默认),false=隐藏(表格更简洁)。 + * 随模板记忆:作用于课表编排预览课表与我的课表;课表编排的编辑表不受影响(编辑仍需节次定位)。 + * 可空:ddl-auto 给存量库加列时旧行为 NULL,按显示处理(读取侧 !Boolean.FALSE.equals 归一)。 + */ + @Column(name = "show_period_name") + private Boolean showPeriodName = Boolean.TRUE; } diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/EffectiveSettingVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/EffectiveSettingVo.java index 1b876bb..c812435 100644 --- a/src/main/java/com/yunhai/acasyllabu/model/syllabus/EffectiveSettingVo.java +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/EffectiveSettingVo.java @@ -24,4 +24,22 @@ public class EffectiveSettingVo { /** 生效模板名称(如 春),无启用模板时为空 */ private String templateName; + + /** 生效模板ID(课表页「显示全部/只显示节次」开关回存用),无启用模板时为空 */ + private Integer templateId; + + /** 课表页是否显示空白行(课间/午休):false=只显示节次,随生效模板记忆,无模板时默认显示 */ + private Boolean showBreakRows = Boolean.TRUE; + + /** 课表页是否显示「节次」列(节次名称):false=隐藏,随生效模板记忆,无模板时默认显示 */ + private Boolean showPeriodName = Boolean.TRUE; + + /** 年级是否启用课表查看(年级设置,未配置视为启用) */ + private Boolean gradeEnabled = Boolean.TRUE; + + /** 该年级是否使用电子课表(未配置视为使用) */ + private Boolean useElectronic = Boolean.TRUE; + + /** 该年级是否使用照片课表(未配置视为使用) */ + private Boolean usePhoto = Boolean.TRUE; } diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/GradeSettingVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/GradeSettingVo.java new file mode 100644 index 0000000..be0eac4 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/GradeSettingVo.java @@ -0,0 +1,24 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Getter; +import lombok.Setter; + +/** + * 年级应用设置 VO(启用 + 使用电子版/照片版;作用于「我的课表」视图切换)。 + */ +@Getter +@Setter +public class GradeSettingVo { + + /** 年级名称(如 高一) */ + private String gradeName; + + /** 是否启用该年级课表查看 */ + private Boolean enabled = Boolean.TRUE; + + /** 是否使用电子课表 */ + private Boolean useElectronic = Boolean.TRUE; + + /** 是否使用照片课表 */ + private Boolean usePhoto = Boolean.TRUE; +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/MyClassesVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/MyClassesVo.java new file mode 100644 index 0000000..876ad36 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/MyClassesVo.java @@ -0,0 +1,27 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 「我的课表」班级列表 + 当前用户身份标识(「我的课表」页按身份切换视图用)。 + *

身份可叠加(如管理员同时是某班班主任、又在其他班任教); + * 前端按 管理员 > 班主任 > 任教老师 优先级展示默认视图,多身份时可点击切换。

+ */ +@Data +public class MyClassesVo { + + /** 是否管理员/校领导身份( roleId 7/2) */ + private boolean admin; + + /** 是否班主任身份(任一班的正/副班主任是本人) */ + private boolean headTeacher; + + /** 是否任教老师身份(任一班有任教科目) */ + private boolean subjectTeacher; + + /** 可查看班级(范围规则同身份:管理员=全校;班主任/任教=所带∪任教;学生=本人班级) */ + private List classes = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/PeriodTemplateVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/PeriodTemplateVo.java index 1ac89aa..a920e5a 100644 --- a/src/main/java/com/yunhai/acasyllabu/model/syllabus/PeriodTemplateVo.java +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/PeriodTemplateVo.java @@ -39,6 +39,12 @@ public class PeriodTemplateVo { /** 课间时长(分钟),模板加空白行的默认用时 */ private Integer breakMinutes = 10; + /** 课表页是否显示空白行(课间/午休):false=只显示节次,随模板保存 */ + private Boolean showBreakRows = Boolean.TRUE; + + /** 课表页是否显示「节次」列(节次名称):false=隐藏,随模板保存 */ + private Boolean showPeriodName = Boolean.TRUE; + /** 模板行(节次 + 空白行,按顺序) */ private List rows = new ArrayList<>(); } diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateSaveRequest.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateSaveRequest.java index 9b5a32f..6262f86 100644 --- a/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateSaveRequest.java +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateSaveRequest.java @@ -38,5 +38,11 @@ public class TemplateSaveRequest { /** 课间时长(分钟),空=沿用现值 */ private Integer breakMinutes; + /** 课表页是否显示空白行(课间/午休):false=只显示节次,空=沿用现值 */ + private Boolean showBreakRows; + + /** 课表页是否显示「节次」列(节次名称):false=隐藏,空=沿用现值 */ + private Boolean showPeriodName; + private List rows = new ArrayList<>(); } diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableOverviewVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableOverviewVo.java new file mode 100644 index 0000000..063aa9f --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableOverviewVo.java @@ -0,0 +1,34 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Data; + +/** + * 排课情况视图(按班级汇总电子版 / 照片版的编排状态,「排课情况」页卡片数据源)。 + */ +@Data +public class TimetableOverviewVo { + + /** 班级ID(跳转课表编排回显用) */ + private Integer classId; + + /** 年级名称 */ + private String gradeName; + + /** 班级名称 */ + private String className; + + /** 电子版是否已设置(当前学年学期已保存过节次) */ + private boolean electronicSet; + + /** 已排节次数(电子版已设置时的节次行数) */ + private int periodCount; + + /** 已排科目数(节次内去重学科名) */ + private int subjectCount; + + /** 照片版是否已上传 */ + private boolean photoSet; + + /** 照片课表附件ID(缩略图预览用,未上传为 null) */ + private Integer photoFileId; +} diff --git a/src/main/java/com/yunhai/acasyllabu/repository/GradeSettingRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/GradeSettingRepository.java new file mode 100644 index 0000000..575297f --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/repository/GradeSettingRepository.java @@ -0,0 +1,14 @@ +package com.yunhai.acasyllabu.repository; + +import com.yunhai.acasyllabu.entity.GradeSetting; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface GradeSettingRepository extends JpaRepository { + + List findBySchIdOrderByIdAsc(Integer schId); + + Optional findBySchIdAndGradeName(Integer schId, String gradeName); +} diff --git a/src/main/java/com/yunhai/acasyllabu/repository/TimetableRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/TimetableRepository.java index eeac7b9..9e7b074 100644 --- a/src/main/java/com/yunhai/acasyllabu/repository/TimetableRepository.java +++ b/src/main/java/com/yunhai/acasyllabu/repository/TimetableRepository.java @@ -11,4 +11,7 @@ public interface TimetableRepository extends JpaRepository { Optional findBySchIdAndClassIdAndYearAndTerm(Integer schId, Integer classId, Integer year, Integer term); List findBySchIdOrderByUpdateTimeDesc(Integer schId); + + /** 当前学年学期本校全部课表(排课情况汇总用) */ + List findBySchIdAndYearAndTerm(Integer schId, Integer year, Integer term); } diff --git a/src/main/java/com/yunhai/acasyllabu/repository/TimetableSlotRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/TimetableSlotRepository.java index 040cbda..9689908 100644 --- a/src/main/java/com/yunhai/acasyllabu/repository/TimetableSlotRepository.java +++ b/src/main/java/com/yunhai/acasyllabu/repository/TimetableSlotRepository.java @@ -3,6 +3,7 @@ package com.yunhai.acasyllabu.repository; import com.yunhai.acasyllabu.entity.TimetableSlot; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; import java.util.List; public interface TimetableSlotRepository extends JpaRepository { @@ -10,4 +11,7 @@ public interface TimetableSlotRepository extends JpaRepository findByTimetableIdOrderBySortOrderAsc(Integer timetableId); void deleteByTimetableId(Integer timetableId); + + /** 多张课表的节次一次取回(排课情况汇总用,避免按班逐张查询) */ + List findByTimetableIdIn(Collection timetableIds); } diff --git a/src/main/java/com/yunhai/acasyllabu/service/IFileService.java b/src/main/java/com/yunhai/acasyllabu/service/IFileService.java index 373b57a..3dcbad0 100644 --- a/src/main/java/com/yunhai/acasyllabu/service/IFileService.java +++ b/src/main/java/com/yunhai/acasyllabu/service/IFileService.java @@ -7,6 +7,7 @@ import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletResponse; import java.util.List; +import java.util.Map; /** * 文件上传/下载/删除业务接口。 @@ -72,6 +73,18 @@ public interface IFileService { */ void downloadZip(List files, String zipName, HttpServletResponse response); + /** + * 打包下载多个附件为 ZIP,条目名由调用方指定(fileId -> 条目名,如按班级命名)。 + * 未指定的文件回退用原始文件名。 + * + * @param files 附件记录列表 + * @param entryNames 条目名映射(可为空) + * @param zipName 压缩包显示名(自动追加 .zip) + * @param response HTTP 响应 + */ + void downloadZip(List files, Map entryNames, String zipName, + HttpServletResponse response); + /** * 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。 * diff --git a/src/main/java/com/yunhai/acasyllabu/service/ISettingService.java b/src/main/java/com/yunhai/acasyllabu/service/ISettingService.java index 48e6255..cf847fd 100644 --- a/src/main/java/com/yunhai/acasyllabu/service/ISettingService.java +++ b/src/main/java/com/yunhai/acasyllabu/service/ISettingService.java @@ -1,12 +1,15 @@ package com.yunhai.acasyllabu.service; import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; +import com.yunhai.acasyllabu.model.syllabus.GradeSettingVo; import com.yunhai.acasyllabu.model.syllabus.SettingGetVo; import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo; +import java.util.List; + /** - * 应用设置业务:时段判定时间设置 + 节次模板(含行)维护。 + * 应用设置业务:时段判定时间设置 + 节次模板(含行)+ 年级设置维护。 */ public interface ISettingService { @@ -22,6 +25,18 @@ public interface ISettingService { /** 删除本校模板(连带行) */ void deleteTemplate(Integer templateId); + /** 保存课表页「显示全部/只显示节次」偏好到指定模板(绑定模板记忆) */ + void saveTemplateShowBreakRows(Integer templateId, Boolean showBreakRows); + + /** 保存课表页「节次名称 显示/隐藏」偏好到指定模板(绑定模板记忆) */ + void saveTemplateShowPeriodName(Integer templateId, Boolean showPeriodName); + /** 课表编辑页生效设置:时间边界 + 当前学期下指定年级启用模板的行(无启用模板则 rows 为空) */ EffectiveSettingVo effective(String gradeName, Integer term); + + /** 年级设置列表(班级树年级与已存设置合并;未配置的年级默认全启用) */ + List grades(); + + /** 批量保存年级设置(按年级名 upsert) */ + void saveGrades(List grades); } diff --git a/src/main/java/com/yunhai/acasyllabu/service/ITimetableService.java b/src/main/java/com/yunhai/acasyllabu/service/ITimetableService.java index 4273605..7f0c0ef 100644 --- a/src/main/java/com/yunhai/acasyllabu/service/ITimetableService.java +++ b/src/main/java/com/yunhai/acasyllabu/service/ITimetableService.java @@ -1,11 +1,15 @@ package com.yunhai.acasyllabu.service; import com.yunhai.acasyllabu.model.syllabus.MyClassVo; +import com.yunhai.acasyllabu.model.syllabus.MyClassesVo; import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo; import com.yunhai.acasyllabu.model.syllabus.OapiTimetableClassVo; +import com.yunhai.acasyllabu.model.syllabus.TimetableOverviewVo; import com.yunhai.acasyllabu.model.syllabus.TimetableSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TimetableVo; +import org.springframework.web.multipart.MultipartFile; +import javax.servlet.http.HttpServletResponse; import java.util.List; /** @@ -44,6 +48,41 @@ public interface ITimetableService { */ void removePhoto(Integer classId); + /** + * 排课情况汇总(「排课情况」页):本校全部班级按学生树年级→班级顺序返回, + * 每班给出电子版(是否已设置 / 节次数 / 去重科目数)与照片版(是否已上传 / 附件ID)状态。 + * 数据取当前学年学期;未建课表的班级电子版视为未设置。 + */ + List overview(); + + /** + * 打包下载指定年级全部照片课表为 ZIP(「排课情况」页),条目按「年级+班级名.扩展名」命名。 + * 数据取当前学年学期;年级下无已上传照片时抛业务异常。 + * + * @param gradeName 年级名(学生树第一层节点名) + * @param response HTTP 响应(ZIP 文件流) + */ + void downloadGradePhotos(String gradeName, HttpServletResponse response); + + /** + * 下载课表导入模板(xlsx):按班级所在年级的生效节次模板生成,含节次序号、上课时间与 + * 教学日列,学科格留空待填。年级未启用节次模板时抛业务异常。 + */ + void writeImportTemplate(Integer classId, HttpServletResponse response); + + /** + * 导入课表(xlsx):按模板「节次」列对齐,节次骨架与上课时间取当前生效节次模板, + * 只录入各教学日学科;整表替换该班当前学年学期课表(与「保存」同语义)。 + * + * @return 课表ID + */ + Integer importTimetable(Integer classId, MultipartFile file); + + /** + * 导出班级当前学年学期电子课表为 xlsx(直接写响应流)。未编排电子课表时抛业务异常。 + */ + void exportTimetable(Integer classId, HttpServletResponse response); + /** * 「我的课表」可查看班级列表(按当前用户角色过滤)。 *
    @@ -53,9 +92,16 @@ public interface ITimetableService { *
  • 学生(3):本人所在班级,全部科目。
  • *
* 排序按学生树年级→班级顺序;任教/班级接口不可达时降级返回可得部分。 + * 班主任/任教身份标记按用户事实计算(管理员会话同样标记),供多身份视图切换。 */ List myClasses(); + /** + * 「我的课表」班级列表 + 当前用户身份标识(admin/headTeacher/subjectTeacher 可叠加, + * 前端按 管理员>班主任>任教老师 优先级展示默认视图,多身份时可切换)。 + */ + MyClassesVo myClassesVo(); + /** * 「我的课表」可查看班级列表(oapi 显式身份版本,身份由调用方传入,不取 UserContext)。 * 角色范围同 {@link #myClasses()};roleId 缺省按普通教师处理。 diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java index a0187dc..9e30e8f 100644 --- a/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java +++ b/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java @@ -217,6 +217,12 @@ public class FileService implements IFileService { @Override public void downloadZip(List files, String zipName, HttpServletResponse response) { + downloadZip(files, null, zipName, response); + } + + @Override + public void downloadZip(List files, Map entryNames, String zipName, + HttpServletResponse response) { if (files == null || files.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); } @@ -233,7 +239,7 @@ public class FileService implements IFileService { log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName()); continue; } - String entryName = sanitizeEntryName(f.getFileTrueName()); + String entryName = sanitizeEntryName(entryNameOf(f, entryNames)); if (used.contains(entryName)) { entryName = f.getId() + "_" + entryName; } @@ -249,6 +255,17 @@ public class FileService implements IFileService { } } + /** 条目名:优先取调用方指定的名称(按 fileId),否则用原始文件名 */ + private String entryNameOf(UpFile f, Map entryNames) { + if (entryNames != null) { + String named = entryNames.get(f.getId()); + if (named != null && !named.trim().isEmpty()) { + return named; + } + } + return f.getFileTrueName(); + } + /** * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。 */ diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java index 0ea6bc0..491c363 100644 --- a/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java +++ b/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java @@ -1,17 +1,22 @@ package com.yunhai.acasyllabu.service.impl; +import com.yunhai.acasyllabu.entity.GradeSetting; import com.yunhai.acasyllabu.entity.PeriodTemplate; import com.yunhai.acasyllabu.entity.TemplateRow; import com.yunhai.acasyllabu.entity.TimeSetting; +import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; +import com.yunhai.acasyllabu.model.syllabus.GradeSettingVo; import com.yunhai.acasyllabu.model.syllabus.PeriodTemplateVo; import com.yunhai.acasyllabu.model.syllabus.SettingGetVo; import com.yunhai.acasyllabu.model.syllabus.TemplateRowVo; import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo; +import com.yunhai.acasyllabu.repository.GradeSettingRepository; import com.yunhai.acasyllabu.repository.PeriodTemplateRepository; import com.yunhai.acasyllabu.repository.TemplateRowRepository; import com.yunhai.acasyllabu.repository.TimeSettingRepository; +import com.yunhai.acasyllabu.service.IClassService; import com.yunhai.acasyllabu.service.ISettingService; import com.yunhai.acasyllabu.utils.UserContext; import com.yunhai.platform.apistd.BizException; @@ -27,9 +32,12 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -78,6 +86,9 @@ public class SettingService implements ISettingService { /** 年级串(逗号拼接)总长上限,与实体列长一致 */ private static final int MAX_GRADES_LENGTH = 64; + /** 年级名称单长上限,与实体列一致 */ + private static final int MAX_GRADE_NAME = 16; + @Autowired private TimeSettingRepository timeSettingRepository; @@ -87,6 +98,12 @@ public class SettingService implements ISettingService { @Autowired private TemplateRowRepository rowRepository; + @Autowired + private GradeSettingRepository gradeSettingRepository; + + @Autowired + private IClassService classService; + // ============================ 查询 ============================ @Override @@ -109,6 +126,12 @@ public class SettingService implements ISettingService { return vo; } String grade = gradeName.trim(); + // 年级设置随生效设置一并带出(我的课表按其控制电子/照片视图切换;未配置视为全启用) + gradeSettingRepository.findBySchIdAndGradeName(schId, grade).ifPresent(gs -> { + vo.setGradeEnabled(!Boolean.FALSE.equals(gs.getEnabled())); + vo.setUseElectronic(!Boolean.FALSE.equals(gs.getUseElectronic())); + vo.setUsePhoto(!Boolean.FALSE.equals(gs.getUsePhoto())); + }); // 请求学期只可能是 1/2(课表页按当前学年学期查询);学期专属模板优先于全年模板 Integer reqTerm = Integer.valueOf(2).equals(term) ? 2 : 1; templateRepository.findBySchIdAndEnabledTrueOrderByIdAsc(schId).stream() @@ -117,8 +140,12 @@ public class SettingService implements ISettingService { .sorted(Comparator.comparingInt(tpl -> Objects.equals(tpl.getTerm(), reqTerm) ? 0 : 1)) .findFirst() .ifPresent(tpl -> { + vo.setTemplateId(tpl.getId()); vo.setTeachDays(tpl.getTeachDays()); vo.setTemplateName(tpl.getName()); + // 「显示全部/只显示节次」「节次名称 显示/隐藏」偏好绑定生效模板(预览课表与我的课表同随此值) + vo.setShowBreakRows(!Boolean.FALSE.equals(tpl.getShowBreakRows())); + vo.setShowPeriodName(!Boolean.FALSE.equals(tpl.getShowPeriodName())); // 启用时段与节次/课间时长绑定模板:课表编辑页按生效模板的时段判定与展示 vo.getTime().setEnabledBands(sanitizeBands(tpl.getEnabledBands())); if (tpl.getPeriodMinutes() != null) { @@ -222,6 +249,13 @@ public class SettingService implements ISettingService { tpl.setEnabledBands(bands); tpl.setPeriodMinutes(periodMinutes); tpl.setBreakMinutes(breakMinutes); + // 课表页显示偏好:空沿用现值(新建默认显示空白行、显示节次名称) + tpl.setShowBreakRows(request.getShowBreakRows() != null + ? request.getShowBreakRows() + : !Boolean.FALSE.equals(tpl.getShowBreakRows())); + tpl.setShowPeriodName(request.getShowPeriodName() != null + ? request.getShowPeriodName() + : !Boolean.FALSE.equals(tpl.getShowPeriodName())); tpl = templateRepository.save(tpl); if (enabled) { @@ -269,6 +303,95 @@ public class SettingService implements ISettingService { log.info("[deleteTemplate] schId={}, id={}, {}·{}", schId, tpl.getId(), tpl.getGradeName(), tpl.getName()); } + @Override + @Transactional + public void saveTemplateShowBreakRows(Integer templateId, Boolean showBreakRows) { + Integer schId = currentSchId(); + PeriodTemplate tpl = templateRepository.findById(templateId) + .filter(t -> Objects.equals(t.getSchId(), schId)) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "模板不存在: " + templateId)); + tpl.setShowBreakRows(!Boolean.FALSE.equals(showBreakRows)); + templateRepository.save(tpl); + log.info("[saveTemplateShowBreakRows] schId={}, id={}, showBreakRows={}", schId, tpl.getId(), tpl.getShowBreakRows()); + } + + @Override + @Transactional + public void saveTemplateShowPeriodName(Integer templateId, Boolean showPeriodName) { + Integer schId = currentSchId(); + PeriodTemplate tpl = templateRepository.findById(templateId) + .filter(t -> Objects.equals(t.getSchId(), schId)) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "模板不存在: " + templateId)); + tpl.setShowPeriodName(!Boolean.FALSE.equals(showPeriodName)); + templateRepository.save(tpl); + log.info("[saveTemplateShowPeriodName] schId={}, id={}, showPeriodName={}", schId, tpl.getId(), tpl.getShowPeriodName()); + } + + // ============================ 年级设置 ============================ + + @Override + public List grades() { + Integer schId = currentSchId(); + // 班级树年级节点为口径(与排课情况/我的课表所见年级一致),已存设置按名合并,未配置默认全启用 + Map saved = new LinkedHashMap<>(); + for (GradeSetting gs : gradeSettingRepository.findBySchIdOrderByIdAsc(schId)) { + saved.putIfAbsent(gs.getGradeName(), gs); + } + List out = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (ClassTreeNodeVo gradeNode : classService.getClassTree(schId)) { + String name = gradeNode.getName() == null ? "" : gradeNode.getName().trim(); + if (!StringUtils.hasText(name) || !seen.add(name)) { + continue; + } + out.add(toGradeVo(name, saved.remove(name))); + } + // 班级树暂不可达时兜底返回已存设置,避免已有配置在页面消失 + for (Map.Entry e : saved.entrySet()) { + out.add(toGradeVo(e.getKey(), e.getValue())); + } + return out; + } + + @Override + @Transactional + public void saveGrades(List grades) { + Integer schId = currentSchId(); + if (grades == null || grades.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "年级设置不能为空"); + } + for (GradeSettingVo vo : grades) { + if (vo == null || !StringUtils.hasText(vo.getGradeName())) { + throw new BizException(ErrorCode.PARAM_ERROR, "年级名称不能为空"); + } + String name = vo.getGradeName().trim(); + if (name.length() > MAX_GRADE_NAME) { + throw new BizException(ErrorCode.PARAM_ERROR, "年级名称过长: " + name); + } + GradeSetting gs = gradeSettingRepository.findBySchIdAndGradeName(schId, name).orElseGet(() -> { + GradeSetting created = new GradeSetting(); + created.setSchId(schId); + created.setGradeName(name); + return created; + }); + gs.setEnabled(!Boolean.FALSE.equals(vo.getEnabled())); + gs.setUseElectronic(!Boolean.FALSE.equals(vo.getUseElectronic())); + gs.setUsePhoto(!Boolean.FALSE.equals(vo.getUsePhoto())); + gradeSettingRepository.save(gs); + } + log.info("[saveGrades] schId={}, 保存年级设置 {} 条", schId, grades.size()); + } + + private GradeSettingVo toGradeVo(String gradeName, GradeSetting gs) { + GradeSettingVo vo = new GradeSettingVo(); + vo.setGradeName(gradeName); + // 未配置的年级视为全启用(与历史行为一致:电子版恒展示、照片版随上传展示) + vo.setEnabled(gs != null ? !Boolean.FALSE.equals(gs.getEnabled()) : Boolean.TRUE); + vo.setUseElectronic(gs != null ? !Boolean.FALSE.equals(gs.getUseElectronic()) : Boolean.TRUE); + vo.setUsePhoto(gs != null ? !Boolean.FALSE.equals(gs.getUsePhoto()) : Boolean.TRUE); + return vo; + } + // ============================ 校验与辅助 ============================ /** @@ -550,6 +673,8 @@ public class SettingService implements ISettingService { vo.setEnabledBands(sanitizeBands(tpl.getEnabledBands())); vo.setPeriodMinutes(orDefault(tpl.getPeriodMinutes(), DEFAULT_PERIOD_MINUTES)); vo.setBreakMinutes(orDefault(tpl.getBreakMinutes(), DEFAULT_BREAK_MINUTES)); + vo.setShowBreakRows(!Boolean.FALSE.equals(tpl.getShowBreakRows())); + vo.setShowPeriodName(!Boolean.FALSE.equals(tpl.getShowPeriodName())); for (TemplateRow row : rowRepository.findByTemplateIdOrderBySortOrderAsc(tpl.getId())) { vo.getRows().add(toRowVo(row)); } diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/TimetableService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/TimetableService.java index 590ea95..810f012 100644 --- a/src/main/java/com/yunhai/acasyllabu/service/impl/TimetableService.java +++ b/src/main/java/com/yunhai/acasyllabu/service/impl/TimetableService.java @@ -6,9 +6,13 @@ import com.yunhai.acasyllabu.entity.UpFile; import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; import com.yunhai.acasyllabu.model.bsch.SubjectVo; +import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; import com.yunhai.acasyllabu.model.syllabus.MyClassVo; +import com.yunhai.acasyllabu.model.syllabus.MyClassesVo; import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo; import com.yunhai.acasyllabu.model.syllabus.OapiTimetableClassVo; +import com.yunhai.acasyllabu.model.syllabus.TemplateRowVo; +import com.yunhai.acasyllabu.model.syllabus.TimetableOverviewVo; import com.yunhai.acasyllabu.model.syllabus.TimetableSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TimetableSlotVo; import com.yunhai.acasyllabu.model.syllabus.TimetableVo; @@ -17,10 +21,12 @@ import com.yunhai.acasyllabu.repository.TimetableSlotRepository; import com.yunhai.acasyllabu.repository.UpFileRepository; import com.yunhai.acasyllabu.service.IClassService; import com.yunhai.acasyllabu.service.IFileService; +import com.yunhai.acasyllabu.service.ISettingService; import com.yunhai.acasyllabu.service.ITimetableService; +import com.yunhai.acasyllabu.utils.TimetableExcelUtil; +import com.yunhai.acasyllabu.utils.UserContext; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; -import com.yunhai.acasyllabu.utils.UserContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeanUtils; @@ -28,9 +34,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashMap; @@ -83,6 +95,9 @@ public class TimetableService implements ITimetableService { @Autowired private IFileService fileService; + @Autowired + private ISettingService settingService; + // ============================ 查询 ============================ @Override @@ -228,6 +243,295 @@ public class TimetableService implements ITimetableService { log.info("[removePhoto] schId={}, classId={}, fileId={}", currentSchId(), classId, oldFileId); } + // ============================ 排课情况 ============================ + + @Override + public List overview() { + Integer schId = currentSchId(); + NowYearTermVo yearTerm = currentYearTerm(); + + // 当前学年学期课表按班级ID归并;节次按课表ID一次取回,避免逐班查询 + Map byClass = new LinkedHashMap<>(); + for (Timetable t : timetableRepository.findBySchIdAndYearAndTerm(schId, yearTerm.getYear(), yearTerm.getTerm())) { + byClass.put(t.getClassId(), t); + } + Map> slotsByTimetable = slotsByTimetable(byClass.values()); + + List result = new ArrayList<>(); + int electronicCount = 0; + int photoCount = 0; + for (ClassTreeNodeVo grade : classService.getClassTree(schId)) { + for (ClassTreeNodeVo cls : grade.getChildren() == null ? new ArrayList() : grade.getChildren()) { + TimetableOverviewVo vo = new TimetableOverviewVo(); + vo.setClassId(cls.getId()); + vo.setGradeName(grade.getName()); + vo.setClassName(cls.getName()); + Timetable t = cls.getId() == null ? null : byClass.get(cls.getId()); + if (t != null) { + vo.setPhotoSet(t.getPhotoFileId() != null); + vo.setPhotoFileId(t.getPhotoFileId()); + List slots = slotsByTimetable.getOrDefault(t.getId(), new ArrayList<>()); + vo.setElectronicSet(!slots.isEmpty()); + vo.setPeriodCount(slots.size()); + vo.setSubjectCount(countDistinctSubjects(slots)); + if (!slots.isEmpty()) { + electronicCount++; + } + if (t.getPhotoFileId() != null) { + photoCount++; + } + } + result.add(vo); + } + } + log.info("[overview] schId={}, year={} term={}, 班级 {} 个,电子版已设置 {} 个,照片已上传 {} 个", + schId, yearTerm.getYear(), yearTerm.getTerm(), result.size(), electronicCount, photoCount); + return result; + } + + /** 课表ID -> 节次列表(一次查询取回,按 sortOrder 升序整理) */ + private Map> slotsByTimetable(Collection timetables) { + Map> map = new LinkedHashMap<>(); + List ids = new ArrayList<>(); + for (Timetable t : timetables) { + ids.add(t.getId()); + } + if (ids.isEmpty()) { + return map; + } + for (TimetableSlot slot : slotRepository.findByTimetableIdIn(ids)) { + map.computeIfAbsent(slot.getTimetableId(), k -> new ArrayList<>()).add(slot); + } + for (List list : map.values()) { + list.sort(Comparator.comparing(TimetableSlot::getSortOrder, Comparator.nullsLast(Comparator.naturalOrder()))); + } + return map; + } + + /** 节次内去重科目数(周一~周日所有格,空格不计) */ + private static int countDistinctSubjects(List slots) { + Set names = new LinkedHashSet<>(); + for (TimetableSlot slot : slots) { + for (String name : Arrays.asList(slot.getDay1(), slot.getDay2(), slot.getDay3(), + slot.getDay4(), slot.getDay5(), slot.getDay6(), slot.getDay7())) { + if (StringUtils.hasText(name)) { + names.add(name.trim()); + } + } + } + return names.size(); + } + + // ============================ 年级照片打包下载 ============================ + + @Override + public void downloadGradePhotos(String gradeName, HttpServletResponse response) { + if (!StringUtils.hasText(gradeName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "年级不能为空"); + } + Integer schId = currentSchId(); + NowYearTermVo yearTerm = currentYearTerm(); + + // 该年级下的班级集合(按学生树;年级名不存在直接报错,避免拼出空包) + Set classIds = new LinkedHashSet<>(); + boolean gradeFound = false; + for (ClassTreeNodeVo grade : classService.getClassTree(schId)) { + if (!gradeName.equals(grade.getName())) { + continue; + } + gradeFound = true; + for (ClassTreeNodeVo cls : grade.getChildren() == null ? new ArrayList() : grade.getChildren()) { + if (cls.getId() != null) { + classIds.add(cls.getId()); + } + } + } + if (!gradeFound) { + throw new BizException(ErrorCode.PARAM_ERROR, "年级不存在: " + gradeName); + } + + // 当前学年学期该年级课表的照片附件,条目按「年级+班级名.扩展名」命名 + List files = new ArrayList<>(); + Map entryNames = new LinkedHashMap<>(); + for (Timetable t : timetableRepository.findBySchIdAndYearAndTerm(schId, yearTerm.getYear(), yearTerm.getTerm())) { + if (t.getPhotoFileId() == null || !classIds.contains(t.getClassId())) { + continue; + } + UpFile f = upFileRepository.findById(t.getPhotoFileId()).orElse(null); + if (f == null) { + log.warn("[downloadGradePhotos] 照片附件记录缺失,跳过: classId={}, fileId={}", + t.getClassId(), t.getPhotoFileId()); + continue; + } + files.add(f); + String ext = f.getFileType() == null ? "" : f.getFileType().trim(); + String base = StringUtils.hasText(t.getGradeName()) ? t.getGradeName() : gradeName; + entryNames.put(f.getId(), base + t.getClassName() + (ext.isEmpty() ? "" : "." + ext)); + } + if (files.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "「" + gradeName + "」暂无已上传的照片课表"); + } + log.info("[downloadGradePhotos] schId={}, grade={}, year={} term={}, 打包 {} 个班级照片", + schId, gradeName, yearTerm.getYear(), yearTerm.getTerm(), files.size()); + String termName = yearTerm.getTerm() == 2 ? "第二学期" : "第一学期"; + fileService.downloadZip(files, entryNames, gradeName + "照片课表_" + yearTerm.getYear() + termName, response); + } + + // ============================ 导入导出(Excel) ============================ + + @Override + public void writeImportTemplate(Integer classId, HttpServletResponse response) { + Integer schId = currentSchId(); + String className = requireSchoolClass(schId, classId); + String gradeName = classService.gradeNameOf(schId, classId); + EffectiveSettingVo effective = effectiveSetting(gradeName); + List periodRows = effectivePeriodRows(effective); + boolean[] taught = taughtDays(effective.getTeachDays()); + + List rows = new ArrayList<>(); + for (int i = 0; i < periodRows.size(); i++) { + rows.add(new TimetableExcelUtil.GridRow("第" + (i + 1) + "节", + periodTimeText(periodRows.get(i)), emptyCells(taught.length))); + } + NowYearTermVo yearTerm = currentYearTerm(); + String title = className + " 课程表导入模板(" + yearTerm.getYear() + "学年" + termName(yearTerm.getTerm()) + ")"; + String notice = "说明:请在各节次行填写学科名称(留空为不排),学科须为本校学科列表中的学科;" + + "节次与上课时间由系统节次模板生成,导入时以其为准、按「节次」列对齐。" + subjectHint(schId); + TimetableExcelUtil.SheetGrid grid = new TimetableExcelUtil.SheetGrid(title, notice, dayHeads(taught), rows); + log.info("[writeImportTemplate] schId={}, classId={}, grade={}, 节次 {} 个", schId, classId, gradeName, rows.size()); + writeExcelResponse(response, className + "-课程表导入模板.xlsx", grid); + } + + @Override + @Transactional + public Integer importTimetable(Integer classId, MultipartFile file) { + requireClassId(classId); + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "导入文件不能为空"); + } + String fileName = file.getOriginalFilename(); + if (fileName == null || !fileName.toLowerCase().matches("(?s).*\\.(xlsx|xls)")) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅支持 Excel 文件(.xlsx / .xls),请使用下载的导入模板填写"); + } + Integer schId = currentSchId(); + requireSchoolClass(schId, classId); + String gradeName = classService.gradeNameOf(schId, classId); + EffectiveSettingVo effective = effectiveSetting(gradeName); + List periodRows = effectivePeriodRows(effective); + boolean[] taught = taughtDays(effective.getTeachDays()); + + TimetableExcelUtil.ParsedSheet sheet; + try { + sheet = TimetableExcelUtil.parse(file.getInputStream()); + } catch (IOException e) { + throw new BizException(ErrorCode.PARAM_ERROR, "导入文件读取失败,请重试", e); + } + + // 文件行 → 节次:按「节次」列序号对齐,空标签按行序顺延;重复/超出直接报错 + int periods = periodRows.size(); + String[][] grid = new String[periods][7]; + Set used = new HashSet<>(); + int sequential = 0; + for (TimetableExcelUtil.ParsedRow row : sheet.getRows()) { + Integer no = TimetableExcelUtil.slotIndexOf(row.getSlotLabel()); + if (no == null) { + no = ++sequential; + } else { + sequential = no; + } + if (no < 1 || no > periods) { + throw new BizException(ErrorCode.PARAM_ERROR, + "导入文件第 " + row.getRowNo() + " 行的节次超出当前模板范围(共 " + periods + " 节),请重新下载模板填写"); + } + if (!used.add(no)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "导入文件第 " + row.getRowNo() + " 行与前面的第 " + no + " 节重复,请检查"); + } + for (Map.Entry cell : row.getDayCells().entrySet()) { + int day = cell.getKey(); + if (!taught[day]) { + throw new BizException(ErrorCode.PARAM_ERROR, + "导入文件第 " + row.getRowNo() + " 行填写了" + TimetableExcelUtil.dayName(day) + + ",当前节次模板未启用该教学日,请删除该列内容"); + } + grid[no - 1][day] = cell.getValue(); + } + } + + // 学科校验(与保存同规则);节次骨架与上课时间取生效模板,导入只录入学科 + Set subjectNames = validSubjectNames(); + List slots = new ArrayList<>(periods); + for (int i = 0; i < periods; i++) { + TemplateRowVo period = periodRows.get(i); + if (!TIME_PATTERN.matcher(orEmpty(period.getStart())).matches() + || !TIME_PATTERN.matcher(orEmpty(period.getEnd())).matches() + || toMinutes(period.getEnd()) <= toMinutes(period.getStart())) { + throw new BizException(ErrorCode.PARAM_ERROR, + "当前节次模板第 " + (i + 1) + " 节时间不完整或不合法,请先到「应用设置」修正后再导入"); + } + TimetableSlotVo slot = new TimetableSlotVo(); + slot.setSlotNo(i + 1); + slot.setStart(period.getStart()); + slot.setEnd(period.getEnd()); + List subjects = new ArrayList<>(7); + for (int d = 0; d < 7; d++) { + String name = orEmpty(grid[i][d]); + if (!name.isEmpty() && !subjectNames.isEmpty() && !subjectNames.contains(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "第 " + (i + 1) + " 节「" + TimetableExcelUtil.dayName(d) + "」学科不在本校学科列表中: " + name); + } + subjects.add(name); + } + slot.setSubjects(subjects); + slots.add(slot); + } + + TimetableSaveRequest request = new TimetableSaveRequest(); + request.setClassId(classId); + request.setSlots(slots); + Integer timetableId = save(request); + log.info("[importTimetable] schId={}, classId={}, grade={}, 文件={}, 导入节次 {} 个", + schId, classId, gradeName, fileName, periods); + return timetableId; + } + + @Override + public void exportTimetable(Integer classId, HttpServletResponse response) { + TimetableVo vo = get(classId); + if (vo.getSlots().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该班尚未编排电子课表,请先保存后再导出"); + } + boolean[] taught = taughtDays(effectiveSetting(vo.getGradeName()).getTeachDays()); + // 教学日 ∪ 实际排课日(历史数据可能落在当前模板未启用的教学日,导出不丢格) + boolean[] days = new boolean[7]; + for (TimetableSlotVo slot : vo.getSlots()) { + List subjects = slot.getSubjects(); + for (int d = 0; d < 7; d++) { + if (taught[d] || (subjects != null && d < subjects.size() && StringUtils.hasText(subjects.get(d)))) { + days[d] = true; + } + } + } + + List rows = new ArrayList<>(); + for (int i = 0; i < vo.getSlots().size(); i++) { + TimetableSlotVo slot = vo.getSlots().get(i); + int slotNo = slot.getSlotNo() == null ? i + 1 : slot.getSlotNo(); + List subjects = slot.getSubjects(); + List cells = new ArrayList<>(); + for (int d = 0; d < 7; d++) { + cells.add(days[d] && subjects != null && d < subjects.size() ? orEmpty(subjects.get(d)) : ""); + } + rows.add(new TimetableExcelUtil.GridRow("第" + slotNo + "节", + periodTimeText(slot.getStart(), slot.getEnd()), cells)); + } + String title = vo.getClassName() + " 课程表(" + vo.getYear() + "学年" + termName(vo.getTerm()) + ")"; + String notice = "说明:本表由课程表系统导出(当前学年学期),空白格表示未排课,共 " + vo.getSlots().size() + " 节。"; + TimetableExcelUtil.SheetGrid grid = new TimetableExcelUtil.SheetGrid(title, notice, dayHeads(days), rows); + log.info("[exportTimetable] schId={}, classId={}, 节次 {} 个", currentSchId(), classId, vo.getSlots().size()); + writeExcelResponse(response, vo.getClassName() + "-课程表-" + vo.getYear() + "学年" + termName(vo.getTerm()) + ".xlsx", grid); + } + // ============================ 我的课表 ============================ @Override @@ -235,32 +539,34 @@ public class TimetableService implements ITimetableService { return myClasses(UserContext.getUserId(), UserContext.getRoleId(), currentSchId()); } + @Override + public MyClassesVo myClassesVo() { + List classes = myClasses(); + MyClassesVo vo = new MyClassesVo(); + vo.setAdmin(isAdminRole(UserContext.getRoleId())); + boolean head = false; + boolean teach = false; + for (MyClassVo c : classes) { + head |= c.isHeadTeacher(); + teach |= !c.getSubjects().isEmpty(); + } + vo.setHeadTeacher(head); + vo.setSubjectTeacher(teach); + vo.setClasses(classes); + return vo; + } + @Override public List myClasses(Integer userId, Integer roleId, Integer schId) { List> classList = classService.getSchClassList(schId); boolean admin = isAdminRole(roleId); boolean student = !admin && isStudentRole(roleId); - // 班主任班级:正/副班主任是本人 + // 班主任班级:正/副班主任是本人(身份是用户事实,管理员会话同样计算,供多身份视图切换) Set headIds = new HashSet<>(); // 任教班级:classId -> 任教科目(去重保序) Map> teachSubjects = new LinkedHashMap<>(); - // 范围:管理员=全部,学生=本人班级,教师=班主任∪任教 - Set scope = new LinkedHashSet<>(); - if (admin) { - for (Map c : classList) { - Integer cid = asInt(c.get("classId")); - if (cid != null) { - scope.add(cid); - } - } - } else if (student) { - Map stu = userId == null ? null : classService.resolveStudent(schId, userId); - Integer cid = stu == null ? null : asInt(stu.get("classId")); - if (cid != null) { - scope.add(cid); - } - } else if (userId != null) { + if (!student && userId != null) { for (Map c : classList) { Integer cid = asInt(c.get("classId")); if (cid != null && (idEquals(c.get("firstTeacherId"), userId) || idEquals(c.get("secondTeacherId"), userId))) { @@ -278,6 +584,23 @@ public class TimetableService implements ITimetableService { names.add(sub.trim()); } } + } + // 范围:管理员=全部,学生=本人班级,教师=班主任∪任教 + Set scope = new LinkedHashSet<>(); + if (admin) { + for (Map c : classList) { + Integer cid = asInt(c.get("classId")); + if (cid != null) { + scope.add(cid); + } + } + } else if (student) { + Map stu = userId == null ? null : classService.resolveStudent(schId, userId); + Integer cid = stu == null ? null : asInt(stu.get("classId")); + if (cid != null) { + scope.add(cid); + } + } else if (userId != null) { scope.addAll(headIds); scope.addAll(teachSubjects.keySet()); } @@ -588,6 +911,122 @@ public class TimetableService implements ITimetableService { } } + // ---------- 导入导出(Excel)辅助 ---------- + + /** 班级归属校验:不存在或不属于本校时抛业务异常,返回班级名称 */ + private String requireSchoolClass(Integer schId, Integer classId) { + String className = classService.classNameOf(schId, classId); + if (!StringUtils.hasText(className)) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级不存在或不属于当前学校"); + } + return className; + } + + /** 班级所在年级的生效设置(按当前学年学期) */ + private EffectiveSettingVo effectiveSetting(String gradeName) { + return settingService.effective(gradeName, currentYearTerm().getTerm()); + } + + /** 生效模板的节次行(rowType=period,按顺序);年级未启用模板时抛业务异常 */ + private List effectivePeriodRows(EffectiveSettingVo effective) { + List rows = new ArrayList<>(); + for (TemplateRowVo row : effective.getRows()) { + if ("period".equals(row.getRowType())) { + rows.add(row); + } + } + if (rows.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "当前年级未启用节次模板,请先到「应用设置」启用后再使用导入导出"); + } + return rows; + } + + /** 教学日位串(7 位 0/1,周一~周日)→ 启用数组;位串缺省/非法按全启用 */ + private boolean[] taughtDays(String teachDays) { + boolean[] days = new boolean[7]; + Arrays.fill(days, true); + if (teachDays == null || !teachDays.matches("^[01]{7}$")) { + return days; + } + for (int i = 0; i < 7; i++) { + days[i] = teachDays.charAt(i) == '1'; + } + return days; + } + + /** 启用教学日 → 表头列表(周一~周日子集) */ + private static List dayHeads(boolean[] days) { + List heads = TimetableExcelUtil.allDayHeads(); + List result = new ArrayList<>(); + for (int i = 0; i < heads.size() && i < days.length; i++) { + if (days[i]) { + result.add(heads.get(i)); + } + } + return result; + } + + /** n 个空学科格 */ + private static List emptyCells(int n) { + List cells = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + cells.add(""); + } + return cells; + } + + /** 起止时间文本(start-end;缺省侧留空) */ + private static String periodTimeText(String start, String end) { + if (!StringUtils.hasText(start) && !StringUtils.hasText(end)) { + return ""; + } + return orEmpty(start) + "-" + orEmpty(end); + } + + private static String periodTimeText(TemplateRowVo row) { + return periodTimeText(row.getStart(), row.getEnd()); + } + + /** 学期名(1第一学期 2第二学期) */ + private static String termName(Integer term) { + return term != null && term == 2 ? "第二学期" : "第一学期"; + } + + /** 可填学科提示(学科服务不可达时省略);最多列 12 门,多余以「等 N 门」收尾 */ + private String subjectHint(Integer schId) { + try { + List names = new ArrayList<>(); + for (SubjectVo subject : classService.getSubjectList(schId)) { + if (subject != null && StringUtils.hasText(subject.getName()) && !names.contains(subject.getName().trim())) { + names.add(subject.getName().trim()); + } + } + if (names.isEmpty()) { + return ""; + } + int limit = Math.min(names.size(), 12); + String joined = String.join("、", names.subList(0, limit)); + return " 可填学科:" + joined + (names.size() > limit ? " 等 " + names.size() + " 门" : ""); + } catch (Exception e) { + log.warn("[subjectHint] 获取学科列表失败,模板省略可填学科提示: {}", e.getMessage()); + return ""; + } + } + + /** xlsx 写出到响应(浏览器下载;文件名 URL 编码,与附件下载约定一致) */ + private void writeExcelResponse(HttpServletResponse response, String fileName, TimetableExcelUtil.SheetGrid grid) { + String encoded; + try { + encoded = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20"); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + encoded + "\""); + OutputStream out = response.getOutputStream(); + TimetableExcelUtil.write(out, grid); + } catch (IOException e) { + throw new BizException(ErrorCode.ERROR, "课表 Excel 写出失败", e); + } + } + private static int toMinutes(String time) { String[] parts = time.split(":"); return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]); diff --git a/src/main/java/com/yunhai/acasyllabu/utils/TimetableExcelUtil.java b/src/main/java/com/yunhai/acasyllabu/utils/TimetableExcelUtil.java new file mode 100644 index 0000000..114193c --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/utils/TimetableExcelUtil.java @@ -0,0 +1,432 @@ +package com.yunhai.acasyllabu.utils; + +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.context.AnalysisContext; +import com.alibaba.excel.event.AnalysisEventListener; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFCellStyle; +import org.apache.poi.xssf.usermodel.XSSFColor; +import org.apache.poi.xssf.usermodel.XSSFFont; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 课表导入/导出 Excel(固定网格布局:标题、说明、表头「节次|时间|周一~周日…」、节次行)。 + *

写出用 POI(标题合并/表头底色/冻结窗格需要精确控制);读取用 EasyExcel 流式按行取值, + * 自动识别 xls/xlsx。表头行按「节次」单元格定位,星期列按表头别名(周X/星期X)映射到 + * day 0~6(周一~周日);数据行以「节次」列序号对齐到课表节次(见 {@link #slotIndexOf})。

+ */ +public final class TimetableExcelUtil { + + /** 表头首列名(定位表头行的锚点,与写出侧一致) */ + public static final String LABEL_HEADER = "节次"; + + /** 星期表头 → day 下标(0=周一 … 6=周日);兼容「周X」与「星期X」两种写法 */ + private static final Map DAY_ALIASES = new LinkedHashMap<>(); + /** 中文数字(解析「第十一节」类标签用,一~九十九足够覆盖 30 节上限) */ + private static final String CN_DIGITS = "零一二三四五六七八九"; + + static { + String[] heads = {"周一", "周二", "周三", "周四", "周五", "周六", "周日"}; + for (int i = 0; i < heads.length; i++) { + DAY_ALIASES.put(heads[i], i); + DAY_ALIASES.put("星期" + heads[i].substring(1), i); + } + } + + private TimetableExcelUtil() { + } + + // ============================ 写出 ============================ + + /** 网格一行:节次标签、时间文本、各天学科(cells 与 dayHeads 等长) */ + public static class GridRow { + private final String label; + private final String time; + private final List cells; + + public GridRow(String label, String time, List cells) { + this.label = label; + this.time = time; + this.cells = cells; + } + } + + /** 整表网格:标题、说明行、表头星期列、数据行 */ + public static class SheetGrid { + private final String title; + private final String notice; + private final List dayHeads; + private final List rows; + + public SheetGrid(String title, String notice, List dayHeads, List rows) { + this.title = title; + this.notice = notice; + this.dayHeads = dayHeads; + this.rows = rows; + } + } + + /** + * 写出 xlsx:第1行标题(通栏合并居中)、第2行说明(单格长文本,借空格溢出完整显示)、 + * 第3行表头(灰底加粗)、随后节次行;冻结前 3 行,网格画细边框。 + */ + public static void write(OutputStream out, SheetGrid grid) { + try (XSSFWorkbook wb = new XSSFWorkbook()) { + XSSFSheet sheet = wb.createSheet("课程表"); + int lastCol = grid.dayHeads.size() + 1; + StyleSet styles = createStyles(wb); + int rowIdx = 0; + rowIdx = titleRow(sheet, rowIdx, grid.title, lastCol, styles.title); + rowIdx = noticeRow(sheet, rowIdx, grid.notice, styles.notice); + + Row head = sheet.createRow(rowIdx++); + head.setHeightInPoints(20); + putCell(head, 0, LABEL_HEADER, styles.head); + putCell(head, 1, "时间", styles.head); + for (int d = 0; d < grid.dayHeads.size(); d++) { + putCell(head, d + 2, grid.dayHeads.get(d), styles.head); + } + + for (GridRow gridRow : grid.rows) { + Row row = sheet.createRow(rowIdx++); + putCell(row, 0, gridRow.label, styles.center); + putCell(row, 1, gridRow.time, styles.center); + for (int d = 0; d < grid.dayHeads.size(); d++) { + String value = d < gridRow.cells.size() ? gridRow.cells.get(d) : ""; + putCell(row, d + 2, value, styles.center); + } + } + + // 网格细边框(标题/说明行不画框);表头与数据格共用两个样式,统一补边即可 + for (int r = 2; r < rowIdx; r++) { + for (int c = 0; c <= lastCol; c++) { + applyBorder(sheet.getRow(r).getCell(c)); + } + } + sheet.setColumnWidth(0, 10 * 256); + sheet.setColumnWidth(1, 18 * 256); + for (int c = 2; c <= lastCol; c++) { + sheet.setColumnWidth(c, 14 * 256); + } + sheet.createFreezePane(0, 3); + wb.write(out); + } catch (IOException e) { + throw new BizException(ErrorCode.ERROR, "课表 Excel 生成失败", e); + } + } + + /** 标题行:通栏合并居中,返回下一行下标 */ + private static int titleRow(XSSFSheet sheet, int rowIdx, String text, int lastCol, CellStyle style) { + Row row = sheet.createRow(rowIdx); + row.setHeightInPoints(26); + for (int c = 0; c <= lastCol; c++) { + row.createCell(c).setCellStyle(style); + } + row.getCell(0).setCellValue(orEmpty(text)); + if (lastCol > 0) { + sheet.addMergedRegion(new CellRangeAddress(rowIdx, rowIdx, 0, lastCol)); + } + return rowIdx + 1; + } + + /** 说明行:单格长文本不合并不换行,靠右侧空格溢出完整显示,返回下一行下标 */ + private static int noticeRow(XSSFSheet sheet, int rowIdx, String text, CellStyle style) { + Row row = sheet.createRow(rowIdx); + row.setHeightInPoints(16); + putCell(row, 0, text, style); + return rowIdx + 1; + } + + private static void putCell(Row row, int col, String value, CellStyle style) { + Cell cell = row.createCell(col); + cell.setCellStyle(style); + cell.setCellValue(orEmpty(value)); + } + + private static void applyBorder(Cell cell) { + if (cell == null) { + return; + } + CellStyle style = cell.getCellStyle(); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + } + + private static final class StyleSet { + CellStyle title; + CellStyle notice; + CellStyle head; + CellStyle center; + } + + private static StyleSet createStyles(XSSFWorkbook wb) { + StyleSet styles = new StyleSet(); + + Font titleFont = wb.createFont(); + titleFont.setBold(true); + titleFont.setFontHeightInPoints((short) 14); + CellStyle title = wb.createCellStyle(); + title.setFont(titleFont); + title.setAlignment(HorizontalAlignment.CENTER); + title.setVerticalAlignment(VerticalAlignment.CENTER); + styles.title = title; + + Font noticeFont = wb.createFont(); + noticeFont.setFontHeightInPoints((short) 10); + noticeFont.setColor(IndexedColors.GREY_50_PERCENT.getIndex()); + CellStyle notice = wb.createCellStyle(); + notice.setFont(noticeFont); + notice.setAlignment(HorizontalAlignment.LEFT); + notice.setVerticalAlignment(VerticalAlignment.CENTER); + styles.notice = notice; + + Font headFont = wb.createFont(); + headFont.setBold(true); + headFont.setFontHeightInPoints((short) 11); + XSSFCellStyle head = wb.createCellStyle(); + head.setFont(headFont); + head.setAlignment(HorizontalAlignment.CENTER); + head.setVerticalAlignment(VerticalAlignment.CENTER); + head.setFillForegroundColor(new XSSFColor(new byte[]{(byte) 0xF2, (byte) 0xF3, (byte) 0xF5}, null)); + head.setFillPattern(FillPatternType.SOLID_FOREGROUND); + styles.head = head; + + CellStyle center = wb.createCellStyle(); + center.setAlignment(HorizontalAlignment.CENTER); + center.setVerticalAlignment(VerticalAlignment.CENTER); + styles.center = center; + return styles; + } + + // ============================ 读取 ============================ + + /** 解析出的一行:Excel 行号(1 起,报错定位用)、「节次」标签、day 下标 → 学科名 */ + public static class ParsedRow { + private final int rowNo; + private final String slotLabel; + private final Map dayCells; + + ParsedRow(int rowNo, String slotLabel, Map dayCells) { + this.rowNo = rowNo; + this.slotLabel = slotLabel; + this.dayCells = dayCells; + } + + public int getRowNo() { + return rowNo; + } + + public String getSlotLabel() { + return slotLabel; + } + + public Map getDayCells() { + return dayCells; + } + } + + /** 解析结果:表头行号、「Excel列号 → day 下标」映射、有效数据行 */ + public static class ParsedSheet { + private final int headerRowNo; + private final Map dayColIndex; + private final List rows; + + ParsedSheet(int headerRowNo, Map dayColIndex, List rows) { + this.headerRowNo = headerRowNo; + this.dayColIndex = dayColIndex; + this.rows = rows; + } + + public int getHeaderRowNo() { + return headerRowNo; + } + + public Map getDayColIndex() { + return dayColIndex; + } + + public List getRows() { + return rows; + } + } + + /** + * 解析课表 Excel:扫描「节次」表头行定位各列,随后逐行收集学科格; + * 节次与学科格全空的行跳过。文件损坏或非 Excel 时抛业务异常。 + */ + public static ParsedSheet parse(InputStream in) { + Holder holder = new Holder(); + try { + EasyExcel.read(in, new AnalysisEventListener>() { + @Override + public void invoke(Map row, AnalysisContext context) { + // readRowHolder 的 rowIndex 为 0 起的实际 Excel 行号,报错话术 +1 + Integer rowIndex = context.readRowHolder().getRowIndex(); + int rowNo = (rowIndex == null ? 0 : rowIndex) + 1; + if (holder.headerRowNo < 0) { + scanHeader(row, rowNo, holder); + return; + } + collectDataRow(row, rowNo, holder); + } + + @Override + public void doAfterAllAnalysed(AnalysisContext context) { + // 无收尾处理 + } + }).headRowNumber(0).sheet(0).doRead(); + } catch (BizException e) { + throw e; + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "导入文件解析失败,请使用系统下载的导入模板填写", e); + } + if (holder.headerRowNo < 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "未找到表头行(首列应为「节次」),请使用系统下载的导入模板"); + } + return new ParsedSheet(holder.headerRowNo, holder.dayCols, holder.dataRows); + } + + /** 解析过程可变状态(监听器内逐行累加) */ + private static final class Holder { + int headerRowNo = -1; + Integer labelCol; + Map dayCols = new LinkedHashMap<>(); + List dataRows = new ArrayList<>(); + } + + /** 扫描表头行:定位「节次」列与星期列;未命中(缺少节次或星期列)则继续向后找 */ + private static void scanHeader(Map row, int rowNo, Holder holder) { + Integer labelCol = null; + Map dayCols = new LinkedHashMap<>(); + for (Map.Entry cell : row.entrySet()) { + String text = normalize(cell.getValue()); + if (text.isEmpty()) { + continue; + } + if (LABEL_HEADER.equals(text)) { + labelCol = cell.getKey(); + } else if (DAY_ALIASES.containsKey(text)) { + dayCols.put(cell.getKey(), DAY_ALIASES.get(text)); + } + } + if (labelCol != null && !dayCols.isEmpty()) { + holder.headerRowNo = rowNo; + holder.labelCol = labelCol; + holder.dayCols = dayCols; + } + } + + /** 收集表头后的数据行;节次与学科全空的行跳过 */ + private static void collectDataRow(Map row, int rowNo, Holder holder) { + String label = normalize(holder.labelCol == null ? null : row.get(holder.labelCol)); + Map dayCells = new LinkedHashMap<>(); + boolean hasSubject = false; + for (Map.Entry col : holder.dayCols.entrySet()) { + String value = normalize(row.get(col.getKey())); + if (!value.isEmpty()) { + dayCells.put(col.getValue(), value); + hasSubject = true; + } + } + if (label.isEmpty() && !hasSubject) { + return; + } + holder.dataRows.add(new ParsedRow(rowNo, label, dayCells)); + } + + /** + * 节次标签 → 1 起序号:兼容「第N节」「N」「第X节」(X 中文数字)。 + * 空标签返回 null(调用方按行序兜底);纯数字超 30 原样返回(由调用方按超出模板节次报错); + * 无法识别的非空标签抛业务异常。 + */ + public static Integer slotIndexOf(String label) { + String text = normalize(label); + if (text.isEmpty()) { + return null; + } + String inner = text; + if (inner.startsWith("第")) { + inner = inner.substring(1); + } + if (inner.endsWith("节")) { + inner = inner.substring(0, inner.length() - 1); + } + inner = inner.trim(); + if (inner.matches("^\\d+$")) { + return Integer.valueOf(inner); + } + if (isCnNumber(inner)) { + return parseCn(inner); + } + throw new BizException(ErrorCode.PARAM_ERROR, "无法识别的节次名称「" + text + "」,请保留模板的「第N节」格式"); + } + + /** 是否合法中文数字(一~九十九) */ + private static boolean isCnNumber(String text) { + if (text.isEmpty()) { + return false; + } + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c != '十' && CN_DIGITS.indexOf(c) < 0) { + return false; + } + } + return true; + } + + /** 中文数字 → int(一~九十九) */ + private static int parseCn(String text) { + int ten = text.indexOf('十'); + if (ten < 0) { + return CN_DIGITS.indexOf(text.charAt(0)); + } + int value = ten > 0 ? CN_DIGITS.indexOf(text.charAt(0)) * 10 : 10; + if (ten + 1 < text.length()) { + value += CN_DIGITS.indexOf(text.charAt(ten + 1)); + } + return value; + } + + private static String normalize(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", ""); + } + + private static String orEmpty(String value) { + return value == null ? "" : value; + } + + /** day 下标 → 星期名(报错话术用) */ + public static String dayName(int dayIndex) { + String[] names = {"周一", "周二", "周三", "周四", "周五", "周六", "周日"}; + return dayIndex >= 0 && dayIndex < names.length ? names[dayIndex] : "周" + (dayIndex + 1); + } + + /** 全部星期表头(周一~周日,下标即 day 下标) */ + public static List allDayHeads() { + return Arrays.asList("周一", "周二", "周三", "周四", "周五", "周六", "周日"); + } +} diff --git a/src/main/resources/sql/acasyllabu_schema.sql b/src/main/resources/sql/acasyllabu_schema.sql index 0bb928e..0331ba7 100644 --- a/src/main/resources/sql/acasyllabu_schema.sql +++ b/src/main/resources/sql/acasyllabu_schema.sql @@ -80,6 +80,7 @@ CREATE TABLE IF NOT EXISTS `timetable_period_template` ( `enabled_bands` VARCHAR(16) NOT NULL DEFAULT '11111' COMMENT '启用时段 5 位 0/1 串(早自习 上午 中午 下午 晚自习)', `period_minutes` INT NOT NULL DEFAULT 45 COMMENT '节次时长(分钟),模板加节次的默认用时', `break_minutes` INT NOT NULL DEFAULT 10 COMMENT '课间时长(分钟),模板加空白行的默认用时', + `show_break_rows` BIT NULL DEFAULT 1 COMMENT '课表页是否显示空白行:1=显示全部(默认) 0=只显示节次,NULL 视为显示全部', `create_by` VARCHAR(255) NULL, `create_time` DATETIME NULL, `update_by` VARCHAR(255) NULL, @@ -108,6 +109,23 @@ CREATE TABLE IF NOT EXISTS `timetable_template_row` ( KEY `idx_row_template` (`template_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='节次模板行'; +-- 年级应用设置(每校×年级一行;统一控制「我的课表」该年级课表查看与电子/照片视图切换; +-- 未配置的年级视为全启用,与历史行为一致) +CREATE TABLE IF NOT EXISTS `timetable_grade_setting` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL COMMENT '学校ID(多租户)', + `grade_name` VARCHAR(16) NOT NULL COMMENT '年级名称(与班级服务学生树年级节点名一致)', + `enabled` BIT NOT NULL COMMENT '是否启用该年级课表查看', + `use_electronic` BIT NOT NULL COMMENT '是否使用电子课表', + `use_photo` BIT NOT NULL COMMENT '是否使用照片课表', + `create_by` VARCHAR(255) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(255) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_grade_sch_name` (`sch_id`, `grade_name`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='年级应用设置'; + -- 旧库升级(2026-10-12:模板绑定学期 + 年级多选;本地/生产均已执行或由 ddl-auto 加列后手动收尾): -- ALTER TABLE `timetable_period_template` -- ADD COLUMN `term` INT NOT NULL DEFAULT 1 COMMENT '绑定学期 1=上学期 2=下学期', @@ -155,3 +173,7 @@ CREATE TABLE IF NOT EXISTS `timetable_template_row` ( -- ELSE 'ev' END -- WHERE r.band IS NULL OR r.band = ''; -- 若空白行归到“看似不合理”的段(如课间跨在边界上),在应用设置编辑器里用 ↑ ↓ 调整即可,保存后即以行为准。 +-- 旧库升级(2026-10-13:课表页「显示全部/只显示节次」偏好绑定节次模板;由 ddl-auto 加列,或手动执行): +-- 列可空、无默认约束:ddl-auto 加列后存量行 NULL,按「显示全部」处理,无需回填。 +-- ALTER TABLE `timetable_period_template` +-- ADD COLUMN `show_break_rows` BIT NULL DEFAULT 1 COMMENT '课表页是否显示空白行:1=显示全部(默认) 0=只显示节次,NULL 视为显示全部' AFTER `break_minutes`; diff --git a/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceExcelTest.java b/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceExcelTest.java new file mode 100644 index 0000000..29fa3b7 --- /dev/null +++ b/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceExcelTest.java @@ -0,0 +1,375 @@ +package com.yunhai.acasyllabu.service.impl; + +import com.yunhai.acasyllabu.entity.Timetable; +import com.yunhai.acasyllabu.entity.TimetableSlot; +import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; +import com.yunhai.acasyllabu.model.bsch.SubjectVo; +import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; +import com.yunhai.acasyllabu.model.syllabus.TemplateRowVo; +import com.yunhai.acasyllabu.repository.TimetableRepository; +import com.yunhai.acasyllabu.repository.TimetableSlotRepository; +import com.yunhai.acasyllabu.repository.UpFileRepository; +import com.yunhai.acasyllabu.service.IClassService; +import com.yunhai.acasyllabu.service.IFileService; +import com.yunhai.acasyllabu.service.ISettingService; +import com.yunhai.acasyllabu.utils.TimetableExcelUtil; +import com.yunhai.acasyllabu.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 课表导入/导出单元测试(mock 班级/设置服务与仓储,不起 Spring 容器)。 + * 覆盖:模板下载→解析回读、导入对齐与学科校验、导出网格回读、节次序号解析。 + */ +public class TimetableServiceExcelTest { + + private static final int SCH_ID = 3; + private static final int CLASS_ID = 101; + + private TimetableService service; + private IClassService classService; + private ISettingService settingService; + private TimetableRepository timetableRepository; + private TimetableSlotRepository slotRepository; + + @BeforeEach + public void setUp() { + service = new TimetableService(); + classService = mock(IClassService.class); + settingService = mock(ISettingService.class); + timetableRepository = mock(TimetableRepository.class); + slotRepository = mock(TimetableSlotRepository.class); + ReflectionTestUtils.setField(service, "classService", classService); + ReflectionTestUtils.setField(service, "settingService", settingService); + ReflectionTestUtils.setField(service, "timetableRepository", timetableRepository); + ReflectionTestUtils.setField(service, "slotRepository", slotRepository); + ReflectionTestUtils.setField(service, "upFileRepository", mock(UpFileRepository.class)); + ReflectionTestUtils.setField(service, "fileService", mock(IFileService.class)); + + NowYearTermVo yearTerm = new NowYearTermVo(); + yearTerm.setYear(2026); + yearTerm.setTerm(1); + when(classService.getNowYearTerm()).thenReturn(yearTerm); + when(classService.classNameOf(SCH_ID, CLASS_ID)).thenReturn("1班"); + when(classService.gradeNameOf(SCH_ID, CLASS_ID)).thenReturn("高一"); + when(classService.getSubjectList(SCH_ID)).thenReturn(subjects("语文", "数学", "英语")); + // 生效模板:3 个节次 + 教学日周一~周五 + EffectiveSettingVo effective = new EffectiveSettingVo(); + effective.setTeachDays("1111100"); + effective.getRows().addAll(Arrays.asList( + periodRow("08:20", "09:00"), + periodRow("09:10", "09:50"), + periodRow("10:00", "10:40"))); + when(settingService.effective("高一", 1)).thenReturn(effective); + + UserContext.setSchId(SCH_ID); + UserContext.setUserId(10008); + UserContext.setRoleId(1); + } + + @AfterEach + public void tearDown() { + UserContext.clear(); + } + + // ============================ 造数 ============================ + + private static TemplateRowVo periodRow(String start, String end) { + TemplateRowVo row = new TemplateRowVo(); + row.setRowType("period"); + row.setBand("am"); + row.setStart(start); + row.setEnd(end); + return row; + } + + private static List subjects(String... names) { + List list = new ArrayList<>(); + for (int i = 0; i < names.length; i++) { + SubjectVo vo = new SubjectVo(); + vo.setId(i + 1); + vo.setName(names[i]); + list.add(vo); + } + return list; + } + + private static byte[] excelBytes(TimetableExcelUtil.SheetGrid grid) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + TimetableExcelUtil.write(out, grid); + return out.toByteArray(); + } + + /** 3 节次 × 周一~周五 的导入网格 */ + private static TimetableExcelUtil.SheetGrid importGrid(List rows) { + return new TimetableExcelUtil.SheetGrid("高一1班 课程表导入模板(2026学年第一学期)", "说明:示例", dayHeads(5), rows); + } + + private static List dayHeads(int n) { + return TimetableExcelUtil.allDayHeads().subList(0, n); + } + + private void mockSavedTimetable() { + Timetable entity = new Timetable(); + entity.setId(77); + entity.setSchId(SCH_ID); + entity.setClassId(CLASS_ID); + entity.setYear(2026); + entity.setTerm(1); + entity.setClassName("1班"); + entity.setGradeName("高一"); + when(timetableRepository.findBySchIdAndClassIdAndYearAndTerm(SCH_ID, CLASS_ID, 2026, 1)) + .thenReturn(Optional.of(entity)); + when(timetableRepository.save(any())).thenAnswer(inv -> { + Timetable t = inv.getArgument(0); + if (t.getId() == null) { + t.setId(77); + } + return t; + }); + } + + private static TimetableSlot slot(int order, String start, String end, String... days) { + TimetableSlot slot = new TimetableSlot(); + slot.setTimetableId(77); + slot.setSortOrder(order); + slot.setStartTime(start); + slot.setEndTime(end); + List values = new ArrayList<>(Arrays.asList(days)); + while (values.size() < 7) { + values.add(""); + } + for (int i = 0; i < 7; i++) { + setDay(slot, i + 1, values.get(i)); + } + return slot; + } + + private static void setDay(TimetableSlot slot, int day, String value) { + switch (day) { + case 1: slot.setDay1(value); break; + case 2: slot.setDay2(value); break; + case 3: slot.setDay3(value); break; + case 4: slot.setDay4(value); break; + case 5: slot.setDay5(value); break; + case 6: slot.setDay6(value); break; + default: slot.setDay7(value); break; + } + } + + // ============================ 模板下载 → 回读 ============================ + + @Test + public void templateRoundTripThroughParser() { + MockHttpServletResponse response = new MockHttpServletResponse(); + service.writeImportTemplate(CLASS_ID, response); + + byte[] bytes = response.getContentAsByteArray(); + assertTrue(bytes.length > 0, "模板应产出非空 xlsx"); + assertTrue(response.getHeader("Content-Disposition").contains(".xlsx"), "应为附件下载头"); + + TimetableExcelUtil.ParsedSheet sheet = TimetableExcelUtil.parse(new ByteArrayInputStream(bytes)); + // 教学日列 5 个(周一~周五)、3 个节次行、学科格留空 + assertEquals(5, sheet.getDayColIndex().size()); + assertEquals(3, sheet.getRows().size()); + assertEquals("第1节", sheet.getRows().get(0).getSlotLabel()); + assertTrue(sheet.getRows().get(0).getDayCells().isEmpty(), "模板学科格应为空"); + } + + // ============================ 导入 ============================ + + @Test + public void importSavesSubjectsAlignedToTemplate() { + mockSavedTimetable(); + List rows = new ArrayList<>(); + rows.add(new TimetableExcelUtil.GridRow("第1节", "08:20-09:00", Arrays.asList("语文", "数学", "", "", "英语"))); + rows.add(new TimetableExcelUtil.GridRow("第2节", "09:10-09:50", Arrays.asList("数学", "", "", "", ""))); + rows.add(new TimetableExcelUtil.GridRow("第3节", "10:00-10:40", new ArrayList<>())); + MockMultipartFile file = new MockMultipartFile("file", "课表.xlsx", null, excelBytes(importGrid(rows))); + + Integer id = service.importTimetable(CLASS_ID, file); + assertEquals(77, id); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TimetableSlot.class); + verify(slotRepository, times(3)).save(captor.capture()); + TimetableSlot first = captor.getAllValues().get(0); + assertEquals("08:20", first.getStartTime()); + assertEquals("09:00", first.getEndTime()); + assertEquals("语文", first.getDay1()); + assertEquals("数学", first.getDay2()); + assertEquals("英语", first.getDay5()); + TimetableSlot third = captor.getAllValues().get(2); + assertEquals("10:00", third.getStartTime()); + assertEquals("", third.getDay1()); + } + + @Test + public void importFillsSequentiallyWhenLabelBlank() { + mockSavedTimetable(); + List rows = new ArrayList<>(); + rows.add(new TimetableExcelUtil.GridRow("", "", Arrays.asList("语文", "", "", "", ""))); + rows.add(new TimetableExcelUtil.GridRow("", "", Arrays.asList("", "数学", "", "", ""))); + MockMultipartFile file = new MockMultipartFile("file", "课表.xlsx", null, excelBytes(importGrid(rows))); + + service.importTimetable(CLASS_ID, file); + // 整表替换:3 个模板节次都会保存(空节次保持空) + ArgumentCaptor captor = ArgumentCaptor.forClass(TimetableSlot.class); + verify(slotRepository, times(3)).save(captor.capture()); + assertEquals("语文", captor.getAllValues().get(0).getDay1()); + assertEquals("数学", captor.getAllValues().get(1).getDay2()); + assertEquals("", captor.getAllValues().get(2).getDay1()); + } + + @Test + public void importRejectsUnknownSubject() { + mockSavedTimetable(); + List rows = new ArrayList<>(); + rows.add(new TimetableExcelUtil.GridRow("第1节", "08:20-09:00", Arrays.asList("奥数", "", "", "", ""))); + MockMultipartFile file = new MockMultipartFile("file", "课表.xlsx", null, excelBytes(importGrid(rows))); + + try { + service.importTimetable(CLASS_ID, file); + fail("未在学科白名单内应报错"); + } catch (BizException e) { + assertTrue(e.getMessage().contains("学科不在本校学科列表"), "实际消息: " + e.getMessage()); + } + } + + @Test + public void importRejectsSlotBeyondTemplate() { + mockSavedTimetable(); + List rows = new ArrayList<>(); + rows.add(new TimetableExcelUtil.GridRow("第4节", "", Arrays.asList("语文", "", "", "", ""))); + MockMultipartFile file = new MockMultipartFile("file", "课表.xlsx", null, excelBytes(importGrid(rows))); + + try { + service.importTimetable(CLASS_ID, file); + fail("超出模板节次数应报错"); + } catch (BizException e) { + assertTrue(e.getMessage().contains("超出当前模板范围"), "实际消息: " + e.getMessage()); + } + } + + @Test + public void importRejectsDisabledTeachDay() { + mockSavedTimetable(); + // 模板只教周一~周五,文件多出周六列且填了内容 + List heads = new ArrayList<>(dayHeads(5)); + heads.add("周六"); + TimetableExcelUtil.SheetGrid grid = new TimetableExcelUtil.SheetGrid("t", "n", heads, + Arrays.asList(new TimetableExcelUtil.GridRow("第1节", "", Arrays.asList("", "", "", "", "", "体育")))); + MockMultipartFile file = new MockMultipartFile("file", "课表.xlsx", null, excelBytes(grid)); + + try { + service.importTimetable(CLASS_ID, file); + fail("未启用教学日应报错"); + } catch (BizException e) { + assertTrue(e.getMessage().contains("未启用该教学日"), "实际消息: " + e.getMessage()); + } + } + + @Test + public void importRejectsDuplicateSlot() { + mockSavedTimetable(); + List rows = new ArrayList<>(); + rows.add(new TimetableExcelUtil.GridRow("第1节", "", Arrays.asList("语文", "", "", "", ""))); + rows.add(new TimetableExcelUtil.GridRow("第1节", "", Arrays.asList("数学", "", "", "", ""))); + MockMultipartFile file = new MockMultipartFile("file", "课表.xlsx", null, excelBytes(importGrid(rows))); + + try { + service.importTimetable(CLASS_ID, file); + fail("重复节次应报错"); + } catch (BizException e) { + assertTrue(e.getMessage().contains("重复"), "实际消息: " + e.getMessage()); + } + } + + @Test + public void importRejectsNonExcelFile() { + MockMultipartFile file = new MockMultipartFile("file", "课表.csv", null, "a,b".getBytes()); + try { + service.importTimetable(CLASS_ID, file); + fail("非 Excel 文件应报错"); + } catch (BizException e) { + assertTrue(e.getMessage().contains("仅支持 Excel"), "实际消息: " + e.getMessage()); + } + } + + // ============================ 导出 ============================ + + @Test + public void exportWritesSavedGrid() { + mockSavedTimetable(); + when(slotRepository.findByTimetableIdOrderBySortOrderAsc(77)).thenReturn(Arrays.asList( + slot(1, "08:20", "09:00", "语文", "数学", "", "", "英语"), + slot(2, "09:10", "09:50", "数学", "", "", "", ""))); + MockHttpServletResponse response = new MockHttpServletResponse(); + + service.exportTimetable(CLASS_ID, response); + + byte[] bytes = response.getContentAsByteArray(); + assertTrue(bytes.length > 0); + TimetableExcelUtil.ParsedSheet sheet = TimetableExcelUtil.parse(new ByteArrayInputStream(bytes)); + assertEquals(2, sheet.getRows().size()); + Map first = sheet.getRows().get(0).getDayCells(); + assertEquals("语文", first.get(0)); + assertEquals("数学", first.get(1)); + assertEquals("英语", first.get(4)); + assertEquals("第2节", sheet.getRows().get(1).getSlotLabel()); + } + + @Test + public void exportRejectsEmptyTimetable() { + when(timetableRepository.findBySchIdAndClassIdAndYearAndTerm(SCH_ID, CLASS_ID, 2026, 1)) + .thenReturn(Optional.empty()); + try { + service.exportTimetable(CLASS_ID, new MockHttpServletResponse()); + fail("无课表应报错"); + } catch (BizException e) { + assertTrue(e.getMessage().contains("尚未编排电子课表"), "实际消息: " + e.getMessage()); + } + } + + // ============================ 节次序号解析 ============================ + + @Test + public void slotIndexOfParsesLabels() { + assertEquals(Integer.valueOf(3), TimetableExcelUtil.slotIndexOf("第3节")); + assertEquals(Integer.valueOf(12), TimetableExcelUtil.slotIndexOf("第12节")); + assertEquals(Integer.valueOf(11), TimetableExcelUtil.slotIndexOf("第十一节")); + assertEquals(Integer.valueOf(5), TimetableExcelUtil.slotIndexOf("5")); + assertEquals(Integer.valueOf(23), TimetableExcelUtil.slotIndexOf("二十三")); + assertNull(TimetableExcelUtil.slotIndexOf("")); + assertNull(TimetableExcelUtil.slotIndexOf(null)); + try { + TimetableExcelUtil.slotIndexOf("早读"); + fail("无法识别的标签应报错"); + } catch (BizException e) { + assertTrue(e.getMessage().contains("无法识别的节次名称")); + } + } +} diff --git a/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceMyTest.java b/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceMyTest.java index ed80843..9098992 100644 --- a/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceMyTest.java +++ b/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceMyTest.java @@ -5,6 +5,7 @@ import com.yunhai.acasyllabu.entity.TimetableSlot; import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; import com.yunhai.acasyllabu.model.syllabus.MyClassVo; +import com.yunhai.acasyllabu.model.syllabus.MyClassesVo; import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo; import com.yunhai.acasyllabu.repository.TimetableRepository; import com.yunhai.acasyllabu.repository.TimetableSlotRepository; @@ -165,8 +166,37 @@ public class TimetableServiceMyTest { assertEquals(Integer.valueOf(12), result.get(0).getClassId()); assertEquals(Integer.valueOf(371), result.get(1).getClassId()); assertTrue(result.get(0).isViewAllSubjects()); - assertFalse(result.get(0).isHeadTeacher()); + // 班主任身份按用户事实标记(本人是 12 班正班主任),范围仍为全校 + assertTrue(result.get(0).isHeadTeacher()); assertTrue(result.get(1).isViewAllSubjects()); + assertFalse(result.get(1).isHeadTeacher()); + } + + @Test + public void myClasses_adminMultiIdentity_marksHeadAndTeach() { + loginAs(7); + // 管理员本人:12 班班主任 + 371 班任教语文;373 无个人关系但仍在全校范围内 + when(classService.getSchClassList(SCH_ID)).thenReturn(Arrays.asList( + classItem(12, "1班", "高一", TEA_ID), + classItem(371, "2班", "高一", 20001), + classItem(373, "3班", "高一", 20002))); + when(classService.getTeacherTeachList(SCH_ID, TEA_ID)).thenReturn(Collections.singletonList(teachItem(371, "语文"))); + when(classService.getClassTree(SCH_ID)).thenReturn(classTree(12, 371, 373)); + + MyClassesVo vo = service.myClassesVo(); + + assertTrue(vo.isAdmin()); + assertTrue(vo.isHeadTeacher()); + assertTrue(vo.isSubjectTeacher()); + assertEquals(3, vo.getClasses().size()); + MyClassVo cls12 = vo.getClasses().get(0); + assertEquals(Integer.valueOf(12), cls12.getClassId()); + assertTrue(cls12.isViewAllSubjects()); + assertTrue(cls12.isHeadTeacher()); + MyClassVo cls371 = vo.getClasses().get(1); + assertEquals(Integer.valueOf(371), cls371.getClassId()); + assertTrue(cls371.isViewAllSubjects()); + assertEquals(Collections.singletonList("语文"), cls371.getSubjects()); } @Test