Implement comprehensive academic statistics and grade analysis workflows

This commit is contained in:
biss committed 2026-08-18 11:05:03 +08:00
1 parent 370a82d4d4
commit f677cf8dca
24 files changed
+8703 -42

No files matched your search

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import http from '../api/http'
const route = useRoute()
const classroomId = computed(() => String(route.params.classroomId))
const loading = ref(true)
const submitting = ref(false)
const classroom = ref<any>()
const equipment = ref<any[]>([])
const form = ref({ classroomEquipmentId: '', category: 1, faultItem: '', description: '', reporterName: '', reporterContact: '' })
async function load() {
loading.value = true
try {
const { data } = await http.get(`/classroom-repairs/report/${classroomId.value}`)
classroom.value = data.classroom
equipment.value = data.equipment
} catch (_) { ElMessage.error('未找到该教室,请核对二维码。') } finally { loading.value = false }
}
async function submit() {
if (!form.value.faultItem || !form.value.description || !form.value.reporterName || !form.value.reporterContact) {
ElMessage.warning('请完整填写故障现象和联系方式。')
return
}
submitting.value = true
try {
await http.post(`/classroom-repairs/report/${classroomId.value}`, {
...form.value,
classroomEquipmentId: form.value.classroomEquipmentId || null,
})
ElMessage.success('报修已提交,维修部门将尽快处理。')
form.value = { classroomEquipmentId: '', category: 1, faultItem: '', description: '', reporterName: '', reporterContact: '' }
await load()
} finally { submitting.value = false }
}
onMounted(load)
</script>
<template>
<main class="repair-report" v-loading="loading">
<section v-if="classroom" class="report-card">
<p class="eyebrow">教室服务 · 扫码报修</p>
<h1>{{ classroom.buildingName }} · {{ classroom.name }}</h1>
<p class="subtle">{{ classroom.code }}。提交后将生成维修工单,并提醒受影响的后续授课教师办理调换教室。</p>
<el-form label-position="top" @submit.prevent="submit">
<el-form-item label="故障设施">
<el-select v-model="form.classroomEquipmentId" clearable placeholder="选择具体设备;基础设施可不选">
<el-option v-for="item in equipment" :key="item.id" :label="`${item.name}${item.isAvailable ? '' : '(不可用)'}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="故障类型">
<el-select v-model="form.category">
<el-option label="多媒体(投影仪、幕布、电子白板等)" :value="1" />
<el-option label="课桌椅" :value="2" />
<el-option label="水、电、暖等基础设施" :value="3" />
<el-option label="空调与通风" :value="4" />
<el-option label="其他" :value="5" />
</el-select>
</el-form-item>
<el-form-item label="故障项目"><el-input v-model="form.faultItem" maxlength="80" placeholder="例如:投影仪无法开机、第三排座椅损坏" /></el-form-item>
<el-form-item label="详细说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="2000" show-word-limit placeholder="请说明故障表现、位置、发生时间和可能影响,便于针对性维修。" /></el-form-item>
<div class="contact-row">
<el-form-item label="报修人"><el-input v-model="form.reporterName" maxlength="50" /></el-form-item>
<el-form-item label="联系方式"><el-input v-model="form.reporterContact" maxlength="100" /></el-form-item>
</div>
<el-button type="primary" native-type="submit" :loading="submitting">提交报修</el-button>
</el-form>
</section>
</main>
</template>
<style scoped>
.repair-report { min-height: 100vh; display: grid; place-items: start center; padding: 8vh 20px; background: linear-gradient(145deg, #edf5ff, #f7fafc); }
.report-card { width: min(680px, 100%); padding: 34px; border-radius: 22px; background: #fff; box-shadow: 0 18px 55px rgb(31 67 117 / 12%); }
.eyebrow { margin: 0; color: #2770c8; font-weight: 700; letter-spacing: .08em; font-size: 13px; }
h1 { margin: 10px 0; font-size: clamp(24px, 5vw, 34px); color: #17355d; }.subtle { line-height: 1.7; color: #66788e; margin-bottom: 24px; }
.contact-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .contact-row :deep(.el-form-item) { margin-bottom: 18px; }
@media (max-width: 520px) { .report-card { padding: 24px 18px; border-radius: 16px; }.contact-row { grid-template-columns: 1fr; gap: 0; } }
</style>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import * as QRCode from 'qrcode'
import { ElMessage } from 'element-plus'
import http from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const tickets = ref<any[]>([])
const workers = ref<any[]>([])
const classrooms = ref<any[]>([])
const loading = ref(false)
const status = ref<number | ''>('')
const assignVisible = ref(false)
const selected = ref<any>()
const assigneeId = ref('')
const completeVisible = ref(false)
const result = ref('')
const qrVisible = ref(false)
const qrDataUrl = ref('')
const qrUrl = ref('')
const qrClassroomId = ref('')
const equipmentVisible = ref(false)
const equipmentClassroomId = ref('')
const equipmentRows = ref<any[]>([])
const equipmentForm = ref({ name: '', category: 1, notes: '' })
const isManager = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin', 'MaintenanceAdmin'].includes(role)) ?? false)
async function load() {
loading.value = true
try {
const { data } = await http.get('/classroom-repairs', { params: status.value ? { status: status.value } : {} })
tickets.value = data
if (isManager.value) {
workers.value = (await http.get('/classroom-repairs/workers')).data
classrooms.value = (await http.get('/classroom-repairs/classrooms')).data
}
} finally { loading.value = false }
}
async function assign(ticket: any) { selected.value = ticket; assigneeId.value = ticket.assignedToUserId ?? ''; assignVisible.value = true }
async function submitAssign() { await http.put(`/classroom-repairs/${selected.value.id}/assign`, { assignedToUserId: assigneeId.value }); ElMessage.success('已分派'); assignVisible.value = false; load() }
async function start(ticket: any) { await http.put(`/classroom-repairs/${ticket.id}/start`); ElMessage.success('已开始维修'); load() }
async function openComplete(ticket: any) { selected.value = ticket; result.value = ''; completeVisible.value = true }
async function submitComplete() { if (!result.value) return ElMessage.warning('请填写维修结果。'); await http.put(`/classroom-repairs/${selected.value.id}/complete`, { repairResult: result.value }); ElMessage.success('工单已完成,教室状态已更新'); completeVisible.value = false; load() }
async function showQr(ticket: any) { qrUrl.value = `${location.origin}${import.meta.env.BASE_URL.replace(/\/$/, '')}/classroom-repairs/report/${ticket.classroomId}`; qrDataUrl.value = await QRCode.toDataURL(qrUrl.value, { width: 280, margin: 1 }); qrVisible.value = true }
async function showClassroomQr() { if (!qrClassroomId.value) return ElMessage.warning('请选择教室。'); await showQr({ classroomId: qrClassroomId.value }) }
async function loadEquipment() { if (!equipmentClassroomId.value) return; equipmentRows.value = (await http.get(`/classroom-repairs/classrooms/${equipmentClassroomId.value}/equipment`)).data }
async function addEquipment() { if (!equipmentForm.value.name) return ElMessage.warning('请填写设备名称。'); await http.post(`/classroom-repairs/classrooms/${equipmentClassroomId.value}/equipment`, equipmentForm.value); equipmentForm.value = { name: '', category: 1, notes: '' }; await loadEquipment() }
function statusName(value: number) { return ['待分派', '已分派', '维修中', '已完成'][value - 1] }
function statusType(value: number) { return value === 4 ? 'success' : value === 3 ? 'warning' : value === 2 ? 'primary' : 'info' }
onMounted(load)
</script>
<template>
<section class="page-shell">
<div class="page-heading"><div><p class="eyebrow">FACILITY CARE</p><h1>教室设备与故障闭环</h1><p>扫码报修、维修分派、状态恢复与受影响教师提醒。</p></div><div class="heading-actions"><el-button v-if="isManager" @click="equipmentVisible = true">设备档案</el-button><el-button v-if="isManager" @click="qrDataUrl = ''; qrVisible = true">生成场地报修码</el-button><el-button @click="load">刷新</el-button></div></div>
<el-card shadow="never"><div class="toolbar"><el-select v-model="status" clearable placeholder="全部状态" @change="load"><el-option v-for="item in [1,2,3,4]" :key="item" :label="statusName(item)" :value="item" /></el-select></div>
<el-table :data="tickets" v-loading="loading" stripe><el-table-column label="场地" min-width="160"><template #default="{ row }"><b>{{ row.buildingName }} · {{ row.classroomName }}</b><br><span>{{ row.equipmentName || row.faultItem }}</span></template></el-table-column><el-table-column prop="description" label="故障说明" min-width="240" show-overflow-tooltip /><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusName(row.status) }}</el-tag></template></el-table-column><el-table-column label="维修员" width="120"><template #default="{ row }">{{ row.assigneeName || '未分派' }}</template></el-table-column><el-table-column label="报修时间" width="170"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column fixed="right" label="操作" width="220"><template #default="{ row }"><el-button link @click="showQr(row)">报修码</el-button><el-button v-if="isManager && row.status !== 4" link type="primary" @click="assign(row)">分派</el-button><el-button v-if="row.status === 2" link type="warning" @click="start(row)">开始</el-button><el-button v-if="row.status === 3" link type="success" @click="openComplete(row)">完成</el-button></template></el-table-column></el-table></el-card>
<el-dialog v-model="assignVisible" title="分派维修员" width="420px"><el-select v-model="assigneeId" placeholder="选择维修员" style="width:100%"><el-option v-for="worker in workers" :key="worker.id" :label="worker.displayName" :value="worker.id" /></el-select><template #footer><el-button @click="assignVisible=false">取消</el-button><el-button type="primary" @click="submitAssign">确认分派</el-button></template></el-dialog>
<el-dialog v-model="completeVisible" title="填写维修结果" width="520px"><el-input v-model="result" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="说明处理方式、已恢复项目或遗留事项。" /><template #footer><el-button @click="completeVisible=false">取消</el-button><el-button type="success" @click="submitComplete">完成工单</el-button></template></el-dialog>
<el-dialog v-model="equipmentVisible" title="教室设备档案" width="620px"><el-select v-model="equipmentClassroomId" filterable placeholder="选择教室" style="width:100%" @change="loadEquipment"><el-option v-for="room in classrooms" :key="room.id" :label="`${room.buildingName} · ${room.name}`" :value="room.id" /></el-select><template v-if="equipmentClassroomId"><el-table :data="equipmentRows" size="small" class="equipment-table"><el-table-column prop="name" label="设备/设施"/><el-table-column label="可用状态" width="100"><template #default="{ row }"><el-tag :type="row.isAvailable ? 'success' : 'danger'">{{ row.isAvailable ? '可用' : '不可用' }}</el-tag></template></el-table-column><el-table-column prop="notes" label="备注"/></el-table><div class="equipment-add"><el-input v-model="equipmentForm.name" placeholder="新增:投影仪、幕布、电子白板、座位、水电暖、空调等"/><el-select v-model="equipmentForm.category"><el-option label="多媒体" :value="1"/><el-option label="课桌椅" :value="2"/><el-option label="水电暖" :value="3"/><el-option label="空调" :value="4"/><el-option label="其他" :value="5"/></el-select><el-button type="primary" @click="addEquipment">添加</el-button></div></template></el-dialog>
<el-dialog v-model="qrVisible" title="教室扫码报修二维码" width="380px" center><el-select v-if="!qrDataUrl" v-model="qrClassroomId" filterable placeholder="选择教室" style="width:100%"><el-option v-for="room in classrooms" :key="room.id" :label="`${room.buildingName} · ${room.name}`" :value="room.id" /></el-select><el-button v-if="!qrDataUrl" type="primary" class="qr-generate" @click="showClassroomQr">生成二维码</el-button><img v-if="qrDataUrl" :src="qrDataUrl" class="qr" alt="教室报修二维码"><p class="qr-hint">请将二维码张贴在教室内,任何人员扫码即可提交故障说明。</p></el-dialog>
</section>
</template>
<style scoped>
.page-shell { padding: 24px; }.page-heading { display:flex; justify-content:space-between; gap:20px; align-items:start; margin-bottom:20px; }.heading-actions { display:flex; gap:10px; }.page-heading h1 { margin:4px 0; }.page-heading p { margin:0; color:#738399; }.eyebrow { color:#2b72c8 !important; font-size:12px; font-weight:800; letter-spacing:.11em; }.toolbar { margin-bottom:16px; }.toolbar .el-select { width:150px; }.qr { display:block; width:280px; margin:18px auto 0; }.qr-generate { width:100%; margin-top:14px; }.qr-hint { color:#728197; line-height:1.6; text-align:center; }.equipment-table { margin:16px 0; }.equipment-add { display:grid; grid-template-columns:1fr 110px auto; gap:10px; } @media(max-width:600px){.page-shell{padding:16px}.page-heading{display:block}.heading-actions{margin-top:14px}.equipment-add{grid-template-columns:1fr}}
</style>
+23 -30
View File
@@ -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>