From 9898dd39fb26fb6878f9aeb442ee9bfe6f1b4947 Mon Sep 17 00:00:00 2001 From: voson Date: Mon, 27 Jul 2026 21:28:01 +0800 Subject: [PATCH] v0.4.2: host preview icons on OSS for slim Pages deploys. Add STATIC_ASSET_BASE so Cloudflare Pages ships ~2MB HTML/JS/data; icons served from climperor OSS via _oss_static_assets.py. New machines can deploy without local fetch scripts. Co-authored-by: Cursor --- AGENTS.md | 13 +-- CHANGELOG.md | 13 +++ README.md | 3 +- _oss_static_assets.py | 176 +++++++++++++++++++++++++++++++++++++++ deploy_relations.py | 57 ++++++++++--- export_relations_site.py | 130 ++++++++++++++++++----------- web/relations/app.js | 76 ++++++++++++----- web/relations/config.js | 3 +- web/relations/index.html | 6 +- 9 files changed, 384 insertions(+), 93 deletions(-) create mode 100644 _oss_static_assets.py diff --git a/AGENTS.md b/AGENTS.md index 4e27fa5..9b00376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,10 +29,10 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段 | `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 | | `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 | | `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json`) | -| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`(data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base` 写 `config.js` 指向 OSS 技能视频;`--with-videos` 可选本地拷贝视频,生产部署勿用) | -| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 `--ability-video-base` 指向 `climperor` OSS;凭据经 keyzoo 注入或 env) | +| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`(data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base` / `--static-asset-base` 写 `config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist;`--with-videos` 可选本地拷贝视频,生产部署勿用) | +| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env) | | `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) | -| `_oss_ability_videos.py` | 阿里云 OSS 桶 `climperor` 建桶 / CORS / 同步 `assets/ability_videos/` → `ability-video/`(凭据经 keyzoo `digitevents/voson-RAM` 注入);`_oss_fix_public.py` / `_oss_launch_upload.py` 为配套辅助 | +| `_oss_ability_videos.py` | 阿里云 OSS 桶 `climperor` 建桶 / CORS / 同步 `assets/ability_videos/` → `ability-video/`(凭据经 keyzoo `digitevents/voson-RAM` 注入);`_oss_static_assets.py` 同步图标等静态图;`_oss_fix_public.py` / `_oss_launch_upload.py` 为配套辅助 | | `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` + 图标 | @@ -58,7 +58,7 @@ 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/` | 关系只读预览页静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js` 含 `SITE_VERSION` 与 `ABILITY_VIDEO_BASE`;版本页底部显示 `v{SITE_VERSION}` | +| `web/relations/` | 关系只读预览页静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js` 含 `SITE_VERSION`、`ABILITY_VIDEO_BASE`、`STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}` | | `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) | | `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) | | `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 | @@ -123,8 +123,9 @@ python item_fears.py # 英雄怕的装备 python fetch_patches.py # 近一年版本日志 + 详情(版本页) python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店 python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages) -python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com -python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;技能视频走 OSS) +python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com --static-asset-base https://climperor.oss-cn-shanghai.aliyuncs.com +python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;视频与图标走 OSS) +python _oss_static_assets.py upload # 图标/portrait 变更后同步 OSS(keyzoo voson-RAM) python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态 python gsi_setup.py --check python gsi_watch.py --once diff --git a/CHANGELOG.md b/CHANGELOG.md index 601eb0e..5344528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ## [Unreleased] +## [0.4.2] - 2026-07-27 + +### Changed + +- 关系预览静态图标(头像 / 装备 / 技能 / 属性等)改由阿里云 OSS 同桶托管; + `config.js` 新增 `STATIC_ASSET_BASE`,生产 Pages 包体约 **2 MB**(仅 + HTML/JS/data),换机部署无需本地 fetch 图标。同步脚本 + `_oss_static_assets.py upload`。 + +### Added + +- 新增 `_oss_static_assets.py`:将 portrait/item/ability 等图标同步到 OSS。 + ## [0.4.1] - 2026-07-27 ### Changed diff --git a/README.md b/README.md index dd26731..73c7d1e 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ python deploy_relations.py # 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key); # 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行 # 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖) -# 技能演示视频默认走阿里云 OSS 桶 climperor(Pages 只部署 ~42MB 静态资源) +# 技能演示视频与静态图标默认走阿里云 OSS 桶 climperor(Pages 只部署 ~2MB HTML/JS/data) +python _oss_static_assets.py upload # patch/新英雄后同步图标到 OSS python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态) ``` diff --git a/_oss_static_assets.py b/_oss_static_assets.py new file mode 100644 index 0000000..6019506 --- /dev/null +++ b/_oss_static_assets.py @@ -0,0 +1,176 @@ +"""Sync relations preview static images to Aliyun OSS (keyzoo inject). + +Builds the same portrait/item/ability/... tree as export_relations_site and +uploads under oss:///{attr,item,portrait,...}/. + +Env (from keyzoo asset_exec on digitevents/voson-RAM): + KEYZOO_ASSET_META_ACCESSKEY_ID + KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET + +Usage: + python _oss_static_assets.py upload [--force] + python _oss_static_assets.py verify +""" + +from __future__ import annotations + +import argparse +import mimetypes +import os +import shutil +import sys +import tempfile +import time +import urllib.request +from pathlib import Path + +import oss2 + +from common import ROOT +from export_relations_site import DEFAULT_OSS_BASE, populate_static_assets +from serve_relations import build_payload + +ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com" +REGION = "cn-shanghai" +ASSET_DIRS = ("attr", "item", "item-cat", "ability", "ui-icon", "portrait") + + +def _creds() -> tuple[str, str]: + ak = os.environ.get("KEYZOO_ASSET_META_ACCESSKEY_ID") or os.environ.get( + "OSS_ACCESS_KEY_ID" + ) + sk = os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") or os.environ.get( + "OSS_ACCESS_KEY_SECRET" + ) + if not ak or not sk: + raise SystemExit( + "missing AccessKey: inject via keyzoo or set OSS_ACCESS_KEY_ID / " + "OSS_ACCESS_KEY_SECRET" + ) + return ak, sk + + +def _bucket(name: str) -> oss2.Bucket: + return oss2.Bucket(oss2.Auth(*_creds()), ENDPOINT, name) + + +def _resolve_bucket(explicit: str | None) -> str: + if explicit: + return explicit + state = ROOT / ".oss_ability_videos_bucket" + if state.is_file(): + return state.read_text(encoding="utf-8").strip() + return "climperor" + + +def _build_staging() -> tuple[Path, dict[str, int]]: + payload = build_payload() + staging = Path(tempfile.mkdtemp(prefix="climperor-static-")) + counts = populate_static_assets(staging, payload) + return staging, counts + + +def _iter_files(root: Path) -> list[Path]: + files: list[Path] = [] + for sub in ASSET_DIRS: + d = root / sub + if not d.is_dir(): + continue + files.extend(sorted(p for p in d.rglob("*") if p.is_file())) + return files + + +def upload(bucket_name: str, *, force: bool = False) -> None: + staging, counts = _build_staging() + try: + files = _iter_files(staging) + total_bytes = sum(f.stat().st_size for f in files) + print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)") + for name, n in counts.items(): + print(f" {name}/: {n}") + + bucket = _bucket(bucket_name) + remote_sizes: dict[str, int] = {} + if not force: + print("listing remote static objects ...") + for sub in ASSET_DIRS: + for obj in oss2.ObjectIterator(bucket, prefix=f"{sub}/"): + remote_sizes[obj.key] = int(obj.size) + print(f"remote keys indexed: {len(remote_sizes)}") + + uploaded = skipped = failed = 0 + t0 = time.time() + for i, path in enumerate(files, 1): + key = path.relative_to(staging).as_posix() + size = path.stat().st_size + if not force and remote_sizes.get(key) == size: + skipped += 1 + continue + headers: dict[str, str] = {} + ctype, _ = mimetypes.guess_type(str(path)) + if ctype: + headers["Content-Type"] = ctype + try: + bucket.put_object_from_file(key, str(path), headers=headers) + uploaded += 1 + if i % 100 == 0 or i == len(files) or uploaded <= 5: + print(f"[{i}/{len(files)}] ok {key} ({size} bytes)") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f"[{i}/{len(files)}] FAIL {key}: {exc}") + + elapsed = time.time() - t0 + print( + f"done: uploaded={uploaded} skipped={skipped} failed={failed} " + f"elapsed={elapsed:.0f}s" + ) + if failed: + raise SystemExit(1) + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def verify(bucket_name: str, samples: int = 8) -> None: + staging, _ = _build_staging() + try: + files = _iter_files(staging) + if not files: + raise SystemExit("no files in staging — run fetch scripts first") + base = f"https://{bucket_name}.oss-{REGION}.aliyuncs.com" + step = max(1, len(files) // samples) + picks = [files[i] for i in range(0, len(files), step)][:samples] + ok = 0 + for path in picks: + key = path.relative_to(staging).as_posix() + url = f"{base}/{key}" + try: + req = urllib.request.Request(url, method="HEAD") + with urllib.request.urlopen(req, timeout=30) as resp: + print(f" {resp.status} {resp.headers.get('Content-Type', '')} {url}") + if resp.status == 200: + ok += 1 + except Exception as exc: # noqa: BLE001 + print(f" FAIL {url}: {exc}") + print(f"spot-check ok: {ok}/{len(picks)}") + if ok < len(picks): + raise SystemExit(1) + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("cmd", choices=("upload", "verify")) + ap.add_argument("--bucket", default=None) + ap.add_argument("--force", action="store_true") + args = ap.parse_args() + name = _resolve_bucket(args.bucket) + print(f"bucket: {name} public_base: {DEFAULT_OSS_BASE}") + if args.cmd == "upload": + upload(name, force=args.force) + else: + verify(name) + + +if __name__ == "__main__": + main() diff --git a/deploy_relations.py b/deploy_relations.py index 6082c53..dde476d 100644 --- a/deploy_relations.py +++ b/deploy_relations.py @@ -43,6 +43,7 @@ ROOT = Path(__file__).resolve().parent DEFAULT_PROJECT = "climperor-relations" DEFAULT_DOMAIN = "dota2.refining.dev" DEFAULT_DIST = ROOT / "dist" / "relations" +DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" # 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 @@ -148,25 +149,49 @@ def ensure_project(email: str, api_key: str, account_id: str, project: str) -> N raise SystemExit(f"create project failed: {d.get('errors')}") -def run_export(ability_video_base: str | None = None) -> None: +def run_export( + ability_video_base: str | None = None, + static_asset_base: str | None = None, +) -> None: """Re-run export_relations_site.py to refresh dist/relations.""" print("exporting static site ...") cmd = [sys.executable, str(ROOT / "export_relations_site.py")] - # Production Pages should load ability demos from OSS, not bundle GB of video. - base = ability_video_base - if base is None: - base = os.environ.get( - "ABILITY_VIDEO_BASE", - "https://climperor.oss-cn-shanghai.aliyuncs.com", - ) - if base: - cmd.extend(["--ability-video-base", base]) - print(f"ABILITY_VIDEO_BASE={base}") + oss = ability_video_base + if oss is None: + oss = os.environ.get("ABILITY_VIDEO_BASE", DEFAULT_OSS_BASE) + static = static_asset_base + if static is None: + static = os.environ.get("STATIC_ASSET_BASE", oss or DEFAULT_OSS_BASE) + if oss: + cmd.extend(["--ability-video-base", oss]) + print(f"ABILITY_VIDEO_BASE={oss}") + if static: + cmd.extend(["--static-asset-base", static]) + print(f"STATIC_ASSET_BASE={static}") subprocess.run(cmd, cwd=str(ROOT), check=True) +def read_config_js_var(dist: Path, name: str) -> str: + """Parse a string literal from generated config.js.""" + cfg = dist / "config.js" + if not cfg.is_file(): + return "" + prefix = f"var {name} = " + for line in cfg.read_text(encoding="utf-8").splitlines(): + if line.startswith(prefix): + return json.loads(line[len(prefix) :].rstrip(";")) + return "" + + def check_integrity(dist: Path) -> None: """Abort if a hard-required asset directory is empty; warn otherwise.""" + static_base = read_config_js_var(dist, "STATIC_ASSET_BASE") + if static_base: + print( + f"asset integrity check: skipped local dirs " + f"(STATIC_ASSET_BASE={static_base})" + ) + return problems: list[str] = [] for sub, required in INTEGRITY_DIRS.items(): d = dist / sub @@ -191,7 +216,7 @@ def check_integrity(dist: Path) -> None: if hard: raise SystemExit( f"aborting deploy: required asset dirs empty: {hard}. " - "Run the fetch scripts first so the exported site is complete." + "Run the fetch scripts first, or set STATIC_ASSET_BASE for OSS hosting." ) @@ -322,6 +347,12 @@ def main() -> None: help="passed to export as --ability-video-base (default: OSS climperor endpoint " "or ABILITY_VIDEO_BASE env)", ) + ap.add_argument( + "--static-asset-base", + default=None, + help="passed to export as --static-asset-base (default: same as ability-video-base " + "or STATIC_ASSET_BASE env)", + ) args = ap.parse_args() dist = Path(args.dist).resolve() @@ -333,7 +364,7 @@ def main() -> None: ensure_project(email, api_key, account_id, args.project_name) if not args.no_export: - run_export(args.ability_video_base) + run_export(args.ability_video_base, args.static_asset_base) else: print("--no-export: using existing dist") diff --git a/export_relations_site.py b/export_relations_site.py index ebe04e3..b0858d2 100644 --- a/export_relations_site.py +++ b/export_relations_site.py @@ -2,7 +2,7 @@ Usage: python export_relations_site.py [--out dist/relations] [--with-videos] - [--ability-video-base URL] + [--ability-video-base URL] [--static-asset-base URL] Copies web/relations/ + a snapshot of the /api/data payload (data.json) + the referenced image assets into one directory, ready for any static host @@ -16,6 +16,8 @@ Notes: - Ability videos (several GB locally) are skipped unless --with-videos is given; production deploys point the UI at OSS via --ability-video-base (or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages. +- When --static-asset-base is set, image dirs are omitted from dist/ and the + UI loads icons/portraits from OSS (see _oss_static_assets.py to sync). """ from __future__ import annotations @@ -39,7 +41,8 @@ from common import ( ) from serve_relations import WEB_DIR, build_payload -SITE_VERSION = "0.4.1" +SITE_VERSION = "0.4.2" +DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int: @@ -54,17 +57,57 @@ def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int: return n -def write_config_js(out: Path, ability_video_base: str, site_version: str) -> None: - """Write config.js consumed by app.js (SITE_VERSION, ABILITY_VIDEO_BASE).""" - base = (ability_video_base or "").strip().rstrip("/") +def populate_static_assets(out: Path, payload: dict) -> dict[str, int]: + """Copy preview image dirs into ``out`` (portrait/item/ability/...).""" + counts = { + "attr": copy_glob(ATTR_ICONS, out / "attr"), + "item": copy_glob(ITEM_ICONS, out / "item"), + "item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"), + "ability": copy_glob(ABILITY_ICONS, out / "ability"), + "ui-icon": copy_glob(UI_ICONS, out / "ui-icon"), + } + + portrait_dst = out / "portrait" + portrait_dst.mkdir(exist_ok=True) + n_portrait = 0 + for hero in payload.get("heroes") or []: + key = hero.get("key") + if not key: + continue + src = HERO_PORTRAITS / f"{key}.png" + if not src.is_file(): + src = TEMPLATES_CDN / f"{key}.png" + if src.is_file(): + shutil.copy2(src, portrait_dst / f"{key}.png") + n_portrait += 1 + 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 + return counts + + +def write_config_js( + out: Path, + *, + ability_video_base: str, + static_asset_base: str, + site_version: str, +) -> None: + """Write config.js consumed by app.js.""" + video = (ability_video_base or "").strip().rstrip("/") + static = (static_asset_base or "").strip().rstrip("/") ver = (site_version or "").strip() - # JSON-encode so values are safe JS string literals. - ver_lit = json.dumps(ver, ensure_ascii=False) - base_lit = json.dumps(base, ensure_ascii=False) (out / "config.js").write_text( - f"/* generated by export_relations_site.py — do not edit */\n" - f"var SITE_VERSION = {ver_lit};\n" - f"var ABILITY_VIDEO_BASE = {base_lit};\n", + "/* generated by export_relations_site.py — do not edit */\n" + f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n" + f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n" + f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n", encoding="utf-8", ) @@ -85,6 +128,13 @@ def main() -> None: help="public base URL for ability demos (writes config.js); " "falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)", ) + ap.add_argument( + "--static-asset-base", + default=None, + help="public base URL for icons/portraits (writes config.js); " + "when set, image dirs are not copied into dist; " + "falls back to STATIC_ASSET_BASE env, else empty", + ) args = ap.parse_args() video_base = ( @@ -92,6 +142,11 @@ def main() -> None: if args.ability_video_base is not None else os.environ.get("ABILITY_VIDEO_BASE", "") ) + static_base = ( + args.static_asset_base + if args.static_asset_base is not None + else os.environ.get("STATIC_ASSET_BASE", "") + ) out = Path(args.out).resolve() if out == ROOT.resolve() or out.parent == out: @@ -100,54 +155,28 @@ def main() -> None: shutil.rmtree(out) out.mkdir(parents=True) - # Frontend (uses relative paths — works from any sub-path). - # `_headers` is a Cloudflare Pages config file (cache TTLs for HTML/JSON/JS). for name in ("index.html", "router.js", "app.js", "style.css", "_headers"): src = WEB_DIR / name if src.is_file(): shutil.copy2(src, out / name) - write_config_js(out, video_base, SITE_VERSION) + write_config_js( + out, + ability_video_base=video_base, + static_asset_base=static_base, + site_version=SITE_VERSION, + ) - # Data snapshot (same payload as serve_relations /api/data). payload = build_payload() (out / "data.json").write_text( json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8", ) - # Assets. - counts = { - "attr": copy_glob(ATTR_ICONS, out / "attr"), - "item": copy_glob(ITEM_ICONS, out / "item"), - "item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"), - "ability": copy_glob(ABILITY_ICONS, out / "ability"), - "ui-icon": copy_glob(UI_ICONS, out / "ui-icon"), - } - - # Portraits: official wide cards, fall back to CDN match templates. - portrait_dst = out / "portrait" - portrait_dst.mkdir(exist_ok=True) - n_portrait = 0 - for hero in payload.get("heroes") or []: - key = hero.get("key") - if not key: - continue - src = HERO_PORTRAITS / f"{key}.png" - if not src.is_file(): - src = TEMPLATES_CDN / f"{key}.png" - 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 + if static_base: + counts = {name: 0 for name in ("attr", "item", "item-cat", "ability", "ui-icon", "portrait")} + print(" static assets: omitted (STATIC_ASSET_BASE set — served from OSS)") + else: + counts = populate_static_assets(out, payload) n_videos = 0 if args.with_videos and ABILITY_VIDEOS.is_dir(): @@ -160,7 +189,10 @@ def main() -> None: print(f" {name}/: {n} files") if args.with_videos: print(f" ability-video/: {n_videos} files") - print(f" config.js SITE_VERSION={SITE_VERSION!r} ABILITY_VIDEO_BASE={video_base!r}") + print( + f" config.js SITE_VERSION={SITE_VERSION!r} " + f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r}" + ) print(f" total: {total / 1e6:.1f} MB") print(f"local check: python -m http.server -d {out} 8080") print("deploy: upload the directory to any static host (Pages / nginx / ...)") diff --git a/web/relations/app.js b/web/relations/app.js index 6a0b28f..91de84a 100644 --- a/web/relations/app.js +++ b/web/relations/app.js @@ -1,10 +1,12 @@ -/* global fetch, document, ABILITY_VIDEO_BASE, SITE_VERSION */ +/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION */ -/** OSS / CDN base for ability demos; empty => same-origin `/ability-video/...`. */ +function trimBase(raw) { + return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : ""; +} + +/** OSS base for ability demos; empty => same-origin `/ability-video/...`. */ function abilityVideoBase() { - const raw = - typeof ABILITY_VIDEO_BASE === "string" ? ABILITY_VIDEO_BASE.trim() : ""; - return raw.replace(/\/+$/, ""); + return trimBase(ABILITY_VIDEO_BASE); } function abilityVideoUrl(heroKey, abilityKey) { @@ -13,6 +15,41 @@ function abilityVideoUrl(heroKey, abilityKey) { return base ? `${base}${rel}` : rel; } +/** OSS base for icons/portraits; empty => same-origin relative paths. */ +function staticAssetBase() { + return trimBase(STATIC_ASSET_BASE); +} + +function assetUrl(rel) { + const clean = String(rel || "").replace(/^\//, ""); + const base = staticAssetBase(); + return base ? `${base}/${clean}` : clean; +} + +function attrIconSrc(key) { + return assetUrl(`attr/${key}.png`); +} + +function portraitSrc(key) { + return assetUrl(`portrait/${encodeURIComponent(key)}.png?v=wide`); +} + +function abilityIconSrc(abilityKey) { + return assetUrl(`ability/${encodeURIComponent(abilityKey)}.png`); +} + +function innateIconSrc() { + return assetUrl("ability/innate.png"); +} + +function talentTreeIconSrc() { + return assetUrl("ability/talent_tree.png"); +} + +function itemCatIconSrc(icon) { + return assetUrl(`item-cat/${encodeURIComponent(icon)}`); +} + function siteVersionLabel() { const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : ""; return v ? `v${v}` : ""; @@ -104,7 +141,7 @@ function renderBoard() { for (const attr of attr_order) { const col = document.createElement("section"); col.className = "col"; - col.innerHTML = `
${attr_labels[attr]}${attr_labels[attr]}
`; + col.innerHTML = `
${attr_labels[attr]}${attr_labels[attr]}
`; const grid = document.createElement("div"); grid.className = "grid"; @@ -157,7 +194,7 @@ function renderBoard() { } const img = document.createElement("img"); - img.src = `portrait/${h.key}.png?v=wide`; + img.src = portraitSrc(h.key); img.alt = h.name_loc; img.loading = "lazy"; btn.appendChild(img); @@ -243,9 +280,6 @@ function heroAbilityCell(heroKey) { }; } -const INNATE_ICON_SRC = "ability/innate.png"; -const TALENT_TREE_ICON_SRC = "ability/talent_tree.png"; - /** Floating talent popover (body-level; avoids #detail overflow clipping). */ let talentPopoverEl = null; let talentPopoverHideTimer = 0; @@ -391,11 +425,11 @@ function wireTalentTrigger(btn, talents) { */ function setAbilityIcon(img, abilityKey, isInnate) { if (isInnate) { - img.src = INNATE_ICON_SRC; + img.src = innateIconSrc(); img.onerror = null; return; } - img.src = `ability/${encodeURIComponent(abilityKey)}.png`; + img.src = abilityIconSrc(abilityKey); img.onerror = () => { img.onerror = null; img.removeAttribute("src"); @@ -618,7 +652,7 @@ function buildSkillsPanel(heroKey) { talentBtn.setAttribute("aria-haspopup", "dialog"); talentBtn.title = "天赋树"; const talentImg = document.createElement("img"); - talentImg.src = TALENT_TREE_ICON_SRC; + talentImg.src = talentTreeIconSrc(); talentImg.alt = "天赋树"; talentImg.loading = "lazy"; talentBtn.appendChild(talentImg); @@ -836,7 +870,7 @@ function mountSkillInspect(root, skillEnt, heroKey) { cd.className = "skill-stat cooldown"; const ico = document.createElement("img"); ico.className = "skill-stat-icon"; - ico.src = "ui-icon/cooldown.png"; + ico.src = assetUrl("ui-icon/cooldown.png"); ico.alt = ""; const txt = document.createElement("span"); txt.className = "skill-stat-text"; @@ -1150,7 +1184,7 @@ function buildHeroVitalsAttrs(hero) { (primary === a.key || primary === "all" ? " primary" : ""); cell.setAttribute("title", a.label); cell.innerHTML = - `${a.label}` + + `${a.label}` + `${a.base}` + `${fmtGain(a.gain)}`; attrs.appendChild(cell); @@ -1447,7 +1481,7 @@ function appendShopColumn(parent, sec) { if (sec.icon) { const cat = document.createElement("img"); cat.className = "shop-column-cat"; - cat.src = `item-cat/${encodeURIComponent(sec.icon)}`; + cat.src = itemCatIconSrc(sec.icon); cat.alt = sec.label || ""; cat.title = sec.label || ""; col.appendChild(cat); @@ -1493,7 +1527,7 @@ function renderItemShop() { function itemIconSrc(key) { const stem = key.startsWith("recipe_") ? "recipe" : key; - return `item/${encodeURIComponent(stem)}.png`; + return assetUrl(`item/${encodeURIComponent(stem)}.png`); } function makeCraftChip(key, { clickable = true, onPick = null } = {}) { @@ -1594,7 +1628,7 @@ const STAT_ATTR_ICON = { function statChip(icon) { if (!icon) return ""; const a = STAT_ATTR_ICON[icon]; - if (a) return ``; + if (a) return ``; const label = STAT_LABELS[icon] || icon; return `${escapeHtml(label)}`; } @@ -1655,7 +1689,7 @@ function patchAbilityBlockHtml(ab, lookup) { const aname = ameta ? ameta.name_loc : "# " + ab.ability_id; const innate = akey && innateAbilityKeys().has(akey); const aicon = akey - ? `` + ? `` : ""; return `
${aicon}
${escapeHtml(aname)}${renderNotesList(ab.ability_notes)}
`; } @@ -1675,7 +1709,7 @@ function renderHeroEntry(hero, lookup) { const key = meta ? meta.key : ""; const name = meta ? meta.name_loc : "其它单位"; const portrait = key - ? `` + ? `` : ""; let html = `
${portrait}${escapeHtml(name)}
`; html += renderHeroNotes(hero.hero_notes); @@ -2032,6 +2066,8 @@ async function main() { ); bindSearch(); bindPageChrome(); + const brandLogo = document.querySelector(".brand-logo"); + if (brandLogo) brandLogo.src = assetUrl("ui-icon/dota2_logo.png"); const res = await fetch("data.json"); if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`); state.data = await res.json(); diff --git a/web/relations/config.js b/web/relations/config.js index 048c60c..c5b9ee3 100644 --- a/web/relations/config.js +++ b/web/relations/config.js @@ -1,3 +1,4 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.4.1"; +var SITE_VERSION = "0.4.2"; var ABILITY_VIDEO_BASE = ""; +var STATIC_ASSET_BASE = ""; diff --git a/web/relations/index.html b/web/relations/index.html index 5229d9f..209172e 100644 --- a/web/relations/index.html +++ b/web/relations/index.html @@ -70,8 +70,8 @@
- - - + + +