v1.2.0 独立详情页+赛季选择+季后赛对阵图:/team|player|game|news|person/<id> 详情页(交叉链接);弹窗加查看完整详情;排名页多赛季切换(2024-25/2025-26);季后赛bracket对阵图(playoffs.py);seed_seasons补充数据
This commit is contained in:
@@ -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/<id>` | 球队列表/详情(含阵容+近期比赛) |
|
||||
| `GET /api/players[?q=]` `GET /api/players/<id>` | 球员列表/详情 |
|
||||
| `GET /api/games[?q=&status=]` `GET /api/games/<id>` | 比赛列表/详情(含技术统计) |
|
||||
| `GET /api/standings?conf=西部/东部` | 排名 |
|
||||
| `GET /api/standings?conf=西部/东部&season=2025-26` | 排名(多赛季) |
|
||||
| `GET /api/seasons` | 可用赛季列表 |
|
||||
| `GET /api/playoffs?season=2025-26` | 季后赛对阵图(首轮/半决/分区决赛/总决赛) |
|
||||
| `GET /team/<id>` `/player/<id>` `/game/<id>` `/news/<id>` `/person/<id>` | 独立详情页 |
|
||||
| `GET /api/news[?q=&kind=]` `GET /api/news/<id>` | 新闻/百科 |
|
||||
| `GET /api/persons[?q=&role=]` | 人物(coach/commentator/host/agent/gm/legend) |
|
||||
| `GET /api/health` | 健康检查 + 数据统计 |
|
||||
|
||||
@@ -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("/<etype>/<int:eid>")
|
||||
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/<path:path>")
|
||||
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/<int:pid>")
|
||||
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)
|
||||
|
||||
+144
@@ -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]
|
||||
+164
@@ -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"])
|
||||
+72
-3
@@ -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) {
|
||||
<div class="section-title">🧑🏫 主教练</div><div>${esc(t.head_coach)}</div>
|
||||
<div class="section-title">⭐ 主要球员(本赛季场均)</div>${roster || "暂无"}
|
||||
<div class="section-title">📅 近期比赛</div>${games || "暂无"}
|
||||
<div style="margin-top:16px;text-align:right">${DL("team", t.id)}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -229,6 +233,7 @@ async function openPlayer(id) {
|
||||
</div>
|
||||
<div class="section-title">🏅 荣誉</div><div style="font-size:13.5px;color:#c9d4e0">${esc(p.awards || "暂无")}</div>
|
||||
<div class="section-title">📝 简介</div><div style="font-size:13.5px;color:#c9d4e0">${esc(p.bio || "暂无")}</div>
|
||||
<div style="margin-top:16px;text-align:right">${DL("player", p.id)}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -280,6 +285,7 @@ async function openGame(id) {
|
||||
<div style="text-align:center;color:var(--sub);font-size:13px">🕐 ${esc(g.game_time)} · ${esc(g.venue)} · ${esc(g.broadcast)}</div>
|
||||
${stats ? `<div class="section-title">📊 球员技术统计</div>
|
||||
<table><tr><th>球队</th><th>球员</th><th>得分</th><th>篮板</th><th>助攻</th><th>抢断</th><th>盖帽</th><th>分钟</th></tr>${stats}</table>` : ""}
|
||||
<div style="margin-top:16px;text-align:right">${DL("game", g.id)}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -304,6 +310,7 @@ async function openNews(id) {
|
||||
<h2>${esc(n.title)}</h2>
|
||||
<div class="en">🕐 ${esc(n.publish_time)} · ${esc(n.source)} · ${esc(n.author || "")} · ${n.kind === "wiki" ? "百科词条" : "新闻"}</div>
|
||||
<div class="news-body" style="margin-top:12px">${esc(n.content).replace(/\n/g, "<br>")}</div>
|
||||
<div style="margin-top:16px;text-align:right">${DL("news", n.id)}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -334,19 +341,40 @@ async function openPerson(id) {
|
||||
<div class="en">${esc(p.role_cn)} · ${esc(p.title || "")}${p.team ? " · " + esc(p.team) : ""}</div>
|
||||
<div class="section-title">📝 简介</div><div style="font-size:14px;line-height:1.8;color:#c9d4e0">${esc(p.bio || "暂无")}</div>
|
||||
<div class="section-title">🏅 成就</div><div style="font-size:14px;line-height:1.8;color:#c9d4e0">${esc(p.achievements || "暂无")}</div>
|
||||
<div style="margin-top:16px;text-align:right">${DL("person", p.id)}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
/* ================= 排名 ================= */
|
||||
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) => `<option value="${esc(s)}">${esc(s)} 赛季</option>`).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]) => `
|
||||
<h3 style="margin:14px 0 8px;color:var(--orange2)">${conf}赛区</h3>
|
||||
<table><tr><th>排名</th><th>球队</th><th>胜</th><th>负</th><th>胜率</th><th>战绩</th></tr>
|
||||
${list.map((r) => `<tr><td class="num">${r.rank}</td><td>${esc(r.team)}</td>
|
||||
${list.map((r) => `<tr><td class="num">${r.rank}</td><td><a class="xlink" href="/team/${r.team_id}">${esc(r.team)}</a></td>
|
||||
<td class="num">${r.wins}</td><td class="num">${r.losses}</td>
|
||||
<td class="num">${r.win_pct}%</td><td class="num">${r.wins}-${r.losses}</td></tr>`).join("")}
|
||||
</table>`).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 = `<div class="meta">对阵图加载失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function matchHtml(m) {
|
||||
if (!m) return "";
|
||||
const gid = (m.games || [])[0];
|
||||
const row = (side) => `<div class="mteam ${side.advance ? "win" : ""}" ${gid ? `onclick="openGame(${gid})"` : ""}>
|
||||
<span class="mteam-name">${side.advance ? "🏆 " : ""}${esc(side.team)}</span><span class="mteam-wins">${side.wins}</span></div>`;
|
||||
return `<div class="match">${row(m.home)}${row(m.away)}</div>`;
|
||||
}
|
||||
|
||||
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) => `<div class="bracket-col"><div class="bracket-col-title">${title}</div>${items.map(matchHtml).join("")}</div>`;
|
||||
$("#wrap-bracket").innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin:14px 0 8px">
|
||||
<h3 style="color:var(--orange2)">🏆 季后赛对阵图</h3>
|
||||
<span class="season-pill">${esc(bracketData?.season || standingsSeason)} 赛季</span>
|
||||
</div>
|
||||
<div class="bracket">
|
||||
${col("首轮", first)}
|
||||
${col("半决赛", semi)}
|
||||
${col("分区决赛", conf)}
|
||||
${col("总决赛", final ? [final] : [])}
|
||||
</div>
|
||||
<div class="meta" style="margin-top:8px">💡 点击对阵可查看比赛详情 · 胜者显示 🏆</div>`;
|
||||
}
|
||||
|
||||
/* ================= 弹窗 ================= */
|
||||
function openModal(html) {
|
||||
$("#modal-body").innerHTML = html;
|
||||
$("#modal").classList.remove("hidden");
|
||||
}
|
||||
/* 详情页链接(弹窗底部) */
|
||||
const DL = (type, id) => `<a class="detail-link" href="/${type}/${id}" target="_blank">查看完整详情 →</a>`;
|
||||
$("#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"); });
|
||||
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>详情 - NBA球迷大全</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<style>
|
||||
.detail-bar { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; flex-wrap: wrap; }
|
||||
.back-btn { background: var(--bg2); border: 1px solid var(--line); color: var(--txt); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-size: 13.5px; }
|
||||
.back-btn:hover { border-color: var(--orange2); color: var(--orange2); }
|
||||
.detail-title { font-size: 24px; font-weight: 800; }
|
||||
.detail-title .en { color: var(--sub); font-size: 14px; font-weight: 400; margin-left: 8px; }
|
||||
.detail-sub { color: var(--sub); font-size: 13.5px; margin-top: 4px; }
|
||||
.hero { background: linear-gradient(135deg, #1a120b, #1c2333); border: 1px solid var(--line); border-radius: 16px; padding: 24px 26px; margin-bottom: 18px; }
|
||||
.hero .big { font-size: 26px; font-weight: 800; background: linear-gradient(90deg, #fb923c, #f97316, #fbbf24); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.kv { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px 14px; margin: 16px 0; }
|
||||
.kv div { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
|
||||
.kv .k { color: var(--sub); font-size: 11.5px; display: block; }
|
||||
.kv .v { font-size: 14.5px; font-weight: 600; }
|
||||
.section { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px; margin-bottom: 16px; }
|
||||
.section h3 { font-size: 15.5px; color: var(--orange2); margin-bottom: 12px; }
|
||||
.section .txt { font-size: 14px; line-height: 1.9; color: #c9d4e0; }
|
||||
.xlink { color: var(--blue); text-decoration: none; }
|
||||
.xlink:hover { text-decoration: underline; color: var(--orange2); }
|
||||
.row-item { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 8px 2px; border-bottom: 1px dashed var(--line); font-size: 13.5px; flex-wrap: wrap; }
|
||||
.row-item:last-child { border-bottom: none; }
|
||||
.num-badge { color: var(--orange2); font-weight: 700; }
|
||||
.win { color: var(--green); font-weight: 700; }
|
||||
.lose { color: var(--sub); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 9px 11px; text-align: left; font-size: 13px; border-bottom: 1px solid var(--line); }
|
||||
th { background: var(--bg2); color: var(--sub); font-size: 12px; }
|
||||
td.num { text-align: center; }
|
||||
.score-head { display: flex; align-items: center; justify-content: center; gap: 16px; font-size: 22px; margin: 10px 0 6px; flex-wrap: wrap; }
|
||||
.score-head .s { font-weight: 800; font-size: 30px; }
|
||||
.news-body { line-height: 2; font-size: 14.5px; color: #c9d4e0; white-space: pre-wrap; }
|
||||
.season-pill { display: inline-block; background: rgba(56,189,248,.1); color: var(--blue); border-radius: 999px; padding: 2px 10px; font-size: 12px; margin: 2px 4px 2px 0; }
|
||||
.loading { text-align: center; color: var(--sub); padding: 80px 0; font-size: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="logo">
|
||||
<span class="logo-ball">🏀</span>
|
||||
<div>
|
||||
<h1 id="site-name">NBA球迷大全</h1>
|
||||
<p class="sub">详情页</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="tabs">
|
||||
<a class="tab" href="/">💬 对话</a>
|
||||
<a class="tab" href="/#/teams">🏟️ 球队</a>
|
||||
<a class="tab" href="/#/players">⭐ 球员</a>
|
||||
<a class="tab" href="/#/games">📅 比赛</a>
|
||||
<a class="tab" href="/#/news">📰 新闻</a>
|
||||
<a class="tab" href="/#/persons">👥 人物</a>
|
||||
<a class="tab" href="/#/standings">🏆 排名</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="detail-bar">
|
||||
<button class="back-btn" onclick="history.length > 1 ? history.back() : (location.href='/')">← 返回</button>
|
||||
<div id="crumb"></div>
|
||||
</div>
|
||||
<div id="detail-body"><div class="loading">⏳ 加载中…</div></div>
|
||||
</main>
|
||||
|
||||
<footer class="footer" id="footer-text">NBA球迷大全 · 数据为模拟演示数据</footer>
|
||||
|
||||
<script>
|
||||
fetch("/api/boot").then(r => r.json()).then(b => {
|
||||
if (b.site_name) { document.title = b.site_name + " · 详情"; document.getElementById("site-name").textContent = b.site_name; }
|
||||
if (b.footer_text) document.getElementById("footer-text").textContent = b.footer_text;
|
||||
}).catch(() => {});
|
||||
</script>
|
||||
<script src="/static/detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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) => `<a class="xlink" href="/team/${id}">${esc(name)}</a>`;
|
||||
const P = (id, name) => `<a class="xlink" href="/player/${id}">${esc(name)}</a>`;
|
||||
const G = (id, text) => `<a class="xlink" href="/game/${id}">${esc(text)}</a>`;
|
||||
const N = (id, title) => `<a class="xlink" href="/news/${id}">${esc(title)}</a>`;
|
||||
const PER = (id, name) => `<a class="xlink" href="/person/${id}">${esc(name)}</a>`;
|
||||
|
||||
/* ================= 球队 ================= */
|
||||
function renderTeam(d) {
|
||||
const t = d.team;
|
||||
const standings = (d.standings || []).map((s) =>
|
||||
`<span class="season-pill">${esc(s.season)} ${esc(s.conference)}第${s.rank}名 · ${s.wins}-${s.losses}</span>`).join("");
|
||||
const roster = (d.roster || []).map((p) => `
|
||||
<div class="row-item">
|
||||
<span>${P(p.id, p.name)} <span class="en">#${p.number} · ${esc(p.position)}</span></span>
|
||||
<span>本季场均 <span class="num-badge">${p.season.pts}</span> 分 / ${p.season.reb} 板 / ${p.season.ast} 助</span>
|
||||
</div>`).join("") || '<div class="txt">暂无球员数据</div>';
|
||||
const games = (d.recent_games || []).map((g) => {
|
||||
const score = g.status === "finished" ? `<span class="num-badge">${g.away_score} : ${g.home_score}</span>` : "VS";
|
||||
return `<div class="row-item">
|
||||
<span>${G(g.id, `${g.away_team} ${score} ${g.home_team}`)}</span>
|
||||
<span class="en">${esc(g.round_name)} · ${esc(g.game_time.slice(0, 16))} · ${STATUS_CN[g.status]}</span>
|
||||
</div>`;
|
||||
}).join("") || '<div class="txt">暂无比赛</div>';
|
||||
$("#crumb").innerHTML = `<span class="en">🏟️ 球队 / ${esc(t.name)}</span>`;
|
||||
$("#detail-body").innerHTML = `
|
||||
<div class="hero">
|
||||
<div class="big">${esc(t.name)}</div>
|
||||
<div class="detail-sub">${esc(t.name_en)} · ${esc(t.city)} · ${esc(t.arena)} · 建队 ${t.founded} 年 · 总冠军 ×${t.champion_count}</div>
|
||||
</div>
|
||||
<div class="section"><h3>📈 各赛季战绩</h3>${standings || '<span class="txt">暂无排名数据</span>'}</div>
|
||||
<div class="section"><h3>📝 球队简介</h3><div class="txt">${esc(t.intro || "暂无简介")}</div></div>
|
||||
<div class="section"><h3>🧑🏫 主教练</h3><div class="txt">${esc(t.head_coach || "未知")}</div></div>
|
||||
<div class="section"><h3>⭐ 主要球员(本赛季场均)</h3>${roster}</div>
|
||||
<div class="section"><h3>📅 近期比赛</h3>${games}</div>`;
|
||||
}
|
||||
|
||||
/* ================= 球员 ================= */
|
||||
function renderPlayer(p) {
|
||||
const logs = (p.game_logs || []).map((g) => {
|
||||
const score = g.home_score !== null ? `${g.away_score} : ${g.home_score}` : "VS";
|
||||
return `<div class="row-item">
|
||||
<span>${G(g.game_id, `${g.away_team} ${score} ${g.home_team}`)} <span class="en">${esc(g.round_name)} · ${esc(g.game_time.slice(0, 10))}</span></span>
|
||||
<span><span class="num-badge">${g.pts}</span>分 ${g.reb}板 ${g.ast}助 ${g.stl}断 ${g.blk}帽 ${g.min}分钟</span>
|
||||
</div>`;
|
||||
}).join("") || '<div class="txt">暂无比赛记录</div>';
|
||||
$("#crumb").innerHTML = `<span class="en">⭐ 球员 / ${esc(p.name)}</span>`;
|
||||
$("#detail-body").innerHTML = `
|
||||
<div class="hero">
|
||||
<div class="big">${esc(p.name)}</div>
|
||||
<div class="detail-sub">${esc(p.name_en || "")} · ${p.team ? T(p.team_id ?? 0, p.team) : "自由球员"} · ${esc(p.position)} · #${p.number} · ${esc(p.country)}</div>
|
||||
</div>
|
||||
<div class="section"><h3>📋 基本信息</h3>
|
||||
<div class="kv">
|
||||
<div><span class="k">身高</span><span class="v">${p.height_cm} cm</span></div>
|
||||
<div><span class="k">体重</span><span class="v">${p.weight_kg} kg</span></div>
|
||||
<div><span class="k">选秀</span><span class="v">${esc(p.draft)}</span></div>
|
||||
<div><span class="k">年薪</span><span class="v">$${(p.salary_m / 100).toFixed(2)} 亿</span></div>
|
||||
<div><span class="k">本季场均</span><span class="v">${p.season.pts}分 ${p.season.reb}板 ${p.season.ast}助</span></div>
|
||||
<div><span class="k">本季防守</span><span class="v">${p.season.stl}断 ${p.season.blk}帽</span></div>
|
||||
<div><span class="k">生涯场均</span><span class="v">${p.career.pts}分 ${p.career.reb}板 ${p.career.ast}助</span></div>
|
||||
<div><span class="k">生涯场次</span><span class="v">${p.career.games} 场</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section"><h3>🏅 主要荣誉</h3><div class="txt">${esc(p.awards || "暂无")}</div></div>
|
||||
<div class="section"><h3>📝 球员简介</h3><div class="txt">${esc(p.bio || "暂无")}</div></div>
|
||||
<div class="section"><h3>🏀 近期比赛记录</h3>${logs}</div>`;
|
||||
}
|
||||
|
||||
/* ================= 比赛 ================= */
|
||||
function renderGame(g) {
|
||||
const finished = g.status === "finished";
|
||||
const head = finished
|
||||
? `<div class="score-head">${T(g.home_team_id, g.home_team)} <span class="s">${g.home_score}</span> : <span class="s">${g.away_score}</span> ${T(g.away_team_id, g.away_team)}</div>`
|
||||
: `<div class="score-head">${T(g.home_team_id, g.home_team)} VS ${T(g.away_team_id, g.away_team)}</div>`;
|
||||
const stats = (g.box_score || []).map((s) => `
|
||||
<tr><td>${esc(s.team_name)}</td><td>${P(s.player_id, s.player_name)}</td>
|
||||
<td class="num">${s.points}</td><td class="num">${s.rebounds}</td><td class="num">${s.assists}</td>
|
||||
<td class="num">${s.steals}</td><td class="num">${s.blocks}</td><td class="num">${s.minutes}</td></tr>`).join("");
|
||||
$("#crumb").innerHTML = `<span class="en">📅 比赛 / ${esc(g.round_name)}</span>`;
|
||||
$("#detail-body").innerHTML = `
|
||||
<div class="hero">
|
||||
<div style="text-align:center;color:var(--sub);font-size:13px">${esc(g.round_name)} · ${STATUS_CN[g.status]}</div>
|
||||
${head}
|
||||
<div style="text-align:center;color:var(--sub);font-size:13px;margin-top:6px">🕐 ${esc(g.game_time)} · ${esc(g.venue)} · ${esc(g.broadcast)}</div>
|
||||
</div>
|
||||
${stats ? `<div class="section"><h3>📊 球员技术统计</h3>
|
||||
<table><tr><th>球队</th><th>球员</th><th>得分</th><th>篮板</th><th>助攻</th><th>抢断</th><th>盖帽</th><th>分钟</th></tr>${stats}</table>
|
||||
</div>` : '<div class="section"><h3>📊 球员技术统计</h3><div class="txt">暂无统计</div></div>'}`;
|
||||
}
|
||||
|
||||
/* ================= 新闻/百科 ================= */
|
||||
function renderNews(n) {
|
||||
$("#crumb").innerHTML = `<span class="en">📰 ${n.kind === "wiki" ? "百科" : "新闻"} / ${esc(n.title)}</span>`;
|
||||
$("#detail-body").innerHTML = `
|
||||
<div class="hero">
|
||||
<div class="big">${esc(n.title)}</div>
|
||||
<div class="detail-sub">🕐 ${esc(n.publish_time || "")} · ${esc(n.source || "")}${n.author ? " · " + esc(n.author) : ""} · ${n.kind === "wiki" ? "百科词条" : "新闻"}${n.tags ? " · " + esc(n.tags) : ""}</div>
|
||||
</div>
|
||||
<div class="section"><h3>📄 正文</h3><div class="news-body">${esc(n.content || "")}</div></div>`;
|
||||
}
|
||||
|
||||
/* ================= 人物 ================= */
|
||||
function renderPerson(p) {
|
||||
$("#crumb").innerHTML = `<span class="en">👥 人物 / ${esc(p.name)}</span>`;
|
||||
$("#detail-body").innerHTML = `
|
||||
<div class="hero">
|
||||
<div class="big">${esc(p.name)}</div>
|
||||
<div class="detail-sub">${esc(p.role_cn || p.role || "")} · ${esc(p.title || "")}${p.team_id ? " · " + T(p.team_id, p.team_name || "相关球队") : ""}</div>
|
||||
</div>
|
||||
<div class="section"><h3>📝 人物简介</h3><div class="txt">${esc(p.bio || "暂无")}</div></div>
|
||||
<div class="section"><h3>🏅 主要成就</h3><div class="txt">${esc(p.achievements || "暂无")}</div></div>`;
|
||||
}
|
||||
|
||||
/* ================= 路由 ================= */
|
||||
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 = '<div class="loading">❌ 地址无效</div>'; 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 = `<div class="loading">❌ ${esc(e.message)}<br><br><a class="tab" href="/">← 返回首页</a></div>`;
|
||||
}
|
||||
})();
|
||||
@@ -84,11 +84,14 @@
|
||||
</section>
|
||||
<section id="view-standings" class="view">
|
||||
<div class="toolbar">
|
||||
<label class="season-label" for="season-select">📅 赛季</label>
|
||||
<select id="season-select" class="search" style="flex:0 0 auto;max-width:160px"></select>
|
||||
<button class="btn small active" data-conf="">全部</button>
|
||||
<button class="btn small" data-conf="西部">西部</button>
|
||||
<button class="btn small" data-conf="东部">东部</button>
|
||||
</div>
|
||||
<div class="table-wrap" id="wrap-standings"></div>
|
||||
<div id="wrap-bracket"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 融合)
|
||||
|
||||
Reference in New Issue
Block a user