展板优化

This commit is contained in:
biss committed 2026-08-19 11:50:46 +08:00
1 parent b96d0f04a4
commit 7e169d64a2
14 files changed
+8417 -23

No files matched your search

+1 -1
View File
@@ -154,7 +154,7 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
),
...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: '场地信息展牌' }),
...whenVisible(hasAnyRole(['SuperAdmin', 'AcademicAdmin']), { path: '/venue-displays', label: '场地信息展牌' }),
{
path: '/class-timetable',
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
+1 -1
View File
@@ -226,7 +226,7 @@ const router = createRouter({
path: 'venue-displays',
name: 'venue-displays',
component: () => import('../views/VenueDisplaysView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
},
{
path: 'classroom-reservations',
+41 -4
View File
@@ -7,16 +7,50 @@ 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
let clockTimer = 0; let refreshTimer = 0; let pageTimer = 0; let resizeObserver: ResizeObserver | undefined; let loading = false
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 announcements = computed(() => data.value?.announcements ?? [])
// Duplicate every sequence so the -50% marquee distance always covers one complete run, including a single long announcement.
const tickerItems = computed(() => [...announcements.value, ...announcements.value])
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() }
async function load() {
if (loading) return
loading = true
try {
data.value = (await http.get(`/venue-displays/${type.value}s/${id.value}`)).data
await nextTick()
fitPage()
} finally {
loading = false
}
}
function scheduleRefresh() {
window.clearTimeout(refreshTimer)
const today = data.value?.today
const boundaries = (data.value?.slots ?? []).flatMap((slot: any) => [slot.startsAt, slot.endsAt])
.map((time: string) => new Date(`${today}T${time}`).getTime())
.filter((time: number) => time > Date.now())
const nextBoundary = boundaries.length ? Math.min(...boundaries) : Number.POSITIVE_INFINITY
// Keep approved reservations and timetable changes reasonably fresh, while refreshing immediately after a class boundary.
const delay = Math.max(1_000, Math.min(60_000, nextBoundary - Date.now() + 1_000))
refreshTimer = window.setTimeout(() => void refresh(), delay)
}
async function refresh() {
try {
await load()
} catch {
// Keep the last successful screen visible and retry soon instead of blanking the display.
refreshTimer = window.setTimeout(() => void refresh(), 15_000)
return
}
scheduleRefresh()
}
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 }
@@ -25,14 +59,16 @@ async function toggleFullscreen() { if (document.fullscreenElement) await docume
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) })
function handleVisibilityChange() { if (!document.hidden) void refresh() }
onMounted(async () => { await refresh(); resizeObserver = new ResizeObserver(fitPage); if (board.value) resizeObserver.observe(board.value); window.addEventListener('resize', fitPage); window.addEventListener('keydown', handleKeydown); document.addEventListener('fullscreenchange', handleFullscreen); document.addEventListener('visibilitychange', handleVisibilityChange); clockTimer = window.setInterval(() => { now.value = new Date() }, 1000) })
onBeforeUnmount(() => { window.clearInterval(clockTimer); window.clearTimeout(refreshTimer); window.clearInterval(pageTimer); resizeObserver?.disconnect(); window.removeEventListener('resize', fitPage); window.removeEventListener('keydown', handleKeydown); document.removeEventListener('fullscreenchange', handleFullscreen); document.removeEventListener('visibilitychange', handleVisibilityChange) })
</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>
<aside v-if="announcements.length" class="announcement-ticker" aria-label="展屏公告"><b>公告</b><div class="ticker-window"><div class="ticker-track"><span v-for="(announcement, index) in tickerItems" :key="`${announcement.id}-${index}`" :class="{ urgent: announcement.isUrgent }"><em v-if="announcement.isUrgent">紧急</em><strong>{{ announcement.title }}</strong><i>—</i>{{ announcement.content }}</span></div></div></aside>
<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>
@@ -40,4 +76,5 @@ onBeforeUnmount(() => { window.clearInterval(clockTimer); window.clearInterval(r
<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:12pxLine truncated
.announcement-ticker{display:flex;align-items:center;gap:16px;min-height:52px;padding:0 18px;border-left:4px solid #65d7ba;background:#123443;overflow:hidden}.announcement-ticker>b{flex:none;color:#65d7ba;font-size:14px;letter-spacing:.14em}.ticker-window{min-width:0;overflow:hidden}.ticker-track{display:flex;width:max-content;animation:ticker-scroll 42s linear infinite}.ticker-track span{display:inline-flex;align-items:center;gap:10px;padding-right:72px;white-space:nowrap;color:#e7f6f4}.ticker-track strong{font-weight:700}.ticker-track i{font-style:normal;color:#65d7ba}.ticker-track em{padding:3px 6px;background:#d9504a;color:#fff;font-size:11px;font-style:normal;font-weight:700}@keyframes ticker-scroll{to{transform:translateX(-50%)}}@media(prefers-reduced-motion:reduce){.ticker-track{animation:none}}
</style>
Loaded 3 of 14 files, more files were not shown because too many files have changed in this diff. Show more