diff --git a/CHANGELOG.md b/CHANGELOG.md index e0496d8..a000fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ - PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。 - 定性克制:远古冰魂 → 瘟疫法师(冰晶爆轰禁疗)。 +## [0.6.55] - 2026-07-31 + +### Fixed + +- 玩家同步:OpenDota 空/残缺响应不再覆盖已有近 20 场、常用英雄、队友与段位。 +- 样式:`style.css` 缓存改为 60s,并 bump 版本戳,避免同 `?v=` 内容变更仍命中旧 CSS 导致「我」页双栏错乱。 +- 补齐 OSS `ui-icon`(含 wordmark),避免顶栏 Logo 404。 + ## [0.6.54] - 2026-07-31 ### Fixed diff --git a/DESIGN.md b/DESIGN.md index 0d6ec76..d9f627f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -317,7 +317,7 @@ components: - **十人详情**:返回控件放在比赛摘要卡上方(非卡内):`surface-raised` + 细边框 + `radius-md`,高 36px,左侧 18px SVG 左箭头 +「返回玩家主页」;hover 用 `primary` 描边/字色。摘要卡仅比赛 ID + 时间 · 时长 · 胜方。天辉/夜魇共享可见列头;昵称列封顶约 16rem(超长截断 + title),勿用弹性列挤占指标;参战/伤害/经济/KDA 四等分占满中间剩余宽度,装备靠右,跨队对齐。胜负始终用 `good` / `danger`,不得把天辉/夜魇阵营色混作胜负色。 - **活动热力图**:按周分列、星期分行的 7×N 网格,使用绿色四级强度并提供「少—多」图例;标注「最近 180 天样本」。 - 主列宽度用 `data`(1200);外层容器须为 1200 内容宽度预留 padding,不得以更小的父级 `max-width` 截断。900–1200px 保持摘要五列与十人详情横向对比,只在空间确实不足时堆叠。 -- **加载**:有生涯/近场数据时首屏直出,勿因 TTL `stale` 再转圈或清空页面;空档案才显示「正在同步」并轮询。软过期只静默再拉 `/me`(生产 Queue / 本机后台 enrich),禁止 `loadKey=null` 闪「加载中…」。 +- **加载**:有生涯/近场数据时首屏直出,勿因 TTL `stale` 再转圈或清空页面;空档案才显示「正在同步」并轮询。软过期只静默再拉 `/me`(生产 Queue / 本机后台 enrich),禁止 `loadKey=null` 闪「加载中…」。Cloudflare Worker 在 OpenDota 空/残缺时不得覆盖已有近场/英雄/队友/段位。 ### 导航 diff --git a/web/_oss_upload_ui_icons.py b/web/_oss_upload_ui_icons.py new file mode 100644 index 0000000..9d6ea4f --- /dev/null +++ b/web/_oss_upload_ui_icons.py @@ -0,0 +1,46 @@ +"""Upload web/assets/ui_icons/* to OSS ui-icon/ (no secret echo).""" + +from __future__ import annotations + +import mimetypes +import os +import sys +from pathlib import Path + +import oss2 + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "web" / "assets" / "ui_icons" + + +def main() -> int: + 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: + print("missing OSS credentials", file=sys.stderr) + return 2 + bucket = oss2.Bucket(oss2.Auth(ak, sk), "https://oss-cn-shanghai.aliyuncs.com", "climperor") + n = 0 + for f in sorted(SRC.iterdir()): + if not f.is_file() or f.name.startswith("."): + continue + key = f"ui-icon/{f.name}" + ctype = mimetypes.guess_type(f.name)[0] or "application/octet-stream" + bucket.put_object_from_file( + key, + str(f), + headers={"Content-Type": ctype, "Cache-Control": "public, max-age=86400"}, + ) + n += 1 + print(f"uploaded {n} ui-icon files", flush=True) + for name in ("dota2_logo_wordmark.png", "dota2_logo.png"): + print(f" {name} exists={bucket.object_exists('ui-icon/' + name)}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/cloudflare/player-sync/src/db.js b/web/cloudflare/player-sync/src/db.js index ea02878..3f9d74b 100644 --- a/web/cloudflare/player-sync/src/db.js +++ b/web/cloudflare/player-sync/src/db.js @@ -37,8 +37,8 @@ export async function upsertProfile(db, accountId, profile) { availability_complete, source, fetched_at, enriched_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET - rank_tier=excluded.rank_tier, - leaderboard_rank=excluded.leaderboard_rank, + rank_tier=COALESCE(excluded.rank_tier, player_profiles.rank_tier), + leaderboard_rank=COALESCE(excluded.leaderboard_rank, player_profiles.leaderboard_rank), availability_status=excluded.availability_status, availability_note=excluded.availability_note, availability_complete=excluded.availability_complete, @@ -102,9 +102,11 @@ export async function upsertStats(db, accountId, scope, stats) { } export async function replaceHeroes(db, accountId, heroes) { + // Empty OpenDota heroes must not wipe a good cache. + if (!Array.isArray(heroes) || heroes.length === 0) return; const now = utcNow(); await db.prepare(`DELETE FROM player_heroes WHERE account_id = ?`).bind(accountId).run(); - for (const h of heroes || []) { + for (const h of heroes) { await db .prepare( `INSERT INTO player_heroes ( @@ -127,9 +129,11 @@ export async function replaceHeroes(db, accountId, heroes) { } export async function replacePeers(db, accountId, peers) { + // Empty OpenDota peers must not wipe a good cache. + if (!Array.isArray(peers) || peers.length === 0) return; const now = utcNow(); await db.prepare(`DELETE FROM player_peers WHERE account_id = ?`).bind(accountId).run(); - for (const p of peers || []) { + for (const p of peers) { await db .prepare( `INSERT INTO player_peers ( diff --git a/web/cloudflare/player-sync/src/index.js b/web/cloudflare/player-sync/src/index.js index e632f80..f8424eb 100644 --- a/web/cloudflare/player-sync/src/index.js +++ b/web/cloudflare/player-sync/src/index.js @@ -193,9 +193,17 @@ async function syncAccount(env, msg) { }; } + const existing = await loadPlayerBundle(env.DB, accountId); + const existingRecentN = Array.isArray(existing && existing.recent) + ? existing.recent.length + : 0; + const existingR20 = + existing && existing.recent_20 ? Number(existing.recent_20.sample) || 0 : 0; + const odRecentOk = Array.isArray(recentRaw) && recentRaw.length > 0; + const fetched = utcNow(); const availability = mergeAvailability({ - opendotaRecentN: Array.isArray(recentRaw) ? recentRaw.length : 0, + opendotaRecentN: odRecentOk ? recentRaw.length : 0, career, steamHistoryStatus: steamStatus, fetchedAt: fetched, @@ -204,11 +212,13 @@ async function syncAccount(env, msg) { availability.status = "private"; availability.note = "未公开比赛数据"; } - // Steam public but OpenDota empty → almost always rate-limit; retry queue. + // Cold account + Steam public + empty OpenDota → rate-limit; retry queue. if ( - availability.status === "syncing" && - (!Array.isArray(recentRaw) || recentRaw.length === 0) && - !career + !odRecentOk && + !career && + existingRecentN === 0 && + !(existing && existing.career && existing.career.games > 0) && + steamStatus === 1 ) { throw new Error("OpenDota empty while Steam public — retry"); } @@ -222,14 +232,28 @@ async function syncAccount(env, msg) { availability, enriched_at: fetched, }; + // Partial OpenDota (career ok, recent empty): keep prior freshness so /me + // stays soft-stale and do not pretend the row is fully refreshed. + if (!odRecentOk && (existingR20 > 0 || existingRecentN > 0)) { + profile.enriched_at = + (existing && existing.enriched_at) || profile.enriched_at; + if (existing && existing.availability && existing.availability.fetched_at) { + availability.fetched_at = existing.availability.fetched_at; + } + } await upsertProfile(env.DB, accountId, profile); if (career) await upsertStats(env.DB, accountId, "career", career); - await upsertStats(env.DB, accountId, "recent20", aggregateFromRows(recent20, 20)); + const recentAgg = aggregateFromRows(recent20, 20); + if ((recentAgg.sample || 0) > 0) { + await upsertStats(env.DB, accountId, "recent20", recentAgg); + await upsertMatches(env.DB, accountId, recent20); + } else if (existingR20 <= 0 && existingRecentN <= 0) { + await upsertStats(env.DB, accountId, "recent20", recentAgg); + } if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180); await replaceHeroes(env.DB, accountId, topHeroes); await replacePeers(env.DB, accountId, peerRows); - await upsertMatches(env.DB, accountId, recent20); // Optional: store a published match detail into R2 (deduped by match_id). if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) { diff --git a/web/export_relations_site.py b/web/export_relations_site.py index db2925b..a1b4089 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -55,7 +55,7 @@ from shared.paths import ( from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle from serve_relations import WEB_DIR, build_payload -SITE_VERSION = "0.6.54" +SITE_VERSION = "0.6.55" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" diff --git a/web/frontend/_headers b/web/frontend/_headers index be1d552..e733132 100644 --- a/web/frontend/_headers +++ b/web/frontend/_headers @@ -20,7 +20,7 @@ Cache-Control: public, max-age=60, must-revalidate /style.css - Cache-Control: public, max-age=300, must-revalidate + Cache-Control: public, max-age=60, must-revalidate /fonts/* Cache-Control: public, max-age=31536000, immutable diff --git a/web/frontend/config.js b/web/frontend/config.js index 555afa3..014a66f 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -1,5 +1,5 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.6.54"; +var SITE_VERSION = "0.6.55"; var SITE_ORIGIN = ""; var ABILITY_VIDEO_BASE = ""; var STATIC_ASSET_BASE = ""; diff --git a/web/frontend/index.html b/web/frontend/index.html index 5042b11..21316c0 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -43,8 +43,8 @@ } - - + +

DOTA2 上分帝

@@ -256,8 +256,8 @@ - - - + + +