Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/TimetableManagementController.cs
T
biss c91d826169 学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[BaseDataController.cs (line 286)](/E:/jiaowu/src/Jiaowu.Api/Controllers/BaseDataController.cs:286)
切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1)
桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211)
归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。
已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
2026-07-26 11:14:05 +08:00

407 lines
15 KiB
C#

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)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate)
.Select(x => new
{
x.Id,
x.Name,
x.StartDate,
x.EndDate,
x.IsCurrent,
x.IsArchived,
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.IsArchived && 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()
.Where(x =>
x.AcademicTermId == academicTermId &&
x.IsEnabled)
.WhereIn(requestedPeriods, x => x.PeriodNumber)
.CountAsync(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)
.WhereNotIn(occupiedIds, x => 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);