feat: M3-M7 全模块落地 + 集成接线

- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+138 -144
View File
@@ -1,69 +1,105 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted } from 'vue'
import type { Component } from 'vue'
import { mix, parseHexColor, readableForeground, toCss } from './color'
import type { DisplayPageKey } from './types'
import { useScoreboard } from './useScoreboard'
import { useStageScale } from './useStageScale'
import DetailOverlay from './components/DetailOverlay.vue'
import TickerBar from './components/TickerBar.vue'
import TopBar from './components/TopBar.vue'
import AnnouncementPage from './components/pages/AnnouncementPage.vue'
import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue'
import EventResultsPage from './components/pages/EventResultsPage.vue'
import StandingsPage from './components/pages/StandingsPage.vue'
import TeamMedalsPage from './components/pages/TeamMedalsPage.vue'
// 占位数据:M1/M3 接入 /api 后替换为实时数据
const teams = ref([
{ rank: 1, name: '第一分公司', points: 186 },
{ rank: 2, name: '机关本部', points: 172 },
{ rank: 3, name: '第二分公司', points: 158 },
{ rank: 4, name: '第三分公司', points: 141 },
{ rank: 5, name: '第四分公司', points: 127 },
{ rank: 6, name: '第五分公司', points: 103 },
{ rank: 7, name: '第六分公司', points: 95 },
])
const scale = ref(1)
const stage = ref<HTMLElement | null>(null)
function fit() {
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
scale.value = s
if (stage.value) {
stage.value.style.left = `${(window.innerWidth - 1920 * s) / 2}px`
stage.value.style.top = `${(window.innerHeight - 1080 * s) / 2}px`
}
const PAGES: Record<DisplayPageKey, Component> = {
standings: StandingsPage,
'team-medals': TeamMedalsPage,
'athlete-standings': AthleteStandingsPage,
'event-results': EventResultsPage,
announcement: AnnouncementPage,
}
const { bootstrap, dispose, page, settings } = useScoreboard()
const { scale, offsetX, offsetY } = useStageScale()
const currentPage = computed<Component>(() => PAGES[page.value])
const stageStyle = computed(() => ({
transform: `scale(${scale.value})`,
left: `${offsetX.value}px`,
top: `${offsetY.value}px`,
}))
const background = computed(() => {
const value = settings.value?.display.background?.trim()
return value !== undefined && value !== '' ? value : null
})
/** 主色由配置驱动,亮/暗变体与前景色在这里一次性算好挂成 CSS 变量。 */
const themeVars = computed<Record<string, string>>(() => {
const vars: Record<string, string> = {}
const primary = parseHexColor(settings.value?.display.primaryColor ?? null)
if (primary === null) return vars
const light = mix(primary, { r: 255, g: 255, b: 255 }, 0.45)
const dark = mix(primary, { r: 0, g: 0, b: 0 }, 0.45)
vars['--primary'] = toCss(primary)
vars['--primary-light'] = toCss(light)
vars['--primary-dark'] = toCss(dark)
vars['--primary-fg'] = readableForeground(primary)
vars['--primary-text'] = toCss(light)
vars['--primary-line'] = `rgba(${primary.r}, ${primary.g}, ${primary.b}, 0.38)`
vars['--primary-flash'] = `rgba(${primary.r}, ${primary.g}, ${primary.b}, 0.14)`
return vars
})
// 无人值守兜底:每 24h 刷新一次,清掉长跑累积的内存与渲染状态(06 第 8 节)。
const RELOAD_MS = 24 * 60 * 60 * 1000
let reloadTimer = 0
onMounted(() => {
fit()
window.addEventListener('resize', fit)
void bootstrap()
reloadTimer = window.setTimeout(() => window.location.reload(), RELOAD_MS)
})
onUnmounted(() => {
window.clearTimeout(reloadTimer)
dispose()
})
onUnmounted(() => window.removeEventListener('resize', fit))
</script>
<template>
<div ref="stage" class="stage" :style="{ transform: `scale(${scale})` }">
<header class="head">
<div class="logo">会标</div>
<h1>赛事积分榜</h1>
<div class="spacer" />
</header>
<div class="screen">
<div class="stage" :style="[stageStyle, themeVars]">
<img v-if="background" class="bg" :src="background" alt="" />
<div class="bg-fallback" />
<section class="board">
<h2>总积分榜</h2>
<table>
<thead>
<tr>
<th class="rk">名次</th>
<th>单位</th>
<th class="pt">总分</th>
</tr>
</thead>
<tbody>
<tr v-for="t in teams" :key="t.name">
<td class="rk">{{ t.rank }}</td>
<td class="nm">{{ t.name }}</td>
<td class="pt">{{ t.points }}</td>
</tr>
</tbody>
</table>
</section>
<div class="content">
<TopBar />
<main class="main">
<Transition name="page" mode="out-in">
<component :is="currentPage" :key="page" />
</Transition>
</main>
<TickerBar />
</div>
<footer class="ticker">底栏信息条(轮播占位)</footer>
<DetailOverlay />
</div>
</div>
</template>
<style scoped>
.screen {
position: fixed;
inset: 0;
overflow: hidden;
background: #000;
cursor: none;
}
.stage {
position: absolute;
top: 0;
@@ -72,109 +108,67 @@ onUnmounted(() => window.removeEventListener('resize', fit))
height: 1080px;
transform-origin: top left;
padding: 40px 48px;
overflow: hidden;
background: var(--bg);
--primary: #4fd8ff;
--primary-light: #8fe6ff;
--primary-dark: #1b7fa8;
--primary-fg: #04121c;
--primary-text: #8fe6ff;
--primary-line: rgba(79, 216, 255, 0.38);
--primary-flash: rgba(79, 216, 255, 0.14);
}
.bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.55;
}
.bg-fallback {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 18% 0%, rgba(79, 216, 255, 0.16), transparent 55%),
radial-gradient(ellipse at 88% 100%, rgba(255, 215, 94, 0.1), transparent 50%),
linear-gradient(160deg, #0b1e3a 0%, #06152b 100%);
}
.content {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
gap: 24px;
background:
radial-gradient(ellipse at 20% 0%, rgba(79, 216, 255, 0.12), transparent 55%),
radial-gradient(ellipse at 85% 100%, rgba(255, 215, 94, 0.08), transparent 50%), var(--bg);
cursor: none;
gap: 20px;
}
.head {
display: flex;
align-items: center;
gap: 28px;
}
.logo {
width: 96px;
height: 96px;
border-radius: 50%;
border: 3px solid var(--gold);
display: grid;
place-items: center;
font-size: 22px;
color: var(--text-dim);
}
h1 {
font-size: 64px;
letter-spacing: 8px;
background: linear-gradient(180deg, #fff 30%, var(--gold) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.spacer {
flex: 1;
}
.board {
.main {
flex: 1;
min-height: 0;
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 16px;
padding: 20px 28px 24px;
display: flex;
flex-direction: column;
}
h2 {
font-size: 34px;
letter-spacing: 4px;
color: var(--cyan);
padding-bottom: 10px;
border-bottom: 2px solid rgba(79, 216, 255, 0.35);
margin-bottom: 8px;
.main > * {
flex: 1;
min-width: 0;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
.page-enter-active,
.page-leave-active {
transition:
opacity 0.35s ease,
transform 0.35s ease;
}
th,
td {
font-size: 30px;
padding: 12px 8px;
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
.page-enter-from {
opacity: 0;
transform: translateY(24px);
}
th {
font-size: 20px;
color: var(--text-dim);
font-weight: 400;
letter-spacing: 2px;
.page-leave-to {
opacity: 0;
transform: translateY(-24px);
}
td.rk {
width: 140px;
font-weight: 800;
font-size: 34px;
}
tbody tr:nth-child(1) td.rk {
color: var(--gold);
}
tbody tr:nth-child(2) td.rk {
color: #d8e2ee;
}
tbody tr:nth-child(3) td.rk {
color: #e0995e;
}
td.nm {
text-align: left;
padding-left: 32px;
}
td.pt {
width: 220px;
color: var(--gold);
font-weight: 800;
font-size: 36px;
}
.ticker {
height: 88px;
border-radius: 16px;
background: var(--panel);
border: 2px solid var(--panel-border);
display: grid;
place-items: center;
font-size: 26px;
color: var(--text-dim);
letter-spacing: 2px;
:global(body) {
cursor: none;
overflow: hidden;
}
</style>
+77
View File
@@ -0,0 +1,77 @@
/**
* 主色派生(06 第 2 节):亮色变体做标题渐变、暗色变体做背景光晕,
* 前景色按对比度自动选黑/白,保证主色换成任何颜色都仍然可读。
*/
export interface Rgb {
r: number
g: number
b: number
}
const HEX_SHORT = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i
const HEX_LONG = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i
export function parseHexColor(value: string | null | undefined): Rgb | null {
if (value === null || value === undefined) return null
const text = value.trim()
const short = HEX_SHORT.exec(text)
if (short !== null) {
return {
r: Number.parseInt(`${short[1]}${short[1]}`, 16),
g: Number.parseInt(`${short[2]}${short[2]}`, 16),
b: Number.parseInt(`${short[3]}${short[3]}`, 16),
}
}
const long = HEX_LONG.exec(text)
if (long !== null) {
return {
r: Number.parseInt(long[1] ?? '0', 16),
g: Number.parseInt(long[2] ?? '0', 16),
b: Number.parseInt(long[3] ?? '0', 16),
}
}
return null
}
export function mix(a: Rgb, b: Rgb, ratio: number): Rgb {
const t = Math.min(1, Math.max(0, ratio))
return {
r: Math.round(a.r + (b.r - a.r) * t),
g: Math.round(a.g + (b.g - a.g) * t),
b: Math.round(a.b + (b.b - a.b) * t),
}
}
export function toCss(color: Rgb): string {
return `rgb(${color.r} ${color.g} ${color.b})`
}
function channelLuminance(channel: number): number {
const c = channel / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
export function relativeLuminance(color: Rgb): number {
return (
0.2126 * channelLuminance(color.r) +
0.7152 * channelLuminance(color.g) +
0.0722 * channelLuminance(color.b)
)
}
function contrastRatio(a: Rgb, b: Rgb): number {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
const lighter = Math.max(la, lb)
const darker = Math.min(la, lb)
return (lighter + 0.05) / (darker + 0.05)
}
const WHITE: Rgb = { r: 255, g: 255, b: 255 }
const INK: Rgb = { r: 11, g: 18, b: 32 }
/** 主色上放文字:对比度更高的那个(目标 ≥ 4.5:1)。 */
export function readableForeground(color: Rgb): string {
return contrastRatio(color, WHITE) >= contrastRatio(color, INK) ? '#ffffff' : '#0b1220'
}
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { formatPoints } from '@/shared'
import { prefersReducedMotion } from '../useReducedMotion'
const props = withDefaults(defineProps<{ value: number; duration?: number }>(), { duration: 900 })
const reduced = prefersReducedMotion()
const shown = ref(props.value)
let frame = 0
// 整数分数滚动时不要出现小数抖动;权重分(非整数)才保留两位。
const text = computed(() =>
formatPoints(Number.isInteger(props.value) ? Math.round(shown.value) : shown.value),
)
function stop(): void {
if (frame !== 0) {
cancelAnimationFrame(frame)
frame = 0
}
}
function run(target: number): void {
stop()
const from = shown.value
if (reduced.value || props.duration <= 0 || from === target) {
shown.value = target
return
}
const started = performance.now()
const step = (now: number): void => {
const progress = Math.min(1, (now - started) / props.duration)
const eased = 1 - (1 - progress) ** 3
shown.value = from + (target - from) * eased
if (progress < 1) {
frame = requestAnimationFrame(step)
} else {
shown.value = target
frame = 0
}
}
frame = requestAnimationFrame(step)
}
watch(() => props.value, run)
onUnmounted(stop)
</script>
<template>
<span class="animated-number">{{ text }}</span>
</template>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useScoreboard } from '../useScoreboard'
import AnimatedNumber from './AnimatedNumber.vue'
const { detail } = useScoreboard()
const remaining = ref(0)
let timer = 0
// 倒计时按 openedAt 计算而不是每 1s 减一:页面被系统休眠后回来仍显示真实剩余时间。
watch(
detail,
(state) => {
window.clearInterval(timer)
timer = 0
if (state === null) {
remaining.value = 0
return
}
const tick = (): void => {
const elapsed = (Date.now() - state.openedAt) / 1000
remaining.value = Math.max(0, Math.ceil(state.autoCloseSeconds - elapsed))
}
tick()
timer = window.setInterval(tick, 1000)
},
{ immediate: true },
)
onUnmounted(() => window.clearInterval(timer))
const items = computed(() => detail.value?.detail?.items ?? [])
const totalPoints = computed(() => detail.value?.detail?.totalPoints ?? 0)
const autoClose = computed(() => (detail.value?.autoCloseSeconds ?? 0) > 0)
</script>
<template>
<Transition name="overlay">
<div v-if="detail" class="overlay">
<div class="scrim" />
<section class="panel">
<header class="head">
<div>
<div class="kicker">积分明细</div>
<h2>{{ detail.title }}</h2>
</div>
<div class="total">
<AnimatedNumber :value="totalPoints" />
<span class="unit">分</span>
</div>
</header>
<div v-if="detail.loading" class="empty">正在获取明细…</div>
<div v-else-if="items.length === 0" class="empty">暂无明细记录</div>
<div v-else class="table">
<div class="row head-row">
<span class="col-name">项目 / 组别</span>
<span class="col-rank">名次</span>
<span class="col-result">成绩</span>
<span class="col-points">积分</span>
</div>
<div v-for="item in items" :key="item.ledgerId" class="row">
<span class="col-name">
{{ item.eventName ?? '人工调整' }}
<em v-if="item.categoryName">{{ item.categoryName }}</em>
</span>
<span class="col-rank">{{ item.rank ?? '—' }}</span>
<span class="col-result">{{ item.resultText ?? '—' }}</span>
<span class="col-points">{{ item.points }}</span>
</div>
</div>
<footer class="foot">
<span v-if="autoClose">将在 {{ remaining }}s 后自动关闭</span>
<span v-else>等待管理端关闭</span>
</footer>
</section>
</div>
</Transition>
</template>
<style scoped>
.overlay {
position: absolute;
inset: 0;
z-index: 40;
}
.scrim {
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(4, 12, 26, 0.25) 0%, rgba(4, 12, 26, 0.85) 46%);
}
.panel {
position: absolute;
top: 0;
right: 0;
width: 50%;
height: 100%;
padding: 40px 44px 32px;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(9, 24, 46, 0.98), rgba(7, 18, 36, 0.98));
border-left: 3px solid var(--primary);
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
padding-bottom: 18px;
border-bottom: 2px solid var(--primary-line);
}
.kicker {
font-size: 20px;
letter-spacing: 6px;
color: var(--primary-text);
}
h2 {
margin-top: 8px;
font-size: 46px;
letter-spacing: 4px;
color: var(--text);
}
.total {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 60px;
font-weight: 800;
color: var(--gold);
font-variant-numeric: tabular-nums;
}
.total .unit {
font-size: 26px;
color: var(--text-dim);
}
.table {
flex: 1;
min-height: 0;
margin-top: 16px;
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 1fr 110px 160px 130px;
align-items: center;
gap: 12px;
padding: 14px 8px;
font-size: 27px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.head-row {
font-size: 20px;
letter-spacing: 2px;
color: var(--text-dim);
}
.col-rank,
.col-result,
.col-points {
text-align: right;
font-variant-numeric: tabular-nums;
}
.col-points {
color: var(--gold);
font-weight: 700;
}
.col-name em {
margin-left: 10px;
font-style: normal;
font-size: 20px;
color: var(--text-dim);
}
.empty {
flex: 1;
display: grid;
place-items: center;
font-size: 30px;
color: var(--text-dim);
}
.foot {
flex: none;
padding-top: 16px;
text-align: right;
font-size: 22px;
letter-spacing: 2px;
color: var(--text-dim);
}
.overlay-enter-active,
.overlay-leave-active {
transition: opacity 0.35s ease;
}
.overlay-enter-from,
.overlay-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useScoreboard } from '../useScoreboard'
const { booted, status, source, version } = useScoreboard()
const offline = computed(() => booted.value && status.value !== 'online')
const mock = computed(() => source.value === 'mock')
</script>
<template>
<div class="badges">
<span v-if="mock" class="badge mock">示例数据</span>
<span v-if="offline" class="badge offline">⚠ 连接中断 · 数据可能不是最新</span>
<span v-if="version !== null" class="badge version">v{{ version }}</span>
</div>
</template>
<style scoped>
.badges {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.badge {
padding: 5px 14px;
border-radius: 999px;
font-size: 18px;
letter-spacing: 1px;
border: 1px solid transparent;
white-space: nowrap;
}
.offline {
background: rgba(255, 96, 96, 0.16);
border-color: rgba(255, 96, 96, 0.55);
color: #ffb4b4;
}
.mock {
background: rgba(255, 215, 94, 0.16);
border-color: rgba(255, 215, 94, 0.5);
color: var(--gold);
}
.version {
background: rgba(255, 255, 255, 0.06);
color: var(--text-dim);
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
defineProps<{ title: string; hint?: string }>()
</script>
<template>
<section class="page">
<header class="page-head">
<h2>{{ title }}</h2>
<span v-if="hint" class="hint">{{ hint }}</span>
</header>
<div class="page-body">
<slot />
</div>
</section>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 18px;
padding: 18px 28px 22px;
}
.page-head {
display: flex;
align-items: baseline;
gap: 18px;
padding-bottom: 12px;
border-bottom: 2px solid var(--primary-line);
}
h2 {
font-size: 34px;
font-weight: 700;
letter-spacing: 4px;
color: var(--primary-text);
}
.hint {
font-size: 20px;
letter-spacing: 2px;
color: var(--text-dim);
}
.page-body {
flex: 1;
min-height: 0;
padding-top: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useScoreboard } from '../useScoreboard'
type TickerKind = 'medals' | 'results' | 'announcement'
interface TickerItem {
key: TickerKind
label: string
}
const { standings, latestResults, announcement, detail, settings } = useScoreboard()
const topMedals = computed(() =>
[...standings.value]
.sort((a, b) => b.gold - a.gold || b.silver - a.silver || b.bronze - a.bronze)
.slice(0, 3),
)
const latestGroup = computed(() => latestResults.value[0] ?? null)
const items = computed<TickerItem[]>(() => {
const result: TickerItem[] = []
if (topMedals.value.length > 0) result.push({ key: 'medals', label: '奖牌榜 Top3' })
if (latestGroup.value !== null) result.push({ key: 'results', label: '最新完成项目' })
// 公告是固定槽位:没有公告时显示占位,避免底栏只剩两屏来回跳。
result.push({ key: 'announcement', label: '公告' })
return result
})
const index = ref(0)
const current = computed<TickerItem | null>(() => items.value[index.value] ?? items.value[0] ?? null)
const paused = computed(() => detail.value !== null)
const rotateMs = computed(() => Math.max(2, settings.value?.display.rotateSeconds ?? 8) * 1000)
let timer = 0
function restart(): void {
window.clearInterval(timer)
timer = 0
if (paused.value) return
timer = window.setInterval(() => {
index.value = (index.value + 1) % Math.max(1, items.value.length)
}, rotateMs.value)
}
watch([rotateMs, paused], restart, { immediate: true })
watch(
() => items.value.length,
(total) => {
if (index.value >= total) index.value = 0
},
)
onUnmounted(() => window.clearInterval(timer))
</script>
<template>
<footer class="ticker">
<div v-if="current" class="label">{{ current.label }}</div>
<div class="viewport">
<div v-if="current" :key="current.key" class="item">
<template v-if="current.key === 'medals'">
<div v-for="(row, i) in topMedals" :key="row.teamId" class="chip">
<span class="chip-rank">{{ i + 1 }}</span>
<span class="chip-name">{{ row.shortName ?? row.teamName }}</span>
<span class="chip-stat gold">金 {{ row.gold }}</span>
<span class="chip-stat silver">银 {{ row.silver }}</span>
<span class="chip-stat bronze">铜 {{ row.bronze }}</span>
</div>
</template>
<template v-else-if="current.key === 'results' && latestGroup">
<div class="event-name">
{{ latestGroup.eventName }}
<span class="event-cat">{{ latestGroup.categoryName }}</span>
</div>
<div v-for="row in latestGroup.rows.slice(0, 3)" :key="row.entryId" class="chip">
<span class="chip-rank">{{ row.rank ?? '—' }}</span>
<span class="chip-name">{{ row.athleteName ?? row.teamName }}</span>
<span class="chip-stat">{{ row.resultText }}</span>
</div>
</template>
<template v-else>
<div class="announce">{{ announcement?.text ?? '暂无公告' }}</div>
</template>
</div>
</div>
</footer>
</template>
<style scoped>
.ticker {
display: flex;
align-items: center;
gap: 24px;
height: 112px;
flex: none;
padding: 0 28px;
border-radius: 18px;
background: var(--panel);
border: 2px solid var(--panel-border);
}
.label {
flex: none;
padding: 10px 20px;
border-radius: 10px;
font-size: 22px;
letter-spacing: 3px;
color: var(--primary-fg);
background: var(--primary);
}
.viewport {
position: relative;
flex: 1;
min-width: 0;
height: 100%;
overflow: hidden;
}
.item {
position: absolute;
left: 0;
top: 0;
height: 100%;
display: flex;
align-items: center;
gap: 34px;
white-space: nowrap;
/* 只做入场淡入:切换时旧内容立即移除,不会因为离场过渡卡住而出现空档。 */
animation: ticker-in 0.4s ease-out both;
}
@keyframes ticker-in {
from {
opacity: 0;
transform: translateX(36px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.chip {
display: flex;
align-items: center;
gap: 12px;
font-size: 26px;
}
.chip-rank {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: var(--text-dim);
font-size: 22px;
}
.chip-name {
color: var(--text);
}
.chip-stat {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.gold {
color: var(--gold);
}
.silver {
color: #d8e2ee;
}
.bronze {
color: #e0995e;
}
.event-name {
font-size: 28px;
color: var(--primary-light);
letter-spacing: 2px;
}
.event-cat {
margin-left: 12px;
font-size: 22px;
color: var(--text-dim);
}
.announce {
font-size: 28px;
letter-spacing: 3px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { formatClock } from '@/shared'
import { useScoreboard } from '../useScoreboard'
import OfflineBadge from './OfflineBadge.vue'
const { settings, options } = useScoreboard()
const now = ref(new Date().toISOString())
const timer = window.setInterval(() => {
now.value = new Date().toISOString()
}, 15_000)
onUnmounted(() => window.clearInterval(timer))
const display = computed(() => settings.value?.display ?? null)
const title = computed(() => display.value?.title.trim() || '赛事标题待配置')
const subtitle = computed(() => display.value?.subtitle?.trim() ?? '')
const host = computed(() => settings.value?.event.host?.trim() || '主办方待配置')
const logo = computed(() => display.value?.logo?.trim() || '')
const clock = computed(() => formatClock(now.value))
const identity = computed(() => {
const parts: string[] = []
if (options.value.instance !== null) parts.push(`实例 ${options.value.instance}`)
if (options.value.screen !== null) parts.push(`屏 ${options.value.screen}`)
return parts.join(' · ')
})
</script>
<template>
<header class="top">
<div class="logo">
<img v-if="logo" :src="logo" alt="" />
<span v-else>会标</span>
</div>
<div class="titles">
<h1>{{ title }}</h1>
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
</div>
<div class="right">
<div class="clock">{{ clock }}</div>
<div class="host">{{ host }}</div>
<div v-if="identity" class="identity">{{ identity }}</div>
<OfflineBadge />
</div>
</header>
</template>
<style scoped>
.top {
display: flex;
align-items: center;
gap: 28px;
min-height: 132px;
flex: none;
}
.logo {
width: 108px;
height: 108px;
flex: none;
border-radius: 50%;
border: 3px solid var(--primary);
display: grid;
place-items: center;
font-size: 22px;
letter-spacing: 2px;
color: var(--text-dim);
overflow: hidden;
}
.logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.titles {
min-width: 0;
}
h1 {
font-size: 62px;
line-height: 1.1;
letter-spacing: 8px;
white-space: nowrap;
background: linear-gradient(180deg, #ffffff 25%, var(--primary-light) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle {
margin-top: 8px;
font-size: 26px;
letter-spacing: 6px;
color: var(--text-dim);
}
.right {
margin-left: auto;
text-align: right;
flex: none;
}
.clock {
font-size: 44px;
font-weight: 700;
letter-spacing: 3px;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.host {
margin-top: 6px;
font-size: 24px;
letter-spacing: 3px;
color: var(--text-dim);
}
.identity {
margin-top: 4px;
font-size: 18px;
letter-spacing: 1px;
color: rgba(157, 180, 212, 0.6);
}
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
const { announcement } = useScoreboard()
const text = computed(() => announcement.value?.text ?? '暂无公告')
// 重复三遍 + padding-left 100%,滚动到末尾时下一条刚好接上,不出现空白。
const marquee = computed(() => `${text.value}  ·  ${text.value}  ·  ${text.value}`)
</script>
<template>
<PageFrame title="公告">
<div class="body">
<p class="text">{{ text }}</p>
</div>
<div class="marquee">
<div class="marquee-inner">{{ marquee }}</div>
</div>
</PageFrame>
</template>
<style scoped>
.body {
flex: 1;
min-height: 0;
display: grid;
place-items: center;
padding: 0 40px;
}
.text {
font-size: 56px;
line-height: 1.5;
letter-spacing: 6px;
text-align: center;
color: var(--text);
}
.marquee {
flex: none;
overflow: hidden;
white-space: nowrap;
padding: 16px 0 4px;
border-top: 2px solid var(--primary-line);
}
.marquee-inner {
display: inline-block;
padding-left: 100%;
font-size: 28px;
letter-spacing: 4px;
color: var(--text-dim);
animation: marquee 26s linear infinite;
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
</style>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed } from 'vue'
import { fitRowFont } from '../../fitFont'
import { useScoreboard } from '../../useScoreboard'
import AnimatedNumber from '../AnimatedNumber.vue'
import PageFrame from '../PageFrame.vue'
const { athletes, settings } = useScoreboard()
const rows = computed(() => athletes.value)
const showMedals = computed(() => settings.value?.standings.showIndividualBoard ?? true)
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
</script>
<template>
<PageFrame title="个人积分奖牌榜">
<div class="board" :class="{ 'no-medals': !showMedals }" :style="{ '--row-font': rowFont }">
<div class="row head">
<span>名次</span>
<span class="name">姓名</span>
<span class="team">单位</span>
<span class="points">总分</span>
<template v-if="showMedals">
<span class="medal gold">金</span>
<span class="medal silver">银</span>
<span class="medal bronze">铜</span>
</template>
</div>
<div v-for="row in rows" :key="row.athleteId" class="row">
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
<span class="name">{{ row.athleteName }}</span>
<span class="team">{{ row.shortName ?? row.teamName }}</span>
<span class="points"><AnimatedNumber :value="row.totalPoints" /></span>
<template v-if="showMedals">
<span class="medal gold">{{ row.gold }}</span>
<span class="medal silver">{{ row.silver }}</span>
<span class="medal bronze">{{ row.bronze }}</span>
</template>
</div>
<div v-if="rows.length === 0" class="empty">暂无个人积分数据</div>
</div>
</PageFrame>
</template>
<style scoped>
.board {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 130px 1fr 1fr 200px 110px 110px 110px;
align-items: center;
gap: 8px;
flex: 1 1 0;
min-height: 0;
padding: 0 18px;
font-size: var(--row-font, 42px);
line-height: 1.1;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.row:last-child {
border-bottom: none;
}
.no-medals .row {
grid-template-columns: 130px 1fr 1fr 220px;
}
.head {
flex: none;
font-size: 22px;
letter-spacing: 4px;
color: var(--text-dim);
border-bottom: 2px solid var(--primary-line);
}
.rank,
.medal,
.points {
font-variant-numeric: tabular-nums;
}
.rank {
font-weight: 800;
}
.top-1 {
color: var(--gold);
}
.top-2 {
color: #d8e2ee;
}
.top-3 {
color: #e0995e;
}
.name,
.team {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team {
color: var(--text-dim);
font-size: 0.82em;
}
.points {
text-align: right;
font-weight: 800;
color: var(--gold);
}
.medal {
text-align: center;
}
.gold {
color: var(--gold);
}
.silver {
color: #d8e2ee;
}
.bronze {
color: #e0995e;
}
.empty {
flex: 1;
display: grid;
place-items: center;
font-size: 34px;
color: var(--text-dim);
}
</style>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatResultText } from '@/shared'
import type { EventResultRow, ResultStatus } from '@/shared'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
const { latestResults, options } = useScoreboard()
// 数据口径:state='published' 且 status='finished',按发布时间倒序取前 N 个(06 第 5 节)。
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount))
const STATUS_LABELS: Record<ResultStatus, string> = {
ok: '',
dq: 'DQ',
dns: 'DNS',
dnf: 'DNF',
withdrawn: '退赛',
}
function displayResult(row: EventResultRow): string {
if (row.status !== 'ok') return STATUS_LABELS[row.status]
if (row.resultValue === null) return row.resultText
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
}
function displayName(row: EventResultRow): string {
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
if (row.memberNames !== null) return row.memberNames.join(' / ')
return row.shortName ?? row.teamName
}
</script>
<template>
<PageFrame title="分项成绩" :hint="`最新完成 ${groups.length} 项`">
<div v-if="groups.length > 0" class="groups">
<article v-for="group in groups" :key="`${group.eventId}-${group.categoryId}`" class="group">
<header class="group-head">
<h3>{{ group.eventName }}</h3>
<span class="cat">{{ group.categoryName }}</span>
</header>
<div class="lines">
<div
v-for="row in group.rows.slice(0, 6)"
:key="row.entryId"
class="line"
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span>
<span class="team">{{ row.shortName ?? row.teamName }}</span>
<span class="rs">{{ displayResult(row) }}</span>
</div>
</div>
</article>
</div>
<div v-else class="empty">暂无已发布项目</div>
</PageFrame>
</template>
<style scoped>
.groups {
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
gap: 26px;
}
.group {
flex: 1 1 0;
max-width: 620px;
min-width: 0;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.035);
border: 1px solid var(--primary-line);
border-radius: 14px;
padding: 18px 20px;
overflow: hidden;
}
.group-head {
display: flex;
align-items: baseline;
gap: 12px;
padding-bottom: 12px;
border-bottom: 2px solid var(--primary-line);
}
h3 {
font-size: 34px;
letter-spacing: 3px;
color: var(--primary-light);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cat {
flex: none;
font-size: 22px;
color: var(--text-dim);
}
.lines {
flex: 1;
min-height: 0;
margin-top: 8px;
}
.line {
display: grid;
grid-template-columns: 60px 1fr 150px 170px;
align-items: center;
gap: 10px;
padding: 11px 6px;
font-size: 28px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
animation: row-in 0.45s ease-out both;
}
.line:last-child {
border-bottom: none;
}
.rk {
font-weight: 800;
text-align: center;
font-variant-numeric: tabular-nums;
}
.nm {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team {
font-size: 22px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rs {
text-align: right;
color: var(--gold);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.top-1 .rk {
color: var(--gold);
}
.top-2 .rk {
color: #d8e2ee;
}
.top-3 .rk {
color: #e0995e;
}
.empty {
flex: 1;
display: grid;
place-items: center;
font-size: 34px;
color: var(--text-dim);
}
@keyframes row-in {
from {
opacity: 0;
transform: translateX(-40px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatRankChange } from '@/shared'
import { fitRowFont } from '../../fitFont'
import { useScoreboard } from '../../useScoreboard'
import AnimatedNumber from '../AnimatedNumber.vue'
import PageFrame from '../PageFrame.vue'
const { standings, rankDeltaFor } = useScoreboard()
const rows = computed(() => standings.value)
// 行高由 flex 均分,字号按行数算,单位再多也不会顶到轮播条下面。
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
</script>
<template>
<PageFrame title="总积分榜">
<div class="board" :style="{ '--row-font': rowFont }">
<div class="row head">
<span class="rank">名次</span>
<span class="name">单位</span>
<span class="delta" />
<span class="points">总分</span>
</div>
<div
v-for="row in rows"
:key="row.teamId"
class="row"
:class="{ flash: rankDeltaFor(row.teamId) !== null }"
>
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
<span class="name">{{ row.teamName }}</span>
<span class="delta">
<span v-if="rankDeltaFor(row.teamId) !== null" :class="rankDeltaFor(row.teamId)! > 0 ? 'up' : 'down'">
{{ formatRankChange(rankDeltaFor(row.teamId)) }}
</span>
</span>
<span class="points"><AnimatedNumber :value="row.totalPoints" /></span>
</div>
<div v-if="rows.length === 0" class="empty">暂无积分数据</div>
</div>
</PageFrame>
</template>
<style scoped>
.board {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 150px 1fr 110px 260px;
align-items: center;
gap: 12px;
flex: 1 1 0;
min-height: 0;
padding: 0 18px;
font-size: var(--row-font, 48px);
line-height: 1.1;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
transition:
background-color 0.4s ease,
box-shadow 0.4s ease;
}
.row:last-child {
border-bottom: none;
}
.head {
flex: none;
font-size: 22px;
letter-spacing: 4px;
color: var(--text-dim);
border-bottom: 2px solid var(--primary-line);
}
.rank {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.top-1 {
color: var(--gold);
}
.top-2 {
color: #d8e2ee;
}
.top-3 {
color: #e0995e;
}
.name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.points {
text-align: right;
font-weight: 800;
color: var(--gold);
font-variant-numeric: tabular-nums;
}
.delta {
text-align: right;
font-size: 0.7em;
font-weight: 700;
}
.up {
color: #5cf08a;
}
.down {
color: #ff8a8a;
}
.flash {
background: var(--primary-flash);
box-shadow: inset 4px 0 0 var(--primary);
}
.empty {
flex: 1;
display: grid;
place-items: center;
font-size: 34px;
color: var(--text-dim);
}
</style>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed } from 'vue'
import { fitRowFont } from '../../fitFont'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
const { standings } = useScoreboard()
const rows = computed(() =>
[...standings.value].sort(
(a, b) =>
b.gold - a.gold || b.silver - a.silver || b.bronze - a.bronze || b.totalPoints - a.totalPoints,
),
)
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
</script>
<template>
<PageFrame title="团体奖牌榜">
<div class="board" :style="{ '--row-font': rowFont }">
<div class="row head">
<span>名次</span>
<span class="name">单位</span>
<span class="medal gold">金</span>
<span class="medal silver">银</span>
<span class="medal bronze">铜</span>
<span class="total">合计</span>
</div>
<div v-for="(row, index) in rows" :key="row.teamId" class="row">
<span class="rank" :class="`top-${Math.min(index + 1, 4)}`">{{ index + 1 }}</span>
<span class="name">{{ row.teamName }}</span>
<span class="medal gold">{{ row.gold }}</span>
<span class="medal silver">{{ row.silver }}</span>
<span class="medal bronze">{{ row.bronze }}</span>
<span class="total">{{ row.medalTotal }}</span>
</div>
<div v-if="rows.length === 0" class="empty">暂无奖牌数据</div>
</div>
</PageFrame>
</template>
<style scoped>
.board {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 150px 1fr 140px 140px 140px 180px;
align-items: center;
gap: 8px;
flex: 1 1 0;
min-height: 0;
padding: 0 18px;
font-size: var(--row-font, 44px);
line-height: 1.1;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.row:last-child {
border-bottom: none;
}
.head {
flex: none;
font-size: 22px;
letter-spacing: 4px;
color: var(--text-dim);
border-bottom: 2px solid var(--primary-line);
}
.rank,
.medal,
.total {
text-align: center;
font-variant-numeric: tabular-nums;
}
.rank {
font-weight: 800;
}
.top-1 {
color: var(--gold);
}
.top-2 {
color: #d8e2ee;
}
.top-3 {
color: #e0995e;
}
.name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gold {
color: var(--gold);
}
.silver {
color: #d8e2ee;
}
.bronze {
color: #e0995e;
}
.total {
font-weight: 800;
color: var(--primary-light);
}
.empty {
flex: 1;
display: grid;
place-items: center;
font-size: 34px;
color: var(--text-dim);
}
</style>
+12
View File
@@ -0,0 +1,12 @@
/**
* 行数不可控,字号必须跟着算,否则单位一多就会把最后一行顶到轮播条下面。
*
* 行高由 flex 均分,这里按「可用高度 ÷ 行数 × 比例」给出字号,
* 比例留出 padding 与行距,保证文字始终装得下。
*/
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
if (count <= 0) return '48px'
const perRow = availablePx / count
const size = Math.min(56, Math.max(16, perRow * ratio))
return `${Math.floor(size)}px`
}
+313
View File
@@ -0,0 +1,313 @@
import type {
AthleteStanding,
BreakdownItem,
CompareMode,
Event,
EventResultGroup,
EventResultRow,
EventSettings,
EventStatus,
EventType,
Gender,
LedgerSourceType,
RecordEntry,
RecordLevel,
RecordSource,
ResultMode,
ResultStatus,
ResultType,
SnapshotMessage,
StandingRow,
Team,
TeamBreakdown,
} from '@/shared'
/**
* WebSocket 与 localStorage 里的东西一律先当 unknown 再收窄。
*
* 大屏是无人值守的:一条脏数据不能把整页渲染炸掉,所以这里逐字段兜底,
* 而不是直接断言成后端类型。
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function list(value: unknown): unknown[] {
return Array.isArray(value) ? value : []
}
export function str(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
export function strOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
export function num(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
export function numOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
export function bool(value: unknown, fallback = false): boolean {
return typeof value === 'boolean' ? value : fallback
}
export function strings(value: unknown): string[] | null {
const items = list(value).filter((item): item is string => typeof item === 'string')
return items.length === 0 ? null : items
}
export function oneOf<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
? (value as T)
: fallback
}
const EVENT_TYPES = ['individual', 'team', 'match'] as const satisfies readonly EventType[]
const RESULT_MODES = ['auto', 'manual'] as const satisfies readonly ResultMode[]
const RESULT_TYPES = ['time', 'distance', 'count', 'score', 'none'] as const satisfies readonly ResultType[]
const COMPARE_MODES = ['asc', 'desc', 'none'] as const satisfies readonly CompareMode[]
const EVENT_STATUSES = ['pending', 'ongoing', 'finished', 'published'] as const satisfies readonly EventStatus[]
const RESULT_STATUSES = ['ok', 'dq', 'dns', 'dnf', 'withdrawn'] as const satisfies readonly ResultStatus[]
const GENDERS = ['男', '女'] as const satisfies readonly Gender[]
const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[]
const RECORD_SOURCES = ['history', 'current'] as const satisfies readonly RecordSource[]
const LEDGER_SOURCES = ['result', 'record_bonus', 'adjustment'] as const satisfies readonly LedgerSourceType[]
function record(source: unknown): Record<string, unknown> {
return isRecord(source) ? source : {}
}
interface TeamRefShape {
teamId: number
teamCode: string
teamName: string
shortName: string | null
color: string | null
logoPath: string | null
}
function parseTeamRef(source: unknown): TeamRefShape {
const rec = record(source)
return {
teamId: num(rec.teamId),
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
color: strOrNull(rec.color),
logoPath: strOrNull(rec.logoPath),
}
}
export function parseTeam(source: unknown): Team {
const rec = record(source)
return {
id: num(rec.id),
code: str(rec.code),
name: str(rec.name),
shortName: strOrNull(rec.shortName),
logoPath: strOrNull(rec.logoPath),
color: strOrNull(rec.color),
sortOrder: num(rec.sortOrder),
active: bool(rec.active, true),
}
}
export function parseEvent(source: unknown): Event {
const rec = record(source)
return {
id: num(rec.id),
sportId: num(rec.sportId),
code: str(rec.code),
name: str(rec.name),
eventType: oneOf(rec.eventType, EVENT_TYPES, 'individual'),
resultMode: oneOf(rec.resultMode, RESULT_MODES, 'auto'),
resultType: oneOf(rec.resultType, RESULT_TYPES, 'none'),
compare: oneOf(rec.compare, COMPARE_MODES, 'none'),
recordEligible: bool(rec.recordEligible),
scored: bool(rec.scored, true),
multiplier: num(rec.multiplier, 1),
status: oneOf(rec.status, EVENT_STATUSES, 'pending'),
scheduledAt: strOrNull(rec.scheduledAt),
venue: strOrNull(rec.venue),
sortOrder: num(rec.sortOrder),
remark: strOrNull(rec.remark),
}
}
export function parseStandingRow(source: unknown): StandingRow {
const rec = record(source)
const gold = num(rec.gold)
const silver = num(rec.silver)
const bronze = num(rec.bronze)
return {
...parseTeamRef(rec),
rank: num(rec.rank),
gold,
silver,
bronze,
medalTotal: num(rec.medalTotal, gold + silver + bronze),
totalPoints: num(rec.totalPoints),
rankChange: numOrNull(rec.rankChange),
}
}
export function parseStandingRows(value: unknown): StandingRow[] {
return list(value).map(parseStandingRow)
}
export function parseAthleteStanding(source: unknown): AthleteStanding {
const rec = record(source)
return {
rank: num(rec.rank),
athleteId: num(rec.athleteId),
athleteName: str(rec.athleteName),
gender: rec.gender === null || rec.gender === undefined ? null : oneOf(rec.gender, GENDERS, '男'),
teamId: num(rec.teamId),
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
totalPoints: num(rec.totalPoints),
gold: num(rec.gold),
silver: num(rec.silver),
bronze: num(rec.bronze),
}
}
export function parseAthleteStandings(value: unknown): AthleteStanding[] {
return list(value).map(parseAthleteStanding)
}
export function parseEventResultRow(source: unknown): EventResultRow {
const rec = record(source)
return {
entryId: num(rec.entryId),
rank: numOrNull(rec.rank),
teamId: num(rec.teamId),
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
athleteId: numOrNull(rec.athleteId),
athleteName: strOrNull(rec.athleteName),
memberNames: strings(rec.memberNames),
resultText: str(rec.resultText, '—'),
resultValue: numOrNull(rec.resultValue),
unit: str(rec.unit),
isTie: bool(rec.isTie),
status: oneOf(rec.status, RESULT_STATUSES, 'ok'),
}
}
export function parseEventResultGroup(source: unknown): EventResultGroup {
const rec = record(source)
return {
eventId: num(rec.eventId),
eventCode: str(rec.eventCode),
eventName: str(rec.eventName),
categoryId: num(rec.categoryId),
categoryName: str(rec.categoryName),
publishedAt: strOrNull(rec.publishedAt),
rows: list(rec.rows).map(parseEventResultRow),
}
}
export function parseEventResultGroups(value: unknown): EventResultGroup[] {
return list(value).map(parseEventResultGroup)
}
export function parseRecordEntry(source: unknown): RecordEntry {
const rec = record(source)
return {
id: num(rec.id),
eventId: num(rec.eventId),
level: oneOf(rec.level, RECORD_LEVELS, 'meet'),
resultValue: num(rec.resultValue),
resultText: str(rec.resultText, '—'),
holderName: strOrNull(rec.holderName),
holderTeamId: numOrNull(rec.holderTeamId),
achievedAt: strOrNull(rec.achievedAt),
isCurrent: bool(rec.isCurrent),
source: oneOf(rec.source, RECORD_SOURCES, 'history'),
}
}
export function parseSettings(source: unknown): EventSettings {
const rec = record(source)
const event = record(rec.event)
const display = record(rec.display)
const standings = record(rec.standings)
return {
event: {
name: str(event.name),
subtitle: strOrNull(event.subtitle),
host: strOrNull(event.host),
logoPath: strOrNull(event.logoPath),
primaryColor: strOrNull(event.primaryColor),
},
display: {
background: strOrNull(display.background),
logo: strOrNull(display.logo),
title: str(display.title),
subtitle: strOrNull(display.subtitle),
primaryColor: strOrNull(display.primaryColor),
rotateSeconds: num(display.rotateSeconds, 8),
},
standings: {
individualToTeamWeight: num(standings.individualToTeamWeight, 1),
rankingMode: str(standings.rankingMode, 'points'),
showIndividualBoard: bool(standings.showIndividualBoard, true),
},
defaultProfileId: numOrNull(rec.defaultProfileId),
requireApproval: bool(rec.requireApproval),
}
}
export function parseBreakdownItem(source: unknown): BreakdownItem {
const rec = record(source)
return {
ledgerId: num(rec.ledgerId),
sourceType: oneOf(rec.sourceType, LEDGER_SOURCES, 'result'),
eventId: numOrNull(rec.eventId),
eventCode: strOrNull(rec.eventCode),
eventName: strOrNull(rec.eventName),
categoryName: strOrNull(rec.categoryName),
athleteId: numOrNull(rec.athleteId),
athleteName: strOrNull(rec.athleteName),
rank: numOrNull(rec.rank),
resultText: strOrNull(rec.resultText),
points: num(rec.points),
createdAt: str(rec.createdAt),
}
}
export function parseTeamBreakdown(value: unknown): TeamBreakdown | null {
if (!isRecord(value)) return null
return {
teamId: num(value.teamId),
teamCode: str(value.teamCode),
teamName: str(value.teamName),
totalPoints: num(value.totalPoints),
items: list(value.items).map(parseBreakdownItem),
}
}
/** 快照是整包信任的入口,字段缺失时补空数组而不是拒绝整包。 */
export function parseSnapshot(value: unknown): SnapshotMessage | null {
if (!isRecord(value)) return null
const version = numOrNull(value.version)
if (version === null) return null
return {
version,
settings: parseSettings(value.settings),
teams: list(value.teams).map(parseTeam),
events: list(value.events).map(parseEvent),
standings: parseStandingRows(value.standings),
records: list(value.records).map(parseRecordEntry),
latestResults: parseEventResultGroups(value.latestResults),
}
}
+288
View File
@@ -0,0 +1,288 @@
import type {
AthleteStanding,
Event,
EventResultGroup,
EventResultRow,
SnapshotMessage,
StandingRow,
Team,
} from '@/shared'
/**
* 后端未就绪时的降级数据(任务要求:降级 mock 并标注「示例数据」)。
*
* 数值是手写的固定值而不是随机数:现场调试时截图对比不会因为每次刷新而变。
*/
const TEAM_NAMES: readonly string[] = [
'第一分公司',
'机关本部',
'第二分公司',
'第三分公司',
'第四分公司',
'第五分公司',
'第六分公司',
'技术研究院',
]
function team(index: number, code: string): Team {
const name = TEAM_NAMES[index] ?? `单位${index + 1}`
return {
id: index + 1,
code,
name,
shortName: null,
logoPath: null,
color: null,
sortOrder: index,
active: true,
}
}
const MOCK_TEAMS: Team[] = [
team(0, 'T01'),
team(1, 'T02'),
team(2, 'T03'),
team(3, 'T04'),
team(4, 'T05'),
team(5, 'T06'),
team(6, 'T07'),
team(7, 'T08'),
]
interface StandingSeed {
rank: number
totalPoints: number
gold: number
silver: number
bronze: number
rankChange: number | null
}
const STANDING_SEEDS: readonly StandingSeed[] = [
{ rank: 1, totalPoints: 186, gold: 8, silver: 5, bronze: 3, rankChange: 1 },
{ rank: 2, totalPoints: 172, gold: 6, silver: 7, bronze: 4, rankChange: -1 },
{ rank: 3, totalPoints: 158, gold: 5, silver: 4, bronze: 8, rankChange: 0 },
{ rank: 4, totalPoints: 141, gold: 4, silver: 5, bronze: 6, rankChange: 2 },
{ rank: 5, totalPoints: 127, gold: 3, silver: 4, bronze: 5, rankChange: null },
{ rank: 6, totalPoints: 103, gold: 2, silver: 3, bronze: 4, rankChange: 0 },
{ rank: 7, totalPoints: 95, gold: 2, silver: 2, bronze: 2, rankChange: -2 },
{ rank: 8, totalPoints: 78, gold: 1, silver: 1, bronze: 3, rankChange: 0 },
]
function buildStandings(): StandingRow[] {
return MOCK_TEAMS.map((item, index) => {
const seed = STANDING_SEEDS[index] ?? STANDING_SEEDS[STANDING_SEEDS.length - 1]
const row = seed ?? { rank: index + 1, totalPoints: 0, gold: 0, silver: 0, bronze: 0, rankChange: null }
return {
teamId: item.id,
teamCode: item.code,
teamName: item.name,
shortName: item.shortName,
color: item.color,
logoPath: item.logoPath,
rank: row.rank,
gold: row.gold,
silver: row.silver,
bronze: row.bronze,
medalTotal: row.gold + row.silver + row.bronze,
totalPoints: row.totalPoints,
rankChange: row.rankChange,
}
})
}
function buildEvents(): Event[] {
const seeds: readonly [string, string, Event['resultType'], Event['compare']][] = [
['E1001', '男子 100 米', 'time', 'asc'],
['E1002', '女子 100 米', 'time', 'asc'],
['E2001', '男子跳远', 'distance', 'desc'],
['E3001', '女子 4×100 米接力', 'time', 'asc'],
['E4001', '拔河', 'count', 'desc'],
]
return seeds.map(([code, name, resultType, compare], index) => ({
id: index + 1,
sportId: 1,
code,
name,
eventType: index === 3 || index === 4 ? 'team' : 'individual',
resultMode: 'auto',
resultType,
compare,
recordEligible: index < 3,
scored: true,
multiplier: 1,
status: index < 3 ? 'published' : 'finished',
scheduledAt: null,
venue: null,
sortOrder: index,
remark: null,
}))
}
interface ResultSeed {
rank: number
teamIndex: number
athlete: string
resultText: string
resultValue: number
}
function buildRows(seeds: readonly ResultSeed[], unit: string): EventResultRow[] {
return seeds.map((seed, index) => {
const team = MOCK_TEAMS[seed.teamIndex]
return {
entryId: (seed.rank + 1) * 100 + index,
rank: seed.rank,
teamId: team?.id ?? 0,
teamCode: team?.code ?? '',
teamName: team?.name ?? '',
shortName: team?.shortName ?? null,
athleteId: index + 1,
athleteName: seed.athlete,
memberNames: null,
resultText: seed.resultText,
resultValue: seed.resultValue,
unit,
isTie: false,
status: 'ok',
}
})
}
function buildLatestResults(): EventResultGroup[] {
return [
{
eventId: 1,
eventCode: 'E1001',
eventName: '男子 100 米',
categoryId: 1,
categoryName: '青年组',
publishedAt: '2026-09-09T15:42:00+08:00',
rows: buildRows(
[
{ rank: 1, teamIndex: 0, athlete: '张伟', resultText: '11.58', resultValue: 11.58 },
{ rank: 2, teamIndex: 2, athlete: '李强', resultText: '11.72', resultValue: 11.72 },
{ rank: 3, teamIndex: 1, athlete: '王磊', resultText: '11.85', resultValue: 11.85 },
{ rank: 4, teamIndex: 4, athlete: '刘洋', resultText: '11.94', resultValue: 11.94 },
{ rank: 5, teamIndex: 3, athlete: '陈晨', resultText: '12.03', resultValue: 12.03 },
{ rank: 6, teamIndex: 5, athlete: '赵鹏', resultText: '12.15', resultValue: 12.15 },
],
's',
),
},
{
eventId: 2,
eventCode: 'E1002',
eventName: '女子 100 米',
categoryId: 2,
categoryName: '青年组',
publishedAt: '2026-09-09T15:10:00+08:00',
rows: buildRows(
[
{ rank: 1, teamIndex: 1, athlete: '李娜', resultText: '13.04', resultValue: 13.04 },
{ rank: 2, teamIndex: 0, athlete: '孙悦', resultText: '13.21', resultValue: 13.21 },
{ rank: 3, teamIndex: 3, athlete: '周敏', resultText: '13.36', resultValue: 13.36 },
{ rank: 4, teamIndex: 6, athlete: '吴倩', resultText: '13.48', resultValue: 13.48 },
{ rank: 5, teamIndex: 2, athlete: '郑爽', resultText: '13.55', resultValue: 13.55 },
{ rank: 6, teamIndex: 7, athlete: '冯雪', resultText: '13.62', resultValue: 13.62 },
],
's',
),
},
{
eventId: 3,
eventCode: 'E2001',
eventName: '男子跳远',
categoryId: 3,
categoryName: '中年组',
publishedAt: '2026-09-09T14:35:00+08:00',
rows: buildRows(
[
{ rank: 1, teamIndex: 2, athlete: '黄涛', resultText: '6.42', resultValue: 6.42 },
{ rank: 2, teamIndex: 5, athlete: '徐斌', resultText: '6.18', resultValue: 6.18 },
{ rank: 3, teamIndex: 0, athlete: '马超', resultText: '6.05', resultValue: 6.05 },
{ rank: 4, teamIndex: 7, athlete: '朱峰', resultText: '5.96', resultValue: 5.96 },
{ rank: 5, teamIndex: 1, athlete: '胡军', resultText: '5.88', resultValue: 5.88 },
{ rank: 6, teamIndex: 4, athlete: '林凯', resultText: '5.71', resultValue: 5.71 },
],
'm',
),
},
]
}
const ATHLETE_SEEDS: readonly [string, number, number, number, number, number][] = [
['张伟', 0, 42, 2, 1, 0],
['李娜', 1, 38, 2, 0, 1],
['李强', 2, 34, 1, 2, 0],
['孙悦', 0, 30, 1, 1, 1],
['黄涛', 2, 28, 1, 0, 2],
['王磊', 1, 26, 1, 1, 0],
['周敏', 3, 24, 1, 0, 1],
['刘洋', 4, 22, 0, 2, 1],
['徐斌', 5, 20, 1, 0, 1],
['陈晨', 3, 18, 0, 1, 2],
['马超', 0, 16, 0, 1, 1],
['吴倩', 6, 14, 0, 0, 2],
]
function buildAthletes(): AthleteStanding[] {
return ATHLETE_SEEDS.map(([name, teamIndex, totalPoints, gold, silver, bronze], index) => {
const item = MOCK_TEAMS[teamIndex]
return {
rank: index + 1,
athleteId: index + 1,
athleteName: name,
gender: null,
teamId: item?.id ?? 0,
teamCode: item?.code ?? '',
teamName: item?.name ?? '',
shortName: item?.shortName ?? null,
totalPoints,
gold,
silver,
bronze,
}
})
}
export function createMockSnapshot(): SnapshotMessage {
return {
// version 固定 0 且不写入缓存:真实快照一到就能直接覆盖,不会因为版本号更大而被丢弃。
version: 0,
settings: {
event: {
name: '2026 职工运动会',
subtitle: null,
host: '示例组委会',
logoPath: null,
primaryColor: '#4fd8ff',
},
display: {
background: null,
logo: null,
title: '2026 职工运动会',
subtitle: '积分榜 · 示例数据',
primaryColor: '#4fd8ff',
rotateSeconds: 8,
},
standings: {
individualToTeamWeight: 1,
rankingMode: 'points',
showIndividualBoard: true,
},
defaultProfileId: null,
requireApproval: false,
},
teams: MOCK_TEAMS,
events: buildEvents(),
standings: buildStandings(),
records: [],
latestResults: buildLatestResults(),
}
}
export function createMockAthletes(): AthleteStanding[] {
return buildAthletes()
}
+43
View File
@@ -0,0 +1,43 @@
import type { SnapshotMessage } from '@/shared'
import { isRecord, num, parseSnapshot } from './guards'
/**
* 断线时继续出屏靠这份 localStorage 快照(06 第 8 节)。
*
* 只保留最近一份:现场大屏内存有限,历史版本没有回放价值;
* 读到的内容仍要过一遍 parseSnapshot,避免旧版本格式把渲染炸掉。
*/
const SNAPSHOT_KEY = 'scoreboard:display:snapshot'
export interface CachedSnapshot {
version: number
savedAt: number
snapshot: SnapshotMessage
}
export function readCachedSnapshot(): CachedSnapshot | null {
try {
const raw = localStorage.getItem(SNAPSHOT_KEY)
if (raw === null) return null
const parsed: unknown = JSON.parse(raw)
if (!isRecord(parsed)) return null
const snapshot = parseSnapshot(parsed.snapshot)
if (snapshot === null) return null
return { version: snapshot.version, savedAt: num(parsed.savedAt), snapshot }
} catch {
// 隐私模式或配额异常时静默降级:缓存只是兜底,不能因此不出屏。
return null
}
}
export function writeCachedSnapshot(snapshot: SnapshotMessage): void {
try {
localStorage.setItem(
SNAPSHOT_KEY,
JSON.stringify({ version: snapshot.version, savedAt: Date.now(), snapshot }),
)
} catch {
// 写失败同样只影响断线兜底,不阻断实时数据上屏。
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { TeamBreakdown } from '@/shared'
/**
* 大屏页面清单。顺序即整页轮播顺序(公告是手动触发的页,不参与轮播)。
*/
export const DISPLAY_PAGES = [
{ key: 'standings', title: '总积分榜' },
{ key: 'team-medals', title: '团体奖牌榜' },
{ key: 'athlete-standings', title: '个人积分奖牌榜' },
{ key: 'event-results', title: '分项成绩' },
{ key: 'announcement', title: '公告' },
] as const
export type DisplayPageKey = (typeof DISPLAY_PAGES)[number]['key']
export const PAGE_TITLES: Record<DisplayPageKey, string> = {
standings: '总积分榜',
'team-medals': '团体奖牌榜',
'athlete-standings': '个人积分奖牌榜',
'event-results': '分项成绩',
announcement: '公告',
}
/** 管理端 `goto` 下发的页名不保证与内部 key 一致,这里统一归一化。 */
const PAGE_ALIASES: Record<string, DisplayPageKey> = {
standings: 'standings',
total: 'standings',
points: 'standings',
积分榜: 'standings',
总积分榜: 'standings',
'team-medals': 'team-medals',
medals: 'team-medals',
medal: 'team-medals',
奖牌榜: 'team-medals',
团体奖牌榜: 'team-medals',
'athlete-standings': 'athlete-standings',
athletes: 'athlete-standings',
individual: 'athlete-standings',
个人积分奖牌榜: 'athlete-standings',
个人榜: 'athlete-standings',
'event-results': 'event-results',
results: 'event-results',
分项成绩: 'event-results',
成绩: 'event-results',
announcement: 'announcement',
announce: 'announcement',
公告: 'announcement',
}
export function normalizePage(value: string | null | undefined): DisplayPageKey | null {
if (value === null || value === undefined) return null
const key = value.trim()
if (key === '') return null
return PAGE_ALIASES[key] ?? PAGE_ALIASES[key.toLowerCase()] ?? null
}
export interface DisplayUrlOptions {
page: DisplayPageKey | null
/** true = 主区域锁页不轮播(默认)。 */
locked: boolean
screen: string | null
instance: string | null
/** 分项成绩页展示的已完成项目数,默认 3。 */
resultsCount: number
}
export interface DetailOverlayState {
title: string
detail: TeamBreakdown | null
autoCloseSeconds: number
openedAt: number
/** 管理端只给了 teamId、明细还在拉取时为 true。 */
loading: boolean
}
export interface AnnouncementState {
text: string
seconds: number
startedAt: number
}
/** live = 实时/接口数据;cache = 断线时回放的本地快照;mock = 后端未就绪的示例数据。 */
export type DisplaySource = 'live' | 'cache' | 'mock'
+25
View File
@@ -0,0 +1,25 @@
import { ref, type Ref } from 'vue'
/**
* 「减少动态效果」系统偏好(06 第 7 节:所有动画在该偏好下自动关闭)。
*
* 做成模块级单例:大屏上几十个数字组件各自 matchMedia 会白白多出几十个监听。
*/
const reduced = ref(false)
let initialized = false
function init(): void {
if (initialized) return
initialized = true
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
const query = window.matchMedia('(prefers-reduced-motion: reduce)')
reduced.value = query.matches
query.addEventListener('change', (event) => {
reduced.value = event.matches
})
}
export function prefersReducedMotion(): Ref<boolean> {
init()
return reduced
}
+507
View File
@@ -0,0 +1,507 @@
import { computed, ref } from 'vue'
import { RealtimeClient, createApiClient } from '@/shared'
import type {
AthleteStanding,
Change,
DisplayCommand,
Event,
EventResultGroup,
EventSettings,
RealtimeStatus,
RecordEntry,
SnapshotMessage,
StandingRow,
Team,
} from '@/shared'
import {
isRecord,
list,
numOrNull,
parseAthleteStandings,
parseEvent,
parseEventResultGroups,
parseSettings,
parseStandingRow,
parseStandingRows,
parseTeam,
parseTeamBreakdown,
} from './guards'
import { createMockAthletes, createMockSnapshot } from './mock'
import { readCachedSnapshot, writeCachedSnapshot } from './storage'
import { DISPLAY_PAGES, normalizePage } from './types'
import type {
AnnouncementState,
DetailOverlayState,
DisplayPageKey,
DisplaySource,
DisplayUrlOptions,
} from './types'
import { prefersReducedMotion } from './useReducedMotion'
/** 整页轮播间隔(06 第 4 节:后台可切换为整页轮播,每页 20s)。 */
const PAGE_ROTATE_MS = 20_000
/** 名次升降箭头保留时长(06 第 7 节:2s 后淡出)。 */
const RANK_DELTA_MS = 2_000
const api = createApiClient()
const reducedMotion = prefersReducedMotion()
const source = ref<DisplaySource>('live')
const status = ref<RealtimeStatus>('idle')
const version = ref<number | null>(null)
const settings = ref<EventSettings | null>(null)
const teams = ref<Team[]>([])
const events = ref<Event[]>([])
const standings = ref<StandingRow[]>([])
const athletes = ref<AthleteStanding[]>([])
const latestResults = ref<EventResultGroup[]>([])
const records = ref<RecordEntry[]>([])
const rankDelta = ref<Record<number, number>>({})
const page = ref<DisplayPageKey>('standings')
const locked = ref(true)
const options = ref<DisplayUrlOptions>({
page: null,
locked: true,
screen: null,
instance: null,
resultsCount: 3,
})
const detail = ref<DetailOverlayState | null>(null)
const announcement = ref<AnnouncementState | null>(null)
const booted = ref(false)
let client: RealtimeClient | null = null
let rankTimer: ReturnType<typeof setTimeout> | null = null
let detailTimer: ReturnType<typeof setTimeout> | null = null
let announceTimer: ReturnType<typeof setTimeout> | null = null
let pageTimer: ReturnType<typeof setInterval> | null = null
const showAthleteBoard = computed(() => settings.value?.standings.showIndividualBoard ?? true)
/** 参与整页轮播的页;公告是手动触发的页,个人榜按配置可隐藏。 */
const rotationPages = computed<DisplayPageKey[]>(() =>
DISPLAY_PAGES.map((item) => item.key).filter(
(key) => key !== 'announcement' && (key !== 'athlete-standings' || showAthleteBoard.value),
),
)
function compareStanding(a: StandingRow, b: StandingRow): number {
return a.rank - b.rank || b.totalPoints - a.totalPoints
}
function clearTimer(timer: ReturnType<typeof setTimeout> | null): null {
if (timer !== null) clearTimeout(timer)
return null
}
// ---------------------------------------------------------------------------
// 数据落地
// ---------------------------------------------------------------------------
/**
* 用新的积分榜替换本地榜单,并算出相对上一次的名次变化。
*
* 服务端给的 rankChange 优先;没有基准(首屏 / 刚换页)时用本地前后对比兜底,
* 这样断线重连后即使服务端没带 rankChange,箭头也不会凭空消失。
*/
function applyStandings(rows: StandingRow[]): void {
const previous = new Map(standings.value.map((row) => [row.teamId, row.rank]))
const deltas: Record<number, number> = {}
const next = rows.map((row) => {
const before = previous.get(row.teamId)
const local = before === undefined ? null : before - row.rank
const delta = row.rankChange ?? local
if (delta !== null && delta !== 0) deltas[row.teamId] = delta
return { ...row, rankChange: delta }
})
standings.value = next.sort(compareStanding)
if (Object.keys(deltas).length === 0) return
rankDelta.value = deltas
rankTimer = clearTimer(rankTimer)
rankTimer = setTimeout(() => {
rankDelta.value = {}
rankTimer = null
}, RANK_DELTA_MS)
}
function applySnapshot(snapshot: SnapshotMessage, kind: DisplaySource): void {
settings.value = snapshot.settings
teams.value = snapshot.teams
events.value = snapshot.events
latestResults.value = snapshot.latestResults
records.value = snapshot.records
version.value = snapshot.version
applyStandings(snapshot.standings)
source.value = kind
}
function mergeById<T extends { id: number }>(
current: T[],
change: Change,
parse: (value: unknown) => T,
): T[] {
if (change.action === 'replace') return list(change.data).map(parse)
if (change.action === 'void') {
const id = change.id ?? (isRecord(change.data) ? numOrNull(change.data.id) : null)
return id === null ? current : current.filter((item) => item.id !== id)
}
const item = parse(change.data)
return [...current.filter((entry) => entry.id !== item.id), item]
}
function mergeStanding(row: StandingRow): void {
applyStandings([...standings.value.filter((item) => item.teamId !== row.teamId), row])
}
/**
* 应用增量变更。
*
* 只有榜单类实体能在客户端可靠合并;其余实体(如逐条成绩)合并规则在服务端,
* 这里直接 resync 拉全量——宁可多拉一次,也不允许大屏上出现算错的分数。
*/
function applyChanges(changes: Change[]): void {
let resyncNeeded = false
for (const change of changes) {
switch (change.entity) {
case 'standings':
if (change.action === 'replace') applyStandings(parseStandingRows(change.data))
else if (change.action === 'upsert') mergeStanding(parseStandingRow(change.data))
break
case 'athleteStandings':
case 'athlete_standings':
if (change.action === 'replace') athletes.value = parseAthleteStandings(change.data)
break
case 'latestResults':
case 'eventResults':
if (change.action === 'replace') latestResults.value = parseEventResultGroups(change.data)
break
case 'settings':
if (change.action !== 'void') settings.value = parseSettings(change.data)
break
case 'teams':
teams.value = mergeById(teams.value, change, parseTeam)
break
case 'events':
events.value = mergeById(events.value, change, parseEvent)
break
default:
resyncNeeded = true
}
}
if (resyncNeeded) client?.requestResync()
}
// ---------------------------------------------------------------------------
// 断线兜底与启动
// ---------------------------------------------------------------------------
function hydrateFromCache(): void {
const cached = readCachedSnapshot()
if (cached === null) return
applySnapshot(cached.snapshot, 'cache')
}
/**
* REST 兜底:快照不含个人榜,所以个人榜必须单独取;
* 但快照比 REST 权威(REST 没有 version),已收到快照时不用 REST 覆盖。
*/
async function loadRest(): Promise<boolean> {
const hasSnapshot = version.value !== null
const [settingsResult, standingsResult, athletesResult] = await Promise.allSettled([
api.settings.get(),
api.standings.teams(),
api.standings.athletes(),
])
let ok = false
if (settingsResult.status === 'fulfilled' && !hasSnapshot) {
settings.value = settingsResult.value
ok = true
}
if (standingsResult.status === 'fulfilled' && !hasSnapshot) {
applyStandings(standingsResult.value)
ok = true
}
if (athletesResult.status === 'fulfilled') {
athletes.value = athletesResult.value
ok = true
}
return ok
}
function activateMock(): void {
const snapshot = createMockSnapshot()
settings.value = snapshot.settings
teams.value = snapshot.teams
events.value = snapshot.events
latestResults.value = snapshot.latestResults
records.value = snapshot.records
athletes.value = createMockAthletes()
standings.value = snapshot.standings
source.value = 'mock'
// 示例数据也把升降箭头演示出来,方便验收动效。
const deltas: Record<number, number> = {}
for (const row of snapshot.standings) {
if (row.rankChange !== null && row.rankChange !== 0) deltas[row.teamId] = row.rankChange
}
rankDelta.value = deltas
rankTimer = clearTimer(rankTimer)
rankTimer = setTimeout(() => {
rankDelta.value = {}
rankTimer = null
}, RANK_DELTA_MS)
}
function readUrlOptions(): DisplayUrlOptions {
const params = new URLSearchParams(window.location.search)
const lockParam = params.get('lock')
return {
page: normalizePage(params.get('page')),
// 主区域默认锁定总积分榜,只有显式 lock=0 才开整页轮播。
locked: lockParam !== '0' && lockParam !== 'false',
screen: params.get('screen'),
instance: params.get('instance'),
resultsCount: clampInt(params.get('n'), 1, 6, 3),
}
}
function clampInt(value: string | null, min: number, max: number, fallback: number): number {
if (value === null) return fallback
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) return fallback
return Math.min(max, Math.max(min, parsed))
}
// ---------------------------------------------------------------------------
// 轮播
// ---------------------------------------------------------------------------
function stopPageRotation(): void {
if (pageTimer !== null) {
clearInterval(pageTimer)
pageTimer = null
}
}
function startPageRotation(): void {
stopPageRotation()
if (locked.value || reducedMotion.value) return
const order = rotationPages.value
if (order.length < 2) return
pageTimer = setInterval(() => {
const current = order.indexOf(page.value)
const next = order[(current + 1) % order.length]
if (next !== undefined) page.value = next
}, PAGE_ROTATE_MS)
}
function setPage(next: DisplayPageKey): void {
page.value = next
}
function setLocked(value: boolean): void {
locked.value = value
if (value) stopPageRotation()
else startPageRotation()
}
// ---------------------------------------------------------------------------
// 覆盖层与公告
// ---------------------------------------------------------------------------
function clearDetailTimer(): void {
detailTimer = clearTimer(detailTimer)
}
function closeDetail(): void {
clearDetailTimer()
detail.value = null
if (!locked.value) startPageRotation()
}
async function openDetail(command: DisplayCommand): Promise<void> {
const payload = command.payload
const provided = payload.detail === undefined ? null : parseTeamBreakdown(payload.detail)
const teamId = typeof payload.teamId === 'number' ? payload.teamId : null
const openedAt = Date.now()
detail.value = {
title: payload.title ?? provided?.teamName ?? '积分明细',
detail: provided,
autoCloseSeconds: payload.autoCloseSeconds ?? 60,
openedAt,
loading: provided === null && teamId !== null,
}
// 明细覆盖期间暂停轮播(06 第 5 节)。
stopPageRotation()
clearDetailTimer()
if (detail.value.autoCloseSeconds > 0) {
detailTimer = setTimeout(closeDetail, detail.value.autoCloseSeconds * 1000)
}
if (!detail.value.loading || teamId === null) return
try {
const breakdown = parseTeamBreakdown(await api.standings.breakdown(teamId))
// 请求期间可能已被 hide-detail 或自动关闭,用 openedAt 判断这份响应还属于当前覆盖层。
if (detail.value !== null && detail.value.openedAt === openedAt) {
detail.value.detail = breakdown
detail.value.loading = false
}
} catch {
if (detail.value !== null && detail.value.openedAt === openedAt) detail.value.loading = false
}
}
function announce(text: string, seconds: number): void {
const trimmed = text.trim()
if (trimmed === '') return
announcement.value = { text: trimmed, seconds, startedAt: Date.now() }
const previous = page.value
page.value = 'announcement'
announceTimer = clearTimer(announceTimer)
if (seconds > 0) {
announceTimer = setTimeout(() => {
announceTimer = null
if (page.value === 'announcement') page.value = previous
}, seconds * 1000)
}
}
function handleCommand(command: DisplayCommand): void {
switch (command.action) {
case 'goto': {
const target = normalizePage(command.payload.page)
if (target !== null) {
closeDetail()
page.value = target
}
break
}
case 'lock':
setLocked(true)
break
case 'unlock':
setLocked(false)
break
case 'reload':
window.location.reload()
break
case 'announce':
announce(command.payload.text ?? '', command.payload.seconds ?? 0)
break
case 'show-detail':
void openDetail(command)
break
case 'hide-detail':
closeDetail()
break
}
}
// ---------------------------------------------------------------------------
// 生命周期
// ---------------------------------------------------------------------------
function connect(): void {
if (client !== null) return
client = new RealtimeClient({
role: 'display',
displayKey: options.value.instance ?? options.value.screen ?? undefined,
initialVersion: version.value,
onSnapshot: (message) => {
applySnapshot(message, 'live')
writeCachedSnapshot(message)
},
onCatchup: (message) => {
applyChanges(message.changes)
},
onChange: (message) => {
applyChanges(message.changes)
},
onCommand: handleCommand,
onStatusChange: (next) => {
status.value = next
},
onVersionChange: (next) => {
version.value = next
},
})
client.connect()
}
async function bootstrap(): Promise<void> {
if (booted.value) return
booted.value = true
const parsed = readUrlOptions()
options.value = parsed
if (parsed.page !== null) page.value = parsed.page
locked.value = parsed.locked
hydrateFromCache()
connect()
const restOk = await loadRest()
if (!restOk && version.value === null) activateMock()
if (!locked.value) startPageRotation()
}
function dispose(): void {
stopPageRotation()
clearDetailTimer()
rankTimer = clearTimer(rankTimer)
announceTimer = clearTimer(announceTimer)
client?.close()
client = null
booted.value = false
}
function rankDeltaFor(teamId: number): number | null {
return rankDelta.value[teamId] ?? null
}
/**
* 大屏只有这一份状态:多块屏各自是独立窗口/标签页,不需要跨实例共享,
* 所以用模块级单例而不是 Pinia,少一层依赖。
*/
export function useScoreboard() {
return {
source,
status,
version,
settings,
teams,
events,
standings,
athletes,
latestResults,
records,
rankDelta,
page,
locked,
options,
detail,
announcement,
booted,
rotationPages,
bootstrap,
dispose,
setPage,
setLocked,
startPageRotation,
stopPageRotation,
closeDetail,
rankDeltaFor,
}
}
+37
View File
@@ -0,0 +1,37 @@
import { onUnmounted, ref } from 'vue'
export const DESIGN_WIDTH = 1920
export const DESIGN_HEIGHT = 1080
/**
* 1920×1080 设计基准 + `transform: scale()` 等比适配(06 第 3 节)。
*
* 只按「较小的一边」缩放并居中,非 16:9 窗口留黑边而不拉伸;
* 不写死设备像素,1080p / 4K / 系统缩放都能正确铺满。
*/
export function useStageScale() {
const scale = ref(1)
const offsetX = ref(0)
const offsetY = ref(0)
function fit(): void {
const width = window.innerWidth
const height = window.innerHeight
const next = Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT)
scale.value = next
offsetX.value = (width - DESIGN_WIDTH * next) / 2
offsetY.value = (height - DESIGN_HEIGHT * next) / 2
}
fit()
// display-metrics-changed(Electron 改分辨率/缩放)在页面里同样表现为 resize。
window.addEventListener('resize', fit)
window.addEventListener('orientationchange', fit)
onUnmounted(() => {
window.removeEventListener('resize', fit)
window.removeEventListener('orientationchange', fit)
})
return { scale, offsetX, offsetY, fit }
}