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

@@ -0,0 +1,3 @@
-- 批次是「组别之下」的录入 / 展示分组,不参与算分(排名与积分仍按「小项 + 组别」算)。
-- 历史报名没有批次,允许为空;空值(NULL)表示「未分批」。
ALTER TABLE entries ADD COLUMN heat INTEGER;
+87
View File
@@ -529,3 +529,90 @@ func TestMigration0018AddsDeletedAtForExistingTeams(t *testing.T) {
t.Fatalf("清 deleted_at: %v", err)
}
}
// 老库升级:0019 只给 entries 补一列 heat(可空),已有报名一行不少、批次一律为空(未分批)。
// 批次只是录入 / 展示分组,迁移不能改动任何成绩或积分数据。
func TestMigration0019AddsHeatForExistingEntries(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0019")
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
VALUES (1, 1, 1, '11.58', 11.58, 'published')`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
var probe sql.NullInt64
if err := db.QueryRow(`SELECT heat FROM entries`).Scan(&probe); err == nil {
t.Fatal("升级前不该有 heat 列")
}
// 走真实升级路径;ALTER TABLE ADD COLUMN 只登记一次,重放会报 duplicate column。
for i := 1; i <= 2; i++ {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 %d 次迁移: %v", i, err)
}
}
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
for table, want := range map[string]int{
"entries": 1, "results": 1, "score_ledger": 1,
} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
// 老报名一律「未分批」,成绩与积分原样保留。
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if probe.Valid {
t.Errorf("老报名的 heat 应为空(未分批),得到 %d", probe.Int64)
}
var (
rank int
state string
)
if err := db.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
t.Fatalf("读成绩: %v", err)
}
if rank != 1 || state != "published" {
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
}
var points float64
if err := db.QueryRow(`SELECT points FROM score_ledger WHERE entry_id = 1`).Scan(&points); err != nil {
t.Fatalf("读流水: %v", err)
}
if points != 10 {
t.Errorf("老积分被改动: %v", points)
}
// 新列可写可清。
if _, err := db.Exec(`UPDATE entries SET heat = 3 WHERE id = 1`); err != nil {
t.Fatalf("写 heat: %v", err)
}
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if !probe.Valid || probe.Int64 != 3 {
t.Errorf("写入的 heat 未生效: %+v", probe)
}
if _, err := db.Exec(`UPDATE entries SET heat = NULL WHERE id = 1`); err != nil {
t.Fatalf("清 heat: %v", err)
}
}