fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:
导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向
导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)
错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)
大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)
文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)
自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
This commit is contained in:
1 parent
a52e6a4a3b
commit
8dd599bd97
39 files changed
+1308
-106
No files matched your search
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
|
||||
import { fitLiveRowFontPx, liveResultColumnPx } from '../../fitFont'
|
||||
import { LIVE_REFRESH_INTERVAL_MS, fallbackLiveSources, pickLiveColumns } from '../../live'
|
||||
import type { LiveEventSource } from '../../live'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
@@ -42,11 +42,24 @@ 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)
|
||||
})
|
||||
// 字号取行数最多的那一栏,两栏行高才一致;成绩列宽再反过来给字号封顶(见 fitFont.ts)。
|
||||
const rowCount = computed(() =>
|
||||
boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0),
|
||||
)
|
||||
|
||||
// 成绩列宽与字号互相牵制,所以由页面把一屏的成绩文本收齐了一次算完:行数 → 基准字号,
|
||||
// 文本宽度 → 字号上限,字号 + 文本宽度 → 列宽。两栏各算各的会让两栏列宽不一致。
|
||||
const resultTexts = computed(() =>
|
||||
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
|
||||
)
|
||||
const rowFontPx = computed(() =>
|
||||
fitLiveRowFontPx(rowCount.value, boards.value.length, resultTexts.value),
|
||||
)
|
||||
|
||||
const boardStyle = computed(() => ({
|
||||
'--row-font': `${rowFontPx.value}px`,
|
||||
'--rs-width': `${liveResultColumnPx(resultTexts.value, boards.value.length, rowFontPx.value)}px`,
|
||||
}))
|
||||
|
||||
// 定时兜底:项目切换靠 scheduled_at 自己过期,快照不会为时间流逝推送,所以必须有这条轮询。
|
||||
// 只在本页显示时跑(锁在别的页上时没必要打服务端);切回本页时挂载会立刻拉一次。
|
||||
@@ -70,7 +83,7 @@ onUnmounted(() => {
|
||||
v-if="boards.length > 0"
|
||||
class="boards"
|
||||
:class="`boards-${boards.length}`"
|
||||
:style="{ '--row-font': rowFont }"
|
||||
:style="boardStyle"
|
||||
>
|
||||
<article v-for="board in boards" :key="board.eventId" class="board">
|
||||
<header class="board-head">
|
||||
@@ -165,7 +178,9 @@ h3 {
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr 300px 220px;
|
||||
/* 成绩列宽由内容算(--rs-width,见 fitFont.ts):写死列宽时 56px 字号的 `1:09.09`
|
||||
墨迹有 201px,超出的部分会被 .rows 的 overflow:hidden 裁掉。名字列可省略号截断,先让位。 */
|
||||
grid-template-columns: 90px minmax(0, 1fr) 300px var(--rs-width, 220px);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1 1 0;
|
||||
@@ -177,7 +192,8 @@ h3 {
|
||||
animation: row-in 0.45s ease-out both;
|
||||
}
|
||||
.boards-2 .row {
|
||||
grid-template-columns: 60px 1fr 190px 150px;
|
||||
/* 两栏时名次 / 单位也窄一档,成绩列同样跟着内容走。 */
|
||||
grid-template-columns: 60px minmax(0, 1fr) 190px var(--rs-width, 150px);
|
||||
gap: 8px;
|
||||
}
|
||||
.row:last-child {
|
||||
@@ -230,6 +246,8 @@ h3 {
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* 成绩绝不折行:折成两行会被读成两个成绩,且第二行会被 .rows 的 overflow:hidden 竖着裁掉。 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.board-empty,
|
||||
.empty {
|
||||
|
||||
+113
-4
@@ -4,15 +4,124 @@
|
||||
*/
|
||||
export const BOARD_AVAILABLE_PX = 560
|
||||
|
||||
/** 行字号上限:大屏一行数字的观感上限,与行数无关的硬顶。 */
|
||||
export const MAX_ROW_FONT_PX = 56
|
||||
|
||||
/** 行字号下限:再小就 10 米外看不清,宁可截断也不能比这更小。 */
|
||||
export const MIN_ROW_FONT_PX = 16
|
||||
|
||||
/** 没有行可排(还没数据)时的兜底字号。 */
|
||||
const EMPTY_ROW_FONT_PX = 48
|
||||
|
||||
/**
|
||||
* 行数不可控,字号必须跟着算,否则单位一多就会把最后一行顶到轮播条下面。
|
||||
*
|
||||
* 行高由 flex 均分,这里按「可用高度 ÷ 行数 × 比例」给出字号,
|
||||
* 比例留出 padding 与行距,保证文字始终装得下。
|
||||
*/
|
||||
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
|
||||
if (count <= 0) return '48px'
|
||||
export function fitRowFontPx(count: number, availablePx: number, ratio = 0.62): number {
|
||||
if (count <= 0) return EMPTY_ROW_FONT_PX
|
||||
const perRow = availablePx / count
|
||||
const size = Math.min(56, Math.max(16, perRow * ratio))
|
||||
return `${Math.floor(size)}px`
|
||||
return Math.floor(Math.min(MAX_ROW_FONT_PX, Math.max(MIN_ROW_FONT_PX, perRow * ratio)))
|
||||
}
|
||||
|
||||
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
|
||||
return `${fitRowFontPx(count, availablePx, ratio)}px`
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时赛况一屏分几栏 → 行高比例。
|
||||
*
|
||||
* 与 LivePage.vue 的 .boards-1 / .boards-2 网格同源:两栏时每栏只有半屏宽,
|
||||
* 行高(也就是字号)要再收一档。
|
||||
*/
|
||||
export function liveRowFontRatio(columns: number): number {
|
||||
return columns > 1 ? 0.48 : 0.62
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩列宽度下限(px),与 LivePage.vue 网格里原来的写死列宽一致。
|
||||
*
|
||||
* 保留下限是为了「短成绩时观感不变」:`59.99` 这类文本本来就装得下,列宽不该因为
|
||||
* 改成按文本算就抖一下。
|
||||
*/
|
||||
export function liveResultColumnFloorPx(columns: number): number {
|
||||
return columns > 1 ? 150 : 220
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩列宽度预算(px):成绩列最多占这么宽,再宽就得靠收字号解决。
|
||||
*
|
||||
* 两栏时一行可用宽约 815px,扣掉名次 60 / 单位 190 / 间距 24 后剩 541px 由「运动员 + 成绩」
|
||||
* 分。预算 240px 给成绩,运动员列还能留 300px(56px 字号下约 5 个汉字);名字本来就是
|
||||
* 可以省略号截断的次要信息,末位成绩被裁才是不能接受的(`1:09.0` 会被读成另一个成绩)。
|
||||
*/
|
||||
export function liveResultColumnBudgetPx(columns: number): number {
|
||||
return columns > 1 ? 240 : 340
|
||||
}
|
||||
|
||||
/** 估宽时额外留的像素:抹掉 ceil 与亚像素误差,避免差 1px 又把末位数字裁掉。 */
|
||||
const RESULT_COLUMN_PAD_PX = 4
|
||||
|
||||
/**
|
||||
* 成绩文本的估算墨迹宽(em = 墨迹宽 / 字号)。
|
||||
*
|
||||
* 纯函数里量不到真实字形,所以按字符类别取四种内置字体里最宽的那个再上浮一点:
|
||||
* 数字 0.70(MiSans 的 `0`/`8` 实测 0.6786,最宽)、小数点与冒号 0.34(实测 0.3393)、
|
||||
* 拉丁字母 0.85(实测最宽的 `D` 是 0.8036)、空白 0.30、其余(汉字 / 破折号等)1.00
|
||||
* (汉字实测 1.00)。取值只许偏大:偏大多留几像素,偏小就会重新裁掉末位数字。
|
||||
*/
|
||||
export function resultTextEm(text: string): number {
|
||||
let em = 0
|
||||
for (const ch of text) {
|
||||
if (ch >= '0' && ch <= '9') em += 0.7
|
||||
else if (ch === ':' || ch === '.' || ch === ',') em += 0.34
|
||||
else if (ch === ' ') em += 0.3
|
||||
else if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')) em += 0.85
|
||||
else em += 1
|
||||
}
|
||||
return em
|
||||
}
|
||||
|
||||
/** 一屏所有成绩里最宽的那个(em);页面一次算全页,各行列宽才不会左右错开。 */
|
||||
function widestResultEm(results: readonly string[]): number {
|
||||
let widest = 0
|
||||
for (const text of results) widest = Math.max(widest, resultTextEm(text))
|
||||
return widest
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时赛况的行字号(px):行数定基准,成绩列宽只做上限。
|
||||
*
|
||||
* 真机缺陷:两栏时成绩列写死 150px,而 `1:09.09` 在 56px 字号下墨迹 201px,末位数字被外层
|
||||
* overflow:hidden 裁掉;1–6 行时字号都大于 41px(150px 列的容限),于是全中招。这里把
|
||||
* 「装不下就收字号」当兜底:典型成绩(4.2em)在 240px 预算下仍能取满 56px,靠加宽成绩列解决;
|
||||
* 只有 `64:21.50` 这类特别长的成绩才真的收字号。
|
||||
*/
|
||||
export function fitLiveRowFontPx(
|
||||
rowCount: number,
|
||||
columns: number,
|
||||
results: readonly string[],
|
||||
): number {
|
||||
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, liveRowFontRatio(columns))
|
||||
const widest = widestResultEm(results)
|
||||
if (widest <= 0) return base
|
||||
const byWidth = Math.floor((liveResultColumnBudgetPx(columns) - RESULT_COLUMN_PAD_PX) / widest)
|
||||
return Math.max(MIN_ROW_FONT_PX, Math.min(base, byWidth))
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时赛况的成绩列宽(px):当前字号下最宽成绩的估算墨迹 + 余量,下限是原来的写死列宽。
|
||||
*
|
||||
* 由内容定宽是必须的——成绩文本长短不可控(`59.99` 与 `64:21.50` 差一倍),写死就一定会裁。
|
||||
* 全页取一个宽度而不是逐行 max-content:每行是各自独立的网格,逐行按内容撑宽会让
|
||||
* 「单位」列的左边界一行一个位置(实测偏移可达 14px 以上),大屏上很显眼。
|
||||
*/
|
||||
export function liveResultColumnPx(
|
||||
results: readonly string[],
|
||||
columns: number,
|
||||
fontPx: number,
|
||||
): number {
|
||||
const need = Math.ceil(widestResultEm(results) * fontPx) + RESULT_COLUMN_PAD_PX
|
||||
return Math.max(liveResultColumnFloorPx(columns), need)
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { BOARD_AVAILABLE_PX, fitRowFont } from '../fitFont'
|
||||
import {
|
||||
BOARD_AVAILABLE_PX,
|
||||
MAX_ROW_FONT_PX,
|
||||
fitLiveRowFontPx,
|
||||
fitRowFont,
|
||||
liveResultColumnBudgetPx,
|
||||
liveResultColumnFloorPx,
|
||||
liveResultColumnPx,
|
||||
resultTextEm,
|
||||
} from '../fitFont'
|
||||
|
||||
test('fitRowFont 空列表用固定字号', () => {
|
||||
assert.equal(fitRowFont(0, BOARD_AVAILABLE_PX), '48px')
|
||||
@@ -13,3 +22,85 @@ test('fitRowFont 行数少时封顶 56px', () => {
|
||||
test('fitRowFont 行数多时守住 16px 下限', () => {
|
||||
assert.equal(fitRowFont(100, BOARD_AVAILABLE_PX), '16px')
|
||||
})
|
||||
|
||||
// 真机实测(Noto Sans SC、56px):`1:09.09` 墨迹 201px、`12.345 米` 252px、`64:21.50` 234px。
|
||||
// 估算一旦小于实测,末位数字就会重新被 .rows 的 overflow:hidden 裁掉(屏上是 `1:09.0`)。
|
||||
test('成绩估宽按实测最宽的字体上浮,宁可偏大', () => {
|
||||
assert.ok(resultTextEm('1:09.09') * 56 >= 201)
|
||||
assert.ok(resultTextEm('12.345 米') * 56 >= 252)
|
||||
assert.ok(resultTextEm('64:21.50') * 56 >= 234)
|
||||
// MiSans 的数字比 Noto 宽(`0` 实测 0.6786em),按最宽的那个估。
|
||||
assert.ok(resultTextEm('0:00.00') >= 4)
|
||||
assert.equal(resultTextEm(''), 0)
|
||||
})
|
||||
|
||||
test('两栏 1–10 行:字号封顶 56px、墨迹一律装得进算出来的列宽', () => {
|
||||
const results = ['1:06.33', '1:09.09', '1:10.55', '59.99']
|
||||
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)
|
||||
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 更窄`)
|
||||
assert.ok(widest * font <= column, `${rows} 行仍会裁`)
|
||||
}
|
||||
})
|
||||
|
||||
test('两栏 1–7 行才需要加宽成绩列,8 行以上仍用原来的 150px', () => {
|
||||
const results = ['1:06.33']
|
||||
const widest = resultTextEm('1:06.33')
|
||||
for (let rows = 1; rows <= 7; rows++) {
|
||||
const font = fitLiveRowFontPx(rows, 2, results)
|
||||
// 写死 150px 时这些行数真的会裁:这正是本次要修的真机缺陷。
|
||||
assert.ok(widest * font > liveResultColumnFloorPx(2), `${rows} 行`)
|
||||
assert.ok(liveResultColumnPx(results, 2, 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)
|
||||
}
|
||||
})
|
||||
|
||||
test('长成绩:先加宽列,装不下才收字号,且不越预算', () => {
|
||||
const results = ['64:21.50']
|
||||
const font = fitLiveRowFontPx(1, 2, results)
|
||||
assert.ok(font < MAX_ROW_FONT_PX, '特别长的成绩要让出字号')
|
||||
assert.ok(font >= 40, '让出的幅度不该把大屏字变小')
|
||||
const column = liveResultColumnPx(results, 2, font)
|
||||
assert.ok(resultTextEm('64:21.50') * font <= column)
|
||||
assert.ok(column <= liveResultColumnBudgetPx(2))
|
||||
|
||||
// 典型成绩(7 字符)不该因为这条上限被收字号。
|
||||
assert.equal(fitLiveRowFontPx(1, 2, ['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('没有行可排时退回固定字号与列宽下限', () => {
|
||||
assert.equal(fitLiveRowFontPx(0, 2, []), 48)
|
||||
assert.equal(liveResultColumnPx([], 2, 56), liveResultColumnFloorPx(2))
|
||||
assert.equal(liveResultColumnPx([], 1, 56), liveResultColumnFloorPx(1))
|
||||
})
|
||||
|
||||
test('行数越多字号只减不增', () => {
|
||||
const results = ['1:06.33', 'DNF', '12.35米']
|
||||
for (const columns of [1, 2]) {
|
||||
let previous = Infinity
|
||||
for (let rows = 1; rows <= 12; rows++) {
|
||||
const font = fitLiveRowFontPx(rows, columns, results)
|
||||
assert.ok(font <= previous, `${columns} 栏 ${rows} 行`)
|
||||
previous = font
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -254,3 +254,20 @@ test('页面按栏数切 class,三种文案都不留白', () => {
|
||||
assert.match(source, /liveFeed/)
|
||||
assert.match(source, /onMounted\(\(\) => \{\s*void refreshLiveEvents\(\)/)
|
||||
})
|
||||
|
||||
test('成绩列宽由内容算出来,网格里不能写死列宽', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
// 真机缺陷:两栏写死 150px,56px 字号的 `1:09.09` 墨迹 201px,末位数字被裁掉。
|
||||
// 列宽改成由 fitFont.ts 按成绩文本算(--rs-width),字号也要经过同一个口径。
|
||||
assert.doesNotMatch(source, /grid-template-columns: 60px 1fr 190px 150px/)
|
||||
assert.doesNotMatch(source, /grid-template-columns: 90px 1fr 300px 220px/)
|
||||
assert.match(source, /var\(--rs-width, 150px\)/)
|
||||
assert.match(source, /var\(--rs-width, 220px\)/)
|
||||
assert.match(source, /fitLiveRowFontPx/)
|
||||
assert.match(source, /liveResultColumnPx/)
|
||||
// 成绩不能折行:折成两行会被读成两个成绩,第二行还会被 .rows 竖着裁掉。
|
||||
assert.match(source, /\.rs \{[^}]*white-space: nowrap/s)
|
||||
})
|
||||
Reference in new issue
Block a user