File size: 16,928 Bytes
6e876c1 837a707 6e876c1 4bc82b0 23938a8 7bd5748 6e876c1 837a707 6e876c1 23938a8 6e876c1 23938a8 6e876c1 837a707 6e876c1 c140aff 30c9067 c140aff 6e876c1 837a707 6e876c1 837a707 6e876c1 837a707 6e876c1 837a707 6e876c1 384e6b6 4bc82b0 6e876c1 384e6b6 6e876c1 837a707 334e00e 837a707 334e00e 837a707 334e00e 837a707 384e6b6 4bc82b0 384e6b6 23938a8 8193c6f 4bc82b0 384e6b6 23938a8 384e6b6 4bc82b0 384e6b6 6e876c1 837a707 6e876c1 334e00e 6e876c1 334e00e 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 442 443 444 445 | """肆带贰足球俱乐部 · 球队管理系统(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())
# ------------------------------------------------------------------ 页面主体
@render.ui
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())
# ------------------------------------------------------------------ 登录 / 退出
@reactive.effect
@reactive.event(input.btn_login, ignore_init=True)
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)
@reactive.effect
@reactive.event(input.btn_logout, ignore_init=True)
def _logout():
if not input.btn_logout():
return
current_user.set(None)
# ------------------------------------------------------------------ 主页
@render.ui
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(),
)
# ------------------------------------------------------------------ 导出榜单图片
@render.download(filename="肆带贰_榜单.png")
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
)
# ------------------------------------------------------------------ 我的数据
@render.ui
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)
)
@render.ui
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"]))
@render.ui
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"]))
@render.ui
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"]))
@reactive.effect
@reactive.event(input.btn_save_stats, ignore_init=True)
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 投票
@render.ui
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(),
)
@reactive.effect
@reactive.event(input.btn_save_mvp, ignore_init=True)
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")
# ------------------------------------------------------------------ 修改密码(自助)
@reactive.effect
@reactive.event(input.btn_change_pw, ignore_init=True)
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")
# ------------------------------------------------------------------ 管理:比赛
@render.ui
def admin_matches():
data_version.get()
if not is_admin():
return ui.div()
return views.admin_match_editor(repo.list_matches())
@render.ui
def admin_players():
data_version.get()
if not is_admin():
return ui.div()
return views.admin_players_view(repo.list_users())
# ------------------------------------------------------------------ 管理:编辑任意队员数据
@render.ui
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,
)
@reactive.effect
@reactive.event(input.btn_ae_save, ignore_init=True)
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
@render.ui
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
)
@reactive.effect
@reactive.event(input.btn_save_mvp_lock, ignore_init=True)
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"
)
@reactive.effect
@reactive.event(input.btn_add_match, ignore_init=True)
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")
@reactive.effect
@reactive.event(input.btn_save_matches, ignore_init=True)
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")
# ------------------------------------------------------------------ 管理:队员
@reactive.effect
@reactive.event(input.btn_add_player, ignore_init=True)
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")
@reactive.effect
@reactive.event(input.btn_reset_pw, ignore_init=True)
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")
@reactive.effect
@reactive.event(input.btn_clear_demo, ignore_init=True)
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 刷新(避免数据变动时把用户选中的赛季重置回「全部」)。
@reactive.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)})
|