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

+4 -4
View File
@@ -35,8 +35,8 @@ const { scale, offsetX, offsetY } = useStageScale({
const currentPage = computed<Component>(() => PAGES[page.value])
const rootStyle = { '--display-titlebar-h': `${WINDOWED_TITLEBAR_HEIGHT}px` }
const barTitle = computed(() => settings.value?.display.title.trim() || '赛事标题待配置')
const barLogo = computed(() => settings.value?.display.logo?.trim() ?? '')
const barTitle = computed(() => settings.value?.display?.title?.trim() || '赛事标题待配置')
const barLogo = computed(() => settings.value?.display?.logo?.trim() ?? '')
const stageStyle = computed(() => ({
transform: `scale(${scale.value})`,
@@ -45,14 +45,14 @@ const stageStyle = computed(() => ({
}))
const background = computed(() => {
const value = settings.value?.display.background?.trim()
const value = settings.value?.display?.background?.trim()
return value !== undefined && value !== '' ? value : null
})
/** 主色由配置驱动,亮/暗变体与前景色在这里一次性算好挂成 CSS 变量。 */
const themeVars = computed<Record<string, string>>(() => {
const vars: Record<string, string> = {}
const primary = parseHexColor(settings.value?.display.primaryColor ?? null)
const primary = parseHexColor(settings.value?.display?.primaryColor ?? null)
if (primary === null) return vars
const light = mix(primary, { r: 255, g: 255, b: 255 }, 0.45)
+1 -1
View File
@@ -31,7 +31,7 @@ const items = computed<TickerItem[]>(() => {
const index = ref(0)
const current = computed<TickerItem | null>(() => items.value[index.value] ?? items.value[0] ?? null)
const paused = computed(() => detail.value !== null)
const rotateMs = computed(() => Math.max(2, settings.value?.display.rotateSeconds ?? 8) * 1000)
const rotateMs = computed(() => Math.max(2, settings.value?.display?.rotateSeconds ?? 8) * 1000)
let timer = 0
@@ -8,7 +8,11 @@ import PageFrame from '../PageFrame.vue'
const { athletes, settings } = useScoreboard()
const rows = computed(() => athletes.value)
const showMedals = computed(() => settings.value?.standings.showIndividualBoard ?? true)
// 奖牌列由独立开关控制;旧配置没有 showIndividualMedals 时回退到「个人榜上屏」开关,避免升级后突然多出一列。
const showMedals = computed(() => {
const standings = settings.value?.standings
return standings?.showIndividualMedals ?? standings?.showIndividualBoard ?? true
})
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
</script>
@@ -6,12 +6,8 @@ import PageFrame from '../PageFrame.vue'
const { standings } = useScoreboard()
const rows = computed(() =>
[...standings.value].sort(
(a, b) =>
b.gold - a.gold || b.silver - a.silver || b.bronze - a.bronze || b.totalPoints - a.totalPoints,
),
)
// 排序与名次都取服务端口径(总分优先),前端不再按金→银→铜重排,否则名次会与总积分榜打架。
const rows = computed(() => standings.value)
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
</script>
@@ -27,8 +23,8 @@ const rowFont = computed(() => fitRowFont(rows.value.length, 560))
<span class="medal bronze">铜</span>
<span class="total">合计</span>
</div>
<div v-for="(row, index) in rows" :key="row.teamId" class="row">
<span class="rank" :class="`top-${Math.min(index + 1, 4)}`">{{ index + 1 }}</span>
<div v-for="row in rows" :key="row.teamId" class="row">
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
<span class="name">{{ row.teamName }}</span>
<span class="medal gold">{{ row.gold }}</span>
<span class="medal silver">{{ row.silver }}</span>
+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),
}
}
+26 -9
View File
@@ -6,7 +6,6 @@ import type {
DisplayCommand,
Event,
EventResultGroup,
EventSettings,
RealtimeStatus,
RecordEntry,
SnapshotMessage,
@@ -21,11 +20,13 @@ import {
parseEvent,
parseEventResultGroups,
parseSettings,
parseSnapshot,
parseStandingRow,
parseStandingRows,
parseTeam,
parseTeamBreakdown,
} from './guards'
import type { DisplaySettings } from './guards'
import { createMockAthletes, createMockSnapshot } from './mock'
import { readCachedSnapshot, writeCachedSnapshot } from './storage'
import { DISPLAY_PAGES, normalizePage } from './types'
@@ -49,7 +50,7 @@ const reducedMotion = prefersReducedMotion()
const source = ref<DisplaySource>('live')
const status = ref<RealtimeStatus>('idle')
const version = ref<number | null>(null)
const settings = ref<EventSettings | null>(null)
const settings = ref<DisplaySettings | null>(null)
const teams = ref<Team[]>([])
const events = ref<Event[]>([])
const standings = ref<StandingRow[]>([])
@@ -169,8 +170,17 @@ function applyChanges(changes: Change[]): void {
for (const change of changes) {
switch (change.entity) {
case 'standings':
if (change.action === 'replace') applyStandings(parseStandingRows(change.data))
else if (change.action === 'upsert') mergeStanding(parseStandingRow(change.data))
if (change.action === 'replace') {
// 服务端当前下发的是 {eventId:N} 这类定位信息而不是榜单数组;
// 直接当空数组用会把大屏清空,所以忽略并拉全量。
if (!Array.isArray(change.data)) {
resyncNeeded = true
break
}
applyStandings(parseStandingRows(change.data))
} else if (change.action === 'upsert') {
mergeStanding(parseStandingRow(change.data))
}
break
case 'athleteStandings':
case 'athlete_standings':
@@ -178,7 +188,9 @@ function applyChanges(changes: Change[]): void {
break
case 'latestResults':
case 'eventResults':
if (change.action === 'replace') latestResults.value = parseEventResultGroups(change.data)
if (change.action === 'replace') {
latestResults.value = parseEventResultGroups(change.data, teams.value)
}
break
case 'settings':
if (change.action !== 'void') settings.value = parseSettings(change.data)
@@ -221,7 +233,8 @@ async function loadRest(): Promise<boolean> {
let ok = false
if (settingsResult.status === 'fulfilled' && !hasSnapshot) {
settings.value = settingsResult.value
// REST 载荷同样过解析层,保证嵌套结构与默认值和大屏其余入口一致。
settings.value = parseSettings(settingsResult.value)
ok = true
}
if (standingsResult.status === 'fulfilled' && !hasSnapshot) {
@@ -237,7 +250,7 @@ async function loadRest(): Promise<boolean> {
function activateMock(): void {
const snapshot = createMockSnapshot()
settings.value = snapshot.settings
settings.value = parseSettings(snapshot.settings)
teams.value = snapshot.teams
events.value = snapshot.events
latestResults.value = snapshot.latestResults
@@ -419,8 +432,12 @@ function connect(): void {
displayKey: options.value.instance ?? options.value.screen ?? undefined,
initialVersion: version.value,
onSnapshot: (message) => {
applySnapshot(message, 'live')
writeCachedSnapshot(message)
// 服务端快照的 settings 是点号扁平 map,原始载荷直写 ref 会让 display 变 undefined;
// 必须过解析层归一,解析失败则整包丢弃(心跳/重连会再给一次)。
const snapshot = parseSnapshot(message)
if (snapshot === null) return
applySnapshot(snapshot, 'live')
writeCachedSnapshot(snapshot)
},
onCatchup: (message) => {
applyChanges(message.changes)