本批七项(三处是现场报的 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 与两处前端注释的旧窗口说法一并订正。
415 lines
14 KiB
Vue
415 lines
14 KiB
Vue
<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>
|