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)})