切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[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)
407 lines
15 KiB
C#
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);
|