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
@@ -214,6 +214,59 @@ func boolDefault(v string, def bool) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 大屏公告 ----------
|
||||
|
||||
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
|
||||
// 而 PUT /api/settings 是 admin-only,公告的持久化只能走指令这一侧。
|
||||
const (
|
||||
announceTextKey = "display.announce_text"
|
||||
announceSecondsKey = "display.announce_seconds"
|
||||
defaultAnnounceSeconds = 20
|
||||
)
|
||||
|
||||
// saveAnnouncement 落库当前公告正文与全屏推送时长;text 为空串即「无公告」。
|
||||
func (s *server) saveAnnouncement(ctx context.Context, text string, seconds int, actorID int64) error {
|
||||
if seconds <= 0 {
|
||||
seconds = defaultAnnounceSeconds
|
||||
}
|
||||
return s.writeAnnouncement(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds))
|
||||
}
|
||||
|
||||
// clearAnnouncement 清空当前公告;全屏时长保留,供下次推送沿用。
|
||||
func (s *server) clearAnnouncement(ctx context.Context, actorID int64) error {
|
||||
return s.writeAnnouncement(ctx, actorID, announceTextKey, "")
|
||||
}
|
||||
|
||||
// writeAnnouncement 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。
|
||||
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到公告变化。
|
||||
func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...string) error {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||||
kv[i], kv[i+1]); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
snapshot, err := s.settingsSnapshotQ(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- standings ----------
|
||||
|
||||
func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) {
|
||||
|
||||
Reference in new issue
Block a user