From e4a12931eaaf50fc22d14957120864c0735d91ab Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 10:55:09 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8E=92=E8=AF=BE=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=90=8E=E9=80=90=E6=9D=A1=E6=98=BE=E7=A4=BA=E2=80=9C?= =?UTF-8?q?=E9=9C=80=E4=BA=BA=E5=B7=A5=E5=A4=84=E7=90=86=E2=80=9D=E7=9A=84?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=92=8C=E5=8E=9F=E5=9B=A0=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E2=80=9C=E6=A3=80=E6=9F=A5=E6=8E=92=E8=AF=BE?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=E2=80=9D=E5=85=A5=E5=8F=A3=EF=BC=9B=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E9=A1=B5=E9=9D=A2=E5=90=8E=E4=BB=8D=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E6=9C=80=E8=BF=91=E4=B8=80=E6=AC=A1=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E3=80=82=20=E6=89=B9=E9=87=8F=E6=8E=92=E8=AF=BE=E7=BA=A6?= =?UTF-8?q?=E6=9D=9F=E6=96=B0=E5=A2=9E=E6=A0=A1=E5=8C=BA=E3=80=81=E6=95=99?= =?UTF-8?q?=E5=AD=A6=E6=A5=BC=E3=80=81=E5=A4=9A=E6=95=99=E5=AE=A4=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=8C=87=E5=AE=9A=EF=BC=8C=E4=B9=9F=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=B8=85=E9=99=A4=E5=8E=9F=E6=9C=89=E9=99=90=E5=AE=9A=E3=80=82?= =?UTF-8?q?=20=E8=AF=BE=E8=A1=A8=E6=96=B0=E5=A2=9E=E2=80=9C=E6=80=BB?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E2=80=9D=EF=BC=8C=E5=90=8C=E4=B8=80=E6=97=B6?= =?UTF-8?q?=E6=AE=B5=E3=80=81=E4=B8=8D=E5=90=8C=E5=91=A8=E6=AC=A1=E8=AF=BE?= =?UTF-8?q?=E7=A8=8B=E4=BC=9A=E5=B9=B6=E5=88=97=E6=98=BE=E7=A4=BA=E3=80=82?= =?UTF-8?q?=20=E5=91=A8=E8=A7=86=E5=9B=BE=E6=8C=89=E6=95=99=E5=AD=A6?= =?UTF-8?q?=E5=91=A8=E8=BF=87=E6=BB=A4=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E5=91=A8=E3=80=81=E4=B8=8B=E4=B8=80=E5=91=A8=E3=80=81?= =?UTF-8?q?=E5=91=A8=E6=AC=A1=E4=B8=8B=E6=8B=89=E5=92=8C=E5=9B=9E=E5=88=B0?= =?UTF-8?q?=E6=9C=AC=E5=91=A8=E3=80=82=20=E6=97=A5=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=8C=89=E5=91=A8=E6=AC=A1=E8=BF=87=E6=BB=A4=EF=BC=9B=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E4=B8=A4=E8=8A=82=E8=AF=BE=E7=A8=8B=E4=BC=9A=E8=B7=A8?= =?UTF-8?q?=E4=B8=A4=E8=A1=8C=E5=AE=8C=E6=95=B4=E5=8D=A0=E6=A0=BC=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ScheduleSettingsController.cs | 63 ++++ .../Controllers/SchedulesController.cs | 6 +- .../ScheduleSettingsControllerTests.cs | 121 +++++++ .../SchedulesControllerTests.cs | 68 ++++ web/src/style.css | 4 + web/src/views/SchedulesView.vue | 102 ++++++ web/src/views/TeachingTasksView.vue | 23 +- web/src/views/TimetableView.vue | 316 ++++++++++++++++-- 8 files changed, 652 insertions(+), 51 deletions(-) create mode 100644 tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs create mode 100644 tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs diff --git a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs index 97d8561..ccd13a6 100644 --- a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs +++ b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs @@ -245,10 +245,13 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase if (!request.SchedulingMode.HasValue && !request.RequiresClassroom.HasValue && request.AllowedDayOfWeeks is null && + !request.UpdateClassroomScope && !request.UpdatePeriodRange && !request.EarliestPeriod.HasValue && !request.LatestPeriod.HasValue) return ValidationProblem("请至少选择一项需要批量修改的设置。"); + if (request.UpdateClassroomScope && request.RequiresClassroom == false) + return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。"); var tasks = await db.TeachingTasks .Where(x => @@ -261,6 +264,48 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible && await HasScheduleEntriesAsync(taskIds, cancellationToken)) return ConflictProblem("所选教学任务中存在已有正常排课记录的课程,请先删除这些排课记录。"); + if (request.UpdateClassroomScope && tasks.Any(task => + (request.SchedulingMode ?? task.SchedulingMode) == + TeachingTaskSchedulingMode.Flexible)) + return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。"); + + Building? building = null; + List allowedRooms = []; + if (request.UpdateClassroomScope) + { + if (request.RequiredBuildingId.HasValue) + { + building = await db.Buildings.AsNoTracking() + .FirstOrDefaultAsync( + x => x.Id == request.RequiredBuildingId && x.IsEnabled, + cancellationToken); + if (building is null) + return ValidationProblem("指定教学楼不存在或已停用。"); + if (request.RequiredCampusId.HasValue && + building.CampusId != request.RequiredCampusId) + return ValidationProblem("指定教学楼不属于所选校区。"); + } + if (request.RequiredCampusId.HasValue && + !await db.Campuses.AnyAsync( + x => x.Id == request.RequiredCampusId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("指定校区不存在或已停用。"); + + var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? []; + allowedRooms = await db.Classrooms.AsNoTracking() + .Where(x => roomIds.Contains(x.Id) && x.IsEnabled) + .Include(x => x.Building) + .ToListAsync(cancellationToken); + if (allowedRooms.Count != roomIds.Length) + return ValidationProblem("部分指定教室不存在或已停用。"); + if (building is not null && + allowedRooms.Any(x => x.BuildingId != building.Id)) + return ValidationProblem("指定教室必须位于所选教学楼。"); + if (request.RequiredCampusId.HasValue && + allowedRooms.Any(x => + x.Building!.CampusId != request.RequiredCampusId)) + return ValidationProblem("指定教室必须位于所选校区。"); + } var constraints = await db.TeachingTaskScheduleConstraints .Where(x => taskIds.Contains(x.TeachingTaskId)) @@ -282,6 +327,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase var changesConstraint = request.RequiresClassroom.HasValue || request.AllowedDayOfWeeks is not null || + request.UpdateClassroomScope || request.UpdatePeriodRange; if (!changesConstraint) continue; constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id }; @@ -306,6 +352,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase ? null : string.Join(',', request.AllowedDayOfWeeks.Distinct().Order()); } + if (request.UpdateClassroomScope) + { + constraint.RequiresClassroom = true; + constraint.RequiredCampusId = request.RequiredCampusId; + constraint.RequiredBuildingId = request.RequiredBuildingId; + db.TeachingTaskAllowedClassrooms.RemoveRange( + constraint.AllowedClassrooms); + constraint.AllowedClassrooms = allowedRooms.Select(room => + new TeachingTaskAllowedClassroom + { + ClassroomId = room.Id + }).ToList(); + } if (request.UpdatePeriodRange) { constraint.EarliestPeriod = request.EarliestPeriod; @@ -374,6 +433,10 @@ public sealed record TeachingTaskScheduleConstraintBatchRequest( TeachingTaskSchedulingMode? SchedulingMode, bool? RequiresClassroom, IReadOnlyList? AllowedDayOfWeeks, + bool UpdateClassroomScope, + Guid? RequiredCampusId, + Guid? RequiredBuildingId, + IReadOnlyList? AllowedClassroomIds, bool UpdatePeriodRange, [Range(1, 30)] int? EarliestPeriod, [Range(1, 30)] int? LatestPeriod); diff --git a/src/Jiaowu.Api/Controllers/SchedulesController.cs b/src/Jiaowu.Api/Controllers/SchedulesController.cs index 9fb9b5e..0e9a22d 100644 --- a/src/Jiaowu.Api/Controllers/SchedulesController.cs +++ b/src/Jiaowu.Api/Controllers/SchedulesController.cs @@ -353,12 +353,12 @@ public sealed class SchedulesController( [HttpGet("plans/{planId:guid}/auto-schedule-job")] public async Task> - GetActiveAutomaticScheduleJob( + GetLatestAutomaticScheduleJob( Guid planId, - CancellationToken cancellationToken) + CancellationToken cancellationToken) { var job = await db.AutomaticScheduleJobs.AsNoTracking() - .Where(x => x.ActiveSchedulePlanId == planId) + .Where(x => x.SchedulePlanId == planId) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); return Ok(job is null ? null : ToResponse(job)); diff --git a/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs new file mode 100644 index 0000000..12c41c0 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs @@ -0,0 +1,121 @@ +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class ScheduleSettingsControllerTests +{ + [Fact] + public async Task Batch_constraints_assign_same_classroom_scope_to_all_tasks() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var campus = new Campus { Code = "MAIN", Name = "主校区" }; + var building = new Building + { + Code = "J1", + Name = "第一教学楼", + Campus = campus + }; + var classroom = new Classroom + { + Code = "J1-201", + Name = "J1-201", + Building = building, + Capacity = 80 + }; + var college = new College { Code = "CS", Name = "计算机学院" }; + var course = new Course + { + Code = "CS101", + Name = "程序设计基础", + College = college, + Credits = 4, + TotalHours = 64, + LectureHours = 48, + PracticeHours = 16 + }; + var term = new AcademicTerm + { + Code = "2026-F", + Name = "2026 秋季", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 7), + EndDate = new DateOnly(2027, 1, 17) + }; + var firstTask = NewTask("TASK-01", course, term); + var secondTask = NewTask("TASK-02", course, term); + db.AddRange( + campus, + building, + classroom, + college, + course, + term, + firstTask, + secondTask); + await db.SaveChangesAsync(); + + var controller = new ScheduleSettingsController(db); + var result = await controller.SaveConstraintsBatch( + new TeachingTaskScheduleConstraintBatchRequest( + term.Id, + [firstTask.Id, secondTask.Id], + null, + null, + null, + true, + campus.Id, + building.Id, + [classroom.Id], + false, + null, + null), + CancellationToken.None); + + Assert.IsType(result); + db.ChangeTracker.Clear(); + var constraints = await db.TeachingTaskScheduleConstraints + .Include(item => item.AllowedClassrooms) + .OrderBy(item => item.TeachingTaskId) + .ToListAsync(); + Assert.Equal(2, constraints.Count); + Assert.All(constraints, constraint => + { + Assert.True(constraint.RequiresClassroom); + Assert.Equal(campus.Id, constraint.RequiredCampusId); + Assert.Equal(building.Id, constraint.RequiredBuildingId); + Assert.Equal( + classroom.Id, + Assert.Single(constraint.AllowedClassrooms).ClassroomId); + }); + } + + private static TeachingTask NewTask( + string taskNumber, + Course course, + AcademicTerm term) => + new() + { + TaskNumber = taskNumber, + Name = $"{course.Name}教学班", + Course = course, + AcademicTerm = term, + Capacity = 60, + WeeklyHours = 4, + StartWeek = 1, + EndWeek = 16, + Status = TeachingTaskStatus.Published + }; +} diff --git a/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs b/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs new file mode 100644 index 0000000..0de4c22 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs @@ -0,0 +1,68 @@ +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Scheduling; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class SchedulesControllerTests +{ + [Fact] + public async Task Latest_auto_schedule_job_remains_available_after_completion() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .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, 7), + EndDate = new DateOnly(2027, 1, 17) + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "排课草稿", + Version = "V1" + }; + var completed = new AutomaticScheduleJob + { + SchedulePlan = plan, + Status = AutomaticScheduleJobStatus.Succeeded, + CreatedEntries = 12, + CompletedTasks = 5, + TotalTasks = 6, + ProcessedTasks = 6, + MessagesJson = "[\"TASK-06 仍有 2 学时无法安排\"]", + CompletedAt = DateTime.UtcNow + }; + db.AddRange(term, plan, completed); + await db.SaveChangesAsync(); + + var controller = new SchedulesController( + db, + new AutomaticScheduleJobQueue()); + var result = await controller.GetLatestAutomaticScheduleJob( + plan.Id, + CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var response = Assert.IsType(ok.Value); + Assert.Equal(completed.Id, response.Id); + Assert.Equal(AutomaticScheduleJobStatus.Succeeded, response.Status); + Assert.Equal( + "TASK-06 仍有 2 学时无法安排", + Assert.Single(response.Messages)); + } +} diff --git a/web/src/style.css b/web/src/style.css index 2c1f7c9..2ddef95 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -331,6 +331,9 @@ button { cursor: pointer; } .auto-schedule-progress.is-succeeded { border-bottom-color: #bedac8; background: #f1f8f3; } .auto-schedule-progress.is-failed { border-bottom-color: #e6c5c5; background: #fff5f4; } .auto-schedule-progress.is-failed b { color: #9d3434; } +.auto-schedule-progress .auto-schedule-issues { grid-column: 1 / -1; padding: 11px 13px; gap: 8px; border: 1px solid #e3d3aa; background: #fffdf7; } +.auto-schedule-issues-head { display: flex !important; align-items: center; justify-content: space-between; gap: 12px; } +.auto-schedule-issues ol { max-height: 240px; margin: 0; padding: 0 8px 0 21px; display: grid; gap: 6px; overflow: auto; color: #715826; font-size: 11px; line-height: 1.55; } .schedule-search { padding: 12px 16px; display: flex; align-items: center; gap: 10px; background: #fafbfc; border-bottom: 1px solid var(--line); } .schedule-search .el-input { width: 320px; } .schedule-search > span { margin-left: auto; color: var(--muted); font-size: 10px; } @@ -363,6 +366,7 @@ button { cursor: pointer; } .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; } +.batch-classroom-scope { padding: 12px 14px 2px; display: grid; gap: 12px; border: 1px solid #d8e2e8; background: #fbfcfd; } .constraint-list article { min-width: 0; padding: 13px 15px; display: grid; grid-template-columns: minmax(230px, 1fr) minmax(170px, auto) auto; align-items: center; gap: 14px; border: 1px solid var(--line); background: #fff; } .constraint-list article > div:first-child { min-width: 0; display: grid; gap: 4px; } .constraint-list article span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; } diff --git a/web/src/views/SchedulesView.vue b/web/src/views/SchedulesView.vue index 192f9e4..f7e2995 100644 --- a/web/src/views/SchedulesView.vue +++ b/web/src/views/SchedulesView.vue @@ -141,6 +141,19 @@ const filteredClassrooms = computed(() => (!constraintForm.requiredBuildingId || item.buildingId === constraintForm.requiredBuildingId), ), ) +const batchFilteredBuildings = computed(() => + constraintBatchForm.requiredCampusId + ? buildings.value.filter((item) => item.campusId === constraintBatchForm.requiredCampusId) + : buildings.value, +) +const batchFilteredClassrooms = computed(() => + classrooms.value.filter((item) => + (!constraintBatchForm.requiredCampusId || + item.campusId === constraintBatchForm.requiredCampusId) && + (!constraintBatchForm.requiredBuildingId || + item.buildingId === constraintBatchForm.requiredBuildingId), + ), +) const filteredEntries = computed(() => { const text = keyword.value.trim().toLowerCase() if (!text) return selected.value?.entries ?? [] @@ -285,6 +298,10 @@ function openConstraintBatch() { schedulingMode: 'Standard', updateRequiresClassroom: false, requiresClassroom: true, + updateClassroomScope: false, + requiredCampusId: undefined, + requiredBuildingId: undefined, + allowedClassroomIds: [], updateDays: false, allowedDayOfWeeks: [1, 2, 3, 4, 5], updatePeriodRange: false, @@ -297,6 +314,7 @@ function openConstraintBatch() { async function saveConstraintBatch() { if (!constraintBatchForm.updateSchedulingMode && !constraintBatchForm.updateRequiresClassroom && + !constraintBatchForm.updateClassroomScope && !constraintBatchForm.updateDays && !constraintBatchForm.updatePeriodRange) { ElMessage.warning('请至少勾选一项需要批量修改的设置。') @@ -312,6 +330,10 @@ async function saveConstraintBatch() { constraintBatchSaving.value = true const flexible = constraintBatchForm.updateSchedulingMode && constraintBatchForm.schedulingMode === 'Flexible' + const updateClassroomScope = !flexible && + !(constraintBatchForm.updateRequiresClassroom && + !constraintBatchForm.requiresClassroom) && + constraintBatchForm.updateClassroomScope const { data } = await http.put('/schedules/constraints/batch', { academicTermId: termId.value, teachingTaskIds: targets.map((item) => item.id), @@ -321,6 +343,16 @@ async function saveConstraintBatch() { requiresClassroom: !flexible && constraintBatchForm.updateRequiresClassroom ? constraintBatchForm.requiresClassroom : null, + updateClassroomScope, + requiredCampusId: updateClassroomScope + ? constraintBatchForm.requiredCampusId || null + : null, + requiredBuildingId: updateClassroomScope + ? constraintBatchForm.requiredBuildingId || null + : null, + allowedClassroomIds: updateClassroomScope + ? constraintBatchForm.allowedClassroomIds + : null, allowedDayOfWeeks: !flexible && constraintBatchForm.updateDays ? constraintBatchForm.allowedDayOfWeeks : null, @@ -342,6 +374,11 @@ async function saveConstraintBatch() { } } +function openManualHandling() { + settingsTab.value = 'constraints' + settingsDrawer.value = true +} + async function autoSchedule() { try { await ElMessageBox.confirm( @@ -694,6 +731,20 @@ onBeforeUnmount(clearAutoSchedulePoll) :indeterminate="autoJob.status === 'Queued'" :duration="2" /> +
+
+ 未完成任务与处理建议 + + 检查排课约束 + +
+
    +
  1. {{ message }}
  2. +
+