主要更新:
班级课表支持年级、学院、专业、行政班分级筛选。 管理员课表查询中心支持班级、教师、场地三种课表。 超级管理员、校级教务、学院教务、领导可查询草稿和已发布版本;学院管理员保留学院数据范围。 增加周视图、日视图切换。 支持 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 System.Security.Claims;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -10,7 +12,9 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/timetables")]
|
[Route("api/timetables")]
|
||||||
public sealed class TimetablesController(AppDbContext db) : ControllerBase
|
public sealed class TimetablesController(
|
||||||
|
AppDbContext db,
|
||||||
|
TimetableDataService timetableDataService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("options")]
|
[HttpGet("options")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -49,7 +53,9 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
|
|||||||
x.Code,
|
x.Code,
|
||||||
x.Name,
|
x.Name,
|
||||||
x.Grade,
|
x.Grade,
|
||||||
|
x.MajorId,
|
||||||
MajorName = x.Major!.Name,
|
MajorName = x.Major!.Name,
|
||||||
|
CollegeId = x.Major.CollegeId,
|
||||||
CollegeName = x.Major.College!.Name,
|
CollegeName = x.Major.College!.Name,
|
||||||
HasPublishedTimetable = defaultTermId.HasValue &&
|
HasPublishedTimetable = defaultTermId.HasValue &&
|
||||||
(db.ScheduleEntries.Any(entry =>
|
(db.ScheduleEntries.Any(entry =>
|
||||||
@@ -64,7 +70,17 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
|
|||||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))
|
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
return Ok(new { Terms = terms, Classes = classes });
|
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);
|
||||||
|
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}")]
|
[HttpGet("classes/{classId:guid}")]
|
||||||
@@ -75,6 +91,26 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
|
|||||||
CancellationToken cancellationToken) =>
|
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")]
|
[HttpGet("mine")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> GetMyTimetable(
|
public async Task<ActionResult> GetMyTimetable(
|
||||||
@@ -106,7 +142,39 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
|
|||||||
academicTermId,
|
academicTermId,
|
||||||
student.Id,
|
student.Id,
|
||||||
cancellationToken,
|
cancellationToken,
|
||||||
new { student.StudentNumber, student.Name });
|
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,
|
||||||
|
new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||||
|
cancellationToken);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return ExcelFile(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ActionResult> BuildTimetableAsync(
|
private async Task<ActionResult> BuildTimetableAsync(
|
||||||
@@ -114,174 +182,32 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase
|
|||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
Guid? studentId,
|
Guid? studentId,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
object? student = null)
|
TimetableStudentDto? student = null)
|
||||||
{
|
{
|
||||||
var administrativeClass = await db.AdministrativeClasses.AsNoTracking()
|
var result = await timetableDataService.BuildAsync(
|
||||||
.Where(x => x.Id == classId && x.IsEnabled)
|
TimetableResourceType.Class,
|
||||||
.Select(x => new
|
classId,
|
||||||
{
|
academicTermId,
|
||||||
x.Id,
|
null,
|
||||||
x.Code,
|
false,
|
||||||
x.Name,
|
studentId,
|
||||||
x.Grade,
|
student,
|
||||||
MajorName = x.Major!.Name,
|
cancellationToken);
|
||||||
CollegeName = x.Major.College!.Name
|
return result is null ? NotFound() : Ok(result);
|
||||||
})
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
if (administrativeClass is null) return NotFound();
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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
|
private ActionResult ExcelFile(TimetableData result)
|
||||||
.OrderBy(x => x.DayOfWeek)
|
|
||||||
.ThenBy(x => x.StartPeriod)
|
|
||||||
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
|
||||||
.Select(x => new
|
|
||||||
{
|
{
|
||||||
x.Id,
|
var bytes = TimetableExcelExporter.Create(result);
|
||||||
x.TeachingTaskId,
|
var fileName =
|
||||||
TaskNumber = x.TeachingTask!.TaskNumber,
|
$"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.xlsx";
|
||||||
TaskName = x.TeachingTask.Name,
|
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
||||||
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
|
private static string SafeFileName(string value)
|
||||||
{
|
{
|
||||||
Term = term,
|
foreach (var character in Path.GetInvalidFileNameChars())
|
||||||
Class = administrativeClass,
|
value = value.Replace(character, '-');
|
||||||
Student = student,
|
return value.Trim();
|
||||||
Plan = plan,
|
|
||||||
Slots = slots,
|
|
||||||
Entries = result,
|
|
||||||
FlexibleCourses = flexibleCourses
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Middleware;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
@@ -89,6 +90,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
|
|||||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||||
|
builder.Services.AddScoped<TimetableDataService>();
|
||||||
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("非排时课程"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+232
@@ -11,6 +11,8 @@
|
|||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"vue": "^3.5.39",
|
"vue": "^3.5.39",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
@@ -59,6 +61,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
@@ -563,6 +574,26 @@
|
|||||||
"undici-types": "~7.18.0"
|
"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": {
|
"node_modules/@types/web-bluetooth": {
|
||||||
"version": "0.0.21",
|
"version": "0.0.21",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||||
@@ -872,6 +903,15 @@
|
|||||||
"proxy-from-env": "^2.1.0"
|
"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": {
|
"node_modules/birpc": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||||
@@ -895,6 +935,26 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -930,6 +990,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -978,6 +1059,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1101,6 +1192,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"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": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
@@ -1274,6 +1382,19 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
@@ -1287,6 +1408,12 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
@@ -1294,6 +1421,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.33.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
@@ -1742,6 +1886,22 @@
|
|||||||
"node": ">=12.20.0"
|
"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": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -1763,6 +1923,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1873,6 +2040,16 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
@@ -1887,6 +2064,23 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||||
@@ -1937,6 +2131,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/strip-literal": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||||
@@ -1950,6 +2154,25 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.5",
|
"version": "8.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"vue": "^3.5.39",
|
"vue": "^3.5.39",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
|
|||||||
Vendored
+1
@@ -13,6 +13,7 @@ declare module 'vue' {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const isTeacher = computed(() => roles.value.includes('Teacher'))
|
|||||||
const isTeachingAdmin = computed(() =>
|
const isTeachingAdmin = computed(() =>
|
||||||
roles.value.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)),
|
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[]) {
|
function hasAnyRole(allowedRoles: string[]) {
|
||||||
return roles.value.some((role) => allowedRoles.includes(role))
|
return roles.value.some((role) => allowedRoles.includes(role))
|
||||||
@@ -99,7 +104,11 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
{ path: '/schedules', label: '排课与课表' },
|
{ path: '/schedules', label: '排课与课表' },
|
||||||
),
|
),
|
||||||
...whenVisible(isStudent.value, { path: '/my-timetable', 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(
|
...whenVisible(
|
||||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/TimetableView.vue'),
|
component: () => import('../views/TimetableView.vue'),
|
||||||
meta: { roles: ['Student'], mine: true },
|
meta: { roles: ['Student'], mine: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'free-classrooms',
|
||||||
|
name: 'free-classrooms',
|
||||||
|
component: () => import('../views/FreeClassroomsView.vue'),
|
||||||
|
meta: { roles: ['Student'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'course-selections',
|
path: 'course-selections',
|
||||||
name: 'course-selections',
|
name: 'course-selections',
|
||||||
|
|||||||
@@ -0,0 +1,536 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Location, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
interface TermOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
isCurrent: boolean
|
||||||
|
hasPublishedTimetable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaceOption {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
campusId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeSlotOption {
|
||||||
|
periodNumber: number
|
||||||
|
name: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FreeClassroom {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
capacity: number
|
||||||
|
buildingId: string
|
||||||
|
buildingName: string
|
||||||
|
campusId: string
|
||||||
|
campusName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdayOptions = [
|
||||||
|
{ value: 1, label: '周一' },
|
||||||
|
{ value: 2, label: '周二' },
|
||||||
|
{ value: 3, label: '周三' },
|
||||||
|
{ value: 4, label: '周四' },
|
||||||
|
{ value: 5, label: '周五' },
|
||||||
|
{ value: 6, label: '周六' },
|
||||||
|
{ value: 7, label: '周日' },
|
||||||
|
]
|
||||||
|
const terms = ref<TermOption[]>([])
|
||||||
|
const campuses = ref<PlaceOption[]>([])
|
||||||
|
const buildings = ref<PlaceOption[]>([])
|
||||||
|
const timeSlots = ref<TimeSlotOption[]>([])
|
||||||
|
const classrooms = ref<FreeClassroom[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const searched = ref(false)
|
||||||
|
const resultPage = ref(1)
|
||||||
|
const pageSize = 48
|
||||||
|
const form = reactive({
|
||||||
|
academicTermId: '',
|
||||||
|
week: 1,
|
||||||
|
dayOfWeek: new Date().getDay() || 7,
|
||||||
|
startPeriod: 1,
|
||||||
|
periodCount: 1,
|
||||||
|
campusId: '',
|
||||||
|
buildingId: '',
|
||||||
|
minimumCapacity: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedTerm = computed(() =>
|
||||||
|
terms.value.find((item) => item.id === form.academicTermId),
|
||||||
|
)
|
||||||
|
const filteredBuildings = computed(() =>
|
||||||
|
buildings.value.filter((item) => !form.campusId || item.campusId === form.campusId),
|
||||||
|
)
|
||||||
|
const availableStartPeriods = computed(() =>
|
||||||
|
timeSlots.value.filter((slot) =>
|
||||||
|
timeSlots.value.some((candidate) =>
|
||||||
|
candidate.periodNumber === slot.periodNumber + form.periodCount - 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const pagedClassrooms = computed(() =>
|
||||||
|
classrooms.value.slice((resultPage.value - 1) * pageSize, resultPage.value * pageSize),
|
||||||
|
)
|
||||||
|
const groupedRooms = computed(() => {
|
||||||
|
const groups = new Map<string, { campusName: string; buildingName: string; rooms: FreeClassroom[] }>()
|
||||||
|
for (const room of pagedClassrooms.value) {
|
||||||
|
const key = `${room.campusId}:${room.buildingId}`
|
||||||
|
const group = groups.get(key) ?? {
|
||||||
|
campusName: room.campusName,
|
||||||
|
buildingName: room.buildingName,
|
||||||
|
rooms: [],
|
||||||
|
}
|
||||||
|
group.rooms.push(room)
|
||||||
|
groups.set(key, group)
|
||||||
|
}
|
||||||
|
return [...groups.values()]
|
||||||
|
})
|
||||||
|
const querySummary = computed(() => {
|
||||||
|
const day = weekdayOptions.find((item) => item.value === form.dayOfWeek)?.label ?? ''
|
||||||
|
const end = form.startPeriod + form.periodCount - 1
|
||||||
|
const periods = end === form.startPeriod
|
||||||
|
? `第 ${form.startPeriod} 节`
|
||||||
|
: `第 ${form.startPeriod}—${end} 节`
|
||||||
|
return `第 ${form.week} 周 · ${day} · ${periods}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function calculateCurrentWeek(term?: TermOption) {
|
||||||
|
if (!term) return 1
|
||||||
|
const start = new Date(`${term.startDate}T00:00:00`)
|
||||||
|
const today = new Date()
|
||||||
|
const week = Math.floor((today.getTime() - start.getTime()) / 604800000) + 1
|
||||||
|
return Math.min(30, Math.max(1, week))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCampusChanged() {
|
||||||
|
if (!filteredBuildings.value.some((item) => item.id === form.buildingId)) {
|
||||||
|
form.buildingId = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions(academicTermId?: string) {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/timetables/free-classrooms/options', {
|
||||||
|
params: academicTermId ? { academicTermId } : undefined,
|
||||||
|
})
|
||||||
|
terms.value = data.terms
|
||||||
|
campuses.value = data.campuses
|
||||||
|
buildings.value = data.buildings
|
||||||
|
timeSlots.value = data.timeSlots
|
||||||
|
if (!form.academicTermId) {
|
||||||
|
form.academicTermId = data.selectedTermId ?? ''
|
||||||
|
form.week = calculateCurrentWeek(
|
||||||
|
terms.value.find((item) => item.id === form.academicTermId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!availableStartPeriods.value.some(
|
||||||
|
(item) => item.periodNumber === form.startPeriod,
|
||||||
|
)) {
|
||||||
|
form.startPeriod = availableStartPeriods.value[0]?.periodNumber ?? 1
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTermChanged() {
|
||||||
|
form.week = calculateCurrentWeek(selectedTerm.value)
|
||||||
|
classrooms.value = []
|
||||||
|
searched.value = false
|
||||||
|
await loadOptions(form.academicTermId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchRooms() {
|
||||||
|
if (!form.academicTermId) {
|
||||||
|
ElMessage.warning('请先选择学期。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/timetables/free-classrooms', {
|
||||||
|
params: {
|
||||||
|
academicTermId: form.academicTermId,
|
||||||
|
week: form.week,
|
||||||
|
dayOfWeek: form.dayOfWeek,
|
||||||
|
startPeriod: form.startPeriod,
|
||||||
|
periodCount: form.periodCount,
|
||||||
|
campusId: form.campusId || undefined,
|
||||||
|
buildingId: form.buildingId || undefined,
|
||||||
|
minimumCapacity: form.minimumCapacity,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
classrooms.value = data.items
|
||||||
|
resultPage.value = 1
|
||||||
|
searched.value = true
|
||||||
|
} catch (error) {
|
||||||
|
classrooms.value = []
|
||||||
|
searched.value = false
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadOptions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="free-room-page">
|
||||||
|
<header class="page-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">STUDENT SERVICE · 空间查询</span>
|
||||||
|
<h2>空闲教室查询</h2>
|
||||||
|
<p>依据已发布课表,查询指定周次和连续节次内没有排课的教学场所。</p>
|
||||||
|
</div>
|
||||||
|
<el-tag type="success" effect="plain">仅使用正式课表</el-tag>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="query-card">
|
||||||
|
<el-form label-position="top" class="query-form">
|
||||||
|
<el-form-item label="学期">
|
||||||
|
<el-select v-model="form.academicTermId" @change="onTermChanged">
|
||||||
|
<el-option
|
||||||
|
v-for="term in terms"
|
||||||
|
:key="term.id"
|
||||||
|
:label="term.name"
|
||||||
|
:value="term.id"
|
||||||
|
:disabled="!term.hasPublishedTimetable"
|
||||||
|
>
|
||||||
|
<span>{{ term.name }}</span>
|
||||||
|
<small v-if="!term.hasPublishedTimetable">尚未发布课表</small>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="周次">
|
||||||
|
<el-input-number v-model="form.week" :min="1" :max="30" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="星期">
|
||||||
|
<el-select v-model="form.dayOfWeek">
|
||||||
|
<el-option
|
||||||
|
v-for="item in weekdayOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始节次">
|
||||||
|
<el-select v-model="form.startPeriod">
|
||||||
|
<el-option
|
||||||
|
v-for="slot in availableStartPeriods"
|
||||||
|
:key="slot.periodNumber"
|
||||||
|
:label="slot.startTime
|
||||||
|
? `${slot.name}(${slot.startTime.slice(0, 5)}—${slot.endTime.slice(0, 5)})`
|
||||||
|
: slot.name"
|
||||||
|
:value="slot.periodNumber"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="连续节数">
|
||||||
|
<el-input-number v-model="form.periodCount" :min="1" :max="6" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="校区">
|
||||||
|
<el-select v-model="form.campusId" clearable placeholder="全部校区" @change="onCampusChanged">
|
||||||
|
<el-option
|
||||||
|
v-for="campus in campuses"
|
||||||
|
:key="campus.id"
|
||||||
|
:label="campus.name"
|
||||||
|
:value="campus.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="教学楼">
|
||||||
|
<el-select v-model="form.buildingId" clearable placeholder="全部教学楼">
|
||||||
|
<el-option
|
||||||
|
v-for="building in filteredBuildings"
|
||||||
|
:key="building.id"
|
||||||
|
:label="building.name"
|
||||||
|
:value="building.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="至少容纳人数">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.minimumCapacity"
|
||||||
|
:min="0"
|
||||||
|
:max="10000"
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<div class="query-action">
|
||||||
|
<el-button type="primary" :icon="Search" :loading="loading" @click="searchRooms">
|
||||||
|
查询空闲教室
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<section v-loading="loading" class="result-panel">
|
||||||
|
<div class="result-heading">
|
||||||
|
<div>
|
||||||
|
<span>查询条件</span>
|
||||||
|
<h3>{{ querySummary }}</h3>
|
||||||
|
</div>
|
||||||
|
<div v-if="searched" class="result-count">
|
||||||
|
<strong>{{ classrooms.length }}</strong>
|
||||||
|
<span>间空闲教室</span>
|
||||||
|
</div>
|
||||||
|
<el-button v-if="searched" text :icon="Refresh" @click="searchRooms">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="searched && groupedRooms.length">
|
||||||
|
<div class="building-groups">
|
||||||
|
<article v-for="group in groupedRooms" :key="`${group.campusName}-${group.buildingName}`">
|
||||||
|
<header>
|
||||||
|
<el-icon><Location /></el-icon>
|
||||||
|
<div>
|
||||||
|
<small>{{ group.campusName }}</small>
|
||||||
|
<h4>{{ group.buildingName }}</h4>
|
||||||
|
</div>
|
||||||
|
<span>{{ group.rooms.length }} 间</span>
|
||||||
|
</header>
|
||||||
|
<div class="room-grid">
|
||||||
|
<div v-for="room in group.rooms" :key="room.id" class="room-item">
|
||||||
|
<b>{{ room.name }}</b>
|
||||||
|
<span>{{ room.code }}</span>
|
||||||
|
<em>容纳 {{ room.capacity }} 人</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
v-if="classrooms.length > pageSize"
|
||||||
|
v-model:current-page="resultPage"
|
||||||
|
class="result-pagination"
|
||||||
|
background
|
||||||
|
layout="prev, pager, next, jumper"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="classrooms.length"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<el-empty
|
||||||
|
v-else-if="searched"
|
||||||
|
description="当前条件下没有空闲教室,可尝试缩短连续节数或调整场地范围。"
|
||||||
|
/>
|
||||||
|
<el-empty v-else description="设置条件后查询空闲教室" />
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.free-room-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading,
|
||||||
|
.result-heading,
|
||||||
|
.result-count,
|
||||||
|
.building-groups article > header,
|
||||||
|
.room-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: #17867c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #183b56;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading p {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-card {
|
||||||
|
border-color: #dce6eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||||
|
gap: 2px 16px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-form :deep(.el-select),
|
||||||
|
.query-form :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-form small {
|
||||||
|
float: right;
|
||||||
|
margin-left: 12px;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-action {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-action .el-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-panel {
|
||||||
|
min-height: 260px;
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px solid #dce6eb;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-heading {
|
||||||
|
gap: 18px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid #edf2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-heading > div:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-heading span,
|
||||||
|
.building-groups small,
|
||||||
|
.room-item span {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-heading h3 {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #24445d;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-count {
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-count strong {
|
||||||
|
color: #16867c;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-pagination {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-groups article {
|
||||||
|
border: 1px solid #dfe8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-groups article > header {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-bottom: 1px solid #e7eef1;
|
||||||
|
background: #f5faf9;
|
||||||
|
color: #17867c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-groups article > header div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-groups h4 {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #24445d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-groups article > header > span {
|
||||||
|
color: #17867c;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
background: #e7eef1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-item {
|
||||||
|
min-height: 92px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-item b {
|
||||||
|
color: #24445d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-item em {
|
||||||
|
margin-top: auto;
|
||||||
|
color: #17867c;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.query-form {
|
||||||
|
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.page-heading,
|
||||||
|
.result-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+415
-29
@@ -1,20 +1,85 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Document, Download } from '@element-plus/icons-vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
import { downloadApiFile } from '../api/excel'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const auth = useAuthStore()
|
||||||
const isMine = computed(() => route.meta.mine === true)
|
const isMine = computed(() => route.meta.mine === true)
|
||||||
const isPublic = computed(() => route.meta.public === true)
|
const isPublic = computed(() => route.meta.public === true)
|
||||||
|
const isManager = computed(() =>
|
||||||
|
!isMine.value &&
|
||||||
|
!isPublic.value &&
|
||||||
|
(auth.user?.roles.some((role) =>
|
||||||
|
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ?? false),
|
||||||
|
)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const exportingPdf = ref(false)
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
|
const colleges = ref<any[]>([])
|
||||||
|
const majors = ref<any[]>([])
|
||||||
const classes = ref<any[]>([])
|
const classes = ref<any[]>([])
|
||||||
|
const teachers = ref<any[]>([])
|
||||||
|
const campuses = ref<any[]>([])
|
||||||
|
const buildings = ref<any[]>([])
|
||||||
|
const classrooms = ref<any[]>([])
|
||||||
|
const plans = ref<any[]>([])
|
||||||
const termId = ref('')
|
const termId = ref('')
|
||||||
|
const planId = ref('')
|
||||||
|
const resourceType = ref<'Class' | 'Teacher' | 'Classroom'>('Class')
|
||||||
|
const grade = ref<number | undefined>()
|
||||||
|
const collegeId = ref('')
|
||||||
|
const majorId = ref('')
|
||||||
const classId = ref('')
|
const classId = ref('')
|
||||||
|
const teacherId = ref('')
|
||||||
|
const campusId = ref('')
|
||||||
|
const buildingId = ref('')
|
||||||
|
const classroomId = ref('')
|
||||||
const timetable = ref<any | null>(null)
|
const timetable = ref<any | null>(null)
|
||||||
|
const viewMode = ref<'week' | 'day'>('week')
|
||||||
|
const selectedDay = ref(1)
|
||||||
|
const exportArea = ref<HTMLElement | null>(null)
|
||||||
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
||||||
|
const planStatusLabels: Record<string, string> = {
|
||||||
|
Draft: '草稿',
|
||||||
|
Published: '已发布',
|
||||||
|
Archived: '已归档',
|
||||||
|
}
|
||||||
|
|
||||||
|
const grades = computed(() =>
|
||||||
|
[...new Set(classes.value.map((item) => item.grade))].sort((a, b) => b - a),
|
||||||
|
)
|
||||||
|
const filteredMajors = computed(() =>
|
||||||
|
majors.value.filter((item) => !collegeId.value || item.collegeId === collegeId.value),
|
||||||
|
)
|
||||||
|
const filteredClasses = computed(() =>
|
||||||
|
classes.value.filter((item) =>
|
||||||
|
(!grade.value || item.grade === grade.value) &&
|
||||||
|
(!collegeId.value || item.collegeId === collegeId.value) &&
|
||||||
|
(!majorId.value || item.majorId === majorId.value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const filteredTeachers = computed(() =>
|
||||||
|
teachers.value.filter((item) => !collegeId.value || item.collegeId === collegeId.value),
|
||||||
|
)
|
||||||
|
const filteredBuildings = computed(() =>
|
||||||
|
buildings.value.filter((item) => !campusId.value || item.campusId === campusId.value),
|
||||||
|
)
|
||||||
|
const filteredClassrooms = computed(() =>
|
||||||
|
classrooms.value.filter((item) =>
|
||||||
|
(!campusId.value || item.campusId === campusId.value) &&
|
||||||
|
(!buildingId.value || item.buildingId === buildingId.value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const selectedResourceId = computed(() => {
|
||||||
|
if (resourceType.value === 'Teacher') return teacherId.value
|
||||||
|
if (resourceType.value === 'Classroom') return classroomId.value
|
||||||
|
return classId.value
|
||||||
|
})
|
||||||
const maxPeriods = computed(() => {
|
const maxPeriods = computed(() => {
|
||||||
const slotMaximum = Math.max(0, ...((timetable.value?.slots ?? []).map((x: any) => x.periodNumber)))
|
const slotMaximum = Math.max(0, ...((timetable.value?.slots ?? []).map((x: any) => x.periodNumber)))
|
||||||
const entryMaximum = Math.max(
|
const entryMaximum = Math.max(
|
||||||
@@ -23,12 +88,14 @@ const maxPeriods = computed(() => {
|
|||||||
)
|
)
|
||||||
return Math.max(8, slotMaximum, entryMaximum)
|
return Math.max(8, slotMaximum, entryMaximum)
|
||||||
})
|
})
|
||||||
|
|
||||||
const slotMap = computed<Map<number, any>>(() =>
|
const slotMap = computed<Map<number, any>>(() =>
|
||||||
new Map<number, any>(
|
new Map<number, any>(
|
||||||
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
|
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
const dayEntries = computed(() =>
|
||||||
|
(timetable.value?.entries ?? []).filter((entry: any) => entry.dayOfWeek === selectedDay.value),
|
||||||
|
)
|
||||||
|
|
||||||
function formatTime(value: string) {
|
function formatTime(value: string) {
|
||||||
return value?.slice(0, 5) ?? ''
|
return value?.slice(0, 5) ?? ''
|
||||||
@@ -50,27 +117,132 @@ function location(entry: any) {
|
|||||||
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
|
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
function dayEntriesAt(period: number) {
|
||||||
|
return dayEntries.value.filter((entry: any) => entry.startPeriod === period)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setClassFilters(item: any) {
|
||||||
|
if (!item) return
|
||||||
|
grade.value = item.grade
|
||||||
|
collegeId.value = item.collegeId
|
||||||
|
majorId.value = item.majorId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicOptions() {
|
||||||
const { data } = await http.get('/timetables/options')
|
const { data } = await http.get('/timetables/options')
|
||||||
terms.value = data.terms
|
terms.value = data.terms
|
||||||
|
colleges.value = data.colleges
|
||||||
|
majors.value = data.majors
|
||||||
classes.value = data.classes
|
classes.value = data.classes
|
||||||
termId.value = data.terms.find((item: any) => item.isCurrent)?.id
|
termId.value = data.terms.find((item: any) => item.isCurrent)?.id
|
||||||
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
|
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
|
||||||
?? data.terms[0]?.id
|
?? data.terms[0]?.id
|
||||||
?? ''
|
?? ''
|
||||||
classId.value = data.classes.find((item: any) => item.hasPublishedTimetable)?.id
|
const initialClass = data.classes.find((item: any) => item.hasPublishedTimetable)
|
||||||
?? data.classes[0]?.id
|
?? data.classes[0]
|
||||||
|
classId.value = initialClass?.id ?? ''
|
||||||
|
setClassFilters(initialClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManagementOptions() {
|
||||||
|
if (!isManager.value || !termId.value) return
|
||||||
|
const { data } = await http.get('/timetables/management/options', {
|
||||||
|
params: { academicTermId: termId.value },
|
||||||
|
})
|
||||||
|
plans.value = data.plans
|
||||||
|
colleges.value = data.colleges
|
||||||
|
majors.value = data.majors
|
||||||
|
classes.value = data.classes
|
||||||
|
teachers.value = data.teachers
|
||||||
|
campuses.value = data.campuses
|
||||||
|
buildings.value = data.buildings
|
||||||
|
classrooms.value = data.classrooms
|
||||||
|
if (!data.plans.some((item: any) => item.id === planId.value)) {
|
||||||
|
planId.value = data.plans.find((item: any) => item.status === 'Published')?.id
|
||||||
|
?? data.plans[0]?.id
|
||||||
?? ''
|
?? ''
|
||||||
|
}
|
||||||
|
if (!data.classes.some((item: any) => item.id === classId.value)) {
|
||||||
|
classId.value = data.classes[0]?.id ?? ''
|
||||||
|
setClassFilters(data.classes[0])
|
||||||
|
}
|
||||||
|
if (!data.teachers.some((item: any) => item.id === teacherId.value)) {
|
||||||
|
teacherId.value = data.teachers[0]?.id ?? ''
|
||||||
|
}
|
||||||
|
if (!data.classrooms.some((item: any) => item.id === classroomId.value)) {
|
||||||
|
classroomId.value = data.classrooms[0]?.id ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTermChanged() {
|
||||||
|
if (isManager.value) await loadManagementOptions()
|
||||||
|
await loadTimetable()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGradeChanged() {
|
||||||
|
classId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCollegeChanged() {
|
||||||
|
majorId.value = ''
|
||||||
|
classId.value = ''
|
||||||
|
teacherId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMajorChanged() {
|
||||||
|
classId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCampusChanged() {
|
||||||
|
buildingId.value = ''
|
||||||
|
classroomId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBuildingChanged() {
|
||||||
|
classroomId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResourceTypeChanged() {
|
||||||
|
collegeId.value = ''
|
||||||
|
majorId.value = ''
|
||||||
|
grade.value = undefined
|
||||||
|
campusId.value = ''
|
||||||
|
buildingId.value = ''
|
||||||
|
if (resourceType.value === 'Class') {
|
||||||
|
classId.value = classes.value[0]?.id ?? ''
|
||||||
|
setClassFilters(classes.value[0])
|
||||||
|
} else if (resourceType.value === 'Teacher') {
|
||||||
|
teacherId.value = teachers.value[0]?.id ?? ''
|
||||||
|
} else {
|
||||||
|
classroomId.value = classrooms.value[0]?.id ?? ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTimetable() {
|
async function loadTimetable() {
|
||||||
if (!termId.value || (!isMine.value && !classId.value)) return
|
if (!termId.value || (!isMine.value && !selectedResourceId.value)) {
|
||||||
|
timetable.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const url = isMine.value ? '/timetables/mine' : `/timetables/classes/${classId.value}`
|
if (isMine.value) {
|
||||||
timetable.value = (await http.get(url, {
|
timetable.value = (await http.get('/timetables/mine', {
|
||||||
params: { academicTermId: termId.value },
|
params: { academicTermId: termId.value },
|
||||||
})).data
|
})).data
|
||||||
|
} else if (isManager.value) {
|
||||||
|
timetable.value = (await http.get('/timetables/management/query', {
|
||||||
|
params: {
|
||||||
|
resourceType: resourceType.value,
|
||||||
|
resourceId: selectedResourceId.value,
|
||||||
|
academicTermId: termId.value,
|
||||||
|
schedulePlanId: planId.value || undefined,
|
||||||
|
},
|
||||||
|
})).data
|
||||||
|
} else {
|
||||||
|
timetable.value = (await http.get(`/timetables/classes/${classId.value}`, {
|
||||||
|
params: { academicTermId: termId.value },
|
||||||
|
})).data
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
timetable.value = null
|
timetable.value = null
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -79,11 +251,78 @@ async function loadTimetable() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([termId, classId], loadTimetable)
|
async function exportExcel() {
|
||||||
|
try {
|
||||||
|
if (isMine.value) {
|
||||||
|
await downloadApiFile(
|
||||||
|
`/timetables/mine/export.xlsx?academicTermId=${termId.value}`,
|
||||||
|
'我的课表.xlsx',
|
||||||
|
)
|
||||||
|
} else if (isManager.value) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
resourceType: resourceType.value,
|
||||||
|
resourceId: selectedResourceId.value,
|
||||||
|
academicTermId: termId.value,
|
||||||
|
})
|
||||||
|
if (planId.value) query.set('schedulePlanId', planId.value)
|
||||||
|
await downloadApiFile(
|
||||||
|
`/timetables/management/export.xlsx?${query}`,
|
||||||
|
`${timetable.value?.subject?.name ?? '课表'}.xlsx`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await downloadApiFile(
|
||||||
|
`/timetables/classes/${classId.value}/export.xlsx?academicTermId=${termId.value}`,
|
||||||
|
`${timetable.value?.subject?.name ?? '班级'}课表.xlsx`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPdf() {
|
||||||
|
if (!exportArea.value || !timetable.value) return
|
||||||
|
exportingPdf.value = true
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
|
||||||
|
import('html2canvas'),
|
||||||
|
import('jspdf'),
|
||||||
|
])
|
||||||
|
const canvas = await html2canvas(exportArea.value, {
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
})
|
||||||
|
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
||||||
|
const pageWidth = 297
|
||||||
|
const pageHeight = 210
|
||||||
|
const imageHeight = canvas.height * pageWidth / canvas.width
|
||||||
|
const image = canvas.toDataURL('image/png')
|
||||||
|
let remaining = imageHeight
|
||||||
|
let position = 0
|
||||||
|
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
|
||||||
|
remaining -= pageHeight
|
||||||
|
while (remaining > 0) {
|
||||||
|
position = remaining - imageHeight
|
||||||
|
pdf.addPage()
|
||||||
|
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
|
||||||
|
remaining -= pageHeight
|
||||||
|
}
|
||||||
|
pdf.save(`${timetable.value.subject?.name ?? '课表'}-${timetable.value.term.name}.pdf`)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
|
||||||
|
} finally {
|
||||||
|
exportingPdf.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([classId, teacherId, classroomId, planId, resourceType], loadTimetable)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await loadOptions()
|
await loadPublicOptions()
|
||||||
|
if (isManager.value) await loadManagementOptions()
|
||||||
await loadTimetable()
|
await loadTimetable()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -100,41 +339,128 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<section class="timetable-heading">
|
<section class="timetable-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="section-kicker">{{ isMine ? 'MY TIMETABLE' : 'CLASS TIMETABLE' }}</span>
|
<span class="section-kicker">{{ isMine ? 'MY TIMETABLE' : isManager ? 'TIMETABLE CENTER' : 'CLASS TIMETABLE' }}</span>
|
||||||
<h2>{{ isMine ? '我的课表' : '班级课表查询' }}</h2>
|
<h2>{{ isMine ? '我的课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2>
|
||||||
<p v-if="isMine">行政班课程与本人已选课程统一展示,仅采用教务处已发布课表。</p>
|
<p v-if="isMine">行政班课程与本人已选课程统一展示,仅采用教务处已发布课表。</p>
|
||||||
<p v-else>无需登录即可查询各行政班已正式发布的课程安排。</p>
|
<p v-else-if="isManager">查询班级、教师与场地课表,可切换草稿或正式发布版本。</p>
|
||||||
|
<p v-else>按年级、学院、专业和班级分类查询正式发布的课程安排。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="timetable-filters">
|
<div class="timetable-filters">
|
||||||
<el-select v-model="termId" filterable placeholder="选择学期">
|
<el-select v-model="termId" filterable placeholder="选择学期" @change="onTermChanged">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="term in terms"
|
v-for="term in terms"
|
||||||
:key="term.id"
|
:key="term.id"
|
||||||
:label="`${term.name}${term.hasPublishedTimetable ? '' : '(未发布)'}`"
|
:label="`${term.name}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`"
|
||||||
:value="term.id"
|
:value="term.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select
|
<el-select v-if="isManager" v-model="planId" placeholder="选择课表版本">
|
||||||
v-if="!isMine"
|
|
||||||
v-model="classId"
|
|
||||||
filterable
|
|
||||||
placeholder="选择行政班"
|
|
||||||
>
|
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in classes"
|
v-for="item in plans"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:label="`${item.collegeName} · ${item.majorName} · ${item.name}`"
|
:label="`${item.version} · ${item.name} · ${planStatusLabels[item.status]}`"
|
||||||
:value="item.id"
|
:value="item.id"
|
||||||
/>
|
>
|
||||||
|
<span>{{ item.version }} · {{ item.name }}</span>
|
||||||
|
<el-tag
|
||||||
|
size="small"
|
||||||
|
:type="item.status === 'Published' ? 'success' : 'warning'"
|
||||||
|
>{{ planStatusLabels[item.status] }}</el-tag>
|
||||||
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!isMine" class="resource-filter-panel">
|
||||||
|
<div v-if="isManager" class="resource-type-tabs">
|
||||||
|
<el-radio-group v-model="resourceType" @change="onResourceTypeChanged">
|
||||||
|
<el-radio-button value="Class">班级课表</el-radio-button>
|
||||||
|
<el-radio-button value="Teacher">教师课表</el-radio-button>
|
||||||
|
<el-radio-button value="Classroom">场地课表</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<div v-if="resourceType === 'Class'" class="hierarchy-filters">
|
||||||
|
<el-select v-model="grade" clearable placeholder="全部年级" @change="onGradeChanged">
|
||||||
|
<el-option v-for="item in grades" :key="item" :label="`${item} 级`" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="collegeId" clearable placeholder="全部学院" @change="onCollegeChanged">
|
||||||
|
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="majorId" clearable placeholder="全部专业" @change="onMajorChanged">
|
||||||
|
<el-option v-for="item in filteredMajors" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="classId" filterable placeholder="选择行政班">
|
||||||
|
<el-option
|
||||||
|
v-for="item in filteredClasses"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.code} · ${item.name}`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="resourceType === 'Teacher'" class="hierarchy-filters compact">
|
||||||
|
<el-select v-model="collegeId" clearable placeholder="全部学院" @change="onCollegeChanged">
|
||||||
|
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="teacherId" filterable placeholder="选择教师">
|
||||||
|
<el-option
|
||||||
|
v-for="item in filteredTeachers"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.teacherNumber} · ${item.name}${item.title ? ` · ${item.title}` : ''}`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div v-else class="hierarchy-filters compact">
|
||||||
|
<el-select v-model="campusId" clearable placeholder="全部校区" @change="onCampusChanged">
|
||||||
|
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="buildingId" clearable placeholder="全部教学楼" @change="onBuildingChanged">
|
||||||
|
<el-option v-for="item in filteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="classroomId" filterable placeholder="选择教室">
|
||||||
|
<el-option
|
||||||
|
v-for="item in filteredClassrooms"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.buildingName} · ${item.name}(${item.capacity} 人)`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
当前可选:
|
||||||
|
{{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }}
|
||||||
|
项
|
||||||
|
</small>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-loading="loading" class="timetable-sheet">
|
<section v-loading="loading" class="timetable-sheet">
|
||||||
<div v-if="timetable" class="sheet-meta">
|
<div v-if="timetable" class="timetable-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ timetable.class.name }}</strong>
|
<el-radio-group v-model="viewMode" size="small">
|
||||||
<span>{{ timetable.class.collegeName }} · {{ timetable.class.majorName }}</span>
|
<el-radio-button value="week">周视图</el-radio-button>
|
||||||
|
<el-radio-button value="day">日视图</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-select v-if="viewMode === 'day'" v-model="selectedDay" size="small">
|
||||||
|
<el-option v-for="day in 7" :key="day" :label="weekdays[day]" :value="day" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<el-button :icon="Download" @click="exportExcel">导出 Excel</el-button>
|
||||||
|
<el-button :icon="Document" :loading="exportingPdf" @click="exportPdf">导出 PDF</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="timetable" ref="exportArea" class="timetable-export-area">
|
||||||
|
<div class="sheet-meta">
|
||||||
|
<div>
|
||||||
|
<strong>{{ timetable.subject.name }}</strong>
|
||||||
|
<span>
|
||||||
|
{{ timetable.subject.code }}
|
||||||
|
<template v-if="timetable.subject.collegeName"> · {{ timetable.subject.collegeName }}</template>
|
||||||
|
<template v-if="timetable.subject.majorName"> · {{ timetable.subject.majorName }}</template>
|
||||||
|
<template v-if="timetable.subject.description"> · {{ timetable.subject.description }}</template>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="timetable.student">
|
<div v-if="timetable.student">
|
||||||
<strong>{{ timetable.student.name }}</strong>
|
<strong>{{ timetable.student.name }}</strong>
|
||||||
@@ -142,7 +468,12 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ timetable.term.name }}</strong>
|
<strong>{{ timetable.term.name }}</strong>
|
||||||
<span v-if="timetable.plan">发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}</span>
|
<span v-if="timetable.plan">
|
||||||
|
{{ timetable.plan.version }} · {{ planStatusLabels[timetable.plan.status] }}
|
||||||
|
<template v-if="timetable.plan.publishedAt">
|
||||||
|
· 发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
<span v-else>本学期课表尚未发布</span>
|
<span v-else>本学期课表尚未发布</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +499,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="timetable?.plan && timetable.entries.length" class="timetable-scroll">
|
<div v-if="viewMode === 'week' && timetable?.plan && timetable.entries.length" class="timetable-scroll">
|
||||||
<div
|
<div
|
||||||
class="week-grid"
|
class="week-grid"
|
||||||
:style="{ gridTemplateRows: `48px repeat(${maxPeriods}, 110px)` }"
|
:style="{ gridTemplateRows: `48px repeat(${maxPeriods}, 110px)` }"
|
||||||
@@ -202,10 +533,34 @@ onMounted(async () => {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="viewMode === 'day' && timetable?.plan" class="day-view">
|
||||||
|
<header>
|
||||||
|
<strong>{{ weekdays[selectedDay] }}</strong>
|
||||||
|
<span>按开始节次排列 · 共 {{ dayEntries.length }} 项安排</span>
|
||||||
|
</header>
|
||||||
|
<div v-for="period in maxPeriods" :key="`day-${period}`" class="day-period-row">
|
||||||
|
<div class="day-period-label">
|
||||||
|
<strong>第 {{ period }} 节</strong>
|
||||||
|
<span v-if="slotMap.get(period)">
|
||||||
|
{{ formatTime(slotMap.get(period).startsAt) }}—{{ formatTime(slotMap.get(period).endsAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="day-course-list">
|
||||||
|
<article v-for="entry in dayEntriesAt(period)" :key="entry.id">
|
||||||
|
<strong>{{ entry.courseName }}</strong>
|
||||||
|
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
|
||||||
|
<span>{{ location(entry) }}</span>
|
||||||
|
<small>{{ weeks(entry) }} · 连续 {{ entry.periodCount }} 节</small>
|
||||||
|
</article>
|
||||||
|
<span v-if="!dayEntriesAt(period).length" class="day-empty">无课程安排</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<el-empty
|
<el-empty
|
||||||
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
|
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
|
||||||
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
|
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -221,7 +576,16 @@ onMounted(async () => {
|
|||||||
.timetable-heading p { margin: 0; color: #647587; }
|
.timetable-heading p { margin: 0; color: #647587; }
|
||||||
.timetable-filters { display: flex; flex-wrap: wrap; gap: 12px; }
|
.timetable-filters { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
.timetable-filters .el-select { width: 270px; }
|
.timetable-filters .el-select { width: 270px; }
|
||||||
|
.resource-filter-panel { padding: 16px 18px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; border: 1px solid #dce4eb; border-left: 4px solid #176b87; background: #fff; }
|
||||||
|
.resource-type-tabs { flex-shrink: 0; }
|
||||||
|
.hierarchy-filters { min-width: 0; display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 10px; }
|
||||||
|
.hierarchy-filters.compact { grid-template-columns: repeat(3, minmax(170px, 260px)); }
|
||||||
|
.resource-filter-panel > small { color: #718191; white-space: nowrap; }
|
||||||
.timetable-sheet { min-width: 0; min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; }
|
.timetable-sheet { min-width: 0; min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; }
|
||||||
|
.timetable-toolbar { margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.timetable-toolbar > div { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.timetable-toolbar .el-select { width: 120px; }
|
||||||
|
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
|
||||||
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
|
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
|
||||||
.sheet-meta div { display: grid; gap: 3px; }
|
.sheet-meta div { display: grid; gap: 3px; }
|
||||||
.sheet-meta strong { color: #17324d; }
|
.sheet-meta strong { color: #17324d; }
|
||||||
@@ -258,11 +622,33 @@ onMounted(async () => {
|
|||||||
.course-block strong { color: #123a4b; font-size: 14px; }
|
.course-block strong { color: #123a4b; font-size: 14px; }
|
||||||
.course-block span { font-size: 12px; }
|
.course-block span { font-size: 12px; }
|
||||||
.course-block small { margin-top: auto; color: #5f7885; font-size: 11px; }
|
.course-block small { margin-top: auto; color: #5f7885; font-size: 11px; }
|
||||||
|
.day-view { border: 1px solid #dce4eb; }
|
||||||
|
.day-view > header { padding: 14px 16px; display: flex; align-items: baseline; justify-content: space-between; background: #173e72; color: #fff; }
|
||||||
|
.day-view > header strong { font-size: 18px; }
|
||||||
|
.day-view > header span { color: #dce8f3; font-size: 12px; }
|
||||||
|
.day-period-row { min-height: 88px; display: grid; grid-template-columns: 120px minmax(0, 1fr); border-top: 1px solid #e1e7eb; }
|
||||||
|
.day-period-label { padding: 12px; display: grid; place-content: center; gap: 4px; text-align: center; background: #f2f5f7; color: #43586a; }
|
||||||
|
.day-period-label span { color: #7b8b98; font-size: 11px; }
|
||||||
|
.day-course-list { padding: 9px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; align-items: stretch; }
|
||||||
|
.day-course-list article { padding: 10px 12px; display: grid; gap: 3px; border-left: 4px solid #176b87; background: #e9f3f5; color: #395d6c; }
|
||||||
|
.day-course-list article strong { color: #123a4b; }
|
||||||
|
.day-course-list article span, .day-course-list article small { font-size: 11px; }
|
||||||
|
.day-empty { align-self: center; color: #9aa7b1; font-size: 12px; }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.resource-filter-panel { grid-template-columns: 1fr; }
|
||||||
|
.hierarchy-filters { grid-template-columns: repeat(2, minmax(150px, 1fr)); }
|
||||||
|
.resource-filter-panel > small { white-space: normal; }
|
||||||
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.public-timetable { padding: 0 14px 24px; }
|
.public-timetable { padding: 0 14px 24px; }
|
||||||
.timetable-heading, .sheet-meta { align-items: stretch; flex-direction: column; }
|
.timetable-heading, .sheet-meta { align-items: stretch; flex-direction: column; }
|
||||||
.timetable-filters { flex-direction: column; }
|
.timetable-filters { flex-direction: column; }
|
||||||
.timetable-filters .el-select { width: 100%; }
|
.timetable-filters .el-select { width: 100%; }
|
||||||
|
.hierarchy-filters, .hierarchy-filters.compact { grid-template-columns: 1fr; }
|
||||||
|
.timetable-toolbar { align-items: stretch; flex-direction: column; }
|
||||||
|
.timetable-toolbar > div { flex-wrap: wrap; }
|
||||||
.timetable-sheet { padding: 12px; }
|
.timetable-sheet { padding: 12px; }
|
||||||
|
.day-period-row { grid-template-columns: 90px minmax(0, 1fr); }
|
||||||
|
.day-course-list { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user