fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)

P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-09 22:16:58 +08:00
1 parent 96cf181ae3
commit 13f1f03cd0
124 files changed
+5658 -1820

No files matched your search

+16 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Component } from 'vue'
import { mix, parseHexColor, readableForeground, toCss } from './color'
import type { DisplayPageKey } from './types'
@@ -26,10 +26,13 @@ const PAGES: Record<DisplayPageKey, Component> = {
const { bootstrap, dispose, page, settings } = useScoreboard()
// 单屏窗口化才有标题栏;kiosk 下 windowed 为 false,下面所有相关分支都不生效
// 单屏窗口化才有标题栏;kiosk 下 windowed 为 false,下面所有相关分支都不生效。
const windowed = isWindowed()
const { scale, offsetX, offsetY } = useStageScale({
topInset: () => (windowed ? WINDOWED_TITLEBAR_HEIGHT : 0),
// Console 的 F11 由主进程切换全屏:全屏时自绘标题栏必须让位,否则舞台顶部被多扣 50px。
const fullscreen = ref(document.fullscreenElement !== null)
const showTitleBar = computed(() => windowed && !fullscreen.value)
const { scale, offsetX, offsetY, fit } = useStageScale({
topInset: () => (showTitleBar.value ? WINDOWED_TITLEBAR_HEIGHT : 0),
})
const currentPage = computed<Component>(() => PAGES[page.value])
@@ -71,12 +74,20 @@ const themeVars = computed<Record<string, string>>(() => {
const RELOAD_MS = 24 * 60 * 60 * 1000
let reloadTimer = 0
function onFullscreenChange(): void {
fullscreen.value = document.fullscreenElement !== null
// 标题栏出现/消失会改变可用高度,必须重新算缩放(fullscreenchange 不保证带 resize)。
fit()
}
onMounted(() => {
document.addEventListener('fullscreenchange', onFullscreenChange)
void bootstrap()
reloadTimer = window.setTimeout(() => window.location.reload(), RELOAD_MS)
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange)
window.clearTimeout(reloadTimer)
dispose()
})
@@ -84,7 +95,7 @@ onUnmounted(() => {
<template>
<div class="root" :style="rootStyle">
<WindowedTitleBar v-if="windowed" :title="barTitle" :logo="barLogo" />
<WindowedTitleBar v-if="showTitleBar" :title="barTitle" :logo="barLogo" />
<div class="screen">
<div class="stage" :style="[stageStyle, themeVars]">
+9 -2
View File
@@ -2,15 +2,17 @@
import { computed } from 'vue'
import { useScoreboard } from '../useScoreboard'
const { booted, status, source, version } = useScoreboard()
const { booted, status, source, version, authRequired } = useScoreboard()
const offline = computed(() => booted.value && status.value !== 'online')
// connecting 是启动瞬间的正常状态,闪「连接中断」会误报;只有真正断开才提示。
const offline = computed(() => booted.value && status.value === 'offline')
const mock = computed(() => source.value === 'mock')
</script>
<template>
<div class="badges">
<span v-if="mock" class="badge mock">示例数据</span>
<span v-if="authRequired" class="badge auth">登录已失效 · 请重新登录</span>
<span v-if="offline" class="badge offline">⚠ 连接中断 · 数据可能不是最新</span>
<span v-if="version !== null" class="badge version">v{{ version }}</span>
</div>
@@ -43,6 +45,11 @@ const mock = computed(() => source.value === 'mock')
border-color: rgba(255, 215, 94, 0.5);
color: var(--gold);
}
.auth {
background: rgba(255, 160, 64, 0.16);
border-color: rgba(255, 160, 64, 0.55);
color: #ffd0a0;
}
.version {
background: rgba(255, 255, 255, 0.06);
color: var(--text-dim);
+4 -7
View File
@@ -11,11 +11,8 @@ interface TickerItem {
const { standings, latestResults, announcement, detail, settings } = useScoreboard()
const topMedals = computed(() =>
[...standings.value]
.sort((a, b) => b.gold - a.gold || b.silver - a.silver || b.bronze - a.bronze)
.slice(0, 3),
)
// 与团体奖牌榜页同一口径:按服务端名次(总分优先)取前 3,不在前端按金→银→铜重排。
const topMedals = computed(() => standings.value.slice(0, 3))
const latestGroup = computed(() => latestResults.value[0] ?? null)
@@ -60,8 +57,8 @@ onUnmounted(() => window.clearInterval(timer))
<div class="viewport">
<div v-if="current" :key="current.key" class="item">
<template v-if="current.key === 'medals'">
<div v-for="(row, i) in topMedals" :key="row.teamId" class="chip">
<span class="chip-rank">{{ i + 1 }}</span>
<div v-for="row in topMedals" :key="row.teamId" class="chip">
<span class="chip-rank">{{ row.rank }}</span>
<span class="chip-name">{{ row.shortName ?? row.teamName }}</span>
<span class="chip-stat gold">金 {{ row.gold }}</span>
<span class="chip-stat silver">银 {{ row.silver }}</span>
@@ -1,17 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { fitRowFont } from '../../fitFont'
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
import { useScoreboard } from '../../useScoreboard'
import AnimatedNumber from '../AnimatedNumber.vue'
import PageFrame from '../PageFrame.vue'
const { athletes, settings } = useScoreboard()
const rows = computed(() => athletes.value)
/** 个人榜行数不可控:只取前 N 名,其余用一行提示,避免整页文字重叠。 */
const MAX_ROWS = 15
const visibleRows = computed(() => athletes.value.slice(0, MAX_ROWS))
const hiddenCount = computed(() => Math.max(0, athletes.value.length - MAX_ROWS))
// 奖牌列由独立开关控制;缺字段时的回退已在 guards.parseSettings 里按「跟随个人榜上屏」处理。
const showMedals = computed(() => settings.value?.standings.showIndividualMedals ?? true)
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
// 「其余略」也占一行,字号按实际渲染行数算。
const rowFont = computed(() =>
fitRowFont(visibleRows.value.length + (hiddenCount.value > 0 ? 1 : 0), BOARD_AVAILABLE_PX),
)
</script>
<template>
@@ -28,7 +34,7 @@ const rowFont = computed(() => fitRowFont(rows.value.length, 560))
<span class="medal bronze">铜</span>
</template>
</div>
<div v-for="row in rows" :key="row.athleteId" class="row">
<div v-for="row in visibleRows" :key="row.athleteId" class="row">
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
<span class="name">{{ row.athleteName }}</span>
<span class="team">{{ row.shortName ?? row.teamName }}</span>
@@ -39,7 +45,8 @@ const rowFont = computed(() => fitRowFont(rows.value.length, 560))
<span class="medal bronze">{{ row.bronze }}</span>
</template>
</div>
<div v-if="rows.length === 0" class="empty">暂无个人积分数据</div>
<div v-if="hiddenCount > 0" class="more">其余 {{ hiddenCount }} 人略</div>
<div v-if="visibleRows.length === 0" class="empty">暂无个人积分数据</div>
</div>
</PageFrame>
</template>
@@ -62,6 +69,7 @@ const rowFont = computed(() => fitRowFont(rows.value.length, 560))
padding: 0 18px;
font-size: var(--row-font, 42px);
line-height: 1.1;
overflow: hidden;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.row:last-child {
@@ -122,6 +130,14 @@ const rowFont = computed(() => fitRowFont(rows.value.length, 560))
.bronze {
color: #e0995e;
}
.more {
flex: none;
padding: 6px 18px 0;
font-size: 20px;
letter-spacing: 2px;
color: var(--text-dim);
text-align: right;
}
.empty {
flex: 1;
display: grid;
@@ -7,7 +7,7 @@ import PageFrame from '../PageFrame.vue'
const { latestResults, options } = useScoreboard()
// 数据口径:state='published' 且 status='finished',按发布时间倒序取前 N 个(06 第 5 节)。
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount))
const STATUS_LABELS: Record<ResultStatus, string> = {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatRankChange } from '@/shared'
import { fitRowFont } from '../../fitFont'
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
import { useScoreboard } from '../../useScoreboard'
import AnimatedNumber from '../AnimatedNumber.vue'
import PageFrame from '../PageFrame.vue'
@@ -11,7 +11,7 @@ const { standings, rankDeltaFor } = useScoreboard()
const rows = computed(() => standings.value)
// 行高由 flex 均分,字号按行数算,单位再多也不会顶到轮播条下面。
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX))
</script>
<template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { fitRowFont } from '../../fitFont'
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
@@ -9,7 +9,7 @@ const { standings } = useScoreboard()
// 排序与名次都取服务端口径(总分优先),前端不再按金→银→铜重排,否则名次会与总积分榜打架。
const rows = computed(() => standings.value)
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX))
</script>
<template>
+6
View File
@@ -1,3 +1,9 @@
/**
* 榜单主体可用高度:1920×1080 舞台扣掉顶栏 / 页框标题 / 底栏后的经验值。
* 三张榜单页共用,避免各写一个 560 导致布局一改就错位。
*/
export const BOARD_AVAILABLE_PX = 560
/**
* 行数不可控,字号必须跟着算,否则单位一多就会把最后一行顶到轮播条下面。
*
+2 -2
View File
@@ -304,8 +304,8 @@ export type DisplaySettings = EventSettings
/**
* 嵌套路径取值。
*
* REST 的 `/api/settings` 是嵌套对象,WS 快照却是 `display.rotate_seconds`
* 这类点号扁平键;同一份配置两种形态都要认,故先走嵌套路径再退回扁平键。
* 当前 REST 与 WS 快照都是嵌套对象;`display.rotate_seconds` 这类点号扁平键是
* 旧版本地缓存 / settings 表的遗留形态,先走嵌套路径再退回扁平键,升级后首屏仍能读到配置。
*/
function settingValue(
source: Record<string, unknown>,
+2 -5
View File
@@ -22,7 +22,6 @@ const TEAM_NAMES: readonly string[] = [
'第四分公司',
'第五分公司',
'第六分公司',
'技术研究院',
]
function team(index: number, code: string): Team {
@@ -47,7 +46,6 @@ const MOCK_TEAMS: Team[] = [
team(4, 'T05'),
team(5, 'T06'),
team(6, 'T07'),
team(7, 'T08'),
]
interface StandingSeed {
@@ -67,7 +65,6 @@ const STANDING_SEEDS: readonly StandingSeed[] = [
{ rank: 5, totalPoints: 127, gold: 3, silver: 4, bronze: 5, rankChange: null },
{ rank: 6, totalPoints: 103, gold: 2, silver: 3, bronze: 4, rankChange: 0 },
{ rank: 7, totalPoints: 95, gold: 2, silver: 2, bronze: 2, rankChange: -2 },
{ rank: 8, totalPoints: 78, gold: 1, silver: 1, bronze: 3, rankChange: 0 },
]
function buildStandings(): StandingRow[] {
@@ -185,7 +182,7 @@ function buildLatestResults(): EventResultGroup[] {
{ rank: 3, teamIndex: 3, athlete: '周敏', resultText: '13.36', resultValue: 13.36 },
{ rank: 4, teamIndex: 6, athlete: '吴倩', resultText: '13.48', resultValue: 13.48 },
{ rank: 5, teamIndex: 2, athlete: '郑爽', resultText: '13.55', resultValue: 13.55 },
{ rank: 6, teamIndex: 7, athlete: '冯雪', resultText: '13.62', resultValue: 13.62 },
{ rank: 6, teamIndex: 6, athlete: '冯雪', resultText: '13.62', resultValue: 13.62 },
],
's',
),
@@ -202,7 +199,7 @@ function buildLatestResults(): EventResultGroup[] {
{ rank: 1, teamIndex: 2, athlete: '黄涛', resultText: '6.42', resultValue: 6.42 },
{ rank: 2, teamIndex: 5, athlete: '徐斌', resultText: '6.18', resultValue: 6.18 },
{ rank: 3, teamIndex: 0, athlete: '马超', resultText: '6.05', resultValue: 6.05 },
{ rank: 4, teamIndex: 7, athlete: '朱峰', resultText: '5.96', resultValue: 5.96 },
{ rank: 4, teamIndex: 3, athlete: '朱峰', resultText: '5.96', resultValue: 5.96 },
{ rank: 5, teamIndex: 1, athlete: '胡军', resultText: '5.88', resultValue: 5.88 },
{ rank: 6, teamIndex: 4, athlete: '林凯', resultText: '5.71', resultValue: 5.71 },
],
+7 -1
View File
@@ -10,6 +10,9 @@ import { isRecord, num, parseSnapshot } from './guards'
const SNAPSHOT_KEY = 'scoreboard:display:snapshot'
/** 缓存时效:一届赛事一天办完,隔夜快照只会在开屏瞬间显示上一场的错数据。 */
const SNAPSHOT_TTL_MS = 12 * 60 * 60 * 1000
export interface CachedSnapshot {
version: number
savedAt: number
@@ -24,7 +27,10 @@ export function readCachedSnapshot(): CachedSnapshot | null {
if (!isRecord(parsed)) return null
const snapshot = parseSnapshot(parsed.snapshot)
if (snapshot === null) return null
return { version: snapshot.version, savedAt: num(parsed.savedAt), snapshot }
const savedAt = num(parsed.savedAt)
// savedAt 缺失或过期都按无缓存处理;过期的版本号也不再作为 hello 的基准。
if (savedAt <= 0 || Date.now() - savedAt > SNAPSHOT_TTL_MS) return null
return { version: snapshot.version, savedAt, snapshot }
} catch {
// 隐私模式或配额异常时静默降级:缓存只是兜底,不能因此不出屏。
return null
+15
View File
@@ -0,0 +1,15 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { BOARD_AVAILABLE_PX, fitRowFont } from '../fitFont'
test('fitRowFont 空列表用固定字号', () => {
assert.equal(fitRowFont(0, BOARD_AVAILABLE_PX), '48px')
})
test('fitRowFont 行数少时封顶 56px', () => {
assert.equal(fitRowFont(1, BOARD_AVAILABLE_PX), '56px')
})
test('fitRowFont 行数多时守住 16px 下限', () => {
assert.equal(fitRowFont(100, BOARD_AVAILABLE_PX), '16px')
})
-8
View File
@@ -13,14 +13,6 @@ export const DISPLAY_PAGES = [
export type DisplayPageKey = (typeof DISPLAY_PAGES)[number]['key']
export const PAGE_TITLES: Record<DisplayPageKey, string> = {
standings: '总积分榜',
'team-medals': '团体奖牌榜',
'athlete-standings': '个人积分奖牌榜',
'event-results': '分项成绩',
announcement: '公告',
}
/** 管理端 `goto` 下发的页名不保证与内部 key 一致,这里统一归一化。 */
const PAGE_ALIASES: Record<string, DisplayPageKey> = {
standings: 'standings',
+80 -103
View File
@@ -1,29 +1,19 @@
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { RealtimeClient, createApiClient } from '@/shared'
import type {
AthleteStanding,
Change,
DisplayCommand,
Event,
EventResultGroup,
RealtimeStatus,
RecordEntry,
SnapshotMessage,
StandingRow,
Team,
} from '@/shared'
import {
isRecord,
list,
numOrNull,
parseAthleteStandings,
parseEvent,
parseEventResultGroups,
parseSettings,
parseSnapshot,
parseStandingRow,
parseStandingRows,
parseTeam,
parseTeamBreakdown,
} from './guards'
import type { DisplaySettings } from './guards'
@@ -51,15 +41,13 @@ const source = ref<DisplaySource>('live')
const status = ref<RealtimeStatus>('idle')
const version = ref<number | null>(null)
const settings = ref<DisplaySettings | null>(null)
const teams = ref<Team[]>([])
const events = ref<Event[]>([])
const standings = ref<StandingRow[]>([])
const athletes = ref<AthleteStanding[]>([])
const latestResults = ref<EventResultGroup[]>([])
const records = ref<RecordEntry[]>([])
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,
@@ -76,6 +64,8 @@ let rankTimer: ReturnType<typeof setTimeout> | null = null
let detailTimer: ReturnType<typeof setTimeout> | null = null
let announceTimer: ReturnType<typeof setTimeout> | null = null
let pageTimer: ReturnType<typeof setInterval> | null = null
let announceReturnPage: DisplayPageKey | null = null
let athletesLoading = false
const showAthleteBoard = computed(() => settings.value?.standings.showIndividualBoard ?? true)
@@ -95,6 +85,12 @@ function clearTimer(timer: ReturnType<typeof setTimeout> | null): null {
return null
}
// 翻页(含整页轮播)后把当前页同步给服务端,管理端实例看板才不会一直显示旧页。
// 模块级单例与页面同生命周期,不需要随组件卸载停表。
watch(page, (next) => {
client?.setPage(next)
})
// ---------------------------------------------------------------------------
// 数据落地
// ---------------------------------------------------------------------------
@@ -129,31 +125,12 @@ function applyStandings(rows: StandingRow[]): void {
function applySnapshot(snapshot: SnapshotMessage, kind: DisplaySource): void {
settings.value = snapshot.settings
teams.value = snapshot.teams
events.value = snapshot.events
latestResults.value = snapshot.latestResults
records.value = snapshot.records
version.value = snapshot.version
applyStandings(snapshot.standings)
source.value = kind
}
function mergeById<T extends { id: number }>(
current: T[],
change: Change,
parse: (value: unknown) => T,
): T[] {
if (change.action === 'replace') return list(change.data).map(parse)
if (change.action === 'void') {
const id = change.id ?? (isRecord(change.data) ? numOrNull(change.data.id) : null)
return id === null ? current : current.filter((item) => item.id !== id)
}
const item = parse(change.data)
return [...current.filter((entry) => entry.id !== item.id), item]
}
function mergeStanding(row: StandingRow): void {
applyStandings([...standings.value.filter((item) => item.teamId !== row.teamId), row])
}
@@ -161,54 +138,51 @@ function mergeStanding(row: StandingRow): void {
/**
* 应用增量变更。
*
* 只有榜单类实体能在客户端可靠合并;其余实体(如逐条成绩)合并规则在服务端,
* 只有总积分榜能在客户端可靠合并;其余实体(逐条成绩、分项成绩等)的合并规则在服务端,
* 这里直接 resync 拉全量——宁可多拉一次,也不允许大屏上出现算错的分数。
* 个人榜不在快照 / 增量里,总榜一变就单独重拉一次,否则它会停在开屏那一刻。
*/
function applyChanges(changes: Change[]): void {
let resyncNeeded = false
let standingsTouched = false
for (const change of changes) {
switch (change.entity) {
case 'standings':
if (change.action === 'replace') {
// 服务端当前下发的是 {eventId:N} 这类定位信息而不是榜单数组;
// 直接当空数组用会把大屏清空,所以忽略并拉全量。
if (!Array.isArray(change.data)) {
resyncNeeded = true
break
}
applyStandings(parseStandingRows(change.data))
} else if (change.action === 'upsert') {
mergeStanding(parseStandingRow(change.data))
}
break
case 'athleteStandings':
case 'athlete_standings':
if (change.action === 'replace') athletes.value = parseAthleteStandings(change.data)
break
case 'latestResults':
case 'eventResults':
if (change.action === 'replace') {
latestResults.value = parseEventResultGroups(change.data, teams.value)
}
break
case 'settings':
if (change.action !== 'void') settings.value = parseSettings(change.data)
break
case 'teams':
teams.value = mergeById(teams.value, change, parseTeam)
break
case 'events':
events.value = mergeById(events.value, change, parseEvent)
break
default:
resyncNeeded = true
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
}
}
// ---------------------------------------------------------------------------
// 断线兜底与启动
// ---------------------------------------------------------------------------
@@ -220,23 +194,20 @@ function hydrateFromCache(): void {
}
/**
* REST 兜底:快照不含个人榜,所以个人榜必须单独取;
* 但快照比 REST 权威(REST 没有 version),已收到快照时不用 REST 覆盖。
* REST 兜底。
*
* 大屏不请求 /api/settings(admin-only,guest 必然 403),配置只认 WS 快照与本地缓存;
* 快照比 REST 权威(REST 没有 version),且请求飞行途中可能已有快照到达,
* 所以是否套用 REST 必须在 await 之后重新判断,不能沿用发请求前的结论。
*/
async function loadRest(): Promise<boolean> {
const hasSnapshot = version.value !== null
const [settingsResult, standingsResult, athletesResult] = await Promise.allSettled([
api.settings.get(),
const [standingsResult, athletesResult] = await Promise.allSettled([
api.standings.teams(),
api.standings.athletes(),
])
const hasSnapshot = version.value !== null
let ok = false
if (settingsResult.status === 'fulfilled' && !hasSnapshot) {
// REST 载荷同样过解析层,保证嵌套结构与默认值和大屏其余入口一致。
settings.value = parseSettings(settingsResult.value)
ok = true
}
if (standingsResult.status === 'fulfilled' && !hasSnapshot) {
applyStandings(standingsResult.value)
ok = true
@@ -251,10 +222,7 @@ async function loadRest(): Promise<boolean> {
function activateMock(): void {
const snapshot = createMockSnapshot()
settings.value = parseSettings(snapshot.settings)
teams.value = snapshot.teams
events.value = snapshot.events
latestResults.value = snapshot.latestResults
records.value = snapshot.records
athletes.value = createMockAthletes()
standings.value = snapshot.standings
source.value = 'mock'
@@ -281,7 +249,8 @@ function readUrlOptions(): DisplayUrlOptions {
locked: lockParam !== '0' && lockParam !== 'false',
screen: params.get('screen'),
instance: params.get('instance'),
resultsCount: clampInt(params.get('n'), 1, 6, 3),
// 分项成绩每组最小宽度约 450px,5 组起就超出 1920 舞台,上限压到 4。
resultsCount: clampInt(params.get('n'), 1, 4, 3),
}
}
@@ -305,7 +274,8 @@ function stopPageRotation(): void {
function startPageRotation(): void {
stopPageRotation()
if (locked.value || reducedMotion.value) return
// 公告不在轮播页清单里,轮播会把公告切走;公告期间一律不启动。
if (locked.value || reducedMotion.value || page.value === 'announcement') return
const order = rotationPages.value
if (order.length < 2) return
@@ -316,10 +286,6 @@ function startPageRotation(): void {
}, PAGE_ROTATE_MS)
}
function setPage(next: DisplayPageKey): void {
page.value = next
}
function setLocked(value: boolean): void {
locked.value = value
if (value) stopPageRotation()
@@ -377,15 +343,23 @@ function announce(text: string, seconds: number): void {
const trimmed = text.trim()
if (trimmed === '') return
// 第二条公告到来时 page 已经是 announcement,必须复用「进入公告前的真实页」,
// 否则返回页会被记成公告页自身,计时结束后永远停在公告页。
if (page.value !== 'announcement') announceReturnPage = page.value
const returnPage = announceReturnPage ?? 'standings'
announcement.value = { text: trimmed, seconds, startedAt: Date.now() }
const previous = page.value
page.value = 'announcement'
// 公告不在轮播页清单里,20s 整页轮播会把它切走;公告期间必须停轮播。
stopPageRotation()
announceTimer = clearTimer(announceTimer)
if (seconds > 0) {
announceTimer = setTimeout(() => {
announceTimer = null
if (page.value === 'announcement') page.value = previous
if (page.value === 'announcement') page.value = returnPage
announceReturnPage = null
if (!locked.value) startPageRotation()
}, seconds * 1000)
}
}
@@ -397,6 +371,8 @@ function handleCommand(command: DisplayCommand): void {
if (target !== null) {
closeDetail()
page.value = target
// 公告期间轮播是停着的,goto 抢回控制权后按锁定状态恢复。
if (!locked.value) startPageRotation()
}
break
}
@@ -430,14 +406,17 @@ function connect(): void {
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) => {
// 服务端快照的 settings 是点号扁平 map,原始载荷直写 ref 会让 display 变 undefined;
// 必须过解析层归一,解析失败则整包丢弃(心跳/重连会再给一次)。
// 快照载荷一律过解析层逐字段兜底,解析失败则整包丢弃(心跳/重连会再给一次)。
const snapshot = parseSnapshot(message)
if (snapshot === null) return
applySnapshot(snapshot, 'live')
writeCachedSnapshot(snapshot)
// 个人榜不在快照里,每次拿到快照(含 resync 结果)都单独补拉一次。
void refreshAthletes()
},
onCatchup: (message) => {
applyChanges(message.changes)
@@ -448,10 +427,18 @@ function connect(): void {
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()
}
@@ -498,27 +485,17 @@ export function useScoreboard() {
status,
version,
settings,
teams,
events,
standings,
athletes,
latestResults,
records,
rankDelta,
page,
locked,
authRequired,
options,
detail,
announcement,
booted,
rotationPages,
bootstrap,
dispose,
setPage,
setLocked,
startPageRotation,
stopPageRotation,
closeDetail,
rankDeltaFor,
}
}