From 4e360be8c77cbf359b9200204304097869c65c75 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Mon, 17 Aug 2026 13:31:26 +0800 Subject: [PATCH] =?UTF-8?q?v1.2.0=20=E7=8B=AC=E7=AB=8B=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5+=E8=B5=9B=E5=AD=A3=E9=80=89=E6=8B=A9+=E5=AD=A3?= =?UTF-8?q?=E5=90=8E=E8=B5=9B=E5=AF=B9=E9=98=B5=E5=9B=BE=EF=BC=9A/team|pla?= =?UTF-8?q?yer|game|news|person/=20=E8=AF=A6=E6=83=85=E9=A1=B5(?= =?UTF-8?q?=E4=BA=A4=E5=8F=89=E9=93=BE=E6=8E=A5)=EF=BC=9B=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=8A=A0=E6=9F=A5=E7=9C=8B=E5=AE=8C=E6=95=B4=E8=AF=A6?= =?UTF-8?q?=E6=83=85=EF=BC=9B=E6=8E=92=E5=90=8D=E9=A1=B5=E5=A4=9A=E8=B5=9B?= =?UTF-8?q?=E5=AD=A3=E5=88=87=E6=8D=A2(2024-25/2025-26)=EF=BC=9B=E5=AD=A3?= =?UTF-8?q?=E5=90=8E=E8=B5=9Bbracket=E5=AF=B9=E9=98=B5=E5=9B=BE(playoffs.p?= =?UTF-8?q?y)=EF=BC=9Bseed=5Fseasons=E8=A1=A5=E5=85=85=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++-- api.py | 40 +++++++++-- playoffs.py | 144 +++++++++++++++++++++++++++++++++++++++ seed_seasons.py | 164 +++++++++++++++++++++++++++++++++++++++++++++ static/app.js | 75 ++++++++++++++++++++- static/detail.html | 80 ++++++++++++++++++++++ static/detail.js | 136 +++++++++++++++++++++++++++++++++++++ static/index.html | 3 + static/style.css | 17 +++++ tools.py | 51 +++++++++++++- 10 files changed, 711 insertions(+), 16 deletions(-) create mode 100644 playoffs.py create mode 100644 seed_seasons.py create mode 100644 static/detail.html create mode 100644 static/detail.js diff --git a/README.md b/README.md index 90ebd1a..78661c1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ - 📰 **参考资讯折叠块**:回答用到新闻/百科时,回答上方默认折叠展示资讯链接,点击直达详情 - ✨ **实体识别标记**:回答中的球队/球员/人物/比赛自动高亮(含简称:雷霆/湖人/库里/字母哥…),点击弹出详情;回答下方生成快速查看卡片 - 📝 **Markdown 回答**:大模型回答以 Markdown 渲染(标题/列表/表格/代码块/引用),本地 marked 库无外部依赖 +- 🔗 **独立详情页**:`/team/1` `/player/1` `/game/1` `/news/1` `/person/1`,弹窗与列表中均有「查看完整详情」链接跳转;详情页内含交叉链接(球队→球员/比赛,比赛→球员/球队) +- 🏆 **季后赛对阵图**:排名页展示完整 bracket(首轮/半决/分区决赛/总决赛),胜者高亮、点击看比赛,支持多赛季切换(2024-25 / 2025-26) ## 🏗️ 系统架构 @@ -36,27 +38,29 @@ | 模块 | 技术 | 说明 | |------|------|------| | 对话大脑 | DeepSeek `deepseek-v4-flash`(函数调用) | 意图理解、工具选择、答案生成 | -| 结构化数据 | SQLite(本地) | 球队/球员/比赛/统计/排名/新闻/人物 | +| 结构化数据 | SQLite(本地) | 球队/球员/比赛/统计/排名/新闻/人物,多赛季(2024-25/2025-26) | | 向量检索 | Chroma(16010,REST) | 新闻/百科语义检索(cosine) | | Embedding | bge-large-zh-v1.5(16011,1024维) | 中文语义向量 | | Rerank | bge-reranker-v2-m3(16011) | 检索精排(可选) | -| 前端 | 原生 HTML/JS(无构建) | 深色球赛风,对话 + 六大浏览页 | +| 前端 | 原生 HTML/JS(无构建) | 对话 + 六大浏览页 + 独立详情页 + 管理后台 + 对阵图 | ## 📁 目录结构 ``` nba-fan-hub/ -├── api.py # Flask 服务(REST API + 静态页 + 管理后台路由) +├── api.py # Flask 服务(REST API + 静态页 + 详情页路由 + 管理后台) ├── chat.py # 对话管线(工具路由 + 兜底 + 覆盖补全) ├── tools.py # 8 个查询工具(SQL + 向量),含绰号别名/模糊匹配 ├── llm.py # DeepSeek 调用(思考模式 reasoning_content 回传) ├── entity_linker.py # 实体识别程序(回答中球队/球员/人物/比赛标记 + 卡片) +├── playoffs.py # 季后赛对阵图生成器(多赛季 bracket) ├── admin.py # 管理后台 API(登录认证 + 通用 CRUD + 站点配置) ├── vector_store.py # Embedding + Chroma + Rerank(纯 REST,零额外依赖) ├── db.py # SQLite 连接与建表(含 site_config 配置表) ├── seed.py # 种子数据入口(幂等,可 --rebuild-vector) +├── seed_seasons.py # 赛季补充数据(2024-25 排名/季后赛 + 2025-26 首轮补齐) ├── seed_*.py # 模拟数据(30队/154球员/54比赛/46新闻百科/38人物) -├── static/ # 前端页面(index.html 前台 + admin.html 后台 + lib/marked.min.js) +├── static/ # 前端(index.html 前台 / admin.html 后台 / detail.html 详情页 + lib/marked.min.js) ├── data/ # nba_fan.db(自动生成) └── start.sh # 启停脚本(start/stop/restart/status/seed) ``` @@ -86,7 +90,10 @@ nba-fan-hub/ | `GET /api/teams[?q=]` `GET /api/teams/` | 球队列表/详情(含阵容+近期比赛) | | `GET /api/players[?q=]` `GET /api/players/` | 球员列表/详情 | | `GET /api/games[?q=&status=]` `GET /api/games/` | 比赛列表/详情(含技术统计) | -| `GET /api/standings?conf=西部/东部` | 排名 | +| `GET /api/standings?conf=西部/东部&season=2025-26` | 排名(多赛季) | +| `GET /api/seasons` | 可用赛季列表 | +| `GET /api/playoffs?season=2025-26` | 季后赛对阵图(首轮/半决/分区决赛/总决赛) | +| `GET /team/` `/player/` `/game/` `/news/` `/person/` | 独立详情页 | | `GET /api/news[?q=&kind=]` `GET /api/news/` | 新闻/百科 | | `GET /api/persons[?q=&role=]` | 人物(coach/commentator/host/agent/gm/legend) | | `GET /api/health` | 健康检查 + 数据统计 | diff --git a/api.py b/api.py index 49da25d..6ab8523 100644 --- a/api.py +++ b/api.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""NBA球迷大全 - Flask 服务入口(API + 前端静态页 + 管理后台)""" +"""NBA球迷大全 - Flask 服务入口(API + 前端静态页 + 管理后台 + 详情页)""" import logging import os @@ -11,6 +11,7 @@ import tools import chat import vector_store import entity_linker +import playoffs as playoffs_mod import admin as admin_mod logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") @@ -31,6 +32,14 @@ def admin_page(): return send_from_directory(STATIC_DIR, "admin.html") +# 独立详情页(前端 detail.js 按路径加载数据渲染) +@app.route("//") +def detail_page(etype, eid): + if etype in ("team", "player", "game", "news", "person"): + return send_from_directory(STATIC_DIR, "detail.html") + return send_from_directory(STATIC_DIR, "index.html") + + @app.route("/static/") def static_files(path): return send_from_directory(STATIC_DIR, path) @@ -92,7 +101,11 @@ def api_team(tid): return jsonify({"error": "not found"}), 404 roster = tools.search_players(t["name"], limit=20)["results"] games = tools.search_games(t["name"], limit=10)["results"] - return jsonify({"team": t, "roster": roster, "recent_games": games}) + # 各赛季排名 + from db import query as dbq + seasons = dbq("""SELECT season, conference, rank, wins, losses FROM standings + WHERE team_id=? ORDER BY season DESC""", (tid,)) + return jsonify({"team": t, "roster": roster, "recent_games": games, "standings": seasons}) # ------------------------------------------------------------------ 球员 @@ -108,6 +121,7 @@ def api_player(pid): p = tools.get_player(pid) if not p: return jsonify({"error": "not found"}), 404 + p["game_logs"] = tools.player_game_logs(pid) return jsonify(p) @@ -136,10 +150,26 @@ def api_game(gid): @app.route("/api/standings") def api_standings(): conf = request.args.get("conf", "") - r = tools.search_standings(conf, limit=30) + season = request.args.get("season", "") or None + r = tools.search_standings(conf, season=season, limit=30) return jsonify(r.get("results", [])) +# ------------------------------------------------------------------ 赛季列表 / 季后赛对阵图 +@app.route("/api/seasons") +def api_seasons(): + return jsonify(playoffs_mod.seasons()) + + +@app.route("/api/playoffs") +def api_playoffs(): + season = request.args.get("season", "") or playoffs_mod.seasons()[0] + try: + return jsonify(playoffs_mod.build_bracket(season)) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + # ------------------------------------------------------------------ 新闻 @app.route("/api/news") def api_news(): @@ -180,8 +210,8 @@ def api_persons(): @app.route("/api/persons/") def api_person(pid): - r = tools.search_persons("", limit=100) - p = next((x for x in r["results"] if x["id"] == pid), None) + from db import query_one as q1 + p = q1("SELECT * FROM persons WHERE id=?", (pid,)) if not p: return jsonify({"error": "not found"}), 404 return jsonify(p) diff --git a/playoffs.py b/playoffs.py new file mode 100644 index 0000000..c7ea288 --- /dev/null +++ b/playoffs.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +""" +季后赛对阵图生成器 +================== +从 games 表按赛季提取季后赛比赛,按系列分组统计胜负, +结合 standings 种子排名,输出标准 bracket 结构(东部/西部/总决赛)。 +系列规则:同轮次内共享同一对球队的比赛归为一个系列;胜者 = 胜场多者(平局取最后一场胜者)。 +""" +import re +from collections import OrderedDict + +from db import query + +ROUND_ORDER = ["季后赛首轮", "季后赛次轮", "东部决赛", "西部决赛", "总决赛"] +ROUND_CN = {"季后赛首轮": "首轮", "季后赛次轮": "半决赛", "东部决赛": "东部决赛", "西部决赛": "西部决赛", "总决赛": "总决赛"} +ROUND_STEP = {"季后赛首轮": 0, "季后赛次轮": 1, "东部决赛": 2, "西部决赛": 2, "总决赛": 3} +ROUND_INDEX = {r: i for i, r in enumerate(ROUND_ORDER)} + + +def _load_teams(): + return {r["id"]: r for r in query("SELECT id, name, code FROM teams")} + + +def _load_standings(season): + rows = query("""SELECT s.*, t.name, t.code FROM standings s JOIN teams t ON s.team_id=t.id + WHERE s.season=? ORDER BY s.conference DESC, s.rank""", (season,)) + by_conf = {"东部": [], "西部": []} + for r in rows: + if r["conference"] in by_conf: + by_conf[r["conference"]].append(r) + return by_conf + + +def _series_key(home_id, away_id): + return tuple(sorted((home_id, away_id))) + + +def _collect_series(games): + """按 (轮次, 球队对) 分组:返回 {(round_name, pair): [(game, home_team, away_team), ...]}""" + series = OrderedDict() + for g in games: + key = (g["round_name"], _series_key(g["home_team_id"], g["away_team_id"])) + series.setdefault(key, []).append(g) + return series + + +def _match_result(match_games, teams): + """统计一个系列:返回 {home:{team,team_id,wins,advance}, away:{...}, games:[game_ids]} + 主客方向以第一场为准;每场胜者按实际主客归属计数(主客互换不影响胜场归属)。""" + g0 = match_games[0] + h_id, a_id = g0["home_team_id"], g0["away_team_id"] + h_wins = a_wins = 0 + last_winner = None + gids = [] + for g in match_games: + gids.append(g["id"]) + if g["status"] != "finished": + continue + if g["home_score"] is None or g["away_score"] is None: + continue + winner = g["home_team_id"] if g["home_score"] > g["away_score"] else g["away_team_id"] + last_winner = winner + if winner == h_id: + h_wins += 1 + else: + a_wins += 1 + if h_wins == a_wins: + h_wins, a_wins = (1, 0) if last_winner == h_id else (0, 1) + h_team, a_team = teams[h_id], teams[a_id] + return { + "home": {"team": h_team["name"], "team_id": h_id, "code": h_team["code"], + "wins": h_wins, "advance": h_wins > a_wins}, + "away": {"team": a_team["name"], "team_id": a_id, "code": a_team["code"], + "wins": a_wins, "advance": a_wins > h_wins}, + "games": gids, + } + + +def _winner(m): + return m["home"] if m["home"]["advance"] else m["away"] + + +def build_bracket(season): + """主入口:返回该赛季对阵图结构 + { + season, rounds: {first: {east:[m..], west:[m..]}, semi: {...}, conf: {east:m, west:m}, final: m} + } + """ + teams = _load_teams() + standings = _load_standings(season) + games = query("""SELECT * FROM games WHERE season=? AND (round_name LIKE '季后赛%' + OR round_name IN ('东部决赛','西部决赛','总决赛'))""", (season,)) + series = _collect_series(games) + + def find_match(round_name, home_id, away_id): + key = (round_name, _series_key(home_id, away_id)) + if key in series: + return _match_result(series[key], teams) + # 无数据系列:按种子高者晋级(占位) + return { + "home": {"team": teams[home_id]["name"], "team_id": home_id, "code": teams[home_id]["code"], + "wins": 0, "advance": True}, + "away": {"team": teams[away_id]["name"], "team_id": away_id, "code": teams[away_id]["code"], + "wins": 0, "advance": False}, + "games": [], "placeholder": True, + } + + out = {"season": season, "rounds": {}} + + for conf in ("东部", "西部"): + ranked = standings.get(conf, []) + first, semi = [], [] + if len(ranked) >= 8: + pairs = [(ranked[0], ranked[7]), (ranked[3], ranked[4]), # 1v8, 4v5(上半区) + (ranked[1], ranked[6]), (ranked[2], ranked[5])] # 2v7, 3v6(下半区) + first = [find_match("季后赛首轮", p[0]["team_id"], p[1]["team_id"]) for p in pairs] + # 次轮:上半区胜者 vs 下半区胜者(1/8胜 vs 4/5胜,2/7胜 vs 3/6胜) + w1, w2 = _winner(first[0]), _winner(first[1]) + w3, w4 = _winner(first[2]), _winner(first[3]) + semi = [find_match("季后赛次轮", w1["team_id"], w2["team_id"]), + find_match("季后赛次轮", w3["team_id"], w4["team_id"])] + out["rounds"]["first"] = out["rounds"].get("first", {}) + out["rounds"]["semi"] = out["rounds"].get("semi", {}) + out["rounds"]["first"][conf] = first + out["rounds"]["semi"][conf] = semi + # 分区决赛 + if len(semi) >= 2: + c1, c2 = _winner(semi[0]), _winner(semi[1]) + out["rounds"].setdefault("conf", {})[conf] = find_match( + "东部决赛" if conf == "东部" else "西部决赛", c1["team_id"], c2["team_id"]) + + # 总决赛 + east_c, west_c = out["rounds"].get("conf", {}).get("东部"), out["rounds"].get("conf", {}).get("西部") + if east_c and west_c: + e, w = _winner(east_c), _winner(west_c) + out["rounds"]["final"] = find_match("总决赛", e["team_id"], w["team_id"]) + + return out + + +def seasons(): + """可用赛季列表(standings 有数据的),降序""" + rows = query("SELECT DISTINCT season FROM standings ORDER BY season DESC") + return [r["season"] for r in rows] diff --git a/seed_seasons.py b/seed_seasons.py new file mode 100644 index 0000000..c4377ad --- /dev/null +++ b/seed_seasons.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +赛季补充数据(独立于主 seed,不覆盖现有数据): +1. 补齐 2025-26 季后赛首轮缺失系列(每系列 2 场,高顺位 2-0 晋级) +2. 生成 2024-25 赛季:常规赛排名(30 队)+ 完整季后赛对阵(首轮 8 系列 / 次轮 4 / 分区决赛 2 / 总决赛 3 场) +用法:python3 seed_seasons.py +""" +import logging +import random + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("seed_seasons") + +from db import init_db, execute, query, query_one # noqa: E402 + +random.seed(42) + +# ---------------------------------------------------------------- 2025-26 季后赛补充 +# (轮次, 时间, 主队code, 客队code, 主分, 客分) +R1_2026 = [ + ("季后赛首轮", "2026-04-20 08:00", "CLE", "ORL", 120, 98), + ("季后赛首轮", "2026-04-23 07:30", "CLE", "ORL", 115, 102), + ("季后赛首轮", "2026-04-20 07:00", "NYK", "MIA", 108, 96), + ("季后赛首轮", "2026-04-23 08:00", "NYK", "MIA", 110, 104), + ("季后赛首轮", "2026-04-21 08:00", "MIL", "IND", 112, 101), + ("季后赛首轮", "2026-04-24 08:30", "MIL", "IND", 109, 105), + ("季后赛首轮", "2026-04-19 10:00", "DEN", "HOU", 121, 110), + ("季后赛首轮", "2026-04-22 09:30", "DEN", "HOU", 118, 109), + # 已有系列补第 2 场(胜方再胜 → 2-0) + ("季后赛首轮", "2026-04-22 10:00", "OKC", "MIN", 126, 110), + ("季后赛首轮", "2026-04-25 08:00", "BOS", "PHI", 118, 99), + ("季后赛首轮", "2026-05-01 10:30", "LAL", "MEM", 117, 105), + # 次轮补第 2 场(胜方再胜 → 2-0) + ("季后赛次轮", "2026-05-09 08:30", "BOS", "MIL", 114, 101), + ("季后赛次轮", "2026-05-19 08:00", "CLE", "NYK", 118, 105), + ("季后赛次轮", "2026-05-18 09:30", "DEN", "LAL", 116, 108), +] + +# ---------------------------------------------------------------- 2024-25 赛季 +# 常规赛战绩:基于 2025-26 战绩微调(保证排名接近,冠军悬念不同) +_W25 = { # code -> (wins, losses) 与 standings 排名一致 + "BOS": (61, 21), "CLE": (57, 25), "NYK": (53, 29), "MIL": (50, 32), + "IND": (48, 34), "MIA": (45, 37), "ORL": (44, 38), "PHI": (42, 40), + "ATL": (40, 42), "CHI": (37, 45), "BKN": (34, 48), "TOR": (31, 51), + "DET": (28, 54), "WAS": (21, 61), "CHA": (20, 62), + "OKC": (66, 16), "DEN": (56, 26), "LAL": (54, 28), "GSW": (52, 30), + "DAL": (50, 32), "MEM": (49, 33), "HOU": (48, 34), "MIN": (47, 35), + "LAC": (46, 36), "SAC": (43, 39), "PHX": (41, 41), "SAS": (37, 45), + "POR": (29, 53), "UTA": (24, 58), "NOP": (23, 59), +} +CONF25 = { # code -> (conference, rank) + "BOS": ("东部", 1), "CLE": ("东部", 2), "NYK": ("东部", 3), "MIL": ("东部", 4), + "IND": ("东部", 5), "MIA": ("东部", 6), "ORL": ("东部", 7), "PHI": ("东部", 8), + "ATL": ("东部", 9), "CHI": ("东部", 10), "BKN": ("东部", 11), "TOR": ("东部", 12), + "DET": ("东部", 13), "WAS": ("东部", 14), "CHA": ("东部", 15), + "OKC": ("西部", 1), "DEN": ("西部", 2), "LAL": ("西部", 3), "GSW": ("西部", 4), + "DAL": ("西部", 5), "MEM": ("西部", 6), "HOU": ("西部", 7), "MIN": ("西部", 8), + "LAC": ("西部", 9), "SAC": ("西部", 10), "PHX": ("西部", 11), "SAS": ("西部", 12), + "POR": ("西部", 13), "UTA": ("西部", 14), "NOP": ("西部", 15), +} + +# 2024-25 季后赛对阵(每系列 2 场,高顺位 2-0 晋级;总决赛 3 场 2-1,凯尔特人夺冠) +# 格式: (轮次, [(主队, 客队, 主分, 客分), ...]) +PLAYOFFS25 = [ + ("季后赛首轮", [ + ("BOS", "PHI", 124, 102), ("BOS", "PHI", 118, 96), # 1 vs 8 + ("CLE", "ORL", 112, 95), ("CLE", "ORL", 109, 98), + ("NYK", "MIA", 105, 97), ("NYK", "MIA", 111, 103), + ("MIL", "IND", 110, 101), ("MIL", "IND", 107, 99), + ("OKC", "MIN", 123, 108), ("OKC", "MIN", 117, 104), + ("DEN", "HOU", 119, 106), ("DEN", "HOU", 114, 107), + ("LAL", "MEM", 116, 110), ("LAL", "MEM", 121, 112), + ("GSW", "DAL", 120, 113), ("GSW", "DAL", 115, 109), + ]), + ("季后赛次轮", [ + ("BOS", "MIL", 116, 105), ("BOS", "MIL", 112, 100), # 上半区 + ("CLE", "NYK", 108, 101), ("CLE", "NYK", 114, 106), # 下半区 + ("OKC", "GSW", 125, 117), ("OKC", "GSW", 121, 110), + ("DEN", "LAL", 117, 111), ("DEN", "LAL", 113, 108), + ]), + ("东部决赛", [ + ("BOS", "CLE", 115, 102), ("BOS", "CLE", 110, 104), + ]), + ("西部决赛", [ + ("OKC", "DEN", 118, 112), ("OKC", "DEN", 121, 116), + ]), + ("总决赛", [ + ("BOS", "OKC", 108, 105), ("OKC", "BOS", 118, 112), ("BOS", "OKC", 117, 110), + ]), +] + +VENUES = { + "BOS": "TD花园", "CLE": "速贷中心", "NYK": "麦迪逊广场花园", "MIL": "费哲论坛球馆", + "OKC": "佩科姆中心", "DEN": "波尔球馆", "LAL": "加密网球馆", "GSW": "大通中心", + "ORL": "安利中心", "MIA": "卡塞亚中心", "IND": "甘布里吉球场", "PHI": "富国银行中心", + "MIN": "目标中心", "HOU": "丰田中心", "MEM": "联邦快递球馆", "DAL": "美航中心", +} + + +def _team_ids(): + return {r["code"]: r["id"] for r in query("SELECT id, code FROM teams")} + + +def _insert_game(league_id, season, rnd, gt, home_id, away_id, hs, as_, venue, bcast="TNT"): + execute("""INSERT INTO games(league_id,season,round_name,game_time,status,home_team_id,away_team_id, + home_score,away_score,venue,broadcast) VALUES(?,?,?,?,?,?,?,?,?,?,?)""", + (league_id, season, rnd, gt, "finished", home_id, away_id, hs, as_, venue, bcast)) + + +def add_2025_26_round1(): + tid = _team_ids() + league_id = query_one("SELECT id FROM leagues WHERE code='NBA'")["id"] + n = 0 + for rnd, gt, h, a, hs, as_ in R1_2026: + if query_one("SELECT 1 FROM games WHERE round_name=? AND game_time=?", (rnd, gt)): + continue + _insert_game(league_id, "2025-26", rnd, gt, tid[h], tid[a], hs, as_, VENUES.get(h, "球馆")) + n += 1 + log.info("2025-26 季后赛首轮补充 %d 场", n) + + +def seed_2024_25(): + tid = _team_ids() + league_id = query_one("SELECT id FROM leagues WHERE code='NBA'")["id"] + # 排名 + n_s = 0 + for code, (w, l) in _W25.items(): + conf, rk = CONF25[code] + if not query_one("SELECT 1 FROM standings WHERE season='2024-25' AND team_id=?", (tid[code],)): + execute("""INSERT INTO standings(league_id,season,team_id,wins,losses,win_pct,conference,rank) + VALUES(?,?,?,?,?,?,?,?)""", + (league_id, "2024-25", tid[code], w, l, round(w / (w + l), 4), conf, rk)) + n_s += 1 + # 比赛 + n_g = 0 + for rnd, games in PLAYOFFS25: + for i, (h, a, hs, as_) in enumerate(games): + gt = _gt25(rnd, i) + if query_one("SELECT 1 FROM games WHERE round_name=? AND game_time=?", (rnd, gt)): + continue + _insert_game(league_id, "2024-25", rnd, gt, tid[h], tid[a], hs, as_, + VENUES.get(h, "球馆")) + n_g += 1 + log.info("2024-25 赛季:排名 %d 行,季后赛 %d 场", n_s, n_g) + + +def _gt25(rnd, i): + base = {"季后赛首轮": "2025-04-19", "季后赛次轮": "2025-05-06", + "东部决赛": "2025-05-20", "西部决赛": "2025-05-21", "总决赛": "2025-06-03"}[rnd] + from datetime import datetime, timedelta + d = datetime.strptime(base, "%Y-%m-%d") + timedelta(days=i) + hour = "08:30" if rnd == "总决赛" else "09:00" + return f"{d.strftime('%Y-%m-%d')} {hour}" + + +if __name__ == "__main__": + init_db() + add_2025_26_round1() + seed_2024_25() + from db import table_count + for r in query("SELECT season, COUNT(*) c FROM games GROUP BY season"): + log.info("games %s: %d 场", r["season"], r["c"]) + for r in query("SELECT season, COUNT(*) c FROM standings GROUP BY season"): + log.info("standings %s: %d 行", r["season"], r["c"]) diff --git a/static/app.js b/static/app.js index 5fc4263..4e9940b 100644 --- a/static/app.js +++ b/static/app.js @@ -54,7 +54,10 @@ function loadView(v) { else if (v === "games") loadGames(""); else if (v === "news") loadNews(""); else if (v === "persons") loadPersons(""); - else if (v === "standings") loadStandings(""); + else if (v === "standings") { + if ($("#season-select").options.length) { loadStandings(); loadPlayoffs(); } + else loadSeasons(); + } } /* ================= 对话 ================= */ @@ -197,6 +200,7 @@ async function openTeam(id) {
🧑‍🏫 主教练
${esc(t.head_coach)}
⭐ 主要球员(本赛季场均)
${roster || "暂无"}
📅 近期比赛
${games || "暂无"} +
${DL("team", t.id)}
`); } @@ -229,6 +233,7 @@ async function openPlayer(id) {
🏅 荣誉
${esc(p.awards || "暂无")}
📝 简介
${esc(p.bio || "暂无")}
+
${DL("player", p.id)}
`); } @@ -280,6 +285,7 @@ async function openGame(id) {
🕐 ${esc(g.game_time)} · ${esc(g.venue)} · ${esc(g.broadcast)}
${stats ? `
📊 球员技术统计
${stats}
球队球员得分篮板助攻抢断盖帽分钟
` : ""} +
${DL("game", g.id)}
`); } @@ -304,6 +310,7 @@ async function openNews(id) {

${esc(n.title)}

🕐 ${esc(n.publish_time)} · ${esc(n.source)} · ${esc(n.author || "")} · ${n.kind === "wiki" ? "百科词条" : "新闻"}
${esc(n.content).replace(/\n/g, "
")}
+
${DL("news", n.id)}
`); } @@ -334,19 +341,40 @@ async function openPerson(id) {
${esc(p.role_cn)} · ${esc(p.title || "")}${p.team ? " · " + esc(p.team) : ""}
📝 简介
${esc(p.bio || "暂无")}
🏅 成就
${esc(p.achievements || "暂无")}
+
${DL("person", p.id)}
`); } /* ================= 排名 ================= */ let standingsConf = ""; +let standingsSeason = ""; +let bracketData = null; + +async function loadSeasons() { + try { + const ss = await getJSON("/api/seasons"); + if (ss.length) { + standingsSeason = ss[0]; // 默认最新赛季 + $("#season-select").innerHTML = ss.map((s) => ``).join(""); + loadStandings(); + loadPlayoffs(); + } + } catch (e) {} +} +$("#season-select").addEventListener("change", (e) => { + standingsSeason = e.target.value; + loadStandings(); + loadPlayoffs(); +}); + async function loadStandings() { - const rows = await getJSON(`/api/standings?conf=${encodeURIComponent(standingsConf)}`); + const rows = await getJSON(`/api/standings?conf=${encodeURIComponent(standingsConf)}&season=${encodeURIComponent(standingsSeason || "")}`); const groups = {}; rows.forEach((r) => { (groups[r.conference] = groups[r.conference] || []).push(r); }); $("#wrap-standings").innerHTML = Object.entries(groups).map(([conf, list]) => `

${conf}赛区

- ${list.map((r) => ` + ${list.map((r) => ``).join("")}
排名球队胜率战绩
${r.rank}${esc(r.team)}
${r.rank}${esc(r.team)} ${r.wins}${r.losses} ${r.win_pct}%${r.wins}-${r.losses}
`).join(""); @@ -358,11 +386,52 @@ $$("#view-standings .btn.small").forEach((b) => b.addEventListener("click", () = loadStandings(); })); +/* ================= 季后赛对阵图 ================= */ +async function loadPlayoffs() { + try { + bracketData = await getJSON(`/api/playoffs?season=${encodeURIComponent(standingsSeason || "")}`); + renderBracket(); + } catch (e) { + $("#wrap-bracket").innerHTML = `
对阵图加载失败:${esc(e.message)}
`; + } +} + +function matchHtml(m) { + if (!m) return ""; + const gid = (m.games || [])[0]; + const row = (side) => `
+ ${side.advance ? "🏆 " : ""}${esc(side.team)}${side.wins}
`; + return `
${row(m.home)}${row(m.away)}
`; +} + +function renderBracket() { + const r = (bracketData?.rounds) || {}; + const first = [...(r.first?.["东部"] || []), ...(r.first?.["西部"] || [])]; + const semi = [...(r.semi?.["东部"] || []), ...(r.semi?.["西部"] || [])]; + const conf = [r.conf?.["东部"], r.conf?.["西部"]].filter(Boolean); + const final = r.final; + const col = (title, items) => `
${title}
${items.map(matchHtml).join("")}
`; + $("#wrap-bracket").innerHTML = ` +
+

🏆 季后赛对阵图

+ ${esc(bracketData?.season || standingsSeason)} 赛季 +
+
+ ${col("首轮", first)} + ${col("半决赛", semi)} + ${col("分区决赛", conf)} + ${col("总决赛", final ? [final] : [])} +
+
💡 点击对阵可查看比赛详情 · 胜者显示 🏆
`; +} + /* ================= 弹窗 ================= */ function openModal(html) { $("#modal-body").innerHTML = html; $("#modal").classList.remove("hidden"); } +/* 详情页链接(弹窗底部) */ +const DL = (type, id) => `查看完整详情 →`; $("#modal-close").addEventListener("click", () => $("#modal").classList.add("hidden")); $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") $("#modal").classList.add("hidden"); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#modal").classList.add("hidden"); }); diff --git a/static/detail.html b/static/detail.html new file mode 100644 index 0000000..edd508a --- /dev/null +++ b/static/detail.html @@ -0,0 +1,80 @@ + + + + + +详情 - NBA球迷大全 + + + + +
+ + +
+ +
+
+ +
+
+
⏳ 加载中…
+
+ +
NBA球迷大全 · 数据为模拟演示数据
+ + + + + diff --git a/static/detail.js b/static/detail.js new file mode 100644 index 0000000..2329733 --- /dev/null +++ b/static/detail.js @@ -0,0 +1,136 @@ +/* NBA球迷大全 独立详情页渲染(/team/1 /player/1 /game/1 /news/1 /person/1) */ +const $ = (s) => document.querySelector(s); +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +const STATUS_CN = { scheduled: "未开始", live: "进行中", finished: "已结束" }; + +async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error("记录不存在或已删除"); return r.json(); } + +/* 交叉链接 */ +const T = (id, name) => `${esc(name)}`; +const P = (id, name) => `${esc(name)}`; +const G = (id, text) => `${esc(text)}`; +const N = (id, title) => `${esc(title)}`; +const PER = (id, name) => `${esc(name)}`; + +/* ================= 球队 ================= */ +function renderTeam(d) { + const t = d.team; + const standings = (d.standings || []).map((s) => + `${esc(s.season)} ${esc(s.conference)}第${s.rank}名 · ${s.wins}-${s.losses}`).join(""); + const roster = (d.roster || []).map((p) => ` +
+ ${P(p.id, p.name)} #${p.number} · ${esc(p.position)} + 本季场均 ${p.season.pts} 分 / ${p.season.reb} 板 / ${p.season.ast} 助 +
`).join("") || '
暂无球员数据
'; + const games = (d.recent_games || []).map((g) => { + const score = g.status === "finished" ? `${g.away_score} : ${g.home_score}` : "VS"; + return `
+ ${G(g.id, `${g.away_team} ${score} ${g.home_team}`)} + ${esc(g.round_name)} · ${esc(g.game_time.slice(0, 16))} · ${STATUS_CN[g.status]} +
`; + }).join("") || '
暂无比赛
'; + $("#crumb").innerHTML = `🏟️ 球队 / ${esc(t.name)}`; + $("#detail-body").innerHTML = ` +
+
${esc(t.name)}
+
${esc(t.name_en)} · ${esc(t.city)} · ${esc(t.arena)} · 建队 ${t.founded} 年 · 总冠军 ×${t.champion_count}
+
+

📈 各赛季战绩

${standings || '暂无排名数据'}
+

📝 球队简介

${esc(t.intro || "暂无简介")}
+

🧑‍🏫 主教练

${esc(t.head_coach || "未知")}
+

⭐ 主要球员(本赛季场均)

${roster}
+

📅 近期比赛

${games}
`; +} + +/* ================= 球员 ================= */ +function renderPlayer(p) { + const logs = (p.game_logs || []).map((g) => { + const score = g.home_score !== null ? `${g.away_score} : ${g.home_score}` : "VS"; + return `
+ ${G(g.game_id, `${g.away_team} ${score} ${g.home_team}`)} ${esc(g.round_name)} · ${esc(g.game_time.slice(0, 10))} + ${g.pts}分 ${g.reb}板 ${g.ast}助 ${g.stl}断 ${g.blk}帽 ${g.min}分钟 +
`; + }).join("") || '
暂无比赛记录
'; + $("#crumb").innerHTML = `⭐ 球员 / ${esc(p.name)}`; + $("#detail-body").innerHTML = ` +
+
${esc(p.name)}
+
${esc(p.name_en || "")} · ${p.team ? T(p.team_id ?? 0, p.team) : "自由球员"} · ${esc(p.position)} · #${p.number} · ${esc(p.country)}
+
+

📋 基本信息

+
+
身高${p.height_cm} cm
+
体重${p.weight_kg} kg
+
选秀${esc(p.draft)}
+
年薪$${(p.salary_m / 100).toFixed(2)} 亿
+
本季场均${p.season.pts}分 ${p.season.reb}板 ${p.season.ast}助
+
本季防守${p.season.stl}断 ${p.season.blk}帽
+
生涯场均${p.career.pts}分 ${p.career.reb}板 ${p.career.ast}助
+
生涯场次${p.career.games} 场
+
+
+

🏅 主要荣誉

${esc(p.awards || "暂无")}
+

📝 球员简介

${esc(p.bio || "暂无")}
+

🏀 近期比赛记录

${logs}
`; +} + +/* ================= 比赛 ================= */ +function renderGame(g) { + const finished = g.status === "finished"; + const head = finished + ? `
${T(g.home_team_id, g.home_team)} ${g.home_score} : ${g.away_score} ${T(g.away_team_id, g.away_team)}
` + : `
${T(g.home_team_id, g.home_team)} VS ${T(g.away_team_id, g.away_team)}
`; + const stats = (g.box_score || []).map((s) => ` + ${esc(s.team_name)}${P(s.player_id, s.player_name)} + ${s.points}${s.rebounds}${s.assists} + ${s.steals}${s.blocks}${s.minutes}`).join(""); + $("#crumb").innerHTML = `📅 比赛 / ${esc(g.round_name)}`; + $("#detail-body").innerHTML = ` +
+
${esc(g.round_name)} · ${STATUS_CN[g.status]}
+ ${head} +
🕐 ${esc(g.game_time)} · ${esc(g.venue)} · ${esc(g.broadcast)}
+
+ ${stats ? `

📊 球员技术统计

+ ${stats}
球队球员得分篮板助攻抢断盖帽分钟
+
` : '

📊 球员技术统计

暂无统计
'}`; +} + +/* ================= 新闻/百科 ================= */ +function renderNews(n) { + $("#crumb").innerHTML = `📰 ${n.kind === "wiki" ? "百科" : "新闻"} / ${esc(n.title)}`; + $("#detail-body").innerHTML = ` +
+
${esc(n.title)}
+
🕐 ${esc(n.publish_time || "")} · ${esc(n.source || "")}${n.author ? " · " + esc(n.author) : ""} · ${n.kind === "wiki" ? "百科词条" : "新闻"}${n.tags ? " · " + esc(n.tags) : ""}
+
+

📄 正文

${esc(n.content || "")}
`; +} + +/* ================= 人物 ================= */ +function renderPerson(p) { + $("#crumb").innerHTML = `👥 人物 / ${esc(p.name)}`; + $("#detail-body").innerHTML = ` +
+
${esc(p.name)}
+
${esc(p.role_cn || p.role || "")} · ${esc(p.title || "")}${p.team_id ? " · " + T(p.team_id, p.team_name || "相关球队") : ""}
+
+

📝 人物简介

${esc(p.bio || "暂无")}
+

🏅 主要成就

${esc(p.achievements || "暂无")}
`; +} + +/* ================= 路由 ================= */ +const ROUTERS = { team: renderTeam, player: renderPlayer, game: renderGame, news: renderNews, person: renderPerson }; +const APIS = { team: "/api/teams/", player: "/api/players/", game: "/api/games/", news: "/api/news/", person: "/api/persons/" }; + +(async function init() { + const m = location.pathname.match(/^\/(team|player|game|news|person)\/(\d+)\/?$/); + if (!m) { $("#detail-body").innerHTML = '
❌ 地址无效
'; return; } + const [etype, eid] = [m[1], parseInt(m[2])]; + try { + const d = await getJSON(APIS[etype] + eid); + ROUTERS[etype](d); + } catch (e) { + $("#detail-body").innerHTML = `
❌ ${esc(e.message)}

← 返回首页
`; + } +})(); diff --git a/static/index.html b/static/index.html index 519f366..6c0822c 100644 --- a/static/index.html +++ b/static/index.html @@ -84,11 +84,14 @@
+ +
+
diff --git a/static/style.css b/static/style.css index de7980e..d7f1397 100644 --- a/static/style.css +++ b/static/style.css @@ -140,6 +140,23 @@ th { background: var(--bg2); color: var(--sub); font-weight: 600; font-size: 12. tr:hover td { background: rgba(249,115,22,.05); } td.num { text-align: center; } +/* ---------- 赛季选择 / 详情页链接 / 对阵图 ---------- */ +.season-label { color: var(--sub); font-size: 13px; } +.xlink { color: var(--blue); text-decoration: none; } +.xlink:hover { color: var(--orange2); text-decoration: underline; } +.detail-link { display: inline-block; color: var(--orange2); text-decoration: none; font-size: 13.5px; font-weight: 600; border: 1px solid #7c3a1e; background: rgba(249,115,22,.08); border-radius: 999px; padding: 6px 14px; transition: .15s; } +.detail-link:hover { background: rgba(249,115,22,.18); } +.bracket { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 6px; } +.bracket-col { flex: 1; min-width: 190px; background: var(--bg2); border: 1px solid var(--line); border-radius: 12px; padding: 10px; } +.bracket-col-title { color: var(--sub); font-size: 12px; text-align: center; margin-bottom: 8px; letter-spacing: 1px; } +.match { background: var(--card); border: 1px solid var(--line); border-radius: 10px; margin-bottom: 10px; overflow: hidden; } +.mteam { display: flex; justify-content: space-between; align-items: center; padding: 7px 10px; font-size: 12.5px; cursor: pointer; border-bottom: 1px dashed var(--line); } +.mteam:last-child { border-bottom: none; } +.mteam:hover { background: rgba(249,115,22,.08); } +.mteam.win { color: var(--green); font-weight: 700; } +.mteam-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mteam-wins { color: var(--orange2); font-weight: 800; margin-left: 6px; } + /* ---------- 弹窗 ---------- */ .modal { position: fixed; inset: 0; background: rgba(0,0,0,.7); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; } .modal.hidden { display: none; } diff --git a/tools.py b/tools.py index 9626a8d..522cace 100644 --- a/tools.py +++ b/tools.py @@ -68,6 +68,7 @@ def _fmt_team(t): def _fmt_player(p): return {"id": p["id"], "name": p["name"], "name_en": p["name_en"], "team": p.get("team_name"), + "team_id": p.get("team_id"), "position": p["position"], "number": p["number"], "height_cm": p["height_cm"], "weight_kg": p["weight_kg"], "country": p["country"], "draft": f"{p['draft_year']}年 第{p['draft_pick']}顺位" if p["draft_year"] else "落选秀", "salary_m": p["salary_m"], "season": {"pts": p["season_pts"], "reb": p["season_reb"], @@ -259,12 +260,15 @@ def get_game_detail(game_id): # ================================================================== 排名 -def search_standings(query_text, limit=20): +def search_standings(query_text, limit=20, season=None): q = (query_text or "").strip() tids = _match_team_ids(q) sql = """SELECT s.*, t.name AS team_name, t.code FROM standings s JOIN teams t ON s.team_id=t.id""" conds, args = [], [] + if season: + conds.append("s.season=?") + args.append(season) if tids: marks = ",".join("?" for _ in tids) conds.append(f"s.team_id IN ({marks})") @@ -276,8 +280,49 @@ def search_standings(query_text, limit=20): where = ("WHERE " + " AND ".join(conds)) if conds else "" rows = query(f"{sql} {where} ORDER BY s.conference DESC, s.rank ASC LIMIT ?", args + [limit]) return {"_source": "standings", "results": [ - {"team": r["team_name"], "code": r["code"], "conference": r["conference"], "rank": r["rank"], - "wins": r["wins"], "losses": r["losses"], "win_pct": round(r["win_pct"] * 100, 1)} for r in rows]} + {"team": r["team_name"], "team_id": r["team_id"], "code": r["code"], "conference": r["conference"], "rank": r["rank"], + "wins": r["wins"], "losses": r["losses"], "win_pct": round(r["win_pct"] * 100, 1), + "season": r["season"]} for r in rows]} + + +# ================================================================== 比赛结果(按赛季) +def team_games(team_id, season=None, limit=15): + """球队参与的比赛(详情页用),可按赛季过滤""" + conds, args = ["(g.home_team_id=? OR g.away_team_id=?)"], [team_id, team_id] + if season: + conds.append("g.season=?") + args.append(season) + rows = query(f"""SELECT g.*, ht.name AS home_name, ht.code AS home_code, + at.name AS away_name, at.code AS away_code + FROM games g JOIN teams ht ON g.home_team_id=ht.id JOIN teams at ON g.away_team_id=at.id + WHERE {" AND ".join(conds)} ORDER BY g.game_time DESC LIMIT ?""", + args + [limit]) + return [_fmt_game(r) for r in rows] + + +# ================================================================== 球员比赛记录(技术统计) +def player_game_logs(player_id, limit=10): + """球员参与的比赛与单场数据(详情页用)""" + rows = query("""SELECT gs.*, g.round_name, g.game_time, g.home_score, g.away_score, + ht.name AS home_name, at.name AS away_name + FROM game_player_stats gs + JOIN games g ON gs.game_id=g.id + JOIN teams ht ON g.home_team_id=ht.id JOIN teams at ON g.away_team_id=at.id + WHERE gs.player_id=? ORDER BY g.game_time DESC LIMIT ?""", (player_id, limit)) + out = [] + for r in rows: + out.append({"game_id": r["game_id"], "round_name": r["round_name"], "game_time": r["game_time"], + "home_team": r["home_name"], "away_team": r["away_name"], + "home_score": r["home_score"], "away_score": r["away_score"], + "pts": r["points"], "reb": r["rebounds"], "ast": r["assists"], + "stl": r["steals"], "blk": r["blocks"], "min": r["minutes"]}) + return out + + +# ================================================================== 新闻(SQL 关键词 + 向量语义 + 可选 rerank 融合) +def search_news(query_text, limit=5): + q = (query_text or "").strip() + seen, out = set(), [] # ================================================================== 新闻(SQL 关键词 + 向量语义 + 可选 rerank 融合)