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
@@ -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"`
+1
View File
@@ -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"`
@@ -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)
}
@@ -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])
}
}
+4 -2
View File
@@ -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
+27
View File
@@ -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)