feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相

## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发)
- 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、
  手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。
- 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错
  (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。

## 实时赛况(新大屏页 + 定向接口)
- 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩
  (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。
  设置键 display.live_events / display.live_source。
- 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面
  (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。
- 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only),
  故对非管理员整组置灰 + 提示条(不新增角色)。

## 显式批次层(组别之下)
- 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果
  完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 +
  一键均分(按 N 人切片,已有批次号接着往后排)。

## 成绩输入框实时悬浮提示
- 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻;
  合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文;
  与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。

## 窄屏 / 手机端布局
- 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针,
  四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框
  塌成 22px 这类真问题。

## 版本号单一真相
- 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json
  的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。

测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
This commit is contained in:
AskaEth committed 2026-09-11 04:05:02 +08:00
1 parent e5e0944d26
commit 08a5740fb2
64 files changed
+4968 -270

No files matched your search

+98 -3
View File
@@ -4,21 +4,23 @@ import type {
AthleteStanding,
Change,
DisplayCommand,
Event,
EventResultGroup,
RealtimeStatus,
SnapshotMessage,
StandingRow,
} from '@/shared'
import {
parseLiveFeed,
parseSettings,
parseSnapshot,
parseStandingRow,
parseStandingRows,
parseTeamBreakdown,
} from './guards'
import type { DisplaySettings } 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'
@@ -45,6 +47,17 @@ 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)
@@ -84,6 +97,11 @@ 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 = ''
@@ -143,9 +161,10 @@ function applyStandings(rows: StandingRow[]): void {
}, RANK_DELTA_MS)
}
function applySnapshot(snapshot: SnapshotMessage, kind: DisplaySource): void {
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
@@ -203,6 +222,73 @@ async function refreshAthletes(): Promise<void> {
}
}
/**
* 记一条拉取失败的日志:只在「刚变坏」时记。
*
* 服务端长时间不可用时定时轮询会一直失败,每次都记会把日志刷成噪声;
* 现场需要的是「什么时候开始坏、什么时候恢复」,中间的重复失败没有信息量。
*/
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()
}
}
}
// ---------------------------------------------------------------------------
// 断线兜底与启动
// ---------------------------------------------------------------------------
@@ -243,6 +329,7 @@ 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'
@@ -450,6 +537,8 @@ function connect(): void {
writeCachedSnapshot(snapshot)
// 个人榜不在快照里,每次拿到快照(含 resync 结果)都单独补拉一次。
void refreshAthletes()
// 当前项目的判定依赖「最近一次发布」,成绩一发布就要重算;快照就是发布的信号。
void refreshLiveEvents()
},
onCatchup: (message) => {
applyChanges(message.changes)
@@ -498,6 +587,8 @@ function dispose(): void {
stopPageRotation()
clearDetailTimer()
rankTimer = clearTimer(rankTimer)
liveQueueTimer = clearTimer(liveQueueTimer)
liveQueued = false
announcementStore.dispose()
client?.close()
client = null
@@ -521,6 +612,9 @@ export function useScoreboard() {
standings,
athletes,
latestResults,
events,
liveFeed,
liveStale,
page,
authRequired,
options,
@@ -530,6 +624,7 @@ export function useScoreboard() {
booted,
bootstrap,
dispose,
refreshLiveEvents,
rankDeltaFor,
}
}