diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/PersonalCalendarService.cs b/src/Jiaowu.Api/Infrastructure/Timetables/PersonalCalendarService.cs index fa5b8a3..900c5f3 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/PersonalCalendarService.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/PersonalCalendarService.cs @@ -115,6 +115,7 @@ public sealed class PersonalCalendarService( AddScheduledCourses(builder, timetable); AddExamEntries(builder, timetable, teacher is not null); + AddExperimentEntries(builder, timetable); AddFlexibleCourseReminders(builder, timetable); } @@ -246,6 +247,46 @@ public sealed class PersonalCalendarService( } } + private static void AddExperimentEntries( + IcsBuilder builder, + TimetableData timetable) + { + var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber); + foreach (var entry in timetable.ExperimentEntries) + { + if (entry.ExperimentDate is not DateOnly experimentDate || + !slots.TryGetValue(entry.StartPeriod, out var startSlot) || + !slots.TryGetValue( + entry.StartPeriod + entry.PeriodCount - 1, + out var endSlot)) + continue; + + var arrangement = entry.ExperimentArrangementMode switch + { + ExperimentArrangementMode.Centralized => "集中安排", + ExperimentArrangementMode.SelfScheduled => "自主预约", + _ => "实验安排" + }; + builder.AddTimedEvent( + $"experiment-{entry.Id:N}@jiaowu", + $"[实验] {entry.ExperimentProjectName ?? entry.CourseName}", + ToUtc(experimentDate, startSlot.StartsAt), + ToUtc(experimentDate, endSlot.EndsAt), + JoinLocation( + entry.CampusName, + entry.BuildingName, + entry.ClassroomName), + JoinDescription( + $"{entry.ExperimentProjectCode} · {arrangement}", + entry.CourseName, + entry.TaskName, + $"教师:{string.Join('、', entry.TeacherNames)}", + entry.Notes), + "实验", + entry.UpdatedAt); + } + } + private async Task> LoadMakeupSessionsAsync( Guid? studentId, Guid? teacherId, diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs index d8f4e72..1f98267 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs @@ -1,5 +1,6 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Timetables; @@ -117,6 +118,8 @@ public sealed class TimetableDataService(AppDbContext db) // Load exam sessions for student/teacher timetables var examEntries = await LoadExamEntriesAsync( resourceType, resourceId, term.Id, studentId, slots, cancellationToken); + var experimentEntries = await LoadExperimentEntriesAsync( + resourceType, term, studentId, cancellationToken); return new TimetableData( term, @@ -127,7 +130,10 @@ public sealed class TimetableDataService(AppDbContext db) slots, entries, flexibleCourses, - examEntries); + examEntries) + { + ExperimentEntries = experimentEntries + }; } private async Task LoadSubjectAsync( @@ -420,6 +426,115 @@ public sealed class TimetableDataService(AppDbContext db) return result; } + private async Task> LoadExperimentEntriesAsync( + TimetableResourceType resourceType, + TimetableTermDto term, + Guid? studentId, + CancellationToken cancellationToken) + { + if (resourceType != TimetableResourceType.Class || !studentId.HasValue) + return []; + + var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent( + db, + studentId.Value); + var sessions = await db.ExperimentSessions.AsNoTracking() + .AsSplitQuery() + .Where(x => + x.ExperimentProject!.TeachingTask!.AcademicTermId == term.Id && + (x.ExperimentProject.Status == + ExperimentProjectStatus.Published || + x.ExperimentProject.Status == + ExperimentProjectStatus.Closed) && + x.Status == ExperimentSessionStatus.Scheduled) + .Where(x => + (x.ExperimentProject!.ArrangementMode == + ExperimentArrangementMode.Centralized && + rosterTaskIds.Contains( + x.ExperimentProject.TeachingTaskId)) || + (x.ExperimentProject.ArrangementMode == + ExperimentArrangementMode.SelfScheduled && + x.Bookings.Any(booking => + booking.StudentId == studentId.Value && + booking.Status == ExperimentBookingStatus.Booked))) + .OrderBy(x => x.SessionDate) + .ThenBy(x => x.StartPeriod) + .ThenBy(x => x.ExperimentProject!.Code) + .Select(x => new ExperimentSessionProjection( + x.Id, + x.ExperimentProjectId, + x.ExperimentProject!.Code, + x.ExperimentProject.Name, + x.ExperimentProject.ArrangementMode, + x.ExperimentProject.TeachingTaskId, + x.ExperimentProject.TeachingTask!.TaskNumber, + x.ExperimentProject.TeachingTask.Name, + x.ExperimentProject.TeachingTask.Course!.Code, + x.ExperimentProject.TeachingTask.Course.Name, + x.ExperimentProject.TeachingTask.Teachers + .OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name) + .ToList(), + x.ExperimentProject.TeachingTask.Classes + .Select(item => item.AdministrativeClass!.Name) + .ToList(), + x.Classroom!.Name, + x.Classroom.Building!.Name, + x.Classroom.Building.Campus!.Name, + x.SessionDate, + x.StartPeriod, + x.PeriodCount, + x.Notes, + x.UpdatedAt)) + .ToListAsync(cancellationToken); + + var termMonday = StartOfWeek(term.StartDate); + return sessions.Select(x => + { + var week = (x.SessionDate.DayNumber - termMonday.DayNumber) / 7 + 1; + var dayOfWeek = x.SessionDate.DayOfWeek == DayOfWeek.Sunday + ? 7 + : (int)x.SessionDate.DayOfWeek; + return new TimetableEntryDto( + x.Id, + x.TeachingTaskId, + x.TaskNumber, + x.TaskName, + x.CourseCode, + x.CourseName, + x.TeacherNames, + x.ClassNames, + x.ClassroomName, + x.BuildingName, + x.CampusName, + dayOfWeek, + x.StartPeriod, + x.PeriodCount, + week, + week, + WeekPattern.All, + x.Notes, + false, + null, + null, + x.UpdatedAt, + true, + x.ExperimentProjectId, + x.ProjectCode, + x.ProjectName, + x.SessionDate, + x.ArrangementMode); + }).ToList(); + } + + private static DateOnly StartOfWeek(DateOnly date) + { + var dayOfWeek = date.DayOfWeek == DayOfWeek.Sunday + ? 7 + : (int)date.DayOfWeek; + return date.AddDays(1 - dayOfWeek); + } + private static bool MatchesMixedResource( ExamRoomAssignment room, ExamSession session, @@ -535,6 +650,28 @@ public sealed class TimetableDataService(AppDbContext db) List InvigilatorNames, string? Notes, DateTime UpdatedAt); + + private sealed record ExperimentSessionProjection( + Guid Id, + Guid ExperimentProjectId, + string ProjectCode, + string ProjectName, + ExperimentArrangementMode ArrangementMode, + Guid TeachingTaskId, + string TaskNumber, + string TaskName, + string CourseCode, + string CourseName, + List TeacherNames, + List ClassNames, + string ClassroomName, + string BuildingName, + string CampusName, + DateOnly SessionDate, + int StartPeriod, + int PeriodCount, + string? Notes, + DateTime UpdatedAt); } public enum TimetableResourceType @@ -553,7 +690,10 @@ public sealed record TimetableData( IReadOnlyList Slots, IReadOnlyList Entries, IReadOnlyList FlexibleCourses, - IReadOnlyList ExamEntries); + IReadOnlyList ExamEntries) +{ + public IReadOnlyList ExperimentEntries { get; init; } = []; +} public sealed record TimetableTermDto( Guid Id, @@ -616,7 +756,13 @@ public sealed record TimetableEntryDto( bool IsExam = false, string? ExamPlanName = null, DateOnly? ExamDate = null, - DateTime UpdatedAt = default); + DateTime UpdatedAt = default, + bool IsExperiment = false, + Guid? ExperimentProjectId = null, + string? ExperimentProjectCode = null, + string? ExperimentProjectName = null, + DateOnly? ExperimentDate = null, + ExperimentArrangementMode? ExperimentArrangementMode = null); public sealed record FlexibleCourseDto( Guid Id, diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs index 27b7276..10f0af6 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs @@ -25,14 +25,21 @@ public static class TimetableExcelExporter sheet.Row(1).Height = 34; sheet.Range("A2:H2").Merge(); - var examSummary = timetable.ExamEntries.Count > 0 - ? $" · 已融入 {timetable.ExamEntries.Count} 场考试" - : ""; + var activitySummary = string.Concat( + timetable.ExamEntries.Count > 0 + ? $" · 已融入 {timetable.ExamEntries.Count} 场考试" + : "", + timetable.ExperimentEntries.Count > 0 + ? $" · 已融入 {timetable.ExperimentEntries.Count} 场实验" + : ""); + var hasOneOffActivities = + timetable.ExamEntries.Count > 0 || + timetable.ExperimentEntries.Count > 0; sheet.Cell("A2").Value = timetable.Plan is null - ? timetable.ExamEntries.Count > 0 - ? $"固定课表尚未发布{examSummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}" + ? hasOneOffActivities + ? $"固定课表尚未发布{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}" : "本学期暂无可用课表版本" - : $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)}{examSummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"; + : $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)}{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"; sheet.Cell("A2").Style .Font.SetFontColor(XLColor.FromHtml("#52677A")) .Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6")); @@ -55,6 +62,7 @@ public static class TimetableExcelExporter var timedEntries = timetable.Entries .Concat(timetable.ExamEntries) + .Concat(timetable.ExperimentEntries) .ToArray(); var periods = timetable.Slots.Select(x => x.PeriodNumber) .Concat(timedEntries.SelectMany(x => @@ -95,7 +103,11 @@ public static class TimetableExcelExporter { cell.Style .Fill.SetBackgroundColor(XLColor.FromHtml( - entries.Any(x => x.IsExam) ? "#FFF1E8" : "#E8F3F2")) + entries.Any(x => x.IsExam) + ? "#FFF1E8" + : entries.Any(x => x.IsExperiment) + ? "#E7F5F1" + : "#E8F3F2")) .Font.SetFontColor(XLColor.FromHtml("#173F4C")); } } @@ -147,7 +159,14 @@ public static class TimetableExcelExporter } private static string EntryText(TimetableEntryDto entry) => - entry.IsExam + entry.IsExperiment + ? $"【实验】{entry.ExperimentProjectName}\n" + + $"{entry.CourseName} · {ArrangementMode(entry)}\n" + + $"{string.Join('、', entry.TeacherNames)}\n" + + $"{Location(entry)}\n" + + $"{entry.ExperimentDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" + + $"{entry.StartPeriod + entry.PeriodCount - 1} 节" + : entry.IsExam ? $"【考试】{entry.CourseName}\n" + $"{entry.ExamPlanName}\n" + $"{string.Join('、', entry.TeacherNames)}\n" + @@ -159,6 +178,14 @@ public static class TimetableExcelExporter $"{Location(entry)}\n" + $"{entry.StartWeek}-{entry.EndWeek} 周"; + private static string ArrangementMode(TimetableEntryDto entry) => + entry.ExperimentArrangementMode switch + { + Domain.Academic.ExperimentArrangementMode.Centralized => "集中安排", + Domain.Academic.ExperimentArrangementMode.SelfScheduled => "自主预约", + _ => "实验安排" + }; + private static string Location(TimetableEntryDto entry) => string.Join(" · ", new[] { diff --git a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs index 2a98a25..d05a248 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs @@ -138,6 +138,107 @@ public sealed class ExperimentsControllerTests Assert.Empty(fixture.Db.ExperimentBookings); } + [Fact] + public async Task StudentTimetable_IncludesCentralizedAndBookedSelfScheduledSessions() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var manager = fixture.Controller(fixture.ManagerScope); + + await manager.CreateProject( + fixture.ProjectRequest(ExperimentArrangementMode.Centralized), + CancellationToken.None); + var centralized = await fixture.Db.ExperimentProjects.SingleAsync(); + await manager.CreateSession( + centralized.Id, + fixture.SessionRequest(1, 2), + CancellationToken.None); + await manager.PublishProject(centralized.Id, CancellationToken.None); + + await manager.CreateProject( + fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled), + CancellationToken.None); + var selfScheduled = await fixture.Db.ExperimentProjects.SingleAsync( + x => x.ArrangementMode == ExperimentArrangementMode.SelfScheduled); + await manager.CreateSession( + selfScheduled.Id, + fixture.SessionRequest(4, 2), + CancellationToken.None); + await manager.PublishProject(selfScheduled.Id, CancellationToken.None); + + var selfSession = await fixture.Db.ExperimentSessions.SingleAsync( + x => x.ExperimentProjectId == selfScheduled.Id); + var studentController = fixture.Controller(fixture.StudentScope); + Assert.IsType( + await studentController.Book( + selfSession.Id, + CancellationToken.None)); + + var student = await fixture.Db.Students.AsNoTracking().SingleAsync(); + var service = new TimetableDataService(fixture.Db); + var timetable = await service.BuildAsync( + TimetableResourceType.Class, + student.AdministrativeClassId, + fixture.Term.Id, + null, + false, + student.Id, + new TimetableStudentDto(student.StudentNumber, student.Name), + CancellationToken.None); + + Assert.NotNull(timetable); + Assert.Equal(2, timetable.ExperimentEntries.Count); + Assert.Contains( + timetable.ExperimentEntries, + x => x.ExperimentArrangementMode == + ExperimentArrangementMode.Centralized); + Assert.Contains( + timetable.ExperimentEntries, + x => x.Id == selfSession.Id && + x.ExperimentArrangementMode == + ExperimentArrangementMode.SelfScheduled); + Assert.All( + timetable.ExperimentEntries, + x => + { + Assert.True(x.IsExperiment); + Assert.Equal(fixture.Term.StartDate, x.ExperimentDate); + Assert.Equal(1, x.StartWeek); + }); + + var publicClassTimetable = await service.BuildAsync( + TimetableResourceType.Class, + student.AdministrativeClassId, + fixture.Term.Id, + null, + false, + null, + null, + CancellationToken.None); + Assert.NotNull(publicClassTimetable); + Assert.Empty(publicClassTimetable.ExperimentEntries); + + var booking = await fixture.Db.ExperimentBookings.SingleAsync(); + Assert.IsType( + await studentController.CancelBooking( + booking.Id, + CancellationToken.None)); + timetable = await service.BuildAsync( + TimetableResourceType.Class, + student.AdministrativeClassId, + fixture.Term.Id, + null, + false, + student.Id, + new TimetableStudentDto(student.StudentNumber, student.Name), + CancellationToken.None); + + Assert.NotNull(timetable); + Assert.Single(timetable.ExperimentEntries); + Assert.Equal( + ExperimentArrangementMode.Centralized, + timetable.ExperimentEntries[0].ExperimentArrangementMode); + } + private sealed class ExperimentFixture : IAsyncDisposable { private ExperimentFixture( diff --git a/tests/Jiaowu.Api.Tests/PersonalCalendarServiceTests.cs b/tests/Jiaowu.Api.Tests/PersonalCalendarServiceTests.cs index 2f11c2b..4ea0996 100644 --- a/tests/Jiaowu.Api.Tests/PersonalCalendarServiceTests.cs +++ b/tests/Jiaowu.Api.Tests/PersonalCalendarServiceTests.cs @@ -251,6 +251,198 @@ public sealed class PersonalCalendarServiceTests : IAsyncLifetime $"ICS line exceeds 75 octets: {line}")); } + [Fact] + public async Task Build_creates_student_feed_with_booked_experiment() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var mondayOffset = today.DayOfWeek == DayOfWeek.Sunday + ? 6 + : (int)today.DayOfWeek - 1; + var termStart = today.AddDays(-mondayOffset); + var user = new ApplicationUser + { + Id = Guid.NewGuid(), + UserName = "calendar-student", + NormalizedUserName = "CALENDAR-STUDENT", + DisplayName = "实验学生", + CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(), + CalendarSubscriptionCreatedAt = DateTime.UtcNow + }; + var campus = new Campus { Code = "LAB-MAIN", Name = "主校区" }; + var college = new College + { + Code = "LAB-CS", + Name = "计算机学院", + CampusId = campus.Id + }; + var major = new Major + { + Code = "LAB-SE", + Name = "软件工程", + CollegeId = college.Id, + DegreeType = "工学" + }; + var administrativeClass = new AdministrativeClass + { + Code = "LAB-CLASS", + Name = "软件工程实验班", + MajorId = major.Id, + Grade = today.Year + }; + var student = new Student + { + StudentNumber = "LAB-STUDENT-01", + Name = "实验学生", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = today.Year, + EnrollmentDate = termStart, + UserId = user.Id, + Status = StudentStatus.Active + }; + var teacher = new Teacher + { + TeacherNumber = "LAB-TEACHER-01", + Name = "实验教师", + CollegeId = college.Id, + Status = TeacherStatus.Active + }; + var course = new Course + { + Code = "LAB-CALENDAR", + Name = "嵌入式系统实验", + CollegeId = college.Id, + Credits = 2, + TotalHours = 32, + LectureHours = 16, + PracticeHours = 16, + Nature = CourseNature.Practice, + AssessmentMethod = AssessmentMethod.Assessment + }; + var term = new AcademicTerm + { + Code = "LAB-CALENDAR-TERM", + Name = "实验日历测试学期", + AcademicYear = $"{today.Year}-{today.Year + 1}", + Season = TermSeason.Autumn, + StartDate = termStart, + EndDate = termStart.AddDays(40), + IsCurrent = true + }; + var building = new Building + { + Code = "LAB-CENTER", + Name = "实验中心", + CampusId = campus.Id + }; + var classroom = new Classroom + { + Code = "LAB-CENTER-301", + Name = "301", + BuildingId = building.Id, + Capacity = 30 + }; + _db.AddRange( + user, + campus, + college, + major, + administrativeClass, + student, + teacher, + course, + term, + building, + classroom); + await _db.SaveChangesAsync(); + + var task = new TeachingTask + { + TaskNumber = "LAB-CALENDAR-TASK", + Name = "嵌入式系统实验教学班", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 30, + Status = TeachingTaskStatus.Published, + Teachers = + [ + new TeachingTaskTeacher + { + TeacherId = teacher.Id, + IsPrimary = true + } + ], + Classes = + [ + new TeachingTaskClass + { + AdministrativeClassId = administrativeClass.Id + } + ] + }; + _db.AddRange( + task, + new ScheduleTimeSlot + { + AcademicTermId = term.Id, + PeriodNumber = 3, + Name = "第三节", + StartsAt = new TimeOnly(10, 0), + EndsAt = new TimeOnly(10, 45) + }, + new ScheduleTimeSlot + { + AcademicTermId = term.Id, + PeriodNumber = 4, + Name = "第四节", + StartsAt = new TimeOnly(10, 55), + EndsAt = new TimeOnly(11, 40) + }); + await _db.SaveChangesAsync(); + + var project = new ExperimentProject + { + TeachingTaskId = task.Id, + Code = "LAB-SELF-01", + Name = "自主硬件实验", + ArrangementMode = ExperimentArrangementMode.SelfScheduled, + StartDate = termStart, + EndDate = termStart.AddDays(14), + Status = ExperimentProjectStatus.Published, + PublishedAt = DateTime.UtcNow + }; + var session = new ExperimentSession + { + ExperimentProject = project, + ClassroomId = classroom.Id, + SessionDate = termStart.AddDays(2), + StartPeriod = 3, + PeriodCount = 2, + Capacity = 30, + ReservedCount = 1 + }; + session.Bookings.Add(new ExperimentBooking + { + ExperimentProject = project, + StudentId = student.Id, + Status = ExperimentBookingStatus.Booked, + BookedAt = DateTime.UtcNow + }); + _db.Add(session); + await _db.SaveChangesAsync(); + + var result = await CreateService().BuildAsync( + user, + CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(1, result.EventCount); + var ics = Encoding.UTF8.GetString(result.Content); + Assert.Contains("SUMMARY:[实验] 自主硬件实验", ics); + Assert.Contains("CATEGORIES:实验", ics); + Assert.Contains("LOCATION:主校区 · 实验中心 · 301", ics); + Assert.Contains("LAB-SELF-01 · 自主预约", ics); + } + [Fact] public void Access_token_is_bound_to_user_stamp_and_can_be_rotated() { diff --git a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs index 268feb6..8de9524 100644 --- a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs +++ b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs @@ -108,7 +108,41 @@ public sealed class TimetableExcelExporterTests "2026-2027 学年第一学期期末考试", new DateOnly(2027, 1, 5), DateTime.UtcNow) - ]); + ]) + { + ExperimentEntries = + [ + new TimetableEntryDto( + Guid.NewGuid(), + Guid.NewGuid(), + "TASK-LAB", + "程序设计实验教学班", + "CSLAB101", + "程序设计实验", + ["陈老师"], + ["计算机科学 2601 班"], + "301", + "实验中心", + "主校区", + 4, + 1, + 2, + 2, + 2, + WeekPattern.All, + null, + false, + null, + null, + DateTime.UtcNow, + true, + Guid.NewGuid(), + "LAB-01", + "基础控制实验", + new DateOnly(2026, 9, 10), + ExperimentArrangementMode.SelfScheduled) + ] + }; var bytes = TimetableExcelExporter.Create(timetable); @@ -117,8 +151,11 @@ public sealed class TimetableExcelExporterTests var sheet = workbook.Worksheet("课表"); Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString()); Assert.Contains("已融入 1 场考试", sheet.Cell("A2").GetString()); + Assert.Contains("已融入 1 场实验", sheet.Cell("A2").GetString()); Assert.Contains("高等数学", sheet.Cell(5, 2).GetString()); Assert.Contains("【考试】高等数学", sheet.Cell(5, 3).GetString()); + Assert.Contains("【实验】基础控制实验", sheet.Cell(5, 5).GetString()); + Assert.Contains("自主预约", sheet.Cell(5, 5).GetString()); Assert.Contains("2027-01-05", sheet.Cell(5, 3).GetString()); Assert.Contains( sheet.CellsUsed(), diff --git a/web/src/views/TimetableView.vue b/web/src/views/TimetableView.vue index 5cb6848..8ee00f5 100644 --- a/web/src/views/TimetableView.vue +++ b/web/src/views/TimetableView.vue @@ -69,6 +69,10 @@ const planStatusLabels: Record = { Published: '已发布', Archived: '已归档', } +const experimentModeLabels: Record = { + Centralized: '集中安排', + SelfScheduled: '自主预约', +} const grades = computed(() => [...new Set(classes.value.map((item) => item.grade))].sort((a, b) => b - a), @@ -128,9 +132,27 @@ const examEntries = computed(() => { }] }) }) +const experimentEntries = computed(() => { + if (!termMonday.value) return [] + return (timetable.value?.experimentEntries ?? []).flatMap((entry: any) => { + const experimentDate = parseDate(entry.experimentDate) + if (!experimentDate) return [] + const week = Math.floor( + (experimentDate.getTime() - termMonday.value!.getTime()) / (7 * 86400000), + ) + 1 + if (week < 1) return [] + return [{ + ...entry, + startWeek: week, + endWeek: week, + weekPattern: 'All', + }] + }) +}) const timedEntries = computed(() => [ ...(timetable.value?.entries ?? []), ...examEntries.value, + ...experimentEntries.value, ]) const hasTimedEntries = computed(() => timedEntries.value.length > 0) const maxPeriods = computed(() => { @@ -174,6 +196,13 @@ const sortedExamEntries = computed(() => String(a.courseCode).localeCompare(String(b.courseCode)), ), ) +const sortedExperimentEntries = computed(() => + experimentEntries.value.slice().sort((a: any, b: any) => + String(a.experimentDate).localeCompare(String(b.experimentDate)) || + a.startPeriod - b.startPeriod || + String(a.experimentProjectCode).localeCompare(String(b.experimentProjectCode)), + ), +) const visibleGridEntries = computed(() => viewMode.value === 'overview' ? (timetable.value?.entries ?? []) @@ -215,19 +244,31 @@ function weeks(entry: any) { } function entryKey(entry: any) { - return `${entry.isExam ? 'exam' : 'course'}-${entry.id}-${entry.teachingTaskId ?? ''}` + const kind = entry.isExperiment ? 'experiment' : entry.isExam ? 'exam' : 'course' + return `${kind}-${entry.id}-${entry.teachingTaskId ?? ''}` } function entryTiming(entry: any) { const periods = `第 ${entry.startPeriod}—${entry.startPeriod + entry.periodCount - 1} 节` + if (entry.isExperiment) { + return `${formatActivityDate(entry.experimentDate, '实验日期待定')} · ${periods}` + } return entry.isExam - ? `${formatExamDate(entry.examDate)} · ${periods}` + ? `${formatActivityDate(entry.examDate, '考试日期待定')} · ${periods}` : `${weeks(entry)} · ${periods}` } function formatExamDate(value?: string) { + return formatActivityDate(value, '考试日期待定') +} + +function formatActivityDate(value?: string, fallback = '日期待定') { const date = parseDate(value) - return date ? `${date.getMonth() + 1}月${date.getDate()}日` : '考试日期待定' + return date ? `${date.getMonth() + 1}月${date.getDate()}日` : fallback +} + +function experimentMode(entry: any) { + return experimentModeLabels[entry.experimentArrangementMode] ?? '实验安排' } function location(entry: any) { @@ -890,9 +931,14 @@ onMounted(async () => { + - - 固定课表尚未发布 · 已载入 {{ examEntries.length }} 场考试 + + 固定课表尚未发布 + + 本学期课表尚未发布 @@ -920,7 +966,9 @@ onMounted(async () => {
@@ -928,12 +976,12 @@ onMounted(async () => { {{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }} {{ viewMode === 'overview' - ? '固定课程按节次展示,考试安排按日期列于下方' - : `本周共 ${weekEntries.length} 项课程、考试安排` }} + ? '固定课程按节次展示,考试与实验按日期列于下方' + : `本周共 ${weekEntries.length} 项课程、考试与实验安排` }}
课程卡片保留起止周与单双周信息 - 本周没有课程或考试安排 + 本周没有课程、考试或实验安排
{ v-for="entry in visibleGridEntries" :key="entryKey(entry)" class="course-block" - :class="{ 'exam-block': entry.isExam }" + :class="{ + 'exam-block': entry.isExam, + 'experiment-block': entry.isExperiment, + }" :style="gridEntryStyle(entry)" > 考试 - {{ entry.courseName }} + 实验 + {{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }} {{ entry.examPlanName }} + + {{ entry.courseName }} · {{ experimentMode(entry) }} + {{ entry.teacherNames.join('、') || '教师待定' }} {{ location(entry) }} {{ entryTiming(entry) }} @@ -1006,24 +1061,33 @@ onMounted(async () => { v-for="entry in dayEntries" :key="entryKey(entry)" class="day-course-block" - :class="{ 'exam-block': entry.isExam }" + :class="{ + 'exam-block': entry.isExam, + 'experiment-block': entry.isExperiment, + }" :style="dayEntryStyle(entry)" > 考试 - {{ entry.courseName }} + 实验 + {{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }} {{ entry.examPlanName }} + + {{ entry.courseName }} · {{ experimentMode(entry) }} + {{ entry.teacherNames.join('、') || '教师待定' }} {{ location(entry) }} {{ entryTiming(entry) }} - 当日无课程安排 + 当日无教学安排
@@ -1054,6 +1118,38 @@ onMounted(async () => {
+ +
+
+
+ LAB SESSIONS + 个人实验安排 +
+ 共 {{ sortedExperimentEntries.length }} 场 · 预约变化会自动同步 +
+
+
+
+ {{ formatActivityDate(entry.experimentDate, '日期待定') }} + {{ weekdays[entry.dayOfWeek] }} + 第 {{ entry.startWeek }} 周 +
+
+ {{ entry.experimentProjectCode }} · {{ experimentMode(entry) }} + {{ entry.experimentProjectName }} +

{{ entry.courseName }} · {{ location(entry) }}

+ + 第 {{ entry.startWeek }} 周 · + 第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节 + + {{ entry.teacherNames.join('、') || '实验教师待定' }} +
+
+
+
@@ -1069,7 +1165,7 @@ onMounted(async () => { LIVE FEED
订阅已启用 - 课表和考试安排变更后,日历客户端会在下次同步时自动更新。 + 课表、考试和实验安排变更后,日历客户端会在下次同步时自动更新。
@@ -1132,6 +1228,7 @@ onMounted(async () => {
  • 已发布课表中的固定课程和调停课后的最新安排
  • 学生考试、补考;教师授课、监考和补考监考
  • +
  • 集中实验及学生本人已预约的自主实验场次
  • 非排时课程会在学期首日生成全天提醒

苹果日历可直接点击订阅;Google 日历、Outlook 可复制地址后选择“通过 URL 添加”。服务需具备外网可访问的 HTTPS 地址才能跨设备同步。

@@ -1227,7 +1324,11 @@ onMounted(async () => { .course-block.exam-block, .day-course-block.exam-block { border-left-color: #b65b32; background: #fff1e8; color: #70442f; } .course-block.exam-block strong, .day-course-block.exam-block strong { color: #78391e; } .course-block.exam-block small, .day-course-block.exam-block small { color: #8b5b43; } +.course-block.experiment-block, .day-course-block.experiment-block { border-left-color: #168276; background: #e7f5f1; color: #315f59; } +.course-block.experiment-block strong, .day-course-block.experiment-block strong { color: #155b53; } +.course-block.experiment-block small, .day-course-block.experiment-block small { color: #527a74; } .entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; } +.entry-kind.experiment-kind { background: #168276; } .exam-overview { margin-top: 20px; border: 1px solid #e2d6cf; background: #fffaf7; } .exam-overview > header { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #eaded7; background: #fff5ef; } .exam-overview > header > div { display: flex; align-items: baseline; gap: 10px; } @@ -1245,6 +1346,20 @@ onMounted(async () => { .exam-summary > strong { color: #533225; font-size: 15px; } .exam-summary p { margin: 0; color: #6e584e; font-size: 12px; } .exam-summary small { color: #8a7469; font-size: 11px; line-height: 1.5; } +.experiment-overview { border-color: #cfe4df; background: #f8fcfb; } +.experiment-overview > header { border-bottom-color: #d8ebe7; background: #eff9f6; } +.experiment-overview > header span { color: #168276; } +.experiment-overview > header strong { color: #174f49; } +.experiment-overview > header small { color: #63827d; } +.experiment-overview .exam-overview-list article { border-color: #d5e8e4; border-left-color: #168276; } +.experiment-overview .exam-date { border-right-color: #dbece8; background: #f0faf7; } +.experiment-overview .exam-date strong { color: #176b62; } +.experiment-overview .exam-date span { color: #4c706b; } +.experiment-overview .exam-date small { color: #718d88; } +.experiment-overview .exam-summary > span { color: #168276; } +.experiment-overview .exam-summary > strong { color: #174f49; } +.experiment-overview .exam-summary p { color: #506f6a; } +.experiment-overview .exam-summary small { color: #6c8581; } .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 > div { display: grid; gap: 3px; }