自动排课:保留手工安排,按教师、行政班、周次、节次、容量和场地约束补排。

课程约束:可限定校区、教学楼、指定教室、允许上课日、最早/最晚节次。
无教室课程:教室可为空,但仍校验教师和班级冲突。
作息维护:按学期维护节次、上下课时间和启用状态。
发布保护:发布前重新检查全部约束,并阻止学时未排满的课表发布。
工作台升级:增加“排课规则与作息”“自动排课”入口,自动生成后仍支持手工微调。
同时兼容 SQLite 和 MySQL,已生成正式迁移。
This commit is contained in:
2026-07-24 19:19:18 +08:00 Unverified
parent d7cf3f9e76
commit 6538b6080a
19 changed files with 4344 additions and 37 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持学期作息维护、单双周周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。 权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
@@ -349,6 +349,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
.Select(x => new .Select(x => new
{ {
x.Id, x.Code, x.Name, x.BuildingId, x.Id, x.Code, x.Name, x.BuildingId,
CampusId = x.Building!.CampusId,
BuildingName = x.Building!.Name, BuildingName = x.Building!.Name,
CampusName = x.Building.Campus!.Name, CampusName = x.Building.Campus!.Name,
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
@@ -376,7 +376,7 @@ public sealed class CourseSelectionsController(
entry.StartWeek, entry.StartWeek,
entry.EndWeek, entry.EndWeek,
entry.WeekPattern, entry.WeekPattern,
entry.Classroom!.Name)) entry.Classroom == null ? "不占用教室" : entry.Classroom.Name))
.ToList())) .ToList()))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -0,0 +1,220 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
[Route("api/schedules")]
public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{
[HttpGet("time-slots")]
public async Task<ActionResult> GetTimeSlots(
Guid academicTermId,
CancellationToken cancellationToken) =>
Ok(await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == academicTermId)
.OrderBy(x => x.PeriodNumber)
.Select(x => new
{
x.Id,
x.PeriodNumber,
x.Name,
StartsAt = x.StartsAt.ToString("HH:mm"),
EndsAt = x.EndsAt.ToString("HH:mm"),
x.IsEnabled
})
.ToListAsync(cancellationToken));
[HttpPut("time-slots/{academicTermId:guid}")]
public async Task<ActionResult> ReplaceTimeSlots(
Guid academicTermId,
IReadOnlyList<ScheduleTimeSlotRequest> requests,
CancellationToken cancellationToken)
{
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == academicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("学期不存在或已停用。");
if (requests.Count == 0)
return ValidationProblem("上课时间表至少需要一个节次。");
if (requests.Select(x => x.PeriodNumber).Distinct().Count() != requests.Count)
return ValidationProblem("节次编号不能重复。");
if (requests.Any(x => x.StartsAt >= x.EndsAt))
return ValidationProblem("每个节次的上课时间必须早于下课时间。");
var ordered = requests.OrderBy(x => x.StartsAt).ToList();
if (ordered.Zip(ordered.Skip(1)).Any(pair => pair.First.EndsAt > pair.Second.StartsAt))
return ValidationProblem("上课时间段不能相互重叠。");
var existing = await db.ScheduleTimeSlots
.Where(x => x.AcademicTermId == academicTermId)
.ToListAsync(cancellationToken);
db.ScheduleTimeSlots.RemoveRange(existing);
db.ScheduleTimeSlots.AddRange(requests.Select(request => new ScheduleTimeSlot
{
AcademicTermId = academicTermId,
PeriodNumber = request.PeriodNumber,
Name = request.Name.Trim(),
StartsAt = request.StartsAt,
EndsAt = request.EndsAt,
IsEnabled = request.IsEnabled
}));
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("constraints")]
public async Task<ActionResult> GetConstraints(
Guid academicTermId,
CancellationToken cancellationToken)
{
var tasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == academicTermId &&
x.Status == TeachingTaskStatus.Published)
.OrderBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
CourseName = x.Course!.Name,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Capacity,
x.WeeklyHours
})
.ToListAsync(cancellationToken);
var taskIds = tasks.Select(x => x.Id).ToList();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => taskIds.Contains(x.TeachingTaskId))
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
return Ok(tasks.Select(task =>
{
constraints.TryGetValue(task.Id, out var constraint);
return new
{
task.Id,
task.TaskNumber,
task.Name,
task.CourseName,
task.TeacherNames,
task.Capacity,
task.WeeklyHours,
RequiresClassroom = constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId,
constraint?.RequiredBuildingId,
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
constraint?.EarliestPeriod,
constraint?.LatestPeriod,
AllowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) ?? []
};
}));
}
[HttpPut("constraints/{teachingTaskId:guid}")]
public async Task<ActionResult> SaveConstraint(
Guid teachingTaskId,
TeachingTaskScheduleConstraintRequest request,
CancellationToken cancellationToken)
{
if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7))
return ValidationProblem("允许上课日必须位于星期一至星期日。");
if (request.EarliestPeriod.HasValue &&
request.LatestPeriod.HasValue &&
request.EarliestPeriod > request.LatestPeriod)
return ValidationProblem("最早节次不能晚于最晚节次。");
var task = await db.TeachingTasks
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound();
Building? building = null;
if (request.RequiredBuildingId.HasValue)
{
building = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == request.RequiredBuildingId && x.IsEnabled,
cancellationToken);
if (building is null) return ValidationProblem("指定教学楼不存在或已停用。");
if (request.RequiredCampusId.HasValue &&
building.CampusId != request.RequiredCampusId)
return ValidationProblem("指定教学楼不属于所选校区。");
}
if (request.RequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(
x => x.Id == request.RequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定校区不存在或已停用。");
var allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => request.AllowedClassroomIds.Contains(x.Id) && x.IsEnabled)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count())
return ValidationProblem("部分指定教室不存在或已停用。");
if (building is not null && allowedRooms.Any(x => x.BuildingId != building.Id))
return ValidationProblem("指定教室必须位于所选教学楼。");
if (request.RequiredCampusId.HasValue &&
allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。");
var constraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (constraint is null)
{
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = teachingTaskId };
db.TeachingTaskScheduleConstraints.Add(constraint);
}
constraint.RequiresClassroom = request.RequiresClassroom;
constraint.RequiredCampusId = request.RequiresClassroom
? request.RequiredCampusId
: null;
constraint.RequiredBuildingId = request.RequiresClassroom
? request.RequiredBuildingId
: null;
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
? null
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
constraint.EarliestPeriod = request.EarliestPeriod;
constraint.LatestPeriod = request.LatestPeriod;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
constraint.AllowedClassrooms = request.RequiresClassroom
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: [];
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private static int[] ParseDays(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(int.Parse)
.ToArray();
}
public sealed record ScheduleTimeSlotRequest(
[Range(1, 30)] int PeriodNumber,
[Required, MaxLength(40)] string Name,
TimeOnly StartsAt,
TimeOnly EndsAt,
bool IsEnabled);
public sealed record TeachingTaskScheduleConstraintRequest(
bool RequiresClassroom,
Guid? RequiredCampusId,
Guid? RequiredBuildingId,
IReadOnlyList<Guid> AllowedClassroomIds,
IReadOnlyList<int> AllowedDayOfWeeks,
[Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod);
+117 -11
View File
@@ -12,7 +12,9 @@ namespace Jiaowu.Api.Controllers;
[ApiController] [ApiController]
[Authorize(Roles = ManagementRoles)] [Authorize(Roles = ManagementRoles)]
[Route("api/schedules")] [Route("api/schedules")]
public sealed class SchedulesController(AppDbContext db) : ControllerBase public sealed class SchedulesController(
AppDbContext db,
AutomaticScheduleGenerator scheduleGenerator) : ControllerBase
{ {
private const string ManagementRoles = private const string ManagementRoles =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -76,9 +78,15 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
ClassNames = entry.TeachingTask.Classes ClassNames = entry.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name), .Select(item => item.AdministrativeClass!.Name),
entry.ClassroomId, entry.ClassroomId,
ClassroomName = entry.Classroom!.Name, ClassroomName = entry.Classroom == null
BuildingName = entry.Classroom.Building!.Name, ? null
CampusName = entry.Classroom.Building.Campus!.Name, : entry.Classroom.Name,
BuildingName = entry.Classroom == null
? null
: entry.Classroom.Building!.Name,
CampusName = entry.Classroom == null
? null
: entry.Classroom.Building!.Campus!.Name,
entry.DayOfWeek, entry.DayOfWeek,
entry.StartPeriod, entry.StartPeriod,
entry.PeriodCount, entry.PeriodCount,
@@ -194,6 +202,41 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
if (plan.Entries.Count == 0) if (plan.Entries.Count == 0)
return ConflictProblem("排课版本中至少需要一条课表安排。"); return ConflictProblem("排课版本中至少需要一条课表安排。");
foreach (var entry in plan.Entries)
{
var validation = await ValidateEntryAsync(
plan,
entry.Id,
new ScheduleEntryRequest(
entry.TeachingTaskId,
entry.ClassroomId,
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Notes),
cancellationToken);
if (validation is not null) return validation;
}
var requiredTasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
.ToListAsync(cancellationToken);
var scheduledHours = plan.Entries
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
var incomplete = requiredTasks.FirstOrDefault(task =>
!scheduledHours.TryGetValue(task.Id, out var hours) ||
hours < task.WeeklyHours);
if (incomplete is not null)
return ConflictProblem(
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。");
var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList()); var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList());
if (conflict is not null) return ConflictProblem(conflict); if (conflict is not null) return ConflictProblem(conflict);
@@ -227,6 +270,17 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
return await SaveAsync(entry.Id, true, cancellationToken); return await SaveAsync(entry.Id, true, cancellationToken);
} }
[HttpPost("plans/{planId:guid}/auto-schedule")]
public async Task<ActionResult> AutoSchedule(
Guid planId,
CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var result = await scheduleGenerator.GenerateAsync(plan, cancellationToken);
return Ok(result);
}
[HttpPut("plans/{planId:guid}/entries/{entryId:guid}")] [HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
public async Task<ActionResult> UpdateEntry( public async Task<ActionResult> UpdateEntry(
Guid planId, Guid planId,
@@ -286,8 +340,15 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
{ {
if (request.StartWeek > request.EndWeek) if (request.StartWeek > request.EndWeek)
return ValidationProblem("开始周不能晚于结束周。"); return ValidationProblem("开始周不能晚于结束周。");
if (request.StartPeriod + request.PeriodCount - 1 > 12) var activePeriods = await db.ScheduleTimeSlots.AsNoTracking()
return ValidationProblem("结束节次不能超过第 12 节。"); .Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.Select(x => x.PeriodNumber)
.ToListAsync(cancellationToken);
if (activePeriods.Count == 0)
return ValidationProblem("请先维护该学期的上课时间表。");
if (Enumerable.Range(request.StartPeriod, request.PeriodCount)
.Any(period => !activePeriods.Contains(period)))
return ValidationProblem("所选节次包含未启用或不存在的上课时间。");
var task = await db.TeachingTasks.AsNoTracking() var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers) .Include(x => x.Teachers)
@@ -302,17 +363,55 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
var classroom = await db.Classrooms.AsNoTracking() var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(
x => x.TeachingTaskId == request.TeachingTaskId,
cancellationToken);
var requiresClassroom = constraint?.RequiresClassroom ?? true;
if (requiresClassroom && !request.ClassroomId.HasValue)
return ValidationProblem("该课程需要占用教室,请选择教室。");
if (!requiresClassroom && request.ClassroomId.HasValue)
return ValidationProblem("该课程已设置为不占用教室,请清空教室。");
var allowedDays = ParseDays(constraint?.AllowedDayOfWeeks);
if (allowedDays.Count > 0 && !allowedDays.Contains(request.DayOfWeek))
return ValidationProblem("所选星期不在该教学任务允许的上课日内。");
if (constraint?.EarliestPeriod is int earliest &&
request.StartPeriod < earliest)
return ValidationProblem($"该教学任务最早只能从第 {earliest} 节开始。");
if (constraint?.LatestPeriod is int latest &&
request.StartPeriod + request.PeriodCount - 1 > latest)
return ValidationProblem($"该教学任务最晚必须在第 {latest} 节结束。");
Classroom? classroom = null;
if (request.ClassroomId.HasValue)
{
classroom = await db.Classrooms.AsNoTracking()
.Include(x => x.Building)
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
x => x.Id == request.ClassroomId && x.IsEnabled, x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken); cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。"); if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
return ValidationProblem("所选教室不在该课程指定的校区。");
if (constraint?.RequiredBuildingId is Guid buildingId &&
classroom.BuildingId != buildingId)
return ValidationProblem("所选教室不在该课程指定的教学楼。");
var allowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (allowedClassroomIds.Count > 0 &&
!allowedClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
}
var studentCount = task.Classes.Sum(x => var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student => x.AdministrativeClass!.Students.Count(student =>
student.Status == StudentStatus.Active)); student.Status == StudentStatus.Active));
if (studentCount > classroom.Capacity) var requiredCapacity = Math.Max(task.Capacity, studentCount);
if (classroom is not null && requiredCapacity > classroom.Capacity)
return ConflictProblem( return ConflictProblem(
$"教室容量不足:教学班有 {studentCount} 名学生,教室仅容纳 {classroom.Capacity} 人。"); $"教室容量不足:教学任务容量为 {requiredCapacity} 人,教室仅容纳 {classroom.Capacity} 人。");
var candidates = await db.ScheduleEntries.AsNoTracking() var candidates = await db.ScheduleEntries.AsNoTracking()
.Where(x => .Where(x =>
@@ -356,6 +455,13 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
Notes = Normalize(request.Notes) Notes = Normalize(request.Notes)
}; };
private static HashSet<int> ParseDays(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(int.Parse)
.ToHashSet();
private async Task<ActionResult> SaveAsync( private async Task<ActionResult> SaveAsync(
Guid id, Guid id,
bool created, bool created,
@@ -396,9 +502,9 @@ public sealed record CloneSchedulePlanRequest(
public sealed record ScheduleEntryRequest( public sealed record ScheduleEntryRequest(
Guid TeachingTaskId, Guid TeachingTaskId,
Guid ClassroomId, Guid? ClassroomId,
[Range(1, 7)] int DayOfWeek, [Range(1, 7)] int DayOfWeek,
[Range(1, 12)] int StartPeriod, [Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount, [Range(1, 6)] int PeriodCount,
[Range(1, 30)] int StartWeek, [Range(1, 30)] int StartWeek,
[Range(1, 30)] int EndWeek, [Range(1, 30)] int EndWeek,
@@ -20,7 +20,7 @@ public sealed class ScheduleEntry : EntityBase
public SchedulePlan? SchedulePlan { get; set; } public SchedulePlan? SchedulePlan { get; set; }
public Guid TeachingTaskId { get; set; } public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; } public TeachingTask? TeachingTask { get; set; }
public Guid ClassroomId { get; set; } public Guid? ClassroomId { get; set; }
public Classroom? Classroom { get; set; } public Classroom? Classroom { get; set; }
public int DayOfWeek { get; set; } public int DayOfWeek { get; set; }
public int StartPeriod { get; set; } public int StartPeriod { get; set; }
@@ -31,6 +31,40 @@ public sealed class ScheduleEntry : EntityBase
public string? Notes { get; set; } public string? Notes { get; set; }
} }
public sealed class ScheduleTimeSlot : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public int PeriodNumber { get; set; }
public required string Name { get; set; }
public TimeOnly StartsAt { get; set; }
public TimeOnly EndsAt { get; set; }
public bool IsEnabled { get; set; } = true;
}
public sealed class TeachingTaskScheduleConstraint : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public bool RequiresClassroom { get; set; } = true;
public Guid? RequiredCampusId { get; set; }
public Campus? RequiredCampus { get; set; }
public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; }
public string? AllowedDayOfWeeks { get; set; }
public int? EarliestPeriod { get; set; }
public int? LatestPeriod { get; set; }
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
}
public sealed class TeachingTaskAllowedClassroom
{
public Guid TeachingTaskScheduleConstraintId { get; set; }
public TeachingTaskScheduleConstraint? TeachingTaskScheduleConstraint { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
}
public enum SchedulePlanStatus public enum SchedulePlanStatus
{ {
Draft = 1, Draft = 1,
@@ -29,6 +29,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>(); public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>(); public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>(); public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints =>
Set<TeachingTaskScheduleConstraint>();
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
Set<TeachingTaskAllowedClassroom>();
public DbSet<CourseSelectionRound> CourseSelectionRounds => public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>(); Set<CourseSelectionRound>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings => public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
@@ -293,6 +298,51 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.WithMany() .WithMany()
.HasForeignKey(x => x.TeachingTaskId) .HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<ScheduleTimeSlot>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(40);
entity.HasIndex(x => new { x.AcademicTermId, x.PeriodNumber }).IsUnique();
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<TeachingTaskScheduleConstraint>(entity =>
{
entity.Property(x => x.AllowedDayOfWeeks).HasMaxLength(20);
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.RequiredCampus)
.WithMany()
.HasForeignKey(x => x.RequiredCampusId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.RequiredBuilding)
.WithMany()
.HasForeignKey(x => x.RequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskAllowedClassroom>(entity =>
{
entity.HasKey(x => new
{
x.TeachingTaskScheduleConstraintId,
x.ClassroomId
});
entity.HasOne(x => x.TeachingTaskScheduleConstraint)
.WithMany(x => x.AllowedClassrooms)
.HasForeignKey(x => x.TeachingTaskScheduleConstraintId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Classroom) entity.HasOne(x => x.Classroom)
.WithMany() .WithMany()
.HasForeignKey(x => x.ClassroomId) .HasForeignKey(x => x.ClassroomId)
@@ -19,6 +19,8 @@ public sealed class DevelopmentSqliteMigrator(
private const string DegreeAwardsMigration = "20260724_11_degree_awards"; private const string DegreeAwardsMigration = "20260724_11_degree_awards";
private const string GraduationClearanceMigration = "20260724_12_graduation_clearance"; private const string GraduationClearanceMigration = "20260724_12_graduation_clearance";
private const string CourseCategoriesMigration = "20260724_13_course_categories"; private const string CourseCategoriesMigration = "20260724_13_course_categories";
private const string SchedulingOptimizationMigration =
"20260724_14_scheduling_optimization";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -107,6 +109,18 @@ public sealed class DevelopmentSqliteMigrator(
"ALTER TABLE", StringComparison.OrdinalIgnoreCase)) "ALTER TABLE", StringComparison.OrdinalIgnoreCase))
: CourseCategoriesStatements, : CourseCategoriesStatements,
cancellationToken); cancellationToken);
var schedulingOptimizationExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ScheduleTimeSlots'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
SchedulingOptimizationMigration,
schedulingOptimizationExists ? [] : SchedulingOptimizationStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -814,4 +828,129 @@ public sealed class DevelopmentSqliteMigrator(
ON "Courses" ("CourseCategoryId"); ON "Courses" ("CourseCategoryId");
""" """
]; ];
private static readonly string[] SchedulingOptimizationStatements =
[
"""
CREATE TABLE "ScheduleEntries_New" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ScheduleEntries" PRIMARY KEY,
"SchedulePlanId" TEXT NOT NULL,
"TeachingTaskId" TEXT NOT NULL,
"ClassroomId" TEXT NULL,
"DayOfWeek" INTEGER NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"StartWeek" INTEGER NOT NULL,
"EndWeek" INTEGER NOT NULL,
"WeekPattern" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ScheduleEntries_SchedulePlans_SchedulePlanId"
FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ScheduleEntries_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ScheduleEntries_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
ON DELETE SET NULL
);
""",
"""
INSERT INTO "ScheduleEntries_New"
SELECT "Id", "SchedulePlanId", "TeachingTaskId", "ClassroomId",
"DayOfWeek", "StartPeriod", "PeriodCount", "StartWeek", "EndWeek",
"WeekPattern", "Notes", "CreatedAt", "UpdatedAt"
FROM "ScheduleEntries";
""",
"""DROP TABLE "ScheduleEntries";""",
"""ALTER TABLE "ScheduleEntries_New" RENAME TO "ScheduleEntries";""",
"""
CREATE INDEX "IX_ScheduleEntries_SchedulePlanId_DayOfWeek_StartPeriod"
ON "ScheduleEntries" ("SchedulePlanId", "DayOfWeek", "StartPeriod");
""",
"""
CREATE INDEX "IX_ScheduleEntries_TeachingTaskId"
ON "ScheduleEntries" ("TeachingTaskId");
""",
"""
CREATE INDEX "IX_ScheduleEntries_ClassroomId"
ON "ScheduleEntries" ("ClassroomId");
""",
"""
CREATE TABLE "ScheduleTimeSlots" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ScheduleTimeSlots" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL,
"PeriodNumber" INTEGER NOT NULL,
"Name" TEXT NOT NULL,
"StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL,
"IsEnabled" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ScheduleTimeSlots_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id")
ON DELETE CASCADE
);
""",
"""
CREATE UNIQUE INDEX "IX_ScheduleTimeSlots_AcademicTermId_PeriodNumber"
ON "ScheduleTimeSlots" ("AcademicTermId", "PeriodNumber");
""",
"""
CREATE TABLE "TeachingTaskScheduleConstraints" (
"Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskScheduleConstraints" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"RequiresClassroom" INTEGER NOT NULL,
"RequiredCampusId" TEXT NULL,
"RequiredBuildingId" TEXT NULL,
"AllowedDayOfWeeks" TEXT NULL,
"EarliestPeriod" INTEGER NULL,
"LatestPeriod" INTEGER NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_TeachingTaskScheduleConstraints_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_TeachingTaskScheduleConstraints_Campuses_RequiredCampusId"
FOREIGN KEY ("RequiredCampusId") REFERENCES "Campuses" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_TeachingTaskScheduleConstraints_Buildings_RequiredBuildingId"
FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "IX_TeachingTaskScheduleConstraints_TeachingTaskId"
ON "TeachingTaskScheduleConstraints" ("TeachingTaskId");
""",
"""
CREATE INDEX "IX_TeachingTaskScheduleConstraints_RequiredCampusId"
ON "TeachingTaskScheduleConstraints" ("RequiredCampusId");
""",
"""
CREATE INDEX "IX_TeachingTaskScheduleConstraints_RequiredBuildingId"
ON "TeachingTaskScheduleConstraints" ("RequiredBuildingId");
""",
"""
CREATE TABLE "TeachingTaskAllowedClassrooms" (
"TeachingTaskScheduleConstraintId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
CONSTRAINT "PK_TeachingTaskAllowedClassrooms"
PRIMARY KEY ("TeachingTaskScheduleConstraintId", "ClassroomId"),
CONSTRAINT "FK_TeachingTaskAllowedClassrooms_Constraints"
FOREIGN KEY ("TeachingTaskScheduleConstraintId")
REFERENCES "TeachingTaskScheduleConstraints" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_TeachingTaskAllowedClassrooms_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_TeachingTaskAllowedClassrooms_ClassroomId"
ON "TeachingTaskAllowedClassrooms" ("ClassroomId");
"""
];
} }
@@ -0,0 +1,187 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class SchedulingOptimization : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
table: "ScheduleEntries");
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ScheduleEntries",
type: "char(36)",
nullable: true,
oldClrType: typeof(Guid),
oldType: "char(36)");
migrationBuilder.CreateTable(
name: "ScheduleTimeSlots",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
PeriodNumber = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
StartsAt = table.Column<TimeOnly>(type: "time", nullable: false),
EndsAt = table.Column<TimeOnly>(type: "time", nullable: false),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ScheduleTimeSlots", x => x.Id);
table.ForeignKey(
name: "FK_ScheduleTimeSlots_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "TeachingTaskScheduleConstraints",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
RequiresClassroom = table.Column<bool>(type: "tinyint(1)", nullable: false),
RequiredCampusId = table.Column<Guid>(type: "char(36)", nullable: true),
RequiredBuildingId = table.Column<Guid>(type: "char(36)", nullable: true),
AllowedDayOfWeeks = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true),
EarliestPeriod = table.Column<int>(type: "int", nullable: true),
LatestPeriod = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskScheduleConstraints", x => x.Id);
table.ForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Buildings_RequiredBuildingId",
column: x => x.RequiredBuildingId,
principalTable: "Buildings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Campuses_RequiredCampusId",
column: x => x.RequiredCampusId,
principalTable: "Campuses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskScheduleConstraints_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "TeachingTaskAllowedClassrooms",
columns: table => new
{
TeachingTaskScheduleConstraintId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskAllowedClassrooms", x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
table.ForeignKey(
name: "FK_TeachingTaskAllowedClassrooms_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskAllowedClassrooms_TeachingTaskScheduleConstraint~",
column: x => x.TeachingTaskScheduleConstraintId,
principalTable: "TeachingTaskScheduleConstraints",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ScheduleTimeSlots_AcademicTermId_PeriodNumber",
table: "ScheduleTimeSlots",
columns: new[] { "AcademicTermId", "PeriodNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskAllowedClassrooms_ClassroomId",
table: "TeachingTaskAllowedClassrooms",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_RequiredBuildingId",
table: "TeachingTaskScheduleConstraints",
column: "RequiredBuildingId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_RequiredCampusId",
table: "TeachingTaskScheduleConstraints",
column: "RequiredCampusId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_TeachingTaskId",
table: "TeachingTaskScheduleConstraints",
column: "TeachingTaskId",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
table: "ScheduleEntries",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
table: "ScheduleEntries");
migrationBuilder.DropTable(
name: "ScheduleTimeSlots");
migrationBuilder.DropTable(
name: "TeachingTaskAllowedClassrooms");
migrationBuilder.DropTable(
name: "TeachingTaskScheduleConstraints");
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ScheduleEntries",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
table: "ScheduleEntries",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}
@@ -1292,7 +1292,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<Guid>("ClassroomId") b.Property<Guid?>("ClassroomId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
@@ -1385,6 +1385,46 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("SchedulePlans"); b.ToTable("SchedulePlans");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<TimeOnly>("EndsAt")
.HasColumnType("time");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<int>("PeriodNumber")
.HasColumnType("int");
b.Property<TimeOnly>("StartsAt")
.HasColumnType("time");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "PeriodNumber")
.IsUnique();
b.ToTable("ScheduleTimeSlots");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1636,6 +1676,21 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("TeachingTasks"); b.ToTable("TeachingTasks");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b =>
{
b.Property<Guid>("TeachingTaskScheduleConstraintId")
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId");
b.HasIndex("ClassroomId");
b.ToTable("TeachingTaskAllowedClassrooms");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{ {
b.Property<Guid>("TeachingTaskId") b.Property<Guid>("TeachingTaskId")
@@ -1651,6 +1706,52 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("TeachingTaskClasses"); b.ToTable("TeachingTaskClasses");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("AllowedDayOfWeeks")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("EarliestPeriod")
.HasColumnType("int");
b.Property<int?>("LatestPeriod")
.HasColumnType("int");
b.Property<Guid?>("RequiredBuildingId")
.HasColumnType("char(36)");
b.Property<Guid?>("RequiredCampusId")
.HasColumnType("char(36)");
b.Property<bool>("RequiresClassroom")
.HasColumnType("tinyint(1)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("RequiredBuildingId");
b.HasIndex("RequiredCampusId");
b.HasIndex("TeachingTaskId")
.IsUnique();
b.ToTable("TeachingTaskScheduleConstraints");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b =>
{ {
b.Property<Guid>("TeachingTaskId") b.Property<Guid>("TeachingTaskId")
@@ -2277,8 +2378,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany() .WithMany()
.HasForeignKey("ClassroomId") .HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.SetNull);
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan")
.WithMany("Entries") .WithMany("Entries")
@@ -2310,6 +2410,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("AcademicTerm"); b.Navigation("AcademicTerm");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
@@ -2372,6 +2483,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Course"); b.Navigation("Course");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint")
.WithMany("AllowedClassrooms")
.HasForeignKey("TeachingTaskScheduleConstraintId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("TeachingTaskScheduleConstraint");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
@@ -2391,6 +2521,31 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTask"); b.Navigation("TeachingTask");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding")
.WithMany()
.HasForeignKey("RequiredBuildingId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "RequiredCampus")
.WithMany()
.HasForeignKey("RequiredCampusId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RequiredBuilding");
b.Navigation("RequiredCampus");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
@@ -2532,6 +2687,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Teachers"); b.Navigation("Teachers");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.Navigation("AllowedClassrooms");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
@@ -0,0 +1,219 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class AutomaticScheduleGenerator(AppDbContext db)
{
public async Task<AutomaticScheduleResult> GenerateAsync(
SchedulePlan plan,
CancellationToken cancellationToken)
{
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken);
if (timeSlots.Count == 0)
return new(0, 0, ["请先维护该学期的上课时间表。"]);
var activePeriods = timeSlots.Select(x => x.PeriodNumber).ToHashSet();
var tasks = await db.TeachingTasks
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.Include(x => x.Teachers)
.Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.OrderByDescending(x => x.Classes.Count + x.Teachers.Count)
.ThenByDescending(x => x.Capacity)
.ThenBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => tasks.Select(task => task.Id).Contains(x.TeachingTaskId))
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.Include(x => x.Building)
.OrderBy(x => x.Capacity)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken);
var entries = await db.ScheduleEntries
.Where(x => x.SchedulePlanId == plan.Id)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.ToListAsync(cancellationToken);
var created = 0;
var completedTasks = 0;
var messages = new List<string>();
foreach (var task in tasks)
{
constraints.TryGetValue(task.Id, out var constraint);
var scheduledHours = entries
.Where(x => x.TeachingTaskId == task.Id)
.Sum(x => x.PeriodCount);
var remainingHours = Math.Max(0, task.WeeklyHours - scheduledHours);
if (remainingHours == 0)
{
completedTasks++;
continue;
}
while (remainingHours > 0)
{
var desiredBlock = remainingHours >= 2 ? 2 : 1;
var candidate = FindBestCandidate(
plan.Id,
task,
constraint,
desiredBlock,
activePeriods,
classrooms,
entries);
if (candidate is null && desiredBlock > 1)
{
candidate = FindBestCandidate(
plan.Id,
task,
constraint,
1,
activePeriods,
classrooms,
entries);
}
if (candidate is null) break;
db.ScheduleEntries.Add(candidate);
entries.Add(candidate);
created++;
remainingHours -= candidate.PeriodCount;
}
if (remainingHours == 0)
{
completedTasks++;
}
else
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
}
}
if (created > 0)
await db.SaveChangesAsync(cancellationToken);
return new(created, completedTasks, messages);
}
private static ScheduleEntry? FindBestCandidate(
Guid planId,
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
int periodCount,
HashSet<int> activePeriods,
IReadOnlyList<Classroom> classrooms,
IReadOnlyList<ScheduleEntry> entries)
{
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
var rooms = EligibleRooms(task, constraint, classrooms);
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
return null;
var candidates = new List<(ScheduleEntry Entry, int Score)>();
foreach (var day in allowedDays)
{
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
{
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
continue;
var roomOptions = constraint?.RequiresClassroom == false
? new Classroom?[] { null }
: rooms.Cast<Classroom?>().ToArray();
foreach (var room in roomOptions)
{
var proposed = new ScheduleEntry
{
SchedulePlanId = planId,
TeachingTaskId = task.Id,
TeachingTask = task,
ClassroomId = room?.Id,
Classroom = room,
DayOfWeek = day,
StartPeriod = start,
PeriodCount = periodCount,
StartWeek = task.StartWeek,
EndWeek = task.EndWeek,
WeekPattern = WeekPattern.All,
Notes = "自动排课"
};
if (entries.Any(existing =>
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
continue;
var sameTaskDay = entries.Count(x =>
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
var dayLoad = entries.Count(x => x.DayOfWeek == day);
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
var score = sameTaskDay * 1000 + dayLoad * 10 + start + roomWaste / 10;
candidates.Add((proposed, score));
}
}
}
return candidates
.OrderBy(x => x.Score)
.ThenBy(x => x.Entry.DayOfWeek)
.ThenBy(x => x.Entry.StartPeriod)
.Select(x => x.Entry)
.FirstOrDefault();
}
private static IReadOnlyList<Classroom> EligibleRooms(
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
IReadOnlyList<Classroom> classrooms)
{
if (constraint?.RequiresClassroom == false) return [];
var allowedRoomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
var minimumCapacity = Math.Max(
task.Capacity,
task.Classes.Sum(x =>
x.AdministrativeClass?.Students.Count(student =>
student.Status == StudentStatus.Active) ?? 0));
return classrooms.Where(room =>
room.Capacity >= minimumCapacity &&
(!constraint?.RequiredCampusId.HasValue ?? true ||
room.Building!.CampusId == constraint.RequiredCampusId) &&
(!constraint?.RequiredBuildingId.HasValue ?? true ||
room.BuildingId == constraint.RequiredBuildingId) &&
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)))
.ToList();
}
private static int[] ParseAllowedDays(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
var days = value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(item => int.TryParse(item, out var day) ? day : 0)
.Where(day => day is >= 1 and <= 7)
.Distinct()
.Order()
.ToArray();
return days.Length == 0 ? [1, 2, 3, 4, 5] : days;
}
}
public sealed record AutomaticScheduleResult(
int CreatedEntries,
int CompletedTasks,
IReadOnlyList<string> Messages);
@@ -29,7 +29,10 @@ public static class ScheduleConflictDetector
public static string? ConflictReason(ScheduleEntry first, ScheduleEntry second) public static string? ConflictReason(ScheduleEntry first, ScheduleEntry second)
{ {
if (first.ClassroomId == second.ClassroomId) return "教室"; if (first.ClassroomId.HasValue &&
second.ClassroomId.HasValue &&
first.ClassroomId == second.ClassroomId)
return "教室";
var firstTeachers = first.TeachingTask!.Teachers.Select(x => x.TeacherId).ToHashSet(); var firstTeachers = first.TeachingTask!.Teachers.Select(x => x.TeacherId).ToHashSet();
if (second.TeachingTask!.Teachers.Any(x => firstTeachers.Contains(x.TeacherId))) if (second.TeachingTask!.Teachers.Any(x => firstTeachers.Contains(x.TeacherId)))
return "教师"; return "教师";
+2
View File
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Middleware;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -86,6 +87,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>(); builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
builder.Services.AddScoped<DatabaseInitializer>(); builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>(); builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
@@ -0,0 +1,126 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jiaowu.Api.Tests;
public sealed class AutomaticScheduleGeneratorTests
{
[Fact]
public async Task Development_migration_chain_accepts_current_sqlite_schema()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var migrator = new DevelopmentSqliteMigrator(
db,
NullLogger<DevelopmentSqliteMigrator>.Instance);
await migrator.MigrateAsync();
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
Assert.True(await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('ScheduleEntries')
WHERE name = 'ClassroomId' AND "notnull" = 0
""")
.AnyAsync(value => value > 0));
}
[Fact]
public async Task Generator_schedules_roomless_course_within_allowed_time()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-A",
Name = "2026 秋季",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15)
};
var college = new College { Code = "CS", Name = "计算机学院" };
var course = new Course
{
Code = "MOOC-01",
Name = "在线专题",
College = college,
Credits = 1,
TotalHours = 32,
LectureHours = 32
};
var task = new TeachingTask
{
TaskNumber = "TASK-MOOC",
Name = "在线专题教学班",
AcademicTerm = term,
Course = course,
Capacity = 200,
StartWeek = 1,
EndWeek = 16,
WeeklyHours = 2,
Status = TeachingTaskStatus.Published
};
var plan = new SchedulePlan
{
AcademicTerm = term,
Name = "自动排课测试",
Version = "V1"
};
db.AddRange(term, college, course, task, plan);
db.ScheduleTimeSlots.AddRange(
new ScheduleTimeSlot
{
AcademicTerm = term,
PeriodNumber = 1,
Name = "第 1 节",
StartsAt = new TimeOnly(8, 0),
EndsAt = new TimeOnly(8, 45)
},
new ScheduleTimeSlot
{
AcademicTerm = term,
PeriodNumber = 2,
Name = "第 2 节",
StartsAt = new TimeOnly(8, 55),
EndsAt = new TimeOnly(9, 40)
});
db.TeachingTaskScheduleConstraints.Add(new TeachingTaskScheduleConstraint
{
TeachingTask = task,
RequiresClassroom = false,
AllowedDayOfWeeks = "3",
EarliestPeriod = 1,
LatestPeriod = 2
});
await db.SaveChangesAsync();
var result = await new AutomaticScheduleGenerator(db)
.GenerateAsync(plan, CancellationToken.None);
var entry = await db.ScheduleEntries.SingleAsync();
Assert.Equal(1, result.CreatedEntries);
Assert.Empty(result.Messages);
Assert.Equal(3, entry.DayOfWeek);
Assert.Equal(1, entry.StartPeriod);
Assert.Equal(2, entry.PeriodCount);
Assert.Null(entry.ClassroomId);
}
}
@@ -63,8 +63,26 @@ public sealed class ScheduleConflictTests
Assert.Equal("教师", ScheduleConflictDetector.ConflictReason(first, second)); Assert.Equal("教师", ScheduleConflictDetector.ConflictReason(first, second));
} }
[Fact]
public void Two_roomless_courses_do_not_create_a_classroom_conflict()
{
var first = Entry(null, WeekPattern.All, new TeachingTask
{
TaskNumber = "ONLINE-1",
Name = "线上课程一"
});
var second = Entry(null, WeekPattern.All, new TeachingTask
{
TaskNumber = "ONLINE-2",
Name = "线上课程二"
});
Assert.True(ScheduleConflictDetector.TimeOverlaps(first, second));
Assert.Null(ScheduleConflictDetector.ConflictReason(first, second));
}
private static ScheduleEntry Entry( private static ScheduleEntry Entry(
Guid classroomId, Guid? classroomId,
WeekPattern weekPattern, WeekPattern weekPattern,
TeachingTask task) => TeachingTask task) =>
new() new()
+4
View File
@@ -14,6 +14,7 @@ declare module 'vue' {
ElAlert: typeof import('element-plus/es')['ElAlert'] ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -35,7 +36,10 @@ declare module 'vue' {
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }
+19
View File
@@ -337,6 +337,21 @@ button { cursor: pointer; }
.schedule-card small, .schedule-card i { overflow: hidden; color: #c6cfe6; font-size: 8px; font-style: normal; white-space: nowrap; text-overflow: ellipsis; } .schedule-card small, .schedule-card i { overflow: hidden; color: #c6cfe6; font-size: 8px; font-style: normal; white-space: nowrap; text-overflow: ellipsis; }
.schedule-card > button { position: absolute; right: 4px; top: 3px; border: none; color: #b9c4df; background: transparent; font-size: 14px; } .schedule-card > button { position: absolute; right: 4px; top: 3px; border: none; color: #b9c4df; background: transparent; font-size: 14px; }
.schedule-card > button:hover { color: white; } .schedule-card > button:hover { color: white; }
.schedule-settings-drawer .el-drawer__body { padding-top: 0; }
.settings-lead { margin: 4px 0 16px; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 3px solid var(--teal); background: #f4faf9; }
.settings-lead > div:first-child { display: grid; gap: 4px; }
.settings-lead b { color: #223052; font-family: "STZhongsong", "Songti SC", serif; font-size: 17px; }
.settings-lead span { color: var(--muted); font-size: 10px; line-height: 1.6; }
.settings-lead > div:last-child { display: flex; flex-shrink: 0; gap: 8px; }
.settings-table .el-input-number { width: 76px; }
.constraint-list { display: grid; gap: 8px; }
.constraint-list article { min-width: 0; padding: 13px 15px; display: grid; grid-template-columns: minmax(230px, 1fr) minmax(170px, auto) auto; align-items: center; gap: 14px; border: 1px solid var(--line); background: #fff; }
.constraint-list article > div:first-child { min-width: 0; display: grid; gap: 4px; }
.constraint-list article span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; }
.constraint-list article b { overflow: hidden; color: #263453; font-size: 12px; white-space: nowrap; text-overflow: ellipsis; }
.constraint-list article small { color: var(--muted); font-size: 9px; }
.constraint-badges { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 5px; }
.constraint-title { margin-bottom: 16px; padding: 12px 14px; color: #eef4ff; background: linear-gradient(120deg, #243d7a, #182b5d); font-size: 13px; font-weight: 650; }
.selection-page { min-width: 0; } .selection-page { min-width: 0; }
.selection-round-strip { .selection-round-strip {
@@ -950,6 +965,10 @@ button { cursor: pointer; }
.schedule-search { flex-wrap: wrap; } .schedule-search { flex-wrap: wrap; }
.schedule-search .el-input { width: 100%; } .schedule-search .el-input { width: 100%; }
.schedule-search > span { width: 100%; margin-left: 0; } .schedule-search > span { width: 100%; margin-left: 0; }
.settings-lead { align-items: stretch; flex-direction: column; }
.settings-lead > div:last-child { flex-wrap: wrap; }
.constraint-list article { grid-template-columns: 1fr; }
.constraint-badges { justify-content: flex-start; }
.selection-window { grid-template-columns: 82px 1fr; } .selection-window { grid-template-columns: 82px 1fr; }
.credit-meter, .round-actions { .credit-meter, .round-actions {
grid-column: 1 / -1; width: auto; border-top: 1px solid rgba(255,255,255,.13); grid-column: 1 / -1; width: auto; border-top: 1px solid rgba(255,255,255,.13);
+333 -15
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { CopyDocument, Plus, Promotion, Refresh, Search } from '@element-plus/icons-vue' import { CopyDocument, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
const plans = ref<any[]>([]) const plans = ref<any[]>([])
@@ -8,11 +8,19 @@ const selected = ref<any | null>(null)
const terms = ref<any[]>([]) const terms = ref<any[]>([])
const tasks = ref<any[]>([]) const tasks = ref<any[]>([])
const classrooms = ref<any[]>([]) const classrooms = ref<any[]>([])
const campuses = ref<any[]>([])
const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([])
const constraints = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const autoLoading = ref(false)
const planDialog = ref(false) const planDialog = ref(false)
const cloneDialog = ref(false) const cloneDialog = ref(false)
const entryDialog = ref(false) const entryDialog = ref(false)
const settingsDrawer = ref(false)
const settingsTab = ref('time')
const constraintDialog = ref(false)
const editingPlanId = ref('') const editingPlanId = ref('')
const editingEntryId = ref('') const editingEntryId = ref('')
const keyword = ref('') const keyword = ref('')
@@ -20,6 +28,7 @@ const termId = ref<string | undefined>()
const planForm = reactive<Record<string, any>>({}) const planForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({}) const cloneForm = reactive<Record<string, any>>({})
const entryForm = reactive<Record<string, any>>({}) const entryForm = reactive<Record<string, any>>({})
const constraintForm = reactive<Record<string, any>>({})
const weekdays = [ const weekdays = [
{ value: 1, label: '星期一' }, { value: 1, label: '星期一' },
@@ -30,7 +39,12 @@ const weekdays = [
{ value: 6, label: '星期六' }, { value: 6, label: '星期六' },
{ value: 7, label: '星期日' }, { value: 7, label: '星期日' },
] ]
const periods = Array.from({ length: 12 }, (_, index) => index + 1) const periods = computed(() => {
const configured = timeSlots.value
.filter((item) => item.isEnabled)
.map((item) => item.periodNumber)
return configured.length ? configured : Array.from({ length: 12 }, (_, index) => index + 1)
})
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
Draft: '草稿', Draft: '草稿',
Published: '已发布', Published: '已发布',
@@ -42,6 +56,20 @@ const patternLabels: Record<string, string> = {
Even: '双周', Even: '双周',
} }
const isDraft = computed(() => selected.value?.status === 'Draft') const isDraft = computed(() => selected.value?.status === 'Draft')
const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
)
const filteredBuildings = computed(() =>
constraintForm.requiredCampusId
? buildings.value.filter((item) => item.campusId === constraintForm.requiredCampusId)
: buildings.value,
)
const filteredClassrooms = computed(() =>
classrooms.value.filter((item) =>
(!constraintForm.requiredCampusId || item.campusId === constraintForm.requiredCampusId) &&
(!constraintForm.requiredBuildingId || item.buildingId === constraintForm.requiredBuildingId),
),
)
const filteredEntries = computed(() => { const filteredEntries = computed(() => {
const text = keyword.value.trim().toLowerCase() const text = keyword.value.trim().toLowerCase()
if (!text) return selected.value?.entries ?? [] if (!text) return selected.value?.entries ?? []
@@ -64,6 +92,125 @@ function entriesAt(day: number, period: number) {
) )
} }
function timeLabel(period: number) {
const slot = timeSlots.value.find((item) => item.periodNumber === period)
return slot ? `${slot.startsAt}${slot.endsAt}` : `${period}`
}
async function loadSchedulingSettings() {
if (!termId.value) return
try {
const [timeRes, constraintRes] = await Promise.all([
http.get('/schedules/time-slots', { params: { academicTermId: termId.value } }),
http.get('/schedules/constraints', { params: { academicTermId: termId.value } }),
])
timeSlots.value = timeRes.data
constraints.value = constraintRes.data
tasks.value = constraintRes.data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function changeTerm() {
await Promise.all([loadPlans(false), loadSchedulingSettings()])
}
function initializeTimeSlots() {
const defaults = [
['08:00', '08:45'], ['08:55', '09:40'], ['10:00', '10:45'], ['10:55', '11:40'],
['14:00', '14:45'], ['14:55', '15:40'], ['16:00', '16:45'], ['16:55', '17:40'],
['19:00', '19:45'], ['19:55', '20:40'],
]
timeSlots.value = defaults.map(([startsAt, endsAt], index) => ({
periodNumber: index + 1,
name: `${index + 1}`,
startsAt,
endsAt,
isEnabled: true,
}))
}
function addTimeSlot() {
const last = timeSlots.value.at(-1)
timeSlots.value.push({
periodNumber: (last?.periodNumber ?? 0) + 1,
name: `${(last?.periodNumber ?? 0) + 1}`,
startsAt: '08:00',
endsAt: '08:45',
isEnabled: true,
})
}
async function saveTimeSlots() {
try {
await http.put(`/schedules/time-slots/${termId.value}`, timeSlots.value)
ElMessage.success('上课时间表已保存')
await loadSchedulingSettings()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openConstraint(item: any) {
Object.assign(constraintForm, {
teachingTaskId: item.id,
title: `${item.taskNumber} · ${item.name}`,
requiresClassroom: item.requiresClassroom,
requiredCampusId: item.requiredCampusId,
requiredBuildingId: item.requiredBuildingId,
allowedClassroomIds: [...item.allowedClassroomIds],
allowedDayOfWeeks: item.allowedDayOfWeeks.length
? [...item.allowedDayOfWeeks]
: [1, 2, 3, 4, 5],
earliestPeriod: item.earliestPeriod,
latestPeriod: item.latestPeriod,
})
constraintDialog.value = true
}
async function saveConstraint() {
try {
const payload = {
requiresClassroom: constraintForm.requiresClassroom,
requiredCampusId: constraintForm.requiredCampusId || null,
requiredBuildingId: constraintForm.requiredBuildingId || null,
allowedClassroomIds: constraintForm.allowedClassroomIds ?? [],
allowedDayOfWeeks: constraintForm.allowedDayOfWeeks ?? [],
earliestPeriod: constraintForm.earliestPeriod || null,
latestPeriod: constraintForm.latestPeriod || null,
}
await http.put(`/schedules/constraints/${constraintForm.teachingTaskId}`, payload)
constraintDialog.value = false
ElMessage.success('排课约束已保存')
await loadSchedulingSettings()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function autoSchedule() {
try {
await ElMessageBox.confirm(
'系统会保留当前手工安排,并为尚未排满的教学任务分配教师可用时间和符合约束的教室。生成后仍可手工调整。',
'开始自动排课',
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
)
autoLoading.value = true
const { data } = await http.post(`/schedules/plans/${selected.value.id}/auto-schedule`)
await loadDetail(selected.value.id)
if (data.messages.length) {
ElMessage.warning(`已生成 ${data.createdEntries} 条安排,仍有 ${data.messages.length} 个任务需人工处理`)
} else {
ElMessage.success(`自动排课完成,共生成 ${data.createdEntries} 条安排`)
}
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally {
autoLoading.value = false
}
}
async function loadPlans(keepSelection = true) { async function loadPlans(keepSelection = true) {
loading.value = true loading.value = true
try { try {
@@ -185,10 +332,12 @@ function openEntry(entry?: any, day?: number, period?: number) {
} }
async function saveEntry() { async function saveEntry() {
if (!entryForm.teachingTaskId || !entryForm.classroomId) { if (!entryForm.teachingTaskId ||
ElMessage.warning('请选择教学任务和教室。') (selectedTaskConstraint.value?.requiresClassroom !== false && !entryForm.classroomId)) {
ElMessage.warning('请选择教学任务,并按课程要求选择教室。')
return return
} }
if (selectedTaskConstraint.value?.requiresClassroom === false) entryForm.classroomId = null
try { try {
const base = `/schedules/plans/${selected.value.id}/entries` const base = `/schedules/plans/${selected.value.id}/entries`
if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm) if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm)
@@ -214,16 +363,18 @@ async function deleteEntry(entry: any) {
} }
onMounted(async () => { onMounted(async () => {
const [termRes, taskRes, classroomRes] = await Promise.all([ const [termRes, classroomRes, campusRes, buildingRes] = await Promise.all([
http.get('/base-data/terms'), http.get('/base-data/terms'),
http.get('/teaching-tasks', { params: { page: 1, pageSize: 100, status: 'Published' } }),
http.get('/base-data/classrooms'), http.get('/base-data/classrooms'),
http.get('/base-data/campuses'),
http.get('/base-data/buildings'),
]) ])
terms.value = termRes.data terms.value = termRes.data
tasks.value = taskRes.data.items
classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled) classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled)
campuses.value = campusRes.data.filter((item: any) => item.isEnabled)
buildings.value = buildingRes.data.filter((item: any) => item.isEnabled)
termId.value = terms.value.find((item) => item.isCurrent)?.id termId.value = terms.value.find((item) => item.isCurrent)?.id
await loadPlans(false) await Promise.all([loadPlans(false), loadSchedulingSettings()])
}) })
</script> </script>
@@ -233,13 +384,16 @@ onMounted(async () => {
<div> <div>
<span class="section-kicker">TIMETABLE BOARD</span> <span class="section-kicker">TIMETABLE BOARD</span>
<h2>排课与课表</h2> <h2>排课与课表</h2>
<p>在发布前消除教师行政班与教室冲突并保留每次发布的版本</p> <p>用作息时间和课程约束驱动教师班级场地自动分配生成后仍可逐项微调</p>
</div> </div>
<div class="page-actions">
<el-button :icon="Setting" @click="settingsDrawer = true">排课规则与作息</el-button>
<el-button type="primary" :icon="Plus" @click="openPlan()">新建排课版本</el-button> <el-button type="primary" :icon="Plus" @click="openPlan()">新建排课版本</el-button>
</div>
</section> </section>
<section class="schedule-toolbar"> <section class="schedule-toolbar">
<el-select v-model="termId" placeholder="选择学期" @change="loadPlans(false)"> <el-select v-model="termId" placeholder="选择学期" @change="changeTerm">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
</el-select> </el-select>
<div class="schedule-version-strip"> <div class="schedule-version-strip">
@@ -268,6 +422,16 @@ onMounted(async () => {
<div class="plan-actions"> <div class="plan-actions">
<el-button v-if="isDraft" @click="openPlan(selected)">编辑版本</el-button> <el-button v-if="isDraft" @click="openPlan(selected)">编辑版本</el-button>
<el-button :icon="CopyDocument" @click="openClone">复制调整</el-button> <el-button :icon="CopyDocument" @click="openClone">复制调整</el-button>
<el-button
v-if="isDraft"
type="warning"
plain
:icon="Promotion"
:loading="autoLoading"
@click="autoSchedule"
>
自动排课
</el-button>
<el-button v-if="isDraft" type="primary" :icon="Plus" @click="openEntry()">添加排课</el-button> <el-button v-if="isDraft" type="primary" :icon="Plus" @click="openEntry()">添加排课</el-button>
<el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布课表</el-button> <el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布课表</el-button>
<el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除草稿</el-button> <el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除草稿</el-button>
@@ -287,7 +451,7 @@ onMounted(async () => {
<template v-for="period in periods" :key="period"> <template v-for="period in periods" :key="period">
<div class="timetable-period"> <div class="timetable-period">
<b>{{ period }}</b> <b>{{ period }}</b>
<span> {{ period }} </span> <span>{{ timeLabel(period) }}</span>
</div> </div>
<div <div
v-for="day in weekdays" v-for="day in weekdays"
@@ -305,7 +469,7 @@ onMounted(async () => {
> >
<span>{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}</span> <span>{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}</span>
<b>{{ entry.courseName }}</b> <b>{{ entry.courseName }}</b>
<small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName }}</small> <small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName || '不占用教室' }}</small>
<i>{{ entry.startWeek }}{{ entry.endWeek }} / 连上 {{ entry.periodCount }} </i> <i>{{ entry.startWeek }}{{ entry.endWeek }} / 连上 {{ entry.periodCount }} </i>
<button v-if="isDraft" type="button" @click.stop="deleteEntry(entry)">×</button> <button v-if="isDraft" type="button" @click.stop="deleteEntry(entry)">×</button>
</article> </article>
@@ -342,14 +506,44 @@ onMounted(async () => {
<el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" /> <el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="教室" required> <el-alert
v-if="selectedTaskConstraint?.requiresClassroom === false"
title="该课程不占用教室,仍会校验教师和行政班时间冲突。"
type="info"
:closable="false"
show-icon
/>
<el-form-item
v-else
label="教室"
required
:hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''"
>
<el-select v-model="entryForm.classroomId" filterable> <el-select v-model="entryForm.classroomId" filterable>
<el-option v-for="item in classrooms" :key="item.id" :label="`${item.campusName} / ${item.buildingName} / ${item.name}${item.capacity}人)`" :value="item.id" /> <el-option
v-for="item in classrooms.filter((room) =>
(!selectedTaskConstraint?.requiredCampusId || room.campusId === selectedTaskConstraint.requiredCampusId) &&
(!selectedTaskConstraint?.requiredBuildingId || room.buildingId === selectedTaskConstraint.requiredBuildingId) &&
(!selectedTaskConstraint?.allowedClassroomIds?.length || selectedTaskConstraint.allowedClassroomIds.includes(room.id))
)"
:key="item.id"
:label="`${item.campusName} / ${item.buildingName} / ${item.name}${item.capacity}人)`"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<div class="form-grid three"> <div class="form-grid three">
<el-form-item label="星期"><el-select v-model="entryForm.dayOfWeek"><el-option v-for="day in weekdays" :key="day.value" :label="day.label" :value="day.value" /></el-select></el-form-item> <el-form-item label="星期"><el-select v-model="entryForm.dayOfWeek"><el-option v-for="day in weekdays" :key="day.value" :label="day.label" :value="day.value" /></el-select></el-form-item>
<el-form-item label="开始节次"><el-input-number v-model="entryForm.startPeriod" :min="1" :max="12" /></el-form-item> <el-form-item label="开始节次">
<el-select v-model="entryForm.startPeriod">
<el-option
v-for="period in periods"
:key="period"
:label="`第 ${period} 节 · ${timeLabel(period)}`"
:value="period"
/>
</el-select>
</el-form-item>
<el-form-item label="连续节数"><el-input-number v-model="entryForm.periodCount" :min="1" :max="6" /></el-form-item> <el-form-item label="连续节数"><el-input-number v-model="entryForm.periodCount" :min="1" :max="6" /></el-form-item>
</div> </div>
<div class="form-grid three"> <div class="form-grid three">
@@ -361,5 +555,129 @@ onMounted(async () => {
</el-form> </el-form>
<template #footer><el-button @click="entryDialog = false">取消</el-button><el-button type="primary" @click="saveEntry">检查冲突并保存</el-button></template> <template #footer><el-button @click="entryDialog = false">取消</el-button><el-button type="primary" @click="saveEntry">检查冲突并保存</el-button></template>
</el-dialog> </el-dialog>
<el-drawer v-model="settingsDrawer" title="排课规则与作息" size="760px" class="schedule-settings-drawer">
<el-tabs v-model="settingsTab">
<el-tab-pane label="上课时间表" name="time">
<div class="settings-lead">
<div>
<b>上课时间表</b>
<span>当前学期的节次上下课时间和可排课状态自动排课只使用启用节次</span>
</div>
<div>
<el-button v-if="!timeSlots.length" @click="initializeTimeSlots">载入常用作息</el-button>
<el-button @click="addTimeSlot">增加节次</el-button>
<el-button type="primary" @click="saveTimeSlots">保存时间表</el-button>
</div>
</div>
<el-table :data="timeSlots" class="settings-table">
<el-table-column label="节次" width="100">
<template #default="{ row }"><el-input-number v-model="row.periodNumber" :min="1" :max="30" controls-position="right" /></template>
</el-table-column>
<el-table-column label="名称" min-width="130">
<template #default="{ row }"><el-input v-model="row.name" /></template>
</el-table-column>
<el-table-column label="上课" width="130">
<template #default="{ row }"><el-time-select v-model="row.startsAt" start="06:00" step="00:05" end="23:00" /></template>
</el-table-column>
<el-table-column label="下课" width="130">
<template #default="{ row }"><el-time-select v-model="row.endsAt" start="06:00" step="00:05" end="23:00" /></template>
</el-table-column>
<el-table-column label="可排课" width="90">
<template #default="{ row }"><el-switch v-model="row.isEnabled" /></template>
</el-table-column>
<el-table-column width="60">
<template #default="{ $index }"><el-button link type="danger" @click="timeSlots.splice($index, 1)">移除</el-button></template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="课程排课约束" name="constraints">
<div class="settings-lead">
<div>
<b>课程排课约束</b>
<span>教师来自已发布教学任务这里限定可用时间校区教学楼指定教室以及是否占用教室</span>
</div>
</div>
<div class="constraint-list">
<article v-for="item in constraints" :key="item.id">
<div>
<span>{{ item.taskNumber }} · 每周 {{ item.weeklyHours }} 学时</span>
<b>{{ item.name }}</b>
<small>{{ item.teacherNames.join('、') || '未分配教师' }} · {{ item.capacity }} </small>
</div>
<div class="constraint-badges">
<el-tag :type="item.requiresClassroom ? 'primary' : 'info'">
{{ item.requiresClassroom ? '占用教室' : '不占教室' }}
</el-tag>
<el-tag v-if="item.requiredBuildingId" type="warning">限定教学楼</el-tag>
<el-tag v-if="item.allowedClassroomIds.length" type="warning">
指定 {{ item.allowedClassroomIds.length }} 间教室
</el-tag>
</div>
<el-button @click="openConstraint(item)">设置约束</el-button>
</article>
</div>
</el-tab-pane>
</el-tabs>
</el-drawer>
<el-dialog v-model="constraintDialog" title="设置课程排课约束" width="720px">
<div class="constraint-title">{{ constraintForm.title }}</div>
<el-form label-position="top">
<el-form-item>
<el-switch
v-model="constraintForm.requiresClassroom"
active-text="需要占用教室"
inactive-text="不占用教室"
/>
</el-form-item>
<template v-if="constraintForm.requiresClassroom">
<div class="form-grid">
<el-form-item label="限定校区">
<el-select v-model="constraintForm.requiredCampusId" clearable @change="constraintForm.requiredBuildingId = undefined; constraintForm.allowedClassroomIds = []">
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="限定教学楼">
<el-select v-model="constraintForm.requiredBuildingId" clearable @change="constraintForm.allowedClassroomIds = []">
<el-option v-for="item in filteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="指定可用教室">
<el-select v-model="constraintForm.allowedClassroomIds" multiple filterable collapse-tags>
<el-option
v-for="item in filteredClassrooms"
:key="item.id"
:label="`${item.buildingName} / ${item.name}${item.capacity}人)`"
:value="item.id"
/>
</el-select>
</el-form-item>
</template>
<el-form-item label="允许上课日">
<el-checkbox-group v-model="constraintForm.allowedDayOfWeeks">
<el-checkbox v-for="day in weekdays" :key="day.value" :value="day.value">{{ day.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<div class="form-grid">
<el-form-item label="最早开始节次">
<el-select v-model="constraintForm.earliestPeriod" clearable>
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节 · ${timeLabel(period)}`" :value="period" />
</el-select>
</el-form-item>
<el-form-item label="最晚结束节次">
<el-select v-model="constraintForm.latestPeriod" clearable>
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节 · ${timeLabel(period)}`" :value="period" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="constraintDialog = false">取消</el-button>
<el-button type="primary" @click="saveConstraint">保存约束</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>