主要更新:

班级课表支持年级、学院、专业、行政班分级筛选。
管理员课表查询中心支持班级、教师、场地三种课表。
超级管理员、校级教务、学院教务、领导可查询草稿和已发布版本;学院管理员保留学院数据范围。
增加周视图、日视图切换。
支持 Excel 导出和当前视图 PDF 导出。
学生端增加“空闲教室”,支持学期、周次、星期、连续节次、校区、教学楼、容量筛选,并按教学楼分组、分页展示。
空闲教室只依据正式课表计算;未配置节次表时自动提供默认节次。
This commit is contained in:
2026-07-25 08:53:42 +08:00 Unverified
parent fe508054d0
commit 0d34936d38
15 changed files with 2411 additions and 209 deletions
@@ -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()
};
}
+2
View File
@@ -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("非排时课程"));
}
}
+232
View File
@@ -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",
+2
View File
@@ -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"
+1
View File
@@ -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']
+10 -1
View File
@@ -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']),
{ {
+6
View File
@@ -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',
+536
View File
@@ -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
View File
@@ -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>