fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件
This commit is contained in:
1 parent
c121111f51
commit
b80c64a115
39 files changed
+4217
-345
No files matched your search
+129
-26
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user