实验课现在直接进入普通排课流程,不再要求先到实验排课模块逐个安排。
普通“添加排课”新增“理论课 / 实验课”类型。 自动排课会分别补足理论学时和实验学时。 实验课只能安排到实验室、实训室、机房、语音室等场地。 发布课表时分别校验理论、实验学时;任一未排足都不能发布。 普通课表及 Excel 导出会标注“实验课”。 历史排课保持不变,迁移后默认识别为理论课;后续新建或修订版本时再补充实验课。
This commit is contained in:
@@ -3,7 +3,6 @@ using Jiaowu.Api.Domain.Academic;
|
|||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Teaching;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -110,15 +109,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
return Ok(tasks.Select(task =>
|
return Ok(tasks.Select(task =>
|
||||||
{
|
{
|
||||||
constraints.TryGetValue(task.Id, out var constraint);
|
constraints.TryGetValue(task.Id, out var constraint);
|
||||||
var weeklyHours = task.WeeklyHours;
|
|
||||||
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
|
|
||||||
TeachingTaskHours.TryResolveRegularWeeklyHours(
|
|
||||||
task.CourseTotalHours,
|
|
||||||
task.CoursePracticeHours,
|
|
||||||
task.StartWeek,
|
|
||||||
task.EndWeek,
|
|
||||||
out var regularWeeklyHours))
|
|
||||||
weeklyHours = regularWeeklyHours;
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
task.Id,
|
task.Id,
|
||||||
@@ -132,7 +122,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
task.Capacity,
|
task.Capacity,
|
||||||
task.StartWeek,
|
task.StartWeek,
|
||||||
task.EndWeek,
|
task.EndWeek,
|
||||||
WeeklyHours = weeklyHours,
|
task.WeeklyHours,
|
||||||
task.CourseTotalHours,
|
task.CourseTotalHours,
|
||||||
task.CoursePracticeHours,
|
task.CoursePracticeHours,
|
||||||
task.SchedulingMode,
|
task.SchedulingMode,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public sealed class SchedulesController(
|
|||||||
{
|
{
|
||||||
entry.Id,
|
entry.Id,
|
||||||
entry.TeachingTaskId,
|
entry.TeachingTaskId,
|
||||||
|
entry.Kind,
|
||||||
TaskNumber = entry.TeachingTask!.TaskNumber,
|
TaskNumber = entry.TeachingTask!.TaskNumber,
|
||||||
TaskName = entry.TeachingTask.Name,
|
TaskName = entry.TeachingTask.Name,
|
||||||
CourseCode = entry.TeachingTask.Course!.Code,
|
CourseCode = entry.TeachingTask.Course!.Code,
|
||||||
@@ -167,6 +168,7 @@ public sealed class SchedulesController(
|
|||||||
Entries = source.Entries.Select(entry => new ScheduleEntry
|
Entries = source.Entries.Select(entry => new ScheduleEntry
|
||||||
{
|
{
|
||||||
TeachingTaskId = entry.TeachingTaskId,
|
TeachingTaskId = entry.TeachingTaskId,
|
||||||
|
Kind = entry.Kind,
|
||||||
ClassroomId = entry.ClassroomId,
|
ClassroomId = entry.ClassroomId,
|
||||||
DayOfWeek = entry.DayOfWeek,
|
DayOfWeek = entry.DayOfWeek,
|
||||||
StartPeriod = entry.StartPeriod,
|
StartPeriod = entry.StartPeriod,
|
||||||
@@ -419,6 +421,7 @@ public sealed class SchedulesController(
|
|||||||
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
|
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
|
||||||
if (validation is not null) return validation;
|
if (validation is not null) return validation;
|
||||||
entry.TeachingTaskId = request.TeachingTaskId;
|
entry.TeachingTaskId = request.TeachingTaskId;
|
||||||
|
entry.Kind = request.Kind;
|
||||||
entry.ClassroomId = request.ClassroomId;
|
entry.ClassroomId = request.ClassroomId;
|
||||||
entry.DayOfWeek = request.DayOfWeek;
|
entry.DayOfWeek = request.DayOfWeek;
|
||||||
entry.StartPeriod = request.StartPeriod;
|
entry.StartPeriod = request.StartPeriod;
|
||||||
@@ -508,34 +511,52 @@ public sealed class SchedulesController(
|
|||||||
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
||||||
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
||||||
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
||||||
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
var targetHours = TeachingTaskHours.TargetHours(
|
||||||
task.Course!,
|
task.Course!,
|
||||||
task.StartWeek,
|
request.Kind);
|
||||||
task.EndWeek,
|
if (targetHours == 0)
|
||||||
out var requiredWeeklyHours))
|
|
||||||
return ValidationProblem(
|
return ValidationProblem(
|
||||||
"该课程的普通排课学时不能按授课周次整除,请先调整教学任务周次。");
|
request.Kind == ScheduleEntryKind.Experiment
|
||||||
if (requiredWeeklyHours == 0)
|
? "该课程没有实践学时,不能安排实验课。"
|
||||||
return ValidationProblem(
|
: "该课程没有理论学时,不能安排理论课。");
|
||||||
"该课程全部为实践学时,无需进入普通课表,请在实验管理中安排。");
|
var existingEntries = await db.ScheduleEntries.AsNoTracking()
|
||||||
var existingHours = await db.ScheduleEntries.AsNoTracking()
|
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.SchedulePlanId == plan.Id &&
|
x.SchedulePlanId == plan.Id &&
|
||||||
x.TeachingTaskId == request.TeachingTaskId &&
|
x.TeachingTaskId == request.TeachingTaskId &&
|
||||||
|
x.Kind == request.Kind &&
|
||||||
x.Id != entryId)
|
x.Id != entryId)
|
||||||
.SumAsync(x => x.PeriodCount, cancellationToken);
|
.Select(x => new
|
||||||
if (existingHours + request.PeriodCount > requiredWeeklyHours)
|
{
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
x.WeekPattern,
|
||||||
|
x.PeriodCount
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingHours = existingEntries.Sum(x =>
|
||||||
|
TeachingTaskHours.ScheduledHours(
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
x.WeekPattern,
|
||||||
|
x.PeriodCount));
|
||||||
|
var proposedHours = TeachingTaskHours.ScheduledHours(
|
||||||
|
request.StartWeek,
|
||||||
|
request.EndWeek,
|
||||||
|
request.WeekPattern,
|
||||||
|
request.PeriodCount);
|
||||||
|
if (existingHours + proposedHours > targetHours)
|
||||||
return ValidationProblem(
|
return ValidationProblem(
|
||||||
$"该教学任务普通课表每周只需 {requiredWeeklyHours} 学时;" +
|
$"该教学任务{(request.Kind == ScheduleEntryKind.Experiment ? "实验" : "理论")}课" +
|
||||||
$"当前操作后将达到 {existingHours + request.PeriodCount} 学时," +
|
$"共需 {targetHours} 学时;当前操作后将达到 " +
|
||||||
"实践学时请在实验管理中安排。");
|
$"{existingHours + proposedHours} 学时。");
|
||||||
|
|
||||||
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
x => x.TeachingTaskId == request.TeachingTaskId,
|
x => x.TeachingTaskId == request.TeachingTaskId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var requiresClassroom = constraint?.RequiresClassroom ?? true;
|
var requiresClassroom = request.Kind == ScheduleEntryKind.Experiment ||
|
||||||
|
constraint?.RequiresClassroom != false;
|
||||||
if (requiresClassroom && !request.ClassroomId.HasValue)
|
if (requiresClassroom && !request.ClassroomId.HasValue)
|
||||||
return ValidationProblem("该课程需要占用教室,请选择教室。");
|
return ValidationProblem("该课程需要占用教室,请选择教室。");
|
||||||
if (!requiresClassroom && request.ClassroomId.HasValue)
|
if (!requiresClassroom && request.ClassroomId.HasValue)
|
||||||
@@ -559,6 +580,10 @@ public sealed class SchedulesController(
|
|||||||
x => x.Id == request.ClassroomId && x.IsEnabled,
|
x => x.Id == request.ClassroomId && x.IsEnabled,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
||||||
|
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
!IsExperimentRoom(classroom.RoomType))
|
||||||
|
return ValidationProblem(
|
||||||
|
$"实验课必须安排在实验室、实训室或机房;“{classroom.Name}”的场地类型为“{classroom.RoomType}”。");
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||||
@@ -612,6 +637,7 @@ public sealed class SchedulesController(
|
|||||||
{
|
{
|
||||||
SchedulePlanId = planId,
|
SchedulePlanId = planId,
|
||||||
TeachingTaskId = request.TeachingTaskId,
|
TeachingTaskId = request.TeachingTaskId,
|
||||||
|
Kind = request.Kind,
|
||||||
ClassroomId = request.ClassroomId,
|
ClassroomId = request.ClassroomId,
|
||||||
DayOfWeek = request.DayOfWeek,
|
DayOfWeek = request.DayOfWeek,
|
||||||
StartPeriod = request.StartPeriod,
|
StartPeriod = request.StartPeriod,
|
||||||
@@ -629,6 +655,12 @@ public sealed class SchedulesController(
|
|||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
|
private static bool IsExperimentRoom(string roomType) =>
|
||||||
|
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private async Task<ActionResult> SaveAsync(
|
private async Task<ActionResult> SaveAsync(
|
||||||
Guid id,
|
Guid id,
|
||||||
bool created,
|
bool created,
|
||||||
@@ -726,7 +758,8 @@ public sealed record ScheduleEntryRequest(
|
|||||||
[Range(1, 30)] int StartWeek,
|
[Range(1, 30)] int StartWeek,
|
||||||
[Range(1, 30)] int EndWeek,
|
[Range(1, 30)] int EndWeek,
|
||||||
WeekPattern WeekPattern,
|
WeekPattern WeekPattern,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes,
|
||||||
|
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
|
||||||
|
|
||||||
public sealed record AutomaticScheduleJobResponse(
|
public sealed record AutomaticScheduleJobResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
|
|||||||
public SchedulePlan? SchedulePlan { get; set; }
|
public SchedulePlan? SchedulePlan { get; set; }
|
||||||
public Guid TeachingTaskId { get; set; }
|
public Guid TeachingTaskId { get; set; }
|
||||||
public TeachingTask? TeachingTask { get; set; }
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
|
||||||
public Guid? ClassroomId { get; set; }
|
public Guid? ClassroomId { get; set; }
|
||||||
public Classroom? Classroom { get; set; }
|
public Classroom? Classroom { get; set; }
|
||||||
public int DayOfWeek { get; set; }
|
public int DayOfWeek { get; set; }
|
||||||
@@ -129,3 +130,9 @@ public enum WeekPattern
|
|||||||
Odd = 2,
|
Odd = 2,
|
||||||
Even = 3
|
Even = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ScheduleEntryKind
|
||||||
|
{
|
||||||
|
Lecture = 1,
|
||||||
|
Experiment = 2
|
||||||
|
}
|
||||||
|
|||||||
@@ -416,6 +416,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
|
|
||||||
builder.Entity<ScheduleEntry>(entity =>
|
builder.Entity<ScheduleEntry>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.Property(x => x.Kind)
|
||||||
|
.HasDefaultValue(ScheduleEntryKind.Lecture);
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
entity.HasIndex(x => new
|
entity.HasIndex(x => new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260728_40_experiment_grade_management";
|
"20260728_40_experiment_grade_management";
|
||||||
private const string AppUpdateReleasesMigration =
|
private const string AppUpdateReleasesMigration =
|
||||||
"20260729_41_app_update_releases";
|
"20260729_41_app_update_releases";
|
||||||
|
private const string IntegratedExperimentSchedulingMigration =
|
||||||
|
"20260802_42_integrated_experiment_scheduling";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -577,6 +579,20 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
AppUpdateReleasesMigration,
|
AppUpdateReleasesMigration,
|
||||||
AppUpdateReleasesStatements,
|
AppUpdateReleasesStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var scheduleEntryKindExists = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_table_info('ScheduleEntries')
|
||||||
|
WHERE name = 'Kind'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
IntegratedExperimentSchedulingMigration,
|
||||||
|
scheduleEntryKindExists
|
||||||
|
? []
|
||||||
|
: IntegratedExperimentSchedulingStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -2714,4 +2730,12 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
|
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
|
||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] IntegratedExperimentSchedulingStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
ALTER TABLE "ScheduleEntries"
|
||||||
|
ADD COLUMN "Kind" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+5991
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 IntegratedExperimentScheduling : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Kind",
|
||||||
|
table: "ScheduleEntries",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Kind",
|
||||||
|
table: "ScheduleEntries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -3296,6 +3296,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("EndWeek")
|
b.Property<int>("EndWeek")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("varchar(500)");
|
.HasColumnType("varchar(500)");
|
||||||
|
|||||||
@@ -74,97 +74,62 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
constraints.TryGetValue(task.Id, out var constraint);
|
constraints.TryGetValue(task.Id, out var constraint);
|
||||||
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
var taskCompleted = true;
|
||||||
task.Course!,
|
foreach (var kind in new[]
|
||||||
task.StartWeek,
|
{
|
||||||
task.EndWeek,
|
ScheduleEntryKind.Lecture,
|
||||||
out var requiredWeeklyHours))
|
ScheduleEntryKind.Experiment
|
||||||
|
})
|
||||||
{
|
{
|
||||||
messages.Add(
|
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind);
|
||||||
$"{task.TaskNumber} · {task.Name} 的普通排课学时不能按授课周次整除,请调整教学任务周次。");
|
var scheduledHours = entries
|
||||||
processedTasks++;
|
.Where(x =>
|
||||||
if (reportProgress is not null)
|
x.TeachingTaskId == task.Id &&
|
||||||
|
x.Kind == kind)
|
||||||
|
.Sum(TeachingTaskHours.ScheduledHours);
|
||||||
|
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
|
||||||
|
if (scheduledHours > targetHours)
|
||||||
{
|
{
|
||||||
await reportProgress(
|
messages.Add(
|
||||||
new(tasks.Count, processedTasks, created, completedTasks),
|
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
|
||||||
cancellationToken);
|
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
|
||||||
|
taskCompleted = false;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var scheduledHours = entries
|
var remainingHours = targetHours - scheduledHours;
|
||||||
.Where(x => x.TeachingTaskId == task.Id)
|
while (remainingHours > 0)
|
||||||
.Sum(x => x.PeriodCount);
|
|
||||||
if (scheduledHours > requiredWeeklyHours)
|
|
||||||
{
|
|
||||||
messages.Add(
|
|
||||||
$"{task.TaskNumber} · {task.Name} 已安排每周 {scheduledHours} 学时," +
|
|
||||||
$"普通课表只需 {requiredWeeklyHours} 学时;请删除已包含的实践学时。");
|
|
||||||
processedTasks++;
|
|
||||||
if (reportProgress is not null)
|
|
||||||
{
|
{
|
||||||
await reportProgress(
|
var candidate = FindBestCandidateForHours(
|
||||||
new(tasks.Count, processedTasks, created, completedTasks),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var remainingHours = requiredWeeklyHours - scheduledHours;
|
|
||||||
if (remainingHours == 0)
|
|
||||||
{
|
|
||||||
completedTasks++;
|
|
||||||
processedTasks++;
|
|
||||||
if (reportProgress is not null)
|
|
||||||
{
|
|
||||||
await reportProgress(
|
|
||||||
new(tasks.Count, processedTasks, created, completedTasks),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (remainingHours > 0)
|
|
||||||
{
|
|
||||||
var desiredBlock = remainingHours >= 2 ? 2 : 1;
|
|
||||||
var candidate = FindBestCandidate(
|
|
||||||
plan.Id,
|
|
||||||
task,
|
|
||||||
constraint,
|
|
||||||
desiredBlock,
|
|
||||||
activePeriods,
|
|
||||||
classrooms,
|
|
||||||
entries,
|
|
||||||
cancellationToken);
|
|
||||||
if (candidate is null && desiredBlock > 1)
|
|
||||||
{
|
|
||||||
candidate = FindBestCandidate(
|
|
||||||
plan.Id,
|
plan.Id,
|
||||||
task,
|
task,
|
||||||
constraint,
|
constraint,
|
||||||
1,
|
kind,
|
||||||
|
remainingHours,
|
||||||
activePeriods,
|
activePeriods,
|
||||||
classrooms,
|
classrooms,
|
||||||
entries,
|
entries,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
if (candidate is null) break;
|
||||||
|
|
||||||
|
db.ScheduleEntries.Add(candidate);
|
||||||
|
entries.Add(candidate);
|
||||||
|
created++;
|
||||||
|
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
|
||||||
}
|
}
|
||||||
if (candidate is null) break;
|
|
||||||
|
|
||||||
db.ScheduleEntries.Add(candidate);
|
if (remainingHours > 0)
|
||||||
entries.Add(candidate);
|
{
|
||||||
created++;
|
messages.Add(
|
||||||
remainingHours -= candidate.PeriodCount;
|
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
|
||||||
|
(kind == ScheduleEntryKind.Experiment
|
||||||
|
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
|
||||||
|
: "请检查教师/班级冲突或场地与时间约束。"));
|
||||||
|
taskCompleted = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remainingHours == 0)
|
if (taskCompleted) completedTasks++;
|
||||||
{
|
|
||||||
completedTasks++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
messages.Add(
|
|
||||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
|
||||||
}
|
|
||||||
|
|
||||||
processedTasks++;
|
processedTasks++;
|
||||||
if (reportProgress is not null)
|
if (reportProgress is not null)
|
||||||
@@ -185,11 +150,51 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
processedTasks);
|
processedTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ScheduleEntry? FindBestCandidateForHours(
|
||||||
|
Guid planId,
|
||||||
|
TeachingTask task,
|
||||||
|
TeachingTaskScheduleConstraint? constraint,
|
||||||
|
ScheduleEntryKind kind,
|
||||||
|
int remainingHours,
|
||||||
|
HashSet<int> activePeriods,
|
||||||
|
IReadOnlyList<Classroom> classrooms,
|
||||||
|
IReadOnlyList<ScheduleEntry> entries,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var weekCount = task.EndWeek - task.StartWeek + 1;
|
||||||
|
foreach (var periodCount in remainingHours >= 2
|
||||||
|
? new[] { 2, 1 }
|
||||||
|
: new[] { 1 })
|
||||||
|
{
|
||||||
|
var maxOccurrences = Math.Min(
|
||||||
|
weekCount,
|
||||||
|
remainingHours / periodCount);
|
||||||
|
for (var occurrences = maxOccurrences; occurrences >= 1; occurrences--)
|
||||||
|
{
|
||||||
|
var candidate = FindBestCandidate(
|
||||||
|
planId,
|
||||||
|
task,
|
||||||
|
constraint,
|
||||||
|
kind,
|
||||||
|
periodCount,
|
||||||
|
occurrences,
|
||||||
|
activePeriods,
|
||||||
|
classrooms,
|
||||||
|
entries,
|
||||||
|
cancellationToken);
|
||||||
|
if (candidate is not null) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static ScheduleEntry? FindBestCandidate(
|
private static ScheduleEntry? FindBestCandidate(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
TeachingTask task,
|
TeachingTask task,
|
||||||
TeachingTaskScheduleConstraint? constraint,
|
TeachingTaskScheduleConstraint? constraint,
|
||||||
|
ScheduleEntryKind kind,
|
||||||
int periodCount,
|
int periodCount,
|
||||||
|
int occurrenceCount,
|
||||||
HashSet<int> activePeriods,
|
HashSet<int> activePeriods,
|
||||||
IReadOnlyList<Classroom> classrooms,
|
IReadOnlyList<Classroom> classrooms,
|
||||||
IReadOnlyList<ScheduleEntry> entries,
|
IReadOnlyList<ScheduleEntry> entries,
|
||||||
@@ -198,50 +203,60 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
||||||
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
||||||
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
|
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
|
||||||
var rooms = EligibleRooms(task, constraint, classrooms);
|
var rooms = EligibleRooms(task, constraint, kind, classrooms);
|
||||||
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
|
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
||||||
foreach (var day in allowedDays)
|
for (var startWeek = task.StartWeek;
|
||||||
|
startWeek + occurrenceCount - 1 <= task.EndWeek;
|
||||||
|
startWeek++)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
foreach (var day in allowedDays)
|
||||||
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
||||||
continue;
|
|
||||||
|
|
||||||
var roomOptions = constraint?.RequiresClassroom == false
|
|
||||||
? new Classroom?[] { null }
|
|
||||||
: rooms.Cast<Classroom?>().ToArray();
|
|
||||||
foreach (var room in roomOptions)
|
|
||||||
{
|
{
|
||||||
var proposed = new ScheduleEntry
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
{
|
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||||
SchedulePlanId = planId,
|
|
||||||
TeachingTaskId = task.Id,
|
|
||||||
TeachingTask = task,
|
|
||||||
ClassroomId = room?.Id,
|
|
||||||
DayOfWeek = day,
|
|
||||||
StartPeriod = start,
|
|
||||||
PeriodCount = periodCount,
|
|
||||||
StartWeek = task.StartWeek,
|
|
||||||
EndWeek = task.EndWeek,
|
|
||||||
WeekPattern = WeekPattern.All,
|
|
||||||
Notes = "自动排课"
|
|
||||||
};
|
|
||||||
if (entries.Any(existing =>
|
|
||||||
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
|
|
||||||
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var sameTaskDay = entries.Count(x =>
|
var roomOptions = kind != ScheduleEntryKind.Experiment &&
|
||||||
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
|
constraint?.RequiresClassroom == false
|
||||||
var dayLoad = entries.Count(x => x.DayOfWeek == day);
|
? new Classroom?[] { null }
|
||||||
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
|
: rooms.Cast<Classroom?>().ToArray();
|
||||||
var score = sameTaskDay * 1000 + dayLoad * 10 + start + roomWaste / 10;
|
foreach (var room in roomOptions)
|
||||||
candidates.Add((proposed, score));
|
{
|
||||||
|
var proposed = new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = planId,
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
TeachingTask = task,
|
||||||
|
Kind = kind,
|
||||||
|
ClassroomId = room?.Id,
|
||||||
|
DayOfWeek = day,
|
||||||
|
StartPeriod = start,
|
||||||
|
PeriodCount = periodCount,
|
||||||
|
StartWeek = startWeek,
|
||||||
|
EndWeek = startWeek + occurrenceCount - 1,
|
||||||
|
WeekPattern = WeekPattern.All,
|
||||||
|
Notes = kind == ScheduleEntryKind.Experiment
|
||||||
|
? "自动排课 · 实验课"
|
||||||
|
: "自动排课 · 理论课"
|
||||||
|
};
|
||||||
|
if (entries.Any(existing =>
|
||||||
|
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
|
||||||
|
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var sameTaskDay = entries.Count(x =>
|
||||||
|
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
|
||||||
|
var dayLoad = entries.Count(x => x.DayOfWeek == day);
|
||||||
|
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
|
||||||
|
var score = sameTaskDay * 1000 + dayLoad * 10 + start +
|
||||||
|
roomWaste / 10 + startWeek;
|
||||||
|
candidates.Add((proposed, score));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,9 +271,11 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
private static IReadOnlyList<Classroom> EligibleRooms(
|
private static IReadOnlyList<Classroom> EligibleRooms(
|
||||||
TeachingTask task,
|
TeachingTask task,
|
||||||
TeachingTaskScheduleConstraint? constraint,
|
TeachingTaskScheduleConstraint? constraint,
|
||||||
|
ScheduleEntryKind kind,
|
||||||
IReadOnlyList<Classroom> classrooms)
|
IReadOnlyList<Classroom> classrooms)
|
||||||
{
|
{
|
||||||
if (constraint?.RequiresClassroom == false) return [];
|
if (kind != ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.RequiresClassroom == false) return [];
|
||||||
var allowedRoomIds = constraint?.AllowedClassrooms
|
var allowedRoomIds = constraint?.AllowedClassrooms
|
||||||
.Select(x => x.ClassroomId)
|
.Select(x => x.ClassroomId)
|
||||||
.ToHashSet() ?? [];
|
.ToHashSet() ?? [];
|
||||||
@@ -273,10 +290,17 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
room.Building!.CampusId == requiredCampusId) &&
|
room.Building!.CampusId == requiredCampusId) &&
|
||||||
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
||||||
room.BuildingId == requiredBuildingId) &&
|
room.BuildingId == requiredBuildingId) &&
|
||||||
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)))
|
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
|
||||||
|
(kind != ScheduleEntryKind.Experiment || IsExperimentRoom(room.RoomType)))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsExperimentRoom(string roomType) =>
|
||||||
|
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static int[] ParseAllowedDays(string? value)
|
private static int[] ParseAllowedDays(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
|
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
|
||||||
|
|||||||
@@ -191,57 +191,30 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
CoursePracticeHours = x.Course.PracticeHours
|
CoursePracticeHours = x.Course.PracticeHours
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var invalidHours = requiredTasks.FirstOrDefault(task =>
|
|
||||||
!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
|
||||||
task.CourseTotalHours,
|
|
||||||
task.CoursePracticeHours,
|
|
||||||
task.StartWeek,
|
|
||||||
task.EndWeek,
|
|
||||||
out _));
|
|
||||||
if (invalidHours is not null)
|
|
||||||
{
|
|
||||||
throw new SchedulePublishValidationException(
|
|
||||||
$"{invalidHours.TaskNumber} · {invalidHours.Name} 的普通排课学时" +
|
|
||||||
"不能按授课周次整除,请先调整教学任务周次。");
|
|
||||||
}
|
|
||||||
|
|
||||||
var requiredWeeklyHours = requiredTasks
|
|
||||||
.Select(task =>
|
|
||||||
{
|
|
||||||
TeachingTaskHours.TryResolveRegularWeeklyHours(
|
|
||||||
task.CourseTotalHours,
|
|
||||||
task.CoursePracticeHours,
|
|
||||||
task.StartWeek,
|
|
||||||
task.EndWeek,
|
|
||||||
out var hours);
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
Task = task,
|
|
||||||
Hours = hours
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
var scheduledHours = plan.Entries
|
var scheduledHours = plan.Entries
|
||||||
.GroupBy(x => x.TeachingTaskId)
|
.GroupBy(x => new { x.TeachingTaskId, x.Kind })
|
||||||
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
|
.ToDictionary(
|
||||||
var incomplete = requiredWeeklyHours.FirstOrDefault(item =>
|
group => (group.Key.TeachingTaskId, group.Key.Kind),
|
||||||
scheduledHours.GetValueOrDefault(item.Task.Id) < item.Hours);
|
group => group.Sum(TeachingTaskHours.ScheduledHours));
|
||||||
if (incomplete is not null)
|
foreach (var task in requiredTasks)
|
||||||
{
|
{
|
||||||
throw new SchedulePublishValidationException(
|
var targets = new[]
|
||||||
$"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " +
|
{
|
||||||
$"{incomplete.Hours} 个普通排课学时,不能发布。");
|
(Kind: ScheduleEntryKind.Lecture,
|
||||||
}
|
Hours: Math.Max(0, task.CourseTotalHours - task.CoursePracticeHours),
|
||||||
|
Label: "理论课"),
|
||||||
var excessive = requiredWeeklyHours.FirstOrDefault(item =>
|
(Kind: ScheduleEntryKind.Experiment,
|
||||||
scheduledHours.GetValueOrDefault(item.Task.Id) > item.Hours);
|
Hours: Math.Max(0, task.CoursePracticeHours),
|
||||||
if (excessive is not null)
|
Label: "实验课")
|
||||||
{
|
};
|
||||||
var actualHours = scheduledHours.GetValueOrDefault(excessive.Task.Id);
|
foreach (var target in targets)
|
||||||
throw new SchedulePublishValidationException(
|
{
|
||||||
$"{excessive.Task.TaskNumber} · {excessive.Task.Name} 已安排每周 " +
|
var actual = scheduledHours.GetValueOrDefault((task.Id, target.Kind));
|
||||||
$"{actualHours} 学时,普通课表应为 {excessive.Hours} 学时;" +
|
if (actual == target.Hours) continue;
|
||||||
"请删除已包含的实践学时后再发布。");
|
throw new SchedulePublishValidationException(
|
||||||
|
$"{task.TaskNumber} · {task.Name} 的{target.Label}应安排 " +
|
||||||
|
$"{target.Hours} 学时,当前已安排 {actual} 学时,不能发布。");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
||||||
@@ -275,7 +248,8 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
Fail(entry, "排课周次不在教学任务的授课周次内");
|
Fail(entry, "排课周次不在教学任务的授课周次内");
|
||||||
|
|
||||||
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
|
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
|
||||||
var requiresClassroom = constraint?.RequiresClassroom ?? true;
|
var requiresClassroom = entry.Kind == ScheduleEntryKind.Experiment ||
|
||||||
|
constraint?.RequiresClassroom != false;
|
||||||
if (requiresClassroom && entry.ClassroomId is null)
|
if (requiresClassroom && entry.ClassroomId is null)
|
||||||
Fail(entry, "该课程需要占用教室");
|
Fail(entry, "该课程需要占用教室");
|
||||||
if (!requiresClassroom && entry.ClassroomId is not null)
|
if (!requiresClassroom && entry.ClassroomId is not null)
|
||||||
@@ -296,6 +270,9 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
{
|
{
|
||||||
if (classroom is null || !classroom.IsEnabled)
|
if (classroom is null || !classroom.IsEnabled)
|
||||||
Fail(entry, "所选教室不存在或已停用");
|
Fail(entry, "所选教室不存在或已停用");
|
||||||
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
!IsExperimentRoom(classroom.RoomType))
|
||||||
|
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
Fail(entry, "所选教室不在指定校区");
|
Fail(entry, "所选教室不在指定校区");
|
||||||
@@ -328,6 +305,12 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
|
private static bool IsExperimentRoom(string roomType) =>
|
||||||
|
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
[DoesNotReturn]
|
[DoesNotReturn]
|
||||||
private static void Fail(ScheduleEntry entry, string message)
|
private static void Fail(ScheduleEntry entry, string message)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,10 +16,35 @@ public static class TeachingTaskHours
|
|||||||
public static int TargetHours(
|
public static int TargetHours(
|
||||||
Course course,
|
Course course,
|
||||||
TeachingTaskSchedulingMode schedulingMode) =>
|
TeachingTaskSchedulingMode schedulingMode) =>
|
||||||
schedulingMode == TeachingTaskSchedulingMode.Flexible
|
course.TotalHours;
|
||||||
? course.TotalHours
|
|
||||||
|
public static int TargetHours(Course course, ScheduleEntryKind kind) =>
|
||||||
|
kind == ScheduleEntryKind.Experiment
|
||||||
|
? Math.Max(0, course.PracticeHours)
|
||||||
: RegularScheduleHours(course);
|
: RegularScheduleHours(course);
|
||||||
|
|
||||||
|
public static int ScheduledHours(ScheduleEntry entry) =>
|
||||||
|
ScheduledHours(
|
||||||
|
entry.StartWeek,
|
||||||
|
entry.EndWeek,
|
||||||
|
entry.WeekPattern,
|
||||||
|
entry.PeriodCount);
|
||||||
|
|
||||||
|
public static int ScheduledHours(
|
||||||
|
int startWeek,
|
||||||
|
int endWeek,
|
||||||
|
WeekPattern weekPattern,
|
||||||
|
int periodCount)
|
||||||
|
{
|
||||||
|
if (endWeek < startWeek || periodCount <= 0) return 0;
|
||||||
|
var occurrences = Enumerable.Range(startWeek, endWeek - startWeek + 1)
|
||||||
|
.Count(week =>
|
||||||
|
weekPattern == WeekPattern.All ||
|
||||||
|
weekPattern == WeekPattern.Odd && week % 2 == 1 ||
|
||||||
|
weekPattern == WeekPattern.Even && week % 2 == 0);
|
||||||
|
return occurrences * periodCount;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool TryResolveRegularWeeklyHours(
|
public static bool TryResolveRegularWeeklyHours(
|
||||||
Course course,
|
Course course,
|
||||||
int startWeek,
|
int startWeek,
|
||||||
@@ -62,14 +87,8 @@ public static class TeachingTaskHours
|
|||||||
|
|
||||||
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
|
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||||
{
|
{
|
||||||
if (targetHours == 0)
|
|
||||||
{
|
|
||||||
return $"课程“{course.Name}”的 {course.TotalHours} 学时均为实践学时," +
|
|
||||||
"无需进入普通课表;请将授课方式设为“非排时课程”,并在实验管理中安排。";
|
|
||||||
}
|
|
||||||
|
|
||||||
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
|
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
|
||||||
$"{course.PracticeHours},普通课表应安排 {targetHours} 学时;当前第 " +
|
$"{course.PracticeHours};理论课和实验课均应进入课表。当前第 " +
|
||||||
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
|
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
|
||||||
$"{plannedHours} 学时。请调整授课周次或周学时。";
|
$"{plannedHours} 学时。请调整授课周次或周学时。";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,14 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
x.UpdatedAt))
|
x.UpdatedAt,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
x.Kind))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,7 +758,8 @@ public sealed record TimetableEntryDto(
|
|||||||
string? ExperimentProjectCode = null,
|
string? ExperimentProjectCode = null,
|
||||||
string? ExperimentProjectName = null,
|
string? ExperimentProjectName = null,
|
||||||
DateOnly? ExperimentDate = null,
|
DateOnly? ExperimentDate = null,
|
||||||
ExperimentArrangementMode? ExperimentArrangementMode = null);
|
ExperimentArrangementMode? ExperimentArrangementMode = null,
|
||||||
|
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
|
||||||
|
|
||||||
public sealed record FlexibleCourseDto(
|
public sealed record FlexibleCourseDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ public static class TimetableExcelExporter
|
|||||||
$"{Location(entry)}\n" +
|
$"{Location(entry)}\n" +
|
||||||
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
|
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
|
||||||
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
|
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
|
||||||
: $"{entry.CourseName}\n" +
|
: $"{(entry.Kind == Domain.Academic.ScheduleEntryKind.Experiment ? "【实验课】" : "")}{entry.CourseName}\n" +
|
||||||
$"{string.Join('、', entry.TeacherNames)}\n" +
|
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||||
$"{Location(entry)}\n" +
|
$"{Location(entry)}\n" +
|
||||||
$"{entry.StartWeek}-{entry.EndWeek} 周";
|
$"{entry.StartWeek}-{entry.EndWeek} 周";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -157,6 +158,21 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
EndDate = new DateOnly(2027, 1, 15)
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
};
|
};
|
||||||
var college = new College { Code = "LAB", Name = "实验学院" };
|
var college = new College { Code = "LAB", Name = "实验学院" };
|
||||||
|
var campus = new Campus { Code = "LAB-CAMPUS", Name = "实验校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "LAB-BUILDING",
|
||||||
|
Name = "实验楼",
|
||||||
|
Campus = campus
|
||||||
|
};
|
||||||
|
var laboratory = new Classroom
|
||||||
|
{
|
||||||
|
Code = "LAB-101",
|
||||||
|
Name = "实验室 101",
|
||||||
|
Building = building,
|
||||||
|
Capacity = 40,
|
||||||
|
RoomType = "实验室"
|
||||||
|
};
|
||||||
var course = new Course
|
var course = new Course
|
||||||
{
|
{
|
||||||
Code = "LAB-01",
|
Code = "LAB-01",
|
||||||
@@ -185,7 +201,7 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
Name = "实验学时拆分测试",
|
Name = "实验学时拆分测试",
|
||||||
Version = "V1"
|
Version = "V1"
|
||||||
};
|
};
|
||||||
db.AddRange(term, college, course, task, plan);
|
db.AddRange(term, college, campus, building, laboratory, course, task, plan);
|
||||||
db.ScheduleTimeSlots.AddRange(
|
db.ScheduleTimeSlots.AddRange(
|
||||||
new ScheduleTimeSlot
|
new ScheduleTimeSlot
|
||||||
{
|
{
|
||||||
@@ -203,22 +219,22 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
StartsAt = new TimeOnly(8, 55),
|
StartsAt = new TimeOnly(8, 55),
|
||||||
EndsAt = new TimeOnly(9, 40)
|
EndsAt = new TimeOnly(9, 40)
|
||||||
});
|
});
|
||||||
db.TeachingTaskScheduleConstraints.Add(
|
|
||||||
new TeachingTaskScheduleConstraint
|
|
||||||
{
|
|
||||||
TeachingTask = task,
|
|
||||||
RequiresClassroom = false
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var result = await new AutomaticScheduleGenerator(db)
|
var result = await new AutomaticScheduleGenerator(db)
|
||||||
.GenerateAsync(plan, CancellationToken.None);
|
.GenerateAsync(plan, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(1, result.CreatedEntries);
|
Assert.Equal(2, result.CreatedEntries);
|
||||||
Assert.Equal(1, result.CompletedTasks);
|
Assert.Equal(1, result.CompletedTasks);
|
||||||
Assert.Equal(
|
var entries = await db.ScheduleEntries.OrderBy(x => x.Kind).ToListAsync();
|
||||||
1,
|
Assert.Equal(2, entries.Count);
|
||||||
(await db.ScheduleEntries.SingleAsync()).PeriodCount);
|
Assert.Contains(entries, x =>
|
||||||
|
x.Kind == ScheduleEntryKind.Lecture &&
|
||||||
|
TeachingTaskHours.ScheduledHours(x) == 16);
|
||||||
|
Assert.Contains(entries, x =>
|
||||||
|
x.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
x.ClassroomId == laboratory.Id &&
|
||||||
|
TeachingTaskHours.ScheduledHours(x) == 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -32,40 +32,41 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||||
Assert.Null(result.ActiveAcademicTermId);
|
Assert.Null(result.ActiveAcademicTermId);
|
||||||
Assert.Equal("检查未通过", result.CurrentStep);
|
Assert.Equal("检查未通过", result.CurrentStep);
|
||||||
Assert.Contains("尚未达到每周 2 个普通排课学时", result.ErrorMessage);
|
Assert.Contains("理论课应安排 32 学时,当前已安排 16 学时", result.ErrorMessage);
|
||||||
Assert.Null(result.PublishedAt);
|
Assert.Null(result.PublishedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Processor_excludes_practice_hours_for_existing_tasks()
|
public async Task Processor_requires_experiment_hours_in_regular_schedule()
|
||||||
{
|
{
|
||||||
var result = await RunPublishAsync(
|
var result = await RunPublishAsync(
|
||||||
weeklyHours: 2,
|
weeklyHours: 2,
|
||||||
scheduledHours: 1,
|
scheduledHours: 1,
|
||||||
practiceHours: 16);
|
practiceHours: 16,
|
||||||
|
experimentScheduledHours: 1);
|
||||||
|
|
||||||
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
||||||
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Processor_rejects_practice_hours_already_added_to_draft()
|
public async Task Processor_rejects_missing_experiment_hours()
|
||||||
{
|
{
|
||||||
var result = await RunPublishAsync(
|
var result = await RunPublishAsync(
|
||||||
weeklyHours: 2,
|
weeklyHours: 2,
|
||||||
scheduledHours: 2,
|
scheduledHours: 1,
|
||||||
practiceHours: 16);
|
practiceHours: 16);
|
||||||
|
|
||||||
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
||||||
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||||
Assert.Contains("普通课表应为 1 学时", result.ErrorMessage);
|
Assert.Contains("实验课应安排 16 学时,当前已安排 0 学时", result.ErrorMessage);
|
||||||
Assert.Contains("删除已包含的实践学时", result.ErrorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<PublishResult> RunPublishAsync(
|
private static async Task<PublishResult> RunPublishAsync(
|
||||||
int weeklyHours,
|
int weeklyHours,
|
||||||
int scheduledHours,
|
int scheduledHours,
|
||||||
int practiceHours = 0)
|
int practiceHours = 0,
|
||||||
|
int experimentScheduledHours = 0)
|
||||||
{
|
{
|
||||||
var databasePath = Path.Combine(
|
var databasePath = Path.Combine(
|
||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
@@ -97,6 +98,21 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
EndDate = new DateOnly(2027, 1, 15)
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
};
|
};
|
||||||
var college = new College { Code = "PUB", Name = "发布测试学院" };
|
var college = new College { Code = "PUB", Name = "发布测试学院" };
|
||||||
|
var campus = new Campus { Code = "PUB-CAMPUS", Name = "发布测试校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "PUB-BUILDING",
|
||||||
|
Name = "实验楼",
|
||||||
|
Campus = campus
|
||||||
|
};
|
||||||
|
var laboratory = new Classroom
|
||||||
|
{
|
||||||
|
Code = "PUB-LAB",
|
||||||
|
Name = "发布测试实验室",
|
||||||
|
Building = building,
|
||||||
|
Capacity = 80,
|
||||||
|
RoomType = "实验室"
|
||||||
|
};
|
||||||
var course = new Course
|
var course = new Course
|
||||||
{
|
{
|
||||||
Code = "PUB-01",
|
Code = "PUB-01",
|
||||||
@@ -135,6 +151,21 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
EndWeek = 16,
|
EndWeek = 16,
|
||||||
WeekPattern = WeekPattern.All
|
WeekPattern = WeekPattern.All
|
||||||
});
|
});
|
||||||
|
if (experimentScheduledHours > 0)
|
||||||
|
{
|
||||||
|
plan.Entries.Add(new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
Kind = ScheduleEntryKind.Experiment,
|
||||||
|
Classroom = laboratory,
|
||||||
|
DayOfWeek = 2,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = experimentScheduledHours,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
});
|
||||||
|
}
|
||||||
var job = new SchedulePublishJob
|
var job = new SchedulePublishJob
|
||||||
{
|
{
|
||||||
SchedulePlan = plan,
|
SchedulePlan = plan,
|
||||||
@@ -143,7 +174,16 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
CurrentStep = "等待后台检查"
|
CurrentStep = "等待后台检查"
|
||||||
};
|
};
|
||||||
jobId = job.Id;
|
jobId = job.Id;
|
||||||
db.AddRange(term, college, course, task, plan, job);
|
db.AddRange(
|
||||||
|
term,
|
||||||
|
college,
|
||||||
|
campus,
|
||||||
|
building,
|
||||||
|
laboratory,
|
||||||
|
course,
|
||||||
|
task,
|
||||||
|
plan,
|
||||||
|
job);
|
||||||
db.ScheduleTimeSlots.AddRange(
|
db.ScheduleTimeSlots.AddRange(
|
||||||
new ScheduleTimeSlot
|
new ScheduleTimeSlot
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ public sealed class ScheduleSettingsControllerTests
|
|||||||
});
|
});
|
||||||
Assert.Contains("测试教师", json);
|
Assert.Contains("测试教师", json);
|
||||||
Assert.Contains(classroom.Id.ToString(), json);
|
Assert.Contains(classroom.Id.ToString(), json);
|
||||||
Assert.Contains("\"WeeklyHours\":3", json);
|
Assert.Contains("\"WeeklyHours\":4", json);
|
||||||
Assert.Contains("\"CoursePracticeHours\":16", json);
|
Assert.Contains("\"CoursePracticeHours\":16", json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace Jiaowu.Api.Tests;
|
|||||||
public sealed class SchedulesControllerTests
|
public sealed class SchedulesControllerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Manual_entry_rejects_hours_reserved_for_experiments()
|
public async Task Manual_entry_adds_experiment_hours_to_regular_schedule()
|
||||||
{
|
{
|
||||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
await connection.OpenAsync();
|
await connection.OpenAsync();
|
||||||
@@ -33,6 +33,21 @@ public sealed class SchedulesControllerTests
|
|||||||
EndDate = new DateOnly(2027, 1, 17)
|
EndDate = new DateOnly(2027, 1, 17)
|
||||||
};
|
};
|
||||||
var college = new College { Code = "LAB", Name = "实验学院" };
|
var college = new College { Code = "LAB", Name = "实验学院" };
|
||||||
|
var campus = new Campus { Code = "LAB-CAMPUS", Name = "实验校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "LAB-BUILDING",
|
||||||
|
Name = "实验楼",
|
||||||
|
Campus = campus
|
||||||
|
};
|
||||||
|
var laboratory = new Classroom
|
||||||
|
{
|
||||||
|
Code = "LAB-201",
|
||||||
|
Name = "实验室 201",
|
||||||
|
Building = building,
|
||||||
|
Capacity = 40,
|
||||||
|
RoomType = "实验室"
|
||||||
|
};
|
||||||
var course = new Course
|
var course = new Course
|
||||||
{
|
{
|
||||||
Code = "LAB-01",
|
Code = "LAB-01",
|
||||||
@@ -71,7 +86,7 @@ public sealed class SchedulesControllerTests
|
|||||||
EndWeek = 16,
|
EndWeek = 16,
|
||||||
WeekPattern = WeekPattern.All
|
WeekPattern = WeekPattern.All
|
||||||
});
|
});
|
||||||
db.AddRange(term, college, course, task, plan);
|
db.AddRange(term, college, campus, building, laboratory, course, task, plan);
|
||||||
db.ScheduleTimeSlots.AddRange(
|
db.ScheduleTimeSlots.AddRange(
|
||||||
new ScheduleTimeSlot
|
new ScheduleTimeSlot
|
||||||
{
|
{
|
||||||
@@ -102,22 +117,22 @@ public sealed class SchedulesControllerTests
|
|||||||
plan.Id,
|
plan.Id,
|
||||||
new ScheduleEntryRequest(
|
new ScheduleEntryRequest(
|
||||||
task.Id,
|
task.Id,
|
||||||
null,
|
laboratory.Id,
|
||||||
2,
|
|
||||||
2,
|
2,
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
|
1,
|
||||||
16,
|
16,
|
||||||
WeekPattern.All,
|
WeekPattern.All,
|
||||||
null),
|
null,
|
||||||
|
ScheduleEntryKind.Experiment),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
var problem = Assert.IsType<ObjectResult>(result);
|
Assert.IsType<CreatedResult>(result);
|
||||||
var details = Assert.IsType<ValidationProblemDetails>(problem.Value);
|
var entries = await db.ScheduleEntries.OrderBy(x => x.Kind).ToListAsync();
|
||||||
Assert.Contains(
|
Assert.Equal(2, entries.Count);
|
||||||
"实践学时请在实验管理中安排",
|
Assert.Equal(ScheduleEntryKind.Experiment, entries[1].Kind);
|
||||||
details.Detail);
|
Assert.Equal(laboratory.Id, entries[1].ClassroomId);
|
||||||
Assert.Single(await db.ScheduleEntries.ToListAsync());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class TeachingTaskHoursTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Standard_schedule_excludes_practice_hours()
|
public void Standard_schedule_includes_theory_and_experiment_hours()
|
||||||
{
|
{
|
||||||
var course = new Course
|
var course = new Course
|
||||||
{
|
{
|
||||||
@@ -52,12 +52,12 @@ public sealed class TeachingTaskHoursTests
|
|||||||
16,
|
16,
|
||||||
out var weeklyHours));
|
out var weeklyHours));
|
||||||
Assert.Equal(3, weeklyHours);
|
Assert.Equal(3, weeklyHours);
|
||||||
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 3));
|
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 4));
|
||||||
|
|
||||||
var result = TeachingTaskHours.Validate(course, 1, 16, 4);
|
var result = TeachingTaskHours.Validate(course, 1, 16, 3);
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Contains("普通课表应安排 48 学时", result);
|
Assert.Contains("理论课和实验课均应进入课表", result);
|
||||||
Assert.Contains("实践学时 16", result);
|
Assert.Contains("共 48 学时", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -79,8 +79,6 @@ public sealed class TeachingTaskHoursTests
|
|||||||
16,
|
16,
|
||||||
1,
|
1,
|
||||||
TeachingTaskSchedulingMode.Flexible));
|
TeachingTaskSchedulingMode.Flexible));
|
||||||
Assert.Contains(
|
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 1));
|
||||||
"无需进入普通课表",
|
|
||||||
TeachingTaskHours.Validate(course, 1, 16, 1)!);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,6 +502,8 @@ button { cursor: pointer; }
|
|||||||
.cell-add { margin: auto; color: transparent; font-size: 9px; }
|
.cell-add { margin: auto; color: transparent; font-size: 9px; }
|
||||||
.timetable-cell.editable:hover .cell-add { color: #a0a8b4; }
|
.timetable-cell.editable:hover .cell-add { color: #a0a8b4; }
|
||||||
.schedule-card { min-width: 0; padding: 8px 9px; display: grid; gap: 4px; position: relative; border-left: 3px solid #45bcae; color: #eaf2ff; background: linear-gradient(130deg, #263f80, #1a2e64); box-shadow: 0 4px 10px rgba(24,40,83,.12); cursor: pointer; }
|
.schedule-card { min-width: 0; padding: 8px 9px; display: grid; gap: 4px; position: relative; border-left: 3px solid #45bcae; color: #eaf2ff; background: linear-gradient(130deg, #263f80, #1a2e64); box-shadow: 0 4px 10px rgba(24,40,83,.12); cursor: pointer; }
|
||||||
|
.schedule-card.experiment { border-left-color: #f2b84b; background: linear-gradient(130deg, #705022, #4d3518); }
|
||||||
|
.schedule-card.experiment span { color: #ffd88a; }
|
||||||
.schedule-card.readonly { cursor: default; }
|
.schedule-card.readonly { cursor: default; }
|
||||||
.schedule-card span { padding-right: 16px; color: #74d5c8; font: 700 8px/1.2 Consolas, monospace; letter-spacing: .04em; }
|
.schedule-card span { padding-right: 16px; color: #74d5c8; font: 700 8px/1.2 Consolas, monospace; letter-spacing: .04em; }
|
||||||
.schedule-card b { overflow: hidden; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
|
.schedule-card b { overflow: hidden; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ const publishStatusText = computed(() => {
|
|||||||
const selectedTaskConstraint = computed(() =>
|
const selectedTaskConstraint = computed(() =>
|
||||||
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
|
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
|
||||||
)
|
)
|
||||||
|
const isExperimentRoom = (room: any) =>
|
||||||
|
['实验', '实训', '机房', '语音'].some((keyword) => room.roomType?.includes(keyword))
|
||||||
|
const entryClassrooms = computed(() =>
|
||||||
|
classrooms.value.filter((room) =>
|
||||||
|
(!selectedTaskConstraint.value?.requiredCampusId
|
||||||
|
|| room.campusId === selectedTaskConstraint.value.requiredCampusId) &&
|
||||||
|
(!selectedTaskConstraint.value?.requiredBuildingId
|
||||||
|
|| room.buildingId === selectedTaskConstraint.value.requiredBuildingId) &&
|
||||||
|
(!selectedTaskConstraint.value?.allowedClassroomIds?.length
|
||||||
|
|| selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) &&
|
||||||
|
(entryForm.kind !== 'Experiment' || isExperimentRoom(room)),
|
||||||
|
),
|
||||||
|
)
|
||||||
const entryWeekdays = computed(() => {
|
const entryWeekdays = computed(() => {
|
||||||
const allowedDays = selectedTaskConstraint.value?.allowedDayOfWeeks ?? []
|
const allowedDays = selectedTaskConstraint.value?.allowedDayOfWeeks ?? []
|
||||||
return allowedDays.length
|
return allowedDays.length
|
||||||
@@ -416,7 +429,7 @@ function openManualHandling() {
|
|||||||
async function autoSchedule() {
|
async function autoSchedule() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'系统会保留当前手工安排,并为尚未排满的教学任务分配教师可用时间和符合约束的教室。生成后仍可手工调整。',
|
'系统会保留当前手工安排,同时补齐理论课和实验课。实验课仅使用实验室、实训室、机房等场地;生成后仍可手工调整。',
|
||||||
'开始自动排课',
|
'开始自动排课',
|
||||||
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
|
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
|
||||||
)
|
)
|
||||||
@@ -657,6 +670,7 @@ function openEntry(entry?: any, day?: number, period?: number) {
|
|||||||
editingEntryId.value = entry?.id ?? ''
|
editingEntryId.value = entry?.id ?? ''
|
||||||
Object.assign(entryForm, {
|
Object.assign(entryForm, {
|
||||||
teachingTaskId: entry?.teachingTaskId,
|
teachingTaskId: entry?.teachingTaskId,
|
||||||
|
kind: entry?.kind ?? 'Lecture',
|
||||||
classroomId: entry?.classroomId,
|
classroomId: entry?.classroomId,
|
||||||
dayOfWeek: entry?.dayOfWeek ?? day ?? 1,
|
dayOfWeek: entry?.dayOfWeek ?? day ?? 1,
|
||||||
startPeriod: entry?.startPeriod ?? period ?? 1,
|
startPeriod: entry?.startPeriod ?? period ?? 1,
|
||||||
@@ -679,7 +693,7 @@ function changeEntryTask() {
|
|||||||
!task.allowedDayOfWeeks.includes(entryForm.dayOfWeek)) {
|
!task.allowedDayOfWeeks.includes(entryForm.dayOfWeek)) {
|
||||||
entryForm.dayOfWeek = task.allowedDayOfWeeks[0]
|
entryForm.dayOfWeek = task.allowedDayOfWeeks[0]
|
||||||
}
|
}
|
||||||
if (task.requiresClassroom === false) {
|
if (task.requiresClassroom === false && entryForm.kind !== 'Experiment') {
|
||||||
entryForm.classroomId = null
|
entryForm.classroomId = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -687,15 +701,25 @@ function changeEntryTask() {
|
|||||||
if (room && (
|
if (room && (
|
||||||
(task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
|
(task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
|
||||||
(task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
|
(task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
|
||||||
(task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id))
|
(task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
|
||||||
|
(entryForm.kind === 'Experiment' && !isExperimentRoom(room))
|
||||||
)) {
|
)) {
|
||||||
entryForm.classroomId = null
|
entryForm.classroomId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeEntryKind() {
|
||||||
|
const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
|
||||||
|
if (entryForm.kind === 'Experiment' && room && !isExperimentRoom(room)) {
|
||||||
|
entryForm.classroomId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveEntry() {
|
async function saveEntry() {
|
||||||
if (!entryForm.teachingTaskId ||
|
if (!entryForm.teachingTaskId ||
|
||||||
(selectedTaskConstraint.value?.requiresClassroom !== false && !entryForm.classroomId)) {
|
((entryForm.kind === 'Experiment' ||
|
||||||
|
selectedTaskConstraint.value?.requiresClassroom !== false) &&
|
||||||
|
!entryForm.classroomId)) {
|
||||||
ElMessage.warning('请选择教学任务,并按课程要求选择教室。')
|
ElMessage.warning('请选择教学任务,并按课程要求选择教室。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -714,7 +738,8 @@ async function saveEntry() {
|
|||||||
ElMessage.warning('所选星期不在该教学任务允许的上课日内。')
|
ElMessage.warning('所选星期不在该教学任务允许的上课日内。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (selectedTaskConstraint.value?.requiresClassroom === false) entryForm.classroomId = null
|
if (entryForm.kind !== 'Experiment' &&
|
||||||
|
selectedTaskConstraint.value?.requiresClassroom === false) entryForm.classroomId = null
|
||||||
try {
|
try {
|
||||||
const base = `/schedules/plans/${selected.value.id}/entries`
|
const base = `/schedules/plans/${selected.value.id}/entries`
|
||||||
if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm)
|
if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm)
|
||||||
@@ -928,10 +953,13 @@ onBeforeUnmount(() => {
|
|||||||
v-for="entry in entriesAt(day.value, period)"
|
v-for="entry in entriesAt(day.value, period)"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="schedule-card"
|
class="schedule-card"
|
||||||
:class="{ readonly: !isDraft }"
|
:class="{ readonly: !isDraft, experiment: entry.kind === 'Experiment' }"
|
||||||
@click="isDraft && openEntry(entry)"
|
@click="isDraft && openEntry(entry)"
|
||||||
>
|
>
|
||||||
<span>{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}</span>
|
<span>
|
||||||
|
{{ entry.kind === 'Experiment' ? '实验课' : '理论课' }} ·
|
||||||
|
{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}
|
||||||
|
</span>
|
||||||
<b>{{ entry.courseName }}</b>
|
<b>{{ entry.courseName }}</b>
|
||||||
<small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName || '不占用教室' }}</small>
|
<small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName || '不占用教室' }}</small>
|
||||||
<i>{{ entry.startWeek }}—{{ entry.endWeek }} 周 / 连上 {{ entry.periodCount }} 节</i>
|
<i>{{ entry.startWeek }}—{{ entry.endWeek }} 周 / 连上 {{ entry.periodCount }} 节</i>
|
||||||
@@ -986,15 +1014,23 @@ onBeforeUnmount(() => {
|
|||||||
<el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" />
|
<el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="课次类型" required>
|
||||||
|
<el-radio-group v-model="entryForm.kind" @change="changeEntryKind">
|
||||||
|
<el-radio-button value="Lecture">理论课</el-radio-button>
|
||||||
|
<el-radio-button value="Experiment" :disabled="!selectedTaskConstraint?.coursePracticeHours">
|
||||||
|
实验课
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="selectedTaskConstraint"
|
v-if="selectedTaskConstraint"
|
||||||
:title="`普通课表每周 ${selectedTaskConstraint.weeklyHours} 学时(实践 ${selectedTaskConstraint.coursePracticeHours} 学时另由实验管理安排);可排第 ${selectedTaskConstraint.startWeek}—${selectedTaskConstraint.endWeek} 周;允许上课日:${entryWeekdays.map((day) => day.label).join('、')}`"
|
:title="`课程共 ${selectedTaskConstraint.courseTotalHours} 学时:理论 ${selectedTaskConstraint.courseTotalHours - selectedTaskConstraint.coursePracticeHours} 学时、实验 ${selectedTaskConstraint.coursePracticeHours} 学时,均须在本课表排足;可排第 ${selectedTaskConstraint.startWeek}—${selectedTaskConstraint.endWeek} 周;允许上课日:${entryWeekdays.map((day) => day.label).join('、')}`"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
/>
|
/>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="selectedTaskConstraint?.requiresClassroom === false"
|
v-if="selectedTaskConstraint?.requiresClassroom === false && entryForm.kind !== 'Experiment'"
|
||||||
title="该课程不占用教室,仍会校验教师和行政班时间冲突。"
|
title="该课程不占用教室,仍会校验教师和行政班时间冲突。"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
@@ -1002,19 +1038,15 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
<el-form-item
|
<el-form-item
|
||||||
v-else
|
v-else
|
||||||
label="教室"
|
:label="entryForm.kind === 'Experiment' ? '实验室 / 实训室 / 机房' : '教室'"
|
||||||
required
|
required
|
||||||
:hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''"
|
:hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''"
|
||||||
>
|
>
|
||||||
<el-select v-model="entryForm.classroomId" filterable>
|
<el-select v-model="entryForm.classroomId" filterable>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in classrooms.filter((room) =>
|
v-for="item in entryClassrooms"
|
||||||
(!selectedTaskConstraint?.requiredCampusId || room.campusId === selectedTaskConstraint.requiredCampusId) &&
|
|
||||||
(!selectedTaskConstraint?.requiredBuildingId || room.buildingId === selectedTaskConstraint.requiredBuildingId) &&
|
|
||||||
(!selectedTaskConstraint?.allowedClassroomIds?.length || selectedTaskConstraint.allowedClassroomIds.includes(room.id))
|
|
||||||
)"
|
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:label="`${item.campusName} / ${item.buildingName} / ${item.name}(${item.capacity}人)`"
|
:label="`${item.campusName} / ${item.buildingName} / ${item.name}(${item.roomType},${item.capacity}人)`"
|
||||||
:value="item.id"
|
:value="item.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|||||||
@@ -79,12 +79,10 @@ const selectedCourse = computed(() =>
|
|||||||
)
|
)
|
||||||
const regularScheduleHours = (course: any) =>
|
const regularScheduleHours = (course: any) =>
|
||||||
Math.max(0, Number(course?.totalHours ?? 0) - Number(course?.practiceHours ?? 0))
|
Math.max(0, Number(course?.totalHours ?? 0) - Number(course?.practiceHours ?? 0))
|
||||||
const targetCourseHours = (course: any, schedulingMode: string) =>
|
const targetCourseHours = (course: any) =>
|
||||||
schedulingMode === 'Flexible'
|
Number(course?.totalHours ?? 0)
|
||||||
? Number(course?.totalHours ?? 0)
|
|
||||||
: regularScheduleHours(course)
|
|
||||||
const selectedCourseTargetHours = computed(() =>
|
const selectedCourseTargetHours = computed(() =>
|
||||||
targetCourseHours(selectedCourse.value, form.schedulingMode),
|
targetCourseHours(selectedCourse.value),
|
||||||
)
|
)
|
||||||
const plannedHours = computed(() =>
|
const plannedHours = computed(() =>
|
||||||
form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek
|
form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek
|
||||||
@@ -124,7 +122,7 @@ const generationPlannedHours = computed(() =>
|
|||||||
)
|
)
|
||||||
const generationHoursMatch = computed(() =>
|
const generationHoursMatch = computed(() =>
|
||||||
!selectedGenerationCourse.value ||
|
!selectedGenerationCourse.value ||
|
||||||
generationPlannedHours.value === regularScheduleHours(selectedGenerationCourse.value),
|
generationPlannedHours.value === Number(selectedGenerationCourse.value?.totalHours ?? 0),
|
||||||
)
|
)
|
||||||
const manageableCourses = computed(() => {
|
const manageableCourses = computed(() => {
|
||||||
if (isSuperAdmin.value) return courses.value
|
if (isSuperAdmin.value) return courses.value
|
||||||
@@ -364,7 +362,7 @@ async function save() {
|
|||||||
}
|
}
|
||||||
if (!hoursMatch.value) {
|
if (!hoursMatch.value) {
|
||||||
ElMessage.warning(
|
ElMessage.warning(
|
||||||
`该课程普通课表应安排 ${selectedCourseTargetHours.value} 学时,当前安排合计 ${plannedHours.value} 学时;实践学时请在实验管理中安排。`,
|
`该课程理论课和实验课共应安排 ${selectedCourseTargetHours.value} 学时,当前安排合计 ${plannedHours.value} 学时。`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -551,7 +549,7 @@ async function generatePublicTasks() {
|
|||||||
}
|
}
|
||||||
if (!generationHoursMatch.value) {
|
if (!generationHoursMatch.value) {
|
||||||
ElMessage.warning(
|
ElMessage.warning(
|
||||||
`该课程普通课表应安排 ${regularScheduleHours(selectedGenerationCourse.value)} 学时,当前安排合计 ${generationPlannedHours.value} 学时;实践学时不进入普通课表。`,
|
`该课程理论课和实验课共应安排 ${selectedGenerationCourse.value?.totalHours ?? 0} 学时,当前安排合计 ${generationPlannedHours.value} 学时。`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -773,7 +771,7 @@ onMounted(async () => {
|
|||||||
<el-radio-button value="Flexible">非排时课程</el-radio-button>
|
<el-radio-button value="Flexible">非排时课程</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<small class="field-hint">
|
<small class="field-hint">
|
||||||
正常排课只安排非实践学时;全部由实验模块安排的课程请选择“非排时课程”。
|
正常排课会同时安排理论课和实验课;只有无需固定星期、节次和场地的课程才选择“非排时课程”。
|
||||||
</small>
|
</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="授课教师">
|
<el-form-item label="授课教师">
|
||||||
|
|||||||
@@ -1014,12 +1014,14 @@ onMounted(async () => {
|
|||||||
:class="{
|
:class="{
|
||||||
'exam-block': entry.isExam,
|
'exam-block': entry.isExam,
|
||||||
'experiment-block': entry.isExperiment,
|
'experiment-block': entry.isExperiment,
|
||||||
|
'scheduled-experiment-block': !entry.isExperiment && entry.kind === 'Experiment',
|
||||||
}"
|
}"
|
||||||
:style="gridEntryStyle(entry)"
|
:style="gridEntryStyle(entry)"
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
||||||
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
|
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
|
||||||
|
<span v-if="!entry.isExperiment && entry.kind === 'Experiment'" class="entry-kind scheduled-experiment-kind">实验课</span>
|
||||||
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
|
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
|
||||||
</strong>
|
</strong>
|
||||||
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
||||||
@@ -1064,12 +1066,14 @@ onMounted(async () => {
|
|||||||
:class="{
|
:class="{
|
||||||
'exam-block': entry.isExam,
|
'exam-block': entry.isExam,
|
||||||
'experiment-block': entry.isExperiment,
|
'experiment-block': entry.isExperiment,
|
||||||
|
'scheduled-experiment-block': !entry.isExperiment && entry.kind === 'Experiment',
|
||||||
}"
|
}"
|
||||||
:style="dayEntryStyle(entry)"
|
:style="dayEntryStyle(entry)"
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
||||||
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
|
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
|
||||||
|
<span v-if="!entry.isExperiment && entry.kind === 'Experiment'" class="entry-kind scheduled-experiment-kind">实验课</span>
|
||||||
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
|
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
|
||||||
</strong>
|
</strong>
|
||||||
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
||||||
@@ -1329,6 +1333,9 @@ onMounted(async () => {
|
|||||||
.course-block.experiment-block small, .day-course-block.experiment-block small { color: #527a74; }
|
.course-block.experiment-block small, .day-course-block.experiment-block small { color: #527a74; }
|
||||||
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
|
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
|
||||||
.entry-kind.experiment-kind { background: #168276; }
|
.entry-kind.experiment-kind { background: #168276; }
|
||||||
|
.course-block.scheduled-experiment-block, .day-course-block.scheduled-experiment-block { border-left-color: #b77819; background: #fff6df; color: #73521f; }
|
||||||
|
.course-block.scheduled-experiment-block strong, .day-course-block.scheduled-experiment-block strong { color: #66430e; }
|
||||||
|
.entry-kind.scheduled-experiment-kind { background: #b77819; }
|
||||||
.exam-overview { margin-top: 20px; border: 1px solid #e2d6cf; background: #fffaf7; }
|
.exam-overview { margin-top: 20px; border: 1px solid #e2d6cf; background: #fffaf7; }
|
||||||
.exam-overview > header { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #eaded7; background: #fff5ef; }
|
.exam-overview > header { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #eaded7; background: #fff5ef; }
|
||||||
.exam-overview > header > div { display: flex; align-items: baseline; gap: 10px; }
|
.exam-overview > header > div { display: flex; align-items: baseline; gap: 10px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user