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

+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,