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:
voson
2026-07-31 11:01:32 +08:00
co-authored by Cursor
parent d6f7c3f0f5
commit 4a61aeeb26
16 changed files with 1684 additions and 23 deletions
+371 -4
View File
@@ -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;
+3 -1
View File
@@ -1,6 +1,8 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.6.15";
var SITE_VERSION = "0.6.16";
var SITE_ORIGIN = "";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
/* Player pages JSON (OSS players/); empty → STATIC_ASSET_BASE, then local /api/players. */
var PLAYERS_ASSET_BASE = "";
@@ -0,0 +1,411 @@
/**
* Pages Function: POST /api/players/publish
*
* Body: { account_id, match_id }
* Optional header: X-Climperor-Publish-Secret (when PLAYER_PAGES_PUBLISH_SECRET set).
*
* Fetches OpenDota match, verifies account_id is in the lobby, normalizes Max+-style
* JSON, merges profile.recent, PUTs to Aliyun OSS:
* players/{account_id}/profile.json
* players/{account_id}/matches/{match_id}.json
*
* Secrets (Pages env): OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET,
* optional OSS_BUCKET, OSS_ENDPOINT, PLAYER_PAGES_PUBLISH_SECRET, OPENDOTA_API_KEY.
*
* Soft-fail: match not ready → 202; bad membership → 403; never echo secrets.
*/
const OPENDOTA = "https://api.opendota.com/api";
const DEFAULT_BUCKET = "climperor";
const DEFAULT_ENDPOINT = "oss-cn-shanghai.aliyuncs.com";
const RECENT_LIMIT = 30;
function jsonResponse(body, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
function envOf(context) {
return (context && context.env) || {};
}
function intField(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? Math.trunc(n) : fallback;
}
function kda(kills, deaths, assists) {
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
}
function mvpScore(p) {
const k = intField(p.kills);
const d = intField(p.deaths);
const a = intField(p.assists);
const dmg = intField(p.hero_damage);
const nw = intField(p.net_worth) || intField(p.gold) + intField(p.gold_spent);
return (k * 1.5 + a + dmg / 1000 + nw / 2000) / Math.max(d, 1);
}
function itemIds(player) {
const out = [];
for (let i = 0; i < 6; i++) {
const id = intField(player[`item_${i}`]);
if (id > 0) out.push(id);
}
return out;
}
function accountInMatch(match, accountId) {
const players = match.players || [];
for (const p of players) {
if (p && intField(p.account_id, -1) === accountId) return true;
}
return false;
}
function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
async function fetchJson(url, { headers } = {}) {
const res = await fetch(url, {
headers: { Accept: "application/json", "User-Agent": "climperor-publish", ...(headers || {}) },
});
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
async function loadHeroMap(opendotaKey) {
const q = opendotaKey ? `?api_key=${encodeURIComponent(opendotaKey)}` : "";
try {
const rows = await fetchJson(`${OPENDOTA}/heroes${q}`);
const map = new Map();
if (Array.isArray(rows)) {
for (const h of rows) {
if (!h || h.id == null) continue;
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
map.set(intField(h.id), {
key,
name_loc: h.localized_name || key,
});
}
}
return map;
} catch {
return new Map();
}
}
function normalizeMatch(match, focusAccountId, heroMap) {
const playersRaw = match.players;
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
const matchId = intField(match.match_id);
if (matchId <= 0) return null;
const radiantWin = !!match.radiant_win;
const teamKills = [0, 0];
const teamNw = [0, 0];
const teamDmg = [0, 0];
const slim = [];
for (const p of playersRaw) {
if (!p || typeof p !== "object") continue;
const slot = intField(p.player_slot);
const isRadiant = slot < 128;
const side = isRadiant ? 0 : 1;
const kills = intField(p.kills);
const deaths = intField(p.deaths);
const assists = intField(p.assists);
const heroDamage = intField(p.hero_damage);
let netWorth = intField(p.net_worth);
if (netWorth <= 0) netWorth = intField(p.gold) + intField(p.gold_spent);
teamKills[side] += kills;
teamNw[side] += netWorth;
teamDmg[side] += heroDamage;
const heroId = intField(p.hero_id);
const hero = heroMap.get(heroId) || {};
let accountId = null;
if (p.account_id != null) {
const a = intField(p.account_id, -1);
accountId = a >= 0 ? a : null;
}
let personaname = typeof p.personaname === "string" ? p.personaname.trim() : null;
if (!personaname) personaname = null;
slim.push({
account_id: accountId,
personaname,
hero_id: heroId,
hero_key: hero.key || null,
hero_name_loc: hero.name_loc || hero.key || null,
level: intField(p.level),
kills,
deaths,
assists,
kda: kda(kills, deaths, assists),
hero_damage: heroDamage,
net_worth: netWorth,
items: itemIds(p),
is_radiant: isRadiant,
won: isRadiant ? radiantWin : !radiantWin,
_mvp: mvpScore(p),
_side: side,
});
}
if (slim.length < 2) return null;
for (const p of slim) {
const side = p._side;
const tk = teamKills[side] || 1;
const td = teamDmg[side] || 1;
p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000;
p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000;
}
let mvp = slim[0];
for (const p of slim) {
if (p._mvp > mvp._mvp) mvp = p;
}
const mvpAccount = mvp.account_id;
for (const p of slim) {
p.is_mvp = mvpAccount != null && p.account_id === mvpAccount;
delete p._mvp;
delete p._side;
}
let startTime = null;
if (match.start_time != null) {
const t = intField(match.start_time, -1);
startTime = t >= 0 ? t : null;
}
return {
match_id: matchId,
start_time: startTime,
duration: intField(match.duration),
radiant_win: radiantWin,
radiant: { kills: teamKills[0], net_worth: teamNw[0] },
dire: { kills: teamKills[1], net_worth: teamNw[1] },
mvp_account_id: mvpAccount,
players: slim,
focus_account_id: focusAccountId,
fetched_at: utcNow(),
source: "opendota",
};
}
function summaryForProfile(detail, accountId) {
const focus = (detail.players || []).find((p) => p.account_id === accountId);
if (!focus) return null;
return {
match_id: detail.match_id,
start_time: detail.start_time,
duration: detail.duration,
won: !!focus.won,
hero_id: focus.hero_id,
hero_key: focus.hero_key,
hero_name_loc: focus.hero_name_loc,
kills: focus.kills,
deaths: focus.deaths,
assists: focus.assists,
kda: focus.kda,
};
}
async function ossGetJson(env, key) {
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const url = `https://${bucket}.${endpoint}/${key}`;
try {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function hmacSha1Base64(secret, stringToSign) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(stringToSign));
const bytes = new Uint8Array(sig);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
async function ossPutJson(env, key, obj) {
const accessKeyId = env.OSS_ACCESS_KEY_ID;
const accessKeySecret = env.OSS_ACCESS_KEY_SECRET;
if (!accessKeyId || !accessKeySecret) {
const err = new Error("OSS credentials missing");
err.status = 503;
throw err;
}
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const body = JSON.stringify(obj);
const contentType = "application/json; charset=utf-8";
const date = new Date().toUTCString();
const resource = `/${bucket}/${key}`;
// Rely on bucket/prefix public-read policy (no x-oss-object-acl; some buckets disallow ACL).
const stringToSign = `PUT\n\n${contentType}\n${date}\n${resource}`;
const signature = await hmacSha1Base64(accessKeySecret, stringToSign);
const url = `https://${bucket}.${endpoint}/${key}`;
const res = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": contentType,
Date: date,
Authorization: `OSS ${accessKeyId}:${signature}`,
"Cache-Control": "public, max-age=60",
},
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(`OSS PUT ${res.status}: ${text.slice(0, 200)}`);
err.status = 502;
throw err;
}
}
function mergeProfile(existing, accountId, summary, personaname) {
const profile =
existing && typeof existing === "object"
? { ...existing }
: { account_id: accountId, personaname: null, recent: [] };
let recent = Array.isArray(profile.recent) ? profile.recent.filter((r) => r && typeof r === "object") : [];
recent = recent.filter((r) => intField(r.match_id) !== summary.match_id);
recent.unshift(summary);
profile.recent = recent.slice(0, RECENT_LIMIT);
profile.account_id = accountId;
if (personaname) profile.personaname = personaname;
profile.public_share = true;
profile.updated_at = utcNow();
return profile;
}
export async function onRequestPost(context) {
try {
const env = envOf(context);
const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim();
if (expected) {
const got = (context.request.headers.get("X-Climperor-Publish-Secret") || "").trim();
if (got !== expected) {
return jsonResponse({ error: "forbidden" }, 403);
}
}
let body;
try {
body = await context.request.json();
} catch {
return jsonResponse({ error: "invalid json" }, 400);
}
const accountId = intField(body && body.account_id, -1);
const matchId = intField(body && body.match_id, -1);
if (accountId <= 0 || matchId <= 0) {
return jsonResponse({ error: "account_id and match_id required" }, 400);
}
const odKey = (env.OPENDOTA_API_KEY || "").trim();
const q = odKey ? `?api_key=${encodeURIComponent(odKey)}` : "";
let match;
try {
match = await fetchJson(`${OPENDOTA}/matches/${matchId}${q}`);
} catch (e) {
if (e && e.status === 404) {
return jsonResponse(
{ ok: false, pending: true, message: "match not ready on OpenDota" },
202
);
}
return jsonResponse({ error: "opendota fetch failed", detail: String(e.message || e) }, 502);
}
if (!match || !Array.isArray(match.players) || !match.players.length) {
return jsonResponse(
{ ok: false, pending: true, message: "match incomplete on OpenDota" },
202
);
}
if (!accountInMatch(match, accountId)) {
return jsonResponse({ error: "account not in match" }, 403);
}
const heroMap = await loadHeroMap(odKey);
const detail = normalizeMatch(match, accountId, heroMap);
if (!detail) {
return jsonResponse({ error: "normalize failed" }, 500);
}
const summary = summaryForProfile(detail, accountId);
if (!summary) {
return jsonResponse({ error: "focus player missing" }, 500);
}
let personaname = null;
for (const p of detail.players) {
if (p.account_id === accountId && p.personaname) {
personaname = p.personaname;
break;
}
}
const profileKey = `players/${accountId}/profile.json`;
const matchKey = `players/${accountId}/matches/${matchId}.json`;
const existing = await ossGetJson(env, profileKey);
const profile = mergeProfile(existing, accountId, summary, personaname);
await ossPutJson(env, matchKey, detail);
await ossPutJson(env, profileKey, profile);
return jsonResponse({
ok: true,
account_id: accountId,
match_id: matchId,
profile_key: profileKey,
match_key: matchKey,
});
} catch (e) {
const status = (e && e.status) || 500;
return jsonResponse(
{ error: "publish failed", detail: String((e && e.message) || e) },
status >= 400 && status < 600 ? status : 500
);
}
}
export async function onRequestOptions() {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Climperor-Publish-Secret",
"Access-Control-Max-Age": "86400",
},
});
}
+12 -5
View File
@@ -43,8 +43,8 @@
}
</script>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.6.15" />
<script src="/mobile-gate.js?v=0.6.15"></script>
<link rel="stylesheet" href="/style.css?v=0.6.16" />
<script src="/mobile-gate.js?v=0.6.16"></script>
</head>
<body>
<h1 class="sr-only">DOTA2 上分帝</h1>
@@ -62,6 +62,7 @@
<li><a href="/rankings">Immortal 排行</a></li>
<li><a href="/streamers">主播</a></li>
<li><a href="/matches">明星比赛</a></li>
<li><a href="/players">玩家战绩</a></li>
</ul>
</aside>
<div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true">
@@ -155,6 +156,12 @@
</div>
</main>
<main id="players-view" class="board rankings-board players-board hidden">
<div class="players-cluster">
<div class="players-body" id="players-body" aria-live="polite"></div>
</div>
</main>
<main id="streamers-view" class="board rankings-board streamers-board hidden">
<div class="rankings-cluster">
<div class="rankings-center-wrap streamers-center-wrap">
@@ -240,8 +247,8 @@
</div>
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
<script src="/config.js?v=0.6.15"></script>
<script src="/router.js?v=0.6.15"></script>
<script src="/app.js?v=0.6.15"></script>
<script src="/config.js?v=0.6.16"></script>
<script src="/router.js?v=0.6.16"></script>
<script src="/app.js?v=0.6.16"></script>
</body>
</html>
+30 -2
View File
@@ -4,7 +4,7 @@
* Path-based router for the Climperor web site (web/frontend).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
* - hero: selected hero key + detail sub-tab
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats trends)
* - rankings: Immortal leaderboard region
@@ -12,6 +12,8 @@
* - matches: star-player recent matches (pro_matches)
* /matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted)
* - players: PC post-match player home + match detail (local/OSS JSON)
* /players/{account_id}[/{match_id}]
* - streamers: curated streamer directory
* /streamers
* - trends: medal bracket for the 8-week win/pick board
@@ -38,7 +40,17 @@
*/
const ROUTE_DEFAULT = "/heroes";
const VALID_PAGES = ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"];
const VALID_PAGES = [
"heroes",
"rankings",
"matches",
"streamers",
"trends",
"mechanics",
"items",
"patches",
"players",
];
const VALID_DETAIL_TABS = [
"skills",
"core",
@@ -114,6 +126,8 @@ function parseHash(hashOrPath) {
matchesPlayerId: null,
matchesOrigin: null,
matchesPage: null,
playerAccountId: null,
playerMatchId: null,
trendsBracket: null,
trendsSort: null,
mechanicEffect: null,
@@ -147,6 +161,13 @@ function parseHash(hashOrPath) {
if (segs[1] && /^\d+$/.test(segs[1])) {
out.matchesPlayerId = segs[1];
}
} else if (page === "players") {
if (segs[1] && /^\d+$/.test(segs[1])) {
out.playerAccountId = segs[1];
}
if (segs[2] && /^\d+$/.test(segs[2])) {
out.playerMatchId = segs[2];
}
} else if (page === "trends") {
if (segs[1]) out.trendsBracket = safeDecode(segs[1]);
} else if (page === "mechanics") {
@@ -219,6 +240,13 @@ function serializeHash(state) {
if (state.matchesPlayerId) {
path += "/" + encodeURIComponent(String(state.matchesPlayerId));
}
} else if (state.page === "players") {
if (state.playerAccountId) {
path += "/" + encodeURIComponent(String(state.playerAccountId));
if (state.playerMatchId) {
path += "/" + encodeURIComponent(String(state.playerMatchId));
}
}
} else if (state.page === "trends") {
// Omit bracket when it is the default (legend) — bare /trends means legend.
const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET;
+232
View File
@@ -4421,3 +4421,235 @@ html.mobile-client #mobile-gate {
font-weight: 600;
}
/* —— PC post-match player pages (/players) —— */
.players-board .players-cluster {
max-width: 1100px;
margin: 0 auto;
padding: var(--space-lg) var(--space-md) 48px;
}
.players-body {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.players-back {
appearance: none;
align-self: flex-start;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
padding: 6px 12px;
border-radius: var(--radius-md);
cursor: pointer;
}
.players-back:hover {
color: var(--text);
border-color: rgba(94, 200, 255, 0.45);
}
.players-profile-head,
.players-match-head {
margin-bottom: var(--space-sm);
}
.players-recent {
display: flex;
flex-direction: column;
gap: 8px;
}
.players-recent-row {
appearance: none;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
border: 1px solid var(--border);
background: var(--surface-raised);
color: var(--text);
border-radius: var(--radius-md);
padding: 10px 12px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.players-recent-row:hover {
border-color: rgba(94, 200, 255, 0.4);
}
.players-recent-row.won {
border-left: 3px solid var(--good);
}
.players-recent-row.lost {
border-left: 3px solid var(--danger);
}
.players-recent-portrait {
width: 64px;
height: 36px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.players-recent-body {
flex: 1;
min-width: 0;
}
.players-recent-top,
.players-recent-bot {
display: flex;
justify-content: space-between;
gap: 12px;
}
.players-recent-top {
font-weight: 700;
}
.players-recent-bot {
margin-top: 4px;
font-size: 12px;
color: var(--muted);
}
.players-recent-wl {
font-size: 13px;
letter-spacing: 0.04em;
}
.player-scoreboard {
display: flex;
flex-direction: column;
gap: 18px;
}
.player-team {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--panel-soft);
overflow: hidden;
}
.player-team.radiant {
border-color: rgba(61, 206, 122, 0.35);
}
.player-team.dire {
border-color: rgba(232, 106, 106, 0.35);
}
.player-team-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 10px 14px;
font-size: 14px;
}
.player-team.radiant .player-team-head {
background: rgba(61, 206, 122, 0.12);
}
.player-team.dire .player-team-head {
background: rgba(232, 106, 106, 0.12);
}
.player-team-name {
font-weight: 800;
letter-spacing: 0.06em;
}
.player-team-stats {
color: var(--muted);
font-size: 13px;
}
.player-team-rows {
display: flex;
flex-direction: column;
}
.player-match-row {
display: grid;
grid-template-columns: minmax(160px, 1.2fr) minmax(220px, 1.4fr) auto;
gap: 12px;
align-items: center;
padding: 10px 14px;
border-top: 1px solid var(--border);
}
.player-match-row.is-focus {
background: rgba(94, 200, 255, 0.06);
}
.player-match-row.is-mvp .player-match-name {
color: var(--gold);
}
.player-match-hero {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.player-match-portrait {
width: 72px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.player-match-meta {
min-width: 0;
}
.player-match-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-mvp-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
color: #1a1408;
background: linear-gradient(180deg, #f0d78a, #c9a24a);
border-radius: 3px;
padding: 1px 5px;
}
.player-match-sub {
margin-top: 2px;
font-size: 12px;
color: var(--muted);
}
.player-match-metrics {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
font-size: 13px;
}
.player-match-metrics em {
font-style: normal;
color: var(--muted);
margin-right: 4px;
font-size: 11px;
}
.player-match-items {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: flex-end;
}
.player-match-item {
width: 36px;
height: 28px;
border-radius: 3px;
background: rgba(8, 12, 20, 0.55);
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
}
.player-match-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.player-match-item.empty {
opacity: 0.35;
}
@media (max-width: 900px) {
.player-match-row {
grid-template-columns: 1fr;
gap: 8px;
}
.player-match-items {
justify-content: flex-start;
}
}