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
+3
View File
@@ -16,5 +16,8 @@
/router.js
Cache-Control: public, max-age=60, must-revalidate
/mobile-gate.js
Cache-Control: public, max-age=60, must-revalidate
/style.css
Cache-Control: public, max-age=300, must-revalidate
+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) {
+1 -1
View File
@@ -1,4 +1,4 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.72";
var SITE_VERSION = "0.5.84";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+117 -31
View File
@@ -4,7 +4,8 @@
* Visit-triggered live-status probing with request coalescing via the edge
* Cache API (no KV, no wrangler config). The first visitor after the 5-minute
* freshness window triggers a re-probe of every streamer with `live_url`;
* concurrent visitors within the window share the cached JSON.
* concurrent visitors within the window share the cached JSON. Concurrent cold
* starts in the same isolate also share one in-flight probe promise.
*
* Probe logic is a JS port of fetch_streamer_live.py:
* - Bilibili: api.live.bilibili.com Room/get_info; data.live_status === 1 is
@@ -16,10 +17,10 @@
*
* Soft-fail everywhere: douyin blocks from datacenter IPs are expected. When a
* single probe fails, the streamer carries over the last known is_live from
* the previous (stale) cache entry with `stale: true`; when every probe fails
* the stale cache entry is served wholesale (X-Live-Cache: stale-override),
* or an empty payload when nothing was ever cached (X-Live-Cache: error).
* The handler never throws a 500 for probe failures.
* data.json / the previous (stale) cache entry with `stale: true`; when every
* probe fails the stale cache entry is served wholesale (X-Live-Cache:
* stale-override), or an empty payload when nothing was ever cached
* (X-Live-Cache: error). The handler never throws a 500 for probe failures.
*
* Named exports double as the local test surface; the Pages runtime only
* routes onRequest* handlers.
@@ -51,6 +52,9 @@ const DOUYIN_ROOMSTORE_RE = /\\"roomStore\\":\s*\{\\"roomInfo\\":\s*\{\\"room\\"
const DOUYIN_STATUS_RE = /\\"status\\":\s*(\d)/;
const DOUYIN_WEBRID_RE = /\\"web_rid\\":\s*\\"(\d+)\\"/;
/** Isolate-local coalescing for concurrent cold starts (same Worker isolate). */
let inFlightProbe = null;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -172,16 +176,45 @@ export function roomRefFromUrl(liveUrl) {
return seg || null;
}
/** Probe backend follows the live room host (may differ from profile platform). */
export function livePlatformFromUrl(liveUrl, fallback = "") {
try {
const host = new URL(String(liveUrl).trim()).hostname.toLowerCase();
if (host.includes("bilibili.com")) return "bilibili";
if (host.includes("douyin.com")) return "douyin";
} catch {
/* keep fallback */
}
return String(fallback || "")
.trim()
.toLowerCase();
}
/**
* Extract the streamer row list from a data.json payload.
* Production nests under `streamers.streamers`; also accept a bare array.
*/
export function streamerRowsFromPayload(payload) {
if (!payload || typeof payload !== "object") return [];
if (Array.isArray(payload.streamers)) return payload.streamers;
const nested = payload.streamers;
if (nested && typeof nested === "object" && Array.isArray(nested.streamers)) {
return nested.streamers;
}
return [];
}
/** Extract probe targets (id/platform/room ref) from a data.json payload. */
export function targetsFromPayload(payload) {
const rows = payload && Array.isArray(payload.streamers) ? payload.streamers : [];
const rows = streamerRowsFromPayload(payload);
const targets = [];
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const sid = String(row.id || "").trim();
const liveUrl = String(row.live_url || "").trim();
if (!sid || !liveUrl) continue;
const platform = String(row.platform || "").trim().toLowerCase();
const fallback = String(row.platform || "").trim().toLowerCase();
const platform = livePlatformFromUrl(liveUrl, fallback);
const ref = roomRefFromUrl(liveUrl);
if (!ref) continue;
targets.push({ id: sid, platform, ref });
@@ -189,6 +222,18 @@ export function targetsFromPayload(payload) {
return targets;
}
/** Seed carry-over map from data.json is_live (daily snapshot). */
export function seedLiveFromPayload(payload) {
const out = {};
for (const row of streamerRowsFromPayload(payload)) {
if (!row || typeof row !== "object") continue;
const sid = String(row.id || "").trim();
if (!sid || typeof row.is_live !== "boolean") continue;
out[sid] = { is_live: row.is_live };
}
return out;
}
async function probeAll(targets) {
const jar = targets.some((t) => t.platform === "douyin")
? await warmDouyinCookies()
@@ -247,34 +292,49 @@ function emptyPayload() {
return { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers: {} };
}
async function handle(context) {
const { request } = context;
const cachedData = await readCachedPayload();
if (cachedData && isFresh(cachedData)) {
return jsonResponse(cachedData, "hit");
}
async function putCache(body) {
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
await caches.default.put(CACHE_KEY, cached);
}
/**
* Load targets + build a fresh probe payload (or return null when there is
* nothing to probe / total failure with no seed).
* Returns { body, cacheState } where cacheState is miss | stale-override | error.
*/
async function probeFresh(requestUrl, cachedData) {
let dataPayload = null;
let targets = [];
try {
const dataUrl = new URL("/data.json", request.url);
const dataUrl = new URL("/data.json", requestUrl);
const res = await fetchWithTimeout(dataUrl.toString(), {
headers: { Accept: "application/json" },
});
if (res.ok) targets = targetsFromPayload(await res.json());
if (res.ok) {
dataPayload = await res.json();
targets = targetsFromPayload(dataPayload);
}
} catch {
// data.json unreachable: fall through to stale/empty below
}
if (!targets.length) {
if (cachedData) return jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
return { body: emptyPayload(), cacheState: "error" };
}
const staleStreamers =
const seeded = seedLiveFromPayload(dataPayload);
const cachedStreamers =
cachedData && cachedData.streamers && typeof cachedData.streamers === "object"
? cachedData.streamers
: {};
// Prefer previous edge cache over the daily snapshot when both exist.
const staleStreamers = { ...seeded, ...cachedStreamers };
const probed = await probeAll(targets);
const streamers = {};
@@ -296,21 +356,47 @@ async function handle(context) {
if (freshCount === 0) {
// Total probe failure (e.g. douyin blocking this colo): serve the stale
// snapshot if one exists, otherwise an explicitly empty payload.
if (cachedData) return jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
if (Object.keys(seeded).length) {
const body = {
probed_at: new Date().toISOString(),
ttl: FRESH_TTL_S,
streamers: Object.fromEntries(
Object.entries(seeded).map(([id, cell]) => [
id,
{ is_live: cell.is_live, stale: true },
])
),
};
return { body, cacheState: "stale-override" };
}
return { body: emptyPayload(), cacheState: "error" };
}
const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers };
const res = jsonResponse(body, "miss");
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
// Cache write must not block the response.
context.waitUntil(caches.default.put(CACHE_KEY, cached));
return res;
return { body, cacheState: "miss" };
}
async function handle(context) {
const { request } = context;
const cachedData = await readCachedPayload();
if (cachedData && isFresh(cachedData)) {
return jsonResponse(cachedData, "hit");
}
// Coalesce concurrent cold starts in this isolate onto one probe run.
if (!inFlightProbe) {
inFlightProbe = probeFresh(request.url, cachedData).finally(() => {
inFlightProbe = null;
});
}
const { body, cacheState } = await inFlightProbe;
if (cacheState === "miss") {
context.waitUntil(putCache(body));
}
return jsonResponse(body, cacheState);
}
export async function onRequestGet(context) {
@@ -0,0 +1,70 @@
/**
* Pages Function: GET|POST /api/mobile-demand
*
* Best-effort edge counter for "please add mobile support" demand.
* Stored via the Cache API (no KV / wrangler binding), same pattern as
* live-status. Counts may undercount under concurrent colo races and can
* reset if the edge entry is evicted; good enough as a demand signal.
*
* GET -> { count }
* POST -> increment once, return { count, voted: true }
*/
const CACHE_KEY = "https://mobile-demand.internal/v1";
// Long store TTL so eviction is rare; freshness is not time-gated.
const CACHE_STORE_MAX_AGE_S = 365 * 24 * 60 * 60;
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
async function readCount() {
try {
const cached = await caches.default.match(CACHE_KEY);
if (!cached) return 0;
const data = await cached.json();
const n = Number(data && data.count);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
} catch {
return 0;
}
}
async function writeCount(count) {
const body = {
count,
updated_at: new Date().toISOString(),
};
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
await caches.default.put(CACHE_KEY, cached);
}
export async function onRequestGet() {
try {
const count = await readCount();
return jsonResponse({ count });
} catch {
return jsonResponse({ count: 0 });
}
}
export async function onRequestPost() {
try {
const next = (await readCount()) + 1;
await writeCount(next);
return jsonResponse({ count: next, voted: true });
} catch {
return jsonResponse({ error: "increment_failed" }, 503);
}
}
+22 -5
View File
@@ -5,9 +5,25 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOTA2 上分帝</title>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.5.72" />
<link rel="stylesheet" href="/style.css?v=0.5.84" />
<script src="/mobile-gate.js?v=0.5.84"></script>
</head>
<body>
<div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true">
<div class="mobile-gate-card">
<div class="mobile-gate-brand" aria-hidden="true">
<img class="mobile-gate-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="" />
<span class="mobile-gate-brand-title">上分帝</span>
</div>
<h1 id="mobile-gate-title" class="mobile-gate-title">暂不支持移动端</h1>
<p class="mobile-gate-desc">请从 PC 端浏览器访问本站。</p>
<button type="button" id="mobile-demand-btn" class="mobile-demand-btn">催更移动端</button>
<p id="mobile-demand-count" class="mobile-demand-count" hidden>
已有 <span id="mobile-demand-count-n">0</span> 人希望开通
</p>
</div>
</div>
<header class="topbar">
<div class="brand" aria-label="Dota 2 上分帝">
<img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" />
@@ -72,7 +88,8 @@
<div class="rankings-cluster">
<div class="rankings-center-wrap matches-center-wrap">
<div class="rankings-body matches-body" id="matches-body"></div>
<aside class="rankings-aside" aria-label="选手筛选">
<aside class="rankings-aside" aria-label="比赛筛选">
<div class="matches-origins" id="matches-origins" role="tablist" aria-label="职业或国服"></div>
<div class="matches-players" id="matches-players" role="tablist" aria-label="选择选手"></div>
<p class="rankings-sub" id="matches-sub"></p>
</aside>
@@ -149,8 +166,8 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.72"></script>
<script src="/router.js?v=0.5.72"></script>
<script src="/app.js?v=0.5.72"></script>
<script src="/config.js?v=0.5.84"></script>
<script src="/router.js?v=0.5.84"></script>
<script src="/app.js?v=0.5.84"></script>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
/**
* Early mobile client gate (loaded from <head>).
* Sets html.mobile-client before paint; wires the demand button after DOM ready.
* Exposes window.__CLIMPEROR_MOBILE__ for app.js to skip the desktop boot path.
*/
(function () {
function isMobileClient() {
var ua = navigator.userAgent || "";
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) {
return true;
}
// iPadOS 13+ reports as MacIntel with touch.
if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) {
return true;
}
return false;
}
var mobile = isMobileClient();
window.__CLIMPEROR_MOBILE__ = mobile;
if (!mobile) return;
document.documentElement.classList.add("mobile-client");
var STORAGE_KEY = "climperor_mobile_demand_voted";
function $(id) {
return document.getElementById(id);
}
function markVoted(btn) {
try {
localStorage.setItem(STORAGE_KEY, "1");
} catch (_) {
/* ignore quota / private mode */
}
if (btn) {
btn.disabled = true;
btn.textContent = "已记录催更";
}
}
function alreadyVoted() {
try {
return localStorage.getItem(STORAGE_KEY) === "1";
} catch (_) {
return false;
}
}
function setCount(n) {
var el = $("mobile-demand-count-n");
if (el) el.textContent = String(n);
var wrap = $("mobile-demand-count");
if (wrap) wrap.hidden = false;
}
async function refreshCount() {
try {
var res = await fetch("/api/mobile-demand");
if (!res.ok) return;
var data = await res.json();
var n = Number(data && data.count);
if (Number.isFinite(n) && n >= 0) setCount(Math.floor(n));
} catch (_) {
/* soft-fail: keep count hidden */
}
}
document.addEventListener("DOMContentLoaded", function () {
var logo = document.querySelector(".mobile-gate-logo");
if (logo && typeof STATIC_ASSET_BASE === "string") {
var base = STATIC_ASSET_BASE.trim().replace(/\/+$/, "");
if (base) logo.src = base + "/ui-icon/dota2_logo_wordmark.png";
}
var btn = $("mobile-demand-btn");
var voted = alreadyVoted();
if (voted) markVoted(btn);
refreshCount();
if (!btn || voted) return;
btn.addEventListener("click", async function () {
btn.disabled = true;
try {
var res = await fetch("/api/mobile-demand", { method: "POST" });
if (!res.ok) {
btn.disabled = false;
return;
}
var data = await res.json();
var n = Number(data && data.count);
if (Number.isFinite(n) && n >= 0) setCount(Math.floor(n));
markVoted(btn);
} catch (_) {
btn.disabled = false;
}
});
});
})();
+27 -1
View File
@@ -10,7 +10,8 @@
* - rankings: Immortal leaderboard region
* #/rankings[/region] (legacy #/rankings/meta[/bracket] players / china)
* - matches: star-player recent matches (pro_matches)
* #/matches[/account_id]
* #/matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted)
* - streamers: curated Douyin streamer directory
* #/streamers
* - trends: medal bracket for the 8-week win/pick board
@@ -50,6 +51,8 @@ const DEFAULT_TRENDS_SORT = "wr_end";
const VALID_TRENDS_SORTS = ["wr_end", "pr_end"];
const TRENDS_SORT_URL = { wr_end: "wr", pr_end: "pr" };
const TRENDS_SORT_FROM_URL = { wr: "wr_end", pr: "pr_end", wr_end: "wr_end", pr_end: "pr_end" };
const DEFAULT_MATCHES_ORIGIN = "all";
const VALID_MATCHES_ORIGINS = ["all", "pro", "china"];
const DEFAULT_MECHANIC_EFFECT = "basic_dispel";
const VALID_MECHANIC_EFFECTS = [
"basic_dispel",
@@ -95,6 +98,8 @@ function parseHash(hash) {
patchVersion: null,
rankingRegion: null,
matchesPlayerId: null,
matchesOrigin: null,
matchesPage: null,
trendsBracket: null,
trendsSort: null,
mechanicEffect: null,
@@ -152,6 +157,17 @@ function parseHash(hash) {
out.trendsSort = TRENDS_SORT_FROM_URL[sortParam];
}
}
if (page === "matches") {
const originParam = params.get("origin");
if (originParam && VALID_MATCHES_ORIGINS.includes(originParam)) {
out.matchesOrigin = originParam;
}
const pageParam = params.get("page");
if (pageParam && /^\d+$/.test(pageParam)) {
const n = parseInt(pageParam, 10);
if (n >= 1) out.matchesPage = n;
}
}
return out;
}
@@ -229,6 +245,16 @@ function serializeHash(state) {
if (state.itemQuery) qs.set("q", state.itemQuery);
const s = qs.toString();
if (s) hash += "?" + s;
} else if (state.page === "matches") {
const qs = new URLSearchParams();
const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN;
if (VALID_MATCHES_ORIGINS.includes(origin) && origin !== DEFAULT_MATCHES_ORIGIN) {
qs.set("origin", origin);
}
const pageNum = Number(state.matchesPage) || 1;
if (pageNum > 1) qs.set("page", String(pageNum));
const s = qs.toString();
if (s) hash += "?" + s;
}
return hash;
}
+241 -31
View File
@@ -1873,13 +1873,18 @@ a.match-player-name:hover {
color: rgba(180, 195, 215, 0.8);
}
.match-items,
.match-skills,
.match-backpack {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.match-skills {
display: flex;
flex-wrap: nowrap;
gap: 5px;
align-items: center;
}
.match-inv {
display: flex;
flex-direction: column;
@@ -2603,22 +2608,90 @@ body:has(#items-view:not(.hidden)) {
}
.matches-board .matches-body {
min-height: 0;
width: 100%;
min-width: 0;
}
.matches-center-wrap {
.matches-board .rankings-center-wrap.matches-center-wrap {
align-items: stretch;
/* Fixed column width so cards align; ~lv30 skill row + a little slack. */
width: min(1680px, 100%, calc(100vw - 280px));
}
.matches-board .match-list,
.matches-board .match-card {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.matches-board .match-skills {
max-width: 100%;
}
@media (max-width: 1460px) {
.match-skills {
flex-wrap: wrap;
}
}
.matches-board .rankings-aside {
align-items: stretch;
}
.matches-origins,
.matches-players {
display: flex;
flex-direction: column;
gap: 6px;
max-height: min(70vh, 560px);
overflow: auto;
scrollbar-width: thin;
padding-right: 2px;
width: 100%;
}
.matches-board .rankings-aside .rankings-region-btn {
width: 100%;
box-sizing: border-box;
}
.matches-board .match-list {
gap: 12px;
}
.matches-page-wrap {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.matches-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 0 8px;
}
.matches-pager-btn {
min-width: 36px;
height: 34px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid rgba(140, 170, 210, 0.28);
background: rgba(0, 0, 0, 0.28);
color: rgba(220, 230, 245, 0.9);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.matches-pager-btn:hover:not(:disabled):not(.active) {
border-color: rgba(180, 200, 230, 0.55);
color: #fff;
}
.matches-pager-btn.active {
border-color: rgba(232, 200, 120, 0.75);
background: rgba(180, 130, 40, 0.28);
color: rgba(255, 220, 160, 0.98);
cursor: default;
}
.matches-pager-btn:disabled {
opacity: 0.4;
cursor: default;
}
.matches-pager-ell {
color: rgba(160, 175, 195, 0.7);
padding: 0 2px;
user-select: none;
}
.rankings-empty {
padding: 48px 0;
text-align: center;
@@ -2696,6 +2769,9 @@ body:has(#items-view:not(.hidden)) {
gap: 18px;
width: min(640px, 94%);
}
.matches-board .rankings-center-wrap.matches-center-wrap {
width: min(1680px, 94%);
}
.rankings-aside {
position: static;
width: 100%;
@@ -3232,7 +3308,7 @@ body:has(#items-view:not(.hidden)) {
background: rgba(16, 24, 36, 0.92);
box-sizing: border-box;
}
/* Row 1 only: avatar | nickname | actions — meta/signature live below as siblings */
/* Row 1: avatar | info (name/meta/signature) | follow */
.streamer-card-head {
display: flex;
align-items: center;
@@ -3285,11 +3361,9 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
font-weight: 700;
color: var(--muted);
}
/* Douyin search live: outer ring expands+fades; avatar shrinks (1s ease). */
.streamer-avatar-wrap.is-live {
padding-bottom: 10px;
margin-bottom: -10px;
}
/* Douyin live avatar: ring pulse + badge overlaps ring (no gap).
Measured on live.douyin.com: badge bottom:-6px on 54px wrap, ~10px overlap,
gradient pill, no border/shadow. */
.streamer-avatar-wrap.is-live .streamer-avatar {
border-color: rgb(69, 71, 79);
}
@@ -3303,7 +3377,7 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
width: 99px;
height: 99px;
border-radius: 50%;
border: 1px solid rgb(254, 26, 104);
border: 1px solid rgb(254, 44, 85);
box-sizing: border-box;
pointer-events: none;
z-index: 0;
@@ -3335,23 +3409,37 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
.streamer-live-badge {
position: absolute;
left: 50%;
bottom: 0;
/* Douyin: bottom:-6px → badge overlaps ring/avatar bottom, no air gap. */
bottom: -6px;
transform: translateX(-50%);
z-index: 2;
padding: 1px 6px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 38px;
height: 16px;
padding: 0 6px;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
color: #fff;
background: rgb(254, 26, 104);
background: linear-gradient(131.17deg, rgb(255, 23, 100) 0%, rgb(237, 52, 149) 94.15%);
border: 0;
border-radius: 4px;
box-shadow: none;
white-space: nowrap;
pointer-events: none;
}
.streamer-name {
.streamer-card-info {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
}
.streamer-name {
margin: 0;
font-size: 20px;
font-weight: 700;
@@ -3362,38 +3450,52 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
white-space: nowrap;
}
.streamer-meta {
display: flex;
align-items: center;
width: fit-content;
max-width: 100%;
margin: 0;
font-size: 13px;
line-height: 20px;
letter-spacing: 0.6px;
line-height: 18px;
letter-spacing: 0.2px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Douyin search card: 1×10px divider, 8px side margin */
.streamer-meta-sep {
display: inline-block;
flex: 0 0 auto;
width: 1px;
height: 10px;
margin: 0 8px;
vertical-align: baseline;
background: rgba(255, 255, 255, 0.16);
height: 11px;
margin: 0 10px;
background: rgba(255, 255, 255, 0.13);
}
.streamer-meta-item {
display: inline;
flex: 0 0 auto;
}
.streamer-meta-value {
color: #dce5f2;
font-weight: 600;
}
.streamer-meta-item.is-account .streamer-meta-value {
color: #aab8cd;
font-weight: 500;
}
.streamer-meta-item.is-stat .streamer-meta-label {
margin-left: 3px;
}
.streamer-signature {
margin: 0;
font-size: 13px;
line-height: 20px;
letter-spacing: 0.4px;
color: var(--muted);
letter-spacing: 0.3px;
color: #8e9bb0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.streamer-actions {
flex: 0 0 auto;
@@ -3586,9 +3688,21 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
.streamer-avatar-fallback {
font-size: 24px;
}
.streamer-live-badge {
bottom: -5px;
min-width: 34px;
height: 15px;
font-size: 9px;
}
.streamer-name {
font-size: 18px;
}
.streamer-meta {
font-size: 12px;
}
.streamer-meta-sep {
margin: 0 6px;
}
.streamer-actions {
min-width: 76px;
gap: 8px;
@@ -3636,3 +3750,99 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
}
}
/* —— Mobile gate (phones / tablets; set by mobile-gate.js) —— */
#mobile-gate {
display: none;
}
html.mobile-client,
html.mobile-client body {
height: 100%;
max-height: none;
overflow: auto;
}
html.mobile-client body > :not(#mobile-gate) {
display: none !important;
}
html.mobile-client #mobile-gate {
display: flex;
flex: 1 1 auto;
min-height: 100dvh;
align-items: center;
justify-content: center;
padding: 32px 20px;
}
.mobile-gate-card {
width: min(360px, 100%);
text-align: center;
}
.mobile-gate-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 28px;
}
.mobile-gate-logo {
height: 28px;
width: auto;
display: block;
}
.mobile-gate-brand-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--text);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.mobile-gate-title {
margin: 0 0 10px;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
}
.mobile-gate-desc {
margin: 0 0 28px;
font-size: 15px;
line-height: 1.55;
color: var(--muted);
}
.mobile-demand-btn {
appearance: none;
border: 1px solid rgba(94, 200, 255, 0.45);
background: rgba(94, 200, 255, 0.12);
color: var(--sel);
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.06em;
padding: 12px 22px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.mobile-demand-btn:hover:not(:disabled) {
background: rgba(94, 200, 255, 0.2);
border-color: rgba(94, 200, 255, 0.7);
}
.mobile-demand-btn:focus-visible {
outline: 2px solid var(--sel);
outline-offset: 2px;
}
.mobile-demand-btn:disabled {
cursor: default;
opacity: 0.75;
border-color: rgba(140, 170, 210, 0.28);
background: rgba(140, 170, 210, 0.1);
color: var(--muted);
}
.mobile-demand-count {
margin: 16px 0 0;
font-size: 13px;
color: var(--muted);
}
.mobile-demand-count span {
color: var(--text);
font-weight: 600;
}