feat(display): 分项成绩与个人榜也显示单位 logo(个人榜在姓名前)

- 服务端补下发:dto.EventResultRow 与 athleteStandingDTO 各加 logoPath,
  分别取「该行单位」与「该运动员所属单位」的 teams.logo_path;空串统一收敛为 null
- 大屏抽出 TeamLogo.vue(1em / object-fit:contain / 加载失败保留占位、@load 复原),
  原先在积分榜与团体奖牌榜各写一遍的逻辑收敛成一份,四处复用
- 分项成绩页:单位列名字前显示 logo;个人积分榜:运动员姓名前显示其单位 logo
- guards/mock 同步解析与补字段;补 11 条测试(类型解析 + SFC 模板断言)

顺带修:管理端示例数据 athleteStandings 缺新必填字段导致的 vue-tsc 报错。

自检:服务端 11 包全绿;前端 vue-tsc + build、node --test 80/80。
This commit is contained in:
AskaEth committed 2026-09-10 10:32:19 +08:00
1 parent 6f47849e1e
commit 8c74a82da4
17 files changed
+391 -51

No files matched your search

+1
View File
@@ -642,6 +642,7 @@ class MockDb {
teamCode: entry.teamCode ?? '',
teamName: entry.teamName ?? '',
shortName: entry.shortName ?? null,
logoPath: this.teams.find((item) => item.id === entry.teamId)?.logoPath ?? null,
totalPoints: points,
gold: rank === 1 ? 1 : 0,
silver: rank === 2 ? 1 : 0,
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { resolveAssetUrl } from '@/shared'
defineProps<{ path: string | null | undefined }>()
// 加载失败时不显示破图:藏起来但保留占位,重新加载出图后再放出来,
// 两种情况行内的名字与行高都不会跳动。
function setVisible(event: Event, visible: boolean): void {
;(event.target as HTMLImageElement).style.visibility = visible ? '' : 'hidden'
}
</script>
<template>
<img
v-if="path"
class="team-logo"
:src="resolveAssetUrl(path)"
alt=""
@error="setVisible($event, false)"
@load="setVisible($event, true)"
/>
</template>
<style scoped>
/* 尺寸用 em 跟着所在行字号浮动:既不会撑开行高,也不会挤动名次/分数列。 */
.team-logo {
flex: none;
width: 1em;
height: 1em;
object-fit: contain;
}
</style>
@@ -4,6 +4,7 @@ import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
import { useScoreboard } from '../../useScoreboard'
import AnimatedNumber from '../AnimatedNumber.vue'
import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue'
const { athletes, settings } = useScoreboard()
@@ -36,7 +37,10 @@ const rowFont = computed(() =>
</div>
<div v-for="row in visibleRows" :key="row.athleteId" class="row">
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
<span class="name">{{ row.athleteName }}</span>
<span class="name">
<TeamLogo :path="row.logoPath" />
<span class="athlete-name">{{ row.athleteName }}</span>
</span>
<span class="team">{{ row.shortName ?? row.teamName }}</span>
<span class="points"><AnimatedNumber :value="row.totalPoints" /></span>
<template v-if="showMedals">
@@ -102,14 +106,24 @@ const rowFont = computed(() =>
.top-3 {
color: #e0995e;
}
.name,
.team {
.name {
display: flex;
align-items: center;
gap: 0.28em;
min-width: 0;
overflow: hidden;
}
.athlete-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-dim);
font-size: 0.82em;
}
@@ -4,6 +4,7 @@ import { formatResultText } from '@/shared'
import type { EventResultRow, ResultStatus } from '@/shared'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue'
const { latestResults, options } = useScoreboard()
@@ -48,7 +49,10 @@ function displayName(row: EventResultRow): string {
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span>
<span class="team">{{ row.shortName ?? row.teamName }}</span>
<span class="team">
<TeamLogo :path="row.logoPath" />
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
</span>
<span class="rs">{{ displayResult(row) }}</span>
</div>
</div>
@@ -128,8 +132,16 @@ h3 {
white-space: nowrap;
}
.team {
display: flex;
align-items: center;
gap: 0.28em;
min-width: 0;
overflow: hidden;
font-size: 22px;
color: var(--text-dim);
}
.team-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatRankChange, resolveAssetUrl } from '@/shared'
import { formatRankChange } from '@/shared'
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
import { useScoreboard } from '../../useScoreboard'
import AnimatedNumber from '../AnimatedNumber.vue'
import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue'
const { standings, rankDeltaFor } = useScoreboard()
@@ -12,12 +13,6 @@ const rows = computed(() => standings.value)
// 行高由 flex 均分,字号按行数算,单位再多也不会顶到轮播条下面。
const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX))
// 单位 logo 加载失败时不显示破图:失败藏起来、重新加载出图后再放出来,
// 两种情况下都保留占位,行内文字与行高都不会跳动。
function setLogoVisible(event: Event, visible: boolean): void {
;(event.target as HTMLImageElement).style.visibility = visible ? '' : 'hidden'
}
</script>
<template>
@@ -37,14 +32,7 @@ function setLogoVisible(event: Event, visible: boolean): void {
>
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
<span class="name">
<img
v-if="row.logoPath"
class="team-logo"
:src="resolveAssetUrl(row.logoPath)"
alt=""
@error="setLogoVisible($event, false)"
@load="setLogoVisible($event, true)"
/>
<TeamLogo :path="row.logoPath" />
<span class="team-name">{{ row.teamName }}</span>
</span>
<span class="delta">
@@ -112,13 +100,6 @@ function setLogoVisible(event: Event, visible: boolean): void {
min-width: 0;
overflow: hidden;
}
/* logo 尺寸用 em 跟着行字号走:既不会撑开行高,也不会挤动名次/分数列。 */
.team-logo {
flex: none;
width: 1em;
height: 1em;
object-fit: contain;
}
.team-name {
min-width: 0;
overflow: hidden;
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { resolveAssetUrl } from '@/shared'
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue'
const { standings } = useScoreboard()
@@ -11,12 +11,6 @@ const { standings } = useScoreboard()
const rows = computed(() => standings.value)
const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX))
// 单位 logo 加载失败时不显示破图:失败藏起来、重新加载出图后再放出来,
// 两种情况下都保留占位,行内文字与行高都不会跳动。
function setLogoVisible(event: Event, visible: boolean): void {
;(event.target as HTMLImageElement).style.visibility = visible ? '' : 'hidden'
}
</script>
<template>
@@ -33,14 +27,7 @@ function setLogoVisible(event: Event, visible: boolean): void {
<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">
<img
v-if="row.logoPath"
class="team-logo"
:src="resolveAssetUrl(row.logoPath)"
alt=""
@error="setLogoVisible($event, false)"
@load="setLogoVisible($event, true)"
/>
<TeamLogo :path="row.logoPath" />
<span class="team-name">{{ row.teamName }}</span>
</span>
<span class="medal gold">{{ row.gold }}</span>
@@ -108,13 +95,6 @@ function setLogoVisible(event: Event, visible: boolean): void {
min-width: 0;
overflow: hidden;
}
/* logo 尺寸用 em 跟着行字号走:既不会撑开行高,也不会挤动名次/奖牌列。 */
.team-logo {
flex: none;
width: 1em;
height: 1em;
object-fit: contain;
}
.team-name {
min-width: 0;
overflow: hidden;
+5 -1
View File
@@ -178,6 +178,8 @@ export function parseAthleteStanding(source: unknown): AthleteStanding {
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
// 个人榜行显示运动员所属单位的 logo;旧服务端不下发时为 null,页面自动不留空洞。
logoPath: strOrNull(rec.logoPath),
totalPoints: num(rec.totalPoints),
gold: num(rec.gold),
silver: num(rec.silver),
@@ -198,6 +200,7 @@ export function parseEventResultRow(source: unknown): EventResultRow {
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
logoPath: strOrNull(rec.logoPath),
athleteId: numOrNull(rec.athleteId),
athleteName: strOrNull(rec.athleteName),
memberNames: strings(rec.memberNames),
@@ -229,12 +232,13 @@ function parseFlatResultRow(
const row = parseEventResultRow(rec)
const team = byTeam.get(row.teamId)
if (team === undefined) return row
// 平铺行只带 teamId,单位名要从快照的 teams 里补,否则底栏只能显示空白。
// 平铺行只带 teamId,单位名与 logo 要从快照的 teams 里补,否则底栏只能显示空白。
return {
...row,
teamCode: row.teamCode === '' ? team.code : row.teamCode,
teamName: row.teamName === '' ? team.name : row.teamName,
shortName: row.shortName ?? team.shortName,
logoPath: row.logoPath ?? team.logoPath,
}
}
+2
View File
@@ -135,6 +135,7 @@ function buildRows(seeds: readonly ResultSeed[], unit: string): EventResultRow[]
teamCode: team?.code ?? '',
teamName: team?.name ?? '',
shortName: team?.shortName ?? null,
logoPath: team?.logoPath ?? null,
athleteId: index + 1,
athleteName: seed.athlete,
memberNames: null,
@@ -236,6 +237,7 @@ function buildAthletes(): AthleteStanding[] {
teamCode: item?.code ?? '',
teamName: item?.name ?? '',
shortName: item?.shortName ?? null,
logoPath: item?.logoPath ?? null,
totalPoints,
gold,
silver,
+46
View File
@@ -0,0 +1,46 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseAthleteStanding, parseEventResultGroups, parseEventResultRow } from '../guards'
import type { Team } from '@/shared'
// 分项成绩行与个人榜行都要带单位 logo。契约要求缺字段时补 null 而不是 undefined:
// 页面据此判断「不渲染 <img>」,否则会留下一个空占位把名字推歪。
test('parseEventResultRow 收下单位 logoPath,缺失或类型不对时补 null', () => {
assert.equal(
parseEventResultRow({ entryId: 1, teamId: 2, logoPath: '/uploads/t2.png' }).logoPath,
'/uploads/t2.png',
)
assert.equal(parseEventResultRow({ entryId: 1, teamId: 2 }).logoPath, null)
assert.equal(parseEventResultRow({ entryId: 1, teamId: 2, logoPath: 42 }).logoPath, null)
})
test('parseAthleteStanding 收下所属单位的 logoPath,缺失时补 null', () => {
const row = parseAthleteStanding({ athleteId: 7, teamId: 2, logoPath: '/uploads/t2.png' })
assert.equal(row.logoPath, '/uploads/t2.png')
assert.equal(parseAthleteStanding({ athleteId: 7, teamId: 2 }).logoPath, null)
})
// 平铺成绩行只带 teamId,单位表里有 logo 时要跟着补上,否则分项成绩页这一格会空着。
test('平铺成绩行从单位表补 logoPath', () => {
const teams: Team[] = [
{
id: 2,
code: 'T02',
name: '第二分公司',
shortName: null,
logoPath: '/uploads/t2.png',
color: null,
sortOrder: 1,
active: true,
},
]
const groups = parseEventResultGroups([{ eventId: 1, categoryId: 1, entryId: 5, teamId: 2 }], teams)
assert.equal(groups[0]?.rows[0]?.logoPath, '/uploads/t2.png')
})
// 单位表里那条没有 logo:补上去的必须是 null,页面才不会去请求一个不存在的图。
test('平铺成绩行补不到 logo 时保持 null', () => {
const groups = parseEventResultGroups([{ eventId: 1, categoryId: 1, entryId: 5, teamId: 9 }], [])
assert.equal(groups[0]?.rows[0]?.logoPath, null)
})
+190
View File
@@ -0,0 +1,190 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { compileTemplate, parse } from 'vue/compiler-sfc'
// 单位 logo 只有一份实现:4 个榜单页都走 TeamLogo.vue,页面里不再手写 <img>。
// 项目没有 jsdom,这里编译 SFC 模板取 AST,按元素与顺序断言,改坏排版会直接失败。
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'],
]
// @vue/compiler-core 的 NodeTypes 没有从 compiler-sfc 再导出,按枚举值取用。
const NODE_ELEMENT = 1
const NODE_IF = 9
const NODE_ATTRIBUTE = 6
const NODE_DIRECTIVE = 7
interface AstAttr {
type: number
name?: string
value?: { content?: string }
/** v-bind 的 :name="expr":表达式在 exp.loc.source 上。 */
arg?: { content?: string }
exp?: { loc?: { source?: string } }
}
interface AstBranch {
condition?: { content?: string }
children: AstNode[]
}
interface AstNode {
type: number
tag?: string
props?: AstAttr[]
children?: AstNode[]
branches?: AstBranch[]
}
interface Container {
classes: string[]
logoPathExpr: string
children: AstNode[]
}
function classTokens(node: AstNode): string[] {
const attr = node.props?.find((prop) => prop.type === NODE_ATTRIBUTE && prop.name === 'class')
return attr?.value?.content?.split(/\s+/).filter(Boolean) ?? []
}
/** 直接子元素;v-if 分支展开一层,免得元素被条件包住时漏看。 */
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 {
out.push(child)
}
}
return out
}
function collect(node: AstNode, out: AstNode[]): void {
out.push(node)
for (const child of node.children ?? []) collect(child, out)
for (const branch of node.branches ?? []) {
for (const child of branch.children) collect(child, out)
}
}
function templateAst(source: string, filename: string): AstNode {
const { descriptor, errors } = parse(source, { filename })
assert.equal(errors.length, 0, `${filename} 应能被解析`)
const template = descriptor.template
if (template == null) throw new Error(`${filename} 缺少 <template>`)
const built = compileTemplate({ source: template.content, filename, id: filename })
assert.deepEqual(built.errors, [], `${filename} 模板应能编译`)
if (built.ast === undefined) throw new Error(`${filename} 模板未产出 AST`)
return built.ast as unknown as AstNode
}
/** setup 里的绑定在模板 AST 里被改写成 _ctx.xxx,比较表达式时统一去掉前缀。 */
function stripContext(expr: string): string {
return expr.replace(/^_ctx\./, '')
}
/** 取元素上 `:name="expr"` 绑定的表达式源码;静态写法(不带冒号)返回空串。 */
function boundExpression(node: AstNode, name: string): string {
const prop = node.props?.find(
(item) => item.type === NODE_DIRECTIVE && item.arg?.content === name,
)
return stripContext(prop?.exp?.loc?.source ?? '')
}
/** 找出直接渲染 <TeamLogo> 的父元素,并带上它的 :path 表达式。 */
function logoContainers(ast: AstNode): Container[] {
const nodes: AstNode[] = []
collect(ast, nodes)
const out: Container[] = []
for (const node of nodes) {
if (node.type !== NODE_ELEMENT) continue
const children = childElements(node)
if (!children.some((child) => child.tag === 'TeamLogo')) continue
const logo = children.find((child) => child.tag === 'TeamLogo')
out.push({
classes: classTokens(node),
logoPathExpr: logo === undefined ? '' : boundExpression(logo, 'path'),
children,
})
}
return out
}
function styleRule(style: string, selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(style)
assert.ok(match !== null, `样式里应存在 ${selector} 规则`)
return match[1] ?? ''
}
test('TeamLogo 没有 path 时不渲染 img,有 path 时才出图', () => {
const nodes: AstNode[] = []
collect(templateAst(LOGO_SOURCE, 'TeamLogo.vue'), nodes)
assert.equal(nodes.filter((node) => node.tag === 'img').length, 1, 'TeamLogo 应只渲染一个 <img>')
// 条件挂在 v-if="path" 上:调用方不用自己判断,留不下占位空洞。
const ifNode = nodes.find(
(node) => node.type === NODE_IF && node.branches?.[0]?.children[0]?.tag === 'img',
)
assert.ok(ifNode !== undefined, '<img> 应是 v-if 分支的根')
assert.equal(
stripContext(ifNode.branches?.[0]?.condition?.content ?? ''),
'path',
'<img> 应由 path 控制渲染',
)
})
test('TeamLogo 用 em 定尺寸、object-fit: contain,不撑行高', () => {
const { descriptor } = parse(LOGO_SOURCE, { filename: 'TeamLogo.vue' })
const style = descriptor.styles[0]?.content ?? ''
const rule = styleRule(style, '.team-logo')
assert.match(rule, /width:\s*1em/)
assert.match(rule, /height:\s*1em/)
assert.match(rule, /object-fit:\s*contain/)
assert.match(rule, /flex:\s*none/, 'logo 不得被 flex 压缩变形')
})
for (const [page, column] of PAGES) {
test(`${page} 用 TeamLogo 在 ${column} 列的名字前渲染单位 logo`, () => {
const source = readFileSync(new URL(`../components/pages/${page}`, import.meta.url), 'utf8')
const ast = templateAst(source, page)
const containers = logoContainers(ast)
assert.equal(containers.length, 1, '同一页只应有一处渲染 logo')
const container = containers[0]
assert.ok(container !== undefined)
assert.ok(
container.classes.includes(column),
`logo 应放在 .${column} 列里,实际是 .${container.classes.join('.')}`,
)
assert.equal(container.logoPathExpr, 'row.logoPath', 'logo 应取行数据的 logoPath')
// 用户要求:logo 在名字前面。名字是容器里紧跟 logo 的那个元素。
assert.equal(container.children[0]?.tag, 'TeamLogo', 'logo 应是这一列的首个子元素')
assert.equal(container.children.length, 2, '这一列只应有 logo + 名字两个子元素')
assert.equal(container.children[1]?.tag, 'span')
})
}
test('四个页面都不再手写 logo 的 <img>,逻辑只留一份', () => {
for (const [page] of PAGES) {
const source = readFileSync(new URL(`../components/pages/${page}`, import.meta.url), 'utf8')
const nodes: AstNode[] = []
collect(templateAst(source, page), nodes)
assert.equal(
nodes.filter((node) => node.tag === 'img').length,
0,
`${page} 里不应再出现手写的 <img>`,
)
assert.match(source, /from '\.\.\/TeamLogo\.vue'/, `${page} 应引入 TeamLogo`)
}
})
+4
View File
@@ -298,6 +298,8 @@ export interface AthleteStanding {
teamCode: string
teamName: string
shortName: string | null
/** 运动员所属单位的 logo;无 logo 时为 null。 */
logoPath: string | null
totalPoints: number
gold: number
silver: number
@@ -663,6 +665,8 @@ export interface EventResultRow {
teamCode: string
teamName: string
shortName: string | null
/** 该行单位的 logo;无 logo 时为 null。 */
logoPath: string | null
athleteId: number | null
athleteName: string | null
memberNames: string[] | null