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:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+507
View File
@@ -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,
}
}