主要更新:
班级课表支持年级、学院、专业、行政班分级筛选。 管理员课表查询中心支持班级、教师、场地三种课表。 超级管理员、校级教务、学院教务、领导可查询草稿和已发布版本;学院管理员保留学院数据范围。 增加周视图、日视图切换。 支持 Excel 导出和当前视图 PDF 导出。 学生端增加“空闲教室”,支持学期、周次、星期、连续节次、校区、教学楼、容量筛选,并按教学楼分组、分页展示。 空闲教室只依据正式课表计算;未配置节次表时自动提供默认节次。
This commit is contained in:
@@ -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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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);
|
||||
@@ -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<ActionResult> 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<ActionResult> 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<ActionResult> 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<object>(),
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||
builder.Services.AddScoped<DatabaseInitializer>();
|
||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||
builder.Services.AddScoped<TimetableDataService>();
|
||||
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||
|
||||
Reference in New Issue
Block a user