## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
660 lines
22 KiB
TypeScript
660 lines
22 KiB
TypeScript
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,
|
||
}
|
||
}
|