实验课表
This commit is contained in:
@@ -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[]
|
||||
{
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user