Add PC post-match player pages with opt-in public OSS sync.
Generate /players/{account_id}[/{match_id}] locally after POST_GAME via OpenDota; publish to OSS only when public_share is enabled.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+371
-4
@@ -1,4 +1,4 @@
|
||||
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION, SITE_ORIGIN */
|
||||
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, PLAYERS_ASSET_BASE, SITE_VERSION, SITE_ORIGIN */
|
||||
|
||||
function trimBase(raw) {
|
||||
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
|
||||
@@ -135,6 +135,7 @@ const PAGE_SEO_LABELS = {
|
||||
rankings: "Immortal 排行",
|
||||
streamers: "主播",
|
||||
matches: "明星比赛",
|
||||
players: "玩家战绩",
|
||||
trends: "近 8 周走势",
|
||||
mechanics: "机制查询",
|
||||
items: "物品商店",
|
||||
@@ -218,6 +219,18 @@ function describeStateForSeo(st) {
|
||||
} else if (page === "matches") {
|
||||
title = `明星比赛 — ${brand}`;
|
||||
description = "明星选手近期职业与国服对局、终局出装与加点。";
|
||||
} else if (page === "players") {
|
||||
const aid = st.playerAccountId;
|
||||
if (aid && st.playerMatchId) {
|
||||
title = `比赛 ${st.playerMatchId} — ${brand}`;
|
||||
description = `玩家 ${aid} 的比赛 ${st.playerMatchId} 战绩详情。`;
|
||||
} else if (aid) {
|
||||
title = `玩家 ${aid} — ${brand}`;
|
||||
description = `玩家 ${aid} 的近期比赛与战绩(上分帝 PC 赛后生成)。`;
|
||||
} else {
|
||||
title = `玩家战绩 — ${brand}`;
|
||||
description = "PC 上分帝赛后生成的玩家主页与比赛详情。";
|
||||
}
|
||||
} else if (page === "heroes") {
|
||||
title = `英雄克制与搭档 — ${brand}`;
|
||||
description =
|
||||
@@ -257,7 +270,7 @@ function clearSeoPrerender() {
|
||||
|
||||
const state = {
|
||||
data: null,
|
||||
page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches
|
||||
page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
|
||||
selectedKey: null,
|
||||
selectedItemKey: null,
|
||||
/** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */
|
||||
@@ -278,6 +291,13 @@ const state = {
|
||||
matchesOrigin: "all",
|
||||
/** Top-level matches page: 1-based page index */
|
||||
matchesPage: 1,
|
||||
/** PC post-match player pages: account_id / match_id */
|
||||
playerAccountId: null,
|
||||
playerMatchId: null,
|
||||
/** In-memory cache for /players fetches */
|
||||
_playerProfile: null,
|
||||
_playerMatch: null,
|
||||
_playerLoadKey: null,
|
||||
/** Top-level 走势 page medal bracket */
|
||||
trendsBracket: "legend",
|
||||
/** Sort key for trends board: wr_end | pr_end */
|
||||
@@ -741,6 +761,51 @@ function heroByKey(key) {
|
||||
return (state.data.heroes || []).find((h) => h.key === key) || null;
|
||||
}
|
||||
|
||||
function heroById(id) {
|
||||
if (id == null) return null;
|
||||
const n = Number(id);
|
||||
return (state.data.heroes || []).find((h) => Number(h.id) === n) || null;
|
||||
}
|
||||
|
||||
/** OSS/local base for players/*.json; empty PLAYERS_ASSET_BASE → STATIC_ASSET_BASE. */
|
||||
function playersAssetBase() {
|
||||
const dedicated =
|
||||
typeof PLAYERS_ASSET_BASE !== "undefined" ? trimBase(PLAYERS_ASSET_BASE) : "";
|
||||
return dedicated || staticAssetBase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load player profile or match detail.
|
||||
* kind: "profile" | "match"
|
||||
* Local: /api/players/{account}[/match]
|
||||
* OSS: {base}/players/{account}/profile.json
|
||||
* {base}/players/{account}/matches/{match}.json
|
||||
*/
|
||||
async function fetchPlayerJson(accountId, matchId) {
|
||||
const aid = String(accountId || "");
|
||||
if (!/^\d+$/.test(aid)) return null;
|
||||
const mid = matchId != null ? String(matchId) : "";
|
||||
const localPath = mid ? `/api/players/${aid}/${mid}` : `/api/players/${aid}`;
|
||||
try {
|
||||
const local = await fetch(localPath);
|
||||
if (local.ok) return await local.json();
|
||||
} catch (_) {
|
||||
/* fall through to OSS */
|
||||
}
|
||||
const base = playersAssetBase();
|
||||
if (!base) return null;
|
||||
const ossPath = mid
|
||||
? `${base}/players/${aid}/matches/${mid}.json`
|
||||
: `${base}/players/${aid}/profile.json`;
|
||||
try {
|
||||
const res = await fetch(ossPath);
|
||||
if (res.ok) return await res.json();
|
||||
} catch (_) {
|
||||
/* missing / private */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function itemMeta(id) {
|
||||
const items = state.data.hero_items?.items || {};
|
||||
return items[String(id)] || null;
|
||||
@@ -5998,6 +6063,262 @@ function renderMechanics() {
|
||||
}
|
||||
|
||||
|
||||
function fmtDuration(sec) {
|
||||
const s = Math.max(0, Number(sec) || 0);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${String(r).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function pctLabel(v) {
|
||||
if (v == null || !Number.isFinite(Number(v))) return "—";
|
||||
return `${Math.round(Number(v) * 100)}%`;
|
||||
}
|
||||
|
||||
function appendPlayerItemIcons(row, itemIds) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "player-match-items";
|
||||
(Array.isArray(itemIds) ? itemIds : []).forEach((id) => {
|
||||
const meta = itemMetaFromId(id);
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "player-match-item";
|
||||
if (meta && meta.key) {
|
||||
const img = document.createElement("img");
|
||||
img.alt = meta.name_loc || meta.key;
|
||||
img.title = meta.name_loc || meta.key;
|
||||
setItemIcon(img, meta.key);
|
||||
cell.appendChild(img);
|
||||
} else {
|
||||
cell.classList.add("empty");
|
||||
cell.title = id != null ? String(id) : "";
|
||||
}
|
||||
wrap.appendChild(cell);
|
||||
});
|
||||
row.appendChild(wrap);
|
||||
}
|
||||
|
||||
function buildPlayerScoreboardTeam(detail, isRadiant) {
|
||||
const side = document.createElement("section");
|
||||
side.className = `player-team ${isRadiant ? "radiant" : "dire"}`;
|
||||
const team = isRadiant ? detail.radiant || {} : detail.dire || {};
|
||||
const won = Boolean(detail.radiant_win) === isRadiant;
|
||||
const head = document.createElement("header");
|
||||
head.className = "player-team-head";
|
||||
head.innerHTML = `
|
||||
<span class="player-team-name">${isRadiant ? "天辉" : "夜魇"}${won ? " · 胜利" : " · 失败"}</span>
|
||||
<span class="player-team-stats">击杀 ${team.kills ?? "—"} · 经济 ${Number(team.net_worth || 0).toLocaleString("zh-CN")}</span>
|
||||
`;
|
||||
side.appendChild(head);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "player-team-rows";
|
||||
const players = (detail.players || []).filter((p) => !!p.is_radiant === isRadiant);
|
||||
for (const p of players) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "player-match-row";
|
||||
if (p.is_mvp) row.classList.add("is-mvp");
|
||||
if (
|
||||
state.playerAccountId &&
|
||||
String(p.account_id) === String(state.playerAccountId)
|
||||
) {
|
||||
row.classList.add("is-focus");
|
||||
}
|
||||
|
||||
const heroKey = p.hero_key || (heroById(p.hero_id) || {}).key;
|
||||
const heroName =
|
||||
p.hero_name_loc ||
|
||||
(heroById(p.hero_id) || {}).name_loc ||
|
||||
heroKey ||
|
||||
"—";
|
||||
const left = document.createElement("div");
|
||||
left.className = "player-match-hero";
|
||||
if (heroKey) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "player-match-portrait";
|
||||
img.src = portraitSrc(heroKey);
|
||||
img.alt = heroName;
|
||||
left.appendChild(img);
|
||||
}
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "player-match-meta";
|
||||
const nameLine = document.createElement("div");
|
||||
nameLine.className = "player-match-name";
|
||||
nameLine.textContent = p.personaname || heroName;
|
||||
if (p.is_mvp) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "player-mvp-badge";
|
||||
badge.textContent = "MVP";
|
||||
nameLine.appendChild(badge);
|
||||
}
|
||||
meta.appendChild(nameLine);
|
||||
const sub = document.createElement("div");
|
||||
sub.className = "player-match-sub";
|
||||
sub.textContent = `Lv.${p.level ?? "—"} · ${heroName}`;
|
||||
meta.appendChild(sub);
|
||||
left.appendChild(meta);
|
||||
row.appendChild(left);
|
||||
|
||||
const metrics = document.createElement("div");
|
||||
metrics.className = "player-match-metrics";
|
||||
metrics.innerHTML = `
|
||||
<span title="参战率"><em>参战</em>${pctLabel(p.participation)}</span>
|
||||
<span title="伤害占比"><em>伤害</em>${pctLabel(p.damage_share)}</span>
|
||||
<span title="K/D/A"><em>KDA</em>${p.kills ?? 0}/${p.deaths ?? 0}/${p.assists ?? 0}</span>
|
||||
<span title="KDA 比"><em>比</em>${p.kda ?? "—"}</span>
|
||||
`;
|
||||
row.appendChild(metrics);
|
||||
appendPlayerItemIcons(row, p.items);
|
||||
list.appendChild(row);
|
||||
}
|
||||
side.appendChild(list);
|
||||
return side;
|
||||
}
|
||||
|
||||
function renderPlayerMatchDetail(root, detail) {
|
||||
root.replaceChildren();
|
||||
const back = document.createElement("button");
|
||||
back.type = "button";
|
||||
back.className = "players-back";
|
||||
back.textContent = "← 返回主页";
|
||||
back.addEventListener("click", () => {
|
||||
state.playerMatchId = null;
|
||||
state._playerMatch = null;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
});
|
||||
root.appendChild(back);
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "players-match-head";
|
||||
const dur = fmtDuration(detail.duration);
|
||||
head.innerHTML = `
|
||||
<h2 class="page-title">比赛 ${detail.match_id}</h2>
|
||||
<p class="page-sub">时长 ${dur}${detail.radiant_win ? " · 天辉胜" : " · 夜魇胜"}</p>
|
||||
`;
|
||||
root.appendChild(head);
|
||||
|
||||
const board = document.createElement("div");
|
||||
board.className = "player-scoreboard";
|
||||
board.appendChild(buildPlayerScoreboardTeam(detail, true));
|
||||
board.appendChild(buildPlayerScoreboardTeam(detail, false));
|
||||
root.appendChild(board);
|
||||
}
|
||||
|
||||
function renderPlayerProfile(root, profile) {
|
||||
root.replaceChildren();
|
||||
const head = document.createElement("header");
|
||||
head.className = "players-profile-head";
|
||||
const name = profile.personaname || `玩家 ${profile.account_id}`;
|
||||
head.innerHTML = `
|
||||
<h2 class="page-title">${name}</h2>
|
||||
<p class="page-sub">ID ${profile.account_id}${profile.public_share ? " · 已公开" : " · 本机/未公开"}</p>
|
||||
`;
|
||||
root.appendChild(head);
|
||||
|
||||
const recent = Array.isArray(profile.recent) ? profile.recent : [];
|
||||
if (!recent.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "rankings-empty";
|
||||
empty.textContent = "暂无近期比赛";
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "players-recent";
|
||||
for (const row of recent) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `players-recent-row ${row.won ? "won" : "lost"}`;
|
||||
const heroKey = row.hero_key || (heroById(row.hero_id) || {}).key;
|
||||
const heroName =
|
||||
row.hero_name_loc ||
|
||||
(heroById(row.hero_id) || {}).name_loc ||
|
||||
heroKey ||
|
||||
"—";
|
||||
if (heroKey) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "players-recent-portrait";
|
||||
img.src = portraitSrc(heroKey);
|
||||
img.alt = heroName;
|
||||
btn.appendChild(img);
|
||||
}
|
||||
const body = document.createElement("div");
|
||||
body.className = "players-recent-body";
|
||||
body.innerHTML = `
|
||||
<div class="players-recent-top">
|
||||
<span class="players-recent-hero">${heroName}</span>
|
||||
<span class="players-recent-wl">${row.won ? "胜利" : "失败"}</span>
|
||||
</div>
|
||||
<div class="players-recent-bot">
|
||||
<span>${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0}</span>
|
||||
<span>${fmtDuration(row.duration)}</span>
|
||||
<span>#${row.match_id}</span>
|
||||
</div>
|
||||
`;
|
||||
btn.appendChild(body);
|
||||
btn.addEventListener("click", () => {
|
||||
state.playerMatchId = String(row.match_id);
|
||||
state._playerMatch = null;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
});
|
||||
list.appendChild(btn);
|
||||
}
|
||||
root.appendChild(list);
|
||||
}
|
||||
|
||||
function renderPlayersPage() {
|
||||
const root = $("#players-body");
|
||||
if (!root) return;
|
||||
|
||||
const accountId = state.playerAccountId;
|
||||
if (!accountId) {
|
||||
root.innerHTML =
|
||||
'<div class="rankings-empty">打开 /players/{account_id} 查看 PC 赛后生成的玩家主页。<br/>默认仅本机可见;在 pc/config.json 将 player_pages.public_share 设为 true 后可同步到站点。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const loadKey = `${accountId}:${state.playerMatchId || ""}`;
|
||||
if (state._playerLoadKey === loadKey) {
|
||||
if (state.playerMatchId) {
|
||||
if (state._playerMatch) renderPlayerMatchDetail(root, state._playerMatch);
|
||||
else
|
||||
root.innerHTML =
|
||||
'<div class="rankings-empty">未找到该场比赛(未公开或尚未同步)。</div>';
|
||||
} else if (state._playerProfile) {
|
||||
renderPlayerProfile(root, state._playerProfile);
|
||||
} else {
|
||||
root.innerHTML =
|
||||
'<div class="rankings-empty">未找到玩家主页(未公开或尚未同步)。</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
root.innerHTML = '<div class="rankings-empty">加载中…</div>';
|
||||
const requested = loadKey;
|
||||
(async () => {
|
||||
let profile = null;
|
||||
let match = null;
|
||||
try {
|
||||
if (state.playerMatchId) {
|
||||
match = await fetchPlayerJson(accountId, state.playerMatchId);
|
||||
} else {
|
||||
profile = await fetchPlayerJson(accountId);
|
||||
}
|
||||
} catch (_) {
|
||||
/* empty */
|
||||
}
|
||||
if (`${state.playerAccountId}:${state.playerMatchId || ""}` !== requested) {
|
||||
return;
|
||||
}
|
||||
state._playerLoadKey = requested;
|
||||
state._playerProfile = profile;
|
||||
state._playerMatch = match;
|
||||
renderPlayersPage();
|
||||
})();
|
||||
}
|
||||
|
||||
function setPage(page) {
|
||||
if (
|
||||
page !== "heroes" &&
|
||||
@@ -6007,7 +6328,8 @@ function setPage(page) {
|
||||
page !== "trends" &&
|
||||
page !== "mechanics" &&
|
||||
page !== "items" &&
|
||||
page !== "patches"
|
||||
page !== "patches" &&
|
||||
page !== "players"
|
||||
)
|
||||
return;
|
||||
closeTalentPopover();
|
||||
@@ -6022,6 +6344,10 @@ function setPage(page) {
|
||||
state.selectedKey = null;
|
||||
state.selectedItemKey = null;
|
||||
state.inspect = null;
|
||||
} else if (page === "players") {
|
||||
state.selectedKey = null;
|
||||
state.selectedItemKey = null;
|
||||
state.inspect = null;
|
||||
} else if (page === "streamers") {
|
||||
state.selectedKey = null;
|
||||
state.selectedItemKey = null;
|
||||
@@ -6057,6 +6383,7 @@ function syncChrome() {
|
||||
const heroesView = $("#heroes-view");
|
||||
const rankingsView = $("#rankings-view");
|
||||
const matchesView = $("#matches-view");
|
||||
const playersView = $("#players-view");
|
||||
const streamersView = $("#streamers-view");
|
||||
const trendsView = $("#trends-view");
|
||||
const mechanicsView = $("#mechanics-view");
|
||||
@@ -6069,6 +6396,7 @@ function syncChrome() {
|
||||
if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes");
|
||||
if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings");
|
||||
if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches");
|
||||
if (playersView) playersView.classList.toggle("hidden", state.page !== "players");
|
||||
if (streamersView) streamersView.classList.toggle("hidden", state.page !== "streamers");
|
||||
if (trendsView) trendsView.classList.toggle("hidden", state.page !== "trends");
|
||||
if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics");
|
||||
@@ -6088,6 +6416,8 @@ function render() {
|
||||
renderRankings();
|
||||
} else if (state.page === "matches") {
|
||||
renderMatchesPage();
|
||||
} else if (state.page === "players") {
|
||||
renderPlayersPage();
|
||||
} else if (state.page === "streamers") {
|
||||
renderStreamers();
|
||||
} else if (state.page === "trends") {
|
||||
@@ -6122,7 +6452,17 @@ function applyPatch(patch) {
|
||||
// Page (default heroes on bad/missing).
|
||||
if (
|
||||
patch.page &&
|
||||
["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"].includes(patch.page)
|
||||
[
|
||||
"heroes",
|
||||
"rankings",
|
||||
"matches",
|
||||
"streamers",
|
||||
"trends",
|
||||
"mechanics",
|
||||
"items",
|
||||
"patches",
|
||||
"players",
|
||||
].includes(patch.page)
|
||||
) {
|
||||
state.page = patch.page;
|
||||
} else {
|
||||
@@ -6200,6 +6540,33 @@ function applyPatch(patch) {
|
||||
state.matchesPage =
|
||||
Number.isFinite(mp) && mp >= 1 ? Math.floor(mp) : 1;
|
||||
}
|
||||
// PC post-match player pages.
|
||||
if (state.page === "players") {
|
||||
const nextAccount =
|
||||
patch.playerAccountId && /^\d+$/.test(String(patch.playerAccountId))
|
||||
? String(patch.playerAccountId)
|
||||
: null;
|
||||
const nextMatch =
|
||||
patch.playerMatchId && /^\d+$/.test(String(patch.playerMatchId))
|
||||
? String(patch.playerMatchId)
|
||||
: null;
|
||||
if (
|
||||
nextAccount !== state.playerAccountId ||
|
||||
nextMatch !== state.playerMatchId
|
||||
) {
|
||||
state._playerLoadKey = null;
|
||||
state._playerProfile = null;
|
||||
state._playerMatch = null;
|
||||
}
|
||||
state.playerAccountId = nextAccount;
|
||||
state.playerMatchId = nextMatch;
|
||||
} else {
|
||||
state.playerAccountId = null;
|
||||
state.playerMatchId = null;
|
||||
state._playerLoadKey = null;
|
||||
state._playerProfile = null;
|
||||
state._playerMatch = null;
|
||||
}
|
||||
// Item (items page) — must exist in the shop catalog.
|
||||
if (patch.itemKey && shopItem(patch.itemKey)) {
|
||||
state.selectedItemKey = patch.itemKey;
|
||||
|
||||
Reference in New Issue
Block a user