Implement comprehensive academic statistics and grade analysis workflows
This commit is contained in:
1 parent
370a82d4d4
commit
f677cf8dca
24 files changed
+8703
-42
No files matched your search
@@ -1,50 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
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)
|
||||
let timer = 0
|
||||
let pageTimer = 0
|
||||
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))
|
||||
async function load() { data.value = (await http.get(`/venue-displays/${type.value}s/${id.value}`)).data }
|
||||
function fitPage() {
|
||||
const header = window.innerWidth < 620 ? 220 : 170
|
||||
const available = Math.max(1, window.innerHeight - header)
|
||||
if (type.value === 'classroom') {
|
||||
const columns = window.innerWidth >= 1280 ? 3 : window.innerWidth >= 760 ? 2 : 1
|
||||
pageSize.value = Math.max(1, Math.floor(available / 190) * columns)
|
||||
} else pageSize.value = Math.max(1, Math.floor(available / 58))
|
||||
page.value = Math.min(page.value, pageCount.value - 1)
|
||||
}
|
||||
function nextPage() { page.value = (page.value + 1) % pageCount.value }
|
||||
onMounted(async () => { await load(); fitPage(); window.addEventListener('resize', fitPage); timer = window.setInterval(() => { now.value = new Date(); if (now.value.getMinutes() % 5 === 0 && now.value.getSeconds() === 0) void load() }, 1000); pageTimer = window.setInterval(nextPage, 25000) })
|
||||
onBeforeUnmount(() => { window.clearInterval(timer); window.clearInterval(pageTimer); window.removeEventListener('resize', fitPage) })
|
||||
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}`">
|
||||
<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 visibleItems" :key="`${entry.startPeriod}-${entry.courseName}`" :class="{ reservation: entry.isReservation }"><b>第 {{ entry.startPeriod }}-{{ entry.startPeriod + entry.periodCount - 1 }} 节 <em v-if="entry.isReservation">已借用</em></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 visibleItems" :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>
|
||||
<footer v-if="pageCount > 1"><span v-for="index in pageCount" :key="index" :class="{ active: index - 1 === page }"></span><b>{{ page + 1 }} / {{ pageCount }}</b></footer>
|
||||
<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{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 article.reservation{border-left-color:#f5a524;background:#3b3020}.course-board b{color:#63d6bc}.course-board article.reservation b{color:#ffd07b}.course-board em{margin-left:8px;padding:3px 8px;border-radius:999px;background:#7a4b00;color:#fff;font-size:13px;font-style:normal}.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}}
|
||||
.venue-display footer{display:flex;align-items:center;justify-content:center;gap:7px;margin-top:20px;color:#abc5cf}.venue-display footer span{width:7px;height:7px;border-radius:50%;background:#42616d}.venue-display footer span.active{width:22px;border-radius:5px;background:#63d6bc}.venue-display footer b{margin-left:8px;font:12px ui-monospace,monospace}
|
||||
.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
|
||||
</style>
|
||||
Reference in new issue
Block a user