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
@@ -14,6 +14,7 @@ import WindowedTitleBar from './components/WindowedTitleBar.vue'
|
||||
import AnnouncementPage from './components/pages/AnnouncementPage.vue'
|
||||
import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue'
|
||||
import EventResultsPage from './components/pages/EventResultsPage.vue'
|
||||
import LivePage from './components/pages/LivePage.vue'
|
||||
import StandingsPage from './components/pages/StandingsPage.vue'
|
||||
import TeamMedalsPage from './components/pages/TeamMedalsPage.vue'
|
||||
|
||||
@@ -22,6 +23,7 @@ const PAGES: Record<DisplayPageKey, Component> = {
|
||||
'team-medals': TeamMedalsPage,
|
||||
'athlete-standings': AthleteStandingsPage,
|
||||
'event-results': EventResultsPage,
|
||||
live: LivePage,
|
||||
announcement: AnnouncementPage,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatResultText } from '@/shared'
|
||||
import type { EventResultRow, ResultStatus } from '@/shared'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
import TeamLogo from '../TeamLogo.vue'
|
||||
@@ -10,26 +9,6 @@ const { latestResults, options } = useScoreboard()
|
||||
|
||||
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
|
||||
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount))
|
||||
|
||||
const STATUS_LABELS: Record<ResultStatus, string> = {
|
||||
ok: '',
|
||||
dq: 'DQ',
|
||||
dns: 'DNS',
|
||||
dnf: 'DNF',
|
||||
withdrawn: '退赛',
|
||||
}
|
||||
|
||||
function displayResult(row: EventResultRow): string {
|
||||
if (row.status !== 'ok') return STATUS_LABELS[row.status]
|
||||
if (row.resultValue === null) return row.resultText
|
||||
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
|
||||
}
|
||||
|
||||
function displayName(row: EventResultRow): string {
|
||||
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
|
||||
if (row.memberNames !== null) return row.memberNames.join(' / ')
|
||||
return row.shortName ?? row.teamName
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
|
||||
import { LIVE_REFRESH_INTERVAL_MS, fallbackLiveSources, pickLiveColumns } from '../../live'
|
||||
import type { LiveEventSource } from '../../live'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
import TeamLogo from '../TeamLogo.vue'
|
||||
|
||||
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents } = useScoreboard()
|
||||
|
||||
const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name])))
|
||||
|
||||
/**
|
||||
* 「当前项目」由服务端判定(GET /api/display/live:人工指定 → 排期命中 → 最近发布)。
|
||||
*
|
||||
* 拉到过结论就用它(不受快照 20 行截断,auto/manual 都一样);还没成功过(老服务端、断网首屏、
|
||||
* 示例数据)才退回「人工指定 + 快照分组」的兜底素材,有总比没有强。
|
||||
*/
|
||||
const sources = computed<LiveEventSource[]>(() =>
|
||||
liveFeed.value !== null
|
||||
? liveFeed.value.events
|
||||
: fallbackLiveSources(
|
||||
settings.value?.display.liveEvents ?? [],
|
||||
latestResults.value,
|
||||
eventNames.value,
|
||||
),
|
||||
)
|
||||
|
||||
const boards = computed(() => pickLiveColumns(sources.value))
|
||||
|
||||
const hint = computed(() => {
|
||||
if (boards.value.length === 0) return ''
|
||||
const base = `进行中 ${boards.value.length} 项`
|
||||
// 不显眼的降级标记:拿不到新结论时画面照旧,只在标题旁说明数据可能不是最新。
|
||||
return liveStale.value ? `${base} · 刷新中断` : base
|
||||
})
|
||||
|
||||
// 还没成功拉到过又没失败 = 第一次请求还在飞,别用「暂无项目」误导现场。
|
||||
const emptyText = computed(() =>
|
||||
liveFeed.value === null && !liveStale.value ? '正在获取当前项目…' : '暂无进行中的项目',
|
||||
)
|
||||
|
||||
// 字号取行数最多的那一栏,两栏行高才一致;两栏时每栏只有半屏宽,再收一档。
|
||||
const rowFont = computed(() => {
|
||||
const count = boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0)
|
||||
return fitRowFont(count, BOARD_AVAILABLE_PX, boards.value.length > 1 ? 0.48 : 0.62)
|
||||
})
|
||||
|
||||
// 定时兜底:项目切换靠 scheduled_at 自己过期,快照不会为时间流逝推送,所以必须有这条轮询。
|
||||
// 只在本页显示时跑(锁在别的页上时没必要打服务端);切回本页时挂载会立刻拉一次。
|
||||
let pollTimer = 0
|
||||
|
||||
onMounted(() => {
|
||||
void refreshLiveEvents()
|
||||
pollTimer = window.setInterval(() => {
|
||||
void refreshLiveEvents()
|
||||
}, LIVE_REFRESH_INTERVAL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageFrame title="实时赛况" :hint="hint">
|
||||
<div
|
||||
v-if="boards.length > 0"
|
||||
class="boards"
|
||||
:class="`boards-${boards.length}`"
|
||||
:style="{ '--row-font': rowFont }"
|
||||
>
|
||||
<article v-for="board in boards" :key="board.eventId" class="board">
|
||||
<header class="board-head">
|
||||
<h3>{{ board.title }}</h3>
|
||||
<span v-if="board.category" class="cat">{{ board.category }}</span>
|
||||
<span v-if="board.hiddenGroups > 0" class="more">另有 {{ board.hiddenGroups }} 组</span>
|
||||
</header>
|
||||
<div v-if="board.rows.length > 0" class="rows">
|
||||
<div class="row head">
|
||||
<span class="rk">名次</span>
|
||||
<span class="nm">运动员 / 队</span>
|
||||
<span class="team">单位</span>
|
||||
<span class="rs">成绩</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in board.rows"
|
||||
:key="row.entryId"
|
||||
class="row"
|
||||
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
|
||||
>
|
||||
<span class="rk">{{ row.rank ?? '—' }}</span>
|
||||
<span class="nm">{{ displayName(row) }}</span>
|
||||
<span class="team">
|
||||
<TeamLogo :path="row.logoPath" />
|
||||
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
|
||||
</span>
|
||||
<span class="rs">{{ displayResult(row) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="board-empty">该项目暂无已发布的成绩</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="empty">{{ emptyText }}</div>
|
||||
</PageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.boards {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 26px;
|
||||
}
|
||||
.board {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid var(--primary-line);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 只有一个项目时不必铺满 1920(一行字太长反而难读),居中留白与分项成绩页一致。 */
|
||||
.boards-1 .board {
|
||||
max-width: 1180px;
|
||||
}
|
||||
.board-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
h3 {
|
||||
font-size: 34px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--primary-light);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cat {
|
||||
flex: none;
|
||||
font-size: 22px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.more {
|
||||
flex: none;
|
||||
font-size: 20px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.rows {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr 300px 220px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 0 6px;
|
||||
font-size: var(--row-font, 34px);
|
||||
line-height: 1.1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
animation: row-in 0.45s ease-out both;
|
||||
}
|
||||
.boards-2 .row {
|
||||
grid-template-columns: 60px 1fr 190px 150px;
|
||||
gap: 8px;
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.head {
|
||||
flex: none;
|
||||
font-size: 22px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
.rk {
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.top-1 .rk {
|
||||
color: var(--gold);
|
||||
}
|
||||
.top-2 .rk {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
.top-3 .rk {
|
||||
color: #e0995e;
|
||||
}
|
||||
.nm {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.28em;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0.78em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.team-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rs {
|
||||
text-align: right;
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.board-empty,
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 34px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
@keyframes row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+106
-3
@@ -25,6 +25,7 @@ import type {
|
||||
Team,
|
||||
TeamBreakdown,
|
||||
} from '@/shared'
|
||||
import type { LiveEventSource } from './live'
|
||||
|
||||
/**
|
||||
* WebSocket 与 localStorage 里的东西一律先当 unknown 再收窄。
|
||||
@@ -89,6 +90,7 @@ const GENDERS = ['男', '女'] as const satisfies readonly Gender[]
|
||||
const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[]
|
||||
const RECORD_SOURCES = ['history', 'current'] as const satisfies readonly RecordSource[]
|
||||
const LEDGER_SOURCES = ['result', 'record_bonus', 'adjustment'] as const satisfies readonly LedgerSourceType[]
|
||||
const LIVE_SOURCES = ['manual', 'auto'] as const satisfies readonly LiveSource[]
|
||||
|
||||
function record(source: unknown): Record<string, unknown> {
|
||||
return isRecord(source) ? source : {}
|
||||
@@ -321,11 +323,77 @@ export function parseRecordEntry(source: unknown): RecordEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/** 「实时赛况」页当前项目的来源:manual = 人工指定,auto = 服务端按时段 / 最近发布自动判定。 */
|
||||
export type LiveSource = 'manual' | 'auto'
|
||||
|
||||
/**
|
||||
* 大屏侧赛事配置。字段与 shared 契约一致;
|
||||
* 实时赛况页的两个赛事设置键(settings 表里的 `display.live_events` / `display.live_source`)。
|
||||
*
|
||||
* 它们是**管理端的配置**:liveEvents 是人工指定的项目,liveSource 决定服务端怎么判定当前项目。
|
||||
* 判定结论由 GET /api/display/live 下发(auto 时 liveEvents 通常是空的),
|
||||
* 这两个键在大屏侧只在接口拿不到时当兜底素材用,缺键时按「没有人工指定 / auto」处理。
|
||||
*/
|
||||
export interface LiveDisplaySettings {
|
||||
/** 人工指定的当前项目 id;auto 模式下未人工指定时为空数组。 */
|
||||
liveEvents: number[]
|
||||
liveSource: LiveSource
|
||||
}
|
||||
|
||||
/**
|
||||
* 大屏侧赛事配置。字段与 shared 契约一致,另外补上实时赛况的两个键;
|
||||
* `showIndividualMedals` 缺失时由 parseSettings 回退到 `showIndividualBoard`(旧服务端只下发后者)。
|
||||
*/
|
||||
export type DisplaySettings = EventSettings
|
||||
export type DisplaySettings = Omit<EventSettings, 'display'> & {
|
||||
display: EventSettings['display'] & LiveDisplaySettings
|
||||
}
|
||||
|
||||
/**
|
||||
* 大屏侧快照:settings 用的是解析后的配置,比 shared 契约多「实时赛况」两个键。
|
||||
* 其余字段与共享契约一致,缓存与实时消息两条路径都走它。
|
||||
*/
|
||||
export interface DisplaySnapshot extends Omit<SnapshotMessage, 'settings'> {
|
||||
settings: DisplaySettings
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/display/live 的解析结果。
|
||||
*
|
||||
* 服务端返回 `{ source, eventIds, events: [{ event, reason, groups }] }`(见 dto.LiveEvents);
|
||||
* 大屏只用到「判定方式」与每个项目的名字和成绩,eventIds 与 events 一一对应,
|
||||
* 因此按 events 里的小项 id 落库,reason(manual/scheduled/recent)只作诊断用,不参与渲染。
|
||||
*/
|
||||
export interface DisplayLiveFeed {
|
||||
source: LiveSource
|
||||
events: LiveEventSource[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 /api/display/live。
|
||||
*
|
||||
* `events` 是契约里的必填数组(没有当前项目时服务端也发 `[]`,不是 null),所以:
|
||||
* 拿到数组 → 这就是权威结论,空数组表示「现在确实没有项目」;
|
||||
* 拿不到数组(老服务端 404、代理返回错误页)→ 返回 null,页面当作请求失败,保留上一次画面。
|
||||
*/
|
||||
export function parseLiveFeed(value: unknown): DisplayLiveFeed | null {
|
||||
if (!isRecord(value)) return null
|
||||
if (!Array.isArray(value.events)) return null
|
||||
return {
|
||||
source: oneOf(str(value.source).toLowerCase(), LIVE_SOURCES, 'auto'),
|
||||
events: value.events
|
||||
.map(parseLiveEventSource)
|
||||
.filter((item) => item.eventId > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function parseLiveEventSource(source: unknown): LiveEventSource {
|
||||
const rec = record(source)
|
||||
const event = parseEvent(rec.event)
|
||||
return {
|
||||
eventId: event.id,
|
||||
eventName: event.name,
|
||||
groups: list(rec.groups).map(parseEventResultGroup),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌套路径取值。
|
||||
@@ -350,6 +418,30 @@ function settingValue(
|
||||
return source[flatKey]
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前项目 id 列表。
|
||||
*
|
||||
* settings 表里存的是文本,服务端可能下发数组(数字或数字字符串)、单个数字,
|
||||
* 或 `1,2` 这样的逗号串;这里一律收窄成正整数 id,去重并保持服务端给的顺序。
|
||||
* 坏值直接丢弃而不是让整包设置解析失败:少一栏总好过整页空态。
|
||||
*/
|
||||
export function parseLiveEventIds(value: unknown): number[] {
|
||||
const raw = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value.split(',')
|
||||
: typeof value === 'number'
|
||||
? [value]
|
||||
: []
|
||||
const ids: number[] = []
|
||||
for (const item of raw) {
|
||||
const id = typeof item === 'number' ? item : Number.parseInt(String(item).trim(), 10)
|
||||
if (!Number.isFinite(id) || !Number.isInteger(id) || id <= 0) continue
|
||||
if (!ids.includes(id)) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function parseSettings(source: unknown): DisplaySettings {
|
||||
const rec = record(source)
|
||||
const showIndividualBoard = bool(
|
||||
@@ -362,6 +454,15 @@ export function parseSettings(source: unknown): DisplaySettings {
|
||||
// 字体 id 同理:空白归一成 null,调用方按「用默认字体」处理。
|
||||
const fontTitle = str(settingValue(rec, 'display.fontTitle', 'display.font_title')).trim()
|
||||
const fontBody = str(settingValue(rec, 'display.fontBody', 'display.font_body')).trim()
|
||||
// 实时赛况:服务端判定「当前项目」,这里只做收窄;缺键 = 没有进行中的项目。
|
||||
const liveEvents = parseLiveEventIds(
|
||||
settingValue(rec, 'display.liveEvents', 'display.live_events'),
|
||||
)
|
||||
const liveSource = oneOf(
|
||||
str(settingValue(rec, 'display.liveSource', 'display.live_source')).toLowerCase(),
|
||||
LIVE_SOURCES,
|
||||
'auto',
|
||||
)
|
||||
return {
|
||||
event: {
|
||||
name: str(settingValue(rec, 'event.name', 'event.name')),
|
||||
@@ -380,6 +481,8 @@ export function parseSettings(source: unknown): DisplaySettings {
|
||||
announce: announce === '' ? null : announce,
|
||||
fontTitle: fontTitle === '' ? null : fontTitle,
|
||||
fontBody: fontBody === '' ? null : fontBody,
|
||||
liveEvents,
|
||||
liveSource,
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: num(
|
||||
@@ -432,7 +535,7 @@ export function parseTeamBreakdown(value: unknown): TeamBreakdown | null {
|
||||
}
|
||||
|
||||
/** 快照是整包信任的入口,字段缺失时补空数组而不是拒绝整包。 */
|
||||
export function parseSnapshot(value: unknown): SnapshotMessage | null {
|
||||
export function parseSnapshot(value: unknown): DisplaySnapshot | null {
|
||||
if (!isRecord(value)) return null
|
||||
const version = numOrNull(value.version)
|
||||
if (version === null) return null
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+35
-25
@@ -7,6 +7,7 @@ import type {
|
||||
StandingRow,
|
||||
Team,
|
||||
} from '@/shared'
|
||||
import type { DisplaySettings } from './guards'
|
||||
|
||||
/**
|
||||
* 后端未就绪时的降级数据(任务要求:降级 mock 并标注「示例数据」)。
|
||||
@@ -247,35 +248,44 @@ function buildAthletes(): AthleteStanding[] {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例配置。单列出来是因为它比共享契约多「实时赛况」两个键(后端未就绪时也要能演示):
|
||||
* 直接写在 SnapshotMessage 字面量里会被多余属性检查拦下。
|
||||
*/
|
||||
const MOCK_SETTINGS: DisplaySettings = {
|
||||
event: {
|
||||
name: '2026 职工运动会',
|
||||
subtitle: null,
|
||||
host: '示例组委会',
|
||||
logoPath: null,
|
||||
primaryColor: '#4fd8ff',
|
||||
},
|
||||
display: {
|
||||
background: null,
|
||||
logo: null,
|
||||
title: '2026 职工运动会',
|
||||
subtitle: '积分榜 · 示例数据',
|
||||
primaryColor: '#4fd8ff',
|
||||
rotateSeconds: 8,
|
||||
// 示例数据里有 3 个已完成项目,取前两个演示双栏分栏。
|
||||
liveEvents: [1, 2],
|
||||
liveSource: 'manual',
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: 1,
|
||||
rankingMode: 'points',
|
||||
showIndividualBoard: true,
|
||||
showIndividualMedals: true,
|
||||
},
|
||||
defaultProfileId: null,
|
||||
requireApproval: false,
|
||||
}
|
||||
|
||||
export function createMockSnapshot(): SnapshotMessage {
|
||||
return {
|
||||
// version 固定 0 且不写入缓存:真实快照一到就能直接覆盖,不会因为版本号更大而被丢弃。
|
||||
version: 0,
|
||||
settings: {
|
||||
event: {
|
||||
name: '2026 职工运动会',
|
||||
subtitle: null,
|
||||
host: '示例组委会',
|
||||
logoPath: null,
|
||||
primaryColor: '#4fd8ff',
|
||||
},
|
||||
display: {
|
||||
background: null,
|
||||
logo: null,
|
||||
title: '2026 职工运动会',
|
||||
subtitle: '积分榜 · 示例数据',
|
||||
primaryColor: '#4fd8ff',
|
||||
rotateSeconds: 8,
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: 1,
|
||||
rankingMode: 'points',
|
||||
showIndividualBoard: true,
|
||||
showIndividualMedals: true,
|
||||
},
|
||||
defaultProfileId: null,
|
||||
requireApproval: false,
|
||||
},
|
||||
settings: MOCK_SETTINGS,
|
||||
teams: MOCK_TEAMS,
|
||||
events: buildEvents(),
|
||||
standings: buildStandings(),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { formatResultText } from '@/shared'
|
||||
import type { EventResultRow, ResultStatus } from '@/shared'
|
||||
|
||||
/**
|
||||
* 成绩行的展示口径:分项成绩页与实时赛况页共用,避免同一行数据在两页显示成两种样子。
|
||||
*/
|
||||
|
||||
const STATUS_LABELS: Record<ResultStatus, string> = {
|
||||
ok: '',
|
||||
dq: 'DQ',
|
||||
dns: 'DNS',
|
||||
dnf: 'DNF',
|
||||
withdrawn: '退赛',
|
||||
}
|
||||
|
||||
/** 成绩列:异常状态优先,其次按数值 + 单位格式化,最后退回服务端给的原文。 */
|
||||
export function displayResult(row: EventResultRow): string {
|
||||
if (row.status !== 'ok') return STATUS_LABELS[row.status]
|
||||
if (row.resultValue === null) return row.resultText
|
||||
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
|
||||
}
|
||||
|
||||
/** 运动员列:个人项目用姓名,团体项目用成员名单,都缺时退回单位简称。 */
|
||||
export function displayName(row: EventResultRow): string {
|
||||
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
|
||||
if (row.memberNames !== null) return row.memberNames.join(' / ')
|
||||
return row.shortName ?? row.teamName
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SnapshotMessage } from '@/shared'
|
||||
import type { DisplaySnapshot } from './guards'
|
||||
import { isRecord, num, parseSnapshot } from './guards'
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ const SNAPSHOT_TTL_MS = 12 * 60 * 60 * 1000
|
||||
export interface CachedSnapshot {
|
||||
version: number
|
||||
savedAt: number
|
||||
snapshot: SnapshotMessage
|
||||
snapshot: DisplaySnapshot
|
||||
}
|
||||
|
||||
export function readCachedSnapshot(): CachedSnapshot | null {
|
||||
@@ -37,7 +37,7 @@ export function readCachedSnapshot(): CachedSnapshot | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedSnapshot(snapshot: SnapshotMessage): void {
|
||||
export function writeCachedSnapshot(snapshot: DisplaySnapshot): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SNAPSHOT_KEY,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { EventResultGroup, EventResultRow } from '@/shared'
|
||||
import {
|
||||
MAX_LIVE_ROWS,
|
||||
fallbackLiveSources,
|
||||
liveRefreshDelay,
|
||||
liveRows,
|
||||
pickLiveColumns,
|
||||
} from '../live'
|
||||
import type { LiveEventSource } from '../live'
|
||||
import { parseLiveEventIds, parseLiveFeed, parseSettings } from '../guards'
|
||||
import { DISPLAY_PAGES, normalizePage } from '../types'
|
||||
|
||||
// 实时赛况页的判定都是纯函数:服务端判定「当前项目」,大屏只决定画几栏、画哪些行。
|
||||
|
||||
function row(entryId: number, rank: number | null): EventResultRow {
|
||||
return {
|
||||
entryId,
|
||||
rank,
|
||||
teamId: 1,
|
||||
teamCode: 'T01',
|
||||
teamName: '第一分公司',
|
||||
shortName: null,
|
||||
logoPath: null,
|
||||
athleteId: entryId,
|
||||
athleteName: `运动员${entryId}`,
|
||||
memberNames: null,
|
||||
resultText: '11.58',
|
||||
resultValue: 11.58,
|
||||
unit: 's',
|
||||
isTie: false,
|
||||
status: 'ok',
|
||||
}
|
||||
}
|
||||
|
||||
function group(
|
||||
eventId: number,
|
||||
categoryId = 1,
|
||||
publishedAt: string | null = null,
|
||||
rows: EventResultRow[] = [row(1, 1)],
|
||||
): EventResultGroup {
|
||||
return {
|
||||
eventId,
|
||||
eventCode: `E${eventId}`,
|
||||
eventName: `项目${eventId}`,
|
||||
categoryId,
|
||||
categoryName: `组别${categoryId}`,
|
||||
publishedAt,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
function event(eventId: number, groups: EventResultGroup[] = []): LiveEventSource {
|
||||
return { eventId, eventName: `项目${eventId}`, groups }
|
||||
}
|
||||
|
||||
test('没有当前项目时返回空栏,页面显示空态', () => {
|
||||
assert.deepEqual(pickLiveColumns([]), [])
|
||||
// 全是无效 id 的条目等同于没有项目,不能凭空造出一栏。
|
||||
assert.deepEqual(pickLiveColumns([event(0), event(-3)]), [])
|
||||
})
|
||||
|
||||
test('一个项目只分一栏,标题/组别/行都来自接口', () => {
|
||||
const columns = pickLiveColumns([event(2, [group(2, 5, '2026-09-09T15:10:00+08:00')])])
|
||||
assert.equal(columns.length, 1)
|
||||
assert.equal(columns[0]?.eventId, 2)
|
||||
assert.equal(columns[0]?.title, '项目2')
|
||||
assert.equal(columns[0]?.category, '组别5')
|
||||
assert.equal(columns[0]?.hiddenGroups, 0)
|
||||
assert.equal(columns[0]?.rows.length, 1)
|
||||
})
|
||||
|
||||
test('两个项目左右分栏,顺序照服务端给的来', () => {
|
||||
const columns = pickLiveColumns([event(2, [group(2)]), event(1, [group(1)])])
|
||||
assert.deepEqual(
|
||||
columns.map((column) => column.eventId),
|
||||
[2, 1],
|
||||
)
|
||||
assert.deepEqual(
|
||||
columns.map((column) => column.title),
|
||||
['项目2', '项目1'],
|
||||
)
|
||||
})
|
||||
|
||||
test('超过两个项目时只分两栏,重复 id 只算一栏', () => {
|
||||
const columns = pickLiveColumns([event(1), event(1), event(2), event(3)])
|
||||
assert.deepEqual(
|
||||
columns.map((column) => column.eventId),
|
||||
[1, 2],
|
||||
)
|
||||
})
|
||||
|
||||
test('项目还没发布成绩时保留占位栏(不静默少一栏)', () => {
|
||||
const columns = pickLiveColumns([event(1, [group(1)]), event(9)])
|
||||
assert.equal(columns.length, 2)
|
||||
assert.equal(columns[0]?.rows.length, 1)
|
||||
assert.equal(columns[1]?.eventId, 9)
|
||||
assert.equal(columns[1]?.title, '项目9')
|
||||
assert.equal(columns[1]?.category, '')
|
||||
assert.deepEqual(columns[1]?.rows, [])
|
||||
})
|
||||
|
||||
test('接口没给名字时标题退回「项目 #id」', () => {
|
||||
const columns = pickLiveColumns([{ eventId: 12, eventName: ' ', groups: [] }])
|
||||
assert.equal(columns[0]?.title, '项目 #12')
|
||||
})
|
||||
|
||||
test('同一项目多组别时取发布时间最近的一组,其余组只报数量', () => {
|
||||
const older = group(1, 1, '2026-09-09T09:00:00+08:00')
|
||||
const newer = group(1, 2, '2026-09-09T15:00:00+08:00')
|
||||
const columns = pickLiveColumns([event(1, [older, newer])])
|
||||
assert.equal(columns[0]?.category, '组别2')
|
||||
assert.equal(columns[0]?.hiddenGroups, 1)
|
||||
|
||||
// 发布时间缺失按最旧处理,不会把没有时间的组顶掉有时间的组。
|
||||
const noTime = group(1, 3, null)
|
||||
assert.equal(pickLiveColumns([event(1, [noTime, newer])])[0]?.category, '组别2')
|
||||
assert.equal(pickLiveColumns([event(1, [noTime, older])])[0]?.category, '组别1')
|
||||
})
|
||||
|
||||
test('栏内按名次排序、无名次排最后,并截断到上限', () => {
|
||||
const rows = [row(9, null), row(3, 3), row(1, 1), row(2, 2)]
|
||||
assert.deepEqual(
|
||||
liveRows(group(1, 1, null, rows)).map((item) => item.rank),
|
||||
[1, 2, 3, null],
|
||||
)
|
||||
assert.deepEqual(liveRows(null), [])
|
||||
|
||||
const many = Array.from({ length: MAX_LIVE_ROWS + 5 }, (_, index) => row(index + 1, index + 1))
|
||||
assert.equal(liveRows(group(1, 1, null, many)).length, MAX_LIVE_ROWS)
|
||||
})
|
||||
|
||||
test('接口还没成功过时的兜底素材:人工指定 id + 快照分组 + 小项名', () => {
|
||||
const names = new Map([[7, '男子 1500 米']])
|
||||
const sources = fallbackLiveSources([7, 3], [group(7), group(8)], names)
|
||||
assert.deepEqual(
|
||||
sources.map((item) => ({ id: item.eventId, name: item.eventName })),
|
||||
[
|
||||
{ id: 7, name: '男子 1500 米' },
|
||||
{ id: 3, name: '' },
|
||||
],
|
||||
)
|
||||
// 快照里没有的项目保留空组,页面显示占位栏而不是少一栏。
|
||||
assert.equal(sources[0]?.groups.length, 1)
|
||||
assert.deepEqual(sources[1]?.groups, [])
|
||||
|
||||
const columns = pickLiveColumns(sources)
|
||||
assert.equal(columns.length, 2)
|
||||
assert.equal(columns[0]?.title, '男子 1500 米')
|
||||
assert.equal(columns[1]?.title, '项目 #3')
|
||||
})
|
||||
|
||||
test('parseLiveFeed 解析 /api/display/live 的真实形状', () => {
|
||||
const feed = parseLiveFeed({
|
||||
source: 'AUTO',
|
||||
eventIds: [7, 3],
|
||||
events: [
|
||||
{
|
||||
event: { id: 7, name: '男子 1500 米', status: 'ongoing' },
|
||||
reason: 'scheduled',
|
||||
groups: [
|
||||
{
|
||||
eventId: 7,
|
||||
eventCode: 'E7',
|
||||
eventName: '男子 1500 米',
|
||||
categoryId: 1,
|
||||
categoryName: '青年组',
|
||||
publishedAt: '2026-09-11T15:20:00+08:00',
|
||||
rows: [{ entryId: 11, rank: 1, teamId: 1, teamName: '第一分公司', resultText: '4:10.88' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
// 没成绩的项目:groups 为空数组,仍要成为一栏。
|
||||
{ event: { id: 3, name: '女子跳高' }, reason: 'scheduled', groups: [] },
|
||||
],
|
||||
})
|
||||
assert.equal(feed?.source, 'auto')
|
||||
assert.equal(feed?.events.length, 2)
|
||||
assert.equal(feed?.events[0]?.eventId, 7)
|
||||
assert.equal(feed?.events[0]?.groups[0]?.categoryName, '青年组')
|
||||
assert.equal(feed?.events[0]?.groups[0]?.rows[0]?.rank, 1)
|
||||
assert.deepEqual(feed?.events[1]?.groups, [])
|
||||
|
||||
// 空 events 是权威结论:现在确实没有进行中的项目(页面显示空态,而不是保留旧画面)。
|
||||
assert.deepEqual(parseLiveFeed({ source: 'auto', eventIds: [], events: [] })?.events, [])
|
||||
|
||||
// 形状不对时返回 null,由调用方保留上一次画面。
|
||||
assert.equal(parseLiveFeed(null), null)
|
||||
assert.equal(parseLiveFeed('{}'), null)
|
||||
assert.equal(parseLiveFeed({ source: 'auto' }), null)
|
||||
assert.equal(parseLiveFeed({ events: {} }), null)
|
||||
// 小项 id 非法(event 缺失/坏数据)的条目直接丢掉,不让它占一栏。
|
||||
assert.equal(parseLiveFeed({ events: [{ reason: 'recent', groups: [] }] })?.events.length, 0)
|
||||
})
|
||||
|
||||
test('请求间隔:首次立刻发,间隔内返回剩余时间,超过间隔返回 0', () => {
|
||||
const gap = 4_000
|
||||
assert.equal(liveRefreshDelay(0, 1_000_000, gap), 0)
|
||||
assert.equal(liveRefreshDelay(1_000_000, 1_000_500, gap), 3_500)
|
||||
assert.equal(liveRefreshDelay(1_000_000, 1_004_000, gap), 0)
|
||||
assert.equal(liveRefreshDelay(1_000_000, 1_060_000, gap), 0)
|
||||
})
|
||||
|
||||
test('实时赛况的两个设置键容错解析', () => {
|
||||
assert.deepEqual(parseLiveEventIds([3, '4', 0, 'x', 3]), [3, 4])
|
||||
assert.deepEqual(parseLiveEventIds('5, 6'), [5, 6])
|
||||
assert.deepEqual(parseLiveEventIds(7), [7])
|
||||
assert.deepEqual(parseLiveEventIds(null), [])
|
||||
assert.deepEqual(parseLiveEventIds({ eventId: 1 }), [])
|
||||
})
|
||||
|
||||
test('parseSettings 归一化 liveEvents / liveSource,缺键时按没有项目处理', () => {
|
||||
const parsed = parseSettings({
|
||||
display: { liveEvents: [2, '1'], liveSource: 'MANUAL' },
|
||||
})
|
||||
assert.deepEqual(parsed.display.liveEvents, [2, 1])
|
||||
assert.equal(parsed.display.liveSource, 'manual')
|
||||
|
||||
// settings 表里是点号键时也要能读到(旧版缓存形态)。
|
||||
const flat = parseSettings({ 'display.live_events': '8,9', 'display.live_source': 'auto' })
|
||||
assert.deepEqual(flat.display.liveEvents, [8, 9])
|
||||
|
||||
const missing = parseSettings({})
|
||||
assert.deepEqual(missing.display.liveEvents, [])
|
||||
assert.equal(missing.display.liveSource, 'auto')
|
||||
})
|
||||
|
||||
test('page=live 能过页名归一化,页名是「实时赛况」', () => {
|
||||
assert.equal(DISPLAY_PAGES.find((page) => page.key === 'live')?.title, '实时赛况')
|
||||
for (const value of ['live', 'LIVE', ' live ', '实时赛况', '实时', '赛况']) {
|
||||
assert.equal(normalizePage(value), 'live')
|
||||
}
|
||||
// 不能把别的页名错归到实时赛况上。
|
||||
assert.equal(normalizePage('event-results'), 'event-results')
|
||||
})
|
||||
|
||||
test('页面按栏数切 class,三种文案都不留白', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
// 分栏比例由 CSS 决定(.boards-1 / .boards-2 各一套网格),class 必须跟着栏数走。
|
||||
assert.match(source, /boards-\$\{boards\.length\}/)
|
||||
// 首屏加载中 / 确实没有项目 / 指定项目还没成绩,三种情况都要有明确中文。
|
||||
assert.match(source, /正在获取当前项目…/)
|
||||
assert.match(source, /暂无进行中的项目/)
|
||||
assert.match(source, /该项目暂无已发布的成绩/)
|
||||
assert.match(source, /:path="row\.logoPath"/)
|
||||
// 定时兜底必须挂在页面上,否则项目切换会停在旧结论。
|
||||
assert.match(source, /LIVE_REFRESH_INTERVAL_MS/)
|
||||
// 数据来自接口结论(liveFeed),且挂载时立刻拉一次。
|
||||
assert.match(source, /liveFeed/)
|
||||
assert.match(source, /onMounted\(\(\) => \{\s*void refreshLiveEvents\(\)/)
|
||||
})
|
||||
@@ -8,6 +8,7 @@ export const DISPLAY_PAGES = [
|
||||
{ key: 'team-medals', title: '团体奖牌榜' },
|
||||
{ key: 'athlete-standings', title: '个人积分奖牌榜' },
|
||||
{ key: 'event-results', title: '分项成绩' },
|
||||
{ key: 'live', title: '实时赛况' },
|
||||
{ key: 'announcement', title: '公告' },
|
||||
] as const
|
||||
|
||||
@@ -34,6 +35,11 @@ const PAGE_ALIASES: Record<string, DisplayPageKey> = {
|
||||
results: 'event-results',
|
||||
分项成绩: 'event-results',
|
||||
成绩: 'event-results',
|
||||
live: 'live',
|
||||
'live-results': 'live',
|
||||
实时赛况: 'live',
|
||||
实时: 'live',
|
||||
赛况: 'live',
|
||||
announcement: 'announcement',
|
||||
announce: 'announcement',
|
||||
公告: 'announcement',
|
||||
|
||||
@@ -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