1课表模板管理;

2、课表生成;
3、课表的网格视图;
4、导出与打印
This commit is contained in:
2026-09-18 09:02:41 +08:00
parent 971ea46da5
commit 911dfe911b
16 changed files with 1864 additions and 0 deletions
Binary file not shown.
@@ -1,17 +1,24 @@
package com.roomroot.web.controller.jwgl; package com.roomroot.web.controller.jwgl;
import com.roomroot.jwgl.dto.kcb.TimetableGridQuery;
import com.roomroot.jwgl.dto.kcb.TimetableQuery; import com.roomroot.jwgl.dto.kcb.TimetableQuery;
import com.roomroot.jwgl.entity.KCB; import com.roomroot.jwgl.entity.KCB;
import com.roomroot.jwgl.service.KCBService; import com.roomroot.jwgl.service.KCBService;
import com.roomroot.jwgl.service.TimetableGridService;
import com.roomroot.jwgl.unit.PageQuery; import com.roomroot.jwgl.unit.PageQuery;
import com.roomroot.jwgl.unit.PageResult; import com.roomroot.jwgl.unit.PageResult;
import com.roomroot.jwgl.unit.Result; import com.roomroot.jwgl.unit.Result;
import com.roomroot.jwgl.vo.kcb.ClassTimetableVO; import com.roomroot.jwgl.vo.kcb.ClassTimetableVO;
import com.roomroot.jwgl.vo.kcb.DailyWeeklyTimetableRangeVO; import com.roomroot.jwgl.vo.kcb.DailyWeeklyTimetableRangeVO;
import com.roomroot.jwgl.vo.kcb.TimetableGridVO;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
/** /**
@@ -24,6 +31,9 @@ public class KCBController {
@Resource @Resource
private KCBService kcbService; private KCBService kcbService;
@Resource
private TimetableGridService timetableGridService;
/** /**
* 新增 * 新增
*/ */
@@ -133,4 +143,27 @@ public class KCBController {
public void exportClassCourses(TimetableQuery cond, HttpServletResponse response) { public void exportClassCourses(TimetableQuery cond, HttpServletResponse response) {
kcbService.exportClassCourses(cond, response); kcbService.exportClassCourses(cond, response);
} }
/**
* 周次横版课表网格(手册一第12章)。
* <p>dim=semester|team|teacher|roomid 为对应编号;可传 mbbh 套用课表模板。</p>
*/
@GetMapping("/grid")
public Result<TimetableGridVO> grid(TimetableGridQuery query) {
return Result.success(timetableGridService.grid(query));
}
/**
* 网格课表矩阵 Excel 导出(每周一个 sheet,节次 × 星期)。
*/
@GetMapping("/grid/export")
public ResponseEntity<byte[]> exportGrid(TimetableGridQuery query) {
byte[] bytes = timetableGridService.exportGrid(query);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
String name = "课表_" + query.getDim() + ".xlsx";
headers.setContentDispositionFormData("attachment",
new String(name.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1));
return ResponseEntity.ok().headers(headers).body(bytes);
}
} }
@@ -0,0 +1,63 @@
package com.roomroot.web.controller.jwgl;
import com.roomroot.jwgl.entity.KCBMBB;
import com.roomroot.jwgl.service.KCBMBService;
import com.roomroot.jwgl.unit.PageQuery;
import com.roomroot.jwgl.unit.PageResult;
import com.roomroot.jwgl.unit.Result;
import org.springframework.web.bind.annotation.*;
import jakarta.annotation.Resource;
import java.util.List;
/**
* 课表模版管理(手册一第12章;CRUD + 启停 + 下拉选项)
*/
@RestController
@RequestMapping("/kcbmb")
public class KCBMBController {
@Resource
private KCBMBService kcbmbService;
@GetMapping("/list")
public Result<PageResult<KCBMBB>> list(PageQuery query, @RequestParam(required = false) String mbmc) {
return Result.success(kcbmbService.page(query, mbmc));
}
@GetMapping("/get")
public Result<KCBMBB> get(@RequestParam("bh") String bh) {
return Result.success(kcbmbService.get(bh));
}
/** 可用模板下拉(课表生成页套用选择) */
@GetMapping("/options")
public Result<List<KCBMBB>> options() {
return Result.success(kcbmbService.options());
}
@PostMapping("/add")
public Result<Void> add(@RequestBody KCBMBB tpl) {
kcbmbService.add(tpl);
return Result.success();
}
@PostMapping("/update")
public Result<Void> update(@RequestBody KCBMBB tpl) {
kcbmbService.update(tpl);
return Result.success();
}
@PostMapping("/delete")
public Result<Void> delete(@RequestParam("bh") String bh) {
kcbmbService.delete(bh);
return Result.success();
}
/** 启用/停用模版 */
@PostMapping("/toggle")
public Result<Void> toggle(@RequestParam("bh") String bh, @RequestParam("enabled") boolean enabled) {
kcbmbService.toggle(bh, enabled);
return Result.success();
}
}
@@ -0,0 +1,30 @@
package com.roomroot.jwgl.dto.kcb;
import lombok.Data;
/**
* 课表网格查询参数(手册一第12章 课表生成)。
* <p>dim 维度:semester 班次学期 / team 学员队 / teacher 教员 / room 教室。
* id 为对应维度主键(xydxqbh / xydbh / jybh / jsbh)。</p>
*/
@Data
public class TimetableGridQuery {
/** 维度:semester | team | teacher | room */
private String dim;
/** 维度目标编号 */
private String id;
/** 年度(校历/备注取数用;semester 维度自动从班次学期带出) */
private Integer nd;
/** 起始日期 yyyy-MM-dd;空时 semester 取学期范围,其它维度取本周 */
private String start;
/** 结束日期 yyyy-MM-dd */
private String end;
/** 课表模板编号(可选;套用模板显示开关:78节/晚上/夜间/选修/天数) */
private String mbbh;
}
@@ -1,5 +1,7 @@
package com.roomroot.jwgl.service; package com.roomroot.jwgl.service;
import com.roomroot.jwgl.entity.JCSJB;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -18,4 +20,10 @@ public interface ClassPeriodService {
* 上午时段的双节次标签,用于「正课」默认判定。 * 上午时段的双节次标签,用于「正课」默认判定。
*/ */
Set<String> mainSlotLabels(); Set<String> mainSlotLabels();
/**
* 单节次时间行(双节次=0),按节次序号升序;表内无数据时返回空,
* 调用方回退默认 1–12 节。
*/
List<JCSJB> singleRows();
} }
@@ -0,0 +1,29 @@
package com.roomroot.jwgl.service;
import com.roomroot.jwgl.entity.KCBMBB;
import com.roomroot.jwgl.unit.PageQuery;
import com.roomroot.jwgl.unit.PageResult;
import java.util.List;
/**
* 课表模版管理(手册一第12章:只做 CRUD + 套用,不建新表)。
*/
public interface KCBMBService {
PageResult<KCBMBB> page(PageQuery query, String mbmc);
KCBMBB get(String bh);
void add(KCBMBB tpl);
void update(KCBMBB tpl);
void delete(String bh);
/** 启用/停用(模版可用开关) */
void toggle(String bh, boolean enabled);
/** 可用模板下拉(只回编号+名称) */
List<KCBMBB> options();
}
@@ -0,0 +1,21 @@
package com.roomroot.jwgl.service;
import com.roomroot.jwgl.dto.kcb.TimetableGridQuery;
import com.roomroot.jwgl.vo.kcb.TimetableGridVO;
/**
* 课表网格生成(手册一第12章)。
* <p>按 班次学期/学员队/教员/教室 四个维度生成「周次 × 星期 × 节次」横版网格。</p>
*/
public interface TimetableGridService {
/**
* 生成课表网格数据。
*/
TimetableGridVO grid(TimetableGridQuery query);
/**
* 导出网格课表为矩阵 Excel(每周一个 sheet)。
*/
byte[] exportGrid(TimetableGridQuery query);
}
@@ -51,6 +51,14 @@ public class ClassPeriodServiceImpl implements ClassPeriodService {
return main.isEmpty() ? DEFAULT_MAIN_SLOTS : main; return main.isEmpty() ? DEFAULT_MAIN_SLOTS : main;
} }
@Override
public List<JCSJB> singleRows() {
List<JCSJB> rows = jcsjbMapper.selectList(new LambdaQueryWrapper<JCSJB>()
.eq(JCSJB::getSjc, false));
rows.sort(Comparator.comparingInt(r -> PeriodUtil.firstPeriod(r.getJcsy())));
return rows;
}
private List<JCSJB> loadSlots() { private List<JCSJB> loadSlots() {
List<JCSJB> rows = jcsjbMapper.selectList(new LambdaQueryWrapper<JCSJB>() List<JCSJB> rows = jcsjbMapper.selectList(new LambdaQueryWrapper<JCSJB>()
.eq(JCSJB::getSjc, true)); .eq(JCSJB::getSjc, true));
@@ -0,0 +1,94 @@
package com.roomroot.jwgl.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.roomroot.jwgl.entity.KCBMBB;
import com.roomroot.jwgl.mapper.KCBMBBMapper;
import com.roomroot.jwgl.service.KCBMBService;
import com.roomroot.jwgl.unit.BusinessException;
import com.roomroot.jwgl.unit.PageQuery;
import com.roomroot.jwgl.unit.PageResult;
import com.roomroot.jwgl.utils.UuidUtil;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class KCBMBServiceImpl implements KCBMBService {
@Resource
private KCBMBBMapper kcbmbbMapper;
@Override
public PageResult<KCBMBB> page(PageQuery query, String mbmc) {
Page<KCBMBB> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<KCBMBB> qw = new LambdaQueryWrapper<KCBMBB>()
.like(mbmc != null && !mbmc.isEmpty(), KCBMBB::getMBMC, mbmc)
.orderByDesc(KCBMBB::getGXSJ);
Page<KCBMBB> result = kcbmbbMapper.selectPage(page, qw);
return new PageResult<>(result.getRecords(), result.getTotal(), query.getPageNum(), query.getPageSize());
}
@Override
public KCBMBB get(String bh) {
return kcbmbbMapper.selectById(bh);
}
@Override
public void add(KCBMBB tpl) {
if (tpl.getMBMC() == null || tpl.getMBMC().isEmpty()) {
throw new BusinessException("模版名称不能为空");
}
tpl.setBH(UuidUtil.getUUID());
tpl.setGXSJ(LocalDateTime.now());
if (tpl.getMBKY() == null) {
tpl.setMBKY(true);
}
tpl.setMBCZ(tpl.getMB() != null && !tpl.getMB().isEmpty());
kcbmbbMapper.insert(tpl);
}
@Override
public void update(KCBMBB tpl) {
if (tpl.getBH() == null || tpl.getBH().isEmpty()) {
throw new BusinessException("模版编号不能为空");
}
tpl.setGXSJ(LocalDateTime.now());
tpl.setMBCZ(tpl.getMB() != null && !tpl.getMB().isEmpty());
kcbmbbMapper.updateById(tpl);
}
@Override
public void delete(String bh) {
kcbmbbMapper.deleteById(bh);
}
@Override
public void toggle(String bh, boolean enabled) {
KCBMBB tpl = kcbmbbMapper.selectById(bh);
if (tpl == null) {
throw new BusinessException("模版不存在:" + bh);
}
tpl.setMBKY(enabled);
tpl.setGXSJ(LocalDateTime.now());
kcbmbbMapper.updateById(tpl);
}
@Override
public List<KCBMBB> options() {
return kcbmbbMapper.selectList(new LambdaQueryWrapper<KCBMBB>()
.eq(KCBMBB::getMBKY, true)
.orderByDesc(KCBMBB::getGXSJ))
.stream()
.map(t -> {
KCBMBB o = new KCBMBB();
o.setBH(t.getBH());
o.setMBMC(t.getMBMC());
return o;
})
.collect(Collectors.toList());
}
}
@@ -0,0 +1,617 @@
package com.roomroot.jwgl.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.roomroot.jwgl.dto.kcb.TimetableGridQuery;
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.KB;
import com.roomroot.jwgl.entity.KCBMBB;
import com.roomroot.jwgl.entity.SSKC;
import com.roomroot.jwgl.entity.SSKCB;
import com.roomroot.jwgl.entity.SSKCBJS;
import com.roomroot.jwgl.entity.SSKCBFZJY;
import com.roomroot.jwgl.entity.SSKCBXYD;
import com.roomroot.jwgl.entity.XQXLB;
import com.roomroot.jwgl.entity.XYDB;
import com.roomroot.jwgl.entity.XYDNDXQJBXXB;
import com.roomroot.jwgl.mapper.ClassRoomMapper;
import com.roomroot.jwgl.mapper.ClassSemesterMapper;
import com.roomroot.jwgl.mapper.JQBMapper;
import com.roomroot.jwgl.mapper.JYBMapper;
import com.roomroot.jwgl.mapper.KBMapper;
import com.roomroot.jwgl.mapper.KCBMBBMapper;
import com.roomroot.jwgl.mapper.SSKCBJSMapper;
import com.roomroot.jwgl.mapper.SSKCBFZJYMapper;
import com.roomroot.jwgl.mapper.SSKCBMapper;
import com.roomroot.jwgl.mapper.SSKCBXYDMapper;
import com.roomroot.jwgl.mapper.SSKCMapper;
import com.roomroot.jwgl.mapper.SemesterCalendarMapper;
import com.roomroot.jwgl.mapper.XYDBMapper;
import com.roomroot.jwgl.service.ClassPeriodService;
import com.roomroot.jwgl.service.TimetableGridService;
import com.roomroot.jwgl.unit.BusinessException;
import com.roomroot.jwgl.utils.PeriodUtil;
import com.roomroot.jwgl.vo.kcb.TimetableGridVO;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
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;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 课表网格生成实现。
* <p>课次一律从 实施_课程表(SSKCB) 按日期范围取;维度过滤经三张关联子表
* (学员队 / 教室 / 辅助教员)在内存归并,避免 N+1 与达梦 GUID 双形态问题。</p>
*/
@Service
public class TimetableGridServiceImpl implements TimetableGridService {
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter COMPACT = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final String[] WEEKDAY_NAMES = {"", "", "", "", "", "", "", ""};
@Resource
private SSKCBMapper sskcbMapper;
@Resource
private SSKCBXYDMapper sskcbxydMapper;
@Resource
private SSKCBJSMapper sskcbjsMapper;
@Resource
private SSKCBFZJYMapper sskcbfzjyMapper;
@Resource
private SSKCMapper sskcMapper;
@Resource
private KBMapper kbMapper;
@Resource
private XYDBMapper xydbMapper;
@Resource
private JYBMapper jybMapper;
@Resource
private ClassRoomMapper classRoomMapper;
@Resource
private ClassSemesterMapper classSemesterMapper;
@Resource
private SemesterCalendarMapper semesterCalendarMapper;
@Resource
private JQBMapper jqbMapper;
@Resource
private KCBMBBMapper kcbmbbMapper;
@Resource
private ClassPeriodService classPeriodService;
@Override
public TimetableGridVO grid(TimetableGridQuery query) {
if (query == null || query.getDim() == null || query.getId() == null || query.getId().isEmpty()) {
throw new BusinessException("课表网格需要维度(dim)与目标编号(id)");
}
// ---- 维度目标与日期范围 ----
String dim = query.getDim();
String targetName = query.getId();
String teamFilter = null; // 学员队编号(semester/team 维度)
String teacherFilter = null;
String roomFilter = null;
String semesterBh = null; // 班历取数用(semester 维度)
LocalDate start = parseDay(query.getStart());
LocalDate end = parseDay(query.getEnd());
Integer nd = query.getNd();
switch (dim) {
case "semester": {
XYDNDXQJBXXB sem = classSemesterMapper.selectById(query.getId());
if (sem == null) {
throw new BusinessException("班次学期不存在:" + query.getId());
}
semesterBh = sem.getBh();
teamFilter = sem.getXydbh();
nd = sem.getNd();
if (start == null) start = sem.getKxrq();
if (end == null) end = sem.getJsrq();
String xydmc = resolveTeamName(teamFilter);
targetName = xydmc + "" + sem.getNd() + "年度 第" + sem.getXqdc() + "学期)";
break;
}
case "team": {
XYDB team = xydbMapper.selectOne(new LambdaQueryWrapper<XYDB>().eq(XYDB::getXydbh, query.getId()));
if (team == null) {
throw new BusinessException("学员队不存在:" + query.getId());
}
teamFilter = team.getXydbh();
targetName = team.getXydmc() != null ? team.getXydmc() : team.getXydbh();
break;
}
case "teacher": {
JYB teacher = jybMapper.selectOne(new LambdaQueryWrapper<JYB>().eq(JYB::getJybh, query.getId()));
if (teacher == null) {
throw new BusinessException("教员不存在:" + query.getId());
}
teacherFilter = teacher.getJybh();
targetName = (teacher.getJyxm() != null ? teacher.getJyxm() : teacher.getJybh());
break;
}
case "room": {
JSB room = classRoomMapper.selectOne(new LambdaQueryWrapper<JSB>()
.eq(JSB::getJsbh, query.getId()).eq(JSB::getDelFlag, 0));
if (room == null) {
throw new BusinessException("教室不存在:" + query.getId());
}
roomFilter = room.getJsbh();
targetName = room.getJsmc() != null ? room.getJsmc() : room.getJsbh();
break;
}
default:
throw new BusinessException("不支持的课表维度:" + dim + "(可选 semester/team/teacher/room");
}
if (start == null || end == null) {
LocalDate monday = LocalDate.now().with(DayOfWeek.MONDAY);
if (start == null) start = monday;
if (end == null) end = monday.plusDays(6);
}
if (end.isBefore(start)) {
throw new BusinessException("结束日期不能早于开始日期");
}
if (nd == null) {
nd = start.getYear();
}
// ---- 模板显示开关 ----
KCBMBB tpl = null;
if (query.getMbbh() != null && !query.getMbbh().isEmpty()) {
tpl = kcbmbbMapper.selectById(query.getMbbh());
}
// ---- 课次与关联 ----
List<SSKCB> lessons = sskcbMapper.selectList(new LambdaQueryWrapper<SSKCB>()
.ge(SSKCB::getRq, start.atStartOfDay())
.lt(SSKCB::getRq, end.plusDays(1).atStartOfDay())
.eq(SSKCB::getSczt, 0)
.isNotNull(SSKCB::getRq));
List<String> lessonBhs = lessons.stream().map(SSKCB::getBh).filter(Objects::nonNull).distinct().collect(Collectors.toList());
List<String> courseBhs = lessons.stream().map(SSKCB::getSskcbh).filter(Objects::nonNull).distinct().collect(Collectors.toList());
Map<String, List<String>> teamsOfLesson = new HashMap<>();
Map<String, String> teamShortName = new HashMap<>(); // sskcbbh -> 简称(SSKCBXYD.jc
Map<String, List<String>> roomsOfLesson = new HashMap<>();
Map<String, List<String>> teachersOfLesson = new HashMap<>();
Map<String, String> mainTeacherOfLesson = new HashMap<>();
if (!lessonBhs.isEmpty()) {
for (SSKCBXYD l : sskcbxydMapper.selectList(new LambdaQueryWrapper<SSKCBXYD>()
.in(SSKCBXYD::getSskcbbh, lessonBhs))) {
teamsOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getXydbh());
if (l.getJc() != null && !l.getJc().isEmpty()) {
teamShortName.putIfAbsent(l.getSskcbbh(), l.getJc());
}
}
for (SSKCBJS l : sskcbjsMapper.selectList(new LambdaQueryWrapper<SSKCBJS>()
.in(SSKCBJS::getSskcbbh, lessonBhs))) {
roomsOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getJsbh());
}
for (SSKCBFZJY l : sskcbfzjyMapper.selectList(new LambdaQueryWrapper<SSKCBFZJY>()
.in(SSKCBFZJY::getSskcbbh, lessonBhs))) {
if (l.getFzjybh() == null) continue;
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());
}
}
}
Map<String, SSKC> courses = courseBhs.isEmpty() ? Map.of()
: sskcMapper.selectByBhs(courseBhs).stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(SSKC::getBh, c -> c, (a, b) -> a));
Set<String> kbhs = courses.values().stream().map(SSKC::getKmbh).filter(Objects::nonNull).collect(Collectors.toSet());
Map<String, KB> kbByKbh = kbhs.isEmpty() ? Map.of()
: kbMapper.selectBatchIds(kbhs).stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(KB::getKbh, k -> k, (a, b) -> a));
Set<String> jybhSet = new HashSet<>();
teachersOfLesson.values().forEach(jybhSet::addAll);
courses.values().forEach(c -> { if (c.getJybh() != null) jybhSet.add(c.getJybh()); });
Map<String, String> teacherNames = jybhSet.isEmpty() ? Map.of()
: jybMapper.selectList(new LambdaQueryWrapper<JYB>().in(JYB::getJybh, jybhSet)).stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(JYB::getJybh, j -> j.getJyxm() == null ? j.getJybh() : j.getJyxm(), (a, b) -> a));
Set<String> jsbhSet = new HashSet<>();
roomsOfLesson.values().forEach(jsbhSet::addAll);
Map<String, String> roomNames = jsbhSet.isEmpty() ? Map.of()
: classRoomMapper.selectList(new LambdaQueryWrapper<JSB>()
.in(JSB::getJsbh, jsbhSet).eq(JSB::getDelFlag, 0)).stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(JSB::getJsbh, j -> j.getJsmc() == null ? j.getJsbh() : j.getJsmc(), (a, b) -> a));
Set<String> xydbhSet = new HashSet<>();
teamsOfLesson.values().forEach(xydbhSet::addAll);
Map<String, String> teamNames = xydbhSet.isEmpty() ? Map.of()
: xydbMapper.selectList(new LambdaQueryWrapper<XYDB>().in(XYDB::getXydbh, xydbhSet)).stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(XYDB::getXydbh, t -> t.getXydmc() == null ? t.getXydbh() : t.getXydmc(), (a, b) -> a));
// ---- 节次行 ----
List<TimetableGridVO.Period> periods = buildPeriods(tpl);
Set<Integer> periodJcs = periods.stream().map(TimetableGridVO.Period::getJc).collect(Collectors.toSet());
Map<Integer, String> periodSd = periods.stream()
.collect(Collectors.toMap(TimetableGridVO.Period::getJc, TimetableGridVO.Period::getSd, (a, b) -> a));
// ---- 历表:不可排格 + 备注栏 ----
// 备注栏规则:晚上/夜间/周末 且 备注显示=1 的事件写入备注而非格子。
Map<String, String> blockByCell = new HashMap<>(); // date#jc -> 事件名
List<TimetableGridVO.Remark> remarks = new ArrayList<>();
if (nd != null) {
for (XQXLB g : semesterCalendarMapper.selectList(new LambdaQueryWrapper<XQXLB>().eq(XQXLB::getNd, nd))) {
applyCalendarRow(g.getJqsj(), g.getCourseClass(), g.getKpk() != null && g.getKpk(),
Boolean.TRUE.equals(g.getBzxs()), joinName(g.getJqmc(), g.getBz()),
start, end, periodSd, blockByCell, remarks, "校历");
}
}
if (semesterBh != null) {
for (JQB g : jqbMapper.selectList(new LambdaQueryWrapper<JQB>().eq(JQB::getXydxqbh, semesterBh))) {
String dateStr = g.getJqsj() == null ? null : intToDay(g.getJqsj());
applyCalendarRow(dateStr, g.getCourseClass(), g.getKpk() != null && g.getKpk() == 1,
g.getBzxs() != null && g.getBzxs() == 1, joinName(g.getJqmc(), g.getBz()),
start, end, periodSd, blockByCell, remarks, "班历");
}
}
// ---- 组装网格 ----
TimetableGridVO vo = new TimetableGridVO();
vo.setDim(dim);
vo.setTargetName(targetName);
vo.setNd(nd);
vo.setStartDate(start.format(DAY));
vo.setEndDate(end.format(DAY));
vo.setPeriods(periods);
vo.setRemarks(remarks);
// 课次按 date#jc 归并(先按维度过滤)
Map<String, List<SSKCB>> lessonByCell = new HashMap<>();
for (SSKCB l : lessons) {
if (!matchDimension(l, dim, teamFilter, teacherFilter, roomFilter, teamsOfLesson, teachersOfLesson, roomsOfLesson, courses)) {
continue;
}
SSKC course = courses.get(l.getSskcbh());
KB kb = course == null ? null : kbByKbh.get(course.getKmbh());
if (tpl != null && Boolean.FALSE.equals(tpl.getKBBHXX())
&& kb != null && kb.getKclx() != null && kb.getKclx().contains("选修")) {
continue;
}
if (l.getJc() == null || !periodJcs.contains(l.getJc())) {
continue;
}
lessonByCell.computeIfAbsent(l.getRq().toLocalDate().format(DAY) + "#" + l.getJc(), k -> new ArrayList<>()).add(l);
}
LocalDate weekStart = start.with(DayOfWeek.MONDAY);
int weekNo = 1;
while (!weekStart.isAfter(end)) {
TimetableGridVO.Week week = new TimetableGridVO.Week();
week.setWeekNo(weekNo++);
week.setStartDate(weekStart.format(DAY));
week.setEndDate(weekStart.plusDays(6).format(DAY));
for (int wd = 1; wd <= 7; wd++) {
LocalDate date = weekStart.plusDays(wd - 1);
TimetableGridVO.Day day = new TimetableGridVO.Day();
day.setDate(date.format(DAY));
day.setWeekday(wd);
if (tpl != null && tpl.getZXXSTS() != null && tpl.getZXXSTS() <= 5 && wd > tpl.getZXXSTS()) {
// 模板「纵向显示天数」截断周末列
continue;
}
if (date.isBefore(start) || date.isAfter(end)) {
day.setBlocked(true);
day.setBlockReason("范围外");
week.getDays().add(day);
continue;
}
boolean dayBlocked = false;
String reason = null;
for (Integer jc : periodJcs) {
String key = date.format(DAY) + "#" + jc;
String block = blockByCell.get(key);
if (block != null) {
dayBlocked = true;
reason = block;
}
List<SSKCB> cellLessons = lessonByCell.get(key);
if (cellLessons == null) continue;
for (SSKCB l : cellLessons) {
day.getCells().computeIfAbsent(String.valueOf(jc), k -> new ArrayList<>())
.add(buildCell(l, dim, courses, kbByKbh, teamShortName, teacherNames,
roomNames, teamNames, mainTeacherOfLesson, roomsOfLesson, teamsOfLesson));
}
}
day.setBlocked(dayBlocked);
day.setBlockReason(reason);
week.getDays().add(day);
}
vo.getWeeks().add(week);
}
return vo;
}
@Override
public byte[] exportGrid(TimetableGridQuery query) {
TimetableGridVO vo = grid(query);
try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
CellStyle head = wb.createCellStyle();
Font hf = wb.createFont();
hf.setBold(true);
head.setFont(hf);
head.setAlignment(HorizontalAlignment.CENTER);
head.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle center = wb.createCellStyle();
center.setAlignment(HorizontalAlignment.CENTER);
center.setVerticalAlignment(VerticalAlignment.CENTER);
center.setWrapText(true);
CellStyle title = wb.createCellStyle();
Font tf = wb.createFont();
tf.setBold(true);
tf.setFontHeightInPoints((short) 14);
title.setFont(tf);
title.setAlignment(HorizontalAlignment.CENTER);
for (TimetableGridVO.Week week : vo.getWeeks()) {
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("" + week.getWeekNo() + "");
int r = 0;
org.apache.poi.ss.usermodel.Row titleRow = sheet.createRow(r++);
org.apache.poi.ss.usermodel.Cell tc = titleRow.createCell(0);
tc.setCellValue(vo.getTargetName() + " 课表(" + week.getStartDate() + " ~ " + week.getEndDate() + "");
tc.setCellStyle(title);
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(0, 0, 0, 7));
org.apache.poi.ss.usermodel.Row headRow = sheet.createRow(r++);
String[] heads = {"节次", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"};
for (int i = 0; i < heads.length; i++) {
org.apache.poi.ss.usermodel.Cell c = headRow.createCell(i);
c.setCellValue(heads[i]);
c.setCellStyle(head);
}
for (TimetableGridVO.Period p : vo.getPeriods()) {
org.apache.poi.ss.usermodel.Row row = sheet.createRow(r++);
org.apache.poi.ss.usermodel.Cell pc = row.createCell(0);
pc.setCellValue(p.getMc() + (p.getKssj() != null ? "\n" + p.getKssj() + "-" + p.getJssj() : ""));
pc.setCellStyle(head);
for (TimetableGridVO.Day day : week.getDays()) {
org.apache.poi.ss.usermodel.Cell c = row.createCell(day.getWeekday());
List<TimetableGridVO.Cell> cells = day.getCells().get(String.valueOf(p.getJc()));
if (cells != null && !cells.isEmpty()) {
c.setCellValue(cells.stream().map(this::cellText).collect(Collectors.joining("\n")));
}
c.setCellStyle(center);
}
}
if (!vo.getRemarks().isEmpty()) {
r++;
org.apache.poi.ss.usermodel.Row rh = sheet.createRow(r++);
org.apache.poi.ss.usermodel.Cell rc = rh.createCell(0);
rc.setCellValue("备注栏");
rc.setCellStyle(head);
for (TimetableGridVO.Remark rm : vo.getRemarks()) {
org.apache.poi.ss.usermodel.Row rr = sheet.createRow(r++);
org.apache.poi.ss.usermodel.Cell cc = rr.createCell(0);
cc.setCellValue(rm.getDate() + " " + rm.getScope() + "" + rm.getText());
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(rr.getRowNum(), rr.getRowNum(), 0, 7));
}
}
sheet.setColumnWidth(0, 14 * 256);
for (int i = 1; i <= 7; i++) {
sheet.setColumnWidth(i, 22 * 256);
}
}
if (vo.getWeeks().isEmpty()) {
wb.createSheet("课表").createRow(0).createCell(0).setCellValue("范围内无课表数据");
}
wb.write(out);
return out.toByteArray();
} catch (Exception e) {
throw new BusinessException("课表导出失败:" + e.getMessage());
}
}
// ==================== 内部方法 ====================
private String cellText(TimetableGridVO.Cell c) {
StringBuilder sb = new StringBuilder(c.getKcjc() != null && !c.getKcjc().isEmpty() ? c.getKcjc() : c.getKcmc());
List<String> extra = new ArrayList<>();
if (c.getJyxm() != null && !c.getJyxm().isEmpty()) extra.add(c.getJyxm());
if (c.getJsmc() != null && !c.getJsmc().isEmpty()) extra.add(c.getJsmc());
if (c.getXydmc() != null && !c.getXydmc().isEmpty()) extra.add(c.getXydmc());
if (!extra.isEmpty()) sb.append("\n(").append(String.join(" ", extra)).append(")");
return sb.toString();
}
private TimetableGridVO.Cell buildCell(SSKCB l, String dim,
Map<String, SSKC> courses, Map<String, KB> kbByKbh,
Map<String, String> teamShortName,
Map<String, String> teacherNames, Map<String, String> roomNames,
Map<String, String> teamNames,
Map<String, String> mainTeacherOfLesson,
Map<String, List<String>> roomsOfLesson,
Map<String, List<String>> teamsOfLesson) {
TimetableGridVO.Cell cell = new TimetableGridVO.Cell();
cell.setSskcbbh(l.getBh());
cell.setSskcbh(l.getSskcbh());
SSKC course = courses.get(l.getSskcbh());
KB kb = course == null ? null : kbByKbh.get(course.getKmbh());
cell.setKcmc(kb != null && kb.getKmc() != null ? kb.getKmc() : l.getJxnr());
cell.setKclx(kb == null ? null : kb.getKclx());
// 简称优先:课次-学员队关联上的简称 > 课表.简称 > 课程名称
String shortName = teamShortName.get(l.getBh());
if (shortName == null || shortName.isEmpty()) {
shortName = kb != null && kb.getJc() != null && !kb.getJc().isEmpty() ? kb.getJc() : cell.getKcmc();
}
cell.setKcjc(shortName);
String mainJybh = mainTeacherOfLesson.get(l.getBh());
if (mainJybh == null && course != null) {
mainJybh = course.getJybh();
}
cell.setJyxm(mainJybh == null ? null : teacherNames.getOrDefault(mainJybh, mainJybh));
cell.setJsmc(roomsOfLesson.getOrDefault(l.getBh(), List.of()).stream()
.map(b -> roomNames.getOrDefault(b, b)).distinct().collect(Collectors.joining("")));
cell.setXydmc(teamsOfLesson.getOrDefault(l.getBh(), List.of()).stream()
.map(b -> teamNames.getOrDefault(b, b)).distinct().collect(Collectors.joining("")));
return cell;
}
private boolean matchDimension(SSKCB l, String dim, String teamFilter, String teacherFilter, String roomFilter,
Map<String, List<String>> teamsOfLesson,
Map<String, List<String>> teachersOfLesson,
Map<String, List<String>> roomsOfLesson,
Map<String, SSKC> courses) {
switch (dim) {
case "semester":
case "team":
return teamsOfLesson.getOrDefault(l.getBh(), List.of()).contains(teamFilter);
case "teacher": {
if (teachersOfLesson.getOrDefault(l.getBh(), List.of()).contains(teacherFilter)) {
return true;
}
SSKC c = courses.get(l.getSskcbh());
return c != null && (teacherFilter.equals(c.getJybh()) || teacherFilter.equals(c.getZrjybh()));
}
case "room":
return roomsOfLesson.getOrDefault(l.getBh(), List.of()).contains(roomFilter);
default:
return false;
}
}
/**
* 历表一行 → 不可排格标记或备注栏条目。
* 备注栏规则:晚上/夜间时段或周末 且 备注显示 → remarks;可排课=0 → blockByCell。
*/
private void applyCalendarRow(String dateStr, String courseClass, boolean schedulable, boolean bzxs,
String text, LocalDate start, LocalDate end,
Map<Integer, String> periodSd,
Map<String, String> blockByCell,
List<TimetableGridVO.Remark> remarks, String scope) {
if (dateStr == null) {
return;
}
LocalDate day;
try {
day = LocalDate.parse(dateStr.substring(0, 10), DAY);
} catch (Exception e) {
return;
}
if (day.isBefore(start) || day.isAfter(end)) {
return;
}
boolean weekend = day.getDayOfWeek() == DayOfWeek.SATURDAY || day.getDayOfWeek() == DayOfWeek.SUNDAY;
List<Integer> jcs = PeriodUtil.parsePeriods(courseClass);
boolean evening = jcs.stream().anyMatch(jc -> {
String sd = periodSd.getOrDefault(jc, "");
return "晚上".equals(sd) || "夜间".equals(sd);
});
if (bzxs && (weekend || evening) && text != null && !text.isEmpty()) {
TimetableGridVO.Remark rm = new TimetableGridVO.Remark();
rm.setDate(day.format(DAY));
rm.setScope(scope);
rm.setText(text);
remarks.add(rm);
return;
}
if (!schedulable) {
for (Integer jc : jcs) {
blockByCell.put(day.format(DAY) + "#" + jc, text == null ? "不可排课" : text);
}
}
}
/** 节次行:节次时间表单节次行优先,空表回退 1-12;再套模板显示开关 */
private List<TimetableGridVO.Period> buildPeriods(KCBMBB tpl) {
List<TimetableGridVO.Period> periods = new ArrayList<>();
List<JCSJB> rows = classPeriodService.singleRows();
if (rows.isEmpty()) {
for (int jc = 1; jc <= 12; jc++) {
TimetableGridVO.Period p = new TimetableGridVO.Period();
p.setJc(jc);
p.setMc("" + jc + "");
p.setSd(defaultSd(jc));
periods.add(p);
}
} else {
for (JCSJB row : rows) {
for (Integer jc : PeriodUtil.parsePeriods(row.getJcsy())) {
TimetableGridVO.Period p = new TimetableGridVO.Period();
p.setJc(jc);
p.setMc(row.getMc() != null ? row.getMc() : "" + jc + "");
p.setSd(row.getSd() != null ? row.getSd() : defaultSd(jc));
p.setKssj(trimTime(row.getKssj()));
p.setJssj(trimTime(row.getJssj()));
periods.add(p);
}
}
periods.sort(Comparator.comparingInt(TimetableGridVO.Period::getJc));
}
if (tpl != null) {
periods = periods.stream().filter(p -> {
if (Boolean.FALSE.equals(tpl.getZXXS78J()) && (p.getJc() == 7 || p.getJc() == 8)) return false;
if (Boolean.FALSE.equals(tpl.getZXXSWS()) && "晚上".equals(p.getSd())) return false;
if (Boolean.FALSE.equals(tpl.getXSYJ()) && "夜间".equals(p.getSd())) return false;
return true;
}).collect(Collectors.toList());
}
return periods;
}
private static String defaultSd(int jc) {
if (jc <= 4) return "上午";
if (jc <= 8) return "下午";
if (jc <= 10) return "晚上";
return "夜间";
}
private static String trimTime(String t) {
if (t == null) return null;
String s = t.trim();
return s.length() > 5 ? s.substring(0, 5) : s;
}
private static String joinName(String mc, String bz) {
if (mc == null || mc.isEmpty()) return bz;
if (bz == null || bz.isEmpty()) return mc;
return mc + "" + bz + "";
}
private static String intToDay(Integer yyyymmdd) {
String s = String.valueOf(yyyymmdd);
if (s.length() != 8) return null;
return s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6);
}
private static LocalDate parseDay(String s) {
if (s == null || s.trim().isEmpty()) return null;
try {
return LocalDate.parse(s.trim().substring(0, 10), DAY);
} catch (Exception e) {
return null;
}
}
private String resolveTeamName(String xydbh) {
if (xydbh == null) return "";
XYDB team = xydbMapper.selectOne(new LambdaQueryWrapper<XYDB>().eq(XYDB::getXydbh, xydbh));
return team != null && team.getXydmc() != null ? team.getXydmc() : xydbh;
}
}
@@ -0,0 +1,101 @@
package com.roomroot.jwgl.vo.kcb;
import lombok.Data;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 周次横版课表网格。
* <p>weeks 按周纵向排列;每周内 行=节次、列=星期一~日;
* 晚上/夜间/周末且「备注显示」的历表事件进 remarks,不占格子。</p>
*/
@Data
public class TimetableGridVO {
/** 维度 */
private String dim;
/** 维度目标显示名(标题用) */
private String targetName;
/** 年度 */
private Integer nd;
private String startDate;
private String endDate;
/** 节次行定义(升序) */
private List<Period> periods = new ArrayList<>();
/** 周块 */
private List<Week> weeks = new ArrayList<>();
/** 备注栏条目(历表「备注显示」且落在晚上/夜间/周末的事件) */
private List<Remark> remarks = new ArrayList<>();
@Data
public static class Period {
/** 节次序号 */
private Integer jc;
/** 显示名(如 第1节 / 第1-2节) */
private String mc;
/** 时段:上午/下午/晚上/夜间 */
private String sd;
/** 开始时间 HH:mm */
private String kssj;
/** 结束时间 HH:mm */
private String jssj;
}
@Data
public static class Week {
private Integer weekNo;
private String startDate;
private String endDate;
/** 固定 7 天(周一~周日) */
private List<Day> days = new ArrayList<>();
}
@Data
public static class Day {
private String date;
/** 1=周一 … 7=周日 */
private Integer weekday;
/** 校历/班历标记不可排课(该日所有节次或部分节次不可排时为 true) */
private Boolean blocked = false;
/** 不可排说明(如假期名称) */
private String blockReason;
/** key=节次序号字符串 */
private Map<String, List<Cell>> cells = new LinkedHashMap<>();
}
@Data
public static class Cell {
private String sskcbbh;
private String sskcbh;
/** 课程名称 */
private String kcmc;
/** 课程简称(格子主显示) */
private String kcjc;
/** 主讲教员姓名 */
private String jyxm;
/** 场地名称 */
private String jsmc;
/** 班次/学员队名(教员、教室维度下展示) */
private String xydmc;
/** 课程类型(选修过滤用) */
private String kclx;
}
@Data
public static class Remark {
private String date;
/** 来源:校历 / 班历 */
private String scope;
/** 备注文本(事件名 + 备注) */
private String text;
}
}
+65
View File
@@ -0,0 +1,65 @@
import request from '@/utils/request'
// 课表模版管理(KCBMBController /kcbmb,手册一第12章)
/** 分页查询模版列表 GET /kcbmb/listpageNum/pageSize/mbmc */
export function listKcbmb(params) {
return request({
url: '/kcbmb/list',
method: 'get',
params
})
}
/** 模版详情 GET /kcbmb/get?bh= */
export function getKcbmb(bh) {
return request({
url: '/kcbmb/get',
method: 'get',
params: { bh }
})
}
/** 可用模版下拉 GET /kcbmb/options → [{bh, mbmc}] */
export function listKcbmbOptions() {
return request({
url: '/kcbmb/options',
method: 'get'
})
}
/** 新增模版 POST /kcbmb/add */
export function addKcbmb(data) {
return request({
url: '/kcbmb/add',
method: 'post',
data
})
}
/** 更新模版 POST /kcbmb/update */
export function updateKcbmb(data) {
return request({
url: '/kcbmb/update',
method: 'post',
data
})
}
/** 删除模版 POST /kcbmb/delete?bh= */
export function deleteKcbmb(bh) {
return request({
url: '/kcbmb/delete',
method: 'post',
params: { bh }
})
}
/** 启用/停用模版 POST /kcbmb/toggle?bh=&enabled= */
export function toggleKcbmb(bh, enabled) {
return request({
url: '/kcbmb/toggle',
method: 'post',
params: { bh, enabled }
})
}
@@ -87,3 +87,30 @@ export function exportClassCourses(data) {
responseType: 'blob' responseType: 'blob'
}) })
} }
/**
* 周次横版课表网格(手册一第12章)
* GET /kcb/grid?dim=&id=&nd=&start=&end=&mbbh=
* dim: semester 班次学期 | team 学员队 | teacher 教员 | room 教室
* 返回 TimetableGridVOperiods / weeks[].days[].cells / remarks
*/
export function getTimetableGrid(params) {
return request({
url: '/kcb/grid',
method: 'get',
params
})
}
/**
* 网格课表矩阵 Excel 导出(每周一个 sheet
* GET /kcb/grid/export(参数同 getTimetableGrid
*/
export function exportTimetableGrid(params) {
return request({
url: '/kcb/grid/export',
method: 'get',
params,
responseType: 'blob'
})
}
@@ -0,0 +1,217 @@
<template>
<div class="tg-view" v-loading="loading">
<template v-if="grid && grid.weeks && grid.weeks.length">
<div v-for="week in grid.weeks" :key="week.weekNo" class="tg-week">
<div class="tg-week-title">
{{ week.weekNo }} {{ week.startDate }} ~ {{ week.endDate }}
</div>
<table class="tg-table">
<thead>
<tr>
<th class="tg-col-period">节次</th>
<th v-for="d in week.days" :key="d.date" :class="{ 'tg-blocked': d.blocked }">
<div>星期{{ weekdayName(d.weekday) }}</div>
<div class="tg-date">{{ d.date }}</div>
<div v-if="d.blocked && d.blockReason" class="tg-block-reason">{{ d.blockReason }}</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="p in grid.periods" :key="p.jc">
<td class="tg-period">
<div class="tg-period-name">{{ p.mc }}</div>
<div v-if="p.kssj" class="tg-period-time">{{ p.kssj }}-{{ p.jssj }}</div>
<div class="tg-period-sd">{{ p.sd }}</div>
</td>
<td
v-for="d in week.days"
:key="d.date"
class="tg-cell"
:class="{ 'tg-blocked': d.blocked }"
>
<div v-for="(cell, i) in cellsOf(d, p.jc)" :key="i" class="tg-lesson">
<div class="tg-course">{{ cell.kcjc || cell.kcmc }}</div>
<div v-if="showTeacher && cell.jyxm" class="tg-sub">{{ cell.jyxm }}</div>
<div v-if="showRoom && cell.jsmc" class="tg-sub">{{ cell.jsmc }}</div>
<div v-if="showTeam && cell.xydmc" class="tg-sub">{{ cell.xydmc }}</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="grid.remarks && grid.remarks.length" class="tg-remarks">
<div class="tg-remarks-title">备注栏</div>
<div v-for="(r, i) in grid.remarks" :key="i" class="tg-remark-row">
<span class="tg-remark-date">{{ r.date }}</span>
<span class="tg-remark-scope">[{{ r.scope }}]</span>
<span>{{ r.text }}</span>
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="请选择维度与日期范围后生成课表" />
</div>
</template>
<script>
const WEEKDAY_NAMES = ['', '一', '二', '三', '四', '五', '六', '日']
/**
* 周次横版课表网格(手册一第12章)。
* 行=节次、列=星期一~日、纵向按周分块;备注栏事件不落格。
* 供「课表生成」页与后续角色化页面(教员端/学员端)复用。
*/
export default {
name: 'TimetableGridView',
props: {
grid: { type: Object, default: null },
loading: { type: Boolean, default: false },
showTeacher: { type: Boolean, default: true },
showRoom: { type: Boolean, default: true },
showTeam: { type: Boolean, default: false }
},
methods: {
weekdayName(wd) {
return WEEKDAY_NAMES[wd] || wd
},
cellsOf(day, jc) {
return (day.cells && day.cells[String(jc)]) || []
}
}
}
</script>
<style scoped lang="scss">
.tg-view {
.tg-week {
margin-bottom: 20px;
}
.tg-week-title {
font-size: 14px;
font-weight: 700;
color: #303133;
margin-bottom: 8px;
}
.tg-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
background: #fff;
th, td {
border: 1px solid #dcdfe6;
padding: 6px;
vertical-align: top;
font-size: 12px;
}
th {
background: #f5f7fa;
text-align: center;
font-weight: 600;
}
.tg-col-period {
width: 96px;
}
.tg-date {
font-size: 11px;
color: #909399;
font-weight: 400;
}
.tg-block-reason {
font-size: 11px;
color: #f56c6c;
font-weight: 400;
}
th.tg-blocked {
background: repeating-linear-gradient(45deg, #f5f7fa, #f5f7fa 6px, #eceff2 6px, #eceff2 12px);
}
td.tg-blocked {
background: repeating-linear-gradient(45deg, #fafafa, #fafafa 6px, #f0f1f2 6px, #f0f1f2 12px);
}
}
.tg-period {
text-align: center;
background: #fafbfc;
.tg-period-name {
font-weight: 600;
}
.tg-period-time {
font-size: 11px;
color: #909399;
}
.tg-period-sd {
font-size: 11px;
color: #c0c4cc;
}
}
.tg-lesson {
margin-bottom: 4px;
padding: 4px;
background: #eef6f2;
border-radius: 3px;
border-left: 3px solid var(--edu-color-primary, #0b6b55);
.tg-course {
font-weight: 600;
color: #303133;
}
.tg-sub {
font-size: 11px;
color: #606266;
}
}
.tg-remarks {
margin-top: 8px;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px 14px;
background: #fff;
.tg-remarks-title {
font-weight: 700;
font-size: 13px;
margin-bottom: 6px;
}
.tg-remark-row {
font-size: 12px;
color: #606266;
line-height: 1.8;
.tg-remark-date {
color: #909399;
margin-right: 6px;
}
.tg-remark-scope {
color: #e6a23c;
margin-right: 6px;
}
}
}
}
@media print {
.tg-view {
.tg-week {
page-break-inside: avoid;
}
}
}
</style>
@@ -0,0 +1,259 @@
<template>
<div class="app-container tg-page">
<div class="section-card no-print">
<div class="section-header">
<div>
<div class="section-title">课表生成</div>
<div class="section-desc"> 班次学期 / 学员队 / 教员 / 教室 生成周次横版课表可套用课表模板导出矩阵 Excel 或直接打印</div>
</div>
<div class="header-actions">
<el-button type="success" size="mini" icon="el-icon-download" :loading="exporting" :disabled="!grid" @click="handleExport">导出 Excel</el-button>
<el-button type="primary" size="mini" icon="el-icon-printer" :disabled="!grid" @click="handlePrint">打印</el-button>
</div>
</div>
<el-form inline class="filter-form" @submit.native.prevent>
<el-form-item label="维度">
<el-select v-model="dim" style="width: 130px" @change="handleDimChange">
<el-option label="班次学期" value="semester" />
<el-option label="学员队" value="team" />
<el-option label="教员" value="teacher" />
<el-option label="教室" value="room" />
</el-select>
</el-form-item>
<el-form-item :label="dimLabel">
<el-select v-model="targetId" :placeholder="'请选择' + dimLabel" clearable filterable :loading="optionsLoading" style="width: 240px">
<el-option v-for="o in targetOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="默认学期/本周"
end-placeholder="默认学期/本周"
value-format="yyyy-MM-dd"
style="width: 250px"
/>
</el-form-item>
<el-form-item label="课表模板">
<el-select v-model="mbbh" placeholder="默认样式" clearable style="width: 160px">
<el-option v-for="t in templateOptions" :key="t.bh" :label="t.mbmc" :value="t.bh" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="mini" icon="el-icon-search" :loading="loading" @click="handleGenerate">生成课表</el-button>
</el-form-item>
<el-form-item label="格内显示">
<el-checkbox v-model="showTeacher">教员</el-checkbox>
<el-checkbox v-model="showRoom">场地</el-checkbox>
<el-checkbox v-model="showTeam">班次</el-checkbox>
</el-form-item>
</el-form>
</div>
<div class="section-card">
<div v-if="grid" class="grid-title">
{{ grid.targetName }}{{ grid.startDate }} ~ {{ grid.endDate }}
<el-tag v-if="mbbh" size="mini" type="warning" class="plan-tag">计划课表 · {{ templateName }}</el-tag>
<el-tag v-else size="mini" type="success" class="plan-tag">运行课表</el-tag>
</div>
<timetable-grid-view
:grid="grid"
:loading="loading"
:show-teacher="showTeacher"
:show-room="showRoom"
:show-team="showTeam"
/>
</div>
</div>
</template>
<script>
import TimetableGridView from '@/components/TimetableGridView'
import { getTimetableGrid, exportTimetableGrid } from '@/api/teachBusiness/timetable'
import { listKcbmbOptions } from '@/api/teachBusiness/kcbmb'
import { allSemester } from '@/api/studentRecords/semester'
import { listTeam } from '@/api/studentRecords/team'
import { listTeacher } from '@/api/teachOffice/teacher'
import { listAllClassroom } from '@/api/teachBusiness/classroom'
import { saveAs } from 'file-saver'
const DIM_LABELS = { semester: '班次学期', team: '学员队', teacher: '教员', room: '教室' }
export default {
name: 'TimetableGrid',
components: { TimetableGridView },
data() {
return {
dim: 'semester',
targetId: undefined,
targetOptions: [],
optionsLoading: false,
dateRange: null,
mbbh: undefined,
templateOptions: [],
showTeacher: true,
showRoom: true,
showTeam: false,
loading: false,
exporting: false,
grid: null
}
},
computed: {
dimLabel() {
return DIM_LABELS[this.dim] || '对象'
},
templateName() {
const t = this.templateOptions.find(o => o.bh === this.mbbh)
return t ? t.mbmc : ''
}
},
created() {
this.loadTemplates()
this.loadTargetOptions()
},
methods: {
loadTemplates() {
listKcbmbOptions().then(response => {
this.templateOptions = response.data || []
}).catch(() => {})
},
handleDimChange() {
this.targetId = undefined
this.targetOptions = []
this.loadTargetOptions()
},
loadTargetOptions() {
this.optionsLoading = true
const done = list => {
this.targetOptions = list
this.optionsLoading = false
}
const fail = () => {
this.targetOptions = []
this.optionsLoading = false
}
if (this.dim === 'semester') {
allSemester().then(response => {
done((response.data || []).map(s => ({
value: s.bh,
label: `${s.xydbh}${s.nd}年度 第${s.xqdc}学期)`
})))
}).catch(fail)
} else if (this.dim === 'team') {
listTeam({ pageNum: 1, pageSize: 500 }).then(response => {
const page = response.data || {}
done((page.records || []).map(t => ({ value: t.xydbh, label: t.xydmc || t.xydbh })))
}).catch(fail)
} else if (this.dim === 'teacher') {
listTeacher({ pageNum: 1, pageSize: 500 }).then(response => {
const page = response.data || {}
done((page.records || []).map(t => ({ value: t.jybh, label: (t.jyxm || '-') + '' + t.jybh + '' })))
}).catch(fail)
} else {
listAllClassroom().then(response => {
done((response.data || []).map(r => ({ value: r.jsbh, label: (r.jsmc || '-') + '' + r.jsbh + '' })))
}).catch(fail)
}
},
buildQuery() {
const q = { dim: this.dim, id: this.targetId, mbbh: this.mbbh }
if (this.dateRange && this.dateRange.length === 2) {
q.start = this.dateRange[0]
q.end = this.dateRange[1]
}
return q
},
handleGenerate() {
if (!this.targetId) {
this.$message.warning('请选择' + this.dimLabel)
return
}
this.loading = true
getTimetableGrid(this.buildQuery()).then(response => {
this.grid = response.data
}).catch(() => {
this.grid = null
}).finally(() => {
this.loading = false
})
},
handleExport() {
this.exporting = true
exportTimetableGrid(this.buildQuery()).then(blob => {
saveAs(blob, `课表_${this.grid && this.grid.targetName ? this.grid.targetName : this.dim}.xlsx`)
}).catch(() => {
this.$message.error('导出失败')
}).finally(() => {
this.exporting = false
})
},
handlePrint() {
window.print()
}
}
}
</script>
<style scoped lang="scss">
.tg-page {
.section-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 16px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
.header-actions {
display: flex;
gap: 8px;
}
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #303133;
}
.section-desc {
margin-top: 6px;
font-size: 13px;
color: #909399;
}
.filter-form ::v-deep .el-form-item {
margin-bottom: 0;
}
.grid-title {
font-size: 15px;
font-weight: 700;
color: #303133;
text-align: center;
margin-bottom: 14px;
.plan-tag {
margin-left: 10px;
vertical-align: middle;
}
}
}
@media print {
.tg-page .no-print {
display: none;
}
}
</style>
@@ -0,0 +1,292 @@
<template>
<div class="app-container tpl-page">
<div class="section-card">
<div class="section-header">
<div>
<div class="section-title">课表模板管理</div>
<div class="section-desc">维护课表输出样式模板课表生成页可选择模板套用其显示开关78 / 晚上 / 夜间 / 选修 / 显示天数</div>
</div>
<el-button type="primary" size="mini" icon="el-icon-plus" @click="handleAdd">新增模板</el-button>
</div>
<el-form inline class="filter-form" @submit.native.prevent>
<el-form-item label="模板名称">
<el-input v-model="queryMbmc" placeholder="按名称模糊" clearable style="width: 200px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="mini" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button size="mini" icon="el-icon-refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="section-card">
<el-table v-loading="loading" :data="tableData" border stripe size="mini">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="mbmc" label="模板名称" min-width="160" show-overflow-tooltip />
<el-table-column label="78节" width="70" align="center">
<template slot-scope="scope">{{ boolText(scope.row.zxxs78j) }}</template>
</el-table-column>
<el-table-column label="晚上" width="70" align="center">
<template slot-scope="scope">{{ boolText(scope.row.zxxsws) }}</template>
</el-table-column>
<el-table-column label="夜间" width="70" align="center">
<template slot-scope="scope">{{ boolText(scope.row.xsyj) }}</template>
</el-table-column>
<el-table-column label="含选修" width="70" align="center">
<template slot-scope="scope">{{ boolText(scope.row.kbbhxx) }}</template>
</el-table-column>
<el-table-column prop="zxxsts" label="显示天数" width="80" align="center" />
<el-table-column prop="wkzwzf" label="无课站位" width="90" align="center" />
<el-table-column label="可用" width="80" align="center">
<template slot-scope="scope">
<el-switch
:value="isTrue(scope.row.mbky)"
active-text=""
@change="v => handleToggle(scope.row, v)"
/>
</template>
</el-table-column>
<el-table-column prop="gxsj" label="更新时间" width="150" align="center">
<template slot-scope="scope">{{ fmt(scope.row.gxsj) }}</template>
</el-table-column>
<el-table-column prop="bz" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="130" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="text" size="mini" icon="el-icon-delete" class="danger-text" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > 0"
class="pagination"
background
layout="total, sizes, prev, pager, next"
:current-page="pageNum"
:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="620px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="130px">
<el-form-item label="模板名称" prop="mbmc">
<el-input v-model="form.mbmc" placeholder="如:标准周课表" maxlength="50" />
</el-form-item>
<el-form-item label="显示 7-8 节">
<el-switch v-model="form.zxxs78j" />
</el-form-item>
<el-form-item label="显示晚上时段">
<el-switch v-model="form.zxxsws" />
</el-form-item>
<el-form-item label="显示夜间时段">
<el-switch v-model="form.xsyj" />
</el-form-item>
<el-form-item label="包含选修课">
<el-switch v-model="form.kbbhxx" />
</el-form-item>
<el-form-item label="一周显示天数">
<el-input-number v-model="form.zxxsts" :min="1" :max="7" />
<span class="form-tip">5 = 只显示工作日</span>
</el-form-item>
<el-form-item label="无课站位字符">
<el-input v-model="form.wkzwzf" placeholder="留空则空格,如:—" maxlength="10" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.bz" type="textarea" :rows="2" maxlength="200" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false"> </el-button>
<el-button type="primary" :loading="saving" @click="handleSave"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listKcbmb, addKcbmb, updateKcbmb, deleteKcbmb, toggleKcbmb } from '@/api/teachBusiness/kcbmb'
export default {
name: 'TimetableTemplate',
data() {
return {
loading: false,
saving: false,
queryMbmc: '',
tableData: [],
total: 0,
pageNum: 1,
pageSize: 20,
dialogVisible: false,
dialogTitle: '新增模板',
form: this.emptyForm(),
rules: {
mbmc: [{ required: true, message: '请输入模板名称', trigger: 'blur' }]
}
}
},
created() {
this.loadList()
},
methods: {
emptyForm() {
return {
bh: undefined,
mbmc: '',
zxxs78j: true,
zxxsws: true,
xsyj: false,
kbbhxx: true,
zxxsts: 7,
wkzwzf: '',
bz: ''
}
},
isTrue(v) {
return v === true || v === 1 || v === '1' || v === 'true'
},
boolText(v) {
return this.isTrue(v) ? '显示' : '隐藏'
},
fmt(t) {
return (t || '').replace('T', ' ').substring(0, 16)
},
loadList() {
this.loading = true
listKcbmb({ pageNum: this.pageNum, pageSize: this.pageSize, mbmc: this.queryMbmc }).then(response => {
const page = response.data || {}
this.tableData = page.records || []
this.total = page.total || 0
}).catch(() => {
this.tableData = []
this.total = 0
}).finally(() => {
this.loading = false
})
},
handleQuery() {
this.pageNum = 1
this.loadList()
},
handleReset() {
this.queryMbmc = ''
this.pageNum = 1
this.loadList()
},
handlePageChange(p) {
this.pageNum = p
this.loadList()
},
handleSizeChange(s) {
this.pageSize = s
this.pageNum = 1
this.loadList()
},
handleAdd() {
this.dialogTitle = '新增模板'
this.form = this.emptyForm()
this.dialogVisible = true
},
handleEdit(row) {
this.dialogTitle = '编辑模板'
this.form = {
bh: row.bh,
mbmc: row.mbmc,
zxxs78j: this.isTrue(row.zxxs78j),
zxxsws: this.isTrue(row.zxxsws),
xsyj: this.isTrue(row.xsyj),
kbbhxx: this.isTrue(row.kbbhxx),
zxxsts: row.zxxsts || 7,
wkzwzf: row.wkzwzf || '',
bz: row.bz || ''
}
this.dialogVisible = true
},
handleSave() {
this.$refs.form.validate(valid => {
if (!valid) return
this.saving = true
const fn = this.form.bh ? updateKcbmb : addKcbmb
fn(this.form).then(() => {
this.$message.success('保存成功')
this.dialogVisible = false
this.loadList()
}).finally(() => {
this.saving = false
})
})
},
handleToggle(row, enabled) {
toggleKcbmb(row.bh, enabled).then(() => {
row.mbky = enabled
this.$message.success(enabled ? '已启用' : '已停用')
}).catch(() => {})
},
handleDelete(row) {
this.$confirm(`确认删除模板「${row.mbmc}」?`, '提示', { type: 'warning' }).then(() => {
return deleteKcbmb(row.bh)
}).then(() => {
this.$message.success('已删除')
this.loadList()
}).catch(() => {})
}
}
}
</script>
<style scoped lang="scss">
.tpl-page {
.section-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 16px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #303133;
}
.section-desc {
margin-top: 6px;
font-size: 13px;
color: #909399;
}
.filter-form ::v-deep .el-form-item {
margin-bottom: 0;
}
.form-tip {
margin-left: 8px;
font-size: 12px;
color: #909399;
}
.danger-text {
color: #f56c6c;
}
.pagination {
margin-top: 12px;
text-align: right;
}
}
</style>