实验课表
This commit is contained in:
@@ -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 > 0
|
||||||
? $" · 已融入 {timetable.ExamEntries.Count} 场考试"
|
? $" · 已融入 {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[]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -138,6 +138,107 @@ public sealed class ExperimentsControllerTests
|
|||||||
Assert.Empty(fixture.Db.ExperimentBookings);
|
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 sealed class ExperimentFixture : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private ExperimentFixture(
|
private ExperimentFixture(
|
||||||
|
|||||||
@@ -251,6 +251,198 @@ public sealed class PersonalCalendarServiceTests : IAsyncLifetime
|
|||||||
$"ICS line exceeds 75 octets: {line}"));
|
$"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]
|
[Fact]
|
||||||
public void Access_token_is_bound_to_user_stamp_and_can_be_rotated()
|
public void Access_token_is_bound_to_user_stamp_and_can_be_rotated()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -108,7 +108,41 @@ public sealed class TimetableExcelExporterTests
|
|||||||
"2026-2027 学年第一学期期末考试",
|
"2026-2027 学年第一学期期末考试",
|
||||||
new DateOnly(2027, 1, 5),
|
new DateOnly(2027, 1, 5),
|
||||||
DateTime.UtcNow)
|
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);
|
var bytes = TimetableExcelExporter.Create(timetable);
|
||||||
|
|
||||||
@@ -117,8 +151,11 @@ public sealed class TimetableExcelExporterTests
|
|||||||
var sheet = workbook.Worksheet("课表");
|
var sheet = workbook.Worksheet("课表");
|
||||||
Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString());
|
Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString());
|
||||||
Assert.Contains("已融入 1 场考试", sheet.Cell("A2").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, 2).GetString());
|
||||||
Assert.Contains("【考试】高等数学", sheet.Cell(5, 3).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("2027-01-05", sheet.Cell(5, 3).GetString());
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
sheet.CellsUsed(),
|
sheet.CellsUsed(),
|
||||||
|
|||||||
+131
-16
@@ -69,6 +69,10 @@ const planStatusLabels: Record<string, string> = {
|
|||||||
Published: '已发布',
|
Published: '已发布',
|
||||||
Archived: '已归档',
|
Archived: '已归档',
|
||||||
}
|
}
|
||||||
|
const experimentModeLabels: Record<string, string> = {
|
||||||
|
Centralized: '集中安排',
|
||||||
|
SelfScheduled: '自主预约',
|
||||||
|
}
|
||||||
|
|
||||||
const grades = computed(() =>
|
const grades = computed(() =>
|
||||||
[...new Set(classes.value.map((item) => item.grade))].sort((a, b) => b - a),
|
[...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(() => [
|
const timedEntries = computed(() => [
|
||||||
...(timetable.value?.entries ?? []),
|
...(timetable.value?.entries ?? []),
|
||||||
...examEntries.value,
|
...examEntries.value,
|
||||||
|
...experimentEntries.value,
|
||||||
])
|
])
|
||||||
const hasTimedEntries = computed(() => timedEntries.value.length > 0)
|
const hasTimedEntries = computed(() => timedEntries.value.length > 0)
|
||||||
const maxPeriods = computed(() => {
|
const maxPeriods = computed(() => {
|
||||||
@@ -174,6 +196,13 @@ const sortedExamEntries = computed(() =>
|
|||||||
String(a.courseCode).localeCompare(String(b.courseCode)),
|
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(() =>
|
const visibleGridEntries = computed(() =>
|
||||||
viewMode.value === 'overview'
|
viewMode.value === 'overview'
|
||||||
? (timetable.value?.entries ?? [])
|
? (timetable.value?.entries ?? [])
|
||||||
@@ -215,19 +244,31 @@ function weeks(entry: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function entryKey(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) {
|
function entryTiming(entry: any) {
|
||||||
const periods = `第 ${entry.startPeriod}—${entry.startPeriod + entry.periodCount - 1} 节`
|
const periods = `第 ${entry.startPeriod}—${entry.startPeriod + entry.periodCount - 1} 节`
|
||||||
|
if (entry.isExperiment) {
|
||||||
|
return `${formatActivityDate(entry.experimentDate, '实验日期待定')} · ${periods}`
|
||||||
|
}
|
||||||
return entry.isExam
|
return entry.isExam
|
||||||
? `${formatExamDate(entry.examDate)} · ${periods}`
|
? `${formatActivityDate(entry.examDate, '考试日期待定')} · ${periods}`
|
||||||
: `${weeks(entry)} · ${periods}`
|
: `${weeks(entry)} · ${periods}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExamDate(value?: string) {
|
function formatExamDate(value?: string) {
|
||||||
|
return formatActivityDate(value, '考试日期待定')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActivityDate(value?: string, fallback = '日期待定') {
|
||||||
const date = parseDate(value)
|
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) {
|
function location(entry: any) {
|
||||||
@@ -890,9 +931,14 @@ onMounted(async () => {
|
|||||||
<template v-if="examEntries.length">
|
<template v-if="examEntries.length">
|
||||||
· 已融入 {{ examEntries.length }} 场考试
|
· 已融入 {{ examEntries.length }} 场考试
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="experimentEntries.length">
|
||||||
|
· 已融入 {{ experimentEntries.length }} 场实验
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="examEntries.length">
|
<span v-else-if="examEntries.length || experimentEntries.length">
|
||||||
固定课表尚未发布 · 已载入 {{ examEntries.length }} 场考试
|
固定课表尚未发布
|
||||||
|
<template v-if="examEntries.length"> · 已载入 {{ examEntries.length }} 场考试</template>
|
||||||
|
<template v-if="experimentEntries.length"> · 已载入 {{ experimentEntries.length }} 场实验</template>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>本学期课表尚未发布</span>
|
<span v-else>本学期课表尚未发布</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -920,7 +966,9 @@ onMounted(async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div
|
<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"
|
class="timetable-view-section"
|
||||||
>
|
>
|
||||||
<div class="view-context">
|
<div class="view-context">
|
||||||
@@ -928,12 +976,12 @@ onMounted(async () => {
|
|||||||
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
|
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
|
||||||
<span>
|
<span>
|
||||||
{{ viewMode === 'overview'
|
{{ viewMode === 'overview'
|
||||||
? '固定课程按节次展示,考试安排按日期列于下方'
|
? '固定课程按节次展示,考试与实验按日期列于下方'
|
||||||
: `本周共 ${weekEntries.length} 项课程、考试安排` }}
|
: `本周共 ${weekEntries.length} 项课程、考试与实验安排` }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<small v-if="viewMode === 'overview'">课程卡片保留起止周与单双周信息</small>
|
<small v-if="viewMode === 'overview'">课程卡片保留起止周与单双周信息</small>
|
||||||
<small v-else-if="!weekEntries.length">本周没有课程或考试安排</small>
|
<small v-else-if="!weekEntries.length">本周没有课程、考试或实验安排</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="timetable-scroll">
|
<div class="timetable-scroll">
|
||||||
<div
|
<div
|
||||||
@@ -963,14 +1011,21 @@ onMounted(async () => {
|
|||||||
v-for="entry in visibleGridEntries"
|
v-for="entry in visibleGridEntries"
|
||||||
:key="entryKey(entry)"
|
:key="entryKey(entry)"
|
||||||
class="course-block"
|
class="course-block"
|
||||||
:class="{ 'exam-block': entry.isExam }"
|
:class="{
|
||||||
|
'exam-block': entry.isExam,
|
||||||
|
'experiment-block': entry.isExperiment,
|
||||||
|
}"
|
||||||
:style="gridEntryStyle(entry)"
|
:style="gridEntryStyle(entry)"
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
<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>
|
</strong>
|
||||||
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
<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>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
|
||||||
<span>{{ location(entry) }}</span>
|
<span>{{ location(entry) }}</span>
|
||||||
<small>{{ entryTiming(entry) }}</small>
|
<small>{{ entryTiming(entry) }}</small>
|
||||||
@@ -1006,24 +1061,33 @@ onMounted(async () => {
|
|||||||
v-for="entry in dayEntries"
|
v-for="entry in dayEntries"
|
||||||
:key="entryKey(entry)"
|
:key="entryKey(entry)"
|
||||||
class="day-course-block"
|
class="day-course-block"
|
||||||
:class="{ 'exam-block': entry.isExam }"
|
:class="{
|
||||||
|
'exam-block': entry.isExam,
|
||||||
|
'experiment-block': entry.isExperiment,
|
||||||
|
}"
|
||||||
:style="dayEntryStyle(entry)"
|
:style="dayEntryStyle(entry)"
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
<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>
|
</strong>
|
||||||
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
<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>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
|
||||||
<span>{{ location(entry) }}</span>
|
<span>{{ location(entry) }}</span>
|
||||||
<small>{{ entryTiming(entry) }}</small>
|
<small>{{ entryTiming(entry) }}</small>
|
||||||
</article>
|
</article>
|
||||||
<span v-if="!dayEntries.length" class="day-no-courses">当日无课程安排</span>
|
<span v-if="!dayEntries.length" class="day-no-courses">当日无教学安排</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty
|
<el-empty
|
||||||
v-else-if="timetable && !loading && !hasTimedEntries && !timetable.flexibleCourses?.length"
|
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">
|
<section v-if="viewMode === 'overview' && sortedExamEntries.length" class="exam-overview">
|
||||||
@@ -1054,6 +1118,38 @@ onMounted(async () => {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1069,7 +1165,7 @@ onMounted(async () => {
|
|||||||
<span>LIVE FEED</span>
|
<span>LIVE FEED</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>订阅已启用</strong>
|
<strong>订阅已启用</strong>
|
||||||
<small>课表和考试安排变更后,日历客户端会在下次同步时自动更新。</small>
|
<small>课表、考试和实验安排变更后,日历客户端会在下次同步时自动更新。</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1132,6 +1228,7 @@ onMounted(async () => {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>已发布课表中的固定课程和调停课后的最新安排</li>
|
<li>已发布课表中的固定课程和调停课后的最新安排</li>
|
||||||
<li>学生考试、补考;教师授课、监考和补考监考</li>
|
<li>学生考试、补考;教师授课、监考和补考监考</li>
|
||||||
|
<li>集中实验及学生本人已预约的自主实验场次</li>
|
||||||
<li>非排时课程会在学期首日生成全天提醒</li>
|
<li>非排时课程会在学期首日生成全天提醒</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>苹果日历可直接点击订阅;Google 日历、Outlook 可复制地址后选择“通过 URL 添加”。服务需具备外网可访问的 HTTPS 地址才能跨设备同步。</p>
|
<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, .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 strong, .day-course-block.exam-block strong { color: #78391e; }
|
||||||
.course-block.exam-block small, .day-course-block.exam-block small { color: #8b5b43; }
|
.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 { 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 { 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 { 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; }
|
.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 > strong { color: #533225; font-size: 15px; }
|
||||||
.exam-summary p { margin: 0; color: #6e584e; font-size: 12px; }
|
.exam-summary p { margin: 0; color: #6e584e; font-size: 12px; }
|
||||||
.exam-summary small { color: #8a7469; font-size: 11px; line-height: 1.5; }
|
.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 { 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 { padding: 14px 16px; display: flex; align-items: baseline; justify-content: space-between; background: #173e72; color: #fff; }
|
||||||
.day-view > header > div { display: grid; gap: 3px; }
|
.day-view > header > div { display: grid; gap: 3px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user