v0.5.109: hero role toolbar, skill side-by-side layout, taller detail panel.

Move role filters between grid and detail, fix skill video/text layout and detail height jump, unify item dividers, drop OpenDota matchup cross, sync docs and assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-30 00:48:07 +08:00
co-authored by Cursor
parent 09f0a0b4a0
commit d6212169af
38 changed files with 7225 additions and 6924 deletions
+116 -107
View File
@@ -56,6 +56,21 @@ function rankIconSrc(file) {
return assetUrl(`rank/${name}`);
}
const ROLE_ICON_FILES = {
核心: "Carry.png",
辅助: "Support.png",
推进: "Pusher.png",
逃生: "Escape.png",
爆发: "Nuker.png",
先手: "Initiator.png",
控制: "Disabler.png",
耐久: "Durable.png",
};
function roleIconSrc(file) {
return assetUrl(`role-icon/${encodeURIComponent(file)}`);
}
function siteVersionLabel() {
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
return v ? `v${v}` : "";
@@ -243,10 +258,37 @@ function renderTagbar() {
if (!bar) return;
const order = state.data.tag_order || [];
bar.innerHTML = "";
const addLabel = (button, text, iconFile) => {
if (iconFile) {
const icon = document.createElement("img");
icon.className = "tagbar-role-icon";
icon.src = roleIconSrc(iconFile);
icon.alt = "";
icon.setAttribute("aria-hidden", "true");
button.appendChild(icon);
} else {
const icon = document.createElement("span");
icon.className = "tagbar-all-icon";
icon.setAttribute("aria-hidden", "true");
icon.innerHTML =
'<svg viewBox="0 0 16 16" focusable="false"><path d="M1.5 1.5h5v5h-5zM9.5 1.5h5v5h-5zM1.5 9.5h5v5h-5zM9.5 9.5h5v5h-5z"/></svg>';
button.appendChild(icon);
}
const label = document.createElement("span");
label.textContent = text;
button.appendChild(label);
};
const selectedHero = state.selectedKey ? heroByKey(state.selectedKey) : null;
const heroTags = new Set(selectedHero?.tags || []);
const allBtn = document.createElement("button");
allBtn.type = "button";
allBtn.textContent = "全部";
allBtn.className = state.tagFilters.size ? "" : "active";
addLabel(allBtn, "全部");
// "全部" only when no filter and no hero selected; hero roles use `.matched`.
allBtn.className =
!state.tagFilters.size && !state.selectedKey ? "active" : "";
allBtn.addEventListener("click", () => {
state.tagFilters.clear();
syncStateToUrl({ replace: true });
@@ -254,10 +296,16 @@ function renderTagbar() {
});
bar.appendChild(allBtn);
for (const tag of order) {
const iconFile = ROLE_ICON_FILES[tag];
if (!iconFile) continue;
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = tag;
addLabel(btn, tag, iconFile);
if (state.tagFilters.has(tag)) btn.classList.add("active");
if (heroTags.has(tag)) {
btn.classList.add("matched");
btn.title = "该英雄定位";
}
btn.addEventListener("click", () => {
if (state.tagFilters.has(tag)) state.tagFilters.delete(tag);
else state.tagFilters.add(tag);
@@ -1674,7 +1722,6 @@ function stratzMatchupPack() {
kind: "global_aggregate",
label_zh: "全局聚合(未按段位 / 分路 / 周过滤)",
},
cross_source: null,
stats: {},
}
);
@@ -1985,13 +2032,6 @@ function formatMatchupWr(wr) {
return `${(Number(wr) * 100).toFixed(1)}%`;
}
function matchupCrossLabel(cross) {
const status = cross?.status;
if (status === "agree") return "来源一致";
if (status === "conflict") return "来源分歧";
return null;
}
function buildMatchupColumn(title, entries, scoreKey, scoreLabel) {
const col = document.createElement("div");
col.className = "matchup-col";
@@ -2040,40 +2080,20 @@ function buildMatchupColumn(title, entries, scoreKey, scoreLabel) {
const meta = document.createElement("span");
meta.className = "matchup-meta detail-muted";
const wrText = formatMatchupWr(row.wr);
const crossLab = matchupCrossLabel(row.cross);
meta.textContent = [
`胜率 ${wrText}`,
`${formatPickCount(row.games)}`,
crossLab,
]
.filter(Boolean)
.join(" · ");
meta.textContent = [`胜率 ${wrText}`, `${formatPickCount(row.games)}`].join(
" · "
);
mid.appendChild(name);
mid.appendChild(meta);
const score = document.createElement("span");
score.className = "matchup-score";
score.textContent = formatAdvantage(row[scoreKey]);
const crossStatus = row.cross?.status;
if (crossStatus === "agree") score.classList.add("is-agree");
if (crossStatus === "conflict") score.classList.add("is-conflict");
const odotaTip =
row.cross && row.cross.opendota_games != null
? ` · OpenDota 基线差 ${
row.cross.opendota_adv == null
? "—"
: formatAdvantage(row.cross.opendota_adv * 100)
}pp / ${formatPickCount(row.cross.opendota_games)}`
: "";
score.title = [
`STRATZ 相对优势 ${formatAdvantage(row[scoreKey])}(非胜率百分点)`,
`对局胜率 ${wrText}`,
`${formatPickCount(row.games)}`,
crossLab ? `交叉:${crossLab}` : "",
odotaTip,
]
.filter(Boolean)
.join(" · ");
].join(" · ");
item.appendChild(img);
item.appendChild(mid);
@@ -2115,20 +2135,9 @@ function buildMatchupsPanel(heroKey) {
const notes = document.createElement("div");
notes.className = "matchup-notes detail-muted";
const hintBits = [
"数字是相对表现(综合胜率与自身强弱),不是直接胜率;也不分段位、分路,和「走势」页不是同一套数据。",
];
const cross = pack.cross_source;
if (cross && cross.available === false) {
hintBits.push("暂无其他数据源可对照,交叉结论仅供参考。");
} else if (cross && cross.available) {
hintBits.push(
"「来源一致 / 分歧」表示是否与 OpenDota 公开统计方向相同,仅作对照。"
);
}
const hint = document.createElement("p");
hint.className = "matchup-note-line";
hint.textContent = hintBits.join(" ");
hint.textContent = "数据来自 STRATZ(相对优势,全局聚合)。";
notes.appendChild(hint);
const stale = cell.stale === true;
@@ -2369,7 +2378,7 @@ function mountItemInspect(root, meta, { onPickItem, hideIcon = false } = {}) {
const nameRow = document.createElement("div");
nameRow.className = "item-detail-name-row";
const title = document.createElement("div");
const title = document.createElement("h3");
title.className = "item-detail-name";
title.textContent = meta.name_loc || meta.key;
nameRow.appendChild(title);
@@ -2590,28 +2599,6 @@ function appendVitalBar(parent, kind, label, value, regen, scale) {
parent.appendChild(bar);
}
/** Hero role tags (skills tab center column, above skill icons). */
function buildHeroTagsRow(hero) {
const tags = document.createElement("div");
tags.className = "detail-tags";
tags.setAttribute("aria-label", "英雄定位");
const tagList = hero.tags || [];
if (!tagList.length) {
const none = document.createElement("span");
none.className = "detail-muted";
none.textContent = "暂无定位";
tags.appendChild(none);
return tags;
}
for (const t of tagList) {
const chip = document.createElement("span");
chip.className = "tag-chip";
chip.textContent = t;
tags.appendChild(chip);
}
return tags;
}
/** Left column: HP/Mana + STR/AGI/INT (skills tab only). */
function buildHeroVitalsAttrs(hero) {
if (hero.base_str == null && hero.health == null) return null;
@@ -2672,38 +2659,67 @@ function buildHeroVitalsAttrs(hero) {
return strip.childNodes.length ? strip : null;
}
/** Official dota2.com.cn herostatic/stats icons (served from /ui-icon/). */
function combatStatIconSrc(name) {
return assetUrl(`ui-icon/${name}`);
}
/** Right rail: Attack / Defense / Mobility as 3 side-by-side columns (skills tab). */
function buildHeroCombatStats(hero) {
// Rows: [iconFile, labelZh, value] — icons match dota2.com.cn hero pages.
const combatGroups = [
{
title: "攻击",
rows: [
hero.damage_min != null && hero.damage_max != null
? ["伤害", `${hero.damage_min}${hero.damage_max}`]
? ["icon_damage.png", "伤害", `${hero.damage_min}${hero.damage_max}`]
: null,
hero.attack_rate != null
? ["icon_attack_time.png", "间隔", fmtNum(hero.attack_rate, 1)]
: null,
hero.attack_range != null
? ["icon_attack_range.png", "距离", String(hero.attack_range)]
: null,
hero.attack_rate != null ? ["间隔", fmtNum(hero.attack_rate, 1)] : null,
hero.attack_range != null ? ["距离", String(hero.attack_range)] : null,
// Melee heroes often have projectile_speed 0 in OpenDota — skip those.
hero.projectile_speed != null && Number(hero.projectile_speed) > 0
? ["弹道", String(hero.projectile_speed)]
? [
"icon_projectile_speed.png",
"弹道",
String(hero.projectile_speed),
]
: null,
].filter(Boolean),
},
{
title: "防御",
rows: [
hero.armor != null ? ["护甲", fmtNum(hero.armor, 1)] : null,
hero.magic_resist != null ? ["魔抗", `${hero.magic_resist}%`] : null,
hero.armor != null
? ["icon_armor.png", "护甲", fmtNum(hero.armor, 1)]
: null,
hero.magic_resist != null
? ["icon_magic_resist.png", "魔抗", `${hero.magic_resist}%`]
: null,
].filter(Boolean),
},
{
title: "机动性",
rows: [
hero.move_speed != null ? ["移速", String(hero.move_speed)] : null,
hero.turn_rate != null ? ["转身", fmtNum(hero.turn_rate, 1)] : null,
hero.vision_day != null && hero.vision_night != null
? ["视野", `白天:${hero.vision_day}\n夜晚:${hero.vision_night}`]
hero.move_speed != null
? ["icon_movement_speed.png", "移速", String(hero.move_speed)]
: null,
// OpenDota omits the engine default 0.6; show it like dota2.com.cn.
[
"icon_turn_rate.png",
"转身",
fmtNum(hero.turn_rate != null ? hero.turn_rate : 0.6, 1),
],
[
"icon_vision.png",
"视野",
`${hero.vision_day != null ? hero.vision_day : 1800} / ${
hero.vision_night != null ? hero.vision_night : 800
}`,
],
].filter(Boolean),
},
].filter((g) => g.rows.length);
@@ -2730,30 +2746,21 @@ function buildHeroCombatStats(hero) {
section.appendChild(title);
const list = document.createElement("dl");
list.className = "hero-stats-combat-list";
for (const [label, val] of g.rows) {
for (const [icon, label, val] of g.rows) {
const row = document.createElement("div");
row.className = "hero-stat-row";
const dt = document.createElement("dt");
dt.textContent = label;
const img = document.createElement("img");
img.className = "hero-stat-icon";
img.src = combatStatIconSrc(icon);
img.alt = label;
img.title = label;
img.width = 20;
img.height = 20;
img.decoding = "async";
dt.appendChild(img);
const dd = document.createElement("dd");
if (typeof val === "string" && val.includes("\n")) {
for (const line of val.split("\n")) {
const div = document.createElement("div");
const idx = line.indexOf("");
if (idx > 0) {
const sub = document.createElement("span");
sub.className = "hero-stat-row-sub";
sub.textContent = line.slice(0, idx + 1);
div.appendChild(sub);
div.appendChild(document.createTextNode(line.slice(idx + 1)));
} else {
div.textContent = line;
}
dd.appendChild(div);
}
} else {
dd.textContent = val;
}
dd.textContent = val;
row.appendChild(dt);
row.appendChild(dd);
list.appendChild(row);
@@ -2771,12 +2778,11 @@ function renderDetail() {
root.innerHTML = "";
if (!state.selectedKey) {
root.classList.add("empty");
root.textContent = "点击英雄查看定位、技能、核心装备、公开数据与被克装备";
root.classList.add("empty", "is-collapsed");
return;
}
root.classList.remove("empty");
root.classList.remove("is-collapsed", "empty");
const hero = heroByKey(state.selectedKey);
if (!hero) {
root.classList.add("empty");
@@ -2786,7 +2792,7 @@ function renderDetail() {
const head = document.createElement("div");
head.className = "detail-head";
const title = document.createElement("div");
const title = document.createElement("h2");
title.className = "detail-name";
title.textContent = hero.name_loc || hero.key;
head.appendChild(title);
@@ -2887,7 +2893,6 @@ function renderDetail() {
const center = document.createElement("div");
center.className = "detail-skills-main";
center.appendChild(buildHeroTagsRow(hero));
center.appendChild(buildSkillsPanel(state.selectedKey));
center.appendChild(renderHeroInspect(state.selectedKey));
layout.appendChild(center);
@@ -3739,6 +3744,7 @@ function renderMatchesPage() {
const body = $("#matches-body");
const originsEl = $("#matches-origins");
const playersEl = $("#matches-players");
const sub = $("#matches-sub");
if (!body) return;
const origin = MATCH_ORIGIN_FILTERS.some((o) => o.id === state.matchesOrigin)
@@ -3845,6 +3851,7 @@ function renderMatchesPage() {
const summary = total
? `${total} 场 · 第 ${page}/${pageCount}`
: "";
if (sub) sub.textContent = summary;
if (!rows.length) {
body.innerHTML = state.matchesPlayerId
@@ -5385,7 +5392,8 @@ function syncChrome() {
btn.classList.toggle("active", btn.dataset.page === state.page);
});
const heroesTb = $("#heroes-toolbar");
const itemsTb = $("#items-toolbar");
const heroSearch = $("#q");
const itemSearch = $("#q-item");
const heroesView = $("#heroes-view");
const rankingsView = $("#rankings-view");
const matchesView = $("#matches-view");
@@ -5396,7 +5404,8 @@ function syncChrome() {
const patchesView = $("#patches-view");
const detail = $("#detail");
if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes");
if (itemsTb) itemsTb.classList.toggle("hidden", state.page !== "items");
if (heroSearch) heroSearch.classList.toggle("hidden", state.page !== "heroes");
if (itemSearch) itemSearch.classList.toggle("hidden", state.page !== "items");
if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes");
if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings");
if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches");