diff --git a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs index 7bee118..a747607 100644 --- a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs +++ b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs @@ -242,6 +242,24 @@ public sealed class TimetableManagementController( return File(TimetablePdfExporter.Create(timetables), "application/pdf", fileName); } + [HttpPost("display-links/export.xlsx")] + public async Task ExportDisplayLinks(VenueDisplayLinkExportRequest request, CancellationToken cancellationToken) + { + var classroomIds = request.ClassroomIds.Distinct().Take(200).ToArray(); + var buildingIds = request.BuildingIds.Distinct().Take(100).ToArray(); + if (classroomIds.Length + buildingIds.Length == 0) + return ValidationProblem("请至少选择一个教室或教学楼。", statusCode: StatusCodes.Status400BadRequest); + var classrooms = await db.Classrooms.AsNoTracking().WhereIn(classroomIds, x => x.Id) + .OrderBy(x => x.Building!.Name).ThenBy(x => x.Code) + .Select(x => new object?[] { "教室当天课程", x.Building!.Name + " · " + x.Name, "/venue-display/classroom/" + x.Id }) + .ToListAsync(cancellationToken); + var buildings = await db.Buildings.AsNoTracking().WhereIn(buildingIds, x => x.Id).OrderBy(x => x.Name) + .Select(x => new object?[] { "教学楼空余教室", x.Name, "/venue-display/building/" + x.Id }) + .ToListAsync(cancellationToken); + var bytes = ExcelWorkbookHelper.Create("展牌链接", ["展牌类型", "场地", "相对链接"], classrooms.Concat(buildings).ToList()); + return File(bytes, ExcelWorkbookHelper.ContentType, "场地信息展牌链接.xlsx"); + } + private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync( TimetableResourceType resourceType, Guid resourceId, @@ -290,6 +308,8 @@ public sealed record TimetableBatchExportRequest( Guid AcademicTermId, Guid? SchedulePlanId); +public sealed record VenueDisplayLinkExportRequest(IReadOnlyCollection ClassroomIds, IReadOnlyCollection BuildingIds); + [ApiController] [Route("api/timetables")] public sealed class FreeClassroomsController( diff --git a/src/Jiaowu.Api/Controllers/VenueDisplaysController.cs b/src/Jiaowu.Api/Controllers/VenueDisplaysController.cs new file mode 100644 index 0000000..ffabf50 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/VenueDisplaysController.cs @@ -0,0 +1,61 @@ +using Jiaowu.Api.Domain.Academic; +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] +[AllowAnonymous] +[Route("api/venue-displays")] +public sealed class VenueDisplaysController( + AppDbContext db, + TimetableDataService timetableDataService, + ClassroomReservationAvailabilityService availabilityService) : ControllerBase +{ + [HttpGet("classrooms/{classroomId:guid}")] + public async Task Classroom(Guid classroomId, CancellationToken cancellationToken) + { + var term = await CurrentTermAsync(cancellationToken); + var classroom = await db.Classrooms.AsNoTracking() + .Where(x => x.Id == classroomId && x.IsEnabled && x.Building!.IsEnabled) + .Select(x => new { x.Id, x.Name, x.Code, BuildingName = x.Building!.Name, CampusName = x.Building.Campus!.Name }) + .FirstOrDefaultAsync(cancellationToken); + if (classroom is null) return NotFound(); + if (term is null) return Ok(new { Classroom = classroom, Today = DateOnly.FromDateTime(DateTime.Now), Entries = Array.Empty() }); + + var sheet = await timetableDataService.BuildAsync(TimetableResourceType.Classroom, classroomId, + term.Id, null, false, null, null, cancellationToken); + var today = DateOnly.FromDateTime(DateTime.Now); + var (week, day) = ClassroomReservationAvailabilityService.ResolveTeachingWeek(term, today); + var entries = sheet?.Entries.Where(x => x.DayOfWeek == day && x.StartWeek <= week && x.EndWeek >= week && + (x.WeekPattern == WeekPattern.All || x.WeekPattern == WeekPattern.Odd && week % 2 == 1 || x.WeekPattern == WeekPattern.Even && week % 2 == 0)) + .Select(x => new { x.CourseName, x.TeacherNames, x.ClassNames, x.StartPeriod, x.PeriodCount }) ?? []; + return Ok(new { Classroom = classroom, Term = term.Name, Today = today, Week = week, Slots = sheet?.Slots ?? [], Entries = entries }); + } + + [HttpGet("buildings/{buildingId:guid}")] + public async Task Building(Guid buildingId, CancellationToken cancellationToken) + { + var term = await CurrentTermAsync(cancellationToken); + var building = await db.Buildings.AsNoTracking().Where(x => x.Id == buildingId && x.IsEnabled) + .Select(x => new { x.Id, x.Name, x.Code, CampusName = x.Campus!.Name }).FirstOrDefaultAsync(cancellationToken); + if (building is null) return NotFound(); + if (term is null) return Ok(new { Building = building, Today = DateOnly.FromDateTime(DateTime.Now), Slots = Array.Empty(), Rooms = Array.Empty() }); + + var today = DateOnly.FromDateTime(DateTime.Now); + 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 rooms = await db.Classrooms.AsNoTracking().Where(x => x.BuildingId == buildingId && x.IsEnabled) + .OrderBy(x => x.Code).Select(x => new { x.Id, x.Code, x.Name, x.Capacity }).ToListAsync(cancellationToken); + var occupied = new Dictionary>(); + foreach (var slot in slots) + occupied[slot.PeriodNumber] = await availabilityService.GetOccupiedClassroomIdsAsync(term, today, slot.PeriodNumber, 1, null, cancellationToken); + return Ok(new { Building = building, Term = term.Name, Today = today, Slots = slots, Rooms = rooms.Select(room => new { room.Code, room.Name, room.Capacity, FreePeriods = slots.Where(slot => !occupied[slot.PeriodNumber].Contains(room.Id)).Select(slot => slot.PeriodNumber) }) }); + } + + private Task CurrentTermAsync(CancellationToken cancellationToken) => db.AcademicTerms.AsNoTracking() + .Where(x => x.IsEnabled && x.IsCurrent).FirstOrDefaultAsync(cancellationToken); +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 683c84f..5707895 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -151,6 +151,7 @@ const navigationGroups = computed(() => [ ), ...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }), ...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }), + ...whenVisible(isTimetableManager.value, { path: '/venue-displays', label: '场地信息展牌' }), { path: '/class-timetable', label: isTimetableManager.value ? '课表查询中心' : '班级课表查询', diff --git a/web/src/router/index.ts b/web/src/router/index.ts index b4d916b..1df6bd9 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -31,6 +31,12 @@ const router = createRouter({ component: () => import('../views/TimetableView.vue'), meta: { public: true }, }, + { + path: '/venue-display/:type(classroom|building)/:id', + name: 'venue-display', + component: () => import('../views/VenueDisplayView.vue'), + meta: { public: true }, + }, { path: '/activate', name: 'activate-account', @@ -210,6 +216,12 @@ const router = createRouter({ component: () => import('../views/FreeClassroomsView.vue'), meta: { roles: ['Student'] }, }, + { + path: 'venue-displays', + name: 'venue-displays', + component: () => import('../views/VenueDisplaysView.vue'), + meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] }, + }, { path: 'classroom-reservations', name: 'classroom-reservations', diff --git a/web/src/views/VenueDisplayView.vue b/web/src/views/VenueDisplayView.vue new file mode 100644 index 0000000..c2a82f4 --- /dev/null +++ b/web/src/views/VenueDisplayView.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/web/src/views/VenueDisplaysView.vue b/web/src/views/VenueDisplaysView.vue new file mode 100644 index 0000000..1633c75 --- /dev/null +++ b/web/src/views/VenueDisplaysView.vue @@ -0,0 +1,17 @@ + + +