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");
+2 -1
View File
@@ -1,4 +1,5 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.87";
var SITE_VERSION = "0.5.109";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+58 -45
View File
@@ -5,17 +5,18 @@
<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.87" />
<script src="/mobile-gate.js?v=0.5.87"></script>
<link rel="stylesheet" href="/style.css?v=0.5.109" />
<script src="/mobile-gate.js?v=0.5.109"></script>
</head>
<body>
<h1 class="sr-only">DOTA2 上分帝</h1>
<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>
<h2 id="mobile-gate-title" class="mobile-gate-title">暂不支持移动端</h2>
<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>
@@ -25,60 +26,59 @@
</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" />
<span class="brand-title">上分帝</span>
</div>
<a
class="contact-mail"
href="mailto:c9mhs8vfmuyv@outlook.com"
aria-label="邮件联系"
title="c9mhs8vfmuyv@outlook.com"
>
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</a>
<nav class="main-tabs" aria-label="主分页">
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="rankings">排行</button>
<button type="button" class="main-tab" data-page="streamers">主播</button>
<button type="button" class="main-tab" data-page="matches">比赛</button>
<button type="button" class="main-tab" data-page="trends">走势</button>
<button type="button" class="main-tab" data-page="mechanics">机制</button>
<button type="button" class="main-tab" data-page="items">物品</button>
<button type="button" class="main-tab" data-page="patches">版本</button>
</nav>
<div id="heroes-toolbar" class="toolbar">
<div class="tagbar" id="tagbar" aria-label="定位筛选"></div>
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
</div>
<div id="items-toolbar" class="toolbar hidden">
<div class="item-tools">
<input id="q-item" class="search search-inline" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
<div class="topbar-primary">
<div class="brand" aria-label="Dota 2 上分帝">
<img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" />
<span class="brand-title">上分帝</span>
</div>
<nav class="main-tabs" aria-label="主分页">
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="rankings">排行</button>
<button type="button" class="main-tab" data-page="streamers">主播</button>
<button type="button" class="main-tab" data-page="matches">比赛</button>
<button type="button" class="main-tab" data-page="trends">走势</button>
<button type="button" class="main-tab" data-page="mechanics">机制</button>
<button type="button" class="main-tab" data-page="items">物品</button>
<button type="button" class="main-tab" data-page="patches">版本</button>
</nav>
<div class="topbar-tools">
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
<input id="q-item" class="search search-inline hidden" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
<a
class="contact-mail"
href="mailto:c9mhs8vfmuyv@outlook.com"
aria-label="邮件联系"
title="c9mhs8vfmuyv@outlook.com"
>
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</a>
</div>
</div>
</header>
<main id="heroes-view" class="board">
<div class="columns" id="columns"></div>
</main>
<div id="heroes-toolbar" class="toolbar hero-role-toolbar">
<div class="tagbar" id="tagbar" aria-label="英雄定位筛选"></div>
</div>
<main id="rankings-view" class="board rankings-board hidden">
<div class="rankings-cluster">
<div class="rankings-center-wrap">
<div class="rankings-body" id="rankings-body"></div>
<aside class="rankings-aside" aria-label="排行筛选">
<h2 class="rankings-title">
<h2 class="rankings-title page-title">
<span class="rankings-title-row">
<span class="rankings-kicker" id="rankings-kicker">Immortal 排行榜</span>
<span id="rankings-tip-slot"></span>
</span>
</h2>
<div class="rankings-regions" id="rankings-regions" role="tablist" aria-label="选择地区"></div>
<p class="rankings-sub" id="rankings-sub"></p>
<p class="rankings-sub page-sub" id="rankings-sub"></p>
</aside>
</div>
</div>
@@ -89,8 +89,14 @@
<div class="rankings-center-wrap matches-center-wrap">
<div class="rankings-body matches-body" id="matches-body"></div>
<aside class="rankings-aside" aria-label="比赛筛选">
<h2 class="rankings-title page-title">
<span class="rankings-title-row">
<span class="rankings-kicker" id="matches-kicker">明星比赛</span>
</span>
</h2>
<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 page-sub" id="matches-sub"></p>
</aside>
</div>
</div>
@@ -110,14 +116,14 @@
<div class="rankings-center-wrap trends-center-wrap">
<div class="rankings-body" id="trends-body"></div>
<aside class="rankings-aside" aria-label="走势筛选">
<h2 class="rankings-title">
<h2 class="rankings-title page-title">
<span class="rankings-title-row">
<span class="rankings-kicker" id="trends-kicker">过去 8 周走势</span>
</span>
</h2>
<div class="trends-brackets" id="trends-brackets" aria-label="选择段位"></div>
<div class="trends-sort" id="trends-sort" role="tablist" aria-label="排序"></div>
<p class="rankings-sub" id="trends-sub"></p>
<p class="rankings-sub page-sub" id="trends-sub"></p>
</aside>
</div>
</div>
@@ -128,7 +134,7 @@
<div class="rankings-center-wrap mechanics-center-wrap">
<div class="rankings-body mechanics-body" id="mechanics-body"></div>
<aside class="rankings-aside" aria-label="机制筛选">
<h2 class="rankings-title">
<h2 class="rankings-title page-title">
<span class="rankings-title-row">
<span class="rankings-kicker">机制查询</span>
</span>
@@ -142,6 +148,10 @@
<main id="items-view" class="board items-board hidden">
<div class="items-cluster">
<div class="items-main">
<header class="page-header items-page-header">
<h2 class="page-title">物品商店</h2>
<p class="page-sub">基础 / 合成分类目录</p>
</header>
<div class="shop-col-headers" aria-hidden="true">
<span class="shop-col-header basic">基础分类</span>
<span class="shop-col-header upgraded">合成分类</span>
@@ -156,7 +166,10 @@
<main id="patches-view" class="board patches-board hidden">
<div class="patches-header">
<h2 class="patches-title"><span class="patches-kicker">游戏性更新</span><span class="patches-ver" id="patch-ver"></span></h2>
<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>
@@ -165,8 +178,8 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.87"></script>
<script src="/router.js?v=0.5.87"></script>
<script src="/app.js?v=0.5.87"></script>
<script src="/config.js?v=0.5.109"></script>
<script src="/router.js?v=0.5.109"></script>
<script src="/app.js?v=0.5.109"></script>
</body>
</html>
+506 -261
View File
File diff suppressed because it is too large Load Diff