Files
Scoreboard/web/src/display/live.ts
T
AskaEth 56ed90ff8b fix(display,entry): 修大屏团体名次显示「-」与分项成绩第 3 栏缺第 1 名;发布改弹窗选口径;会话设备名与重命名;录分搜索框;版本 dev_0.5.15
本批七项(三处是现场报的 bug,四处是新功能):

① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」
   根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成
   「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底
   (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。
   改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。
   ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次,
   若通道名次优先,个人项目会在那种发布之后显示旧名次。
   ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。
   ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。

② 分项成绩页第 3 栏看不到第 1 名
   根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏,
   切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉
   (手录项目从第 1 名往下录 → 第 1 名建号最早)。
   改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行);
   组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。
   ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行);
   将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。

③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起
   多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度
   (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。
   取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。

④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一
   默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次;
   Alt+Enter 走同一个入口;取消 = 什么都不发生。
   审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。

⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目
   - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报,
     服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。
     ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。
   - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的
     display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签;
     显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示,
     不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。
   - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目
     (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。
     现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。

⑥ 局域网地址条目都能出二维码
   以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示,
   再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG
   (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。

⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲)
   复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、
   全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」;
   × / Esc / 清除三种清空方式;搜不到给明确空状态。
   ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、
      预览算分一律不受影响,界面上也写明了这一点。
   ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩
      静默写到看不见的人身上。

测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok,
EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状
「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium)
实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。

无数据库迁移;已有成绩、名次、积分与榜单不变。
文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法;
《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步;
devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。
2026-09-17 18:19:38 +08:00

190 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { EventResultGroup, EventResultRow } from '@/shared'
import type { LiveLayout } from './fitFont'
/**
* 实时赛况页的纯逻辑:谁上屏、怎么分栏、栏内排哪些行、什么时候该发下一次请求。
*
* 页面与测试共用这一份判定。全部是纯函数,不依赖 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
/**
* 单项目一栏时,行数到几行就把名次拆成左右两块并排。
*
* 阈值取 7 是因为它正好是「拆开之后字更大」的临界点:字号按 BOARD_AVAILABLE_PX 与高度比例
* 0.62 算(见 fitFont.ts),一张表铺满时 7 行只剩 49px(低于半屏版式的 56px 上限),而 6 行
* 还有 57px。7 行起拆成两块(每块 ≤5 行)换来的字号永远更大,再往下(8/9/10 行)差距越拉越大;
* fitFont.test.ts 里有一条测试盯着这个临界点。
*/
export const LIVE_SPLIT_MIN_ROWS = 7
/**
* 定时兜底刷新间隔。
*
* 成绩发布有 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 窗口是**每组保底前 10 行、最多 8 组**(`realtime/snapshot.go` 的
* `latestResultsGroupLimit` / `latestResultsRowsPerGroup`),所以它只能算「有总比没有强」。
* 快照里没有该项目时保留这一栏(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))
}
/**
* 单栏拆几块:只有「一个项目 + 行数够多」才拆,两个项目时本来就是一栏一块。
*/
export function liveBoardLayout(columns: number, rowCount: number): LiveLayout {
if (columns > 1) return 'duo'
return rowCount >= LIVE_SPLIT_MIN_ROWS ? 'split' : 'single'
}
/**
* 字号要按「一块里有几行」算,而不是整栏几行——拆两块的意义就在这里。
*/
export function liveBlockRowCount(rowCount: number, layout: LiveLayout): number {
return layout === 'split' ? Math.ceil(rowCount / 2) : rowCount
}
/**
* 把一栏的名次切成左右两块:上半(名次靠前)在左,下半在右。
*
* 前一半多一行(7 行 → 4 + 3),让更重要的名次留在左块;两块的行数最多差 1 行,
* 行高才不会一块紧一块松。非 split 版式原样返回单块。
*/
export function liveRowBlocks<T>(rows: readonly T[], layout: LiveLayout): T[][] {
if (layout !== 'split' || rows.length < LIVE_SPLIT_MIN_ROWS) return [rows as T[]]
const half = Math.ceil(rows.length / 2)
return [rows.slice(0, half), rows.slice(half)]
}
/** 同一小项分了多个组别时取最近发布的一组;发布时间缺失按最旧算。 */
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)
}