教学大屏
This commit is contained in:
@@ -242,6 +242,24 @@ public sealed class TimetableManagementController(
|
|||||||
return File(TimetablePdfExporter.Create(timetables), "application/pdf", fileName);
|
return File(TimetablePdfExporter.Create(timetables), "application/pdf", fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("display-links/export.xlsx")]
|
||||||
|
public async Task<ActionResult> 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(
|
private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync(
|
||||||
TimetableResourceType resourceType,
|
TimetableResourceType resourceType,
|
||||||
Guid resourceId,
|
Guid resourceId,
|
||||||
@@ -290,6 +308,8 @@ public sealed record TimetableBatchExportRequest(
|
|||||||
Guid AcademicTermId,
|
Guid AcademicTermId,
|
||||||
Guid? SchedulePlanId);
|
Guid? SchedulePlanId);
|
||||||
|
|
||||||
|
public sealed record VenueDisplayLinkExportRequest(IReadOnlyCollection<Guid> ClassroomIds, IReadOnlyCollection<Guid> BuildingIds);
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/timetables")]
|
[Route("api/timetables")]
|
||||||
public sealed class FreeClassroomsController(
|
public sealed class FreeClassroomsController(
|
||||||
|
|||||||
@@ -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<ActionResult> 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<object>() });
|
||||||
|
|
||||||
|
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<ActionResult> 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<object>(), Rooms = Array.Empty<object>() });
|
||||||
|
|
||||||
|
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<int, HashSet<Guid>>();
|
||||||
|
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<AcademicTerm?> CurrentTermAsync(CancellationToken cancellationToken) => db.AcademicTerms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled && x.IsCurrent).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
@@ -151,6 +151,7 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
),
|
),
|
||||||
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
|
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
|
||||||
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
|
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
|
||||||
|
...whenVisible(isTimetableManager.value, { path: '/venue-displays', label: '场地信息展牌' }),
|
||||||
{
|
{
|
||||||
path: '/class-timetable',
|
path: '/class-timetable',
|
||||||
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
|
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/TimetableView.vue'),
|
component: () => import('../views/TimetableView.vue'),
|
||||||
meta: { public: true },
|
meta: { public: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/venue-display/:type(classroom|building)/:id',
|
||||||
|
name: 'venue-display',
|
||||||
|
component: () => import('../views/VenueDisplayView.vue'),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/activate',
|
path: '/activate',
|
||||||
name: 'activate-account',
|
name: 'activate-account',
|
||||||
@@ -210,6 +216,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/FreeClassroomsView.vue'),
|
component: () => import('../views/FreeClassroomsView.vue'),
|
||||||
meta: { roles: ['Student'] },
|
meta: { roles: ['Student'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'venue-displays',
|
||||||
|
name: 'venue-displays',
|
||||||
|
component: () => import('../views/VenueDisplaysView.vue'),
|
||||||
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'classroom-reservations',
|
path: 'classroom-reservations',
|
||||||
name: 'classroom-reservations',
|
name: 'classroom-reservations',
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import http from '../api/http'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const type = computed(() => route.params.type as 'classroom' | 'building')
|
||||||
|
const id = computed(() => route.params.id as string)
|
||||||
|
const data = ref<any>()
|
||||||
|
const now = ref(new Date())
|
||||||
|
let timer = 0
|
||||||
|
const title = computed(() => type.value === 'classroom' ? data.value?.classroom : data.value?.building)
|
||||||
|
const timeText = computed(() => now.value.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }))
|
||||||
|
async function load() { data.value = (await http.get(`/venue-displays/${type.value}s/${id.value}`)).data }
|
||||||
|
onMounted(async () => { await load(); timer = window.setInterval(() => { now.value = new Date(); if (now.value.getMinutes() % 5 === 0 && now.value.getSeconds() === 0) void load() }, 1000) })
|
||||||
|
onBeforeUnmount(() => window.clearInterval(timer))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="venue-display" :class="`venue-${type}`">
|
||||||
|
<header><div><span>明序大学 · 智慧教学空间</span><h1>{{ title?.name ?? '正在加载' }}</h1><p>{{ title?.campusName }} · {{ title?.buildingName ?? title?.code }} · {{ data?.term ?? '当前教学日' }}</p></div><time>{{ timeText }}<small>{{ data?.today }}</small></time></header>
|
||||||
|
<section v-if="type === 'classroom'" class="course-board">
|
||||||
|
<article v-for="entry in data?.entries" :key="`${entry.startPeriod}-${entry.courseName}`"><b>第 {{ entry.startPeriod }}-{{ entry.startPeriod + entry.periodCount - 1 }} 节</b><h2>{{ entry.courseName }}</h2><p>{{ entry.classNames?.join('、') }} · {{ entry.teacherNames?.join('、') }}</p></article>
|
||||||
|
<div v-if="data && !data.entries?.length" class="empty">今日暂无排课<br><small>此教室可供安排使用</small></div>
|
||||||
|
</section>
|
||||||
|
<section v-else class="room-board"><div class="periods"><span v-for="slot in data?.slots" :key="slot.periodNumber">{{ slot.periodNumber }}<small>{{ slot.startsAt?.slice(0,5) }}</small></span></div><article v-for="room in data?.rooms" :key="room.code"><b>{{ room.name }}</b><span v-for="slot in data?.slots" :key="slot.periodNumber" :class="{ free: room.freePeriods?.includes(slot.periodNumber) }">{{ room.freePeriods?.includes(slot.periodNumber) ? '空闲' : '占用' }}</span></article></section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.venue-display{min-height:100vh;padding:clamp(22px,4vw,62px);background:#0b1e2b;color:#eef7f5;font-family:"Microsoft YaHei",sans-serif}.venue-display header{display:flex;justify-content:space-between;gap:28px;padding-bottom:28px;border-bottom:1px solid #315464}.venue-display header span{color:#63d6bc;letter-spacing:.16em;font-size:12px}.venue-display h1{font-size:clamp(32px,5vw,68px);margin:10px 0}.venue-display p{margin:0;color:#abc5cf}.venue-display time{font:clamp(26px,4vw,52px) ui-monospace,monospace;text-align:right;color:#63d6bc}.venue-display time small{display:block;font:13px "Microsoft YaHei";color:#abc5cf;margin-top:8px}.course-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:18px;margin-top:32px}.course-board article{padding:26px;background:#123243;border-left:5px solid #63d6bc}.course-board b{color:#63d6bc}.course-board h2{font-size:clamp(24px,3vw,40px);margin:24px 0 12px}.empty{grid-column:1/-1;padding:80px 20px;text-align:center;font-size:30px;background:#102a38;color:#63d6bc}.room-board{margin-top:30px;overflow:auto}.periods,.room-board article{display:grid;grid-template-columns:minmax(150px,2fr) repeat(12,minmax(65px,1fr));gap:4px;min-width:950px}.periods{margin-left:0}.periods span{grid-column:span 1;text-align:center;color:#abc5cf}.periods span:first-child{grid-column:1}.periods small{display:block}.room-board article{margin-top:5px}.room-board article b,.room-board article span{padding:16px 8px;background:#213c49;text-align:center}.room-board article span.free{background:#1d6b5d;color:white;font-weight:bold}@media(max-width:620px){.venue-display header{display:block}.venue-display time{text-align:left;margin-top:18px}.venue-display{padding:22px}.course-board article{padding:20px}}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import http from '../api/http'
|
||||||
|
import { downloadApiPostFile } from '../api/excel'
|
||||||
|
import { defaultAcademicTermId } from '../utils/academicTerms'
|
||||||
|
const terms=ref<any[]>([]); const buildings=ref<any[]>([]); const classrooms=ref<any[]>([]); const termId=ref(''); const buildingId=ref(''); const classroomId=ref('')
|
||||||
|
const selectedClassrooms=ref<string[]>([]); const selectedBuildings=ref<string[]>([])
|
||||||
|
const origin=window.location.origin
|
||||||
|
const classroomUrl=computed(()=>classroomId.value?`${origin}/venue-display/classroom/${classroomId.value}`:'')
|
||||||
|
const buildingUrl=computed(()=>buildingId.value?`${origin}/venue-display/building/${buildingId.value}`:'')
|
||||||
|
async function load(){const publicData=(await http.get('/timetables/options')).data;terms.value=publicData.terms;termId.value=defaultAcademicTermId(terms.value)??'';const data=(await http.get('/timetables/management/options',{params:{academicTermId:termId.value}})).data;buildings.value=data.buildings;classrooms.value=data.classrooms}
|
||||||
|
async function copy(value:string){await window.navigator.clipboard.writeText(value);ElMessage.success('展牌链接已复制')}
|
||||||
|
async function exportLinks(){if(!selectedClassrooms.value.length&&!selectedBuildings.value.length){ElMessage.warning('请先选择要导出的展牌链接');return}await downloadApiPostFile('/timetables/management/display-links/export.xlsx',{classroomIds:selectedClassrooms.value,buildingIds:selectedBuildings.value},'场地信息展牌链接.xlsx')}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
<template><main class="venue-manager"><h2>场地信息展牌</h2><p>以下链接适用于教室屏幕和教学楼大屏;它们不出现在普通用户导航中,仅管理员在此配置与投放。</p><section><h3>教室当天课程</h3><el-select v-model="classroomId" filterable placeholder="选择教室"><el-option v-for="x in classrooms" :key="x.id" :value="x.id" :label="`${x.buildingName} · ${x.name}`"/></el-select><el-input v-if="classroomUrl" :model-value="classroomUrl" readonly><template #append><el-button @click="copy(classroomUrl)">复制链接</el-button></template></el-input></section><section><h3>教学楼空余教室</h3><el-select v-model="buildingId" filterable placeholder="选择教学楼"><el-option v-for="x in buildings" :key="x.id" :value="x.id" :label="x.name"/></el-select><el-input v-if="buildingUrl" :model-value="buildingUrl" readonly><template #append><el-button @click="copy(buildingUrl)">复制链接</el-button></template></el-input></section><section><h3>批量导出展牌链接</h3><el-select v-model="selectedClassrooms" multiple filterable collapse-tags placeholder="选择教室"><el-option v-for="x in classrooms" :key="x.id" :value="x.id" :label="`${x.buildingName} · ${x.name}`"/></el-select><el-select v-model="selectedBuildings" multiple filterable collapse-tags placeholder="选择教学楼"><el-option v-for="x in buildings" :key="x.id" :value="x.id" :label="x.name"/></el-select><el-button type="primary" @click="exportLinks">导出 Excel 链接({{ selectedClassrooms.length + selectedBuildings.length }})</el-button></section></main></template>
|
||||||
|
<style scoped>.venue-manager{max-width:900px}.venue-manager>p{color:#637587}.venue-manager section{display:grid;gap:14px;margin-top:24px;padding:22px;border:1px solid #dce6eb;background:#fff}.venue-manager h3{margin:0;color:#17324d}</style>
|
||||||
Reference in New Issue
Block a user