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

+205
View File
@@ -0,0 +1,205 @@
package httpapi_test
import (
"net/http"
"strconv"
"testing"
"time"
)
type liveEventsBody struct {
Source string `json:"source"`
EventIDs []int64 `json:"eventIds"`
Events []struct {
Event struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"event"`
Reason string `json:"reason"`
Groups []struct {
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
CategoryName string
Rows []struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText string `json:"resultText"`
} `json:"rows"`
} `json:"groups"`
} `json:"events"`
}
type settingsBody struct {
Display struct {
LiveEvents []int64 `json:"liveEvents"`
LiveSource string `json:"liveSource"`
} `json:"display"`
}
// 设置读写:display.live_events / display.live_source 要能存取,非法值必须当场拒绝。
func TestSettingsLiveEventsRoundTrip(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
"display": map[string]any{"liveEvents": []int64{ids.event}, "liveSource": "manual"},
}, true); status != http.StatusOK {
t.Fatalf("保存实时赛况设置应 200,得到 %d %+v", status, e.Error)
}
status, e := env.do(client, http.MethodGet, "/api/settings", nil, false)
if status != http.StatusOK {
t.Fatalf("读取设置应 200,得到 %d %+v", status, e.Error)
}
got := decodeData[settingsBody](t, e)
if got.Display.LiveSource != "manual" || len(got.Display.LiveEvents) != 1 || got.Display.LiveEvents[0] != ids.event {
t.Fatalf("设置应原样读回,得到 %+v", got.Display)
}
// 库里以逗号分隔的 id 文本存储,随赛事包一起走。
var raw string
if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.live_events'`).Scan(&raw); err != nil {
t.Fatalf("读取存储值: %v", err)
}
if raw != strconv.FormatInt(ids.event, 10) {
t.Fatalf("display.live_events 应存逗号分隔 id,得到 %q", raw)
}
// 清空人工指定:传空数组。
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
"display": map[string]any{"liveEvents": []int64{}},
}, true); status != http.StatusOK {
t.Fatalf("清空人工指定应 200,得到 %d %+v", status, e.Error)
}
_, e = env.do(client, http.MethodGet, "/api/settings", nil, false)
if cleared := decodeData[settingsBody](t, e); len(cleared.Display.LiveEvents) != 0 {
t.Fatalf("清空后应回空数组,得到 %+v", cleared.Display.LiveEvents)
}
// 非法取值全部拒绝。
for _, patch := range []map[string]any{
{"display": map[string]any{"liveSource": "sometimes"}},
{"display": map[string]any{"liveEvents": []int64{ids.event, ids.event + 1, ids.event + 2}}},
{"display": map[string]any{"liveEvents": []int64{999999}}},
} {
status, e := env.do(client, http.MethodPut, "/api/settings", patch, true)
if status != http.StatusBadRequest || e.Error == nil || e.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("非法设置应 400 VALIDATION_FAILED,得到 %d %+v(%+v)", status, e.Error, patch)
}
}
}
// 当前项目的判定:manual 认人工指定;auto 先排期命中;都不命中回退到最近发布过成绩的项目;空赛事不炸。
func TestDisplayLiveEndpoint(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.createUser("admin", "admin-pass", "admin")
adminClient, _ := env.client()
env.login(adminClient, "admin", "admin-pass")
// 第二、三个小项:只用来验证人工指定(最多 2 个)。
other := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport)
other2 := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'M400', '400米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport)
guest, _ := env.client()
live := func() liveEventsBody {
t.Helper()
status, e := env.do(guest, http.MethodGet, "/api/display/live", nil, false)
if status != http.StatusOK || !e.OK {
t.Fatalf("读取实时赛况应 200,得到 %d %+v", status, e.Error)
}
return decodeData[liveEventsBody](t, e)
}
setDisplay := func(patch map[string]any) {
t.Helper()
if status, e := env.do(adminClient, http.MethodPut, "/api/settings",
map[string]any{"display": patch}, true); status != http.StatusOK {
t.Fatalf("保存设置应 200,得到 %d %+v", status, e.Error)
}
}
// 1) manual 只认人工指定(两个)。
setDisplay(map[string]any{"liveSource": "manual", "liveEvents": []int64{other, other2}})
got := live()
if got.Source != "manual" || len(got.EventIDs) != 2 || got.EventIDs[0] != other || got.EventIDs[1] != other2 {
t.Fatalf("manual 应只认人工指定的两个项目 %d/%d,得到 %+v", other, other2, got)
}
if len(got.Events) != 2 || got.Events[0].Reason != "manual" || got.Events[0].Event.ID != other {
t.Fatalf("manual 命中原因应为 manual,得到 %+v", got.Events)
}
if len(got.Events[0].Groups) != 0 {
t.Fatalf("还没发布成绩时应回空组,得到 %+v", got.Events[0].Groups)
}
// 2) auto:排期落在当前窗口内的小项命中。
scheduled := time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339)
env.exec(t, `UPDATE events SET scheduled_at = ? WHERE id = ?`, scheduled, ids.event)
setDisplay(map[string]any{"liveSource": "auto", "liveEvents": []int64{}})
got = live()
if got.Source != "auto" || len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event {
t.Fatalf("auto 应命中排期窗口内的小项 %d,得到 %+v", ids.event, got)
}
if got.Events[0].Reason != "scheduled" {
t.Fatalf("排期命中的原因应为 scheduled,得到 %+v", got.Events[0])
}
// 3) auto:排期都不命中时回退到最近发布过成绩的小项。
env.exec(t, `UPDATE events SET scheduled_at = NULL WHERE id = ?`, ids.event)
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
}
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
}
got = live()
if len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event {
t.Fatalf("无排期命中时应回退到最近发布的小项 %d,得到 %+v", ids.event, got)
}
if got.Events[0].Reason != "recent" {
t.Fatalf("回退的原因应为 recent,得到 %+v", got.Events[0])
}
if len(got.Events[0].Groups) != 1 || len(got.Events[0].Groups[0].Rows) != 1 {
t.Fatalf("最近发布的小项应带上已发布成绩(1 条),得到 %+v", got.Events[0].Groups)
}
if rank := got.Events[0].Groups[0].Rows[0].Rank; rank == nil || *rank != 1 {
t.Fatalf("已发布成绩应带名次,得到 %+v", got.Events[0].Groups[0].Rows[0])
}
}
// 全新赛事(一个小项都没有)时不能报错。
func TestDisplayLiveEmptyEvent(t *testing.T) {
env := newTestEnv(t)
client, _ := env.client()
status, e := env.do(client, http.MethodGet, "/api/display/live", nil, false)
if status != http.StatusOK || !e.OK {
t.Fatalf("空赛事读取实时赛况应 200,得到 %d %+v", status, e.Error)
}
got := decodeData[liveEventsBody](t, e)
if len(got.EventIDs) != 0 || len(got.Events) != 0 {
t.Fatalf("空赛事应回空列表,得到 %+v", got)
}
if got.Source != "auto" {
t.Fatalf("未配置时默认 auto,得到 %q", got.Source)
}
}