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 }}