三块改动,均为现场反馈:
一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 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/ 同步更新。
533 lines
17 KiB
TypeScript
533 lines
17 KiB
TypeScript
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,
|
||
}
|
||
}
|