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

+150
View File
@@ -0,0 +1,150 @@
import type { EventResultGroup, EventResultRow } from '@/shared'
/**
* 实时赛况页的纯逻辑:谁上屏、怎么分栏、栏内排哪些行、什么时候该发下一次请求。
*
* 页面与测试共用这一份判定。全部是纯函数,不依赖 Vue;「当前是哪个项目」由服务端判定
* (GET /api/display/live,见 server/internal/httpapi/live_events.go),大屏只负责渲染。
*/
/** 一屏最多并排几个项目;与服务端的 MaxLiveEvents 一致。 */
export const MAX_LIVE_COLUMNS = 2
/**
* 一栏最多列几个名次。
*
* 行高由 flex 均分、字号按行数缩,名次一多字就小到看不清,索性截断;
* 实时赛况看的是领先的几行,完整名次该由分项成绩页承担。
*/
export const MAX_LIVE_ROWS = 10
/**
* 定时兜底刷新间隔。
*
* 成绩发布有 WS 快照推送,这条定时只负责「靠时间自己过期」的那部分——项目切换的依据是
* scheduled_at 落在 ±30 分钟窗口内、以及最近一次发布,快照不会为时间流逝推送。
* 30s 把切换延迟压在人的感知以内,同时每块屏只有 2 次/分钟的读请求;
* 比 30 分钟窗口小两个数量级,再密只是白打服务端。
*/
export const LIVE_REFRESH_INTERVAL_MS = 30_000
/**
* 两次请求之间的最小间隔。
*
* 三种触发(挂载 / WS 快照 / 定时)撞在一起时靠它合并:落在间隔内的触发只登记一次尾随刷新,
* 既不会同一秒打两次,也不会漏掉这段时间里发布的新成绩。
*/
export const LIVE_REFRESH_MIN_GAP_MS = 4_000
/** 一个「当前项目」的原始素材:小项信息 + 它已发布的全部组别。 */
export interface LiveEventSource {
eventId: number
eventName: string
/** 已发布成绩按「小项 + 组别」分好的组;还没发布过任何成绩时为空数组。 */
groups: readonly EventResultGroup[]
}
export interface LiveColumn {
eventId: number
/** 小项名;拿不到名字时退回「项目 #id」,不留空白。 */
title: string
/** 主栏那一组的组别名;没有成绩时为空串。 */
category: string
/** 同一项目还有几组别没进这一栏(只取最近发布的一组当主栏)。 */
hiddenGroups: number
/** 栏内的名次行:名次升序、无名次排最后、已截断到 MAX_LIVE_ROWS。 */
rows: EventResultRow[]
}
/**
* 接口判定出的「当前项目」→ 屏幕上要画的栏。
*
* 给了几个就画几栏(最多 MAX_LIVE_COLUMNS),顺序照服务端的来;没有项目时返回空数组,
* 页面显示空态。项目还没发布成绩时仍然保留这一栏(rows 为空),由页面显示占位——
* 少一栏会让剩下的一栏铺满全屏,现场看不出另一个项目其实没数据。
*/
export function pickLiveColumns(events: readonly LiveEventSource[]): LiveColumn[] {
const columns: LiveColumn[] = []
for (const item of events) {
if (!Number.isInteger(item.eventId) || item.eventId <= 0) continue
if (columns.some((column) => column.eventId === item.eventId)) continue
const group = pickLatestGroup(item.groups)
columns.push({
eventId: item.eventId,
title: item.eventName.trim() === '' ? `项目 #${item.eventId}` : item.eventName,
category: group?.categoryName ?? '',
hiddenGroups: item.groups.length > 1 ? item.groups.length - 1 : 0,
rows: liveRows(group),
})
if (columns.length >= MAX_LIVE_COLUMNS) break
}
return columns
}
/**
* 接口还没成功过时的兜底素材:人工指定的 id + 快照 latestResults 里能拿到的组。
*
* 只在老服务端(没有 /api/display/live)、断网首屏、示例数据这三种情况下走这条路;
* 快照的 latestResults 受 20 行上限约束,所以它只能算「有总比没有强」。
* 快照里没有该项目时保留这一栏(groups 为空),由页面显示占位而不是静默少一栏。
*/
export function fallbackLiveSources(
ids: readonly number[],
available: readonly EventResultGroup[],
eventNames: ReadonlyMap<number, string> = new Map(),
): LiveEventSource[] {
return ids.map((eventId) => ({
eventId,
eventName: eventNames.get(eventId) ?? '',
groups: available.filter((group) => group.eventId === eventId),
}))
}
/**
* 距离下一次可以发请求还要等多久(毫秒);0 表示现在就能发。
*
* 纯算术,抽出来是为了让「合并去重」这条规则能被测试覆盖;lastAttemptAt 为 0(还没发过)
* 时一定返回 0,页面挂载后立刻就能拿到结论。
*/
export function liveRefreshDelay(
lastAttemptAt: number,
now: number,
minGapMs: number = LIVE_REFRESH_MIN_GAP_MS,
): number {
if (lastAttemptAt <= 0) return 0
return Math.max(0, minGapMs - (now - lastAttemptAt))
}
/** 同一小项分了多个组别时取最近发布的一组;发布时间缺失按最旧算。 */
function pickLatestGroup(groups: readonly EventResultGroup[]): EventResultGroup | null {
let latest: EventResultGroup | null = null
for (const group of groups) {
if (latest === null || publishedAtMs(group) > publishedAtMs(latest)) latest = group
}
return latest
}
function publishedAtMs(group: EventResultGroup): number {
if (group.publishedAt === null) return 0
const parsed = Date.parse(group.publishedAt)
return Number.isFinite(parsed) ? parsed : 0
}
/**
* 栏内的名次行:按名次升序,无名次的排最后,同名次按录入顺序。
*
* 服务端下发前已经排过一次,但本地缓存 / 示例数据不保证顺序,所以这里统一收口;
* 顺带截断到 MAX_LIVE_ROWS。
*/
export function liveRows(group: EventResultGroup | null): EventResultRow[] {
if (group === null) return []
return [...group.rows]
.sort((a, b) => {
if (a.rank === null && b.rank === null) return a.entryId - b.entryId
if (a.rank === null) return 1
if (b.rank === null) return -1
return a.rank - b.rank || a.entryId - b.entryId
})
.slice(0, MAX_LIVE_ROWS)
}