Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb8f303c2 | ||
|
|
1d0428b4a3 |
@@ -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`。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
@@ -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) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。
|
||||
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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": "熊灵"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user