feat(joint-team): 联队 + 份额分成 —— 迁移 0024、引擎按份额拆、导出 / 大屏 / 录入三路打通;版本 dev_0.5.3
## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。
## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`
## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
`entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
(`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。
## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
(份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
**表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。
## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
**普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
logo 数留位);底栏同理。
## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
`aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。
## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
This commit is contained in:
1 parent
b631418a85
commit
830a4dbdb7
61 files changed
+5649
-121
No files matched your search
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { medalBoardRows } from '@/shared'
|
||||
import type { EventResultRow } from '@/shared'
|
||||
import { tickerRowText } from '../jointTeams'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
import { DEFAULT_ROTATE_SECONDS } from '../guards'
|
||||
|
||||
@@ -11,7 +13,17 @@ interface TickerItem {
|
||||
label: string
|
||||
}
|
||||
|
||||
const { standings, latestResults, announceText, detail, settings } = useScoreboard()
|
||||
const { standings, latestResults, announceText, detail, settings, jointTeamOf } = useScoreboard()
|
||||
|
||||
/**
|
||||
* 底栏「最新完成项目」一行里的名字:团体行是联队时写「A+B联队」。
|
||||
*
|
||||
* 底栏放不下两个 logo,但两个单位的名字必须都在——只写主单位会让现场以为
|
||||
* 这条成绩是它一家拿的(成员单位来自报名表,见 display/jointTeams.ts)。
|
||||
*/
|
||||
function chipName(row: EventResultRow): string {
|
||||
return tickerRowText(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
|
||||
// 与单位奖牌榜页同一口径:按服务端的奖牌榜顺序(金→银→铜)取前 3,不在前端自己重排。
|
||||
const topMedals = computed(() => medalBoardRows(standings.value).slice(0, 3))
|
||||
@@ -77,7 +89,7 @@ onUnmounted(() => window.clearInterval(timer))
|
||||
</div>
|
||||
<div v-for="row in latestGroup.rows.slice(0, 3)" :key="row.entryId" class="chip">
|
||||
<span class="chip-rank">{{ row.rank ?? '—' }}</span>
|
||||
<span class="chip-name">{{ row.athleteName ?? row.teamName }}</span>
|
||||
<span class="chip-name">{{ chipName(row) }}</span>
|
||||
<span class="chip-stat">{{ row.resultText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { EventResultRow } from '@/shared'
|
||||
import { rowTeamLogos, rowTeamText } from '../../jointTeams'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
import TeamLogo from '../TeamLogo.vue'
|
||||
|
||||
const { latestResults, options } = useScoreboard()
|
||||
const { latestResults, options, jointTeamOf } = useScoreboard()
|
||||
|
||||
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
|
||||
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount))
|
||||
|
||||
/**
|
||||
* 单位列的文案与 logo(devdocs/19):联队写「A+B联队」并画两个单位的 logo,
|
||||
* 普通报名与改造前逐字/逐图一致(`shortName ?? teamName` 与单个 TeamLogo)。
|
||||
*/
|
||||
function teamText(row: EventResultRow): string {
|
||||
return rowTeamText(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
|
||||
function teamLogos(row: EventResultRow): (string | null)[] {
|
||||
return rowTeamLogos(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,9 +42,10 @@ const groups = computed(() => latestResults.value.slice(0, options.value.results
|
||||
>
|
||||
<span class="rk">{{ row.rank ?? '—' }}</span>
|
||||
<span class="nm">{{ displayName(row) }}</span>
|
||||
<!-- 联队两个单位的 logo 都要出现:teamLogos 普通报名时只有一个元素。 -->
|
||||
<span class="team">
|
||||
<TeamLogo :path="row.logoPath" />
|
||||
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
|
||||
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
|
||||
<span class="team-name">{{ teamText(row) }}</span>
|
||||
</span>
|
||||
<span class="rs">{{ displayResult(row) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import type { EventResultRow } from '@/shared'
|
||||
import { fitLiveRowFontPx, liveResultColumnPx, liveTeamColumnPx } from '../../fitFont'
|
||||
import {
|
||||
LIVE_REFRESH_INTERVAL_MS,
|
||||
@@ -10,12 +11,26 @@ import {
|
||||
pickLiveColumns,
|
||||
} from '../../live'
|
||||
import type { LiveEventSource } from '../../live'
|
||||
import { rowLogoCount, rowTeamLogos, rowTeamText } from '../../jointTeams'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
import TeamLogo from '../TeamLogo.vue'
|
||||
|
||||
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents } = useScoreboard()
|
||||
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents, jointTeamOf } =
|
||||
useScoreboard()
|
||||
|
||||
/**
|
||||
* 联队(devdocs/19):成绩行里只有主单位,成员单位来自报名表(useScoreboard 会为屏上的小项读一次)。
|
||||
* 普通报名下这两件事与改造前完全一样——文案还是 `shortName ?? teamName`,logo 还是刚一张。
|
||||
*/
|
||||
function teamText(row: EventResultRow): string {
|
||||
return rowTeamText(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
|
||||
function teamLogos(row: EventResultRow): (string | null)[] {
|
||||
return rowTeamLogos(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
|
||||
const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name])))
|
||||
|
||||
@@ -67,13 +82,26 @@ const layout = computed(() => liveBoardLayout(boards.value.length, rowCount.valu
|
||||
const resultTexts = computed(() =>
|
||||
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
|
||||
)
|
||||
// 联队行的单位名更长(两个单位名都写全),列宽预算必须按**实际会写出来的字**算,
|
||||
// 否则联队一到屏上就被挤成省略号。
|
||||
const teamTexts = computed(() =>
|
||||
boards.value.flatMap((board) => board.rows.map((row) => row.shortName ?? row.teamName)),
|
||||
boards.value.flatMap((board) => board.rows.map((row) => teamText(row))),
|
||||
)
|
||||
// 有单位 logo 时单位名那一格还要多占一个 logo 宽(TeamLogo 是 1em + 0.28em 间距)。
|
||||
// 单位名后面还要放 logo(TeamLogo 是 1em + 0.28em 间距),一个不够就按最多的那一行算。
|
||||
const teamsWithLogo = computed(() =>
|
||||
boards.value.some((board) => board.rows.some((row) => row.logoPath !== null && row.logoPath !== '')),
|
||||
)
|
||||
const maxLogosPerRow = computed(() =>
|
||||
boards.value.reduce(
|
||||
(max, board) =>
|
||||
Math.max(
|
||||
max,
|
||||
...board.rows.map((row) => rowLogoCount(row, jointTeamOf(row.entryId))),
|
||||
0,
|
||||
),
|
||||
0,
|
||||
),
|
||||
)
|
||||
const rowFontPx = computed(() =>
|
||||
fitLiveRowFontPx(
|
||||
liveBlockRowCount(rowCount.value, layout.value),
|
||||
@@ -81,6 +109,7 @@ const rowFontPx = computed(() =>
|
||||
resultTexts.value,
|
||||
teamTexts.value,
|
||||
teamsWithLogo.value,
|
||||
maxLogosPerRow.value,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -92,6 +121,7 @@ const boardStyle = computed(() => ({
|
||||
layout.value,
|
||||
rowFontPx.value,
|
||||
teamsWithLogo.value,
|
||||
maxLogosPerRow.value,
|
||||
)}px`,
|
||||
}))
|
||||
|
||||
@@ -141,9 +171,10 @@ onUnmounted(() => {
|
||||
>
|
||||
<span class="rk">{{ row.rank ?? '—' }}</span>
|
||||
<span class="nm">{{ displayName(row) }}</span>
|
||||
<!-- 联队两个单位的 logo 都画(teamLogos 普通报名时只有一个元素)。 -->
|
||||
<span class="team">
|
||||
<TeamLogo :path="row.logoPath" />
|
||||
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
|
||||
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
|
||||
<span class="team-name">{{ teamText(row) }}</span>
|
||||
</span>
|
||||
<span class="rs">{{ displayResult(row) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -193,18 +193,33 @@ function widestResultEm(results: readonly string[]): number {
|
||||
/** 单位列的字号比例:与 LivePage.vue 里 `.team { font-size: 0.78em }` 一致。 */
|
||||
const TEAM_FONT_EM = 0.78
|
||||
|
||||
/** 单位名前的 logo(1em)加间距(0.28em),按单位列自己的字号算。 */
|
||||
/**
|
||||
* 单位名前的 logo(1em)加间距(0.28em),按单位列自己的字号算。
|
||||
*
|
||||
* 联队一行要放**两个**单位的 logo(devdocs/19),所以这个宽度由调用方按「最多的那一行有几个 logo」
|
||||
* 乘出来传进来,写死一个 logo 的宽会让第二个 logo 把单位名挤成省略号。
|
||||
*/
|
||||
const TEAM_LOGO_EM = 1.28
|
||||
|
||||
/** 单位名列最多留几个 logo 的宽度:再多也只是白占宽度,8 个已经远超现场(联队上限 5)。 */
|
||||
const MAX_TEAM_LOGOS = 8
|
||||
|
||||
/** 运动员列至少要留的宽度(em):4 个汉字的名字(复姓、团体成员)不该被单位名挤没。 */
|
||||
const NAME_MIN_EM = 4
|
||||
|
||||
/** 单位列要留的 logo 宽度(em):`withLogo` 为假时是 0,否则按 logoCount 份算。 */
|
||||
function logoWidthEm(withLogo: boolean, logoCount = 1): number {
|
||||
if (!withLogo) return 0
|
||||
const count = Number.isFinite(logoCount) ? Math.floor(logoCount) : 1
|
||||
return TEAM_LOGO_EM * Math.min(MAX_TEAM_LOGOS, Math.max(1, count))
|
||||
}
|
||||
|
||||
/** 一屏所有单位名里最宽的那个(em),已经折成单位列自己的字号。 */
|
||||
function widestTeamEm(teams: readonly string[], withLogo: boolean): number {
|
||||
function widestTeamEm(teams: readonly string[], withLogo: boolean, logoCount = 1): number {
|
||||
let widest = 0
|
||||
for (const text of teams) widest = Math.max(widest, textEm(text))
|
||||
if (widest <= 0) return 0
|
||||
return (widest + (withLogo ? TEAM_LOGO_EM : 0)) * TEAM_FONT_EM
|
||||
return (widest + logoWidthEm(withLogo, logoCount)) * TEAM_FONT_EM
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,9 +235,10 @@ export function liveTeamColumnPx(
|
||||
layout: LiveLayout,
|
||||
fontPx: number,
|
||||
withLogo = false,
|
||||
logoCount = 1,
|
||||
): number {
|
||||
const row = LIVE_ROW_GEOMETRY[layout]
|
||||
const need = Math.ceil(widestTeamEm(teams, withLogo) * fontPx) + RESULT_COLUMN_PAD_PX
|
||||
const need = Math.ceil(widestTeamEm(teams, withLogo, logoCount) * fontPx) + RESULT_COLUMN_PAD_PX
|
||||
return Math.min(row.teamBudgetPx, Math.max(row.teamFloorPx, need))
|
||||
}
|
||||
|
||||
@@ -238,11 +254,12 @@ export function liveNameColumnPx(
|
||||
layout: LiveLayout,
|
||||
fontPx: number,
|
||||
withLogo = false,
|
||||
logoCount = 1,
|
||||
): number {
|
||||
const row = LIVE_ROW_GEOMETRY[layout]
|
||||
const free =
|
||||
row.rowWidthPx - row.rankPx - row.gapPx * 3 - liveResultColumnPx(results, layout, fontPx)
|
||||
const team = liveTeamColumnPx(teams, layout, fontPx, withLogo)
|
||||
const team = liveTeamColumnPx(teams, layout, fontPx, withLogo, logoCount)
|
||||
if (!row.teamSharesWidth) return free - team
|
||||
const share = (free * row.teamFr) / (row.nameFr + row.teamFr)
|
||||
return free - Math.max(team, share)
|
||||
@@ -255,8 +272,9 @@ function liveRowFitsPx(
|
||||
layout: LiveLayout,
|
||||
fontPx: number,
|
||||
withLogo: boolean,
|
||||
logoCount: number,
|
||||
): boolean {
|
||||
const name = liveNameColumnPx(results, teams, layout, fontPx, withLogo)
|
||||
const name = liveNameColumnPx(results, teams, layout, fontPx, withLogo, logoCount)
|
||||
return name >= NAME_MIN_EM * fontPx
|
||||
}
|
||||
|
||||
@@ -279,6 +297,7 @@ export function fitLiveRowFontPx(
|
||||
results: readonly string[],
|
||||
teams: readonly string[] = [],
|
||||
teamsWithLogo = false,
|
||||
logoCount = 1,
|
||||
): number {
|
||||
const spec = LIVE_LAYOUT_SPECS[layout]
|
||||
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, spec.ratio, spec.capPx)
|
||||
@@ -287,7 +306,7 @@ export function fitLiveRowFontPx(
|
||||
if (widest > 0) {
|
||||
font = Math.min(font, Math.floor((spec.resultBudgetPx - RESULT_COLUMN_PAD_PX) / widest))
|
||||
}
|
||||
while (font > MIN_ROW_FONT_PX && !liveRowFitsPx(results, teams, layout, font, teamsWithLogo)) {
|
||||
while (font > MIN_ROW_FONT_PX && !liveRowFitsPx(results, teams, layout, font, teamsWithLogo, logoCount)) {
|
||||
font--
|
||||
}
|
||||
return Math.max(MIN_ROW_FONT_PX, font)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { isJointTeam, jointTeamLabel, normalizeTeamShares } from '@/shared'
|
||||
import type { EntryTeamShare, Team, TeamShareView } from '@/shared'
|
||||
|
||||
/**
|
||||
* 大屏侧的联队渲染(devdocs/19)。
|
||||
*
|
||||
* 大屏拿到的成绩行(分项成绩 / 实时赛况 / 底栏)里只有**主单位**的 teamId 与名字,
|
||||
* 一条报名挂在几个单位下这件事只存在于报名表(`GET /api/events/{id}/entries` 的 teamShares)。
|
||||
* 所以这里做两件事:
|
||||
* 1. 把那个接口的读数收敛成 `entryId → 成员单位`(名字取自份额,logo 取自快照的单位表——
|
||||
* entries 接口不下发 logoPath);
|
||||
* 2. 把「一行该怎么念、放几个 logo」抽成纯函数,页面只负责把结果画出来。
|
||||
*
|
||||
* 单单位(普通报名)时这些函数一律退回调用方原来的表达式,屏上逐像素不变。
|
||||
*/
|
||||
|
||||
/** 大屏侧的一条份额:展示字段之外补上 logo(从快照单位表里取)。 */
|
||||
export interface DisplayTeamShare extends TeamShareView {
|
||||
logoPath: string | null
|
||||
}
|
||||
|
||||
/** 解析 entries 接口里一项报名的 teamShares;认不出来时返回空数组(= 没有配置)。 */
|
||||
export function readEntryTeamShares(source: unknown): EntryTeamShare[] {
|
||||
if (typeof source !== 'object' || source === null) return []
|
||||
const raw = (source as { teamShares?: unknown }).teamShares
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.filter((item): item is EntryTeamShare => typeof item === 'object' && item !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批报名 → `entryId → 成员单位`,**只收联队**(成员 ≥ 2)。
|
||||
*
|
||||
* 单单位报名不进表:调用方拿到的 null 就是「按原来那样渲染」,于是普通报名的成绩行
|
||||
* 与大屏改造前完全一致(连一个多余的节点都不多)。
|
||||
* logo 从快照单位表里按 teamId 取,缺单位表(老快照 / 缓存)时是 null,页面自然不画第二个 logo。
|
||||
*/
|
||||
export function entryShareIndex(
|
||||
entries: readonly unknown[],
|
||||
teams: readonly Team[] = [],
|
||||
): Map<number, DisplayTeamShare[]> {
|
||||
const byTeam = new Map<number, Team>(teams.map((team) => [team.id, team]))
|
||||
const out = new Map<number, DisplayTeamShare[]>()
|
||||
for (const entry of entries) {
|
||||
if (typeof entry !== 'object' || entry === null) continue
|
||||
const rec = entry as { id?: unknown; teamId?: unknown; teamCode?: unknown; teamName?: unknown }
|
||||
const entryId = typeof rec.id === 'number' ? rec.id : 0
|
||||
const primaryTeamId = typeof rec.teamId === 'number' ? rec.teamId : 0
|
||||
if (entryId <= 0) continue
|
||||
const members = normalizeTeamShares(readEntryTeamShares(entry), {
|
||||
teamId: primaryTeamId,
|
||||
weight: 1,
|
||||
teamCode: typeof rec.teamCode === 'string' ? rec.teamCode : '',
|
||||
teamName: typeof rec.teamName === 'string' ? rec.teamName : '',
|
||||
}).map((share) => ({
|
||||
...share,
|
||||
logoPath: byTeam.get(share.teamId)?.logoPath ?? null,
|
||||
}))
|
||||
if (!isJointTeam(members)) continue
|
||||
out.set(entryId, members)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 一行成绩(分项成绩页 / 实时赛况页)在单位列里的文案。 */
|
||||
export function rowTeamText(
|
||||
row: { shortName: string | null; teamName: string },
|
||||
members: readonly TeamShareView[] | null,
|
||||
): string {
|
||||
if (members === null) return row.shortName ?? row.teamName
|
||||
return jointTeamLabel(members)
|
||||
}
|
||||
|
||||
/**
|
||||
* 底栏「最新完成项目」一行里的名字:有运动员就写运动员,团体行才写单位。
|
||||
*
|
||||
* 团体行是联队时写「A+B联队」——底栏只有一行字,写不下两个 logo,
|
||||
* 但两个单位的名字必须都在,否则现场会以为只有一个单位参赛。
|
||||
*/
|
||||
export function tickerRowText(
|
||||
row: { athleteName: string | null; shortName: string | null; teamName: string },
|
||||
members: readonly TeamShareView[] | null,
|
||||
): string {
|
||||
if (row.athleteName !== null && row.athleteName.trim() !== '') return row.athleteName
|
||||
return rowTeamText(row, members)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单位列要画的 logo:联队时是**行自己的 logo + 其余成员的 logo**,普通报名只有一个元素
|
||||
* (可能与改造前一样是 null,TeamLogo 不渲染任何东西)。
|
||||
*
|
||||
* 行自己的 logoPath 就是主单位的 logo(服务端的读侧口径),所以成员里跳过主单位那一条,
|
||||
* 免得同一个 logo 画两遍。
|
||||
*/
|
||||
export function rowTeamLogos(
|
||||
row: { teamId: number; logoPath: string | null },
|
||||
members: readonly DisplayTeamShare[] | null,
|
||||
): (string | null)[] {
|
||||
if (members === null) return [row.logoPath]
|
||||
const extra = members.filter((member) => member.teamId !== row.teamId)
|
||||
return [row.logoPath, ...extra.map((member) => member.logoPath)]
|
||||
}
|
||||
|
||||
/** 一屏里一行最多要画几个 logo:字号与单位列宽要按它留位置(见 fitFont 的 logoEm)。 */
|
||||
export function rowLogoCount(
|
||||
row: { teamId: number; logoPath: string | null },
|
||||
members: readonly DisplayTeamShare[] | null,
|
||||
): number {
|
||||
return rowTeamLogos(row, members).filter((path) => path !== null && path !== '').length
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Team } from '@/shared'
|
||||
import {
|
||||
entryShareIndex,
|
||||
readEntryTeamShares,
|
||||
rowLogoCount,
|
||||
rowTeamLogos,
|
||||
rowTeamText,
|
||||
tickerRowText,
|
||||
} from '../jointTeams'
|
||||
|
||||
/**
|
||||
* 大屏的联队渲染(devdocs/19)。要钉住的是:
|
||||
* 1. **单单位(普通报名)时逐字/逐图不变**:文案还是 `shortName ?? teamName`,logo 还是刚那一张,
|
||||
* 联队成员表里根本没有这条报名的位置;
|
||||
* 2. 联队时写「A+B联队」(两个单位的名字都写全),单位列两个 logo 都画;
|
||||
* 3. 成员单位的 logo 只能从快照的单位表里取(entries 接口不下发 logoPath),
|
||||
* 取不到时那个位置是 null,TeamLogo 不渲染,不会留空洞。
|
||||
*/
|
||||
|
||||
function team(id: number, over: Partial<Team> = {}): Team {
|
||||
return {
|
||||
id,
|
||||
code: `T${id}`,
|
||||
name: `第${id}分公司`,
|
||||
shortName: null,
|
||||
logoPath: null,
|
||||
color: null,
|
||||
sortOrder: id,
|
||||
active: true,
|
||||
deletedAt: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const TEAMS: Team[] = [
|
||||
team(1, { code: 'A', name: '第一分公司', shortName: '一司', logoPath: '/uploads/a.png' }),
|
||||
team(2, { code: 'B', name: '第二分公司', shortName: '二司', logoPath: '/uploads/b.png' }),
|
||||
team(3, { code: 'C', name: '第三分公司', shortName: '三司', logoPath: null }),
|
||||
]
|
||||
|
||||
test('teamShares 解析:缺字段 / 类型不对一律当「没有配置」,不抛异常', () => {
|
||||
assert.deepEqual(readEntryTeamShares({}), [])
|
||||
assert.deepEqual(readEntryTeamShares({ teamShares: null }), [])
|
||||
assert.deepEqual(readEntryTeamShares({ teamShares: 'A+B' }), [])
|
||||
assert.deepEqual(readEntryTeamShares(null), [])
|
||||
assert.equal(readEntryTeamShares({ teamShares: [{ teamId: 1, weight: 1 }] }).length, 1)
|
||||
})
|
||||
|
||||
test('联队进表:成员单位带上各自的 logo(名字与 logo 都来自两处不同的接口)', () => {
|
||||
const index = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
teamId: 1,
|
||||
teamCode: 'A',
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
)
|
||||
const members = index.get(11)
|
||||
assert.equal(members?.length, 2)
|
||||
assert.equal(members?.[1]?.logoPath, '/uploads/b.png')
|
||||
assert.equal(members?.[1]?.percent, 50)
|
||||
})
|
||||
|
||||
test('普通报名(单单位 / 老服务端没有 teamShares)不进表 = 屏上按原来那样渲染', () => {
|
||||
const index = entryShareIndex(
|
||||
[
|
||||
{ id: 11, teamId: 1, teamCode: 'A', teamName: '第一分公司' },
|
||||
{
|
||||
id: 12,
|
||||
teamId: 1,
|
||||
teamCode: 'A',
|
||||
teamName: '第一分公司',
|
||||
teamShares: [{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' }],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
)
|
||||
assert.equal(index.size, 0)
|
||||
assert.equal(index.get(11), undefined)
|
||||
})
|
||||
|
||||
test('单位列文案:单单位与改造前逐字一致,联队写「A+B联队」', () => {
|
||||
const row = { shortName: '一司', teamName: '第一分公司' }
|
||||
assert.equal(rowTeamText(row, null), '一司')
|
||||
assert.equal(rowTeamText({ shortName: null, teamName: '第一分公司' }, null), '第一分公司')
|
||||
const members = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
teamId: 1,
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamName: '第二分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
).get(11)
|
||||
assert.ok(members !== null && members !== undefined)
|
||||
// 联队写全称:两个单位名一个不少(简称在别的单位的人眼里认不出来)。
|
||||
assert.equal(rowTeamText(row, members), '第一分公司+第二分公司联队')
|
||||
})
|
||||
|
||||
test('底栏一行:有运动员写运动员;团体行的联队写「A+B联队」', () => {
|
||||
const members = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
teamId: 1,
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamName: '第二分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
).get(11)
|
||||
assert.ok(members !== null && members !== undefined)
|
||||
assert.equal(
|
||||
tickerRowText({ athleteName: '张三', shortName: '一司', teamName: '第一分公司' }, members),
|
||||
'张三',
|
||||
)
|
||||
assert.equal(
|
||||
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, members),
|
||||
'第一分公司+第二分公司联队',
|
||||
)
|
||||
assert.equal(
|
||||
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, null),
|
||||
'一司',
|
||||
)
|
||||
})
|
||||
|
||||
test('单位列的 logo:普通报名只有行的那一张,联队把两个单位都放上', () => {
|
||||
const row = { teamId: 1, logoPath: '/uploads/a.png' }
|
||||
assert.deepEqual(rowTeamLogos(row, null), ['/uploads/a.png'])
|
||||
assert.equal(rowLogoCount(row, null), 1)
|
||||
|
||||
const members = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
teamId: 1,
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamName: '第二分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
).get(11)
|
||||
assert.ok(members !== null && members !== undefined)
|
||||
// 主单位的 logo 来自行本身(服务端读侧口径),成员单位的 logo 来自快照单位表,不重复画。
|
||||
assert.deepEqual(rowTeamLogos(row, members), ['/uploads/a.png', '/uploads/b.png'])
|
||||
assert.equal(rowLogoCount(row, members), 2)
|
||||
|
||||
// 成员没有 logo 时是 null(TeamLogo 不渲染),不影响另一个 logo 与名字。
|
||||
const noLogo = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 12,
|
||||
teamId: 1,
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamName: '第一分公司' },
|
||||
{ teamId: 3, weight: 1, teamName: '第三分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
).get(12)
|
||||
assert.ok(noLogo !== null && noLogo !== undefined)
|
||||
assert.deepEqual(rowTeamLogos(row, noLogo), ['/uploads/a.png', null])
|
||||
assert.equal(rowLogoCount(row, noLogo), 1, '没有 logo 的那一份不占 logo 宽度')
|
||||
})
|
||||
|
||||
test('大屏为屏上的小项读报名表:读失败静默(屏上仍是主单位,不清屏)', () => {
|
||||
const source = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8')
|
||||
assert.match(source, /api\.events\.entries\(eventId\)/, '成员单位只能来自报名表')
|
||||
assert.match(source, /entryShareIndex\(entries, teams\.value\)/, 'logo 要从快照单位表里补')
|
||||
assert.match(source, /catch \{/, '拿不到报名表时按单单位渲染,不能弹错或清屏')
|
||||
assert.match(source, /jointTeamOf/, '页面通过 jointTeamOf 取成员单位')
|
||||
})
|
||||
@@ -289,7 +289,9 @@ test('页面按栏数切 class,三种文案都不留白', () => {
|
||||
assert.match(source, /正在获取当前项目…/)
|
||||
assert.match(source, /暂无进行中的项目/)
|
||||
assert.match(source, /该项目暂无已发布的成绩/)
|
||||
assert.match(source, /:path="row\.logoPath"/)
|
||||
// 单位 logo 按 teamLogos(row) 逐张画:联队一行两个单位的 logo 都要出现(普通报名只有一个元素)。
|
||||
assert.match(source, /v-for="\(logo, index\) in teamLogos\(row\)"/)
|
||||
assert.match(source, /:path="logo"/)
|
||||
// 定时兜底必须挂在页面上,否则项目切换会停在旧结论。
|
||||
assert.match(source, /LIVE_REFRESH_INTERVAL_MS/)
|
||||
// 数据来自接口结论(liveFeed),且挂载时立刻拉一次。
|
||||
@@ -330,13 +332,15 @@ test('单位列宽同样由内容算出来,长单位名不再被写死列宽
|
||||
// 单栏与拆两块:单位列写成 minmax(内容宽, 1fr),短名字时与运动员列一样宽,观感不变。
|
||||
assert.match(source, /minmax\(var\(--team-width, 300px\), 1fr\)/)
|
||||
assert.match(source, /minmax\(var\(--team-width, 150px\), 1fr\)/)
|
||||
// 页面必须把单位名与 logo 一起喂给字号计算,否则又变成「算完字号再被单位名挤破」。
|
||||
// 页面必须把单位名(联队时是「A+B联队」)与 logo 一起喂给字号计算,
|
||||
// 否则又变成「算完字号再被单位名挤破」;logo 的份数也要喂(联队一行两个 logo)。
|
||||
assert.match(source, /liveTeamColumnPx/)
|
||||
assert.match(source, /row\.shortName \?\? row\.teamName/)
|
||||
assert.match(source, /rowTeamText\(row, jointTeamOf\(row\.entryId\)\)/)
|
||||
assert.match(source, /rowLogoCount\(row, jointTeamOf\(row\.entryId\)\)/)
|
||||
assert.match(source, /'--team-width': `\$\{liveTeamColumnPx/)
|
||||
assert.match(
|
||||
source,
|
||||
/fitLiveRowFontPx\(\s*liveBlockRowCount\([\s\S]*?teamTexts\.value,\s*teamsWithLogo\.value,/,
|
||||
/fitLiveRowFontPx\(\s*liveBlockRowCount\([\s\S]*?teamTexts\.value,\s*teamsWithLogo\.value,\s*maxLogosPerRow\.value,/,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,19 +8,26 @@ import { compileTemplate, parse } from 'vue/compiler-sfc'
|
||||
|
||||
const LOGO_SOURCE = readFileSync(new URL('../components/TeamLogo.vue', import.meta.url), 'utf8')
|
||||
|
||||
/** 页面 → 放 logo 的那一列(分项成绩页的单位列用的是 .team)。 */
|
||||
const PAGES: readonly [string, string][] = [
|
||||
['StandingsPage.vue', 'name'],
|
||||
['TeamMedalsPage.vue', 'name'],
|
||||
['EventResultsPage.vue', 'team'],
|
||||
['AthleteStandingsPage.vue', 'name'],
|
||||
/**
|
||||
* 页面 → 放 logo 的那一列(分项成绩页的单位列用的是 .team)+ 该页 `:path` 的表达式。
|
||||
*
|
||||
* 三张榜单页的行本来就属于某个单位,logo 直接取 `row.logoPath`;
|
||||
* 分项成绩页在联队时要画**两个**单位的 logo(devdocs/19),所以它按 `teamLogos(row)` 逐张渲染
|
||||
* ——普通报名那个数组只有一个元素 `row.logoPath`,屏上与改造前一模一样。
|
||||
*/
|
||||
const PAGES: readonly [string, string, string][] = [
|
||||
['StandingsPage.vue', 'name', 'row.logoPath'],
|
||||
['TeamMedalsPage.vue', 'name', 'row.logoPath'],
|
||||
['EventResultsPage.vue', 'team', 'logo'],
|
||||
['AthleteStandingsPage.vue', 'name', 'row.logoPath'],
|
||||
]
|
||||
|
||||
// @vue/compiler-core 的 NodeTypes 没有从 compiler-sfc 再导出,按枚举值取用。
|
||||
const NODE_ELEMENT = 1
|
||||
const NODE_IF = 9
|
||||
const NODE_ATTRIBUTE = 6
|
||||
const NODE_DIRECTIVE = 7
|
||||
const NODE_IF = 9
|
||||
const NODE_FOR = 11
|
||||
|
||||
interface AstAttr {
|
||||
type: number
|
||||
@@ -55,12 +62,20 @@ function classTokens(node: AstNode): string[] {
|
||||
return attr?.value?.content?.split(/\s+/).filter(Boolean) ?? []
|
||||
}
|
||||
|
||||
/** 直接子元素;v-if 分支展开一层,免得元素被条件包住时漏看。 */
|
||||
/**
|
||||
* 直接子元素;v-if 分支与 v-for 循环展开一层,免得元素被条件 / 循环包住时漏看。
|
||||
*
|
||||
* v-for 展开是联队要的:分项成绩页的单位列按 `teamLogos(row)` 逐张画 logo(普通报名时数组
|
||||
* 只有一个元素),渲染时它就是一个循环,不展开的话这一列看着一个 logo 都没有。
|
||||
* 展开后「子元素个数」数的仍是模板里写了几处,不受运行时数组长度影响。
|
||||
*/
|
||||
function childElements(node: AstNode): AstNode[] {
|
||||
const out: AstNode[] = []
|
||||
for (const child of node.children ?? []) {
|
||||
if (child.type === NODE_IF) {
|
||||
for (const branch of child.branches ?? []) out.push(...branch.children)
|
||||
} else if (child.type === NODE_FOR) {
|
||||
out.push(...childElements(child))
|
||||
} else {
|
||||
out.push(child)
|
||||
}
|
||||
@@ -153,7 +168,7 @@ test('TeamLogo 用 em 定尺寸、object-fit: contain,不撑行高', () => {
|
||||
assert.match(rule, /flex:\s*none/, 'logo 不得被 flex 压缩变形')
|
||||
})
|
||||
|
||||
for (const [page, column] of PAGES) {
|
||||
for (const [page, column, pathExpr] of PAGES) {
|
||||
test(`${page} 用 TeamLogo 在 ${column} 列的名字前渲染单位 logo`, () => {
|
||||
const source = readFileSync(new URL(`../components/pages/${page}`, import.meta.url), 'utf8')
|
||||
const ast = templateAst(source, page)
|
||||
@@ -166,7 +181,7 @@ for (const [page, column] of PAGES) {
|
||||
container.classes.includes(column),
|
||||
`logo 应放在 .${column} 列里,实际是 .${container.classes.join('.')}`,
|
||||
)
|
||||
assert.equal(container.logoPathExpr, 'row.logoPath', 'logo 应取行数据的 logoPath')
|
||||
assert.equal(container.logoPathExpr, pathExpr, 'logo 应取行数据的 logoPath')
|
||||
|
||||
// 用户要求:logo 在名字前面。名字是容器里紧跟 logo 的那个元素。
|
||||
assert.equal(container.children[0]?.tag, 'TeamLogo', 'logo 应是这一列的首个子元素')
|
||||
@@ -175,6 +190,24 @@ for (const [page, column] of PAGES) {
|
||||
})
|
||||
}
|
||||
|
||||
// 联队(一条报名挂两个单位)时两个单位的 logo 都要出现:分项成绩页的那一列按 teamLogos(row)
|
||||
// 逐张画(普通报名时数组只有一个元素),路径表达式从 teamLogos 求和,成员 logo 落在数组里。
|
||||
test('分项成绩页的 logo 列表来自 teamLogos(row),联队时两个都画', () => {
|
||||
const source = readFileSync(new URL('../components/pages/EventResultsPage.vue', import.meta.url), 'utf8')
|
||||
const ast = templateAst(source, 'EventResultsPage.vue')
|
||||
const nodes: AstNode[] = []
|
||||
collect(ast, nodes)
|
||||
const logo = nodes.find((node) => node.tag === 'TeamLogo')
|
||||
assert.ok(logo !== undefined)
|
||||
assert.equal(boundExpression(logo, 'path'), 'logo', ':path 应为 v-for 的每一项')
|
||||
assert.match(
|
||||
source,
|
||||
/v-for="\(logo, index\) in teamLogos\(row\)"/,
|
||||
'logo 必须按 teamLogos(row) 循环渲染,否则联队画不出第二个',
|
||||
)
|
||||
assert.match(source, /rowTeamLogos\(row, jointTeamOf\(row\.entryId\)\)/)
|
||||
})
|
||||
|
||||
test('四个页面都不再手写 logo 的 <img>,逻辑只留一份', () => {
|
||||
for (const [page] of PAGES) {
|
||||
const source = readFileSync(new URL(`../components/pages/${page}`, import.meta.url), 'utf8')
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
EventResultGroup,
|
||||
RealtimeStatus,
|
||||
StandingRow,
|
||||
Team,
|
||||
} from '@/shared'
|
||||
import {
|
||||
DEFAULT_ROTATE_SECONDS,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
parseTeamBreakdown,
|
||||
} from './guards'
|
||||
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
|
||||
import { entryShareIndex } from './jointTeams'
|
||||
import type { DisplayTeamShare } from './jointTeams'
|
||||
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
|
||||
import { applyDisplayFonts } from './fonts'
|
||||
import { liveRefreshDelay } from './live'
|
||||
@@ -51,6 +54,8 @@ const athletes = ref<AthleteStanding[]>([])
|
||||
const latestResults = ref<EventResultGroup[]>([])
|
||||
// 小项清单:实时赛况页要显示「当前项目」的名字,而指定项目不一定在 latestResults 里。
|
||||
const events = ref<Event[]>([])
|
||||
// 单位表:联队成员单位的 logo 只能从这里取(entries 接口不下发 logoPath)。
|
||||
const teams = ref<Team[]>([])
|
||||
/**
|
||||
* 「实时赛况」的当前项目结论(GET /api/display/live)。
|
||||
*
|
||||
@@ -183,6 +188,7 @@ function applySnapshot(snapshot: DisplaySnapshot, kind: DisplaySource): void {
|
||||
settings.value = snapshot.settings
|
||||
latestResults.value = snapshot.latestResults
|
||||
events.value = snapshot.events
|
||||
teams.value = snapshot.teams
|
||||
version.value = snapshot.version
|
||||
applyStandings(snapshot.standings)
|
||||
source.value = kind
|
||||
@@ -251,6 +257,85 @@ function reportLiveFailure(wasStale: boolean, reason: string): void {
|
||||
console.warn('[实时赛况]', `拉取当前项目失败,继续显示上一次成绩:${reason}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 联队成员(一条报名挂在多个单位下,devdocs/19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `entryId → 联队成员`。只有联队(成员 ≥ 2)在里面,单单位报名取不到 = 按原来那样渲染。
|
||||
*
|
||||
* 成绩行里只有主单位,成员单位只存在于报名表,所以大屏要为「正在显示的小项」多读一次
|
||||
* `GET /api/events/{id}/entries`(public,guest 可读)。拿不到就退回单单位渲染:
|
||||
* 大屏是无人值守的,为了一句名字把整页搞空不值得。
|
||||
*/
|
||||
const entryShares = ref<Map<number, DisplayTeamShare[]>>(new Map())
|
||||
|
||||
/** 每个小项上次成功拉到报名表的时间;为 0 表示没拉过。 */
|
||||
const sharesFetchedAt = new Map<number, number>()
|
||||
/** 正在飞的小项,避免定时刷新把同一个请求叠成好几份。 */
|
||||
const sharesInFlight = new Set<number>()
|
||||
/** 每个小项上次读回来的联队报名 id:重读时要先删掉它们,改回单单位的行才不会留在屏上。 */
|
||||
const shareEntryIds = new Map<number, number[]>()
|
||||
|
||||
/**
|
||||
* 报名表的重读间隔(毫秒)。
|
||||
*
|
||||
* 份额改动只影响分记在谁头上,推送的是榜单而不是这条报名,所以屏上要自己按期重读一次;
|
||||
* 60s 让「改完几十秒内上屏」,同时每个小项每分钟最多一次请求(局域网里可以忽略)。
|
||||
*/
|
||||
const SHARES_TTL_MS = 60_000
|
||||
|
||||
async function loadEventShares(eventId: number): Promise<void> {
|
||||
if (sharesInFlight.has(eventId)) return
|
||||
sharesInFlight.add(eventId)
|
||||
try {
|
||||
const entries = await api.events.entries(eventId)
|
||||
const index = entryShareIndex(entries, teams.value)
|
||||
const next = new Map(entryShares.value)
|
||||
for (const id of shareEntryIds.get(eventId) ?? []) next.delete(id)
|
||||
const ids: number[] = []
|
||||
for (const [entryId, members] of index) {
|
||||
next.set(entryId, members)
|
||||
ids.push(entryId)
|
||||
}
|
||||
shareEntryIds.set(eventId, ids)
|
||||
entryShares.value = next
|
||||
sharesFetchedAt.set(eventId, Date.now())
|
||||
} catch {
|
||||
// 静默:没有联队信息时屏上仍是主单位,与升级前一致。
|
||||
} finally {
|
||||
sharesInFlight.delete(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉一批小项的报名表:没拉过的、以及过了重读间隔的。 */
|
||||
async function ensureEntryShares(eventIds: readonly number[]): Promise<void> {
|
||||
const now = Date.now()
|
||||
const due = eventIds.filter(
|
||||
(eventId) => eventId > 0 && now - (sharesFetchedAt.get(eventId) ?? 0) > SHARES_TTL_MS,
|
||||
)
|
||||
await Promise.all(due.map((eventId) => loadEventShares(eventId)))
|
||||
}
|
||||
|
||||
/** 屏上正在显示的小项 id(分项成绩 + 实时赛况去重)。 */
|
||||
const sharesEventIds = computed(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const group of latestResults.value) ids.add(group.eventId)
|
||||
for (const item of liveFeed.value?.events ?? []) ids.add(item.eventId)
|
||||
return [...ids].filter((id) => id > 0).sort((a, b) => a - b)
|
||||
})
|
||||
/** 只有这串 id 真的变了才去读报名表(展示层的其它变化与此无关)。 */
|
||||
const sharesKey = computed(() => sharesEventIds.value.join(','))
|
||||
|
||||
/** 某个报名项的联队成员;单单位 / 没读到都是 null,调用方退回原来的渲染。 */
|
||||
function jointTeamOf(entryId: number): DisplayTeamShare[] | null {
|
||||
return entryShares.value.get(entryId) ?? null
|
||||
}
|
||||
|
||||
watch(sharesKey, () => {
|
||||
void ensureEntryShares(sharesEventIds.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 拉一次「实时赛况」的当前项目(GET /api/display/live)。
|
||||
*
|
||||
@@ -293,6 +378,9 @@ async function refreshLiveEvents(): Promise<void> {
|
||||
} else {
|
||||
liveFeed.value = feed
|
||||
liveStale.value = false
|
||||
// 30s 一次的兜底轮询顺带重读报名表(受 SHARES_TTL_MS 限制,实际每分钟最多一次):
|
||||
// 现场改完成员单位后,屏上不必等到项目切换才跟上。
|
||||
void ensureEntryShares(feed.events.map((item) => item.eventId))
|
||||
}
|
||||
} catch (error) {
|
||||
liveStale.value = true
|
||||
@@ -352,6 +440,7 @@ function activateMock(): void {
|
||||
settings.value = parseSettings(snapshot.settings)
|
||||
latestResults.value = snapshot.latestResults
|
||||
events.value = snapshot.events
|
||||
teams.value = snapshot.teams
|
||||
athletes.value = createMockAthletes()
|
||||
standings.value = snapshot.standings
|
||||
source.value = 'mock'
|
||||
@@ -642,6 +731,8 @@ export function useScoreboard() {
|
||||
athletes,
|
||||
latestResults,
|
||||
events,
|
||||
teams,
|
||||
jointTeamOf,
|
||||
liveFeed,
|
||||
liveStale,
|
||||
page,
|
||||
|
||||
Reference in new issue
Block a user