主要更新:
班级课表支持年级、学院、专业、行政班分级筛选。 管理员课表查询中心支持班级、教师、场地三种课表。 超级管理员、校级教务、学院教务、领导可查询草稿和已发布版本;学院管理员保留学院数据范围。 增加周视图、日视图切换。 支持 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);
|
||||
Reference in New Issue
Block a user