diff --git a/server/internal/dto/results.go b/server/internal/dto/results.go
index b623dca..b42528e 100644
--- a/server/internal/dto/results.go
+++ b/server/internal/dto/results.go
@@ -20,6 +20,7 @@ type EventResultRow struct {
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
ShortName *string `json:"shortName"`
+ LogoPath *string `json:"logoPath"`
AthleteID *int64 `json:"athleteId"`
AthleteName *string `json:"athleteName"`
MemberNames []string `json:"memberNames"`
diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go
index 8301957..1e50226 100644
--- a/server/internal/httpapi/dto.go
+++ b/server/internal/httpapi/dto.go
@@ -145,6 +145,7 @@ type athleteStandingDTO struct {
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
ShortName *string `json:"shortName"`
+ LogoPath *string `json:"logoPath"`
TotalPoints float64 `json:"totalPoints"`
Gold int `json:"gold"`
Silver int `json:"silver"`
diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go
index ee17dfa..a746868 100644
--- a/server/internal/httpapi/handlers_results.go
+++ b/server/internal/httpapi/handlers_results.go
@@ -117,6 +117,8 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
item.TeamCode = t.Code
item.TeamName = t.Name
item.ShortName = t.ShortName
+ // 单位表里可能存的是空串,下发前统一收敛成 null,与总积分榜口径一致。
+ item.LogoPath = nilIfEmpty(strValue(t.LogoPath))
}
out = append(out, item)
}
diff --git a/server/internal/httpapi/handlers_results_test.go b/server/internal/httpapi/handlers_results_test.go
index 398cfd8..fe82278 100644
--- a/server/internal/httpapi/handlers_results_test.go
+++ b/server/internal/httpapi/handlers_results_test.go
@@ -46,3 +46,44 @@ func TestAthleteStandingsUsesFullTeamTable(t *testing.T) {
t.Fatalf("停用单位的个人行应补齐单位名与简称,得到 %+v", stopped)
}
}
+
+// 个人榜的 logoPath 取「运动员所属单位」的 logo:姓名前显示的是单位标识,不是运动员头像。
+func TestAthleteStandingsIncludesTeamLogo(t *testing.T) {
+ env := newTestEnv(t)
+ ids := env.seed(t)
+ env.createUser("recorder", "recorder-pass", "recorder")
+ client, _ := env.client()
+ env.login(client, "recorder", "recorder-pass")
+
+ env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
+ "eventId": ids.event,
+ "results": []map[string]any{
+ {"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
+ {"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
+ },
+ }, true)
+ if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
+ "eventId": ids.event,
+ }, true); status != http.StatusOK {
+ t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
+ }
+ env.exec(t, `UPDATE teams SET logo_path = ? WHERE id = ?`, "/uploads/teams/a.png", ids.teamA)
+ // 历史数据里 logo 可能是空串,同样按「无 logo」下发 null。
+ env.exec(t, `UPDATE teams SET logo_path = '' WHERE id = ?`, ids.teamB)
+
+ _, env2 := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
+ rows := decodeData[[]map[string]any](t, env2)
+ seen := map[int64]map[string]any{}
+ for _, row := range rows {
+ seen[int64(row["athleteId"].(float64))] = row
+ }
+
+ withLogo := seen[ids.athleteA]
+ if withLogo == nil || withLogo["logoPath"] != "/uploads/teams/a.png" {
+ t.Fatalf("有 logo 的单位,个人行应带该单位的 logo: %+v", withLogo)
+ }
+ noLogo, ok := seen[ids.athB]["logoPath"]
+ if !ok || noLogo != nil {
+ t.Fatalf("无 logo 的单位,个人行 logoPath 应为 null,得到 %+v", seen[ids.athB])
+ }
+}
diff --git a/server/internal/realtime/snapshot.go b/server/internal/realtime/snapshot.go
index 7c950b4..a0a141c 100644
--- a/server/internal/realtime/snapshot.go
+++ b/server/internal/realtime/snapshot.go
@@ -366,7 +366,7 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
e.category_id, COALESCE(cat.name, ''),
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''),
r.entry_id, r.rank, e.team_id,
- COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''),
+ COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path,
e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''),
COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status
FROM results r
@@ -401,11 +401,12 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
athleteID sql.NullInt64
athleteName string
shortName string
+ logoPath sql.NullString
memberNames string
isTie int
)
if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &publishedAt,
- &row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName,
+ &row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &logoPath,
&athleteID, &athleteName, &memberNames,
&row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil {
return fmt.Errorf("解析最新成绩: %w", err)
@@ -424,6 +425,7 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
}
row.AthleteName = nilIfEmpty(athleteName)
row.ShortName = nilIfEmpty(shortName)
+ row.LogoPath = nilIfEmpty(logoPath.String)
row.MemberNames = parseMemberNames(memberNames)
row.IsTie = isTie != 0
diff --git a/server/internal/realtime/snapshot_test.go b/server/internal/realtime/snapshot_test.go
index 0b0185e..4e90628 100644
--- a/server/internal/realtime/snapshot_test.go
+++ b/server/internal/realtime/snapshot_test.go
@@ -60,6 +60,7 @@ func TestSnapshotWireContract(t *testing.T) {
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `测试运动会`)
team1 := insertTeam(t, db, "T1", "第一单位", "一队")
+ mustExec(t, db, `UPDATE teams SET logo_path = '/uploads/teams/t1.png' WHERE id = ?`, team1)
event1 := insertEvent(t, db, "E1", "男子 100 米")
cat1 := insertCategory(t, db, event1, "青年组")
athlete1 := insertAthlete(t, db, "张伟", team1)
@@ -151,6 +152,19 @@ func TestSnapshotWireContract(t *testing.T) {
t.Fatalf("成绩分组缺少契约字段 %s: %s", key, top["latestResults"])
}
}
+ var rawRows []map[string]json.RawMessage
+ if err := json.Unmarshal(groups[0]["rows"], &rawRows); err != nil {
+ t.Fatalf("成绩行不是数组: %s", groups[0]["rows"])
+ }
+ if len(rawRows) != 1 {
+ t.Fatalf("成绩行数应为 1,得到 %d: %s", len(rawRows), groups[0]["rows"])
+ }
+ // 行里必须带 logoPath(值为 null 时也要有键),否则前端读不到会拿到 undefined。
+ for _, key := range []string{"entryId", "teamId", "teamCode", "teamName", "shortName", "logoPath", "athleteName", "resultText"} {
+ if _, ok := rawRows[0][key]; !ok {
+ t.Fatalf("成绩行缺少契约字段 %s: %s", key, groups[0]["rows"])
+ }
+ }
var typedGroups []dto.EventResultGroup
if err := json.Unmarshal(top["latestResults"], &typedGroups); err != nil {
t.Fatalf("按 EventResultGroup 解析失败: %v", err)
@@ -160,6 +174,9 @@ func TestSnapshotWireContract(t *testing.T) {
resultRows[0].AthleteName == nil || *resultRows[0].AthleteName != "张伟" {
t.Fatalf("成绩行不符: %+v", resultRows)
}
+ if resultRows[0].LogoPath == nil || *resultRows[0].LogoPath != "/uploads/teams/t1.png" {
+ t.Fatalf("成绩行应带所属单位的 logo: %+v", resultRows[0])
+ }
}
// 同一小项 + 组别的多条成绩必须落进同一组,不同组别/小项拆成不同组,组内按名次排序。
@@ -169,6 +186,9 @@ func TestSnapshotLatestResultsGrouped(t *testing.T) {
team1 := insertTeam(t, db, "T1", "第一单位", "")
team2 := insertTeam(t, db, "T2", "第二单位", "")
+ // 第一单位有 logo,第二单位是历史数据的空串(应下发 null)。
+ mustExec(t, db, `UPDATE teams SET logo_path = '/uploads/teams/t1.png' WHERE id = ?`, team1)
+ mustExec(t, db, `UPDATE teams SET logo_path = '' WHERE id = ?`, team2)
event1 := insertEvent(t, db, "E1", "男子 100 米")
event2 := insertEvent(t, db, "E2", "女子 100 米")
catA := insertCategory(t, db, event1, "青年组")
@@ -222,6 +242,13 @@ func TestSnapshotLatestResultsGrouped(t *testing.T) {
if groupA.Rows[0].TeamCode != "T1" || groupA.Rows[0].TeamName != "第一单位" || groupA.Rows[0].ResultText != "11.58" {
t.Fatalf("成绩行字段不符: %+v", groupA.Rows[0])
}
+ // 每行的 logo 跟着「该行所属单位」走,与队名同源。
+ if groupA.Rows[0].LogoPath == nil || *groupA.Rows[0].LogoPath != "/uploads/teams/t1.png" {
+ t.Fatalf("第一单位的成绩行应带自己的 logo: %+v", groupA.Rows[0])
+ }
+ if groupA.Rows[1].LogoPath != nil {
+ t.Fatalf("无 logo 的单位应下发 null 而不是空串: %+v", groupA.Rows[1])
+ }
if len(byCategory[catB].Rows) != 1 || len(byCategory[catC].Rows) != 1 {
t.Fatalf("其余组别应各自成组: %+v", snap.LatestResults)
diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts
index 8eb4ac1..a547447 100644
--- a/web/src/admin/api/mock.ts
+++ b/web/src/admin/api/mock.ts
@@ -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,
diff --git a/web/src/display/components/TeamLogo.vue b/web/src/display/components/TeamLogo.vue
new file mode 100644
index 0000000..ffd0bc4
--- /dev/null
+++ b/web/src/display/components/TeamLogo.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/web/src/display/components/pages/AthleteStandingsPage.vue b/web/src/display/components/pages/AthleteStandingsPage.vue
index 775f96b..3b4fcc5 100644
--- a/web/src/display/components/pages/AthleteStandingsPage.vue
+++ b/web/src/display/components/pages/AthleteStandingsPage.vue
@@ -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(() =>
{{ row.rank }}
-
{{ row.athleteName }}
+
+
+ {{ row.athleteName }}
+
{{ row.shortName ?? row.teamName }}
@@ -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;
}
diff --git a/web/src/display/components/pages/EventResultsPage.vue b/web/src/display/components/pages/EventResultsPage.vue
index ad935ae..43d61ba 100644
--- a/web/src/display/components/pages/EventResultsPage.vue
+++ b/web/src/display/components/pages/EventResultsPage.vue
@@ -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 {
>
{{ row.rank ?? '—' }}
{{ displayName(row) }}
- {{ row.shortName ?? row.teamName }}
+
+
+ {{ row.shortName ?? row.teamName }}
+
{{ displayResult(row) }}
@@ -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;
diff --git a/web/src/display/components/pages/StandingsPage.vue b/web/src/display/components/pages/StandingsPage.vue
index 7e5305d..652064b 100644
--- a/web/src/display/components/pages/StandingsPage.vue
+++ b/web/src/display/components/pages/StandingsPage.vue
@@ -1,10 +1,11 @@
@@ -37,14 +32,7 @@ function setLogoVisible(event: Event, visible: boolean): void {
>
{{ row.rank }}
-
+
{{ row.teamName }}
@@ -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;
diff --git a/web/src/display/components/pages/TeamMedalsPage.vue b/web/src/display/components/pages/TeamMedalsPage.vue
index b424ab3..a74be38 100644
--- a/web/src/display/components/pages/TeamMedalsPage.vue
+++ b/web/src/display/components/pages/TeamMedalsPage.vue
@@ -1,9 +1,9 @@
@@ -33,14 +27,7 @@ function setLogoVisible(event: Event, visible: boolean): void {
{{ row.rank }}
-
+
{{ row.teamName }}
{{ row.gold }}
@@ -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;
diff --git a/web/src/display/guards.ts b/web/src/display/guards.ts
index e13cb5f..84b2142 100644
--- a/web/src/display/guards.ts
+++ b/web/src/display/guards.ts
@@ -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,
}
}
diff --git a/web/src/display/mock.ts b/web/src/display/mock.ts
index 3c5cab7..abb7ef5 100644
--- a/web/src/display/mock.ts
+++ b/web/src/display/mock.ts
@@ -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,
diff --git a/web/src/display/tests/logo-path.test.ts b/web/src/display/tests/logo-path.test.ts
new file mode 100644
index 0000000..ea994de
--- /dev/null
+++ b/web/src/display/tests/logo-path.test.ts
@@ -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:
+// 页面据此判断「不渲染
![]()
」,否则会留下一个空占位把名字推歪。
+
+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)
+})
diff --git a/web/src/display/tests/team-logo.test.ts b/web/src/display/tests/team-logo.test.ts
new file mode 100644
index 0000000..0e121d8
--- /dev/null
+++ b/web/src/display/tests/team-logo.test.ts
@@ -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,页面里不再手写
![]()
。
+// 项目没有 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} 缺少
`)
+ 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 ?? '')
+}
+
+/** 找出直接渲染 的父元素,并带上它的 :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 应只渲染一个
')
+
+ // 条件挂在 v-if="path" 上:调用方不用自己判断,留不下占位空洞。
+ const ifNode = nodes.find(
+ (node) => node.type === NODE_IF && node.branches?.[0]?.children[0]?.tag === 'img',
+ )
+ assert.ok(ifNode !== undefined, '
应是 v-if 分支的根')
+ assert.equal(
+ stripContext(ifNode.branches?.[0]?.condition?.content ?? ''),
+ 'path',
+ '
应由 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 的
,逻辑只留一份', () => {
+ 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} 里不应再出现手写的
`,
+ )
+ assert.match(source, /from '\.\.\/TeamLogo\.vue'/, `${page} 应引入 TeamLogo`)
+ }
+})
diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts
index 2d83253..aacf032 100644
--- a/web/src/shared/types.ts
+++ b/web/src/shared/types.ts
@@ -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