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

+313
View File
@@ -0,0 +1,313 @@
import type {
AthleteStanding,
BreakdownItem,
CompareMode,
Event,
EventResultGroup,
EventResultRow,
EventSettings,
EventStatus,
EventType,
Gender,
LedgerSourceType,
RecordEntry,
RecordLevel,
RecordSource,
ResultMode,
ResultStatus,
ResultType,
SnapshotMessage,
StandingRow,
Team,
TeamBreakdown,
} from '@/shared'
/**
* WebSocket 与 localStorage 里的东西一律先当 unknown 再收窄。
*
* 大屏是无人值守的:一条脏数据不能把整页渲染炸掉,所以这里逐字段兜底,
* 而不是直接断言成后端类型。
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function list(value: unknown): unknown[] {
return Array.isArray(value) ? value : []
}
export function str(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
export function strOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
export function num(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
export function numOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
export function bool(value: unknown, fallback = false): boolean {
return typeof value === 'boolean' ? value : fallback
}
export function strings(value: unknown): string[] | null {
const items = list(value).filter((item): item is string => typeof item === 'string')
return items.length === 0 ? null : items
}
export function oneOf<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
? (value as T)
: fallback
}
const EVENT_TYPES = ['individual', 'team', 'match'] as const satisfies readonly EventType[]
const RESULT_MODES = ['auto', 'manual'] as const satisfies readonly ResultMode[]
const RESULT_TYPES = ['time', 'distance', 'count', 'score', 'none'] as const satisfies readonly ResultType[]
const COMPARE_MODES = ['asc', 'desc', 'none'] as const satisfies readonly CompareMode[]
const EVENT_STATUSES = ['pending', 'ongoing', 'finished', 'published'] as const satisfies readonly EventStatus[]
const RESULT_STATUSES = ['ok', 'dq', 'dns', 'dnf', 'withdrawn'] as const satisfies readonly ResultStatus[]
const GENDERS = ['男', '女'] as const satisfies readonly Gender[]
const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[]
const RECORD_SOURCES = ['history', 'current'] as const satisfies readonly RecordSource[]
const LEDGER_SOURCES = ['result', 'record_bonus', 'adjustment'] as const satisfies readonly LedgerSourceType[]
function record(source: unknown): Record<string, unknown> {
return isRecord(source) ? source : {}
}
interface TeamRefShape {
teamId: number
teamCode: string
teamName: string
shortName: string | null
color: string | null
logoPath: string | null
}
function parseTeamRef(source: unknown): TeamRefShape {
const rec = record(source)
return {
teamId: num(rec.teamId),
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
color: strOrNull(rec.color),
logoPath: strOrNull(rec.logoPath),
}
}
export function parseTeam(source: unknown): Team {
const rec = record(source)
return {
id: num(rec.id),
code: str(rec.code),
name: str(rec.name),
shortName: strOrNull(rec.shortName),
logoPath: strOrNull(rec.logoPath),
color: strOrNull(rec.color),
sortOrder: num(rec.sortOrder),
active: bool(rec.active, true),
}
}
export function parseEvent(source: unknown): Event {
const rec = record(source)
return {
id: num(rec.id),
sportId: num(rec.sportId),
code: str(rec.code),
name: str(rec.name),
eventType: oneOf(rec.eventType, EVENT_TYPES, 'individual'),
resultMode: oneOf(rec.resultMode, RESULT_MODES, 'auto'),
resultType: oneOf(rec.resultType, RESULT_TYPES, 'none'),
compare: oneOf(rec.compare, COMPARE_MODES, 'none'),
recordEligible: bool(rec.recordEligible),
scored: bool(rec.scored, true),
multiplier: num(rec.multiplier, 1),
status: oneOf(rec.status, EVENT_STATUSES, 'pending'),
scheduledAt: strOrNull(rec.scheduledAt),
venue: strOrNull(rec.venue),
sortOrder: num(rec.sortOrder),
remark: strOrNull(rec.remark),
}
}
export function parseStandingRow(source: unknown): StandingRow {
const rec = record(source)
const gold = num(rec.gold)
const silver = num(rec.silver)
const bronze = num(rec.bronze)
return {
...parseTeamRef(rec),
rank: num(rec.rank),
gold,
silver,
bronze,
medalTotal: num(rec.medalTotal, gold + silver + bronze),
totalPoints: num(rec.totalPoints),
rankChange: numOrNull(rec.rankChange),
}
}
export function parseStandingRows(value: unknown): StandingRow[] {
return list(value).map(parseStandingRow)
}
export function parseAthleteStanding(source: unknown): AthleteStanding {
const rec = record(source)
return {
rank: num(rec.rank),
athleteId: num(rec.athleteId),
athleteName: str(rec.athleteName),
gender: rec.gender === null || rec.gender === undefined ? null : oneOf(rec.gender, GENDERS, '男'),
teamId: num(rec.teamId),
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
totalPoints: num(rec.totalPoints),
gold: num(rec.gold),
silver: num(rec.silver),
bronze: num(rec.bronze),
}
}
export function parseAthleteStandings(value: unknown): AthleteStanding[] {
return list(value).map(parseAthleteStanding)
}
export function parseEventResultRow(source: unknown): EventResultRow {
const rec = record(source)
return {
entryId: num(rec.entryId),
rank: numOrNull(rec.rank),
teamId: num(rec.teamId),
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
athleteId: numOrNull(rec.athleteId),
athleteName: strOrNull(rec.athleteName),
memberNames: strings(rec.memberNames),
resultText: str(rec.resultText, '—'),
resultValue: numOrNull(rec.resultValue),
unit: str(rec.unit),
isTie: bool(rec.isTie),
status: oneOf(rec.status, RESULT_STATUSES, 'ok'),
}
}
export function parseEventResultGroup(source: unknown): EventResultGroup {
const rec = record(source)
return {
eventId: num(rec.eventId),
eventCode: str(rec.eventCode),
eventName: str(rec.eventName),
categoryId: num(rec.categoryId),
categoryName: str(rec.categoryName),
publishedAt: strOrNull(rec.publishedAt),
rows: list(rec.rows).map(parseEventResultRow),
}
}
export function parseEventResultGroups(value: unknown): EventResultGroup[] {
return list(value).map(parseEventResultGroup)
}
export function parseRecordEntry(source: unknown): RecordEntry {
const rec = record(source)
return {
id: num(rec.id),
eventId: num(rec.eventId),
level: oneOf(rec.level, RECORD_LEVELS, 'meet'),
resultValue: num(rec.resultValue),
resultText: str(rec.resultText, '—'),
holderName: strOrNull(rec.holderName),
holderTeamId: numOrNull(rec.holderTeamId),
achievedAt: strOrNull(rec.achievedAt),
isCurrent: bool(rec.isCurrent),
source: oneOf(rec.source, RECORD_SOURCES, 'history'),
}
}
export function parseSettings(source: unknown): EventSettings {
const rec = record(source)
const event = record(rec.event)
const display = record(rec.display)
const standings = record(rec.standings)
return {
event: {
name: str(event.name),
subtitle: strOrNull(event.subtitle),
host: strOrNull(event.host),
logoPath: strOrNull(event.logoPath),
primaryColor: strOrNull(event.primaryColor),
},
display: {
background: strOrNull(display.background),
logo: strOrNull(display.logo),
title: str(display.title),
subtitle: strOrNull(display.subtitle),
primaryColor: strOrNull(display.primaryColor),
rotateSeconds: num(display.rotateSeconds, 8),
},
standings: {
individualToTeamWeight: num(standings.individualToTeamWeight, 1),
rankingMode: str(standings.rankingMode, 'points'),
showIndividualBoard: bool(standings.showIndividualBoard, true),
},
defaultProfileId: numOrNull(rec.defaultProfileId),
requireApproval: bool(rec.requireApproval),
}
}
export function parseBreakdownItem(source: unknown): BreakdownItem {
const rec = record(source)
return {
ledgerId: num(rec.ledgerId),
sourceType: oneOf(rec.sourceType, LEDGER_SOURCES, 'result'),
eventId: numOrNull(rec.eventId),
eventCode: strOrNull(rec.eventCode),
eventName: strOrNull(rec.eventName),
categoryName: strOrNull(rec.categoryName),
athleteId: numOrNull(rec.athleteId),
athleteName: strOrNull(rec.athleteName),
rank: numOrNull(rec.rank),
resultText: strOrNull(rec.resultText),
points: num(rec.points),
createdAt: str(rec.createdAt),
}
}
export function parseTeamBreakdown(value: unknown): TeamBreakdown | null {
if (!isRecord(value)) return null
return {
teamId: num(value.teamId),
teamCode: str(value.teamCode),
teamName: str(value.teamName),
totalPoints: num(value.totalPoints),
items: list(value.items).map(parseBreakdownItem),
}
}
/** 快照是整包信任的入口,字段缺失时补空数组而不是拒绝整包。 */
export function parseSnapshot(value: unknown): SnapshotMessage | null {
if (!isRecord(value)) return null
const version = numOrNull(value.version)
if (version === null) return null
return {
version,
settings: parseSettings(value.settings),
teams: list(value.teams).map(parseTeam),
events: list(value.events).map(parseEvent),
standings: parseStandingRows(value.standings),
records: list(value.records).map(parseRecordEntry),
latestResults: parseEventResultGroups(value.latestResults),
}
}