实验课表

This commit is contained in:
biss committed 2026-07-28 21:25:21 +08:00
1 parent ec979f640d
commit 51c3b81e27
7 files changed
+687 -28

No files matched your search

@@ -115,6 +115,7 @@ public sealed class PersonalCalendarService(
AddScheduledCourses(builder, timetable); AddScheduledCourses(builder, timetable);
AddExamEntries(builder, timetable, teacher is not null); AddExamEntries(builder, timetable, teacher is not null);
AddExperimentEntries(builder, timetable);
AddFlexibleCourseReminders(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( private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
Guid? studentId, Guid? studentId,
Guid? teacherId, Guid? teacherId,
@@ -1,5 +1,6 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Timetables; namespace Jiaowu.Api.Infrastructure.Timetables;
@@ -117,6 +118,8 @@ public sealed class TimetableDataService(AppDbContext db)
// Load exam sessions for student/teacher timetables // Load exam sessions for student/teacher timetables
var examEntries = await LoadExamEntriesAsync( var examEntries = await LoadExamEntriesAsync(
resourceType, resourceId, term.Id, studentId, slots, cancellationToken); resourceType, resourceId, term.Id, studentId, slots, cancellationToken);
var experimentEntries = await LoadExperimentEntriesAsync(
resourceType, term, studentId, cancellationToken);
return new TimetableData( return new TimetableData(
term, term,
@@ -127,7 +130,10 @@ public sealed class TimetableDataService(AppDbContext db)
slots, slots,
entries, entries,
flexibleCourses, flexibleCourses,
examEntries); examEntries)
{
ExperimentEntries = experimentEntries
};
} }
private async Task<TimetableSubjectDto?> LoadSubjectAsync( private async Task<TimetableSubjectDto?> LoadSubjectAsync(
@@ -420,6 +426,115 @@ public sealed class TimetableDataService(AppDbContext db)
return result; 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( private static bool MatchesMixedResource(
ExamRoomAssignment room, ExamRoomAssignment room,
ExamSession session, ExamSession session,
@@ -535,6 +650,28 @@ public sealed class TimetableDataService(AppDbContext db)
List<string> InvigilatorNames, List<string> InvigilatorNames,
string? Notes, string? Notes,
DateTime UpdatedAt); 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 public enum TimetableResourceType
@@ -553,7 +690,10 @@ public sealed record TimetableData(
IReadOnlyList<TimetableSlotDto> Slots, IReadOnlyList<TimetableSlotDto> Slots,
IReadOnlyList<TimetableEntryDto> Entries, IReadOnlyList<TimetableEntryDto> Entries,
IReadOnlyList<FlexibleCourseDto> FlexibleCourses, IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
IReadOnlyList<TimetableEntryDto> ExamEntries); IReadOnlyList<TimetableEntryDto> ExamEntries)
{
public IReadOnlyList<TimetableEntryDto> ExperimentEntries { get; init; } = [];
}
public sealed record TimetableTermDto( public sealed record TimetableTermDto(
Guid Id, Guid Id,
@@ -616,7 +756,13 @@ public sealed record TimetableEntryDto(
bool IsExam = false, bool IsExam = false,
string? ExamPlanName = null, string? ExamPlanName = null,
DateOnly? ExamDate = 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( public sealed record FlexibleCourseDto(
Guid Id, Guid Id,
@@ -25,14 +25,21 @@ public static class TimetableExcelExporter
sheet.Row(1).Height = 34; sheet.Row(1).Height = 34;
sheet.Range("A2:H2").Merge(); sheet.Range("A2:H2").Merge();
var examSummary = timetable.ExamEntries.Count > 0 var activitySummary = string.Concat(
? $" · 已融入 {timetable.ExamEntries.Count} 场考试" 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 sheet.Cell("A2").Value = timetable.Plan is null
? timetable.ExamEntries.Count > 0 ? hasOneOffActivities
? $"固定课表尚未发布{examSummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}" ? $"固定课表尚未发布{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 sheet.Cell("A2").Style
.Font.SetFontColor(XLColor.FromHtml("#52677A")) .Font.SetFontColor(XLColor.FromHtml("#52677A"))
.Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6")); .Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6"));
@@ -55,6 +62,7 @@ public static class TimetableExcelExporter
var timedEntries = timetable.Entries var timedEntries = timetable.Entries
.Concat(timetable.ExamEntries) .Concat(timetable.ExamEntries)
.Concat(timetable.ExperimentEntries)
.ToArray(); .ToArray();
var periods = timetable.Slots.Select(x => x.PeriodNumber) var periods = timetable.Slots.Select(x => x.PeriodNumber)
.Concat(timedEntries.SelectMany(x => .Concat(timedEntries.SelectMany(x =>
@@ -95,7 +103,11 @@ public static class TimetableExcelExporter
{ {
cell.Style cell.Style
.Fill.SetBackgroundColor(XLColor.FromHtml( .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")); .Font.SetFontColor(XLColor.FromHtml("#173F4C"));
} }
} }
@@ -147,7 +159,14 @@ public static class TimetableExcelExporter
} }
private static string EntryText(TimetableEntryDto entry) => 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.CourseName}\n" +
$"{entry.ExamPlanName}\n" + $"{entry.ExamPlanName}\n" +
$"{string.Join('、', entry.TeacherNames)}\n" + $"{string.Join('、', entry.TeacherNames)}\n" +
@@ -159,6 +178,14 @@ public static class TimetableExcelExporter
$"{Location(entry)}\n" + $"{Location(entry)}\n" +
$"{entry.StartWeek}-{entry.EndWeek} 周"; $"{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) => private static string Location(TimetableEntryDto entry) =>
string.Join(" · ", new[] string.Join(" · ", new[]
{ {
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff. Show more