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
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user