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:
1 parent
cded54e440
commit
b5f40642ff
24 files changed
+1073
-67
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
)
|
||||
|
||||
// ---------- 成绩 ----------
|
||||
@@ -197,6 +198,22 @@ func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// ---------- 大屏控制与运维 ----------
|
||||
|
||||
// cmdAnnounceClear 是清空当前公告的指令;实时层的协议清单(realtime.ValidDisplayAction)还没登记它,
|
||||
// 因此转发前要先看实时层认不认,见 displayCommandHandler。
|
||||
const cmdAnnounceClear = "announce_clear"
|
||||
|
||||
// announceFromPayload 从 announce 指令的 payload 里取公告正文与全屏时长;
|
||||
// payload 由 JSON 解出,缺失的字段按零值处理,由落库侧补默认时长。
|
||||
func announceFromPayload(payload any) (string, int) {
|
||||
fields, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return "", 0
|
||||
}
|
||||
text, _ := fields["text"].(string)
|
||||
seconds, _ := fields["seconds"].(float64)
|
||||
return text, int(seconds)
|
||||
}
|
||||
|
||||
func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in displayCommandInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
@@ -205,11 +222,29 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
|
||||
if in.Action == "" {
|
||||
return nil, badRequest("action 必填", nil)
|
||||
}
|
||||
if s.display != nil {
|
||||
// 清空公告必须落库,即使实时层尚不认这个 action(转发会被 NewDisplayCommand 拒收),
|
||||
// 否则「撤销公告」会整体失败;协议登记后这里的转发自动生效。
|
||||
if s.display != nil && (in.Action != cmdAnnounceClear || realtime.ValidDisplayAction(in.Action)) {
|
||||
if err := s.display.SendDisplayCommand(in.Action, in.Payload); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
}
|
||||
// 公告要落库:只转发不落库的话,全屏推送结束(seconds 秒)后底栏会回落到「暂无公告」,
|
||||
// 新连上的大屏也拿不到当前公告。
|
||||
actorID := middlewareUserID(r)
|
||||
switch in.Action {
|
||||
case realtime.CmdAnnounce:
|
||||
text, seconds := announceFromPayload(in.Payload)
|
||||
if err := s.saveAnnouncement(r.Context(), text, seconds, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case cmdAnnounceClear:
|
||||
if err := s.clearAnnouncement(r.Context(), actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 审计沿用同一条 action:现场按 action=display.command 检索的既有查询不受影响,
|
||||
// after 里已经带了这次的具体 action 与公告正文。
|
||||
s.audit(r.Context(), "display.command", "display", 0, nil, in)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user