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: "第一单位"}))
+36 -1
View File
@@ -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
}
@@ -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)
}
}
+53
View File
@@ -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) {
+1 -1
View File
@@ -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"})
+10 -7
View File
@@ -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 是否在协议清单内。
@@ -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<void> {
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<void> {
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号" />
</div>
<span class="bd-note">已勾选 {{ checkedAthletes.length }} 人</span>
<span class="bd-note">
已勾选 {{ checkedAthletes.length }} 人{{ mode === 'team' && checkedAthletes.length === 0 ? '(团体项目可留空)' : '' }}
</span>
</div>
</div>
<p v-if="mode === 'team'" class="bd-note">
团体 / 对抗项目按单位报名:队员名单可选,一名队员都不勾也能把本单位的报名建出来,名单可以之后再来补。
</p>
<div class="picker-list">
<label
v-for="athlete in filteredAthletes"
@@ -243,7 +251,7 @@ async function submit(): Promise<void> {
<p class="bd-note">
<template v-if="mode === 'individual'">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</template>
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
<template v-else>团体 / 对抗项目:勾选的人合成一条报名(一个人也不勾则只报单位),队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
</p>
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
<p v-if="mode === 'individual'" class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
@@ -4,10 +4,12 @@ import type { RecordBonusMode, ScoringProfile, ScoringProfileInput, TieMode } fr
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { parseIndividualToTeamWeight, useBaseData } from './useBaseData'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
const data = useBaseData()
const auth = useAuthStore()
const toast = useToastStore()
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
@@ -54,6 +56,52 @@ const removedRules = ref<string[]>([])
const selectedProfile = computed(() => data.profileById(selectedId.value) ?? null)
const isEditing = computed(() => creating.value || selectedProfile.value !== null)
// ── 榜单口径:全局设置,不随某张模板保存 ──────────────────────────────────
const isAdmin = computed(() => auth.user?.role === 'admin')
const weightInput = ref<number | string>('')
const weightSaving = ref(false)
const weightError = ref<string | null>(null)
/** 库里现有的系数;settings 仅管理员可见,非管理员拿到 null。 */
const storedWeight = computed<number | null>(() => data.settings.value?.standings.individualToTeamWeight ?? null)
const parsedWeight = computed(() => parseIndividualToTeamWeight(weightInput.value))
const weightDirty = computed(
() => parsedWeight.value !== null && storedWeight.value !== null && parsedWeight.value !== storedWeight.value,
)
// 服务端设置变化(规则包导入、别处保存)时回填;本地已改动的输入不被覆盖。
watch(
() => data.settings.value?.standings.individualToTeamWeight,
(value) => {
if (value === undefined) return
if (!weightDirty.value) weightInput.value = value
},
{ immediate: true },
)
async function saveWeight(): Promise<void> {
const value = parsedWeight.value
if (value === null) {
weightError.value = '折算系数需为 0–100 的数字,例如 1 或 0.5'
return
}
// 服务端对 ≤0 直接 400(0 与负数会被拒绝),本地先拦下,别让用户以为能「按 1 处理」。
if (value <= 0) {
weightError.value = '折算系数必须大于 0,服务端会拒绝 0 与负数'
return
}
weightSaving.value = true
weightError.value = null
try {
await data.saveIndividualToTeamWeight(value)
toast.success('榜单口径已保存', '个人项目得分已按新系数折算计入单位总分')
} catch (caught) {
weightError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
weightSaving.value = false
}
}
watch(
() => data.profiles.value,
(list) => {
@@ -363,6 +411,50 @@ const totalTop = computed(() => {
</div>
</section>
<section class="mx-card global-card">
<div class="mx-card-head">
<span class="mx-card-title">榜单口径<span class="mx-badge mx-badge-neutral global-badge">全局设置</span></span>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!isAdmin || !weightDirty || weightSaving"
@click="saveWeight"
>
{{ weightSaving ? '保存中…' : '保存榜单口径' }}
</button>
</div>
<div class="mx-card-body">
<div class="bd-form-grid">
<FormField
label="个人项目得分折算系数"
:error="weightError"
hint="个人项目得分按该系数折算计入单位总分(全局设置,对所有模板生效)。默认 1,步进 0.1;服务端要求大于 0、不超过 100。"
>
<input
v-model="weightInput"
class="mx-cell-input"
type="number"
min="0"
max="100"
step="0.1"
:disabled="!isAdmin || weightSaving"
placeholder="1"
/>
</FormField>
</div>
<p class="bd-note">
这是<strong>全局</strong>设置(standings.individual_to_team_weight),不随某一张积分模板保存,所有模板与所有计分通道共用:
单位总分 = Σ 只记单位的流水 + 系数 × Σ 记到个人的流水。导入规则包时,规则包里的同名配置会覆盖这里。
</p>
<p v-if="!isAdmin" class="bd-note weight-readonly">
只有管理员能读写赛事设置(GET / PUT /api/settings 仅管理员可用),当前账号为只读:读不到具体系数,榜单按库里已有取值折算(未配置时为 1)。
</p>
<p v-else-if="storedWeight === null" class="bd-note">
赛事设置尚未加载(未登录或服务端未连接),暂时读不到当前系数。
</p>
</div>
</section>
<ConfirmDialog
:open="confirmOpen"
tone="warning"
@@ -381,6 +473,17 @@ const totalTop = computed(() => {
.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;
@@ -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[][] {
@@ -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<EventSettings> {
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<Team[]>
sports: Ref<Sport[]>
@@ -253,6 +286,8 @@ export interface BaseDataController {
isMock: ComputedRef<boolean>
mockLabel: ComputedRef<string | null>
canWrite: ComputedRef<boolean>
/** 赛事设置(含榜单口径);GET /api/settings 仅管理员可见,只读访客拿不到时为 null。 */
settings: ComputedRef<EventSettings | null>
load: (force?: boolean) => Promise<void>
loadEventDetail: (id: number, force?: boolean) => Promise<Event | null>
selectEvent: (id: number) => Promise<void>
@@ -274,10 +309,14 @@ export interface BaseDataController {
removeAthlete: (id: number) => Promise<void>
importEntries: (file: File) => Promise<EntryImportResult>
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
/** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */
saveIndividualToTeamWeight: (weight: number) => Promise<EventSettings>
}
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,
}
}
+76 -12
View File
@@ -37,6 +37,23 @@ const announceText = ref('')
const announceSeconds = ref(15)
const pending = ref<string | null>(null)
/**
* 大屏当前公告(服务端随设置持久化的那条,全屏放完仍留在底栏)。
*
* 先取启动时加载到的设置,再向 GET /api/settings 核对一次:公告是别的管理员推的、
* 或者本页面开着时被推过,本地这份设置可能是旧的。
*/
const currentAnnounce = ref<string | null>(data.settings?.display?.announce ?? null)
async function refreshAnnounce(): Promise<void> {
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<void> {
})
}
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<void> {
async function send(
action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce' | 'announce_clear',
): Promise<void> {
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'):
</section>
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">公告</span></div>
<div class="mx-card-head">
<span class="mx-card-title">公告</span>
<span class="mx-badge" :class="currentAnnounce === null ? 'mx-badge-neutral' : 'mx-badge-success'">
{{ currentAnnounce === null ? '当前公告:无' : '当前公告:有' }}
</span>
</div>
<div class="mx-card-body">
<p class="announce-current">
{{ currentAnnounce ?? '暂无公告(大屏底栏显示占位)' }}
</p>
<div class="mx-input-wrap textarea-wrap">
<textarea
v-model="announceText"
@@ -300,16 +337,28 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
<input v-model.number="announceSeconds" class="mx-cell-input seconds-input" type="number" min="5" max="120" />
秒
</label>
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || pending !== null"
@click="send('announce')"
>
<MxIcon name="bell" :size="15" />
{{ pending === 'announce' ? '推送中…' : '推送公告' }}
</button>
<div class="announce-actions">
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || pending !== null"
@click="send('announce')"
>
<MxIcon name="bell" :size="15" />
{{ pending === 'announce' ? '推送中…' : '推送公告' }}
</button>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || pending !== null"
@click="send('announce_clear')"
>
<MxIcon name="close" :size="15" />
{{ pending === 'announce_clear' ? '清除中…' : '清除公告' }}
</button>
</div>
</div>
<p class="hint">公告先全屏显示,到时间后自动退回原页面,并常驻大屏底栏「公告」槽位,直到被新公告替换或点「清除公告」。</p>
</div>
</section>
@@ -514,6 +563,16 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
line-height: 1.6;
padding: 4px 0;
}
.announce-current {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
font-size: 12px;
line-height: 1.6;
color: var(--mx-t2);
overflow-wrap: anywhere;
}
.announce-foot {
display: flex;
align-items: center;
@@ -522,6 +581,11 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
margin-top: 12px;
flex-wrap: wrap;
}
.announce-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.seconds {
display: flex;
align-items: center;
+3 -1
View File
@@ -15,6 +15,7 @@ import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import { entryRowLabel } from '../components/baseData/entrySelection'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
@@ -233,8 +234,9 @@ const issues = computed<Issue[]>(() => {
const errors = computed(() => issues.value.filter((issue) => issue.level === 'error'))
const warnings = computed(() => issues.value.filter((issue) => issue.level === 'warn'))
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
function rowLabel(row: EditableRow): string {
return row.athleteName ?? row.memberNames?.[0] ?? row.teamShort
return entryRowLabel(row)
}
function round(value: number): number {
@@ -0,0 +1,72 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createPinia, setActivePinia } from 'pinia'
import type { EventSettings } from '@/shared'
/**
* 榜单口径(standings.individual_to_team_weight)的保存契约。
*
* adminApi 的 http 实例在模块初始化时就绑定了 `globalThis.fetch`,所以这里先替换 fetch,
* 再用动态 import 拿到 store 与控制器,才能观察到真实请求的 URL / 方法与载荷。
*/
const SETTINGS: EventSettings = {
event: { name: '测试赛事', subtitle: null, host: null, logoPath: null, primaryColor: null },
display: { background: null, logo: null, title: '测试赛事', subtitle: null, primaryColor: null, rotateSeconds: 15 },
standings: {
individualToTeamWeight: 1,
rankingMode: 'points',
showIndividualBoard: true,
showIndividualMedals: true,
},
defaultProfileId: null,
requireApproval: false,
}
interface Captured {
url: string
method: string
body: Record<string, unknown> | null
}
function stubFetch(weight: number): Captured {
const captured: Captured = { url: '', method: '', body: null }
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
captured.url = String(input)
captured.method = init?.method ?? 'GET'
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
const data: EventSettings = { ...SETTINGS, standings: { ...SETTINGS.standings, individualToTeamWeight: weight } }
return new Response(JSON.stringify({ ok: true, data }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
return captured
}
test('保存榜单口径 PUT /api/settings,提交完整 standings 并回写 event store', async () => {
const captured = stubFetch(0.5)
const { useEventStore } = await import('../stores/event')
const { useBaseData } = await import('../components/baseData/useBaseData')
setActivePinia(createPinia())
const event = useEventStore()
event.settings = { ...SETTINGS }
const data = useBaseData()
const saved = await data.saveIndividualToTeamWeight(0.5)
assert.equal(captured.url, '/api/settings')
assert.equal(captured.method, 'PUT')
const standings = captured.body?.standings as Record<string, unknown>
assert.equal(standings.individualToTeamWeight, 0.5)
// 只提交一个字段会把其余口径留在服务端旧值上,所以整个 standings 子对象原样回传。
assert.equal(standings.rankingMode, 'points')
assert.equal(standings.showIndividualBoard, true)
assert.equal(standings.showIndividualMedals, true)
assert.equal(saved.standings.individualToTeamWeight, 0.5)
// 成绩录入页与积分预览直接读 event store 的设置,不回写就等于「保存成功但预览没变」。
assert.equal(event.settings?.standings.individualToTeamWeight, 0.5)
assert.equal(data.settings.value?.standings.individualToTeamWeight, 0.5)
})
+28 -1
View File
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
import type { CurrentUser } from '@/shared'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useBaseData } from '../components/baseData/useBaseData'
import { useBaseData, parseIndividualToTeamWeight } from '../components/baseData/useBaseData'
// 写权限只看角色:实时连接断开(服务端重启、网络瞬断)不得让整页变只读。
// 曾经 canWrite 依赖 realtimeStatus === 'online',WS 一断连「保存全部」都是灰的。
@@ -25,3 +25,30 @@ test('实时连接离线时仍可写,只读访客不可写', () => {
auth.user = null
assert.equal(data.canWrite.value, false, '退出后回到只读访客')
})
// 榜单口径输入框是 type="number",清空时 v-model 给空串;Number('') 是 0,
// 不拦住就会把「清空」当成「系数 0」提交给服务端。
test('榜单系数解析:空值 / 越界 / 非数字一律 null,0–100 原样保留', () => {
assert.equal(parseIndividualToTeamWeight(''), null)
assert.equal(parseIndividualToTeamWeight(' '), null)
assert.equal(parseIndividualToTeamWeight(undefined), null)
assert.equal(parseIndividualToTeamWeight(null), null)
assert.equal(parseIndividualToTeamWeight('abc'), null)
assert.equal(parseIndividualToTeamWeight(Number.NaN), null)
assert.equal(parseIndividualToTeamWeight(-1), null)
assert.equal(parseIndividualToTeamWeight(100.5), null)
assert.equal(parseIndividualToTeamWeight(0), 0)
assert.equal(parseIndividualToTeamWeight('0.5'), 0.5)
assert.equal(parseIndividualToTeamWeight(1), 1)
assert.equal(parseIndividualToTeamWeight('100'), 100)
})
test('赛事设置未加载时保存榜单口径直接报错,不发请求', async () => {
setActivePinia(createPinia())
const event = useEventStore()
const data = useBaseData()
assert.equal(data.settings.value, null, '尚未 bootstrap 时设置应为 null')
await assert.rejects(() => data.saveIndividualToTeamWeight(2), /赛事设置未加载/)
assert.equal(event.settings, null, '失败不应写入 store')
})
@@ -5,6 +5,8 @@ import {
OPTIONAL_COLUMNS,
REQUIRED_COLUMNS,
buildSelectionRows,
entryRowLabel,
selectionAllowsSubmit,
toCsv,
} from '../components/baseData/entrySelection'
@@ -91,3 +93,33 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列
})
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', 'registered', ''])
})
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
mode: 'team',
athletes: [],
})
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
})
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
assert.equal(selectionAllowsSubmit('team', 0), true, '团体项目允许只报单位')
assert.equal(selectionAllowsSubmit('team', 3), true)
assert.equal(selectionAllowsSubmit('individual', 0), false, '个人项目必须至少勾一人')
assert.equal(selectionAllowsSubmit('individual', 1), true)
})
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
// 否则防呆校验的文案会变成「:缺少成绩」。
test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
assert.equal(entryRowLabel({ athleteName: '张伟', memberNames: null, teamShort: '一队' }), '张伟')
assert.equal(entryRowLabel({ athleteName: '', memberNames: ['王芳', '刘洋'], teamShort: '一队' }), '王芳')
assert.equal(entryRowLabel({ athleteName: null, memberNames: [], teamShort: '一队' }), '一队')
assert.equal(entryRowLabel({ athleteName: null, memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
assert.equal(entryRowLabel({}), '')
})
+42
View File
@@ -228,3 +228,45 @@ test('项目与组别权重相乘', () => {
)
close(result.rowPoints.get(1) ?? 0, 60)
})
// 团体行的 athleteId 是 null(服务端 entries.athlete_id 为空,0 队员的「只报单位」报名也是这种行),
// 团体通道的排名主体是单位,不能因为没有运动员就把行丢掉。
test('团体行(athleteId 为 null)按单位通道正常拿名次与积分', () => {
const profile = makeProfile({
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
],
})
const rows: ScorableRow[] = [
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
]
const result = computeEventScore(
rows,
makeContext({
event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }),
channels: [makeChannel({ scope: 'team', basis: 'manual' })],
profiles: [profile],
}),
)
assert.equal(result.ranks.get(1), 1)
assert.equal(result.ranks.get(2), 2)
close(result.rowPoints.get(1) ?? 0, 9)
close(result.rowTeamPoints.get(1) ?? 0, 9)
close(result.teamPoints.get(100) ?? 0, 9)
close(result.teamPoints.get(101) ?? 0, 6)
})
test('团体行只配了录入名次(无计分通道)时也能算出名次', () => {
const rows: ScorableRow[] = [
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
]
const result = computeEventScore(
rows,
makeContext({ event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }) }),
)
assert.equal(result.ranks.get(1), 2)
assert.equal(result.ranks.get(2), 1)
})
+103
View File
@@ -0,0 +1,103 @@
import { ref, type Ref } from 'vue'
import type { AnnouncementState, DisplayPageKey } from './types'
/**
* 公告状态机:把「全屏推送」与「底栏常驻」拆开。
*
* 公告一旦推送(或由服务端持久化的设置恢复)就留在本地,全屏只是 seconds 秒的临时态;
* 计时结束后只退出全屏、回到原页面,正文继续给底栏用,直到下一条公告替换它或被清除。
*/
/** 退出全屏后回到的页:没记下「进入公告前那一页」时用总积分榜兜底。 */
const DEFAULT_RETURN_PAGE: DisplayPageKey = 'standings'
/**
* 凭服务端持久化的公告恢复全屏时的时长。
* 设置里只存正文与推送时长两项,而契约只下发正文,所以这里给一个默认值。
*/
export const DEFAULT_ANNOUNCE_SECONDS = 15
/** 没有任何公告时槽位显示的占位文案。 */
const ANNOUNCE_PLACEHOLDER = '暂无公告'
export interface AnnouncementDeps {
/** 当前页;公告全屏期间是 announcement。 */
page: Ref<DisplayPageKey>
/** 进入公告全屏页(调用方负责停轮播)。 */
onFullscreenEnter: () => void
/** 退出全屏并回到 returnPage(调用方负责恢复轮播)。 */
onFullscreenExit: (returnPage: DisplayPageKey) => void
}
export interface AnnouncementController {
/** 当前公告;底栏与公告页都读它。null = 没有公告。 */
current: Ref<AnnouncementState | null>
/** 推送一条公告:先全屏 seconds 秒,结束后只退全屏,正文留着。 */
push: (text: string, seconds: number) => void
/** 清除公告:退全屏并清空正文。 */
clear: () => void
/** 组件卸载时停表。 */
dispose: () => void
}
export function createAnnouncement(deps: AnnouncementDeps): AnnouncementController {
const current = ref<AnnouncementState | null>(null)
let timer: ReturnType<typeof setTimeout> | null = null
// 第二条公告到来时 page 已经是 announcement,必须复用「进入公告前的真实页」,
// 否则返回页会被记成公告页自身,计时结束后永远停在公告页。
let returnPage: DisplayPageKey | null = null
function stopTimer(): void {
if (timer !== null) clearTimeout(timer)
timer = null
}
function leaveFullscreen(): void {
stopTimer()
const target = returnPage ?? DEFAULT_RETURN_PAGE
returnPage = null
deps.onFullscreenExit(target)
}
function push(text: string, seconds: number): void {
const trimmed = text.trim()
if (trimmed === '') return
if (deps.page.value !== 'announcement') returnPage = deps.page.value
current.value = { text: trimmed, seconds, startedAt: Date.now() }
deps.onFullscreenEnter()
stopTimer()
// seconds <= 0 表示不定时全屏,只能由下一条公告或 clear 收场。
if (seconds > 0) timer = setTimeout(leaveFullscreen, seconds * 1000)
}
function clear(): void {
// 全屏还停在公告页时要连全屏一起收掉;已经回到普通页时只清底栏正文。
const fullscreen = current.value !== null && deps.page.value === 'announcement'
stopTimer()
current.value = null
if (fullscreen) leaveFullscreen()
}
function dispose(): void {
stopTimer()
returnPage = null
}
return { current, push, clear, dispose }
}
/**
* 公告取值:推送中的实时值优先,其次是服务端持久化的当前公告
* (断线重连、新开的大屏只有它),两者都没有才回落占位。
*/
export function resolveAnnounceText(
current: AnnouncementState | null,
persisted: string | null | undefined,
): string {
const live = current?.text.trim() ?? ''
if (live !== '') return live
const saved = persisted?.trim() ?? ''
return saved === '' ? ANNOUNCE_PLACEHOLDER : saved
}
+2 -2
View File
@@ -9,7 +9,7 @@ interface TickerItem {
label: string
}
const { standings, latestResults, announcement, detail, settings } = useScoreboard()
const { standings, latestResults, announceText, detail, settings } = useScoreboard()
// 与团体奖牌榜页同一口径:按服务端名次(总分优先)取前 3,不在前端按金→银→铜重排。
const topMedals = computed(() => standings.value.slice(0, 3))
@@ -79,7 +79,7 @@ onUnmounted(() => window.clearInterval(timer))
</template>
<template v-else>
<div class="announce">{{ announcement?.text ?? '暂无公告' }}</div>
<div class="announce">{{ announceText }}</div>
</template>
</div>
</div>
@@ -3,9 +3,9 @@ import { computed } from 'vue'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
const { announcement } = useScoreboard()
const { announceText } = useScoreboard()
const text = computed(() => announcement.value?.text ?? '暂无公告')
const text = computed(() => announceText.value)
// 重复三遍 + padding-left 100%,滚动到末尾时下一条刚好接上,不出现空白。
const marquee = computed(() => `${text.value}  ·  ${text.value}  ·  ${text.value}`)
</script>
+4
View File
@@ -334,6 +334,9 @@ export function parseSettings(source: unknown): DisplaySettings {
settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'),
true,
)
// 公告在 settings 表里的键是 display.announce_text,聚合后是 display.announce;
// 空白一律归一成 null,调用方只需判断「有没有公告」。
const announce = str(settingValue(rec, 'display.announce', 'display.announce_text')).trim()
return {
event: {
name: str(settingValue(rec, 'event.name', 'event.name')),
@@ -349,6 +352,7 @@ export function parseSettings(source: unknown): DisplaySettings {
subtitle: strOrNull(settingValue(rec, 'display.subtitle', 'display.subtitle')),
primaryColor: strOrNull(settingValue(rec, 'display.primaryColor', 'display.theme_color')),
rotateSeconds: num(settingValue(rec, 'display.rotateSeconds', 'display.rotate_seconds'), 8),
announce: announce === '' ? null : announce,
},
standings: {
individualToTeamWeight: num(
+200
View File
@@ -0,0 +1,200 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { nextTick, ref, type Ref } from 'vue'
import { parseSettings } from '../guards'
import { useScoreboard } from '../useScoreboard'
import {
DEFAULT_ANNOUNCE_SECONDS,
createAnnouncement,
resolveAnnounceText,
type AnnouncementController,
} from '../announce'
import type { DisplayPageKey } from '../types'
// 公告分两段:全屏 seconds 秒 + 底栏常驻。计时结束只退全屏,正文必须留着,
// 只有新公告或 announce_clear 才换掉它——曾经这里跟着计时器一起清空,
// 底栏「公告」槽位立刻回落成「暂无公告」。
interface Harness {
page: Ref<DisplayPageKey>
events: string[]
controller: AnnouncementController
/** 当前公告正文;读成函数,避免 assert.equal 的类型收窄把后续读取判成 never。 */
text: () => string | null
}
/** 复刻 useScoreboard 里的挂接方式:进全屏切到公告页,退全屏切回返回页。 */
function setup(initialPage: DisplayPageKey = 'standings'): Harness {
const page = ref<DisplayPageKey>(initialPage)
const events: string[] = []
const controller = createAnnouncement({
page,
onFullscreenEnter: () => {
events.push('enter')
page.value = 'announcement'
},
onFullscreenExit: (returnPage) => {
events.push(`exit:${returnPage}`)
if (page.value === 'announcement') page.value = returnPage
},
})
return { page, events, controller, text: () => controller.current.value?.text ?? null }
}
test('推送公告:到点只退全屏,正文留在底栏', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const host = setup('standings')
host.controller.push('请各队到检录处集合', 10)
assert.equal(host.text(), '请各队到检录处集合')
assert.equal(host.controller.current.value?.seconds, 10)
assert.equal(host.page.value, 'announcement', '推送即进全屏')
t.mock.timers.tick(9_000)
assert.equal(host.page.value, 'announcement', '没到点不能提前退场')
t.mock.timers.tick(1_000)
assert.equal(host.page.value, 'standings', '到点回到进入公告前的页')
assert.deepEqual(host.events, ['enter', 'exit:standings'])
assert.equal(
host.text(),
'请各队到检录处集合',
'正文不能跟着计时器一起清掉,底栏要继续显示',
)
})
test('清除公告:退全屏并清空正文,计时器不再触发', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const host = setup('team-medals')
host.controller.push('临时通知', 30)
host.controller.clear()
assert.equal(host.controller.current.value, null)
assert.equal(host.page.value, 'team-medals', '全屏中清除要回到进入公告前的页')
t.mock.timers.tick(60_000)
assert.deepEqual(host.events, ['enter', 'exit:team-medals'], '清除后不能再退场一次')
})
test('全屏已结束时清除:只清正文,不动当前页', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const host = setup('standings')
host.controller.push('已过期的公告', 5)
t.mock.timers.tick(5_000)
host.controller.clear()
assert.equal(host.controller.current.value, null)
assert.equal(host.page.value, 'standings')
assert.deepEqual(host.events, ['enter', 'exit:standings'])
})
test('没推过公告时清除是空操作:手动停在公告页不会被踢走', () => {
const host = setup('announcement')
host.controller.clear()
assert.equal(host.controller.current.value, null)
assert.equal(host.page.value, 'announcement')
assert.deepEqual(host.events, [])
})
test('空白公告被忽略,不进全屏也不顶掉当前公告', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const host = setup('standings')
host.controller.push(' ', 10)
host.controller.push('', 10)
assert.equal(host.controller.current.value, null)
assert.equal(host.page.value, 'standings')
assert.deepEqual(host.events, [])
host.controller.push('正式公告', 10)
host.controller.push(' ', 10)
assert.equal(host.text(), '正式公告')
})
test('连推两条:返回页始终是进入公告前那一页', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const host = setup('event-results')
host.controller.push('第一条', 10)
t.mock.timers.tick(3_000)
// 第二条到来时 page 已经是 announcement,不能把返回页记成公告页自身。
host.controller.push('第二条', 10)
assert.equal(host.text(), '第二条')
t.mock.timers.tick(10_000)
assert.equal(host.page.value, 'event-results')
// 第二条会再进一次全屏(页已在公告页,进入动作是幂等的),但返回页仍是 event-results。
assert.deepEqual(host.events, ['enter', 'enter', 'exit:event-results'])
assert.equal(host.text(), '第二条', '换成第二条后仍常驻底栏')
})
test('seconds=0 表示不定时全屏,只能由清除收场', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const host = setup('standings')
host.controller.push('长期公告', 0)
t.mock.timers.tick(600_000)
assert.equal(host.page.value, 'announcement')
host.controller.clear()
assert.equal(host.page.value, 'standings')
assert.equal(host.controller.current.value, null)
})
test('dispose 停表:卸载后不再自动退场', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const host = setup('standings')
host.controller.push('公告', 10)
host.controller.dispose()
t.mock.timers.tick(10_000)
assert.deepEqual(host.events, ['enter'])
assert.equal(host.text(), '公告')
})
test('公告取值优先级:推送中的实时值 > 服务端持久化 > 占位', () => {
const live = { text: '实时公告', seconds: 10, startedAt: 1 }
assert.equal(resolveAnnounceText(live, '持久化公告'), '实时公告')
assert.equal(resolveAnnounceText(null, '持久化公告'), '持久化公告', '大屏错过指令时靠设置兜底')
assert.equal(resolveAnnounceText(null, null), '暂无公告')
assert.equal(resolveAnnounceText(null, undefined), '暂无公告')
assert.equal(resolveAnnounceText(null, ' '), '暂无公告')
assert.equal(resolveAnnounceText({ ...live, text: ' ' }, '持久化公告'), '持久化公告')
assert.equal(resolveAnnounceText(null, ' 两侧留白 '), '两侧留白')
})
test('parseSettings 归一公告:嵌套与扁平键都认,空白当没有', () => {
assert.equal(parseSettings({}).display.announce, null)
assert.equal(parseSettings({ display: { announce: '注意安全' } }).display.announce, '注意安全')
assert.equal(parseSettings({ 'display.announce_text': '注意安全' }).display.announce, '注意安全')
assert.equal(parseSettings({ display: { announce: ' ' } }).display.announce, null)
assert.equal(parseSettings({ display: { announce: 42 } }).display.announce, null)
})
// 大屏可能整条指令都没收到(断线、后开窗口),所以设置里的公告也要驱动同一套状态机。
test('快照里的公告变化:新公告进全屏,同一条不重复,服务端清空后跟着清', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const board = useScoreboard()
board.settings.value = parseSettings({ display: { announce: '紧急通知' } })
await nextTick()
assert.equal(board.announceText.value, '紧急通知')
assert.equal(board.page.value, 'announcement', '设置里出现本机没有的公告时要广播全屏')
t.mock.timers.tick(DEFAULT_ANNOUNCE_SECONDS * 1000)
assert.equal(board.page.value, 'standings', '全屏到点退回原页')
assert.equal(board.announceText.value, '紧急通知', '全屏结束后底栏仍留着这条公告')
// 指令推过同一条后,持久化会跟着回推一次设置:不能再全屏一次。
board.settings.value = parseSettings({ display: { announce: '紧急通知' } })
await nextTick()
assert.equal(board.page.value, 'standings')
assert.equal(board.announcement.value?.text, '紧急通知')
board.settings.value = parseSettings({})
await nextTick()
assert.equal(board.announcement.value, null)
assert.equal(board.announceText.value, '暂无公告')
})
+52 -31
View File
@@ -17,11 +17,11 @@ import {
parseTeamBreakdown,
} from './guards'
import type { DisplaySettings } from './guards'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { createMockAthletes, createMockSnapshot } from './mock'
import { readCachedSnapshot, writeCachedSnapshot } from './storage'
import { DISPLAY_PAGES, normalizePage } from './types'
import type {
AnnouncementState,
DetailOverlayState,
DisplayPageKey,
DisplaySource,
@@ -56,16 +56,35 @@ const options = ref<DisplayUrlOptions>({
resultsCount: 3,
})
const detail = ref<DetailOverlayState | null>(null)
const announcement = ref<AnnouncementState | null>(null)
const booted = ref(false)
/** 公告状态机:全屏退场后正文继续留着,底栏读的就是它。 */
const announcementStore = createAnnouncement({
page,
onFullscreenEnter: () => {
page.value = 'announcement'
// 公告不在轮播页清单里,20s 整页轮播会把它切走;公告期间必须停轮播。
stopPageRotation()
},
onFullscreenExit: (returnPage) => {
// 全屏期间可能被 goto 抢走页面,这时不能再把页拉回公告前那一页。
if (page.value === 'announcement') page.value = returnPage
if (!locked.value) startPageRotation()
},
})
const announcement = announcementStore.current
/** 底栏与公告页的公告正文:推送中的实时值优先,其次是服务端持久化的当前公告。 */
const announceText = computed(() =>
resolveAnnounceText(announcement.value, settings.value?.display?.announce),
)
let client: RealtimeClient | null = null
let rankTimer: ReturnType<typeof setTimeout> | null = null
let detailTimer: ReturnType<typeof setTimeout> | null = null
let announceTimer: ReturnType<typeof setTimeout> | null = null
let pageTimer: ReturnType<typeof setInterval> | null = null
let announceReturnPage: DisplayPageKey | null = null
let athletesLoading = false
/** 上一次见过的「服务端持久化公告」,用来判断设置里的值是真的变了。 */
let seenSettingsAnnounce = ''
const showAthleteBoard = computed(() => settings.value?.standings.showIndividualBoard ?? true)
@@ -339,31 +358,6 @@ async function openDetail(command: DisplayCommand): Promise<void> {
}
}
function announce(text: string, seconds: number): void {
const trimmed = text.trim()
if (trimmed === '') return
// 第二条公告到来时 page 已经是 announcement,必须复用「进入公告前的真实页」,
// 否则返回页会被记成公告页自身,计时结束后永远停在公告页。
if (page.value !== 'announcement') announceReturnPage = page.value
const returnPage = announceReturnPage ?? 'standings'
announcement.value = { text: trimmed, seconds, startedAt: Date.now() }
page.value = 'announcement'
// 公告不在轮播页清单里,20s 整页轮播会把它切走;公告期间必须停轮播。
stopPageRotation()
announceTimer = clearTimer(announceTimer)
if (seconds > 0) {
announceTimer = setTimeout(() => {
announceTimer = null
if (page.value === 'announcement') page.value = returnPage
announceReturnPage = null
if (!locked.value) startPageRotation()
}, seconds * 1000)
}
}
function handleCommand(command: DisplayCommand): void {
switch (command.action) {
case 'goto': {
@@ -386,7 +380,11 @@ function handleCommand(command: DisplayCommand): void {
window.location.reload()
break
case 'announce':
announce(command.payload.text ?? '', command.payload.seconds ?? 0)
// 全屏 seconds 秒后正文留在底栏,只有新公告或 announce_clear 才替换它。
announcementStore.push(command.payload.text ?? '', command.payload.seconds ?? 0)
break
case 'announce_clear':
announcementStore.clear()
break
case 'show-detail':
void openDetail(command)
@@ -397,6 +395,28 @@ function handleCommand(command: DisplayCommand): void {
}
}
/**
* 服务端把当前公告持久化在设置里(display.announce),快照与设置接口都带它。
*
* 指令可能因为断线、重开窗口而错过,所以设置里的值也当作一条公告来源:
* 变成本机没有的新内容就广播一次全屏,服务端清空后本地跟着清掉。
*/
watch(
() => settings.value?.display?.announce ?? null,
(next) => {
const text = next?.trim() ?? ''
if (text === seenSettingsAnnounce) return
seenSettingsAnnounce = text
if (text === '') {
announcementStore.clear()
return
}
// 指令刚推过同一条(持久化会跟着回推一次设置),不重复全屏。
if (announcement.value?.text === text) return
announcementStore.push(text, DEFAULT_ANNOUNCE_SECONDS)
},
)
// ---------------------------------------------------------------------------
// 生命周期
// ---------------------------------------------------------------------------
@@ -465,7 +485,7 @@ function dispose(): void {
stopPageRotation()
clearDetailTimer()
rankTimer = clearTimer(rankTimer)
announceTimer = clearTimer(announceTimer)
announcementStore.dispose()
client?.close()
client = null
booted.value = false
@@ -493,6 +513,7 @@ export function useScoreboard() {
options,
detail,
announcement,
announceText,
booted,
bootstrap,
dispose,
+6
View File
@@ -413,6 +413,11 @@ export interface EventSettings {
subtitle: string | null
primaryColor: string | null
rotateSeconds: number
/**
* 当前公告正文:推送后持久化,直到被替换或清除;没有公告时为 null。
* 旧服务端不下发这个键,所以可选;大屏解析层会归一成 string | null。
*/
announce?: string | null
}
standings: {
individualToTeamWeight: number
@@ -719,6 +724,7 @@ export type DisplayAction =
| 'unlock'
| 'reload'
| 'announce'
| 'announce_clear'
| 'show-detail'
| 'hide-detail'