公告: - 服务端把当前公告落库到 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。
104 lines
3.7 KiB
TypeScript
104 lines
3.7 KiB
TypeScript
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
|
|
}
|