feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
1 parent
8dd599bd97
commit
631c8dd8e1
80 files changed
+12468
-519
No files matched your search
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { fitLiveRowFontPx, liveResultColumnPx } from '../../fitFont'
|
||||
import { LIVE_REFRESH_INTERVAL_MS, fallbackLiveSources, pickLiveColumns } from '../../live'
|
||||
import {
|
||||
LIVE_REFRESH_INTERVAL_MS,
|
||||
fallbackLiveSources,
|
||||
liveBlockRowCount,
|
||||
liveBoardLayout,
|
||||
liveRowBlocks,
|
||||
pickLiveColumns,
|
||||
} from '../../live'
|
||||
import type { LiveEventSource } from '../../live'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
@@ -47,18 +54,28 @@ 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))
|
||||
|
||||
// 成绩列宽与字号互相牵制,所以由页面把一屏的成绩文本收齐了一次算完:行数 → 基准字号,
|
||||
// 文本宽度 → 字号上限,字号 + 文本宽度 → 列宽。两栏各算各的会让两栏列宽不一致。
|
||||
const resultTexts = computed(() =>
|
||||
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
|
||||
)
|
||||
const rowFontPx = computed(() =>
|
||||
fitLiveRowFontPx(rowCount.value, boards.value.length, resultTexts.value),
|
||||
fitLiveRowFontPx(
|
||||
liveBlockRowCount(rowCount.value, layout.value),
|
||||
layout.value,
|
||||
resultTexts.value,
|
||||
),
|
||||
)
|
||||
|
||||
const boardStyle = computed(() => ({
|
||||
'--row-font': `${rowFontPx.value}px`,
|
||||
'--rs-width': `${liveResultColumnPx(resultTexts.value, boards.value.length, rowFontPx.value)}px`,
|
||||
'--rs-width': `${liveResultColumnPx(resultTexts.value, layout.value, rowFontPx.value)}px`,
|
||||
}))
|
||||
|
||||
// 定时兜底:项目切换靠 scheduled_at 自己过期,快照不会为时间流逝推送,所以必须有这条轮询。
|
||||
@@ -91,26 +108,28 @@ onUnmounted(() => {
|
||||
<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 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">{{ 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>
|
||||
<div v-else class="board-empty">该项目暂无已发布的成绩</div>
|
||||
@@ -139,9 +158,15 @@ onUnmounted(() => {
|
||||
padding: 18px 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 只有一个项目时不必铺满 1920(一行字太长反而难读),居中留白与分项成绩页一致。 */
|
||||
/* 只有一个项目时不再把卡片收窄居中。
|
||||
*
|
||||
* 收窄的旧理由是「一行字太长反而难读」,但那条说的是连续文字;这张是一张表——名次、单位、
|
||||
* 成绩都各占一列、位置由列宽固定,视线是沿行走的,行宽不会影响读法。1920 的屏上收窄
|
||||
* 1180 等于左右各留 370px 空白,而且收窄也换不来更大的字(字号由高度决定,与宽度无关):
|
||||
* 那是纯粹浪费。铺满后左右只剩页框自带的 78px 边距,和顶栏、轮播条对齐。
|
||||
*/
|
||||
.boards-1 .board {
|
||||
max-width: 1180px;
|
||||
max-width: none;
|
||||
}
|
||||
.board-head {
|
||||
display: flex;
|
||||
@@ -176,6 +201,22 @@ h3 {
|
||||
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,见 fitFont.ts):写死列宽时 56px 字号的 `1:09.09`
|
||||
@@ -191,6 +232,20 @@ h3 {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
animation: row-in 0.45s ease-out both;
|
||||
}
|
||||
/* 单栏满宽:名次列留足两位数(96px 字号下 `10` 的墨迹约 134px),运动员与单位各占一半弹性宽度。
|
||||
行高再封顶 3.2 倍字号——行数少时(尤其只有 1 行)不把行拉满整屏高:96px 的字铺在 460px 高的行里
|
||||
四周全是空的,封顶后 1 行约占可用高度的七成,余下的高度留在面板底部。
|
||||
这两条只加在单栏版式上:两个项目时每栏的行数可能悬殊,行高拉伸与否维持原样。 */
|
||||
.boards-1 .row {
|
||||
grid-template-columns: 170px minmax(0, 1fr) minmax(0, 1fr) var(--rs-width, 220px);
|
||||
gap: 18px;
|
||||
max-height: calc(var(--row-font, 34px) * 3.2);
|
||||
}
|
||||
/* 拆成两块后每块只剩半屏宽:名次列窄一档但必须装得下两位数(56px 字号下 `10` 约 78px),
|
||||
运动员列分到的宽度比单位多一半,4 个汉字的名字才不会被省略号截掉(实测 4 字名 @56px 约 224px)。 */
|
||||
.boards-1 .rows.split .row {
|
||||
grid-template-columns: 88px minmax(0, 1.5fr) minmax(0, 1fr) var(--rs-width, 150px);
|
||||
}
|
||||
.boards-2 .row {
|
||||
/* 两栏时名次 / 单位也窄一档,成绩列同样跟着内容走。 */
|
||||
grid-template-columns: 60px minmax(0, 1fr) 190px var(--rs-width, 150px);
|
||||
|
||||
+55
-22
@@ -18,11 +18,19 @@ const EMPTY_ROW_FONT_PX = 48
|
||||
*
|
||||
* 行高由 flex 均分,这里按「可用高度 ÷ 行数 × 比例」给出字号,
|
||||
* 比例留出 padding 与行距,保证文字始终装得下。
|
||||
*
|
||||
* capPx 用于实时赛况「单栏满宽」那种一栏占满整屏的版式:栏宽不是限制时,
|
||||
* 半屏版式的 56px 观感上限会白白浪费掉高度换来的字号空间。
|
||||
*/
|
||||
export function fitRowFontPx(count: number, availablePx: number, ratio = 0.62): number {
|
||||
export function fitRowFontPx(
|
||||
count: number,
|
||||
availablePx: number,
|
||||
ratio = 0.62,
|
||||
capPx = MAX_ROW_FONT_PX,
|
||||
): number {
|
||||
if (count <= 0) return EMPTY_ROW_FONT_PX
|
||||
const perRow = availablePx / count
|
||||
return Math.floor(Math.min(MAX_ROW_FONT_PX, Math.max(MIN_ROW_FONT_PX, perRow * ratio)))
|
||||
return Math.floor(Math.min(capPx, Math.max(MIN_ROW_FONT_PX, perRow * ratio)))
|
||||
}
|
||||
|
||||
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
|
||||
@@ -30,13 +38,37 @@ export function fitRowFont(count: number, availablePx: number, ratio = 0.62): st
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时赛况一屏分几栏 → 行高比例。
|
||||
* 实时赛况的三种版式,与 LivePage.vue 的网格一一对应。
|
||||
*
|
||||
* 与 LivePage.vue 的 .boards-1 / .boards-2 网格同源:两栏时每栏只有半屏宽,
|
||||
* 行高(也就是字号)要再收一档。
|
||||
* - single:单项目一栏,且行数少到可以铺满整屏一张表(行号少,字号吃得下满宽);
|
||||
* - split :单项目一栏,但行数多到「一张表」只能压出很小的字——把名次拆成左右两块并排,
|
||||
* 半屏换来的高度直接变成字号(10 行:34px → 56px);
|
||||
* - duo :两个项目左右各一栏(每栏半屏,宽高都紧张)。
|
||||
*/
|
||||
export function liveRowFontRatio(columns: number): number {
|
||||
return columns > 1 ? 0.48 : 0.62
|
||||
export type LiveLayout = 'single' | 'split' | 'duo'
|
||||
|
||||
interface LiveLayoutSpec {
|
||||
/** 行高比例:可用高度 ÷ 行数 × 比例,就是字号。 */
|
||||
ratio: number
|
||||
/** 字号上限。 */
|
||||
capPx: number
|
||||
/** 成绩列宽下限。 */
|
||||
resultFloorPx: number
|
||||
/** 成绩列宽预算:成绩列最多占这么宽,再宽就得靠收字号解决。 */
|
||||
resultBudgetPx: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 各版式的四个数字。
|
||||
*
|
||||
* duo 沿用改前的一整套(0.48 / 56 / 150 / 240),两栏裁切刚修好,别动它。
|
||||
* split 的行高比例回到 0.62:拆两块之后每块只有半屏宽、却是整屏高,行数也减半
|
||||
* (最多 5 行),限制来自宽度而不是高度。
|
||||
*/
|
||||
const LIVE_LAYOUT_SPECS: Record<LiveLayout, LiveLayoutSpec> = {
|
||||
single: { ratio: 0.62, capPx: 96, resultFloorPx: 220, resultBudgetPx: 440 },
|
||||
split: { ratio: 0.62, capPx: MAX_ROW_FONT_PX, resultFloorPx: 150, resultBudgetPx: 300 },
|
||||
duo: { ratio: 0.48, capPx: MAX_ROW_FONT_PX, resultFloorPx: 150, resultBudgetPx: 240 },
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,19 +77,19 @@ export function liveRowFontRatio(columns: number): number {
|
||||
* 保留下限是为了「短成绩时观感不变」:`59.99` 这类文本本来就装得下,列宽不该因为
|
||||
* 改成按文本算就抖一下。
|
||||
*/
|
||||
export function liveResultColumnFloorPx(columns: number): number {
|
||||
return columns > 1 ? 150 : 220
|
||||
export function liveResultColumnFloorPx(layout: LiveLayout): number {
|
||||
return LIVE_LAYOUT_SPECS[layout].resultFloorPx
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩列宽度预算(px):成绩列最多占这么宽,再宽就得靠收字号解决。
|
||||
*
|
||||
* 两栏时一行可用宽约 815px,扣掉名次 60 / 单位 190 / 间距 24 后剩 541px 由「运动员 + 成绩」
|
||||
* 分。预算 240px 给成绩,运动员列还能留 300px(56px 字号下约 5 个汉字);名字本来就是
|
||||
* 可以省略号截断的次要信息,末位成绩被裁才是不能接受的(`1:09.0` 会被读成另一个成绩)。
|
||||
* 成绩列宽度预算(px)。半屏版式(split / duo)一行可用宽约 815px,扣掉名次、单位后剩 541px
|
||||
* 由「运动员 + 成绩」分:预算 240–300px 给成绩,运动员列还能留 240px 以上(56px 字号下约 4 个
|
||||
* 汉字);名字本来就是可以省略号截断的次要信息,末位成绩被裁才是不能接受的
|
||||
* (`1:09.0` 会被读成另一个成绩)。满宽版式(single)一行有 1700px 可用,预算给到 440px,
|
||||
* 连 `64:21.50` 这种长成绩都不用收字号。
|
||||
*/
|
||||
export function liveResultColumnBudgetPx(columns: number): number {
|
||||
return columns > 1 ? 240 : 340
|
||||
export function liveResultColumnBudgetPx(layout: LiveLayout): number {
|
||||
return LIVE_LAYOUT_SPECS[layout].resultBudgetPx
|
||||
}
|
||||
|
||||
/** 估宽时额外留的像素:抹掉 ceil 与亚像素误差,避免差 1px 又把末位数字裁掉。 */
|
||||
@@ -95,18 +127,19 @@ function widestResultEm(results: readonly string[]): number {
|
||||
*
|
||||
* 真机缺陷:两栏时成绩列写死 150px,而 `1:09.09` 在 56px 字号下墨迹 201px,末位数字被外层
|
||||
* overflow:hidden 裁掉;1–6 行时字号都大于 41px(150px 列的容限),于是全中招。这里把
|
||||
* 「装不下就收字号」当兜底:典型成绩(4.2em)在 240px 预算下仍能取满 56px,靠加宽成绩列解决;
|
||||
* 「装不下就收字号」当兜底:典型成绩(4.2em)在预算内仍能取满该版式的上限,靠加宽成绩列解决;
|
||||
* 只有 `64:21.50` 这类特别长的成绩才真的收字号。
|
||||
*/
|
||||
export function fitLiveRowFontPx(
|
||||
rowCount: number,
|
||||
columns: number,
|
||||
layout: LiveLayout,
|
||||
results: readonly string[],
|
||||
): number {
|
||||
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, liveRowFontRatio(columns))
|
||||
const spec = LIVE_LAYOUT_SPECS[layout]
|
||||
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, spec.ratio, spec.capPx)
|
||||
const widest = widestResultEm(results)
|
||||
if (widest <= 0) return base
|
||||
const byWidth = Math.floor((liveResultColumnBudgetPx(columns) - RESULT_COLUMN_PAD_PX) / widest)
|
||||
const byWidth = Math.floor((spec.resultBudgetPx - RESULT_COLUMN_PAD_PX) / widest)
|
||||
return Math.max(MIN_ROW_FONT_PX, Math.min(base, byWidth))
|
||||
}
|
||||
|
||||
@@ -119,9 +152,9 @@ export function fitLiveRowFontPx(
|
||||
*/
|
||||
export function liveResultColumnPx(
|
||||
results: readonly string[],
|
||||
columns: number,
|
||||
layout: LiveLayout,
|
||||
fontPx: number,
|
||||
): number {
|
||||
const need = Math.ceil(widestResultEm(results) * fontPx) + RESULT_COLUMN_PAD_PX
|
||||
return Math.max(liveResultColumnFloorPx(columns), need)
|
||||
return Math.max(liveResultColumnFloorPx(layout), need)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EventResultGroup, EventResultRow } from '@/shared'
|
||||
import type { LiveLayout } from './fitFont'
|
||||
|
||||
/**
|
||||
* 实时赛况页的纯逻辑:谁上屏、怎么分栏、栏内排哪些行、什么时候该发下一次请求。
|
||||
@@ -18,6 +19,16 @@ export const MAX_LIVE_COLUMNS = 2
|
||||
*/
|
||||
export const MAX_LIVE_ROWS = 10
|
||||
|
||||
/**
|
||||
* 单项目一栏时,行数到几行就把名次拆成左右两块并排。
|
||||
*
|
||||
* 阈值取 7 是因为它正好是「拆开之后字更大」的临界点:字号按 BOARD_AVAILABLE_PX 与高度比例
|
||||
* 0.62 算(见 fitFont.ts),一张表铺满时 7 行只剩 49px(低于半屏版式的 56px 上限),而 6 行
|
||||
* 还有 57px。7 行起拆成两块(每块 ≤5 行)换来的字号永远更大,再往下(8/9/10 行)差距越拉越大;
|
||||
* fitFont.test.ts 里有一条测试盯着这个临界点。
|
||||
*/
|
||||
export const LIVE_SPLIT_MIN_ROWS = 7
|
||||
|
||||
/**
|
||||
* 定时兜底刷新间隔。
|
||||
*
|
||||
@@ -116,6 +127,33 @@ export function liveRefreshDelay(
|
||||
return Math.max(0, minGapMs - (now - lastAttemptAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* 单栏拆几块:只有「一个项目 + 行数够多」才拆,两个项目时本来就是一栏一块。
|
||||
*/
|
||||
export function liveBoardLayout(columns: number, rowCount: number): LiveLayout {
|
||||
if (columns > 1) return 'duo'
|
||||
return rowCount >= LIVE_SPLIT_MIN_ROWS ? 'split' : 'single'
|
||||
}
|
||||
|
||||
/**
|
||||
* 字号要按「一块里有几行」算,而不是整栏几行——拆两块的意义就在这里。
|
||||
*/
|
||||
export function liveBlockRowCount(rowCount: number, layout: LiveLayout): number {
|
||||
return layout === 'split' ? Math.ceil(rowCount / 2) : rowCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一栏的名次切成左右两块:上半(名次靠前)在左,下半在右。
|
||||
*
|
||||
* 前一半多一行(7 行 → 4 + 3),让更重要的名次留在左块;两块的行数最多差 1 行,
|
||||
* 行高才不会一块紧一块松。非 split 版式原样返回单块。
|
||||
*/
|
||||
export function liveRowBlocks<T>(rows: readonly T[], layout: LiveLayout): T[][] {
|
||||
if (layout !== 'split' || rows.length < LIVE_SPLIT_MIN_ROWS) return [rows as T[]]
|
||||
const half = Math.ceil(rows.length / 2)
|
||||
return [rows.slice(0, half), rows.slice(half)]
|
||||
}
|
||||
|
||||
/** 同一小项分了多个组别时取最近发布的一组;发布时间缺失按最旧算。 */
|
||||
function pickLatestGroup(groups: readonly EventResultGroup[]): EventResultGroup | null {
|
||||
let latest: EventResultGroup | null = null
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
liveResultColumnPx,
|
||||
resultTextEm,
|
||||
} from '../fitFont'
|
||||
import { LIVE_SPLIT_MIN_ROWS } from '../live'
|
||||
|
||||
test('fitRowFont 空列表用固定字号', () => {
|
||||
assert.equal(fitRowFont(0, BOARD_AVAILABLE_PX), '48px')
|
||||
@@ -39,11 +40,11 @@ test('两栏 1–10 行:字号封顶 56px、墨迹一律装得进算出来的
|
||||
const widest = resultTextEm('1:06.33')
|
||||
const expectedFont = [56, 56, 56, 56, 53, 44, 38, 33, 29, 26]
|
||||
for (let rows = 1; rows <= 10; rows++) {
|
||||
const font = fitLiveRowFontPx(rows, 2, results)
|
||||
const font = fitLiveRowFontPx(rows, 'duo', results)
|
||||
assert.equal(font, expectedFont[rows - 1], `${rows} 行`)
|
||||
assert.ok(font <= MAX_ROW_FONT_PX)
|
||||
const column = liveResultColumnPx(results, 2, font)
|
||||
assert.ok(column >= liveResultColumnFloorPx(2), `${rows} 行不该比原来的 150px 更窄`)
|
||||
const column = liveResultColumnPx(results, 'duo', font)
|
||||
assert.ok(column >= liveResultColumnFloorPx('duo'), `${rows} 行不该比原来的 150px 更窄`)
|
||||
assert.ok(widest * font <= column, `${rows} 行仍会裁`)
|
||||
}
|
||||
})
|
||||
@@ -52,54 +53,84 @@ test('两栏 1–7 行才需要加宽成绩列,8 行以上仍用原来的 150p
|
||||
const results = ['1:06.33']
|
||||
const widest = resultTextEm('1:06.33')
|
||||
for (let rows = 1; rows <= 7; rows++) {
|
||||
const font = fitLiveRowFontPx(rows, 2, results)
|
||||
const font = fitLiveRowFontPx(rows, 'duo', results)
|
||||
// 写死 150px 时这些行数真的会裁:这正是本次要修的真机缺陷。
|
||||
assert.ok(widest * font > liveResultColumnFloorPx(2), `${rows} 行`)
|
||||
assert.ok(liveResultColumnPx(results, 2, font) >= widest * font)
|
||||
assert.ok(widest * font > liveResultColumnFloorPx('duo'), `${rows} 行`)
|
||||
assert.ok(liveResultColumnPx(results, 'duo', font) >= widest * font)
|
||||
}
|
||||
for (const rows of [8, 9, 10]) {
|
||||
const font = fitLiveRowFontPx(rows, 2, results)
|
||||
assert.ok(widest * font <= liveResultColumnFloorPx(2), `${rows} 行`)
|
||||
assert.equal(liveResultColumnPx(results, 2, font), 150)
|
||||
const font = fitLiveRowFontPx(rows, 'duo', results)
|
||||
assert.ok(widest * font <= liveResultColumnFloorPx('duo'), `${rows} 行`)
|
||||
assert.equal(liveResultColumnPx(results, 'duo', font), 150)
|
||||
}
|
||||
})
|
||||
|
||||
test('长成绩:先加宽列,装不下才收字号,且不越预算', () => {
|
||||
const results = ['64:21.50']
|
||||
const font = fitLiveRowFontPx(1, 2, results)
|
||||
const font = fitLiveRowFontPx(1, 'duo', results)
|
||||
assert.ok(font < MAX_ROW_FONT_PX, '特别长的成绩要让出字号')
|
||||
assert.ok(font >= 40, '让出的幅度不该把大屏字变小')
|
||||
const column = liveResultColumnPx(results, 2, font)
|
||||
const column = liveResultColumnPx(results, 'duo', font)
|
||||
assert.ok(resultTextEm('64:21.50') * font <= column)
|
||||
assert.ok(column <= liveResultColumnBudgetPx(2))
|
||||
assert.ok(column <= liveResultColumnBudgetPx('duo'))
|
||||
|
||||
// 典型成绩(7 字符)不该因为这条上限被收字号。
|
||||
assert.equal(fitLiveRowFontPx(1, 2, ['1:06.33', '1:09.09']), 56)
|
||||
assert.equal(fitLiveRowFontPx(1, 'duo', ['1:06.33', '1:09.09']), 56)
|
||||
})
|
||||
|
||||
test('单栏:56px 上限不因宽度被削,短成绩仍用原来的 220px 列', () => {
|
||||
assert.equal(fitLiveRowFontPx(1, 1, ['1:09.09']), MAX_ROW_FONT_PX)
|
||||
assert.equal(fitLiveRowFontPx(6, 1, ['1:09.09']), MAX_ROW_FONT_PX)
|
||||
assert.equal(liveResultColumnPx(['59.99'], 1, MAX_ROW_FONT_PX), liveResultColumnFloorPx(1))
|
||||
// 单栏也会裁长成绩(220px 装不下 56px 的 `64:21.50`),同样由内容定宽修掉。
|
||||
const font = fitLiveRowFontPx(1, 1, ['64:21.50'])
|
||||
assert.equal(font, MAX_ROW_FONT_PX)
|
||||
assert.ok(liveResultColumnPx(['64:21.50'], 1, font) >= resultTextEm('64:21.50') * font)
|
||||
test('单栏满宽:字号用满高度(96px 上限),短成绩仍守着原来的 220px 列', () => {
|
||||
// 收窄到 1180px 的旧版式把这里的字号压在 56px;铺满全场后高度换来的字号不再被削。
|
||||
assert.equal(fitLiveRowFontPx(1, 'single', ['1:09.09']), 96)
|
||||
assert.equal(fitLiveRowFontPx(3, 'single', ['1:09.09']), 96)
|
||||
assert.equal(fitLiveRowFontPx(4, 'single', ['1:09.09']), 86)
|
||||
assert.equal(fitLiveRowFontPx(6, 'single', ['1:09.09']), 57)
|
||||
assert.equal(
|
||||
liveResultColumnPx(['59.99'], 'single', MAX_ROW_FONT_PX),
|
||||
liveResultColumnFloorPx('single'),
|
||||
)
|
||||
// 单栏也会裁长成绩(220px 装不下 96px 的 `64:21.50`),同样由内容定宽修掉。
|
||||
const font = fitLiveRowFontPx(1, 'single', ['64:21.50'])
|
||||
assert.equal(font, 89)
|
||||
assert.ok(liveResultColumnPx(['64:21.50'], 'single', font) >= resultTextEm('64:21.50') * font)
|
||||
})
|
||||
|
||||
test('拆两块:每块 ≤5 行时字号回到 56px 上限,且行数越多越只增不减', () => {
|
||||
const results = ['1:06.33', '1:09.09', '1:10.55', '59.99']
|
||||
for (const rows of [7, 8, 9, 10]) {
|
||||
const perBlock = Math.ceil(rows / 2)
|
||||
const font = fitLiveRowFontPx(perBlock, 'split', results)
|
||||
assert.equal(font, MAX_ROW_FONT_PX, `${rows} 行`)
|
||||
assert.ok(font > fitLiveRowFontPx(rows, 'single', results), `${rows} 行拆开必须更大`)
|
||||
const column = liveResultColumnPx(results, 'split', font)
|
||||
assert.ok(resultTextEm('1:06.33') * font <= column)
|
||||
assert.ok(column <= liveResultColumnBudgetPx('split'))
|
||||
}
|
||||
})
|
||||
|
||||
test('临界点:拆分线起拆两块才划算(差一行时拆开反而更小)', () => {
|
||||
const results = ['1:06.33', '1:09.09', '1:10.55', '59.99']
|
||||
for (let rows = 1; rows <= 10; rows++) {
|
||||
const single = fitLiveRowFontPx(rows, 'single', results)
|
||||
const split = fitLiveRowFontPx(Math.ceil(rows / 2), 'split', results)
|
||||
if (rows >= LIVE_SPLIT_MIN_ROWS) assert.ok(split > single, `${rows} 行应当拆`)
|
||||
else assert.ok(split <= single, `${rows} 行不该拆`)
|
||||
}
|
||||
})
|
||||
|
||||
test('没有行可排时退回固定字号与列宽下限', () => {
|
||||
assert.equal(fitLiveRowFontPx(0, 2, []), 48)
|
||||
assert.equal(liveResultColumnPx([], 2, 56), liveResultColumnFloorPx(2))
|
||||
assert.equal(liveResultColumnPx([], 1, 56), liveResultColumnFloorPx(1))
|
||||
assert.equal(fitLiveRowFontPx(0, 'duo', []), 48)
|
||||
assert.equal(liveResultColumnPx([], 'duo', 56), liveResultColumnFloorPx('duo'))
|
||||
assert.equal(liveResultColumnPx([], 'single', 56), liveResultColumnFloorPx('single'))
|
||||
assert.equal(liveResultColumnPx([], 'split', 56), liveResultColumnFloorPx('split'))
|
||||
})
|
||||
|
||||
test('行数越多字号只减不增', () => {
|
||||
const results = ['1:06.33', 'DNF', '12.35米']
|
||||
for (const columns of [1, 2]) {
|
||||
for (const layout of ['single', 'split', 'duo'] as const) {
|
||||
let previous = Infinity
|
||||
for (let rows = 1; rows <= 12; rows++) {
|
||||
const font = fitLiveRowFontPx(rows, columns, results)
|
||||
assert.ok(font <= previous, `${columns} 栏 ${rows} 行`)
|
||||
const font = fitLiveRowFontPx(rows, layout, results)
|
||||
assert.ok(font <= previous, `${layout} ${rows} 行`)
|
||||
previous = font
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { EventResultGroup, EventResultRow } from '@/shared'
|
||||
import {
|
||||
LIVE_SPLIT_MIN_ROWS,
|
||||
MAX_LIVE_ROWS,
|
||||
fallbackLiveSources,
|
||||
liveBlockRowCount,
|
||||
liveBoardLayout,
|
||||
liveRefreshDelay,
|
||||
liveRowBlocks,
|
||||
liveRows,
|
||||
pickLiveColumns,
|
||||
} from '../live'
|
||||
@@ -132,6 +136,44 @@ test('栏内按名次排序、无名次排最后,并截断到上限', () => {
|
||||
assert.equal(liveRows(group(1, 1, null, many)).length, MAX_LIVE_ROWS)
|
||||
})
|
||||
|
||||
test('版式判定:一个项目行少就满宽一张表,行多拆两块,两个项目左一右一', () => {
|
||||
assert.equal(liveBoardLayout(1, 1), 'single')
|
||||
assert.equal(liveBoardLayout(1, LIVE_SPLIT_MIN_ROWS - 1), 'single')
|
||||
assert.equal(liveBoardLayout(1, LIVE_SPLIT_MIN_ROWS), 'split')
|
||||
assert.equal(liveBoardLayout(1, MAX_LIVE_ROWS), 'split')
|
||||
// 两个项目时本来就是一栏一块,不再二次拆分。
|
||||
assert.equal(liveBoardLayout(2, 1), 'duo')
|
||||
assert.equal(liveBoardLayout(2, MAX_LIVE_ROWS), 'duo')
|
||||
})
|
||||
|
||||
test('字号按「一块几行」算:拆两块后每块行数是整栏的一半(向上取整)', () => {
|
||||
assert.equal(liveBlockRowCount(MAX_LIVE_ROWS, 'split'), 5)
|
||||
assert.equal(liveBlockRowCount(LIVE_SPLIT_MIN_ROWS, 'split'), 4)
|
||||
assert.equal(liveBlockRowCount(8, 'split'), 4)
|
||||
assert.equal(liveBlockRowCount(9, 'split'), 5)
|
||||
// 不拆的版式就是整栏行数。
|
||||
assert.equal(liveBlockRowCount(6, 'single'), 6)
|
||||
assert.equal(liveBlockRowCount(4, 'single'), 4)
|
||||
assert.equal(liveBlockRowCount(MAX_LIVE_ROWS, 'duo'), MAX_LIVE_ROWS)
|
||||
})
|
||||
|
||||
test('拆两块:名次靠前的左块多一行,两块行数最多差 1,非 split 原样一块', () => {
|
||||
const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
assert.deepEqual(liveRowBlocks(ten, 'single'), [ten])
|
||||
assert.deepEqual(liveRowBlocks(ten, 'duo'), [ten])
|
||||
|
||||
const seven = liveRowBlocks(ten.slice(0, 7), 'split')
|
||||
assert.deepEqual(seven[0], [1, 2, 3, 4])
|
||||
assert.deepEqual(seven[1], [5, 6, 7])
|
||||
assert.deepEqual(liveRowBlocks(ten, 'split').map((block) => block.length), [5, 5])
|
||||
assert.deepEqual(liveRowBlocks(ten.slice(0, 8), 'split').map((block) => block.length), [4, 4])
|
||||
assert.deepEqual(liveRowBlocks(ten.slice(0, 9), 'split').map((block) => block.length), [5, 4])
|
||||
|
||||
// 行数不到拆分线时不能拆:否则右块空着,一屏只剩半张表有内容。
|
||||
assert.deepEqual(liveRowBlocks([1, 2, 3], 'split'), [[1, 2, 3]])
|
||||
assert.deepEqual(liveRowBlocks([], 'split'), [[]])
|
||||
})
|
||||
|
||||
test('接口还没成功过时的兜底素材:人工指定 id + 快照分组 + 小项名', () => {
|
||||
const names = new Map([[7, '男子 1500 米']])
|
||||
const sources = fallbackLiveSources([7, 3], [group(7), group(8)], names)
|
||||
@@ -271,3 +313,45 @@ test('成绩列宽由内容算出来,网格里不能写死列宽', () => {
|
||||
// 成绩不能折行:折成两行会被读成两个成绩,第二行还会被 .rows 竖着裁掉。
|
||||
assert.match(source, /\.rs \{[^}]*white-space: nowrap/s)
|
||||
})
|
||||
|
||||
test('单栏铺满页框:不再用 max-width 收窄居中', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
// 旧版式把卡片收窄到 1180px 居中,1920 的屏上左右各空 370px;而且收窄换不来更大的字
|
||||
// (字号由高度决定),属于纯浪费。铺满后左右只剩页框自带的边距。
|
||||
assert.doesNotMatch(source, /max-width: 1180px/)
|
||||
assert.match(source, /\.boards-1 \.board \{[^}]*max-width: none/s)
|
||||
})
|
||||
|
||||
test('版式接到渲染上:拆两块时表头跟着块走,字号按块内行数算', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
// 版式与分块都由 live.ts 判定,页面不许自己写死行数阈值。
|
||||
assert.match(source, /liveBoardLayout\(boards\.value\.length, rowCount\.value\)/)
|
||||
assert.match(source, /liveBlockRowCount\(rowCount\.value, layout\.value\)/)
|
||||
assert.match(source, /liveRowBlocks\(board\.rows, layout\)/)
|
||||
// 每一块都要有自己的表头,否则右块的名次/成绩没有列标题。
|
||||
assert.match(source, /v-for="\(block, index\) in liveRowBlocks[\s\S]*class="row head"/)
|
||||
assert.match(source, /v-for="row in block"/)
|
||||
// 拆块时行不再拉满整屏高(1 行时 96px 的字铺在 460px 行里只会显空)。
|
||||
assert.match(source, /max-height: calc\(var\(--row-font[^)]*\) \* 3\.2\)/)
|
||||
})
|
||||
|
||||
test('拆两块时的名次列必须装得下两位数', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
// 56px 字号下 `10` 的墨迹约 78px:名次列窄于 80px 会被挤进运动员列。
|
||||
const matched = /\.boards-1 \.rows\.split \.row \{[^}]*grid-template-columns: (\d+)px/s.exec(
|
||||
source,
|
||||
)
|
||||
assert.ok(matched !== null, '拆块版式要单独给一套网格')
|
||||
assert.ok(Number(matched[1]) >= 80, `名次列 ${matched[1]}px 装不下两位数`)
|
||||
// 两个项目的版式(duo)沿用改前的网格,别跟着一起动。
|
||||
assert.match(source, /\.boards-2 \.row \{[^}]*60px minmax\(0, 1fr\) 190px/s)
|
||||
})
|
||||
Reference in new issue
Block a user