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,25 @@
|
||||
package dto
|
||||
|
||||
// LiveEvents 是 GET /api/display/live 的线上结构:大屏「实时赛况」页的当前项目及其成绩。
|
||||
//
|
||||
// 为什么单开一个接口而不是塞进 snapshot:当前项目的判定依赖「当前时间 + 排期 + 最近一次发布」,
|
||||
// 是随时间变化的量;快照只在变更时推送,装不下一个会自己过期的结论。
|
||||
// 两个设置键(display.live_events / display.live_source)仍然随 snapshot.settings 下发,
|
||||
// 大屏也可以只用 events / latestResults 自行推导。
|
||||
type LiveEvents struct {
|
||||
// Source 是本次生效的判定方式:manual 或 auto(来自 display.live_source)。
|
||||
Source string `json:"source"`
|
||||
// EventIDs 是判定出的当前项目 id,最多 MaxLiveEvents 个,按重要程度排序;空表示没有。
|
||||
EventIDs []int64 `json:"eventIds"`
|
||||
// Events 与 EventIDs 一一对应,带上小项信息与已发布成绩(名次已由发布时回写)。
|
||||
Events []LiveEvent `json:"events"`
|
||||
}
|
||||
|
||||
// LiveEvent 是一个当前项目。
|
||||
type LiveEvent struct {
|
||||
Event Event `json:"event"`
|
||||
// Reason 说明它为什么被选中:manual(人工指定)/ scheduled(排期命中)/ recent(最近发布)。
|
||||
Reason string `json:"reason"`
|
||||
// Groups 是该小项已发布成绩按「小项 + 组别」分好的组;还没发布过任何成绩时为空数组。
|
||||
Groups []EventResultGroup `json:"groups"`
|
||||
}
|
||||
@@ -42,6 +42,72 @@ type EventSettingsDisplay struct {
|
||||
// null 表示用内置默认字体,取值合法性由 httpapi 在写入时校验。
|
||||
FontTitle *string `json:"fontTitle"`
|
||||
FontBody *string `json:"fontBody"`
|
||||
// LiveEvents 是「实时赛况」页人工指定的当前项目 id,最多 MaxLiveEvents 个。
|
||||
// 库里以逗号分隔的 id 存 display.live_events;空数组表示没有人工指定。
|
||||
LiveEvents []int64 `json:"liveEvents"`
|
||||
// LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走
|
||||
// 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
|
||||
LiveSource string `json:"liveSource"`
|
||||
}
|
||||
|
||||
// 「大屏实时赛况」的两个设置键与取值约束。
|
||||
const (
|
||||
// SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。
|
||||
SettingKeyLiveEvents = "display.live_events"
|
||||
SettingKeyLiveSource = "display.live_source"
|
||||
// MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。
|
||||
MaxLiveEvents = 2
|
||||
// LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。
|
||||
LiveSourceAuto = "auto"
|
||||
LiveSourceManual = "manual"
|
||||
)
|
||||
|
||||
// ParseLiveEventIDs 解析 display.live_events 的逗号分隔 id 列表。
|
||||
//
|
||||
// 非法项(非数字 / 0 / 负数)直接丢弃:这个键可能被手工改过,
|
||||
// 让大屏因为一个坏 id 整块不显示,比忽略它更糟。
|
||||
func ParseLiveEventIDs(raw string) []int64 {
|
||||
out := make([]int64, 0, MaxLiveEvents)
|
||||
seen := make(map[int64]bool)
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil || id <= 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
if len(out) == MaxLiveEvents {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatLiveEventIDs 把 id 列表写成 display.live_events 的存储格式(逗号分隔,最多 MaxLiveEvents 个)。
|
||||
func FormatLiveEventIDs(ids []int64) string {
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, strconv.FormatInt(id, 10))
|
||||
if len(parts) == MaxLiveEvents {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// NormalizeLiveSource 归一 live_source;无法识别时返回空串,由调用方决定报错还是落默认。
|
||||
func NormalizeLiveSource(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", LiveSourceAuto:
|
||||
return LiveSourceAuto
|
||||
case LiveSourceManual:
|
||||
return LiveSourceManual
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// EventSettingsStandings 是榜单口径配置。
|
||||
@@ -112,6 +178,9 @@ func ParseEventSettings(values map[string]string) EventSettings {
|
||||
out.Display.FontBody = nilIfEmpty(values["display.font_body"])
|
||||
// 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。
|
||||
out.Display.Announce = nilIfEmpty(values["display.announce_text"])
|
||||
// 实时赛况的「当前项目」:人工指定 + 判定方式,读取侧(大屏快照与管理端)口径一致。
|
||||
out.Display.LiveEvents = ParseLiveEventIDs(values[SettingKeyLiveEvents])
|
||||
out.Display.LiveSource = NormalizeLiveSource(values[SettingKeyLiveSource])
|
||||
|
||||
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
||||
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user