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:
1 parent
96cf181ae3
commit
13f1f03cd0
124 files changed
+5658
-1820
No files matched your search
+16
-5
@@ -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]">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* 榜单主体可用高度:1920×1080 舞台扣掉顶栏 / 页框标题 / 底栏后的经验值。
|
||||
* 三张榜单页共用,避免各写一个 560 导致布局一改就错位。
|
||||
*/
|
||||
export const BOARD_AVAILABLE_PX = 560
|
||||
|
||||
/**
|
||||
* 行数不可控,字号必须跟着算,否则单位一多就会把最后一行顶到轮播条下面。
|
||||
*
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user