Download app.py from sidaier/fc: direct link, hf CLI and curl.
- Browser
- Download file 16.9 kB
-
https://huggingface.co/spaces/sidaier/fc/resolve/main/app.py
- Command line
-
hf download hf://spaces/sidaier/fc/app.py
-
curl -L -o app.py https://huggingface.co/spaces/sidaier/fc/resolve/main/app.py
16.9 kB
| """肆带贰足球俱乐部 · 球队管理系统(Shiny for Python)。 | |
| 运行:uv run shiny run app.py --reload | |
| 默认本地 SQLite(data/team.db);设置环境变量 DATABASE_URL 可切换到云端 Postgres。 | |
| """ | |
| from __future__ import annotations | |
| import datetime as dt | |
| import logging | |
| from shiny import App, Inputs, Outputs, Session, reactive, render, ui | |
| from src import config, export_img, repo, views | |
| from src.seed import ensure_seed | |
| from src.theme import CSS | |
| logging.basicConfig(level=logging.INFO) | |
| # 首次运行写入名单 + 示例数据(数据库非空则跳过) | |
| ensure_seed() | |
| app_ui = ui.page_fluid( | |
| ui.head_content( | |
| ui.tags.title(f"{config.TEAM_NAME} · 球队系统"), | |
| ui.tags.meta(name="viewport", content="width=device-width, initial-scale=1"), | |
| ui.tags.link(rel="icon", href=config.CREST_URL), | |
| ui.tags.style(ui.HTML(CSS)), | |
| ), | |
| ui.output_ui("app_body"), | |
| ) | |
| def server(input: Inputs, output: Outputs, session: Session) -> None: | |
| current_user: reactive.Value = reactive.value(None) | |
| data_version: reactive.Value = reactive.value(0) | |
| def bump() -> None: | |
| data_version.set(data_version.get() + 1) | |
| def is_admin() -> bool: | |
| u = current_user.get() | |
| return bool(u and u["is_admin"]) | |
| def user_is_gk(uid: int) -> bool: | |
| """实时查询某队员当前是否门将(避免依赖登录时的旧状态)。""" | |
| return any(x["id"] == uid and x["is_goalkeeper"] for x in repo.list_users()) | |
| # ------------------------------------------------------------------ 页面主体 | |
| def app_body(): | |
| u = current_user.get() | |
| if u is None: | |
| return views.login_view() | |
| choices = { | |
| str(x["id"]): f'{x["display_name"]} ({x["username"]})' | |
| for x in repo.list_users() | |
| } | |
| return views.app_shell(u, choices, repo.available_seasons()) | |
| # ------------------------------------------------------------------ 登录 / 退出 | |
| def _login(): | |
| if not input.btn_login(): # 防止动态 UI 重建时计数归零误触发 | |
| return | |
| u = repo.authenticate((input.in_user() or "").strip(), input.in_pw() or "") | |
| if u is None: | |
| ui.notification_show("用户名或密码错误", type="error") | |
| return | |
| current_user.set(u) | |
| def _logout(): | |
| if not input.btn_logout(): | |
| return | |
| current_user.set(None) | |
| # ------------------------------------------------------------------ 主页 | |
| def home_content(): | |
| data_version.get() | |
| u = current_user.get() | |
| sv = input.home_season() | |
| season = int(sv) if (sv and sv != "all" and str(sv).isdigit()) else None | |
| return views.home_view( | |
| repo.team_summary(season), | |
| repo.list_matches(season), | |
| repo.player_aggregate(season), | |
| u["id"] if u else None, | |
| repo.match_contributions(season), | |
| repo.mvp_ranking(season), | |
| repo.settled_mvps(), | |
| repo.dmvp_ranking(season), | |
| repo.all_fifa_cards(), | |
| ) | |
| # ------------------------------------------------------------------ 导出榜单图片 | |
| def dl_leaderboard(): | |
| sv = input.home_season() | |
| season = int(sv) if (sv and sv != "all" and str(sv).isdigit()) else None | |
| label = "全部赛季" if season is None else f"{season} 赛季" | |
| agg = repo.player_aggregate(season) | |
| scorers = sorted(agg, key=lambda x: (-x["goals"], -x["assists"])) | |
| assisters = sorted(agg, key=lambda x: (-x["assists"], -x["goals"])) | |
| attend = sorted(agg, key=lambda x: (-x["attended"], -x["goals"])) | |
| keepers = sorted( | |
| [r for r in agg if r.get("is_goalkeeper")], | |
| key=lambda x: (-x["saves"], -x["attended"]), | |
| ) | |
| mvp = repo.mvp_ranking(season) | |
| dmvp = repo.dmvp_ranking(season) | |
| def rowsify(items, valuefn): | |
| return [(str(i), it["name"], valuefn(it)) for i, it in enumerate(items, 1)] | |
| boards = [ | |
| ("射手榜", rowsify(scorers, lambda x: f'{x["goals"]} 球')), | |
| ("助攻榜", rowsify(assisters, lambda x: f'{x["assists"]} 助')), | |
| ("出勤榜", rowsify(attend, lambda x: f'{x["attended"]}/{x["total"]}')), | |
| ("最佳球员 MVP", rowsify(mvp, lambda x: f'{x["mvps"]} 次')), | |
| ("铁血防守 MVP", rowsify(dmvp, lambda x: f'{x["dmvps"]} 次')), | |
| ] | |
| if keepers: # 有门将才加扑救榜 | |
| boards.append(("扑救榜", rowsify(keepers, lambda x: f'{x["saves"]} 救'))) | |
| yield export_img.render_leaderboard_png( | |
| config.TEAM_NAME, label, dt.date.today().isoformat(), boards | |
| ) | |
| # ------------------------------------------------------------------ 我的数据 | |
| def my_fifa_card(): | |
| data_version.get() | |
| u = current_user.get() | |
| if not u: | |
| return ui.div() | |
| # 钻石皮肤仅在此处(本人个人页)按名单生效;不影响主页卡墙与任何评分 | |
| return views.my_fifa_card_view( | |
| repo.player_fifa_card(u["id"]), diamond=config.is_diamond_user(u) | |
| ) | |
| def my_totals(): | |
| data_version.get() | |
| u = current_user.get() | |
| if not u: | |
| return ui.div() | |
| return views.my_totals_view(repo.player_rank(u["id"])) | |
| def my_form(): | |
| data_version.get() | |
| u = current_user.get() | |
| if not u: | |
| return ui.div() | |
| return views.my_form_view(repo.player_matchlog(u["id"]), user_is_gk(u["id"])) | |
| def my_trend(): | |
| data_version.get() | |
| u = current_user.get() | |
| if not u: | |
| return ui.div() | |
| return views.my_trend_view(repo.player_matchlog(u["id"])) | |
| def _save_stats(): | |
| if not input.btn_save_stats(): | |
| return | |
| u = current_user.get() | |
| if not u: | |
| return | |
| is_gk = user_is_gk(u["id"]) | |
| for m in repo.player_matchlog(u["id"]): | |
| mid = m["id"] | |
| saves = input[f"sv_{mid}"]() if is_gk else 0 | |
| repo.upsert_stat( | |
| u["id"], mid, | |
| input[f"g_{mid}"](), input[f"a_{mid}"](), input[f"att_{mid}"](), saves, | |
| ) | |
| bump() | |
| ui.notification_show("已保存你的数据 ✅", type="message") | |
| # ------------------------------------------------------------------ 我的数据:MVP 投票 | |
| def my_mvp_form(): | |
| data_version.get() | |
| u = current_user.get() | |
| if not u: | |
| return ui.div() | |
| players = [x for x in repo.list_users() if x["is_player"] and x["active"]] | |
| return views.my_mvp_form_view( | |
| repo.player_matchlog(u["id"]), players, repo.my_votes(u["id"]), u["id"], | |
| repo.settled_mvps(), | |
| ) | |
| def _save_mvp(): | |
| if not input.btn_save_mvp(): | |
| return | |
| u = current_user.get() | |
| if not u: | |
| return | |
| for m in repo.player_matchlog(u["id"]): | |
| if m.get("mvp_locked"): | |
| continue # 已结算场次无下拉、不可改,跳过 | |
| mid = m["id"] | |
| raw = input[f"mvp_{mid}"]() | |
| vid = int(raw) if (raw and raw != "none" and str(raw).isdigit()) else None | |
| repo.set_vote(u["id"], mid, vid) | |
| bump() | |
| ui.notification_show("已保存你的 MVP 投票 🏅", type="message") | |
| # ------------------------------------------------------------------ 修改密码(自助) | |
| def _change_pw(): | |
| if not input.btn_change_pw(): | |
| return | |
| u = current_user.get() | |
| if not u: | |
| return | |
| new = (input.cp_new() or "").strip() | |
| if len(new) < 6: | |
| ui.notification_show("新密码至少 6 位", type="error") | |
| return | |
| if new != (input.cp_new2() or "").strip(): | |
| ui.notification_show("两次输入的新密码不一致", type="error") | |
| return | |
| if not repo.change_password(u["id"], input.cp_old() or "", new): | |
| ui.notification_show("当前密码不正确", type="error") | |
| return | |
| ui.notification_show("密码已修改 ✅ 下次请用新密码登录", type="message") | |
| # ------------------------------------------------------------------ 管理:比赛 | |
| def admin_matches(): | |
| data_version.get() | |
| if not is_admin(): | |
| return ui.div() | |
| return views.admin_match_editor(repo.list_matches()) | |
| def admin_players(): | |
| data_version.get() | |
| if not is_admin(): | |
| return ui.div() | |
| return views.admin_players_view(repo.list_users()) | |
| # ------------------------------------------------------------------ 管理:编辑任意队员数据 | |
| def admin_edit_player(): | |
| data_version.get() | |
| if not is_admin(): | |
| return ui.div() | |
| uid_raw = input.ae_user() | |
| if not uid_raw: | |
| return ui.div("请选择要编辑的队员。", class_="sde-empty") | |
| uid = int(uid_raw) | |
| info = next((x for x in repo.list_users() if x["id"] == uid), None) | |
| return views.admin_edit_player_view( | |
| repo.player_matchlog(uid), | |
| user_is_gk(uid), | |
| info["display_name"] if info else "", | |
| info["jersey"] if info else None, | |
| ) | |
| def _ae_save(): | |
| if not input.btn_ae_save() or not is_admin(): | |
| return | |
| uid_raw = input.ae_user() | |
| if not uid_raw: | |
| ui.notification_show("请先选择队员", type="warning") | |
| return | |
| uid = int(uid_raw) | |
| # 改昵称 / 球衣号(仅 admin 可到这里) | |
| jraw = (input.ae_jersey() or "").strip() | |
| jersey = int(jraw) if jraw.isdigit() else None | |
| repo.update_player_profile(uid, input.ae_name() or "", jersey) | |
| repo.set_goalkeeper(uid, bool(input.ae_gk())) | |
| for m in repo.player_matchlog(uid): | |
| mid = m["id"] | |
| repo.upsert_stat( | |
| uid, mid, | |
| input[f"ag_{mid}"](), input[f"aa_{mid}"](), | |
| input[f"aatt_{mid}"](), input[f"asv_{mid}"](), | |
| ) | |
| bump() | |
| ui.notification_show("已保存该队员资料与数据 ✅", type="message") | |
| # ------------------------------------------------------------------ 管理:结算本场 MVP | |
| def admin_mvp_settle(): | |
| data_version.get() | |
| if not is_admin(): | |
| return ui.div() | |
| player_choices = { | |
| str(x["id"]): f'{x["display_name"]} ({x["username"]})' | |
| for x in repo.list_users() | |
| if x["is_player"] and x["active"] | |
| } | |
| return views.admin_mvp_settle_view( | |
| repo.list_matches(), repo.match_mvp_summary(), player_choices | |
| ) | |
| def _save_mvp_lock(): | |
| if not input.btn_save_mvp_lock() or not is_admin(): | |
| return | |
| def _sel(mid: int, prefix: str = "mvpsel") -> int | None: | |
| raw = input[f"{prefix}_{mid}"]() | |
| return int(raw) if (raw and raw != "none" and str(raw).isdigit()) else None | |
| settled = opened = 0 | |
| for m in repo.list_matches(): | |
| mid = m["id"] | |
| w1, w2, wd = _sel(mid), _sel(mid, "mvpsel2"), _sel(mid, "dmvpsel") | |
| cur = (m.get("mvp_user_id"), m.get("mvp2_user_id"), m.get("dmvp_user_id")) | |
| if (w1, w2, wd) != cur: | |
| repo.settle_mvp(mid, w1, w2, wd) | |
| settled += 1 if (w1 or w2 or wd) else 0 | |
| opened += 1 if (w1 is None and w2 is None and wd is None) else 0 | |
| bump() | |
| ui.notification_show( | |
| f"已结算 {settled} 场、重新开放 {opened} 场 🏅", type="message" | |
| ) | |
| def _add_match(): | |
| if not input.btn_add_match() or not is_admin(): | |
| return | |
| opp = (input.m_opp() or "").strip() | |
| if not opp: | |
| ui.notification_show("请填写对手名称", type="error") | |
| return | |
| try: | |
| repo.add_match( | |
| input.m_date(), opp, input.m_ha(), | |
| input.m_our(), input.m_oppscore(), input.m_note(), | |
| ) | |
| except ValueError as e: | |
| ui.notification_show(str(e), type="error") | |
| return | |
| bump() | |
| ui.update_text("m_opp", value="") | |
| ui.update_numeric("m_our", value=0) | |
| ui.update_numeric("m_oppscore", value=0) | |
| ui.update_text("m_note", value="") | |
| ui.notification_show("已添加比赛 ✅", type="message") | |
| def _save_matches(): | |
| if not input.btn_save_matches() or not is_admin(): | |
| return | |
| updated = deleted = 0 | |
| for m in repo.list_matches(): | |
| mid = m["id"] | |
| if input[f"mdel_{mid}"](): | |
| repo.delete_match(mid) | |
| deleted += 1 | |
| continue | |
| repo.update_match( | |
| mid, input[f"mopp_{mid}"](), input[f"mha_{mid}"](), | |
| input[f"mo_{mid}"](), input[f"mp_{mid}"](), | |
| ) | |
| updated += 1 | |
| bump() | |
| ui.notification_show(f"已更新 {updated} 场,删除 {deleted} 场 ✅", type="message") | |
| # ------------------------------------------------------------------ 管理:队员 | |
| def _add_player(): | |
| if not input.btn_add_player() or not is_admin(): | |
| return | |
| jersey_raw = (input.p_jersey() or "").strip() | |
| jersey = int(jersey_raw) if jersey_raw.isdigit() else None | |
| try: | |
| repo.add_user( | |
| input.p_user(), input.p_name(), | |
| input.p_pw() or config.DEFAULT_PASSWORD, | |
| jersey, is_admin=bool(input.p_admin()), is_player=True, | |
| is_goalkeeper=bool(input.p_gk()), | |
| ) | |
| except ValueError as e: | |
| ui.notification_show(str(e), type="error") | |
| return | |
| bump() | |
| ui.update_text("p_name", value="") | |
| ui.update_text("p_user", value="") | |
| ui.update_text("p_jersey", value="") | |
| ui.notification_show("已添加队员 ✅", type="message") | |
| def _reset_pw(): | |
| if not input.btn_reset_pw() or not is_admin(): | |
| return | |
| uid = input.rp_user() | |
| pw = input.rp_pw() | |
| if not uid or not pw: | |
| ui.notification_show("请选择队员并填写新密码", type="error") | |
| return | |
| repo.reset_password(int(uid), pw) | |
| bump() | |
| ui.notification_show("密码已重置 ✅", type="message") | |
| def _clear_demo(): | |
| if not input.btn_clear_demo() or not is_admin(): | |
| return | |
| if not input.confirm_clear(): | |
| ui.notification_show("请先勾选「我确认」开关再清空", type="warning") | |
| return | |
| n = repo.clear_matches_and_stats() | |
| bump() | |
| ui.notification_show(f"已清空 {n} 场比赛及相关数据 ✅", type="message") | |
| # ------------------------------------------------------------------ 保持下拉框同步 | |
| # 注:赛季下拉在每次登录时随 app_shell 用最新 available_seasons() 重建, | |
| # 无需额外 effect 刷新(避免数据变动时把用户选中的赛季重置回「全部」)。 | |
| def _refresh_reset_pw_choices(): | |
| data_version.get() | |
| if not is_admin(): | |
| return | |
| choices = { | |
| str(x["id"]): f'{x["display_name"]} ({x["username"]})' | |
| for x in repo.list_users() | |
| } | |
| # 重置密码、编辑队员两个下拉共用同一份选项;改名后即时同步显示 | |
| ui.update_select("rp_user", choices=choices) | |
| ui.update_select("ae_user", choices=choices) | |
| app = App(app_ui, server, static_assets={"/static": str(config.WWW_DIR)}) | |