实验课表

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
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[]
{
@@ -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<NoContentResult>(
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<NoContentResult>(
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(
@@ -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()
{
@@ -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(),
+131 -16
View File
@@ -69,6 +69,10 @@ const planStatusLabels: Record<string, string> = {
Published: '已发布',
Archived: '已归档',
}
const experimentModeLabels: Record<string, string> = {
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 () => {
<template v-if="examEntries.length">
· 已融入 {{ examEntries.length }} 场考试
</template>
<template v-if="experimentEntries.length">
· 已融入 {{ experimentEntries.length }} 场实验
</template>
</span>
<span v-else-if="examEntries.length">
固定课表尚未发布 · 已载入 {{ examEntries.length }} 场考试
<span v-else-if="examEntries.length || experimentEntries.length">
固定课表尚未发布
<template v-if="examEntries.length"> · 已载入 {{ examEntries.length }} 场考试</template>
<template v-if="experimentEntries.length"> · 已载入 {{ experimentEntries.length }} 场实验</template>
</span>
<span v-else>本学期课表尚未发布</span>
</div>
@@ -920,7 +966,9 @@ onMounted(async () => {
</section>
<div
v-if="(viewMode === 'week' && hasTimedEntries) || (viewMode === 'overview' && hasOverviewCourseEntries)"
v-if="(viewMode === 'week' && hasTimedEntries) ||
(viewMode === 'overview' &&
(hasOverviewCourseEntries || sortedExamEntries.length || sortedExperimentEntries.length))"
class="timetable-view-section"
>
<div class="view-context">
@@ -928,12 +976,12 @@ onMounted(async () => {
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
<span>
{{ viewMode === 'overview'
? '固定课程按节次展示,考试安排按日期列于下方'
: `本周共 ${weekEntries.length} 项课程、考试安排` }}
? '固定课程按节次展示,考试与实验按日期列于下方'
: `本周共 ${weekEntries.length} 项课程、考试与实验安排` }}
</span>
</div>
<small v-if="viewMode === 'overview'">课程卡片保留起止周与单双周信息</small>
<small v-else-if="!weekEntries.length">本周没有课程考试安排</small>
<small v-else-if="!weekEntries.length">本周没有课程考试或实验安排</small>
</div>
<div class="timetable-scroll">
<div
@@ -963,14 +1011,21 @@ onMounted(async () => {
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)"
>
<strong>
<span v-if="entry.isExam" class="entry-kind">考试</span>
{{ entry.courseName }}
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
</strong>
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
<span v-if="entry.isExperiment">
{{ entry.courseName }} · {{ experimentMode(entry) }}
</span>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ entryTiming(entry) }}</small>
@@ -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)"
>
<strong>
<span v-if="entry.isExam" class="entry-kind">考试</span>
{{ entry.courseName }}
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
</strong>
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
<span v-if="entry.isExperiment">
{{ entry.courseName }} · {{ experimentMode(entry) }}
</span>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ entryTiming(entry) }}</small>
</article>
<span v-if="!dayEntries.length" class="day-no-courses">当日无课程安排</span>
<span v-if="!dayEntries.length" class="day-no-courses">当日无教学安排</span>
</div>
</div>
<el-empty
v-else-if="timetable && !loading && !hasTimedEntries && !timetable.flexibleCourses?.length"
:description="timetable.plan ? '该课表暂时没有课程或考试安排' : '所选学期尚未发布课表或考试安排'"
:description="timetable.plan
? '该课表暂时没有课程、考试或实验安排'
: '所选学期尚未发布课表、考试或实验安排'"
/>
<section v-if="viewMode === 'overview' && sortedExamEntries.length" class="exam-overview">
@@ -1054,6 +1118,38 @@ onMounted(async () => {
</article>
</div>
</section>
<section
v-if="viewMode === 'overview' && sortedExperimentEntries.length"
class="exam-overview experiment-overview"
>
<header>
<div>
<span>LAB SESSIONS</span>
<strong>个人实验安排</strong>
</div>
<small>共 {{ sortedExperimentEntries.length }} 场 · 预约变化会自动同步</small>
</header>
<div class="exam-overview-list">
<article v-for="entry in sortedExperimentEntries" :key="entryKey(entry)">
<div class="exam-date">
<strong>{{ formatActivityDate(entry.experimentDate, '日期待定') }}</strong>
<span>{{ weekdays[entry.dayOfWeek] }}</span>
<small>第 {{ entry.startWeek }} 周</small>
</div>
<div class="exam-summary">
<span>{{ entry.experimentProjectCode }} · {{ experimentMode(entry) }}</span>
<strong>{{ entry.experimentProjectName }}</strong>
<p>{{ entry.courseName }} · {{ location(entry) }}</p>
<small>
第 {{ entry.startWeek }} 周 ·
第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节
</small>
<small>{{ entry.teacherNames.join('、') || '实验教师待定' }}</small>
</div>
</article>
</div>
</section>
</div>
</section>
@@ -1069,7 +1165,7 @@ onMounted(async () => {
<span>LIVE FEED</span>
<div>
<strong>订阅已启用</strong>
<small>课表考试安排变更后日历客户端会在下次同步时自动更新</small>
<small>课表考试和实验安排变更后日历客户端会在下次同步时自动更新。</small>
</div>
</div>
@@ -1132,6 +1228,7 @@ onMounted(async () => {
<ul>
<li>已发布课表中的固定课程和调停课后的最新安排</li>
<li>学生考试补考教师授课监考和补考监考</li>
<li>集中实验及学生本人已预约的自主实验场次</li>
<li>非排时课程会在学期首日生成全天提醒</li>
</ul>
<p>苹果日历可直接点击订阅Google 日历Outlook 可复制地址后选择通过 URL 添加服务需具备外网可访问的 HTTPS 地址才能跨设备同步</p>
@@ -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; }