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 { 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 } /** 三态布尔:字段缺失时保持 undefined,调用方才能区分「未配置」与「显式关闭」。 */ function boolOrUndefined(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined } 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(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 { 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), // 快照直接序列化领域行时用的是 code/name,REST 契约用 teamCode/teamName,两种都收。 teamCode: str(rec.teamCode) || str(rec.code), teamName: str(rec.teamName) || str(rec.name), 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, num(rec.medals, gold + silver + bronze)), totalPoints: num(rec.totalPoints, num(rec.points)), 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), } } function parseFlatResultRow( rec: Record, byTeam: ReadonlyMap, ): EventResultRow { const row = parseEventResultRow(rec) const team = byTeam.get(row.teamId) if (team === undefined) return row // 平铺行只带 teamId,单位名要从快照的 teams 里补,否则底栏只能显示空白。 return { ...row, teamCode: row.teamCode === '' ? team.code : row.teamCode, teamName: row.teamName === '' ? team.name : row.teamName, shortName: row.shortName ?? team.shortName, } } /** * 服务端快照的 latestResults 存在「已分组」与「平铺成绩行」两种形态。 * * 平铺行没有 rows,按分组解析会得到一堆空组,底栏轮播与分项成绩页就只剩空白; * 这里检测到平铺行时按 eventId+categoryId 就地聚合,两种契约都能渲染。 */ export function parseEventResultGroups( value: unknown, teams: readonly Team[] = [], ): EventResultGroup[] { const items = list(value) if (items.length === 0) return [] if (items.some((item) => isRecord(item) && Array.isArray(item.rows))) { return items.map(parseEventResultGroup) } const byTeam = new Map(teams.map((team) => [team.id, team])) const groups = new Map() for (const item of items) { const rec = record(item) const eventId = num(rec.eventId) const categoryId = num(rec.categoryId) const key = `${eventId}-${categoryId}` let group = groups.get(key) if (group === undefined) { group = { eventId, eventCode: str(rec.eventCode), eventName: str(rec.eventName), categoryId, categoryName: str(rec.categoryName), publishedAt: strOrNull(rec.publishedAt ?? rec.updatedAt), rows: [], } groups.set(key, group) } group.rows.push(parseFlatResultRow(rec, byTeam)) } return [...groups.values()] } 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'), } } /** * 大屏侧赛事配置。字段与 shared 契约一致; * `showIndividualMedals` 缺失时由 parseSettings 回退到 `showIndividualBoard`(旧服务端只下发后者)。 */ export type DisplaySettings = EventSettings /** * 嵌套路径取值。 * * REST 的 `/api/settings` 是嵌套对象,WS 快照却是 `display.rotate_seconds` * 这类点号扁平键;同一份配置两种形态都要认,故先走嵌套路径再退回扁平键。 */ function settingValue( source: Record, nestedPath: string, flatKey: string, ): unknown { let node: unknown = source for (const segment of nestedPath.split('.')) { if (!isRecord(node)) { node = undefined break } node = node[segment] } if (node !== undefined && node !== null) return node return source[flatKey] } export function parseSettings(source: unknown): DisplaySettings { const rec = record(source) const showIndividualBoard = bool( settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'), true, ) return { event: { name: str(settingValue(rec, 'event.name', 'event.name')), subtitle: strOrNull(settingValue(rec, 'event.subtitle', 'event.subtitle')), host: strOrNull(settingValue(rec, 'event.host', 'event.organizer')), logoPath: strOrNull(settingValue(rec, 'event.logoPath', 'event.logo_path')), primaryColor: strOrNull(settingValue(rec, 'event.primaryColor', 'display.theme_color')), }, display: { background: strOrNull(settingValue(rec, 'display.background', 'display.background')), logo: strOrNull(settingValue(rec, 'display.logo', 'display.logo')), title: str(settingValue(rec, 'display.title', 'display.title')), subtitle: strOrNull(settingValue(rec, 'display.subtitle', 'display.subtitle')), primaryColor: strOrNull(settingValue(rec, 'display.primaryColor', 'display.theme_color')), rotateSeconds: num(settingValue(rec, 'display.rotateSeconds', 'display.rotate_seconds'), 8), }, standings: { individualToTeamWeight: num( settingValue(rec, 'standings.individualToTeamWeight', 'standings.individual_to_team_weight'), 1, ), rankingMode: str( settingValue(rec, 'standings.rankingMode', 'standings.ranking_mode'), 'points', ), showIndividualBoard, // 旧服务端只下发 showIndividualBoard,奖牌列跟随它,避免升级后突然多出一列。 showIndividualMedals: boolOrUndefined( settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'), ) ?? showIndividualBoard, }, 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 const teams = list(value.teams).map(parseTeam) return { version, settings: parseSettings(value.settings), teams, events: list(value.events).map(parseEvent), standings: parseStandingRows(value.standings), records: list(value.records).map(parseRecordEntry), // 平铺成绩行要靠 teams 补单位名,必须传入已解析的单位表。 latestResults: parseEventResultGroups(value.latestResults, teams), } }