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