v0.5.84: matches origin filter, mobile gate, refresh reliability.
Ship Web refresh cache/lock, mobile demand gate, matches 职业/国服 filter, and related site updates through 0.5.84. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+422
-119
@@ -88,6 +88,10 @@ const state = {
|
||||
rankingRegion: "china",
|
||||
/** Top-level matches page: OpenDota account_id string (null = all stars) */
|
||||
matchesPlayerId: null,
|
||||
/** Top-level matches page: all | pro | china */
|
||||
matchesOrigin: "all",
|
||||
/** Top-level matches page: 1-based page index */
|
||||
matchesPage: 1,
|
||||
/** Top-level 走势 page medal bracket */
|
||||
trendsBracket: "legend",
|
||||
/** Sort key for trends board: wr_end | pr_end */
|
||||
@@ -390,7 +394,7 @@ const BRACKET_RANK_ICON = {
|
||||
|
||||
/** Recent matches tab: newest N ladder+league games. */
|
||||
const MATCHES_DISPLAY_LIMIT = 10;
|
||||
const PRO_MATCHES_PAGE_LIMIT = 60;
|
||||
const PRO_MATCHES_PAGE_SIZE = 20;
|
||||
|
||||
function heroStatsPack() {
|
||||
return (
|
||||
@@ -1056,6 +1060,139 @@ function heroMatchesFor(heroKey) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pad 0–9 → "00"…"09" for HH:mm. */
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unix sec/ms, ISO string, or Date → epoch ms. Date-only YYYY-MM-DD is
|
||||
* treated as Asia/Shanghai midnight (avoids UTC-day shift for CN dates).
|
||||
*/
|
||||
function parseTimeMs(input) {
|
||||
if (input == null || input === "") return NaN;
|
||||
if (input instanceof Date) {
|
||||
const t = input.getTime();
|
||||
return Number.isFinite(t) ? t : NaN;
|
||||
}
|
||||
if (typeof input === "number") {
|
||||
if (!Number.isFinite(input) || input <= 0) return NaN;
|
||||
return input > 1e12 ? input : input * 1000;
|
||||
}
|
||||
const s = String(input).trim();
|
||||
if (!s) return NaN;
|
||||
if (/^\d+(\.\d+)?$/.test(s)) {
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || n <= 0) return NaN;
|
||||
return n > 1e12 ? n : n * 1000;
|
||||
}
|
||||
const dayOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (dayOnly) {
|
||||
const t = new Date(
|
||||
`${dayOnly[1]}-${dayOnly[2]}-${dayOnly[3]}T00:00:00+08:00`
|
||||
).getTime();
|
||||
return Number.isFinite(t) ? t : NaN;
|
||||
}
|
||||
const t = Date.parse(s);
|
||||
return Number.isFinite(t) ? t : NaN;
|
||||
}
|
||||
|
||||
/** Calendar + clock parts in Asia/Shanghai (site audience). */
|
||||
function shanghaiParts(ms) {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(new Date(ms));
|
||||
const get = (type) => {
|
||||
const hit = parts.find((p) => p.type === type);
|
||||
return hit ? hit.value : "";
|
||||
};
|
||||
let hour = get("hour");
|
||||
// Some engines emit "24" for midnight under hour12:false.
|
||||
if (hour === "24") hour = "00";
|
||||
return {
|
||||
year: Number(get("year")),
|
||||
month: Number(get("month")),
|
||||
day: Number(get("day")),
|
||||
hour: Number(hour),
|
||||
minute: Number(get("minute")),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute wall time for CN UI.
|
||||
* Same year: "6/13 19:00"; other year: "2024/6/13 19:00".
|
||||
* dateOnly omits the clock.
|
||||
*/
|
||||
function formatAbsoluteTime(input, { dateOnly = false } = {}) {
|
||||
const ms = parseTimeMs(input);
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
const p = shanghaiParts(ms);
|
||||
const now = shanghaiParts(Date.now());
|
||||
const date =
|
||||
p.year === now.year
|
||||
? `${p.month}/${p.day}`
|
||||
: `${p.year}/${p.month}/${p.day}`;
|
||||
if (dateOnly) return date;
|
||||
return `${date} ${pad2(p.hour)}:${pad2(p.minute)}`;
|
||||
}
|
||||
|
||||
/** Full absolute for tooltips: "2026/7/29 16:48". */
|
||||
function formatAbsoluteTimeFull(input) {
|
||||
const ms = parseTimeMs(input);
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
const p = shanghaiParts(ms);
|
||||
return `${p.year}/${p.month}/${p.day} ${pad2(p.hour)}:${pad2(p.minute)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid friendly time (CN feed convention):
|
||||
* 刚刚 → N分钟前 → N小时前 → 昨天[ HH:mm] → N天前 → N周前 → M/D[ HH:mm].
|
||||
* Returns { text, title } so callers can set hover absolute.
|
||||
*/
|
||||
function formatFriendlyTime(input, { dateOnly = false, now = Date.now() } = {}) {
|
||||
const ms = parseTimeMs(input);
|
||||
if (!Number.isFinite(ms)) return { text: "", title: "" };
|
||||
const title = formatAbsoluteTimeFull(ms);
|
||||
const abs = () => formatAbsoluteTime(ms, { dateOnly });
|
||||
const diff = now - ms;
|
||||
// Clock skew / future → absolute.
|
||||
if (diff < -60_000) return { text: abs(), title };
|
||||
|
||||
const sec = Math.floor(Math.max(0, diff) / 1000);
|
||||
if (sec < 45) return { text: "刚刚", title };
|
||||
if (sec < 3600) {
|
||||
return { text: `${Math.max(1, Math.floor(sec / 60))}分钟前`, title };
|
||||
}
|
||||
if (sec < 86400) {
|
||||
return { text: `${Math.max(1, Math.floor(sec / 3600))}小时前`, title };
|
||||
}
|
||||
|
||||
const p = shanghaiParts(ms);
|
||||
const n = shanghaiParts(now);
|
||||
const dayStart = (parts) => Date.UTC(parts.year, parts.month - 1, parts.day);
|
||||
const dayDiff = Math.round((dayStart(n) - dayStart(p)) / 86400000);
|
||||
|
||||
if (dayDiff === 1) {
|
||||
const text = dateOnly
|
||||
? "昨天"
|
||||
: `昨天 ${pad2(p.hour)}:${pad2(p.minute)}`;
|
||||
return { text, title };
|
||||
}
|
||||
if (dayDiff >= 2 && dayDiff < 7) {
|
||||
return { text: `${dayDiff}天前`, title };
|
||||
}
|
||||
if (dayDiff >= 7 && dayDiff < 45) {
|
||||
return { text: `${Math.max(1, Math.floor(dayDiff / 7))}周前`, title };
|
||||
}
|
||||
return { text: abs(), title };
|
||||
}
|
||||
|
||||
function formatMatchDuration(sec) {
|
||||
const n = Number(sec) || 0;
|
||||
if (n <= 0) return "—";
|
||||
@@ -1064,21 +1201,6 @@ function formatMatchDuration(sec) {
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatMatchTime(ts) {
|
||||
const n = Number(ts);
|
||||
if (!Number.isFinite(n) || n <= 0) return "";
|
||||
try {
|
||||
return new Date(n * 1000).toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const RANK_TIER_MEDALS = [
|
||||
null,
|
||||
"herald",
|
||||
@@ -1297,19 +1419,13 @@ function buildMatchCard(heroKey, row, opts = {}) {
|
||||
|
||||
const origin = document.createElement("span");
|
||||
origin.className = "match-origin";
|
||||
if (row.origin === "public") {
|
||||
const cluster = Number(row.cluster);
|
||||
const region = Number(row.region);
|
||||
const chinaRegions = new Set([12, 13, 17, 18, 20, 25]);
|
||||
const chinaClusters = new Set([
|
||||
221, 222, 223, 224, 225, 227, 231, 232, 235, 236, 413, 414, 415, 417,
|
||||
]);
|
||||
const isChina =
|
||||
(Number.isFinite(region) && chinaRegions.has(region)) ||
|
||||
(Number.isFinite(cluster) && chinaClusters.has(cluster));
|
||||
origin.textContent = isChina ? "国服" : "天梯";
|
||||
if (isChina) origin.classList.add("match-origin-china");
|
||||
} else if (row.origin === "pro") {
|
||||
const kind = matchOriginKind(row);
|
||||
if (kind === "china") {
|
||||
origin.textContent = "国服";
|
||||
origin.classList.add("match-origin-china");
|
||||
} else if (kind === "ladder") {
|
||||
origin.textContent = "天梯";
|
||||
} else if (kind === "pro") {
|
||||
origin.textContent = "职业";
|
||||
origin.classList.add("match-origin-pro");
|
||||
} else {
|
||||
@@ -1325,11 +1441,12 @@ function buildMatchCard(heroKey, row, opts = {}) {
|
||||
head.appendChild(league);
|
||||
}
|
||||
|
||||
const when = formatMatchTime(row.start_time);
|
||||
if (when) {
|
||||
const whenInfo = formatFriendlyTime(row.start_time);
|
||||
if (whenInfo.text) {
|
||||
const timeEl = document.createElement("span");
|
||||
timeEl.className = "match-time";
|
||||
timeEl.textContent = when;
|
||||
timeEl.textContent = whenInfo.text;
|
||||
if (whenInfo.title) timeEl.title = whenInfo.title;
|
||||
head.appendChild(timeEl);
|
||||
}
|
||||
|
||||
@@ -1846,9 +1963,10 @@ function buildStatsPanel(heroKey) {
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "detail-muted detail-stats-note";
|
||||
const pack = heroStatsPack();
|
||||
const fetched = pack.fetched_at
|
||||
? `拉取于 ${String(pack.fetched_at).slice(0, 10)}`
|
||||
: "";
|
||||
const fetchedInfo = pack.fetched_at
|
||||
? formatFriendlyTime(pack.fetched_at)
|
||||
: { text: "", title: "" };
|
||||
const fetched = fetchedInfo.text ? `拉取于 ${fetchedInfo.text}` : "";
|
||||
foot.textContent = [
|
||||
`OpenDota ${heroStatsWindowLabel()}`,
|
||||
BRACKET_LABELS[bracket] || bracket,
|
||||
@@ -1856,6 +1974,7 @@ function buildStatsPanel(heroKey) {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
if (fetchedInfo.title) foot.title = fetchedInfo.title;
|
||||
block.appendChild(foot);
|
||||
}
|
||||
return block;
|
||||
@@ -1866,12 +1985,6 @@ function formatMatchupWr(wr) {
|
||||
return `${(Number(wr) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatMatchupFetched(iso) {
|
||||
if (!iso) return "";
|
||||
const s = String(iso);
|
||||
return s.length >= 10 ? s.slice(0, 10) : s;
|
||||
}
|
||||
|
||||
function matchupCrossLabel(cross) {
|
||||
const status = cross?.status;
|
||||
if (status === "agree") return "来源一致";
|
||||
@@ -2026,11 +2139,14 @@ function buildMatchupsPanel(heroKey) {
|
||||
notes.appendChild(line);
|
||||
}
|
||||
|
||||
const fetched = formatMatchupFetched(cell.fetched_at || pack.fetched_at);
|
||||
if (fetched) {
|
||||
const fetchedInfo = formatFriendlyTime(
|
||||
cell.fetched_at || pack.fetched_at
|
||||
);
|
||||
if (fetchedInfo.text) {
|
||||
const time = document.createElement("p");
|
||||
time.className = "matchup-note-time";
|
||||
time.textContent = `更新于 ${fetched}`;
|
||||
time.textContent = `更新于 ${fetchedInfo.text}`;
|
||||
if (fetchedInfo.title) time.title = fetchedInfo.title;
|
||||
notes.appendChild(time);
|
||||
}
|
||||
|
||||
@@ -3160,7 +3276,11 @@ function buildPatchChangesPanel(heroKey) {
|
||||
const hasNotes = (hentry.hero_notes || []).length > 0;
|
||||
const hasAbilities = (hentry.abilities || []).length > 0;
|
||||
if (!hasNotes && !hasAbilities) continue;
|
||||
let html = `<div class="patch-change-ver">${escapeHtml(p.version)}<span class="patch-change-date">${escapeHtml(p.date || "")}</span></div>`;
|
||||
const patchDate =
|
||||
formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) ||
|
||||
p.date ||
|
||||
"";
|
||||
let html = `<div class="patch-change-ver">${escapeHtml(p.version)}<span class="patch-change-date">${escapeHtml(patchDate)}</span></div>`;
|
||||
html += renderHeroNotes(hentry.hero_notes);
|
||||
for (const ab of (hentry.abilities || [])) {
|
||||
html += patchAbilityBlockHtml(ab, lookup);
|
||||
@@ -3206,7 +3326,11 @@ function renderPatches() {
|
||||
.map((p) => {
|
||||
const major = p.website ? " ★" : "";
|
||||
const sel = p.version === state.selectedPatch ? " selected" : "";
|
||||
return `<option value="${escapeHtml(p.version)}"${sel}>${escapeHtml(p.version)}${major} — ${escapeHtml(p.date || "")}</option>`;
|
||||
const patchDate =
|
||||
formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) ||
|
||||
p.date ||
|
||||
"";
|
||||
return `<option value="${escapeHtml(p.version)}"${sel}>${escapeHtml(p.version)}${major} — ${escapeHtml(patchDate)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
select.onchange = () => {
|
||||
@@ -3390,22 +3514,6 @@ function ensureDefaultRankingRegion() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatLeaderboardPosted(ts) {
|
||||
if (ts == null || ts === "") return "";
|
||||
const n = Number(ts);
|
||||
if (!Number.isFinite(n) || n <= 0) return "";
|
||||
// Valve posts unix seconds; guard ms accidentally.
|
||||
const ms = n > 1e12 ? n : n * 1000;
|
||||
try {
|
||||
return new Date(ms).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function renderRankings() {
|
||||
if (!state.data) return;
|
||||
const regionsEl = $("#rankings-regions");
|
||||
@@ -3468,9 +3576,11 @@ function renderRankings() {
|
||||
}
|
||||
|
||||
const cur = regions[state.rankingRegion] || {};
|
||||
const posted = formatLeaderboardPosted(cur.time_posted);
|
||||
const postedInfo = formatFriendlyTime(cur.time_posted);
|
||||
if (sub) {
|
||||
sub.textContent = posted ? `更新于 ${posted}` : "";
|
||||
sub.textContent = postedInfo.text ? `更新于 ${postedInfo.text}` : "";
|
||||
if (postedInfo.title) sub.title = postedInfo.title;
|
||||
else sub.removeAttribute("title");
|
||||
}
|
||||
|
||||
const rows = Array.isArray(cur.top100) ? cur.top100 : [];
|
||||
@@ -3508,7 +3618,47 @@ function proMatchesData() {
|
||||
);
|
||||
}
|
||||
|
||||
function proPlayerOptions() {
|
||||
/** OpenDota China region ids / cluster ids used to label 国服 pubs. */
|
||||
const MATCH_CHINA_REGIONS = new Set([12, 13, 17, 18, 20, 25]);
|
||||
const MATCH_CHINA_CLUSTERS = new Set([
|
||||
221, 222, 223, 224, 225, 227, 231, 232, 235, 236, 413, 414, 415, 417,
|
||||
]);
|
||||
const MATCH_ORIGIN_FILTERS = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "pro", label: "职业" },
|
||||
{ id: "china", label: "国服" },
|
||||
];
|
||||
|
||||
function isRankedPubMatch(row) {
|
||||
return row?.origin === "public" || Number(row?.lobby_type) === 7;
|
||||
}
|
||||
|
||||
function isChinaPubMatch(row) {
|
||||
if (!isRankedPubMatch(row)) return false;
|
||||
const cluster = Number(row.cluster);
|
||||
const region = Number(row.region);
|
||||
return (
|
||||
(Number.isFinite(region) && MATCH_CHINA_REGIONS.has(region)) ||
|
||||
(Number.isFinite(cluster) && MATCH_CHINA_CLUSTERS.has(cluster))
|
||||
);
|
||||
}
|
||||
|
||||
/** Classify a match row for badges / filters: pro | league | china | ladder. */
|
||||
function matchOriginKind(row) {
|
||||
if (isRankedPubMatch(row)) return isChinaPubMatch(row) ? "china" : "ladder";
|
||||
if (row?.origin === "pro") return "pro";
|
||||
return "league";
|
||||
}
|
||||
|
||||
function matchPassesOriginFilter(row, origin) {
|
||||
const want = origin || "all";
|
||||
if (want === "all") return true;
|
||||
if (want === "pro") return !isRankedPubMatch(row);
|
||||
if (want === "china") return isChinaPubMatch(row);
|
||||
return true;
|
||||
}
|
||||
|
||||
function proPlayerOptions(originFilter) {
|
||||
const pack = proMatchesData();
|
||||
const byPro = pack.by_pro || {};
|
||||
const prosMeta = pack.pros || {};
|
||||
@@ -3516,19 +3666,22 @@ function proPlayerOptions() {
|
||||
for (const [sid, cell] of Object.entries(byPro)) {
|
||||
if (!cell || typeof cell !== "object") continue;
|
||||
const matches = Array.isArray(cell.matches) ? cell.matches : [];
|
||||
if (!matches.length) continue;
|
||||
const filtered = matches.filter((row) =>
|
||||
matchPassesOriginFilter(row, originFilter)
|
||||
);
|
||||
if (!filtered.length) continue;
|
||||
const meta = prosMeta[sid] || {};
|
||||
const name =
|
||||
cell.name ||
|
||||
meta.name ||
|
||||
(matches[0] && matchPlayerDisplayName(matches[0])) ||
|
||||
(filtered[0] && matchPlayerDisplayName(filtered[0])) ||
|
||||
sid;
|
||||
const team = cell.team_tag || meta.team_tag || "";
|
||||
rows.push({
|
||||
account_id: String(cell.account_id || sid),
|
||||
name,
|
||||
team_tag: team,
|
||||
match_count: matches.length,
|
||||
match_count: filtered.length,
|
||||
});
|
||||
}
|
||||
rows.sort(
|
||||
@@ -3539,7 +3692,7 @@ function proPlayerOptions() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
function proMatchRows(playerId) {
|
||||
function proMatchRows(playerId, originFilter) {
|
||||
const pack = proMatchesData();
|
||||
const byPro = pack.by_pro || {};
|
||||
const out = [];
|
||||
@@ -3551,6 +3704,7 @@ function proMatchRows(playerId) {
|
||||
const matches = Array.isArray(cell.matches) ? cell.matches : [];
|
||||
for (const row of matches) {
|
||||
if (!row || typeof row !== "object") continue;
|
||||
if (!matchPassesOriginFilter(row, originFilter)) continue;
|
||||
const mid = Number(row.match_id);
|
||||
if (!mid || seen.has(mid)) continue;
|
||||
seen.add(mid);
|
||||
@@ -3576,12 +3730,48 @@ function proMatchRows(playerId) {
|
||||
|
||||
function renderMatchesPage() {
|
||||
const body = $("#matches-body");
|
||||
const originsEl = $("#matches-origins");
|
||||
const playersEl = $("#matches-players");
|
||||
const sub = $("#matches-sub");
|
||||
if (!body) return;
|
||||
|
||||
const options = proPlayerOptions();
|
||||
const hasData = options.length > 0;
|
||||
const origin = MATCH_ORIGIN_FILTERS.some((o) => o.id === state.matchesOrigin)
|
||||
? state.matchesOrigin
|
||||
: "all";
|
||||
if (origin !== state.matchesOrigin) state.matchesOrigin = origin;
|
||||
|
||||
const packHasAny = Object.values(proMatchesData().by_pro || {}).some(
|
||||
(cell) => Array.isArray(cell?.matches) && cell.matches.length
|
||||
);
|
||||
const options = proPlayerOptions(origin);
|
||||
const hasData = packHasAny;
|
||||
|
||||
if (originsEl) {
|
||||
if (!hasData) {
|
||||
originsEl.innerHTML = "";
|
||||
} else {
|
||||
originsEl.innerHTML = MATCH_ORIGIN_FILTERS.map((o) => {
|
||||
const active = origin === o.id ? " active" : "";
|
||||
return `<button type="button" class="rankings-region-btn${active}" data-origin="${escapeHtml(
|
||||
o.id
|
||||
)}" role="tab" aria-selected="${origin === o.id}">${escapeHtml(
|
||||
o.label
|
||||
)}</button>`;
|
||||
}).join("");
|
||||
originsEl.querySelectorAll("[data-origin]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const next = btn.getAttribute("data-origin") || "all";
|
||||
if (!MATCH_ORIGIN_FILTERS.some((o) => o.id === next)) return;
|
||||
state.matchesOrigin = next;
|
||||
state.matchesPage = 1;
|
||||
syncStateToUrl();
|
||||
renderMatchesPage();
|
||||
const board = $("#matches-view");
|
||||
if (board) board.scrollTop = 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (playersEl) {
|
||||
if (!hasData) {
|
||||
@@ -3610,6 +3800,7 @@ function renderMatchesPage() {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.getAttribute("data-player") || "";
|
||||
state.matchesPlayerId = id || null;
|
||||
state.matchesPage = 1;
|
||||
syncStateToUrl();
|
||||
renderMatchesPage();
|
||||
const board = $("#matches-view");
|
||||
@@ -3633,18 +3824,100 @@ function renderMatchesPage() {
|
||||
state.matchesPlayerId = null;
|
||||
}
|
||||
|
||||
const rows = proMatchRows(state.matchesPlayerId).slice(
|
||||
0,
|
||||
PRO_MATCHES_PAGE_LIMIT
|
||||
);
|
||||
if (sub) sub.textContent = "";
|
||||
const allRows = proMatchRows(state.matchesPlayerId, origin);
|
||||
const total = allRows.length;
|
||||
const pageSize = PRO_MATCHES_PAGE_SIZE;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize) || 1);
|
||||
let page = Number(state.matchesPage) || 1;
|
||||
if (page < 1) page = 1;
|
||||
if (page > pageCount) page = pageCount;
|
||||
if (page !== state.matchesPage) {
|
||||
state.matchesPage = page;
|
||||
syncStateToUrl({ replace: true });
|
||||
}
|
||||
const start = (page - 1) * pageSize;
|
||||
const rows = allRows.slice(start, start + pageSize);
|
||||
|
||||
if (sub) {
|
||||
sub.textContent = total
|
||||
? `共 ${total} 场 · 第 ${page}/${pageCount} 页`
|
||||
: "";
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
body.innerHTML = '<div class="rankings-empty">该选手暂无近期比赛</div>';
|
||||
body.innerHTML = state.matchesPlayerId
|
||||
? '<div class="rankings-empty">该选手暂无符合筛选的近期比赛</div>'
|
||||
: '<div class="rankings-empty">暂无符合筛选的近期比赛</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.replaceChildren(buildMatchList(null, rows, { showHero: true }));
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "matches-page-wrap";
|
||||
wrap.appendChild(buildMatchList(null, rows, { showHero: true }));
|
||||
if (pageCount > 1) {
|
||||
wrap.appendChild(
|
||||
buildMatchesPager(page, pageCount, (next) => {
|
||||
state.matchesPage = next;
|
||||
syncStateToUrl();
|
||||
renderMatchesPage();
|
||||
const board = $("#matches-view");
|
||||
if (board) board.scrollTop = 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
body.replaceChildren(wrap);
|
||||
}
|
||||
|
||||
function buildMatchesPager(page, pageCount, onGo) {
|
||||
const nav = document.createElement("nav");
|
||||
nav.className = "matches-pager";
|
||||
nav.setAttribute("aria-label", "比赛分页");
|
||||
|
||||
const mkBtn = (label, target, disabled, current) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "matches-pager-btn" + (current ? " active" : "");
|
||||
btn.textContent = label;
|
||||
btn.disabled = !!disabled;
|
||||
if (current) btn.setAttribute("aria-current", "page");
|
||||
if (!disabled && !current) {
|
||||
btn.addEventListener("click", () => onGo(target));
|
||||
}
|
||||
return btn;
|
||||
};
|
||||
|
||||
nav.appendChild(mkBtn("上一页", page - 1, page <= 1, false));
|
||||
|
||||
const windowSize = 5;
|
||||
let from = Math.max(1, page - Math.floor(windowSize / 2));
|
||||
let to = Math.min(pageCount, from + windowSize - 1);
|
||||
from = Math.max(1, to - windowSize + 1);
|
||||
if (from > 1) {
|
||||
nav.appendChild(mkBtn("1", 1, false, page === 1));
|
||||
if (from > 2) {
|
||||
const ell = document.createElement("span");
|
||||
ell.className = "matches-pager-ell";
|
||||
ell.textContent = "…";
|
||||
nav.appendChild(ell);
|
||||
}
|
||||
}
|
||||
for (let i = from; i <= to; i++) {
|
||||
nav.appendChild(mkBtn(String(i), i, false, i === page));
|
||||
}
|
||||
if (to < pageCount) {
|
||||
if (to < pageCount - 1) {
|
||||
const ell = document.createElement("span");
|
||||
ell.className = "matches-pager-ell";
|
||||
ell.textContent = "…";
|
||||
nav.appendChild(ell);
|
||||
}
|
||||
nav.appendChild(
|
||||
mkBtn(String(pageCount), pageCount, false, page === pageCount)
|
||||
);
|
||||
}
|
||||
|
||||
nav.appendChild(mkBtn("下一页", page + 1, page >= pageCount, false));
|
||||
return nav;
|
||||
}
|
||||
|
||||
function streamersData() {
|
||||
@@ -4059,45 +4332,33 @@ function formatStreamerCount(n) {
|
||||
return `${text}亿`;
|
||||
}
|
||||
|
||||
function formatStreamerFetched(iso) {
|
||||
if (!iso || typeof iso !== "string") return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Account id + social counts for the middle column (between avatar and follow). */
|
||||
function buildStreamerMetaLine(row) {
|
||||
const platform = String(row.platform || "").toLowerCase();
|
||||
const uniqueId = row.unique_id || "";
|
||||
const parts = [];
|
||||
if (uniqueId) {
|
||||
parts.push(
|
||||
platform === "douyin"
|
||||
? `抖音号:${uniqueId}`
|
||||
: platform === "bilibili"
|
||||
? `UID:${uniqueId}`
|
||||
: String(uniqueId),
|
||||
);
|
||||
parts.push({
|
||||
label: platform === "douyin" ? "抖音号" : platform === "bilibili" ? "UID" : "账号",
|
||||
value: String(uniqueId),
|
||||
account: true,
|
||||
});
|
||||
}
|
||||
if (platform === "bilibili") {
|
||||
if (Number.isFinite(Number(row.following_count))) {
|
||||
parts.push(`${formatStreamerCount(row.following_count)}关注`);
|
||||
parts.push({ label: "关注", value: formatStreamerCount(row.following_count) });
|
||||
}
|
||||
} else if (Number.isFinite(Number(row.total_favorited))) {
|
||||
parts.push(`${formatStreamerCount(row.total_favorited)}获赞`);
|
||||
parts.push({ label: "获赞", value: formatStreamerCount(row.total_favorited) });
|
||||
}
|
||||
if (Number.isFinite(Number(row.follower_count))) {
|
||||
parts.push(`${formatStreamerCount(row.follower_count)}粉丝`);
|
||||
parts.push({ label: "粉丝", value: formatStreamerCount(row.follower_count) });
|
||||
}
|
||||
if (!parts.length) return null;
|
||||
|
||||
// Douyin user-search card: item spans + 1px sep with ~8px side margin (not raw "|")
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "streamer-meta";
|
||||
parts.forEach((text, i) => {
|
||||
parts.forEach((part, i) => {
|
||||
if (i > 0) {
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "streamer-meta-sep";
|
||||
@@ -4105,8 +4366,20 @@ function buildStreamerMetaLine(row) {
|
||||
meta.appendChild(sep);
|
||||
}
|
||||
const item = document.createElement("span");
|
||||
item.className = "streamer-meta-item";
|
||||
item.textContent = text;
|
||||
item.className = part.account
|
||||
? "streamer-meta-item is-account"
|
||||
: "streamer-meta-item is-stat";
|
||||
const label = document.createElement("span");
|
||||
label.className = "streamer-meta-label";
|
||||
label.textContent = part.label;
|
||||
const value = document.createElement("span");
|
||||
value.className = "streamer-meta-value";
|
||||
value.textContent = part.value;
|
||||
if (part.account) {
|
||||
item.append(label, document.createTextNode(":"), value);
|
||||
} else {
|
||||
item.append(value, label);
|
||||
}
|
||||
meta.appendChild(item);
|
||||
});
|
||||
return meta;
|
||||
@@ -4234,10 +4507,16 @@ function buildStreamerCard(row) {
|
||||
}
|
||||
head.appendChild(avatarWrap);
|
||||
|
||||
// Middle column: nickname + account/stats only (signature is a full-width row below).
|
||||
const info = document.createElement("div");
|
||||
info.className = "streamer-card-info";
|
||||
const nameEl = document.createElement("h3");
|
||||
nameEl.className = "streamer-name";
|
||||
nameEl.textContent = nick;
|
||||
head.appendChild(nameEl);
|
||||
info.appendChild(nameEl);
|
||||
const metaEl = buildStreamerMetaLine(row);
|
||||
if (metaEl) info.appendChild(metaEl);
|
||||
head.appendChild(info);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "streamer-actions";
|
||||
@@ -4253,10 +4532,7 @@ function buildStreamerCard(row) {
|
||||
if (actions.childNodes.length) head.appendChild(actions);
|
||||
card.appendChild(head);
|
||||
|
||||
// Meta + signature span full card width below the avatar row (Douyin-style)
|
||||
const metaEl = buildStreamerMetaLine(row);
|
||||
if (metaEl) card.appendChild(metaEl);
|
||||
|
||||
// Signature on its own row — long bios must not squeeze the avatar/follow row.
|
||||
if (signature) {
|
||||
const sig = document.createElement("p");
|
||||
sig.className = "streamer-signature";
|
||||
@@ -4402,14 +4678,16 @@ function renderStreamers() {
|
||||
if (!body) return;
|
||||
const data = streamersData();
|
||||
const rows = streamerList();
|
||||
const fetched = formatStreamerFetched(data.fetched_at);
|
||||
const fetchedInfo = formatFriendlyTime(data.fetched_at);
|
||||
body.innerHTML = "";
|
||||
body.appendChild(
|
||||
buildStreamerCards(rows, { emptyText: "暂无主播数据" })
|
||||
);
|
||||
if (foot) {
|
||||
foot.textContent = fetched ? `更新于 ${fetched}` : "";
|
||||
foot.setAttribute("aria-hidden", fetched ? "false" : "true");
|
||||
foot.textContent = fetchedInfo.text ? `更新于 ${fetchedInfo.text}` : "";
|
||||
if (fetchedInfo.title) foot.title = fetchedInfo.title;
|
||||
else foot.removeAttribute("title");
|
||||
foot.setAttribute("aria-hidden", fetchedInfo.text ? "false" : "true");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4449,9 +4727,9 @@ function syncStreamerCardLive(card, row) {
|
||||
|
||||
/**
|
||||
* Visit-triggered live refresh: the Pages Function coalesces concurrent
|
||||
* visitors through a 5-minute edge cache. Local dev has no /api/live-status
|
||||
* (serve_relations.py returns an empty stub), so failures keep the data.json
|
||||
* is_live fallback. Fires at most once per page load.
|
||||
* visitors through a 5-minute edge cache. Local `serve_relations.py` returns
|
||||
* an empty stub (no probing), so that path keeps the data.json is_live
|
||||
* fallback. Fires at most once per page load.
|
||||
*/
|
||||
let liveStatusFetched = false;
|
||||
|
||||
@@ -4459,10 +4737,17 @@ function refreshStreamerLiveStatus() {
|
||||
if (liveStatusFetched) return;
|
||||
liveStatusFetched = true;
|
||||
fetch("/api/live-status")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
const ctype = (res.headers.get("content-type") || "").toLowerCase();
|
||||
if (!ctype.includes("application/json")) return null;
|
||||
return res.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
const probed = payload && payload.streamers;
|
||||
if (!probed || typeof probed !== "object") return;
|
||||
// Empty map (local stub / total probe error) → keep data.json fallback.
|
||||
if (!Object.keys(probed).length) return;
|
||||
const rows =
|
||||
(state.data && state.data.streamers && state.data.streamers.streamers) || [];
|
||||
let changed = false;
|
||||
@@ -4693,10 +4978,14 @@ function renderTrends() {
|
||||
});
|
||||
}
|
||||
|
||||
const fetched = pack.fetched_at
|
||||
? `更新于 ${String(pack.fetched_at).slice(0, 10)}`
|
||||
: "";
|
||||
if (sub) sub.textContent = fetched;
|
||||
const fetchedInfo = pack.fetched_at
|
||||
? formatFriendlyTime(pack.fetched_at)
|
||||
: { text: "", title: "" };
|
||||
if (sub) {
|
||||
sub.textContent = fetchedInfo.text ? `更新于 ${fetchedInfo.text}` : "";
|
||||
if (fetchedInfo.title) sub.title = fetchedInfo.title;
|
||||
else sub.removeAttribute("title");
|
||||
}
|
||||
|
||||
const rows = buildTrendsBoardRows(state.trendsBracket);
|
||||
if (!rows.length) {
|
||||
@@ -5204,13 +5493,25 @@ function applyPatch(patch) {
|
||||
state.rankingRegion = lb.default_region || order[0] || "china";
|
||||
}
|
||||
}
|
||||
// Star-player filter (matches page).
|
||||
// Star-player filter + origin + page (matches page).
|
||||
if (state.page === "matches") {
|
||||
if (patch.matchesPlayerId && /^\d+$/.test(String(patch.matchesPlayerId))) {
|
||||
state.matchesPlayerId = String(patch.matchesPlayerId);
|
||||
} else {
|
||||
state.matchesPlayerId = null;
|
||||
}
|
||||
if (
|
||||
patch.matchesOrigin === "pro" ||
|
||||
patch.matchesOrigin === "china" ||
|
||||
patch.matchesOrigin === "all"
|
||||
) {
|
||||
state.matchesOrigin = patch.matchesOrigin;
|
||||
} else {
|
||||
state.matchesOrigin = "all";
|
||||
}
|
||||
const mp = Number(patch.matchesPage);
|
||||
state.matchesPage =
|
||||
Number.isFinite(mp) && mp >= 1 ? Math.floor(mp) : 1;
|
||||
}
|
||||
// Item (items page) — must exist in the shop catalog.
|
||||
if (patch.itemKey && shopItem(patch.itemKey)) {
|
||||
@@ -5281,6 +5582,8 @@ function bindPageChrome() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// mobile-gate.js sets this before paint; skip desktop data boot on phones/tablets.
|
||||
if (typeof window !== "undefined" && window.__CLIMPEROR_MOBILE__) return;
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (talentPopoverEl) {
|
||||
|
||||
Reference in New Issue
Block a user