教学任务按 授课周数 × 周学时 = 课程总学时 双端校验,不匹配会显示具体差额并阻止保存;公共课批量生成同样校验。
排课约束增加课程、教师、学院、授课方式、场地要求、约束状态筛选,并支持批量修改当前筛选结果。 新增“非排时课程”授课方式:不进入自动排课 不占星期、节次和教室 不阻塞课表发布 允许正常选课 在班级课表和学生个人课表中单独展示
This commit is contained in:
@@ -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;
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+2876
File diff suppressed because it is too large
Load Diff
+29
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user