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,13 +1,20 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createLeaveGuard, resultCellIssue } from '../pages/entryGuards'
|
||||
import { createLeaveGuard, judgeEntryRow, resultCellIssue } from '../pages/entryGuards'
|
||||
|
||||
// 用户实机反馈:只写「缺少成绩」会让人去改本来就对的成绩格式,
|
||||
// 文案必须带上那一格的实际内容,并区分「空的 / 秒位越界 / 写法不认识」。
|
||||
test('空格子:文案说明成绩为空并带出「(空)」', () => {
|
||||
// 现场分批比赛后,空格子只是「还没录」,归为提示、不阻塞发布。
|
||||
test('空格子:文案说明还没录并带出「(空)」,且不阻塞发布', () => {
|
||||
const issue = resultCellIssue('第 3 行 张三', ' ', 's')
|
||||
assert.equal(issue?.kind, 'empty')
|
||||
assert.equal(issue?.message, '第 3 行 张三:成绩为空(当前「(空)」),请填写成绩')
|
||||
assert.equal(issue?.blocking, false)
|
||||
assert.equal(issue?.message, '第 3 行 张三:还没录成绩(当前「(空)」),本次发布不计入这一行')
|
||||
})
|
||||
|
||||
test('写错的成绩阻塞发布:秒位越界与写法不认识都是 blocking', () => {
|
||||
assert.equal(resultCellIssue('第 3 行 张三', '1:70', 's')?.blocking, true)
|
||||
assert.equal(resultCellIssue('第 3 行 张三', '约1分06秒', 's')?.blocking, true)
|
||||
})
|
||||
|
||||
test('秒位 ≥ 60:文案带出原内容和可用的写法', () => {
|
||||
@@ -53,6 +60,61 @@ test('距离 / 计数项目的文案不套用时间格式提示', () => {
|
||||
assert.ok(!issue?.message.includes('1:06'), '不该拿时间格式去教距离项目')
|
||||
})
|
||||
|
||||
// 三类行徽标:已填 / 未录 / 有错。空成绩不再算错误,但要有提示。
|
||||
test('行状态三类互斥:空成绩=未录(提示),写错=有错(阻塞)', () => {
|
||||
const missing = judgeEntryRow({
|
||||
label: '第 22 行 赵六', resultText: ' ', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(missing.state, 'missing')
|
||||
assert.equal(missing.blocker, null)
|
||||
assert.ok(missing.warning?.includes('还没录成绩'), missing.warning ?? '')
|
||||
|
||||
const error = judgeEntryRow({
|
||||
label: '第 3 行 张三', resultText: '1:70', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(error.state, 'error')
|
||||
assert.ok(error.blocker?.includes('秒位不能 ≥ 60'), error.blocker ?? '')
|
||||
|
||||
const filled = judgeEntryRow({
|
||||
label: '第 1 行 李四', resultText: '66.33', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(filled.state, 'filled')
|
||||
assert.equal(filled.blocker, null)
|
||||
})
|
||||
|
||||
test('非 ok 状态(DNS/DQ/DNF/退赛)没有数值也算已填', () => {
|
||||
for (const status of ['dns', 'dq', 'dnf', 'withdrawn']) {
|
||||
const row = judgeEntryRow({
|
||||
label: '第 2 行 王五', resultText: '', unit: 's', status, rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(row.state, 'filled', status)
|
||||
assert.equal(row.warning, null)
|
||||
}
|
||||
})
|
||||
|
||||
test('手动名次项目:只填名次即可发布,缺名次才阻塞', () => {
|
||||
const rankOnly = judgeEntryRow({
|
||||
label: '第 4 行 篮球', resultText: '', unit: '', status: 'ok', rank: 1, needsRank: true,
|
||||
})
|
||||
assert.equal(rankOnly.state, 'filled')
|
||||
assert.equal(rankOnly.blocker, null)
|
||||
|
||||
const noRank = judgeEntryRow({
|
||||
label: '第 4 行 篮球', resultText: '', unit: '', status: 'ok', rank: null, needsRank: true,
|
||||
})
|
||||
assert.equal(noRank.state, 'error')
|
||||
assert.ok(noRank.blocker?.includes('缺少名次'), noRank.blocker ?? '')
|
||||
})
|
||||
|
||||
test('成绩 ≤ 0 只提示不挡:状态仍是已填', () => {
|
||||
const row = judgeEntryRow({
|
||||
label: '第 5 行 田径', resultText: '0', unit: 's', status: 'ok', rank: null, needsRank: false,
|
||||
})
|
||||
assert.equal(row.state, 'filled')
|
||||
assert.equal(row.blocker, null)
|
||||
assert.ok(row.warning?.includes('≤ 0'), row.warning ?? '')
|
||||
})
|
||||
|
||||
// 路由离开守卫:dirty 为 true 时必须拦下,确认后重放那一次必须放行(否则死循环)。
|
||||
test('未保存时离开被拦下,确认后重放放行', () => {
|
||||
let dirty = true
|
||||
|
||||
Reference in new issue
Block a user