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:
voson
2026-07-29 18:31:55 +08:00
co-authored by Cursor
parent 7681fdb069
commit b01552ee6e
50 changed files with 3406 additions and 487 deletions
+422 -119
View File
@@ -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 09 → "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) {