Chuiguohuang commited on
Commit
23938a8
·
verified ·
1 Parent(s): 8193c6f

feat(mvp): 新增铁血防守 MVP(领队结算/红色榜单/🛡️芯片),原 MVP 更名最佳球员

Browse files
Files changed (9) hide show
  1. app.py +12 -8
  2. src/db.py +1 -0
  3. src/models.py +4 -0
  4. src/repo.py +44 -10
  5. src/repo_base.py +1 -0
  6. src/theme.py +13 -0
  7. src/view_admin.py +20 -17
  8. src/view_home.py +20 -9
  9. src/view_me.py +8 -6
app.py CHANGED
@@ -91,6 +91,7 @@ def server(input: Inputs, output: Outputs, session: Session) -> None:
91
  repo.match_contributions(season),
92
  repo.mvp_ranking(season),
93
  repo.settled_mvps(),
 
94
  )
95
 
96
  # ------------------------------------------------------------------ 导出榜单图片
@@ -108,6 +109,7 @@ def server(input: Inputs, output: Outputs, session: Session) -> None:
108
  key=lambda x: (-x["saves"], -x["attended"]),
109
  )
110
  mvp = repo.mvp_ranking(season)
 
111
 
112
  def rowsify(items, valuefn):
113
  return [(str(i), it["name"], valuefn(it)) for i, it in enumerate(items, 1)]
@@ -116,7 +118,8 @@ def server(input: Inputs, output: Outputs, session: Session) -> None:
116
  ("射手榜", rowsify(scorers, lambda x: f'{x["goals"]} 球')),
117
  ("助攻榜", rowsify(assisters, lambda x: f'{x["assists"]} 助')),
118
  ("出勤榜", rowsify(attend, lambda x: f'{x["attended"]}/{x["total"]}')),
119
- ("MVP 榜", rowsify(mvp, lambda x: f'{x["mvps"]} 次')),
 
120
  ]
121
  if keepers: # 有门将才加扑救榜
122
  boards.append(("扑救榜", rowsify(keepers, lambda x: f'{x["saves"]} 救')))
@@ -298,17 +301,18 @@ def server(input: Inputs, output: Outputs, session: Session) -> None:
298
  def _save_mvp_lock():
299
  if not input.btn_save_mvp_lock() or not is_admin():
300
  return
301
- def _sel(mid: int, suffix: str = "") -> int | None:
302
- raw = input[f"mvpsel{suffix}_{mid}"]()
303
  return int(raw) if (raw and raw != "none" and str(raw).isdigit()) else None
304
  settled = opened = 0
305
  for m in repo.list_matches():
306
  mid = m["id"]
307
- w1, w2 = _sel(mid), _sel(mid, "2")
308
- if (w1, w2) != (m.get("mvp_user_id"), m.get("mvp2_user_id")):
309
- repo.settle_mvp(mid, w1, w2)
310
- settled += 1 if (w1 or w2) else 0
311
- opened += 1 if (w1 is None and w2 is None) else 0
 
312
  bump()
313
  ui.notification_show(
314
  f"已结算 {settled} 场、重新开放 {opened} 场 🏅", type="message"
 
91
  repo.match_contributions(season),
92
  repo.mvp_ranking(season),
93
  repo.settled_mvps(),
94
+ repo.dmvp_ranking(season),
95
  )
96
 
97
  # ------------------------------------------------------------------ 导出榜单图片
 
109
  key=lambda x: (-x["saves"], -x["attended"]),
110
  )
111
  mvp = repo.mvp_ranking(season)
112
+ dmvp = repo.dmvp_ranking(season)
113
 
114
  def rowsify(items, valuefn):
115
  return [(str(i), it["name"], valuefn(it)) for i, it in enumerate(items, 1)]
 
118
  ("射手榜", rowsify(scorers, lambda x: f'{x["goals"]} 球')),
119
  ("助攻榜", rowsify(assisters, lambda x: f'{x["assists"]} 助')),
120
  ("出勤榜", rowsify(attend, lambda x: f'{x["attended"]}/{x["total"]}')),
121
+ ("最佳球员 MVP", rowsify(mvp, lambda x: f'{x["mvps"]} 次')),
122
+ ("铁血防守 MVP", rowsify(dmvp, lambda x: f'{x["dmvps"]} 次')),
123
  ]
124
  if keepers: # 有门将才加扑救榜
125
  boards.append(("扑救榜", rowsify(keepers, lambda x: f'{x["saves"]} 救')))
 
301
  def _save_mvp_lock():
302
  if not input.btn_save_mvp_lock() or not is_admin():
303
  return
304
+ def _sel(mid: int, prefix: str = "mvpsel") -> int | None:
305
+ raw = input[f"{prefix}_{mid}"]()
306
  return int(raw) if (raw and raw != "none" and str(raw).isdigit()) else None
307
  settled = opened = 0
308
  for m in repo.list_matches():
309
  mid = m["id"]
310
+ w1, w2, wd = _sel(mid), _sel(mid, "mvpsel2"), _sel(mid, "dmvpsel")
311
+ cur = (m.get("mvp_user_id"), m.get("mvp2_user_id"), m.get("dmvp_user_id"))
312
+ if (w1, w2, wd) != cur:
313
+ repo.settle_mvp(mid, w1, w2, wd)
314
+ settled += 1 if (w1 or w2 or wd) else 0
315
+ opened += 1 if (w1 is None and w2 is None and wd is None) else 0
316
  bump()
317
  ui.notification_show(
318
  f"已结算 {settled} 场、重新开放 {opened} 场 🏅", type="message"
src/db.py CHANGED
@@ -59,6 +59,7 @@ def _run_light_migrations() -> None:
59
  ("matches", "mvp_locked", f"BOOLEAN NOT NULL DEFAULT {bool_false}"),
60
  ("matches", "mvp_user_id", "INTEGER"), # 可空:None=未结算
61
  ("matches", "mvp2_user_id", "INTEGER"), # 可空:双 MVP 第二人
 
62
  ]
63
  for table, col, ddl in wanted:
64
  if table not in existing:
 
59
  ("matches", "mvp_locked", f"BOOLEAN NOT NULL DEFAULT {bool_false}"),
60
  ("matches", "mvp_user_id", "INTEGER"), # 可空:None=未结算
61
  ("matches", "mvp2_user_id", "INTEGER"), # 可空:双 MVP 第二人
62
+ ("matches", "dmvp_user_id", "INTEGER"), # 可空:铁血防守 MVP
63
  ]
64
  for table, col, ddl in wanted:
65
  if table not in existing:
src/models.py CHANGED
@@ -64,6 +64,10 @@ class Match(Base):
64
  mvp2_user_id: Mapped[int | None] = mapped_column(
65
  ForeignKey("users.id"), nullable=True
66
  )
 
 
 
 
67
  created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
68
 
69
 
 
64
  mvp2_user_id: Mapped[int | None] = mapped_column(
65
  ForeignKey("users.id"), nullable=True
66
  )
67
+ # 铁血防守 MVP(同样由管理员结算指定;独立于最佳球员 MVP,可同场同人)
68
+ dmvp_user_id: Mapped[int | None] = mapped_column(
69
+ ForeignKey("users.id"), nullable=True
70
+ )
71
  created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
72
 
73
 
src/repo.py CHANGED
@@ -344,12 +344,16 @@ def player_rank(user_id: int) -> Optional[dict[str, Any]]:
344
  my_mvps = next(
345
  (r["mvps"] for r in mvp_ranking() if r["user_id"] == user_id), 0
346
  )
 
 
 
347
  return {
348
  **me,
349
  "goal_rank": goal_rank,
350
  "assist_rank": assist_rank,
351
  "n_players": len(agg),
352
  "mvps": my_mvps,
 
353
  }
354
 
355
 
@@ -411,12 +415,37 @@ def mvp_ranking(season: Optional[int] = None) -> list[dict[str, Any]]:
411
  return sorted(tally.values(), key=lambda x: (-x["mvps"], x["name"]))
412
 
413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  def settle_mvp(
415
- match_id: int, mvp_user_id: Optional[int], mvp2_user_id: Optional[int] = None
 
 
 
416
  ) -> None:
417
- """管理员结算某场 MVP:指定一或两名获得者并锁定投票;全传 None=取消结算重开。
418
 
419
- 数据不变式:至少一人=已结算锁定;两栏同一人自动去重;第一栏空则第二栏顶上。
 
420
  """
421
  if mvp_user_id is None and mvp2_user_id is not None:
422
  mvp_user_id, mvp2_user_id = mvp2_user_id, None
@@ -428,25 +457,30 @@ def settle_mvp(
428
  return
429
  m.mvp_user_id = mvp_user_id
430
  m.mvp2_user_id = mvp2_user_id
431
- m.mvp_locked = mvp_user_id is not None
 
432
 
433
 
434
  def settled_mvps() -> dict[int, dict[str, Any]]:
435
- """已结算场次的 MVP:{match_id: {"user_ids": [...], "names": [显示名,...]}}。
436
 
437
- 单 MVP 一个名字,双 MVP 两个;顺序与结算时一致。
438
  """
439
  with get_session() as s:
440
  rows = (
441
- s.query(Match.id, Match.mvp_user_id, Match.mvp2_user_id)
442
- .filter(Match.mvp_user_id.isnot(None))
443
  .all()
444
  )
445
  names = {u.id: u.display_name for u in s.query(User).all()}
446
  out: dict[int, dict[str, Any]] = {}
447
- for mid, u1, u2 in rows:
448
  ids = [u for u in (u1, u2) if u is not None]
449
- out[mid] = {"user_ids": ids, "names": [names.get(u, "?") for u in ids]}
 
 
 
 
450
  return out
451
 
452
 
 
344
  my_mvps = next(
345
  (r["mvps"] for r in mvp_ranking() if r["user_id"] == user_id), 0
346
  )
347
+ my_dmvps = next(
348
+ (r["dmvps"] for r in dmvp_ranking() if r["user_id"] == user_id), 0
349
+ )
350
  return {
351
  **me,
352
  "goal_rank": goal_rank,
353
  "assist_rank": assist_rank,
354
  "n_players": len(agg),
355
  "mvps": my_mvps,
356
+ "dmvps": my_dmvps,
357
  }
358
 
359
 
 
415
  return sorted(tally.values(), key=lambda x: (-x["mvps"], x["name"]))
416
 
417
 
418
+ def dmvp_ranking(season: Optional[int] = None) -> list[dict[str, Any]]:
419
+ """当季铁血防守 MVP 榜:按获得次数降序(每场由领队结算指定一人)。"""
420
+ with get_session() as s:
421
+ match_ids = _season_match_ids(s, season)
422
+ if not match_ids:
423
+ return []
424
+ rows = (
425
+ s.query(Match.dmvp_user_id, User.display_name, User.jersey_number)
426
+ .join(User, Match.dmvp_user_id == User.id)
427
+ .filter(Match.id.in_(match_ids), Match.dmvp_user_id.isnot(None))
428
+ .all()
429
+ )
430
+ tally: dict[int, dict[str, Any]] = {}
431
+ for uid, name, jersey in rows:
432
+ t = tally.setdefault(
433
+ uid, {"user_id": uid, "name": name, "jersey": jersey, "dmvps": 0}
434
+ )
435
+ t["dmvps"] += 1
436
+ return sorted(tally.values(), key=lambda x: (-x["dmvps"], x["name"]))
437
+
438
+
439
  def settle_mvp(
440
+ match_id: int,
441
+ mvp_user_id: Optional[int],
442
+ mvp2_user_id: Optional[int] = None,
443
+ dmvp_user_id: Optional[int] = None,
444
  ) -> None:
445
+ """管理员结算某场:最佳球员 MVP(可双)+ 铁血防守 MVP;全传 None=取消结算重开。
446
 
447
+ 数据不变式:任一奖项有人=已结算锁票;最佳球员两栏同一人自动去重、第一栏空则
448
+ 第二栏顶上;防守 MVP 独立评选,可与最佳球员同场同人。
449
  """
450
  if mvp_user_id is None and mvp2_user_id is not None:
451
  mvp_user_id, mvp2_user_id = mvp2_user_id, None
 
457
  return
458
  m.mvp_user_id = mvp_user_id
459
  m.mvp2_user_id = mvp2_user_id
460
+ m.dmvp_user_id = dmvp_user_id
461
+ m.mvp_locked = mvp_user_id is not None or dmvp_user_id is not None
462
 
463
 
464
  def settled_mvps() -> dict[int, dict[str, Any]]:
465
+ """已结算场次的奖项:{match_id: {"user_ids": [...], "names": [...], "dmvp_name": str|None}}。
466
 
467
+ names 为最佳球员 MVP(单/双);dmvp_name 为铁血防守 MVP(可空)。
468
  """
469
  with get_session() as s:
470
  rows = (
471
+ s.query(Match.id, Match.mvp_user_id, Match.mvp2_user_id, Match.dmvp_user_id)
472
+ .filter((Match.mvp_user_id.isnot(None)) | (Match.dmvp_user_id.isnot(None)))
473
  .all()
474
  )
475
  names = {u.id: u.display_name for u in s.query(User).all()}
476
  out: dict[int, dict[str, Any]] = {}
477
+ for mid, u1, u2, ud in rows:
478
  ids = [u for u in (u1, u2) if u is not None]
479
+ out[mid] = {
480
+ "user_ids": ids,
481
+ "names": [names.get(u, "?") for u in ids],
482
+ "dmvp_name": names.get(ud) if ud is not None else None,
483
+ }
484
  return out
485
 
486
 
src/repo_base.py CHANGED
@@ -33,6 +33,7 @@ def _match_dict(m: Match) -> dict[str, Any]:
33
  "mvp_locked": bool(getattr(m, "mvp_locked", False)),
34
  "mvp_user_id": getattr(m, "mvp_user_id", None),
35
  "mvp2_user_id": getattr(m, "mvp2_user_id", None),
 
36
  }
37
 
38
 
 
33
  "mvp_locked": bool(getattr(m, "mvp_locked", False)),
34
  "mvp_user_id": getattr(m, "mvp_user_id", None),
35
  "mvp2_user_id": getattr(m, "mvp2_user_id", None),
36
+ "dmvp_user_id": getattr(m, "dmvp_user_id", None),
37
  }
38
 
39
 
src/theme.py CHANGED
@@ -230,6 +230,17 @@ body{
230
  box-shadow:0 14px 34px -18px rgba(31,169,113,.45), inset 0 1px 0 rgba(255,255,255,.08)}
231
  .sde-hero.c-green .big .n{background:linear-gradient(180deg,#c9f5e2,var(--green));-webkit-background-clip:text;background-clip:text}
232
  .sde-hero.c-green .title{color:var(--green)}
 
 
 
 
 
 
 
 
 
 
 
233
  .sde-board .sde-empty{padding:18px}
234
 
235
  /* ================= 每场比分 · 战报记分牌 ================= */
@@ -278,6 +289,8 @@ body{
278
  .sde-chip.assist{border-color:rgba(61,165,217,.4);background:rgba(61,165,217,.10)}
279
  .sde-chip.mvp{border-color:rgba(212,175,55,.65);background:linear-gradient(90deg,rgba(212,175,55,.22),rgba(212,175,55,.08));
280
  color:#ffe9a8;font-weight:800;box-shadow:0 0 14px -4px rgba(212,175,55,.7)}
 
 
281
  @media(max-width:560px){
282
  .sde-sc-score .n{font-size:30px}
283
  .sde-hero .big .n{font-size:34px}
 
230
  box-shadow:0 14px 34px -18px rgba(31,169,113,.45), inset 0 1px 0 rgba(255,255,255,.08)}
231
  .sde-hero.c-green .big .n{background:linear-gradient(180deg,#c9f5e2,var(--green));-webkit-background-clip:text;background-clip:text}
232
  .sde-hero.c-green .title{color:var(--green)}
233
+ .sde-hero.c-red{border-color:rgba(226,56,75,.55);
234
+ background:radial-gradient(120% 150% at 100% 0%, rgba(200,16,46,.30), transparent 55%),
235
+ linear-gradient(135deg, rgba(200,16,46,.17), rgba(17,37,74,.9) 58%);
236
+ box-shadow:0 14px 34px -18px rgba(200,16,46,.5), inset 0 1px 0 rgba(255,255,255,.08)}
237
+ .sde-hero.c-red .big .n{background:linear-gradient(180deg,#ffd2d8,var(--red-2));-webkit-background-clip:text;background-clip:text;
238
+ filter:drop-shadow(0 4px 14px rgba(200,16,46,.5))}
239
+ .sde-hero.c-red .title{color:var(--red-2)}
240
+ .sde-hero.c-red .crown{filter:drop-shadow(0 3px 8px rgba(200,16,46,.6))}
241
+ .sde-brow.c-red .bar{background:linear-gradient(90deg,var(--red-2),rgba(200,16,46,.15))}
242
+ .sde-brow.c-red .val{color:#ff9fab}
243
+ .sde-brow.c-red.r2,.sde-brow.c-red.r3{border-color:rgba(226,56,75,.30)}
244
  .sde-board .sde-empty{padding:18px}
245
 
246
  /* ================= 每场比分 · 战报记分牌 ================= */
 
289
  .sde-chip.assist{border-color:rgba(61,165,217,.4);background:rgba(61,165,217,.10)}
290
  .sde-chip.mvp{border-color:rgba(212,175,55,.65);background:linear-gradient(90deg,rgba(212,175,55,.22),rgba(212,175,55,.08));
291
  color:#ffe9a8;font-weight:800;box-shadow:0 0 14px -4px rgba(212,175,55,.7)}
292
+ .sde-chip.dmvp{border-color:rgba(226,56,75,.6);background:linear-gradient(90deg,rgba(200,16,46,.24),rgba(200,16,46,.08));
293
+ color:#ffc3cb;font-weight:800;box-shadow:0 0 14px -4px rgba(200,16,46,.7)}
294
  @media(max-width:560px){
295
  .sde-sc-score .n{font-size:30px}
296
  .sde-hero .big .n{font-size:34px}
src/view_admin.py CHANGED
@@ -39,8 +39,9 @@ def _admin_panel(user_choices: dict[str, str]) -> ui.Tag:
39
  mvp_settle = theme.card(
40
  "结算本场 MVP",
41
  ui.div(
42
- "由领队为每场选定 MVP(队员投票数仅作参考),支持双 MVP:选一人即单 MVP,"
43
- "再选第二人即双 MVP(两人各计一次)。全部改回「— 未结算 —」并保存可重新开放投票。",
 
44
  class_="sde-sub",
45
  ),
46
  ui.output_ui("admin_mvp_settle"),
@@ -143,38 +144,40 @@ def admin_mvp_settle_view(
143
  vote_summary: dict[int, dict[str, Any]],
144
  player_choices: dict[str, str],
145
  ) -> ui.Tag:
146
- """每场 MVP 结算表:投票参考 + 双 MVP 下拉(id 为 mvpsel_{mid} / mvpsel2_{mid})。
147
 
148
- 两个下拉都含「— 未结算 —」(none);至少选一人=结算锁定,全 none=重新开放。
149
  """
150
  if not matches:
151
  return ui.div("还没有比赛,请先在上方添加。", class_="sde-empty")
152
  choices = {"none": "— 未结算 —", **player_choices}
153
  head = ui.tags.tr(
154
- *[ui.tags.th(h) for h in ["日期", "对手", "投票参考(票数最高)", "MVP ①", "MVP ②(可选)"]]
 
155
  )
156
  body = []
157
  for m in matches:
158
  mid = m["id"]
159
  win = vote_summary.get(mid)
160
  leader = f'{"、".join(win["names"])}({win["votes"]} 票)' if win else "暂无投票"
161
- sel1 = str(m.get("mvp_user_id") or "none")
162
- sel2 = str(m.get("mvp2_user_id") or "none")
163
- if sel1 not in choices:
164
- sel1 = "none"
165
- if sel2 not in choices:
166
- sel2 = "none"
 
 
 
 
 
 
167
  body.append(
168
  ui.tags.tr(
169
  ui.tags.td(m["date"]),
170
  ui.tags.td(m["opponent"]),
171
  ui.tags.td(leader),
172
- ui.tags.td(
173
- ui.input_select(f"mvpsel_{mid}", "", choices, selected=sel1, width="170px")
174
- ),
175
- ui.tags.td(
176
- ui.input_select(f"mvpsel2_{mid}", "", choices, selected=sel2, width="170px")
177
- ),
178
  )
179
  )
180
  return ui.div(
 
39
  mvp_settle = theme.card(
40
  "结算本场 MVP",
41
  ui.div(
42
+ "由领队为每场选定「最佳球员 MVP」(可双:两人各计一次)与「铁血防守 MVP」"
43
+ "(队员投票数仅作参考)。任一奖项有人即结算锁票;全部改回「— 未结算 —」"
44
+ "并保存可重新开放投票。",
45
  class_="sde-sub",
46
  ),
47
  ui.output_ui("admin_mvp_settle"),
 
144
  vote_summary: dict[int, dict[str, Any]],
145
  player_choices: dict[str, str],
146
  ) -> ui.Tag:
147
+ """每场结算表:投票参考 + 最佳球员双下拉 + 铁血防守下拉。
148
 
149
+ id:mvpsel_{mid} / mvpsel2_{mid} / dmvpsel_{mid};任一有人=结算锁定,全 none=重开。
150
  """
151
  if not matches:
152
  return ui.div("还没有比赛,请先在上方添加。", class_="sde-empty")
153
  choices = {"none": "— 未结算 —", **player_choices}
154
  head = ui.tags.tr(
155
+ *[ui.tags.th(h) for h in
156
+ ["日期", "对手", "投票参考", "最佳球员 ①", "最佳球员 ②(可选)", "🛡️ 铁血防守"]]
157
  )
158
  body = []
159
  for m in matches:
160
  mid = m["id"]
161
  win = vote_summary.get(mid)
162
  leader = f'{"、".join(win["names"])}({win["votes"]} 票)' if win else "暂无投票"
163
+ sels = []
164
+ for key, field in (
165
+ (f"mvpsel_{mid}", "mvp_user_id"),
166
+ (f"mvpsel2_{mid}", "mvp2_user_id"),
167
+ (f"dmvpsel_{mid}", "dmvp_user_id"),
168
+ ):
169
+ sel = str(m.get(field) or "none")
170
+ if sel not in choices:
171
+ sel = "none"
172
+ sels.append(
173
+ ui.tags.td(ui.input_select(key, "", choices, selected=sel, width="150px"))
174
+ )
175
  body.append(
176
  ui.tags.tr(
177
  ui.tags.td(m["date"]),
178
  ui.tags.td(m["opponent"]),
179
  ui.tags.td(leader),
180
+ *sels,
 
 
 
 
 
181
  )
182
  )
183
  return ui.div(
src/view_home.py CHANGED
@@ -17,6 +17,7 @@ def home_view(
17
  contributions: dict[int, dict[str, list[str]]],
18
  mvp: list[dict[str, Any]],
19
  mvp_summary: Optional[dict[int, dict[str, Any]]] = None,
 
20
  ) -> ui.Tag:
21
  stats = ui.div(
22
  theme.stat(summary["played"], "场次", accent="cream"),
@@ -51,12 +52,16 @@ def home_view(
51
  attend, "全勤铁人", lambda r: r["attended"], "场", "c-green",
52
  lambda r: f'共 {r["total"]} 场 · 进球+助攻 {r["ga"]}', me_id,
53
  ), icon="📋"),
54
- theme.card("MVP 榜", _impact_board(
55
- mvp, "MVP 之王", lambda r: r["mvps"], "次", "",
56
  None, me_id,
57
  ), icon="🏅", sub="每场由领队结算 · 计获得次数"),
 
 
 
 
58
  ]
59
- widths = [6, 6, 6, 6]
60
  if keepers: # 有门将才显示扑救榜
61
  board_cards.append(
62
  theme.card("扑救榜", _impact_board(
@@ -64,7 +69,7 @@ def home_view(
64
  lambda r: f'出场 {r["attended"]}/{r["total"]}', me_id,
65
  ), icon="🧤", sub="仅门将")
66
  )
67
- widths = [6, 6, 6, 6, 6]
68
  boards = ui.layout_columns(*board_cards, col_widths=widths, fill=False)
69
  return ui.div(
70
  theme.card("球队战绩", stats, icon="🏆"),
@@ -97,11 +102,17 @@ def _scorecard(
97
  """一场比赛的战报卡:结果色边+光晕、居中大比分、结果大徽章、明细芯片。"""
98
  res = m["result"]
99
  chips: list[Any] = []
100
- if m.get("mvp_locked") and mvp and mvp.get("names"):
101
- chips.append(
102
- ui.span(ui.span("🏅", class_="ic"), "MVP " + "、".join(mvp["names"]),
103
- class_="sde-chip mvp")
104
- )
 
 
 
 
 
 
105
  for who in c.get("goals", []):
106
  chips.append(ui.span(ui.span("⚽", class_="ic"), who, class_="sde-chip goal"))
107
  for who in c.get("assists", []):
 
17
  contributions: dict[int, dict[str, list[str]]],
18
  mvp: list[dict[str, Any]],
19
  mvp_summary: Optional[dict[int, dict[str, Any]]] = None,
20
+ dmvp: Optional[list[dict[str, Any]]] = None,
21
  ) -> ui.Tag:
22
  stats = ui.div(
23
  theme.stat(summary["played"], "场次", accent="cream"),
 
52
  attend, "全勤铁人", lambda r: r["attended"], "场", "c-green",
53
  lambda r: f'共 {r["total"]} 场 · 进球+助攻 {r["ga"]}', me_id,
54
  ), icon="📋"),
55
+ theme.card("最佳球员 MVP", _impact_board(
56
+ mvp, "最佳球员", lambda r: r["mvps"], "次", "",
57
  None, me_id,
58
  ), icon="🏅", sub="每场由领队结算 · 计获得次数"),
59
+ theme.card("铁血防守 MVP", _impact_board(
60
+ dmvp or [], "铁血防守", lambda r: r["dmvps"], "次", "c-red",
61
+ None, me_id,
62
+ ), icon="🛡️", sub="每场由领队结算 · 计获得次数"),
63
  ]
64
+ widths = [6, 6, 6, 6, 6]
65
  if keepers: # 有门将才显示扑救榜
66
  board_cards.append(
67
  theme.card("扑救榜", _impact_board(
 
69
  lambda r: f'出场 {r["attended"]}/{r["total"]}', me_id,
70
  ), icon="🧤", sub="仅门将")
71
  )
72
+ widths = [6, 6, 6, 6, 6, 6]
73
  boards = ui.layout_columns(*board_cards, col_widths=widths, fill=False)
74
  return ui.div(
75
  theme.card("球队战绩", stats, icon="🏆"),
 
102
  """一场比赛的战报卡:结果色边+光晕、居中大比分、结果大徽章、明细芯片。"""
103
  res = m["result"]
104
  chips: list[Any] = []
105
+ if m.get("mvp_locked") and mvp:
106
+ if mvp.get("names"):
107
+ chips.append(
108
+ ui.span(ui.span("🏅", class_="ic"), "最佳球员 " + "、".join(mvp["names"]),
109
+ class_="sde-chip mvp")
110
+ )
111
+ if mvp.get("dmvp_name"):
112
+ chips.append(
113
+ ui.span(ui.span("🛡️", class_="ic"), f'铁血防守 {mvp["dmvp_name"]}',
114
+ class_="sde-chip dmvp")
115
+ )
116
  for who in c.get("goals", []):
117
  chips.append(ui.span(ui.span("⚽", class_="ic"), who, class_="sde-chip goal"))
118
  for who in c.get("assists", []):
src/view_me.py CHANGED
@@ -61,7 +61,8 @@ def my_totals_view(rank: Optional[dict[str, Any]]) -> ui.Tag:
61
  ),
62
  theme.stat(rank["ga"], "进球+助攻", accent="cream"),
63
  theme.stat(f'{rank["attended"]}/{rank["total"]}', "出场", accent="green"),
64
- theme.stat(rank.get("mvps", 0), "MVP", "领队结算", accent="gold"),
 
65
  ]
66
  if rank.get("is_goalkeeper"):
67
  items.append(theme.stat(rank.get("saves", 0), "我的扑救", "门将", accent="blue"))
@@ -197,11 +198,12 @@ def my_mvp_form_view(
197
  mid = m["id"]
198
  if m.get("mvp_locked"):
199
  win = settled.get(mid)
200
- text = (
201
- f'🏅 本场 MVP:{"、".join(win["names"])}(领队已结算)'
202
- if win and win.get("names")
203
- else "本场 MVP 待定(投票已锁定)"
204
- )
 
205
  ctl = ui.div(ui.span(text, class_="sde-mvp-settled"), class_="sde-ctl-row")
206
  else:
207
  sel = str(my_votes_map.get(mid, "none"))
 
61
  ),
62
  theme.stat(rank["ga"], "进球+助攻", accent="cream"),
63
  theme.stat(f'{rank["attended"]}/{rank["total"]}', "出场", accent="green"),
64
+ theme.stat(rank.get("mvps", 0), "最佳球员 MVP", "领队结算", accent="gold"),
65
+ theme.stat(rank.get("dmvps", 0), "铁血防守 MVP", "领队结算", accent="red"),
66
  ]
67
  if rank.get("is_goalkeeper"):
68
  items.append(theme.stat(rank.get("saves", 0), "我的扑救", "门将", accent="blue"))
 
198
  mid = m["id"]
199
  if m.get("mvp_locked"):
200
  win = settled.get(mid)
201
+ parts = []
202
+ if win and win.get("names"):
203
+ parts.append(f'🏅 最佳球员:{"、".join(win["names"])}')
204
+ if win and win.get("dmvp_name"):
205
+ parts.append(f'🛡️ 铁血防守:{win["dmvp_name"]}')
206
+ text = (" · ".join(parts) + "(领队已结算)") if parts else "本场 MVP 待定(投票已锁定)"
207
  ctl = ui.div(ui.span(text, class_="sde-mvp-settled"), class_="sde-ctl-row")
208
  else:
209
  sel = str(my_votes_map.get(mid, "none"))