diff --git a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs new file mode 100644 index 0000000..128cc05 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs @@ -0,0 +1,402 @@ +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Timetables; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize(Roles = ManagementRoles)] +[Route("api/timetables/management")] +public sealed class TimetableManagementController( + AppDbContext db, + TimetableDataService timetableDataService, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + private const string ManagementRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin + "," + + SystemRoles.Leader; + + [HttpGet("options")] + public async Task GetOptions( + Guid academicTermId, + CancellationToken cancellationToken) + { + var scope = currentUserDataScope.Current; + var collegeId = scope.RestrictedCollegeId; + var classes = db.AdministrativeClasses.AsNoTracking() + .Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled); + var teachers = db.Teachers.AsNoTracking() + .Where(x => x.Status == TeacherStatus.Active && x.College!.IsEnabled); + var colleges = db.Colleges.AsNoTracking().Where(x => x.IsEnabled); + var majors = db.Majors.AsNoTracking().Where(x => x.IsEnabled); + if (collegeId.HasValue) + { + classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value); + teachers = teachers.Where(x => x.CollegeId == collegeId.Value); + colleges = colleges.Where(x => x.Id == collegeId.Value); + majors = majors.Where(x => x.CollegeId == collegeId.Value); + } + + var plans = await db.SchedulePlans.AsNoTracking() + .Where(x => + x.AcademicTermId == academicTermId && + x.Status != SchedulePlanStatus.Archived) + .OrderByDescending(x => x.Status == SchedulePlanStatus.Published) + .ThenByDescending(x => x.UpdatedAt) + .Select(x => new + { + x.Id, + x.Name, + x.Version, + x.Status, + x.PublishedAt, + x.UpdatedAt, + EntryCount = x.Entries.Count + }) + .ToListAsync(cancellationToken); + var classOptions = await classes + .OrderByDescending(x => x.Grade) + .ThenBy(x => x.Code) + .Select(x => new + { + x.Id, + x.Code, + x.Name, + x.Grade, + x.MajorId, + MajorName = x.Major!.Name, + CollegeId = x.Major.CollegeId, + CollegeName = x.Major.College!.Name + }) + .ToListAsync(cancellationToken); + var teacherOptions = await teachers + .OrderBy(x => x.TeacherNumber) + .Select(x => new + { + x.Id, + x.TeacherNumber, + x.Name, + x.Title, + x.CollegeId, + CollegeName = x.College!.Name + }) + .ToListAsync(cancellationToken); + var campusOptions = await db.Campuses.AsNoTracking() + .Where(x => x.IsEnabled) + .OrderBy(x => x.SortOrder) + .Select(x => new { x.Id, x.Code, x.Name }) + .ToListAsync(cancellationToken); + var buildingOptions = await db.Buildings.AsNoTracking() + .Where(x => x.IsEnabled && x.Campus!.IsEnabled) + .OrderBy(x => x.SortOrder) + .Select(x => new { x.Id, x.Code, x.Name, x.CampusId }) + .ToListAsync(cancellationToken); + var classroomOptions = await db.Classrooms.AsNoTracking() + .Where(x => x.IsEnabled && x.Building!.IsEnabled && x.Building.Campus!.IsEnabled) + .OrderBy(x => x.Building!.Code) + .ThenBy(x => x.Code) + .Select(x => new + { + x.Id, + x.Code, + x.Name, + x.Capacity, + x.BuildingId, + BuildingName = x.Building!.Name, + CampusId = x.Building.CampusId, + CampusName = x.Building.Campus!.Name + }) + .ToListAsync(cancellationToken); + return Ok(new + { + Plans = plans, + Colleges = await colleges.OrderBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name }) + .ToListAsync(cancellationToken), + Majors = await majors.OrderBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId }) + .ToListAsync(cancellationToken), + Classes = classOptions, + Teachers = teacherOptions, + Campuses = campusOptions, + Buildings = buildingOptions, + Classrooms = classroomOptions + }); + } + + [HttpGet("query")] + public async Task Query( + TimetableResourceType resourceType, + Guid resourceId, + Guid academicTermId, + Guid? schedulePlanId, + CancellationToken cancellationToken) + { + var result = await LoadAuthorizedAsync( + resourceType, + resourceId, + academicTermId, + schedulePlanId, + cancellationToken); + return result.Result is null ? result.Error! : Ok(result.Result); + } + + [HttpGet("export.xlsx")] + public async Task Export( + TimetableResourceType resourceType, + Guid resourceId, + Guid academicTermId, + Guid? schedulePlanId, + CancellationToken cancellationToken) + { + var result = await LoadAuthorizedAsync( + resourceType, + resourceId, + academicTermId, + schedulePlanId, + cancellationToken); + if (result.Result is null) return result.Error!; + var bytes = TimetableExcelExporter.Create(result.Result); + var fileName = + $"{FileName(result.Result.Subject.Name)}-{FileName(result.Result.Term.Name)}-课表.xlsx"; + return File(bytes, ExcelWorkbookHelper.ContentType, fileName); + } + + private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync( + TimetableResourceType resourceType, + Guid resourceId, + Guid academicTermId, + Guid? schedulePlanId, + CancellationToken cancellationToken) + { + if (!Enum.IsDefined(resourceType)) + return (null, ValidationProblem("课表类型无效。")); + var result = await timetableDataService.BuildAsync( + resourceType, + resourceId, + academicTermId, + schedulePlanId, + true, + null, + null, + cancellationToken); + if (result is null || schedulePlanId.HasValue && result.Plan is null) + return (null, NotFound()); + var scope = currentUserDataScope.Current; + if (resourceType is TimetableResourceType.Class or TimetableResourceType.Teacher && + result.Subject.CollegeId.HasValue && + !scope.CanAccessCollege(result.Subject.CollegeId.Value)) + return (null, Forbid()); + return (result, null); + } + + private static string FileName(string value) + { + foreach (var character in Path.GetInvalidFileNameChars()) + value = value.Replace(character, '-'); + return value.Trim(); + } +} + +[ApiController] +[Route("api/timetables")] +public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase +{ + [HttpGet("free-classrooms/options")] + [Authorize(Roles = SystemRoles.Student)] + public async Task GetOptions( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var terms = await db.AcademicTerms.AsNoTracking() + .Where(x => x.IsEnabled) + .OrderByDescending(x => x.IsCurrent) + .ThenByDescending(x => x.StartDate) + .Select(x => new + { + x.Id, + x.Name, + x.StartDate, + x.EndDate, + x.IsCurrent, + HasPublishedTimetable = db.SchedulePlans.Any(plan => + plan.AcademicTermId == x.Id && + plan.Status == SchedulePlanStatus.Published) + }) + .ToListAsync(cancellationToken); + var selectedTermId = academicTermId + ?? terms.FirstOrDefault(x => x.IsCurrent && x.HasPublishedTimetable)?.Id + ?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id; + var campuses = await db.Campuses.AsNoTracking() + .Where(x => x.IsEnabled) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name }) + .ToListAsync(cancellationToken); + var buildings = await db.Buildings.AsNoTracking() + .Where(x => x.IsEnabled && x.Campus!.IsEnabled) + .OrderBy(x => x.Campus!.SortOrder) + .ThenBy(x => x.SortOrder) + .ThenBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name, x.CampusId }) + .ToListAsync(cancellationToken); + var timeSlots = selectedTermId.HasValue + ? await db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == selectedTermId.Value && x.IsEnabled) + .OrderBy(x => x.PeriodNumber) + .Select(x => new FreeClassroomTimeSlotDto( + x.PeriodNumber, + x.Name, + x.StartsAt.ToString("HH:mm"), + x.EndsAt.ToString("HH:mm"))) + .ToListAsync(cancellationToken) + : []; + if (timeSlots.Count == 0) + { + timeSlots = Enumerable.Range(1, 12) + .Select(period => new FreeClassroomTimeSlotDto( + period, + $"第 {period} 节", + "", + "")) + .ToList(); + } + return Ok(new + { + Terms = terms, + SelectedTermId = selectedTermId, + Campuses = campuses, + Buildings = buildings, + TimeSlots = timeSlots + }); + } + + [HttpGet("free-classrooms")] + [Authorize(Roles = SystemRoles.Student)] + public async Task Get( + Guid academicTermId, + [Range(1, 30)] int week, + [Range(1, 7)] int dayOfWeek, + [Range(1, 30)] int startPeriod, + [Range(1, 6)] int periodCount = 1, + Guid? campusId = null, + Guid? buildingId = null, + [Range(0, 10000)] int? minimumCapacity = null, + CancellationToken cancellationToken = default) + { + var term = await db.AcademicTerms.AsNoTracking() + .Where(x => x.Id == academicTermId && x.IsEnabled) + .Select(x => new { x.Id, x.Name }) + .FirstOrDefaultAsync(cancellationToken); + if (term is null) return NotFound(); + var plan = await db.SchedulePlans.AsNoTracking() + .Where(x => + x.AcademicTermId == academicTermId && + x.Status == SchedulePlanStatus.Published) + .Select(x => new { x.Id, x.Version }) + .FirstOrDefaultAsync(cancellationToken); + if (plan is null) + return Conflict(new ProblemDetails + { + Title = "课表尚未发布", + Detail = "所选学期尚未发布正式课表,暂时无法准确查询空闲教室。", + Status = StatusCodes.Status409Conflict + }); + + var requestedPeriods = Enumerable.Range(startPeriod, periodCount).ToArray(); + var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking() + .CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken); + var activePeriodCount = configuredPeriodCount == 0 + ? requestedPeriods.Length + : await db.ScheduleTimeSlots.AsNoTracking().CountAsync(x => + x.AcademicTermId == academicTermId && + x.IsEnabled && + requestedPeriods.Contains(x.PeriodNumber), + cancellationToken); + if (activePeriodCount != requestedPeriods.Length) + return ValidationProblem("查询范围包含不存在或未启用的节次。"); + + var candidates = await db.ScheduleEntries.AsNoTracking() + .Where(x => + x.SchedulePlanId == plan.Id && + x.ClassroomId.HasValue && + x.DayOfWeek == dayOfWeek && + x.StartWeek <= week && + x.EndWeek >= week && + x.StartPeriod < startPeriod + periodCount && + startPeriod < x.StartPeriod + x.PeriodCount) + .Select(x => new + { + x.ClassroomId, + x.WeekPattern, + x.StartPeriod, + x.PeriodCount + }) + .ToListAsync(cancellationToken); + var occupiedIds = candidates + .Where(x => + FreeClassroomRules.MatchesWeek(x.WeekPattern, week) && + FreeClassroomRules.PeriodsOverlap( + startPeriod, + periodCount, + x.StartPeriod, + x.PeriodCount)) + .Select(x => x.ClassroomId.GetValueOrDefault()) + .ToHashSet(); + + var rooms = db.Classrooms.AsNoTracking() + .Where(x => + x.IsEnabled && + x.Building!.IsEnabled && + x.Building.Campus!.IsEnabled && + !occupiedIds.Contains(x.Id)); + if (campusId.HasValue) + rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value); + if (buildingId.HasValue) + rooms = rooms.Where(x => x.BuildingId == buildingId.Value); + if (minimumCapacity.HasValue) + rooms = rooms.Where(x => x.Capacity >= minimumCapacity.Value); + var result = await rooms + .OrderBy(x => x.Building!.Campus!.SortOrder) + .ThenBy(x => x.Building!.SortOrder) + .ThenBy(x => x.Code) + .Select(x => new + { + x.Id, + x.Code, + x.Name, + x.Capacity, + x.BuildingId, + BuildingName = x.Building!.Name, + CampusId = x.Building.CampusId, + CampusName = x.Building.Campus!.Name + }) + .ToListAsync(cancellationToken); + return Ok(new + { + Term = term, + Plan = plan, + Week = week, + DayOfWeek = dayOfWeek, + StartPeriod = startPeriod, + PeriodCount = periodCount, + Items = result + }); + } + +} + +public sealed record FreeClassroomTimeSlotDto( + int PeriodNumber, + string Name, + string StartTime, + string EndTime); diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs index 0974955..37aeefd 100644 --- a/src/Jiaowu.Api/Controllers/TimetablesController.cs +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -1,7 +1,9 @@ using System.Security.Claims; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -10,7 +12,9 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Route("api/timetables")] -public sealed class TimetablesController(AppDbContext db) : ControllerBase +public sealed class TimetablesController( + AppDbContext db, + TimetableDataService timetableDataService) : ControllerBase { [HttpGet("options")] [AllowAnonymous] @@ -47,10 +51,12 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase { x.Id, x.Code, - x.Name, - x.Grade, - MajorName = x.Major!.Name, - CollegeName = x.Major.College!.Name, + x.Name, + x.Grade, + x.MajorId, + MajorName = x.Major!.Name, + CollegeId = x.Major.CollegeId, + CollegeName = x.Major.College!.Name, HasPublishedTimetable = defaultTermId.HasValue && (db.ScheduleEntries.Any(entry => entry.SchedulePlan!.AcademicTermId == defaultTermId.Value && @@ -62,9 +68,19 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase task.Status == TeachingTaskStatus.Published && task.SchedulingMode == TeachingTaskSchedulingMode.Flexible && task.Classes.Any(item => item.AdministrativeClassId == x.Id))) - }) + }) + .ToListAsync(cancellationToken); + var colleges = await db.Colleges.AsNoTracking() + .Where(x => x.IsEnabled) + .OrderBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name }) .ToListAsync(cancellationToken); - return Ok(new { Terms = terms, Classes = classes }); + var majors = await db.Majors.AsNoTracking() + .Where(x => x.IsEnabled && x.College!.IsEnabled) + .OrderBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId }) + .ToListAsync(cancellationToken); + return Ok(new { Terms = terms, Colleges = colleges, Majors = majors, Classes = classes }); } [HttpGet("classes/{classId:guid}")] @@ -73,7 +89,27 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase Guid classId, Guid? academicTermId, CancellationToken cancellationToken) => - BuildTimetableAsync(classId, academicTermId, null, cancellationToken); + BuildTimetableAsync(classId, academicTermId, null, cancellationToken); + + [HttpGet("classes/{classId:guid}/export.xlsx")] + [AllowAnonymous] + public async Task ExportClassTimetable( + Guid classId, + Guid? academicTermId, + CancellationToken cancellationToken) + { + var result = await timetableDataService.BuildAsync( + TimetableResourceType.Class, + classId, + academicTermId, + null, + false, + null, + null, + cancellationToken); + if (result is null) return NotFound(); + return ExcelFile(result); + } [HttpGet("mine")] [Authorize(Roles = SystemRoles.Student)] @@ -102,11 +138,43 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase }); return await BuildTimetableAsync( + student.AdministrativeClassId, + academicTermId, + student.Id, + cancellationToken, + new TimetableStudentDto(student.StudentNumber, student.Name)); + } + + [HttpGet("mine/export.xlsx")] + [Authorize(Roles = SystemRoles.Student)] + public async Task ExportMyTimetable( + Guid? academicTermId, + CancellationToken cancellationToken) + { + if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) + return Unauthorized(); + var student = await db.Students.AsNoTracking() + .Where(x => x.UserId == userId) + .Select(x => new + { + x.Id, + x.AdministrativeClassId, + x.StudentNumber, + x.Name + }) + .FirstOrDefaultAsync(cancellationToken); + if (student is null) return NotFound(); + var result = await timetableDataService.BuildAsync( + TimetableResourceType.Class, student.AdministrativeClassId, academicTermId, + null, + false, student.Id, - cancellationToken, - new { student.StudentNumber, student.Name }); + new TimetableStudentDto(student.StudentNumber, student.Name), + cancellationToken); + if (result is null) return NotFound(); + return ExcelFile(result); } private async Task BuildTimetableAsync( @@ -114,174 +182,32 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase Guid? academicTermId, Guid? studentId, CancellationToken cancellationToken, - object? student = null) + TimetableStudentDto? student = null) { - var administrativeClass = await db.AdministrativeClasses.AsNoTracking() - .Where(x => x.Id == classId && x.IsEnabled) - .Select(x => new - { - x.Id, - x.Code, - x.Name, - x.Grade, - MajorName = x.Major!.Name, - CollegeName = x.Major.College!.Name - }) - .FirstOrDefaultAsync(cancellationToken); - if (administrativeClass is null) return NotFound(); + var result = await timetableDataService.BuildAsync( + TimetableResourceType.Class, + classId, + academicTermId, + null, + false, + studentId, + student, + cancellationToken); + return result is null ? NotFound() : Ok(result); + } - var termQuery = db.AcademicTerms.AsNoTracking().Where(x => x.IsEnabled); - if (academicTermId.HasValue) - termQuery = termQuery.Where(x => x.Id == academicTermId); - else - termQuery = termQuery.OrderByDescending(x => x.IsCurrent) - .ThenByDescending(x => x.StartDate); - var term = await termQuery - .Select(x => new - { - x.Id, - x.Name, - x.AcademicYear, - x.Season, - x.StartDate, - x.EndDate, - x.IsCurrent - }) - .FirstOrDefaultAsync(cancellationToken); - if (term is null) return NotFound(); + private ActionResult ExcelFile(TimetableData result) + { + var bytes = TimetableExcelExporter.Create(result); + var fileName = + $"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.xlsx"; + return File(bytes, ExcelWorkbookHelper.ContentType, fileName); + } - var plan = await db.SchedulePlans.AsNoTracking() - .Where(x => - x.AcademicTermId == term.Id && - x.Status == SchedulePlanStatus.Published) - .Select(x => new { x.Id, x.Name, x.Version, x.PublishedAt }) - .FirstOrDefaultAsync(cancellationToken); - - var slots = await db.ScheduleTimeSlots.AsNoTracking() - .Where(x => x.AcademicTermId == term.Id && x.IsEnabled) - .OrderBy(x => x.PeriodNumber) - .Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt }) - .ToListAsync(cancellationToken); - - var flexibleTasks = db.TeachingTasks.AsNoTracking() - .Where(x => - x.AcademicTermId == term.Id && - x.Status == TeachingTaskStatus.Published && - x.SchedulingMode == TeachingTaskSchedulingMode.Flexible && - x.Classes.Any(item => item.AdministrativeClassId == classId)); - if (studentId.HasValue) - { - var selectedFlexibleTaskIds = db.CourseEnrollments.AsNoTracking() - .Where(x => - x.StudentId == studentId.Value && - x.Status == CourseEnrollmentStatus.Enrolled && - x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == term.Id) - .Select(x => x.CourseSelectionOffering!.TeachingTaskId); - flexibleTasks = db.TeachingTasks.AsNoTracking() - .Where(x => - x.AcademicTermId == term.Id && - x.Status == TeachingTaskStatus.Published && - x.SchedulingMode == TeachingTaskSchedulingMode.Flexible && - (x.Classes.Any(item => item.AdministrativeClassId == classId) || - selectedFlexibleTaskIds.Contains(x.Id))); - } - var flexibleCourses = await flexibleTasks - .OrderBy(x => x.Course!.Code) - .ThenBy(x => x.TaskNumber) - .Select(x => new - { - x.Id, - x.TaskNumber, - x.Name, - CourseCode = x.Course!.Code, - CourseName = x.Course.Name, - x.Course.Credits, - x.Course.TotalHours, - x.StartWeek, - x.EndWeek, - x.WeeklyHours, - TeacherNames = x.Teachers - .OrderByDescending(item => item.IsPrimary) - .Select(item => item.Teacher!.Name), - ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name), - x.Notes - }) - .ToListAsync(cancellationToken); - - if (plan is null) - return Ok(new - { - Term = term, - Class = administrativeClass, - Student = student, - Plan = (object?)null, - Slots = slots, - Entries = Array.Empty(), - FlexibleCourses = flexibleCourses - }); - - var entries = db.ScheduleEntries.AsNoTracking() - .Where(x => - x.SchedulePlanId == plan.Id && - x.TeachingTask!.Classes.Any(item => - item.AdministrativeClassId == classId)); - 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); - entries = db.ScheduleEntries.AsNoTracking() - .Where(x => - x.SchedulePlanId == plan.Id && - (x.TeachingTask!.Classes.Any(item => - item.AdministrativeClassId == classId) || - selectedTaskIds.Contains(x.TeachingTaskId))); - } - - var result = await entries - .OrderBy(x => x.DayOfWeek) - .ThenBy(x => x.StartPeriod) - .ThenBy(x => x.TeachingTask!.Course!.Code) - .Select(x => new - { - x.Id, - x.TeachingTaskId, - TaskNumber = x.TeachingTask!.TaskNumber, - TaskName = x.TeachingTask.Name, - CourseCode = x.TeachingTask.Course!.Code, - CourseName = x.TeachingTask.Course.Name, - TeacherNames = x.TeachingTask.Teachers - .OrderByDescending(item => item.IsPrimary) - .Select(item => item.Teacher!.Name), - ClassNames = x.TeachingTask.Classes - .Select(item => item.AdministrativeClass!.Name), - ClassroomName = x.Classroom == null ? "不占用教室" : x.Classroom.Name, - BuildingName = x.Classroom == null ? null : x.Classroom.Building!.Name, - CampusName = 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); - - return Ok(new - { - Term = term, - Class = administrativeClass, - Student = student, - Plan = plan, - Slots = slots, - Entries = result, - FlexibleCourses = flexibleCourses - }); + private static string SafeFileName(string value) + { + foreach (var character in Path.GetInvalidFileNameChars()) + value = value.Replace(character, '-'); + return value.Trim(); } } diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/FreeClassroomRules.cs b/src/Jiaowu.Api/Infrastructure/Timetables/FreeClassroomRules.cs new file mode 100644 index 0000000..beacb14 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Timetables/FreeClassroomRules.cs @@ -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; +} diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs new file mode 100644 index 0000000..6275cab --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs @@ -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 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(); + 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 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 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 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> 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 Slots, + IReadOnlyList Entries, + IReadOnlyList 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 TeacherNames, + IEnumerable 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 TeacherNames, + IEnumerable ClassNames, + string? Notes); diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs new file mode 100644 index 0000000..2cbe23e --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs @@ -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() + }; +} diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 4eeddd9..68ee16b 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -5,6 +5,7 @@ using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; +using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Data.Sqlite; @@ -89,6 +90,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/tests/Jiaowu.Api.Tests/FreeClassroomRulesTests.cs b/tests/Jiaowu.Api.Tests/FreeClassroomRulesTests.cs new file mode 100644 index 0000000..270488c --- /dev/null +++ b/tests/Jiaowu.Api.Tests/FreeClassroomRulesTests.cs @@ -0,0 +1,40 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Timetables; + +namespace Jiaowu.Api.Tests; + +public sealed class FreeClassroomRulesTests +{ + [Theory] + [InlineData(WeekPattern.All, 2, true)] + [InlineData(WeekPattern.Odd, 3, true)] + [InlineData(WeekPattern.Odd, 4, false)] + [InlineData(WeekPattern.Even, 4, true)] + [InlineData(WeekPattern.Even, 3, false)] + public void MatchesWeek_respects_all_odd_and_even_patterns( + WeekPattern pattern, + int week, + bool expected) + { + Assert.Equal(expected, FreeClassroomRules.MatchesWeek(pattern, week)); + } + + [Theory] + [InlineData(1, 2, 2, 2, true)] + [InlineData(3, 1, 2, 1, false)] + [InlineData(1, 1, 2, 1, false)] + [InlineData(4, 3, 3, 5, true)] + public void PeriodsOverlap_uses_half_open_period_ranges( + int requestedStart, + int requestedCount, + int occupiedStart, + int occupiedCount, + bool expected) + { + Assert.Equal(expected, FreeClassroomRules.PeriodsOverlap( + requestedStart, + requestedCount, + occupiedStart, + occupiedCount)); + } +} diff --git a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs new file mode 100644 index 0000000..09ee378 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs @@ -0,0 +1,99 @@ +using ClosedXML.Excel; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Timetables; + +namespace Jiaowu.Api.Tests; + +public sealed class TimetableExcelExporterTests +{ + [Fact] + public void Create_builds_week_grid_and_flexible_course_section() + { + var timetable = new TimetableData( + new TimetableTermDto( + Guid.NewGuid(), + "2026-2027 学年第一学期", + "2026-2027", + TermSeason.Autumn, + new DateOnly(2026, 9, 1), + new DateOnly(2027, 1, 15), + true), + new TimetableSubjectDto( + Guid.NewGuid(), + "CS2601", + "计算机科学 2601 班", + TimetableResourceType.Class, + 2026, + Guid.NewGuid(), + "计算机科学与技术", + Guid.NewGuid(), + "计算机学院", + null, + null, + null), + null, + null, + new TimetablePlanDto( + Guid.NewGuid(), + "正式课表", + "V1", + SchedulePlanStatus.Published, + DateTime.UtcNow, + DateTime.UtcNow), + [ + new TimetableSlotDto( + 1, + "第一节", + new TimeOnly(8, 0), + new TimeOnly(8, 45)) + ], + [ + new TimetableEntryDto( + Guid.NewGuid(), + Guid.NewGuid(), + "TASK-01", + "高等数学 01", + "MATH101", + "高等数学", + ["张老师"], + ["计算机科学 2601 班"], + "101", + "第一教学楼", + "主校区", + 1, + 1, + 2, + 1, + 16, + WeekPattern.All, + null) + ], + [ + new FlexibleCourseDto( + Guid.NewGuid(), + "TASK-02", + "劳动教育", + "LAB101", + "劳动教育", + 1, + 16, + 1, + 16, + 1, + ["李老师"], + ["计算机科学 2601 班"], + null) + ]); + + var bytes = TimetableExcelExporter.Create(timetable); + + using var stream = new MemoryStream(bytes); + using var workbook = new XLWorkbook(stream); + var sheet = workbook.Worksheet("课表"); + Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString()); + Assert.Contains("高等数学", sheet.Cell(5, 2).GetString()); + Assert.Contains( + sheet.CellsUsed(), + cell => cell.GetString().Contains("非排时课程")); + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 336226b..871c649 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,8 @@ "@element-plus/icons-vue": "^2.3.2", "axios": "^1.18.1", "element-plus": "^2.14.3", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "pinia": "^4.0.2", "vue": "^3.5.39", "vue-router": "^4.6.4" @@ -59,6 +61,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", @@ -563,6 +574,26 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -872,6 +903,15 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -895,6 +935,26 @@ "node": ">= 0.4" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -930,6 +990,27 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -978,6 +1059,16 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1101,6 +1192,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1119,6 +1221,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1274,6 +1382,19 @@ "license": "MIT", "peer": true }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1287,6 +1408,12 @@ "node": ">= 6" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1294,6 +1421,23 @@ "dev": true, "license": "MIT" }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1742,6 +1886,22 @@ "node": ">=12.20.0" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1763,6 +1923,13 @@ "license": "MIT", "peer": true }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1873,6 +2040,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -1887,6 +2064,23 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1937,6 +2131,16 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -1950,6 +2154,25 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2193,6 +2416,15 @@ } } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", diff --git a/web/package.json b/web/package.json index 95f0f7b..42e08e6 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,8 @@ "@element-plus/icons-vue": "^2.3.2", "axios": "^1.18.1", "element-plus": "^2.14.3", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "pinia": "^4.0.2", "vue": "^3.5.39", "vue-router": "^4.6.4" diff --git a/web/src/components.d.ts b/web/src/components.d.ts index 1dd762a..29ea3cd 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -13,6 +13,7 @@ declare module 'vue' { export interface GlobalComponents { ElAlert: typeof import('element-plus/es')['ElAlert'] ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 2a1bd20..10b6ada 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -27,6 +27,11 @@ const isTeacher = computed(() => roles.value.includes('Teacher')) const isTeachingAdmin = computed(() => roles.value.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)), ) +const isTimetableManager = computed(() => + roles.value.some((role) => + ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role), + ), +) function hasAnyRole(allowedRoles: string[]) { return roles.value.some((role) => allowedRoles.includes(role)) @@ -99,7 +104,11 @@ const navigationGroups = computed(() => [ { path: '/schedules', label: '排课与课表' }, ), ...whenVisible(isStudent.value, { path: '/my-timetable', label: '我的课表' }), - ...whenVisible(!isStudent.value, { path: '/class-timetable', label: '班级课表查询' }), + ...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }), + ...whenVisible(!isStudent.value, { + path: '/class-timetable', + label: isTimetableManager.value ? '课表查询中心' : '班级课表查询', + }), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']), { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 8f93f94..6464e4c 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -137,6 +137,12 @@ const router = createRouter({ component: () => import('../views/TimetableView.vue'), meta: { roles: ['Student'], mine: true }, }, + { + path: 'free-classrooms', + name: 'free-classrooms', + component: () => import('../views/FreeClassroomsView.vue'), + meta: { roles: ['Student'] }, + }, { path: 'course-selections', name: 'course-selections', diff --git a/web/src/views/FreeClassroomsView.vue b/web/src/views/FreeClassroomsView.vue new file mode 100644 index 0000000..54232cf --- /dev/null +++ b/web/src/views/FreeClassroomsView.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/web/src/views/TimetableView.vue b/web/src/views/TimetableView.vue index 23c614f..dd19c03 100644 --- a/web/src/views/TimetableView.vue +++ b/web/src/views/TimetableView.vue @@ -1,20 +1,85 @@