Compare commits

..
2 Commits
Author SHA1 Message Date
voson adb8f303c2 v0.4.0: Cloudflare Pages deploy tooling, items search in URL, spirit bear patch lookup 2026-07-27 16:28:31 +08:00
voson 1d0428b4a3 Add hash routing and Dota 2 logo to relations preview.
- New web/relations/router.js (parseHash/serializeHash/installRouter):
  top tabs, hero + detail sub-tab, item, patch version, tag filters and
  search query all sync to URL (#/heroes/axe/core, ?tags=...&q=...);
  pushState for discrete picks, replaceState for debounced search.
- export_relations_site.py copies router.js so static export deep-links.
- Header shows dota2_logo.png + title, served via /ui-icon/ whitelist.
- fetch_dota2_logo.py fetches the transparent emblem asset.
- Docs: CHANGELOG / README / DESIGN / AGENTS updated.
2026-07-27 13:09:14 +08:00
19 changed files with 10963 additions and 27 deletions
+7 -1
View File
@@ -30,6 +30,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json` |
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`data.json 快照 + 前端 + 图片;`--with-videos` 含视频) |
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;凭据经 keyzoo 注入或 env |
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) |
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
| `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `data/item_shop.json` + 图标 |
@@ -55,7 +57,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears |
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
| `web/relations/` | 关系只读预览页静态资源 |
| `web/relations/` | 关系只读预览页静态资源`index.html` / `app.js` / `style.css` / `router.js` |
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
@@ -119,6 +122,8 @@ python item_fears.py # 英雄怕的装备
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
python gsi_setup.py --check
python gsi_watch.py --once
python recognize.py samples/raw/<>.png --sheet
@@ -131,6 +136,7 @@ python evaluate.py
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
- 改匹配阈值或裁切时,用 `evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md`
- 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。
- 改预览路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js`
-`data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles``overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。
- 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
+51
View File
@@ -2,6 +2,42 @@
本文件记录 **上分帝(Climperor** 的用户可见变更。格式参考 [Keep a Changelog](https://keepachangelog.com/)。
## [Unreleased]
## [0.4.0] - 2026-07-27
### Added
- 新增 `deploy_relations.py`:一键把关系预览静态站点部署到 Cloudflare Pages
—— 串联导出(`export_relations_site.py`)→ 资产完整性预检(缺图则中止)
`wrangler pages deploy` 直传 `dist/relations/` → 绑定自定义域名。凭据
经 keyzoo 注入(`refining/cloudflare` 的 Global API Key)或手动
`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` 环境变量;默认项目
`climperor-relations`、域名 `dota2.refining.dev`,均可参数覆盖。
- 新增 `_cf_status.py`:只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名
状态(凭据经 keyzoo 注入),部署后核对用;另有一次性辅助脚本
`_cf_domain.py`(探测域名/ DNS 记录)与 `_cf_add_cname.py`(补建缺失的
CNAME,幂等)。
- 物品页搜索框进 URL`#/items?q=...`,刷新保状态、可分享;英雄页与物品页
搜索各用独立 debounce 计时器,互不干扰。
- 版本页支持非英雄单位:Valve patchnotes `heroes[]` 中的熊灵(id 1961)等
伪英雄 id 现解析为中文名 + 本地头像(`fetch_patches.py``UNIT_NAMES`
静态导出一并拷贝头像)。
### Fixed
- 修复 `serializeHash` 生成 `#heroes`(缺 `/`)导致刷新后路由解析异常的
问题,现统一为 `#/heroes` 形态。
- 版本日志中未知单位 id 不再显示 `# 1961` 原始数字,回退为「其它单位」。
- 本地预览 `serve_relations.py` 增加 CSP 响应头(`default-src 'self'` 等),
限制资源只从本站加载。
- `data.json` 加载失败时显式抛出 HTTP 状态错误,不再静默进入异常渲染。
### Performance
- 关系预览缓存英雄技能条目(`skillEntriesFor`)与血蓝条量程
`resourceBarScale`)计算结果,数据重载时失效,减少重复遍历。
## [0.3.0] - 2026-07-27
### Added
@@ -45,6 +81,21 @@
不再被牵连出滚轮;压缩参数行距使典型技能在高屏零滚轮(无头 Chrome + CDP 实测
`scrollHeight == clientHeight` 验证)。整块宽度上限 1000→1320px、视频 700→820px、
详情列 720→760px。
- 关系预览顶栏左上角新增 Dota 2 logo +「上分帝」标题(`assets/ui_icons/dota2_logo.png`
透明 emblem),经 `/ui-icon/` 路由与静态导出统一分发;窄屏自动隐藏标题或整组标识,
不影响居中分页布局。
- 关系预览新增 **Hash 路由**:顶层标签(英雄 / 物品 / 版本)、选中英雄 + 详情子标签
(技能 / 核心装备 / 被克装备 / 版本变更)、物品页选中物品、版本页选中版本均同步进
URL;英雄页的定位标签筛选与搜索框文本以 query 参数(`?tags=核心,辅助&q=axe`)进
URL。形态如 `#/heroes/axe/core``#/items/black_king_bar``#/patches/7.41`
`#/patches` = 最新版,序列化时省略与最新同名的版本号)。刷新保状态、可分享 /
深度链接、浏览器前进后退还原;搜索框 300ms debounce + `replaceState`(不刷历史栈),
标签筛选用 `replaceState`,其余离散选择用 `pushState`。坏链接(未知英雄 / 物品 /
版本)优雅降级为丢弃该项。新增 `web/relations/router.js``parseHash` /
`serializeHash` / `installRouter`),`app.js``main()` 装载并首屏 `applyUrlToState`
`export_relations_site.py` 静态导出列表补入 `router.js`,静态站点深度链接同样可用
(可部署 GitHub Pages,无需 rewrite)。Hash 路由不命中后端路径,`serve_relations.py`
零改动。
### Fixed
+8
View File
@@ -229,6 +229,14 @@ Dota 2 (-gamestateintegration)
- **展示**:网格左上角青色方标 + 名次 1/2/3(区别于 Plus 右上三角)。
- **预览**`serve_relations.py` 仿选将网格只读查看;数据手改 `data/relations.json` 或从 xlsx 导入。
另有顶级 **物品** 页(基础/升级,网格↔竖式布局),目录见 `data/item_shop.json`
- **预览 Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe` 等)。选 Hash
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`serve_relations.py` 零改动、
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
`web/relations/router.js``app.js` 只在 `main()``installRouter` + 各 state 变更点
`syncStateToUrl`
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `data/heroes.json`
---
+13
View File
@@ -41,6 +41,19 @@ python fetch_patches.py # 近一年版本日志 + 详情 → data/patches
python serve_relations.py # 只读预览 http://127.0.0.1:8765(改 JSON 后刷新)
```
预览页带 **Hash 路由**`#/heroes/axe/core``#/items/black_king_bar``#/patches/7.41``#/patches` = 最新);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。
### 部署到 Cloudflare Pages
```powershell
python deploy_relations.py
# 串联:导出 → 资产完整性预检 → wrangler 直传 → 绑定自定义域名
# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key);
# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行
# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖)
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
```
仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
+62
View File
@@ -0,0 +1,62 @@
"""Add the missing CNAME for dota2.refining.dev -> climperor-relations.pages.dev.
Cloudflare Pages bound the custom domain but did not auto-create the zone
CNAME, so SSL validation is stuck at 'CNAME record not set'. This adds it
(proxied so CF issues SSL + serves from the edge). Idempotent: skips if present.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
ZONE = "refining.dev"
DOMAIN = "dota2.refining.dev"
TARGET = "climperor-relations.pages.dev"
def cf(method: str, path: str, email: str, key: str, body: dict | None = None) -> dict:
url = "https://api.cloudflare.com/client/v4" + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return json.loads(e.read().decode())
def main() -> None:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get("KEYZOO_ASSET_META_USERNAME")
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY")
zd = cf("GET", f"/zones?name={ZONE}", email, key)
zr = zd.get("result") or []
if not zr:
raise SystemExit(f"zone {ZONE} not found")
zid = zr[0]["id"]
print(f"zone {ZONE}: {zid}")
recs = cf("GET", f"/zones/{zid}/dns_records?name={DOMAIN}", email, key)
existing = [r for r in (recs.get("result") or []) if r.get("type") == "CNAME"]
if existing:
for r in existing:
print(f"CNAME already exists: {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})")
return
print(f"adding CNAME {DOMAIN} -> {TARGET} (proxied=true)")
d = cf("POST", f"/zones/{zid}/dns_records", email, key, body={
"type": "CNAME", "name": DOMAIN, "content": TARGET, "proxied": True, "comment": "climperor-relations Pages",
})
if d.get("success"):
r = d.get("result", {})
print(f"created: {r.get('type')} {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})")
else:
print(f"FAILED: {d.get('errors')}")
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
"""Probe a single Cloudflare Pages custom domain + its zone DNS record."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
DOMAIN = "dota2.refining.dev"
PROJECT = "climperor-relations"
def cf(method: str, path: str, email: str, key: str) -> dict:
url = "https://api.cloudflare.com/client/v4" + path
req = urllib.request.Request(url, method=method, headers={
"X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return json.loads(e.read().decode())
def main() -> None:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get("KEYZOO_ASSET_META_USERNAME")
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY")
d = cf("GET", "/accounts", email, key)
aid = (d.get("result") or [{}])[0].get("id")
d = cf("GET", f"/accounts/{aid}/pages/projects/{PROJECT}/domains/{DOMAIN}", email, key)
print("domain detail:")
for k, v in (d.get("result") or {}).items():
if k != "zone_id":
print(f" {k}: {v}")
zd = cf("GET", "/zones?name=refining.dev", email, key)
zr = zd.get("result") or []
if not zr:
print("zone refining.dev: NOT FOUND on this account")
return
zid = zr[0]["id"]
recs = cf("GET", f"/zones/{zid}/dns_records?name={DOMAIN}", email, key)
print(f"dns records for {DOMAIN}:")
for rec in (recs.get("result") or []):
print(f" {rec.get('type')} {rec.get('name')} -> {rec.get('content')} (proxied={rec.get('proxied')})")
if not (recs.get("result") or []):
print(" (none -- CNAME not added yet)")
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
"""Read-only Cloudflare Pages status probe (diagnostic helper).
Prints account id, whether the target project exists, its latest deployment,
and bound custom domains. Credentials come from env (keyzoo inject).
Usage via keyzoo: python _cf_status.py [--project-name NAME]
"""
from __future__ import annotations
import argparse
import json
import os
import urllib.error
import urllib.request
DEFAULT_PROJECT = "climperor-relations"
def cf(method: str, path: str, email: str, key: str, body: dict | None = None) -> dict:
url = "https://api.cloudflare.com/client/v4" + path
headers = {"X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json"}
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode())
except Exception:
return {"success": False, "errors": [{"code": e.code, "message": str(e)}]}
except urllib.error.URLError as e:
return {"success": False, "errors": [{"code": -1, "message": str(e)}]}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--project-name", default=DEFAULT_PROJECT)
args = ap.parse_args()
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get("KEYZOO_ASSET_META_USERNAME")
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY")
if not email or not key:
raise SystemExit("missing credentials in env")
d = cf("GET", "/accounts", email, key)
if not d.get("success"):
print("GET /accounts FAILED:", d.get("errors"))
return
accts = d.get("result") or []
print(f"accounts: {[ (a['id'], a.get('name')) for a in accts ]}")
if not accts:
return
aid = accts[0]["id"]
d = cf("GET", f"/accounts/{aid}/pages/projects/{args.project_name}", email, key)
if not d.get("success"):
print(f"project {args.project_name}: NOT created -> {d.get('errors')}")
return
print(f"project: {args.project_name} (created)")
d = cf("GET", f"/accounts/{aid}/pages/projects/{args.project_name}/deployments", email, key)
deps = d.get("result") or []
print(f"deployments: {len(deps)}")
for dep in deps[:3]:
print(f" - {dep.get('latest_stage', {}).get('name')} / {dep.get('latest_stage', {}).get('status')} | env={dep.get('environment')} | created={dep.get('created_on')} | url={dep.get('url')}")
d = cf("GET", f"/accounts/{aid}/pages/projects/{args.project_name}/domains", email, key)
doms = d.get("result") or []
print("custom domains:")
for x in doms:
print(f" - {x.get('name')} status={x.get('status')}")
# Confirm the CNAME was auto-added on the refining.dev zone.
zd = cf("GET", "/zones?name=refining.dev", email, key)
zr = zd.get("result") or []
if zr:
zid = zr[0]["id"]
recs = cf("GET", f"/zones/{zid}/dns_records?name=dota2.refining.dev", email, key)
for rec in (recs.get("result") or []):
print(f" dns: {rec.get('type')} {rec.get('name')} -> {rec.get('content')} (proxied={rec.get('proxied')})")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+6 -2
View File
@@ -1,7 +1,7 @@
{
"meta": {
"source": "https://www.dota2.com/datafeed/patchnoteslist?language=schinese",
"fetched_at": "2026-07-27T02:53:43.356291+00:00",
"fetched_at": "2026-07-27T07:41:08.378536+00:00",
"window_days": 365,
"since": "2025-07-27",
"count": 10,
@@ -9,7 +9,7 @@
"lookup_counts": {
"items": 171,
"abilities": 583,
"heroes": 127
"heroes": 128
}
},
"patches": [
@@ -3603,6 +3603,10 @@
"155": {
"key": "largo",
"name_loc": "朗戈"
},
"1961": {
"key": "spirit_bear",
"name_loc": "熊灵"
}
}
},
+341
View File
@@ -0,0 +1,341 @@
"""Deploy the relations preview static site to Cloudflare Pages.
Bundles export + integrity check + wrangler direct upload + custom domain
binding into one local command. Designed to run with credentials injected
by keyzoo asset_exec, but also works with manually exported env vars.
Usage:
python deploy_relations.py [--no-export] [--project-name NAME] \\
[--domain DOMAIN] [--dist PATH]
Env vars (credentials, never printed):
CLOUDFLARE_EMAIL / CLOUDFLARE_API_KEY -- preferred
KEYZOO_ASSET_META_USERNAME -- fallback email (keyzoo inject)
KEYZOO_ASSET_SECRET_GLOBAL_API_KEY -- fallback key (keyzoo inject)
Flow:
1. resolve credentials + account_id (GET /accounts)
2. ensure Pages project exists (create if missing, production_branch=main)
3. run export_relations_site.py unless --no-export
4. asset-integrity check: abort if key dirs are empty
5. wrangler pages deploy dist/relations (CI=true, non-interactive)
6. bind custom domain if not already
7. print deployment + custom domain URLs
Security: the API key is only ever placed into HTTP headers and the
wrangler subprocess env. It is never printed, logged, or written to disk.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent
DEFAULT_PROJECT = "climperor-relations"
DEFAULT_DOMAIN = "dota2.refining.dev"
DEFAULT_DIST = ROOT / "dist" / "relations"
# Directories export_relations_site.py writes under dist/relations. Keys are
# the sub-directory names; values flag whether an empty dir is a hard failure
# (True) or just a warning (False). Portrait/ui-icon must be non-empty or the
# site is visibly broken; item/ability degrade with onerror but should exist.
INTEGRITY_DIRS: dict[str, bool] = {
"portrait": True,
"ui-icon": True,
"item": True,
"ability": False,
"attr": True,
"item-cat": True,
}
def resolve_credentials() -> tuple[str, str]:
"""Return (email, api_key) from env, preferring CLOUDFLARE_* vars."""
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
api_key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not api_key:
raise SystemExit(
"missing credentials: set CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY "
"(or run via keyzoo asset_exec on the refining/cloudflare asset)"
)
return email, api_key
def cf_api(
method: str,
path: str,
*,
email: str,
api_key: str,
body: dict | None = None,
) -> dict:
"""Call the Cloudflare REST API with Global API Key auth.
Uses X-Auth-Email + X-Auth-Key headers. Returns the parsed JSON envelope.
Never raises on HTTP error; returns the error envelope so callers can
inspect `success` / `errors` without a traceback.
"""
url = "https://api.cloudflare.com/client/v4" + path
headers = {
"X-Auth-Email": email,
"X-Auth-Key": api_key,
"Content-Type": "application/json",
}
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
# HTTPError still carries the CF JSON envelope; surface it verbatim
# (it contains no secrets, only error codes/messages).
try:
return json.loads(exc.read().decode("utf-8"))
except Exception:
return {"success": False, "errors": [{"code": exc.code, "message": str(exc)}]}
except urllib.error.URLError as exc:
return {"success": False, "errors": [{"code": -1, "message": str(exc)}]}
def get_account_id(email: str, api_key: str) -> str:
"""Resolve the Cloudflare account id via GET /accounts."""
d = cf_api("GET", "/accounts", email=email, api_key=api_key)
if not d.get("success"):
raise SystemExit(f"GET /accounts failed: {d.get('errors')}")
accounts = d.get("result") or []
if not accounts:
raise SystemExit("no Cloudflare accounts found for these credentials")
if len(accounts) > 1:
names = [a.get("name", a.get("id")) for a in accounts]
print(f"multiple accounts found, using the first: {names}", file=sys.stderr)
return str(accounts[0]["id"])
def ensure_project(email: str, api_key: str, account_id: str, project: str) -> None:
"""Create the Pages project if it does not already exist."""
d = cf_api(
"GET",
f"/accounts/{account_id}/pages/projects/{project}",
email=email,
api_key=api_key,
)
if d.get("success"):
print(f"project exists: {project}")
return
# 8000xxx = project not found; anything else is a real error.
print(f"project not found, creating: {project}")
d = cf_api(
"POST",
f"/accounts/{account_id}/pages/projects",
email=email,
api_key=api_key,
body={"name": project, "production_branch": "main"},
)
if not d.get("success"):
raise SystemExit(f"create project failed: {d.get('errors')}")
def run_export() -> None:
"""Re-run export_relations_site.py to refresh dist/relations."""
print("exporting static site ...")
subprocess.run(
[sys.executable, str(ROOT / "export_relations_site.py")],
cwd=str(ROOT),
check=True,
)
def check_integrity(dist: Path) -> None:
"""Abort if a hard-required asset directory is empty; warn otherwise."""
problems: list[str] = []
for sub, required in INTEGRITY_DIRS.items():
d = dist / sub
n = sum(1 for _ in d.glob("*") if _.is_file()) if d.is_dir() else 0
if n == 0:
tag = "MISSING (abort)" if required else "empty (warn)"
problems.append(f" {sub}/: {tag}")
hint = {
"portrait": "run: python fetch_cdn_templates.py && python fetch_hero_portraits.py",
"ability": "run: python fetch_hero_abilities.py --icons-only",
"item": "run: python fetch_hero_items.py",
"attr": "assets/attr_icons is committed; check git checkout",
"item-cat": "assets/item_cat_icons is committed; check git checkout",
"ui-icon": "assets/ui_icons is committed; check git checkout",
}.get(sub, "see AGENTS.md fetch commands")
problems.append(f" -> {hint}")
if not problems:
return
hard = [s for s, r in INTEGRITY_DIRS.items() if r and not any((dist / s).glob("*"))]
print("asset integrity check:")
print("\n".join(problems))
if hard:
raise SystemExit(
f"aborting deploy: required asset dirs empty: {hard}. "
"Run the fetch scripts first so the exported site is complete."
)
def resolve_npx() -> str:
"""Locate the npx executable (Windows ships npx.cmd)."""
for name in ("npx", "npx.cmd"):
path = shutil.which(name)
if path:
return path
raise SystemExit("npx not found on PATH; install Node.js first")
def wrangler_deploy(
dist: Path,
project: str,
*,
email: str,
api_key: str,
account_id: str,
) -> None:
"""Upload dist via `wrangler pages deploy` (direct upload, non-interactive)."""
env = os.environ.copy()
# wrangler reads Global API Key auth from these env vars. They are passed
# to the subprocess only; wrangler does not echo them.
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = api_key
env["CLOUDFLARE_ACCOUNT_ID"] = account_id
env["CI"] = "true"
cmd = [
resolve_npx(),
"--yes",
"wrangler@3",
"pages",
"deploy",
str(dist),
"--project-name",
project,
"--commit-dirty",
"--branch",
"main",
]
print(f"uploading {dist} via wrangler ...")
subprocess.run(cmd, cwd=str(ROOT), env=env, check=True)
def ensure_cname(email: str, api_key: str, domain: str, project: str) -> None:
"""Ensure the zone has a proxied CNAME: <domain> -> <project>.pages.dev.
Pages binds the custom domain but does not always auto-create the zone
CNAME; without it SSL validation stalls at 'CNAME record not set'.
Idempotent: skips if a CNAME already exists for the domain.
"""
zone = domain.split(".", 1)[1] if "." in domain else domain
target = f"{project}.pages.dev"
zd = cf_api("GET", f"/zones?name={zone}", email=email, api_key=api_key)
zr = zd.get("result") or []
if not zr:
print(f"warn: zone {zone} not found on this account; add CNAME manually")
return
zid = zr[0]["id"]
recs = cf_api(
"GET", f"/zones/{zid}/dns_records?name={domain}", email=email, api_key=api_key
)
existing = [r for r in (recs.get("result") or []) if r.get("type") == "CNAME"]
if existing:
r = existing[0]
print(f"CNAME exists: {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})")
return
print(f"adding CNAME: {domain} -> {target} (proxied=true)")
d = cf_api(
"POST",
f"/zones/{zid}/dns_records",
email=email,
api_key=api_key,
body={"type": "CNAME", "name": domain, "content": target, "proxied": True},
)
if d.get("success"):
print("CNAME added; SSL will activate within a few minutes")
else:
print(f"warn: add CNAME failed: {d.get('errors')} (add manually in dashboard)")
def bind_domain(
email: str,
api_key: str,
account_id: str,
project: str,
domain: str,
) -> None:
"""Attach the custom domain to the Pages project and ensure its CNAME."""
d = cf_api(
"GET",
f"/accounts/{account_id}/pages/projects/{project}/domains",
email=email,
api_key=api_key,
)
if not d.get("success"):
raise SystemExit(f"list domains failed: {d.get('errors')}")
existing = [x.get("name") for x in (d.get("result") or [])]
if domain in existing:
print(f"custom domain already bound: {domain}")
else:
print(f"binding custom domain: {domain}")
d = cf_api(
"POST",
f"/accounts/{account_id}/pages/projects/{project}/domains",
email=email,
api_key=api_key,
body={"name": domain},
)
if not d.get("success"):
raise SystemExit(
f"bind domain failed: {d.get('errors')}. "
f"Ensure {domain} zone is hosted on this Cloudflare account."
)
ensure_cname(email, api_key, domain, project)
def main() -> None:
ap = argparse.ArgumentParser(description="Deploy relations site to Cloudflare Pages")
ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist")
ap.add_argument("--project-name", default=DEFAULT_PROJECT)
ap.add_argument("--domain", default=DEFAULT_DOMAIN)
ap.add_argument("--dist", default=str(DEFAULT_DIST))
args = ap.parse_args()
dist = Path(args.dist).resolve()
email, api_key = resolve_credentials()
account_id = get_account_id(email, api_key)
print(f"account_id: {account_id}")
ensure_project(email, api_key, account_id, args.project_name)
if not args.no_export:
run_export()
else:
print("--no-export: using existing dist")
if not dist.is_dir():
raise SystemExit(f"dist not found: {dist}. Run without --no-export first.")
check_integrity(dist)
wrangler_deploy(dist, args.project_name, email=email, api_key=api_key, account_id=account_id)
bind_domain(email, api_key, account_id, args.project_name, args.domain)
print()
print(f"deployment url : https://{args.project_name}.pages.dev")
print(f"custom domain : https://{args.domain}")
print("note: a freshly bound custom domain takes ~1-2 min to issue SSL.")
if __name__ == "__main__":
main()
+9902 -6
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -69,7 +69,7 @@ def main() -> None:
out.mkdir(parents=True)
# Frontend (uses relative paths — works from any sub-path).
for name in ("index.html", "app.js", "style.css"):
for name in ("index.html", "router.js", "app.js", "style.css"):
shutil.copy2(WEB_DIR / name, out / name)
# Data snapshot (same payload as serve_relations /api/data).
@@ -102,6 +102,15 @@ def main() -> None:
if src.is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
# Non-hero units in patch lookup (e.g. 熊灵) use bundled portraits too.
for cell in ((payload.get("patch_lookup") or {}).get("heroes") or {}).values():
key = cell.get("key") if isinstance(cell, dict) else None
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if src.is_file() and not (portrait_dst / f"{key}.png").is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
counts["portrait"] = n_portrait
n_videos = 0
+77
View File
@@ -0,0 +1,77 @@
"""Fetch the official Dota 2 emblem icon (no wordmark) for the preview header.
Source (discovered, not guessed) from the dota2.com.cn site HTML:
<link rel="Shortcut Icon" href="//www.dota2.com.cn/favicon.ico"/>
That .ico is a multi-frame container whose largest frames are 256x256 PNGs of
the red Dota 2 map-marker emblem with a transparent background — i.e. the
brand icon alone, no "DOTA 2" text. We extract the first 256x256 PNG frame,
decode with OpenCV, and write it to assets/ui_icons/dota2_logo.png so it rides
the existing /ui-icon/ route + static-export glob pipeline.
"""
import struct
import sys
import cv2
import numpy as np
from common import UI_ICONS
from http_utils import http_bytes
FAVICON_URL = "https://www.dota2.com.cn/favicon.ico"
OUT = UI_ICONS / "dota2_logo.png"
TARGET_SIZE = 256
def _extract_largest_png_frame(data: bytes) -> bytes | None:
"""Return the bytes of the largest PNG-embedded frame in an ICO blob."""
if len(data) < 6 or data[:4] != b"\x00\x00\x01\x00":
return None
count = struct.unpack("<H", data[4:6])[0]
best: tuple[int, bytes] | None = None
for i in range(count):
off = 6 + 16 * i
if off + 16 > len(data):
break
w = data[off]
size = struct.unpack("<I", data[off + 8 : off + 12])[0]
img_off = struct.unpack("<I", data[off + 12 : off + 16])[0]
width = 256 if w == 0 else w
blob = data[img_off : img_off + size]
if blob[:8] != b"\x89PNG\r\n\x1a\n":
continue
if best is None or width * width > best[0]:
best = (width * width, blob)
return best[1] if best else None
def main() -> int:
if OUT.is_file() and OUT.stat().st_size >= 512:
print(f"exists: {OUT} ({OUT.stat().st_size} bytes)")
return 0
data = http_bytes(FAVICON_URL, timeout=30)
if not data or len(data) < 16:
print("empty favicon download", file=sys.stderr)
return 1
png = _extract_largest_png_frame(data)
if png is None:
print("no PNG frame in .ico", file=sys.stderr)
return 1
arr = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
if arr is None:
print("decode failed", file=sys.stderr)
return 1
UI_ICONS.mkdir(parents=True, exist_ok=True)
ok = cv2.imwrite(str(OUT), arr)
if not ok:
print("write failed", file=sys.stderr)
return 1
h, w = arr.shape[:2]
channels = arr.shape[2] if arr.ndim == 3 else 1
transparent = int(np.count_nonzero(arr[:, :, 3] == 0)) if channels == 4 else 0
print(f"saved {OUT} ({w}x{h}, {OUT.stat().st_size} bytes, {transparent} px transparent)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8
View File
@@ -50,6 +50,10 @@ HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
DEFAULT_WINDOW_DAYS = 365
# Bundled shared badges — never fetched from CDN (many innate keys 404).
SKIP_ABILITY_ICON_KEYS = frozenset({"innate", "talent_tree"})
# Non-hero units that Valve places in patchnotes heroes[] under a pseudo hero_id
# absent from herolist. Resolve to a Chinese name + portrait key so the preview
# never shows #1961. Portrait files live in assets/hero_portraits/<key>.png.
UNIT_NAMES = {1961: {"key": "spirit_bear", "name_loc": "熊灵"}}
def _date(ts: int) -> str:
@@ -231,6 +235,10 @@ def build_lookup(
meta = hero_index.get(hid)
if meta:
heroes[str(hid)] = {"key": meta["key"], "name_loc": meta["name_loc"]}
elif hid in UNIT_NAMES:
# Non-hero unit (e.g. 1961 = 熊灵): bundled portrait in hero_portraits.
u = UNIT_NAMES[hid]
heroes[str(hid)] = {"key": u["key"], "name_loc": u["name_loc"]}
return {"items": items, "abilities": abilities, "heroes": heroes}
+7 -1
View File
@@ -15,6 +15,7 @@ import json
import mimetypes
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
import urllib.error
@@ -416,6 +417,11 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
"script-src 'self'; media-src 'self'",
)
self.end_headers()
self.wfile.write(body)
@@ -448,7 +454,7 @@ class Handler(BaseHTTPRequestHandler):
return
if path.startswith("/ui-icon/"):
key = path[len("/ui-icon/") :]
if key not in ("cooldown.png",):
if key not in ("cooldown.png", "dota2_logo.png"):
self._json(400, {"error": "bad ui icon"})
return
fpath = UI_ICONS / key
+121 -9
View File
@@ -147,6 +147,7 @@ function renderBoard() {
function onHeroClick(key) {
state.selectedKey = state.selectedKey === key ? null : key;
state.inspect = null;
syncStateToUrl();
render();
}
@@ -161,6 +162,7 @@ function renderTagbar() {
allBtn.className = state.tagFilters.size ? "" : "active";
allBtn.addEventListener("click", () => {
state.tagFilters.clear();
syncStateToUrl({ replace: true });
render();
});
bar.appendChild(allBtn);
@@ -172,6 +174,7 @@ function renderTagbar() {
btn.addEventListener("click", () => {
if (state.tagFilters.has(tag)) state.tagFilters.delete(tag);
else state.tagFilters.add(tag);
syncStateToUrl({ replace: true });
render();
});
bar.appendChild(btn);
@@ -394,8 +397,11 @@ function withAbilityDetail(ent, ab) {
return ent;
}
/** Build icon-bar entries: base skills + shard/scepter upgrades (dota2.com style). */
/** Build icon-bar entries: base skills + shard/scepter upgrades (dota2.com style).
* Cached per hero key; invalidated when data reloads. */
const _skillEntriesCache = new Map();
function skillEntriesFor(heroKey) {
if (_skillEntriesCache.has(heroKey)) return _skillEntriesCache.get(heroKey);
const cell = heroAbilityCell(heroKey);
if (cell == null) return null;
const entries = [];
@@ -470,7 +476,9 @@ function skillEntriesFor(heroKey) {
);
}
}
return { entries, talents: cell.talents };
const result = { entries, talents: cell.talents };
_skillEntriesCache.set(heroKey, result);
return result;
}
const DISPEL_LABEL = {
@@ -1026,14 +1034,17 @@ function fmtRegen(v) {
return `+${fmtNum(v, 2)}`;
}
/** Shared HP/Mana bar scale: max base resource across all heroes. */
/** Shared HP/Mana bar scale: max base resource across all heroes (cached). */
let _resourceBarScaleCache = null;
function resourceBarScale() {
if (_resourceBarScaleCache != null) return _resourceBarScaleCache;
let max = 0;
for (const h of state.data?.heroes || []) {
if (h.health != null) max = Math.max(max, Number(h.health) || 0);
if (h.mana != null) max = Math.max(max, Number(h.mana) || 0);
}
return max > 0 ? max : 1000;
_resourceBarScaleCache = max > 0 ? max : 1000;
return _resourceBarScaleCache;
}
/** Fill width % on shared scale; clamp so tiny values stay visible. */
@@ -1286,6 +1297,7 @@ function renderDetail() {
// Reset inspect to the tab default (first skill / first item).
state.inspect = null;
ensureHeroInspectDefault(state.selectedKey);
syncStateToUrl();
renderDetail();
});
tabbar.appendChild(btn);
@@ -1368,6 +1380,7 @@ function matchesItemQuery(key, meta) {
function onShopItemClick(key) {
state.selectedItemKey = state.selectedItemKey === key ? null : key;
state.selectedKey = null;
syncStateToUrl();
render();
}
@@ -1522,6 +1535,7 @@ function renderItemDetail() {
mountItemInspect(root, meta, {
onPickItem: (key) => {
state.selectedItemKey = key;
syncStateToUrl();
render();
},
});
@@ -1631,7 +1645,7 @@ function renderItemEntry(entry, lookup) {
function renderHeroEntry(hero, lookup) {
const meta = (lookup.heroes || {})[String(hero.hero_id)];
const key = meta ? meta.key : "";
const name = meta ? meta.name_loc : "# " + hero.hero_id;
const name = meta ? meta.name_loc : "其它单位";
const portrait = key
? `<img class="patch-hero-icon" src="portrait/${encodeURIComponent(key)}.png?v=wide" alt="" onerror="this.style.visibility='hidden'">`
: "";
@@ -1689,6 +1703,14 @@ function buildPatchChangesPanel(heroKey) {
return wrap;
}
function ensureDefaultPatch() {
const patches = state.data?.patches || [];
if (!patches.length) return;
if (!state.selectedPatch || !patches.some((p) => p.version === state.selectedPatch)) {
state.selectedPatch = patches[0].version;
}
}
function renderPatches() {
if (!state.data) return;
const select = $("#patch-select");
@@ -1704,9 +1726,7 @@ function renderPatches() {
return;
}
if (!state.selectedPatch || !patches.some((p) => p.version === state.selectedPatch)) {
state.selectedPatch = patches[0].version; // default: latest
}
ensureDefaultPatch();
if (select) {
select.innerHTML = patches
@@ -1718,6 +1738,7 @@ function renderPatches() {
.join("");
select.onchange = () => {
state.selectedPatch = select.value;
syncStateToUrl();
renderPatches();
const board = $("#patches-view");
if (board) board.scrollTo(0, 0);
@@ -1780,6 +1801,7 @@ function setPage(page) {
state.selectedKey = null;
state.inspect = null;
}
syncStateToUrl();
render();
}
@@ -1815,6 +1837,77 @@ function render() {
}
}
// Search-box URL sync timers (debounced replace so typing does not spam history).
let heroSearchSyncTimer = 0;
let itemSearchSyncTimer = 0;
const SEARCH_SYNC_DEBOUNCE_MS = 300;
/**
* Apply a router-produced state patch (from a parsed URL) to state, then render.
* Validates every field against loaded data so bad deep-links degrade gracefully
* (unknown hero/item/version is dropped rather than crashing the UI).
*/
function applyPatch(patch) {
if (!state.data) {
render();
return;
}
// Page (default heroes on bad/missing).
if (patch.page && ["heroes", "items", "patches"].includes(patch.page)) {
state.page = patch.page;
} else {
state.page = "heroes";
}
// Hero + detail sub-tab.
if (patch.heroKey && heroByKey(patch.heroKey)) {
state.selectedKey = patch.heroKey;
if (
patch.detailTab &&
["skills", "core", "fears", "patches"].includes(patch.detailTab)
) {
state.detailTab = patch.detailTab;
} else {
state.detailTab = "skills";
}
} else {
state.selectedKey = null;
state.detailTab = "skills";
}
// Reset inspect so ensureHeroInspectDefault re-picks per tab (inspect is URL-less).
state.inspect = null;
// Item (items page) — must exist in the shop catalog.
if (patch.itemKey && shopItem(patch.itemKey)) {
state.selectedItemKey = patch.itemKey;
} else {
state.selectedItemKey = null;
}
// Patch version (patches page; null means latest).
if (
patch.patchVersion &&
(state.data.patches || []).some((p) => p.version === patch.patchVersion)
) {
state.selectedPatch = patch.patchVersion;
} else {
state.selectedPatch = null;
}
// Tag filters (heroes page only) — drop unknown tag names.
if (patch.tags instanceof Set) {
const valid = new Set(state.data.tag_order || []);
state.tagFilters = new Set([...patch.tags].filter((t) => valid.has(t)));
}
if (typeof patch.query === "string") {
state.query = patch.query;
const inp = $("#q");
if (inp && inp.value !== state.query) inp.value = state.query;
}
if (typeof patch.itemQuery === "string") {
state.itemQuery = patch.itemQuery;
const inp = $("#q-item");
if (inp && inp.value !== state.itemQuery) inp.value = state.itemQuery;
}
render();
}
function bindSearch() {
const input = $("#q");
if (input) {
@@ -1822,6 +1915,11 @@ function bindSearch() {
input.addEventListener("input", () => {
state.query = input.value;
if (state.page === "heroes") renderBoard();
if (heroSearchSyncTimer) clearTimeout(heroSearchSyncTimer);
heroSearchSyncTimer = setTimeout(() => {
heroSearchSyncTimer = 0;
syncStateToUrl({ replace: true });
}, SEARCH_SYNC_DEBOUNCE_MS);
});
}
const itemInput = $("#q-item");
@@ -1830,6 +1928,11 @@ function bindSearch() {
itemInput.addEventListener("input", () => {
state.itemQuery = itemInput.value;
if (state.page === "items") renderItemShop();
if (itemSearchSyncTimer) clearTimeout(itemSearchSyncTimer);
itemSearchSyncTimer = setTimeout(() => {
itemSearchSyncTimer = 0;
syncStateToUrl({ replace: true });
}, SEARCH_SYNC_DEBOUNCE_MS);
});
}
}
@@ -1851,12 +1954,14 @@ async function main() {
if (state.query && document.activeElement === $("#q")) {
state.query = "";
$("#q").value = "";
syncStateToUrl({ replace: true });
renderBoard();
return;
}
if (state.selectedKey) {
state.selectedKey = null;
state.inspect = null;
syncStateToUrl();
render();
}
return;
@@ -1864,11 +1969,13 @@ async function main() {
if (state.itemQuery && document.activeElement === $("#q-item")) {
state.itemQuery = "";
$("#q-item").value = "";
syncStateToUrl({ replace: true });
renderItemShop();
return;
}
if (state.selectedItemKey) {
state.selectedItemKey = null;
syncStateToUrl();
render();
}
});
@@ -1895,6 +2002,7 @@ async function main() {
bindSearch();
bindPageChrome();
const res = await fetch("data.json");
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
state.data = await res.json();
state.data.relations = state.data.relations || { counters: [], synergies: [] };
state.data.hero_items = state.data.hero_items || { items: {}, by_hero: {} };
@@ -1909,7 +2017,11 @@ 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 || {};
render();
_skillEntriesCache.clear();
_resourceBarScaleCache = null;
_innateAbilityKeys = null;
installRouter({ getState: () => state, applyPatch });
applyUrlToState();
}
main().catch((err) => {
+6 -1
View File
@@ -8,6 +8,10 @@
</head>
<body>
<header class="topbar">
<div class="brand">
<img class="brand-logo" src="ui-icon/dota2_logo.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="items">物品</button>
@@ -47,7 +51,7 @@
<main id="patches-view" class="board patches-board hidden">
<div class="patches-header">
<h2 class="patches-title">游戏性更新 <span class="patches-ver" id="patch-ver"></span></h2>
<h2 class="patches-title"><span class="patches-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>
@@ -55,6 +59,7 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/router.js"></script>
<script src="/app.js"></script>
</body>
</html>
+158
View File
@@ -0,0 +1,158 @@
/* global window, history, location */
/**
* Hash-based router for the relations preview (web/relations).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | items | patches (top-level tab)
* - hero: selected hero key + detail sub-tab (skills|core|fears|patches)
* - item: selected shop item key (items page)
* - patch: selected version string (patches page; latest when absent)
* - query params: tags=csv (heroes), q=search text (heroes + items)
*
* Two-way binding without feedback loops:
* - App calls syncStateToUrl() after each state mutation. We use
* history.pushState/replaceState, which update the URL silently
* (no hashchange event fires) — so applyUrlToState is never re-entered.
* - Back/forward navigation fires hashchange → applyUrlToState parses
* the new hash, hands a validated patch back to app, and app re-renders.
*
* Depends on app.js for state shape and render(); installed via installRouter().
*/
const ROUTE_DEFAULT = "#/heroes";
const VALID_PAGES = ["heroes", "items", "patches"];
const VALID_DETAIL_TABS = ["skills", "core", "fears", "patches"];
let _deps = null;
/**
* Bind the router. deps = {
* getState: () => state,
* applyPatch: (patch) => void // validates + merges + renders
* }
* Call once after state.data is loaded.
*/
function installRouter(deps) {
_deps = deps;
window.addEventListener("hashchange", applyUrlToState);
}
/** Parse a hash string into a state patch (all fields nullable). */
function parseHash(hash) {
const out = {
page: null,
heroKey: null,
detailTab: null,
itemKey: null,
patchVersion: null,
tags: null,
query: null,
itemQuery: null,
};
let raw = hash || "";
if (raw.startsWith("#")) raw = raw.slice(1);
if (!raw.startsWith("/")) raw = "/" + raw;
const qIdx = raw.indexOf("?");
const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw;
const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : "";
const segs = pathPart.split("/").filter(Boolean);
if (!segs.length) return out;
const page = segs[0];
if (!VALID_PAGES.includes(page)) return out;
out.page = page;
if (page === "heroes") {
if (segs[1]) out.heroKey = safeDecode(segs[1]);
if (segs[2]) out.detailTab = safeDecode(segs[2]);
} else if (page === "items") {
if (segs[1]) out.itemKey = safeDecode(segs[1]);
} else if (page === "patches") {
if (segs[1]) out.patchVersion = safeDecode(segs[1]);
}
const params = new URLSearchParams(queryPart);
const tagsParam = params.get("tags");
if (tagsParam) {
out.tags = new Set(
tagsParam.split(",").map((s) => safeDecode(s)).filter(Boolean)
);
}
const qParam = params.get("q");
if (qParam != null) {
if (page === "items") out.itemQuery = qParam;
else out.query = qParam;
}
return out;
}
function safeDecode(s) {
try {
return decodeURIComponent(s);
} catch {
return "";
}
}
/** Serialize current app state into a hash string (with leading '#'). */
function serializeHash(state) {
if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT;
let hash = "#/" + state.page;
if (state.page === "heroes") {
if (state.selectedKey) {
hash += "/" + encodeURIComponent(state.selectedKey);
if (state.detailTab && VALID_DETAIL_TABS.includes(state.detailTab)) {
hash += "/" + encodeURIComponent(state.detailTab);
}
}
} else if (state.page === "items") {
if (state.selectedItemKey) {
hash += "/" + encodeURIComponent(state.selectedItemKey);
}
} else if (state.page === "patches") {
// Omit version when it is the latest — bare #/patches means "latest".
if (state.selectedPatch) {
const patches = (state.data && state.data.patches) || [];
const isLatest =
patches.length > 0 && patches[0].version === state.selectedPatch;
if (!isLatest) {
hash += "/" + encodeURIComponent(state.selectedPatch);
}
}
}
if (state.page === "heroes") {
const qs = new URLSearchParams();
if (state.tagFilters && state.tagFilters.size) {
qs.set("tags", Array.from(state.tagFilters).join(","));
}
if (state.query) qs.set("q", state.query);
const s = qs.toString();
if (s) hash += "?" + s;
} else if (state.page === "items") {
const qs = new URLSearchParams();
if (state.itemQuery) qs.set("q", state.itemQuery);
const s = qs.toString();
if (s) hash += "?" + s;
}
return hash;
}
/** Write current state into the URL. Use replace:true for high-frequency
* updates (search typing, tag toggles) to avoid history-stack spam. */
function syncStateToUrl({ replace = false } = {}) {
if (!_deps) return;
const hash = serializeHash(_deps.getState());
if (hash === window.location.hash) return; // no-op when state matches URL
if (replace) {
history.replaceState(null, "", hash);
} else {
history.pushState(null, "", hash);
}
}
/** Parse current location.hash, hand it to app for validation+merge, render.
* Invoked on hashchange (back/forward / direct location.hash writes) and
* once at startup for deep-link support. */
function applyUrlToState() {
if (!_deps) return;
const patch = parseHash(window.location.hash);
_deps.applyPatch(patch);
}
+48 -6
View File
@@ -42,6 +42,39 @@ body {
background: rgba(8, 12, 20, 0.45);
flex: 0 0 auto;
}
/* Brand mark: official Dota 2 logo + 上分帝 title, pinned to the top-left
corner of the topbar. Absolutely positioned so the centered tab row and
toolbar layout are left untouched. */
.brand {
position: absolute;
left: 20px;
top: 12px;
height: 40px;
display: flex;
align-items: center;
gap: 12px;
pointer-events: none;
z-index: 2;
}
.brand-logo {
height: 34px;
width: auto;
display: block;
}
.brand-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--text);
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
@media (max-width: 880px) {
.brand-title { display: none; }
}
@media (max-width: 620px) {
.brand { display: none; }
}
.main-tabs {
display: flex;
justify-content: center;
@@ -1532,14 +1565,23 @@ body:has(#items-view:not(.hidden)) {
}
.patches-title {
margin: 0;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--text);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.patches-kicker {
font-size: 15px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.14em;
}
.patches-ver {
color: var(--sel);
margin-left: 4px;
font-size: 40px;
font-weight: 800;
color: var(--text);
letter-spacing: 0.02em;
line-height: 1;
}
#patch-select {
flex: 0 1 auto;