import { computed, ref, watch } from 'vue' import { RealtimeClient, createApiClient } from '@/shared' import type { AthleteStanding, Change, DisplayCommand, EventResultGroup, RealtimeStatus, SnapshotMessage, StandingRow, } from '@/shared' import { parseSettings, parseSnapshot, parseStandingRow, parseStandingRows, parseTeamBreakdown, } from './guards' import type { DisplaySettings } from './guards' import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce' import { applyDisplayFonts } from './fonts' import { createMockAthletes, createMockSnapshot } from './mock' import { readCachedSnapshot, writeCachedSnapshot } from './storage' import { DISPLAY_PAGES, normalizePage } from './types' import type { 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('live') const status = ref('idle') const version = ref(null) const settings = ref(null) const standings = ref([]) const athletes = ref([]) const latestResults = ref([]) const rankDelta = ref>({}) const page = ref('standings') const locked = ref(true) const authRequired = ref(false) const options = ref({ page: null, locked: true, screen: null, instance: null, resultsCount: 3, }) const detail = ref(null) const booted = ref(false) /** 公告状态机:全屏退场后正文继续留着,底栏读的就是它。 */ const announcementStore = createAnnouncement({ page, onFullscreenEnter: () => { page.value = 'announcement' // 公告不在轮播页清单里,20s 整页轮播会把它切走;公告期间必须停轮播。 stopPageRotation() }, onFullscreenExit: (returnPage) => { // 全屏期间可能被 goto 抢走页面,这时不能再把页拉回公告前那一页。 if (page.value === 'announcement') page.value = returnPage if (!locked.value) startPageRotation() }, }) const announcement = announcementStore.current /** 底栏与公告页的公告正文:推送中的实时值优先,其次是服务端持久化的当前公告。 */ const announceText = computed(() => resolveAnnounceText(announcement.value, settings.value?.display?.announce), ) let client: RealtimeClient | null = null let rankTimer: ReturnType | null = null let detailTimer: ReturnType | null = null let pageTimer: ReturnType | null = null let athletesLoading = false /** 上一次见过的「服务端持久化公告」,用来判断设置里的值是真的变了。 */ let seenSettingsAnnounce = '' const showAthleteBoard = computed(() => settings.value?.standings.showIndividualBoard ?? true) /** 参与整页轮播的页;公告是手动触发的页,个人榜按配置可隐藏。 */ const rotationPages = computed(() => 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 | null): null { if (timer !== null) clearTimeout(timer) return null } // 翻页(含整页轮播)后把当前页同步给服务端,管理端实例看板才不会一直显示旧页。 // 模块级单例与页面同生命周期,不需要随组件卸载停表。 watch(page, (next) => { client?.setPage(next) }) // --------------------------------------------------------------------------- // 数据落地 // --------------------------------------------------------------------------- /** * 用新的积分榜替换本地榜单,并算出相对上一次的名次变化。 * * 服务端给的 rankChange 优先;没有基准(首屏 / 刚换页)时用本地前后对比兜底, * 这样断线重连后即使服务端没带 rankChange,箭头也不会凭空消失。 */ function applyStandings(rows: StandingRow[]): void { const previous = new Map(standings.value.map((row) => [row.teamId, row.rank])) const deltas: Record = {} 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 latestResults.value = snapshot.latestResults version.value = snapshot.version applyStandings(snapshot.standings) source.value = kind } function mergeStanding(row: StandingRow): void { applyStandings([...standings.value.filter((item) => item.teamId !== row.teamId), row]) } /** * 应用增量变更。 * * 只有总积分榜能在客户端可靠合并;其余实体(逐条成绩、分项成绩等)的合并规则在服务端, * 这里直接 resync 拉全量——宁可多拉一次,也不允许大屏上出现算错的分数。 * 个人榜不在快照 / 增量里,总榜一变就单独重拉一次,否则它会停在开屏那一刻。 */ function applyChanges(changes: Change[]): void { let resyncNeeded = false let standingsTouched = false for (const change of changes) { if (change.entity !== 'standings') { resyncNeeded = true continue } if (change.action === 'replace' && Array.isArray(change.data)) { applyStandings(parseStandingRows(change.data)) standingsTouched = true } else if (change.action === 'upsert') { mergeStanding(parseStandingRow(change.data)) standingsTouched = true } else { // replace 载荷不是榜单数组时不能当空数组用(会把大屏清空),回退全量。 resyncNeeded = true } } if (standingsTouched) void refreshAthletes() if (resyncNeeded) client?.requestResync() } /** * 个人榜单独走 REST:服务端快照不含 athletes,增量也不写这个 entity。 * 拉取失败时保留上一份,不能因为附属榜单把实时主数据挡住。 */ async function refreshAthletes(): Promise { if (athletesLoading) return athletesLoading = true try { athletes.value = await api.standings.athletes() } catch { // 静默保留旧数据,下一次榜单变更会再试。 } finally { athletesLoading = false } } // --------------------------------------------------------------------------- // 断线兜底与启动 // --------------------------------------------------------------------------- function hydrateFromCache(): void { const cached = readCachedSnapshot() if (cached === null) return applySnapshot(cached.snapshot, 'cache') } /** * REST 兜底。 * * 大屏不请求 /api/settings(admin-only,guest 必然 403),配置只认 WS 快照与本地缓存; * 快照比 REST 权威(REST 没有 version),且请求飞行途中可能已有快照到达, * 所以是否套用 REST 必须在 await 之后重新判断,不能沿用发请求前的结论。 */ async function loadRest(): Promise { const [standingsResult, athletesResult] = await Promise.allSettled([ api.standings.teams(), api.standings.athletes(), ]) const hasSnapshot = version.value !== null let ok = false 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 = parseSettings(snapshot.settings) latestResults.value = snapshot.latestResults athletes.value = createMockAthletes() standings.value = snapshot.standings source.value = 'mock' // 示例数据也把升降箭头演示出来,方便验收动效。 const deltas: Record = {} 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'), // 分项成绩每组最小宽度约 450px,5 组起就超出 1920 舞台,上限压到 4。 resultsCount: clampInt(params.get('n'), 1, 4, 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 || page.value === 'announcement') 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 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 { 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 handleCommand(command: DisplayCommand): void { switch (command.action) { case 'goto': { const target = normalizePage(command.payload.page) if (target !== null) { closeDetail() page.value = target // 公告期间轮播是停着的,goto 抢回控制权后按锁定状态恢复。 if (!locked.value) startPageRotation() } break } case 'lock': setLocked(true) break case 'unlock': setLocked(false) break case 'reload': window.location.reload() break case 'announce': // 全屏 seconds 秒后正文留在底栏,只有新公告或 announce_clear 才替换它。 announcementStore.push(command.payload.text ?? '', command.payload.seconds ?? 0) break case 'announce_clear': announcementStore.clear() break case 'show-detail': void openDetail(command) break case 'hide-detail': closeDetail() break } } // 大屏字体随设置变化:快照、缓存、示例数据任何一条路径改动 settings 都要重新挂字体。 watch( settings, (next) => { void applyDisplayFonts(next) }, { immediate: true }, ) /** * 服务端把当前公告持久化在设置里(display.announce),快照与设置接口都带它。 * * 指令可能因为断线、重开窗口而错过,所以设置里的值也当作一条公告来源: * 变成本机没有的新内容就广播一次全屏,服务端清空后本地跟着清掉。 */ watch( () => settings.value?.display?.announce ?? null, (next) => { const text = next?.trim() ?? '' if (text === seenSettingsAnnounce) return seenSettingsAnnounce = text if (text === '') { announcementStore.clear() return } // 指令刚推过同一条(持久化会跟着回推一次设置),不重复全屏。 if (announcement.value?.text === text) return announcementStore.push(text, DEFAULT_ANNOUNCE_SECONDS) }, ) // --------------------------------------------------------------------------- // 生命周期 // --------------------------------------------------------------------------- function connect(): void { if (client !== null) return client = new RealtimeClient({ role: 'display', displayKey: options.value.instance ?? options.value.screen ?? undefined, instanceId: options.value.instance ?? undefined, page: page.value, initialVersion: version.value, onSnapshot: (message) => { // 快照载荷一律过解析层逐字段兜底,解析失败则整包丢弃(心跳/重连会再给一次)。 const snapshot = parseSnapshot(message) if (snapshot === null) return applySnapshot(snapshot, 'live') writeCachedSnapshot(snapshot) // 个人榜不在快照里,每次拿到快照(含 resync 结果)都单独补拉一次。 void refreshAthletes() }, onCatchup: (message) => { applyChanges(message.changes) }, onChange: (message) => { applyChanges(message.changes) }, onCommand: handleCommand, onStatusChange: (next) => { status.value = next if (next === 'online') authRequired.value = false }, onVersionChange: (next) => { version.value = next }, onAuthRequired: () => { authRequired.value = true }, onError: (error) => { // 心跳超时 / 非 JSON / 握手失败都只在这里可见,现场排障要靠它。 console.warn('[大屏实时]', error.message) }, }) client.connect() } async function bootstrap(): Promise { 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) announcementStore.dispose() 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, standings, athletes, latestResults, page, authRequired, options, detail, announcement, announceText, booted, bootstrap, dispose, rankDeltaFor, } }