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

438 lines
22 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;
/* ================= 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") loadPlayers("");
else if (v === "games") loadGames("");
else if (v === "news") loadNews("");
else if (v === "persons") loadPersons("");
else if (v === "standings") {
if ($("#season-select").options.length) { loadStandings(); loadPlayoffs(); }
else loadSeasons();
}
}
/* ================= 对话 ================= */
function addMsg(role, html) {
const div = document.createElement("div");
div.className = `msg ${role}`;
div.innerHTML = `<div class="avatar">${role === "user" ? "🧑" : "🤖"}</div><div class="bubble">${html}</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="bubble"><span class="typing"><i></i><i></i><i></i></span></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 "";
}
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; }
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>";
}
addMsg("bot", html);
chatHistory.push({ user: text, assistant: d.reply });
if (chatHistory.length > 20) chatHistory.splice(0, chatHistory.length - 20);
} 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 = ""; sendChat(v); } });
$("#chat-input").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#send-btn").click(); });
/* 快捷问题:点击 → 自动填入输入框并自动提交(需求2) */
function askQuick(q) {
$("#chat-input").value = q;
sendChat(q);
}
/* 聊天区事件委托:快捷语句 / 实体标记 / 新闻链接 / 卡片 */
$("#chat-list").addEventListener("click", (e) => {
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>
`);
}
/* ================= 球员 ================= */
async function loadPlayers(q) {
const players = await getJSON(`/api/players?q=${encodeURIComponent(q)}&limit=60`);
$("#grid-players").innerHTML = players.map((p) => `
<div class="card" onclick="openPlayer(${p.id})">
<h3>${esc(p.name)} <span class="en">${esc(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("");
}
$("#btn-players").addEventListener("click", () => loadPlayers($("#search-players").value.trim()));
$("#search-players").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#btn-players").click(); });
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 gameStatusFilter = "";
async function loadGames(q) {
const url = `/api/games?q=${encodeURIComponent(q)}&limit=40`;
const games = await getJSON(url);
const list = games.filter((g) => !gameStatusFilter || g.status === gameStatusFilter);
$("#list-games").innerHTML = list.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>';
}
$$("#view-games .btn.small").forEach((b) => b.addEventListener("click", () => {
$$("#view-games .btn.small").forEach((x) => x.classList.remove("active"));
b.classList.add("active");
gameStatusFilter = b.dataset.status;
loadGames($("#search-games").value.trim());
}));
$("#btn-games").addEventListener("click", () => loadGames($("#search-games").value.trim()));
$("#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>
`);
}
/* ================= 新闻 ================= */
async function loadNews(q) {
const news = await getJSON(`/api/news?q=${encodeURIComponent(q)}&limit=30`);
$("#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("");
}
$("#btn-news").addEventListener("click", () => loadNews($("#search-news").value.trim()));
$("#search-news").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#btn-news").click(); });
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 personRoleFilter = "";
async function loadPersons(q) {
const persons = await getJSON(`/api/persons?q=${encodeURIComponent(q)}&limit=60`);
const list = persons.filter((p) => !personRoleFilter || p.role === personRoleFilter);
$("#grid-persons").innerHTML = list.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("");
}
$$("#view-persons .btn.small").forEach((b) => b.addEventListener("click", () => {
$$("#view-persons .btn.small").forEach((x) => x.classList.remove("active"));
b.classList.add("active");
personRoleFilter = b.dataset.role;
loadPersons($("#search-persons").value.trim());
}));
$("#btn-persons").addEventListener("click", () => loadPersons($("#search-persons").value.trim()));
$("#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"); });