Files
AskaEth 08a5740fb2 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 自定义大屏模板设计文档)。
2026-09-11 04:05:02 +08:00

210 lines
8.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// seedBatch 在 seed 的基础上补齐到 30 条报名项(第 1、2 条来自 seed),
// 供「现场分批比赛」场景使用:跑道只有 8 条,一次只跑完一批。
func seedBatch(t *testing.T, env *testEnv, ids seedIDs, total int) []int64 {
t.Helper()
entries := []int64{ids.entryA, ids.entryB}
for i := len(entries) + 1; i <= total; i++ {
team := ids.teamA
if i%2 == 0 {
team = ids.teamB
}
athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
fmt.Sprintf("运动员%02d", i), team)
entries = append(entries, env.insert(t,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, team, athlete))
}
return entries
}
// 批次草稿:前 filled 条填成绩,其余留空,全量发给服务端(与录入页 buildDraftPayload 一致)。
func batchDraft(entries []int64, filled, from int, times []float64) []map[string]any {
rows := make([]map[string]any, 0, len(entries)-from)
for i := from; i < len(entries); i++ {
row := map[string]any{"entryId": entries[i], "status": "ok"}
if i < filled {
value := times[i]
row["resultValue"] = value
row["resultText"] = fmt.Sprintf("%.2f", value)
row["unit"] = "s"
}
rows = append(rows, row)
}
return rows
}
func assertCount(t *testing.T, env *testEnv, query string, want int, args ...any) {
t.Helper()
var got int
if err := env.ev.QueryRow(query, args...).Scan(&got); err != nil {
t.Fatalf("统计失败: %v", err)
}
if got != want {
t.Fatalf("期望 %d,得到 %d(%s)", want, got, query)
}
}
func rankOfEntry(t *testing.T, env *testEnv, entryID int64) int {
t.Helper()
var rank int
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil {
t.Fatalf("读取名次: %v", err)
}
return rank
}
// 现场分批比赛:30 人报名,先跑完 8 人发布,再跑完 8 人发布。
// 关键要求:没跑/没录的行不阻塞发布、不被发布;已发布的先算名次积分,补发后重算要纳入先前批次。
func TestPartialPublishKeepsUnfinishedRowsDraft(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
entries := seedBatch(t, env, ids, 30)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 第一批:第 1–8 人跑完,成绩由慢到快;其余 22 人留空。
first := make([]float64, 30)
for i := range first {
first[i] = 20.0 + float64(i)*0.1
}
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
"results": batchDraft(entries, 8, 0, first),
}, 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("缺 22 条成绩时发布应成功(不报错),得到 %d %+v", status, e.Error)
}
// 只有 8 条被发布,其余 22 条仍是草稿。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event)
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 22, ids.event)
// 未录行必须保持空值:发布不能给它们塞任何成绩。
assertCount(t, env,
`SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft' AND result_value IS NULL AND rank IS NULL`,
22, ids.event)
for i := 0; i < 8; i++ {
if got := rankOfEntry(t, env, entries[i]); got != i+1 {
t.Fatalf("第一批第 %d 人应第 %d 名,得到 %d", i+1, i+1, got)
}
}
// 积分只按已发布的 8 人算:前六名 10+8+6+3+2+1=30 分。
assertCount(t, env,
`SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`,
30, ids.event)
// 第二批:第 9–16 人跑完且明显更快。已发布的 1–8 不能再存草稿,只补未发布的行。
second := make([]float64, 30)
for i := range second {
second[i] = 10.0 + float64(i)*0.1
}
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
"results": batchDraft(entries, 16, 8, second),
}, 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)
}
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 16, ids.event)
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 14, ids.event)
// 先前的 8 人必须被重算纳入:第二批更快,第一批整体退到第 9–16 名。
if got := rankOfEntry(t, env, entries[0]); got != 9 {
t.Fatalf("补发后第一批第一名应退到第 9 名(证明重算纳入了先前批次),得到 %d", got)
}
if got := rankOfEntry(t, env, entries[8]); got != 1 {
t.Fatalf("第二批最快者应第 1 名,得到 %d", got)
}
if got := rankOfEntry(t, env, entries[15]); got != 8 {
t.Fatalf("第二批最慢者应第 8 名,得到 %d", got)
}
// 16 人里仍只有前六名得分:30 分;流水不能因为重算而翻倍。
assertCount(t, env,
`SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`,
30, ids.event)
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
}
// 一条有效成绩 + 29 条空成绩:发布必须成功,且只有那一条上屏。
// 这也是「服务端是否会因缺成绩报错」的回归断言。
func TestPublishWithSingleFilledRow(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
entries := seedBatch(t, env, ids, 30)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
times := make([]float64, 30)
for i := range times {
times[i] = 12.0 + float64(i)*0.1
}
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
"results": batchDraft(entries, 1, 0, times),
}, true); status != http.StatusOK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
}
status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"categoryId": ids.category,
}, true)
if status != http.StatusOK || !e.OK {
t.Fatalf("缺 29 条成绩不是错误,发布应 200,得到 %d %+v", status, e.Error)
}
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event)
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 29, ids.event)
if got := rankOfEntry(t, env, entries[0]); got != 1 {
t.Fatalf("唯一有效成绩应第 1 名,得到 %d", got)
}
}
// DNS / DQ 这类「没有成绩但确实是现场结果」的行必须能发布(不能被当成空行留在草稿)。
func TestPublishKeepsNonOKStatusWithoutValue(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")
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": "dns"},
},
}, 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("含 DNS 的组应能发布,得到 %d %+v", status, e.Error)
}
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event)
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 0, ids.event)
}