Files
nba-fan-hub/static/app.js
T

670 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* NBA球迷大全 前端逻辑(原生 JS,无构建) */
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const chatHistory = [];
let chatBusy = false;
/* 简洁版当前时间 HH:MM */
function nowTime() {
const d = new Date();
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
/* 轻提示 */
function toast(msg) {
let t = document.getElementById("toast");
if (!t) { t = document.createElement("div"); t.id = "toast"; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add("show");
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove("show"), 1800);
}
/* 复制文本(Clipboard API + 降级) */
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
} catch (e) {
const ta = document.createElement("textarea");
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
document.execCommand("copy");
ta.remove();
}
toast("✅ 已复制");
}
/* ================= Markdown 渲染(marked 本地库,先转义防 XSS ================= */
function md(text) {
if (window.marked) {
marked.setOptions({ breaks: true, gfm: true });
return marked.parse(esc(text));
}
return esc(text).replace(/\n/g, "<br>");
}
/* 实体高亮:先用占位符替换实体区间(避免 markdown 转换后偏移错位),渲染后再恢复为可点击标签 */
function renderMdWithEntities(reply, entities) {
let text = reply || "";
const phs = [];
if (entities && entities.length) {
const sorted = [...entities].sort((a, b) => b.start - a.start);
for (const sp of sorted) {
if (sp.start < 0 || sp.end > text.length || sp.start >= sp.end) continue;
const ph = `\u27E6E${phs.length}\u27E7`;
phs.push(sp);
text = text.slice(0, sp.start) + ph + text.slice(sp.end);
}
}
let html = md(text);
phs.forEach((sp, i) => {
const ph = `\u27E6E${i}\u27E7`;
if (html.includes(ph)) {
const tag = `<span class="entity entity-${sp.type}" data-type="${sp.type}" data-id="${sp.id}" data-name="${esc(sp.name)}">${esc(sp.name)}</span>`;
html = html.split(ph).join(tag);
}
});
return html;
}
/* ================= 标签页切换 ================= */
$$(".tab").forEach((t) => t.addEventListener("click", () => {
$$(".tab").forEach((x) => x.classList.remove("active"));
$$(".view").forEach((x) => x.classList.remove("active"));
t.classList.add("active");
$("#view-" + t.dataset.view).classList.add("active");
loadView(t.dataset.view);
}));
function loadView(v) {
if (v === "teams") loadTeams();
else if (v === "players") {
playersPage = 1; playersQ = ""; playersPos = "";
$("#search-players").value = "";
$$("#view-players .btn.small").forEach((x) => x.classList.toggle("active", x.dataset.pos === ""));
loadPlayers();
}
else if (v === "games") {
gamesPage = 1; gamesQ = ""; gamesStatus = "";
$("#search-games").value = "";
$$("#view-games .btn.small").forEach((x) => x.classList.toggle("active", x.dataset.status === ""));
loadGames();
}
else if (v === "news") {
newsPage = 1; newsQ = ""; newsKind = ""; newsTag = "";
$("#search-news").value = "";
$$("#view-news .btn.small").forEach((x) => x.classList.toggle("active", !x.dataset.kind && !x.dataset.tag));
loadNews();
}
else if (v === "persons") {
personsPage = 1; personsQ = ""; personsRole = "";
$("#search-persons").value = "";
$$("#view-persons .btn.small").forEach((x) => x.classList.toggle("active", x.dataset.role === ""));
loadPersons();
}
else if (v === "standings") {
if ($("#season-select").options.length) { loadStandings(); loadPlayoffs(); }
else loadSeasons();
}
}
/* ================= 对话 ================= */
function addMsg(role, html, opts = {}) {
const div = document.createElement("div");
div.className = `msg ${role}`;
if (opts.idx !== undefined) div.dataset.idx = opts.idx;
const actions = role === "bot"
? `<div class="msg-actions"><button class="act-btn" title="复制回答">📋</button><button class="act-btn" title="重新生成">🔄</button><span class="msg-time">${nowTime()}</span></div>`
: `<div class="msg-actions user-time"><span class="msg-time">${nowTime()}</span></div>`;
div.innerHTML = `<div class="avatar">${role === "user" ? "🧑" : "🤖"}</div><div class="msg-body"><div class="bubble">${html}</div>${actions}</div>`;
$("#chat-list").appendChild(div);
$("#chat-list").scrollTop = $("#chat-list").scrollHeight;
return div;
}
function showTyping() {
const div = document.createElement("div");
div.className = "msg bot";
div.innerHTML = `<div class="avatar">🤖</div><div class="msg-body"><div class="bubble"><span class="typing"><i></i><i></i><i></i></span></div></div>`;
$("#chat-list").appendChild(div);
$("#chat-list").scrollTop = $("#chat-list").scrollHeight;
return div;
}
/* 实体快速查看卡片 HTML */
function cardHtml(c) {
if (c.type === "team") return `<div class="ecard" data-type="team" data-id="${c.id}"><span class="eico">🏟️</span><div><b>${esc(c.name)}</b><small>${esc(c.city || "")} · 总冠军×${c.champion_count ?? "?"}</small></div></div>`;
if (c.type === "player") return `<div class="ecard" data-type="player" data-id="${c.id}"><span class="eico">⭐</span><div><b>${esc(c.name)}</b><small>${esc(c.team || "自由球员")}${c.season ? " · " + (c.season.pts ?? "?") + "分" : ""}</small></div></div>`;
if (c.type === "person") return `<div class="ecard" data-type="person" data-id="${c.id}"><span class="eico">👤</span><div><b>${esc(c.name)}</b><small>${esc(c.role_cn || "")}</small></div></div>`;
if (c.type === "game") {
const score = c.status === "finished" ? `${c.away_score ?? "?"} : ${c.home_score ?? "?"}` : "VS";
return `<div class="ecard" data-type="game" data-id="${c.id}"><span class="eico">🏀</span><div><b>${esc(c.away_team || "")} ${score} ${esc(c.home_team || "")}</b><small>${esc(c.round_name || "")} · ${esc((c.game_time || "").slice(0, 16))}</small></div></div>`;
}
return "";
}
/* 组装回答气泡 HTML(参考资讯 + markdown实体 + 卡片 + 来源) */
function buildBotHtml(d) {
let html = "";
// 需求4:参考资讯(新闻/百科链接),默认折叠,位于回答块上方
if (d.news_refs && d.news_refs.length) {
html += `<details class="refs"><summary>📰 参考资讯(${d.news_refs.length}</summary><ul>` +
d.news_refs.map((n) => `<li><a href="#" data-news="${n.id}">${esc(n.title)}</a>` +
(n.source ? `<span class="ref-src">${esc(n.source)}${n.publish_time ? " · " + esc(n.publish_time.slice(0, 10)) : ""}</span>` : "") + `</li>`).join("") +
`</ul></details>`;
}
// 需求3markdown 渲染 + 需求5:实体特殊标记
html += `<div class="md">${renderMdWithEntities(d.reply, d.entities)}</div>`;
// 需求5:快速查看入口卡片
if (d.cards && d.cards.length) {
html += `<div class="entity-cards">${d.cards.map(cardHtml).join("")}</div>`;
}
if (d.sources && d.sources.length) {
html += "<div class='src-line'>" + d.sources.map((s) => `<span class="src-tag">来源:${esc(s.tool)}</span>`).join("") + "</div>";
}
return html;
}
async function sendChat(text) {
if (chatBusy) return;
chatBusy = true;
$("#send-btn").disabled = true;
addMsg("user", esc(text));
const typing = showTyping();
try {
const r = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, history: chatHistory.slice(-8) }) });
const d = await r.json();
typing.remove();
if (d.error) { addMsg("bot", `⚠️ ${esc(d.error)}`); return; }
chatHistory.push({ user: text, assistant: d.reply });
const idx = chatHistory.length - 1;
addMsg("bot", buildBotHtml(d), { idx });
if (chatHistory.length > 20) {
const removed = chatHistory.length - 20;
chatHistory.splice(0, removed);
document.querySelectorAll("#chat-list .msg[data-idx]").forEach((m) => {
const i = parseInt(m.dataset.idx);
if (i < removed) m.remove();
else m.dataset.idx = i - removed;
});
}
// 大模型预测下一轮快捷问题(异步刷新底部 chips)
const mark = chatHistory.length;
refreshChips(mark);
} catch (e) {
typing.remove();
addMsg("bot", "⚠️ 网络异常,请稍后再试。");
} finally {
chatBusy = false;
$("#send-btn").disabled = false;
}
}
$("#send-btn").addEventListener("click", () => { const v = $("#chat-input").value.trim(); if (v) { $("#chat-input").value = ""; autoResizeInput(); sendChat(v); } });
/* 输入框多行自适应:超一行自动增高,最多 4 行(约90px);Enter 发送,Shift+Enter 换行 */
const chatInput = $("#chat-input");
const INPUT_MAX_H = 90;
function autoResizeInput() {
chatInput.style.height = "auto";
chatInput.style.height = Math.min(chatInput.scrollHeight, INPUT_MAX_H) + "px";
}
chatInput.addEventListener("input", autoResizeInput);
chatInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
$("#send-btn").click();
}
});
/* 复制当前回答(纯文本) */
function copyMsg(btn) {
const bubble = btn.closest(".msg").querySelector(".bubble");
copyText(bubble.innerText.trim());
}
/* 重新生成:以该轮之前的上下文重新提问,替换本条回答,截断后续对话 */
async function regenerate(btn) {
const msgEl = btn.closest(".msg");
const idx = parseInt(msgEl.dataset.idx);
if (isNaN(idx) || chatBusy) return;
if (idx >= chatHistory.length) return;
// 截断:删除该条之后的对话(上下文已变)
chatHistory.splice(idx + 1);
document.querySelectorAll("#chat-list .msg[data-idx]").forEach((m) => {
if (parseInt(m.dataset.idx) > idx) m.remove();
});
const userText = chatHistory[idx].user;
const body = msgEl.querySelector(".msg-body");
const bubble = msgEl.querySelector(".bubble");
bubble.innerHTML = `<span class="typing"><i></i><i></i><i></i></span>`;
msgEl.querySelector(".msg-actions")?.remove();
chatBusy = true;
try {
const r = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: userText, history: chatHistory.slice(0, idx).map((h) => ({ user: h.user, assistant: h.assistant })) }) });
const d = await r.json();
if (d.error) { bubble.innerHTML = `⚠️ ${esc(d.error)}`; return; }
bubble.innerHTML = buildBotHtml(d);
const actions = document.createElement("div");
actions.className = "msg-actions";
actions.innerHTML = `<button class="act-btn" title="复制回答">📋</button><button class="act-btn" title="重新生成">🔄</button><span class="msg-time">${nowTime()}</span>`;
body.appendChild(actions);
chatHistory[idx] = { user: userText, assistant: d.reply };
refreshChips(chatHistory.length);
} catch (e) {
bubble.innerHTML = "⚠️ 网络异常,请稍后再试。";
} finally {
chatBusy = false;
}
}
/* 分享:弹窗展示对话全文 + 一键复制 */
function shareChat() {
if (!chatHistory.length) { toast("还没有对话内容"); return; }
const text = chatHistory.map((h) => `🧑 ${h.user}\n🤖 ${h.assistant}`).join("\n\n");
openModal(`
<h2>🔗 分享对话</h2>
<div class="en">共 ${chatHistory.length} 轮 · 复制后粘贴到任意聊天或文档</div>
<textarea readonly class="share-box" style="width:100%;height:280px;margin-top:10px;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:12px;color:var(--txt);font-size:13px;line-height:1.7;resize:vertical">${esc(text)}</textarea>
<div style="margin-top:12px;text-align:right"><button class="btn" onclick="copyShare()">📋 复制全文</button></div>
`);
}
function copyShare() {
const ta = document.querySelector(".share-box");
if (ta) copyText(ta.value);
}
/* 清空对话从头开始 */
function clearChat() {
if (!chatHistory.length) { toast("对话已经是空的"); return; }
if (!confirm("确定清空当前对话吗?将从头开始。")) return;
chatHistory.length = 0;
const list = $("#chat-list");
const welcome = document.getElementById("welcome-msg");
const w = welcome ? welcome.outerHTML : "";
list.innerHTML = w;
loadBoot();
toast("🗑️ 对话已清空");
}
$("#share-btn").addEventListener("click", shareChat);
$("#clear-btn").addEventListener("click", clearChat);
/* 快捷问题:点击 → 自动填入输入框并自动提交(需求2) */
function askQuick(q) {
$("#chat-input").value = q;
autoResizeInput();
sendChat(q);
}
/* 底部快捷问题:对话进行中由大模型预测用户可能追问的问题(异步刷新) */
async function refreshChips(mark) {
try {
const r = await fetch("/api/suggest", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ history: chatHistory.slice(-6) }) });
const d = await r.json();
if (!d.suggestions || !d.suggestions.length) return;
if (mark !== chatHistory.length) return; // 期间用户又发了新消息 → 丢弃过期预测
$("#chips").innerHTML = d.suggestions.map((q) => `<button>${esc(q)}</button>`).join("");
$$("#chips button").forEach((b) => b.addEventListener("click", () => askQuick(b.textContent)));
} catch (e) {}
}
/* 聊天区事件委托:快捷语句 / 操作按钮(复制·重新生成) / 实体标记 / 新闻链接 / 卡片 */
$("#chat-list").addEventListener("click", (e) => {
const act = e.target.closest(".act-btn");
if (act) {
if (act.title.includes("复制")) copyMsg(act);
else if (act.title.includes("重新生成")) regenerate(act);
return;
}
const q = e.target.closest(".quick-q");
if (q) { e.preventDefault(); askQuick(q.textContent); return; }
const ent = e.target.closest(".entity, .ecard");
if (ent && ent.dataset.type) { openEntity(ent.dataset.type, parseInt(ent.dataset.id)); return; }
const news = e.target.closest("a[data-news]");
if (news) { e.preventDefault(); openNews(parseInt(news.dataset.news)); return; }
});
function openEntity(type, id) {
try {
if (type === "team") openTeam(id);
else if (type === "player") openPlayer(id);
else if (type === "person") openPerson(id);
else if (type === "game") openGame(id);
} catch (e) {
openModal(`<h2>⚠️ 打开失败</h2><p style="color:var(--sub)">该${type}记录可能已被删除(ID=${id})。</p>`);
}
}
/* 启动信息:开场白 + 快捷问题(从配置加载,管理后台可编辑) */
async function loadBoot() {
try {
const b = await (await fetch("/api/boot")).json();
if (b.welcome_text) $("#welcome-text").innerHTML = md(b.welcome_text);
const qs = b.suggestions || [];
const hint = (b.welcome_hint || "试试:") + " ";
$("#welcome-hint").innerHTML = hint + qs.map((q) => `<a class="quick-q" href="#">${esc(q)}</a>`).join(" / ");
$("#chips").innerHTML = qs.map((q) => `<button>${esc(q)}</button>`).join("");
$$("#chips button").forEach((b2) => b2.addEventListener("click", () => askQuick(b2.textContent)));
if (b.site_name) document.title = b.site_name;
if (b.footer_text) $("#footer-text").textContent = b.footer_text;
} catch (e) {}
}
loadBoot();
/* ================= 通用请求 ================= */
async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(r.status); return r.json(); }
/* ================= 球队 ================= */
async function loadTeams() {
const teams = await getJSON("/api/teams?limit=50");
$("#grid-teams").innerHTML = teams.map((t) => `
<div class="card" onclick="openTeam(${t.id})">
<h3>${esc(t.name)} <span class="en">${esc(t.name_en)}</span></h3>
<div class="meta">🏙️ ${esc(t.city)} · ${esc(t.arena)}<br>🏆 总冠军 ×${t.champion_count} · 建队 ${t.founded} 年<br>🧑‍🏫 主帅:${esc(t.head_coach)}</div>
</div>`).join("");
}
async function openTeam(id) {
const d = await getJSON(`/api/teams/${id}`);
const t = d.team;
const roster = d.roster.map((p) => `<div>${esc(p.name)} <span class="en">${esc(p.position)} #${p.number}</span> — 场均 ${p.season.pts}分</div>`).join("");
const games = d.recent_games.map((g) => `<div>${esc(g.game_time.slice(0, 10))} ${esc(g.away_team)} ${g.away_score ?? "?"} : ${g.home_score ?? "?"} ${esc(g.home_team)}${g.status === "finished" ? "已结束" : "未开始"}</div>`).join("");
openModal(`
<h2>${esc(t.name)} <span class="en">${esc(t.name_en)}</span></h2>
<div class="en">${esc(t.city)} · ${esc(t.arena)} · 建队 ${t.founded} · 总冠军 ×${t.champion_count}</div>
<p style="margin-top:8px;font-size:14px;color:#c9d4e0">${esc(t.intro)}</p>
<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>
`);
}
/* ================= 球员 ================= */
let playersPage = 1, playersQ = "", playersPos = "";
const POS_CN = { PG: "控卫", SG: "分卫", SF: "小前", PF: "大前", C: "中锋" };
async function loadPlayers() {
const d = await getJSON(`/api/players?q=${encodeURIComponent(playersQ)}&position=${playersPos}&page=${playersPage}&size=24`);
const players = d.results || [];
$("#grid-players").innerHTML = players.map((p) => `
<div class="card" onclick="openPlayer(${p.id})">
<h3>${esc(p.name)} <span class="en">${esc(POS_CN[p.position] || p.position)} #${p.number}</span></h3>
<div class="en">${esc(p.team || "")} · ${esc(p.country)}</div>
<div class="meta">本季:<span class="big-num">${p.season.pts}</span> 分 / ${p.season.reb} 板 / ${p.season.ast} 助</div>
</div>`).join("");
renderPager(d, "players");
}
/* 通用分页控件 */
function renderPager(d, key) {
const pages = Math.max(1, Math.ceil(d.total / d.size));
const cur = d.page;
const el = document.getElementById(`pager-${key}`);
if (!el) return;
const nums = [];
for (let i = 1; i <= pages; i++) {
if (pages > 9 && i !== 1 && i !== pages && Math.abs(i - cur) > 2) {
if (nums[nums.length - 1] !== "…") nums.push("…");
continue;
}
nums.push(i);
}
el.innerHTML = `
<button ${cur <= 1 ? "disabled" : ""} onclick="goPage('${key}', ${cur - 1})">← 上一页</button>
${nums.map((n) => n === "…" ? `<span>…</span>` : `<button class="${n === cur ? "active" : ""}" onclick="goPage('${key}', ${n})">${n}</button>`).join("")}
<button ${cur >= pages ? "disabled" : ""} onclick="goPage('${key}', ${cur + 1})">下一页 →</button>
<span>共 ${d.total} 条</span>`;
}
function goPage(key, p) {
if (key === "players") { playersPage = p; loadPlayers(); }
else if (key === "games") { gamesPage = p; loadGames(); }
else if (key === "news") { newsPage = p; loadNews(); }
else if (key === "persons") { personsPage = p; loadPersons(); }
}
$("#btn-players").addEventListener("click", () => { playersQ = $("#search-players").value.trim(); playersPage = 1; loadPlayers(); });
$("#search-players").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#btn-players").click(); });
$$("#view-players .btn.small").forEach((b) => b.addEventListener("click", () => {
$$("#view-players .btn.small").forEach((x) => x.classList.remove("active"));
b.classList.add("active");
playersPos = b.dataset.pos;
playersPage = 1;
loadPlayers();
}));
async function openPlayer(id) {
const p = await getJSON(`/api/players/${id}`);
openModal(`
<h2>${esc(p.name)} <span class="en">${esc(p.name_en)}</span></h2>
<div class="en">${esc(p.team || "自由球员")} · ${esc(p.position)} · #${p.number} · ${esc(p.country)}</div>
<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 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>
`);
}
/* ================= 比赛 ================= */
let gamesPage = 1, gamesQ = "", gamesStatus = "";
async function loadGames() {
const d = await getJSON(`/api/games?q=${encodeURIComponent(gamesQ)}&status=${gamesStatus}&page=${gamesPage}&size=12`);
const games = d.results || [];
$("#list-games").innerHTML = games.map((g) => {
const finished = g.status === "finished";
const score = finished ? `<span class="score-big">${g.away_score}</span> : <span class="score-big">${g.home_score}</span>` : "VS";
return `<div class="list-item" onclick="openGame(${g.id})">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px">
<span class="tag">${esc(g.round_name)}</span>
<span class="status-pill status-${g.status}">${finished ? "已结束" : g.status === "scheduled" ? "未开始" : "进行中"}</span>
</div>
<div class="score-line" style="margin-top:8px">
<span style="flex:1;text-align:right">${esc(g.away_team)}</span>
<span class="vs">客</span>${score}<span class="vs">主</span>
<span style="flex:1">${esc(g.home_team)}</span>
</div>
<div class="meta">🕐 ${esc(g.game_time)} · ${esc(g.venue)} · ${esc(g.broadcast)}</div>
</div>`;
}).join("") || '<div class="meta">没有符合条件的比赛</div>';
renderPager(d, "games");
}
$$("#view-games .btn.small").forEach((b) => b.addEventListener("click", () => {
$$("#view-games .btn.small").forEach((x) => x.classList.remove("active"));
b.classList.add("active");
gamesStatus = b.dataset.status;
gamesPage = 1;
loadGames();
}));
$("#btn-games").addEventListener("click", () => { gamesQ = $("#search-games").value.trim(); gamesPage = 1; loadGames(); });
$("#search-games").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#btn-games").click(); });
async function openGame(id) {
const g = await getJSON(`/api/games/${id}`);
const finished = g.status === "finished";
const head = finished
? `<div class="score-line" style="font-size:22px;justify-content:center;margin:10px 0">
<span>${esc(g.away_team)}</span> <span class="score-big">${g.away_score}</span> : <span class="score-big">${g.home_score}</span> <span>${esc(g.home_team)}</span></div>`
: `<div class="score-line" style="font-size:20px;justify-content:center;margin:10px 0">${esc(g.away_team)} VS ${esc(g.home_team)}</div>`;
const stats = (g.box_score || []).map((s) => `
<tr><td>${esc(s.team_name)}</td><td>${esc(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("");
openModal(`
<h2 style="text-align:center">${esc(g.round_name)}</h2>
${head}
<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>
`);
}
/* ================= 新闻 ================= */
let newsPage = 1, newsQ = "", newsKind = "", newsTag = "";
async function loadNews() {
const d = await getJSON(`/api/news?q=${encodeURIComponent(newsQ)}&kind=${newsKind}&tag=${encodeURIComponent(newsTag)}&page=${newsPage}&size=10`);
const news = d.results || [];
$("#list-news").innerHTML = news.map((n) => `
<div class="list-item" onclick="openNews(${n.id})">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<span class="tag">${n.kind === "wiki" ? "百科" : "新闻"}</span>
<h3>${esc(n.title)}</h3>
</div>
<div class="meta">🕐 ${esc(n.publish_time)} · ${esc(n.source)}${n.author ? " · " + esc(n.author) : ""}</div>
<div class="meta" style="margin-top:4px">${esc(n.summary || n.content || "")}…</div>
</div>`).join("") || '<div class="meta">没有符合条件的新闻</div>';
renderPager(d, "news");
}
$("#btn-news").addEventListener("click", () => { newsQ = $("#search-news").value.trim(); newsPage = 1; loadNews(); });
$("#search-news").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#btn-news").click(); });
$$("#view-news .btn.small").forEach((b) => b.addEventListener("click", () => {
$$("#view-news .btn.small").forEach((x) => x.classList.remove("active"));
b.classList.add("active");
newsKind = b.dataset.kind;
newsTag = b.dataset.tag;
newsPage = 1;
loadNews();
}));
async function openNews(id) {
const n = await getJSON(`/api/news/${id}`);
openModal(`
<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>
`);
}
/* ================= 人物 ================= */
let personsPage = 1, personsQ = "", personsRole = "";
async function loadPersons() {
const d = await getJSON(`/api/persons?q=${encodeURIComponent(personsQ)}&role=${personsRole}&page=${personsPage}&size=12`);
const persons = d.results || [];
$("#grid-persons").innerHTML = persons.map((p) => `
<div class="card" onclick="openPerson(${p.id})">
<h3>${esc(p.name)} <span class="en">${esc(p.name_en || "")}</span></h3>
<div class="meta"><span class="tag">${esc(p.role_cn)}</span>${esc(p.title || "")}${p.team ? " · " + esc(p.team) : ""}</div>
<div class="meta">${esc((p.bio || "").slice(0, 60))}…</div>
</div>`).join("");
renderPager(d, "persons");
}
$$("#view-persons .btn.small").forEach((b) => b.addEventListener("click", () => {
$$("#view-persons .btn.small").forEach((x) => x.classList.remove("active"));
b.classList.add("active");
personsRole = b.dataset.role;
personsPage = 1;
loadPersons();
}));
$("#btn-persons").addEventListener("click", () => { personsQ = $("#search-persons").value.trim(); personsPage = 1; loadPersons(); });
$("#search-persons").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#btn-persons").click(); });
async function openPerson(id) {
const p = await getJSON(`/api/persons/${id}`);
openModal(`
<h2>${esc(p.name)} <span class="en">${esc(p.name_en || "")}</span></h2>
<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)}&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><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("");
}
$$("#view-standings .btn.small").forEach((b) => b.addEventListener("click", () => {
$$("#view-standings .btn.small").forEach((x) => x.classList.remove("active"));
b.classList.add("active");
standingsConf = b.dataset.conf;
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"); });