From b5f40642ff9e234f62894aa1b8382175f2c6647f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 10 Sep 2026 11:06:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E5=91=8A=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=EF=BC=88=E5=BA=95=E6=A0=8F=E5=B8=B8=E9=A9=BB=EF=BC=89?= =?UTF-8?q?+=20=E5=9B=A2=E4=BD=93=E5=8F=AF=E5=8D=95=E7=8B=AC=E6=8A=A5?= =?UTF-8?q?=E5=90=8D=20+=20=E6=A6=9C=E5=8D=95=E7=B3=BB=E6=95=B0=E5=BC=80?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公告: - 服务端把当前公告落库到 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。 --- server/internal/dto/settings.go | 5 + server/internal/dto/settings_test.go | 46 ++++ server/internal/httpapi/handlers_results.go | 37 +++- .../internal/httpapi/handlers_results_test.go | 119 +++++++++++ server/internal/httpapi/repo_misc.go | 53 +++++ server/internal/httpapi/system_test.go | 2 +- server/internal/realtime/protocol.go | 17 +- .../admin/components/baseData/EntryPicker.vue | 18 +- .../components/baseData/ProfilesSection.vue | 105 ++++++++- .../components/baseData/entrySelection.ts | 24 ++- .../admin/components/baseData/useBaseData.ts | 43 +++- web/src/admin/pages/DisplayPage.vue | 88 ++++++-- web/src/admin/pages/EntryPage.vue | 4 +- .../admin/tests/base-data-settings.test.ts | 72 +++++++ web/src/admin/tests/base-data.test.ts | 29 ++- web/src/admin/tests/entry-selection.test.ts | 32 +++ web/src/admin/tests/scoring.test.ts | 42 ++++ web/src/display/announce.ts | 103 +++++++++ web/src/display/components/TickerBar.vue | 4 +- .../components/pages/AnnouncementPage.vue | 4 +- web/src/display/guards.ts | 4 + web/src/display/tests/announce.test.ts | 200 ++++++++++++++++++ web/src/display/useScoreboard.ts | 83 +++++--- web/src/shared/types.ts | 6 + 24 files changed, 1073 insertions(+), 67 deletions(-) create mode 100644 web/src/admin/tests/base-data-settings.test.ts create mode 100644 web/src/display/announce.ts create mode 100644 web/src/display/tests/announce.test.ts diff --git a/server/internal/dto/settings.go b/server/internal/dto/settings.go index 4960cab..54971c5 100644 --- a/server/internal/dto/settings.go +++ b/server/internal/dto/settings.go @@ -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") diff --git a/server/internal/dto/settings_test.go b/server/internal/dto/settings_test.go index 0480751..4963026 100644 --- a/server/internal/dto/settings_test.go +++ b/server/internal/dto/settings_test.go @@ -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: "第一单位"})) diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index a746868..d1d70eb 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -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 } diff --git a/server/internal/httpapi/handlers_results_test.go b/server/internal/httpapi/handlers_results_test.go index fe82278..13a3e29 100644 --- a/server/internal/httpapi/handlers_results_test.go +++ b/server/internal/httpapi/handlers_results_test.go @@ -3,8 +3,32 @@ package httpapi_test import ( "net/http" "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" ) +// strictDisplaySender 与 main.go 的 displaySender 同口径:经实时层校验 action,未知 action 直接报错。 +type strictDisplaySender struct{ sent []string } + +func (d *strictDisplaySender) SendDisplayCommand(action string, payload any) error { + if _, err := realtime.NewDisplayCommand(action, payload); err != nil { + return err + } + d.sent = append(d.sent, action) + return nil +} + +// settingValue 读取 settings 表里的点号键。 +func settingValue(t *testing.T, env *testEnv, key string) string { + t.Helper() + var v string + if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v); err != nil { + t.Fatalf("读取配置 %s: %v", key, err) + } + return v +} + // 个人榜的单位展示字段必须取全量单位表:停用但有积分的单位也要显示名称与简称。 func TestAthleteStandingsUsesFullTeamTable(t *testing.T) { env := newTestEnv(t) @@ -87,3 +111,98 @@ func TestAthleteStandingsIncludesTeamLogo(t *testing.T) { t.Fatalf("无 logo 的单位,个人行 logoPath 应为 null,得到 %+v", seen[ids.athB]) } } + +// 公告必须落库:只转发的话全屏推送结束(seconds 秒)后底栏会回落到「暂无公告」, +// 新连上的大屏也拿不到当前公告。 +func TestDisplayAnnouncePersistsAndClears(t *testing.T) { + sender := &strictDisplaySender{} + env := newTestEnvWith(t, func(o *httpapi.Options) { o.Display = sender }) + env.createUser("admin", "admin-pass", "admin") + env.createUser("recorder", "recorder-pass", "recorder") + recorder, _ := env.client() + env.login(recorder, "recorder", "recorder-pass") + admin, _ := env.client() + env.login(admin, "admin", "admin-pass") + + // 公告的推送权限在 recorder,配置接口是 admin-only,所以落库必须由指令这一侧完成。 + announceLogs := changeLogCount(t, env, "settings", "upsert") + status, env2 := env.do(recorder, http.MethodPost, "/api/display/command", map[string]any{ + "action": "announce", + "payload": map[string]any{"text": "决赛推迟到 15:00", "seconds": 30}, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("推送公告应 200,得到 %d %+v", status, env2.Error) + } + if len(sender.sent) != 1 || sender.sent[0] != "announce" { + t.Fatalf("公告应照旧转发给大屏,得到 %v", sender.sent) + } + if got := settingValue(t, env, "display.announce_text"); got != "决赛推迟到 15:00" { + t.Fatalf("公告正文应落库,得到 %q", got) + } + if got := settingValue(t, env, "display.announce_seconds"); got != "30" { + t.Fatalf("全屏时长应落库,得到 %q", got) + } + // 已连接的大屏靠 settings 变更重拉快照,没有这条变更它们看不到公告变化。 + if got := changeLogCount(t, env, "settings", "upsert"); got != announceLogs+1 { + t.Fatalf("公告落库应新增 1 条 settings 变更,得到 %d → %d", announceLogs, got) + } + + _, env2 = env.do(admin, http.MethodGet, "/api/settings", nil, false) + display := decodeData[map[string]any](t, env2)["display"].(map[string]any) + if display["announce"] != "决赛推迟到 15:00" { + t.Fatalf("settings.display.announce 应带当前公告: %+v", display) + } + + // payload 没带 seconds 时落默认 20。 + env.do(recorder, http.MethodPost, "/api/display/command", map[string]any{ + "action": "announce", + "payload": map[string]any{"text": "第二条公告"}, + }, true) + if got := settingValue(t, env, "display.announce_seconds"); got != "20" { + t.Fatalf("payload 未带 seconds 时应落默认 20,得到 %q", got) + } + + // 清空公告:正文清空,重连的大屏拿到 null。 + status, env2 = env.do(recorder, http.MethodPost, "/api/display/command", + map[string]any{"action": "announce_clear"}, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("清空公告应 200,得到 %d %+v", status, env2.Error) + } + if got := settingValue(t, env, "display.announce_text"); got != "" { + t.Fatalf("清空后公告正文应为空串,得到 %q", got) + } + _, env2 = env.do(admin, http.MethodGet, "/api/settings", nil, false) + display = decodeData[map[string]any](t, env2)["display"].(map[string]any) + if v, ok := display["announce"]; !ok || v != nil { + t.Fatalf("清空后 settings.display.announce 应为 null: %+v", display) + } +} + +// 公告归档只对 announce 系列生效,其它指令不能碰 settings;公告时长在清空后保留。 +func TestDisplayAnnounceLeavesOtherCommandsAlone(t *testing.T) { + env := newTestEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + env.do(client, http.MethodPost, "/api/display/command", map[string]any{ + "action": "announce", + "payload": map[string]any{"text": "公告一", "seconds": 45}, + }, true) + if status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{ + "action": "lock", "payload": map[string]any{}, + }, true); status != http.StatusOK { + t.Fatalf("锁屏指令应 200,得到 %d %+v", status, env2.Error) + } + if got := settingValue(t, env, "display.announce_text"); got != "公告一" { + t.Fatalf("非公告指令不应改公告正文,得到 %q", got) + } + + env.do(client, http.MethodPost, "/api/display/command", map[string]any{"action": "announce_clear"}, true) + if got := settingValue(t, env, "display.announce_seconds"); got != "45" { + t.Fatalf("清空公告应保留全屏时长,得到 %q", got) + } + if got := changeLogCount(t, env, "settings", "upsert"); got != 2 { + t.Fatalf("推送、清空各应写 1 条 settings 变更、锁屏不写,共 2 条,得到 %d", got) + } +} diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 66c2334..9a1da3c 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -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) { diff --git a/server/internal/httpapi/system_test.go b/server/internal/httpapi/system_test.go index 8713473..af14cce 100644 --- a/server/internal/httpapi/system_test.go +++ b/server/internal/httpapi/system_test.go @@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) { assertKeys(t, "settings.event", asMap(t, settings["event"]), []string{"name", "subtitle", "host", "logoPath", "primaryColor"}) assertKeys(t, "settings.display", asMap(t, settings["display"]), - []string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds"}) + []string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce"}) assertKeys(t, "settings.standings", asMap(t, settings["standings"]), []string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"}) diff --git a/server/internal/realtime/protocol.go b/server/internal/realtime/protocol.go index f320946..6e58d33 100644 --- a/server/internal/realtime/protocol.go +++ b/server/internal/realtime/protocol.go @@ -53,16 +53,19 @@ const ( CmdAnnounce = "announce" CmdShowDetail = "show-detail" CmdHideDetail = "hide-detail" + // CmdAnnounceClear 清除当前公告:服务端同时清设置里的持久值并广播给大屏。 + CmdAnnounceClear = "announce_clear" ) var displayActions = map[string]bool{ - CmdGoto: true, - CmdLock: true, - CmdUnlock: true, - CmdReload: true, - CmdAnnounce: true, - CmdShowDetail: true, - CmdHideDetail: true, + CmdGoto: true, + CmdLock: true, + CmdUnlock: true, + CmdReload: true, + CmdAnnounce: true, + CmdAnnounceClear: true, + CmdShowDetail: true, + CmdHideDetail: true, } // ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。 diff --git a/web/src/admin/components/baseData/EntryPicker.vue b/web/src/admin/components/baseData/EntryPicker.vue index 4282289..5966f42 100644 --- a/web/src/admin/components/baseData/EntryPicker.vue +++ b/web/src/admin/components/baseData/EntryPicker.vue @@ -5,7 +5,7 @@ import MxIcon from '../MxIcon.vue' import { adminApi } from '../../api/client' import { useBaseData } from './useBaseData' import { useToastStore } from '../../stores/toast' -import { buildSelectionRows, toCsv, type SelectionMode } from './entrySelection' +import { buildSelectionRows, selectionAllowsSubmit, toCsv, type SelectionMode } from './entrySelection' /** * 从已有运动员名单里勾选报名。 @@ -75,7 +75,8 @@ const canSubmit = computed( currentEvent.value !== undefined && currentCategory.value !== undefined && currentTeam.value !== undefined && - checkedAthletes.value.length > 0 && + // 团体 / 对抗项目允许只报单位(0 勾选也放行),个人项目必须勾人。 + selectionAllowsSubmit(mode.value, checkedAthletes.value.length) && !submitting.value, ) @@ -130,7 +131,8 @@ async function submit(): Promise { const category = currentCategory.value const team = currentTeam.value const chosen = checkedAthletes.value - if (event === undefined || category === undefined || team === undefined || chosen.length === 0) return + if (event === undefined || category === undefined || team === undefined) return + if (!selectionAllowsSubmit(mode.value, chosen.length)) return submitting.value = true error.value = null @@ -214,10 +216,16 @@ async function submit(): Promise { - 已勾选 {{ checkedAthletes.length }} 人 + + 已勾选 {{ checkedAthletes.length }} 人{{ mode === 'team' && checkedAthletes.length === 0 ? '(团体项目可留空)' : '' }} + +

+ 团体 / 对抗项目按单位报名:队员名单可选,一名队员都不勾也能把本单位的报名建出来,名单可以之后再来补。 +

+
+
+
+ 榜单口径全局设置 + +
+
+
+ + + +
+

+ 这是全局设置(standings.individual_to_team_weight),不随某一张积分模板保存,所有模板与所有计分通道共用: + 单位总分 = Σ 只记单位的流水 + 系数 × Σ 记到个人的流水。导入规则包时,规则包里的同名配置会覆盖这里。 +

+

+ 只有管理员能读写赛事设置(GET / PUT /api/settings 仅管理员可用),当前账号为只读:读不到具体系数,榜单按库里已有取值折算(未配置时为 1)。 +

+

+ 赛事设置尚未加载(未登录或服务端未连接),暂时读不到当前系数。 +

+
+
+ { .danger-text { color: var(--mx-red); } +/* 榜单口径是全局设置,横跨列表与明细两栏,避免被当成某张模板的一部分。 */ +.global-card { + grid-column: 1 / -1; +} +.global-badge { + margin-left: 8px; + vertical-align: middle; +} +.weight-readonly { + color: var(--mx-amber); +} .form-error { display: block; margin-bottom: 14px; diff --git a/web/src/admin/components/baseData/entrySelection.ts b/web/src/admin/components/baseData/entrySelection.ts index 018785c..53017be 100644 --- a/web/src/admin/components/baseData/entrySelection.ts +++ b/web/src/admin/components/baseData/entrySelection.ts @@ -1,5 +1,5 @@ /** - * 「从已有名单勾选报名」的纯函数部分,以及报名导入冲突的差异 / 决策计算。 + * 报名相关的纯函数:勾选转 CSV、报名行文案,以及报名导入冲突的差异 / 决策计算。 * * 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口, * 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert @@ -39,6 +39,26 @@ export const MANDATORY_COLUMNS: readonly string[] = REQUIRED_COLUMNS.filter( export type SelectionMode = 'individual' | 'team' +/** + * 「加入报名」是否放行。 + * + * 个人项目一人一条报名,必须勾人;团体 / 对抗项目一条报名只挂单位(服务端 athlete_id 为 NULL + * 的报名合法),队员名单可以留空后补,所以 0 勾选也要放行。 + */ +export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number): boolean { + return mode === 'team' || chosenCount > 0 +} + +/** 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。 */ +export function entryRowLabel(row: { + athleteName?: string | null + memberNames?: string[] | null + teamShort?: string | null + teamName?: string | null +}): string { + return row.athleteName || row.memberNames?.[0] || row.teamShort || row.teamName || '' +} + export interface SelectionInput { eventCode: string categoryName: string @@ -54,7 +74,7 @@ export interface SelectionInput { /** * 生成与导入表头一致的二维数组。 * - * 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names。 + * 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names(可以为空,即只报单位)。 * 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名。 */ export function buildSelectionRows(input: SelectionInput): string[][] { diff --git a/web/src/admin/components/baseData/useBaseData.ts b/web/src/admin/components/baseData/useBaseData.ts index 1bffb58..a5f2bf9 100644 --- a/web/src/admin/components/baseData/useBaseData.ts +++ b/web/src/admin/components/baseData/useBaseData.ts @@ -8,6 +8,7 @@ import type { EventCategory, EventCategoryInput, EventInput, + EventSettings, ImportConflictDecision, ScoringProfile, ScoringProfileInput, @@ -18,7 +19,7 @@ import type { Team, TeamInput, } from '@/shared' -import { mockMode, mockReason } from '../../api/client' +import { adminApi, mockMode, mockReason } from '../../api/client' import { useAuthStore } from '../../stores/auth' import { useEventStore } from '../../stores/event' import { baseDataApi, baseDataFallback } from './baseDataApi' @@ -240,6 +241,38 @@ function resolveImportConflicts(file: File, decisions: ImportConflictDecision[]) return baseDataApi.entries.resolveImportConflicts(file, decisions) } +// ── 榜单口径(全局赛事设置) ──────────────────────────────────────────────── + +/** + * 解析榜单系数的输入值。`type="number"` 的 v-model 会给出 number、清空时是空串, + * 所以先 String() 再判断;0–100 之外或非数字一律返回 null,由调用方给出提示。 + */ +export function parseIndividualToTeamWeight(raw: unknown): number | null { + const text = String(raw ?? '').trim() + if (text === '') return null + const value = Number(text) + if (!Number.isFinite(value) || value < 0 || value > 100) return null + return value +} + +/** + * 保存榜单口径(PUT /api/settings)。 + * + * 只提交完整的 standings 子对象,其余设置不动;写操作不降级,失败原样抛给页面提示。 + * 保存后回写 event store:成绩录入页与积分预览都直接读 store 里的设置,不回写就会 + * 「提示保存成功但预览没变」。 + */ +async function saveIndividualToTeamWeight(weight: number): Promise { + const eventStore = useEventStore() + const current = eventStore.settings + if (current === null) throw new Error('赛事设置未加载,无法保存榜单口径') + const saved = await adminApi.client.settings.update({ + standings: { ...current.standings, individualToTeamWeight: weight }, + }) + eventStore.settings = saved + return saved +} + export interface BaseDataController { teams: Ref sports: Ref @@ -253,6 +286,8 @@ export interface BaseDataController { isMock: ComputedRef mockLabel: ComputedRef canWrite: ComputedRef + /** 赛事设置(含榜单口径);GET /api/settings 仅管理员可见,只读访客拿不到时为 null。 */ + settings: ComputedRef load: (force?: boolean) => Promise loadEventDetail: (id: number, force?: boolean) => Promise selectEvent: (id: number) => Promise @@ -274,10 +309,14 @@ export interface BaseDataController { removeAthlete: (id: number) => Promise importEntries: (file: File) => Promise resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise + /** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */ + saveIndividualToTeamWeight: (weight: number) => Promise } export function useBaseData(): BaseDataController { const auth = useAuthStore() + // 榜单口径是全局设置,唯一真值在 event store(bootstrap / 实时快照都写它)。 + const eventStore = useEventStore() return { teams, @@ -295,6 +334,7 @@ export function useBaseData(): BaseDataController { ), // 写权限只看角色:写操作走 HTTP、服务端会做完整校验,实时连接断线不该让整页变只读。 canWrite: computed(() => !auth.readOnly), + settings: computed(() => eventStore.settings), load, loadEventDetail, selectEvent, @@ -316,5 +356,6 @@ export function useBaseData(): BaseDataController { removeAthlete, importEntries, resolveImportConflicts, + saveIndividualToTeamWeight, } } diff --git a/web/src/admin/pages/DisplayPage.vue b/web/src/admin/pages/DisplayPage.vue index c8798c4..f7dda7f 100644 --- a/web/src/admin/pages/DisplayPage.vue +++ b/web/src/admin/pages/DisplayPage.vue @@ -37,6 +37,23 @@ const announceText = ref('') const announceSeconds = ref(15) const pending = ref(null) +/** + * 大屏当前公告(服务端随设置持久化的那条,全屏放完仍留在底栏)。 + * + * 先取启动时加载到的设置,再向 GET /api/settings 核对一次:公告是别的管理员推的、 + * 或者本页面开着时被推过,本地这份设置可能是旧的。 + */ +const currentAnnounce = ref(data.settings?.display?.announce ?? null) + +async function refreshAnnounce(): Promise { + try { + const settings = await adminApi.client.settings.get() + currentAnnounce.value = settings.display?.announce ?? null + } catch { + // 示例数据 / 断网时读不到设置,保留本地这一份,不能把「当前公告」清成未知。 + } +} + /** * 显示器菜单:只有「打开服务端管理面板」那个 Electron 窗口里的 shell-preload 会注入 * window.desktopShell.displays,浏览器直接打开面板时没有这一组能力。 @@ -144,7 +161,10 @@ async function relayoutDisplays(): Promise { }) } -onMounted(loadDisplays) +onMounted(() => { + void loadDisplays() + void refreshAnnounce() +}) /** * 在线看板(占位)。 @@ -164,7 +184,9 @@ function recordDispatch(): void { screens.value = screens.value.map((screen) => ({ ...screen, lastCommandAt: new Date().toISOString() })) } -async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): Promise { +async function send( + action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce' | 'announce_clear', +): Promise { if (!canWrite.value) { toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接') return @@ -191,6 +213,11 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): } else if (action === 'reload') { await adminApi.displayCommand({ action, payload: {} }) toast.success('已请求所有大屏重新拉取快照') + } else if (action === 'announce_clear') { + await adminApi.displayCommand({ action, payload: {} }) + currentAnnounce.value = null + toast.success('已清除大屏公告') + await refreshAnnounce() } else { const text = announceText.value.trim() if (text === '') { @@ -199,7 +226,9 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): } await adminApi.displayCommand({ action, payload: { text, seconds: announceSeconds.value } }) announceText.value = '' + currentAnnounce.value = text toast.success('公告已推送') + await refreshAnnounce() } recordDispatch() } catch (caught) { @@ -284,8 +313,16 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
-
公告
+
+ 公告 + + {{ currentAnnounce === null ? '当前公告:无' : '当前公告:有' }} + +
+

+ {{ currentAnnounce ?? '暂无公告(大屏底栏显示占位)' }} +