44 lines
12 KiB
Vue
44 lines
12 KiB
Vue
<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>
|