Files
Academic-Affairs-System/web/src/views/VenueDisplayView.vue
T

44 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } 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()); const page = ref(0); const pageSize = ref(1); const isFullscreen = ref(false); const isQrExpanded = ref(false); const board = ref<HTMLElement>()
let clockTimer = 0; let refreshTimer = 0; let pageTimer = 0; let resizeObserver: ResizeObserver | undefined
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' }))
const displayItems = computed(() => type.value === 'classroom' ? data.value?.entries ?? [] : data.value?.rooms ?? [])
const pageCount = computed(() => Math.max(1, Math.ceil(displayItems.value.length / pageSize.value)))
const visibleItems = computed(() => displayItems.value.slice(page.value * pageSize.value, (page.value + 1) * pageSize.value))
const reportQrUrl = computed(() => `/api/classroom-repairs/report/${id.value}/qr`)
function toMinutes(value?: string) { const [hour = '0', minute = '0'] = (value ?? '').split(':'); return Number(hour) * 60 + Number(minute) }
const currentEntry = computed(() => type.value === 'classroom' ? (data.value?.entries ?? []).find((entry: any) => { const slot = data.value?.slots?.find((item: any) => item.periodNumber === entry.startPeriod); const end = data.value?.slots?.find((item: any) => item.periodNumber === entry.startPeriod + entry.periodCount - 1); const time = now.value.getHours() * 60 + now.value.getMinutes(); return slot && end && time >= toMinutes(slot.startsAt) && time < toMinutes(end.endsAt) }) : null)
async function load() { data.value = (await http.get(`/venue-displays/${type.value}s/${id.value}`)).data; await nextTick(); fitPage() }
function fitPage() { const height = board.value?.clientHeight ?? window.innerHeight - 230; if (type.value === 'classroom') { const columns = window.innerWidth >= 1180 ? 2 : 1; pageSize.value = Math.max(1, Math.floor(height / (window.innerWidth >= 760 ? 176 : 148)) * columns) } else pageSize.value = Math.max(1, Math.floor(height / 66)); page.value = Math.min(page.value, pageCount.value - 1); restartPaging() }
function nextPage() { if (pageCount.value > 1) page.value = (page.value + 1) % pageCount.value }
function previousPage() { if (pageCount.value > 1) page.value = (page.value - 1 + pageCount.value) % pageCount.value }
function restartPaging() { window.clearInterval(pageTimer); if (pageCount.value > 1) pageTimer = window.setInterval(nextPage, 25000) }
async function toggleFullscreen() { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen() }
function handleFullscreen() { isFullscreen.value = Boolean(document.fullscreenElement); window.setTimeout(fitPage, 100) }
function handleKeydown(event: KeyboardEvent) { if (event.key === 'ArrowRight') nextPage(); if (event.key === 'ArrowLeft') previousPage(); if (event.key.toLowerCase() === 'f') void toggleFullscreen() }
watch(() => displayItems.value.length, () => { page.value = 0; void nextTick(fitPage) })
onMounted(async () => { await load(); resizeObserver = new ResizeObserver(fitPage); if (board.value) resizeObserver.observe(board.value); window.addEventListener('resize', fitPage); window.addEventListener('keydown', handleKeydown); document.addEventListener('fullscreenchange', handleFullscreen); clockTimer = window.setInterval(() => { now.value = new Date() }, 1000); refreshTimer = window.setInterval(() => void load(), 5 * 60 * 1000) })
onBeforeUnmount(() => { window.clearInterval(clockTimer); window.clearInterval(refreshTimer); window.clearInterval(pageTimer); resizeObserver?.disconnect(); window.removeEventListener('resize', fitPage); window.removeEventListener('keydown', handleKeydown); document.removeEventListener('fullscreenchange', handleFullscreen) })
</script>
<template>
<main class="venue-display" :class="[`venue-${type}`, { fullscreen: isFullscreen }]">
<header class="display-header"><div class="identity"><span>明序大学 · 智慧教学空间</span><h1>{{ title?.name ?? '正在加载' }}</h1><p>{{ title?.campusName }} · {{ title?.buildingName ?? title?.code }} · {{ data?.term ?? '当前教学日' }}</p></div><div v-if="type === 'classroom'" class="now-playing"><span>当前状态</span><b>{{ currentEntry?.courseName ?? '当前无课程' }}</b><small v-if="currentEntry">第 {{ currentEntry.startPeriod }}–{{ currentEntry.startPeriod + currentEntry.periodCount - 1 }} 节</small></div><button v-if="type === 'classroom'" class="repair-code" type="button" @click="isQrExpanded = true"><img :src="reportQrUrl" alt="扫码报修二维码"><div><b>扫码报修</b><small>点击放大二维码</small></div></button><time>{{ timeText }}<small>{{ data?.today }}</small></time><button class="fullscreen-button" type="button" @click="toggleFullscreen">{{ isFullscreen ? '退出全屏' : '全屏' }}</button></header>
<section ref="board" class="display-board"><template v-if="type === 'classroom'"><article v-for="entry in visibleItems" :key="`${entry.startPeriod}-${entry.courseName}`" class="course-card" :class="{ reservation: entry.isReservation, current: entry === currentEntry }"><div class="period">{{ entry.startPeriod }}<small>–{{ entry.startPeriod + entry.periodCount - 1 }} 节</small></div><div><span v-if="entry.isReservation" class="reservation-label">已借用</span><h2>{{ entry.courseName }}</h2><p>{{ entry.classNames?.join('、') }}<i></i>{{ entry.teacherNames?.join('、') }}</p></div></article><div v-if="data && !data.entries?.length" class="empty"><b>今日暂无排课</b><small>如需使用,请按学校流程办理借用</small></div></template><template v-else><div class="room-head"><b>教室</b><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 visibleItems" :key="room.code" class="room-row"><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></template></section>
<footer v-if="pageCount > 1"><button type="button" @click="previousPage">‹</button><span v-for="index in pageCount" :key="index" :class="{ active: index - 1 === page }"></span><b>{{ page + 1 }} / {{ pageCount }}</b><button type="button" @click="nextPage">›</button><small>25 秒自动翻页 · ← → 手动翻页</small></footer>
<div v-if="isQrExpanded" class="qr-overlay" role="dialog" aria-modal="true" aria-label="教室报修二维码" @click.self="isQrExpanded = false"><button type="button" aria-label="关闭二维码" @click="isQrExpanded = false">×</button><div><img :src="reportQrUrl" alt="放大的教室报修二维码"><b>扫码报修</b><span>故障将直接提交至 {{ title?.name }}</span></div></div>
</main>
</template>
<style scoped>
.venue-display{--ink:#f0faf8;--muted:#adc4ca;--line:rgb(159 223 210 / .25);--accent:#65d7ba;min-height:100dvh;box-sizing:border-box;padding:clamp(18px,3.4vw,54px);display:flex;flex-direction:column;gap:clamp(18px,2.4vh,30px);overflow:hidden;background:radial-gradient(circle at 80% 0%,#1d5865 0%,transparent 32%),#0a1c29;color:var(--ink);font-family:"Microsoft YaHei",sans-serif}.display-header{display:grid;grid-template-columns:minmax(280px,1fr) auto auto auto auto;align-items:center;gap:clamp(16px,2vw,34px);padding-bottom:clamp(16px,2vh,26px);border-bottom:1px solid var(--line)}.identity>span,.now-playing>span{color:var(--accent);font-size:12px;font-weight:700;letter-spacing:.15em}.identity h1{font-size:clamp(30px,4.5vw,62px);line-height:1.1;margin:8px 0}.identity p{margin:0;color:var(--muted)}.now-playing{padding-left:20px;border-left:2px solid var(--accent);min-width:130px}.now-playing b,.now-playing small{display:block}.now-playing b{margin:7px 0;font-size:clamp(16px,1.8vw,25px)}.now-playing small,.repair-code small,time small{color:var(--muted)}.repair-code{display:flex;align-items:center;gap:10px;padding:8px 12px;border:0;background:#effdf9;color:#102a3a;text-align:left;cursor:zoom-in}.repair-code img{width:72px;height:72px}.repair-code b,.repair-code small{display:block}.repair-code small{color:#50707a;font-size:12px;margin-top:4px}time{font:clamp(25px,3.3vw,48px) ui-monospace,monospace;text-align:right;color:var(--accent);white-space:nowrap}time small{display:block;font:12px "Microsoft YaHei";margin-top:5px}.fullscreen-button{border:1px solid var(--line);background:transparent;color:var(--ink);padding:9px 12px;cursor:pointer}.display-board{flex:1;min-height:0}.venue-classroom .display-board{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:minmax(145px,1fr);gap:14px}.course-card{display:grid;grid-template-columns:auto 1fr;gap:18px;align-items:center;padding:22px;background:linear-gradient(110deg,#123443,#163d4c);border-left:5px solid var(--accent);min-width:0}.course-card.current{box-shadow:inset 0 0 0 1px var(--accent);background:#164756}.course-card.reservation{border-color:#f5a524;background:#3b3020}.period{font:700 clamp(34px,4vw,60px) ui-monospace,monospace;color:var(--accent);line-height:.8}.period small{display:block;font:12px "Microsoft YaHei";color:var(--muted);margin-top:9px;white-space:nowrap}.course-card.reservation .period{color:#ffd07b}.course-card h2{font-size:clamp(21px,2.6vw,38px);line-height:1.15;margin:0 0 12px;word-break:break-word}.course-card p{margin:0;color:var(--muted);font-size:clamp(14px,1.5vw,20px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.course-card i{display:inline-block;width:4px;height:4px;border-radius:50%;background:var(--accent);vertical-align:middle;margin:0 10px}.reservation-label{display:inline-block;margin-bottom:8px;color:#ffd07b;font-size:12px}.empty{grid-column:1/-1;display:grid;place-content:center;text-align:center;background:#102d3b;color:var(--accent);font-size:clamp(28px,4vw,54px)}.empty small{display:block;margin-top:16px;color:var(--muted);font-size:16px}.venue-building .display-board{overflow:hidden}.room-head,.room-row{display:grid;grid-template-columns:minmax(150px,2fr) repeat(12,minmax(54px,1fr));gap:4px;min-width:900px}.room-head{padding-bottom:6px;color:var(--muted);text-align:center}.room-head span small{display:block;font-size:10px}.room-row{margin-top:5px}.room-row>b,.room-row>span{display:grid;place-items:center;padding:12px 7px;background:#213f4b;text-align:center}.room-row>span.free{background:#1b6d5d;color:#fff;font-weight:700}footer{display:flex;align-items:center;justify-content:center;gap:8px;color:var(--muted)}footer span{width:7px;height:7px;border-radius:99px;background:#42616d}footer span.active{width:24px;background:var(--accent)}footer b{font:12px ui-monospace,monospace;margin-left:5px}footer button{border:0;background:#244856;color:#fff;width:28px;height:28px;border-radius:50%;font-size:22px;cursor:pointer}footer small{margin-left:10px;font-size:12px}.qr-overlay{position:fixed;inset:0;z-index:20;display:grid;place-items:center;background:rgb(2 14 22 / .9);cursor:zoom-out}.qr-overlay>div{display:grid;justify-items:center;gap:14px;padding:clamp(24px,5vw,54px);background:#f5fffc;color:#102a3a;box-shadow:0 24px 80px #0008}.qr-overlay img{width:min(62vw,560px);height:auto;image-rendering:pixelated}.qr-overlay b{font-size:clamp(24px,3vw,38px)}.qr-overlay span{color:#50707a}.qr-overlay>button{position:absolute;right:clamp(18px,4vw,46px);top:clamp(18px,4vw,36px);border:1px solid #ffffff66;background:transparent;color:#fff;width:42px;height:42px;font-size:30px;cursor:pointer}@media(max-width:1060px){.display-header{grid-template-columns:1fr auto auto auto}.now-playing{display:none}.venue-classroom .display-board{grid-template-columns:1fr}.repair-code img{width:64px;height:64px}}@media(max-width:680px){.venue-display{padding:18px;overflow:auto}.display-header{grid-template-columns:1fr auto;gap:12px}.repair-code{grid-row:2;grid-column:1/-1}.identity h1{font-size:32px}.identity p{font-size:13px}.fullscreen-button{display:none}.venue-classroom .display-board{grid-auto-rows:minmax(135px,auto)}.course-card{padding:18px}.course-card h2{font-size:24px}footer small{display:none}.qr-overlay img{width:min(82vw,440px)}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
</style>