切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1) 桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211) 归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。 已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
487 lines
17 KiB
C#
487 lines
17 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Infrastructure.Timetables;
|
|
|
|
public sealed class TimetableDataService(AppDbContext db)
|
|
{
|
|
public async Task<TimetableData?> BuildAsync(
|
|
TimetableResourceType resourceType,
|
|
Guid resourceId,
|
|
Guid? academicTermId,
|
|
Guid? schedulePlanId,
|
|
bool allowUnpublishedPlan,
|
|
Guid? studentId,
|
|
TimetableStudentDto? student,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var subject = await LoadSubjectAsync(resourceType, resourceId, cancellationToken);
|
|
if (subject is null) return null;
|
|
|
|
var term = await ResolveTermAsync(academicTermId, cancellationToken);
|
|
if (term is null) return null;
|
|
var plan = await ResolvePlanAsync(
|
|
term.Id,
|
|
schedulePlanId,
|
|
allowUnpublishedPlan,
|
|
cancellationToken);
|
|
var slots = await db.ScheduleTimeSlots.AsNoTracking()
|
|
.Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
|
|
.OrderBy(x => x.PeriodNumber)
|
|
.Select(x => new TimetableSlotDto(
|
|
x.PeriodNumber,
|
|
x.Name,
|
|
x.StartsAt,
|
|
x.EndsAt))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var entries = new List<TimetableEntryDto>();
|
|
if (plan is not null)
|
|
{
|
|
var source = db.ScheduleEntries.AsNoTracking()
|
|
.Where(x => x.SchedulePlanId == plan.Id);
|
|
switch (resourceType)
|
|
{
|
|
case TimetableResourceType.Class:
|
|
if (studentId.HasValue)
|
|
{
|
|
var selectedTaskIds = db.CourseEnrollments.AsNoTracking()
|
|
.Where(x =>
|
|
x.StudentId == studentId.Value &&
|
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
|
x.CourseSelectionOffering!.CourseSelectionRound!
|
|
.AcademicTermId == term.Id)
|
|
.Select(x => x.CourseSelectionOffering!.TeachingTaskId);
|
|
source = source.Where(x =>
|
|
x.TeachingTask!.Classes.Any(item =>
|
|
item.AdministrativeClassId == resourceId) ||
|
|
selectedTaskIds.Contains(x.TeachingTaskId));
|
|
}
|
|
else
|
|
{
|
|
source = source.Where(x =>
|
|
x.TeachingTask!.Classes.Any(item =>
|
|
item.AdministrativeClassId == resourceId));
|
|
}
|
|
break;
|
|
case TimetableResourceType.Teacher:
|
|
source = source.Where(x =>
|
|
x.TeachingTask!.Teachers.Any(item => item.TeacherId == resourceId));
|
|
break;
|
|
case TimetableResourceType.Classroom:
|
|
source = source.Where(x => x.ClassroomId == resourceId);
|
|
break;
|
|
}
|
|
|
|
entries = await source
|
|
.OrderBy(x => x.DayOfWeek)
|
|
.ThenBy(x => x.StartPeriod)
|
|
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
|
.Select(x => new TimetableEntryDto(
|
|
x.Id,
|
|
x.TeachingTaskId,
|
|
x.TeachingTask!.TaskNumber,
|
|
x.TeachingTask.Name,
|
|
x.TeachingTask.Course!.Code,
|
|
x.TeachingTask.Course.Name,
|
|
x.TeachingTask.Teachers
|
|
.OrderByDescending(item => item.IsPrimary)
|
|
.Select(item => item.Teacher!.Name),
|
|
x.TeachingTask.Classes
|
|
.Select(item => item.AdministrativeClass!.Name),
|
|
x.Classroom == null ? "不占用教室" : x.Classroom.Name,
|
|
x.Classroom == null ? null : x.Classroom.Building!.Name,
|
|
x.Classroom == null ? null : x.Classroom.Building!.Campus!.Name,
|
|
x.DayOfWeek,
|
|
x.StartPeriod,
|
|
x.PeriodCount,
|
|
x.StartWeek,
|
|
x.EndWeek,
|
|
x.WeekPattern,
|
|
x.Notes))
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
var flexibleCourses = await LoadFlexibleCoursesAsync(
|
|
resourceType,
|
|
resourceId,
|
|
term.Id,
|
|
studentId,
|
|
cancellationToken);
|
|
|
|
// Load exam sessions for student/teacher timetables
|
|
var examEntries = await LoadExamEntriesAsync(
|
|
resourceType, resourceId, term.Id, studentId, slots, cancellationToken);
|
|
|
|
return new TimetableData(
|
|
term,
|
|
subject,
|
|
resourceType == TimetableResourceType.Class ? subject : null,
|
|
student,
|
|
plan,
|
|
slots,
|
|
entries,
|
|
flexibleCourses,
|
|
examEntries);
|
|
}
|
|
|
|
private async Task<TimetableSubjectDto?> LoadSubjectAsync(
|
|
TimetableResourceType resourceType,
|
|
Guid resourceId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return resourceType switch
|
|
{
|
|
TimetableResourceType.Class => await db.AdministrativeClasses.AsNoTracking()
|
|
.Where(x => x.Id == resourceId && x.IsEnabled)
|
|
.Select(x => new TimetableSubjectDto(
|
|
x.Id,
|
|
x.Code,
|
|
x.Name,
|
|
TimetableResourceType.Class,
|
|
x.Grade,
|
|
x.MajorId,
|
|
x.Major!.Name,
|
|
x.Major.CollegeId,
|
|
x.Major.College!.Name,
|
|
null,
|
|
null,
|
|
null))
|
|
.FirstOrDefaultAsync(cancellationToken),
|
|
TimetableResourceType.Teacher => await db.Teachers.AsNoTracking()
|
|
.Where(x => x.Id == resourceId && x.Status == TeacherStatus.Active)
|
|
.Select(x => new TimetableSubjectDto(
|
|
x.Id,
|
|
x.TeacherNumber,
|
|
x.Name,
|
|
TimetableResourceType.Teacher,
|
|
null,
|
|
null,
|
|
null,
|
|
x.CollegeId,
|
|
x.College!.Name,
|
|
null,
|
|
null,
|
|
x.Title))
|
|
.FirstOrDefaultAsync(cancellationToken),
|
|
TimetableResourceType.Classroom => await db.Classrooms.AsNoTracking()
|
|
.Where(x => x.Id == resourceId && x.IsEnabled)
|
|
.Select(x => new TimetableSubjectDto(
|
|
x.Id,
|
|
x.Code,
|
|
x.Name,
|
|
TimetableResourceType.Classroom,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
x.Building!.CampusId,
|
|
x.BuildingId,
|
|
$"{x.Building.Campus!.Name} · {x.Building.Name} · {x.Capacity} 人"))
|
|
.FirstOrDefaultAsync(cancellationToken),
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private async Task<TimetableTermDto?> ResolveTermAsync(
|
|
Guid? academicTermId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var query = db.AcademicTerms.AsNoTracking().Where(x => x.IsEnabled);
|
|
if (academicTermId.HasValue)
|
|
query = query.Where(x => x.Id == academicTermId);
|
|
else
|
|
query = query.OrderByDescending(x => x.IsCurrent)
|
|
.ThenBy(x => x.IsArchived)
|
|
.ThenByDescending(x => x.StartDate);
|
|
return await query.Select(x => new TimetableTermDto(
|
|
x.Id,
|
|
x.Name,
|
|
x.AcademicYear,
|
|
x.Season,
|
|
x.StartDate,
|
|
x.EndDate,
|
|
x.IsCurrent))
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task<TimetablePlanDto?> ResolvePlanAsync(
|
|
Guid academicTermId,
|
|
Guid? schedulePlanId,
|
|
bool allowUnpublishedPlan,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var query = db.SchedulePlans.AsNoTracking()
|
|
.Where(x => x.AcademicTermId == academicTermId);
|
|
if (schedulePlanId.HasValue)
|
|
query = query.Where(x => x.Id == schedulePlanId);
|
|
if (!allowUnpublishedPlan)
|
|
query = query.Where(x => x.Status == SchedulePlanStatus.Published);
|
|
else
|
|
query = query.Where(x => x.Status != SchedulePlanStatus.Archived);
|
|
return await query
|
|
.OrderByDescending(x => x.Status == SchedulePlanStatus.Published)
|
|
.ThenByDescending(x => x.PublishedAt)
|
|
.ThenByDescending(x => x.CreatedAt)
|
|
.Select(x => new TimetablePlanDto(
|
|
x.Id,
|
|
x.Name,
|
|
x.Version,
|
|
x.Status,
|
|
x.PublishedAt,
|
|
x.UpdatedAt))
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task<List<FlexibleCourseDto>> LoadFlexibleCoursesAsync(
|
|
TimetableResourceType resourceType,
|
|
Guid resourceId,
|
|
Guid academicTermId,
|
|
Guid? studentId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (resourceType == TimetableResourceType.Classroom) return [];
|
|
var source = db.TeachingTasks.AsNoTracking()
|
|
.Where(x =>
|
|
x.AcademicTermId == academicTermId &&
|
|
x.Status == TeachingTaskStatus.Published &&
|
|
x.SchedulingMode == TeachingTaskSchedulingMode.Flexible);
|
|
if (resourceType == TimetableResourceType.Teacher)
|
|
{
|
|
source = source.Where(x =>
|
|
x.Teachers.Any(item => item.TeacherId == resourceId));
|
|
}
|
|
else if (studentId.HasValue)
|
|
{
|
|
var selectedTaskIds = db.CourseEnrollments.AsNoTracking()
|
|
.Where(x =>
|
|
x.StudentId == studentId.Value &&
|
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
|
x.CourseSelectionOffering!.CourseSelectionRound!
|
|
.AcademicTermId == academicTermId)
|
|
.Select(x => x.CourseSelectionOffering!.TeachingTaskId);
|
|
source = source.Where(x =>
|
|
x.Classes.Any(item => item.AdministrativeClassId == resourceId) ||
|
|
selectedTaskIds.Contains(x.Id));
|
|
}
|
|
else
|
|
{
|
|
source = source.Where(x =>
|
|
x.Classes.Any(item => item.AdministrativeClassId == resourceId));
|
|
}
|
|
|
|
return await source
|
|
.OrderBy(x => x.Course!.Code)
|
|
.ThenBy(x => x.TaskNumber)
|
|
.Select(x => new FlexibleCourseDto(
|
|
x.Id,
|
|
x.TaskNumber,
|
|
x.Name,
|
|
x.Course!.Code,
|
|
x.Course.Name,
|
|
x.Course.Credits,
|
|
x.Course.TotalHours,
|
|
x.StartWeek,
|
|
x.EndWeek,
|
|
x.WeeklyHours,
|
|
x.Teachers
|
|
.OrderByDescending(item => item.IsPrimary)
|
|
.Select(item => item.Teacher!.Name),
|
|
x.Classes.Select(item => item.AdministrativeClass!.Name),
|
|
x.Notes))
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task<List<TimetableEntryDto>> LoadExamEntriesAsync(
|
|
TimetableResourceType resourceType,
|
|
Guid resourceId,
|
|
Guid academicTermId,
|
|
Guid? studentId,
|
|
IReadOnlyList<TimetableSlotDto> slots,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (resourceType == TimetableResourceType.Classroom) return [];
|
|
|
|
var slotLookup = slots.ToDictionary(x => x.PeriodNumber);
|
|
|
|
IQueryable<ExamSession> source = db.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ExamPlan!.AcademicTermId == academicTermId &&
|
|
x.ExamPlan.Status == ExamPlanStatus.Published &&
|
|
x.ClassroomId != null);
|
|
|
|
if (resourceType == TimetableResourceType.Teacher)
|
|
{
|
|
source = source.Where(x =>
|
|
x.Invigilators.Any(i => i.TeacherId == resourceId));
|
|
}
|
|
else if (studentId.HasValue)
|
|
{
|
|
source = source.Where(x =>
|
|
db.CourseEnrollments.Any(e =>
|
|
e.StudentId == studentId.Value &&
|
|
e.Status == CourseEnrollmentStatus.Enrolled &&
|
|
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId));
|
|
}
|
|
else
|
|
{
|
|
// Class timetable: exams for the class's teaching tasks
|
|
source = source.Where(x =>
|
|
x.TeachingTask!.Classes.Any(c =>
|
|
c.AdministrativeClassId == resourceId));
|
|
}
|
|
|
|
var sessions = await source
|
|
.OrderBy(x => x.ExamDate)
|
|
.ThenBy(x => x.StartPeriod)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TeachingTaskId,
|
|
x.TeachingTask!.TaskNumber,
|
|
TaskName = x.TeachingTask.Name,
|
|
CourseCode = x.TeachingTask.Course!.Code,
|
|
CourseName = x.TeachingTask.Course.Name,
|
|
TeacherNames = x.TeachingTask.Teachers
|
|
.OrderByDescending(t => t.IsPrimary)
|
|
.Select(t => t.Teacher!.Name),
|
|
ClassNames = x.TeachingTask.Classes
|
|
.Select(c => c.AdministrativeClass!.Name),
|
|
ClassroomName = x.Classroom!.Name,
|
|
BuildingName = x.Classroom.Building!.Name,
|
|
CampusName = x.Classroom.Building.Campus!.Name,
|
|
x.ExamDate,
|
|
x.StartPeriod,
|
|
x.PeriodCount,
|
|
x.ExamPlan!.Name,
|
|
InvigilatorNames = x.Invigilators
|
|
.Select(i => i.Teacher!.Name),
|
|
x.Notes
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return sessions.Select(x =>
|
|
{
|
|
var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek;
|
|
return new TimetableEntryDto(
|
|
x.Id,
|
|
x.TeachingTaskId,
|
|
x.TaskNumber,
|
|
x.TaskName,
|
|
x.CourseCode,
|
|
x.CourseName,
|
|
x.TeacherNames.Concat(
|
|
new[] { "监考:" + string.Join("、", x.InvigilatorNames) }),
|
|
x.ClassNames,
|
|
x.ClassroomName,
|
|
x.BuildingName,
|
|
x.CampusName,
|
|
dayOfWeek,
|
|
x.StartPeriod,
|
|
x.PeriodCount,
|
|
1, 1,
|
|
WeekPattern.All,
|
|
x.Notes,
|
|
true,
|
|
x.Name,
|
|
x.ExamDate);
|
|
}).ToList();
|
|
}
|
|
}
|
|
|
|
public enum TimetableResourceType
|
|
{
|
|
Class = 1,
|
|
Teacher = 2,
|
|
Classroom = 3
|
|
}
|
|
|
|
public sealed record TimetableData(
|
|
TimetableTermDto Term,
|
|
TimetableSubjectDto Subject,
|
|
TimetableSubjectDto? Class,
|
|
TimetableStudentDto? Student,
|
|
TimetablePlanDto? Plan,
|
|
IReadOnlyList<TimetableSlotDto> Slots,
|
|
IReadOnlyList<TimetableEntryDto> Entries,
|
|
IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
|
|
IReadOnlyList<TimetableEntryDto> ExamEntries);
|
|
|
|
public sealed record TimetableTermDto(
|
|
Guid Id,
|
|
string Name,
|
|
string AcademicYear,
|
|
TermSeason Season,
|
|
DateOnly StartDate,
|
|
DateOnly EndDate,
|
|
bool IsCurrent);
|
|
|
|
public sealed record TimetableSubjectDto(
|
|
Guid Id,
|
|
string Code,
|
|
string Name,
|
|
TimetableResourceType Type,
|
|
int? Grade,
|
|
Guid? MajorId,
|
|
string? MajorName,
|
|
Guid? CollegeId,
|
|
string? CollegeName,
|
|
Guid? CampusId,
|
|
Guid? BuildingId,
|
|
string? Description);
|
|
|
|
public sealed record TimetableStudentDto(string StudentNumber, string Name);
|
|
|
|
public sealed record TimetablePlanDto(
|
|
Guid Id,
|
|
string Name,
|
|
string Version,
|
|
SchedulePlanStatus Status,
|
|
DateTime? PublishedAt,
|
|
DateTime UpdatedAt);
|
|
|
|
public sealed record TimetableSlotDto(
|
|
int PeriodNumber,
|
|
string Name,
|
|
TimeOnly StartsAt,
|
|
TimeOnly EndsAt);
|
|
|
|
public sealed record TimetableEntryDto(
|
|
Guid Id,
|
|
Guid TeachingTaskId,
|
|
string TaskNumber,
|
|
string TaskName,
|
|
string CourseCode,
|
|
string CourseName,
|
|
IEnumerable<string> TeacherNames,
|
|
IEnumerable<string> ClassNames,
|
|
string ClassroomName,
|
|
string? BuildingName,
|
|
string? CampusName,
|
|
int DayOfWeek,
|
|
int StartPeriod,
|
|
int PeriodCount,
|
|
int StartWeek,
|
|
int EndWeek,
|
|
WeekPattern WeekPattern,
|
|
string? Notes,
|
|
bool IsExam = false,
|
|
string? ExamPlanName = null,
|
|
DateOnly? ExamDate = null);
|
|
|
|
public sealed record FlexibleCourseDto(
|
|
Guid Id,
|
|
string TaskNumber,
|
|
string Name,
|
|
string CourseCode,
|
|
string CourseName,
|
|
decimal Credits,
|
|
int TotalHours,
|
|
int StartWeek,
|
|
int EndWeek,
|
|
int WeeklyHours,
|
|
IEnumerable<string> TeacherNames,
|
|
IEnumerable<string> ClassNames,
|
|
string? Notes);
|