Add 7.41e patch notes with a hand-authored reading sidebar.

Ship Climperor Web 0.6.13: latest-patch summary panel, icon/innate fallbacks, and layout/scroll fixes; keep shared ability badges in git.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-31 10:06:09 +08:00
co-authored by Cursor
parent a257f96d94
commit 30218790ce
18 changed files with 2169 additions and 63 deletions
+141 -10
View File
@@ -1175,18 +1175,24 @@ function wireTalentTrigger(btn, talents) {
* badge — many innate keys 404 on Steam CDN, matching dota2.com.
* Other abilities load `ability/{key}.png` with a quiet miss state on error.
*/
function markAbilityIconMissing(img) {
img.onerror = null;
img.removeAttribute("src");
img.alt = "";
img.classList.add("missing");
}
function setAbilityIcon(img, abilityKey, isInnate) {
if (isInnate) {
img.src = innateIconSrc();
img.onerror = null;
img.onerror = () => markAbilityIconMissing(img);
return;
}
img.src = abilityIconSrc(abilityKey);
img.onerror = () => {
img.onerror = null;
img.removeAttribute("src");
img.alt = "";
img.classList.add("missing");
// Many innates / renamed keys 404 on CDN — shared badge, then quiet miss.
img.onerror = () => markAbilityIconMissing(img);
img.src = innateIconSrc();
};
}
@@ -3668,13 +3674,25 @@ function innateAbilityKeys() {
return s;
}
/** Inline onerror for patch HTML icons: optional innate fallback, then remove (no empty box). */
function patchIconOnErrorAttr(tryInnate) {
if (tryInnate) {
const src = innateIconSrc().replace(/\\/g, "\\\\").replace(/'/g, "\\'");
return (
`this.onerror=null;this.src='${src}';` +
"this.onerror=function(){this.onerror=null;this.remove()}"
);
}
return "this.onerror=null;this.remove()";
}
function patchAbilityBlockHtml(ab, lookup) {
const ameta = (lookup.abilities || {})[String(ab.ability_id)];
const akey = ameta ? ameta.key : "";
const aname = ameta ? ameta.name_loc : "# " + ab.ability_id;
const innate = akey && innateAbilityKeys().has(akey);
const aicon = akey
? `<img class="patch-ability-icon" src="${innate ? innateIconSrc() : abilityIconSrc(akey)}" alt="" onerror="this.style.visibility='hidden'">`
? `<img class="patch-ability-icon" src="${innate ? innateIconSrc() : abilityIconSrc(akey)}" alt="" onerror="${patchIconOnErrorAttr(!innate)}">`
: "";
return `<div class="patch-ability">${aicon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(aname)}</span>${renderNotesList(ab.ability_notes)}</div></div>`;
}
@@ -3684,7 +3702,7 @@ function renderItemEntry(entry, lookup) {
const key = meta ? meta.key : "";
const name = meta ? meta.name_loc : "# " + entry.ability_id;
const icon = key
? `<img class="patch-item-icon" src="${itemIconSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
? `<img class="patch-item-icon" src="${itemIconSrc(key)}" alt="" onerror="${patchIconOnErrorAttr(false)}">`
: "";
return `<div class="patch-entry-row">${icon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(name)}</span>${renderNotesList(entry.ability_notes)}</div></div>`;
}
@@ -3694,7 +3712,7 @@ function renderHeroEntry(hero, lookup) {
const key = meta ? meta.key : "";
const name = meta ? meta.name_loc : "其它单位";
const portrait = key
? `<img class="patch-hero-icon" src="${portraitSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
? `<img class="patch-hero-icon" src="${portraitSrc(key)}" alt="" onerror="${patchIconOnErrorAttr(false)}">`
: "";
let html = `<div class="patch-hero"><div class="patch-hero-head">${portrait}<span class="patch-name">${escapeHtml(name)}</span></div>`;
html += renderHeroNotes(hero.hero_notes);
@@ -3762,6 +3780,112 @@ function ensureDefaultPatch() {
}
}
function patchSummaryFor(version) {
const pack = state.data?.patch_summaries || {};
const by = pack.by_version || {};
return version && by[version] ? by[version] : null;
}
function patchSummaryEntityLabel(kind, key) {
if (kind === "hero") {
const hero = heroByKey(key);
return (hero && (hero.name_loc || hero.key)) || key;
}
const meta = (state.data.items_meta || {})[key];
if (meta && meta.name_loc) return meta.name_loc;
const shop = ((state.data.item_shop || {}).items || {})[key];
if (shop && shop.name_loc) return shop.name_loc;
return key;
}
function renderPatchSummaryChip(kind, row) {
const key = row && row.key ? String(row.key) : "";
if (!key) return "";
const note = row.note ? String(row.note) : "";
const label = patchSummaryEntityLabel(kind, key);
const img =
kind === "hero"
? `<img class="patch-summary-icon patch-summary-icon--hero" src="${portraitSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
: `<img class="patch-summary-icon patch-summary-icon--item" src="${itemIconSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`;
const clickable = kind === "hero" && heroByKey(key);
const tag = clickable ? "button" : "div";
const attrs = clickable
? ` type="button" class="patch-summary-chip is-clickable" data-hero="${escapeHtml(key)}"`
: ` class="patch-summary-chip"`;
return `<${tag}${attrs}>${img}<span class="patch-summary-chip-body"><span class="patch-summary-chip-name">${escapeHtml(label)}</span>${note ? `<span class="patch-summary-chip-note">${escapeHtml(note)}</span>` : ""}</span></${tag}>`;
}
function renderPatchSummarySection(title, html) {
if (!html) return "";
return `<section class="patch-summary-section"><h4 class="patch-summary-section-title">${escapeHtml(title)}</h4>${html}</section>`;
}
function renderPatchAside(version) {
const aside = $("#patches-aside");
if (!aside) return;
const summary = patchSummaryFor(version);
if (!summary) {
aside.hidden = true;
aside.innerHTML = "";
return;
}
const themes = (summary.themes || [])
.filter((t) => typeof t === "string" && t.trim())
.map((t) => `<li>${escapeHtml(t)}</li>`)
.join("");
const buffs = (summary.buffs || []).map((r) => renderPatchSummaryChip("hero", r)).join("");
const nerfs = (summary.nerfs || []).map((r) => renderPatchSummaryChip("hero", r)).join("");
const items = (summary.items || []).map((r) => renderPatchSummaryChip("item", r)).join("");
const takeaways = (summary.takeaways || [])
.filter((t) => typeof t === "string" && t.trim())
.map((t) => `<li>${escapeHtml(t)}</li>`)
.join("");
let body = "";
if (summary.headline) {
body += `<p class="patch-summary-headline">${escapeHtml(String(summary.headline))}</p>`;
}
if (themes) {
body += renderPatchSummarySection("本版方向", `<ul class="patch-summary-list">${themes}</ul>`);
}
if (buffs) {
body += renderPatchSummarySection("明显加强", `<div class="patch-summary-chips">${buffs}</div>`);
}
if (nerfs) {
body += renderPatchSummarySection("明显削弱", `<div class="patch-summary-chips">${nerfs}</div>`);
}
if (items) {
body += renderPatchSummarySection("装备要点", `<div class="patch-summary-chips">${items}</div>`);
}
if (takeaways) {
body += renderPatchSummarySection("选将提示", `<ul class="patch-summary-list">${takeaways}</ul>`);
}
aside.hidden = false;
aside.innerHTML = `
<div class="patch-summary ui-panel">
<div class="patch-summary-head">
<h3 class="patch-summary-title">版本解读</h3>
<p class="patch-summary-disclaimer">非官方 · 社区摘要</p>
</div>
${body}
</div>`;
aside.querySelectorAll("button.patch-summary-chip[data-hero]").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.dataset.hero;
if (!key || !heroByKey(key)) return;
state.page = "heroes";
state.selectedKey = key;
state.detailTab = "patches";
state.inspect = null;
syncStateToUrl();
render();
});
});
}
function renderPatches() {
if (!state.data) return;
const select = $("#patch-select");
@@ -3774,6 +3898,7 @@ function renderPatches() {
if (select) select.innerHTML = "";
if (verEl) verEl.textContent = "";
root.innerHTML = '<div class="patches-empty">暂无版本数据</div>';
renderPatchAside(null);
return;
}
@@ -3803,6 +3928,8 @@ function renderPatches() {
const cur = patches.find((p) => p.version === state.selectedPatch) || patches[0];
if (verEl) verEl.textContent = (cur && cur.version) || "";
renderPatchAside(state.selectedPatch);
const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
const details = state.data.patch_details || {};
const det = details[state.selectedPatch];
@@ -5721,8 +5848,11 @@ function renderMechanics() {
abImg.alt = "";
abImg.loading = "lazy";
abImg.onerror = function () {
this.onerror = null;
this.src = assetUrl("ability/innate.png");
this.onerror = function () {
this.onerror = null;
this.remove();
};
this.src = innateIconSrc();
};
const textWrap = document.createElement("span");
textWrap.className = "mechanics-text";
@@ -6201,6 +6331,7 @@ async function main() {
state.data.patches = state.data.patches || [];
state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
state.data.patch_details = state.data.patch_details || {};
state.data.patch_summaries = state.data.patch_summaries || { by_version: {} };
state.data.leaderboards = state.data.leaderboards || {
fetched_at: null,
source: "valve",
+1 -1
View File
@@ -1,5 +1,5 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.6.9";
var SITE_VERSION = "0.6.13";
var SITE_ORIGIN = "";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+19 -12
View File
@@ -43,8 +43,8 @@
}
</script>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.6.9" />
<script src="/mobile-gate.js?v=0.6.9"></script>
<link rel="stylesheet" href="/style.css?v=0.6.13" />
<script src="/mobile-gate.js?v=0.6.13"></script>
</head>
<body>
<h1 class="sr-only">DOTA2 上分帝</h1>
@@ -218,14 +218,21 @@
</main>
<main id="patches-view" class="board patches-board hidden">
<div class="patches-header">
<h2 class="patches-title">
<span class="patches-kicker page-kicker">游戏性更新</span>
<span class="patches-ver" id="patch-ver"></span>
</h2>
<select id="patch-select" aria-label="选择版本"></select>
<div class="patches-cluster">
<div class="patches-center-wrap">
<div class="patches-main">
<div class="patches-header">
<h2 class="patches-title">
<span class="patches-kicker page-kicker">游戏性更新</span>
<span class="patches-ver" id="patch-ver"></span>
</h2>
<select id="patch-select" aria-label="选择版本"></select>
</div>
<div class="patches-detail" id="patches-detail"></div>
</div>
<aside class="patches-aside" id="patches-aside" hidden></aside>
</div>
</div>
<div class="patches-detail" id="patches-detail"></div>
</main>
<div id="detail-drawer" class="detail-drawer is-collapsed hidden" aria-hidden="true">
@@ -233,8 +240,8 @@
</div>
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
<script src="/config.js?v=0.6.9"></script>
<script src="/router.js?v=0.6.9"></script>
<script src="/app.js?v=0.6.9"></script>
<script src="/config.js?v=0.6.13"></script>
<script src="/router.js?v=0.6.13"></script>
<script src="/app.js?v=0.6.13"></script>
</body>
</html>
+184 -6
View File
@@ -1675,7 +1675,7 @@ body.detail-drawer-open {
display: block;
}
.skill-icon img.missing {
visibility: hidden;
display: none;
}
/* Circular triggers: talent tree + innate (official dota2.com order). */
.skill-icon.talent-trigger,
@@ -2833,17 +2833,32 @@ body:has(#items-view:not(.hidden)) {
}
.patches-board {
display: flex;
flex-direction: column;
align-items: center;
overflow-x: hidden;
overflow-y: auto;
}
/* Center only the changelog column; summary hangs in the right gutter. */
.patches-cluster {
display: flex;
justify-content: center;
width: 100%;
box-sizing: border-box;
padding: 0 24px;
}
.patches-center-wrap {
position: relative;
width: min(var(--content-read), calc(100% - 48px));
flex: 0 0 auto;
}
.patches-main {
width: 100%;
min-width: 0;
}
.patches-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
width: min(var(--content-read), 92%);
width: 100%;
padding: var(--space-xl) 0 10px;
}
.patches-title {
@@ -2881,9 +2896,169 @@ body:has(#items-view:not(.hidden)) {
cursor: pointer;
}
.patches-detail {
width: min(var(--content-read), 92%);
width: 100%;
padding: 6px 0 64px;
}
.patches-aside {
position: absolute;
left: calc(100% + var(--aside-gap));
top: 0;
bottom: 0;
width: 280px;
pointer-events: none;
box-sizing: border-box;
}
.patches-aside[hidden] {
display: none !important;
}
/* Cap to the visible board (below .topbar), not raw 100vh otherwise the
sticky box hangs past the viewport and the last lines stay unreachable. */
.patch-summary {
position: sticky;
top: 20px;
align-self: start;
width: 100%;
height: fit-content;
max-height: calc(100dvh - 9rem);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
pointer-events: auto;
display: flex;
flex-direction: column;
gap: var(--space-lg);
box-sizing: border-box;
padding-bottom: calc(var(--space-lg) + 8px);
}
.patch-summary-head {
display: flex;
flex-direction: column;
gap: 4px;
}
.patch-summary-title {
margin: 0;
font-size: var(--fs-body-lg);
font-weight: var(--fw-black);
color: var(--text);
letter-spacing: 0.02em;
line-height: var(--lh-tight);
}
.patch-summary-disclaimer {
margin: 0;
font-size: var(--fs-label);
font-weight: var(--fw-medium);
color: var(--muted);
line-height: 1.35;
}
.patch-summary-headline {
margin: 0;
font-size: var(--fs-body-sm);
font-weight: var(--fw-medium);
color: var(--text);
line-height: var(--lh-body);
}
.patch-summary-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.patch-summary-section-title {
margin: 0;
font-size: var(--fs-label);
font-weight: var(--fw-semibold);
color: var(--muted);
letter-spacing: 0.08em;
line-height: var(--lh-title);
}
.patch-summary-list {
margin: 0;
padding-left: 1.15em;
display: flex;
flex-direction: column;
gap: 6px;
font-size: var(--fs-body-sm);
color: var(--text);
line-height: 1.45;
}
.patch-summary-chips {
display: flex;
flex-direction: column;
gap: 8px;
}
.patch-summary-chip {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
}
button.patch-summary-chip.is-clickable {
cursor: pointer;
border-radius: var(--radius-sm);
}
button.patch-summary-chip.is-clickable:hover .patch-summary-chip-name {
color: var(--sel);
}
.patch-summary-icon {
flex-shrink: 0;
margin-top: 2px;
background: var(--panel);
border-radius: 3px;
}
.patch-summary-icon--hero {
width: 32px;
height: 18px;
object-fit: cover;
}
.patch-summary-icon--item {
width: 22px;
height: 22px;
object-fit: contain;
}
.patch-summary-chip-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.patch-summary-chip-name {
font-size: var(--fs-body-sm);
font-weight: var(--fw-semibold);
color: var(--text);
line-height: 1.3;
}
.patch-summary-chip-note {
font-size: var(--fs-label);
font-weight: var(--fw-medium);
color: var(--muted);
line-height: 1.4;
}
/* Need ~820 main + 300 aside gutter on each side of optical center. */
@media (max-width: 1440px) {
.patches-center-wrap {
display: flex;
flex-direction: column;
width: min(var(--content-read), 100%);
}
.patches-aside {
position: static;
width: 100%;
pointer-events: auto;
order: -1;
padding: 0 0 8px;
}
.patch-summary {
position: static;
max-height: none;
overflow: visible;
}
}
.patches-empty,
.rankings-empty,
.streamers-empty {
@@ -3384,6 +3559,9 @@ body:has(#items-view:not(.hidden)) {
height: 48px;
margin-top: 2px;
}
.patch-ability-icon.missing {
display: none;
}
.patch-name {
display: block;
margin-bottom: 4px;