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
@@ -0,0 +1,209 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user