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:
AskaEth committed 2026-09-11 04:05:02 +08:00
1 parent e5e0944d26
commit 08a5740fb2
64 files changed
+4968 -270

No files matched your search

+92
View File
@@ -2,6 +2,7 @@ package dto
import (
"encoding/json"
"strconv"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
@@ -154,6 +155,97 @@ func TestParseEventSettingsFonts(t *testing.T) {
}
}
// 「实时赛况」的两个设置键:live_events 存逗号分隔 id(最多 2 个),live_source 只认 auto/manual。
func TestParseLiveEventSettings(t *testing.T) {
got := ParseEventSettings(map[string]string{
SettingKeyLiveEvents: "12, 15",
SettingKeyLiveSource: "MANUAL",
})
if len(got.Display.LiveEvents) != 2 || got.Display.LiveEvents[0] != 12 || got.Display.LiveEvents[1] != 15 {
t.Fatalf("应解析出人工指定的两个 id,得到 %+v", got.Display.LiveEvents)
}
if got.Display.LiveSource != LiveSourceManual {
t.Fatalf("大小写应归一成 manual,得到 %q", got.Display.LiveSource)
}
// 缺键 / 空值:空数组 + 默认 auto。
empty := ParseEventSettings(map[string]string{})
if empty.Display.LiveEvents == nil || len(empty.Display.LiveEvents) != 0 {
t.Fatalf("缺键应下发空数组而不是 null,得到 %+v", empty.Display.LiveEvents)
}
if empty.Display.LiveSource != LiveSourceAuto {
t.Fatalf("缺键应默认 auto,得到 %q", empty.Display.LiveSource)
}
raw, err := json.Marshal(empty)
if err != nil {
t.Fatalf("序列化: %v", err)
}
var display map[string]json.RawMessage
if err := json.Unmarshal(mustField(t, raw, "display"), &display); err != nil {
t.Fatalf("解析 display: %v", err)
}
if string(display["liveEvents"]) != "[]" || string(display["liveSource"]) != `"auto"` {
t.Fatalf("缺键时应下发 [] 与 auto: %s", raw)
}
}
// 解析要能容忍手工改库留下的脏值:坏项丢弃、去重、超过 2 个截断。
func TestParseLiveEventIDs(t *testing.T) {
cases := []struct {
raw string
want string
}{
{"", ""},
{"12", "12"},
{"12,15", "12,15"},
{" 3 , 4 , 5 ", "3,4"},
{"7,7,8", "7,8"},
{"abc", ""},
{"0,-1,6", "6"},
}
for _, tc := range cases {
got := ParseLiveEventIDs(tc.raw)
joined := ""
for i, id := range got {
if i > 0 {
joined += ","
}
joined += strconv.FormatInt(id, 10)
}
if joined != tc.want {
t.Fatalf("ParseLiveEventIDs(%q) = %q,期望 %q", tc.raw, joined, tc.want)
}
}
}
func TestFormatLiveEventIDs(t *testing.T) {
if got := FormatLiveEventIDs([]int64{9, 11, 13}); got != "9,11" {
t.Fatalf("最多存 2 个,得到 %q", got)
}
if got := FormatLiveEventIDs(nil); got != "" {
t.Fatalf("空列表应存空串,得到 %q", got)
}
if got := FormatLiveEventIDs([]int64{0, -2, 5}); got != "5" {
t.Fatalf("非法 id 应丢弃,得到 %q", got)
}
}
func TestNormalizeLiveSource(t *testing.T) {
cases := map[string]string{
"": LiveSourceAuto,
"auto": LiveSourceAuto,
"AUTO": LiveSourceAuto,
"manual": LiveSourceManual,
" Manual": LiveSourceManual,
"x": "",
}
for raw, want := range cases {
if got := NormalizeLiveSource(raw); got != want {
t.Fatalf("NormalizeLiveSource(%q) = %q,期望 %q", raw, got, want)
}
}
}
// mustField 取出顶层字段的原始 JSON,供字段级断言使用。
func mustField(t *testing.T, raw []byte, key string) []byte {
t.Helper()