From 97aa23bc6496049a56270d36089d961d91daccf3 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 9 Aug 2026 19:44:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E5=AD=A6=E6=9C=9F=E3=80=81=E5=90=8C?= =?UTF-8?q?=E8=AF=BE=E7=A8=8B=E3=80=81=E5=90=8C=E6=95=99=E5=AD=A6=E7=8F=AD?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E9=AA=8C=E9=A1=B9=E7=9B=AE=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=EF=BC=8C=E7=BB=84=E5=86=85=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E6=8E=92=E5=88=97=E3=80=82=20=E7=AE=A1=E7=90=86=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=B9=E4=B8=BA=E6=95=B0=E6=8D=AE=E5=BA=93=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E3=80=81=E6=8C=89=E6=95=99=E5=AD=A6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86=E9=A1=B5=EF=BC=8810?= =?UTF-8?q?=E2=80=93100=20=E6=9D=A1/=E9=A1=B5=EF=BC=89=E3=80=82=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=BC=80=E8=AF=BE=E5=AD=A6=E9=99=A2=E3=80=81?= =?UTF-8?q?=E8=AF=BE=E7=A8=8B=E5=90=8D=E7=A7=B0/=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E3=80=81=E6=95=99=E5=AD=A6=E7=8F=AD=E3=80=81=E4=BB=BB=E8=AF=BE?= =?UTF-8?q?=E6=95=99=E5=B8=88=E3=80=81=E6=95=99=E5=AD=A6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=8F=B7=E7=AD=9B=E9=80=89=E3=80=82=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E9=A1=B5=E8=8D=89=E7=A8=BF=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=9A=84=E6=89=B9=E9=87=8F=E9=80=89=E4=B8=AD=E3=80=81=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=8F=91=E5=B8=83=E3=80=81=E6=89=B9=E9=87=8F=E5=88=A0?= =?UTF-8?q?=E9=99=A4=EF=BC=9B=E5=90=8E=E7=AB=AF=E4=BC=9A=E5=86=8D=E6=AC=A1?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E6=9D=83=E9=99=90=E3=80=81=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=92=8C=E5=8F=91=E5=B8=83=E6=9D=A1=E4=BB=B6=E3=80=82=20?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E6=A1=86=E6=94=B9=E4=B8=BA=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=B1=85=E4=B8=AD=E7=99=BD=E8=89=B2=E5=BA=95=E6=9D=BF=E4=B8=8E?= =?UTF-8?q?=E9=98=B4=E5=BD=B1=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E6=96=87=E5=AD=97=E6=8C=89=E9=92=AE=E3=80=81?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E5=BA=95=E8=89=B2=E7=9A=84=E6=83=85=E5=86=B5?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ExperimentsController.cs | 307 ++++++++++++++--- .../Domain/Academic/ExperimentEntities.cs | 2 + .../Persistence/AppDbContext.cs | 11 +- ...wAllScheduledExperimentLessons.Designer.cs | 19 ++ ...2000_AllowAllScheduledExperimentLessons.cs | 45 +++ ...alizedExperimentProjectsByWeek.Designer.cs | 19 ++ ...plitCentralizedExperimentProjectsByWeek.cs | 78 +++++ .../MySql/AppDbContextModelSnapshot.cs | 5 +- .../ExperimentsControllerTests.cs | 47 +++ versions.props | 6 +- web/src/style.css | 23 ++ web/src/views/ExperimentsView.vue | 309 ++++++++++++++++-- 12 files changed, 793 insertions(+), 78 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809112000_AllowAllScheduledExperimentLessons.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809112000_AllowAllScheduledExperimentLessons.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809114000_SplitCentralizedExperimentProjectsByWeek.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809114000_SplitCentralizedExperimentProjectsByWeek.cs diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index c852b81..01efcde 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -31,12 +31,21 @@ public sealed class ExperimentsController( [Authorize(Roles = Managers)] public async Task GetOptions( Guid? academicTermId, + Guid? offeringCollegeId, + string? courseKeyword, CancellationToken cancellationToken) { var tasks = AccessibleTeachingTasks().AsNoTracking() .Where(x => x.Status == TeachingTaskStatus.Published); if (academicTermId.HasValue) tasks = tasks.Where(x => x.AcademicTermId == academicTermId); + if (offeringCollegeId.HasValue) + tasks = tasks.Where(x => x.Course!.CollegeId == offeringCollegeId); + var normalizedCourseKeyword = Normalize(courseKeyword); + if (normalizedCourseKeyword is not null) + tasks = tasks.Where(x => + x.Course!.Code.Contains(normalizedCourseKeyword) || + x.Course.Name.Contains(normalizedCourseKeyword)); var periods = db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.IsEnabled); @@ -90,6 +99,13 @@ public sealed class ExperimentsController( .OrderBy(item => item.AdministrativeClass!.Code) .Select(item => item.AdministrativeClass!.Name) }) + .Take(200) + .ToListAsync(cancellationToken), + Colleges = await AccessibleTeachingTasks().AsNoTracking() + .Where(x => !academicTermId.HasValue || x.AcademicTermId == academicTermId.Value) + .Select(x => new { x.Course!.CollegeId, CollegeName = x.Course.College!.Name }) + .Distinct() + .OrderBy(x => x.CollegeName) .ToListAsync(cancellationToken), ScheduleEntries = await db.ScheduleEntries.AsNoTracking() .Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published && @@ -99,6 +115,11 @@ public sealed class ExperimentsController( .Contains(x.TeachingTaskId)) .Where(x => !academicTermId.HasValue || x.SchedulePlan!.AcademicTermId == academicTermId.Value) + .Where(x => !offeringCollegeId.HasValue || + x.TeachingTask!.Course!.CollegeId == offeringCollegeId.Value) + .Where(x => normalizedCourseKeyword == null || + x.TeachingTask!.Course!.Code.Contains(normalizedCourseKeyword) || + x.TeachingTask.Course.Name.Contains(normalizedCourseKeyword)) .OrderBy(x => x.TeachingTask!.Course!.Code) .ThenBy(x => x.TeachingTask!.TaskNumber) .ThenBy(x => x.DayOfWeek) @@ -116,10 +137,14 @@ public sealed class ExperimentsController( x.StartWeek, x.EndWeek, x.WeekPattern, + ClassNames = x.TeachingTask!.Classes + .OrderBy(item => item.AdministrativeClass!.Code) + .Select(item => item.AdministrativeClass!.Name), ClassroomName = x.Classroom!.Name, BuildingName = x.Classroom.Building!.Name, CampusName = x.Classroom.Building.Campus!.Name }) + .Take(500) .ToListAsync(cancellationToken), Classrooms = await db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled) @@ -146,7 +171,14 @@ public sealed class ExperimentsController( Guid? academicTermId, ExperimentArrangementMode? arrangementMode, ExperimentProjectStatus? status, - CancellationToken cancellationToken) + Guid? offeringCollegeId, + string? courseKeyword, + string? classKeyword, + string? teacherKeyword, + string? taskKeyword, + CancellationToken cancellationToken, + int page = 1, + int pageSize = 20) { var source = ScopedProjects().AsNoTracking(); if (academicTermId.HasValue) @@ -158,7 +190,47 @@ public sealed class ExperimentsController( if (status.HasValue) source = source.Where(x => x.Status == status); - return Ok(await source + var normalizedCourseKeyword = Normalize(courseKeyword); + var normalizedClassKeyword = Normalize(classKeyword); + var normalizedTeacherKeyword = Normalize(teacherKeyword); + var normalizedTaskKeyword = Normalize(taskKeyword); + var taskSource = AccessibleTeachingTasks().AsNoTracking() + .Where(task => source.Select(project => project.TeachingTaskId).Contains(task.Id)); + if (academicTermId.HasValue) + taskSource = taskSource.Where(x => x.AcademicTermId == academicTermId); + if (offeringCollegeId.HasValue) + taskSource = taskSource.Where(x => x.Course!.CollegeId == offeringCollegeId); + if (normalizedCourseKeyword is not null) + taskSource = taskSource.Where(x => + x.Course!.Code.Contains(normalizedCourseKeyword) || + x.Course.Name.Contains(normalizedCourseKeyword)); + if (normalizedClassKeyword is not null) + taskSource = taskSource.Where(x => x.Classes.Any(item => + item.AdministrativeClass!.Code.Contains(normalizedClassKeyword) || + item.AdministrativeClass.Name.Contains(normalizedClassKeyword))); + if (normalizedTeacherKeyword is not null) + taskSource = taskSource.Where(x => x.Teachers.Any(item => + item.Teacher!.TeacherNumber.Contains(normalizedTeacherKeyword) || + item.Teacher.Name.Contains(normalizedTeacherKeyword))); + if (normalizedTaskKeyword is not null) + taskSource = taskSource.Where(x => + x.TaskNumber.Contains(normalizedTaskKeyword) || + x.Name.Contains(normalizedTaskKeyword)); + + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 10, 100); + var total = await taskSource.CountAsync(cancellationToken); + var taskIds = await taskSource + .OrderByDescending(x => x.AcademicTerm!.StartDate) + .ThenBy(x => x.Course!.Code) + .ThenBy(x => x.TaskNumber) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + var items = await source + .Where(x => taskIds.Contains(x.TeachingTaskId)) .OrderByDescending(x => x.Status == ExperimentProjectStatus.Published) .ThenBy(x => x.StartDate) .ThenBy(x => x.Code) @@ -167,6 +239,7 @@ public sealed class ExperimentsController( x.Id, x.TeachingTaskId, x.ScheduleEntryId, + x.ScheduleWeek, x.Code, x.Name, x.ArrangementMode, @@ -197,6 +270,7 @@ public sealed class ExperimentsController( x.ScheduleEntry.StartWeek, x.ScheduleEntry.EndWeek, x.ScheduleEntry.WeekPattern, + ProjectWeek = x.ScheduleWeek, ClassroomName = x.ScheduleEntry.Classroom!.Name, BuildingName = x.ScheduleEntry.Classroom.Building!.Name, CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name @@ -220,7 +294,9 @@ public sealed class ExperimentsController( CampusName = item.Classroom.Building.Campus!.Name }) }) - .ToListAsync(cancellationToken)); + .ToListAsync(cancellationToken); + + return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize }); } [HttpGet("student")] @@ -254,6 +330,7 @@ public sealed class ExperimentsController( x.Name, x.ArrangementMode, x.ScheduleEntryId, + x.ScheduleWeek, x.Description, x.Requirements, x.StartDate, @@ -275,6 +352,7 @@ public sealed class ExperimentsController( x.ScheduleEntry.StartWeek, x.ScheduleEntry.EndWeek, x.ScheduleEntry.WeekPattern, + ProjectWeek = x.ScheduleWeek, ClassroomName = x.ScheduleEntry.Classroom!.Name, BuildingName = x.ScheduleEntry.Classroom.Building!.Name, CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name @@ -363,8 +441,6 @@ public sealed class ExperimentsController( ExperimentProjectBatchRequest request, CancellationToken cancellationToken) { - if (request.ArrangementMode == ExperimentArrangementMode.Centralized) - return ValidationProblem("集中安排的实验项目请逐项绑定已发布课表中的实验课。"); var taskIds = request.TeachingTaskIds .Where(x => x != Guid.Empty) .Distinct() @@ -384,21 +460,72 @@ public sealed class ExperimentsController( return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。"); var first = tasks[0]; - if (tasks.Any(x => + if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x => x.AcademicTermId != first.AcademicTermId || x.CourseId != first.CourseId)) return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。"); var code = request.Code.Trim(); - var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking() - .WhereIn(taskIds, x => x.TeachingTaskId) - .Where(x => x.Code == code) - .Select(x => x.TeachingTask!.TaskNumber) - .OrderBy(x => x) - .ToListAsync(cancellationToken); - if (conflictingTaskNumbers.Count > 0) - return ConflictProblem( - $"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。"); + if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled) + { + var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking() + .WhereIn(taskIds, x => x.TeachingTaskId) + .Where(x => x.Code == code) + .Select(x => x.TeachingTask!.TaskNumber) + .OrderBy(x => x) + .ToListAsync(cancellationToken); + if (conflictingTaskNumbers.Count > 0) + return ConflictProblem( + $"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。"); + } + + var scheduledEntries = request.ArrangementMode == ExperimentArrangementMode.Centralized + ? await db.ScheduleEntries.AsNoTracking() + .Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published && + x.Kind == ScheduleEntryKind.Experiment && + x.ClassroomId.HasValue && + taskIds.Contains(x.TeachingTaskId)) + .OrderBy(x => x.TeachingTaskId) + .ThenBy(x => x.DayOfWeek) + .ThenBy(x => x.StartPeriod) + .ToListAsync(cancellationToken) + : []; + if (request.ArrangementMode == ExperimentArrangementMode.Centralized && + tasks.Any(task => scheduledEntries.All(entry => entry.TeachingTaskId != task.Id))) + return ValidationProblem("所选教学班中包含未排入实验室的已发布实验课,请先完成课表安排。"); + + var scheduledOccurrences = scheduledEntries + .SelectMany(entry => Enumerable.Range( + entry.StartWeek, + entry.EndWeek - entry.StartWeek + 1) + .Where(week => FreeClassroomRules.MatchesWeek(entry.WeekPattern, week)) + .Select(week => (Entry: entry, Week: week))) + .ToList(); + + var legacyProjects = scheduledEntries.Count > 0 + ? await db.ExperimentProjects + .Where(x => x.Code == code && x.ScheduleEntryId.HasValue && + x.ScheduleWeek == null && + taskIds.Contains(x.TeachingTaskId)) + .ToListAsync(cancellationToken) + : []; + if (legacyProjects.Any(x => x.Status != ExperimentProjectStatus.Draft)) + return ConflictProblem("存在旧版已发布实验项目,不能自动拆分为每周项目;请先关闭后重新设置。"); + + if (scheduledOccurrences.Count > 0) + { + var entryIds = scheduledOccurrences.Select(x => x.Entry.Id).Distinct().ToList(); + var existingOccurrences = await db.ExperimentProjects.AsNoTracking() + .Where(x => x.Code == code && x.ScheduleEntryId.HasValue && + x.ScheduleWeek.HasValue && entryIds.Contains(x.ScheduleEntryId.Value)) + .Select(x => new { ScheduleEntryId = x.ScheduleEntryId!.Value, ScheduleWeek = x.ScheduleWeek!.Value }) + .ToListAsync(cancellationToken); + var existingKeys = existingOccurrences + .Select(x => (x.ScheduleEntryId, x.ScheduleWeek)) + .ToHashSet(); + if (scheduledOccurrences.Any(x => existingKeys.Contains((x.Entry.Id, x.Week)))) + return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。"); + } var projects = new List(tasks.Count); foreach (var task in tasks) @@ -407,17 +534,42 @@ public sealed class ExperimentsController( var problem = ValidateProjectRequest(item, task.AcademicTerm!); if (problem is not null) return ValidationProblem(problem); - projects.Add(new ExperimentProject + IEnumerable<(ScheduleEntry? Entry, int? Week)> taskOccurrences = + request.ArrangementMode == ExperimentArrangementMode.Centralized + ? scheduledOccurrences + .Where(item => item.Entry.TeachingTaskId == task.Id) + .Select(item => ((ScheduleEntry?)item.Entry, (int?)item.Week)) + : [(null, null)]; + foreach (var occurrence in taskOccurrences) { - TeachingTaskId = task.Id, - Code = code, - Name = request.Name.Trim(), - ArrangementMode = request.ArrangementMode, - Description = Normalize(request.Description), - Requirements = Normalize(request.Requirements), - StartDate = request.StartDate, - EndDate = request.EndDate - }); + var scheduleEntry = occurrence.Entry; + var legacy = scheduleEntry is null ? null : legacyProjects + .SingleOrDefault(x => x.ScheduleEntryId == scheduleEntry.Id); + if (legacy is not null) + { + var firstWeek = scheduledOccurrences + .Where(item => item.Entry.Id == scheduleEntry!.Id) + .Min(item => item.Week); + if (occurrence.Week == firstWeek) + { + legacy.ScheduleWeek = occurrence.Week; + continue; + } + } + projects.Add(new ExperimentProject + { + TeachingTaskId = task.Id, + ScheduleEntryId = occurrence.Entry?.Id, + ScheduleWeek = occurrence.Week, + Code = code, + Name = request.Name.Trim(), + ArrangementMode = request.ArrangementMode, + Description = Normalize(request.Description), + Requirements = Normalize(request.Requirements), + StartDate = request.StartDate, + EndDate = request.EndDate + }); + } } db.ExperimentProjects.AddRange(projects); @@ -491,6 +643,26 @@ public sealed class ExperimentsController( return NoContent(); } + [HttpDelete("batch")] + [Authorize(Roles = Managers)] + public async Task DeleteProjects( + ExperimentProjectBulkRequest request, + CancellationToken cancellationToken) + { + var ids = ValidateBulkProjectIds(request.ProjectIds); + if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。"); + var projects = await ScopedProjects() + .Where(x => ids.Contains(x.Id)) + .ToListAsync(cancellationToken); + if (projects.Count != ids.Count) return NotFound(); + if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft)) + return ConflictProblem("批量删除只能包含草稿实验项目。"); + + db.ExperimentProjects.RemoveRange(projects); + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + [HttpPost("{id:guid}/publish")] [Authorize(Roles = Managers)] public async Task PublishProject( @@ -522,28 +694,49 @@ public sealed class ExperimentsController( project.Status = ExperimentProjectStatus.Published; project.PublishedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); + await NotifyProjectPublishedAsync(project, cancellationToken); + return NoContent(); + } - var userIds = await TeachingTaskRosterQuery - .ForTask(db, project.TeachingTaskId) - .Where(x => x.UserId.HasValue) - .Select(x => x.UserId!.Value) - .Distinct() + [HttpPost("batch/publish")] + [Authorize(Roles = Managers)] + public async Task PublishProjects( + ExperimentProjectBulkRequest request, + CancellationToken cancellationToken) + { + var ids = ValidateBulkProjectIds(request.ProjectIds); + if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。"); + var projects = await ScopedProjects() + .Include(x => x.Sessions) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Course) + .Where(x => ids.Contains(x.Id)) .ToListAsync(cancellationToken); - if (userIds.Count > 0) + if (projects.Count != ids.Count) return NotFound(); + + foreach (var project in projects) { - var mode = project.ArrangementMode == - ExperimentArrangementMode.Centralized - ? "集中安排" - : "自行预约"; - await NotificationService.SendToUserIdsAsync( - db, - userIds, - "实验项目已发布", - $"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。", - "/experiments", - cancellationToken, - NotificationCategory.Schedule); + if (project.Status != ExperimentProjectStatus.Draft) + return ConflictProblem("批量发布只能包含草稿实验项目。"); + var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized + ? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled) + : project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled); + if (!hasSchedule) + return ConflictProblem($"“{project.Name}”尚未具备发布条件。"); + if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled && + (x.SessionDate < project.StartDate || x.SessionDate > project.EndDate))) + return ConflictProblem($"“{project.Name}”存在不在开放日期范围内的实验场次。"); } + + var publishedAt = DateTime.UtcNow; + foreach (var project in projects) + { + project.Status = ExperimentProjectStatus.Published; + project.PublishedAt = publishedAt; + } + await db.SaveChangesAsync(cancellationToken); + foreach (var project in projects) + await NotifyProjectPublishedAsync(project, cancellationToken); return NoContent(); } @@ -1234,6 +1427,31 @@ public sealed class ExperimentsController( .Distinct() .ToListAsync(cancellationToken); + private async Task NotifyProjectPublishedAsync( + ExperimentProject project, + CancellationToken cancellationToken) + { + var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken); + if (userIds.Count == 0) return; + var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized + ? "集中安排" + : "自行预约"; + await NotificationService.SendToUserIdsAsync( + db, + userIds, + "实验项目已发布", + $"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。", + "/experiments", + cancellationToken, + NotificationCategory.Schedule); + } + + private static List? ValidateBulkProjectIds(IReadOnlyList? projectIds) + { + var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList(); + return ids is { Count: > 0 and <= 100 } ? ids : null; + } + private static string? ValidateProjectRequest( ExperimentProjectRequest request, AcademicTerm term) @@ -1321,6 +1539,9 @@ public sealed record ExperimentProjectBatchRequest( EndDate); } +public sealed record ExperimentProjectBulkRequest( + [Required] IReadOnlyList ProjectIds); + public sealed record ExperimentSessionRequest( Guid ClassroomId, DateOnly SessionDate, diff --git a/src/Jiaowu.Api/Domain/Academic/ExperimentEntities.cs b/src/Jiaowu.Api/Domain/Academic/ExperimentEntities.cs index 77956a8..ab4a087 100644 --- a/src/Jiaowu.Api/Domain/Academic/ExperimentEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/ExperimentEntities.cs @@ -9,6 +9,8 @@ public sealed class ExperimentProject : EntityBase // 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。 public Guid? ScheduleEntryId { get; set; } public ScheduleEntry? ScheduleEntry { get; set; } + // 集中安排按课表的具体周次拆分为实验项目;自行安排为空。 + public int? ScheduleWeek { get; set; } public required string Code { get; set; } public required string Name { get; set; } public ExperimentArrangementMode ArrangementMode { get; set; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index b3796ee..5b6418b 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -678,7 +678,16 @@ public sealed class AppDbContext(DbContextOptions options) entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Description).HasMaxLength(1000); entity.Property(x => x.Requirements).HasMaxLength(1000); - entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique(); + // 集中安排会为同一教学任务的每一条实验课表记录生成项目; + // 自行安排仍由控制器保持“教学任务 + 编码”唯一。 + entity.HasIndex(x => new + { + x.TeachingTaskId, + x.Code, + x.ScheduleEntryId, + x.ScheduleWeek + }) + .IsUnique(); entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate }); entity.HasOne(x => x.TeachingTask).WithMany() .HasForeignKey(x => x.TeachingTaskId) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809112000_AllowAllScheduledExperimentLessons.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809112000_AllowAllScheduledExperimentLessons.Designer.cs new file mode 100644 index 0000000..8e44dee --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809112000_AllowAllScheduledExperimentLessons.Designer.cs @@ -0,0 +1,19 @@ +// +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +[DbContext(typeof(AppDbContext))] +[Migration("20260809112000_AllowAllScheduledExperimentLessons")] +partial class AllowAllScheduledExperimentLessons +{ + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809112000_AllowAllScheduledExperimentLessons.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809112000_AllowAllScheduledExperimentLessons.cs new file mode 100644 index 0000000..205a0bf --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809112000_AllowAllScheduledExperimentLessons.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +public partial class AllowAllScheduledExperimentLessons : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + // MySQL 可能将旧唯一索引用于外键支撑。先提供同列的普通索引, + // 再替换业务唯一索引,避免线上迁移因外键依赖而中断。 + migrationBuilder.CreateIndex( + name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport", + table: "ExperimentProjects", + columns: new[] { "TeachingTaskId", "Code" }); + + migrationBuilder.DropIndex( + name: "IX_ExperimentProjects_TeachingTaskId_Code", + table: "ExperimentProjects"); + + migrationBuilder.CreateIndex( + name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId", + table: "ExperimentProjects", + columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId", + table: "ExperimentProjects"); + + migrationBuilder.CreateIndex( + name: "IX_ExperimentProjects_TeachingTaskId_Code", + table: "ExperimentProjects", + columns: new[] { "TeachingTaskId", "Code" }, + unique: true); + + migrationBuilder.DropIndex( + name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport", + table: "ExperimentProjects"); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809114000_SplitCentralizedExperimentProjectsByWeek.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809114000_SplitCentralizedExperimentProjectsByWeek.Designer.cs new file mode 100644 index 0000000..ae21181 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809114000_SplitCentralizedExperimentProjectsByWeek.Designer.cs @@ -0,0 +1,19 @@ +// +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +[DbContext(typeof(AppDbContext))] +[Migration("20260809114000_SplitCentralizedExperimentProjectsByWeek")] +partial class SplitCentralizedExperimentProjectsByWeek +{ + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809114000_SplitCentralizedExperimentProjectsByWeek.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809114000_SplitCentralizedExperimentProjectsByWeek.cs new file mode 100644 index 0000000..07443ab --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809114000_SplitCentralizedExperimentProjectsByWeek.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +public partial class SplitCentralizedExperimentProjectsByWeek : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + ExecuteWhenMissing( + migrationBuilder, + "COLUMNS", + "COLUMN_NAME = 'ScheduleWeek'", + "ALTER TABLE `ExperimentProjects` ADD COLUMN `ScheduleWeek` int NULL"); + + ExecuteWhenPresent( + migrationBuilder, + "STATISTICS", + "INDEX_NAME = 'IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId'", + "DROP INDEX `IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId` ON `ExperimentProjects`"); + + ExecuteWhenMissing( + migrationBuilder, + "STATISTICS", + "INDEX_NAME = 'IX_ExpProj_Task_Code_Entry_Week'", + "CREATE UNIQUE INDEX `IX_ExpProj_Task_Code_Entry_Week` ON `ExperimentProjects` (`TeachingTaskId`, `Code`, `ScheduleEntryId`, `ScheduleWeek`)"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ExpProj_Task_Code_Entry_Week", + table: "ExperimentProjects"); + + migrationBuilder.CreateIndex( + name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId", + table: "ExperimentProjects", + columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" }, + unique: true); + + migrationBuilder.DropColumn( + name: "ScheduleWeek", + table: "ExperimentProjects"); + } + + private static void ExecuteWhenMissing( + MigrationBuilder migrationBuilder, + string informationSchemaTable, + string condition, + string command) + { + ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "= 0"); + } + + private static void ExecuteWhenPresent( + MigrationBuilder migrationBuilder, + string informationSchemaTable, + string condition, + string command) + { + ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "> 0"); + } + + private static void ExecuteConditionally( + MigrationBuilder migrationBuilder, + string informationSchemaTable, + string condition, + string command, + string comparison) + { + migrationBuilder.Sql($"SET @jiaowu_exists = (SELECT COUNT(*) FROM `information_schema`.`{informationSchemaTable}` WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'ExperimentProjects' AND {condition})"); + migrationBuilder.Sql($"SET @jiaowu_sql = IF(@jiaowu_exists {comparison}, '{command}', 'SELECT 1')"); + migrationBuilder.Sql("PREPARE jiaowu_migration_statement FROM @jiaowu_sql"); + migrationBuilder.Sql("EXECUTE jiaowu_migration_statement"); + migrationBuilder.Sql("DEALLOCATE PREPARE jiaowu_migration_statement"); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index b4c90db..213b7e0 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -2432,6 +2432,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Property("ScheduleEntryId") .HasColumnType("char(36)"); + b.Property("ScheduleWeek") + .HasColumnType("int"); + b.Property("StartDate") .HasColumnType("date"); @@ -2448,7 +2451,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("ScheduleEntryId"); - b.HasIndex("TeachingTaskId", "Code") + b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek") .IsUnique(); b.HasIndex("Status", "StartDate", "EndDate"); diff --git a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs index 06ca6bb..bdc9d59 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs @@ -37,6 +37,53 @@ public sealed class ExperimentsControllerTests }); } + [Fact] + public async Task CentralizedBatchProjects_CreatesOneProjectForEveryScheduledExperimentLesson() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + fixture.Db.ScheduleEntries.Add(new ScheduleEntry + { + SchedulePlanId = fixture.ScheduleEntry.SchedulePlanId, + TeachingTaskId = fixture.Task.Id, + Kind = ScheduleEntryKind.Experiment, + ClassroomId = fixture.SecondClassroom.Id, + DayOfWeek = 3, + StartPeriod = 5, + PeriodCount = 2, + StartWeek = 1, + EndWeek = 8, + WeekPattern = WeekPattern.All + }); + await fixture.Db.SaveChangesAsync(); + + var result = await fixture.Controller(fixture.ManagerScope).CreateProjects( + new ExperimentProjectBatchRequest( + [fixture.Task.Id], + "LAB-ALL", + "全部课表实验", + ExperimentArrangementMode.Centralized, + null, + null, + fixture.Term.StartDate, + fixture.Term.StartDate.AddDays(14)), + CancellationToken.None); + + Assert.IsType(result); + var projects = await fixture.Db.ExperimentProjects + .Where(x => x.Code == "LAB-ALL") + .OrderBy(x => x.ScheduleEntryId) + .ToListAsync(); + Assert.Equal(16, projects.Count); + Assert.All(projects, project => + { + Assert.Equal(fixture.Task.Id, project.TeachingTaskId); + Assert.Equal(ExperimentArrangementMode.Centralized, project.ArrangementMode); + Assert.NotNull(project.ScheduleEntryId); + }); + Assert.Equal(2, projects.Select(x => x.ScheduleEntryId).Distinct().Count()); + Assert.Equal(8, projects.Select(x => x.ScheduleWeek).Distinct().Count()); + } + [Fact] public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts() { diff --git a/versions.props b/versions.props index 54678bf..169df16 100644 --- a/versions.props +++ b/versions.props @@ -1,7 +1,7 @@ - 2.3.2-beta.4 - 2.3.2-beta.4 - 2.3.2-beta.4 + 2.3.2-beta.5 + 2.3.2-beta.5 + 2.3.2-beta.5 diff --git a/web/src/style.css b/web/src/style.css index 6ce51c4..0438f57 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -343,6 +343,29 @@ button { cursor: pointer; } .form-grid.compact { align-items: center; } .form-grid.three { grid-template-columns: repeat(3, 1fr); } .visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +/* MessageBox 的定位与底色不能依赖其伪元素:部分浏览器在遮罩层中会将其压到左上角。 */ +.el-overlay.is-message-box { + display: flex !important; + align-items: center; + justify-content: center; + padding: 16px; +} +.el-overlay.is-message-box .el-overlay-message-box { + display: block !important; + width: min(420px, calc(100vw - 32px)); + min-height: 0 !important; + margin: 0 !important; + padding: 0 !important; +} +.el-overlay.is-message-box .el-overlay-message-box::after { display: none !important; } +.el-overlay.is-message-box .el-message-box { + width: 100%; + margin: 0 !important; + color: var(--ink); + background: #fff !important; + box-shadow: 0 18px 48px rgba(18, 37, 63, .28); +} .password-reset-form { margin-top: 18px; } .registry-switch { display: grid; grid-template-columns: 1fr 1fr 180px; min-height: 112px; border: 1px solid var(--line); background: white; } diff --git a/web/src/views/ExperimentsView.vue b/web/src/views/ExperimentsView.vue index 7e6321a..f2404ae 100644 --- a/web/src/views/ExperimentsView.vue +++ b/web/src/views/ExperimentsView.vue @@ -16,14 +16,24 @@ const loading = ref(false) const terms = ref([]) const termId = ref('') const projects = ref([]) +const total = ref(0) +const page = ref(1) +const pageSize = ref(20) +const selectedProjectIds = ref([]) const options = reactive({ tasks: [] as any[], + colleges: [] as any[], scheduleEntries: [] as any[], classrooms: [] as any[], periods: [] as any[], }) const modeFilter = ref('') const statusFilter = ref('') +const offeringCollegeId = ref('') +const courseKeyword = ref('') +const classKeyword = ref('') +const teacherKeyword = ref('') +const taskKeyword = ref('') const projectDialog = ref(false) const editingProjectId = ref('') @@ -68,6 +78,51 @@ const batchProjectOptions = computed(() => projects.value.filter((project) => project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'), ) +const taskGroups = computed(() => { + const groups = new Map() + options.tasks.forEach((task) => { + const key = `${task.academicTermId}-${task.courseId}` + const group = groups.get(key) ?? { + key, + termName: task.termName, + courseCode: task.courseCode, + courseName: task.courseName, + tasks: [], + } + group.tasks.push(task) + groups.set(key, group) + }) + return [...groups.values()] +}) +const projectGroups = computed(() => { + const groups = new Map() + projects.value.forEach((project) => { + const classKey = [...(project.classNames ?? [])].sort().join('|') + const key = `${project.academicTermId}-${project.courseCode}-${classKey}` + const group = groups.get(key) ?? { + key, + termName: project.termName, + courseCode: project.courseCode, + courseName: project.courseName, + classNames: project.classNames, + teacherNames: project.teacherNames, + taskNumbers: [], + projects: [], + } + if (!group.taskNumbers.includes(project.taskNumber)) group.taskNumbers.push(project.taskNumber) + group.projects.push(project) + groups.set(key, group) + }) + return [...groups.values()] +}) +const draftSelectedProjectIds = computed(() => + selectedProjectIds.value.filter((id) => + projects.value.some((project) => project.id === id && project.status === 'Draft')), +) +const allPageDraftSelected = computed(() => { + const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id) + return ids.length > 0 && ids.every((id) => selectedProjectIds.value.includes(id)) +}) const activePeriods = computed(() => options.periods.filter((period) => !selectedProject.value @@ -119,6 +174,11 @@ function onScheduleEntryChange() { onTaskChange() } +function formatScheduleEntry(entry: any) { + const classes = entry.classNames?.join('、') || '选课学生' + return `${entry.courseCode} · ${entry.courseName} · ${entry.taskNumber} · ${classes} · 星期 ${entry.dayOfWeek} 第 ${entry.startPeriod}–${entry.startPeriod + entry.periodCount - 1} 节 · ${entry.campusName} ${entry.buildingName} ${entry.classroomName}` +} + function openCreateProject() { resetProjectForm() projectDialog.value = true @@ -141,10 +201,10 @@ function openEditProject(project: any) { async function saveProject() { if (!projectForm.teachingTaskIds.length || - (projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) || + (editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) || !projectForm.code.trim() || !projectForm.name.trim() || projectForm.dates.length !== 2) { - ElMessage.warning(projectForm.arrangementMode === 'Centralized' + ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value ? '请选择课表实验课,并填写项目编码、名称和开放日期' : '请填写教学任务、项目编码、名称和开放日期') return @@ -164,16 +224,15 @@ async function saveProject() { if (editingProjectId.value) { await http.put(`/experiments/${editingProjectId.value}`, payload) ElMessage.success('实验项目已更新') - } else if (projectForm.arrangementMode === 'Centralized') { - await http.post('/experiments', payload) - ElMessage.success('实验项目已绑定课表实验课') } else { await http.post('/experiments/batch', { ...payload, teachingTaskId: undefined, teachingTaskIds: projectForm.teachingTaskIds, }) - ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`) + ElMessage.success(projectForm.arrangementMode === 'Centralized' + ? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目` + : `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`) } projectDialog.value = false await load() @@ -336,6 +395,57 @@ async function deleteProject(project: any) { } } +function togglePageDraftSelection(value: unknown) { + const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id) + selectedProjectIds.value = Boolean(value) + ? [...new Set([...selectedProjectIds.value, ...ids])] + : selectedProjectIds.value.filter((id) => !ids.includes(id)) +} + +function toggleProjectSelection(projectId: string, value: unknown) { + selectedProjectIds.value = Boolean(value) + ? [...new Set([...selectedProjectIds.value, projectId])] + : selectedProjectIds.value.filter((id) => id !== projectId) +} + +async function publishSelectedProjects() { + const ids = draftSelectedProjectIds.value + if (!ids.length) return + try { + await ElMessageBox.confirm( + `将发布选中的 ${ids.length} 个草稿实验项目,并通知对应学生。`, + '批量发布实验项目', + { confirmButtonText: '确认发布', cancelButtonText: '取消', type: 'warning' }, + ) + await http.post('/experiments/batch/publish', { projectIds: ids }) + ElMessage.success(`已发布 ${ids.length} 个实验项目`) + selectedProjectIds.value = [] + await load() + } catch (error: any) { + if (error === 'cancel' || error === 'close') return + ElMessage.error(apiErrorMessage(error)) + } +} + +async function deleteSelectedProjects() { + const ids = draftSelectedProjectIds.value + if (!ids.length) return + try { + await ElMessageBox.confirm( + `将删除选中的 ${ids.length} 个草稿实验项目及其未发布场次。`, + '批量删除实验项目', + { confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' }, + ) + await http.delete('/experiments/batch', { data: { projectIds: ids } }) + ElMessage.success(`已删除 ${ids.length} 个实验项目`) + selectedProjectIds.value = [] + await load() + } catch (error: any) { + if (error === 'cancel' || error === 'close') return + ElMessage.error(apiErrorMessage(error)) + } +} + async function cancelSession(project: any, session: any) { try { await ElMessageBox.confirm( @@ -424,14 +534,20 @@ async function loadOptions() { if (isStudent.value) return try { const { data } = await http.get('/experiments/options', { - params: { academicTermId: termId.value || undefined }, + params: { + academicTermId: termId.value || undefined, + offeringCollegeId: offeringCollegeId.value || undefined, + courseKeyword: courseKeyword.value.trim() || undefined, + }, }) options.tasks = data.tasks + options.colleges = data.colleges options.scheduleEntries = data.scheduleEntries options.classrooms = data.classrooms options.periods = data.periods } catch (error) { options.tasks = [] + options.colleges = [] options.classrooms = [] options.periods = [] ElMessage.error(apiErrorMessage(error)) @@ -445,17 +561,28 @@ async function load() { projects.value = (await http.get('/experiments/student', { params: { academicTermId: termId.value || undefined }, })).data + total.value = projects.value.length } else { - projects.value = (await http.get('/experiments/management', { + const { data } = await http.get('/experiments/management', { params: { academicTermId: termId.value || undefined, arrangementMode: modeFilter.value || undefined, status: statusFilter.value || undefined, + offeringCollegeId: offeringCollegeId.value || undefined, + courseKeyword: courseKeyword.value.trim() || undefined, + classKeyword: classKeyword.value.trim() || undefined, + teacherKeyword: teacherKeyword.value.trim() || undefined, + taskKeyword: taskKeyword.value.trim() || undefined, + page: page.value, + pageSize: pageSize.value, }, - })).data + }) + projects.value = data.items + total.value = data.total } } catch (error) { projects.value = [] + total.value = 0 ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false @@ -463,10 +590,30 @@ async function load() { } async function changeTerm() { + page.value = 1 + selectedProjectIds.value = [] await loadOptions() await load() } +async function searchTaskOptions() { + page.value = 1 + selectedProjectIds.value = [] + await loadOptions() + await load() +} + +async function changePage(nextPage: number) { + page.value = nextPage + selectedProjectIds.value = [] + await load() +} + +async function changePageSize(size: number) { + pageSize.value = size + await changePage(1) +} + onMounted(async () => { try { terms.value = (await http.get('/base-data/terms')).data @@ -524,6 +671,13 @@ onMounted(async () => { /> - 共 {{ projects.length }} 个实验项目 + 共 {{ total }} 个教学班课程 + + +
+ + 选中当前页全部草稿 + + 已选 {{ draftSelectedProjectIds.length }} 个草稿项目 + 批量发布 + 批量删除
+
+
+
+ {{ group.termName }} +

{{ group.courseCode }} · {{ group.courseName }}

+
+
+ {{ group.taskNumbers.join('、') }} + {{ group.classNames?.join('、') || '选课学生' }} +
+
+
{{ project.courseCode }} · {{ project.code }}

{{ project.name }}

-

{{ project.courseName }} · {{ project.taskNumber }}

+

{{ project.arrangementMode === 'Centralized' ? '统一到场实验' : '开放预约实验' }}

+ {
课表固定
- 星期 {{ project.scheduleEntry.dayOfWeek }} · 第 {{ project.scheduleEntry.startPeriod }}–{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} 节 - 第 {{ project.scheduleEntry.startWeek }}–{{ project.scheduleEntry.endWeek }} 周 · {{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }} + 第 {{ project.scheduleEntry.projectWeek ?? `${project.scheduleEntry.startWeek}–${project.scheduleEntry.endWeek}` }} 周 · 星期 {{ project.scheduleEntry.dayOfWeek }} · 第 {{ project.scheduleEntry.startPeriod }}–{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} 节 + {{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}
课表已安排
@@ -736,6 +917,8 @@ onMounted(async () => { +
+
{
+ + {
PROJECT规定实验项目
- + - 集中实验复用课表的时间和实验室,不会再生成独立实验场次。 + 显示当前管理范围内全部已发布、已安排实验室的实验课;集中实验复用其时间和实验室,不会再生成独立实验场次。 - + { :key="task.id" :label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`" :value="task.id" - :disabled="!!selectedTask - && (task.academicTermId !== selectedTask.academicTermId - || task.courseId !== selectedTask.courseId)" /> + +
+ + + + + 查询 +
+
+
+ {{ group.termName }} + {{ group.courseCode }} · {{ group.courseName }} +
+
+ + {{ task.taskNumber }} + {{ task.classNames.join('、') || task.name }} + +
+
+ +
- 已选 {{ projectForm.teachingTaskIds.length }} 个;实验编码、名称、内容和开放日期将一次应用到这些教学任务。 + +
@@ -1066,14 +1292,27 @@ onMounted(async () => { .rail-switch i { width: 30px; height: 7px; border: 2px solid #7794a5; border-radius: 999px; background: #fff; } .experiment-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; } .experiment-toolbar .el-select { width: 210px; } +.experiment-toolbar .el-input { width: 172px; } .result-note { margin-left: auto; color: var(--muted); font-size: 12px; } +.experiment-batch-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-height: 46px; margin: -6px 0 16px; padding: 8px 12px; border: 1px solid #cddce3; background: #f7fafb; } +.experiment-batch-bar > span { margin-right: auto; color: #5c7481; font-size: 12px; } +.experiment-pagination { justify-content: flex-end; margin-top: 4px; } .project-list { display: grid; gap: 14px; min-height: 160px; } +.project-group { overflow: hidden; border: 1px solid #cddce3; background: #f6fafb; } +.project-group-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 17px; border-left: 5px solid var(--lab-blue); background: #edf4f6; } +.project-group-head > div:first-child { display: grid; gap: 3px; } +.project-group-head h3 { margin: 0; color: var(--lab-ink); font-size: 15px; } +.project-group-head > div:first-child span { color: #68808d; font: 700 10px/1 Consolas, monospace; letter-spacing: .06em; } +.project-group-task { display: grid; justify-items: end; gap: 3px; color: #5c7481; font-size: 11px; text-align: right; } +.project-group-task b { color: #365364; font-size: 12px; } +.project-group-projects { display: grid; gap: 0; padding: 0; } .project-card { overflow: hidden; - border: 1px solid #dce5ea; - border-top: 4px solid var(--lab-blue); + border: 0; + border-top: 1px solid #dce5ea; + border-left: 4px solid var(--lab-blue); background: #fff; - box-shadow: 0 7px 22px rgb(35 67 85 / 5%); + box-shadow: none; } .project-card.is-flexible { border-top-color: var(--lab-teal); } .project-card.is-closed { opacity: .82; } @@ -1140,6 +1379,16 @@ onMounted(async () => { .student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; } .student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; } .experiment-form { display: grid; gap: 13px; } +.grouped-task-picker { display: grid; gap: 10px; max-height: 310px; overflow: auto; padding: 10px; border: 1px solid #d8e3e8; background: #fff; } +.task-option-filters { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(180px, 1.2fr) auto; gap: 8px; } +.task-picker-course { overflow: hidden; border: 1px solid #e0e8eb; } +.task-picker-course header { display: flex; align-items: baseline; gap: 8px; padding: 8px 10px; background: #f3f7f8; } +.task-picker-course header span { color: #738994; font: 700 10px/1 Consolas, monospace; } +.task-picker-course header b { color: #314f60; font-size: 12px; } +.task-picker-course > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 2px 12px; padding: 8px 10px; } +.task-picker-course :deep(.el-checkbox) { height: auto; margin-right: 0; white-space: normal; } +.task-picker-course :deep(.el-checkbox__label) { display: inline-flex; gap: 6px; min-width: 0; color: #536b78; font-size: 12px; } +.task-picker-course :deep(.el-checkbox__label b) { flex: 0 0 auto; color: #284757; } .form-help { display: block; margin-top: 7px; color: var(--muted); line-height: 1.5; } .form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; } .form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }