From 351df2cba2de25009c404600a74b1e67f10aa401 Mon Sep 17 00:00:00 2001
From: liweijie
Date: Thu, 17 Sep 2026 17:14:31 +0800
Subject: [PATCH] =?UTF-8?q?=E6=8E=92=E8=AF=BE=E5=8A=9F=E8=83=BD=E4=BC=98?=
=?UTF-8?q?=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../jwgl/SchedulingWindowController.java | 95 ++
.../jwgl/TeacherCalendarController.java | 12 +
.../scheduling/SchedulingArrangeRequest.java | 23 +
.../SchedulingCourseEditRequest.java | 31 +
.../java/com/roomroot/jwgl/entity/BZTGMX.java | 14 +
.../com/roomroot/jwgl/entity/KCBBZMX.java | 8 +
.../com/roomroot/jwgl/mapper/SSKCMapper.java | 28 +
.../com/roomroot/jwgl/mapper/SSKCMapper.xml | 77 ++
.../com/roomroot/jwgl/service/JYLService.java | 15 +
.../jwgl/service/SchedulingWindowService.java | 10 +
.../jwgl/service/TaskBookFillService.java | 3 +-
.../jwgl/service/impl/JYLServiceImpl.java | 86 ++
.../impl/RunningCoursePublishServiceImpl.java | 28 +-
.../impl/SchedulingWindowServiceImpl.java | 493 +++++++-
.../impl/StudentTeamTaskServiceImpl.java | 5 +-
.../service/impl/TaskBookFillServiceImpl.java | 39 +-
.../impl/TimetableConflictServiceImpl.java | 121 +-
.../conflict/TimetableConflictDimension.java | 23 +-
.../jwgl/vo/scheduling/SchedulingViewVO.java | 49 +
.../src/api/teachBusiness/schedulingWindow.js | 44 +
.../src/api/teachOffice/teacherCalendar.js | 32 +
.../teachBusiness/schedulingWindow/index.vue | 1091 +++++++++++++++--
.../teachBusiness/timetableConflict/index.vue | 5 +-
.../views/teachOffice/shiftSemester/index.vue | 69 ++
.../components/TeacherCalendarDialog.vue | 461 +++++++
.../src/views/teachOffice/teacher/index.vue | 27 +-
26 files changed, 2699 insertions(+), 190 deletions(-)
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/scheduling/SchedulingCourseEditRequest.java
create mode 100644 frontend/src/api/teachOffice/teacherCalendar.js
create mode 100644 frontend/src/views/teachOffice/teacher/components/TeacherCalendarDialog.vue
diff --git a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/SchedulingWindowController.java b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/SchedulingWindowController.java
index d4d3231dd..b7c865e2f 100644
--- a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/SchedulingWindowController.java
+++ b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/SchedulingWindowController.java
@@ -1,7 +1,10 @@
package com.roomroot.web.controller.jwgl;
+import com.roomroot.common.exception.ServiceException;
import com.roomroot.jwgl.dto.scheduling.SchedulingArrangeRequest;
import com.roomroot.jwgl.dto.scheduling.SchedulingCellOpRequest;
+import com.roomroot.jwgl.dto.scheduling.SchedulingCourseEditRequest;
+import com.roomroot.jwgl.service.RunningCoursePublishService;
import com.roomroot.jwgl.service.SchedulingWindowService;
import com.roomroot.jwgl.unit.Result;
import com.roomroot.jwgl.vo.scheduling.SchedulingViewVO;
@@ -14,6 +17,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.ArrayList;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -30,6 +35,9 @@ public class SchedulingWindowController {
@Autowired
private SchedulingWindowService schedulingWindowService;
+ @Autowired
+ private RunningCoursePublishService runningCoursePublishService;
+
/**
* 排课窗组合视图:班次头 + 课程列表(排满标绿)+ 周次×星期×节次格子。
*/
@@ -84,4 +92,91 @@ public class SchedulingWindowController {
@RequestParam(value = "czlx", required = false) String czlx) {
return Result.success(schedulingWindowService.logs(sskcbh, czlx));
}
+
+ /**
+ * 编辑课程教学任务信息(手册 12.3.1):学时/周课时/课程简称/责任教员/默认场地。
+ */
+ @PreAuthorize("@ss.hasPermi('teach:schedulingWindow:edit')")
+ @PostMapping("/course/update")
+ public Result
*/
public interface TaskBookFillService {
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/JYLServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/JYLServiceImpl.java
index c65156b02..1b2f80ee7 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/JYLServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/JYLServiceImpl.java
@@ -5,13 +5,19 @@ import com.roomroot.common.utils.StringUtils;
import com.roomroot.jwgl.entity.JYL;
import com.roomroot.jwgl.mapper.JYLMapper;
import com.roomroot.jwgl.service.JYLService;
+import com.roomroot.jwgl.unit.BusinessException;
import com.roomroot.jwgl.unit.PageQuery;
import com.roomroot.jwgl.unit.PageResult;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
+import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
import java.util.UUID;
/**
@@ -81,6 +87,86 @@ public class JYLServiceImpl implements JYLService {
.apply("DATE(rq) = {0}", rq));
}
+ /**
+ * 批量保存:按 (教员编号 + 日期 + 节次) 幂等覆盖。
+ * 先按涉及的教员 + 日期区间一次性把已有记录拉回来判重,避免逐行查库。
+ */
+ @Override
+ public int batchSave(List list) {
+ if (list == null || list.isEmpty()) {
+ return 0;
+ }
+ Set jybhSet = new HashSet<>();
+ LocalDate minDay = null;
+ LocalDate maxDay = null;
+ for (JYL item : list) {
+ if (item == null || StringUtils.isEmpty(item.getJybh())) {
+ throw new BusinessException("教员历缺少教员编号");
+ }
+ if (item.getRq() == null || item.getJc() == null) {
+ throw new BusinessException("教员历缺少日期或节次");
+ }
+ item.setJybh(item.getJybh().trim());
+ jybhSet.add(item.getJybh());
+ LocalDate d = item.getRq().toLocalDate();
+ if (minDay == null || d.isBefore(minDay)) {
+ minDay = d;
+ }
+ if (maxDay == null || d.isAfter(maxDay)) {
+ maxDay = d;
+ }
+ }
+ Map existing = new HashMap<>();
+ for (JYL row : jylMapper.selectList(new LambdaQueryWrapper()
+ .in(JYL::getJybh, jybhSet)
+ .ge(JYL::getRq, minDay.atStartOfDay())
+ .lt(JYL::getRq, maxDay.plusDays(1).atStartOfDay()))) {
+ if (row == null || row.getRq() == null || row.getJc() == null) {
+ continue;
+ }
+ existing.put(cellKey(row.getJybh(), row.getRq().toLocalDate(), row.getJc()), row);
+ }
+ int affected = 0;
+ for (JYL item : list) {
+ JYL old = existing.get(cellKey(item.getJybh(), item.getRq().toLocalDate(), item.getJc()));
+ if (old == null) {
+ if (item.getKpk() == null) {
+ // 教员历的语义是"标出不可排课的时段",新标记默认不可排课
+ item.setKpk(0);
+ }
+ add(item);
+ } else {
+ if (item.getKpk() != null) {
+ old.setKpk(item.getKpk());
+ }
+ if (StringUtils.isNotEmpty(item.getMc())) {
+ old.setMc(item.getMc());
+ }
+ old.setBz(item.getBz());
+ update(old);
+ }
+ affected++;
+ }
+ return affected;
+ }
+
+ @Override
+ public int batchDelete(List bhs) {
+ if (bhs == null || bhs.isEmpty()) {
+ return 0;
+ }
+ List ids = bhs.stream().filter(StringUtils::isNotEmpty).collect(java.util.stream.Collectors.toList());
+ if (ids.isEmpty()) {
+ return 0;
+ }
+ return jylMapper.delete(new LambdaQueryWrapper().in(JYL::getBh, ids));
+ }
+
+ /** 判重键:教员编号|yyyy-MM-dd|节次 */
+ private static String cellKey(String jybh, LocalDate day, Integer jc) {
+ return (jybh == null ? "" : jybh.trim()) + "|" + day + "|" + jc;
+ }
+
private LambdaQueryWrapper buildWrapper(JYL condition) {
LambdaQueryWrapper qw = new LambdaQueryWrapper<>();
if (condition != null) {
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/RunningCoursePublishServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/RunningCoursePublishServiceImpl.java
index c3519f7a9..83956697d 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/RunningCoursePublishServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/RunningCoursePublishServiceImpl.java
@@ -138,7 +138,10 @@ public class RunningCoursePublishServiceImpl implements RunningCoursePublishServ
for (List group : groups.values()) {
XYDRWB head = group.get(0);
SSKC sskc = new SSKC();
- sskc.setBh(UuidUtil.getUUID());
+ // 实施_课程.编号 是 VARBINARY(16):MyBatis 走 @TableId 元数据不带 typeHandler,
+ // 必须绑 32 位无横线十六进制,带横线会被达梦当普通字符串写入并报 String truncated。
+ String sskcBh = UuidUtil.getUUID();
+ sskc.setBh(sskcBh);
sskc.setNd(ndCode);
sskc.setJybh(head.getJybh());
sskc.setKmbh(head.getKbh());
@@ -153,12 +156,27 @@ public class RunningCoursePublishServiceImpl implements RunningCoursePublishServ
sskc.setBjrxypjf(head.getBjrxypjf());
sskc.setCjsj(now);
sskc.setBdsj(now);
+ // 以下非空列必须显式赋值(实施_课程共 26 个 NOT NULL 列,无库级缺省)
+ sskc.setJykcxh(1);
+ sskc.setKcksxs(1.0f);
+ sskc.setKcxycpzt("未开始");
+ sskc.setKbbdsj(now);
+ sskc.setKhxs(head.getKsks() == null ? 0 : head.getKsks());
+ sskc.setYxxs(0);
+ sskc.setYxllxs(0);
+ sskc.setYxsjxs(0);
+ sskc.setYxkhxs(0);
+ sskc.setPxxhdf(0f);
+ sskc.setQrpxzh(0);
+ sskc.setZrjybh(head.getJybh());
+ sskc.setJypxjszt(0);
sskcMapper.insert(sskc);
for (XYDRWB task : group) {
SSKCXYD link = new SSKCXYD();
- link.setBh(UuidUtil.getUUID());
- link.setSskcbh(sskc.getBh());
+ // 实施_课程学员队.编号/实施_课程编号 是 CHAR(36),约定存带横线 36 位 UUID
+ link.setBh(UuidUtil.getOriginalUUID());
+ link.setSskcbh(UuidUtil.fromBytes(UuidUtil.toBytes(sskcBh)));
link.setXydbh(task.getXydbh());
link.setJc(team != null ? team.getJc() : null);
link.setRs(team != null ? team.getXydrs() : null);
@@ -213,7 +231,9 @@ public class RunningCoursePublishServiceImpl implements RunningCoursePublishServ
Long remain = sskcxydMapper.selectCount(new LambdaQueryWrapper()
.eq(SSKCXYD::getSskcbh, sskcbh));
if (remain == null || remain == 0) {
- sskcMapper.deleteById(sskcbh);
+ // 注意:实施_课程.编号为 VARBINARY GUID,deleteById 直绑字符串会报
+ // dm.jdbc.driver.DMException: Invalid hexadecimal digits,必须走 RAWTOHEX
+ sskcMapper.deleteByBhs(java.util.List.of(sskcbh));
}
removed++;
}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/SchedulingWindowServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/SchedulingWindowServiceImpl.java
index 3506c5857..42e9f27b9 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/SchedulingWindowServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/SchedulingWindowServiceImpl.java
@@ -5,10 +5,16 @@ import com.roomroot.common.exception.ServiceException;
import com.roomroot.common.utils.SecurityUtils;
import com.roomroot.jwgl.dto.scheduling.SchedulingArrangeRequest;
import com.roomroot.jwgl.dto.scheduling.SchedulingCellOpRequest;
+import com.roomroot.jwgl.dto.scheduling.SchedulingCourseEditRequest;
+import com.roomroot.jwgl.entity.BZLB;
+import com.roomroot.jwgl.entity.BZTGMX;
+import com.roomroot.jwgl.entity.JCSJB;
import com.roomroot.jwgl.entity.JQB;
+import com.roomroot.jwgl.entity.JSB;
import com.roomroot.jwgl.entity.JYB;
import com.roomroot.jwgl.entity.JXCDL;
import com.roomroot.jwgl.entity.JYL;
+import com.roomroot.jwgl.entity.KCBBZMX;
import com.roomroot.jwgl.entity.KCJXYX_CZRZ;
import com.roomroot.jwgl.entity.SSKC;
import com.roomroot.jwgl.entity.SSKCB;
@@ -18,7 +24,13 @@ import com.roomroot.jwgl.entity.SSKCBXYD;
import com.roomroot.jwgl.entity.SSKCXYD;
import com.roomroot.jwgl.entity.XYDB;
import com.roomroot.jwgl.entity.XYDNDXQJBXXB;
+import com.roomroot.jwgl.entity.XYDRWB;
import com.roomroot.jwgl.entity.XQXLB;
+import com.roomroot.jwgl.mapper.BZLBMapper;
+import com.roomroot.jwgl.mapper.BZTGMXMapper;
+import com.roomroot.jwgl.mapper.ClassRoomMapper;
+import com.roomroot.jwgl.mapper.JCSJBMapper;
+import com.roomroot.jwgl.mapper.KCBBZMXMapper;
import com.roomroot.jwgl.mapper.XYDBMapper;
import com.roomroot.jwgl.mapper.ClassRoomCalendarMapper;
import com.roomroot.jwgl.mapper.ClassSemesterMapper;
@@ -40,6 +52,7 @@ import com.roomroot.jwgl.utils.SemesterCodeUtil;
import com.roomroot.jwgl.utils.PeriodUtil;
import com.roomroot.jwgl.utils.UuidUtil;
import com.roomroot.jwgl.vo.scheduling.SchedulingViewVO;
+import com.roomroot.common.core.domain.entity.SysUser;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -52,6 +65,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -129,6 +143,24 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
@Resource
private com.roomroot.jwgl.mapper.StudentTeamTaskMapper studentTeamTaskMapper;
+ @Resource
+ private JCSJBMapper jcsjbMapper;
+
+ @Resource
+ private ClassRoomMapper classRoomMapper;
+
+ @Resource
+ private BZLBMapper bzlbMapper;
+
+ @Resource
+ private BZTGMXMapper bztgmxMapper;
+
+ @Resource
+ private KCBBZMXMapper kcbbzMapper;
+
+ @Resource
+ private com.roomroot.system.service.ISysUserService userService;
+
// ==================== 视图 ====================
@Override
@@ -147,6 +179,13 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
List sskcIds = links.stream().map(SSKCXYD::getSskcbh).distinct().toList();
Map courses = sskcIds.isEmpty() ? Map.of()
: sskcMapper.selectByBhs(sskcIds).stream().collect(Collectors.toMap(SSKC::getBh, c -> c, (a, b) -> a));
+ // 编制侧任务(排课建议 / 简称 / 计划教员)
+ Map taskByKbh = new LinkedHashMap<>();
+ for (XYDRWB task : studentTeamTaskMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDRWB::getXydxqbh, ctx.semester.getBh())
+ .eq(XYDRWB::getDelFlag, 0))) {
+ taskByKbh.putIfAbsent(task.getKbh(), task);
+ }
List lessons = ctx.lessons;
Map> lessonsByCourse = lessons.stream()
.collect(Collectors.groupingBy(SSKCB::getSskcbh));
@@ -162,6 +201,25 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
c.setZks(course.getZks());
c.setJybh(course.getJybh());
c.setJsbh(course.getJsbh());
+ XYDRWB task = taskByKbh.get(course.getKmbh());
+ if (task != null) {
+ c.setJc(task.getJc());
+ c.setJysjhbz(task.getJysjhbz());
+ c.setJysjhjybh(task.getJysjhjybh());
+ c.setJysjhjyxm(resolveTeacherName(task.getJysjhjybh()));
+ }
+ if (c.getJybh() != null) c.setJyxm(resolveTeacherName(c.getJybh()));
+ if (c.getJsbh() != null) c.setJsmc(resolveRoomName(c.getJsbh()));
+ // ⑤ 合班信息:该运行课程关联的全部班次
+ for (SSKCXYD link : links) {
+ if (!course.getBh().equals(link.getSskcbh())) continue;
+ SchedulingViewVO.Team t = new SchedulingViewVO.Team();
+ t.setXydbh(link.getXydbh());
+ t.setXydxqbh(ctx.semester.getBh());
+ t.setXydmc(link.getXydbh() != null && ctx.team != null && ctx.team.getXydbh().equals(link.getXydbh())
+ ? ctx.team.getXydmc() : resolveTeamName(link.getXydbh()));
+ c.getTeams().add(t);
+ }
List cl = lessonsByCourse.getOrDefault(sskcbh, List.of());
c.setScheduledHours(cl.stream().mapToInt(l -> HOURS_PER_LESSON + (l.getJcdj() == null ? 0 : l.getJcdj())).sum());
c.setFull(course.getXs() != null && c.getScheduledHours() >= course.getXs());
@@ -213,14 +271,55 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
.collect(Collectors.groupingBy(l -> l.getRq().toLocalDate().toString() + "#" + l.getJc()));
Map courseNames = new HashMap<>();
Map courseTeachers = new HashMap<>();
- for (SSKC course : sskcMapper.selectByBhs(ctx.links.stream().map(SSKCXYD::getSskcbh).distinct().toList())) {
+ List courseBhList = ctx.links.stream().map(SSKCXYD::getSskcbh).distinct().toList();
+ for (SSKC course : courseBhList.isEmpty() ? List.of() : sskcMapper.selectByBhs(courseBhList)) {
courseNames.put(course.getBh(), resolveCourseName(course.getKmbh()));
- courseTeachers.put(course.getBh(), safe(course.getJybh()));
+ courseTeachers.put(course.getBh(), resolveTeacherName(course.getJybh()));
+ }
+ // 课次明细:学员队 / 教室 / 教员(供 ② 冲突显示选项与双击回填使用)
+ Map> teamsOfLesson = new HashMap<>();
+ Map> roomsOfLesson = new HashMap<>();
+ Map> teachersOfLesson = new HashMap<>();
+ Map mainTeacherOfLesson = new HashMap<>();
+ List allLessonIds = ctx.lessons.stream().map(SSKCB::getBh).distinct().toList();
+ if (!allLessonIds.isEmpty()) {
+ for (SSKCBXYD l : sskcbxydMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCBXYD::getSskcbbh, allLessonIds))) {
+ teamsOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getXydbh());
+ }
+ for (SSKCBJS l : sskcbjsMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCBJS::getSskcbbh, allLessonIds))) {
+ roomsOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getJsbh());
+ }
+ for (SSKCBFZJY l : sskcbfzjyMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCBFZJY::getSskcbbh, allLessonIds))) {
+ teachersOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getFzjybh());
+ if (l.getZjy() != null && l.getZjy() == 1) {
+ mainTeacherOfLesson.put(l.getSskcbbh(), l.getFzjybh());
+ } else {
+ mainTeacherOfLesson.putIfAbsent(l.getSskcbbh(), l.getFzjybh());
+ }
+ }
+ }
+ // 场地历 / 教员历 不可用(按 date#jc 聚合,供冲突显示选项分类着色)
+ Map> blockedRoomsByCell = new HashMap<>();
+ for (JXCDL row : classRoomCalendarMapper.selectList(new LambdaQueryWrapper()
+ .eq(JXCDL::getKpk, false).eq(JXCDL::getDelFlag, 0))) {
+ if (row.getRq() == null || row.getJsbh() == null) continue;
+ blockedRoomsByCell.computeIfAbsent(row.getRq().toLocalDate() + "#" + row.getJc(), k -> new ArrayList<>())
+ .add(row.getJsbh());
+ }
+ Map> blockedTeachersByCell = new HashMap<>();
+ for (JYL row : jylMapper.selectList(new LambdaQueryWrapper().eq(JYL::getKpk, 0))) {
+ if (row.getRq() == null || row.getJybh() == null) continue;
+ blockedTeachersByCell.computeIfAbsent(row.getRq().toLocalDate() + "#" + row.getJc(), k -> new ArrayList<>())
+ .add(row.getJybh());
}
LocalDate weekStart = kxrq.with(DayOfWeek.MONDAY);
int weekNo = 1;
- int[] periodCount = {8};
+ int[] periodCount = {resolvePeriodCount()};
+ vo.setPeriods(resolvePeriods());
while (!weekStart.isAfter(jsrq)) {
SchedulingViewVO.Week week = new SchedulingViewVO.Week();
week.setWeekNo(weekNo);
@@ -238,23 +337,41 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
String key = date.toString() + "#" + jc;
if (schoolHard.contains(key) || classHard.contains(key)) {
cell.setUnavailable(true);
- cell.setReason("不可排课(" + (schoolHard.contains(key) ? "校历" : "班历") + ")");
+ cell.setCalendarKind(schoolHard.contains(key) ? "SCHOOL" : "CLASS");
+ cell.setReason("不可排课(" + ("SCHOOL".equals(cell.getCalendarKind()) ? "校历" : "班历") + ")");
} else {
if (schoolSoft.contains(key) || classSoft.contains(key)) {
cell.setWarning("非正课时段");
+ cell.setSoft(true);
}
cell.setUnavailable(false);
}
+ cell.setBlockedRooms(blockedRoomsByCell.getOrDefault(key, new ArrayList<>()));
+ cell.setBlockedTeachers(blockedTeachersByCell.getOrDefault(key, new ArrayList<>()));
for (SSKCB lesson : lessonsByGrid.getOrDefault(key, List.of())) {
SchedulingViewVO.Lesson l = new SchedulingViewVO.Lesson();
l.setBh(lesson.getBh());
l.setSskcbh(lesson.getSskcbh());
l.setKcmc(courseNames.getOrDefault(lesson.getSskcbh(), "-"));
l.setJxnr(lesson.getJxnr());
+ l.setJxyd(lesson.getJxyd());
l.setJxff(lesson.getJxff());
l.setJcdj(lesson.getJcdj());
l.setYcxx(lesson.getYcxx());
+ l.setJxbzbz(lesson.getJxbzbz());
+ l.setBzms(lesson.getBzms());
l.setJyxm(courseTeachers.getOrDefault(lesson.getSskcbh(), "-"));
+ l.setJybh(mainTeacherOfLesson.get(lesson.getBh()));
+ if (l.getJybh() != null) {
+ l.setJyxm(resolveTeacherName(l.getJybh()));
+ }
+ l.setXydbhs(teamsOfLesson.getOrDefault(lesson.getBh(), new ArrayList<>()));
+ l.setJsbhs(roomsOfLesson.getOrDefault(lesson.getBh(), new ArrayList<>()));
+ l.setJybhs(teachersOfLesson.getOrDefault(lesson.getBh(), new ArrayList<>()));
+ if (!l.getJsbhs().isEmpty()) {
+ l.setJsbh(l.getJsbhs().get(0));
+ l.setJsmc(resolveRoomName(l.getJsbhs().get(0)));
+ }
cell.getLessons().add(l);
}
day.getCells().add(cell);
@@ -268,6 +385,34 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
vo.setTotalWeeks(weekNo - 1);
}
+ /** 节次字典:节次时间表展开为 1..N,供前端显示控制 */
+ private List resolvePeriods() {
+ List periods = new ArrayList<>();
+ try {
+ for (JCSJB row : jcsjbMapper.selectList(new LambdaQueryWrapper())) {
+ String label = row.getJc() != null && !row.getJc().isEmpty() ? row.getJc() : row.getJcsy();
+ for (Integer jc : PeriodUtil.parsePeriods(row.getJcsy())) {
+ if (periods.stream().anyMatch(p -> p.getJc().equals(jc))) continue;
+ SchedulingViewVO.Period p = new SchedulingViewVO.Period();
+ p.setJc(jc);
+ p.setLabel(label);
+ p.setSd(row.getSd());
+ periods.add(p);
+ }
+ }
+ } catch (Exception e) {
+ log.debug("[排课窗] 读取节次字典失败,退化为默认 1-8: {}", e.getMessage());
+ }
+ periods.sort(Comparator.comparing(SchedulingViewVO.Period::getJc));
+ return periods;
+ }
+
+ /** 最大节次数:优先取节次时间表,缺省 8 */
+ private int resolvePeriodCount() {
+ List periods = resolvePeriods();
+ return periods.isEmpty() ? 8 : periods.get(periods.size() - 1).getJc();
+ }
+
// ==================== 安排所选节次 ====================
@Override
@@ -281,11 +426,51 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
if (!canEdit(course, ctx)) {
throw new ServiceException("没有该课程的排课权限", FORBIDDEN);
}
- String jsbh = request.getJsbh() != null && !request.getJsbh().isEmpty() ? request.getJsbh() : course.getJsbh();
- if (jsbh == null || jsbh.isEmpty()) {
+ // 教学场地(支持多场地):jsbhs > jsbh > 课程默认场地
+ List rooms = new ArrayList<>();
+ if (request.getJsbhs() != null) {
+ request.getJsbhs().stream().filter(s -> s != null && !s.isEmpty()).distinct().forEach(rooms::add);
+ }
+ if (rooms.isEmpty() && request.getJsbh() != null && !request.getJsbh().isEmpty()) {
+ rooms.add(request.getJsbh());
+ }
+ if (rooms.isEmpty() && course.getJsbh() != null && !course.getJsbh().isEmpty()) {
+ rooms.add(course.getJsbh());
+ }
+ if (rooms.isEmpty()) {
throw new ServiceException("请指定场地(课程未设置默认场地)", BAD_REQUEST);
}
+ String jsbh = rooms.get(0);
String jybh = request.getJybh() != null && !request.getJybh().isEmpty() ? request.getJybh() : course.getJybh();
+ List fzjybhs = request.getFzjybhs() == null ? List.of()
+ : request.getFzjybhs().stream().filter(s -> s != null && !s.isEmpty()).distinct().toList();
+ // 教学保障:数量不得超过资源库总量
+ List supportRows = new ArrayList<>();
+ if (request.getSupports() != null) {
+ for (SchedulingArrangeRequest.Support s : request.getSupports()) {
+ if (s == null || s.getBztgmxbh() == null || s.getBztgmxbh().isEmpty()) continue;
+ if (s.getSl() == null || s.getSl() <= 0) {
+ throw new ServiceException("教学保障需求数量必须大于 0", BAD_REQUEST);
+ }
+ BZTGMX res = bztgmxMapper.selectById(s.getBztgmxbh());
+ if (res == null) {
+ throw new ServiceException("教学保障资源不存在:" + s.getBztgmxbh(), BAD_REQUEST);
+ }
+ if (res.getSl() != null && s.getSl() > res.getSl()) {
+ throw new ServiceException("教学保障「" + res.getMc() + "」需求数量 " + s.getSl()
+ + " 超过总量 " + res.getSl() + (res.getDw() == null ? "" : res.getDw()), BAD_REQUEST);
+ }
+ KCBBZMX row = new KCBBZMX();
+ row.setBztgmxbh(s.getBztgmxbh());
+ row.setSl(s.getSl());
+ row.setBz(s.getBz());
+ row.setNd(ctx.ndCode);
+ supportRows.add(row);
+ }
+ }
+ // 教学班次(可临时增删):为空用课程的全部合班班次
+ List xydbhs = request.getXydbhs() == null ? List.of()
+ : request.getXydbhs().stream().filter(s -> s != null && !s.isEmpty()).distinct().toList();
// 硬冲突判定所需数据
List semesterLessons = ctx.lessons;
@@ -351,9 +536,9 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
ignored.add(ignoredCell(cell, "班历不可排课"));
continue;
}
- String roomKey = key + "#" + jsbh;
- if (roomBlocked.containsKey(roomKey)) {
- ignored.add(ignoredCell(cell, "场地历不可用"));
+ String blockedRoom = rooms.stream().filter(r -> roomBlocked.containsKey(key + "#" + r)).findFirst().orElse(null);
+ if (blockedRoom != null) {
+ ignored.add(ignoredCell(cell, "场地历不可用(教室 " + blockedRoom + ")"));
continue;
}
if (jybh != null && teacherBlocked.containsKey(key + "#" + jybh)) {
@@ -375,7 +560,7 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
busy = true; busyReason = "班次该节已有其它课程";
break;
}
- if (roomsOfLesson.getOrDefault(occupant.getBh(), List.of()).contains(jsbh)) {
+ if (roomsOfLesson.getOrDefault(occupant.getBh(), List.of()).stream().anyMatch(rooms::contains)) {
busy = true; busyReason = "教室该节已被占用";
break;
}
@@ -396,7 +581,7 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
}
if (!ownOccupied) {
SSKCB lesson = new SSKCB();
- lesson.setBh(UuidUtil.getUUID());
+ lesson.setBh(UuidUtil.getOriginalUUID());
lesson.setSskcbh(course.getBh());
lesson.setJhsjap(0);
lesson.setSjsjap(0);
@@ -415,39 +600,92 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
lesson.setCjsj(now);
lesson.setBdsj(now);
sskcbMapper.insert(lesson);
- // 学员队 / 教室 / 主讲教员
- for (SSKCXYD link : ctx.links) {
- if (!course.getBh().equals(link.getSskcbh())) continue;
- SSKCBXYD lxyd = new SSKCBXYD();
- lxyd.setBh(UuidUtil.getUUID());
- lxyd.setSskcbbh(lesson.getBh());
- lxyd.setXydbh(link.getXydbh());
- lxyd.setRs(link.getRs());
- lxyd.setJc(link.getJc());
- lxyd.setNd(ctx.ndCode);
- lxyd.setRq(lesson.getRq());
- lxyd.setJc2(cell.getJc());
- sskcbxydMapper.insert(lxyd);
+ // 学员队(教学班次,可临时增删)
+ if (xydbhs.isEmpty()) {
+ for (SSKCXYD link : ctx.links) {
+ if (!course.getBh().equals(link.getSskcbh())) continue;
+ SSKCBXYD lxyd = new SSKCBXYD();
+ lxyd.setBh(UuidUtil.getOriginalUUID());
+ lxyd.setSskcbbh(lesson.getBh());
+ lxyd.setXydbh(link.getXydbh());
+ lxyd.setRs(link.getRs());
+ lxyd.setJc(link.getJc());
+ lxyd.setNd(ctx.ndCode);
+ lxyd.setRq(lesson.getRq());
+ lxyd.setJc2(cell.getJc());
+ sskcbxydMapper.insert(lxyd);
+ }
+ } else {
+ for (String xydbh : xydbhs) {
+ SSKCXYD link = ctx.links.stream()
+ .filter(k -> course.getBh().equals(k.getSskcbh()) && xydbh.equals(k.getXydbh()))
+ .findFirst().orElse(null);
+ XYDB team = xydbMapper.selectById(xydbh);
+ SSKCBXYD lxyd = new SSKCBXYD();
+ lxyd.setBh(UuidUtil.getOriginalUUID());
+ lxyd.setSskcbbh(lesson.getBh());
+ lxyd.setXydbh(xydbh);
+ lxyd.setRs(link != null ? link.getRs() : (team != null ? team.getXydrs() : null));
+ lxyd.setJc(link != null ? link.getJc() : (team != null ? team.getJc() : null));
+ lxyd.setNd(ctx.ndCode);
+ lxyd.setRq(lesson.getRq());
+ lxyd.setJc2(cell.getJc());
+ sskcbxydMapper.insert(lxyd);
+ }
}
- SSKCBJS ljs = new SSKCBJS();
- ljs.setBh(UuidUtil.getUUID());
- ljs.setSskcbbh(lesson.getBh());
- ljs.setJsbh(jsbh);
- ljs.setNd(ctx.ndCode);
- ljs.setRq(lesson.getRq());
- ljs.setJc(cell.getJc());
- sskcbjsMapper.insert(ljs);
+ // 教学场地(可多场地)
+ for (String room : rooms) {
+ SSKCBJS ljs = new SSKCBJS();
+ ljs.setBh(UuidUtil.getOriginalUUID());
+ ljs.setSskcbbh(lesson.getBh());
+ ljs.setJsbh(room);
+ ljs.setNd(ctx.ndCode);
+ ljs.setRq(lesson.getRq());
+ ljs.setJc(cell.getJc());
+ sskcbjsMapper.insert(ljs);
+ }
+ // 授课教员:主讲 zjy=1 + 辅讲 zjy=0
if (jybh != null) {
SSKCBFZJY lfz = new SSKCBFZJY();
- lfz.setBh(UuidUtil.getUUID());
+ lfz.setBh(UuidUtil.getOriginalUUID());
lfz.setSskcbbh(lesson.getBh());
lfz.setFzjybh(jybh);
lfz.setZjy(1);
lfz.setNd(ctx.ndCode);
+ // 参与学员测评 / 学员测评平均分 为非空列:新排课次默认参与、分数待评
+ lfz.setCyxycp(1);
+ lfz.setXycppjf(0f);
lfz.setRq(lesson.getRq());
lfz.setJc(cell.getJc());
sskcbfzjyMapper.insert(lfz);
}
+ for (String fzjybh : fzjybhs) {
+ if (fzjybh.equals(jybh)) continue;
+ SSKCBFZJY lfz = new SSKCBFZJY();
+ lfz.setBh(UuidUtil.getOriginalUUID());
+ lfz.setSskcbbh(lesson.getBh());
+ lfz.setFzjybh(fzjybh);
+ lfz.setZjy(0);
+ lfz.setNd(ctx.ndCode);
+ lfz.setCyxycp(1);
+ lfz.setXycppjf(0f);
+ lfz.setRq(lesson.getRq());
+ lfz.setJc(cell.getJc());
+ sskcbfzjyMapper.insert(lfz);
+ }
+ // 教学保障明细
+ for (KCBBZMX template : supportRows) {
+ KCBBZMX row = new KCBBZMX();
+ row.setBh(UuidUtil.getOriginalUUID());
+ row.setKcbbh(lesson.getBh());
+ row.setBztgmxbh(template.getBztgmxbh());
+ row.setSl(template.getSl());
+ row.setBz(template.getBz());
+ row.setNd(template.getNd());
+ row.setRq(lesson.getRq());
+ row.setJc(cell.getJc());
+ kcbbzMapper.insert(row);
+ }
arranged++;
logOperation(course, "安排", "安排 " + cell.getRq() + " 第" + cell.getJc() + "节"
+ (ownOccupied ? "(覆盖原课次)" : ""),
@@ -551,7 +789,8 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
}
sskcxydMapper.delete(new LambdaQueryWrapper()
.eq(SSKCXYD::getSskcbh, course.getBh()));
- sskcMapper.deleteById(course.getBh());
+ // 实施_课程.编号为 VARBINARY GUID,deleteById 会报 Invalid hexadecimal digits,须走 RAWTOHEX
+ sskcMapper.deleteByBhs(java.util.List.of(course.getBh()));
logOperation(course, "删除", "彻底删除运行课程", null);
return 1;
}
@@ -570,11 +809,160 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
m.put("czlx", r.getCzlx());
m.put("cznr", r.getCznr());
m.put("czrbh", r.getCzrbh());
+ m.put("czrmc", resolveOperatorName(r.getCzrbh()));
m.put("czsj", r.getCzsj());
return m;
}).collect(Collectors.toList());
}
+ /** 操作人显示名:按登录名取用户昵称,取不到则回显登录名 */
+ private String resolveOperatorName(String username) {
+ if (username == null || username.isEmpty()) return "-";
+ try {
+ SysUser user = userService.selectUserByUserName(username);
+ if (user != null && user.getNickName() != null) {
+ return user.getNickName();
+ }
+ } catch (Exception ignore) {
+ // 取不到昵称时回显登录名
+ }
+ return username;
+ }
+
+ // ==================== 编辑课程教学任务信息(手册 12.3.1) ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map updateCourse(SchedulingCourseEditRequest request) {
+ if (request == null || request.getSskcbh() == null || request.getSskcbh().isEmpty()) {
+ throw new ServiceException("请指定运行课程", BAD_REQUEST);
+ }
+ SSKC course = requireCourse(request.getSskcbh());
+ Ctx ctx = loadCtxForCourse(course);
+ if (!canEdit(course, ctx)) {
+ throw new ServiceException("没有该课程的信息修改权限", FORBIDDEN);
+ }
+ boolean changed = false;
+ Integer newXs = null;
+ Integer newZks = null;
+ String newJy = null;
+ String newJs = null;
+ if (request.getXs() != null && !request.getXs().equals(course.getXs())) {
+ newXs = request.getXs(); changed = true;
+ }
+ if (request.getZks() != null && !request.getZks().equals(course.getZks())) {
+ newZks = request.getZks(); changed = true;
+ }
+ if (request.getJybh() != null && !request.getJybh().isEmpty() && !request.getJybh().equals(course.getJybh())) {
+ newJy = request.getJybh(); changed = true;
+ }
+ if (request.getJsbh() != null && !request.getJsbh().isEmpty() && !request.getJsbh().equals(course.getJsbh())) {
+ newJs = request.getJsbh(); changed = true;
+ }
+ if (changed) {
+ // 实施_课程.编号为 VARBINARY GUID,updateById 会对主键绑字符串而报 Invalid hexadecimal digits
+ sskcMapper.updateBasicByBh(course.getBh(), newXs, newZks, newJy, newJs);
+ }
+ // 课程简称回写编制侧任务表(同一班次学期 + 同一课程编号)
+ if (request.getJc() != null && ctx.semester != null) {
+ for (XYDRWB task : studentTeamTaskMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDRWB::getXydxqbh, ctx.semester.getBh())
+ .eq(XYDRWB::getKbh, course.getKmbh())
+ .eq(XYDRWB::getDelFlag, 0))) {
+ task.setJc(request.getJc());
+ task.setBdsj(LocalDateTime.now());
+ studentTeamTaskMapper.updateById(task);
+ changed = true;
+ }
+ }
+ if (changed) {
+ logOperation(course, "修改", "编辑课程教学任务信息(学时=" + (newXs != null ? newXs : course.getXs())
+ + ",周课时=" + (newZks != null ? newZks : course.getZks())
+ + ",简称=" + request.getJc() + ")", null);
+ }
+ Map result = new HashMap<>();
+ result.put("updated", changed);
+ return result;
+ }
+
+ // ==================== 教学班次(合班)与候选班次(手册 12.3.2.2) ====================
+
+ @Override
+ public Map courseTeams(String sskcbh) {
+ SSKC course = requireCourse(sskcbh);
+ Ctx ctx = loadCtxForCourse(course);
+ Map result = new HashMap<>();
+ // 当前教学班次:该运行课程关联的学员队
+ List> current = new ArrayList<>();
+ for (SSKCXYD link : ctx.links) {
+ if (!course.getBh().equals(link.getSskcbh())) continue;
+ Map m = new HashMap<>();
+ m.put("xydbh", link.getXydbh());
+ m.put("xydmc", resolveTeamName(link.getXydbh()));
+ m.put("rs", link.getRs());
+ current.add(m);
+ }
+ // 候选班次:同学期开设同一课程编号的其它班次学期
+ List> candidates = new ArrayList<>();
+ java.util.LinkedHashSet seen = new java.util.LinkedHashSet<>();
+ for (XYDRWB task : studentTeamTaskMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDRWB::getKbh, course.getKmbh())
+ .eq(XYDRWB::getDelFlag, 0))) {
+ if (Objects.equals(task.getXydxqbh(), ctx.semester == null ? null : ctx.semester.getBh())) continue;
+ if (task.getXydxqbh() == null || !seen.add(task.getXydxqbh())) continue;
+ XYDNDXQJBXXB semester = classSemesterMapper.selectById(task.getXydxqbh());
+ if (semester == null) continue;
+ Map m = new HashMap<>();
+ m.put("xydbh", task.getXydbh());
+ m.put("xydxqbh", task.getXydxqbh());
+ m.put("xydmc", resolveTeamName(task.getXydbh()));
+ m.put("nd", semester.getNd());
+ m.put("xqdc", semester.getXqdc());
+ candidates.add(m);
+ }
+ result.put("current", current);
+ result.put("candidates", candidates);
+ return result;
+ }
+
+ // ==================== 教学保障选项(手册 12.3.2.5) ====================
+
+ @Override
+ public Map supportOptions() {
+ Map result = new HashMap<>();
+ List> categories = new ArrayList<>();
+ try {
+ for (BZLB lb : bzlbMapper.selectList(new LambdaQueryWrapper())) {
+ if (lb.getTy() != null && lb.getTy() != 0) continue;
+ Map m = new HashMap<>();
+ m.put("bh", lb.getBh());
+ m.put("mc", lb.getMc());
+ categories.add(m);
+ }
+ } catch (Exception e) {
+ log.debug("[排课窗] 读取保障类别失败: {}", e.getMessage());
+ }
+ List> resources = new ArrayList<>();
+ try {
+ for (BZTGMX mx : bztgmxMapper.selectList(new LambdaQueryWrapper())) {
+ if (mx.getTy() != null && mx.getTy() != 0) continue;
+ Map m = new HashMap<>();
+ m.put("bh", mx.getBh());
+ m.put("mc", mx.getMc());
+ m.put("dw", mx.getDw());
+ m.put("total", mx.getSl());
+ m.put("bzlbbh", mx.getBzlbbh());
+ m.put("zylb", mx.getZylb());
+ resources.add(m);
+ }
+ } catch (Exception e) {
+ log.debug("[排课窗] 读取保障资源失败: {}", e.getMessage());
+ }
+ result.put("categories", categories);
+ result.put("resources", resources);
+ return result;
+ }
+
// ==================== 公共 ====================
/** 上下文:班次学期 + 学员队 + 6 位代号 + 本班次已发布课程与课次 */
@@ -688,6 +1076,7 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
sskcbxydMapper.delete(new LambdaQueryWrapper().eq(SSKCBXYD::getSskcbbh, sskcbbh));
sskcbjsMapper.delete(new LambdaQueryWrapper().eq(SSKCBJS::getSskcbbh, sskcbbh));
sskcbfzjyMapper.delete(new LambdaQueryWrapper().eq(SSKCBFZJY::getSskcbbh, sskcbbh));
+ kcbbzMapper.delete(new LambdaQueryWrapper().eq(KCBBZMX::getKcbbh, sskcbbh));
}
/** 校历/班历不可排格键集合 */
@@ -757,7 +1146,8 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
private void logOperation(SSKC course, String czlx, String cznr, SchedulingArrangeRequest ignored) {
try {
KCJXYX_CZRZ row = new KCJXYX_CZRZ();
- row.setBh(UuidUtil.getUUID());
+ // 课程教学运行_操作日志.编号 为 CHAR(36),按项目约定存带横线 36 位 UUID
+ row.setBh(UuidUtil.getOriginalUUID());
row.setTybh(course.getBh());
row.setCzlx(czlx);
row.setCznr(cznr);
@@ -779,6 +1169,39 @@ public class SchedulingWindowServiceImpl implements SchedulingWindowService {
return kb != null && kb.getKmc() != null ? kb.getKmc() : kbh;
}
+ /** 教员姓名(失败返回编号) */
+ private String resolveTeacherName(String jybh) {
+ if (jybh == null || jybh.isEmpty()) return null;
+ try {
+ JYB jyb = jybMapper.selectById(jybh);
+ return jyb != null && jyb.getJyxm() != null ? jyb.getJyxm() : jybh;
+ } catch (Exception e) {
+ return jybh;
+ }
+ }
+
+ /** 教室名称(失败返回编号) */
+ private String resolveRoomName(String jsbh) {
+ if (jsbh == null || jsbh.isEmpty()) return null;
+ try {
+ List rooms = classRoomMapper.selectList(new LambdaQueryWrapper().eq(JSB::getJsbh, jsbh));
+ return rooms.isEmpty() ? jsbh : (rooms.get(0).getJsmc() != null ? rooms.get(0).getJsmc() : jsbh);
+ } catch (Exception e) {
+ return jsbh;
+ }
+ }
+
+ /** 学员队名称(失败返回编号) */
+ private String resolveTeamName(String xydbh) {
+ if (xydbh == null || xydbh.isEmpty()) return null;
+ try {
+ XYDB team = xydbMapper.selectById(xydbh);
+ return team != null && team.getXydmc() != null ? team.getXydmc() : xydbh;
+ } catch (Exception e) {
+ return xydbh;
+ }
+ }
+
private static String intDate(Integer d) {
String s = String.valueOf(d);
return s.length() == 8 ? s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8) : s;
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/StudentTeamTaskServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/StudentTeamTaskServiceImpl.java
index 4227b6f54..2ec4b34e0 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/StudentTeamTaskServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/StudentTeamTaskServiceImpl.java
@@ -595,8 +595,11 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
if (row.getKbh() == null || row.getXydbh() == null) {
return false;
}
- List courses = sskcMapper.selectList(new LambdaQueryWrapper().eq(SSKC::getKmbh, row.getKbh()));
+ // 注意:实施_课程 为中文列名 + VARBINARY 主键,selectList 会整行返回 null 元素,
+ // 这里必须走显式 XML(否则下面的 course.getBh() 会 NPE)。
+ List courses = sskcMapper.selectByKmbh(row.getKbh());
for (SSKC course : courses) {
+ if (course == null) continue;
Long linked = sskcxydMapper.selectCount(new LambdaQueryWrapper()
.eq(SSKCXYD::getSskcbh, course.getBh())
.eq(SSKCXYD::getXydbh, row.getXydbh()));
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TaskBookFillServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TaskBookFillServiceImpl.java
index a5f80e563..5577dd37c 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TaskBookFillServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TaskBookFillServiceImpl.java
@@ -8,10 +8,12 @@ import com.roomroot.jwgl.dto.taskbook.TaskBookRoomRequest;
import com.roomroot.jwgl.dto.taskbook.TaskBookTeacherRequest;
import com.roomroot.jwgl.entity.JYB;
import com.roomroot.jwgl.entity.JXRW;
+import com.roomroot.jwgl.entity.SSKCXYD;
import com.roomroot.jwgl.entity.XYDNDXQJBXXB;
import com.roomroot.jwgl.entity.XYDRWB;
import com.roomroot.jwgl.mapper.ClassSemesterMapper;
import com.roomroot.jwgl.mapper.JYBMapper;
+import com.roomroot.jwgl.mapper.SSKCXYDMapper;
import com.roomroot.jwgl.mapper.StudentTeamTaskMapper;
import com.roomroot.jwgl.mapper.TeachingTaskMapper;
import com.roomroot.jwgl.service.TaskBookFillService;
@@ -25,10 +27,12 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import static com.roomroot.jwgl.utils.BasicManagementConstants.BAD_REQUEST;
import static com.roomroot.jwgl.utils.BasicManagementConstants.CONFLICT;
@@ -55,6 +59,9 @@ public class TaskBookFillServiceImpl implements TaskBookFillService {
@Resource
private TeachingTaskWriteGuard guard;
+ @Resource
+ private SSKCXYDMapper sskcxydMapper;
+
// ==================== 列表 ====================
@Override
@@ -191,7 +198,8 @@ public class TaskBookFillServiceImpl implements TaskBookFillService {
public int split(TaskBookBhListRequest request) {
List rows = loadRows(request);
assertAllFillable(rows);
- // TODO(阶段 5):行已发布到运行课表的须先撤回,再拆班。阶段 5 落地后在此校验。
+ // 阶段 5 已落地:行所属班次学期若已发布到运行课表,合班组已被固化成运行课程,必须先撤回。
+ assertNotPublishedToRunning(rows);
LocalDateTime now = LocalDateTime.now();
int count = 0;
for (XYDRWB row : rows) {
@@ -375,4 +383,33 @@ public class TaskBookFillServiceImpl implements TaskBookFillService {
assertFillable(row);
}
}
+
+ /**
+ * 阶段 5 反向门禁:拆班前确认行所在班次学期尚未发布到运行课表。
+ *
+ * 判定信号与 {@code RunningCoursePublishServiceImpl#publish} 的幂等门禁同源:
+ * 「实施_课程学员队」按 (学员队编号, 年度=6 位学期代号) 命中即说明该班次学期已整批发布。
+ * 发布后合班组已固化为运行课程的 实施_课程.编号,拆班会让任务书与运行课表不一致。
+ *
+ * 不用「实施_课程表.教学保障备注」或「课表变动时间」判定:前者由 logOperation 只更新自己
+ * 那门课,后者发布时不写,两者都不可靠。
+ */
+ private void assertNotPublishedToRunning(List rows) {
+ Set checked = new HashSet<>();
+ for (XYDRWB row : rows) {
+ String xydxqbh = row.getXydxqbh();
+ if (xydxqbh == null || xydxqbh.isEmpty() || !checked.add(xydxqbh)) {
+ continue;
+ }
+ XYDNDXQJBXXB semester = requireSemester(xydxqbh);
+ Integer ndCode = SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc());
+ Long published = sskcxydMapper.selectCount(new LambdaQueryWrapper()
+ .eq(SSKCXYD::getXydbh, semester.getXydbh())
+ .eq(SSKCXYD::getNd, ndCode));
+ if (published != null && published > 0) {
+ throw new ServiceException("班次学期 " + semester.getXydbh() + "(" + ndCode + ")已发布 "
+ + published + " 条运行课程,请先在运行课表撤回后再拆班", CONFLICT);
+ }
+ }
+ }
}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TimetableConflictServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TimetableConflictServiceImpl.java
index 8a8e01ab5..259ea5ef1 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TimetableConflictServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TimetableConflictServiceImpl.java
@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.roomroot.jwgl.entity.JQB;
import com.roomroot.jwgl.entity.JXCDL;
+import com.roomroot.jwgl.entity.JYL;
import com.roomroot.jwgl.entity.SSKC;
import com.roomroot.jwgl.entity.SSKCB;
+import com.roomroot.jwgl.entity.SSKCBFZJY;
import com.roomroot.jwgl.entity.SSKCXYD;
import com.roomroot.jwgl.entity.TimetableConflictResult;
import com.roomroot.jwgl.entity.XYDB;
@@ -14,6 +16,7 @@ import com.roomroot.jwgl.entity.XQXLB;
import com.roomroot.jwgl.mapper.ClassRoomCalendarMapper;
import com.roomroot.jwgl.mapper.ClassSemesterMapper;
import com.roomroot.jwgl.mapper.JQBMapper;
+import com.roomroot.jwgl.mapper.JYLMapper;
import com.roomroot.jwgl.mapper.KBMapper;
import com.roomroot.jwgl.mapper.SSKCBFZJYMapper;
import com.roomroot.jwgl.mapper.SSKCBJSMapper;
@@ -96,6 +99,9 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
@Resource
private ClassRoomCalendarMapper classRoomCalendarMapper;
+ @Resource
+ private JYLMapper jylMapper;
+
@Resource
private SSKCBMapper sskcbMapper;
@@ -258,7 +264,13 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
details = switch (dimension) {
case TEAM_CONFLICT -> sskcbxydMapper.selectTeamConflictDetails(nd);
case ELECTIVE_REQUIRED_CONFLICT -> sskcbxydMapper.selectElectiveRequiredConflictDetails(nd);
- case TEACHER_CONFLICT -> sskcbfzjyMapper.selectTeacherConflictDetails(nd);
+ case TEACHER_CONFLICT -> {
+ // 口径统一(与排课窗口硬冲突一致):教员时间冲突 = 教员双占 + 教员历不可排
+ List teacherDup =
+ new ArrayList<>(sskcbfzjyMapper.selectTeacherConflictDetails(nd));
+ teacherDup.addAll(checkTeacherCalendarConflicts(nd));
+ yield teacherDup;
+ }
case CLASSROOM_CONFLICT -> {
// 阶段 5.1:教室重复占用 + 场地历不可用
List dup = sskcbjsMapper.selectClassroomConflictDetails(nd);
@@ -626,17 +638,92 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
return result;
}
- /** 构建 不可排课格 类冲突明细 */
+ /**
+ * TEACHER_CONFLICT 追加:教员历 标记不可排课(可排课=0)但该教员该格已有课次。
+ *
+ * 与 {@link #checkClassroomCalendarConflicts} 同为"不可排课格"类检查,只是主体换成教员:
+ * 排课窗口硬冲突里含「教员历不可用」,批量检查此前缺失该维度,此处补齐以保证两套口径一致。
+ *
+ *
+ * 匹配口径与排课窗口一致:按 日期(天) + 节次 + 教员编号 三元组,不比对具体时分。
+ *
+ */
+ private List checkTeacherCalendarConflicts(Integer nd) {
+ List result = new ArrayList<>();
+ List lessons = sskcbMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCB::getNd, nd)
+ .eq(SSKCB::getSczt, 0)
+ .isNotNull(SSKCB::getRq));
+ if (lessons.isEmpty()) {
+ return result;
+ }
+ // 教员历 无"删除状态"列,只需 可排课 = 0
+ List unavailable = jylMapper.selectList(new LambdaQueryWrapper()
+ .eq(JYL::getKpk, 0)
+ .isNotNull(JYL::getRq));
+ if (unavailable.isEmpty()) {
+ return result;
+ }
+ Set lessonIds = lessons.stream().map(SSKCB::getBh).collect(Collectors.toSet());
+ // 课次 → 教员编号集合(主讲 + 辅讲统一纳入检查)
+ Map> teachersOfLesson = new HashMap<>();
+ for (SSKCBFZJY link : sskcbfzjyMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCBFZJY::getSskcbbh, lessonIds))) {
+ if (link.getFzjybh() == null) continue;
+ teachersOfLesson.computeIfAbsent(link.getSskcbbh(), k -> new ArrayList<>()).add(link.getFzjybh());
+ }
+ if (teachersOfLesson.isEmpty()) {
+ return result;
+ }
+ Map courseNames = loadCourseNames(nd);
+ // 按 教员编号#日期#节次 → 该格已有课次,便于一次聚合出明细
+ Map> lessonsByTeacherCell = new HashMap<>();
+ Map lessonByBh = lessons.stream()
+ .collect(Collectors.toMap(SSKCB::getBh, l -> l, (a, b) -> a));
+ for (Map.Entry> e : teachersOfLesson.entrySet()) {
+ SSKCB lesson = lessonByBh.get(e.getKey());
+ if (lesson == null || lesson.getRq() == null || lesson.getJc() == null) continue;
+ String dateKey = lesson.getRq().toLocalDate().toString() + "#" + lesson.getJc();
+ for (String jybh : e.getValue()) {
+ lessonsByTeacherCell.computeIfAbsent(jybh + "#" + dateKey, k -> new ArrayList<>()).add(lesson);
+ }
+ }
+ for (JYL row : unavailable) {
+ if (row.getRq() == null || row.getJybh() == null) continue;
+ String dateStr = row.getRq().toLocalDate().toString();
+ String key = row.getJybh() + "#" + dateStr + "#" + row.getJc();
+ List hit = lessonsByTeacherCell.get(key);
+ if (hit == null || hit.isEmpty()) continue;
+ TimetableConflictDetailVO d = buildEventDetail(
+ "TEACHERCAL|" + row.getJybh() + "|" + dateStr + "#" + row.getJc(),
+ safeName(row.getMc()), dateStr, row.getJc(), hit, courseNames,
+ "教员历标记该教员该时段不可排课,但已有课次安排",
+ TimetableConflictDimension.TEACHER_CONFLICT);
+ result.add(d);
+ }
+ return result;
+ }
+
+ /** 构建 不可排课格 类冲突明细(默认归属"日历冲突"维度) */
private TimetableConflictDetailVO buildEventDetail(String resourceId, String resourceName,
String dateStr, Integer jc, List lessons,
Map courseNames, String reason) {
+ return buildEventDetail(resourceId, resourceName, dateStr, jc, lessons, courseNames, reason,
+ TimetableConflictDimension.EVENT_CONFLICT);
+ }
+
+ /** 构建 不可排课格 类冲突明细(可指定归属维度,使明细归属与其所在卡片一致) */
+ private TimetableConflictDetailVO buildEventDetail(String resourceId, String resourceName,
+ String dateStr, Integer jc, List lessons,
+ Map courseNames, String reason,
+ TimetableConflictDimension dimension) {
String courses = lessons.stream()
.map(l -> courseNames.getOrDefault(l.getSskcbh(), l.getSskcbh()))
.distinct()
.collect(Collectors.joining(";"));
return TimetableConflictDetailVO.builder()
- .dimensionCode("EVENT_CONFLICT")
- .dimensionTitle("日历冲突")
+ .dimensionCode(dimension.getCode())
+ .dimensionTitle(dimension.getTitle())
.resourceId(resourceId)
.resourceName(resourceName)
.rq(LocalDate.parse(dateStr).atStartOfDay())
@@ -652,13 +739,27 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
/** 实施_课程编号 → 课程名称(经 实施_课程.科目编号 → 课表.课名称) */
private Map loadCourseNames(Integer nd) {
Map names = new HashMap<>();
- List courses = sskcMapper.selectList(new LambdaQueryWrapper().eq(SSKC::getNd, nd));
- if (courses.isEmpty()) return names;
- Set kbhs = courses.stream().map(SSKC::getKmbh).collect(Collectors.toSet());
- Map kbhNames = kbMapper.selectBatchIds(kbhs).stream()
- .collect(Collectors.toMap(com.roomroot.jwgl.entity.KB::getKbh, k -> safeName(k.getKmc()), (a, b) -> a));
+ // 必须走显式 XML:selectList 在本表(中文列名 + VARBINARY 主键)会整行返回 null 元素
+ List courses = sskcMapper.selectByNd(nd);
+ if (courses == null || courses.isEmpty()) {
+ return names;
+ }
+ Set kbhs = courses.stream()
+ .filter(Objects::nonNull)
+ .map(SSKC::getKmbh)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ Map kbhNames = new HashMap<>();
+ if (!kbhs.isEmpty()) {
+ for (com.roomroot.jwgl.entity.KB k : kbMapper.selectBatchIds(kbhs)) {
+ if (k == null || k.getKbh() == null) continue;
+ kbhNames.put(k.getKbh(), safeName(k.getKmc()));
+ }
+ }
for (SSKC c : courses) {
- names.put(c.getBh(), kbhNames.getOrDefault(c.getKmbh(), c.getKmbh()));
+ if (c == null) continue;
+ names.put(c.getBh(), c.getKmbh() == null ? safeName(c.getBh())
+ : kbhNames.getOrDefault(c.getKmbh(), safeName(c.getKmbh())));
}
return names;
}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/unit/conflict/TimetableConflictDimension.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/unit/conflict/TimetableConflictDimension.java
index 163dda988..ea8fb5432 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/unit/conflict/TimetableConflictDimension.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/unit/conflict/TimetableConflictDimension.java
@@ -52,33 +52,48 @@ public enum TimetableConflictDimension {
/**
* 教员时间冲突检查。
- * 检查逻辑:同一教员(主讲+辅讲统一在此维度)在同一日期同一节次被安排多门课程 → 冲突
+ * 检查逻辑(两部分,均为硬冲突):
+ *
+ * - 同一教员(主讲+辅讲统一在此维度)在同一日期同一节次被安排多门课程 → 冲突
+ * - 教员历(教员工作时间表)标记"可排课=0"的日期节次上,该教员仍有已排课次 → 冲突
+ *
+ *
* 涉及数据表:
*
* - 实施_课程表 SSKCB —— 主表,日期/节次/年度/删除状态
* - 实施_课程表_辅助教员 SSKCBFZJY —— 课次与教员的关联表(含主讲ZJY=1 与辅讲ZJY=0),获取教员编号(FZJYBH)
+ * - 教员历 JYL —— 获取教员"可排课"标记(可排课=0 表示不可排)
* - 教员表 JYB / 教研室人员 JYSX —— 翻译中文姓名展示
*
*
+ * 口径说明:排课窗口的硬冲突包含「教员历不可用」,为此本维度同样纳入教员历检查,
+ * 使批量冲突检查与排课窗口判断一致。实现方式与「场地历不可用」并入
+ * {@link #CLASSROOM_CONFLICT} 保持一致,不再单开卡片,避免同一事实被重复计数。
*/
TEACHER_CONFLICT("TEACHER_CONFLICT",
"教员时间冲突检查",
- "同一教员在同一时间段被安排多门课程"),
+ "同一教员在同一时间段被安排多门课程,或教员历不可排课时段已有课程"),
/**
* 教室时间冲突检查。
- * 检查逻辑:同一教室(场地)在同一日期同一节次被多门课程占用 → 冲突
+ * 检查逻辑(两部分,均为硬冲突):
+ *
+ * - 同一教室(场地)在同一日期同一节次被多门课程占用 → 冲突
+ * - 教学场地历标记"可排课=0"的教室节次上仍有已排课次 → 冲突(场地历不可用)
+ *
+ *
* 涉及数据表:
*
* - 实施_课程表 SSKCB —— 主表,日期/节次/年度/删除状态
* - 实施_课程表_教室 SSKCBJS —— 课次与教室的关联表,获取教室编号(JSBH)
+ * - 教学场地历 JXCDL —— 获取场地"可排课"标记
* - 教室表 JSB —— 翻译中文名称(含容纳人数/设施等信息)
*
*
*/
CLASSROOM_CONFLICT("CLASSROOM_CONFLICT",
"教室时间冲突检查",
- "同一教室在同一时间段被多门课程占用"),
+ "同一教室在同一时间段被多门课程占用,或场地历不可用时段已有课程"),
/**
* 教学保障资源冲突检查(预留/按需开启)。
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/scheduling/SchedulingViewVO.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/scheduling/SchedulingViewVO.java
index d0d431f15..e9e58688e 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/scheduling/SchedulingViewVO.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/scheduling/SchedulingViewVO.java
@@ -34,11 +34,26 @@ public class SchedulingViewVO {
/** 时间区:按周切分,格子叠加校历/班历不可排与已排课次 */
private List weeks = new ArrayList<>();
+ /** 节次字典(节次时间表),供前端显示控制(7-8 节 / 晚上 / 夜间) */
+ private List periods = new ArrayList<>();
+
+ @Data
+ public static class Period {
+ /** 节次号 */
+ private Integer jc;
+ /** 节次名称/简称 */
+ private String label;
+ /** 时段:上午 / 下午 / 晚上 */
+ private String sd;
+ }
+
@Data
public static class Course {
private String sskcbh;
private String kbh;
private String kcmc;
+ /** 课程简称(任务表 简称,格内优先显示) */
+ private String jc;
private String klx;
private Integer xs;
private Integer zks;
@@ -46,12 +61,27 @@ public class SchedulingViewVO {
private String jyxm;
private String jsbh;
private String jsmc;
+ /** 排课建议(教研室任务书填报的 教研室计划备注) */
+ private String jysjhbz;
+ /** 教研室计划教员编号 / 姓名 */
+ private String jysjhjybh;
+ private String jysjhjyxm;
/** 已排学时(每节 2 学时 + 节次调节) */
private Integer scheduledHours;
/** 是否已排满(scheduledHours >= xs) */
private Boolean full;
/** 当前用户是否可编辑该课程 */
private Boolean editable;
+ /** 合班班次(⑤ 合班信息) */
+ private List teams = new ArrayList<>();
+ }
+
+ /** 班次学期(学员队 + 学期)简项 */
+ @Data
+ public static class Team {
+ private String xydxqbh;
+ private String xydbh;
+ private String xydmc;
}
@Data
@@ -78,8 +108,16 @@ public class SchedulingViewVO {
private Boolean unavailable;
/** 硬冲突原因(不可排课来源:校历/班历等) */
private String reason;
+ /** 不可排来源类别:SCHOOL 校历 / CLASS 班历 */
+ private String calendarKind;
/** 软提示(非正课 / 配当周次不符) */
private String warning;
+ /** 是否为非正课时段 */
+ private Boolean soft;
+ /** 该节次被场地历标记不可用的教室编号 */
+ private List blockedRooms = new ArrayList<>();
+ /** 该节次被教员历标记不可用的教员编号 */
+ private List blockedTeachers = new ArrayList<>();
private List lessons = new ArrayList<>();
}
@@ -89,10 +127,21 @@ public class SchedulingViewVO {
private String sskcbh;
private String kcmc;
private String jxnr;
+ private String jxyd;
private String jxff;
private Integer jcdj;
private String jyxm;
+ private String jybh;
private String jsmc;
+ private String jsbh;
+ private String jxbzbz;
+ private String bzms;
private String ycxx;
+ /** 本次课次的学员队(教学班次) */
+ private List xydbhs = new ArrayList<>();
+ /** 本次课次的教室(可多场地) */
+ private List jsbhs = new ArrayList<>();
+ /** 本次课次的授课教员(含主讲/辅讲) */
+ private List jybhs = new ArrayList<>();
}
}
diff --git a/frontend/src/api/teachBusiness/schedulingWindow.js b/frontend/src/api/teachBusiness/schedulingWindow.js
index d527c8000..766f250ed 100644
--- a/frontend/src/api/teachBusiness/schedulingWindow.js
+++ b/frontend/src/api/teachBusiness/schedulingWindow.js
@@ -58,3 +58,47 @@ export function listSchedulingLogs(sskcbh, czlx) {
params: { sskcbh, czlx }
})
}
+
+// 编辑课程教学任务信息(学时/周课时/课程简称/责任教员/默认场地)
+export function updateSchedulingCourse(data) {
+ return request({
+ url: '/schedulingWindow/course/update',
+ method: 'post',
+ data
+ })
+}
+
+// 教学班次(合班)当前列表 + 候选班次
+export function getCourseTeams(sskcbh) {
+ return request({
+ url: '/schedulingWindow/course/teams',
+ method: 'get',
+ params: { sskcbh }
+ })
+}
+
+// 教学保障选项(保障类别 + 保障资源,含总量与单位)
+export function getSupportOptions() {
+ return request({
+ url: '/schedulingWindow/support/options',
+ method: 'get'
+ })
+}
+
+// 批量发布【运行课表】(手册 12.1)
+export function publishRunningCourses(xydxqbhs) {
+ return request({
+ url: '/schedulingWindow/publish',
+ method: 'post',
+ data: { xydxqbhs }
+ })
+}
+
+// 批量删除【运行课表】(手册 12.2)
+export function withdrawRunningCourses(xydxqbhs) {
+ return request({
+ url: '/schedulingWindow/withdraw',
+ method: 'post',
+ data: { xydxqbhs }
+ })
+}
diff --git a/frontend/src/api/teachOffice/teacherCalendar.js b/frontend/src/api/teachOffice/teacherCalendar.js
new file mode 100644
index 000000000..e4c469200
--- /dev/null
+++ b/frontend/src/api/teachOffice/teacherCalendar.js
@@ -0,0 +1,32 @@
+import request from '@/utils/request'
+
+// 教员历(后端 /teacherCalendar/jyl)。
+// 教员历 = 教员自己的工作时间表:标出的时段「不可排课」。
+// 排课窗口的硬冲突「教员历不可用」与批量冲突检查的教员时间冲突都读这张表。
+
+// 按教员编号查询该教员已登记的教员历(返回全部,前端按周过滤)
+export function listTeacherCalendarByJybh(jybh) {
+ return request({
+ url: '/teacherCalendar/jyl/listByJybh',
+ method: 'get',
+ params: { jybh }
+ })
+}
+
+// 批量保存(按 教员编号+日期+节次 幂等覆盖)
+export function batchSaveTeacherCalendar(list) {
+ return request({
+ url: '/teacherCalendar/jyl/batchSave',
+ method: 'post',
+ data: list
+ })
+}
+
+// 批量删除(教员历表无 del_flag 审计列,为物理删除)
+export function batchDeleteTeacherCalendar(bhs) {
+ return request({
+ url: '/teacherCalendar/jyl/batchDelete',
+ method: 'post',
+ data: bhs
+ })
+}
diff --git a/frontend/src/views/teachBusiness/schedulingWindow/index.vue b/frontend/src/views/teachBusiness/schedulingWindow/index.vue
index 36ccdc855..eff66a325 100644
--- a/frontend/src/views/teachBusiness/schedulingWindow/index.vue
+++ b/frontend/src/views/teachBusiness/schedulingWindow/index.vue
@@ -63,6 +63,7 @@
>
{{ c.kcmc || '未命名课程' }}
+ {{ c.jc }}
已排满
待排 {{ remainHours(c) }}
只读
@@ -71,6 +72,14 @@
教员:{{ c.jyxm || '—' }}
场地:{{ c.jsmc || c.jsbh || '未设置' }}
+
+ 合班 {{ c.teams.length }} 个班次:{{ c.teams.map(t => t.xydmc).join('、') }}
+ 班次:{{ (c.teams && c.teams[0] && c.teams[0].xydmc) || view.xydmc || '—' }}
+
+
+ 教研室计划教员:{{ c.jysjhjyxm }}
+ 排课建议:{{ c.jysjhbz }}
+
{{ c.xs ? (c.scheduledHours || 0) + ' / ' + c.xs + ' 学时' : '学时未设置' }}
- 课程编号 {{ shortId(c.sskcbh) }}
+
+ 课程编号 {{ shortId(c.sskcbh) }}
+ 编辑课程信息
+
@@ -91,68 +103,161 @@
- 上一周
-
- 第 {{ currentWeek ? currentWeek.weekNo : '—' }} 周
- {{ fmtDate(currentWeek.startDate) }} ~ {{ fmtDate(currentWeek.endDate) }}
-
- 下一周
+
+ 单周视图
+ 全学期视图
+
+
+ 上一周
+
+ 第 {{ currentWeek ? currentWeek.weekNo : '—' }} 周
+ {{ fmtDate(currentWeek.startDate) }} ~ {{ fmtDate(currentWeek.endDate) }}
+
+ 下一周
+
+ 全学期 {{ weeks.length }} 周(可直接跨周框选)
可排
已选
已排课次
+ 当前课程
软提示
+ 冲突
不可排
+
+ 冲突显示
+
+ {{ d.label }}
+
+ 显示节次
+
+ 7-8 节
+ 晚上
+ 夜间
+
+ 重置
+
-
-
-
-
节次
-
-
{{ weekdayName(d.weekday) }}
-
{{ fmtDate(d.date) }}
+
+
+
+
+
+
当前格子编排信息
+
点选格子查看详情;双击已排格子可回填预排信息
+
+
+ 日期节次
+ {{ focusedCell.rq }} 第 {{ focusedCell.jc }} 节
+
+
+ 冲突
+ {{ focusedCellCell.reason }}
+
+
+ 提示
+ {{ focusedCellCell.warning }}
+
+
+
已排课次
+
+
+ {{ l.kcmc }}
+ |{{ l.jxnr }}
+ |{{ l.jxff }}
+ |{{ l.jyxm }}
+ |{{ l.jsmc }}
+ |节次调节 {{ l.jcdj > 0 ? '+' : '' }}{{ l.jcdj }}
+ |班次 {{ l.xydbhs.length }} 个
+ |保障 {{ l.jxbzbz }}
+ |用车 {{ l.ycxx }}
+
+
+
+
该格暂未排课
+
+
+
+
选择区域统计
+
框选节次后显示统计
+
+
格数{{ selectedCells.length }} 格,合计 {{ selectedHours }} 学时
+
日期范围{{ statsRangeText }}
+
+ 按周分布
+ {{ statsWeekText }}
+
+
+ 当前课程占
+ {{ pickedLessonCount }} 个课次({{ currentCourse ? (currentCourse.kcmc || '') : '未选课程' }})
+
+
+ 其它课程
+ {{ statsOthers.join(';') }}
+
+
+
+
@@ -167,6 +272,7 @@
已选
{{ selectedCells.length }} 格
清除选择
+
快捷键:S 安排 · R 删除(不在输入框时可加 Alt)· Ctrl+框选多区域 · 双击回填
删除所选节次
+
编辑课程信息
-
+
课程:{{ currentCourse ? currentCourse.kcmc : '—' }}
节次:{{ selectedCells.length }} 格
范围:{{ selectedRangeText }}
-
-
-
+
+ 排课建议:{{ currentCourse.jysjhbz }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 默认使用该课程的全部合班班次;实践教学拆分时移除其它班次,需要集中上课时可加入其它班次。
+
+
+ {{ t.xydmc || t.xydbh }}
+ {{ t.xydbh }}|{{ t.rs }} 人
+ 移除
+
+
至少保留一个教学班次
+
+
+
+
+
+ 增加教学班次
+
+
+
+
+
+
+
+
+
+
+
+
+ 增加辅讲教员
+
+
+
+
+
+
+
+
+ 默认使用课程默认场地;可加入多个场地(如理论教室 + 实践场地)。
+
+
+ {{ roomLabel(r) }}
+ {{ r }}
+ 移除
+
+
未指定时使用课程默认场地(未设置则会排课失败)
+
+
+
+
+
+ 增加教学场地
+
+
+
+
+
+
+ 保障资源尚未登记主数据,直接手动录入保障需求文字,保存到「教学保障备注」。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确认排课
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
+
+
-
-
-
-
-
-
+
+
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- 取消
- 确认排课
+ 取消
+ 保存
@@ -287,7 +523,9 @@ import {
deleteLessonCells,
clearCourseLessons,
deleteRunningCourse,
- listSchedulingLogs
+ listSchedulingLogs,
+ updateSchedulingCourse,
+ getCourseTeams
} from '@/api/teachBusiness/schedulingWindow'
import { listSemester } from '@/api/studentRecords/semester'
import { listAllClassroom } from '@/api/teachBusiness/classroom'
@@ -304,11 +542,31 @@ export default {
loading: false,
submitting: false,
currentCourse: null,
+ /** 已选格子 [{rq, jc}] */
selectedCells: [],
dragStart: null,
dragEnd: null,
dragging: false,
+ /** 本次拖拽是否为追加(Ctrl / Meta) */
+ additiveDrag: false,
+ /** 当前聚焦格(⑨ 当前格子编排信息) */
+ focusedCell: null,
+ /** 视图模式:week 单周 / all 全学期 */
+ viewMode: 'week',
+ /** 冲突显示选项(按维度着色) */
+ conflictDims: [],
+ conflictDimOptions: [
+ { key: 'ROOM', label: '教室' },
+ { key: 'TEACHER', label: '教员' },
+ { key: 'TEAM', label: '班次' },
+ { key: 'ROOM_CAL', label: '场地历' },
+ { key: 'TEACHER_CAL', label: '教员历' },
+ { key: 'CALENDAR', label: '校历/班历' }
+ ],
+ /** 节次显示控制(④ 显示控制选项) */
+ periodToggles: ['p78', 'eve', 'night'],
formVisible: false,
+ formTab: 'subject',
logsVisible: false,
logs: [],
jxffOptions: ['理论讲授', '实践操作', '案例研讨', '综合演练', '现地教学', '自学'],
@@ -320,8 +578,25 @@ export default {
jxbzbz: '',
ycxx: '',
jsbh: '',
- jybh: ''
- }
+ jybh: '',
+ jsbhs: [],
+ fzjybhs: [],
+ xydbhs: []
+ },
+ /** 教学班次面板 */
+ arrangeTeams: [],
+ candidateTeams: [],
+ teamToAdd: '',
+ fzjyToAdd: '',
+ roomToAdd: '',
+ /** 用车信息四要素 */
+ carDepartTime: '',
+ carDepartPlace: '',
+ carReturnTime: '',
+ carDest: '',
+ /** 编辑课程教学任务信息 */
+ courseFormVisible: false,
+ courseForm: { sskcbh: '', kcmc: '', jc: '', xs: 0, zks: 0, jybh: '', jsbh: '' }
}
},
computed: {
@@ -337,12 +612,48 @@ export default {
days() {
return this.currentWeek && this.currentWeek.days ? this.currentWeek.days : []
},
- jcList() {
- const set = new Set()
- this.days.forEach(d => {
- (d.cells || []).forEach(c => set.add(c.jc))
+ /** 全学期扁平日列表(跨周框选用) */
+ flatDays() {
+ const list = []
+ const index = {}
+ this.weeks.forEach(w => {
+ (w.days || []).forEach(d => {
+ index[this.fmtDate(d.date)] = list.length
+ list.push(d)
+ })
})
- return Array.from(set).sort((a, b) => a - b)
+ return { list, index }
+ },
+ /** 网格行:单周一行 / 全学期每周一组 */
+ gridRows() {
+ if (this.viewMode === 'all') {
+ return this.weeks.map(w => ({
+ key: 'w' + w.weekNo,
+ weekNo: w.weekNo,
+ days: (w.days || []).map(d => Object.assign({}, d, { fi: this.fiOf(d.date) }))
+ }))
+ }
+ const w = this.currentWeek
+ if (!w) return []
+ return [{
+ key: 'w' + w.weekNo,
+ weekNo: w.weekNo,
+ days: (w.days || []).map(d => Object.assign({}, d, { fi: this.fiOf(d.date) }))
+ }]
+ },
+ /** 节次字典(来自节次时间表),缺失时按数据推导 */
+ periodDict() {
+ const list = (this.view && this.view.periods) || []
+ if (list.length) return list
+ const set = new Set()
+ this.weeks.forEach(w => (w.days || []).forEach(d => (d.cells || []).forEach(c => set.add(c.jc))))
+ return Array.from(set).sort((a, b) => a - b).map(jc => ({ jc, label: String(jc), sd: jc <= 4 ? '上午' : '下午' }))
+ },
+ /** 应用显示控制后的节次行 */
+ jcList() {
+ return this.periodDict
+ .filter(p => this.periodVisible(p))
+ .map(p => p.jc)
},
canArrange() {
return !!(this.currentCourse && this.currentCourse.editable && this.selectedCells.length)
@@ -350,24 +661,65 @@ export default {
canOperate() {
return !!(this.currentCourse && this.currentCourse.editable)
},
- // 所选格中属于当前课程的已排课次数
+ /** 所选格中属于当前课程的已排课次数 */
pickedLessonCount() {
let n = 0
- this.days.forEach(d => {
- (d.cells || []).forEach(c => {
- if (this.isSelected(d.date, c.jc) && this.currentCourse) {
- n += (c.lessons || []).filter(l => l.sskcbh === this.currentCourse.sskcbh).length
- }
- })
+ this.eachCell((day, cell) => {
+ if (this.isSelected(day.date, cell.jc) && this.currentCourse) {
+ n += (cell.lessons || []).filter(l => l.sskcbh === this.currentCourse.sskcbh).length
+ }
})
return n
},
+ /** 选区统计:合计学时(每节 2 学时) */
+ selectedHours() {
+ return this.selectedCells.length * 2
+ },
selectedRangeText() {
if (!this.selectedCells.length) return ''
const dates = Array.from(new Set(this.selectedCells.map(c => c.rq))).sort()
const jcs = Array.from(new Set(this.selectedCells.map(c => c.jc))).sort((a, b) => a - b)
const dateText = dates.length > 2 ? `${dates[0]} ~ ${dates[dates.length - 1]}(${dates.length} 天)` : dates.join('、')
return `${dateText} 第 ${jcs.join('、')} 节`
+ },
+ statsRangeText() {
+ return this.selectedRangeText || '—'
+ },
+ /** 选区按周分布 */
+ statsWeekText() {
+ const counter = {}
+ this.selectedCells.forEach(c => {
+ const w = this.weekNoOfDate(c.rq)
+ counter[w] = (counter[w] || 0) + 1
+ })
+ const keys = Object.keys(counter).map(Number).sort((a, b) => a - b)
+ if (!keys.length) return '—'
+ return keys.map(w => `第 ${w} 周 ${counter[w]} 格`).join('、')
+ },
+ /** 选区内其它课程的课次 */
+ statsOthers() {
+ const map = {}
+ this.eachCell((day, cell) => {
+ if (!this.isSelected(day.date, cell.jc)) return
+ ;(cell.lessons || []).forEach(l => {
+ if (this.currentCourse && l.sskcbh === this.currentCourse.sskcbh) return
+ map[l.kcmc || '未知课程'] = (map[l.kcmc || '未知课程'] || 0) + 1
+ })
+ })
+ return Object.keys(map).map(k => `${k} ${map[k]} 次`)
+ },
+ focusedCellCell() {
+ if (!this.focusedCell) return null
+ return this.cellByKey(this.focusedCell.rq, this.focusedCell.jc)
+ },
+ focusedLessons() {
+ const cell = this.focusedCellCell
+ return cell && cell.lessons ? cell.lessons : []
+ },
+ /** 可加入的教学班次 */
+ addableTeams() {
+ const used = this.arrangeTeams.map(t => t.xydbh)
+ return this.candidateTeams.filter(t => !used.includes(t.xydbh))
}
},
created() {
@@ -378,6 +730,12 @@ export default {
this.loadClassrooms()
if (this.xydxqbh) this.loadView()
},
+ mounted() {
+ window.addEventListener('keydown', this.onGlobalKeydown)
+ },
+ beforeDestroy() {
+ window.removeEventListener('keydown', this.onGlobalKeydown)
+ },
methods: {
/* ==================== 数据加载 ==================== */
loadSemesters() {
@@ -400,6 +758,7 @@ export default {
this.view = {}
this.currentCourse = null
this.selectedCells = []
+ this.focusedCell = null
return
}
this.loading = true
@@ -407,6 +766,10 @@ export default {
getSchedulingView(this.xydxqbh).then(res => {
this.view = (res && res.data) || {}
this.selectedCells = []
+ this.focusedCell = null
+ this.dragging = false
+ this.dragStart = null
+ this.dragEnd = null
// 尽量保留原来选中的课程
const list = this.courses
this.currentCourse = keepBh ? (list.find(c => c.sskcbh === keepBh) || list[0] || null) : (list[0] || null)
@@ -417,6 +780,7 @@ export default {
this.view = {}
this.currentCourse = null
this.selectedCells = []
+ this.focusedCell = null
this.$message.warning('排课视图加载失败:该班次学期可能暂无课程数据,或课程/场地数据不完整')
if (window.console && console.error) console.error(err)
})
@@ -469,6 +833,75 @@ export default {
cellKey(rq, jc) {
return `${this.fmtDate(rq)}#${jc}`
},
+ /** 全学期扁平序号(跨周框选统一寻址) */
+ fiOf(date) {
+ const key = this.fmtDate(date)
+ const idx = this.flatDays.index[key]
+ return idx === undefined ? -1 : idx
+ },
+ eachCell(fn) {
+ this.weeks.forEach(w => (w.days || []).forEach(d => (d.cells || []).forEach(c => fn(d, c))))
+ },
+ cellByKey(rq, jc) {
+ const key = this.fmtDate(rq)
+ const idx = this.flatDays.index[key]
+ const day = idx === undefined ? null : this.flatDays.list[idx]
+ return day ? this.cellOf(day, jc) : null
+ },
+ weekNoOfDate(rq) {
+ const key = this.fmtDate(rq)
+ const hit = this.weeks.find(w => (w.days || []).some(d => this.fmtDate(d.date) === key))
+ return hit ? hit.weekNo : 0
+ },
+ /** ④ 显示控制选项:7-8 节 / 晚上 / 夜间 */
+ periodVisible(p) {
+ const jc = p.jc
+ if (jc <= 6) return true
+ if (jc === 7 || jc === 8) return this.periodToggles.includes('p78')
+ if (jc <= 10) return this.periodToggles.includes((p.sd || '') === '晚上' ? 'eve' : 'night')
+ return this.periodToggles.includes('night')
+ },
+ periodLabel(jc) {
+ const p = this.periodDict.find(x => x.jc === jc)
+ return p ? `${p.label || jc}(${p.sd || ''})` : String(jc)
+ },
+ /** 格内显示课程简称(无简称退化为课程名) */
+ shortName(l) {
+ if (!l) return '课程'
+ const c = this.courses.find(x => x.sskcbh === l.sskcbh)
+ return (c && c.jc) || l.kcmc || l.jxnr || '课程'
+ },
+ roomLabel(jsbh) {
+ if (!jsbh) return ''
+ const r = this.classroomOptions.find(x => x.jsbh === jsbh)
+ return r ? (r.jsmc ? r.jsbh + '(' + r.jsmc + ')' : r.jsbh) : jsbh
+ },
+ /** ② 冲突显示选项:命中当前课程预排条件的冲突维度 */
+ conflictKinds(day, jc) {
+ const kinds = []
+ const cell = this.cellOf(day, jc)
+ if (!cell) return kinds
+ if (cell.calendarKind || cell.reason) kinds.push('CALENDAR')
+ const room = this.arrangeForm.jsbhs[0] || (this.currentCourse && this.currentCourse.jsbh)
+ const teacher = this.arrangeForm.jybh || (this.currentCourse && this.currentCourse.jybh)
+ const myCourse = this.currentCourse ? this.currentCourse.sskcbh : null
+ const mineTeams = this.currentCourse && this.currentCourse.teams
+ ? this.currentCourse.teams.map(t => t.xydbh) : []
+ if (room && (cell.blockedRooms || []).includes(room)) kinds.push('ROOM_CAL')
+ if (teacher && (cell.blockedTeachers || []).includes(teacher)) kinds.push('TEACHER_CAL')
+ ;(cell.lessons || []).forEach(l => {
+ if (l.sskcbh === myCourse) return
+ if (room && (l.jsbhs || []).includes(room)) kinds.push('ROOM')
+ if (teacher && (l.jybhs || []).includes(teacher)) kinds.push('TEACHER')
+ if ((l.xydbhs || []).some(t => mineTeams.includes(t))) kinds.push('TEAM')
+ })
+ return Array.from(new Set(kinds))
+ },
+ /** 按所选维度命中的冲突 */
+ conflictHit(day, jc) {
+ if (!this.conflictDims.length) return false
+ return this.conflictKinds(day, jc).some(k => this.conflictDims.includes(k))
+ },
isSelected(rq, jc) {
return this.selectedCells.some(c => c.rq === this.fmtDate(rq) && c.jc === jc)
},
@@ -480,6 +913,7 @@ export default {
'is-warn': !!(cell && !cell.unavailable && cell.warning),
'is-picked': !hasLesson && this.isSelected(day.date, jc),
'has-lesson': hasLesson,
+ 'is-conflict': !!(cell && !cell.unavailable && this.conflictHit(day, jc)),
'day-off': !!day.unavailableReason
}
},
@@ -510,33 +944,40 @@ export default {
this.$message.info(`«${c.kcmc || ''}» 当前账号无排课权限,仅可查看`)
}
},
- startDrag(dayIdx, jc, day) {
+ startDrag(fi, jc, day, ev) {
const cell = this.cellOf(day, jc)
if (cell && cell.unavailable) {
this.$message.warning(cell.reason || '该节次不可排课')
return
}
this.dragging = true
- this.dragStart = { dayIdx, jc }
- this.dragEnd = { dayIdx, jc }
+ this.additiveDrag = !!(ev && (ev.ctrlKey || ev.metaKey))
+ this.dragStart = { fi, jc }
+ this.dragEnd = { fi, jc }
+ this.focusCell(day.date, jc)
+ if (this.$refs.gridWrap && this.$refs.gridWrap.focus) this.$refs.gridWrap.focus()
},
- hoverCell(dayIdx, jc) {
+ hoverCell(fi, jc) {
if (!this.dragging) return
- this.dragEnd = { dayIdx, jc }
+ this.dragEnd = { fi, jc }
},
finishDrag() {
if (!this.dragging) return
this.dragging = false
const s = this.dragStart
const e = this.dragEnd
+ const additive = this.additiveDrag
+ this.dragStart = null
+ this.dragEnd = null
+ this.additiveDrag = false
if (!s || !e) return
- const dayFrom = Math.min(s.dayIdx, e.dayIdx)
- const dayTo = Math.max(s.dayIdx, e.dayIdx)
+ const fiFrom = Math.min(s.fi, e.fi)
+ const fiTo = Math.max(s.fi, e.fi)
const jcFrom = Math.min(s.jc, e.jc)
const jcTo = Math.max(s.jc, e.jc)
const picked = []
- for (let di = dayFrom; di <= dayTo; di++) {
- const day = this.days[di]
+ for (let fi = fiFrom; fi <= fiTo; fi++) {
+ const day = this.flatDays.list[fi]
if (!day || day.unavailableReason) continue
for (let jc = jcFrom; jc <= jcTo; jc++) {
const cell = this.cellOf(day, jc)
@@ -544,26 +985,90 @@ export default {
picked.push({ rq: this.fmtDate(day.date), jc })
}
}
- // 单选格:点一下即选中/取消
- if (picked.length === 1) {
- const one = picked[0]
- const idx = this.selectedCells.findIndex(c => c.rq === one.rq && c.jc === one.jc)
- if (idx >= 0) {
- this.selectedCells.splice(idx, 1)
- } else {
- this.selectedCells.push(one)
- }
- } else {
- const map = new Map()
- this.selectedCells.concat(picked).forEach(c => map.set(`${c.rq}#${c.jc}`, c))
- this.selectedCells = Array.from(map.values())
+ const map = new Map()
+ if (additive) {
+ this.selectedCells.forEach(c => map.set(this.cellKey(c.rq, c.jc), c))
}
- this.dragStart = null
- this.dragEnd = null
+ // 单格且非追加:点一下选中/取消
+ if (picked.length === 1 && !additive) {
+ const one = picked[0]
+ if (this.isSelected(one.rq, one.jc)) {
+ this.selectedCells = []
+ return
+ }
+ this.selectedCells = [one]
+ return
+ }
+ picked.forEach(c => map.set(this.cellKey(c.rq, c.jc), c))
+ this.selectedCells = Array.from(map.values())
+ .sort((a, b) => (a.rq === b.rq ? a.jc - b.jc : a.rq < b.rq ? -1 : 1))
},
clearSelection() {
this.selectedCells = []
},
+ /** ⑨ 当前格子编排信息 */
+ focusCell(rq, jc) {
+ this.focusedCell = { rq: this.fmtDate(rq), jc }
+ },
+ /** 双击格子:反选课程 + 回填预排信息(手册 12.3.2) */
+ dblclickCell(day, jc) {
+ const cell = this.cellOf(day, jc)
+ if (cell && cell.unavailable) {
+ this.$message.warning(cell.reason || '该节次不可排课')
+ return
+ }
+ this.focusCell(day.date, jc)
+ const lessons = (cell && cell.lessons) || []
+ if (!lessons.length) return
+ const first = lessons[0]
+ const course = this.courses.find(c => c.sskcbh === first.sskcbh)
+ if (course) {
+ this.currentCourse = course
+ }
+ // 回填:教学方法 / 教学内容 / 教学要点 / 节次调节 / 场地 / 教员 / 教学班次
+ this.arrangeForm.jxnr = first.jxnr || ''
+ this.arrangeForm.jxyd = first.jxyd || ''
+ this.arrangeForm.jxff = first.jxff || '理论讲授'
+ this.arrangeForm.jcdj = first.jcdj || 0
+ this.arrangeForm.jxbzbz = first.jxbzbz || ''
+ this.arrangeForm.ycxx = first.ycxx || ''
+ this.arrangeForm.jsbhs = (first.jsbhs || []).slice()
+ this.arrangeForm.jsbh = this.arrangeForm.jsbhs[0] || ''
+ this.arrangeForm.jybh = first.jybh || ''
+ this.arrangeForm.xydbhs = (first.xydbhs || []).slice()
+ this.arrangeTeams = (first.xydbhs || []).map(bh => ({ xydbh: bh, xydmc: this.teamNameOf(bh) }))
+ this.$message.success(`已回填「${first.kcmc || ''}」的预排信息`)
+ },
+ teamNameOf(xydbh) {
+ const t = (this.currentCourse && this.currentCourse.teams || []).find(x => x.xydbh === xydbh)
+ if (t) return t.xydmc
+ const c = this.candidateTeams.find(x => x.xydbh === xydbh)
+ return c ? c.xydmc : xydbh
+ },
+ /** 快捷键:S 安排 / R 删除(手册 12.3.4 / 12.3.5) */
+ onKeydown(ev) {
+ const tag = (ev.target && ev.target.tagName) || ''
+ const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || (ev.target && ev.target.isContentEditable)
+ if (typing) return
+ this.handleHotkey(ev)
+ },
+ onGlobalKeydown(ev) {
+ const tag = (ev.target && ev.target.tagName) || ''
+ const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || (ev.target && ev.target.isContentEditable)
+ if (typing || ev.ctrlKey || ev.metaKey) return
+ // 时间区未获得焦点时使用 Alt+S / Alt+R
+ if (ev.altKey) this.handleHotkey(ev)
+ },
+ handleHotkey(ev) {
+ const key = (ev.key || '').toLowerCase()
+ if (key === 's') {
+ ev.preventDefault()
+ if (this.canArrange) this.openArrangeForm()
+ } else if (key === 'r') {
+ ev.preventDefault()
+ if (this.canOperate && this.pickedLessonCount) this.handleDeleteCells()
+ }
+ },
/* ==================== 排课 ==================== */
openArrangeForm() {
@@ -571,32 +1076,112 @@ export default {
this.$message.warning('请先选择课程,并在右侧格子中选择要安排的节次')
return
}
+ this.formTab = 'subject'
this.arrangeForm.jxnr = ''
this.arrangeForm.jxyd = ''
this.arrangeForm.jxff = '理论讲授'
this.arrangeForm.jcdj = 0
this.arrangeForm.jxbzbz = ''
this.arrangeForm.ycxx = ''
- this.arrangeForm.jsbh = ''
this.arrangeForm.jybh = ''
+ this.arrangeForm.fzjybhs = []
+ // 场地默认课程默认场地;教学班次默认课程全部合班班次
+ this.arrangeForm.jsbhs = this.currentCourse.jsbh ? [this.currentCourse.jsbh] : []
+ this.arrangeForm.jsbh = this.arrangeForm.jsbhs[0] || ''
+ this.carDepartTime = ''
+ this.carDepartPlace = ''
+ this.carReturnTime = ''
+ this.carDest = ''
+ this.fzjyToAdd = ''
+ this.roomToAdd = ''
+ this.teamToAdd = ''
+ this.loadCourseTeams()
this.formVisible = true
},
+ /** 教学班次:当前 + 候选 */
+ loadCourseTeams() {
+ const course = this.currentCourse
+ this.arrangeTeams = (course.teams || []).map(t => ({ xydbh: t.xydbh, xydmc: t.xydmc, rs: null }))
+ this.candidateTeams = []
+ if (!course || !course.sskcbh) return
+ getCourseTeams(course.sskcbh).then(res => {
+ const data = (res && res.data) || {}
+ if (data.current && data.current.length) this.arrangeTeams = data.current
+ this.candidateTeams = data.candidates || []
+ }).catch(() => {})
+ },
+ addTeam() {
+ if (!this.teamToAdd) {
+ this.$message.warning('请选择要加入的教学班次')
+ return
+ }
+ const t = this.candidateTeams.find(x => x.xydbh === this.teamToAdd)
+ this.arrangeTeams.push({ xydbh: this.teamToAdd, xydmc: t ? t.xydmc : this.teamToAdd, rs: t ? t.rs : null })
+ this.teamToAdd = ''
+ },
+ removeTeam(t) {
+ if (this.arrangeTeams.length <= 1) {
+ this.$message.warning('至少保留一个教学班次')
+ return
+ }
+ const idx = this.arrangeTeams.indexOf(t)
+ if (idx >= 0) this.arrangeTeams.splice(idx, 1)
+ },
+ addFzjy() {
+ const bh = (this.fzjyToAdd || '').trim()
+ if (!bh) {
+ this.$message.warning('请输入辅讲教员工号')
+ return
+ }
+ if (!this.arrangeForm.fzjybhs.includes(bh)) this.arrangeForm.fzjybhs.push(bh)
+ this.fzjyToAdd = ''
+ },
+ addRoom() {
+ const bh = (this.roomToAdd || '').trim()
+ if (!bh) {
+ this.$message.warning('请选择或输入教学场地')
+ return
+ }
+ if (!this.arrangeForm.jsbhs.includes(bh)) this.arrangeForm.jsbhs.push(bh)
+ this.arrangeForm.jsbh = this.arrangeForm.jsbhs[0]
+ this.roomToAdd = ''
+ },
+ /** 用车信息四要素合成文本(字段 ycxx 存文本) */
+ composeCar() {
+ const parts = []
+ if (this.carDepartTime) parts.push('出发 ' + this.carDepartTime)
+ if (this.carDepartPlace) parts.push('自 ' + this.carDepartPlace)
+ if (this.carReturnTime) parts.push('返回 ' + this.carReturnTime)
+ if (this.carDest) parts.push('目的地 ' + this.carDest)
+ return parts.join(';')
+ },
submitArrange() {
if (!this.selectedCells.length) {
this.$message.warning('未选择节次')
return
}
+ if (!this.arrangeTeams.length) {
+ this.$message.warning('请至少保留一个教学班次')
+ return
+ }
+ if (!this.arrangeForm.jsbhs.length && !(this.currentCourse && this.currentCourse.jsbh)) {
+ this.$message.warning('课程未设置默认场地,请先指定教学场地')
+ return
+ }
this.submitting = true
const payload = Object.assign({}, this.arrangeForm, {
xydxqbh: this.xydxqbh,
sskcbh: this.currentCourse.sskcbh,
+ ycxx: this.composeCar(),
+ jsbh: this.arrangeForm.jsbhs[0] || '',
+ xydbhs: this.arrangeTeams.map(t => t.xydbh),
cells: this.selectedCells.slice()
})
arrangeLessons(payload).then(res => {
this.submitting = false
this.formVisible = false
const data = (res && res.data) || {}
- const arranged = (data.arranged || []).length
+ const arranged = data.arranged || 0
const ignored = data.ignored || []
const warnings = data.warnings || []
this.$message.success(`已排入 ${arranged} 个节次`)
@@ -665,8 +1250,49 @@ export default {
this.logs = []
this.logsVisible = true
})
- }
- }
+ },
+
+ /* ==================== 编辑课程教学任务信息(手册 12.3.1) ==================== */
+ openCourseEdit(course) {
+ const c = course || this.currentCourse
+ if (!c) {
+ this.$message.warning('请先选择课程')
+ return
+ }
+ if (!c.editable) {
+ this.$message.warning('当前账号无该课程的信息修改权限')
+ return
+ }
+ this.courseForm = {
+ sskcbh: c.sskcbh,
+ kcmc: c.kcmc,
+ jc: c.jc || '',
+ xs: c.xs || 0,
+ zks: c.zks || 0,
+ jybh: c.jybh || '',
+ jsbh: c.jsbh || ''
+ }
+ this.courseFormVisible = true
+ },
+ submitCourseEdit() {
+ if (!this.courseForm.sskcbh) return
+ this.submitting = true
+ updateSchedulingCourse({
+ sskcbh: this.courseForm.sskcbh,
+ xs: this.courseForm.xs,
+ zks: this.courseForm.zks,
+ jc: this.courseForm.jc,
+ jybh: this.courseForm.jybh,
+ jsbh: this.courseForm.jsbh
+ }).then(() => {
+ this.submitting = false
+ this.courseFormVisible = false
+ this.$message.success('课程信息已保存')
+ this.loadView()
+ }).catch(() => {
+ this.submitting = false
+ })
+ } }
}
@@ -1058,3 +1684,208 @@ export default {
margin-left: 8px;
}
+
+
+
diff --git a/frontend/src/views/teachBusiness/timetableConflict/index.vue b/frontend/src/views/teachBusiness/timetableConflict/index.vue
index 2649f953c..98f77b299 100644
--- a/frontend/src/views/teachBusiness/timetableConflict/index.vue
+++ b/frontend/src/views/teachBusiness/timetableConflict/index.vue
@@ -99,11 +99,12 @@ import { listAllSemester } from '@/api/teachBusiness/semester'
// 四类检查卡片定义(标题/描述与后端 TimetableConflictDimension 枚举一致,作为页面布局常量;
// 检查状态与冲突数完全来自后端接口)
+// 注意:教员卡片含「教员历不可排」、教室卡片含「场地历不可用」,与排课窗口硬冲突口径一致
const CARD_DEFS = [
{ key: 'TEAM_CONFLICT', label: '教学班时间冲突检查', desc: '同一教学班次在同一时间段被安排多门课程' },
{ key: 'ELECTIVE_REQUIRED_CONFLICT', label: '教学班必修与选修时间冲突检查', desc: '教学班次必修课与选修课时间重叠' },
- { key: 'TEACHER_CONFLICT', label: '教员时间冲突检查', desc: '同一教员在同一时间段被安排多门课程' },
- { key: 'CLASSROOM_CONFLICT', label: '教室时间冲突检查', desc: '同一教室在同一时间段被多门课程占用' }
+ { key: 'TEACHER_CONFLICT', label: '教员时间冲突检查', desc: '同一教员在同一时间段被安排多门课程,或教员历不可排课时段已有课程' },
+ { key: 'CLASSROOM_CONFLICT', label: '教室时间冲突检查', desc: '同一教室在同一时间段被多门课程占用,或场地历不可用时段已有课程' }
]
export default {
diff --git a/frontend/src/views/teachOffice/shiftSemester/index.vue b/frontend/src/views/teachOffice/shiftSemester/index.vue
index 449c184f2..1875b52f7 100644
--- a/frontend/src/views/teachOffice/shiftSemester/index.vue
+++ b/frontend/src/views/teachOffice/shiftSemester/index.vue
@@ -64,6 +64,8 @@
编辑班历
编辑班次教学任务
教学配当
+ 发布运行课表
+ 删除运行课表
@@ -333,6 +335,7 @@ import { batchAutoGenerateTasks } from '@/api/studentRecords/classCalendar'
import { listSemester, allSemester, addSemester, updateSemester, delSemester, batchDeleteSemester, batchUpdateDateRange, batchUpdateJxrwbh, batchCreateSemester, batchUpdateXqdc, batchUpdateKfjypk, batchWaveMerge, batchWaveSplit, listCreatableClasses, presetSemesterDates } from '@/api/studentRecords/semester'
import { listAllSemester } from '@/api/teachBusiness/semester'
import { listTeachingTask } from '@/api/teachBusiness/teachingTask'
+import { publishRunningCourses, withdrawRunningCourses } from '@/api/teachBusiness/schedulingWindow'
export default {
name: 'ShiftSemester',
@@ -346,6 +349,8 @@ export default {
return {
loading: false,
saving: false,
+ /** 运行课表批量发布/删除进行中 */
+ runningPublishing: false,
// ==================== 查询条件 ====================
searchForm: { xydbh: '', xqdc: undefined },
@@ -663,6 +668,70 @@ export default {
this.allocationVisible = true
},
+ // 批量发布【运行课表】(手册 12.1):把课程任务发布为运行课程,之后才能在排课窗口排课
+ handlePublishRunning() {
+ const rows = this.selection.slice()
+ if (!rows.length) {
+ this.$message.warning('请先勾选班次学期')
+ return
+ }
+ const ids = rows.map(item => item.bh || item.xydxqbh).filter(Boolean)
+ this.$confirm(
+ `将发布所选 ${ids.length} 个班次学期的课程到运行课表。
发布前要求:教学任务已发布未结束、每门课程已指定责任教员与默认场地。`,
+ '批量发布【运行课表】',
+ { type: 'warning', dangerouslyUseHTMLString: true }
+ ).then(() => {
+ this.batchRunningPublish(publishRunningCourses, ids, '发布')
+ }).catch(() => {})
+ },
+
+ // 批量删除【运行课表】(手册 12.2):撤回运行课程,已排课次一并删除
+ handleWithdrawRunning() {
+ const rows = this.selection.slice()
+ if (!rows.length) {
+ this.$message.warning('请先勾选班次学期')
+ return
+ }
+ const ids = rows.map(item => item.bh || item.xydxqbh).filter(Boolean)
+ this.$confirm(
+ `将从运行课表删除所选 ${ids.length} 个班次学期的全部课程。
这些班次的排课信息(已排课次)将一并删除,编制侧任务保留。`,
+ '批量删除【运行课表】',
+ { type: 'warning', dangerouslyUseHTMLString: true }
+ ).then(() => {
+ this.batchRunningPublish(withdrawRunningCourses, ids, '删除')
+ }).catch(() => {})
+ },
+
+ // 批量发布/删除公共处理:逐条展示成功与失败原因
+ batchRunningPublish(api, ids, action) {
+ this.runningPublishing = true
+ api(ids).then(res => {
+ const list = (res && res.data) || []
+ const ok = list.filter(i => i.success)
+ const fail = list.filter(i => !i.success)
+ const okText = ok.length
+ ? `成功 ${ok.length} 个:` + ok.map(i => `${this.semesterNameOf(i.xydxqbh)}(${i.message})`).join(';')
+ : ''
+ const failText = fail.length
+ ? `失败 ${fail.length} 个:` + fail.map(i => `${this.semesterNameOf(i.xydxqbh)}(${i.message})`).join(';')
+ : ''
+ const html = [okText, failText].filter(Boolean).join('
')
+ if (fail.length) {
+ this.$alert(html, `运行课表${action}结果`, { dangerouslyUseHTMLString: true })
+ } else {
+ this.$message.success(`运行课表${action}完成:成功 ${ok.length} 个`)
+ }
+ this.loadList()
+ }).finally(() => {
+ this.runningPublishing = false
+ })
+ },
+
+ semesterNameOf(bh) {
+ const row = this.selection.find(i => (i.bh || i.xydxqbh) === bh)
+ return row ? (row.xydmc || row.bh) : bh
+ },
+
handleBatchGenerateTasks() {
if (!this.selection.length) {
this.$message.warning('请先勾选班次学期')
diff --git a/frontend/src/views/teachOffice/teacher/components/TeacherCalendarDialog.vue b/frontend/src/views/teachOffice/teacher/components/TeacherCalendarDialog.vue
new file mode 100644
index 000000000..a1873d6db
--- /dev/null
+++ b/frontend/src/views/teachOffice/teacher/components/TeacherCalendarDialog.vue
@@ -0,0 +1,461 @@
+
+ dialogVisible = val"
+ >
+
+ 学期
+
+
+
+ 周次
+
+
+
+ 上一周
+ 下一周
+ 清除本周标记
+ 点击格子标记「不可排课」。标记后,排课窗口会判为硬冲突「教员历不可用」,批量冲突检查的「教员时间冲突」也会报出。
+
+
+
+
+
+ | 节次 |
+
+ {{ d.weekLabel }}
+ {{ d.dateStr.slice(5) }}
+ |
+
+
+
+
+ |
+ 第{{ jc }}节{{ slotGroup(jc) }}
+ |
+
+ 不可排课
+ |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/teachOffice/teacher/index.vue b/frontend/src/views/teachOffice/teacher/index.vue
index 1267833a7..a3185c0e9 100644
--- a/frontend/src/views/teachOffice/teacher/index.vue
+++ b/frontend/src/views/teachOffice/teacher/index.vue
@@ -78,10 +78,11 @@
@click="handleCreateAccount(row)">创建账号
-
+
查看属性
编辑
+ 教员历
离职
@@ -700,6 +701,12 @@
+
+
+
@@ -723,10 +730,11 @@ import { getDicts } from '@/api/system/dict/data'
import { listRole } from '@/api/system/role'
import { saveAs } from 'file-saver'
import OrgSyncResultDialog from '@/components/OrgSyncResultDialog'
+import TeacherCalendarDialog from './components/TeacherCalendarDialog.vue'
export default {
name: 'Teacher',
- components: { OrgSyncResultDialog },
+ components: { OrgSyncResultDialog, TeacherCalendarDialog },
data() {
return {
// 查询条件(仅后端 JYBMapper.xml 支持的字段:jyxm 模糊 / jysdh 等值 / zc 模糊 / jylb 等值)
@@ -790,6 +798,10 @@ export default {
attributeData: {},
attributeForm: {},
attributeLoading: false,
+
+ // 教员历(标记不可排课时段)
+ teacherCalendarVisible: false,
+ teacherCalendarRow: null,
attributeSaving: false,
attributeJybh: '',
// 字典选项
@@ -1086,6 +1098,17 @@ export default {
return payload
},
+ // ==================== 教员历 ====================
+ /** 打开该教员的教员历编辑弹窗(标记不可排课时段) */
+ handleTeacherCalendar(row) {
+ if (!row || !row.jybh) {
+ this.$message.warning('该教员缺少教员工号,无法维护教员历')
+ return
+ }
+ this.teacherCalendarRow = row
+ this.teacherCalendarVisible = true
+ },
+
// ==================== 编辑 ====================
handleEdit(row) {
this.editDialogVisible = true