Files
Scoreboard/web/src/display/components/pages/LivePage.vue
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

415 lines
14 KiB
Vue
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.
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import type { EventResultRow } from '@/shared'
import { fitLiveRowFontPx, liveResultColumnPx, liveTeamColumnPx } from '../../fitFont'
import {
LIVE_REFRESH_INTERVAL_MS,
fallbackLiveSources,
liveBlockRowCount,
liveBoardLayout,
liveRowBlocks,
pickLiveColumns,
} from '../../live'
import type { LiveEventSource } from '../../live'
import { rowLogoCount, rowTeamLogos, rowTeamText } from '../../jointTeams'
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,
jointTeamOf,
squadOf,
rosterOf,
} = useScoreboard()
/**
* 联队与队伍(devdocs/19):成绩行里只有主单位,成员单位与队名来自报名表
* (useScoreboard 会为屏上的小项读一次)。普通报名下这两件事与改造前完全一样——
* 文案还是 `shortName ?? teamName`,logo 还是刚一张。
*/
function teamText(row: EventResultRow): string {
return rowTeamText(row, jointTeamOf(row.entryId), squadOf(row.entryId))
}
function teamLogos(row: EventResultRow): (string | null)[] {
return rowTeamLogos(row, jointTeamOf(row.entryId))
}
/**
* 运动员列(devdocs/20):团体报名挂了队员就写队员名单,没挂队员的报名拿到 null,
* 与改造前逐字一致(见 display/members.ts)。
*/
function athleteText(row: EventResultRow): string {
return displayName(row, rosterOf(row.entryId))
}
const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name])))
/**
* 「当前项目」由服务端判定(GET /api/display/live:人工指定 → 排期命中 → 最近发布)。
*
* 拉到过结论就用它(这个接口按小项给**全量**的已发布成绩、**不设条数上限**,不受快照那个
* 「每组保底前 10 行、最多 8 组」窗口(`latestResultsGroupLimit` / `latestResultsRowsPerGroup`)限制,
* 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 ? '正在获取当前项目…' : '暂无进行中的项目',
)
// 字号取行数最多的那一栏,两栏行高才一致;成绩列与单位列的宽度再反过来给字号封顶(见 fitFont.ts)。
const rowCount = computed(() =>
boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0),
)
/**
* 版式由「几个项目 + 一栏几行」决定(见 live.ts):一个项目且行数多时,一栏拆成左右两块,
* 让字号不再被高度压到 34px。两块的行数相同,所以字号取一块的行数。
*/
const layout = computed(() => liveBoardLayout(boards.value.length, rowCount.value))
// 成绩列宽与字号互相牵制,所以由页面把一屏的成绩文本收齐了一次算完:行数 → 基准字号,
// 文本宽度 → 字号上限,字号 + 文本宽度 → 列宽。两栏各算各的会让两栏列宽不一致。
// 单位名列同理:它和成绩抢的是同一行的宽度,长单位名(8~12 个汉字)必须在同一笔账里算,
// 否则列宽写死、名字一长就被挤成三个点(见 fitFont.ts 的 fitLiveRowFontPx)。
const resultTexts = computed(() =>
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
)
// 联队行的单位名更长(两个单位名都写全),带队名的行也多出几个字,列宽预算必须按
// **实际会写出来的字**算(teamText 已经把队名拼进去了),否则一到屏上就被挤成省略号。
const teamTexts = computed(() =>
boards.value.flatMap((board) => board.rows.map((row) => teamText(row))),
)
// 团体报名的队员名单(devdocs/20)同样要按实际写出来的字算:名字列原来只保证 4 个汉字,
// 一对双打的名单比它长,不传进预算就会被省略号截掉(见 fitFont.fitLiveRowFontPx 的 rosters)。
const rosterTexts = computed(() =>
boards.value.flatMap((board) =>
board.rows.filter((row) => rosterOf(row.entryId) !== null).map((row) => athleteText(row)),
),
)
// 单位名后面还要放 logo(TeamLogo 是 1em + 0.28em 间距),一个不够就按最多的那一行算。
const teamsWithLogo = computed(() =>
boards.value.some((board) => board.rows.some((row) => row.logoPath !== null && row.logoPath !== '')),
)
const maxLogosPerRow = computed(() =>
boards.value.reduce(
(max, board) =>
Math.max(
max,
...board.rows.map((row) => rowLogoCount(row, jointTeamOf(row.entryId))),
0,
),
0,
),
)
const rowFontPx = computed(() =>
fitLiveRowFontPx(
liveBlockRowCount(rowCount.value, layout.value),
layout.value,
resultTexts.value,
teamTexts.value,
teamsWithLogo.value,
maxLogosPerRow.value,
rosterTexts.value,
),
)
const boardStyle = computed(() => ({
'--row-font': `${rowFontPx.value}px`,
'--rs-width': `${liveResultColumnPx(resultTexts.value, layout.value, rowFontPx.value)}px`,
'--team-width': `${liveTeamColumnPx(
teamTexts.value,
layout.value,
rowFontPx.value,
teamsWithLogo.value,
maxLogosPerRow.value,
)}px`,
}))
// 定时兜底:项目切换靠 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="boardStyle"
>
<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" :class="{ split: layout === 'split' }">
<div v-for="(block, index) in liveRowBlocks(board.rows, layout)" :key="index" class="blk">
<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 block"
:key="row.entryId"
class="row"
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ athleteText(row) }}</span>
<!-- 联队两个单位的 logo 都画(teamLogos 普通报名时只有一个元素)。 -->
<span class="team">
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
<span class="team-name">{{ teamText(row) }}</span>
</span>
<span class="rs">{{ displayResult(row) }}</span>
</div>
</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 的屏上收窄
* 1180 等于左右各留 370px 空白,而且收窄也换不来更大的字(字号由高度决定,与宽度无关):
* 那是纯粹浪费。铺满后左右只剩页框自带的 78px 边距,和顶栏、轮播条对齐。
*/
.boards-1 .board {
max-width: none;
}
.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;
}
/* 一块 = 表头 + 它的名次行。单栏版式下这一块从顶部开始排:表头要紧贴上面那条项目名,
行高封顶省下来的高度留在面板底部(行数是会长的,空在下面比空在中间像回事)。 */
.blk {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
}
/* 行数多到一张表压不出字(≥7 行)时,一栏拆成左右两块并排:半屏换来的高度直接变成字号。 */
.boards-1 .rows.split {
display: grid;
grid-template-columns: 1fr 1fr;
align-content: stretch;
align-items: stretch;
gap: 0 26px;
}
.row {
display: grid;
/* 成绩列宽与单位列宽都由内容算(--rs-width / --team-width,见 fitFont.ts):写死列宽时
56px 字号的 `1:09.09` 墨迹有 201px、10 个汉字的单位名有 343px,超出的部分不是被
.rows 的 overflow:hidden 裁掉,就是缩成三个点。名字列可省略号截断,先让位。 */
grid-template-columns:
90px minmax(0, 1fr)
var(--team-width, 300px) var(--rs-width, 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;
}
/* 单栏满宽:名次列留足两位数(96px 字号下 `10` 的墨迹约 134px),运动员与单位一起分剩余宽度。
单位列写成 minmax(单位宽, 1fr):单位名短的时候和运动员列一样宽(观感与改前一致),长了就先吃
自己那份、再吃运动员列的余量,不必挤别人。字号会跟着收,保证它不会收空(见 fitFont.ts)。
行高再封顶 3.2 倍字号——行数少时(尤其只有 1 行)不把行拉满整屏高:96px 的字铺在 460px 高的行里
四周全是空的,封顶后 1 行约占可用高度的七成,余下的高度留在面板底部。
这两条只加在单栏版式上:两个项目时每栏的行数可能悬殊,行高拉伸与否维持原样。 */
.boards-1 .row {
grid-template-columns:
170px minmax(0, 1fr)
minmax(var(--team-width, 300px), 1fr) var(--rs-width, 220px);
gap: 18px;
max-height: calc(var(--row-font, 34px) * 3.2);
}
/* 拆成两块后每块只剩半屏宽:名次列窄一档但必须装得下两位数(56px 字号下 `10` 约 78px),
运动员列分到的宽度比单位多一半(1.5fr : 1fr,与 fitFont.ts 的 nameFr/teamFr 一致),
单位列同样保底内容宽,长单位名不再被截成三个点。 */
.boards-1 .rows.split .row {
grid-template-columns:
88px minmax(0, 1.5fr)
minmax(var(--team-width, 150px), 1fr) var(--rs-width, 150px);
}
.boards-2 .row {
/* 两栏时名次也窄一档;成绩列与单位列都跟着内容走,剩下的宽度全给运动员列——
半屏里两列(运动员 / 单位)再互相比份额,行数一多就两边都不够。 */
grid-template-columns:
60px minmax(0, 1fr)
var(--team-width, 190px) var(--rs-width, 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;
/* 成绩绝不折行:折成两行会被读成两个成绩,且第二行会被 .rows 的 overflow:hidden 竖着裁掉。 */
white-space: nowrap;
}
.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>