feat: 公告持久化(底栏常驻)+ 团体可单独报名 + 榜单系数开关

公告:
- 服务端把当前公告落库到 settings(display.announce_text / announce_seconds),
  作为 display.announce 随 snapshot 与 /api/settings 下发(非 admin 快照也保留)
- announce 指令除转发外写库;新增 announce_clear 清空并广播;两者写审计
- realtime 协议登记 announce_clear(补上,使清除能即时推到大屏)
- 大屏把「全屏推送」与「底栏常驻」拆开:推送 seconds 秒后只退出全屏态,公告留在底栏,
  直到被替换或清除;新增公告状态机(可测)与底栏取值优先级
  推送中 > 服务端持久值 > 「暂无公告」;管理端加「清除公告」与当前公告状态

团体报名:
- EntryPicker 团体/对抗模式允许不勾队员直接报名该单位(此前必须勾人,导致
  「只想给单位报个团体项目」做不到、单位下没有运动员时完全报不了)
- 核实录入页团体行:显示/填名次/保存/发布/算分闭环无缺陷(用真实服务端跑过),
  抽出共享的 entryRowLabel 供两处复用

榜单口径:
- 基础数据 → 积分模板页新增「榜单口径(全局)」卡片:individualToTeamWeight
  数字输入(默认 1、0–100),说明「个人项目得分按该系数折算计入单位总分」,
  保存走 PUT /api/settings;因该值在服务端是全局键、模板表里没有此字段,
  故做成独立卡片并标注全局,不塞进模板表单

自检:服务端 11 包全绿;前端 vue-tsc + build、node --test 99/99。
This commit is contained in:
AskaEth committed 2026-09-10 11:06:58 +08:00
1 parent cded54e440
commit b5f40642ff
24 files changed
+1073 -67

No files matched your search

+5
View File
@@ -36,6 +36,8 @@ type EventSettingsDisplay struct {
Subtitle *string `json:"subtitle"`
PrimaryColor *string `json:"primaryColor"`
RotateSecond int `json:"rotateSeconds"`
// Announce 是当前公告正文,由大屏指令写入;null 表示无公告。
Announce *string `json:"announce"`
}
// EventSettingsStandings 是榜单口径配置。
@@ -51,6 +53,7 @@ type EventSettingsStandings struct {
// GET /api/settings 是 admin-only,而 /ws 快照对所有角色下发;guest/recorder
// 只需要渲染大屏的标题、主题与榜单开关,默认积分模板、审核开关与个人积分折算
// 系数属于系统配置,必须置空/清零后再上线。
// Display 整块保留:其中的公告(announce)就是给大屏底栏用的展示字段,裁剪掉它会丢公告。
func (s EventSettings) WithoutAdminFields() EventSettings {
return EventSettings{
Event: s.Event,
@@ -100,6 +103,8 @@ func ParseEventSettings(values map[string]string) EventSettings {
}
out.Display.PrimaryColor = out.Event.PrimaryColor
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
// 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。
out.Display.Announce = nilIfEmpty(values["display.announce_text"])
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
+46
View File
@@ -59,6 +59,52 @@ func TestEventSettingsWithoutAdminFields(t *testing.T) {
}
}
// 公告是给大屏底栏用的展示字段,非 admin 的快照也必须带上它。
func TestEventSettingsWithoutAdminFieldsKeepsAnnounce(t *testing.T) {
announce := "决赛推迟到 15:00"
full := EventSettings{Display: EventSettingsDisplay{Announce: &announce}}
got := full.WithoutAdminFields()
if got.Display.Announce == nil || *got.Display.Announce != announce {
t.Fatalf("非 admin 快照应保留公告,得到 %v", got.Display.Announce)
}
}
// 公告正文取自 settings 表的 display.announce_text(由大屏指令写入);
// 空串表示无公告,必须下发显式 null,底栏才能回落到「暂无公告」。
func TestParseEventSettingsAnnounce(t *testing.T) {
withText := ParseEventSettings(map[string]string{"display.announce_text": "决赛推迟到 15:00"})
if withText.Display.Announce == nil || *withText.Display.Announce != "决赛推迟到 15:00" {
t.Fatalf("公告应随 settings 下发,得到 %v", withText.Display.Announce)
}
empty := ParseEventSettings(map[string]string{"display.announce_text": ""})
if empty.Display.Announce != nil {
t.Fatalf("空串表示无公告,应解析为 null,得到 %q", *empty.Display.Announce)
}
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["announce"]) != "null" {
t.Fatalf("无公告时应显式序列化为 null: %s", raw)
}
}
// mustField 取出顶层字段的原始 JSON,供字段级断言使用。
func mustField(t *testing.T, raw []byte, key string) []byte {
t.Helper()
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
t.Fatalf("解析 %s: %v (%s)", key, err, raw)
}
return top[key]
}
// 单位/小项的 DTO 必须把可空列写成显式 null,不能沿用 domain 的 omitempty。
func TestTeamAndEventFromDomainNullableFields(t *testing.T) {
teamRaw, err := json.Marshal(TeamFromDomain(domain.Team{ID: 1, Code: "T1", Name: "第一单位"}))