feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,507 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { RealtimeClient, createApiClient } from '@/shared'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
Change,
|
||||
DisplayCommand,
|
||||
Event,
|
||||
EventResultGroup,
|
||||
EventSettings,
|
||||
RealtimeStatus,
|
||||
RecordEntry,
|
||||
SnapshotMessage,
|
||||
StandingRow,
|
||||
Team,
|
||||
} from '@/shared'
|
||||
import {
|
||||
isRecord,
|
||||
list,
|
||||
numOrNull,
|
||||
parseAthleteStandings,
|
||||
parseEvent,
|
||||
parseEventResultGroups,
|
||||
parseSettings,
|
||||
parseStandingRow,
|
||||
parseStandingRows,
|
||||
parseTeam,
|
||||
parseTeamBreakdown,
|
||||
} from './guards'
|
||||
import { createMockAthletes, createMockSnapshot } from './mock'
|
||||
import { readCachedSnapshot, writeCachedSnapshot } from './storage'
|
||||
import { DISPLAY_PAGES, normalizePage } from './types'
|
||||
import type {
|
||||
AnnouncementState,
|
||||
DetailOverlayState,
|
||||
DisplayPageKey,
|
||||
DisplaySource,
|
||||
DisplayUrlOptions,
|
||||
} from './types'
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
|
||||
/** 整页轮播间隔(06 第 4 节:后台可切换为整页轮播,每页 20s)。 */
|
||||
const PAGE_ROTATE_MS = 20_000
|
||||
/** 名次升降箭头保留时长(06 第 7 节:2s 后淡出)。 */
|
||||
const RANK_DELTA_MS = 2_000
|
||||
|
||||
const api = createApiClient()
|
||||
const reducedMotion = prefersReducedMotion()
|
||||
|
||||
const source = ref<DisplaySource>('live')
|
||||
const status = ref<RealtimeStatus>('idle')
|
||||
const version = ref<number | null>(null)
|
||||
const settings = ref<EventSettings | null>(null)
|
||||
const teams = ref<Team[]>([])
|
||||
const events = ref<Event[]>([])
|
||||
const standings = ref<StandingRow[]>([])
|
||||
const athletes = ref<AthleteStanding[]>([])
|
||||
const latestResults = ref<EventResultGroup[]>([])
|
||||
const records = ref<RecordEntry[]>([])
|
||||
const rankDelta = ref<Record<number, number>>({})
|
||||
const page = ref<DisplayPageKey>('standings')
|
||||
const locked = ref(true)
|
||||
const options = ref<DisplayUrlOptions>({
|
||||
page: null,
|
||||
locked: true,
|
||||
screen: null,
|
||||
instance: null,
|
||||
resultsCount: 3,
|
||||
})
|
||||
const detail = ref<DetailOverlayState | null>(null)
|
||||
const announcement = ref<AnnouncementState | null>(null)
|
||||
const booted = ref(false)
|
||||
|
||||
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
|
||||
|
||||
const showAthleteBoard = computed(() => settings.value?.standings.showIndividualBoard ?? true)
|
||||
|
||||
/** 参与整页轮播的页;公告是手动触发的页,个人榜按配置可隐藏。 */
|
||||
const rotationPages = computed<DisplayPageKey[]>(() =>
|
||||
DISPLAY_PAGES.map((item) => item.key).filter(
|
||||
(key) => key !== 'announcement' && (key !== 'athlete-standings' || showAthleteBoard.value),
|
||||
),
|
||||
)
|
||||
|
||||
function compareStanding(a: StandingRow, b: StandingRow): number {
|
||||
return a.rank - b.rank || b.totalPoints - a.totalPoints
|
||||
}
|
||||
|
||||
function clearTimer(timer: ReturnType<typeof setTimeout> | null): null {
|
||||
if (timer !== null) clearTimeout(timer)
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 数据落地
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 用新的积分榜替换本地榜单,并算出相对上一次的名次变化。
|
||||
*
|
||||
* 服务端给的 rankChange 优先;没有基准(首屏 / 刚换页)时用本地前后对比兜底,
|
||||
* 这样断线重连后即使服务端没带 rankChange,箭头也不会凭空消失。
|
||||
*/
|
||||
function applyStandings(rows: StandingRow[]): void {
|
||||
const previous = new Map(standings.value.map((row) => [row.teamId, row.rank]))
|
||||
const deltas: Record<number, number> = {}
|
||||
const next = rows.map((row) => {
|
||||
const before = previous.get(row.teamId)
|
||||
const local = before === undefined ? null : before - row.rank
|
||||
const delta = row.rankChange ?? local
|
||||
if (delta !== null && delta !== 0) deltas[row.teamId] = delta
|
||||
return { ...row, rankChange: delta }
|
||||
})
|
||||
|
||||
standings.value = next.sort(compareStanding)
|
||||
|
||||
if (Object.keys(deltas).length === 0) return
|
||||
rankDelta.value = deltas
|
||||
rankTimer = clearTimer(rankTimer)
|
||||
rankTimer = setTimeout(() => {
|
||||
rankDelta.value = {}
|
||||
rankTimer = null
|
||||
}, RANK_DELTA_MS)
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: SnapshotMessage, kind: DisplaySource): void {
|
||||
settings.value = snapshot.settings
|
||||
teams.value = snapshot.teams
|
||||
events.value = snapshot.events
|
||||
latestResults.value = snapshot.latestResults
|
||||
records.value = snapshot.records
|
||||
version.value = snapshot.version
|
||||
applyStandings(snapshot.standings)
|
||||
source.value = kind
|
||||
}
|
||||
|
||||
function mergeById<T extends { id: number }>(
|
||||
current: T[],
|
||||
change: Change,
|
||||
parse: (value: unknown) => T,
|
||||
): T[] {
|
||||
if (change.action === 'replace') return list(change.data).map(parse)
|
||||
|
||||
if (change.action === 'void') {
|
||||
const id = change.id ?? (isRecord(change.data) ? numOrNull(change.data.id) : null)
|
||||
return id === null ? current : current.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
const item = parse(change.data)
|
||||
return [...current.filter((entry) => entry.id !== item.id), item]
|
||||
}
|
||||
|
||||
function mergeStanding(row: StandingRow): void {
|
||||
applyStandings([...standings.value.filter((item) => item.teamId !== row.teamId), row])
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用增量变更。
|
||||
*
|
||||
* 只有榜单类实体能在客户端可靠合并;其余实体(如逐条成绩)合并规则在服务端,
|
||||
* 这里直接 resync 拉全量——宁可多拉一次,也不允许大屏上出现算错的分数。
|
||||
*/
|
||||
function applyChanges(changes: Change[]): void {
|
||||
let resyncNeeded = false
|
||||
|
||||
for (const change of changes) {
|
||||
switch (change.entity) {
|
||||
case 'standings':
|
||||
if (change.action === 'replace') applyStandings(parseStandingRows(change.data))
|
||||
else if (change.action === 'upsert') mergeStanding(parseStandingRow(change.data))
|
||||
break
|
||||
case 'athleteStandings':
|
||||
case 'athlete_standings':
|
||||
if (change.action === 'replace') athletes.value = parseAthleteStandings(change.data)
|
||||
break
|
||||
case 'latestResults':
|
||||
case 'eventResults':
|
||||
if (change.action === 'replace') latestResults.value = parseEventResultGroups(change.data)
|
||||
break
|
||||
case 'settings':
|
||||
if (change.action !== 'void') settings.value = parseSettings(change.data)
|
||||
break
|
||||
case 'teams':
|
||||
teams.value = mergeById(teams.value, change, parseTeam)
|
||||
break
|
||||
case 'events':
|
||||
events.value = mergeById(events.value, change, parseEvent)
|
||||
break
|
||||
default:
|
||||
resyncNeeded = true
|
||||
}
|
||||
}
|
||||
|
||||
if (resyncNeeded) client?.requestResync()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 断线兜底与启动
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hydrateFromCache(): void {
|
||||
const cached = readCachedSnapshot()
|
||||
if (cached === null) return
|
||||
applySnapshot(cached.snapshot, 'cache')
|
||||
}
|
||||
|
||||
/**
|
||||
* REST 兜底:快照不含个人榜,所以个人榜必须单独取;
|
||||
* 但快照比 REST 权威(REST 没有 version),已收到快照时不用 REST 覆盖。
|
||||
*/
|
||||
async function loadRest(): Promise<boolean> {
|
||||
const hasSnapshot = version.value !== null
|
||||
const [settingsResult, standingsResult, athletesResult] = await Promise.allSettled([
|
||||
api.settings.get(),
|
||||
api.standings.teams(),
|
||||
api.standings.athletes(),
|
||||
])
|
||||
|
||||
let ok = false
|
||||
if (settingsResult.status === 'fulfilled' && !hasSnapshot) {
|
||||
settings.value = settingsResult.value
|
||||
ok = true
|
||||
}
|
||||
if (standingsResult.status === 'fulfilled' && !hasSnapshot) {
|
||||
applyStandings(standingsResult.value)
|
||||
ok = true
|
||||
}
|
||||
if (athletesResult.status === 'fulfilled') {
|
||||
athletes.value = athletesResult.value
|
||||
ok = true
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
function activateMock(): void {
|
||||
const snapshot = createMockSnapshot()
|
||||
settings.value = snapshot.settings
|
||||
teams.value = snapshot.teams
|
||||
events.value = snapshot.events
|
||||
latestResults.value = snapshot.latestResults
|
||||
records.value = snapshot.records
|
||||
athletes.value = createMockAthletes()
|
||||
standings.value = snapshot.standings
|
||||
source.value = 'mock'
|
||||
|
||||
// 示例数据也把升降箭头演示出来,方便验收动效。
|
||||
const deltas: Record<number, number> = {}
|
||||
for (const row of snapshot.standings) {
|
||||
if (row.rankChange !== null && row.rankChange !== 0) deltas[row.teamId] = row.rankChange
|
||||
}
|
||||
rankDelta.value = deltas
|
||||
rankTimer = clearTimer(rankTimer)
|
||||
rankTimer = setTimeout(() => {
|
||||
rankDelta.value = {}
|
||||
rankTimer = null
|
||||
}, RANK_DELTA_MS)
|
||||
}
|
||||
|
||||
function readUrlOptions(): DisplayUrlOptions {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const lockParam = params.get('lock')
|
||||
return {
|
||||
page: normalizePage(params.get('page')),
|
||||
// 主区域默认锁定总积分榜,只有显式 lock=0 才开整页轮播。
|
||||
locked: lockParam !== '0' && lockParam !== 'false',
|
||||
screen: params.get('screen'),
|
||||
instance: params.get('instance'),
|
||||
resultsCount: clampInt(params.get('n'), 1, 6, 3),
|
||||
}
|
||||
}
|
||||
|
||||
function clampInt(value: string | null, min: number, max: number, fallback: number): number {
|
||||
if (value === null) return fallback
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 轮播
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stopPageRotation(): void {
|
||||
if (pageTimer !== null) {
|
||||
clearInterval(pageTimer)
|
||||
pageTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPageRotation(): void {
|
||||
stopPageRotation()
|
||||
if (locked.value || reducedMotion.value) return
|
||||
const order = rotationPages.value
|
||||
if (order.length < 2) return
|
||||
|
||||
pageTimer = setInterval(() => {
|
||||
const current = order.indexOf(page.value)
|
||||
const next = order[(current + 1) % order.length]
|
||||
if (next !== undefined) page.value = next
|
||||
}, PAGE_ROTATE_MS)
|
||||
}
|
||||
|
||||
function setPage(next: DisplayPageKey): void {
|
||||
page.value = next
|
||||
}
|
||||
|
||||
function setLocked(value: boolean): void {
|
||||
locked.value = value
|
||||
if (value) stopPageRotation()
|
||||
else startPageRotation()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 覆盖层与公告
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function clearDetailTimer(): void {
|
||||
detailTimer = clearTimer(detailTimer)
|
||||
}
|
||||
|
||||
function closeDetail(): void {
|
||||
clearDetailTimer()
|
||||
detail.value = null
|
||||
if (!locked.value) startPageRotation()
|
||||
}
|
||||
|
||||
async function openDetail(command: DisplayCommand): Promise<void> {
|
||||
const payload = command.payload
|
||||
const provided = payload.detail === undefined ? null : parseTeamBreakdown(payload.detail)
|
||||
const teamId = typeof payload.teamId === 'number' ? payload.teamId : null
|
||||
const openedAt = Date.now()
|
||||
|
||||
detail.value = {
|
||||
title: payload.title ?? provided?.teamName ?? '积分明细',
|
||||
detail: provided,
|
||||
autoCloseSeconds: payload.autoCloseSeconds ?? 60,
|
||||
openedAt,
|
||||
loading: provided === null && teamId !== null,
|
||||
}
|
||||
// 明细覆盖期间暂停轮播(06 第 5 节)。
|
||||
stopPageRotation()
|
||||
clearDetailTimer()
|
||||
if (detail.value.autoCloseSeconds > 0) {
|
||||
detailTimer = setTimeout(closeDetail, detail.value.autoCloseSeconds * 1000)
|
||||
}
|
||||
|
||||
if (!detail.value.loading || teamId === null) return
|
||||
try {
|
||||
const breakdown = parseTeamBreakdown(await api.standings.breakdown(teamId))
|
||||
// 请求期间可能已被 hide-detail 或自动关闭,用 openedAt 判断这份响应还属于当前覆盖层。
|
||||
if (detail.value !== null && detail.value.openedAt === openedAt) {
|
||||
detail.value.detail = breakdown
|
||||
detail.value.loading = false
|
||||
}
|
||||
} catch {
|
||||
if (detail.value !== null && detail.value.openedAt === openedAt) detail.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function announce(text: string, seconds: number): void {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return
|
||||
|
||||
announcement.value = { text: trimmed, seconds, startedAt: Date.now() }
|
||||
const previous = page.value
|
||||
page.value = 'announcement'
|
||||
|
||||
announceTimer = clearTimer(announceTimer)
|
||||
if (seconds > 0) {
|
||||
announceTimer = setTimeout(() => {
|
||||
announceTimer = null
|
||||
if (page.value === 'announcement') page.value = previous
|
||||
}, seconds * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommand(command: DisplayCommand): void {
|
||||
switch (command.action) {
|
||||
case 'goto': {
|
||||
const target = normalizePage(command.payload.page)
|
||||
if (target !== null) {
|
||||
closeDetail()
|
||||
page.value = target
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'lock':
|
||||
setLocked(true)
|
||||
break
|
||||
case 'unlock':
|
||||
setLocked(false)
|
||||
break
|
||||
case 'reload':
|
||||
window.location.reload()
|
||||
break
|
||||
case 'announce':
|
||||
announce(command.payload.text ?? '', command.payload.seconds ?? 0)
|
||||
break
|
||||
case 'show-detail':
|
||||
void openDetail(command)
|
||||
break
|
||||
case 'hide-detail':
|
||||
closeDetail()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 生命周期
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function connect(): void {
|
||||
if (client !== null) return
|
||||
client = new RealtimeClient({
|
||||
role: 'display',
|
||||
displayKey: options.value.instance ?? options.value.screen ?? undefined,
|
||||
initialVersion: version.value,
|
||||
onSnapshot: (message) => {
|
||||
applySnapshot(message, 'live')
|
||||
writeCachedSnapshot(message)
|
||||
},
|
||||
onCatchup: (message) => {
|
||||
applyChanges(message.changes)
|
||||
},
|
||||
onChange: (message) => {
|
||||
applyChanges(message.changes)
|
||||
},
|
||||
onCommand: handleCommand,
|
||||
onStatusChange: (next) => {
|
||||
status.value = next
|
||||
},
|
||||
onVersionChange: (next) => {
|
||||
version.value = next
|
||||
},
|
||||
})
|
||||
client.connect()
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (booted.value) return
|
||||
booted.value = true
|
||||
|
||||
const parsed = readUrlOptions()
|
||||
options.value = parsed
|
||||
if (parsed.page !== null) page.value = parsed.page
|
||||
locked.value = parsed.locked
|
||||
|
||||
hydrateFromCache()
|
||||
connect()
|
||||
|
||||
const restOk = await loadRest()
|
||||
if (!restOk && version.value === null) activateMock()
|
||||
|
||||
if (!locked.value) startPageRotation()
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
stopPageRotation()
|
||||
clearDetailTimer()
|
||||
rankTimer = clearTimer(rankTimer)
|
||||
announceTimer = clearTimer(announceTimer)
|
||||
client?.close()
|
||||
client = null
|
||||
booted.value = false
|
||||
}
|
||||
|
||||
function rankDeltaFor(teamId: number): number | null {
|
||||
return rankDelta.value[teamId] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 大屏只有这一份状态:多块屏各自是独立窗口/标签页,不需要跨实例共享,
|
||||
* 所以用模块级单例而不是 Pinia,少一层依赖。
|
||||
*/
|
||||
export function useScoreboard() {
|
||||
return {
|
||||
source,
|
||||
status,
|
||||
version,
|
||||
settings,
|
||||
teams,
|
||||
events,
|
||||
standings,
|
||||
athletes,
|
||||
latestResults,
|
||||
records,
|
||||
rankDelta,
|
||||
page,
|
||||
locked,
|
||||
options,
|
||||
detail,
|
||||
announcement,
|
||||
booted,
|
||||
rotationPages,
|
||||
bootstrap,
|
||||
dispose,
|
||||
setPage,
|
||||
setLocked,
|
||||
startPageRotation,
|
||||
stopPageRotation,
|
||||
closeDetail,
|
||||
rankDeltaFor,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user