教学任务按 授课周数 × 周学时 = 课程总学时 双端校验,不匹配会显示具体差额并阻止保存;公共课批量生成同样校验。

排课约束增加课程、教师、学院、授课方式、场地要求、约束状态筛选,并支持批量修改当前筛选结果。
新增“非排时课程”授课方式:不进入自动排课
不占星期、节次和教室
不阻塞课表发布
允许正常选课
在班级课表和学生个人课表中单独展示
This commit is contained in:
2026-07-25 08:18:25 +08:00 Unverified
parent 30c15f89e5
commit fe508054d0
20 changed files with 3785 additions and 136 deletions
@@ -635,6 +635,7 @@ public sealed class CourseSelectionsController(
.Where(item => item.StudentId == student.Id) .Where(item => item.StudentId == student.Id)
.Select(item => (CourseEnrollmentStatus?)item.Status) .Select(item => (CourseEnrollmentStatus?)item.Status)
.FirstOrDefault(), .FirstOrDefault(),
x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible,
db.ScheduleEntries db.ScheduleEntries
.Where(entry => .Where(entry =>
entry.TeachingTaskId == x.TeachingTaskId && entry.TeachingTaskId == x.TeachingTaskId &&
@@ -803,7 +804,8 @@ public sealed class CourseSelectionsController(
round.AcademicTermId, round.AcademicTermId,
[task.Id], [task.Id],
cancellationToken); cancellationToken);
if (candidateEntries.Count == 0) if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。"); return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
var selectedTaskIds = await db.CourseEnrollments var selectedTaskIds = await db.CourseEnrollments
.Where(x => .Where(x =>
@@ -1010,6 +1012,7 @@ public sealed record StudentOfferingDto(
int EnrolledCount, int EnrolledCount,
bool IsOpenToAll, bool IsOpenToAll,
CourseEnrollmentStatus? EnrollmentStatus, CourseEnrollmentStatus? EnrollmentStatus,
bool IsFlexible,
IEnumerable<StudentScheduleDto> Schedules); IEnumerable<StudentScheduleDto> Schedules);
public sealed record StudentScheduleDto( public sealed record StudentScheduleDto(
@@ -131,6 +131,7 @@ public sealed class CoursesController(
x.CourseCategoryId, x.CourseCategoryId,
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
x.Credits, x.Credits,
x.TotalHours,
x.Nature, x.Nature,
x.AssessmentMethod x.AssessmentMethod
}) })
@@ -83,12 +83,16 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
x.Id, x.Id,
x.TaskNumber, x.TaskNumber,
x.Name, x.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course!.Name, CourseName = x.Course!.Name,
CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name,
TeacherNames = x.Teachers TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name), .Select(item => item.Teacher!.Name),
x.Capacity, x.Capacity,
x.WeeklyHours x.WeeklyHours,
x.SchedulingMode
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var taskIds = tasks.Select(x => x.Id).ToList(); var taskIds = tasks.Select(x => x.Id).ToList();
@@ -104,11 +108,18 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
task.Id, task.Id,
task.TaskNumber, task.TaskNumber,
task.Name, task.Name,
task.CourseCode,
task.CourseName, task.CourseName,
task.CollegeId,
task.CollegeName,
task.TeacherNames, task.TeacherNames,
task.Capacity, task.Capacity,
task.WeeklyHours, task.WeeklyHours,
RequiresClassroom = constraint?.RequiresClassroom ?? true, task.SchedulingMode,
HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
? false
: constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId, constraint?.RequiredCampusId,
constraint?.RequiredBuildingId, constraint?.RequiredBuildingId,
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks), AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
@@ -132,9 +143,28 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
request.LatestPeriod.HasValue && request.LatestPeriod.HasValue &&
request.EarliestPeriod > request.LatestPeriod) request.EarliestPeriod > request.LatestPeriod)
return ValidationProblem("最早节次不能晚于最晚节次。"); return ValidationProblem("最早节次不能晚于最晚节次。");
if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。");
var task = await db.TeachingTasks var task = await db.TeachingTasks
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
await HasScheduleEntriesAsync([teachingTaskId], cancellationToken))
return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。");
task.SchedulingMode = request.SchedulingMode;
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
{
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (flexibleConstraint is not null)
{
ClearConstraint(flexibleConstraint);
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
Building? building = null; Building? building = null;
if (request.RequiredBuildingId.HasValue) if (request.RequiredBuildingId.HasValue)
@@ -195,6 +225,124 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
return NoContent(); return NoContent();
} }
[HttpPut("constraints/batch")]
public async Task<ActionResult> SaveConstraintsBatch(
TeachingTaskScheduleConstraintBatchRequest request,
CancellationToken cancellationToken)
{
var taskIds = request.TeachingTaskIds.Distinct().ToArray();
if (taskIds.Length == 0)
return ValidationProblem("请至少选择一个教学任务。");
if (request.AllowedDayOfWeeks?.Any(day => day is < 1 or > 7) == true)
return ValidationProblem("允许上课日必须位于星期一至星期日。");
if (request.EarliestPeriod.HasValue &&
request.LatestPeriod.HasValue &&
request.EarliestPeriod > request.LatestPeriod)
return ValidationProblem("最早节次不能晚于最晚节次。");
if (request.SchedulingMode.HasValue &&
!Enum.IsDefined(request.SchedulingMode.Value))
return ValidationProblem("授课方式无效。");
if (!request.SchedulingMode.HasValue &&
!request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null &&
!request.UpdatePeriodRange &&
!request.EarliestPeriod.HasValue &&
!request.LatestPeriod.HasValue)
return ValidationProblem("请至少选择一项需要批量修改的设置。");
var tasks = await db.TeachingTasks
.Where(x =>
taskIds.Contains(x.Id) &&
x.AcademicTermId == request.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Length)
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
await HasScheduleEntriesAsync(taskIds, cancellationToken))
return ConflictProblem("所选教学任务中存在已有正常排课记录的课程,请先删除这些排课记录。");
var constraints = await db.TeachingTaskScheduleConstraints
.Where(x => taskIds.Contains(x.TeachingTaskId))
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var task in tasks)
{
if (request.SchedulingMode.HasValue)
task.SchedulingMode = request.SchedulingMode.Value;
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
{
if (constraints.TryGetValue(task.Id, out var flexibleConstraint))
ClearConstraint(flexibleConstraint);
continue;
}
if (!constraints.TryGetValue(task.Id, out var constraint))
{
var changesConstraint =
request.RequiresClassroom.HasValue ||
request.AllowedDayOfWeeks is not null ||
request.UpdatePeriodRange;
if (!changesConstraint) continue;
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
constraints[task.Id] = constraint;
db.TeachingTaskScheduleConstraints.Add(constraint);
}
if (request.RequiresClassroom.HasValue)
{
constraint.RequiresClassroom = request.RequiresClassroom.Value;
if (!request.RequiresClassroom.Value)
{
constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
constraint.AllowedClassrooms = [];
}
}
if (request.AllowedDayOfWeeks is not null)
{
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
? null
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
}
if (request.UpdatePeriodRange)
{
constraint.EarliestPeriod = request.EarliestPeriod;
constraint.LatestPeriod = request.LatestPeriod;
}
}
await db.SaveChangesAsync(cancellationToken);
return Ok(new { AffectedCount = tasks.Count });
}
private Task<bool> HasScheduleEntriesAsync(
IReadOnlyCollection<Guid> taskIds,
CancellationToken cancellationToken) =>
db.ScheduleEntries.AsNoTracking()
.AnyAsync(x => taskIds.Contains(x.TeachingTaskId), cancellationToken);
private void ClearConstraint(TeachingTaskScheduleConstraint constraint)
{
constraint.RequiresClassroom = false;
constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null;
constraint.AllowedDayOfWeeks = null;
constraint.EarliestPeriod = null;
constraint.LatestPeriod = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
constraint.AllowedClassrooms = [];
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "操作冲突",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private static int[] ParseDays(string? value) => private static int[] ParseDays(string? value) =>
string.IsNullOrWhiteSpace(value) string.IsNullOrWhiteSpace(value)
? [] ? []
@@ -211,6 +359,7 @@ public sealed record ScheduleTimeSlotRequest(
bool IsEnabled); bool IsEnabled);
public sealed record TeachingTaskScheduleConstraintRequest( public sealed record TeachingTaskScheduleConstraintRequest(
TeachingTaskSchedulingMode SchedulingMode,
bool RequiresClassroom, bool RequiresClassroom,
Guid? RequiredCampusId, Guid? RequiredCampusId,
Guid? RequiredBuildingId, Guid? RequiredBuildingId,
@@ -218,3 +367,13 @@ public sealed record TeachingTaskScheduleConstraintRequest(
IReadOnlyList<int> AllowedDayOfWeeks, IReadOnlyList<int> AllowedDayOfWeeks,
[Range(1, 30)] int? EarliestPeriod, [Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod); [Range(1, 30)] int? LatestPeriod);
public sealed record TeachingTaskScheduleConstraintBatchRequest(
Guid AcademicTermId,
[MinLength(1), MaxLength(500)] IReadOnlyCollection<Guid> TeachingTaskIds,
TeachingTaskSchedulingMode? SchedulingMode,
bool? RequiresClassroom,
IReadOnlyList<int>? AllowedDayOfWeeks,
bool UpdatePeriodRange,
[Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod);
@@ -234,7 +234,8 @@ public sealed class SchedulesController(
var requiredTasks = await db.TeachingTasks.AsNoTracking() var requiredTasks = await db.TeachingTasks.AsNoTracking()
.Where(x => .Where(x =>
x.AcademicTermId == plan.AcademicTermId && x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published) x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours }) .Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var scheduledHours = plan.Entries var scheduledHours = plan.Entries
@@ -453,6 +454,8 @@ public sealed class SchedulesController(
task.Status != TeachingTaskStatus.Published || task.Status != TeachingTaskStatus.Published ||
task.AcademicTermId != plan.AcademicTermId) task.AcademicTermId != plan.AcademicTermId)
return ValidationProblem("只能安排同一学期内已发布的教学任务。"); return ValidationProblem("只能安排同一学期内已发布的教学任务。");
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
@@ -78,6 +78,8 @@ public sealed class TeachingTasksController(
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
TeacherNames = x.Teachers TeacherNames = x.Teachers
@@ -116,6 +118,8 @@ public sealed class TeachingTasksController(
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
x.Notes, x.Notes,
@@ -172,6 +176,7 @@ public sealed class TeachingTasksController(
StartWeek = request.StartWeek, StartWeek = request.StartWeek,
EndWeek = request.EndWeek, EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours, WeeklyHours = request.WeeklyHours,
SchedulingMode = request.SchedulingMode,
Notes = Normalize(request.Notes) Notes = Normalize(request.Notes)
}; };
SetAssignments(task, request); SetAssignments(task, request);
@@ -205,6 +210,7 @@ public sealed class TeachingTasksController(
task.StartWeek = request.StartWeek; task.StartWeek = request.StartWeek;
task.EndWeek = request.EndWeek; task.EndWeek = request.EndWeek;
task.WeeklyHours = request.WeeklyHours; task.WeeklyHours = request.WeeklyHours;
task.SchedulingMode = request.SchedulingMode;
task.Notes = Normalize(request.Notes); task.Notes = Normalize(request.Notes);
db.TeachingTaskTeachers.RemoveRange(task.Teachers); db.TeachingTaskTeachers.RemoveRange(task.Teachers);
db.TeachingTaskClasses.RemoveRange(task.Classes); db.TeachingTaskClasses.RemoveRange(task.Classes);
@@ -344,6 +350,12 @@ public sealed class TeachingTasksController(
cancellationToken); cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。"); if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid(); if (!CanManage(course)) return Forbid();
var hoursProblem = TeachingTaskHours.Validate(
course,
request.StartWeek,
request.EndWeek,
request.WeeklyHours);
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
var scopedCollegeId = ScopedCollegeId(); var scopedCollegeId = ScopedCollegeId();
@@ -503,6 +515,14 @@ public sealed class TeachingTasksController(
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。"); if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid(); if (!CanManage(course)) return Forbid();
if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。");
var hoursProblem = TeachingTaskHours.Validate(
course,
request.StartWeek,
request.EndWeek,
request.WeeklyHours);
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
var collegeId = ScopedCollegeId(); var collegeId = ScopedCollegeId();
if (!await db.AcademicTerms.AnyAsync( if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled, x => x.Id == request.AcademicTermId && x.IsEnabled,
@@ -699,7 +719,8 @@ public sealed record TeachingTaskRequest(
IReadOnlyCollection<Guid> TeacherIds, IReadOnlyCollection<Guid> TeacherIds,
Guid? PrimaryTeacherId, Guid? PrimaryTeacherId,
IReadOnlyCollection<Guid> ClassIds, IReadOnlyCollection<Guid> ClassIds,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes,
TeachingTaskSchedulingMode SchedulingMode = TeachingTaskSchedulingMode.Standard);
public sealed record PublicCourseTaskGenerationRequest( public sealed record PublicCourseTaskGenerationRequest(
Guid AcademicTermId, Guid AcademicTermId,
@@ -30,7 +30,11 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
x.IsCurrent, x.IsCurrent,
HasPublishedTimetable = db.SchedulePlans.Any(plan => HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id && plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published) plan.Status == SchedulePlanStatus.Published) ||
db.TeachingTasks.Any(task =>
task.AcademicTermId == x.Id &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
@@ -48,11 +52,16 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
MajorName = x.Major!.Name, MajorName = x.Major!.Name,
CollegeName = x.Major.College!.Name, CollegeName = x.Major.College!.Name,
HasPublishedTimetable = defaultTermId.HasValue && HasPublishedTimetable = defaultTermId.HasValue &&
db.ScheduleEntries.Any(entry => (db.ScheduleEntries.Any(entry =>
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value && entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published && entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.TeachingTask!.Classes.Any(item => entry.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id)) item.AdministrativeClassId == x.Id)) ||
db.TeachingTasks.Any(task =>
task.AcademicTermId == defaultTermId.Value &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(new { Terms = terms, Classes = classes }); return Ok(new { Terms = terms, Classes = classes });
@@ -154,6 +163,51 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
.Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt }) .Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var flexibleTasks = db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == term.Id &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
x.Classes.Any(item => item.AdministrativeClassId == classId));
if (studentId.HasValue)
{
var selectedFlexibleTaskIds = db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.StudentId == studentId.Value &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == term.Id)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId);
flexibleTasks = db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == term.Id &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
(x.Classes.Any(item => item.AdministrativeClassId == classId) ||
selectedFlexibleTaskIds.Contains(x.Id)));
}
var flexibleCourses = await flexibleTasks
.OrderBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
x.Course.Credits,
x.Course.TotalHours,
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name),
x.Notes
})
.ToListAsync(cancellationToken);
if (plan is null) if (plan is null)
return Ok(new return Ok(new
{ {
@@ -162,7 +216,8 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
Student = student, Student = student,
Plan = (object?)null, Plan = (object?)null,
Slots = slots, Slots = slots,
Entries = Array.Empty<object>() Entries = Array.Empty<object>(),
FlexibleCourses = flexibleCourses
}); });
var entries = db.ScheduleEntries.AsNoTracking() var entries = db.ScheduleEntries.AsNoTracking()
@@ -225,7 +280,8 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
Student = student, Student = student,
Plan = plan, Plan = plan,
Slots = slots, Slots = slots,
Entries = result Entries = result,
FlexibleCourses = flexibleCourses
}); });
} }
} }
@@ -14,6 +14,8 @@ public sealed class TeachingTask : EntityBase
public int StartWeek { get; set; } = 1; public int StartWeek { get; set; } = 1;
public int EndWeek { get; set; } = 16; public int EndWeek { get; set; } = 16;
public int WeeklyHours { get; set; } = 2; public int WeeklyHours { get; set; } = 2;
public TeachingTaskSchedulingMode SchedulingMode { get; set; } =
TeachingTaskSchedulingMode.Standard;
public TeachingTaskStatus Status { get; set; } = TeachingTaskStatus.Draft; public TeachingTaskStatus Status { get; set; } = TeachingTaskStatus.Draft;
public string? GenerationBatchCode { get; set; } public string? GenerationBatchCode { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -63,6 +65,12 @@ public enum TeachingTaskStatus
Closed = 3 Closed = 3
} }
public enum TeachingTaskSchedulingMode
{
Standard = 1,
Flexible = 2
}
public enum TeacherCourseApplicationStatus public enum TeacherCourseApplicationStatus
{ {
Pending = 1, Pending = 1,
@@ -25,6 +25,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260724_15_teacher_course_applications"; "20260724_15_teacher_course_applications";
private const string AutomaticScheduleJobsMigration = private const string AutomaticScheduleJobsMigration =
"20260724_16_automatic_schedule_jobs"; "20260724_16_automatic_schedule_jobs";
private const string TeachingTaskSchedulingModesMigration =
"20260725_17_teaching_task_scheduling_modes";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -149,6 +151,18 @@ public sealed class DevelopmentSqliteMigrator(
AutomaticScheduleJobsMigration, AutomaticScheduleJobsMigration,
automaticScheduleJobsExist ? [] : AutomaticScheduleJobStatements, automaticScheduleJobsExist ? [] : AutomaticScheduleJobStatements,
cancellationToken); cancellationToken);
var teachingTaskSchedulingModeExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('TeachingTasks')
WHERE name = 'SchedulingMode'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
TeachingTaskSchedulingModesMigration,
teachingTaskSchedulingModeExists ? [] : TeachingTaskSchedulingModeStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -385,6 +399,7 @@ public sealed class DevelopmentSqliteMigrator(
"StartWeek" INTEGER NOT NULL, "StartWeek" INTEGER NOT NULL,
"EndWeek" INTEGER NOT NULL, "EndWeek" INTEGER NOT NULL,
"WeeklyHours" INTEGER NOT NULL, "WeeklyHours" INTEGER NOT NULL,
"SchedulingMode" INTEGER NOT NULL DEFAULT 1,
"Status" INTEGER NOT NULL, "Status" INTEGER NOT NULL,
"Notes" TEXT NULL, "Notes" TEXT NULL,
"PublishedAt" TEXT NULL, "PublishedAt" TEXT NULL,
@@ -1081,4 +1096,12 @@ public sealed class DevelopmentSqliteMigrator(
ON "AutomaticScheduleJobs" ("RequestedByUserId"); ON "AutomaticScheduleJobs" ("RequestedByUserId");
""" """
]; ];
private static readonly string[] TeachingTaskSchedulingModeStatements =
[
"""
ALTER TABLE "TeachingTasks"
ADD COLUMN "SchedulingMode" INTEGER NOT NULL DEFAULT 1;
"""
];
} }
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class TeachingTaskSchedulingModes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SchedulingMode",
table: "TeachingTasks",
type: "int",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SchedulingMode",
table: "TeachingTasks");
}
}
}
@@ -1771,6 +1771,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime?>("PublishedAt") b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<int>("SchedulingMode")
.HasColumnType("int");
b.Property<int>("StartWeek") b.Property<int>("StartWeek")
.HasColumnType("int"); .HasColumnType("int");
@@ -28,7 +28,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
var tasks = await db.TeachingTasks var tasks = await db.TeachingTasks
.Where(x => .Where(x =>
x.AcademicTermId == plan.AcademicTermId && x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published) x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Include(x => x.Teachers) .Include(x => x.Teachers)
.Include(x => x.Classes) .Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x.AdministrativeClass)
@@ -0,0 +1,21 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Teaching;
public static class TeachingTaskHours
{
public static int Calculate(int startWeek, int endWeek, int weeklyHours) =>
endWeek < startWeek ? 0 : (endWeek - startWeek + 1) * weeklyHours;
public static string? Validate(
Course course,
int startWeek,
int endWeek,
int weeklyHours)
{
var plannedHours = Calculate(startWeek, endWeek, weeklyHours);
return plannedHours == course.TotalHours
? null
: $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 {startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 {plannedHours} 学时。请调整授课周次或周学时。";
}
}
@@ -35,6 +35,82 @@ public sealed class AutomaticScheduleGeneratorTests
WHERE name = 'ClassroomId' AND "notnull" = 0 WHERE name = 'ClassroomId' AND "notnull" = 0
""") """)
.AnyAsync(value => value > 0)); .AnyAsync(value => value > 0));
Assert.True(await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('TeachingTasks')
WHERE name = 'SchedulingMode'
""")
.AnyAsync(value => value > 0));
}
[Fact]
public async Task Generator_skips_flexible_courses()
{
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-F",
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 = "PUBLIC", Name = "公共教学部" };
var course = new Course
{
Code = "FLEX-01",
Name = "社会实践",
College = college,
Credits = 1,
TotalHours = 16,
PracticeHours = 16
};
var task = new TeachingTask
{
TaskNumber = "TASK-FLEX",
Name = "社会实践教学班",
AcademicTerm = term,
Course = course,
Capacity = 100,
StartWeek = 1,
EndWeek = 16,
WeeklyHours = 1,
SchedulingMode = TeachingTaskSchedulingMode.Flexible,
Status = TeachingTaskStatus.Published
};
var plan = new SchedulePlan
{
AcademicTerm = term,
Name = "非排时课程测试",
Version = "V1"
};
db.AddRange(term, college, course, task, plan);
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
{
AcademicTerm = term,
PeriodNumber = 1,
Name = "第 1 节",
StartsAt = new TimeOnly(8, 0),
EndsAt = new TimeOnly(8, 45)
});
await db.SaveChangesAsync();
var result = await new AutomaticScheduleGenerator(db)
.GenerateAsync(plan, CancellationToken.None);
Assert.Equal(0, result.TotalTasks);
Assert.Equal(0, result.CreatedEntries);
Assert.Empty(await db.ScheduleEntries.ToListAsync());
} }
[Fact] [Fact]
@@ -0,0 +1,33 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Teaching;
namespace Jiaowu.Api.Tests;
public sealed class TeachingTaskHoursTests
{
private static readonly Course Course = new()
{
Code = "TEST-01",
Name = "测试课程",
CollegeId = Guid.NewGuid(),
Credits = 2,
TotalHours = 32,
LectureHours = 32
};
[Fact]
public void Validate_accepts_matching_week_range_and_weekly_hours()
{
Assert.Null(TeachingTaskHours.Validate(Course, 1, 16, 2));
}
[Fact]
public void Validate_reports_course_total_and_planned_hours_when_mismatched()
{
var result = TeachingTaskHours.Validate(Course, 1, 8, 2);
Assert.NotNull(result);
Assert.Contains("总学时为 32", result);
Assert.Contains("共 16 学时", result);
}
}
+9
View File
@@ -358,6 +358,10 @@ button { cursor: pointer; }
.settings-lead > div:last-child { display: flex; flex-shrink: 0; gap: 8px; } .settings-lead > div:last-child { display: flex; flex-shrink: 0; gap: 8px; }
.settings-table .el-input-number { width: 76px; } .settings-table .el-input-number { width: 76px; }
.constraint-list { display: grid; gap: 8px; } .constraint-list { display: grid; gap: 8px; }
.constraint-filter-grid { display: grid; grid-template-columns: 1.4fr repeat(4, minmax(120px, 1fr)); gap: 8px; margin-bottom: 8px; }
.constraint-result-summary { margin-bottom: 10px; color: var(--muted); font-size: 10px; text-align: right; }
.constraint-batch-form { margin-top: 16px; display: grid; gap: 10px; }
.constraint-batch-form > .el-checkbox { padding: 8px 10px; background: #f7f9fb; border-left: 3px solid #ccd8e1; }
.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 { 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 > 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 span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; }
@@ -375,6 +379,10 @@ button { cursor: pointer; }
.generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; } .generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
.generation-preview span, .generation-preview small { font-size: 10px; } .generation-preview span, .generation-preview small { font-size: 10px; }
.field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; } .field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; }
.hours-validation { margin: -5px 0 18px; padding: 10px 13px; display: flex; align-items: center; gap: 12px; border-left: 3px solid #27846f; background: #f1f8f6; color: #46645e; font-size: 11px; }
.hours-validation b { flex-shrink: 0; color: #196b59; }
.hours-validation.invalid { border-left-color: #c75252; background: #fff3f2; color: #835353; }
.hours-validation.invalid b { color: #a83f3f; }
.option-filter-panel { margin: -3px 0 18px; padding: 11px 13px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; border: 1px solid #dce4eb; border-left: 3px solid var(--teal); background: #f8fafb; } .option-filter-panel { margin: -3px 0 18px; padding: 11px 13px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; border: 1px solid #dce4eb; border-left: 3px solid var(--teal); background: #f8fafb; }
.option-filter-title { color: #3f5367; font-size: 10px; font-weight: 650; white-space: nowrap; } .option-filter-title { color: #3f5367; font-size: 10px; font-weight: 650; white-space: nowrap; }
.option-filter-panel > small { color: var(--muted); font-size: 9px; white-space: nowrap; } .option-filter-panel > small { color: var(--muted); font-size: 9px; white-space: nowrap; }
@@ -1028,6 +1036,7 @@ button { cursor: pointer; }
.settings-lead { align-items: stretch; flex-direction: column; } .settings-lead { align-items: stretch; flex-direction: column; }
.settings-lead > div:last-child { flex-wrap: wrap; } .settings-lead > div:last-child { flex-wrap: wrap; }
.constraint-list article { grid-template-columns: 1fr; } .constraint-list article { grid-template-columns: 1fr; }
.constraint-filter-grid { grid-template-columns: 1fr; }
.constraint-badges { justify-content: flex-start; } .constraint-badges { justify-content: flex-start; }
.eligibility-flow { grid-template-columns: 1fr; } .eligibility-flow { grid-template-columns: 1fr; }
.eligibility-flow i { display: none; } .eligibility-flow i { display: none; }
+5 -2
View File
@@ -584,7 +584,10 @@ onMounted(async () => {
<el-icon><Tickets /></el-icon> <el-icon><Tickets /></el-icon>
<span>{{ formatSchedule(schedule) }}</span> <span>{{ formatSchedule(schedule) }}</span>
</div> </div>
<span v-if="!offering.schedules.length" class="schedule-missing">课表尚未发布</span> <span v-if="offering.isFlexible" class="schedule-missing">
非排时课程 · 不占正常时间与场地
</span>
<span v-else-if="!offering.schedules.length" class="schedule-missing">课表尚未发布</span>
</div> </div>
<footer> <footer>
<div class="seat-meter"> <div class="seat-meter">
@@ -601,7 +604,7 @@ onMounted(async () => {
<el-button <el-button
v-else v-else
type="primary" type="primary"
:disabled="!selectedRound.isAvailableNow || offering.enrolledCount >= offering.capacity || !offering.schedules.length" :disabled="!selectedRound.isAvailableNow || offering.enrolledCount >= offering.capacity || (!offering.isFlexible && !offering.schedules.length)"
@click="enroll(offering)" @click="enroll(offering)"
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button> >{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
</footer> </footer>
+231 -5
View File
@@ -21,6 +21,8 @@ const entryDialog = ref(false)
const settingsDrawer = ref(false) const settingsDrawer = ref(false)
const settingsTab = ref('time') const settingsTab = ref('time')
const constraintDialog = ref(false) const constraintDialog = ref(false)
const constraintBatchDialog = ref(false)
const constraintBatchSaving = ref(false)
const editingPlanId = ref('') const editingPlanId = ref('')
const editingEntryId = ref('') const editingEntryId = ref('')
const keyword = ref('') const keyword = ref('')
@@ -29,6 +31,14 @@ 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 constraintForm = reactive<Record<string, any>>({})
const constraintBatchForm = reactive<Record<string, any>>({})
const constraintFilters = reactive({
keyword: '',
collegeId: undefined as string | undefined,
schedulingMode: undefined as string | undefined,
classroomMode: undefined as string | undefined,
constraintState: undefined as string | undefined,
})
let autoPollTimer: ReturnType<typeof setTimeout> | undefined let autoPollTimer: ReturnType<typeof setTimeout> | undefined
const weekdays = [ const weekdays = [
@@ -86,6 +96,39 @@ const autoStatusText = computed(() => {
const selectedTaskConstraint = computed(() => const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId), constraints.value.find((item) => item.id === entryForm.teachingTaskId),
) )
const constraintColleges = computed(() => {
const result = new Map<string, string>()
constraints.value.forEach((item) => result.set(item.collegeId, item.collegeName))
return [...result].map(([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name, 'zh-CN'),
)
})
const filteredConstraints = computed(() => {
const text = constraintFilters.keyword.trim().toLowerCase()
return constraints.value.filter((item) => {
const matchesKeyword = !text || [
item.taskNumber,
item.name,
item.courseCode,
item.courseName,
...item.teacherNames,
].some((value) => String(value).toLowerCase().includes(text))
const matchesCollege = !constraintFilters.collegeId ||
item.collegeId === constraintFilters.collegeId
const matchesMode = !constraintFilters.schedulingMode ||
item.schedulingMode === constraintFilters.schedulingMode
const matchesClassroom = !constraintFilters.classroomMode ||
(constraintFilters.classroomMode === 'required'
? item.requiresClassroom
: !item.requiresClassroom)
const matchesState = !constraintFilters.constraintState ||
(constraintFilters.constraintState === 'custom'
? item.hasCustomConstraint
: !item.hasCustomConstraint)
return matchesKeyword && matchesCollege && matchesMode &&
matchesClassroom && matchesState
})
})
const filteredBuildings = computed(() => const filteredBuildings = computed(() =>
constraintForm.requiredCampusId constraintForm.requiredCampusId
? buildings.value.filter((item) => item.campusId === constraintForm.requiredCampusId) ? buildings.value.filter((item) => item.campusId === constraintForm.requiredCampusId)
@@ -133,7 +176,9 @@ async function loadSchedulingSettings() {
]) ])
timeSlots.value = timeRes.data timeSlots.value = timeRes.data
constraints.value = constraintRes.data constraints.value = constraintRes.data
tasks.value = constraintRes.data tasks.value = constraintRes.data.filter(
(item: any) => item.schedulingMode === 'Standard',
)
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} }
@@ -183,6 +228,7 @@ function openConstraint(item: any) {
Object.assign(constraintForm, { Object.assign(constraintForm, {
teachingTaskId: item.id, teachingTaskId: item.id,
title: `${item.taskNumber} · ${item.name}`, title: `${item.taskNumber} · ${item.name}`,
schedulingMode: item.schedulingMode,
requiresClassroom: item.requiresClassroom, requiresClassroom: item.requiresClassroom,
requiredCampusId: item.requiredCampusId, requiredCampusId: item.requiredCampusId,
requiredBuildingId: item.requiredBuildingId, requiredBuildingId: item.requiredBuildingId,
@@ -199,6 +245,7 @@ function openConstraint(item: any) {
async function saveConstraint() { async function saveConstraint() {
try { try {
const payload = { const payload = {
schedulingMode: constraintForm.schedulingMode,
requiresClassroom: constraintForm.requiresClassroom, requiresClassroom: constraintForm.requiresClassroom,
requiredCampusId: constraintForm.requiredCampusId || null, requiredCampusId: constraintForm.requiredCampusId || null,
requiredBuildingId: constraintForm.requiredBuildingId || null, requiredBuildingId: constraintForm.requiredBuildingId || null,
@@ -216,6 +263,84 @@ async function saveConstraint() {
} }
} }
function resetConstraintFilters() {
Object.assign(constraintFilters, {
keyword: '',
collegeId: undefined,
schedulingMode: undefined,
classroomMode: undefined,
constraintState: undefined,
})
}
function openConstraintBatch() {
if (!filteredConstraints.value.length) {
ElMessage.warning('当前筛选结果中没有可修改的教学任务。')
return
}
Object.keys(constraintBatchForm).forEach((key) => delete constraintBatchForm[key])
Object.assign(constraintBatchForm, {
updateSchedulingMode: false,
schedulingMode: 'Standard',
updateRequiresClassroom: false,
requiresClassroom: true,
updateDays: false,
allowedDayOfWeeks: [1, 2, 3, 4, 5],
updatePeriodRange: false,
earliestPeriod: undefined,
latestPeriod: undefined,
})
constraintBatchDialog.value = true
}
async function saveConstraintBatch() {
if (!constraintBatchForm.updateSchedulingMode &&
!constraintBatchForm.updateRequiresClassroom &&
!constraintBatchForm.updateDays &&
!constraintBatchForm.updatePeriodRange) {
ElMessage.warning('请至少勾选一项需要批量修改的设置。')
return
}
const targets = [...filteredConstraints.value]
try {
await ElMessageBox.confirm(
`将修改当前筛选到的 ${targets.length} 个教学任务,确定继续吗?`,
'批量修改排课约束',
{ type: 'warning', confirmButtonText: '修改当前结果', cancelButtonText: '取消' },
)
constraintBatchSaving.value = true
const flexible = constraintBatchForm.updateSchedulingMode &&
constraintBatchForm.schedulingMode === 'Flexible'
const { data } = await http.put('/schedules/constraints/batch', {
academicTermId: termId.value,
teachingTaskIds: targets.map((item) => item.id),
schedulingMode: constraintBatchForm.updateSchedulingMode
? constraintBatchForm.schedulingMode
: null,
requiresClassroom: !flexible && constraintBatchForm.updateRequiresClassroom
? constraintBatchForm.requiresClassroom
: null,
allowedDayOfWeeks: !flexible && constraintBatchForm.updateDays
? constraintBatchForm.allowedDayOfWeeks
: null,
updatePeriodRange: !flexible && constraintBatchForm.updatePeriodRange,
earliestPeriod: !flexible && constraintBatchForm.updatePeriodRange
? constraintBatchForm.earliestPeriod || null
: null,
latestPeriod: !flexible && constraintBatchForm.updatePeriodRange
? constraintBatchForm.latestPeriod || null
: null,
})
constraintBatchDialog.value = false
ElMessage.success(`已批量更新 ${data.affectedCount} 个教学任务`)
await loadSchedulingSettings()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally {
constraintBatchSaving.value = false
}
}
async function autoSchedule() { async function autoSchedule() {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
@@ -703,17 +828,52 @@ onBeforeUnmount(clearAutoSchedulePoll)
<div class="settings-lead"> <div class="settings-lead">
<div> <div>
<b>课程排课约束</b> <b>课程排课约束</b>
<span>教师来自已发布教学任务这里限定可用时间校区教学楼指定教室以及是否占用教室</span> <span>筛选后可批量修改当前结果非排时课程不占用正常时间和场地</span>
</div> </div>
<div>
<el-button @click="resetConstraintFilters">重置筛选</el-button>
<el-button
type="primary"
:disabled="!filteredConstraints.length"
@click="openConstraintBatch"
>批量修改当前结果{{ filteredConstraints.length }}</el-button>
</div>
</div>
<div class="constraint-filter-grid">
<el-input
v-model="constraintFilters.keyword"
clearable
placeholder="任务、课程或教师"
:prefix-icon="Search"
/>
<el-select v-model="constraintFilters.collegeId" clearable placeholder="全部开课单位">
<el-option v-for="item in constraintColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="constraintFilters.schedulingMode" clearable placeholder="全部授课方式">
<el-option label="正常排课" value="Standard" />
<el-option label="非排时课程" value="Flexible" />
</el-select>
<el-select v-model="constraintFilters.classroomMode" clearable placeholder="全部场地要求">
<el-option label="需要教室" value="required" />
<el-option label="不占教室" value="not-required" />
</el-select>
<el-select v-model="constraintFilters.constraintState" clearable placeholder="全部约束状态">
<el-option label="已自定义约束" value="custom" />
<el-option label="使用默认约束" value="default" />
</el-select>
</div>
<div class="constraint-result-summary">
{{ constraints.length }} 个教学任务当前显示 {{ filteredConstraints.length }}
</div> </div>
<div class="constraint-list"> <div class="constraint-list">
<article v-for="item in constraints" :key="item.id"> <article v-for="item in filteredConstraints" :key="item.id">
<div> <div>
<span>{{ item.taskNumber }} · 每周 {{ item.weeklyHours }} 学时</span> <span>{{ item.taskNumber }} · 每周 {{ item.weeklyHours }} 学时</span>
<b>{{ item.name }}</b> <b>{{ item.name }} · {{ item.courseName }}</b>
<small>{{ item.teacherNames.join('、') || '未分配教师' }} · {{ item.capacity }} </small> <small>{{ item.collegeName }} · {{ item.teacherNames.join('、') || '未分配教师' }} · {{ item.capacity }} </small>
</div> </div>
<div class="constraint-badges"> <div class="constraint-badges">
<el-tag v-if="item.schedulingMode === 'Flexible'" type="success">非排时课程</el-tag>
<el-tag :type="item.requiresClassroom ? 'primary' : 'info'"> <el-tag :type="item.requiresClassroom ? 'primary' : 'info'">
{{ item.requiresClassroom ? '占用教室' : '不占教室' }} {{ item.requiresClassroom ? '占用教室' : '不占教室' }}
</el-tag> </el-tag>
@@ -724,6 +884,7 @@ onBeforeUnmount(clearAutoSchedulePoll)
</div> </div>
<el-button @click="openConstraint(item)">设置约束</el-button> <el-button @click="openConstraint(item)">设置约束</el-button>
</article> </article>
<el-empty v-if="!filteredConstraints.length" description="没有符合当前筛选条件的教学任务" />
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -732,6 +893,14 @@ onBeforeUnmount(clearAutoSchedulePoll)
<el-dialog v-model="constraintDialog" title="设置课程排课约束" width="720px"> <el-dialog v-model="constraintDialog" title="设置课程排课约束" width="720px">
<div class="constraint-title">{{ constraintForm.title }}</div> <div class="constraint-title">{{ constraintForm.title }}</div>
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="授课方式">
<el-radio-group v-model="constraintForm.schedulingMode">
<el-radio-button value="Standard">正常排课</el-radio-button>
<el-radio-button value="Flexible">非排时课程</el-radio-button>
</el-radio-group>
<small class="field-hint">非排时课程不进入自动排课并在班级和个人课表中单独显示</small>
</el-form-item>
<template v-if="constraintForm.schedulingMode === 'Standard'">
<el-form-item> <el-form-item>
<el-switch <el-switch
v-model="constraintForm.requiresClassroom" v-model="constraintForm.requiresClassroom"
@@ -780,11 +949,68 @@ onBeforeUnmount(clearAutoSchedulePoll)
</el-select> </el-select>
</el-form-item> </el-form-item>
</div> </div>
</template>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="constraintDialog = false">取消</el-button> <el-button @click="constraintDialog = false">取消</el-button>
<el-button type="primary" @click="saveConstraint">保存约束</el-button> <el-button type="primary" @click="saveConstraint">保存约束</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="constraintBatchDialog" title="批量修改当前筛选结果" width="720px">
<div class="constraint-title">
将作用于当前筛选到的 {{ filteredConstraints.length }} 个教学任务
</div>
<el-alert
title="仅勾选需要修改的项目,未勾选的设置保持原值。"
type="info"
:closable="false"
show-icon
/>
<el-form label-position="top" class="constraint-batch-form">
<el-checkbox v-model="constraintBatchForm.updateSchedulingMode">修改授课方式</el-checkbox>
<el-form-item v-if="constraintBatchForm.updateSchedulingMode" label="统一授课方式">
<el-radio-group v-model="constraintBatchForm.schedulingMode">
<el-radio-button value="Standard">正常排课</el-radio-button>
<el-radio-button value="Flexible">非排时课程</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="!(constraintBatchForm.updateSchedulingMode && constraintBatchForm.schedulingMode === 'Flexible')">
<el-checkbox v-model="constraintBatchForm.updateRequiresClassroom">修改场地要求</el-checkbox>
<el-form-item v-if="constraintBatchForm.updateRequiresClassroom" label="统一场地要求">
<el-switch
v-model="constraintBatchForm.requiresClassroom"
active-text="需要占用教室"
inactive-text="不占用教室"
/>
</el-form-item>
<el-checkbox v-model="constraintBatchForm.updateDays">修改允许上课日</el-checkbox>
<el-form-item v-if="constraintBatchForm.updateDays" label="统一允许上课日">
<el-checkbox-group v-model="constraintBatchForm.allowedDayOfWeeks">
<el-checkbox v-for="day in weekdays" :key="day.value" :value="day.value">{{ day.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-checkbox v-model="constraintBatchForm.updatePeriodRange">修改节次范围</el-checkbox>
<div v-if="constraintBatchForm.updatePeriodRange" class="form-grid">
<el-form-item label="统一最早开始节次">
<el-select v-model="constraintBatchForm.earliestPeriod" clearable>
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节`" :value="period" />
</el-select>
</el-form-item>
<el-form-item label="统一最晚结束节次">
<el-select v-model="constraintBatchForm.latestPeriod" clearable>
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节`" :value="period" />
</el-select>
</el-form-item>
</div>
</template>
</el-form>
<template #footer>
<el-button @click="constraintBatchDialog = false">取消</el-button>
<el-button type="primary" :loading="constraintBatchSaving" @click="saveConstraintBatch">
修改当前结果
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
+66 -1
View File
@@ -77,6 +77,14 @@ const availableClasses = computed(() => {
const selectedCourse = computed(() => const selectedCourse = computed(() =>
courses.value.find((item) => item.id === form.courseId), courses.value.find((item) => item.id === form.courseId),
) )
const plannedHours = computed(() =>
form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek
? (form.endWeek - form.startWeek + 1) * form.weeklyHours
: 0,
)
const hoursMatch = computed(() =>
!selectedCourse.value || plannedHours.value === selectedCourse.value.totalHours,
)
const assignableTeachers = computed(() => { const assignableTeachers = computed(() => {
if (!selectedCourse.value) return teachers.value if (!selectedCourse.value) return teachers.value
const approvedIds = new Set(manualEligibleTeachers.value.map((item) => item.teacherId)) const approvedIds = new Set(manualEligibleTeachers.value.map((item) => item.teacherId))
@@ -92,6 +100,21 @@ const publicCourses = computed(() =>
['GeneralRequired', 'GeneralElective'].includes(item.nature), ['GeneralRequired', 'GeneralElective'].includes(item.nature),
), ),
) )
const selectedGenerationCourse = computed(() =>
publicCourses.value.find((item) => item.id === generationForm.courseId),
)
const generationPlannedHours = computed(() =>
generationForm.startWeek &&
generationForm.endWeek &&
generationForm.weeklyHours &&
generationForm.endWeek >= generationForm.startWeek
? (generationForm.endWeek - generationForm.startWeek + 1) * generationForm.weeklyHours
: 0,
)
const generationHoursMatch = computed(() =>
!selectedGenerationCourse.value ||
generationPlannedHours.value === selectedGenerationCourse.value.totalHours,
)
const manageableCourses = computed(() => { const manageableCourses = computed(() => {
if (isSuperAdmin.value) return courses.value if (isSuperAdmin.value) return courses.value
return courses.value.filter((item) => { return courses.value.filter((item) => {
@@ -266,6 +289,7 @@ function resetForm(detail?: any) {
startWeek: 1, startWeek: 1,
endWeek: 16, endWeek: 16,
weeklyHours: 2, weeklyHours: 2,
schedulingMode: 'Standard',
teacherIds: [], teacherIds: [],
primaryTeacherId: undefined, primaryTeacherId: undefined,
classIds: [], classIds: [],
@@ -325,6 +349,12 @@ async function save() {
ElMessage.warning('开始周不能晚于结束周。') ElMessage.warning('开始周不能晚于结束周。')
return return
} }
if (!hoursMatch.value) {
ElMessage.warning(
`课程总学时为 ${selectedCourse.value.totalHours},当前授课安排合计 ${plannedHours.value} 学时,请调整周次或周学时。`,
)
return
}
try { try {
if (editingId.value) await http.put(`/teaching-tasks/${editingId.value}`, form) if (editingId.value) await http.put(`/teaching-tasks/${editingId.value}`, form)
else await http.post('/teaching-tasks', form) else await http.post('/teaching-tasks', form)
@@ -466,6 +496,12 @@ async function generatePublicTasks() {
ElMessage.warning('该课程还没有审核通过的可授课教师。') ElMessage.warning('该课程还没有审核通过的可授课教师。')
return return
} }
if (!generationHoursMatch.value) {
ElMessage.warning(
`课程总学时为 ${selectedGenerationCourse.value.totalHours},当前授课安排合计 ${generationPlannedHours.value} 学时,请调整周次或周学时。`,
)
return
}
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`${generationForm.classIds.length} 个行政班分成 ${generationGroupCount.value} 个教学班,并在 ${eligibleTeachers.value.length} 名合格教师中随机均衡分配。生成结果为草稿,确定继续吗?`, `${generationForm.classIds.length} 个行政班分成 ${generationGroupCount.value} 个教学班,并在 ${eligibleTeachers.value.length} 名合格教师中随机均衡分配。生成结果为草稿,确定继续吗?`,
@@ -618,7 +654,7 @@ onMounted(async () => {
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item> <el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="课程" required> <el-form-item label="课程" required>
<el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()"> <el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()">
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" /> <el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}${item.totalHours} 学时)`" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</div> </div>
@@ -641,6 +677,25 @@ onMounted(async () => {
<el-form-item label="结束周"><el-input-number v-model="form.endWeek" :min="1" :max="30" /></el-form-item> <el-form-item label="结束周"><el-input-number v-model="form.endWeek" :min="1" :max="30" /></el-form-item>
</div> </div>
<el-form-item label="周学时"><el-input-number v-model="form.weeklyHours" :min="1" :max="40" /></el-form-item> <el-form-item label="周学时"><el-input-number v-model="form.weeklyHours" :min="1" :max="40" /></el-form-item>
<div
v-if="selectedCourse"
:class="['hours-validation', { invalid: !hoursMatch }]"
>
<b>{{ hoursMatch ? '学时匹配' : '学时不匹配' }}</b>
<span>
{{ form.startWeek }}{{ form.endWeek }} × 每周 {{ form.weeklyHours }} 学时
= {{ plannedHours }} 学时课程库总学时 {{ selectedCourse.totalHours }}
</span>
</div>
<el-form-item label="授课方式">
<el-radio-group v-model="form.schedulingMode">
<el-radio-button value="Standard">正常排课</el-radio-button>
<el-radio-button value="Flexible">非排时课程</el-radio-button>
</el-radio-group>
<small class="field-hint">
非排时课程不进入自动排课不占用星期节次和场地并在课表中单独列出
</small>
</el-form-item>
<el-form-item label="授课教师"> <el-form-item label="授课教师">
<el-select v-model="form.teacherIds" multiple filterable @change="onTeachersChanged"> <el-select v-model="form.teacherIds" multiple filterable @change="onTeachersChanged">
<el-option v-for="item in assignableTeachers" :key="item.id" :label="`${item.teacherNumber} · ${item.name}`" :value="item.id" /> <el-option v-for="item in assignableTeachers" :key="item.id" :label="`${item.teacherNumber} · ${item.name}`" :value="item.id" />
@@ -760,6 +815,16 @@ onMounted(async () => {
<el-form-item label="周学时"> <el-form-item label="周学时">
<el-input-number v-model="generationForm.weeklyHours" :min="1" :max="40" /> <el-input-number v-model="generationForm.weeklyHours" :min="1" :max="40" />
</el-form-item> </el-form-item>
<div
v-if="selectedGenerationCourse"
:class="['hours-validation', { invalid: !generationHoursMatch }]"
>
<b>{{ generationHoursMatch ? '学时匹配' : '学时不匹配' }}</b>
<span>
当前合计 {{ generationPlannedHours }} 学时课程库总学时
{{ selectedGenerationCourse.totalHours }}
</span>
</div>
<div class="generation-preview"> <div class="generation-preview">
<span>预计生成</span> <span>预计生成</span>
<b>{{ generationGroupCount }}</b> <b>{{ generationGroupCount }}</b>
+39 -6
View File
@@ -147,6 +147,27 @@ onMounted(async () => {
</div> </div>
</div> </div>
<section v-if="timetable?.flexibleCourses?.length" class="flexible-courses">
<div class="flexible-heading">
<div>
<span>非排时课程</span>
<strong>不占正常上课时间与场地</strong>
</div>
<small> {{ timetable.flexibleCourses.length }} </small>
</div>
<div class="flexible-course-list">
<article v-for="course in timetable.flexibleCourses" :key="course.id">
<span>{{ course.courseCode }} · {{ course.taskNumber }}</span>
<strong>{{ course.courseName }}</strong>
<p>{{ course.teacherNames.join('、') || '教师待定' }}</p>
<small>
{{ course.startWeek }}{{ course.endWeek }} ·
每周 {{ course.weeklyHours }} 学时 · {{ course.totalHours }} 学时
</small>
</article>
</div>
</section>
<div v-if="timetable?.plan && timetable.entries.length" class="timetable-scroll"> <div v-if="timetable?.plan && timetable.entries.length" class="timetable-scroll">
<div <div
class="week-grid" class="week-grid"
@@ -182,7 +203,7 @@ onMounted(async () => {
</div> </div>
</div> </div>
<el-empty <el-empty
v-else-if="timetable && !loading" v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'" :description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
/> />
</section> </section>
@@ -190,21 +211,33 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
.timetable-page { display: grid; gap: 20px; } .timetable-page { min-width: 0; width: 100%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
.public-timetable { min-height: 100vh; padding: 0 32px 40px; background: #f4f7fa; } .public-timetable { max-width: 100vw; min-height: 100vh; padding: 0 32px 40px; box-sizing: border-box; background: #f4f7fa; }
.public-header { height: 68px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #dce4eb; } .public-header { height: 68px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #dce4eb; }
.public-brand { color: #17324d; font-weight: 750; text-decoration: none; letter-spacing: .02em; } .public-brand { color: #17324d; font-weight: 750; text-decoration: none; letter-spacing: .02em; }
.login-link { color: #176b87; text-decoration: none; font-weight: 650; } .login-link { color: #176b87; text-decoration: none; font-weight: 650; }
.timetable-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 24px 28px; background: #fff; border: 1px solid #dce4eb; } .timetable-heading { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 24px; padding: 24px 28px; background: #fff; border: 1px solid #dce4eb; }
.timetable-heading h2 { margin: 5px 0 8px; color: #17324d; font-size: 27px; } .timetable-heading h2 { margin: 5px 0 8px; color: #17324d; font-size: 27px; }
.timetable-heading p { margin: 0; color: #647587; } .timetable-heading p { margin: 0; color: #647587; }
.timetable-filters { display: flex; gap: 12px; } .timetable-filters { display: flex; flex-wrap: wrap; gap: 12px; }
.timetable-filters .el-select { width: 270px; } .timetable-filters .el-select { width: 270px; }
.timetable-sheet { min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; } .timetable-sheet { min-width: 0; min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; }
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; } .sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
.sheet-meta div { display: grid; gap: 3px; } .sheet-meta div { display: grid; gap: 3px; }
.sheet-meta strong { color: #17324d; } .sheet-meta strong { color: #17324d; }
.sheet-meta span { color: #718191; font-size: 13px; } .sheet-meta span { color: #718191; font-size: 13px; }
.flexible-courses { margin-bottom: 20px; border: 1px solid #cfe1dc; background: #f5faf8; }
.flexible-heading { padding: 12px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #dce9e5; }
.flexible-heading > div { display: flex; align-items: baseline; gap: 10px; }
.flexible-heading span { color: #176b5d; font-size: 12px; font-weight: 750; }
.flexible-heading strong { color: #506c65; font-size: 12px; }
.flexible-heading small { color: #718b84; }
.flexible-course-list { padding: 12px; display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
.flexible-course-list article { padding: 12px 13px; display: grid; gap: 4px; border-left: 3px solid #2d8975; background: #fff; }
.flexible-course-list article > span { color: #277766; font: 700 10px/1.3 Consolas, monospace; }
.flexible-course-list article strong { color: #173f38; font-size: 14px; }
.flexible-course-list article p { margin: 0; color: #5e746f; font-size: 12px; }
.flexible-course-list article small { color: #778b86; font-size: 11px; }
.timetable-scroll { overflow: auto; } .timetable-scroll { overflow: auto; }
.week-grid { min-width: 1120px; display: grid; grid-template-columns: 92px repeat(7, minmax(140px, 1fr)); position: relative; } .week-grid { min-width: 1120px; display: grid; grid-template-columns: 92px repeat(7, minmax(140px, 1fr)); position: relative; }
.grid-corner, .day-head, .period-head { z-index: 2; background: #f3f6f8; border: 1px solid #dce4eb; color: #3f5366; } .grid-corner, .day-head, .period-head { z-index: 2; background: #f3f6f8; border: 1px solid #dce4eb; color: #3f5366; }