v0.4.1: OSS skill videos, site version footer, and mail contact.
Host ability demos on Aliyun OSS instead of Pages bundles; add patches-page version label and top-right mailto link; improve spirit bear portrait export. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,8 +29,8 @@ 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 快照 + 前端 + 图片;`--with-videos` 含视频) |
|
||||
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;凭据经 keyzoo 注入或 env) |
|
||||
| `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) |
|
||||
| `_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/` |
|
||||
@@ -57,7 +57,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` / `app.js` / `style.css` / `router.js`) |
|
||||
| `web/relations/` | 关系只读预览页静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js` 含 `SITE_VERSION` 与 `ABILITY_VIDEO_BASE`;版本页底部显示 `v{SITE_VERSION}` |
|
||||
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
|
||||
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
|
||||
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
|
||||
@@ -68,7 +68,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
||||
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
||||
| `assets/ability_icons/` | Steam CDN 技能图标(关系预览;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
|
||||
| `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标) |
|
||||
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取) |
|
||||
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor` 桶 `ability-video/` 前缀托管) |
|
||||
|
||||
运行时产物(**勿提交**,见 `.gitignore`):
|
||||
|
||||
@@ -122,7 +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 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 _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
|
||||
python gsi_setup.py --check
|
||||
python gsi_watch.py --once
|
||||
@@ -138,6 +139,7 @@ python evaluate.py
|
||||
- 改 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`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。
|
||||
- 发版关系预览时同步 bump `export_relations_site.py` 的 `SITE_VERSION`、`web/relations/config.js` 的同名变量,以及 `index.html` 里 `config.js`/`router.js`/`app.js` 的 `?v=` 缓存戳;并写 `CHANGELOG.md`。
|
||||
- 不要重新引入 real 模板双层库、`cdn_penalty`、`build_library.py`。
|
||||
|
||||
## 文档分工
|
||||
|
||||
@@ -4,6 +4,24 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.1] - 2026-07-27
|
||||
|
||||
### Changed
|
||||
|
||||
- 线上关系预览技能演示视频改由阿里云 OSS 托管(桶 `climperor`,
|
||||
`https://climperor.oss-cn-shanghai.aliyuncs.com/ability-video/...`),不再打进
|
||||
Cloudflare Pages;本地 `serve_relations.py` 仍读 `assets/ability_videos/`。
|
||||
导出写入 `config.js` 的 `ABILITY_VIDEO_BASE`(`export_relations_site.py
|
||||
--ability-video-base` / `deploy_relations.py` 默认 OSS 地址)。
|
||||
|
||||
### Added
|
||||
|
||||
- 版本页底部居中显示站点版本号(`config.js` 的 `SITE_VERSION`,如 `v0.4.1`)。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 版本页熊灵(spirit bear)头像:静态导出包含 `portrait/spirit_bear.png`。
|
||||
|
||||
## [0.4.0] - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -51,6 +51,7 @@ 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 静态资源)
|
||||
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
|
||||
```
|
||||
|
||||
|
||||
+20
-7
@@ -148,14 +148,21 @@ 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() -> None:
|
||||
def run_export(ability_video_base: str | None = None) -> 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,
|
||||
)
|
||||
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}")
|
||||
subprocess.run(cmd, cwd=str(ROOT), check=True)
|
||||
|
||||
|
||||
def check_integrity(dist: Path) -> None:
|
||||
@@ -309,6 +316,12 @@ def main() -> None:
|
||||
ap.add_argument("--project-name", default=DEFAULT_PROJECT)
|
||||
ap.add_argument("--domain", default=DEFAULT_DOMAIN)
|
||||
ap.add_argument("--dist", default=str(DEFAULT_DIST))
|
||||
ap.add_argument(
|
||||
"--ability-video-base",
|
||||
default=None,
|
||||
help="passed to export as --ability-video-base (default: OSS climperor endpoint "
|
||||
"or ABILITY_VIDEO_BASE env)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
dist = Path(args.dist).resolve()
|
||||
@@ -320,7 +333,7 @@ def main() -> None:
|
||||
ensure_project(email, api_key, account_id, args.project_name)
|
||||
|
||||
if not args.no_export:
|
||||
run_export()
|
||||
run_export(args.ability_video_base)
|
||||
else:
|
||||
print("--no-export: using existing dist")
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Usage:
|
||||
python export_relations_site.py [--out dist/relations] [--with-videos]
|
||||
[--ability-video-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
|
||||
@@ -13,13 +14,15 @@ Notes:
|
||||
- For patch-notes names/icons on the 版本 page, run `python fetch_patches.py`
|
||||
first (downloads referenced item + ability icons into assets/).
|
||||
- Ability videos (several GB locally) are skipped unless --with-videos is
|
||||
given; the UI degrades quietly when a video is missing.
|
||||
given; production deploys point the UI at OSS via --ability-video-base
|
||||
(or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
@@ -36,6 +39,8 @@ from common import (
|
||||
)
|
||||
from serve_relations import WEB_DIR, build_payload
|
||||
|
||||
SITE_VERSION = "0.4.1"
|
||||
|
||||
|
||||
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
|
||||
if not src.is_dir():
|
||||
@@ -49,6 +54,21 @@ 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("/")
|
||||
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",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Export relations preview as a static site"
|
||||
@@ -59,8 +79,20 @@ def main() -> None:
|
||||
action="store_true",
|
||||
help="also copy assets/ability_videos (several GB)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--ability-video-base",
|
||||
default=None,
|
||||
help="public base URL for ability demos (writes config.js); "
|
||||
"falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
video_base = (
|
||||
args.ability_video_base
|
||||
if args.ability_video_base is not None
|
||||
else os.environ.get("ABILITY_VIDEO_BASE", "")
|
||||
)
|
||||
|
||||
out = Path(args.out).resolve()
|
||||
if out == ROOT.resolve() or out.parent == out:
|
||||
raise SystemExit(f"refusing unsafe --out: {out}")
|
||||
@@ -69,8 +101,12 @@ def main() -> None:
|
||||
out.mkdir(parents=True)
|
||||
|
||||
# Frontend (uses relative paths — works from any sub-path).
|
||||
for name in ("index.html", "router.js", "app.js", "style.css"):
|
||||
shutil.copy2(WEB_DIR / name, out / name)
|
||||
# `_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)
|
||||
|
||||
# Data snapshot (same payload as serve_relations /api/data).
|
||||
payload = build_payload()
|
||||
@@ -124,6 +160,7 @@ 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" 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 / ...)")
|
||||
|
||||
+93
-2
@@ -33,8 +33,8 @@ import urllib.error
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from common import ABILITY_ICONS, DATA, ITEM_ICONS
|
||||
from http_utils import http_json
|
||||
from common import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
|
||||
from http_utils import http_bytes, http_json
|
||||
|
||||
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
|
||||
PATCH_NOTES_URL = "https://www.dota2.com/datafeed/patchnotes?version={version}&language=schinese"
|
||||
@@ -44,6 +44,13 @@ ABILITY_IDS_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/
|
||||
ABILITIES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
|
||||
ITEM_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
|
||||
ABILITY_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
|
||||
# Hero-card CDN (same as fetch_hero_portraits). Non-hero units may only exist at half res.
|
||||
HERO_CARD_URL = (
|
||||
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
|
||||
)
|
||||
# Prefer ability art when Valve's hero-card for a unit is tiny (spirit_bear is 128x72).
|
||||
UNIT_PORTRAIT_FALLBACK_ABILITY = {"spirit_bear": "lone_druid_spirit_bear"}
|
||||
PORTRAIT_TARGET_SIZE = (256, 144)
|
||||
|
||||
OUT = DATA / "patches.json"
|
||||
HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
|
||||
@@ -242,6 +249,86 @@ def build_lookup(
|
||||
return {"items": items, "abilities": abilities, "heroes": heroes}
|
||||
|
||||
|
||||
def _png_size(data: bytes) -> tuple[int, int] | None:
|
||||
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
return None
|
||||
import struct
|
||||
|
||||
return struct.unpack(">II", data[16:24])
|
||||
|
||||
|
||||
def _cover_resize_png(data: bytes, size: tuple[int, int] = PORTRAIT_TARGET_SIZE) -> bytes:
|
||||
"""Center-crop / scale image bytes to a 16:9 hero-card PNG."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
arr = np.frombuffer(data, dtype=np.uint8)
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
|
||||
if img is None:
|
||||
raise ValueError("cv2 could not decode image")
|
||||
if img.ndim == 2:
|
||||
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||
elif img.shape[2] == 4:
|
||||
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
||||
h, w = img.shape[:2]
|
||||
tw, th = size
|
||||
scale = max(tw / w, th / h)
|
||||
nw, nh = max(tw, int(round(w * scale))), max(th, int(round(h * scale)))
|
||||
resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_CUBIC)
|
||||
x0 = max(0, (nw - tw) // 2)
|
||||
y0 = max(0, (nh - th) // 2)
|
||||
crop = resized[y0 : y0 + th, x0 : x0 + tw]
|
||||
ok, buf = cv2.imencode(".png", crop)
|
||||
if not ok:
|
||||
raise ValueError("cv2 could not encode png")
|
||||
return buf.tobytes()
|
||||
|
||||
|
||||
def download_unit_portraits(*, delay: float) -> None:
|
||||
"""Fetch portraits for UNIT_NAMES into hero_portraits (not in heroes.json).
|
||||
|
||||
Valve ships some unit cards at half resolution (spirit_bear 128x72). When a
|
||||
fallback ability icon is configured, use that art cover-cropped to 256x144
|
||||
so the patch page matches normal hero cards.
|
||||
"""
|
||||
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
|
||||
ok = skip = fail = 0
|
||||
for unit in UNIT_NAMES.values():
|
||||
key = unit["key"]
|
||||
out = HERO_PORTRAITS / f"{key}.png"
|
||||
if out.is_file():
|
||||
dims = _png_size(out.read_bytes())
|
||||
if dims == PORTRAIT_TARGET_SIZE:
|
||||
skip += 1
|
||||
continue
|
||||
try:
|
||||
fb = UNIT_PORTRAIT_FALLBACK_ABILITY.get(key)
|
||||
if fb:
|
||||
raw = http_bytes(ABILITY_ICON_URL.format(key=fb), timeout=30)
|
||||
data = _cover_resize_png(raw)
|
||||
src = f"ability:{fb}"
|
||||
else:
|
||||
raw = http_bytes(HERO_CARD_URL.format(key=key), timeout=30)
|
||||
dims = _png_size(raw)
|
||||
data = (
|
||||
_cover_resize_png(raw)
|
||||
if dims and dims != PORTRAIT_TARGET_SIZE
|
||||
else raw
|
||||
)
|
||||
src = "hero-card"
|
||||
out.write_bytes(data)
|
||||
ok += 1
|
||||
print(f" unit portrait {key} <- {src} ({len(data)} bytes)", flush=True)
|
||||
time.sleep(max(delay, 0.05))
|
||||
except Exception as e: # noqa: BLE001
|
||||
fail += 1
|
||||
print(f" FAIL unit portrait {key}: {e}", flush=True)
|
||||
print(
|
||||
f" unit portraits saved={ok} skipped={skip} fail={fail} -> {HERO_PORTRAITS}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
|
||||
"""Pull referenced item + ability icons into assets/ (skip existing)."""
|
||||
from http_utils import download_icons
|
||||
@@ -266,6 +353,10 @@ def download_referenced_icons(lookup: dict, *, delay: float) -> None:
|
||||
)
|
||||
print(f" ability icons saved={saved} skipped={skipped} fail={fail}", flush=True)
|
||||
|
||||
# Pseudo-heroes in patchnotes (e.g. 熊灵) are absent from herolist / heroes.json.
|
||||
print("downloading unit portraits for patch lookup ...", flush=True)
|
||||
download_unit_portraits(delay=delay)
|
||||
|
||||
|
||||
def load_existing_details() -> dict[str, dict]:
|
||||
if not OUT.is_file():
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
X-Content-Type-Options: nosniff
|
||||
|
||||
/index.html
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
|
||||
/data.json
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
|
||||
/app.js
|
||||
Cache-Control: public, max-age=60, must-revalidate
|
||||
|
||||
/config.js
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
|
||||
/router.js
|
||||
Cache-Control: public, max-age=60, must-revalidate
|
||||
|
||||
/style.css
|
||||
Cache-Control: public, max-age=300, must-revalidate
|
||||
+34
-3
@@ -1,4 +1,30 @@
|
||||
/* global fetch, document */
|
||||
/* global fetch, document, ABILITY_VIDEO_BASE, SITE_VERSION */
|
||||
|
||||
/** OSS / CDN 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(/\/+$/, "");
|
||||
}
|
||||
|
||||
function abilityVideoUrl(heroKey, abilityKey) {
|
||||
const rel = `/ability-video/${heroKey}/${abilityKey}.webm`;
|
||||
const base = abilityVideoBase();
|
||||
return base ? `${base}${rel}` : rel;
|
||||
}
|
||||
|
||||
function siteVersionLabel() {
|
||||
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
|
||||
return v ? `v${v}` : "";
|
||||
}
|
||||
|
||||
function renderPatchesSiteVersion() {
|
||||
const el = $("#patches-site-version");
|
||||
if (!el) return;
|
||||
const label = siteVersionLabel();
|
||||
el.textContent = label;
|
||||
el.setAttribute("aria-hidden", label ? "false" : "true");
|
||||
}
|
||||
|
||||
const state = {
|
||||
data: null,
|
||||
@@ -724,15 +750,17 @@ function mountSkillInspect(root, skillEnt, heroKey) {
|
||||
// Official-page layout: demo clip on the left, name/desc column on the right.
|
||||
const body = document.createElement("div");
|
||||
body.className = "skill-info-body";
|
||||
// Official demo clip (assets/ability_videos); hidden when not cached locally.
|
||||
// Official demo clip: local `/ability-video/...` (serve_relations) or OSS base
|
||||
// from config.js (static export / Pages). Hidden when the file is missing.
|
||||
if (heroKey && skillEnt.ability_key) {
|
||||
const video = document.createElement("video");
|
||||
video.className = "skill-info-video";
|
||||
video.src = `/ability-video/${heroKey}/${skillEnt.ability_key}.webm`;
|
||||
video.src = abilityVideoUrl(heroKey, skillEnt.ability_key);
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = "anonymous";
|
||||
video.addEventListener("error", () => video.remove());
|
||||
body.appendChild(video);
|
||||
}
|
||||
@@ -1723,6 +1751,7 @@ function renderPatches() {
|
||||
if (select) select.innerHTML = "";
|
||||
if (verEl) verEl.textContent = "";
|
||||
root.innerHTML = '<div class="patches-empty">暂无版本数据,请运行 python fetch_patches.py</div>';
|
||||
renderPatchesSiteVersion();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1753,6 +1782,7 @@ function renderPatches() {
|
||||
const det = details[state.selectedPatch];
|
||||
if (!det) {
|
||||
root.innerHTML = '<div class="patches-empty">该版本详情暂未抓取,请重跑 python fetch_patches.py</div>';
|
||||
renderPatchesSiteVersion();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1785,6 +1815,7 @@ function renderPatches() {
|
||||
.map((h) => renderHeroEntry(h, lookup))
|
||||
.join("");
|
||||
renderPatchSection(root, "英雄改动", heroesHtml);
|
||||
renderPatchesSiteVersion();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||
var SITE_VERSION = "0.4.1";
|
||||
var ABILITY_VIDEO_BASE = "";
|
||||
@@ -12,6 +12,16 @@
|
||||
<img class="brand-logo" src="ui-icon/dota2_logo.png" alt="Dota 2" />
|
||||
<span class="brand-title">上分帝</span>
|
||||
</div>
|
||||
<a
|
||||
class="contact-mail"
|
||||
href="mailto:c9mhs8vfmuyv@outlook.com"
|
||||
aria-label="邮件联系"
|
||||
title="c9mhs8vfmuyv@outlook.com"
|
||||
>
|
||||
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<nav class="main-tabs" aria-label="主分页">
|
||||
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
|
||||
<button type="button" class="main-tab" data-page="items">物品</button>
|
||||
@@ -55,11 +65,13 @@
|
||||
<select id="patch-select" aria-label="选择版本"></select>
|
||||
</div>
|
||||
<div class="patches-detail" id="patches-detail"></div>
|
||||
<footer class="patches-site-version" id="patches-site-version" aria-hidden="true"></footer>
|
||||
</main>
|
||||
|
||||
<section class="detail" id="detail" aria-live="polite"></section>
|
||||
|
||||
<script src="/router.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/config.js?v=0.4.1"></script>
|
||||
<script src="/router.js?v=0.4.1"></script>
|
||||
<script src="/app.js?v=0.4.1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -75,6 +75,34 @@ body {
|
||||
@media (max-width: 620px) {
|
||||
.brand { display: none; }
|
||||
}
|
||||
.contact-mail {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 12px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.contact-mail:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.contact-mail:focus-visible {
|
||||
outline: 2px solid var(--sel);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.contact-mail-icon {
|
||||
display: block;
|
||||
}
|
||||
.main-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -1609,6 +1637,17 @@ body:has(#items-view:not(.hidden)) {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.patches-site-version {
|
||||
width: min(820px, 92%);
|
||||
padding: 4px 0 28px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.55;
|
||||
user-select: none;
|
||||
}
|
||||
.patch-section {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user