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:
AskaEth committed 2026-09-14 03:52:34 +08:00
1 parent b631418a85
commit 830a4dbdb7
61 files changed
+5649 -121

No files matched your search

+14 -2
View File
@@ -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>
+36 -5
View File
@@ -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>
+26 -7
View File
@@ -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)
+109
View File
@@ -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
}
+195
View File
@@ -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 取成员单位')
})
+8 -4
View File
@@ -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,/,
)
})
+43 -10
View File
@@ -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')
+91
View File
@@ -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,