Files
Scoreboard/web/src/display/useScoreboard.ts
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

660 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DisplaySource>('live')
const status = ref<RealtimeStatus>('idle')
const version = ref<number | null>(null)
const settings = ref<DisplaySettings | null>(null)
const standings = ref<StandingRow[]>([])
const athletes = ref<AthleteStanding[]>([])
const latestResults = ref<EventResultGroup[]>([])
// 小项清单:实时赛况页要显示「当前项目」的名字,而指定项目不一定在 latestResults 里。
const events = ref<Event[]>([])
/**
* 「实时赛况」的当前项目结论(GET /api/display/live)。
*
* 判定依赖当前时间与最近一次发布,装不进只在变更时推送的快照,所以单独拉;
* null = 还没成功拉到过,页面退回「人工指定 + 快照分组」的兜底素材。
*/
const liveFeed = ref<DisplayLiveFeed | null>(null)
/** 最近一次拉取是否失败;失败时不动 liveFeed(现场要求:下一项目开始前一直显示上一个项目的成绩)。 */
const liveStale = ref(false)
const rankDelta = ref<Record<number, number>>({})
const page = ref<DisplayPageKey>('standings')
const locked = ref(true)
const authRequired = ref(false)
const options = ref<DisplayUrlOptions>({
page: null,
locked: true,
screen: null,
instance: null,
resultsCount: 3,
})
const detail = ref<DetailOverlayState | null>(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<typeof setTimeout> | null = null
let detailTimer: ReturnType<typeof setTimeout> | null = null
let pageTimer: ReturnType<typeof setInterval> | null = null
let athletesLoading = false
// 「实时赛况」刷新的合并去重状态:见 refreshLiveEvents。
let liveLoading = false
let liveQueued = false
let liveLastAttemptAt = 0
let liveQueueTimer: ReturnType<typeof setTimeout> | null = null
/** 上一次见过的「服务端持久化公告」,用来判断设置里的值是真的变了。 */
let seenSettingsAnnounce = ''
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),
),
)
/**
* 整页轮播间隔:与服务端 `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<typeof setTimeout> | 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<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: 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<void> {
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<void> {
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<boolean> {
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<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'),
// 分项成绩每组最小宽度约 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<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 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<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)
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,
}
}