"""我的数据视图:总览、进球/助攻趋势图、逐场登记、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'' ] 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'' ) if r["goals"]: parts.append( f'{r["goals"]}' ) parts.append( f'' ) if r["assists"]: parts.append( f'{r["assists"]}' ) label = r["date"][5:] if len(r["date"]) >= 10 else r["date"] parts.append( f'{label}' ) svg = ( f'' + "".join(parts) + "" ) 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")