自动排课完成后逐条显示“需人工处理”的任务和原因,并提供“检查排课约束”入口;刷新页面后仍可读取最近一次结果。
批量排课约束新增校区、教学楼、多教室统一指定,也支持清除原有限定。 课表新增“总视图”,同一时段、不同周次课程会并列显示。 周视图按教学周过滤,支持上一周、下一周、周次下拉和回到本周。 日视图按周次过滤;连续两节课程会跨两行完整占格。
This commit is contained in:
@@ -245,10 +245,13 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
if (!request.SchedulingMode.HasValue &&
|
if (!request.SchedulingMode.HasValue &&
|
||||||
!request.RequiresClassroom.HasValue &&
|
!request.RequiresClassroom.HasValue &&
|
||||||
request.AllowedDayOfWeeks is null &&
|
request.AllowedDayOfWeeks is null &&
|
||||||
|
!request.UpdateClassroomScope &&
|
||||||
!request.UpdatePeriodRange &&
|
!request.UpdatePeriodRange &&
|
||||||
!request.EarliestPeriod.HasValue &&
|
!request.EarliestPeriod.HasValue &&
|
||||||
!request.LatestPeriod.HasValue)
|
!request.LatestPeriod.HasValue)
|
||||||
return ValidationProblem("请至少选择一项需要批量修改的设置。");
|
return ValidationProblem("请至少选择一项需要批量修改的设置。");
|
||||||
|
if (request.UpdateClassroomScope && request.RequiresClassroom == false)
|
||||||
|
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
|
||||||
|
|
||||||
var tasks = await db.TeachingTasks
|
var tasks = await db.TeachingTasks
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -261,6 +264,48 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||||
await HasScheduleEntriesAsync(taskIds, cancellationToken))
|
await HasScheduleEntriesAsync(taskIds, cancellationToken))
|
||||||
return ConflictProblem("所选教学任务中存在已有正常排课记录的课程,请先删除这些排课记录。");
|
return ConflictProblem("所选教学任务中存在已有正常排课记录的课程,请先删除这些排课记录。");
|
||||||
|
if (request.UpdateClassroomScope && tasks.Any(task =>
|
||||||
|
(request.SchedulingMode ?? task.SchedulingMode) ==
|
||||||
|
TeachingTaskSchedulingMode.Flexible))
|
||||||
|
return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。");
|
||||||
|
|
||||||
|
Building? building = null;
|
||||||
|
List<Classroom> 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
|
var constraints = await db.TeachingTaskScheduleConstraints
|
||||||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||||
@@ -282,6 +327,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
var changesConstraint =
|
var changesConstraint =
|
||||||
request.RequiresClassroom.HasValue ||
|
request.RequiresClassroom.HasValue ||
|
||||||
request.AllowedDayOfWeeks is not null ||
|
request.AllowedDayOfWeeks is not null ||
|
||||||
|
request.UpdateClassroomScope ||
|
||||||
request.UpdatePeriodRange;
|
request.UpdatePeriodRange;
|
||||||
if (!changesConstraint) continue;
|
if (!changesConstraint) continue;
|
||||||
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
|
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
|
||||||
@@ -306,6 +352,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
? null
|
? null
|
||||||
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
|
: 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)
|
if (request.UpdatePeriodRange)
|
||||||
{
|
{
|
||||||
constraint.EarliestPeriod = request.EarliestPeriod;
|
constraint.EarliestPeriod = request.EarliestPeriod;
|
||||||
@@ -374,6 +433,10 @@ public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
|||||||
TeachingTaskSchedulingMode? SchedulingMode,
|
TeachingTaskSchedulingMode? SchedulingMode,
|
||||||
bool? RequiresClassroom,
|
bool? RequiresClassroom,
|
||||||
IReadOnlyList<int>? AllowedDayOfWeeks,
|
IReadOnlyList<int>? AllowedDayOfWeeks,
|
||||||
|
bool UpdateClassroomScope,
|
||||||
|
Guid? RequiredCampusId,
|
||||||
|
Guid? RequiredBuildingId,
|
||||||
|
IReadOnlyList<Guid>? AllowedClassroomIds,
|
||||||
bool UpdatePeriodRange,
|
bool UpdatePeriodRange,
|
||||||
[Range(1, 30)] int? EarliestPeriod,
|
[Range(1, 30)] int? EarliestPeriod,
|
||||||
[Range(1, 30)] int? LatestPeriod);
|
[Range(1, 30)] int? LatestPeriod);
|
||||||
|
|||||||
@@ -353,12 +353,12 @@ public sealed class SchedulesController(
|
|||||||
|
|
||||||
[HttpGet("plans/{planId:guid}/auto-schedule-job")]
|
[HttpGet("plans/{planId:guid}/auto-schedule-job")]
|
||||||
public async Task<ActionResult<AutomaticScheduleJobResponse?>>
|
public async Task<ActionResult<AutomaticScheduleJobResponse?>>
|
||||||
GetActiveAutomaticScheduleJob(
|
GetLatestAutomaticScheduleJob(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var job = await db.AutomaticScheduleJobs.AsNoTracking()
|
var job = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||||
.Where(x => x.ActiveSchedulePlanId == planId)
|
.Where(x => x.SchedulePlanId == planId)
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
return Ok(job is null ? null : ToResponse(job));
|
return Ok(job is null ? null : ToResponse(job));
|
||||||
|
|||||||
@@ -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<AppDbContext>()
|
||||||
|
.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<OkObjectResult>(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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<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, 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<OkObjectResult>(result.Result);
|
||||||
|
var response = Assert.IsType<AutomaticScheduleJobResponse>(ok.Value);
|
||||||
|
Assert.Equal(completed.Id, response.Id);
|
||||||
|
Assert.Equal(AutomaticScheduleJobStatus.Succeeded, response.Status);
|
||||||
|
Assert.Equal(
|
||||||
|
"TASK-06 仍有 2 学时无法安排",
|
||||||
|
Assert.Single(response.Messages));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -331,6 +331,9 @@ button { cursor: pointer; }
|
|||||||
.auto-schedule-progress.is-succeeded { border-bottom-color: #bedac8; background: #f1f8f3; }
|
.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 { border-bottom-color: #e6c5c5; background: #fff5f4; }
|
||||||
.auto-schedule-progress.is-failed b { color: #9d3434; }
|
.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 { 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 .el-input { width: 320px; }
|
||||||
.schedule-search > span { margin-left: auto; color: var(--muted); font-size: 10px; }
|
.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-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 { margin-top: 16px; display: grid; gap: 10px; }
|
||||||
.constraint-batch-form > .el-checkbox { padding: 8px 10px; background: #f7f9fb; border-left: 3px solid #ccd8e1; }
|
.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 { 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; }
|
||||||
|
|||||||
@@ -141,6 +141,19 @@ const filteredClassrooms = computed(() =>
|
|||||||
(!constraintForm.requiredBuildingId || item.buildingId === constraintForm.requiredBuildingId),
|
(!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 filteredEntries = computed(() => {
|
||||||
const text = keyword.value.trim().toLowerCase()
|
const text = keyword.value.trim().toLowerCase()
|
||||||
if (!text) return selected.value?.entries ?? []
|
if (!text) return selected.value?.entries ?? []
|
||||||
@@ -285,6 +298,10 @@ function openConstraintBatch() {
|
|||||||
schedulingMode: 'Standard',
|
schedulingMode: 'Standard',
|
||||||
updateRequiresClassroom: false,
|
updateRequiresClassroom: false,
|
||||||
requiresClassroom: true,
|
requiresClassroom: true,
|
||||||
|
updateClassroomScope: false,
|
||||||
|
requiredCampusId: undefined,
|
||||||
|
requiredBuildingId: undefined,
|
||||||
|
allowedClassroomIds: [],
|
||||||
updateDays: false,
|
updateDays: false,
|
||||||
allowedDayOfWeeks: [1, 2, 3, 4, 5],
|
allowedDayOfWeeks: [1, 2, 3, 4, 5],
|
||||||
updatePeriodRange: false,
|
updatePeriodRange: false,
|
||||||
@@ -297,6 +314,7 @@ function openConstraintBatch() {
|
|||||||
async function saveConstraintBatch() {
|
async function saveConstraintBatch() {
|
||||||
if (!constraintBatchForm.updateSchedulingMode &&
|
if (!constraintBatchForm.updateSchedulingMode &&
|
||||||
!constraintBatchForm.updateRequiresClassroom &&
|
!constraintBatchForm.updateRequiresClassroom &&
|
||||||
|
!constraintBatchForm.updateClassroomScope &&
|
||||||
!constraintBatchForm.updateDays &&
|
!constraintBatchForm.updateDays &&
|
||||||
!constraintBatchForm.updatePeriodRange) {
|
!constraintBatchForm.updatePeriodRange) {
|
||||||
ElMessage.warning('请至少勾选一项需要批量修改的设置。')
|
ElMessage.warning('请至少勾选一项需要批量修改的设置。')
|
||||||
@@ -312,6 +330,10 @@ async function saveConstraintBatch() {
|
|||||||
constraintBatchSaving.value = true
|
constraintBatchSaving.value = true
|
||||||
const flexible = constraintBatchForm.updateSchedulingMode &&
|
const flexible = constraintBatchForm.updateSchedulingMode &&
|
||||||
constraintBatchForm.schedulingMode === 'Flexible'
|
constraintBatchForm.schedulingMode === 'Flexible'
|
||||||
|
const updateClassroomScope = !flexible &&
|
||||||
|
!(constraintBatchForm.updateRequiresClassroom &&
|
||||||
|
!constraintBatchForm.requiresClassroom) &&
|
||||||
|
constraintBatchForm.updateClassroomScope
|
||||||
const { data } = await http.put('/schedules/constraints/batch', {
|
const { data } = await http.put('/schedules/constraints/batch', {
|
||||||
academicTermId: termId.value,
|
academicTermId: termId.value,
|
||||||
teachingTaskIds: targets.map((item) => item.id),
|
teachingTaskIds: targets.map((item) => item.id),
|
||||||
@@ -321,6 +343,16 @@ async function saveConstraintBatch() {
|
|||||||
requiresClassroom: !flexible && constraintBatchForm.updateRequiresClassroom
|
requiresClassroom: !flexible && constraintBatchForm.updateRequiresClassroom
|
||||||
? constraintBatchForm.requiresClassroom
|
? constraintBatchForm.requiresClassroom
|
||||||
: null,
|
: null,
|
||||||
|
updateClassroomScope,
|
||||||
|
requiredCampusId: updateClassroomScope
|
||||||
|
? constraintBatchForm.requiredCampusId || null
|
||||||
|
: null,
|
||||||
|
requiredBuildingId: updateClassroomScope
|
||||||
|
? constraintBatchForm.requiredBuildingId || null
|
||||||
|
: null,
|
||||||
|
allowedClassroomIds: updateClassroomScope
|
||||||
|
? constraintBatchForm.allowedClassroomIds
|
||||||
|
: null,
|
||||||
allowedDayOfWeeks: !flexible && constraintBatchForm.updateDays
|
allowedDayOfWeeks: !flexible && constraintBatchForm.updateDays
|
||||||
? constraintBatchForm.allowedDayOfWeeks
|
? constraintBatchForm.allowedDayOfWeeks
|
||||||
: null,
|
: null,
|
||||||
@@ -342,6 +374,11 @@ async function saveConstraintBatch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openManualHandling() {
|
||||||
|
settingsTab.value = 'constraints'
|
||||||
|
settingsDrawer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
async function autoSchedule() {
|
async function autoSchedule() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
@@ -694,6 +731,20 @@ onBeforeUnmount(clearAutoSchedulePoll)
|
|||||||
:indeterminate="autoJob.status === 'Queued'"
|
:indeterminate="autoJob.status === 'Queued'"
|
||||||
:duration="2"
|
:duration="2"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-if="autoJob.status === 'Succeeded' && autoJob.messages.length"
|
||||||
|
class="auto-schedule-issues"
|
||||||
|
>
|
||||||
|
<div class="auto-schedule-issues-head">
|
||||||
|
<b>未完成任务与处理建议</b>
|
||||||
|
<el-button size="small" type="warning" plain @click="openManualHandling">
|
||||||
|
检查排课约束
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<li v-for="message in autoJob.messages" :key="message">{{ message }}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="schedule-search">
|
<div class="schedule-search">
|
||||||
@@ -1024,8 +1075,59 @@ onBeforeUnmount(clearAutoSchedulePoll)
|
|||||||
v-model="constraintBatchForm.requiresClassroom"
|
v-model="constraintBatchForm.requiresClassroom"
|
||||||
active-text="需要占用教室"
|
active-text="需要占用教室"
|
||||||
inactive-text="不占用教室"
|
inactive-text="不占用教室"
|
||||||
|
@change="!constraintBatchForm.requiresClassroom && (constraintBatchForm.updateClassroomScope = false)"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<template v-if="!(constraintBatchForm.updateRequiresClassroom && !constraintBatchForm.requiresClassroom)">
|
||||||
|
<el-checkbox v-model="constraintBatchForm.updateClassroomScope">
|
||||||
|
批量指定教室范围
|
||||||
|
</el-checkbox>
|
||||||
|
<div v-if="constraintBatchForm.updateClassroomScope" class="batch-classroom-scope">
|
||||||
|
<el-alert
|
||||||
|
title="将统一设置为需要教室;校区、教学楼和教室均不选择时,表示清除原有限定并允许系统自动分配。"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="统一限定校区">
|
||||||
|
<el-select
|
||||||
|
v-model="constraintBatchForm.requiredCampusId"
|
||||||
|
clearable
|
||||||
|
@change="constraintBatchForm.requiredBuildingId = undefined; constraintBatchForm.allowedClassroomIds = []"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="统一限定教学楼">
|
||||||
|
<el-select
|
||||||
|
v-model="constraintBatchForm.requiredBuildingId"
|
||||||
|
clearable
|
||||||
|
@change="constraintBatchForm.allowedClassroomIds = []"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in batchFilteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="统一指定可用教室">
|
||||||
|
<el-select
|
||||||
|
v-model="constraintBatchForm.allowedClassroomIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="不选择则允许范围内任意教室"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in batchFilteredClassrooms"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.buildingName} / ${item.name}(${item.capacity}人)`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<el-checkbox v-model="constraintBatchForm.updateDays">修改允许上课日</el-checkbox>
|
<el-checkbox v-model="constraintBatchForm.updateDays">修改允许上课日</el-checkbox>
|
||||||
<el-form-item v-if="constraintBatchForm.updateDays" label="统一允许上课日">
|
<el-form-item v-if="constraintBatchForm.updateDays" label="统一允许上课日">
|
||||||
<el-checkbox-group v-model="constraintBatchForm.allowedDayOfWeeks">
|
<el-checkbox-group v-model="constraintBatchForm.allowedDayOfWeeks">
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const editingId = ref('')
|
|||||||
const selectedRows = ref<any[]>([])
|
const selectedRows = ref<any[]>([])
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
const courses = ref<any[]>([])
|
const courses = ref<any[]>([])
|
||||||
const teachers = ref<any[]>([])
|
|
||||||
const classes = ref<any[]>([])
|
const classes = ref<any[]>([])
|
||||||
const majors = ref<any[]>([])
|
const majors = ref<any[]>([])
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
@@ -85,13 +84,15 @@ const plannedHours = computed(() =>
|
|||||||
const hoursMatch = computed(() =>
|
const hoursMatch = computed(() =>
|
||||||
!selectedCourse.value || plannedHours.value === selectedCourse.value.totalHours,
|
!selectedCourse.value || plannedHours.value === selectedCourse.value.totalHours,
|
||||||
)
|
)
|
||||||
const assignableTeachers = computed(() => {
|
const assignableTeachers = computed(() =>
|
||||||
if (!selectedCourse.value) return teachers.value
|
manualEligibleTeachers.value.map((item) => ({
|
||||||
const approvedIds = new Set(manualEligibleTeachers.value.map((item) => item.teacherId))
|
id: item.teacherId,
|
||||||
return teachers.value.filter((item) =>
|
teacherNumber: item.teacherNumber,
|
||||||
approvedIds.has(item.id) || form.teacherIds?.includes(item.id),
|
name: item.name,
|
||||||
)
|
title: item.title,
|
||||||
})
|
collegeName: item.collegeName,
|
||||||
|
})),
|
||||||
|
)
|
||||||
const selectedTeachers = computed(() =>
|
const selectedTeachers = computed(() =>
|
||||||
assignableTeachers.value.filter((item) => form.teacherIds?.includes(item.id)),
|
assignableTeachers.value.filter((item) => form.teacherIds?.includes(item.id)),
|
||||||
)
|
)
|
||||||
@@ -521,16 +522,14 @@ async function generatePublicTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [termRes, courseRes, teacherRes, classRes, majorRes] = await Promise.all([
|
const [termRes, courseRes, classRes, majorRes] = await Promise.all([
|
||||||
http.get('/base-data/terms'),
|
http.get('/base-data/terms'),
|
||||||
http.get('/courses/options'),
|
http.get('/courses/options'),
|
||||||
http.get('/personnel/teachers', { params: { page: 1, pageSize: 100, teacherStatus: 'Active' } }),
|
|
||||||
http.get('/base-data/classes'),
|
http.get('/base-data/classes'),
|
||||||
http.get('/base-data/majors'),
|
http.get('/base-data/majors'),
|
||||||
])
|
])
|
||||||
terms.value = termRes.data
|
terms.value = termRes.data
|
||||||
courses.value = courseRes.data
|
courses.value = courseRes.data
|
||||||
teachers.value = teacherRes.data.items
|
|
||||||
classes.value = classRes.data
|
classes.value = classRes.data
|
||||||
majors.value = majorRes.data
|
majors.value = majorRes.data
|
||||||
query.academicTermId = terms.value.find((item) => item.isCurrent)?.id
|
query.academicTermId = terms.value.find((item) => item.isCurrent)?.id
|
||||||
@@ -700,7 +699,7 @@ onMounted(async () => {
|
|||||||
<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" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<small v-if="selectedCourse" class="field-hint">仅显示本学期申报该课程且学院审核通过的教师。</small>
|
<small v-if="selectedCourse" class="field-hint">仅显示本学期已审核通过或由学院直接分配的授课资格教师。</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="主讲教师">
|
<el-form-item label="主讲教师">
|
||||||
<el-select v-model="form.primaryTeacherId" clearable :disabled="!form.teacherIds?.length">
|
<el-select v-model="form.primaryTeacherId" clearable :disabled="!form.teacherIds?.length">
|
||||||
|
|||||||
+280
-36
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { Document, Download } from '@element-plus/icons-vue'
|
import { ArrowLeft, ArrowRight, Document, Download } from '@element-plus/icons-vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { downloadApiFile } from '../api/excel'
|
import { downloadApiFile } from '../api/excel'
|
||||||
@@ -39,8 +39,10 @@ const campusId = ref('')
|
|||||||
const buildingId = ref('')
|
const buildingId = ref('')
|
||||||
const classroomId = ref('')
|
const classroomId = ref('')
|
||||||
const timetable = ref<any | null>(null)
|
const timetable = ref<any | null>(null)
|
||||||
const viewMode = ref<'week' | 'day'>('week')
|
const viewMode = ref<'overview' | 'week' | 'day'>('week')
|
||||||
|
const selectedWeek = ref(1)
|
||||||
const selectedDay = ref(1)
|
const selectedDay = ref(1)
|
||||||
|
const loadedTermId = ref('')
|
||||||
const exportArea = ref<HTMLElement | null>(null)
|
const exportArea = ref<HTMLElement | null>(null)
|
||||||
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
||||||
@@ -93,19 +95,68 @@ const slotMap = computed<Map<number, any>>(() =>
|
|||||||
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
|
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const dayEntries = computed(() =>
|
const termMonday = computed(() => {
|
||||||
(timetable.value?.entries ?? []).filter((entry: any) => entry.dayOfWeek === selectedDay.value),
|
const start = parseDate(timetable.value?.term?.startDate)
|
||||||
|
if (!start) return null
|
||||||
|
const offset = start.getDay() === 0 ? 6 : start.getDay() - 1
|
||||||
|
return addDays(start, -offset)
|
||||||
|
})
|
||||||
|
const totalWeeks = computed(() => {
|
||||||
|
const end = parseDate(timetable.value?.term?.endDate)
|
||||||
|
if (!termMonday.value || !end) return 1
|
||||||
|
const dateWeeks = Math.ceil((end.getTime() - termMonday.value.getTime() + 86400000) /
|
||||||
|
(7 * 86400000))
|
||||||
|
const entryWeeks = Math.max(
|
||||||
|
1,
|
||||||
|
...((timetable.value?.entries ?? []).map((entry: any) => entry.endWeek)),
|
||||||
|
)
|
||||||
|
return Math.max(1, dateWeeks, entryWeeks)
|
||||||
|
})
|
||||||
|
const currentTermWeek = computed(() => {
|
||||||
|
if (!termMonday.value) return null
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
const week = Math.floor((now.getTime() - termMonday.value.getTime()) /
|
||||||
|
(7 * 86400000)) + 1
|
||||||
|
return week >= 1 && week <= totalWeeks.value ? week : null
|
||||||
|
})
|
||||||
|
const weekEntries = computed(() =>
|
||||||
|
(timetable.value?.entries ?? []).filter((entry: any) =>
|
||||||
|
occursInWeek(entry, selectedWeek.value)),
|
||||||
)
|
)
|
||||||
|
const dayEntries = computed(() =>
|
||||||
|
weekEntries.value.filter((entry: any) => entry.dayOfWeek === selectedDay.value),
|
||||||
|
)
|
||||||
|
const visibleGridEntries = computed(() =>
|
||||||
|
viewMode.value === 'overview'
|
||||||
|
? (timetable.value?.entries ?? [])
|
||||||
|
: weekEntries.value,
|
||||||
|
)
|
||||||
|
const selectedWeekLabel = computed(() => weekLabel(selectedWeek.value))
|
||||||
|
const selectedDayDate = computed(() => {
|
||||||
|
if (!termMonday.value) return ''
|
||||||
|
return formatMonthDay(addDays(
|
||||||
|
termMonday.value,
|
||||||
|
(selectedWeek.value - 1) * 7 + selectedDay.value - 1,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
function formatTime(value: string) {
|
function formatTime(value: string) {
|
||||||
return value?.slice(0, 5) ?? ''
|
return value?.slice(0, 5) ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryStyle(entry: any) {
|
function gridEntryStyle(entry: any) {
|
||||||
return {
|
return withLaneStyle(entry, visibleGridEntries.value, {
|
||||||
gridColumn: String(entry.dayOfWeek + 1),
|
gridColumn: String(entry.dayOfWeek + 1),
|
||||||
gridRow: `${entry.startPeriod + 1} / span ${entry.periodCount}`,
|
gridRow: `${entry.startPeriod + 1} / span ${entry.periodCount}`,
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayEntryStyle(entry: any) {
|
||||||
|
return withLaneStyle(entry, dayEntries.value, {
|
||||||
|
gridColumn: '2',
|
||||||
|
gridRow: `${entry.startPeriod} / span ${entry.periodCount}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function weeks(entry: any) {
|
function weeks(entry: any) {
|
||||||
@@ -117,8 +168,116 @@ function location(entry: any) {
|
|||||||
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
|
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayEntriesAt(period: number) {
|
function parseDate(value?: string) {
|
||||||
return dayEntries.value.filter((entry: any) => entry.startPeriod === period)
|
if (!value) return null
|
||||||
|
const date = new Date(`${value.slice(0, 10)}T00:00:00`)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number) {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setDate(result.getDate() + days)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthDay(date: Date) {
|
||||||
|
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekLabel(week: number) {
|
||||||
|
if (!termMonday.value) return `第 ${week} 周`
|
||||||
|
const start = addDays(termMonday.value, (week - 1) * 7)
|
||||||
|
const termEnd = parseDate(timetable.value?.term?.endDate)
|
||||||
|
const calculatedEnd = addDays(start, 6)
|
||||||
|
const end = termEnd && calculatedEnd > termEnd ? termEnd : calculatedEnd
|
||||||
|
return `第 ${week} 周 · ${formatMonthDay(start)}—${formatMonthDay(end)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayDate(day: number) {
|
||||||
|
if (viewMode.value !== 'week' || !termMonday.value) return ''
|
||||||
|
return formatMonthDay(addDays(
|
||||||
|
termMonday.value,
|
||||||
|
(selectedWeek.value - 1) * 7 + day - 1,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function occursInWeek(entry: any, week: number) {
|
||||||
|
if (week < entry.startWeek || week > entry.endWeek) return false
|
||||||
|
if (entry.weekPattern === 'Odd') return week % 2 === 1
|
||||||
|
if (entry.weekPattern === 'Even') return week % 2 === 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneFor(entry: any, entries: any[]) {
|
||||||
|
const sameDay = entries
|
||||||
|
.filter((item: any) => item.dayOfWeek === entry.dayOfWeek)
|
||||||
|
.slice()
|
||||||
|
.sort((a: any, b: any) =>
|
||||||
|
a.startPeriod - b.startPeriod ||
|
||||||
|
b.periodCount - a.periodCount ||
|
||||||
|
String(a.id).localeCompare(String(b.id)),
|
||||||
|
)
|
||||||
|
const layouts = new Map<string, { index: number; count: number }>()
|
||||||
|
let cluster: any[] = []
|
||||||
|
let clusterEnd = -1
|
||||||
|
|
||||||
|
const placeCluster = () => {
|
||||||
|
if (!cluster.length) return
|
||||||
|
const laneEnds: number[] = []
|
||||||
|
const placed = cluster.map((item: any) => {
|
||||||
|
const start = item.startPeriod
|
||||||
|
const end = item.startPeriod + item.periodCount
|
||||||
|
let index = laneEnds.findIndex((laneEnd) => laneEnd <= start)
|
||||||
|
if (index === -1) {
|
||||||
|
index = laneEnds.length
|
||||||
|
laneEnds.push(end)
|
||||||
|
} else {
|
||||||
|
laneEnds[index] = end
|
||||||
|
}
|
||||||
|
return { item, index }
|
||||||
|
})
|
||||||
|
const count = Math.max(1, laneEnds.length)
|
||||||
|
placed.forEach(({ item, index }) => layouts.set(String(item.id), { index, count }))
|
||||||
|
}
|
||||||
|
|
||||||
|
sameDay.forEach((item: any) => {
|
||||||
|
const itemEnd = item.startPeriod + item.periodCount
|
||||||
|
if (cluster.length && item.startPeriod >= clusterEnd) {
|
||||||
|
placeCluster()
|
||||||
|
cluster = []
|
||||||
|
clusterEnd = -1
|
||||||
|
}
|
||||||
|
cluster.push(item)
|
||||||
|
clusterEnd = Math.max(clusterEnd, itemEnd)
|
||||||
|
})
|
||||||
|
placeCluster()
|
||||||
|
return layouts.get(String(entry.id)) ?? { index: 0, count: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLaneStyle(entry: any, entries: any[], base: Record<string, string>) {
|
||||||
|
const lane = laneFor(entry, entries)
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
width: `calc(${100 / lane.count}% - 8px)`,
|
||||||
|
marginLeft: `calc(${100 / lane.count * lane.index}% + 4px)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelectedWeek() {
|
||||||
|
const termKey = timetable.value?.term?.id ?? ''
|
||||||
|
if (termKey !== loadedTermId.value) {
|
||||||
|
loadedTermId.value = termKey
|
||||||
|
selectedWeek.value = currentTermWeek.value ?? 1
|
||||||
|
} else {
|
||||||
|
selectedWeek.value = Math.min(Math.max(1, selectedWeek.value), totalWeeks.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeWeek(delta: number) {
|
||||||
|
selectedWeek.value = Math.min(
|
||||||
|
totalWeeks.value,
|
||||||
|
Math.max(1, selectedWeek.value + delta),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setClassFilters(item: any) {
|
function setClassFilters(item: any) {
|
||||||
@@ -243,6 +402,7 @@ async function loadTimetable() {
|
|||||||
params: { academicTermId: termId.value },
|
params: { academicTermId: termId.value },
|
||||||
})).data
|
})).data
|
||||||
}
|
}
|
||||||
|
syncSelectedWeek()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
timetable.value = null
|
timetable.value = null
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -438,9 +598,44 @@ onMounted(async () => {
|
|||||||
<div v-if="timetable" class="timetable-toolbar">
|
<div v-if="timetable" class="timetable-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<el-radio-group v-model="viewMode" size="small">
|
<el-radio-group v-model="viewMode" size="small">
|
||||||
|
<el-radio-button value="overview">总视图</el-radio-button>
|
||||||
<el-radio-button value="week">周视图</el-radio-button>
|
<el-radio-button value="week">周视图</el-radio-button>
|
||||||
<el-radio-button value="day">日视图</el-radio-button>
|
<el-radio-button value="day">日视图</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
<div v-if="viewMode !== 'overview'" class="week-switcher">
|
||||||
|
<el-button
|
||||||
|
:icon="ArrowLeft"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
aria-label="上一周"
|
||||||
|
:disabled="selectedWeek <= 1"
|
||||||
|
@click="changeWeek(-1)"
|
||||||
|
/>
|
||||||
|
<el-select v-model="selectedWeek" size="small" aria-label="选择教学周">
|
||||||
|
<el-option
|
||||||
|
v-for="week in totalWeeks"
|
||||||
|
:key="week"
|
||||||
|
:label="weekLabel(week)"
|
||||||
|
:value="week"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button
|
||||||
|
:icon="ArrowRight"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
aria-label="下一周"
|
||||||
|
:disabled="selectedWeek >= totalWeeks"
|
||||||
|
@click="changeWeek(1)"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
v-if="currentTermWeek"
|
||||||
|
size="small"
|
||||||
|
:disabled="selectedWeek === currentTermWeek"
|
||||||
|
@click="selectedWeek = currentTermWeek"
|
||||||
|
>
|
||||||
|
回到本周
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
<el-select v-if="viewMode === 'day'" v-model="selectedDay" size="small">
|
<el-select v-if="viewMode === 'day'" v-model="selectedDay" size="small">
|
||||||
<el-option v-for="day in 7" :key="day" :label="weekdays[day]" :value="day" />
|
<el-option v-for="day in 7" :key="day" :label="weekdays[day]" :value="day" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -499,13 +694,32 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="viewMode === 'week' && timetable?.plan && timetable.entries.length" class="timetable-scroll">
|
<div
|
||||||
|
v-if="(viewMode === 'overview' || viewMode === 'week') && timetable?.plan && timetable.entries.length"
|
||||||
|
class="timetable-view-section"
|
||||||
|
>
|
||||||
|
<div class="view-context">
|
||||||
|
<div>
|
||||||
|
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
|
||||||
|
<span>
|
||||||
|
{{ viewMode === 'overview'
|
||||||
|
? '同时段但不同周次的课程并列显示'
|
||||||
|
: `本周共 ${weekEntries.length} 项课程安排` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<small v-if="viewMode === 'overview'">课程卡片保留起止周与单双周信息</small>
|
||||||
|
<small v-else-if="!weekEntries.length">本周没有课程安排</small>
|
||||||
|
</div>
|
||||||
|
<div class="timetable-scroll">
|
||||||
<div
|
<div
|
||||||
class="week-grid"
|
class="week-grid"
|
||||||
:style="{ gridTemplateRows: `48px repeat(${maxPeriods}, 110px)` }"
|
:style="{ gridTemplateRows: `48px repeat(${maxPeriods}, 110px)` }"
|
||||||
>
|
>
|
||||||
<div class="grid-corner">节次</div>
|
<div class="grid-corner">节次</div>
|
||||||
<div v-for="day in 7" :key="`head-${day}`" class="day-head">{{ weekdays[day] }}</div>
|
<div v-for="day in 7" :key="`head-${day}`" class="day-head">
|
||||||
|
<strong>{{ weekdays[day] }}</strong>
|
||||||
|
<small v-if="weekdayDate(day)">{{ weekdayDate(day) }}</small>
|
||||||
|
</div>
|
||||||
<template v-for="period in maxPeriods" :key="`period-${period}`">
|
<template v-for="period in maxPeriods" :key="`period-${period}`">
|
||||||
<div class="period-head" :style="{ gridRow: String(period + 1) }">
|
<div class="period-head" :style="{ gridRow: String(period + 1) }">
|
||||||
<strong>第 {{ period }} 节</strong>
|
<strong>第 {{ period }} 节</strong>
|
||||||
@@ -521,10 +735,10 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<article
|
<article
|
||||||
v-for="entry in timetable.entries"
|
v-for="entry in visibleGridEntries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="course-block"
|
class="course-block"
|
||||||
:style="entryStyle(entry)"
|
:style="gridEntryStyle(entry)"
|
||||||
>
|
>
|
||||||
<strong>{{ entry.courseName }}</strong>
|
<strong>{{ entry.courseName }}</strong>
|
||||||
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
|
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
|
||||||
@@ -532,28 +746,44 @@ onMounted(async () => {
|
|||||||
<small>{{ weeks(entry) }} · 第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节</small>
|
<small>{{ weeks(entry) }} · 第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节</small>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="viewMode === 'day' && timetable?.plan" class="day-view">
|
<div v-else-if="viewMode === 'day' && timetable?.plan" class="day-view">
|
||||||
<header>
|
<header>
|
||||||
<strong>{{ weekdays[selectedDay] }}</strong>
|
<div>
|
||||||
<span>按开始节次排列 · 共 {{ dayEntries.length }} 项安排</span>
|
<strong>{{ weekdays[selectedDay] }} · {{ selectedDayDate }}</strong>
|
||||||
|
<small>{{ selectedWeekLabel }}</small>
|
||||||
|
</div>
|
||||||
|
<span>共 {{ dayEntries.length }} 项安排 · 连续课程按实际节数占格</span>
|
||||||
</header>
|
</header>
|
||||||
<div v-for="period in maxPeriods" :key="`day-${period}`" class="day-period-row">
|
<div
|
||||||
<div class="day-period-label">
|
class="day-grid"
|
||||||
|
:style="{ gridTemplateRows: `repeat(${maxPeriods}, 92px)` }"
|
||||||
|
>
|
||||||
|
<template v-for="period in maxPeriods" :key="`day-${period}`">
|
||||||
|
<div class="day-period-label" :style="{ gridRow: String(period) }">
|
||||||
<strong>第 {{ period }} 节</strong>
|
<strong>第 {{ period }} 节</strong>
|
||||||
<span v-if="slotMap.get(period)">
|
<span v-if="slotMap.get(period)">
|
||||||
{{ formatTime(slotMap.get(period).startsAt) }}—{{ formatTime(slotMap.get(period).endsAt) }}
|
{{ formatTime(slotMap.get(period).startsAt) }}—{{ formatTime(slotMap.get(period).endsAt) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="day-course-list">
|
<div
|
||||||
<article v-for="entry in dayEntriesAt(period)" :key="entry.id">
|
class="day-grid-cell"
|
||||||
<strong>{{ entry.courseName }}</strong>
|
:style="{ gridColumn: '2', gridRow: String(period) }"
|
||||||
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
|
/>
|
||||||
<span>{{ location(entry) }}</span>
|
</template>
|
||||||
<small>{{ weeks(entry) }} · 连续 {{ entry.periodCount }} 节</small>
|
<article
|
||||||
</article>
|
v-for="entry in dayEntries"
|
||||||
<span v-if="!dayEntriesAt(period).length" class="day-empty">无课程安排</span>
|
:key="entry.id"
|
||||||
</div>
|
class="day-course-block"
|
||||||
|
:style="dayEntryStyle(entry)"
|
||||||
|
>
|
||||||
|
<strong>{{ entry.courseName }}</strong>
|
||||||
|
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
|
||||||
|
<span>{{ location(entry) }}</span>
|
||||||
|
<small>{{ weeks(entry) }} · 第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节</small>
|
||||||
|
</article>
|
||||||
|
<span v-if="!dayEntries.length" class="day-no-courses">当日无课程安排</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty
|
<el-empty
|
||||||
@@ -585,6 +815,8 @@ onMounted(async () => {
|
|||||||
.timetable-toolbar { margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
.timetable-toolbar { margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
.timetable-toolbar > div { display: flex; align-items: center; gap: 10px; }
|
.timetable-toolbar > div { display: flex; align-items: center; gap: 10px; }
|
||||||
.timetable-toolbar .el-select { width: 120px; }
|
.timetable-toolbar .el-select { width: 120px; }
|
||||||
|
.timetable-toolbar .week-switcher { display: flex; align-items: center; gap: 6px; padding-left: 2px; }
|
||||||
|
.timetable-toolbar .week-switcher .el-select { width: 245px; }
|
||||||
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
|
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
|
||||||
.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; }
|
||||||
@@ -603,9 +835,16 @@ onMounted(async () => {
|
|||||||
.flexible-course-list article p { margin: 0; color: #5e746f; font-size: 12px; }
|
.flexible-course-list article p { margin: 0; color: #5e746f; font-size: 12px; }
|
||||||
.flexible-course-list article small { color: #778b86; font-size: 11px; }
|
.flexible-course-list article small { color: #778b86; font-size: 11px; }
|
||||||
.timetable-scroll { overflow: auto; }
|
.timetable-scroll { overflow: auto; }
|
||||||
|
.timetable-view-section { border: 1px solid #dce4eb; }
|
||||||
|
.view-context { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #dce4eb; background: #f8fafb; }
|
||||||
|
.view-context > div { display: grid; gap: 3px; }
|
||||||
|
.view-context strong { color: #17324d; font-size: 14px; }
|
||||||
|
.view-context span, .view-context small { color: #718191; font-size: 11px; }
|
||||||
.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; }
|
||||||
.grid-corner, .day-head { display: grid; place-items: center; font-weight: 700; }
|
.grid-corner, .day-head { display: grid; place-items: center; font-weight: 700; }
|
||||||
|
.day-head { align-content: center; gap: 2px; }
|
||||||
|
.day-head small { color: #7a8996; font-size: 10px; font-weight: 500; }
|
||||||
.grid-corner { grid-column: 1; grid-row: 1; }
|
.grid-corner { grid-column: 1; grid-row: 1; }
|
||||||
.day-head { grid-row: 1; }
|
.day-head { grid-row: 1; }
|
||||||
.day-head:nth-of-type(2) { grid-column: 2; }
|
.day-head:nth-of-type(2) { grid-column: 2; }
|
||||||
@@ -618,22 +857,25 @@ onMounted(async () => {
|
|||||||
.period-head { grid-column: 1; display: grid; place-content: center; gap: 4px; text-align: center; }
|
.period-head { grid-column: 1; display: grid; place-content: center; gap: 4px; text-align: center; }
|
||||||
.period-head span { color: #7c8b98; font-size: 11px; }
|
.period-head span { color: #7c8b98; font-size: 11px; }
|
||||||
.grid-cell { border: 1px solid #e4e9ee; background: #fff; }
|
.grid-cell { border: 1px solid #e4e9ee; background: #fff; }
|
||||||
.course-block { z-index: 3; margin: 4px; padding: 9px 10px; overflow: hidden; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #24475a; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
|
.course-block { z-index: 3; margin: 4px; padding: 9px 10px; overflow: hidden; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #24475a; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
|
||||||
.course-block strong { color: #123a4b; font-size: 14px; }
|
.course-block strong { color: #123a4b; font-size: 14px; }
|
||||||
.course-block span { font-size: 12px; }
|
.course-block span { font-size: 12px; }
|
||||||
.course-block small { margin-top: auto; color: #5f7885; font-size: 11px; }
|
.course-block small { margin-top: auto; color: #5f7885; font-size: 11px; }
|
||||||
.day-view { border: 1px solid #dce4eb; }
|
.day-view { border: 1px solid #dce4eb; }
|
||||||
.day-view > header { padding: 14px 16px; display: flex; align-items: baseline; justify-content: space-between; background: #173e72; color: #fff; }
|
.day-view > header { padding: 14px 16px; display: flex; align-items: baseline; justify-content: space-between; background: #173e72; color: #fff; }
|
||||||
|
.day-view > header > div { display: grid; gap: 3px; }
|
||||||
.day-view > header strong { font-size: 18px; }
|
.day-view > header strong { font-size: 18px; }
|
||||||
|
.day-view > header small { color: #bed1e4; font-size: 11px; }
|
||||||
.day-view > header span { color: #dce8f3; font-size: 12px; }
|
.day-view > header span { color: #dce8f3; font-size: 12px; }
|
||||||
.day-period-row { min-height: 88px; display: grid; grid-template-columns: 120px minmax(0, 1fr); border-top: 1px solid #e1e7eb; }
|
.day-grid { display: grid; grid-template-columns: 120px minmax(0, 1fr); position: relative; }
|
||||||
.day-period-label { padding: 12px; display: grid; place-content: center; gap: 4px; text-align: center; background: #f2f5f7; color: #43586a; }
|
.day-period-label { z-index: 2; grid-column: 1; padding: 12px; display: grid; place-content: center; gap: 4px; border-top: 1px solid #e1e7eb; text-align: center; background: #f2f5f7; color: #43586a; }
|
||||||
.day-period-label span { color: #7b8b98; font-size: 11px; }
|
.day-period-label span { color: #7b8b98; font-size: 11px; }
|
||||||
.day-course-list { padding: 9px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; align-items: stretch; }
|
.day-grid-cell { border-top: 1px solid #e1e7eb; background: #fff; }
|
||||||
.day-course-list article { padding: 10px 12px; display: grid; gap: 3px; border-left: 4px solid #176b87; background: #e9f3f5; color: #395d6c; }
|
.day-course-block { z-index: 3; margin: 4px; padding: 10px 12px; overflow: hidden; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #395d6c; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
|
||||||
.day-course-list article strong { color: #123a4b; }
|
.day-course-block strong { color: #123a4b; }
|
||||||
.day-course-list article span, .day-course-list article small { font-size: 11px; }
|
.day-course-block span, .day-course-block small { font-size: 11px; }
|
||||||
.day-empty { align-self: center; color: #9aa7b1; font-size: 12px; }
|
.day-course-block small { margin-top: auto; color: #5f7885; }
|
||||||
|
.day-no-courses { z-index: 3; grid-column: 2; grid-row: 1; align-self: center; justify-self: center; color: #9aa7b1; font-size: 12px; }
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.resource-filter-panel { grid-template-columns: 1fr; }
|
.resource-filter-panel { grid-template-columns: 1fr; }
|
||||||
.hierarchy-filters { grid-template-columns: repeat(2, minmax(150px, 1fr)); }
|
.hierarchy-filters { grid-template-columns: repeat(2, minmax(150px, 1fr)); }
|
||||||
@@ -647,8 +889,10 @@ onMounted(async () => {
|
|||||||
.hierarchy-filters, .hierarchy-filters.compact { grid-template-columns: 1fr; }
|
.hierarchy-filters, .hierarchy-filters.compact { grid-template-columns: 1fr; }
|
||||||
.timetable-toolbar { align-items: stretch; flex-direction: column; }
|
.timetable-toolbar { align-items: stretch; flex-direction: column; }
|
||||||
.timetable-toolbar > div { flex-wrap: wrap; }
|
.timetable-toolbar > div { flex-wrap: wrap; }
|
||||||
|
.timetable-toolbar .week-switcher { width: 100%; padding-left: 0; }
|
||||||
|
.timetable-toolbar .week-switcher .el-select { min-width: 190px; flex: 1; }
|
||||||
.timetable-sheet { padding: 12px; }
|
.timetable-sheet { padding: 12px; }
|
||||||
.day-period-row { grid-template-columns: 90px minmax(0, 1fr); }
|
.view-context, .day-view > header { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||||
.day-course-list { grid-template-columns: 1fr; }
|
.day-grid { grid-template-columns: 90px minmax(0, 1fr); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user