fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件

This commit is contained in:
AskaEth committed 2026-09-09 20:57:58 +08:00
1 parent c121111f51
commit b80c64a115
39 files changed
+4217 -345

No files matched your search

+129 -26
View File
@@ -57,6 +57,11 @@ 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
@@ -96,8 +101,9 @@ function parseTeamRef(source: unknown): TeamRefShape {
const rec = record(source)
return {
teamId: num(rec.teamId),
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
// 快照直接序列化领域行时用的是 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),
@@ -151,8 +157,8 @@ export function parseStandingRow(source: unknown): StandingRow {
gold,
silver,
bronze,
medalTotal: num(rec.medalTotal, gold + silver + bronze),
totalPoints: num(rec.totalPoints),
medalTotal: num(rec.medalTotal, num(rec.medals, gold + silver + bronze)),
totalPoints: num(rec.totalPoints, num(rec.points)),
rankChange: numOrNull(rec.rankChange),
}
}
@@ -216,8 +222,61 @@ export function parseEventResultGroup(source: unknown): EventResultGroup {
}
}
export function parseEventResultGroups(value: unknown): EventResultGroup[] {
return list(value).map(parseEventResultGroup)
function parseFlatResultRow(
rec: Record<string, unknown>,
byTeam: ReadonlyMap<number, Team>,
): 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<number, Team>(teams.map((team) => [team.id, team]))
const groups = new Map<string, EventResultGroup>()
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 {
@@ -236,31 +295,73 @@ export function parseRecordEntry(source: unknown): RecordEntry {
}
}
export function parseSettings(source: unknown): EventSettings {
/**
* 大屏侧扩展的赛事配置。
*
* `standings.showIndividualMedals` 还没进入 shared 契约,但大屏需要它独立控制奖牌列;
* 声明成可选,旧配置(服务端只给 showIndividualBoard)仍可赋值,页面再回退。
*/
export type DisplaySettings = EventSettings & {
standings: EventSettings['standings'] & { showIndividualMedals?: boolean }
}
/**
* 嵌套路径取值。
*
* REST 的 `/api/settings` 是嵌套对象,WS 快照却是 `display.rotate_seconds`
* 这类点号扁平键;同一份配置两种形态都要认,故先走嵌套路径再退回扁平键。
*/
function settingValue(
source: Record<string, unknown>,
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 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),
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(display.background),
logo: strOrNull(display.logo),
title: str(display.title),
subtitle: strOrNull(display.subtitle),
primaryColor: strOrNull(display.primaryColor),
rotateSeconds: num(display.rotateSeconds, 8),
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(standings.individualToTeamWeight, 1),
rankingMode: str(standings.rankingMode, 'points'),
showIndividualBoard: bool(standings.showIndividualBoard, true),
individualToTeamWeight: num(
settingValue(rec, 'standings.individualToTeamWeight', 'standings.individual_to_team_weight'),
1,
),
rankingMode: str(
settingValue(rec, 'standings.rankingMode', 'standings.ranking_mode'),
'points',
),
showIndividualBoard: bool(
settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'),
true,
),
showIndividualMedals: boolOrUndefined(
settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'),
),
},
defaultProfileId: numOrNull(rec.defaultProfileId),
requireApproval: bool(rec.requireApproval),
@@ -301,13 +402,15 @@ 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: list(value.teams).map(parseTeam),
teams,
events: list(value.events).map(parseEvent),
standings: parseStandingRows(value.standings),
records: list(value.records).map(parseRecordEntry),
latestResults: parseEventResultGroups(value.latestResults),
// 平铺成绩行要靠 teams 补单位名,必须传入已解析的单位表。
latestResults: parseEventResultGroups(value.latestResults, teams),
}
}