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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user