主要更新:

班级课表支持年级、学院、专业、行政班分级筛选。
管理员课表查询中心支持班级、教师、场地三种课表。
超级管理员、校级教务、学院教务、领导可查询草稿和已发布版本;学院管理员保留学院数据范围。
增加周视图、日视图切换。
支持 Excel 导出和当前视图 PDF 导出。
学生端增加“空闲教室”,支持学期、周次、星期、连续节次、校区、教学楼、容量筛选,并按教学楼分组、分页展示。
空闲教室只依据正式课表计算;未配置节次表时自动提供默认节次。
This commit is contained in:
2026-07-25 08:53:42 +08:00 Unverified
parent fe508054d0
commit 0d34936d38
15 changed files with 2411 additions and 209 deletions
@@ -0,0 +1,19 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Timetables;
public static class FreeClassroomRules
{
public static bool MatchesWeek(WeekPattern pattern, int week) =>
pattern == WeekPattern.All ||
pattern == WeekPattern.Odd && week % 2 == 1 ||
pattern == WeekPattern.Even && week % 2 == 0;
public static bool PeriodsOverlap(
int requestedStart,
int requestedCount,
int occupiedStart,
int occupiedCount) =>
requestedStart < occupiedStart + occupiedCount &&
occupiedStart < requestedStart + requestedCount;
}
@@ -0,0 +1,380 @@
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);
return new TimetableData(
term,
subject,
resourceType == TimetableResourceType.Class ? subject : null,
student,
plan,
slots,
entries,
flexibleCourses);
}
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)
.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);
}
}
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);
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);
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);
@@ -0,0 +1,162 @@
using ClosedXML.Excel;
namespace Jiaowu.Api.Infrastructure.Timetables;
public static class TimetableExcelExporter
{
private static readonly string[] Weekdays =
["", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"];
public static byte[] Create(TimetableData timetable)
{
using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add("课表");
sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.Range("A1:H1").Merge();
sheet.Cell("A1").Value = $"{timetable.Subject.Name} · {timetable.Term.Name}课表";
sheet.Cell("A1").Style
.Font.SetBold()
.Font.SetFontSize(18)
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(XLColor.FromHtml("#173E72"))
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Left)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
sheet.Row(1).Height = 34;
sheet.Range("A2:H2").Merge();
sheet.Cell("A2").Value = timetable.Plan is null
? "本学期暂无可用课表版本"
: $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
sheet.Cell("A2").Style
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
.Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6"));
sheet.Row(2).Height = 24;
var headerRow = 4;
var headers = new[] { "节次 / 时间" }.Concat(Weekdays.Skip(1)).ToArray();
for (var column = 1; column <= headers.Length; column++)
{
var cell = sheet.Cell(headerRow, column);
cell.Value = headers[column - 1];
cell.Style
.Font.SetBold()
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(XLColor.FromHtml("#24706A"))
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
}
sheet.Row(headerRow).Height = 26;
var periods = timetable.Slots.Select(x => x.PeriodNumber)
.Concat(timetable.Entries.SelectMany(x =>
Enumerable.Range(x.StartPeriod, x.PeriodCount)))
.Distinct()
.Order()
.ToArray();
if (periods.Length == 0) periods = Enumerable.Range(1, 8).ToArray();
for (var index = 0; index < periods.Length; index++)
{
var period = periods[index];
var row = headerRow + index + 1;
var slot = timetable.Slots.FirstOrDefault(x => x.PeriodNumber == period);
sheet.Cell(row, 1).Value = slot is null
? $"第 {period} 节"
: $"第 {period} 节\n{slot.StartsAt:HH\\:mm}-{slot.EndsAt:HH\\:mm}";
sheet.Cell(row, 1).Style
.Font.SetBold()
.Fill.SetBackgroundColor(XLColor.FromHtml("#F0F4F6"))
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center)
.Alignment.SetWrapText();
for (var day = 1; day <= 7; day++)
{
var entries = timetable.Entries
.Where(x =>
x.DayOfWeek == day &&
x.StartPeriod <= period &&
x.StartPeriod + x.PeriodCount - 1 >= period)
.ToList();
var cell = sheet.Cell(row, day + 1);
cell.Value = string.Join("\n\n", entries.Select(EntryText));
cell.Style
.Alignment.SetVertical(XLAlignmentVerticalValues.Top)
.Alignment.SetWrapText();
if (entries.Count > 0)
{
cell.Style
.Fill.SetBackgroundColor(XLColor.FromHtml("#E8F3F2"))
.Font.SetFontColor(XLColor.FromHtml("#173F4C"));
}
}
sheet.Row(row).Height = 64;
}
var grid = sheet.Range(
headerRow,
1,
headerRow + periods.Length,
headers.Length);
grid.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
grid.Style.Border.InsideBorderColor = XLColor.FromHtml("#D8E1E6");
grid.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
grid.Style.Border.OutsideBorderColor = XLColor.FromHtml("#AABBC5");
sheet.Column(1).Width = 16;
for (var column = 2; column <= 8; column++) sheet.Column(column).Width = 24;
sheet.SheetView.FreezeRows(headerRow);
if (timetable.FlexibleCourses.Count > 0)
{
var startRow = headerRow + periods.Length + 3;
sheet.Range(startRow, 1, startRow, 8).Merge();
sheet.Cell(startRow, 1).Value = "非排时课程(不占正常上课时间与场地)";
sheet.Cell(startRow, 1).Style
.Font.SetBold()
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(XLColor.FromHtml("#805C27"));
var row = startRow + 1;
foreach (var course in timetable.FlexibleCourses)
{
sheet.Range(row, 1, row, 8).Merge();
sheet.Cell(row, 1).Value =
$"{course.CourseCode} · {course.CourseName} · {string.Join('、', course.TeacherNames)} · 第 {course.StartWeek}-{course.EndWeek} 周 · 每周 {course.WeeklyHours} 学时";
sheet.Cell(row, 1).Style
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
.Alignment.SetWrapText();
row++;
}
}
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
private static string EntryText(TimetableEntryDto entry) =>
$"{entry.CourseName}\n" +
$"{string.Join('、', entry.TeacherNames)}\n" +
$"{Location(entry)}\n" +
$"{entry.StartWeek}-{entry.EndWeek} 周";
private static string Location(TimetableEntryDto entry) =>
string.Join(" · ", new[]
{
entry.CampusName,
entry.BuildingName,
entry.ClassroomName
}.Where(x => !string.IsNullOrWhiteSpace(x)));
private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) =>
status switch
{
Domain.Academic.SchedulePlanStatus.Draft => "草稿",
Domain.Academic.SchedulePlanStatus.Published => "已发布",
Domain.Academic.SchedulePlanStatus.Archived => "已归档",
_ => status.ToString()
};
}