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>
@@ -37,6 +37,7 @@ ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com"
|
||||
REGION = "cn-shanghai"
|
||||
ASSET_DIRS = (
|
||||
"attr",
|
||||
"role-icon",
|
||||
"rank",
|
||||
"item",
|
||||
"item-cat",
|
||||
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -41,6 +41,7 @@ from shared.paths import (
|
||||
ITEM_CAT_ICONS,
|
||||
ITEM_ICONS,
|
||||
RANK_ICONS,
|
||||
ROLE_ICONS,
|
||||
ROOT,
|
||||
STREAMER_AVATARS,
|
||||
STREAMER_VIDEOS,
|
||||
@@ -51,7 +52,7 @@ from shared.paths import (
|
||||
|
||||
from serve_relations import WEB_DIR, build_payload
|
||||
|
||||
SITE_VERSION = "0.5.87"
|
||||
SITE_VERSION = "0.5.109"
|
||||
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||
|
||||
|
||||
@@ -71,6 +72,7 @@ def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
|
||||
"""Copy web site image dirs into ``out`` (portrait/item/ability/...)."""
|
||||
counts = {
|
||||
"attr": copy_glob(ATTR_ICONS, out / "attr"),
|
||||
"role-icon": copy_glob(ROLE_ICONS, out / "role-icon"),
|
||||
"rank": copy_glob(RANK_ICONS, out / "rank", "rank*.png"),
|
||||
"item": copy_glob(ITEM_ICONS, out / "item"),
|
||||
"item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"),
|
||||
@@ -260,3 +262,4 @@ def main() -> None:
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Download UI art used by https://www.dota2.com/heroes.
|
||||
"""Download UI art used by https://www.dota2.com/heroes / dota2.com.cn.
|
||||
|
||||
- Landscape hero cards: Steam CDN `heroes/{key}.png`
|
||||
- Attribute icons: Steam CDN `icons/hero_{strength,agility,...}.png`
|
||||
- Combat stat icons: dota2.com.cn `herostatic/stats/icon_*.png`
|
||||
|
||||
Separate from `templates/cdn/` face crops used for top-bar matching.
|
||||
|
||||
@@ -12,6 +13,7 @@ Usage:
|
||||
Writes:
|
||||
assets/hero_portraits/{key}.png
|
||||
assets/attr_icons/{str,agi,int,all}.png
|
||||
assets/ui_icons/icon_{damage,armor,...}.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +27,7 @@ import argparse
|
||||
import json
|
||||
|
||||
from shared.http_utils import fetch_hero_keys, http_bytes
|
||||
from shared.paths import ATTR_ICONS, HERO_PORTRAITS, HEROES_JSON
|
||||
from shared.paths import ATTR_ICONS, HERO_PORTRAITS, HEROES_JSON, UI_ICONS
|
||||
|
||||
# wide = face headshots; crop = waist-up 3D renders (usually worse in dense grids).
|
||||
CARD_URL = (
|
||||
@@ -43,6 +45,19 @@ ATTR_FILES = {
|
||||
"int": "hero_intelligence",
|
||||
"all": "hero_universal",
|
||||
}
|
||||
# Same filenames as https://www.dota2.com.cn/hero/<key> DetailsStats panel.
|
||||
COMBAT_STAT_ICON_URL = "https://www.dota2.com.cn/herostatic/stats/{name}.png"
|
||||
COMBAT_STAT_ICONS = (
|
||||
"icon_damage",
|
||||
"icon_attack_time",
|
||||
"icon_attack_range",
|
||||
"icon_projectile_speed",
|
||||
"icon_armor",
|
||||
"icon_magic_resist",
|
||||
"icon_movement_speed",
|
||||
"icon_turn_rate",
|
||||
"icon_vision",
|
||||
)
|
||||
|
||||
|
||||
def hero_keys() -> list[str]:
|
||||
@@ -71,6 +86,25 @@ def fetch_attr_icons(*, force: bool) -> tuple[int, int, int]:
|
||||
return ok, skip, fail
|
||||
|
||||
|
||||
def fetch_combat_stat_icons(*, force: bool) -> tuple[int, int, int]:
|
||||
UI_ICONS.mkdir(parents=True, exist_ok=True)
|
||||
ok = skip = fail = 0
|
||||
for name in COMBAT_STAT_ICONS:
|
||||
out = UI_ICONS / f"{name}.png"
|
||||
if out.is_file() and not force:
|
||||
skip += 1
|
||||
continue
|
||||
try:
|
||||
data = http_bytes(COMBAT_STAT_ICON_URL.format(name=name), timeout=30)
|
||||
out.write_bytes(data)
|
||||
ok += 1
|
||||
print(f" combat {name} ({len(data)} bytes)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
fail += 1
|
||||
print(f" FAIL combat {name}: {e}")
|
||||
return ok, skip, fail
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Download dota2.com/heroes UI art")
|
||||
ap.add_argument("--force", action="store_true", help="re-download existing files")
|
||||
@@ -85,6 +119,9 @@ def main() -> None:
|
||||
print("attribute icons...")
|
||||
a_ok, a_skip, a_fail = fetch_attr_icons(force=args.force)
|
||||
|
||||
print("combat stat icons...")
|
||||
c_ok, c_skip, c_fail = fetch_combat_stat_icons(force=args.force)
|
||||
|
||||
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
|
||||
keys = hero_keys()
|
||||
ok = skip = fail = 0
|
||||
@@ -104,6 +141,9 @@ def main() -> None:
|
||||
fail += 1
|
||||
print(f" FAIL {key}: {e}")
|
||||
print(f"attrs: downloaded={a_ok} skipped={a_skip} failed={a_fail} -> {ATTR_ICONS}")
|
||||
print(
|
||||
f"combat: downloaded={c_ok} skipped={c_skip} failed={c_fail} -> {UI_ICONS}"
|
||||
)
|
||||
print(f"heroes: downloaded={ok} skipped={skip} failed={fail} -> {HERO_PORTRAITS}")
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,7 +28,6 @@ import urllib.error
|
||||
from shared.grid import ATTR_ORDER, hero_table
|
||||
from shared.hero_tags import TAG_ORDER, tags_for_hero
|
||||
from shared.http_utils import http_bytes
|
||||
from shared.matchup_cross import build_baseline, enrich_hero_matchups_cross
|
||||
from shared.paths import (
|
||||
ABILITY_ICONS,
|
||||
ABILITY_VIDEOS,
|
||||
@@ -38,8 +37,8 @@ from shared.paths import (
|
||||
ITEM_CAT_ICONS,
|
||||
ITEM_ICONS,
|
||||
RANK_ICONS,
|
||||
ROLE_ICONS,
|
||||
ROOT,
|
||||
SHARED_DATA,
|
||||
STREAMER_AVATARS,
|
||||
STREAMER_VIDEOS,
|
||||
TEMPLATES_CDN,
|
||||
@@ -91,7 +90,6 @@ PRO_MATCHES_PATH = DATA / "pro_matches.json"
|
||||
STREAMERS_PATH = DATA / "streamers.json"
|
||||
STRATZ_HERO_META_PATH = DATA / "stratz_hero_meta.json"
|
||||
STRATZ_MATCHUP_TOPS_PATH = DATA / "stratz_matchup_tops.json"
|
||||
OPENDOTA_MATCHUPS_PATH = SHARED_DATA / "matchups.json"
|
||||
_LEGACY_DATA = ROOT / "data"
|
||||
|
||||
|
||||
@@ -428,23 +426,8 @@ def load_stratz_hero_meta() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _load_opendota_matchups() -> dict:
|
||||
path = _first_existing(OPENDOTA_MATCHUPS_PATH, _LEGACY_DATA / "matchups.json")
|
||||
if path is None:
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def load_stratz_matchup_tops() -> dict:
|
||||
"""STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only.
|
||||
|
||||
When OpenDota matchups.json is present, each counter/countered row gets a
|
||||
``cross`` quality blob (agree / conflict / weak). Observation only.
|
||||
"""
|
||||
"""STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only."""
|
||||
empty: dict = {
|
||||
"fetched_at": None,
|
||||
"started_at": None,
|
||||
@@ -459,7 +442,6 @@ def load_stratz_matchup_tops() -> dict:
|
||||
},
|
||||
"note": None,
|
||||
"stats": {},
|
||||
"cross_source": None,
|
||||
"by_hero": {},
|
||||
}
|
||||
path = _first_existing(
|
||||
@@ -474,9 +456,8 @@ def load_stratz_matchup_tops() -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
return empty
|
||||
|
||||
by_hero_raw = dict(raw.get("by_hero") or {})
|
||||
scope = raw.get("scope") if isinstance(raw.get("scope"), dict) else empty["scope"]
|
||||
out: dict = {
|
||||
return {
|
||||
"fetched_at": raw.get("fetched_at"),
|
||||
"started_at": raw.get("started_at"),
|
||||
"finished_at": raw.get("finished_at"),
|
||||
@@ -487,50 +468,9 @@ def load_stratz_matchup_tops() -> dict:
|
||||
"scope": scope,
|
||||
"note": raw.get("note"),
|
||||
"stats": dict(raw.get("stats") or {}),
|
||||
"cross_source": None,
|
||||
"by_hero": by_hero_raw,
|
||||
"by_hero": dict(raw.get("by_hero") or {}),
|
||||
}
|
||||
|
||||
odota = _load_opendota_matchups()
|
||||
by_odota = odota.get("by_hero") or {}
|
||||
if not by_odota:
|
||||
out["cross_source"] = {
|
||||
"available": False,
|
||||
"label_zh": "暂无可靠交叉结论",
|
||||
"reason": "missing_opendota_matchups",
|
||||
}
|
||||
return out
|
||||
|
||||
key_to_id = {h["key"]: int(h["id"]) for h in hero_table()}
|
||||
baseline = build_baseline(by_odota)
|
||||
enriched: dict[str, dict] = {}
|
||||
for key, cell in by_hero_raw.items():
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
hid = key_to_id.get(key)
|
||||
if hid is None:
|
||||
enriched[key] = cell
|
||||
continue
|
||||
enriched[key] = enrich_hero_matchups_cross(
|
||||
cell,
|
||||
hero_id=hid,
|
||||
by_odota=by_odota,
|
||||
baseline=baseline,
|
||||
)
|
||||
out["by_hero"] = enriched
|
||||
out["cross_source"] = {
|
||||
"available": True,
|
||||
"source": "opendota",
|
||||
"attribution": odota.get("attribution") or "https://opendota.com",
|
||||
"fetched_at": odota.get("fetched_at"),
|
||||
"label_zh": "OpenDota 独立交叉(基线校正)",
|
||||
"manual_review_note": (
|
||||
"Dota2ProTracker(7k+ / 职业)仅作人工复核「来源分歧」条目的高分段参考,"
|
||||
"不进入自动流水线。"
|
||||
),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def load_hero_abilities() -> dict:
|
||||
"""Slim per-hero abilities / Aghs upgrades / talents for the web hero pane."""
|
||||
@@ -932,6 +872,27 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._send(200, fpath.read_bytes(), "image/png")
|
||||
return
|
||||
if path.startswith("/role-icon/"):
|
||||
key = path[len("/role-icon/") :]
|
||||
allowed_role_icons = {
|
||||
"Carry.png",
|
||||
"Support.png",
|
||||
"Pusher.png",
|
||||
"Escape.png",
|
||||
"Nuker.png",
|
||||
"Initiator.png",
|
||||
"Disabler.png",
|
||||
"Durable.png",
|
||||
}
|
||||
if key not in allowed_role_icons:
|
||||
self._json(400, {"error": "bad role icon"})
|
||||
return
|
||||
fpath = ROLE_ICONS / key
|
||||
if not fpath.is_file():
|
||||
self.send_error(404)
|
||||
return
|
||||
self._send(200, fpath.read_bytes(), "image/png")
|
||||
return
|
||||
if path.startswith("/ui-icon/"):
|
||||
key = path[len("/ui-icon/") :]
|
||||
allowed_ui = {
|
||||
@@ -939,6 +900,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"dota2_logo.png",
|
||||
"dota2_logo_wordmark.png",
|
||||
"platform_douyin.png",
|
||||
"platform_bilibili.png",
|
||||
# Hero combat stats (dota2.com.cn/herostatic/stats)
|
||||
"icon_damage.png",
|
||||
"icon_attack_time.png",
|
||||
"icon_attack_range.png",
|
||||
"icon_projectile_speed.png",
|
||||
"icon_armor.png",
|
||||
"icon_magic_resist.png",
|
||||
"icon_movement_speed.png",
|
||||
"icon_turn_rate.png",
|
||||
"icon_vision.png",
|
||||
}
|
||||
if key not in allowed_ui:
|
||||
self._json(400, {"error": "bad ui icon"})
|
||||
|
||||