import { computed, ref, watch } from 'vue' import { RealtimeClient, createApiClient } from '@/shared' import type { AthleteStanding, Change, DisplayCommand, Event, EventResultGroup, RealtimeStatus, StandingRow, } from '@/shared' import { DEFAULT_ROTATE_SECONDS, parseAthleteStandings, parseLiveFeed, parseSettings, parseSnapshot, parseStandingRow, parseStandingRows, parseTeamBreakdown, } from './guards' import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards' import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce' import { applyDisplayFonts } from './fonts' import { liveRefreshDelay } from './live' 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' /** 轮播间隔下限(秒):配置成 0 或负数时按 2 秒兜底,不然会疯狂翻页。 */ const MIN_ROTATE_SECONDS = 2 /** 名次升降箭头保留时长(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([]) // 小项清单:实时赛况页要显示「当前项目」的名字,而指定项目不一定在 latestResults 里。 const events = ref([]) /** * 「实时赛况」的当前项目结论(GET /api/display/live)。 * * 判定依赖当前时间与最近一次发布,装不进只在变更时推送的快照,所以单独拉; * null = 还没成功拉到过,页面退回「人工指定 + 快照分组」的兜底素材。 */ const liveFeed = ref(null) /** 最近一次拉取是否失败;失败时不动 liveFeed(现场要求:下一项目开始前一直显示上一个项目的成绩)。 */ const liveStale = ref(false) 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 // 「实时赛况」刷新的合并去重状态:见 refreshLiveEvents。 let liveLoading = false let liveQueued = false let liveLastAttemptAt = 0 let liveQueueTimer: ReturnType | null = null /** 上一次见过的「服务端持久化公告」,用来判断设置里的值是真的变了。 */ 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), ), ) /** * 整页轮播间隔:与服务端 `display.rotateSeconds` 同源(06 第 4 节「与底栏同间隔」)。 * * 以前写死 20s,管理员把轮播改成 15s 或 30s 只影响底栏,整页轮播毫无反应。 * 缺配置 / 配置非法时按服务端默认值兜底(`dto/settings.go` 的 rotate_seconds 兜底 15), * 并结合下限,避免配成 0 时疯狂翻页。 */ const pageRotateMs = computed(() => { const seconds = settings.value?.display?.rotateSeconds const safe = typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_ROTATE_SECONDS return Math.max(MIN_ROTATE_SECONDS, safe) * 1000 }) 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: DisplaySnapshot, kind: DisplaySource): void { settings.value = snapshot.settings latestResults.value = snapshot.latestResults events.value = snapshot.events 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 reportLiveFailure(wasStale: boolean, reason: string): void { if (wasStale) return console.warn('[实时赛况]', `拉取当前项目失败,继续显示上一次成绩:${reason}`) } /** * 拉一次「实时赛况」的当前项目(GET /api/display/live)。 * * 三种触发都走这里:页面挂载、WS 快照变更(成绩发布 → 快照会推)、定时兜底。 * 合并去重靠 liveRefreshDelay:请求还在飞、或距上次尝试不足最小间隔时,只登记一次 * 尾随刷新,既不会同一秒打两次,也不会漏掉这段窗口里发布的新成绩。 * * 失败只挂 liveStale:现场要求「下一项目开始前持续显示上一个项目的成绩」, * 拿不到新结论时宁可显示旧画面,也不能把屏幕清空(老服务端没有这个接口时同样如此)。 */ async function refreshLiveEvents(): Promise { if (liveLoading) { liveQueued = true return } const wait = liveRefreshDelay(liveLastAttemptAt, Date.now()) if (wait > 0) { liveQueued = true if (liveQueueTimer === null) { liveQueueTimer = setTimeout(() => { liveQueueTimer = null if (!liveQueued) return liveQueued = false void refreshLiveEvents() }, wait) } return } liveLastAttemptAt = Date.now() liveLoading = true const wasStale = liveStale.value try { const feed = parseLiveFeed(await api.display.live()) if (feed === null) { // 响应形状不对与请求失败同责:都不是一份可信的结论,保留上一份。 liveStale.value = true reportLiveFailure(wasStale, '响应格式不符') } else { liveFeed.value = feed liveStale.value = false } } catch (error) { liveStale.value = true reportLiveFailure(wasStale, error instanceof Error ? error.message : String(error)) } finally { liveLoading = false if (liveQueued) { liveQueued = false // 立刻再调一次:此时仍在最小间隔内,会由上面那条分支排队到间隔之后再发。 void refreshLiveEvents() } } } // --------------------------------------------------------------------------- // 断线兜底与启动 // --------------------------------------------------------------------------- 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 之后重新判断,不能沿用发请求前的结论。 * * 两条路都必须过 guards:`api.standings.*` 只做了 JSON 解析(`{ok,data}` 拆包), * 字段缺失 / 类型不对时它照样把裸值交出来,而大屏是无人值守的—— * 少一个字段就可能让整页模板渲染成空。WS 快照那条路一直是过 guards 的,两边口径要一致。 */ 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(parseStandingRows(standingsResult.value)) ok = true } if (athletesResult.status === 'fulfilled') { athletes.value = parseAthleteStandings(athletesResult.value) ok = true } return ok } function activateMock(): void { const snapshot = createMockSnapshot() settings.value = parseSettings(snapshot.settings) latestResults.value = snapshot.latestResults events.value = snapshot.events 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 }, pageRotateMs.value) } 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 }, ) // 轮播间隔也来自 settings(display.rotateSeconds),而 setInterval 的周期在建表时就固定了: // 设置到达 / 被改后必须重排计时器,否则首屏(快照还没到)会一直按兜底间隔翻。 // 只在轮播真在跑时才动它——锁定、全屏公告、明细覆盖期间本来就该停着。 watch(pageRotateMs, () => { if (pageTimer !== null) startPageRotation() }) /** * 服务端把当前公告持久化在设置里(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, // Console 从 URL 带下来的屏号(screen=<显示器 id>),让管理端把本机显示器与在线实例对上; // 局域网浏览器直接开 /display 时没有它,留空。 screen: options.value.screen ?? 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() // 当前项目的判定依赖「最近一次发布」,成绩一发布就要重算;快照就是发布的信号。 void refreshLiveEvents() }, 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) liveQueueTimer = clearTimer(liveQueueTimer) liveQueued = false 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, events, liveFeed, liveStale, page, authRequired, options, detail, announcement, announceText, booted, bootstrap, dispose, refreshLiveEvents, rankDeltaFor, } }