commit
build / build-push (push) Successful in 58s

This commit is contained in:
wanghu
2026-10-14 08:30:13 +08:00
parent e974d46770
commit 1d0d5df9ef
23 changed files with 1805 additions and 25 deletions
@@ -2,6 +2,7 @@ package com.yunhai.acasyllabu.controller.api;
import com.yunhai.acasyllabu.config.Config; import com.yunhai.acasyllabu.config.Config;
import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; 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.SettingGetVo;
import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest;
import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo; 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.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** /**
* 应用设置接口(setting/)。 * 应用设置接口(setting/)。
* <p>时间设置(时段判定边界 + 节次/课间时长)与节次模板(绑定学期×多年级,行含节次与课间/午休空白行) * <p>时间设置(时段判定边界 + 节次/课间时长)与节次模板(绑定学期×多年级,行含节次与课间/午休空白行)
@@ -50,10 +53,36 @@ public class SettingController {
settingService.deleteTemplate(id); 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) */ /** 课表编辑页生效设置:时间边界 + 当前学期下指定年级启用模板的行(gradeName 如 高一,term 1/2) */
@GetMapping("ForGrade") @GetMapping("ForGrade")
public EffectiveSettingVo forGrade(@RequestParam(value = "gradeName", required = false) String gradeName, public EffectiveSettingVo forGrade(@RequestParam(value = "gradeName", required = false) String gradeName,
@RequestParam(value = "term", required = false) Integer term) { @RequestParam(value = "term", required = false) Integer term) {
return settingService.effective(gradeName, term); return settingService.effective(gradeName, term);
} }
/** 年级设置列表(启用 + 使用电子版/照片版;班级树年级与已存设置合并,未配置默认全启用) */
@GetMapping("Grades")
public List<GradeSettingVo> grades() {
return settingService.grades();
}
/** 批量保存年级设置(按年级名 upsert;作用于「我的课表」电子/照片视图切换) */
@PostMapping("SaveGrades")
public void saveGrades(@RequestBody List<GradeSettingVo> grades) {
settingService.saveGrades(grades);
}
} }
@@ -3,8 +3,9 @@ package com.yunhai.acasyllabu.controller.api;
import com.yunhai.acasyllabu.config.Config; import com.yunhai.acasyllabu.config.Config;
import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; import com.yunhai.acasyllabu.model.bsch.NowYearTermVo;
import com.yunhai.acasyllabu.model.bsch.SubjectVo; 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.MyTimetableVo;
import com.yunhai.acasyllabu.model.syllabus.TimetableOverviewVo;
import com.yunhai.acasyllabu.model.syllabus.TimetableSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TimetableSaveRequest;
import com.yunhai.acasyllabu.model.syllabus.TimetableVo; import com.yunhai.acasyllabu.model.syllabus.TimetableVo;
import com.yunhai.acasyllabu.service.IClassService; 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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List; import java.util.List;
/** /**
@@ -74,15 +77,49 @@ public class TimetableController {
return classService.getNowYearTerm(); return classService.getNowYearTerm();
} }
/** 排课情况汇总(本校全部班级的电子版 / 照片版编排状态,按学生树年级→班级顺序) */
@GetMapping("Overview")
public List<TimetableOverviewVo> 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") @GetMapping("MyClasses")
public List<MyClassVo> myClasses() { public MyClassesVo myClasses() {
return timetableService.myClasses(); return timetableService.myClassesVo();
} }
/** 「我的课表」班级课表(先校验访问权限;非班主任的任教老师只返回任教科目格) */ /** 「我的课表」班级课表(先校验访问权限;非班主任的任教老师只返回任教科目格) */
@@ -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;
/**
* 年级应用设置(每校×年级一行)。
* <p>统一控制整个年级的课表查看:enabled 关闭后「我的课表」不再展示该年级课表;
* useElectronic / usePhoto 分别控制「我的课表」电子课表、照片课表两个视图是否开放
* (都开放时页内 tab 切换,只开一个则直接展示该版)。未配置的年级视为全开,
* 与历史行为(电子版恒展示、照片版随上传展示)保持一致。</p>
*/
@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;
}
@@ -67,4 +67,20 @@ public class PeriodTemplate extends BaseEntity {
/** 课间时长(分钟):模板编辑器「+空白行」自动推算结束时间的默认时长,默认 10 */ /** 课间时长(分钟):模板编辑器「+空白行」自动推算结束时间的默认时长,默认 10 */
@Column(name = "break_minutes", nullable = false) @Column(name = "break_minutes", nullable = false)
private Integer breakMinutes = 10; 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;
} }
@@ -24,4 +24,22 @@ public class EffectiveSettingVo {
/** 生效模板名称(如 春),无启用模板时为空 */ /** 生效模板名称(如 春),无启用模板时为空 */
private String templateName; 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;
} }
@@ -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;
}
@@ -0,0 +1,27 @@
package com.yunhai.acasyllabu.model.syllabus;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 「我的课表」班级列表 + 当前用户身份标识(「我的课表」页按身份切换视图用)。
* <p>身份可叠加(如管理员同时是某班班主任、又在其他班任教);
* 前端按 管理员 &gt; 班主任 &gt; 任教老师 优先级展示默认视图,多身份时可点击切换。</p>
*/
@Data
public class MyClassesVo {
/** 是否管理员/校领导身份( roleId 7/2) */
private boolean admin;
/** 是否班主任身份(任一班的正/副班主任是本人) */
private boolean headTeacher;
/** 是否任教老师身份(任一班有任教科目) */
private boolean subjectTeacher;
/** 可查看班级(范围规则同身份:管理员=全校;班主任/任教=所带∪任教;学生=本人班级) */
private List<MyClassVo> classes = new ArrayList<>();
}
@@ -39,6 +39,12 @@ public class PeriodTemplateVo {
/** 课间时长(分钟),模板加空白行的默认用时 */ /** 课间时长(分钟),模板加空白行的默认用时 */
private Integer breakMinutes = 10; private Integer breakMinutes = 10;
/** 课表页是否显示空白行(课间/午休):false=只显示节次,随模板保存 */
private Boolean showBreakRows = Boolean.TRUE;
/** 课表页是否显示「节次」列(节次名称):false=隐藏,随模板保存 */
private Boolean showPeriodName = Boolean.TRUE;
/** 模板行(节次 + 空白行,按顺序) */ /** 模板行(节次 + 空白行,按顺序) */
private List<TemplateRowVo> rows = new ArrayList<>(); private List<TemplateRowVo> rows = new ArrayList<>();
} }
@@ -38,5 +38,11 @@ public class TemplateSaveRequest {
/** 课间时长(分钟),空=沿用现值 */ /** 课间时长(分钟),空=沿用现值 */
private Integer breakMinutes; private Integer breakMinutes;
/** 课表页是否显示空白行(课间/午休):false=只显示节次,空=沿用现值 */
private Boolean showBreakRows;
/** 课表页是否显示「节次」列(节次名称):false=隐藏,空=沿用现值 */
private Boolean showPeriodName;
private List<TemplateRowVo> rows = new ArrayList<>(); private List<TemplateRowVo> rows = new ArrayList<>();
} }
@@ -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;
}
@@ -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<GradeSetting, Integer> {
List<GradeSetting> findBySchIdOrderByIdAsc(Integer schId);
Optional<GradeSetting> findBySchIdAndGradeName(Integer schId, String gradeName);
}
@@ -11,4 +11,7 @@ public interface TimetableRepository extends JpaRepository<Timetable, Integer> {
Optional<Timetable> findBySchIdAndClassIdAndYearAndTerm(Integer schId, Integer classId, Integer year, Integer term); Optional<Timetable> findBySchIdAndClassIdAndYearAndTerm(Integer schId, Integer classId, Integer year, Integer term);
List<Timetable> findBySchIdOrderByUpdateTimeDesc(Integer schId); List<Timetable> findBySchIdOrderByUpdateTimeDesc(Integer schId);
/** 当前学年学期本校全部课表(排课情况汇总用) */
List<Timetable> findBySchIdAndYearAndTerm(Integer schId, Integer year, Integer term);
} }
@@ -3,6 +3,7 @@ package com.yunhai.acasyllabu.repository;
import com.yunhai.acasyllabu.entity.TimetableSlot; import com.yunhai.acasyllabu.entity.TimetableSlot;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List; import java.util.List;
public interface TimetableSlotRepository extends JpaRepository<TimetableSlot, Integer> { public interface TimetableSlotRepository extends JpaRepository<TimetableSlot, Integer> {
@@ -10,4 +11,7 @@ public interface TimetableSlotRepository extends JpaRepository<TimetableSlot, In
List<TimetableSlot> findByTimetableIdOrderBySortOrderAsc(Integer timetableId); List<TimetableSlot> findByTimetableIdOrderBySortOrderAsc(Integer timetableId);
void deleteByTimetableId(Integer timetableId); void deleteByTimetableId(Integer timetableId);
/** 多张课表的节次一次取回(排课情况汇总用,避免按班逐张查询) */
List<TimetableSlot> findByTimetableIdIn(Collection<Integer> timetableIds);
} }
@@ -7,6 +7,7 @@ import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 文件上传/下载/删除业务接口。 * 文件上传/下载/删除业务接口。
@@ -72,6 +73,18 @@ public interface IFileService {
*/ */
void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response); void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response);
/**
* 打包下载多个附件为 ZIP,条目名由调用方指定(fileId -> 条目名,如按班级命名)。
* 未指定的文件回退用原始文件名。
*
* @param files 附件记录列表
* @param entryNames 条目名映射(可为空)
* @param zipName 压缩包显示名(自动追加 .zip)
* @param response HTTP 响应
*/
void downloadZip(List<UpFile> files, Map<Integer, String> entryNames, String zipName,
HttpServletResponse response);
/** /**
* 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。 * 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。
* *
@@ -1,12 +1,15 @@
package com.yunhai.acasyllabu.service; package com.yunhai.acasyllabu.service;
import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; 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.SettingGetVo;
import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest;
import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo; import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo;
import java.util.List;
/** /**
* 应用设置业务:时段判定时间设置 + 节次模板(含行)维护。 * 应用设置业务:时段判定时间设置 + 节次模板(含行)+ 年级设置维护。
*/ */
public interface ISettingService { public interface ISettingService {
@@ -22,6 +25,18 @@ public interface ISettingService {
/** 删除本校模板(连带行) */ /** 删除本校模板(连带行) */
void deleteTemplate(Integer templateId); void deleteTemplate(Integer templateId);
/** 保存课表页「显示全部/只显示节次」偏好到指定模板(绑定模板记忆) */
void saveTemplateShowBreakRows(Integer templateId, Boolean showBreakRows);
/** 保存课表页「节次名称 显示/隐藏」偏好到指定模板(绑定模板记忆) */
void saveTemplateShowPeriodName(Integer templateId, Boolean showPeriodName);
/** 课表编辑页生效设置:时间边界 + 当前学期下指定年级启用模板的行(无启用模板则 rows 为空) */ /** 课表编辑页生效设置:时间边界 + 当前学期下指定年级启用模板的行(无启用模板则 rows 为空) */
EffectiveSettingVo effective(String gradeName, Integer term); EffectiveSettingVo effective(String gradeName, Integer term);
/** 年级设置列表(班级树年级与已存设置合并;未配置的年级默认全启用) */
List<GradeSettingVo> grades();
/** 批量保存年级设置(按年级名 upsert) */
void saveGrades(List<GradeSettingVo> grades);
} }
@@ -1,11 +1,15 @@
package com.yunhai.acasyllabu.service; package com.yunhai.acasyllabu.service;
import com.yunhai.acasyllabu.model.syllabus.MyClassVo; 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.MyTimetableVo;
import com.yunhai.acasyllabu.model.syllabus.OapiTimetableClassVo; 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.TimetableSaveRequest;
import com.yunhai.acasyllabu.model.syllabus.TimetableVo; import com.yunhai.acasyllabu.model.syllabus.TimetableVo;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List; import java.util.List;
/** /**
@@ -44,6 +48,41 @@ public interface ITimetableService {
*/ */
void removePhoto(Integer classId); void removePhoto(Integer classId);
/**
* 排课情况汇总(「排课情况」页):本校全部班级按学生树年级→班级顺序返回,
* 每班给出电子版(是否已设置 / 节次数 / 去重科目数)与照片版(是否已上传 / 附件ID)状态。
* 数据取当前学年学期;未建课表的班级电子版视为未设置。
*/
List<TimetableOverviewVo> 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);
/** /**
* 「我的课表」可查看班级列表(按当前用户角色过滤)。 * 「我的课表」可查看班级列表(按当前用户角色过滤)。
* <ul> * <ul>
@@ -53,9 +92,16 @@ public interface ITimetableService {
* <li>学生(3):本人所在班级,全部科目。</li> * <li>学生(3):本人所在班级,全部科目。</li>
* </ul> * </ul>
* 排序按学生树年级→班级顺序;任教/班级接口不可达时降级返回可得部分。 * 排序按学生树年级→班级顺序;任教/班级接口不可达时降级返回可得部分。
* 班主任/任教身份标记按用户事实计算(管理员会话同样标记),供多身份视图切换。
*/ */
List<MyClassVo> myClasses(); List<MyClassVo> myClasses();
/**
* 「我的课表」班级列表 + 当前用户身份标识(admin/headTeacher/subjectTeacher 可叠加,
* 前端按 管理员&gt;班主任&gt;任教老师 优先级展示默认视图,多身份时可切换)。
*/
MyClassesVo myClassesVo();
/** /**
* 「我的课表」可查看班级列表(oapi 显式身份版本,身份由调用方传入,不取 UserContext)。 * 「我的课表」可查看班级列表(oapi 显式身份版本,身份由调用方传入,不取 UserContext)。
* 角色范围同 {@link #myClasses()};roleId 缺省按普通教师处理。 * 角色范围同 {@link #myClasses()};roleId 缺省按普通教师处理。
@@ -217,6 +217,12 @@ public class FileService implements IFileService {
@Override @Override
public void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response) { public void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response) {
downloadZip(files, null, zipName, response);
}
@Override
public void downloadZip(List<UpFile> files, Map<Integer, String> entryNames, String zipName,
HttpServletResponse response) {
if (files == null || files.isEmpty()) { if (files == null || files.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载");
} }
@@ -233,7 +239,7 @@ public class FileService implements IFileService {
log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName()); log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName());
continue; continue;
} }
String entryName = sanitizeEntryName(f.getFileTrueName()); String entryName = sanitizeEntryName(entryNameOf(f, entryNames));
if (used.contains(entryName)) { if (used.contains(entryName)) {
entryName = f.getId() + "_" + entryName; entryName = f.getId() + "_" + entryName;
} }
@@ -249,6 +255,17 @@ public class FileService implements IFileService {
} }
} }
/** 条目名:优先取调用方指定的名称(按 fileId),否则用原始文件名 */
private String entryNameOf(UpFile f, Map<Integer, String> entryNames) {
if (entryNames != null) {
String named = entryNames.get(f.getId());
if (named != null && !named.trim().isEmpty()) {
return named;
}
}
return f.getFileTrueName();
}
/** /**
* 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。 * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。
*/ */
@@ -1,17 +1,22 @@
package com.yunhai.acasyllabu.service.impl; package com.yunhai.acasyllabu.service.impl;
import com.yunhai.acasyllabu.entity.GradeSetting;
import com.yunhai.acasyllabu.entity.PeriodTemplate; import com.yunhai.acasyllabu.entity.PeriodTemplate;
import com.yunhai.acasyllabu.entity.TemplateRow; import com.yunhai.acasyllabu.entity.TemplateRow;
import com.yunhai.acasyllabu.entity.TimeSetting; 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.EffectiveSettingVo;
import com.yunhai.acasyllabu.model.syllabus.GradeSettingVo;
import com.yunhai.acasyllabu.model.syllabus.PeriodTemplateVo; import com.yunhai.acasyllabu.model.syllabus.PeriodTemplateVo;
import com.yunhai.acasyllabu.model.syllabus.SettingGetVo; import com.yunhai.acasyllabu.model.syllabus.SettingGetVo;
import com.yunhai.acasyllabu.model.syllabus.TemplateRowVo; import com.yunhai.acasyllabu.model.syllabus.TemplateRowVo;
import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest; import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest;
import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo; import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo;
import com.yunhai.acasyllabu.repository.GradeSettingRepository;
import com.yunhai.acasyllabu.repository.PeriodTemplateRepository; import com.yunhai.acasyllabu.repository.PeriodTemplateRepository;
import com.yunhai.acasyllabu.repository.TemplateRowRepository; import com.yunhai.acasyllabu.repository.TemplateRowRepository;
import com.yunhai.acasyllabu.repository.TimeSettingRepository; import com.yunhai.acasyllabu.repository.TimeSettingRepository;
import com.yunhai.acasyllabu.service.IClassService;
import com.yunhai.acasyllabu.service.ISettingService; import com.yunhai.acasyllabu.service.ISettingService;
import com.yunhai.acasyllabu.utils.UserContext; import com.yunhai.acasyllabu.utils.UserContext;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
@@ -27,9 +32,12 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; 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_GRADES_LENGTH = 64;
/** 年级名称单长上限,与实体列一致 */
private static final int MAX_GRADE_NAME = 16;
@Autowired @Autowired
private TimeSettingRepository timeSettingRepository; private TimeSettingRepository timeSettingRepository;
@@ -87,6 +98,12 @@ public class SettingService implements ISettingService {
@Autowired @Autowired
private TemplateRowRepository rowRepository; private TemplateRowRepository rowRepository;
@Autowired
private GradeSettingRepository gradeSettingRepository;
@Autowired
private IClassService classService;
// ============================ 查询 ============================ // ============================ 查询 ============================
@Override @Override
@@ -109,6 +126,12 @@ public class SettingService implements ISettingService {
return vo; return vo;
} }
String grade = gradeName.trim(); 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(课表页按当前学年学期查询);学期专属模板优先于全年模板 // 请求学期只可能是 1/2(课表页按当前学年学期查询);学期专属模板优先于全年模板
Integer reqTerm = Integer.valueOf(2).equals(term) ? 2 : 1; Integer reqTerm = Integer.valueOf(2).equals(term) ? 2 : 1;
templateRepository.findBySchIdAndEnabledTrueOrderByIdAsc(schId).stream() templateRepository.findBySchIdAndEnabledTrueOrderByIdAsc(schId).stream()
@@ -117,8 +140,12 @@ public class SettingService implements ISettingService {
.sorted(Comparator.comparingInt(tpl -> Objects.equals(tpl.getTerm(), reqTerm) ? 0 : 1)) .sorted(Comparator.comparingInt(tpl -> Objects.equals(tpl.getTerm(), reqTerm) ? 0 : 1))
.findFirst() .findFirst()
.ifPresent(tpl -> { .ifPresent(tpl -> {
vo.setTemplateId(tpl.getId());
vo.setTeachDays(tpl.getTeachDays()); vo.setTeachDays(tpl.getTeachDays());
vo.setTemplateName(tpl.getName()); vo.setTemplateName(tpl.getName());
// 「显示全部/只显示节次」「节次名称 显示/隐藏」偏好绑定生效模板(预览课表与我的课表同随此值)
vo.setShowBreakRows(!Boolean.FALSE.equals(tpl.getShowBreakRows()));
vo.setShowPeriodName(!Boolean.FALSE.equals(tpl.getShowPeriodName()));
// 启用时段与节次/课间时长绑定模板:课表编辑页按生效模板的时段判定与展示 // 启用时段与节次/课间时长绑定模板:课表编辑页按生效模板的时段判定与展示
vo.getTime().setEnabledBands(sanitizeBands(tpl.getEnabledBands())); vo.getTime().setEnabledBands(sanitizeBands(tpl.getEnabledBands()));
if (tpl.getPeriodMinutes() != null) { if (tpl.getPeriodMinutes() != null) {
@@ -222,6 +249,13 @@ public class SettingService implements ISettingService {
tpl.setEnabledBands(bands); tpl.setEnabledBands(bands);
tpl.setPeriodMinutes(periodMinutes); tpl.setPeriodMinutes(periodMinutes);
tpl.setBreakMinutes(breakMinutes); 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); tpl = templateRepository.save(tpl);
if (enabled) { if (enabled) {
@@ -269,6 +303,95 @@ public class SettingService implements ISettingService {
log.info("[deleteTemplate] schId={}, id={}, {}·{}", schId, tpl.getId(), tpl.getGradeName(), tpl.getName()); 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<GradeSettingVo> grades() {
Integer schId = currentSchId();
// 班级树年级节点为口径(与排课情况/我的课表所见年级一致),已存设置按名合并,未配置默认全启用
Map<String, GradeSetting> saved = new LinkedHashMap<>();
for (GradeSetting gs : gradeSettingRepository.findBySchIdOrderByIdAsc(schId)) {
saved.putIfAbsent(gs.getGradeName(), gs);
}
List<GradeSettingVo> out = new ArrayList<>();
Set<String> 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<String, GradeSetting> e : saved.entrySet()) {
out.add(toGradeVo(e.getKey(), e.getValue()));
}
return out;
}
@Override
@Transactional
public void saveGrades(List<GradeSettingVo> 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.setEnabledBands(sanitizeBands(tpl.getEnabledBands()));
vo.setPeriodMinutes(orDefault(tpl.getPeriodMinutes(), DEFAULT_PERIOD_MINUTES)); vo.setPeriodMinutes(orDefault(tpl.getPeriodMinutes(), DEFAULT_PERIOD_MINUTES));
vo.setBreakMinutes(orDefault(tpl.getBreakMinutes(), DEFAULT_BREAK_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())) { for (TemplateRow row : rowRepository.findByTemplateIdOrderBySortOrderAsc(tpl.getId())) {
vo.getRows().add(toRowVo(row)); vo.getRows().add(toRowVo(row));
} }
@@ -6,9 +6,13 @@ import com.yunhai.acasyllabu.entity.UpFile;
import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo;
import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; import com.yunhai.acasyllabu.model.bsch.NowYearTermVo;
import com.yunhai.acasyllabu.model.bsch.SubjectVo; 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.MyClassVo;
import com.yunhai.acasyllabu.model.syllabus.MyClassesVo;
import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo; import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo;
import com.yunhai.acasyllabu.model.syllabus.OapiTimetableClassVo; 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.TimetableSaveRequest;
import com.yunhai.acasyllabu.model.syllabus.TimetableSlotVo; import com.yunhai.acasyllabu.model.syllabus.TimetableSlotVo;
import com.yunhai.acasyllabu.model.syllabus.TimetableVo; 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.repository.UpFileRepository;
import com.yunhai.acasyllabu.service.IClassService; import com.yunhai.acasyllabu.service.IClassService;
import com.yunhai.acasyllabu.service.IFileService; import com.yunhai.acasyllabu.service.IFileService;
import com.yunhai.acasyllabu.service.ISettingService;
import com.yunhai.acasyllabu.service.ITimetableService; 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.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.acasyllabu.utils.UserContext;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
@@ -28,9 +34,15 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; 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.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -83,6 +95,9 @@ public class TimetableService implements ITimetableService {
@Autowired @Autowired
private IFileService fileService; private IFileService fileService;
@Autowired
private ISettingService settingService;
// ============================ 查询 ============================ // ============================ 查询 ============================
@Override @Override
@@ -228,6 +243,295 @@ public class TimetableService implements ITimetableService {
log.info("[removePhoto] schId={}, classId={}, fileId={}", currentSchId(), classId, oldFileId); log.info("[removePhoto] schId={}, classId={}, fileId={}", currentSchId(), classId, oldFileId);
} }
// ============================ 排课情况 ============================
@Override
public List<TimetableOverviewVo> overview() {
Integer schId = currentSchId();
NowYearTermVo yearTerm = currentYearTerm();
// 当前学年学期课表按班级ID归并;节次按课表ID一次取回,避免逐班查询
Map<Integer, Timetable> byClass = new LinkedHashMap<>();
for (Timetable t : timetableRepository.findBySchIdAndYearAndTerm(schId, yearTerm.getYear(), yearTerm.getTerm())) {
byClass.put(t.getClassId(), t);
}
Map<Integer, List<TimetableSlot>> slotsByTimetable = slotsByTimetable(byClass.values());
List<TimetableOverviewVo> result = new ArrayList<>();
int electronicCount = 0;
int photoCount = 0;
for (ClassTreeNodeVo grade : classService.getClassTree(schId)) {
for (ClassTreeNodeVo cls : grade.getChildren() == null ? new ArrayList<ClassTreeNodeVo>() : 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<TimetableSlot> 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<Integer, List<TimetableSlot>> slotsByTimetable(Collection<Timetable> timetables) {
Map<Integer, List<TimetableSlot>> map = new LinkedHashMap<>();
List<Integer> 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<TimetableSlot> list : map.values()) {
list.sort(Comparator.comparing(TimetableSlot::getSortOrder, Comparator.nullsLast(Comparator.naturalOrder())));
}
return map;
}
/** 节次内去重科目数(周一~周日所有格,空格不计) */
private static int countDistinctSubjects(List<TimetableSlot> slots) {
Set<String> 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<Integer> 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<ClassTreeNodeVo>() : grade.getChildren()) {
if (cls.getId() != null) {
classIds.add(cls.getId());
}
}
}
if (!gradeFound) {
throw new BizException(ErrorCode.PARAM_ERROR, "年级不存在: " + gradeName);
}
// 当前学年学期该年级课表的照片附件,条目按「年级+班级名.扩展名」命名
List<UpFile> files = new ArrayList<>();
Map<Integer, String> 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<TemplateRowVo> periodRows = effectivePeriodRows(effective);
boolean[] taught = taughtDays(effective.getTeachDays());
List<TimetableExcelUtil.GridRow> 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<TemplateRowVo> 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<Integer> 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<Integer, String> 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<String> subjectNames = validSubjectNames();
List<TimetableSlotVo> 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<String> 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<String> 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<TimetableExcelUtil.GridRow> 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<String> subjects = slot.getSubjects();
List<String> 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 @Override
@@ -235,32 +539,34 @@ public class TimetableService implements ITimetableService {
return myClasses(UserContext.getUserId(), UserContext.getRoleId(), currentSchId()); return myClasses(UserContext.getUserId(), UserContext.getRoleId(), currentSchId());
} }
@Override
public MyClassesVo myClassesVo() {
List<MyClassVo> 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 @Override
public List<MyClassVo> myClasses(Integer userId, Integer roleId, Integer schId) { public List<MyClassVo> myClasses(Integer userId, Integer roleId, Integer schId) {
List<Map<String, Object>> classList = classService.getSchClassList(schId); List<Map<String, Object>> classList = classService.getSchClassList(schId);
boolean admin = isAdminRole(roleId); boolean admin = isAdminRole(roleId);
boolean student = !admin && isStudentRole(roleId); boolean student = !admin && isStudentRole(roleId);
// 班主任班级:正/副班主任是本人 // 班主任班级:正/副班主任是本人(身份是用户事实,管理员会话同样计算,供多身份视图切换)
Set<Integer> headIds = new HashSet<>(); Set<Integer> headIds = new HashSet<>();
// 任教班级:classId -> 任教科目(去重保序) // 任教班级:classId -> 任教科目(去重保序)
Map<Integer, List<String>> teachSubjects = new LinkedHashMap<>(); Map<Integer, List<String>> teachSubjects = new LinkedHashMap<>();
// 范围:管理员=全部,学生=本人班级,教师=班主任∪任教 if (!student && userId != null) {
Set<Integer> scope = new LinkedHashSet<>();
if (admin) {
for (Map<String, Object> c : classList) {
Integer cid = asInt(c.get("classId"));
if (cid != null) {
scope.add(cid);
}
}
} else if (student) {
Map<String, Object> 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) {
for (Map<String, Object> c : classList) { for (Map<String, Object> c : classList) {
Integer cid = asInt(c.get("classId")); Integer cid = asInt(c.get("classId"));
if (cid != null && (idEquals(c.get("firstTeacherId"), userId) || idEquals(c.get("secondTeacherId"), userId))) { 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()); names.add(sub.trim());
} }
} }
}
// 范围:管理员=全部,学生=本人班级,教师=班主任∪任教
Set<Integer> scope = new LinkedHashSet<>();
if (admin) {
for (Map<String, Object> c : classList) {
Integer cid = asInt(c.get("classId"));
if (cid != null) {
scope.add(cid);
}
}
} else if (student) {
Map<String, Object> 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(headIds);
scope.addAll(teachSubjects.keySet()); 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<TemplateRowVo> effectivePeriodRows(EffectiveSettingVo effective) {
List<TemplateRowVo> 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<String> dayHeads(boolean[] days) {
List<String> heads = TimetableExcelUtil.allDayHeads();
List<String> 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<String> emptyCells(int n) {
List<String> 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<String> 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) { private static int toMinutes(String time) {
String[] parts = time.split(":"); String[] parts = time.split(":");
return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]); return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]);
@@ -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(固定网格布局:标题、说明、表头「节次|时间|周一~周日…」、节次行)。
* <p>写出用 POI(标题合并/表头底色/冻结窗格需要精确控制);读取用 EasyExcel 流式按行取值,
* 自动识别 xls/xlsx。表头行按「节次」单元格定位,星期列按表头别名(周X/星期X)映射到
* day 0~6(周一~周日);数据行以「节次」列序号对齐到课表节次(见 {@link #slotIndexOf})。</p>
*/
public final class TimetableExcelUtil {
/** 表头首列名(定位表头行的锚点,与写出侧一致) */
public static final String LABEL_HEADER = "节次";
/** 星期表头 → day 下标(0=周一 … 6=周日);兼容「周X」与「星期X」两种写法 */
private static final Map<String, Integer> 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<String> cells;
public GridRow(String label, String time, List<String> 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<String> dayHeads;
private final List<GridRow> rows;
public SheetGrid(String title, String notice, List<String> dayHeads, List<GridRow> 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<Integer, String> dayCells;
ParsedRow(int rowNo, String slotLabel, Map<Integer, String> dayCells) {
this.rowNo = rowNo;
this.slotLabel = slotLabel;
this.dayCells = dayCells;
}
public int getRowNo() {
return rowNo;
}
public String getSlotLabel() {
return slotLabel;
}
public Map<Integer, String> getDayCells() {
return dayCells;
}
}
/** 解析结果:表头行号、「Excel列号 → day 下标」映射、有效数据行 */
public static class ParsedSheet {
private final int headerRowNo;
private final Map<Integer, Integer> dayColIndex;
private final List<ParsedRow> rows;
ParsedSheet(int headerRowNo, Map<Integer, Integer> dayColIndex, List<ParsedRow> rows) {
this.headerRowNo = headerRowNo;
this.dayColIndex = dayColIndex;
this.rows = rows;
}
public int getHeaderRowNo() {
return headerRowNo;
}
public Map<Integer, Integer> getDayColIndex() {
return dayColIndex;
}
public List<ParsedRow> getRows() {
return rows;
}
}
/**
* 解析课表 Excel:扫描「节次」表头行定位各列,随后逐行收集学科格;
* 节次与学科格全空的行跳过。文件损坏或非 Excel 时抛业务异常。
*/
public static ParsedSheet parse(InputStream in) {
Holder holder = new Holder();
try {
EasyExcel.read(in, new AnalysisEventListener<Map<Integer, String>>() {
@Override
public void invoke(Map<Integer, String> 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<Integer, Integer> dayCols = new LinkedHashMap<>();
List<ParsedRow> dataRows = new ArrayList<>();
}
/** 扫描表头行:定位「节次」列与星期列;未命中(缺少节次或星期列)则继续向后找 */
private static void scanHeader(Map<Integer, String> row, int rowNo, Holder holder) {
Integer labelCol = null;
Map<Integer, Integer> dayCols = new LinkedHashMap<>();
for (Map.Entry<Integer, String> 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<Integer, String> row, int rowNo, Holder holder) {
String label = normalize(holder.labelCol == null ? null : row.get(holder.labelCol));
Map<Integer, String> dayCells = new LinkedHashMap<>();
boolean hasSubject = false;
for (Map.Entry<Integer, Integer> 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<String> allDayHeads() {
return Arrays.asList("周一", "周二", "周三", "周四", "周五", "周六", "周日");
}
}
@@ -80,6 +80,7 @@ CREATE TABLE IF NOT EXISTS `timetable_period_template` (
`enabled_bands` VARCHAR(16) NOT NULL DEFAULT '11111' COMMENT '启用时段 5 位 0/1 串(早自习 上午 中午 下午 晚自习)', `enabled_bands` VARCHAR(16) NOT NULL DEFAULT '11111' COMMENT '启用时段 5 位 0/1 串(早自习 上午 中午 下午 晚自习)',
`period_minutes` INT NOT NULL DEFAULT 45 COMMENT '节次时长(分钟),模板加节次的默认用时', `period_minutes` INT NOT NULL DEFAULT 45 COMMENT '节次时长(分钟),模板加节次的默认用时',
`break_minutes` INT NOT NULL DEFAULT 10 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_by` VARCHAR(255) NULL,
`create_time` DATETIME NULL, `create_time` DATETIME NULL,
`update_by` VARCHAR(255) NULL, `update_by` VARCHAR(255) NULL,
@@ -108,6 +109,23 @@ CREATE TABLE IF NOT EXISTS `timetable_template_row` (
KEY `idx_row_template` (`template_id`) KEY `idx_row_template` (`template_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='节次模板行'; ) 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 加列后手动收尾): -- 旧库升级(2026-10-12:模板绑定学期 + 年级多选;本地/生产均已执行或由 ddl-auto 加列后手动收尾):
-- ALTER TABLE `timetable_period_template` -- ALTER TABLE `timetable_period_template`
-- ADD COLUMN `term` INT NOT NULL DEFAULT 1 COMMENT '绑定学期 1=上学期 2=下学期', -- 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 -- ELSE 'ev' END
-- WHERE r.band IS NULL OR r.band = ''; -- 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`;
@@ -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<SubjectVo> subjects(String... names) {
List<SubjectVo> 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<TimetableExcelUtil.GridRow> rows) {
return new TimetableExcelUtil.SheetGrid("高一1班 课程表导入模板(2026学年第一学期)", "说明:示例", dayHeads(5), rows);
}
private static List<String> 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<String> 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<TimetableExcelUtil.GridRow> 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<TimetableSlot> 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<TimetableExcelUtil.GridRow> 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<TimetableSlot> 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<TimetableExcelUtil.GridRow> 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<TimetableExcelUtil.GridRow> 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<String> 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<TimetableExcelUtil.GridRow> 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<Integer, String> 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("无法识别的节次名称"));
}
}
}
@@ -5,6 +5,7 @@ import com.yunhai.acasyllabu.entity.TimetableSlot;
import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo;
import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; import com.yunhai.acasyllabu.model.bsch.NowYearTermVo;
import com.yunhai.acasyllabu.model.syllabus.MyClassVo; 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.MyTimetableVo;
import com.yunhai.acasyllabu.repository.TimetableRepository; import com.yunhai.acasyllabu.repository.TimetableRepository;
import com.yunhai.acasyllabu.repository.TimetableSlotRepository; import com.yunhai.acasyllabu.repository.TimetableSlotRepository;
@@ -165,8 +166,37 @@ public class TimetableServiceMyTest {
assertEquals(Integer.valueOf(12), result.get(0).getClassId()); assertEquals(Integer.valueOf(12), result.get(0).getClassId());
assertEquals(Integer.valueOf(371), result.get(1).getClassId()); assertEquals(Integer.valueOf(371), result.get(1).getClassId());
assertTrue(result.get(0).isViewAllSubjects()); assertTrue(result.get(0).isViewAllSubjects());
assertFalse(result.get(0).isHeadTeacher()); // 班主任身份按用户事实标记(本人是 12 班正班主任),范围仍为全校
assertTrue(result.get(0).isHeadTeacher());
assertTrue(result.get(1).isViewAllSubjects()); 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 @Test