"""我的数据视图:总览、进球/助攻趋势图、逐场登记、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'"
)
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")