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