实验课表

This commit is contained in:
2026-07-28 21:25:21 +08:00 Unverified
parent ec979f640d
commit 51c3b81e27
7 changed files with 687 additions and 28 deletions
@@ -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<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
Guid? studentId,
Guid? teacherId,
@@ -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<TimetableSubjectDto?> LoadSubjectAsync(
@@ -420,6 +426,115 @@ public sealed class TimetableDataService(AppDbContext db)
return result;
}
private async Task<List<TimetableEntryDto>> 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<string> 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<string> TeacherNames,
List<string> 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<TimetableSlotDto> Slots,
IReadOnlyList<TimetableEntryDto> Entries,
IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
IReadOnlyList<TimetableEntryDto> ExamEntries);
IReadOnlyList<TimetableEntryDto> ExamEntries)
{
public IReadOnlyList<TimetableEntryDto> 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,
@@ -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[]
{