File size: 19,571 Bytes
6e876c1
 
 
 
 
 
 
 
 
 
 
c140aff
6e876c1
 
 
 
 
c140aff
6e876c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e79a549
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c140aff
 
 
 
 
 
78a4939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30c9067
7bd5748
 
 
78a4939
30c9067
 
7bd5748
c140aff
30c9067
 
49944c9
c140aff
 
 
 
 
 
 
 
 
78a4939
7bd5748
c140aff
7bd5748
c140aff
7bd5748
 
 
c140aff
7bd5748
 
c140aff
7bd5748
 
 
78a4939
7bd5748
c140aff
78a4939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c140aff
 
30c9067
 
 
 
 
 
7bd5748
 
30c9067
 
 
 
7bd5748
 
6e876c1
e79a549
6e876c1
 
e79a549
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837a707
 
e79a549
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e876c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837a707
6e876c1
 
837a707
6e876c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837a707
 
 
 
 
 
 
 
 
6e876c1
 
 
 
 
 
 
 
 
 
 
 
 
4bc82b0
6e876c1
384e6b6
 
4bc82b0
384e6b6
4bc82b0
6e876c1
 
 
 
 
 
 
 
 
384e6b6
4bc82b0
23938a8
 
 
 
 
 
384e6b6
 
 
 
 
 
 
 
 
 
 
6e876c1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
"""我的数据视图:总览、进球/助攻趋势图、逐场登记、MVP 投票、改密面板。

约定:触发动作的按钮放在 _my_panel 静态区(每次登录构建一次),id 稳定;
仅「带初值的输入框」放进随数据刷新的 output_ui。
"""
from __future__ import annotations

from typing import Any, Optional

from shiny import ui

from . import config, theme
from .view_common import _match_top, _name_with_jersey


def _my_panel() -> ui.Tag:
    return ui.div(
        ui.output_ui("my_fifa_card"),
        ui.output_ui("my_totals"),
        ui.output_ui("my_trend"),
        theme.card(
            "逐场登记",
            ui.div(
                "默认每场都算你出勤。某场没上场就关掉「出勤」开关;"
                "进了球 / 有助攻就填上数字,然后点保存。",
                class_="sde-sub",
            ),
            ui.output_ui("my_form"),
            ui.input_action_button("btn_save_stats", "💾 保存我的数据", class_="btn-primary"),
            icon="📝",
        ),
        theme.card(
            "评选本场 MVP",
            ui.div("为每场比赛投出你心中的 MVP(可改、可不投;不能投自己)。", class_="sde-sub"),
            ui.output_ui("my_mvp_form"),
            ui.input_action_button("btn_save_mvp", "🏅 保存我的投票", class_="btn-primary"),
            icon="🗳️",
        ),
        theme.card(
            "修改密码",
            ui.div("首次登录请尽快修改初始密码。新密码至少 6 位。", class_="sde-sub"),
            ui.layout_columns(
                ui.input_password("cp_old", "当前密码"),
                ui.input_password("cp_new", "新密码"),
                ui.input_password("cp_new2", "确认新密码"),
                col_widths=[4, 4, 4],
                fill=False,
            ),
            ui.input_action_button("btn_change_pw", "🔑 修改密码", class_="btn-primary"),
            icon="🔒",
        ),
    )


def _earned_badges(rank: dict[str, Any]) -> list[ui.Tag]:
    """按数据自动点亮称号徽章:队内第一/荣誉/里程碑。"""
    b: list[ui.Tag] = []
    if rank["goals"] > 0 and rank["goal_rank"] == 1:
        b.append(ui.span("⚽ 队内射手王", class_="sde-badge"))
    if rank["assists"] > 0 and rank["assist_rank"] == 1:
        b.append(ui.span("🎯 队内助攻王", class_="sde-badge b-blue"))
    if rank.get("mvps", 0) > 0:
        b.append(ui.span(f'🏅 最佳球员 ×{rank["mvps"]}', class_="sde-badge"))
    if rank.get("dmvps", 0) > 0:
        b.append(ui.span(f'🛡️ 铁血防守 ×{rank["dmvps"]}', class_="sde-badge b-red"))
    if rank["total"] > 0 and rank["attended"] == rank["total"]:
        b.append(ui.span("📋 全勤铁人", class_="sde-badge b-green"))
    if rank.get("is_goalkeeper") and rank.get("saves", 0) > 0:
        b.append(ui.span(f'🧤 扑救 ×{rank["saves"]}', class_="sde-badge b-blue"))
    if rank["ga"] >= 10:
        b.append(ui.span("🔥 进攻核心 · 参与10+球", class_="sde-badge b-red"))
    elif rank["ga"] >= 5:
        b.append(ui.span("⚡ 关键先生 · 参与5+球", class_="sde-badge"))
    return b


def _motivation(rank: dict[str, Any]) -> tuple[str, str]:
    """按数据生成一句教练寄语(标题, 正文)。规则从上到下取第一条命中。"""
    n = rank["n_players"]
    if rank.get("mvps", 0) >= 2:
        return "球队的招牌 🌟", (
            f'{rank["mvps"]} 座最佳球员奖杯说明了一切——你就是肆带贰的门面。'
            "保持状态,队史会记住你的名字。"
        )
    if rank["goals"] > 0 and rank["goal_rank"] == 1:
        return "锋线杀神 🔥", (
            f'{rank["goals"]} 粒进球领跑全队!对手教练布置战术时,'
            "白板上圈出来的那个号码就是你。继续轰门!"
        )
    if rank["assists"] > 0 and rank["assist_rank"] == 1:
        return "中场大脑 🧠", (
            f'{rank["assists"]} 次助攻全队第一。队友们的进球集锦里,'
            "一半镜头都该给传出致命一传的你。"
        )
    if rank.get("dmvps", 0) > 0:
        return "后防铁闸 🛡️", (
            f'{rank["dmvps"]} 次铁血防守认证——前锋们敢内切,是因为知道身后有你。'
            "防守赢得冠军!"
        )
    if rank.get("is_goalkeeper") and rank.get("saves", 0) > 0:
        return "门线守护神 🧤", (
            f'{rank["saves"]} 次扑救,每一次都是白捡的净胜球。'
            "手套已经热了,下一场继续封门!"
        )
    if rank["total"] > 0 and rank["attended"] == rank["total"]:
        return "全勤铁人 💪", (
            "一场不落!天赋会迟到,但出勤从不缺席——球队最稳的一块拼图就是你。"
        )
    if rank["ga"] > 0:
        return "稳步上分 📈", (
            f'已参与 {rank["ga"]} 粒进球,射手榜第 {rank["goal_rank"]}/{n}。'
            "距离前排只差几脚发力,下一场就是你的破纪录之夜。"
        )
    return "登场即主角 🚀", (
        "数据栏还空着?那是留给你书写的。下一场比赛,从第一粒进球、"
        "第一次助攻开始——全队都在等你的高光时刻。"
    )


_FIFA_LABELS = {
    "SHO": "射术", "PAS": "传球", "STA": "体能",
    "HON": "荣誉", "DEF": "防守", "IMP": "影响",
}


def _card_quote(card: dict[str, Any]) -> tuple[str, str]:
    """球星卡背面寄语(标题, 正文):按卡面数据画像,从上到下取第一条命中。"""
    if card.get("mvps", 0) >= 2:
        return "球队的招牌 🌟", (
            f'{card["mvps"]} 座最佳球员奖杯说明了一切——你就是肆带贰的门面。'
        )
    if card.get("mvps", 0) == 1:
        return "高光时刻 🏅", "最佳球员奖杯已入账,状态正盛——下一座已经在路上。"
    if card.get("dmvps", 0) > 0:
        return "后防铁闸 🛡️", (
            f'{card["dmvps"]} 次铁血防守认证——前锋们敢内切,是因为身后有你。'
        )
    if card.get("is_goalkeeper") and card.get("saves", 0) > 0:
        return "门线守护神 🧤", f'{card["saves"]} 次扑救,每一次都是白捡的净胜球。'
    if card.get("goals", 0) >= 3:
        return "锋线杀神 🔥", f'{card["goals"]} 粒进球在手,对手后卫看到你就头疼。'
    if card.get("assists", 0) >= 3:
        return "中场大脑 🧠", f'{card["assists"]} 次助攻,队友的进球集锦里全是你的传球。'
    if card.get("total", 0) > 0 and card.get("attended") == card.get("total"):
        return "全勤铁人 💪", "一场不落!球队最稳的一块拼图就是你。"
    if card.get("ga", 0) > 0:
        return "稳步上分 📈", f'已参与 {card["ga"]} 粒进球,下一场就是爆发之夜。'
    return "登场即主角 🚀", "数据栏还空着?那是留给你书写的。"


def _fifa_back_tag(card: dict[str, Any], tier: str) -> ui.Tag:
    """球星卡背面:真实数据 2×2 + 出场 + 寄语(与正面同档配色)。"""
    qt, qb = _card_quote(card)
    cells = [
        ("⚽", "进球", card.get("goals", 0)),
        ("🅰️", "助攻", card.get("assists", 0)),
        ("🏅", "最佳球员", card.get("mvps", 0)),
        ("🛡️", "防守MVP", card.get("dmvps", 0)),
    ]
    if card.get("is_goalkeeper"):
        cells.append(("🧤", "扑救", card.get("saves", 0)))
    grid = [
        ui.div(
            ui.span(f"{ic} {lb}", class_="bk"),
            ui.span(str(v), class_="bn"),
            class_="bst",
        )
        for ic, lb, v in cells
    ]
    return ui.div(
        ui.div(card["name"], class_="bname"),
        ui.div(f'出场 {card.get("attended", 0)}/{card.get("total", 0)}', class_="bapps"),
        ui.div(*grid, class_="bgrid"),
        ui.div(ui.div(qt, class_="qt"), ui.div(qb, class_="qb"), class_="bquote"),
        ui.div("点击翻回 ↩", class_="bflip"),
        class_=f"sde-fifa sde-fifa-back {tier}",
    )


def fifa_card_tag(card: dict[str, Any], is_me: bool = False,
                  flip_prefix: str = "", diamond: bool = False) -> ui.Tag:
    """渲染一张 FIFA 卡(供个人页与主页卡墙复用)。

    档位:OVR≥92 传奇紫 / ≥80 金 / ≥68 银 / 其余铜。
    flip_prefix 非空时启用点击 3D 翻面(checkbox+label,零 JS),背面为真实数据+寄语。
    diamond=True 时套用「钻石」专属皮肤(纯外观,仅个人页本人触发)——OVR 数字与
    六维数据完全不变,只换配色/边框,不影响任何评分与榜单。
    """
    ovr = card["ovr"]
    tier = ("diamond" if diamond else
            "legend" if ovr >= 88 else "gold" if ovr >= 74
            else "silver" if ovr >= 66 else "bronze")
    order = ["SHO", "PAS", "STA", "HON", "DEF", "IMP"]
    grid = [
        ui.div(
            ui.span(str(card["stats"][k]), class_="n"),
            ui.span(_FIFA_LABELS[k] + " " + k, class_="k"),
            class_="st",
        )
        for k in order
    ]
    front = ui.div(
        ui.div(class_="halo"),
        ui.div(
            ui.div(str(ovr), class_="ovr"),
            ui.div(
                ui.div(card["pos"], class_="pos"),
                (ui.div(f'NO.{card["jersey"]}', class_="jer") if card.get("jersey") else None),
                class_="meta",
            ),
            ui.div(ui.img(src=config.CREST_URL), class_="crest-s"),
            class_="top",
        ),
        ui.div(card["name"], class_="fname"),
        ui.div(*grid, class_="grid"),
        ui.div("SI DAI ER FC · 肆带贰", class_="club"),
        (ui.div("点击查看战绩 ↻", class_="fflip") if flip_prefix else None),
        class_=f"sde-fifa {tier}" + (" is-me-card" if is_me else ""),
    )
    if not flip_prefix:
        return front
    cb_id = f"{flip_prefix}_{card['user_id']}"
    return ui.div(
        ui.tags.input(type="checkbox", id=cb_id, class_="sde-flip-cb"),
        ui.tags.label(
            ui.div(
                ui.div(front, class_="face front"),
                ui.div(_fifa_back_tag(card, tier), class_="face back"),
                class_="sde-flipper",
            ),
            **{"for": cb_id},
            class_="sde-flip",
        ),
        class_="sde-flip-box",
    )


def my_fifa_card_view(card: Optional[dict[str, Any]],
                      diamond: bool = False) -> ui.Tag:
    """个人页顶部的大尺寸 FIFA 卡(可翻面)。

    diamond=True 时本人的卡显示为钻石皮肤(专属外观,不改数据)。
    """
    if not card:
        return ui.div()
    return ui.div(
        fifa_card_tag(card, flip_prefix="flipme", diamond=diamond),
        class_="sde-fifa-wrap",
    )


def my_totals_view(rank: Optional[dict[str, Any]]) -> ui.Tag:
    """我的主场:球员卡 Hero——背号水印 + 大名 + 称号徽章 + 教练寄语 + 荣誉瓦片。"""
    if not rank:
        return ui.div()
    n = rank["n_players"]
    qt, qb = _motivation(rank)
    tiles = [
        ui.div(ui.div(str(rank["goals"]), class_="v"), ui.div("进球", class_="l"),
               ui.div(f'队内第 {rank["goal_rank"]}/{n}', class_="r"), class_="sde-ptile"),
        ui.div(ui.div(str(rank["assists"]), class_="v"), ui.div("助攻", class_="l"),
               ui.div(f'队内第 {rank["assist_rank"]}/{n}', class_="r"), class_="sde-ptile t-blue"),
        ui.div(ui.div(str(rank["ga"]), class_="v"), ui.div("参与进球", class_="l"),
               ui.div("进球+助攻", class_="r"), class_="sde-ptile t-cream"),
        ui.div(ui.div(f'{rank["attended"]}/{rank["total"]}', class_="v"), ui.div("出场", class_="l"),
               ui.div("出勤率 %d%%" % round(100 * rank["attended"] / rank["total"]) if rank["total"] else "—",
                      class_="r"), class_="sde-ptile t-green"),
        ui.div(ui.div(str(rank.get("mvps", 0)), class_="v"), ui.div("最佳球员 MVP", class_="l"),
               ui.div("领队结算", class_="r"), class_="sde-ptile"),
        ui.div(ui.div(str(rank.get("dmvps", 0)), class_="v"), ui.div("铁血防守 MVP", class_="l"),
               ui.div("领队结算", class_="r"), class_="sde-ptile t-red"),
    ]
    if rank.get("is_goalkeeper"):
        tiles.append(
            ui.div(ui.div(str(rank.get("saves", 0)), class_="v"), ui.div("扑救", class_="l"),
                   ui.div("门将", class_="r"), class_="sde-ptile t-blue")
        )
    badges = _earned_badges(rank)
    return ui.div(
        ui.div(f'#{rank["jersey"]}' if rank.get("jersey") else "SDE", class_="wm"),
        ui.div("MY ARENA · 我的主场", class_="kicker"),
        ui.div(rank["name"], class_="nm"),
        ui.div(
            ("GOALKEEPER · 门将" if rank.get("is_goalkeeper") else "PLAYER · 球员")
            + (f' · NO.{rank["jersey"]}' if rank.get("jersey") else ""),
            class_="rl",
        ),
        (ui.div(*badges, class_="sde-badges") if badges else None),
        ui.div(ui.div(qt, class_="h"), ui.div(qb, class_="b"), class_="sde-quote"),
        ui.div(*tiles, class_="sde-ptiles"),
        class_="sde-pcard",
    )


def trend_svg(matchlog: list[dict[str, Any]]) -> ui.Tag:
    """手写 SVG 柱状趋势:每场两根柱(进球=金、助攻=蓝),按时间正序。无外部依赖。"""
    rows = list(reversed(matchlog))  # player_matchlog 是日期倒序,这里转正序
    if not rows:
        return ui.div("还没有比赛数据,等领队录入比分后即可看趋势。", class_="sde-empty")
    maxv = max([1] + [r["goals"] for r in rows] + [r["assists"] for r in rows])
    slot, barw, gap = 56, 15, 7
    pad_l = pad_r = 10
    top, bottom, h = 20, 28, 176
    area = h - top - bottom
    y0 = top + area
    w = pad_l + pad_r + slot * len(rows)
    parts: list[str] = [
        f'<line x1="{pad_l}" y1="{y0}" x2="{w - pad_r}" y2="{y0}" '
        f'stroke="rgba(255,255,255,.18)" stroke-width="1"/>'
    ]
    for i, r in enumerate(rows):
        cx = pad_l + slot * i + slot / 2
        gx, ax = cx - barw - gap / 2, cx + gap / 2
        gh, ah = r["goals"] / maxv * area, r["assists"] / maxv * area
        parts.append(
            f'<rect x="{gx:.1f}" y="{y0 - gh:.1f}" width="{barw}" height="{gh:.1f}" rx="3" fill="#EBCB6B"/>'
        )
        if r["goals"]:
            parts.append(
                f'<text x="{gx + barw / 2:.1f}" y="{y0 - gh - 4:.1f}" text-anchor="middle" '
                f'font-size="10" fill="#EBCB6B">{r["goals"]}</text>'
            )
        parts.append(
            f'<rect x="{ax:.1f}" y="{y0 - ah:.1f}" width="{barw}" height="{ah:.1f}" rx="3" fill="#3DA5D9"/>'
        )
        if r["assists"]:
            parts.append(
                f'<text x="{ax + barw / 2:.1f}" y="{y0 - ah - 4:.1f}" text-anchor="middle" '
                f'font-size="10" fill="#7fd0f0">{r["assists"]}</text>'
            )
        label = r["date"][5:] if len(r["date"]) >= 10 else r["date"]
        parts.append(
            f'<text x="{cx:.1f}" y="{y0 + 16:.1f}" text-anchor="middle" '
            f'font-size="10" fill="#9DB0D0">{label}</text>'
        )
    svg = (
        f'<svg viewBox="0 0 {w} {h}" preserveAspectRatio="xMinYMid meet" '
        f'style="width:100%;max-width:{w}px;height:auto;overflow:visible">'
        + "".join(parts)
        + "</svg>"
    )
    legend = ui.div(
        ui.span(ui.span(class_="sde-dot-g"), "进球"),
        ui.span(ui.span(class_="sde-dot-a"), "助攻"),
        class_="sde-legend",
    )
    return ui.div(legend, ui.div(ui.HTML(svg), class_="sde-trend-wrap"), class_="sde-trend")


def my_trend_view(matchlog: list[dict[str, Any]]) -> ui.Tag:
    return theme.card("我的进球/助攻趋势", trend_svg(matchlog), icon="📈", sub="按时间正序")


def my_form_view(matchlog: list[dict[str, Any]], is_gk: bool = False) -> ui.Tag:
    """逐场登记:每场一张卡片,出勤/进球/助攻控件 flex 换行,手机端不溢出。

    输入框 id(att_/g_/a_)保持不变;门将额外多一个扑救栏(sv_)。
    """
    if not matchlog:
        return ui.div("还没有比赛,等领队录入比分后即可登记。", class_="sde-empty")
    cards = []
    for m in matchlog:
        mid = m["id"]
        cards.append(
            ui.div(
                _match_top(m),
                ui.div(
                    ui.div(
                        ui.span("出勤", class_="sde-ctl-l"),
                        ui.input_switch(f"att_{mid}", "", value=bool(m["attended"])),
                        class_="sde-ctl",
                    ),
                    ui.div(
                        ui.span("进球", class_="sde-ctl-l"),
                        ui.input_numeric(f"g_{mid}", None, int(m["goals"]), min=0, max=99, width="80px"),
                        class_="sde-ctl",
                    ),
                    ui.div(
                        ui.span("助攻", class_="sde-ctl-l"),
                        ui.input_numeric(f"a_{mid}", None, int(m["assists"]), min=0, max=99, width="80px"),
                        class_="sde-ctl",
                    ),
                    (
                        ui.div(
                            ui.span("扑救", class_="sde-ctl-l"),
                            ui.input_numeric(f"sv_{mid}", None, int(m["saves"]), min=0, max=99, width="80px"),
                            class_="sde-ctl",
                        )
                        if is_gk
                        else None
                    ),
                    class_="sde-ctl-row",
                ),
                class_="sde-mc sde-mc-form",
            )
        )
    return ui.div(*cards, class_="sde-mc-list")


def my_mvp_form_view(
    matchlog: list[dict[str, Any]],
    players: list[dict[str, Any]],
    my_votes_map: dict[int, int],
    me_id: Optional[int],
    settled: Optional[dict[int, dict[str, Any]]] = None,
) -> ui.Tag:
    """每场投 MVP:下拉选队员(排除自己),预填已投票;id 为 mvp_{mid}。

    已结算的场次不再显示下拉,改显示「本场 MVP:XXX(领队已结算)」。
    """
    settled = settled or {}
    if not matchlog:
        return ui.div("还没有比赛,等领队录入比分后即可投票。", class_="sde-empty")
    choices: dict[str, str] = {"none": "— 未投 —"}
    for p in players:
        if p["id"] != me_id:
            choices[str(p["id"])] = _name_with_jersey(p)
    cards = []
    for m in matchlog:
        mid = m["id"]
        if m.get("mvp_locked"):
            win = settled.get(mid)
            parts = []
            if win and win.get("names"):
                parts.append(f'🏅 最佳球员:{"、".join(win["names"])}')
            if win and win.get("dmvp_name"):
                parts.append(f'🛡️ 铁血防守:{win["dmvp_name"]}')
            text = (" · ".join(parts) + "(领队已结算)") if parts else "本场 MVP 待定(投票已锁定)"
            ctl = ui.div(ui.span(text, class_="sde-mvp-settled"), class_="sde-ctl-row")
        else:
            sel = str(my_votes_map.get(mid, "none"))
            if sel not in choices:
                sel = "none"
            ctl = ui.div(
                ui.span("我投 MVP", class_="sde-ctl-l"),
                ui.input_select(f"mvp_{mid}", "", choices, selected=sel, width="190px"),
                class_="sde-ctl-row",
            )
        cards.append(ui.div(_match_top(m), ctl, class_="sde-mc sde-mc-form"))
    return ui.div(*cards, class_="sde-mc-list")