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

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