Files
Scoreboard/web/src/display/useScoreboard.ts
T
AskaEth fc96d17660 feat: 字体可配并随赛事包打包 + 大屏调试标识收敛 + 报名表 CSV 编码兼容
三块改动,均为现场反馈:

一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 httpapi/fonts.go 与 4 条路由:GET /api/fonts(recorder)、
  POST /api/fonts(admin,multipart file)、DELETE /api/fonts/{name}(admin)、
  GET /uploads/fonts/{name}(匿名——大屏 @font-face 不带会话)
- 上传限 ttf/otf/woff/woff2、单个 20 MiB;文件名经 safeFontName 收敛且需与
  fontContentType 往返一致,挡住路径穿越;重名 409、删除被引用字体 409
- 新增设置键 display.font_title / display.font_body(DTO fontTitle/fontBody,
  随快照下发给大屏),写入时校验取值合法
- 导出:接上一直没被赋值的 IncludeFonts/FontsDir;包内落点用 uploads/fonts/
  (运行时只读这里,落顶层 fonts/ 等于没人读)
- 大屏按配置动态注册字体并挂 --display-font-title/body,加载失败静默回落默认字体
- 管理端「赛事信息」新增字体区块:标题/正文字体下拉、上传、字体库、实时预览

二、大屏显示收敛
- 数据版本号角标 vN 彻底移除(原先只有窗口化窗口会显示,两处显隐口径不一致)
- 窗口化窗口按 F11 全屏后自绘标题栏不让位:根因是 Console 用 win.setFullScreen()
  做窗口级全屏,而页面用 document.fullscreenElement 判断,后者恒为 null;
  新增 display-preload.js 下发窗口级全屏状态,页面侧 shell.ts 把两级全屏取或,
  无 preload 时(浏览器直连 /display)回落到文档级监听

三、报名表 CSV 编码兼容(用户报「解析中文字符遇到编码问题」)
- 服务端不再把原始字节直接喂给 csv.Reader:先摘 UTF-8 BOM,再认 UTF-16 LE/BE
  BOM,其余非合法 UTF-8 按 GB18030 解(GBK 是其子集)
- 修复前症状:GBK 文件(Excel 另存为 CSV 的默认编码)中文变乱码;带 BOM 的文件
  (Excel「CSV UTF-8」,也正是我们自己模板的格式)把 BOM 拼进第一列表头,
  报「报名表缺少列 event_code」——连「下载模板→填→传回来」都走不通
- golang.org/x/text 由 indirect 提升为直接依赖,未新增外部依赖

测试:服务端 12 包全绿(新增 fonts_test.go / fonts_internal_test.go /
handlers_import_encoding_test.go);前端 vue-tsc 通过、pnpm build 通过、157 例全过;
Console 语法检查 17 个文件通过。docs/ 与 devdocs/ 同步更新。
2026-09-10 17:45:11 +08:00

533 lines
17 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,
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<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[]>([])
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
/** 上一次见过的「服务端持久化公告」,用来判断设置里的值是真的变了。 */
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),
),
)
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: 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<void> {
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<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(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<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
}, 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<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 },
)
/**
* 服务端把当前公告持久化在设置里(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<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)
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,
}
}