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