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,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),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user