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

排课约束增加课程、教师、学院、授课方式、场地要求、约束状态筛选,并支持批量修改当前筛选结果。
新增“非排时课程”授课方式:不进入自动排课
不占星期、节次和教室
不阻塞课表发布
允许正常选课
在班级课表和学生个人课表中单独展示
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
@@ -631,11 +631,12 @@ public sealed class CourseSelectionsController(
x.Enrollments.Count(item =>
item.Status == CourseEnrollmentStatus.Enrolled),
x.IsOpenToAll,
x.Enrollments
.Where(item => item.StudentId == student.Id)
.Select(item => (CourseEnrollmentStatus?)item.Status)
.FirstOrDefault(),
db.ScheduleEntries
x.Enrollments
.Where(item => item.StudentId == student.Id)
.Select(item => (CourseEnrollmentStatus?)item.Status)
.FirstOrDefault(),
x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible,
db.ScheduleEntries
.Where(entry =>
entry.TeachingTaskId == x.TeachingTaskId &&
entry.SchedulePlan!.AcademicTermId == round.AcademicTermId &&
@@ -803,8 +804,9 @@ public sealed class CourseSelectionsController(
round.AcademicTermId,
[task.Id],
cancellationToken);
if (candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
var selectedTaskIds = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
@@ -1008,9 +1010,10 @@ public sealed record StudentOfferingDto(
IEnumerable<string> TeacherNames,
int Capacity,
int EnrolledCount,
bool IsOpenToAll,
CourseEnrollmentStatus? EnrollmentStatus,
IEnumerable<StudentScheduleDto> Schedules);
bool IsOpenToAll,
CourseEnrollmentStatus? EnrollmentStatus,
bool IsFlexible,
IEnumerable<StudentScheduleDto> Schedules);
public sealed record StudentScheduleDto(
int DayOfWeek,
@@ -128,10 +128,11 @@ public sealed class CoursesController(
x.Name,
x.CollegeId,
CollegeName = x.College!.Name,
x.CourseCategoryId,
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
x.Credits,
x.Nature,
x.CourseCategoryId,
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
x.Credits,
x.TotalHours,
x.Nature,
x.AssessmentMethod
})
.ToListAsync(cancellationToken));
@@ -82,13 +82,17 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{
x.Id,
x.TaskNumber,
x.Name,
CourseName = x.Course!.Name,
TeacherNames = x.Teachers
x.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course!.Name,
CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Capacity,
x.WeeklyHours
x.Capacity,
x.WeeklyHours,
x.SchedulingMode
})
.ToListAsync(cancellationToken);
var taskIds = tasks.Select(x => x.Id).ToList();
@@ -103,12 +107,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{
task.Id,
task.TaskNumber,
task.Name,
task.CourseName,
task.TeacherNames,
task.Capacity,
task.WeeklyHours,
RequiresClassroom = constraint?.RequiresClassroom ?? true,
task.Name,
task.CourseCode,
task.CourseName,
task.CollegeId,
task.CollegeName,
task.TeacherNames,
task.Capacity,
task.WeeklyHours,
task.SchedulingMode,
HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
? false
: constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId,
constraint?.RequiredBuildingId,
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
@@ -128,15 +139,34 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{
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();
if (request.EarliestPeriod.HasValue &&
request.LatestPeriod.HasValue &&
request.EarliestPeriod > request.LatestPeriod)
return ValidationProblem("最早节次不能晚于最晚节次。");
if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。");
var task = await db.TeachingTasks
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound();
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
await HasScheduleEntriesAsync([teachingTaskId], cancellationToken))
return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。");
Building? building = null;
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;
if (request.RequiredBuildingId.HasValue)
{
building = await db.Buildings.AsNoTracking()
@@ -191,10 +221,128 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: [];
await db.SaveChangesAsync(cancellationToken);
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 NoContent();
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) =>
string.IsNullOrWhiteSpace(value)
? []
@@ -211,6 +359,7 @@ public sealed record ScheduleTimeSlotRequest(
bool IsEnabled);
public sealed record TeachingTaskScheduleConstraintRequest(
TeachingTaskSchedulingMode SchedulingMode,
bool RequiresClassroom,
Guid? RequiredCampusId,
Guid? RequiredBuildingId,
@@ -218,3 +367,13 @@ public sealed record TeachingTaskScheduleConstraintRequest(
IReadOnlyList<int> AllowedDayOfWeeks,
[Range(1, 30)] int? EarliestPeriod,
[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);
@@ -232,9 +232,10 @@ public sealed class SchedulesController(
}
var requiredTasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
.ToListAsync(cancellationToken);
var scheduledHours = plan.Entries
@@ -449,10 +450,12 @@ public sealed class SchedulesController(
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null ||
task.Status != TeachingTaskStatus.Published ||
task.AcademicTermId != plan.AcademicTermId)
return ValidationProblem("只能安排同一学期内已发布的教学任务。");
if (task is null ||
task.Status != TeachingTaskStatus.Published ||
task.AcademicTermId != plan.AcademicTermId)
return ValidationProblem("只能安排同一学期内已发布的教学任务。");
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
@@ -76,9 +76,11 @@ public sealed class TeachingTasksController(
CollegeName = x.Course.College!.Name,
x.Capacity,
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
x.GenerationBatchCode,
x.EndWeek,
x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode,
x.Status,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
@@ -114,9 +116,11 @@ public sealed class TeachingTasksController(
CollegeName = x.Course.College!.Name,
x.Capacity,
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
x.GenerationBatchCode,
x.EndWeek,
x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode,
x.Status,
x.Notes,
x.PublishedAt,
@@ -170,9 +174,10 @@ public sealed class TeachingTasksController(
CourseId = request.CourseId,
Capacity = request.Capacity,
StartWeek = request.StartWeek,
EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours,
Notes = Normalize(request.Notes)
EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours,
SchedulingMode = request.SchedulingMode,
Notes = Normalize(request.Notes)
};
SetAssignments(task, request);
db.TeachingTasks.Add(task);
@@ -203,9 +208,10 @@ public sealed class TeachingTasksController(
task.CourseId = request.CourseId;
task.Capacity = request.Capacity;
task.StartWeek = request.StartWeek;
task.EndWeek = request.EndWeek;
task.WeeklyHours = request.WeeklyHours;
task.Notes = Normalize(request.Notes);
task.EndWeek = request.EndWeek;
task.WeeklyHours = request.WeeklyHours;
task.SchedulingMode = request.SchedulingMode;
task.Notes = Normalize(request.Notes);
db.TeachingTaskTeachers.RemoveRange(task.Teachers);
db.TeachingTaskClasses.RemoveRange(task.Classes);
task.Teachers = [];
@@ -342,8 +348,14 @@ public sealed class TeachingTasksController(
.FirstOrDefaultAsync(
x => x.Id == request.CourseId && x.IsEnabled,
cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid();
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
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))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
var scopedCollegeId = ScopedCollegeId();
@@ -501,9 +513,17 @@ public sealed class TeachingTasksController(
return ValidationProblem("开始周不能晚于结束周。");
var course = await db.Courses.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid();
var collegeId = ScopedCollegeId();
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
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();
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
@@ -695,11 +715,12 @@ public sealed record TeachingTaskRequest(
[Range(1, 10000)] int Capacity,
[Range(1, 30)] int StartWeek,
[Range(1, 30)] int EndWeek,
[Range(1, 40)] int WeeklyHours,
IReadOnlyCollection<Guid> TeacherIds,
Guid? PrimaryTeacherId,
IReadOnlyCollection<Guid> ClassIds,
[MaxLength(500)] string? Notes);
[Range(1, 40)] int WeeklyHours,
IReadOnlyCollection<Guid> TeacherIds,
Guid? PrimaryTeacherId,
IReadOnlyCollection<Guid> ClassIds,
[MaxLength(500)] string? Notes,
TeachingTaskSchedulingMode SchedulingMode = TeachingTaskSchedulingMode.Standard);
public sealed record PublicCourseTaskGenerationRequest(
Guid AcademicTermId,
@@ -28,9 +28,13 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
x.StartDate,
x.EndDate,
x.IsCurrent,
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published)
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published) ||
db.TeachingTasks.Any(task =>
task.AcademicTermId == x.Id &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
})
.ToListAsync(cancellationToken);
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
@@ -47,12 +51,17 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
x.Grade,
MajorName = x.Major!.Name,
CollegeName = x.Major.College!.Name,
HasPublishedTimetable = defaultTermId.HasValue &&
db.ScheduleEntries.Any(entry =>
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id))
HasPublishedTimetable = defaultTermId.HasValue &&
(db.ScheduleEntries.Any(entry =>
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.TeachingTask!.Classes.Any(item =>
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);
return Ok(new { Terms = terms, Classes = classes });
@@ -148,21 +157,67 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
.Select(x => new { x.Id, x.Name, x.Version, x.PublishedAt })
.FirstOrDefaultAsync(cancellationToken);
var slots = await db.ScheduleTimeSlots.AsNoTracking()
var slots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt })
.Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt })
.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)
return Ok(new
if (plan is null)
return Ok(new
{
Term = term,
Class = administrativeClass,
Student = student,
Plan = (object?)null,
Slots = slots,
Entries = Array.Empty<object>()
Plan = (object?)null,
Slots = slots,
Entries = Array.Empty<object>(),
FlexibleCourses = flexibleCourses
});
var entries = db.ScheduleEntries.AsNoTracking()
@@ -223,9 +278,10 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
Term = term,
Class = administrativeClass,
Student = student,
Plan = plan,
Slots = slots,
Entries = result
});
Plan = plan,
Slots = slots,
Entries = result,
FlexibleCourses = flexibleCourses
});
}
}
@@ -14,6 +14,8 @@ public sealed class TeachingTask : EntityBase
public int StartWeek { get; set; } = 1;
public int EndWeek { get; set; } = 16;
public int WeeklyHours { get; set; } = 2;
public TeachingTaskSchedulingMode SchedulingMode { get; set; } =
TeachingTaskSchedulingMode.Standard;
public TeachingTaskStatus Status { get; set; } = TeachingTaskStatus.Draft;
public string? GenerationBatchCode { get; set; }
public string? Notes { get; set; }
@@ -63,6 +65,12 @@ public enum TeachingTaskStatus
Closed = 3
}
public enum TeachingTaskSchedulingMode
{
Standard = 1,
Flexible = 2
}
public enum TeacherCourseApplicationStatus
{
Pending = 1,
@@ -23,8 +23,10 @@ public sealed class DevelopmentSqliteMigrator(
"20260724_14_scheduling_optimization";
private const string TeacherCourseApplicationsMigration =
"20260724_15_teacher_course_applications";
private const string AutomaticScheduleJobsMigration =
"20260724_16_automatic_schedule_jobs";
private const string AutomaticScheduleJobsMigration =
"20260724_16_automatic_schedule_jobs";
private const string TeachingTaskSchedulingModesMigration =
"20260725_17_teaching_task_scheduling_modes";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -145,9 +147,21 @@ public sealed class DevelopmentSqliteMigrator(
WHERE type = 'table' AND name = 'AutomaticScheduleJobs'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AutomaticScheduleJobsMigration,
automaticScheduleJobsExist ? [] : AutomaticScheduleJobStatements,
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(
AutomaticScheduleJobsMigration,
automaticScheduleJobsExist ? [] : AutomaticScheduleJobStatements,
TeachingTaskSchedulingModesMigration,
teachingTaskSchedulingModeExists ? [] : TeachingTaskSchedulingModeStatements,
cancellationToken);
}
@@ -384,8 +398,9 @@ public sealed class DevelopmentSqliteMigrator(
"Capacity" INTEGER NOT NULL,
"StartWeek" INTEGER NOT NULL,
"EndWeek" INTEGER NOT NULL,
"WeeklyHours" INTEGER NOT NULL,
"Status" INTEGER NOT NULL,
"WeeklyHours" INTEGER NOT NULL,
"SchedulingMode" INTEGER NOT NULL DEFAULT 1,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
@@ -1081,4 +1096,12 @@ public sealed class DevelopmentSqliteMigrator(
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")
.HasColumnType("datetime(6)");
b.Property<int>("SchedulingMode")
.HasColumnType("int");
b.Property<int>("StartWeek")
.HasColumnType("int");
@@ -26,9 +26,10 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
var activePeriods = timeSlots.Select(x => x.PeriodNumber).ToHashSet();
var tasks = await db.TeachingTasks
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Include(x => x.Teachers)
.Include(x => x.Classes)
.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} 学时。请调整授课周次或周学时。";
}
}
@@ -27,16 +27,92 @@ public sealed class AutomaticScheduleGeneratorTests
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
Assert.True(await db.Database
.SqlQueryRaw<int>(
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));
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]
public async Task Generator_schedules_roomless_course_within_allowed_time()
{
@@ -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-table .el-input-number { width: 76px; }
.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 > div:first-child { min-width: 0; display: grid; gap: 4px; }
.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 span, .generation-preview small { font-size: 10px; }
.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-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; }
@@ -1028,6 +1036,7 @@ button { cursor: pointer; }
.settings-lead { align-items: stretch; flex-direction: column; }
.settings-lead > div:last-child { flex-wrap: wrap; }
.constraint-list article { grid-template-columns: 1fr; }
.constraint-filter-grid { grid-template-columns: 1fr; }
.constraint-badges { justify-content: flex-start; }
.eligibility-flow { grid-template-columns: 1fr; }
.eligibility-flow i { display: none; }
+5 -2
View File
@@ -584,7 +584,10 @@ onMounted(async () => {
<el-icon><Tickets /></el-icon>
<span>{{ formatSchedule(schedule) }}</span>
</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>
<footer>
<div class="seat-meter">
@@ -601,7 +604,7 @@ onMounted(async () => {
<el-button
v-else
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)"
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
</footer>
+253 -27
View File
@@ -21,6 +21,8 @@ const entryDialog = ref(false)
const settingsDrawer = ref(false)
const settingsTab = ref('time')
const constraintDialog = ref(false)
const constraintBatchDialog = ref(false)
const constraintBatchSaving = ref(false)
const editingPlanId = ref('')
const editingEntryId = ref('')
const keyword = ref('')
@@ -29,6 +31,14 @@ const planForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({})
const entryForm = 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
const weekdays = [
@@ -86,6 +96,39 @@ const autoStatusText = computed(() => {
const selectedTaskConstraint = computed(() =>
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(() =>
constraintForm.requiredCampusId
? buildings.value.filter((item) => item.campusId === constraintForm.requiredCampusId)
@@ -133,7 +176,9 @@ async function loadSchedulingSettings() {
])
timeSlots.value = timeRes.data
constraints.value = constraintRes.data
tasks.value = constraintRes.data
tasks.value = constraintRes.data.filter(
(item: any) => item.schedulingMode === 'Standard',
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
@@ -183,6 +228,7 @@ function openConstraint(item: any) {
Object.assign(constraintForm, {
teachingTaskId: item.id,
title: `${item.taskNumber} · ${item.name}`,
schedulingMode: item.schedulingMode,
requiresClassroom: item.requiresClassroom,
requiredCampusId: item.requiredCampusId,
requiredBuildingId: item.requiredBuildingId,
@@ -199,6 +245,7 @@ function openConstraint(item: any) {
async function saveConstraint() {
try {
const payload = {
schedulingMode: constraintForm.schedulingMode,
requiresClassroom: constraintForm.requiresClassroom,
requiredCampusId: constraintForm.requiredCampusId || 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() {
try {
await ElMessageBox.confirm(
@@ -699,40 +824,84 @@ onBeforeUnmount(clearAutoSchedulePoll)
</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-tab-pane label="课程排课约束" name="constraints">
<div class="settings-lead">
<div>
<b>课程排课约束</b>
<span>筛选后可批量修改当前结果非排时课程不占用正常时间和场地</span>
</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 class="constraint-list">
<article v-for="item in filteredConstraints" :key="item.id">
<div>
<span>{{ item.taskNumber }} · 每周 {{ item.weeklyHours }} 学时</span>
<b>{{ item.name }} · {{ item.courseName }}</b>
<small>{{ item.collegeName }} · {{ item.teacherNames.join('、') || '未分配教师' }} · {{ item.capacity }} </small>
</div>
<div class="constraint-badges">
<el-tag v-if="item.schedulingMode === 'Flexible'" type="success">非排时课程</el-tag>
<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-button @click="openConstraint(item)">设置约束</el-button>
</article>
<el-empty v-if="!filteredConstraints.length" description="没有符合当前筛选条件的教学任务" />
</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>
<div class="constraint-title">{{ constraintForm.title }}</div>
<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-switch
v-model="constraintForm.requiresClassroom"
active-text="需要占用教室"
@@ -779,12 +948,69 @@ onBeforeUnmount(clearAutoSchedulePoll)
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节 · ${timeLabel(period)}`" :value="period" />
</el-select>
</el-form-item>
</div>
</el-form>
</div>
</template>
</el-form>
<template #footer>
<el-button @click="constraintDialog = false">取消</el-button>
<el-button type="primary" @click="saveConstraint">保存约束</el-button>
</template>
</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>
</template>
+71 -6
View File
@@ -77,6 +77,14 @@ const availableClasses = computed(() => {
const selectedCourse = computed(() =>
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(() => {
if (!selectedCourse.value) return teachers.value
const approvedIds = new Set(manualEligibleTeachers.value.map((item) => item.teacherId))
@@ -92,6 +100,21 @@ const publicCourses = computed(() =>
['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(() => {
if (isSuperAdmin.value) return courses.value
return courses.value.filter((item) => {
@@ -265,7 +288,8 @@ function resetForm(detail?: any) {
capacity: 60,
startWeek: 1,
endWeek: 16,
weeklyHours: 2,
weeklyHours: 2,
schedulingMode: 'Standard',
teacherIds: [],
primaryTeacherId: undefined,
classIds: [],
@@ -325,6 +349,12 @@ async function save() {
ElMessage.warning('开始周不能晚于结束周。')
return
}
if (!hoursMatch.value) {
ElMessage.warning(
`课程总学时为 ${selectedCourse.value.totalHours},当前授课安排合计 ${plannedHours.value} 学时,请调整周次或周学时。`,
)
return
}
try {
if (editingId.value) await http.put(`/teaching-tasks/${editingId.value}`, form)
else await http.post('/teaching-tasks', form)
@@ -466,6 +496,12 @@ async function generatePublicTasks() {
ElMessage.warning('该课程还没有审核通过的可授课教师。')
return
}
if (!generationHoursMatch.value) {
ElMessage.warning(
`课程总学时为 ${selectedGenerationCourse.value.totalHours},当前授课安排合计 ${generationPlannedHours.value} 学时,请调整周次或周学时。`,
)
return
}
try {
await ElMessageBox.confirm(
`${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.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-form-item>
</div>
@@ -640,7 +676,26 @@ onMounted(async () => {
<el-form-item label="开始周"><el-input-number v-model="form.startWeek" :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>
<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-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" />
@@ -757,9 +812,19 @@ onMounted(async () => {
<el-input-number v-model="generationForm.endWeek" :min="1" :max="30" />
</el-form-item>
</div>
<el-form-item label="周学时">
<el-input-number v-model="generationForm.weeklyHours" :min="1" :max="40" />
</el-form-item>
<el-form-item label="周学时">
<el-input-number v-model="generationForm.weeklyHours" :min="1" :max="40" />
</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">
<span>预计生成</span>
<b>{{ generationGroupCount }}</b>
+40 -7
View File
@@ -145,7 +145,28 @@ onMounted(async () => {
<span v-if="timetable.plan">发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}</span>
<span v-else>本学期课表尚未发布</span>
</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
@@ -182,7 +203,7 @@ onMounted(async () => {
</div>
</div>
<el-empty
v-else-if="timetable && !loading"
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
/>
</section>
@@ -190,21 +211,33 @@ onMounted(async () => {
</template>
<style scoped>
.timetable-page { display: grid; gap: 20px; }
.public-timetable { min-height: 100vh; padding: 0 32px 40px; background: #f4f7fa; }
.timetable-page { min-width: 0; width: 100%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
.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-brand { color: #17324d; font-weight: 750; text-decoration: none; letter-spacing: .02em; }
.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 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-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 div { display: grid; gap: 3px; }
.sheet-meta strong { color: #17324d; }
.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; }
.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; }