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:
1 parent
e5e0944d26
commit
08a5740fb2
64 files changed
+4968
-270
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user