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
@@ -69,7 +69,8 @@ async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Prom
|
||||
}
|
||||
|
||||
export interface Bootstrap {
|
||||
settings: EventSettings
|
||||
/** settings 是 admin-only;访客/录入员读不到时为 null,其余赛事数据照常返回。 */
|
||||
settings: EventSettings | null
|
||||
teams: Team[]
|
||||
sports: Sport[]
|
||||
events: Event[]
|
||||
@@ -78,11 +79,17 @@ export interface Bootstrap {
|
||||
athleteStandings: AthleteStanding[]
|
||||
}
|
||||
|
||||
/** 除 settings 外都视为必需:失败时交给 call 决定降级到示例数据还是抛出业务错误。 */
|
||||
function required<T>(result: PromiseSettledResult<T>): T {
|
||||
if (result.status === 'fulfilled') return result.value
|
||||
throw result.reason
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<Bootstrap> {
|
||||
return call(
|
||||
async () => {
|
||||
const [settings, teams, sports, events, profiles, standings, athleteStandings] =
|
||||
await Promise.all([
|
||||
await Promise.allSettled([
|
||||
client.settings.get(),
|
||||
client.teams.list(),
|
||||
client.sports.list(),
|
||||
@@ -91,7 +98,17 @@ async function bootstrap(): Promise<Bootstrap> {
|
||||
client.standings.teams(),
|
||||
client.standings.athletes(),
|
||||
])
|
||||
return { settings, teams, sports, events, profiles, standings, athleteStandings }
|
||||
// GET /api/settings 是 admin-only,guest/recorder 必然 403。
|
||||
// 这是权限边界而非服务不可用,不能让整页数据跟着失败,所以 settings 单独降级为 null。
|
||||
return {
|
||||
settings: settings.status === 'fulfilled' ? settings.value : null,
|
||||
teams: required(teams),
|
||||
sports: required(sports),
|
||||
events: required(events),
|
||||
profiles: required(profiles),
|
||||
standings: required(standings),
|
||||
athleteStandings: required(athleteStandings),
|
||||
}
|
||||
},
|
||||
() => mockDb.bootstrap(),
|
||||
)
|
||||
|
||||
@@ -8,48 +8,51 @@ import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
/** 大屏页面表里的规范值;写错会被大屏静默丢弃,所以用字面量联合锁死。 */
|
||||
type DisplayPage = 'standings' | 'team-medals' | 'athlete-standings' | 'event-results'
|
||||
|
||||
interface ScreenNode {
|
||||
key: string
|
||||
name: string
|
||||
/** null = 未知;后端没有实例上报接口,占位节点恒为未知。 */
|
||||
online: boolean | null
|
||||
lastSeen: string | null
|
||||
/** 本机最后一次下发指令的时间,不是大屏心跳。 */
|
||||
lastCommandAt: string | null
|
||||
}
|
||||
|
||||
const PAGES: Array<{ key: string; label: string }> = [
|
||||
const PAGES: Array<{ key: DisplayPage; label: string }> = [
|
||||
{ key: 'standings', label: '总积分榜' },
|
||||
{ key: 'medals', label: '团体奖牌榜' },
|
||||
{ key: 'athletes', label: '个人积分榜' },
|
||||
{ key: 'latest', label: '最新成绩' },
|
||||
{ key: 'team-medals', label: '团体奖牌榜' },
|
||||
{ key: 'athlete-standings', label: '个人积分榜' },
|
||||
{ key: 'event-results', label: '最新成绩' },
|
||||
]
|
||||
|
||||
const data = useEventStore()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const currentPage = ref('standings')
|
||||
const currentPage = ref<DisplayPage>('standings')
|
||||
const locked = ref(false)
|
||||
const announceText = ref('')
|
||||
const announceSeconds = ref(15)
|
||||
const pending = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 在线看板。
|
||||
* 在线看板(占位)。
|
||||
*
|
||||
* devdocs/05 §7 未定义「大屏在线列表」接口,管理端拿不到每块屏的心跳,
|
||||
* 因此这里只登记本地已知的屏,并用「最近一次命令是否送达」推断在线状态。
|
||||
* 后端补上接口后,把 screens 换成接口数据即可。
|
||||
* 所以这里只登记本地已知的屏,在线状态恒为「未知」——指令送达不等于大屏在线。
|
||||
* 后端补上实例上报接口后,把 screens 换成接口数据、hasInstanceData 才可能为真。
|
||||
*/
|
||||
const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastSeen: null }])
|
||||
const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastCommandAt: null }])
|
||||
|
||||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||
/** 只有真实实例上报(online 非 null)才算有在线数据,占位节点一律不谎报在线。 */
|
||||
const hasInstanceData = computed(() => screens.value.some((screen) => screen.online !== null))
|
||||
const onlineCount = computed(() => screens.value.filter((screen) => screen.online === true).length)
|
||||
|
||||
function markSeen(): void {
|
||||
screens.value = screens.value.map((screen) => ({
|
||||
...screen,
|
||||
online: true,
|
||||
lastSeen: new Date().toISOString(),
|
||||
}))
|
||||
function recordDispatch(): void {
|
||||
screens.value = screens.value.map((screen) => ({ ...screen, lastCommandAt: new Date().toISOString() }))
|
||||
}
|
||||
|
||||
async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): Promise<void> {
|
||||
@@ -57,9 +60,19 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
}
|
||||
if (data.isMock) {
|
||||
// 示例数据模式下指令不会真正下发,不能提示「已切换」。
|
||||
toast.warning('示例数据模式未真正下发', '后端未就绪,指令不会到达大屏')
|
||||
return
|
||||
}
|
||||
pending.value = action
|
||||
try {
|
||||
if (action === 'goto') {
|
||||
// 页面值不在大屏别名表内会被静默丢弃,这里先拦下并明确报错,避免谎报成功。
|
||||
if (!PAGES.some((page) => page.key === currentPage.value)) {
|
||||
toast.error('大屏不认识该页面', `页面值「${currentPage.value}」不在大屏页面表内`)
|
||||
return
|
||||
}
|
||||
await adminApi.displayCommand({ action, payload: { page: currentPage.value } })
|
||||
toast.success(`已切换大屏到「${PAGES.find((page) => page.key === currentPage.value)?.label ?? currentPage.value}」`)
|
||||
} else if (action === 'lock' || action === 'unlock') {
|
||||
@@ -79,7 +92,7 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
announceText.value = ''
|
||||
toast.success('公告已推送')
|
||||
}
|
||||
markSeen()
|
||||
recordDispatch()
|
||||
} catch (caught) {
|
||||
toast.error('指令下发失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
@@ -195,14 +208,14 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">在线看板</span>
|
||||
<span class="mx-badge" :class="onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||
{{ onlineCount }} / {{ screens.length }} 在线
|
||||
<span class="mx-badge" :class="hasInstanceData ? (onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-error') : 'mx-badge-neutral'">
|
||||
{{ hasInstanceData ? `${onlineCount} / ${screens.length} 在线` : '在线状态未知(占位)' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<div class="board-note">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span>后端未提供大屏在线接口(devdocs/05 §7 未定义),下表为本地登记节点,在线状态按最近一次指令送达推断。</span>
|
||||
<span>后端未提供大屏实例上报接口(devdocs/05 §7 未定义),下表是本地占位节点:「在线」无法判定,也不按指令送达推断;「最近下发」只是本机下发时间,不代表大屏在线。</span>
|
||||
</div>
|
||||
<div class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
@@ -210,7 +223,7 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
<tr>
|
||||
<th>大屏</th>
|
||||
<th>状态</th>
|
||||
<th>最后心跳</th>
|
||||
<th>最近下发</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -224,14 +237,14 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
{{ screen.online === true ? '在线' : screen.online === false ? '掉线' : '未知' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="mono muted">{{ screen.lastSeen === null ? '—' : formatDateTime(screen.lastSeen) }}</td>
|
||||
<td class="mono muted">{{ screen.lastCommandAt === null ? '—' : formatDateTime(screen.lastCommandAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="board-hint">
|
||||
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ currentPage }}</p>
|
||||
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ PAGES.find((page) => page.key === currentPage)?.label ?? currentPage }}</p>
|
||||
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,6 @@ interface EditableRow {
|
||||
isTie: boolean
|
||||
note: string
|
||||
resultState: ResultState | null
|
||||
serverPoints: number | null
|
||||
original: string
|
||||
}
|
||||
|
||||
@@ -134,11 +133,18 @@ const score = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
|
||||
*
|
||||
* 不能用 result.points:服务端从不返回该字段(恒为 0),否则已发布分会被重复计算。
|
||||
* 这里改用与预览同一套算法算出的 rowPoints,口径与服务端计分一致。
|
||||
*/
|
||||
const publishedContribution = computed(() => {
|
||||
const map = new Map<number, number>()
|
||||
const rowPoints = score.value?.rowPoints
|
||||
for (const row of visibleRows.value) {
|
||||
if (row.resultState !== 'published') continue
|
||||
const points = row.serverPoints ?? 0
|
||||
const points = rowPoints?.get(row.entryId) ?? 0
|
||||
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (isIndividual.value ? points * weight.value : points))
|
||||
}
|
||||
return map
|
||||
@@ -250,7 +256,6 @@ function toRow(entry: EntryWithResult): EditableRow {
|
||||
isTie: result?.isTie ?? false,
|
||||
note: result?.note ?? '',
|
||||
resultState: result?.state ?? null,
|
||||
serverPoints: result?.points ?? null,
|
||||
original: '',
|
||||
}
|
||||
row.original = snapshotOf(entry)
|
||||
@@ -347,6 +352,11 @@ async function saveDraft(silent = false): Promise<boolean> {
|
||||
|
||||
function requestPublish(): void {
|
||||
if (selectedEventId.value === null) return
|
||||
if (data.isMock) {
|
||||
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
|
||||
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
|
||||
return
|
||||
}
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
@@ -515,7 +525,7 @@ watch(
|
||||
<button
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="!canWrite || publishing"
|
||||
:disabled="!canWrite || publishing || data.isMock"
|
||||
@click="requestPublish"
|
||||
>
|
||||
<MxIcon name="push" :size="15" /> 发布
|
||||
@@ -523,6 +533,11 @@ watch(
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="data.isMock" class="mock-note">
|
||||
<MxIcon name="warning" :size="15" />
|
||||
<span>{{ data.mockLabel }},示例数据模式下「发布」已停用,成绩不会写入真实赛事。</span>
|
||||
</div>
|
||||
|
||||
<div class="entry-grid">
|
||||
<aside class="mx-card project-panel">
|
||||
<div class="mx-card-head"><span class="mx-card-title">项目</span></div>
|
||||
@@ -778,6 +793,17 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mock-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
font-size: 13px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.entry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 264px minmax(0, 1fr);
|
||||
|
||||
@@ -31,7 +31,8 @@ interface SuspectedBreak {
|
||||
resultValue: number
|
||||
level: RecordLevel
|
||||
recordText: string
|
||||
bonusPoints: number
|
||||
/** null = 规则包读不到,分值未知,以服务端为准。 */
|
||||
bonusPoints: number | null
|
||||
}
|
||||
|
||||
interface RecordGroup {
|
||||
@@ -75,8 +76,18 @@ const rejectedKeys = ref<string[]>([])
|
||||
const approvedKeys = ref<string[]>([])
|
||||
|
||||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||
const recordBonusEnabled = computed(() => rulePack.value?.recordBonus.enabled ?? false)
|
||||
const bonusMode = computed(() => (rulePack.value?.recordBonus.mode === 'sum' ? '多档累加' : '取最高档'))
|
||||
/**
|
||||
* 破纪录加分开关:true / false / null(未知)。
|
||||
*
|
||||
* 规则包只有 admin 能读(GET /api/rules/export 是 admin-only),
|
||||
* 录入员必然 403——不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。
|
||||
* 读不到时为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。
|
||||
* 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。
|
||||
*/
|
||||
const recordBonusEnabled = computed<boolean | null>(() => rulePack.value?.recordBonus.enabled ?? null)
|
||||
const bonusMode = computed(() =>
|
||||
rulePack.value === null ? '—' : rulePack.value.recordBonus.mode === 'sum' ? '多档累加' : '取最高档',
|
||||
)
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
const keyword = search.value.trim()
|
||||
|
||||
@@ -163,10 +163,11 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
||||
const individual = event.eventType === 'individual'
|
||||
|
||||
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
|
||||
// result.points 服务端从不返回(恒为 0),改用与预览同一套算法算出的 rowPoints。
|
||||
const published = new Map<number, number>()
|
||||
for (const entry of scoped) {
|
||||
if (entry.result?.state !== 'published') continue
|
||||
const points = (entry.result.points ?? 0) * (individual ? weight.value : 1)
|
||||
const points = round(score.rowPoints.get(entry.id) ?? 0) * (individual ? weight.value : 1)
|
||||
published.set(entry.teamId, (published.get(entry.teamId) ?? 0) + points)
|
||||
}
|
||||
|
||||
@@ -267,6 +268,11 @@ function isReturned(group: DraftGroup): boolean {
|
||||
}
|
||||
|
||||
function requestPublish(group: DraftGroup): void {
|
||||
if (data.isMock) {
|
||||
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
|
||||
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
|
||||
return
|
||||
}
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
@@ -337,6 +343,11 @@ watch(
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="data.isMock" class="mock-note">
|
||||
<MxIcon name="warning" :size="15" />
|
||||
<span>{{ data.mockLabel }},示例数据模式下「发布整组」已停用,草稿不会写入真实赛事。</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error !== null" class="error-banner">
|
||||
<MxIcon name="warning" :size="15" />
|
||||
<span>{{ error }}</span>
|
||||
@@ -427,7 +438,7 @@ watch(
|
||||
<button
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="!canWrite || publishing"
|
||||
:disabled="!canWrite || publishing || data.isMock"
|
||||
@click="requestPublish(selectedGroup)"
|
||||
>
|
||||
<MxIcon name="push" :size="14" /> 发布整组
|
||||
@@ -717,6 +728,17 @@ tr.selected {
|
||||
font-size: 13px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.mock-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: var(--mx-amber);
|
||||
font-size: 13px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
@media (max-width: 1279px) {
|
||||
.review-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -16,8 +16,13 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
/** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */
|
||||
const initialized = ref(false)
|
||||
|
||||
const isGuest = computed(() => user.value === null)
|
||||
const readOnly = computed(() => user.value === null)
|
||||
/**
|
||||
* 未登录时 /auth/me 返回 200 + guest DTO 而不是 401,
|
||||
* 所以「访客」必须按角色判定:只看 user === null 会把访客当成已登录,
|
||||
* 只读横幅与 useBaseData.canWrite 的写保护都会失效。
|
||||
*/
|
||||
const isGuest = computed(() => user.value === null || user.value.role === 'guest')
|
||||
const readOnly = computed(() => isGuest.value)
|
||||
const displayName = computed(() => user.value?.displayName ?? '访客')
|
||||
const roleLabel = computed(() => (isGuest.value ? '只读访客' : (user.value?.role ?? '用户')))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,8 +5,19 @@
|
||||
* 直接断言会把服务端字段改名这类问题推迟到运行期某个深处才炸。
|
||||
*/
|
||||
|
||||
/**
|
||||
* FormData / Blob(含 File)/ URLSearchParams 不是普通对象:
|
||||
* 展开或 JSON 序列化都会丢掉内容,必须原样交给 fetch(报名表导入就是被展平才丢文件)。
|
||||
*/
|
||||
export function isRawBody(value: unknown): value is FormData | Blob | URLSearchParams {
|
||||
if (typeof FormData !== 'undefined' && value instanceof FormData) return true
|
||||
if (typeof Blob !== 'undefined' && value instanceof Blob) return true
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) && !isRawBody(value)
|
||||
}
|
||||
|
||||
export function readString(source: unknown, key: string): string | null {
|
||||
@@ -14,3 +25,52 @@ export function readString(source: unknown, key: string): string | null {
|
||||
const value = source[key]
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {}
|
||||
}
|
||||
|
||||
/** 依次取第一个可用值:容忍服务端把数字写成字符串(settings 表存的是文本)。 */
|
||||
export function firstString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function firstNumber(def: number, ...values: unknown[]): number {
|
||||
const found = firstNumberOrNull(...values)
|
||||
return found === null ? def : found
|
||||
}
|
||||
|
||||
export function firstNumberOrNull(...values: unknown[]): number | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function firstBool(def: boolean, ...values: unknown[]): boolean {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') return value !== 0
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
export function emptyToNull(value: string | null): string | null {
|
||||
return value === null || value.trim() === '' ? null : value
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 服务端两类载荷的归一化。
|
||||
*
|
||||
* REST 与 WebSocket 快照对同一份数据用了两套字段名(快照直接序列化 domain 类型),
|
||||
* 归一化放在 shared 层,display 与 admin 各自只认归一后的契约类型。
|
||||
*/
|
||||
|
||||
import {
|
||||
asRecord,
|
||||
emptyToNull,
|
||||
firstBool,
|
||||
firstNumber,
|
||||
firstNumberOrNull,
|
||||
firstString,
|
||||
isRecord,
|
||||
} from './internal'
|
||||
import type { EventSettings, StandingRow } from './types'
|
||||
|
||||
/**
|
||||
* 赛事配置归一化。
|
||||
*
|
||||
* `GET /api/settings` 是嵌套对象,而 WebSocket 快照的 `settings` 是
|
||||
* settings 表的点号扁平 map(`display.rotate_seconds`、`standings.mode`…)。
|
||||
* 两种形状都接受,缺省值对齐服务端 `getSettings`,避免大屏首包读不到字段。
|
||||
*/
|
||||
export function normalizeSettings(raw: unknown): EventSettings {
|
||||
const source = asRecord(raw)
|
||||
const nestedEvent = isRecord(source.event) ? source.event : null
|
||||
const nestedDisplay = isRecord(source.display) ? source.display : null
|
||||
const nestedStandings = isRecord(source.standings) ? source.standings : null
|
||||
const isFlat = nestedEvent === null && nestedDisplay === null && nestedStandings === null
|
||||
|
||||
const name = firstString(isFlat ? source['event.name'] : undefined, nestedEvent?.name) ?? '未命名赛事'
|
||||
const subtitle = emptyToNull(
|
||||
firstString(isFlat ? source['event.subtitle'] : undefined, nestedEvent?.subtitle),
|
||||
)
|
||||
const host = emptyToNull(firstString(isFlat ? source['event.organizer'] : undefined, nestedEvent?.host))
|
||||
const logoPath = emptyToNull(
|
||||
firstString(isFlat ? source['event.logo_path'] : undefined, nestedEvent?.logoPath),
|
||||
)
|
||||
const primaryColor = emptyToNull(
|
||||
firstString(isFlat ? source['display.theme_color'] : undefined, nestedEvent?.primaryColor),
|
||||
)
|
||||
const displayLogo = emptyToNull(
|
||||
firstString(isFlat ? source['display.logo'] : undefined, nestedDisplay?.logo),
|
||||
)
|
||||
|
||||
return {
|
||||
event: { name, subtitle, host, logoPath, primaryColor },
|
||||
display: {
|
||||
background: emptyToNull(
|
||||
firstString(isFlat ? source['display.background'] : undefined, nestedDisplay?.background),
|
||||
),
|
||||
logo: displayLogo ?? logoPath,
|
||||
title: firstString(isFlat ? source['display.title'] : undefined, nestedDisplay?.title) ?? name,
|
||||
subtitle:
|
||||
emptyToNull(
|
||||
firstString(isFlat ? source['display.subtitle'] : undefined, nestedDisplay?.subtitle),
|
||||
) ?? subtitle,
|
||||
primaryColor,
|
||||
rotateSeconds: firstNumber(
|
||||
15,
|
||||
isFlat ? source['display.rotate_seconds'] : undefined,
|
||||
nestedDisplay?.rotateSeconds,
|
||||
),
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: firstNumber(
|
||||
1,
|
||||
isFlat ? source['standings.individual_to_team_weight'] : undefined,
|
||||
nestedStandings?.individualToTeamWeight,
|
||||
),
|
||||
rankingMode:
|
||||
firstString(isFlat ? source['standings.mode'] : undefined, nestedStandings?.rankingMode) ??
|
||||
'points',
|
||||
showIndividualBoard: firstBool(
|
||||
true,
|
||||
isFlat ? source['standings.show_individual_board'] : undefined,
|
||||
isFlat ? source['standings.show_individual_medals'] : undefined,
|
||||
nestedStandings?.showIndividualBoard,
|
||||
),
|
||||
},
|
||||
// 扁平 map 里只有模板编码(scoring.default_profile),解析不出数据库 id,只能置空。
|
||||
defaultProfileId: isFlat ? null : firstNumberOrNull(source.defaultProfileId),
|
||||
requireApproval: firstBool(
|
||||
false,
|
||||
isFlat ? source['results.review_required'] : undefined,
|
||||
source.requireApproval,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 总积分榜归一化。
|
||||
*
|
||||
* 快照用 domain.StandingsRow(`code/name/points/medals`),
|
||||
* REST 用 standingRowDTO(`teamCode/teamName/totalPoints/medalTotal`),两种都接受。
|
||||
*/
|
||||
export function normalizeStandingRows(raw: unknown): StandingRow[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map(normalizeStandingRow)
|
||||
}
|
||||
|
||||
function normalizeStandingRow(raw: unknown): StandingRow {
|
||||
const rec = asRecord(raw)
|
||||
const gold = firstNumber(0, rec.gold)
|
||||
const silver = firstNumber(0, rec.silver)
|
||||
const bronze = firstNumber(0, rec.bronze)
|
||||
const rankChange = rec.rankChange
|
||||
return {
|
||||
rank: firstNumber(0, rec.rank),
|
||||
teamId: firstNumber(0, rec.teamId),
|
||||
teamCode: firstString(rec.teamCode, rec.code) ?? '',
|
||||
teamName: firstString(rec.teamName, rec.name) ?? '',
|
||||
shortName: emptyToNull(firstString(rec.shortName)),
|
||||
color: emptyToNull(firstString(rec.color)),
|
||||
logoPath: emptyToNull(firstString(rec.logoPath)),
|
||||
gold,
|
||||
silver,
|
||||
bronze,
|
||||
medalTotal: firstNumber(gold + silver + bronze, rec.medalTotal, rec.medals),
|
||||
totalPoints: firstNumber(0, rec.totalPoints, rec.points),
|
||||
rankChange: typeof rankChange === 'number' && Number.isFinite(rankChange) ? rankChange : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 登录态判定。
|
||||
*
|
||||
* 服务端 `/auth/me` 未登录时返回 200 + guest DTO(role = "guest"、username = "guest"),
|
||||
* 而不是 401,所以「user 是否为 null」判不出访客;统一走这里的判定函数。
|
||||
*/
|
||||
|
||||
import type { CurrentUser } from './types'
|
||||
|
||||
/** 服务端 auth.RoleGuest 的字符串值。 */
|
||||
export const GUEST_ROLE = 'guest'
|
||||
|
||||
export function isGuestRole(role: unknown): boolean {
|
||||
return typeof role === 'string' && role.trim().toLowerCase() === GUEST_ROLE
|
||||
}
|
||||
|
||||
/** null(还没探测 / 探测失败)与 guest DTO 都算访客。 */
|
||||
export function isGuestUser(user: CurrentUser | null | undefined): boolean {
|
||||
return user === null || user === undefined || isGuestRole(user.role)
|
||||
}
|
||||
+15
-3
@@ -383,6 +383,11 @@ export interface CurrentUser {
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
/**
|
||||
* 由客户端按 role 补出:true 表示这是未登录时 /auth/me 返回的 guest DTO。
|
||||
* 服务端未登录也回 200,不能用「对象是否为 null」判访客,见 shared/session.ts。
|
||||
*/
|
||||
guest?: boolean
|
||||
/** 首次登录或管理员重置密码后为 true,前端应强制引导改密 */
|
||||
mustChangePassword: boolean
|
||||
lastLoginAt: string | null
|
||||
@@ -415,7 +420,7 @@ export interface HealthInfo {
|
||||
// 规则包(见 02-积分与排名规则 第 7 节)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode'
|
||||
export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode' | 'name'
|
||||
|
||||
export interface RulePackMeta {
|
||||
code: string
|
||||
@@ -431,7 +436,8 @@ export interface RulePackStandings {
|
||||
tieBreak: TieBreakKey[]
|
||||
medalCountsTeamEvents: boolean
|
||||
showIndividualBoard: boolean
|
||||
showIndividualMedals: boolean
|
||||
/** 服务端规则包会带出该开关;缺省视为 true。 */
|
||||
showIndividualMedals?: boolean
|
||||
}
|
||||
|
||||
export interface RuleProfile {
|
||||
@@ -440,6 +446,8 @@ export interface RuleProfile {
|
||||
awardRanks: number
|
||||
lastPlaceZero: boolean
|
||||
tieMode: TieMode
|
||||
/** 单个模板可覆盖破纪录加分口径;服务端 Canonical 后总会带出。 */
|
||||
recordBonusMode?: RecordBonusMode
|
||||
rules: ScoringProfileRule[]
|
||||
}
|
||||
|
||||
@@ -465,11 +473,13 @@ export interface RuleChannel {
|
||||
scope: ChannelScope
|
||||
basis: ChannelBasis
|
||||
profile: string
|
||||
/** basis 为 aggregate 时的汇总口径(avg/max/min/count),服务端缺省 sum。 */
|
||||
aggregate?: string
|
||||
}
|
||||
|
||||
export interface ChannelTemplateMatch {
|
||||
eventType?: EventType
|
||||
eventCode?: string
|
||||
resultType?: ResultType
|
||||
sportCode?: string
|
||||
}
|
||||
|
||||
@@ -482,6 +492,8 @@ export interface EventOverride {
|
||||
eventCode: string
|
||||
name?: string
|
||||
scored?: boolean
|
||||
/** 单个项目的计分权重覆盖;缺省表示不动。 */
|
||||
multiplier?: number
|
||||
channels?: RuleChannel[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* 极简 YAML 子集解析器。
|
||||
*
|
||||
* `GET /api/rules/export` 返回的是服务端 `rules.Marshal` 生成的 YAML 文本,
|
||||
* 而前端不引入 YAML 依赖,所以这里只实现规则包实际用到的语法:
|
||||
* 块状映射 / 序列、流式 `[]` / `{}`、单双引号、块标量、数字 / 布尔 / null 标量。
|
||||
* 解析失败一律抛 `YamlParseError`,由调用方转成 PARSE_FAILED 并带上原文。
|
||||
*/
|
||||
|
||||
export class YamlParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'YamlParseError'
|
||||
}
|
||||
}
|
||||
|
||||
interface Line {
|
||||
indent: number
|
||||
text: string
|
||||
}
|
||||
|
||||
interface Parsed {
|
||||
value: unknown
|
||||
next: number
|
||||
}
|
||||
|
||||
export function parseYaml(text: string): unknown {
|
||||
const lines = tokenize(text)
|
||||
if (lines.length === 0) return null
|
||||
const parsed = parseBlock(lines, 0, lines[0].indent)
|
||||
if (parsed.next < lines.length) {
|
||||
throw new YamlParseError(`第 ${parsed.next + 1} 行无法归入任何块:${lines[parsed.next].text}`)
|
||||
}
|
||||
return parsed.value
|
||||
}
|
||||
|
||||
function tokenize(text: string): Line[] {
|
||||
const out: Line[] = []
|
||||
const raw = text.split(/\r?\n/)
|
||||
for (const line of raw) {
|
||||
const withoutComment = stripComment(line)
|
||||
if (withoutComment.trim() === '') continue
|
||||
// 规则包只允许一个 YAML 文档,遇到分隔符直接跳过而不是当成新文档。
|
||||
if (/^\s*(---|\.\.\.)\s*$/.test(withoutComment)) continue
|
||||
out.push({
|
||||
indent: withoutComment.length - withoutComment.trimStart().length,
|
||||
text: withoutComment.trim(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function stripComment(line: string): string {
|
||||
let quote: '"' | "'" | null = null
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]
|
||||
if (quote === '"') {
|
||||
if (ch === '\\') i++
|
||||
else if (ch === '"') quote = null
|
||||
continue
|
||||
}
|
||||
if (quote === "'") {
|
||||
if (ch === "'") {
|
||||
if (line[i + 1] === "'") i++
|
||||
else quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
// YAML 注释必须前面是空白(`a#b` 里的 # 属于标量)。
|
||||
if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
function parseBlock(lines: Line[], index: number, indent: number): Parsed {
|
||||
const line = lines[index]
|
||||
if (line === undefined || line.indent < indent) return { value: null, next: index }
|
||||
if (isSequenceItem(line.text)) return parseSequence(lines, index, line.indent)
|
||||
return parseMapping(lines, index, line.indent)
|
||||
}
|
||||
|
||||
function parseMapping(lines: Line[], index: number, indent: number): Parsed {
|
||||
const out: Record<string, unknown> = {}
|
||||
let i = index
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (line.indent !== indent || isSequenceItem(line.text)) break
|
||||
const entry = splitEntry(line.text)
|
||||
if (entry === null) throw new YamlParseError(`第 ${i + 1} 行不是「键: 值」:${line.text}`)
|
||||
if (entry.rest === '') {
|
||||
const next = lines[i + 1]
|
||||
if (next !== undefined && next.indent > indent) {
|
||||
const child = parseBlock(lines, i + 1, indent + 1)
|
||||
out[entry.key] = child.value
|
||||
i = child.next
|
||||
} else {
|
||||
out[entry.key] = null
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isBlockScalarHeader(entry.rest)) {
|
||||
const scalar = readBlockScalar(lines, i + 1, indent, entry.rest)
|
||||
out[entry.key] = scalar.value
|
||||
i = scalar.next
|
||||
continue
|
||||
}
|
||||
out[entry.key] = parseScalar(entry.rest)
|
||||
i++
|
||||
}
|
||||
return { value: out, next: i }
|
||||
}
|
||||
|
||||
function parseSequence(lines: Line[], index: number, indent: number): Parsed {
|
||||
const out: unknown[] = []
|
||||
let i = index
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (line.indent !== indent || !isSequenceItem(line.text)) break
|
||||
const afterDash = line.text.slice(1)
|
||||
const lead = afterDash.length - afterDash.trimStart().length
|
||||
const rest = afterDash.trimStart()
|
||||
if (rest === '') {
|
||||
const next = lines[i + 1]
|
||||
if (next !== undefined && next.indent > indent) {
|
||||
const child = parseBlock(lines, i + 1, indent + 1)
|
||||
out.push(child.value)
|
||||
i = child.next
|
||||
} else {
|
||||
out.push(null)
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isBlockScalarHeader(rest)) {
|
||||
const scalar = readBlockScalar(lines, i + 1, indent, rest)
|
||||
out.push(scalar.value)
|
||||
i = scalar.next
|
||||
continue
|
||||
}
|
||||
if (isSequenceItem(rest) || splitEntry(rest) !== null) {
|
||||
// `- key: value` 把映射的起始写在同一行:造一条等价的虚拟行再按同一份行数组解析,
|
||||
// 这样后续与虚拟行同缩进的键仍能正确归入本项。
|
||||
const effectiveIndent = indent + 1 + lead
|
||||
const virtual = lines.slice()
|
||||
virtual[i] = { indent: effectiveIndent, text: rest }
|
||||
const child = isSequenceItem(rest)
|
||||
? parseSequence(virtual, i, effectiveIndent)
|
||||
: parseMapping(virtual, i, effectiveIndent)
|
||||
out.push(child.value)
|
||||
i = child.next
|
||||
continue
|
||||
}
|
||||
out.push(parseScalar(rest))
|
||||
i++
|
||||
}
|
||||
return { value: out, next: i }
|
||||
}
|
||||
|
||||
function isSequenceItem(text: string): boolean {
|
||||
return text === '-' || text.startsWith('- ') || text.startsWith('-\t')
|
||||
}
|
||||
|
||||
/** 找顶层的「键: 值」分隔冒号;引号内和流式括号内的冒号不算。 */
|
||||
function splitEntry(text: string): { key: string; rest: string } | null {
|
||||
let quote: '"' | "'" | null = null
|
||||
let depth = 0
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]
|
||||
if (quote === '"') {
|
||||
if (ch === '\\') i++
|
||||
else if (ch === '"') quote = null
|
||||
continue
|
||||
}
|
||||
if (quote === "'") {
|
||||
if (ch === "'") {
|
||||
if (text[i + 1] === "'") i++
|
||||
else quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
if (ch === '{' || ch === '[') {
|
||||
depth++
|
||||
continue
|
||||
}
|
||||
if (ch === '}' || ch === ']') {
|
||||
depth--
|
||||
continue
|
||||
}
|
||||
if (ch === ':' && depth === 0) {
|
||||
const after = text[i + 1]
|
||||
if (after === undefined || after === ' ' || after === '\t') {
|
||||
return {
|
||||
key: unquote(text.slice(0, i).trim()),
|
||||
rest: text.slice(i + 1).trim(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isBlockScalarHeader(text: string): boolean {
|
||||
return /^[|>][+-]?\d*$/.test(text)
|
||||
}
|
||||
|
||||
function readBlockScalar(
|
||||
lines: Line[],
|
||||
index: number,
|
||||
parentIndent: number,
|
||||
header: string,
|
||||
): { value: string; next: number } {
|
||||
const collected: string[] = []
|
||||
let i = index
|
||||
while (i < lines.length && lines[i].indent > parentIndent) {
|
||||
collected.push(lines[i].text)
|
||||
i++
|
||||
}
|
||||
if (collected.length === 0) return { value: '', next: i }
|
||||
const folded = header.startsWith('>')
|
||||
const keep = header.includes('+')
|
||||
const joined = folded ? collected.join(' ') : collected.join('\n')
|
||||
return { value: keep ? `${joined}\n` : joined, next: i }
|
||||
}
|
||||
|
||||
function parseScalar(text: string): unknown {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return null
|
||||
if (trimmed.startsWith('"')) return parseDoubleQuoted(trimmed)
|
||||
if (trimmed.startsWith("'")) return parseSingleQuoted(trimmed)
|
||||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) return parseFlow(trimmed)
|
||||
return parsePlain(trimmed)
|
||||
}
|
||||
|
||||
function parseDoubleQuoted(text: string): string {
|
||||
let out = ''
|
||||
let i = 1
|
||||
while (i < text.length) {
|
||||
const ch = text[i]
|
||||
if (ch === '\\') {
|
||||
const esc = text[i + 1]
|
||||
i += 2
|
||||
if (esc === 'n') out += '\n'
|
||||
else if (esc === 't') out += '\t'
|
||||
else if (esc === 'r') out += '\r'
|
||||
else if (esc === 'u') {
|
||||
const hex = text.slice(i, i + 4)
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new YamlParseError(`非法 \\u 转义:${hex}`)
|
||||
out += String.fromCharCode(parseInt(hex, 16))
|
||||
i += 4
|
||||
} else out += esc ?? ''
|
||||
continue
|
||||
}
|
||||
if (ch === '"') {
|
||||
if (text.slice(i + 1).trim() !== '') throw new YamlParseError(`双引号后有多余内容:${text}`)
|
||||
return out
|
||||
}
|
||||
out += ch
|
||||
i++
|
||||
}
|
||||
throw new YamlParseError(`双引号未闭合:${text}`)
|
||||
}
|
||||
|
||||
function parseSingleQuoted(text: string): string {
|
||||
let out = ''
|
||||
let i = 1
|
||||
while (i < text.length) {
|
||||
const ch = text[i]
|
||||
if (ch === "'") {
|
||||
if (text[i + 1] === "'") {
|
||||
out += "'"
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (text.slice(i + 1).trim() !== '') throw new YamlParseError(`单引号后有多余内容:${text}`)
|
||||
return out
|
||||
}
|
||||
out += ch
|
||||
i++
|
||||
}
|
||||
throw new YamlParseError(`单引号未闭合:${text}`)
|
||||
}
|
||||
|
||||
function parseFlow(text: string): unknown {
|
||||
let pos = 0
|
||||
|
||||
function skip(): void {
|
||||
while (pos < text.length && /\s/.test(text[pos])) pos++
|
||||
}
|
||||
|
||||
function value(): unknown {
|
||||
skip()
|
||||
const ch = text[pos]
|
||||
if (ch === '[') return sequence()
|
||||
if (ch === '{') return mapping()
|
||||
if (ch === '"' || ch === "'") return quoted()
|
||||
return plain()
|
||||
}
|
||||
|
||||
function quoted(): string {
|
||||
const start = pos
|
||||
const quote = text[pos]
|
||||
pos++
|
||||
while (pos < text.length) {
|
||||
const ch = text[pos]
|
||||
if (quote === '"' && ch === '\\') {
|
||||
pos += 2
|
||||
continue
|
||||
}
|
||||
if (ch === quote) {
|
||||
if (quote === "'" && text[pos + 1] === "'") {
|
||||
pos += 2
|
||||
continue
|
||||
}
|
||||
pos++
|
||||
const parsed = parseScalar(text.slice(start, pos))
|
||||
return typeof parsed === 'string' ? parsed : String(parsed ?? '')
|
||||
}
|
||||
pos++
|
||||
}
|
||||
throw new YamlParseError(`流式引号未闭合:${text}`)
|
||||
}
|
||||
|
||||
function plain(): unknown {
|
||||
const start = pos
|
||||
while (pos < text.length && text[pos] !== ',' && text[pos] !== ']' && text[pos] !== '}') pos++
|
||||
return parsePlain(text.slice(start, pos).trim())
|
||||
}
|
||||
|
||||
function sequence(): unknown[] {
|
||||
pos++
|
||||
const out: unknown[] = []
|
||||
skip()
|
||||
if (text[pos] === ']') {
|
||||
pos++
|
||||
return out
|
||||
}
|
||||
while (pos < text.length) {
|
||||
out.push(value())
|
||||
skip()
|
||||
if (text[pos] === ',') {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (text[pos] === ']') {
|
||||
pos++
|
||||
return out
|
||||
}
|
||||
throw new YamlParseError(`流式序列缺少逗号或右括号:${text}`)
|
||||
}
|
||||
throw new YamlParseError(`流式序列未闭合:${text}`)
|
||||
}
|
||||
|
||||
function mapping(): Record<string, unknown> {
|
||||
pos++
|
||||
const out: Record<string, unknown> = {}
|
||||
skip()
|
||||
if (text[pos] === '}') {
|
||||
pos++
|
||||
return out
|
||||
}
|
||||
while (pos < text.length) {
|
||||
skip()
|
||||
const keyStart = pos
|
||||
while (pos < text.length && text[pos] !== ':') pos++
|
||||
if (pos >= text.length) throw new YamlParseError(`流式映射缺少冒号:${text}`)
|
||||
const key = unquote(text.slice(keyStart, pos).trim())
|
||||
pos++
|
||||
out[key] = value()
|
||||
skip()
|
||||
if (text[pos] === ',') {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (text[pos] === '}') {
|
||||
pos++
|
||||
return out
|
||||
}
|
||||
throw new YamlParseError(`流式映射缺少逗号或右括号:${text}`)
|
||||
}
|
||||
throw new YamlParseError(`流式映射未闭合:${text}`)
|
||||
}
|
||||
|
||||
const result = value()
|
||||
skip()
|
||||
if (pos !== text.length) throw new YamlParseError(`流式值后有多余内容:${text}`)
|
||||
return result
|
||||
}
|
||||
|
||||
function parsePlain(text: string): unknown {
|
||||
if (text === '' || text === '~' || text === 'null' || text === 'Null' || text === 'NULL') return null
|
||||
if (/^(true|false)$/i.test(text)) return text.toLowerCase() === 'true'
|
||||
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(text)) return Number(text)
|
||||
return text
|
||||
}
|
||||
|
||||
function unquote(text: string): string {
|
||||
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
|
||||
try {
|
||||
return parseDoubleQuoted(text)
|
||||
} catch {
|
||||
return text.slice(1, -1)
|
||||
}
|
||||
}
|
||||
if (text.length >= 2 && text.startsWith("'") && text.endsWith("'")) {
|
||||
return text.slice(1, -1).replace(/''/g, "'")
|
||||
}
|
||||
return text
|
||||
}
|
||||
Reference in new issue
Block a user