// ==UserScript== // @name Steam 游戏库收藏分析 // @namespace steam-game-library-viewer // @author Github: SmallRob,SteamID: Healer007 // @version 2.46.2 // @description 给 Steam 商店页和个人主页加一个「游戏库分析」面板,一屏看清你的游戏资产。🏆 我的成就:同步你在 Steam 上的真实成就,按完成率、稀有度浏览每一款游戏,看得见哪款已完美通关、哪些是全球稀有成就、还差哪几个。🎮 游戏收藏:绝版游戏、锁区(你所在地区无法购买)、Epic / PS 限时免费、DLC 与捆绑包、年度佳作清单。💰 跨区比价:自动对比美 / 日 / 俄 / 土等多区售价,标出最便宜的国家。📊 统计图表:游玩时长、玩得最久的游戏、最近入库、年度曲库曲线。☁️ 云存档:查询各游戏的云存档状态。💗 愿望单:整理愿望单并识别家庭共享。全部数据只存在你自己的浏览器里,不上传任何服务器。完整更新日志见项目 README。 // @description:en Adds a "Game Library" panel to Steam's store and profile pages so you can see your whole game collection at a glance. 🏆 Achievements: syncs your real Steam achievements and lets you browse every game by completion rate and rarity — see which ones you've perfected, which contain globally rare achievements, and what's still missing. 🎮 Collection: delisted games, region-locked titles, Epic/PS giveaways, DLC and bundles, and Game of the Year picks. 💰 Price comparison: compares prices across US / JP / RU / TR regions and flags the cheapest country. 📊 Charts: playtime, most-played games, recent purchases, yearly library growth. ☁️ Cloud saves: check cloud-save status per game. 💗 Wishlist: tidy up your wishlist and spot family-shared titles. Everything stays in your own browser — nothing is uploaded. Full changelog in the project README. // @homepageURL https://github.com/SmallRob/steam-userscripts/blob/main/steam-game-library-viewer/README.md // @supportURL https://github.com/SmallRob/steam-userscripts/issues // @license MIT // @match https://store.steampowered.com/* // @match https://steamcommunity.com/id/*/home/ // @match https://steamcommunity.com/id/*/home // @match https://steamcommunity.com/profiles/*/home/ // @match https://steamcommunity.com/profiles/*/home // @connect api.steampowered.com // @connect store.steampowered.com // @connect steamcommunity.com // @connect cdn.akamai.steamstatic.com // @connect cdn.cloudflare.steamstatic.com // @connect shared.akamai.steamstatic.com // @connect shared.fastly.steamstatic.com // @connect fastly.jsdelivr.net // @connect gcore.jsdelivr.net // @connect jsdelivr.net // @connect cdn.jsdelivr.net // @connect leanisssharedstorage.blob.core.windows.net // @connect api.augmentedsteam.com // @connect open.er-api.com // @connect flagcdn.com // @connect api.isthereanydeal.com // @connect api.cheapshark.com // @connect steamcardexchange.net // @connect api.deepseek.com // @connect api.openai.com // @connect open.bigmodel.cn // @connect dashscope.aliyuncs.com // @connect api.moonshot.cn // @connect api.minimaxi.com // @connect ark.cn-beijing.volces.com // @connect playstation.com // @connect store.playstation.com // @connect bartervg.com // @connect raw.githubusercontent.com // @connect gamestatus.info // @connect steam-tracker.com // @connect api.xiaoheihe.cn // ============================================================================ // 依赖清单 —— 改任何引用之前,先读这一段 // ============================================================================ // 【规范】四条,改 @require / @resource 前先对照: // ① 一条依赖只能有一种加载方式:@require(远程)或内联进本文件,**互斥**。 // 同时存在 = 白下载 + 白解析 + 同名全局被赋值两次(v2.42.2 实测踩过)。 // ② @require 一律钉具体版本 / 提交号。@main、@latest 会静默漂移 —— // 上游一提交就换掉你正在跑的代码。@resource 例外(纯数据、无执行风险、需自动更新)。 // ③ 头部只指向**已发布且存在**的版本。core 缺失会让整个脚本致命早退 // (见下方「SGLVCore 依赖注入」一节),指向未发布版本 = 整脚本哑火。 // ④ 本地真源在 libs/,远程是发布产物。改库 → 发布 → 再改本头部,三步缺一即漂移。 // 校验:node test-scripts/verify-remote-deps.js // (头部 ↔ 清单 ↔ 本地真源 ↔ 远程实际内容,四方逐行比对) // 清单:test-scripts/remote-deps.config.json // // 【A. 基础层】@require · scriptcat.org · 已发布 // core v2.0.6 基础能力:IDB / 负缓存 / StageProgress / 并发池 / gmFetch / 通知 // 本地真源 libs/sglv-libs-core.lib.js // theme v2.1.4 全局 token 与基座样式(幂等:root.SGLVTheme 已存在则不重复注入) // 本地真源 libs/sglv-libs-theme.lib.js // @require https://scriptcat.org/lib/7432/2.0.8/SGLV%20Core%20Library.js // @require https://scriptcat.org/lib/7493/2.1.4/%20SGLV%20Shared%20CSS%20(Library).js // // 【B. 浮窗层】@require · scriptcat.org · 已发布 // ptw-framework v3.0.0 浮窗外壳:Tab 注册表 / 懒挂载 / 生命周期 / 拖拽 / 降级卡 / 诊断卡 // ptw-tabs v3.0.0 10 个功能 Tab:总览 / 全部 / 日历 / 家庭组 / 成就 / 价值 / 足迹 / 库 / 玩 / 行情 // 本地真源 libs/sglv-libs-ptw-framework.lib.js · libs/sglv-libs-ptw-tabs.lib.js // 框架库内嵌了一份 token 层副本 → PTW 自带样式,不依赖上面独立 theme 库也能浮窗 // @require https://scriptcat.org/lib/8427/3.0.0/SGLV%20PTW%20Framework.js // @require https://scriptcat.org/lib/8430/3.0.0/SGLV%20PTW%20Tab%20All.js // // 【C. 共享宿主层】@require · jsdelivr · 钉 commit(坏推送不会连带打挂本插件) // plugin-host v0.3.0 插件宿主:别的脚本接入本插件能力的桥(window.__PLUGIN_HOST__) // 本地真源:steam-namespace 仓 shared/ // ⚠️ v2.45.0:saa-auth / hub-relay(数据回流层)随「数据回流」功能整体下线一并移除 —— // 本脚本不再把任何数据发往自建数据中心,也不再需要登录态自证 / 出箱链路。 // 保留 plugin-host 是因为它承担的是「别的脚本读本插件版本号」,与回流无关。 // @require https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@50174b3/libs/sglv-plugin-host.lib.js // // 【D. 内联依赖】无远程发布版,直接内联进本文件(见下方对应 INLINE 块) // shelf 暂存箱 libs/sglv-libs-shelf.lib.js(版本号未维护) // cover-fallback v2.0.3 封面降级链路 libs/sglv-libs-cover-fallback.lib.js // (曾发布于 scriptcat lib 7495 —— 远程副本已停用,勿恢复) // game-collection v2.9.9 收藏模块 libs/sglv-libs-game-collection.lib.js // (曾发布于 scriptcat lib 8411 —— 远程副本已停用,勿恢复) // // 【E. 数据资源】@resource · jsdelivr · @main 自动更新(纯数据,无执行风险) // @resource gameDb https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/game-db-v1.2.json // @resource delistedData https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/steam_delisted_apps.json // @resource psplusData https://fastly.jsdelivr.net/gh/oXnMe/psplus-steam-overlay@main/data/psplus-games.json // @resource humbleChoiceData https://fastly.jsdelivr.net/gh/SmallFork/json@main/Humble_Choice.json // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_getResourceText // @grant GM_listValues // @grant GM_notification // @grant unsafeWindow // @grant GM_info // @run-at document-idle // @tag Steam // @tag games // @icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%238b5cf6'/%3E%3Cstop offset='0.5' stop-color='%233b82f6'/%3E%3Cstop offset='1' stop-color='%2306b6d4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Cg fill='%23fff'%3E%3Crect x='13' y='13' width='17' height='17' rx='3.5' opacity='.96'/%3E%3Crect x='34' y='13' width='17' height='17' rx='3.5' opacity='.78'/%3E%3Crect x='13' y='34' width='17' height='17' rx='3.5' opacity='.78'/%3E%3Crect x='34' y='34' width='17' height='17' rx='3.5' opacity='.96'/%3E%3C/g%3E%3Cpath d='M38 39 L47 44.5 L38 50 Z' fill='url(%23g)'/%3E%3C/svg%3E // ==/UserScript== // ============================================================================ // 游戏收藏模块为独立 GreasyFork 库(sglv-game-collection) // ============================================================================ // 收藏模块核心实现(供应商分类 / 绝版收藏 / PS会免 / Epic赠送 / 锁区游戏 / // 年度大作 / DLC收藏 / 捆绑包 / Humble Choice 月包分析 + 收藏浮窗管理)经 // 上方 @require 外部加载;库缺失时走降级链路:菜单项置灰 + 警告日志。 // 主脚本仅保留收藏基本界面:子菜单入口 / 心型按钮 / SGIS 侧边栏按钮 / // CollectionHost 数据桥 / init 握手(见下方"游戏收藏模块"桥接区)。 // 本地开发库源码: script_libs/sglv-game-collection.lib.js // (与 GreasyFork 发布版同步维护,apiVersion=1 桥接契约不变)。 // ============================================================================ // 游戏数据浮窗(PTW)三层架构——浮窗框架与 8 个功能 Tab(总览 / 全部游戏 / 日历 / // 家庭组 / 成就分析 / 价值分析 / 游玩足迹,游玩足迹基于本地每日快照 IDB 滚动 90 天) // v2.32.0:Tab 栏引入「分组」机制(概览/分析/明细/垂直),Tab 数量增长后仍可扫读; // 总览瘦身(HERO + Top20),新增「游玩分析」Tab(时长分档 / 5 维雷达 / 最近两周 / // 时长集中度 / 活跃度分层 / SCI 剧情完成度);PTW_HOST 补挂 calcPlaytimeDist / // isDlc / computeInsightData(此前已实现但从未挂到 host,Tab 库够不着)。 // 位于独立库经上方 @require 加载,主脚本退化为薄壳(仅保留 PTW_HOST 桥接与 2 个入口)。 // 框架含设置与诊断卡、Tab 熔断器(连续 3 次 mount 失败自动停用)、无障碍 tablist // 键盘导航与窄屏适配。如需临时回退内联,运行 node test-scripts/build-ptw-inline.js // 重新生成(该脚本会同时停用远程 @require 并切到内联模式,不会留下双载)。 // ==== SGLV-SHELF-INLINE-BEGIN (build-shelf-inline.js 自动生成,勿手改) ==== /** * 内联内容由 test-scripts/build-shelf-inline.js 生成 —— 请勿手改。 * 来源:libs/{sglv-libs-shelf.lib.js} * 改动请编辑库文件后重跑本脚本;verify-shelf-inline-sync.js 会拦截漏跑。 */ // ---------- sglv-libs-shelf.lib.js (1971 行) ---------- /* ===================================================================== * * SGLV 暂存箱库 (sglv-shelf) * * 定位:Steam 商店全域可达的「暂存箱」——把暂时不想买/想买但现在不买的 * 商品、愿望单项、外部链接与备忘统一暂存,随时查看与恢复。 * * 与 Steam-Cart-Enhanced.js(购物车增强)的关系 * --------------------------------------------------------------------- * 共享 localStorage['sce_cart_shelf_v1'],即「购物类」记录(kind = * app/bundle/package)双向互通:SCE 暂存的项在 SGLV 浮窗可见,SGLV 从 * 购物车暂存的项在 SCE 暂存架可见。用户未安装 SCE 时该 key 由本库独享, * 行为完全一致,无需任何配置。 * * 为什么 wishlist/note 不写进同一个 items 数组 * --------------------------------------------------------------------- * SCE 的 loadShelf() 过滤条件为 * it.id && (it.appid || it.bundleid || it.packageid || appids.length) * 且 saveShelf() 用过滤后的 shelf.items **整体重写** items 数组。 * 若把备忘录/外部链接塞进同一数组,用户一旦在购物车页使用 SCE 暂存, * 这些记录会被 SCE 静默丢弃(无 appid/bundleid/packageid)。 * 故采用双桶: * INTEROP_KEY = 'sce_cart_shelf_v1' 购物类,与 SCE 互通(有损风险 0) * EXTRA_KEY = 'sglv_shelf_extra_v1' 愿望单/备忘,本库独享 * 读取时合并为统一视图,写入时按 kind 路由,用户感知不到分桶。 * * 扩展字段安全性 * --------------------------------------------------------------------- * 顶层未知字段(如自定义 meta)会被 SCE 的 saveShelf 丢弃,故所有扩展 * 一律挂在「记录级」字段上——SCE 只改写它认识的字段,记录级扩展可无损 * 往返。写入互通桶时保留 days/tab 等 SCE 需要的顶层键(Object.assign 合并)。 * * 能力边界(诚实声明) * ------------------------------------------------------------------- * · 恢复(加购)走 AddItemsToCart/v1,全站可用,但**依赖当前登录账号**: * 记录写入时记 steamid,恢复前校验,不匹配直接拒绝(参考脚本无此保护, * 跨账号会静默加到错误账号的购物车)。 * · 暂存(移出购物车)**没有官方 API**,只能点 Steam 自己的「移除」按钮, * 因此仅在 /cart 页可用(与 SCE 同一约束)。其他页面按钮置灰并给跳转。 * · 愿望单不提供移除 API,故 wishlist 记录只做「打开/定位 + 记录管理」。 * · 折扣到期时间 endTs 仅当记录带有该字段时显示倒计时(由 SCE 写入); * 本库从购物车暂存时不主动拉折扣数据。 * * 桥接契约(宿主注入,见 init(ShelfHost)) * --------------------------------------------------------------------- * apiVersion: 1 * isZh: boolean * getSteamId(): string 当前账号 steamid64(可为空) * toast: { success, error, warning, info } * resolveName(appid): Promise 名称补全(可选,缺失时降级) * openUrl(url), isCartPage(): boolean (可选,缺失时降级) * * 暴露:window.SGLVShelf * ===================================================================== */ window.SGLVShelf = (function () { 'use strict'; const apiVersion = 1; // ==================== 常量 ==================== const CFG = { INTEROP_KEY: 'sce_cart_shelf_v1', // 与 Steam-Cart-Enhanced.js 共用 EXTRA_KEY: 'sglv_shelf_extra_v1', // 本库独享(wishlist / note) INTEROP_KINDS: ['app', 'bundle', 'package'], RESTORABLE: ['app', 'bundle', 'package'], CART_API: 'https://api.steampowered.com/IAccountCartService/GetCart/v1/', CART_ADD_API: 'https://api.steampowered.com/IAccountCartService/AddItemsToCart/v1/', ITEMS_API: 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1/', SHELVE_STEP_MS: 700, RESTORE_STEP_MS: 250, CANDIDATE_STEP_MS: 200, MAX_ITEMS: 300, STALE_DAYS: 180, TICK_MS: 1000, }; const KIND_META = { app: { label: '单品', tag: 'cart' }, bundle: { label: '捆绑包', tag: 'cart' }, package: { label: '购买包', tag: 'cart' }, wishlist: { label: '愿望单', tag: 'wishlist' }, note: { label: '备忘', tag: 'note' }, }; const FILTER_META = [ { key: 'all', label: '全部' }, { key: 'cart', label: '购物' }, { key: 'wishlist', label: '愿望单' }, { key: 'note', label: '备忘' }, ]; // ==================== 宿主 ==================== let Host = null; let _hostReady = false; const _changeCbs = []; function zh(s, en) { return Host && Host.isZh === false ? en : s; } function toast(msg, type) { try { if (Host && Host.toast && typeof Host.toast[type] === 'function') Host.toast[type](msg); else if (typeof console !== 'undefined') console.log('[SGLVShelf]', type || 'info', msg); } catch (e) { /* ignore */ } } function debug() { if (typeof console === 'undefined') return; console.log.apply(console, ['[SGLVShelf]'].concat([].slice.call(arguments))); } // ==================== 基础工具 ==================== const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const now = () => Date.now(); const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } function uid(prefix) { return (prefix || 'sh') + '_' + now().toString(36) + '_' + Math.random().toString(36).slice(2, 7); } function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); } function absTime(ts) { if (!ts) return ''; try { const d = new Date(ts); const p = (x) => String(x).padStart(2, '0'); return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } catch (e) { return ''; } } /** 剩余时间文案;ts<=0 返回空串 */ function remainText(ts) { const t = num(ts); if (t <= 0) return ''; let s = Math.floor((t - now()) / 1000); if (s <= 0) return '已到期'; const d = Math.floor(s / 86400); s -= d * 86400; const h = Math.floor(s / 3600); s -= h * 3600; const m = Math.floor(s / 60); if (d > 0) return '剩 ' + d + ' 天 ' + h + ' 小时'; if (h > 0) return '剩 ' + h + ' 小时 ' + m + ' 分'; return '剩 ' + Math.max(1, m) + ' 分'; } /** 紧迫度:0 已过期/无,1 紧张(<24h),2 临近(<72h),3 宽松 */ function severity(ts) { const t = num(ts); if (t <= 0) return 0; const h = (t - now()) / 3600000; if (h <= 0) return 1; if (h < 24) return 1; if (h < 72) return 2; return 3; } function isVisible(el) { if (!el) return false; try { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return false; } catch (e) { /* ignore */ } try { if (el.getClientRects && document.body && document.body.getClientRects) { if (document.body.getClientRects().length && !el.getClientRects().length) return false; } } catch (e) { /* ignore */ } return true; } // ==================== 存储层 ==================== function lsGet(key) { try { return window.localStorage.getItem(key); } catch (e) { return null; } } function lsSet(key, val) { try { window.localStorage.setItem(key, val); return true; } catch (e) { return false; } } function readRaw(key) { const s = lsGet(key); if (!s) return null; try { const v = JSON.parse(s); return (v && typeof v === 'object') ? v : null; } catch (e) { return null; } } function isInteropKind(kind) { return CFG.INTEROP_KINDS.indexOf(kind) !== -1; } function bucketOf(kind) { return isInteropKind(kind) ? 'interop' : 'extra'; } /** * kind 推断的唯一权威入口(导入 / 归一化共用)。 * 顺序:显式合法 kind → 购买项 id → 未知 kind 按有无 appid 归一 → 有 url 判备忘 → 默认。 * 「先看 kind 再看 id」:kind='weird' + appid 应归一为 app,而 kind 缺失但带 bundleid * 才按 bundle 处理(与 SCE 的 legacy 推断兼容)。 */ function resolveKind(rec) { const r = rec || {}; const k = r.kind; if (k && KIND_META[k]) return k; if (r.bundleid) return 'bundle'; if (r.packageid) return 'package'; const hasApp = !!r.appid || (Array.isArray(r.appids) && r.appids.length); if (k) return hasApp ? 'app' : 'note'; if (r.url) return 'note'; return hasApp ? 'app' : 'note'; } function currentSteamId() { try { return (Host && typeof Host.getSteamId === 'function' && String(Host.getSteamId() || '')) || ''; } catch (e) { return ''; } } /** * 记录规范化。 * - 互通桶强制 kind ∈ app/bundle/package(与 SCE 的理解对齐) * - 缺失 kind 时按 bundleid/packageid 推断并打 legacy(与 SCE 2216-2230 同规则) * - 补齐 appids/names/displayName/count 等展示字段 * 不丢弃任何未知字段(往返无损) */ function normalizeRecord(rec, bucket) { const r = Object.assign({}, rec || {}); r.id = r.id || uid('sh'); if (!Array.isArray(r.appids)) r.appids = r.appids != null ? [r.appids] : []; r.appids = r.appids.map(num).filter((n) => n > 0); if (!r.names || typeof r.names !== 'object') r.names = {}; let kind = r.kind; if (!kind) { r.legacy = true; if (r.bundleid) kind = 'bundle'; else if (r.packageid) kind = 'package'; else kind = 'app'; if (r.appid == null && kind === 'app' && r.appids.length === 1) r.appid = r.appids[0]; } if (!KIND_META[kind]) kind = resolveKind(r); // 互通桶只允许购物类(SCE 不认其它 kind) if (bucket === 'interop' && !isInteropKind(kind)) kind = 'app'; r.kind = kind; // 三 id 字段互斥化(与 SCE 结构一致:冗余三字段,方便按类型直读) if (kind === 'bundle') { r.packageid = null; r.appid = null; } else if (kind === 'package') { r.bundleid = null; r.appid = null; } else { r.bundleid = null; r.packageid = null; } if (kind === 'bundle') r.bundleid = num(r.bundleid) || null; if (kind === 'package') r.packageid = num(r.packageid) || null; if ((kind === 'app' || kind === 'wishlist') && !r.appid && r.appids.length === 1) r.appid = r.appids[0]; if (kind === 'app' || kind === 'wishlist') r.appid = num(r.appid) || null; if (kind === 'note') { r.appid = null; r.bundleid = null; r.packageid = null; r.appids = []; } r.endTs = num(r.endTs); r.pct = num(r.pct); r.count = num(r.count) || r.appids.length || (kind === 'note' ? 1 : 0); r.removedAt = num(r.removedAt); r.addedAt = num(r.addedAt) || r.removedAt || 0; r.tags = Array.isArray(r.tags) ? r.tags.filter((x) => x != null && x !== '').slice(0, 12) : []; if (r.url == null) r.url = ''; if (r.note == null) r.note = ''; if (r.origin != 'sce') r.origin = 'sglv'; if (!r.source) r.source = bucket === 'interop' ? 'cart' : 'manual'; if (r.lastError === undefined) r.lastError = null; if (r.displayName == null) r.displayName = ''; // SCE 记录常只有 targetName 而 displayName 为空 —— 回填一份, // 使记录自描述(两个脚本都能直接读 displayName) if (!r.displayName && r.targetName) r.displayName = r.targetName; return r; } /** SCE 认可的记录形状(有 id 且至少一个可定位 id) */ function sceAcceptable(rec) { return !!(rec && rec.id && (rec.appid || rec.bundleid || rec.packageid || (Array.isArray(rec.appids) && rec.appids.length))); } function readBucket(bucket) { const key = bucket === 'interop' ? CFG.INTEROP_KEY : CFG.EXTRA_KEY; const raw = readRaw(key); const items = []; if (raw && Array.isArray(raw.items)) { raw.items.forEach((it) => { if (!it || typeof it !== 'object') return; if (bucket === 'interop' && !sceAcceptable(it)) return; items.push(normalizeRecord(it, bucket)); }); } return { raw: raw || {}, items: items }; } /** * 写桶。互通桶用 Object.assign 合并而非整体覆盖, * 保留 SCE 需要的顶层键(days/tab)与任何未来新增键。 */ function writeBucket(bucket, items) { const key = bucket === 'interop' ? CFG.INTEROP_KEY : CFG.EXTRA_KEY; const cur = readRaw(key) || {}; const payload = Object.assign({}, cur); payload.items = items; if (bucket === 'extra' && !payload.version) payload.version = 1; const ok = lsSet(key, JSON.stringify(payload)); if (!ok) toast(zh('暂存箱保存失败:浏览器存储不可用或已满', 'Shelf save failed: storage unavailable or full'), 'error'); return ok; } /** 统一视图:互通桶 + 独享桶 */ function allItems() { return readBucket('interop').items.concat(readBucket('extra').items); } function getItem(id) { const list = allItems(); for (let i = 0; i < list.length; i++) if (list[i].id === id) return list[i]; return null; } function countAll() { return allItems().length; } /** * 容量上限:**跨两桶的全局上限**。 * 淘汰顺序:先独享桶(备忘/愿望单,最不贵)→ 再互通桶(购物类,最贵)。 * 同桶内按 addedAt 升序(最旧先走)。 * 返回被淘汰条数。 */ function enforceCap() { const ex = readBucket('extra').items; const it = readBucket('interop').items; let over = ex.length + it.length - CFG.MAX_ITEMS; if (over <= 0) return 0; let dropped = 0; const drain = (arr) => { const order = arr.slice().sort((a, b) => (num(a.addedAt) || num(a.removedAt)) - (num(b.addedAt) || num(b.removedAt))); for (const r of order) { if (over <= 0) break; const i = arr.indexOf(r); if (i >= 0) { arr.splice(i, 1); over--; dropped++; } } }; drain(ex); if (over > 0) drain(it); if (dropped) { writeBucket('extra', ex); writeBucket('interop', it); } return dropped; } /** * 写入/更新一条记录(按 kind 路由桶)。 * opts.silent 内部批处理时静默(不逐条 toast、不逐条广播) */ function putItem(rec, opts) { const o = opts || {}; const inp = Object.assign({}, rec || {}); // 别名归一:允许调用方直接传 title/name(其他 SGLV 模块的常见用法) if (!inp.displayName && !inp.targetName) { const t = inp.title || inp.name; if (t) { inp.displayName = t; inp.targetName = t; } } // 时间戳补齐:无任何时间戳的记录(外部模块直接 putItem)盖章 now, // 否则 addedAt=0 会让「按加入时间排序」与陈旧清理全部失效 if (!num(inp.addedAt) && !num(inp.removedAt)) { inp.addedAt = now(); inp.removedAt = inp.addedAt; } const r = normalizeRecord(inp, bucketOf(inp.kind)); if (!sceAcceptable(r) && isInteropKind(r.kind)) { toast(zh('这条记录缺少可定位的商品 ID,未保存', 'Record lacks a resolvable id, skipped'), 'warning'); return null; } const bucket = bucketOf(r.kind); const cur = readBucket(bucket).items; const idx = cur.findIndex((x) => x.id === r.id); if (idx >= 0) cur[idx] = r; else cur.unshift(r); writeBucket(bucket, cur); const dropped = enforceCap(); if (dropped && !o.silent) { toast(zh('暂存箱已满(上限 ' + CFG.MAX_ITEMS + ' 条),已淘汰最旧的 ' + dropped + ' 条', 'Shelf full (' + CFG.MAX_ITEMS + '), pruned ' + dropped + ' oldest'), 'warning'); } if (!o.silent) emitChange(); return r; } function deleteItem(id, opts) { const o = opts || {}; const rec = getItem(id); if (!rec) return false; const bucket = bucketOf(rec.kind); const cur = readBucket(bucket).items.filter((x) => x.id !== id); writeBucket(bucket, cur); if (!o.silent) emitChange(); return true; } /** 清理长期未处理的记录(用户手动触发,绝不自动删) */ function pruneStale(days) { const limit = now() - (num(days) || CFG.STALE_DAYS) * 86400000; let n = 0; ['interop', 'extra'].forEach((b) => { const cur = readBucket(b).items; const keep = cur.filter((it) => (num(it.addedAt) || num(it.removedAt)) > limit); if (keep.length !== cur.length) { n += cur.length - keep.length; writeBucket(b, keep); } }); if (n) emitChange(); return n; } // ---- 偏好(存独享桶,SGLV 私有) ---- function getPrefs() { const raw = readRaw(CFG.EXTRA_KEY) || {}; const p = raw.prefs && typeof raw.prefs === 'object' ? raw.prefs : {}; return { filter: p.filter || 'all', sort: p.sort || 'end', compatOnly: !!p.compatOnly, }; } function setPrefs(patch) { const raw = readRaw(CFG.EXTRA_KEY) || {}; const next = Object.assign({}, raw.prefs || {}, patch || {}); const payload = Object.assign({}, raw); payload.prefs = next; if (!payload.version) payload.version = 1; lsSet(CFG.EXTRA_KEY, JSON.stringify(payload)); } // ---- 展示派生 ---- function displayName(rec) { if (!rec) return ''; if (rec.displayName) return rec.displayName; if (rec.targetName) return rec.targetName; if (rec.kind === 'note') return rec.url || zh('(未命名备忘)', '(untitled note)'); const n = rec.names && Object.keys(rec.names)[0]; if (n) return rec.names[n]; if (rec.appid) return 'App ' + rec.appid; if (rec.bundleid) return 'Bundle ' + rec.bundleid; if (rec.packageid) return 'Package ' + rec.packageid; return zh('(未命名)', '(untitled)'); } function linkOf(rec) { if (!rec) return ''; if (rec.kind === 'bundle' && rec.bundleid) return 'https://store.steampowered.com/bundle/' + rec.bundleid; if (rec.kind === 'package' && rec.packageid) return 'https://store.steampowered.com/sub/' + rec.packageid; if (rec.appid) return 'https://store.steampowered.com/app/' + rec.appid; if (rec.url) return rec.url; return ''; } /** 该记录是否属于其他账号(写入时账号 ≠ 当前账号) */ function foreignAccount(rec) { const mine = currentSteamId(); if (!mine || !rec || !rec.steamid) return false; return String(rec.steamid) !== mine; } function canRestore(rec) { return !!rec && CFG.RESTORABLE.indexOf(rec.kind) !== -1; } // ==================== 变更广播 ==================== function emitChange() { const n = countAll(); try { setBadge(n); } catch (e) { /* ignore */ } // 浮窗开着就同步重绘:删除/清空/导入/恢复都走 emitChange, // 否则弹窗确认后列表会停留在旧快照(UI 与数据不一致) if (isOpenFlag) { try { renderList(); } catch (e) { console.warn('[SGLVShelf] 重绘失败', e); } } _changeCbs.forEach((cb) => { try { cb(n); } catch (e) { console.warn('[SGLVShelf] onChange 回调异常', e); } }); } function onChange(cb) { if (typeof cb === 'function') _changeCbs.push(cb); } // ==================== Steam 接口层 ==================== function appConfigEl() { return document.querySelector('#application_config'); } function readJsonAttr(el, name) { if (!el) return null; try { return JSON.parse(el.getAttribute(name) || 'null'); } catch (e) { return null; } } function getAccessToken() { const uc = readJsonAttr(appConfigEl(), 'data-store_user_config'); return (uc && uc.webapi_token) || null; } function isLoggedIn() { const ui = readJsonAttr(appConfigEl(), 'data-userinfo'); return !!(ui && ui.logged_in); } function getContext() { const cfg = readJsonAttr(appConfigEl(), 'data-config') || {}; const lang = cfg.LANGUAGE || (window.g_Languages && window.g_Languages[0]) || 'schinese'; let cc = cfg.COUNTRY; if (!cc) { try { cc = new URLSearchParams(location.search).get('cc'); } catch (e) { /* ignore */ } } return { language: String(lang).toLowerCase(), country_code: String(cc || 'CN').toUpperCase(), steam_realm: 1 }; } function gmJson(url) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { fetch(url, { credentials: 'include' }).then((r) => r.json()).then(resolve).catch(reject); return; } GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 15000, onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('JSON 解析失败')); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } function gmFormPost(url, body) { return new Promise((resolve, reject) => { const headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }; if (typeof GM_xmlhttpRequest !== 'function') { fetch(url, { method: 'POST', credentials: 'include', headers: headers, body: body }) .then((r) => r.json()).then(resolve).catch(reject); return; } GM_xmlhttpRequest({ method: 'POST', url: url, headers: headers, data: body, timeout: 20000, onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('JSON 解析失败')); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } async function fetchLineItems(token) { const data = await gmJson(CFG.CART_API + '?access_token=' + encodeURIComponent(token)); const cart = data && data.response && data.response.cart; return ((cart && cart.line_items) || []).map((it) => ({ lineItemId: it.line_item_id != null ? it.line_item_id : null, type: it.type != null ? it.type : null, packageid: it.packageid ? Number(it.packageid) : null, bundleid: it.bundleid ? Number(it.bundleid) : null, })); } async function fetchStoreItems(ids) { if (!ids || !ids.length) return []; const payload = { ids: ids, context: getContext(), data_request: { include_basic_info: true, include_all_purchase_options: true }, }; const data = await gmJson(CFG.ITEMS_API + '?input_json=' + encodeURIComponent(JSON.stringify(payload))); return (data && data.response && data.response.store_items) || []; } function kindQuery(t) { return t.kind === 'bundle' ? { bundleid: t.id } : { packageid: t.id }; } function cartNavData(cc) { return { domain: 'store.steampowered.com', controller: 'default', method: 'default', submethod: '', feature: 'store_home', depth: 1, countrycode: cc, webkey: 0, is_client: false, curator_data: { clanid: null, listid: null }, is_likely_bot: false, is_utm: false, }; } function describeCartError(data) { const r = data && data.response; if (!r) return zh('接口没有返回内容,可能是网络或登录态问题', 'No response from API'); const e = r.error; if (e) { const bits = []; if (e.message) bits.push(String(e.message)); if (e.error_description && e.error_description !== e.message) bits.push(String(e.error_description)); if (e.eresult != null) bits.push('eresult ' + e.eresult); if (!bits.length) bits.push(JSON.stringify(e).slice(0, 200)); return bits.join(' / '); } return zh('Steam 没有把它加入购物车', 'Steam refused to add it'); } async function addOneToCart(token, cc, cand) { const item = { gift_info: null, flags: { is_gift: false, is_private: false } }; if (cand.kind === 'bundle') item.bundleid = cand.id; else item.packageid = cand.id; const payload = { user_country: cc, items: [item], navdata: cartNavData(cc) }; const data = await gmFormPost( CFG.CART_ADD_API + '?access_token=' + encodeURIComponent(token), 'input_json=' + encodeURIComponent(JSON.stringify(payload)) ); if (data && data.response && data.response.cart) return { ok: true, data: data }; return { ok: false, data: data, err: describeCartError(data) }; } function verifyRestored(data, cand) { const resp = (data && data.response) || {}; const lis = (resp.cart && resp.cart.line_items) || []; const ids = (resp.line_item_ids || []).map(String); const mine = ids.length ? lis.filter((li) => ids.indexOf(String(li.line_item_id)) !== -1) : []; const field = cand.kind === 'bundle' ? 'bundleid' : 'packageid'; if (mine.length > 1) return zh('Steam 一次加回了 ' + mine.length + ' 条(预期 1 条)', 'Steam added ' + mine.length + ' lines'); const pool = mine.length ? mine : lis; if (pool.some((li) => String(li[field] || '') === String(cand.id))) return ''; return zh('加回的购买项对不上(Steam 返回了别的项)', 'Restored item mismatch'); } async function bestOptionOfApp(appid) { try { const items = await fetchStoreItems([{ appid: Number(appid) }]); const si = items[0]; const opt = (si && si.best_purchase_option) || null; if (!opt) return null; if (opt.bundleid) return { kind: 'bundle', id: Number(opt.bundleid), name: si.name || '' }; if (opt.packageid) return { kind: 'package', id: Number(opt.packageid), name: si.name || '' }; } catch (e) { debug('bestOptionOfApp 失败', appid, e); } return null; } /** 旧记录(无购买项 id)按内容重新识别购买项 */ async function resolveTargetByContent(appids) { const idsWant = (appids || []).map(Number).filter((n) => n > 0); if (!idsWant.length) return null; try { const items = await fetchStoreItems(idsWant.map((a) => ({ appid: a }))); const cand = []; const seen = new Set(); const push = (kind, id) => { if (!kind || !id) return; const k = kind + ':' + id; if (seen.has(k)) return; seen.add(k); cand.push({ kind: kind, id: Number(id) }); }; items.forEach((si) => { (si.purchase_options || []).forEach((o) => { if (o.bundleid) push('bundle', o.bundleid); else if (o.packageid) push('package', o.packageid); }); const b = si.best_purchase_option; if (b) { if (b.bundleid) push('bundle', b.bundleid); else if (b.packageid) push('package', b.packageid); } }); if (!cand.length) return null; const detail = await fetchStoreItems(cand.map(kindQuery)); const byId = new Map(); detail.forEach((si) => { if (si && si.id != null) byId.set(String(si.id), si); }); let best = null; cand.forEach((c) => { const si = byId.get(String(c.id)); if (!si || !Array.isArray(si.included_appids)) return; const inc = si.included_appids.map(String); if (!idsWant.every((a) => inc.indexOf(String(a)) !== -1)) return; if (!best || inc.length < best.incLength) best = { kind: c.kind, id: c.id, name: si.name || '', incLength: inc.length, how: zh('按商品内容重新识别', 'resolved by content') }; }); return best; } catch (e) { debug('按内容解析购买项失败', e); return null; } } async function resolveRestorePlan(rec) { if (rec.legacy && rec.appids && rec.appids.length) { const byContent = await resolveTargetByContent(rec.appids); if (byContent) return [byContent]; } if (rec.kind === 'bundle' && rec.bundleid) return [{ kind: 'bundle', id: Number(rec.bundleid) }]; if (rec.kind === 'package' && rec.packageid) return [{ kind: 'package', id: Number(rec.packageid) }]; const appid = num(rec.appid || ((rec.appids && rec.appids.length === 1) ? rec.appids[0] : 0)); if (appid > 0 && rec.kind === 'app') { const one = await bestOptionOfApp(appid); if (one) return [one]; } return []; } function candidateLabel(c) { return c.kind === 'bundle' ? 'bundle ' + c.id : 'package ' + c.id; } // ==================== 恢复(加购) ==================== let busy = false; let busyText = ''; function isBusy() { return busy; } async function executeRestore(ids) { if (busy) { toast(zh('暂存箱正忙,稍后再试', 'Shelf is busy, try later'), 'warning'); return; } const targets = allItems().filter((it) => ids.indexOf(it.id) !== -1 && canRestore(it)); if (!targets.length) { toast(zh('选中的记录里没有可恢复到购物车的项', 'Nothing restorable selected'), 'warning'); return; } // 账号归属优先于登录态:「来自其他账号」比「未登录」更具体, // 若先判登录态会把它掩盖成一句误导性的登录提示 const blocked = targets.filter(foreignAccount); if (blocked.length === targets.length) { toast(zh('这些记录来自其他 Steam 账号,已阻止恢复', 'Records belong to another account — restore blocked'), 'error'); return; } if (!isLoggedIn() && !getAccessToken()) { toast(zh('未登录 Steam,请先登录后再恢复', 'Not logged in — log in first'), 'warning'); return; } const token = getAccessToken(); const cc = getContext().country_code; const added = []; const failed = []; const warns = []; busy = true; setBusy(zh('准备恢复 ' + targets.length + ' 项…', 'Preparing ' + targets.length + ' items…')); try { for (let i = 0; i < targets.length; i++) { const rec = targets[i]; if (foreignAccount(rec)) { failed.push({ name: displayName(rec), why: zh('来自其他 Steam 账号', 'from another account') }); continue; } setBusy(zh('正在恢复 ' + (i + 1) + '/' + targets.length + ':', 'Restoring ') + displayName(rec)); const plan = await resolveRestorePlan(rec); if (!plan.length) { rec.lastError = zh('记录里没有可用的购买项', 'no purchasable option'); putItem(rec, { silent: true }); failed.push({ name: displayName(rec), why: rec.lastError }); continue; } const errs = []; let ok = false; for (const cand of plan) { try { const r = await addOneToCart(token, cc, cand); if (r.ok) { const bad = verifyRestored(r.data, cand); rec.kind = cand.kind; rec.bundleid = cand.kind === 'bundle' ? cand.id : null; rec.packageid = cand.kind === 'package' ? cand.id : null; rec.appid = null; if (cand.name) rec.targetName = cand.name; rec.displayName = rec.targetName || displayName(rec); rec.legacy = false; rec.lastError = null; rec.restoredAt = now(); putItem(rec, { silent: true }); if (bad) warns.push({ name: displayName(rec), why: bad }); added.push(rec.id); ok = true; break; } errs.push(candidateLabel(cand) + ' → ' + r.err); } catch (e) { errs.push(candidateLabel(cand) + ' → ' + ((e && e.message) || zh('请求失败', 'request failed'))); } await sleep(CFG.CANDIDATE_STEP_MS); } if (!ok) { rec.lastError = errs.join(zh(';', '; ')) || zh('未知原因', 'unknown'); putItem(rec, { silent: true }); failed.push({ name: displayName(rec), why: rec.lastError }); } await sleep(CFG.RESTORE_STEP_MS); } } finally { busy = false; setBusy(''); emitChange(); } const parts = []; if (added.length) parts.push(zh('已恢复 ', 'Restored ') + added.length + ' ' + zh('项到购物车', 'items to cart')); if (failed.length) parts.push(zh('失败 ', 'Failed ') + failed.length + ' ' + zh('项', 'items')); if (warns.length) parts.push(zh('有 ', '') + warns.length + ' ' + zh('项 Steam 返回与预期不符', 'items returned unexpected cart content')); toast(parts.join(' · ') || zh('没有可恢复的项目', 'Nothing restored'), failed.length ? 'warning' : 'success'); if (failed.length || warns.length) { const rows = failed.concat(warns).map((f) => '
' + esc(f.name) + '' + esc(f.why) + '
').join(''); showModal({ title: zh('恢复结果', 'Restore result'), body: '
' + (parts.join(' · ') || '') + '
' + rows, buttons: [{ label: zh('知道了', 'OK'), primary: true }, { label: zh('刷新购物车', 'Refresh cart'), act: () => { location.href = 'https://store.steampowered.com/cart/'; } }], }); } return { added: added.length, failed: failed.length, warns: warns.length }; } // ==================== 购物车页:行内暂存 ==================== const CART_REMOVE_RE = /(remove|移除|删除|entfernen|supprimer|eliminar|rimuovi)/i; function cartRoot() { return document.querySelector('[data-featuretarget="react-root"]') || document.querySelector('.responsive_page_template_content') || document.body; } function collectAnchors(root) { const out = { apps: [], bundles: [], subs: [], all: [] }; const seen = new Set(); (root.querySelectorAll('a[href*="/app/"], a[href*="/bundle/"], a[href*="/sub/"]') || []).forEach((a) => { const href = a.getAttribute('href') || ''; const ma = href.match(/\/app\/(\d+)/); const mb = href.match(/\/bundle\/(\d+)/); const ms = href.match(/\/sub\/(\d+)/); if (ma && out.apps.indexOf(Number(ma[1])) === -1) out.apps.push(Number(ma[1])); if (mb && out.bundles.indexOf(Number(mb[1])) === -1) out.bundles.push(Number(mb[1])); if (ms && out.subs.indexOf(Number(ms[1])) === -1) out.subs.push(Number(ms[1])); const key = href; if (!seen.has(key)) { seen.add(key); out.all.push({ href: href, el: a }); } }); return out; } function nearestRowWith(el, root) { let n = el; for (let d = 0; n && d < 14; d++) { const anchors = collectAnchors(n); if (anchors.apps.length || anchors.bundles.length || anchors.subs.length) { if (n !== root && n.querySelector && n.querySelector('button, a')) return n; } n = n.parentElement; } return null; } function removeActionEl(rowEl) { let found = null; (rowEl.querySelectorAll('button, a') || []).forEach((el) => { if (found) return; const t = ((el.textContent || '') + ' ' + (el.getAttribute('aria-label') || '') + ' ' + (el.title || '')).trim(); if (t && CART_REMOVE_RE.test(t)) found = el; }); return found; } function rowKey(row) { return [row.appids.join('.'), row.bundleIds.join('.'), row.subIds.join('.')].join('|'); } function scanCartRows() { const root = cartRoot(); if (!root) return []; const rows = []; const seen = new Set(); collectAnchors(root).all.forEach((a) => { const rowEl = nearestRowWith(a.el, root); if (!rowEl) return; const key = rowEl.__sgshRowKey || (rowEl.__sgshRowKey = uid('row')); if (seen.has(key)) return; const an = collectAnchors(rowEl); if (!an.apps.length && !an.bundles.length && !an.subs.length) return; seen.add(key); rows.push({ rowEl: rowEl, key: key, appids: an.apps, bundleIds: an.bundles, subIds: an.subs, removeBtn: removeActionEl(rowEl), }); }); return rows; } /** 行内注入「暂存」按钮;检测到 SCE 已注入 .sce-shelve-btn 时跳过,避免双按钮 */ function injectCartButtons() { if (!onCartPage()) return 0; let n = 0; scanCartRows().forEach((row) => { if (row.rowEl.querySelector('.sgsh-shelve-btn') || row.rowEl.querySelector('.sce-shelve-btn')) return; const btn = document.createElement('button'); btn.className = 'sgsh-shelve-btn'; btn.type = 'button'; btn.textContent = zh('暂存', 'Stash'); btn.title = zh('加入暂存箱(会从购物车移除)', 'Move to shelf (removes from cart)'); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); shelveOneRow(row); }); const act = removeActionEl(row.rowEl); if (act && act.parentElement) act.parentElement.insertBefore(btn, act); else row.rowEl.appendChild(btn); n++; }); return n; } function onCartPage() { try { return /^\/cart\/?(\?|$)/i.test(location.pathname); } catch (e) { return false; } } async function buildCartIndex() { const token = getAccessToken(); if (!token) return { entries: [], ok: false, error: zh('未登录 Steam,读不到登录令牌', 'Not logged in: no access token') }; let lineItems; try { lineItems = await fetchLineItems(token); } catch (e) { return { entries: [], ok: false, error: zh('读取购物车接口失败:', 'GetCart failed: ') + ((e && e.message) || e) }; } const targets = lineItems.map((li) => ( li.bundleid ? { kind: 'bundle', id: Number(li.bundleid) } : li.packageid ? { kind: 'package', id: Number(li.packageid) } : null )).filter(Boolean); let storeItems = []; if (targets.length) { try { storeItems = await fetchStoreItems(targets.map(kindQuery)); } catch (e) { debug('读取购买项内容失败', e); } } const byKey = new Map(); storeItems.forEach((si) => { if (!si || si.id == null) return; const t = targets.find((x) => String(x.id) === String(si.id)); const kind = (t && t.kind) || (si.item_type === 2 ? 'bundle' : 'package'); byKey.set(kind + ':' + si.id, si); }); return { ok: true, error: '', entries: lineItems.map((li, i) => { const t = targets[i]; if (!t) return null; const si = byKey.get(t.kind + ':' + t.id) || null; return { lineItemId: li.lineItemId, kind: t.kind, id: t.id, name: (si && si.name) || '', included: (si && Array.isArray(si.included_appids)) ? si.included_appids.map(String) : [], }; }).filter(Boolean), }; } function classifyRow(row, index) { const ents = ((index && index.entries) || []).filter((e) => e && e.kind); if (!ents.length) return null; const apps = (row.appids || []).map(String); if (apps.length) { const cover = ents.filter((e) => e.included.length && apps.every((a) => e.included.indexOf(a) !== -1)); if (cover.length) { const exact = cover.filter((e) => e.included.length === apps.length); const pool = exact.length ? exact : cover; pool.sort((a, b) => a.included.length - b.included.length); return { kind: pool[0].kind, id: pool[0].id, name: pool[0].name, lineItemId: pool[0].lineItemId, how: exact.length ? zh('内容与购物车里那一项一致', 'content matches cart line') : zh('内容都属于购物车里那一项', 'content belongs to cart line'), }; } } const links = []; (row.bundleIds || []).forEach((id) => links.push({ kind: 'bundle', id: num(id), how: '/bundle/' })); (row.subIds || []).forEach((id) => links.push({ kind: 'package', id: num(id), how: '/sub/' })); for (const l of links) { const hit = ents.find((e) => e.kind === l.kind && String(e.id) === String(l.id)); if (hit) return { kind: hit.kind, id: hit.id, name: hit.name, lineItemId: hit.lineItemId, how: l.how }; } return null; } function buildRecord(row, target) { const rec = normalizeRecord({ id: uid('sh'), kind: target.kind, bundleid: target.kind === 'bundle' ? target.id : null, packageid: target.kind === 'package' ? target.id : null, appid: null, targetName: target.name || '', targetHow: target.how || '', lineItemId: target.lineItemId != null ? target.lineItemId : null, appids: row.appids.slice(), names: {}, endTsMap: {}, endTs: 0, pct: 0, count: row.appids.length, removedAt: now(), addedAt: now(), source: 'cart', steamid: currentSteamId(), }, 'interop'); return rec; } /** 单行暂存(无确认弹窗,与 SCE 行内行为一致) */ async function shelveOneRow(row) { if (busy) { toast(zh('暂存箱正忙,稍后再试', 'Shelf is busy, try later'), 'warning'); return; } const index = await buildCartIndex(); if (!index.ok) { toast(index.error, 'error'); return; } let target = classifyRow(row, index); if (!target && row.appids.length === 1) { target = { kind: 'app', id: num(row.appids[0]), name: '', lineItemId: null, how: zh('单个商品,恢复时按商店当前购买项', 'single app; resolved at restore time') }; } if (!target) { showModal({ title: zh('无法暂存这一项', 'Cannot stash this line'), body: '
' + zh('这一行认不出对应的购买项(bundle/sub)。为避免暂存错东西,已中止。', 'Cannot resolve the purchase option for this line; aborted to avoid a wrong record.') + '
', buttons: [{ label: zh('知道了', 'OK'), primary: true }] }); return; } const rec = buildRecord(row, target); const ok = doShelveRows([{ row: row, rec: rec }]); if (ok) toast(zh('已暂存:', 'Stashed: ') + displayName(rec), 'success'); } /** 批量暂存(确认弹窗 → 执行) */ function shelveAllRows() { if (busy) { toast(zh('暂存箱正忙,稍后再试', 'Shelf is busy, try later'), 'warning'); return; } const rows = scanCartRows(); if (!rows.length) { toast(zh('购物车里没有可暂存的行', 'No cart rows found'), 'warning'); return; } showModal({ title: zh('暂存购物车', 'Stash whole cart'), body: '
' + zh('将把购物车里的 ', 'Moves ') + rows.length + ' ' + zh('项移出购物车并存入暂存箱:', 'lines out of the cart into the shelf:') + '
' + '
    ' + rows.map((r) => '
  • ' + esc((r.appids[0] ? 'App ' + r.appids[0] : (r.bundleIds[0] ? 'Bundle ' + r.bundleIds[0] : 'Package ' + (r.subIds[0] || '?')))) + '
  • ').join('') + '
' + '
' + zh('整包内只要有一项认不出购买项,该行会被跳过。', 'Lines whose purchase option cannot be resolved will be skipped.') + '
', buttons: [ { label: zh('取消', 'Cancel') }, { label: zh('确认暂存 ' + rows.length + ' 项', 'Stash ' + rows.length), primary: true, act: async () => { await doShelveRows(rows.map((r) => ({ row: r, rec: null }))); } }, ], }); } async function doShelveRows(items) { if (busy) return false; busy = true; let done = 0; let fail = 0; try { const index = await buildCartIndex(); if (!index.ok) { toast(index.error, 'error'); return false; } for (let i = 0; i < items.length; i++) { const it = items[i]; let rec = it.rec; if (!rec) { const t = classifyRow(it.row, index) || (it.row.appids.length === 1 ? { kind: 'app', id: num(it.row.appids[0]), name: '', lineItemId: null, how: zh('单个商品', 'single app') } : null); if (!t) { fail++; continue; } rec = buildRecord(it.row, t); } setBusy(zh('正在暂存 ' + (done + fail + 1) + '/' + items.length + ':', 'Stashing ') + displayName(rec)); let btn = it.row.removeBtn; if (!btn || !btn.isConnected) btn = removeActionEl(it.row.rowEl); if (!btn || !btn.isConnected) { const again = scanCartRows().find((r) => rowKey(r) === rowKey(it.row)); btn = again && (again.removeBtn || removeActionEl(again.rowEl)); } if (!btn || !btn.isConnected) { fail++; continue; } try { btn.click(); } catch (e) { fail++; continue; } putItem(rec, { silent: true }); done++; await sleep(CFG.SHELVE_STEP_MS); } } finally { busy = false; setBusy(''); emitChange(); } if (done) toast(zh('已暂存 ', 'Stashed ') + done + ' ' + zh('项', 'items'), 'success'); if (fail) toast(zh('有 ', '') + fail + ' ' + zh('项未暂存(找不到移除按钮或认不出购买项)', 'items not stashed (no remove button / unresolvable)'), 'warning'); if (done) setTimeout(() => { try { location.reload(); } catch (e) { /* ignore */ } }, 700); return done > 0; } // ==================== 公开:新增记录 ==================== async function resolveName(appid) { if (Host && typeof Host.resolveName === 'function') { try { const n = await Host.resolveName(appid); if (n) return n; } catch (e) { /* ignore */ } } try { const items = await fetchStoreItems([{ appid: num(appid) }]); if (items[0] && items[0].name) return items[0].name; } catch (e) { /* ignore */ } return ''; } /** 统一入口:{kind, appid/bundleid/packageid, url, title, note, tags, endTs, pct} */ async function addItem(input) { const inp = Object.assign({}, input || {}); let kind = inp.kind; if (!kind) { if (inp.url) kind = 'note'; else if (inp.bundleid) kind = 'bundle'; else if (inp.packageid) kind = 'package'; else kind = inp.appid ? 'app' : 'note'; } const rec = normalizeRecord({ id: inp.id || uid('sh'), kind: kind, appid: inp.appid != null ? num(inp.appid) : null, bundleid: inp.bundleid != null ? num(inp.bundleid) : null, packageid: inp.packageid != null ? num(inp.packageid) : null, appids: Array.isArray(inp.appids) ? inp.appids : (inp.appid ? [num(inp.appid)] : []), names: inp.names || {}, targetName: inp.title || inp.name || '', displayName: inp.title || inp.name || '', note: inp.note || '', url: inp.url || '', tags: inp.tags || [], endTs: num(inp.endTs), pct: num(inp.pct), price: num(inp.price), currency: inp.currency || '', source: inp.source || 'manual', steamid: currentSteamId(), addedAt: now(), removedAt: now(), }, bucketOf(kind)); if (isInteropKind(kind) && !rec.appid && !rec.bundleid && !rec.packageid) { toast(zh('缺少商品 ID,未保存', 'Missing product id'), 'warning'); return null; } // 名称补全(app/wishlist 且未命名) if (!rec.displayName && (kind === 'app' || kind === 'wishlist') && rec.appid) { rec.displayName = await resolveName(rec.appid); if (rec.displayName) rec.targetName = rec.displayName; } const saved = putItem(rec); if (saved) toast(zh('已加入暂存箱:', 'Added to shelf: ') + displayName(saved), 'success'); return saved; } function addWishlist(appid, name, note) { return addItem({ kind: 'wishlist', appid: appid, title: name, note: note, source: 'wishlist' }); } function addNote(url, title, note, tags) { return addItem({ kind: 'note', url: url, title: title, note: note, tags: tags, source: 'manual' }); } // ==================== 导入 / 导出 ==================== function buildExport(opts) { const o = opts || {}; let items = allItems(); if (o.compatOnly) items = items.filter((r) => isInteropKind(r.kind)); const interopRaw = readRaw(CFG.INTEROP_KEY) || {}; return { format: 'sglv-shelf', version: 2, exportedAt: now(), source: 'SGLV 暂存箱', compatOnly: !!o.compatOnly, counts: { cart: items.filter((r) => isInteropKind(r.kind)).length, wishlist: items.filter((r) => r.kind === 'wishlist').length, note: items.filter((r) => r.kind === 'note').length, }, days: typeof interopRaw.days === 'number' ? interopRaw.days : 3, items: items, }; } /** * 导入:兼容三种形态 * a) 本库导出体 {format:'sglv-shelf', items:[...]} * b) SCE 原样体 {items:[...], days, tab} * c) 裸数组 [...] * 返回 {added, updated, skipped} */ function parseImport(text) { let data = null; try { data = JSON.parse(String(text || '').trim()); } catch (e) { return { ok: false, error: zh('JSON 解析失败:', 'JSON parse failed: ') + (e.message || e) }; } let list = null; if (Array.isArray(data)) list = data; else if (data && Array.isArray(data.items)) list = data.items; if (!list) return { ok: false, error: zh('没有找到 items 数组', 'No items array found') }; let added = 0; let updated = 0; let skipped = 0; const interopNew = []; const extraNew = []; list.forEach((raw) => { if (!raw || typeof raw !== 'object') { skipped++; return; } const kind = resolveKind(raw); const bucket = bucketOf(kind); // 互通桶必须自带 id:SCE 读取时会丢弃无 id 的记录, // 放进去等于「导入成功但下次 SCE 保存时静默消失」 if (bucket === 'interop' && !raw.id) { skipped++; return; } const rec = normalizeRecord(raw, bucket); if (bucket === 'interop' && !sceAcceptable(raw)) { skipped++; return; } if (bucket === 'interop') interopNew.push(rec); else extraNew.push(rec); }); if (interopNew.length) { const cur = readBucket('interop').items; interopNew.forEach((rec) => { const idx = cur.findIndex((x) => sameTarget(x, rec)); if (idx >= 0) { cur[idx] = Object.assign({}, cur[idx], rec, { id: cur[idx].id }); updated++; } else { cur.unshift(rec); added++; } }); writeBucket('interop', cur); } if (extraNew.length) { const cur = readBucket('extra').items; extraNew.forEach((rec) => { const idx = cur.findIndex((x) => sameTarget(x, rec)); if (idx >= 0) { cur[idx] = Object.assign({}, cur[idx], rec, { id: cur[idx].id }); updated++; } else { cur.unshift(rec); added++; } }); writeBucket('extra', cur); } emitChange(); return { ok: true, added: added, updated: updated, skipped: skipped }; } /** 去重比较:同 kind 同 id 视为同一条(id 不同也不重复入库) */ function sameTarget(a, b) { if (!a || !b) return false; if (a.id && b.id && a.id === b.id) return true; if (a.kind !== b.kind) return false; if (a.kind === 'bundle') return !!a.bundleid && String(a.bundleid) === String(b.bundleid); if (a.kind === 'package') return !!a.packageid && String(a.packageid) === String(b.packageid); if (a.kind === 'note') return !!a.url && a.url === b.url; return !!a.appid && String(a.appid) === String(b.appid); } // ==================== UI:浮窗 ==================== let overlayEl = null; let panelEl = null; let modalEl = null; let modalOnClose = null; let tickTimer = null; let isOpenFlag = false; let uiState = { filter: 'all', sort: 'end', q: '', compatOnly: false }; /* ================================================================= * CSS —— steam-industrial token 体系 * 参考 steam-industrial-theme.css(oil-ui 方法论): * 高信息密度 · 小圆角(工业风) · 左侧色条强调 · 斑马纹列表 · 大写小标签 * 表面/文字用 Steam 原生 #171a21 色阶(dark/purple 主题下均稳定); * 强调色走 var(--sglv-accent),跟随主脚本主题切换(color-mix 派生阶)。 * ============================================================== */ const CSS = ` /* ---------- 0. Design Tokens(作用域:浮窗 / 弹窗 / 购物车行内按钮) ---------- */ .sgsh-panel,.sgsh-modal,.sgsh-shelve-btn{ /* 表面 */ --sgsh-bg:#171a21; --sgsh-bg-2:#1b2838; --sgsh-bg-3:#2a3040; --sgsh-bg-4:#333c4e; --sgsh-border:#3d4450; --sgsh-border-2:#2a3040; /* 文字 */ --sgsh-text:#c6d4df; --sgsh-text-2:#8f98a0; --sgsh-text-3:#5c6b7a; --sgsh-heading:#e5e9ed; /* 强调(跟随主题) */ --sgsh-accent:var(--sglv-accent,#66c0f4); --sgsh-accent-wash:color-mix(in srgb,var(--sgsh-accent) 8%,transparent); --sgsh-accent-soft:color-mix(in srgb,var(--sgsh-accent) 16%,transparent); --sgsh-accent-line:color-mix(in srgb,var(--sgsh-accent) 42%,transparent); /* 语义色 */ --sgsh-gold:#ffb74c; --sgsh-gold-text:#d4a853; --sgsh-red:#d94a4a; --sgsh-red-text:#e07070; --sgsh-red-wash:color-mix(in srgb,var(--sgsh-red) 14%,transparent); --sgsh-red-line:color-mix(in srgb,var(--sgsh-red) 42%,transparent); --sgsh-green:#7ba82a; --sgsh-sky:#7dd3fc; /* 刻度 */ --sgsh-r:3px; --sgsh-ease:150ms ease; } /* ---------- 1. 窗体 ---------- */ .sgsh-overlay { background: rgba(8,11,17,0.72) !important; } .sgsh-panel { width: 920px !important; height: 82vh !important; background: var(--sgsh-bg) !important; border: 1px solid var(--sgsh-border) !important; border-radius: 4px !important; box-shadow: 0 18px 60px rgba(0,0,0,.55) !important; } .sgsh-panel .sglv-header { background: var(--sgsh-bg-2) !important; border-bottom: 1px solid var(--sgsh-border); } /* ---------- 2. 头部 ---------- */ .sgsh-head { display:flex; align-items:center; gap:10px; } .sgsh-head-icon { width:24px; height:24px; display:flex; align-items:center; justify-content:center; background: var(--sgsh-accent-wash); border:1px solid var(--sgsh-accent-line); border-radius: var(--sgsh-r); color: var(--sgsh-accent); font-size:13px; } .sgsh-head-title { font-size:14px; font-weight:600; color:var(--sgsh-heading); letter-spacing:.3px; } .sgsh-count { min-width:22px; height:18px; padding:0 7px; border-radius:2px; font-size:11px; font-weight:700; display:inline-flex; align-items:center; justify-content:center; color:var(--sgsh-gold-text); background:rgba(255,183,76,.12); border:1px solid rgba(255,183,76,.35); font-variant-numeric:tabular-nums; } /* ---------- 3. 按钮(扁平 + 描边) ---------- */ .sgsh-btn { border:1px solid var(--sgsh-border-2); border-radius:var(--sgsh-r); padding:5px 11px; font-size:11px; font-weight:600; letter-spacing:.3px; cursor:pointer; background:var(--sgsh-bg-3); color:var(--sgsh-text); display:inline-flex; align-items:center; gap:5px; transition:color var(--sgsh-ease), background var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-btn:hover { background:var(--sgsh-bg-4); color:var(--sgsh-heading); border-color:var(--sgsh-border); } .sgsh-btn.primary { background:var(--sgsh-accent-soft); border-color:var(--sgsh-accent-line); color:var(--sgsh-accent); } .sgsh-btn.primary:hover { background:color-mix(in srgb,var(--sgsh-accent) 26%,transparent); border-color:var(--sgsh-accent); } .sgsh-btn.danger { background:transparent; border-color:var(--sgsh-red-line); color:var(--sgsh-red-text); } .sgsh-btn.danger:hover { background:var(--sgsh-red-wash); color:#fff; } .sgsh-btn:disabled { opacity:.4; cursor:not-allowed; } /* ---------- 4. 工具栏(搜索 / 筛选 / 排序) ---------- */ .sgsh-toolbar { display:flex; align-items:center; gap:10px; padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--sgsh-border-2); background:var(--sgsh-bg); flex-wrap:wrap; } .sgsh-search { flex:1; min-width:160px; padding:6px 10px; border-radius:var(--sgsh-r); font-size:12px; background:var(--sgsh-bg-2); border:1px solid var(--sgsh-border-2); color:var(--sgsh-text); outline:none; transition:border-color var(--sgsh-ease), box-shadow var(--sgsh-ease); } .sgsh-search::placeholder { color:var(--sgsh-text-3); } .sgsh-search:focus { border-color:var(--sgsh-accent-line); box-shadow:0 0 0 1px var(--sgsh-accent-wash); } .sgsh-chips { display:flex; gap:4px; } .sgsh-chip { border:1px solid var(--sgsh-border-2); background:transparent; color:var(--sgsh-text-2); border-radius:var(--sgsh-r); padding:4px 11px; font-size:11px; font-weight:600; cursor:pointer; transition:color var(--sgsh-ease), background var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-chip:hover { color:var(--sgsh-text); border-color:var(--sgsh-border); } .sgsh-chip.active { background:var(--sgsh-accent-soft); color:var(--sgsh-accent); border-color:var(--sgsh-accent-line); } .sgsh-sort { padding:6px 9px; border-radius:var(--sgsh-r); font-size:11px; background:var(--sgsh-bg-2); border:1px solid var(--sgsh-border-2); color:var(--sgsh-text); outline:none; cursor:pointer; transition:border-color var(--sgsh-ease); } .sgsh-sort:focus { border-color:var(--sgsh-accent-line); } /* ---------- 5. 摘要条 ---------- */ .sgsh-summary { padding:7px 14px; font-size:11px; color:var(--sgsh-text-2); border-bottom:1px solid var(--sgsh-border-2); display:flex; align-items:center; gap:12px; flex-shrink:0; font-variant-numeric:tabular-nums; } .sgsh-summary b { color:var(--sgsh-accent); } .sgsh-busy { color:var(--sgsh-gold-text); font-weight:600; } /* ---------- 6. 列表(斑马纹 + 左侧色条强调,替代卡片流) ---------- */ .sgsh-body { flex:1; overflow-y:auto; padding:8px 14px 12px; } .sgsh-body::-webkit-scrollbar { width:6px; } .sgsh-body::-webkit-scrollbar-track { background:transparent; } .sgsh-body::-webkit-scrollbar-thumb { background:var(--sgsh-border-2); border-radius:3px; } .sgsh-body::-webkit-scrollbar-thumb:hover { background:var(--sgsh-border); } .sgsh-row { display:flex; align-items:flex-start; gap:10px; padding:9px 12px 9px 9px; border-left:3px solid transparent; border-bottom:1px solid var(--sgsh-border-2); transition:background var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-row:first-child { border-top:1px solid var(--sgsh-border-2); } .sgsh-row:nth-child(odd) { background:rgba(255,255,255,.02); } .sgsh-row:hover { background:var(--sgsh-accent-wash); border-left-color:var(--sgsh-accent); } .sgsh-row.is-foreign { border-left-color:var(--sgsh-red); } .sgsh-kindtag { flex-shrink:0; font-size:9px; font-weight:700; padding:2px 6px; border-radius:2px; letter-spacing:.8px; text-transform:uppercase; background:var(--sgsh-accent-wash); color:var(--sgsh-accent); border:1px solid var(--sgsh-accent-line); margin-top:1px; } .sgsh-kindtag.wishlist { background:rgba(125,211,252,.1); color:var(--sgsh-sky); border-color:rgba(125,211,252,.32); } .sgsh-kindtag.note { background:rgba(255,183,76,.1); color:var(--sgsh-gold-text); border-color:rgba(255,183,76,.32); } .sgsh-main { flex:1; min-width:0; } .sgsh-name { font-size:13px; font-weight:600; color:var(--sgsh-heading); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .sgsh-meta { display:flex; flex-wrap:wrap; gap:8px; margin-top:3px; font-size:10.5px; color:var(--sgsh-text-2); font-variant-numeric:tabular-nums; } .sgsh-meta .sev1 { color:var(--sgsh-red-text); font-weight:600; } .sgsh-meta .sev2 { color:var(--sgsh-gold-text); font-weight:600; } .sgsh-meta .sev3 { color:var(--sgsh-green); } .sgsh-meta .sgsh-warn { color:var(--sgsh-gold-text); } .sgsh-note { margin-top:4px; font-size:11px; color:var(--sgsh-text-2); background:rgba(255,255,255,.03); border-left:2px solid var(--sgsh-accent-line); padding:4px 8px; border-radius:0 var(--sgsh-r) var(--sgsh-r) 0; word-break:break-all; } .sgsh-acts { display:flex; gap:4px; flex-shrink:0; } .sgsh-act { width:26px; height:26px; border-radius:var(--sgsh-r); border:1px solid var(--sgsh-border-2); background:transparent; color:var(--sgsh-text-2); cursor:pointer; font-size:12px; line-height:1; display:flex; align-items:center; justify-content:center; transition:color var(--sgsh-ease), background var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-act:hover { background:var(--sgsh-accent-soft); color:var(--sgsh-accent); border-color:var(--sgsh-accent-line); } .sgsh-act.del:hover { background:var(--sgsh-red-wash); color:var(--sgsh-red-text); border-color:var(--sgsh-red-line); } .sgsh-act:disabled { opacity:.35; cursor:not-allowed; } .sgsh-empty { text-align:center; padding:50px 20px; color:var(--sgsh-text-3); font-size:12px; line-height:1.9; } .sgsh-foot { display:flex; align-items:center; gap:8px; padding:10px 14px; border-top:1px solid var(--sgsh-border); background:var(--sgsh-bg-2); flex-shrink:0; } .sgsh-foot .sp { flex:1; } /* ---------- 7. 购物车页行内暂存按钮 ---------- */ .sgsh-shelve-btn { margin:0 6px; padding:2px 9px; font-size:10px; font-weight:700; letter-spacing:.5px; cursor:pointer; border-radius:2px; border:1px solid var(--sgsh-accent-line); background:var(--sgsh-accent-wash); color:var(--sgsh-accent); transition:background var(--sgsh-ease), color var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-shelve-btn:hover { background:var(--sgsh-accent-soft); border-color:var(--sgsh-accent); color:var(--sgsh-accent); } /* ---------- 8. 弹窗 ---------- */ .sgsh-modal-mask { position:fixed; inset:0; z-index:1000001; background:rgba(8,11,17,.7); display:flex; align-items:center; justify-content:center; } .sgsh-modal { width:520px; max-width:calc(100vw - 40px); max-height:82vh; display:flex; flex-direction:column; background:var(--sgsh-bg); border:1px solid var(--sgsh-border); border-radius:4px; box-shadow:0 18px 60px rgba(0,0,0,.6); overflow:hidden; } .sgsh-modal-head { padding:12px 16px; font-size:13px; font-weight:600; color:var(--sgsh-heading); background:var(--sgsh-bg-2); border-bottom:1px solid var(--sgsh-border); display:flex; align-items:center; letter-spacing:.3px; } .sgsh-modal-body { padding:14px 16px; overflow-y:auto; font-size:12px; color:var(--sgsh-text); line-height:1.7; } .sgsh-modal-foot { padding:11px 16px; border-top:1px solid var(--sgsh-border-2); display:flex; gap:8px; justify-content:flex-end; } .sgsh-m-lead { margin-bottom:9px; color:var(--sgsh-text); } .sgsh-m-lead b { color:var(--sgsh-accent); font-variant-numeric:tabular-nums; } .sgsh-m-warn { margin-top:9px; padding:7px 10px; border-radius:0 var(--sgsh-r) var(--sgsh-r) 0; font-size:11px; background:rgba(255,183,76,.07); border-left:2px solid var(--sgsh-gold); color:var(--sgsh-gold-text); line-height:1.5; } .sgsh-m-list { margin:6px 0 0; padding-left:18px; max-height:220px; overflow-y:auto; } .sgsh-m-line { display:flex; flex-direction:column; padding:5px 0; border-bottom:1px dashed var(--sgsh-border-2); } .sgsh-m-line span { color:var(--sgsh-text-2); font-size:11px; word-break:break-all; } /* ---------- 9. 表单 ---------- */ .sgsh-field { margin-bottom:11px; } .sgsh-field label { display:block; font-size:10px; color:var(--sgsh-text-2); margin-bottom:5px; font-weight:700; text-transform:uppercase; letter-spacing:.6px; } .sgsh-field input, .sgsh-field textarea, .sgsh-field select { width:100%; box-sizing:border-box; padding:7px 10px; border-radius:var(--sgsh-r); font-size:12px; background:var(--sgsh-bg-2); border:1px solid var(--sgsh-border-2); color:var(--sgsh-text); outline:none; font-family:inherit; transition:border-color var(--sgsh-ease); } .sgsh-field textarea { min-height:110px; resize:vertical; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:11px; } .sgsh-field input:focus, .sgsh-field textarea:focus, .sgsh-field select:focus { border-color:var(--sgsh-accent-line); } .sgsh-field .hint { font-size:10.5px; color:var(--sgsh-text-3); margin-top:4px; } .sgsh-check { display:flex; align-items:center; gap:7px; font-size:11.5px; color:var(--sgsh-text-2); margin-bottom:10px; cursor:pointer; } .sgsh-check input { width:auto; } /* ---------- 10. 侧栏角标:红点,不展示条数(--sgsh-red 只定义在浮窗上,这里写死颜色) ---------- */ .sgsh-badge { position:absolute; top:2px; right:2px; width:7px; height:7px; min-width:0; padding:0; display:block; border-radius:50%; background:#e23b3b; box-shadow:0 0 0 1.5px rgba(15,18,24,.9); pointer-events:none; font-size:0; line-height:0; color:transparent; overflow:hidden; } `; function ensureCss() { if (document.getElementById('sgsh-css')) return; const st = document.createElement('style'); st.id = 'sgsh-css'; st.textContent = CSS; (document.head || document.documentElement).appendChild(st); } function ensureUI() { if (panelEl) return; ensureCss(); overlayEl = document.createElement('div'); overlayEl.className = 'sglv-overlay sgsh-overlay'; overlayEl.setAttribute('data-sglv-plugin-ui', '1'); overlayEl.addEventListener('click', closeShelf); document.body.appendChild(overlayEl); panelEl = document.createElement('div'); panelEl.className = 'sglv-panel sgsh-panel'; panelEl.setAttribute('data-sglv-plugin-ui', '1'); panelEl.innerHTML = `
📦
${zh('暂存箱', 'Shelf')}
0
`; document.body.appendChild(panelEl); panelEl.addEventListener('click', onPanelClick); panelEl.querySelector('#sgsh-q').addEventListener('input', (e) => { uiState.q = e.target.value; renderList(); }); panelEl.querySelector('#sgsh-sort').addEventListener('change', (e) => { uiState.sort = e.target.value; setPrefs({ sort: uiState.sort }); renderList(); }); document.addEventListener('keydown', onKeydown); window.addEventListener('storage', onStorage); renderChips(); } function onKeydown(e) { if (e.key === 'Escape') { if (modalEl) { closeModal(); return; } if (isOpenFlag) closeShelf(); } } /** 跨脚本热更新:SCE 改了互通桶 → 同步角标与列表 */ function onStorage(e) { if (!e || (e.key && e.key !== CFG.INTEROP_KEY && e.key !== CFG.EXTRA_KEY)) return; emitChange(); if (isOpenFlag) render(); } /** 有暂存则亮红点,不把条数写进角标 */ function setBadge(n) { try { const on = num(n) > 0; document.querySelectorAll('[data-sgsh-badge]').forEach((el) => { if (el.textContent) el.textContent = ''; el.style.display = on ? '' : 'none'; }); } catch (e) { /* ignore */ } } function renderChips() { const box = panelEl && panelEl.querySelector('#sgsh-chips'); if (!box) return; box.innerHTML = FILTER_META.map((f) => ( `` )).join(''); } function visibleItems() { let list = allItems(); if (uiState.filter !== 'all') { list = list.filter((r) => (KIND_META[r.kind] || {}).tag === uiState.filter); } const q = uiState.q.trim().toLowerCase(); if (q) { list = list.filter((r) => { const hay = [displayName(r), r.note, (r.tags || []).join(' '), r.appid, r.bundleid, r.packageid, r.url] .filter(Boolean).join(' ').toLowerCase(); return hay.indexOf(q) !== -1; }); } const sort = uiState.sort; list.sort((a, b) => { if (sort === 'name') return displayName(a).localeCompare(displayName(b)); if (sort === 'added') return (num(b.addedAt) || num(b.removedAt)) - (num(a.addedAt) || num(a.removedAt)); const ae = num(a.endTs) || Infinity; const be = num(b.endTs) || Infinity; if (ae !== be) return ae - be; return (num(a.addedAt) || num(a.removedAt)) - (num(b.addedAt) || num(b.removedAt)); }); return list; } function rowHtml(rec) { const name = displayName(rec); const km = KIND_META[rec.kind] || { label: rec.kind, tag: 'cart' }; const meta = []; if (rec.count > 1) meta.push(zh('含 ', 'incl. ') + rec.count + ' ' + zh('项', 'items')); if (rec.pct) meta.push('-' + rec.pct + '%'); if (num(rec.endTs)) { const s = severity(rec.endTs); meta.push('' + esc(absTime(rec.endTs)) + ' · ' + esc(remainText(rec.endTs)) + ''); } if (rec.price) meta.push((rec.currency || '') + ' ' + rec.price); meta.push(rec.source === 'cart' ? zh('来自购物车', 'from cart') : rec.source === 'wishlist' ? zh('来自愿望单', 'from wishlist') : zh('手动', 'manual')); if (foreignAccount(rec)) meta.push('' + zh('⚠ 其他账号写入', '⚠ other account') + ''); if (rec.lastError) meta.push('' + esc(rec.lastError) + ''); if ((rec.tags || []).length) meta.push('#' + rec.tags.map(esc).join(' #')); const acts = []; if (canRestore(rec)) acts.push(''); const url = linkOf(rec); if (url) acts.push(''); if (rec.url) acts.push(''); acts.push(''); acts.push(''); return `
${esc(km.label)}
${esc(name)}
${meta.join('·')}
${rec.note ? '
' + esc(rec.note) + '
' : ''}
${acts.join('')}
`; } function renderList() { const body = panelEl && panelEl.querySelector('#sgsh-body'); if (!body) return; const list = visibleItems(); const total = countAll(); const cnt = panelEl.querySelector('#sgsh-count'); if (cnt) cnt.textContent = String(total); const share = panelEl.querySelector('#sgsh-share'); if (share) { const nInterop = readBucket('interop').items.length; share.textContent = zh('与购物车增强互通 ', 'shared with cart script: ') + nInterop + ' · ' + zh('独享 ', 'private: ') + (total - nInterop); } const restoreable = list.filter(canRestore).length; const soonest = list.filter((r) => num(r.endTs)).sort((a, b) => a.endTs - b.endTs)[0]; const sum = panelEl.querySelector('#sgsh-summary'); if (sum) { sum.innerHTML = `${zh('共 ', 'Total ') + '' + list.length + '' + (list.length !== total ? zh('(全部 ' + total + ')', ' of ' + total) : '')}` + `${zh('可恢复 ', 'Restorable ') + '' + restoreable + ''}` + (soonest ? `${zh('最近到期 ', 'Next expiry ') + '' + esc(remainText(soonest.endTs)) + ''}` : '') + (busy ? `${esc(busyText)}` : ''); } const cartBtn = panelEl.querySelector('#sgsh-shelve-cart'); if (cartBtn) { cartBtn.style.display = onCartPage() ? '' : 'none'; cartBtn.disabled = busy; } const restoreAllBtn = panelEl.querySelector('[data-act="restore-all"]'); if (restoreAllBtn) restoreAllBtn.disabled = busy || restoreable === 0; if (!list.length) { body.innerHTML = `
📦
${total ? zh('没有符合当前筛选的记录', 'No record matches the filter') : zh('暂存箱是空的。
在购物车页点行内「暂存」、或用右上角「+ 新建」记录备忘与外部链接。', 'Shelf is empty.
Use the row-level “Stash” on /cart, or “+ New” to save notes and links.')}
`; return; } body.innerHTML = list.map(rowHtml).join(''); } function render() { renderList(); } function setBusy(text) { busyText = text || ''; if (isOpenFlag) renderList(); } function openShelf() { if (!_hostReady) { toast(zh('暂存箱模块未就绪', 'Shelf module not ready'), 'warning'); return; } ensureUI(); const p = getPrefs(); uiState.filter = p.filter; uiState.sort = p.sort; uiState.compatOnly = p.compatOnly; isOpenFlag = true; overlayEl.classList.add('sglv-show'); panelEl.classList.add('sglv-show'); const q = panelEl.querySelector('#sgsh-q'); if (q) q.value = uiState.q; const s = panelEl.querySelector('#sgsh-sort'); if (s) s.value = uiState.sort; renderChips(); renderList(); startTick(); injectCartButtons(); } function closeShelf() { if (!panelEl) return; isOpenFlag = false; stopTick(); closeModal(); overlayEl.classList.remove('sglv-show'); panelEl.classList.remove('sglv-show'); } function isOpen() { return isOpenFlag; } function toggleShelf() { if (isOpenFlag) closeShelf(); else openShelf(); } function startTick() { stopTick(); tickTimer = setInterval(() => { if (!isOpenFlag) { stopTick(); return; } const nodes = document.querySelectorAll('#sgsh-body [data-end]'); for (let i = 0; i < nodes.length; i++) { const el = nodes[i]; const ts = num(el.getAttribute('data-end')); const cls = 'sev' + severity(ts); if (el.className !== cls) el.className = cls; const txt = absTime(ts) + ' · ' + remainText(ts); if (el.textContent !== txt) el.textContent = txt; } }, CFG.TICK_MS); } function stopTick() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } } // ==================== 弹窗 ==================== function showModal(opts) { const o = opts || {}; closeModal(); modalOnClose = o.onClose || null; const mask = document.createElement('div'); mask.className = 'sgsh-modal-mask'; mask.setAttribute('data-sglv-plugin-ui', '1'); const buttons = (o.buttons || [{ label: zh('关闭', 'Close'), primary: true }]).map((b, i) => ( `` )).join(''); mask.innerHTML = `
${esc(o.title || '')}
${o.body || ''}
${buttons}
`; mask.addEventListener('click', (e) => { if (e.target === mask && o.maskClose !== false) closeModal(); }); mask.querySelector('.sgsh-modal-foot').addEventListener('click', async (e) => { const b = e.target.closest('[data-mi]'); if (!b) return; const def = (o.buttons || [])[Number(b.getAttribute('data-mi'))]; if (def && typeof def.act === 'function') { b.disabled = true; try { await def.act(); } catch (err) { console.warn('[SGLVShelf] 弹窗动作失败', err); } b.disabled = false; } if (!def || def.keep !== true) closeModal(); }); document.body.appendChild(mask); modalEl = mask; return mask; } function closeModal() { if (!modalEl) return; modalEl.remove(); modalEl = null; const cb = modalOnClose; modalOnClose = null; if (cb) { try { cb(); } catch (e) { /* ignore */ } } } function showConfirm(title, lead, okLabel, act) { showModal({ title: title, body: '
' + lead + '
', buttons: [ { label: zh('取消', 'Cancel') }, { label: okLabel, primary: true, act: act }, ], }); } function copyText(txt) { try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(txt); return true; } } catch (e) { /* ignore */ } try { const ta = document.createElement('textarea'); ta.value = txt; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); return true; } catch (e) { return false; } } // ==================== 交互分发 ==================== function onPanelClick(e) { const chip = e.target.closest('[data-filter]'); if (chip) { uiState.filter = chip.getAttribute('data-filter'); setPrefs({ filter: uiState.filter }); renderChips(); renderList(); return; } const actBtn = e.target.closest('[data-act]'); if (!actBtn) return; const act = actBtn.getAttribute('data-act'); const rowEl = actBtn.closest('.sgsh-row'); const id = rowEl ? rowEl.getAttribute('data-id') : null; const rec = id ? getItem(id) : null; switch (act) { case 'close': closeShelf(); break; case 'add': showEditModal(null); break; case 'edit': if (rec) showEditModal(rec); break; case 'restore': if (rec) executeRestore([rec.id]); break; case 'restore-all': { const ids = visibleItems().filter(canRestore).map((r) => r.id); if (!ids.length) { toast(zh('没有可恢复的记录', 'Nothing to restore'), 'warning'); break; } showConfirm(zh('全部恢复', 'Restore all'), zh('将把 ', 'Restores ') + ids.length + ' ' + zh('项恢复到购物车。', 'items back into the cart.'), zh('确认恢复', 'Restore'), () => executeRestore(ids)); break; } case 'open': { const u = rec ? linkOf(rec) : ''; if (!u) break; if (Host && typeof Host.openUrl === 'function') { try { Host.openUrl(u); break; } catch (err) { /* fallthrough */ } } window.open(u, '_blank', 'noopener'); break; } case 'copy': if (rec && rec.url) toast(copyText(rec.url) ? zh('链接已复制', 'Link copied') : zh('复制失败', 'Copy failed'), copyText(rec.url) ? 'success' : 'error'); break; case 'del': if (rec) showConfirm(zh('删除这条暂存?', 'Delete this record?'), zh('将从暂存箱移除 ', 'Removes ') + esc(displayName(rec)) + '。', zh('删除', 'Delete'), () => { deleteItem(rec.id); toast(zh('已删除', 'Deleted'), 'success'); }); break; case 'clear': { const n = countAll(); if (!n) { toast(zh('暂存箱已经是空的', 'Shelf is already empty'), 'warning'); break; } showConfirm(zh('清空暂存箱', 'Clear shelf'), zh('将删除全部 ', 'Deletes all ') + n + ' ' + zh('条记录(含与购物车增强互通的)。此操作不可撤销,建议先导出。', 'records (including shared ones). This cannot be undone — export first.'), zh('确认清空', 'Clear all'), () => { writeBucket('interop', []); writeBucket('extra', []); emitChange(); toast(zh('暂存箱已清空', 'Shelf cleared'), 'success'); }); break; } case 'prune': { const n = pruneStale(CFG.STALE_DAYS); toast(n ? zh('已清理 ', 'Pruned ') + n + zh(' 条陈旧记录', ' stale records') : zh('没有超过 ' + CFG.STALE_DAYS + ' 天的记录', 'Nothing older than ' + CFG.STALE_DAYS + ' days'), n ? 'success' : 'info'); break; } case 'export': showExportModal(); break; case 'import': showImportModal(); break; case 'shelve-cart': shelveAllRows(); break; default: break; } } // ---- 新建 / 编辑 ---- function showEditModal(rec) { const isNew = !rec; const kind = rec ? rec.kind : 'note'; const kinds = ['note', 'app', 'wishlist', 'bundle', 'package']; const opt = (k) => ``; showModal({ title: isNew ? zh('新建暂存', 'New shelf entry') : zh('编辑暂存', 'Edit shelf entry'), body: `
${zh('单品/捆绑包/购买包会写入与「购物车增强」互通的存储;愿望单与备忘由本库独享。', 'app/bundle/package go to the bucket shared with the cart script; wishlist/note are private.')}
${zh('填了就会在列表里倒计时并按到期排序', 'Enables countdown and expiry sorting')}
`, buttons: [ { label: zh('取消', 'Cancel') }, { label: isNew ? zh('加入暂存箱', 'Add') : zh('保存', 'Save'), primary: true, keep: true, act: async () => { const g = (id) => { const el = document.getElementById(id); return el ? el.value.trim() : ''; }; const k = g('f-kind'); const payload = { kind: k, title: g('f-title'), appid: num(g('f-appid')) || null, bundleid: num(g('f-bundleid')) || null, packageid: num(g('f-packageid')) || null, url: g('f-url'), note: g('f-note'), tags: g('f-tags').split(/\s+/).filter(Boolean), endTs: num(g('f-endts')), source: k === 'wishlist' ? 'wishlist' : 'manual', }; if (k !== 'note' && !payload.appid && !payload.bundleid && !payload.packageid) { toast(zh('该类型必须填写对应 ID', 'This kind requires its id'), 'warning'); return; } if (rec) { const merged = Object.assign({}, rec, payload); putItem(merged); toast(zh('已保存', 'Saved'), 'success'); } else { await addItem(payload); } } }, ], onClose: () => { if (isOpenFlag) renderList(); }, }); } // ---- 导出 ---- function showExportModal() { const full = buildExport({ compatOnly: false }); const compat = buildExport({ compatOnly: true }); showModal({ title: zh('导出暂存箱', 'Export shelf'), body: `
${zh('共 ' + full.items.length + ' 条(购物类 ' + compat.items.length + ' 条)。Ctrl/⌘+C 或点复制。', full.items.length + ' records (' + compat.items.length + ' cart). Ctrl/⌘+C or use Copy.')}
`, buttons: [ { label: zh('取消', 'Cancel') }, { label: zh('复制', 'Copy'), act: () => { const ta = document.getElementById('f-json'); const txt = ta ? ta.value : ''; toast(copyText(txt) ? zh('已复制到剪贴板', 'Copied to clipboard') : zh('复制失败,请手动全选', 'Copy failed — select manually'), copyText(txt) ? 'success' : 'warning'); } }, ], }); const chk = document.getElementById('f-compat'); const ta = document.getElementById('f-json'); if (chk && ta) chk.addEventListener('change', () => { ta.value = JSON.stringify(chk.checked ? compat : full, null, 2); }); } // ---- 导入 ---- function showImportModal() { showModal({ title: zh('导入暂存箱', 'Import shelf'), body: `
${zh('粘贴 JSON。支持本库导出体、购物车增强的原样导出、以及裸数组。相同商品自动合并,不产生重复。', 'Paste JSON. Accepts this shelf export, the cart script raw export, or a bare array. Identical products merge instead of duplicating.')}
`, buttons: [ { label: zh('取消', 'Cancel') }, { label: zh('导入', 'Import'), primary: true, act: () => { const ta = document.getElementById('f-json'); const r = parseImport(ta ? ta.value : ''); if (!r.ok) { toast(r.error, 'error'); return; } toast(zh('导入完成:新增 ', 'Imported: ') + r.added + zh(' · 更新 ', ' · updated ') + r.updated + (r.skipped ? zh(' · 跳过 ', ' · skipped ') + r.skipped : ''), r.added || r.updated ? 'success' : 'info'); } }, ], onClose: () => { if (isOpenFlag) renderList(); }, }); } // ==================== 购物车页监听 ==================== let cartObserver = null; let cartDebounce = 0; function watchCartPage() { if (cartObserver) return; const boot = () => { if (cartObserver || !onCartPage()) return; injectCartButtons(); cartObserver = new MutationObserver((mutations) => { for (let i = 0; i < mutations.length; i++) { const t = mutations[i].target; if (t && typeof t.closest === 'function' && t.closest('#sgis-panel, .sglv-panel, .sglv-overlay, .sglv-menu-wrap, #familyAnalysisPanel, #swe-panel, #swe-fab, .swe-nav-entry, [data-sglv-plugin-ui], [data-fa-plugin-ui], [data-swe-plugin-ui]')) continue; clearTimeout(cartDebounce); cartDebounce = setTimeout(() => { if (onCartPage()) injectCartButtons(); }, 600); return; } }); cartObserver.observe(document.body, { childList: true, subtree: true }); }; boot(); // 单页应用路由(/cart 内部不会切页,但保险起见监听一次 popstate) window.addEventListener('popstate', () => setTimeout(boot, 300)); } function refreshCartButtons() { if (!onCartPage()) return; injectCartButtons(); if (isOpenFlag) renderList(); } // ==================== 对外 ==================== function init(host) { Host = host || null; if (!Host || Host.apiVersion !== apiVersion) { console.warn('[SGLVShelf] 握手失败:Host.apiVersion 与库版本不匹配'); _hostReady = false; return false; } _hostReady = true; try { // 样式必须此刻注入,不能等到 ensureUI():侧栏角标在浮窗首次打开前 // 就可能显示(init 的 emitChange 会点亮它),懒加载会让角标裸奔 ensureCss(); // 初始化即广播一次,让侧栏按钮拿到初始角标 emitChange(); watchCartPage(); } catch (e) { console.warn('[SGLVShelf] 初始化异常(数据层仍可用)', e); } debug('init ok · interop=%d extra=%d', readBucket('interop').items.length, readBucket('extra').items.length); return true; } return { init: init, apiVersion: apiVersion, // 浮窗 openShelf: openShelf, closeShelf: closeShelf, toggleShelf: toggleShelf, isOpen: isOpen, // 数据 getCount: countAll, allItems: allItems, getItem: getItem, addItem: addItem, addWishlist: addWishlist, addNote: addNote, deleteItem: deleteItem, putItem: putItem, executeRestore: executeRestore, pruneStale: pruneStale, buildExport: buildExport, parseImport: parseImport, onChange: onChange, setBadge: setBadge, // 购物车页 injectCartButtons: refreshCartButtons, onCartPage: onCartPage, shelveAllRows: shelveAllRows, // 测试/诊断用内部接口 _internals: { normalizeRecord: normalizeRecord, isInteropKind: isInteropKind, bucketOf: bucketOf, resolveKind: resolveKind, sceAcceptable: sceAcceptable, readBucket: readBucket, writeBucket: writeBucket, getPrefs: getPrefs, setPrefs: setPrefs, sameTarget: sameTarget, resolveRestorePlan: resolveRestorePlan, displayName: displayName, linkOf: linkOf, remainText: remainText, severity: severity, CFG: CFG, }, }; })(); // ==== SGLV-SHELF-INLINE-END ==== // ==== SGLV-COVER-FALLBACK-INLINE-BEGIN (inline-cover-fallback.js 自动生成,勿手改) ==== /** * 内联内容由 inline-cover-fallback.js 生成 —— 请勿手改。 * 来源:libs/sglv-libs-cover-fallback.lib.js * 改动请编辑库文件后重跑本脚本;verify-cover-fallback-inline-sync.js 会拦截漏跑。 * * v2.0.0 重写要点: * - 链首 cdn.cloudflare(老游戏命中率最高,替代 v1.x 的 shared.akamai schinese_2x) * - 链长 7-8 (从 20 砍掉一半多) * - schinese 变体放链尾(避免老游戏先吃 4-5 个 404) * - 拿掉 steamui.com 渠道(被 Cloudflare managed challenge 拦截,永久失效) * - JS 驱动的 chainLoad(img, ...) — 参考 steam-friend-manager 1.5.3 dashChainLoad * - addEventListener('error') 单一 listener,链耗尽异步走 Steam Store API * * v2.0.1: good URL 跨会话缓存补 6 个月 TTL(COVER_GOOD_TTL)—— * 验证可用的封面 URL 半年内不再重走整条链;游戏换封面后半年自动重新探测。 * * v2.0.2: 修复封面"越改越坏"根因—— * chainLoad 单一状态机(不再 onerror + addEventListener 双推进); * skipUrl 跳过刚失败的 src;成功后 recordGood 落缓存;锁可复位; * getCoverUrl 先查 lang 键再回落无 lang 键。 * * v2.0.3: 缓存复用 / 渐进式失效—— * 过期条目不再批量删除(旧 URL 继续当初始 src,命中即刷新 ts,404 才走链); * LRU 500 → 2000;负缓存真正接上(网络失败 10min / 确认无资产 24h); * 只剪除 data:/blob: 垃圾条目。 */ // ---------- sglv-libs-cover-fallback.lib.js (610 行) ---------- /* * SGLV Cover Fallback Library v2.0.3 * * v2.0.3 缓存策略(渐进式失效 / 增量刷新 / 最大化复用旧缓存): * - 过期条目不再批量删除:旧 URL 继续作为初始 src 使用,命中即 recordGood 刷新 ts(零成本续期), * 404 只让这一条走回退链 —— 杜绝"半年后整库同时过期 → 全量重爬" * - PERSISTED_MAX 500 → 2000:大库不再频繁淘汰仍在使用的 good URL * - 负缓存真正接上(此前 _coverNeg 定义了但无人使用):API 网络失败 10min 重试、 * 确认无资产 24h 重试,避免每次渲染都打 appdetails * - loadPersisted 只剪除 data:/blob: 垃圾条目,http(s) 条目一律保留 * * v2.0.2 修复(封面"越改越坏"根因): * - chainLoad 不再混用 img.onerror + addEventListener('error')(同一次失败双推进, * 一次 error 跳过 2 个 URL 直冲 placeholder);改为单一状态机 * - 支持 skipUrl:跳过调用方刚失败的 src(旧实现重设同一 URL → 立刻再失败) * - 加载成功一律 recordGood() 写缓存(旧实现要求 onLoaded,宿主全都没传 → 缓存恒空) * - recordGood 只接受 http(s),data:/blob: 占位图绝不入缓存 * - 成功/放弃时复位 _sglvChainLoading(旧实现一次性锁永不复位,视图切换后彻底失效) * - getCoverUrl 先查 lang 键再回落无 lang 键(旧实现写入无 lang、读取只有 lang → 中文界面永不命中) * * v2.0.0 重写(性能与覆盖率重设计): * - 链首 cdn.cloudflare.steamstatic.com(Steam 老游戏命中率最高,2018 前所有发售游戏都覆盖) * - 链长度从 20 砍到 7-8(去掉冗余的 4 CDN × schinese 变体置顶;实测 schinese 变体在老游戏几乎全 404) * - schinese 变体放链尾部(仅在所有基础 URL 失败后才尝试,节省 4-5 个 404) * - 新增 chainLoad(img, appid, kind, opts) — JS 驱动链式加载(参考 steam-friend-manager 1.5.3 dashChainLoad) * img 创建时无 src(避免浏览器主动加载链首 404),addEventListener('error') 单一 listener, * 链耗尽后异步走 Steam Store API(不阻塞当前链) * - 移除 v1.x 的 steamui.com 渠道(被 Cloudflare managed challenge 拦截,受限网络下 _steamuiCfBlockedUntil * 永久触发,getSteamuiAssets 永远返回 null,整个 hash URL 直通渠道失效——回退到传统 CDN 链 + API 兜底) * - 保留 good URL 缓存 + GM 持久化(LRU 500)+ 负缓存(双 TTL:确认无资产 24h / 网络抖动快速自愈) * - 兼容 v1.x API:getCoverUrl(appid, kind, lang) / resolveCover(appid, kind, lang) / * bindErrorHandler(rootEl, onFailed) / loadImageWithFallback(img, onFailed) * * 设计目标:从 sglv-libs-core 抽出"封面降级 + 多 CDN 探测"基础设施, * 供所有 SGLV 系列脚本 @require 共享,避免每个脚本都重写一遍: * - cdn.cloudflare 链(老游戏最稳)→ store_item_assets 链(新游戏)→ header.jpg 兜底 * - 跨视图/跨会话的 good URL 缓存(按 kind 隔离) * - appdetails API 兜底(kind 感知:capsule 优先 capsule_image, 其余 header_image) * - SVG placeholder * * 使用方式: * 1) 宿主 @require 此 lib * 2) img 标签加 data-sglv-cover="1" + data-appid="123" + data-kind="capsule"(kind 默认 capsule) * 3) 初始 src 推荐用 SGLVCoverFallback.getCoverUrl(appId, kind) 同步取(命中缓存或链首) * 4) 在根容器上调用 SGLVCoverFallback.bindErrorHandler(rootEl) * 5) 错误时自动走 chainLoad(JS 接管,加 addEventListener('error') 单一 listener) * 6) 成功时自动写缓存(同会话 + 跨会话 GM) * 7) 链耗尽后异步走 Steam Store API(不阻塞当前链) * * 桥接契约(测试/宿主需要注入): * window.__sglvCoverMockGmXhr = function(method, url, opts, cb) 可选,默认走 GM_xmlhttpRequest * * API: * version: '2.0.0' * apiVersion: 3 * SVG_PLACEHOLDER: data:image/svg+xml 兜底图 * CHAIN: 默认 CDN fallback 链(横图 7:3,向后兼容) * KINDS: 支持的图片类型列表 * LANG_VARIANTS: 各 kind 的语言变体文件名模板(链尾部,老游戏通常 miss) * getCoverUrl(appId, kind?, lang?): 获取初始 URL(good 缓存→链首) * resolveCover(appId, kind?, lang?): Promise 版(同步 good 缓存→链首;异步仅在 Steam API 调用时使用) * chainLoad(img, appid, kind?, opts?): JS 驱动的链式加载(主要入口 — 推荐) * bindErrorHandler(rootEl, onFailed?): 在 rootEl 上绑定 error/load 委托(全局只调一次) * loadImageWithFallback(img, onFailed?): 兼容 v1.x 手动触发单图降级 * getSteamuiAssets: (v1.x 兼容, v2.0 返回 null, 避免误调) * getSteamuiGoodUrl: (v1.x 兼容, v2.0 返回 '') * * 宿主需 @grant GM_xmlhttpRequest, GM_getValue, GM_setValue。 */ (function (root) { 'use strict'; // ==================== 常量 ==================== const VERSION = '2.0.3'; const API_VERSION = 3; // CDN 前缀(按"老游戏命中率"排序,链首=命中率最高) // v2.0.0: cdn.cloudflare 排第一 — Steam 老游戏(2018 前)基本都在这 // shared.akamai store_item_assets 排第二 — 新游戏(2018+ / 哈希子目录) // cdn.akamai 排第三 — 部分老 CDN 资源 // shared.fastly store_item_assets 排第四 — 备用哈希子目录 const CDN_CF = 'https://cdn.cloudflare.steamstatic.com/steam/apps/'; const CDN_SHARED_AK = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/'; const CDN_AK = 'https://cdn.akamai.steamstatic.com/steam/apps/'; const CDN_SHARED_FASTLY = 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/'; // kind → 基础文件列表(无语言变体;变体放链尾部,避免老游戏先吃 4-5 个 404) const POSTER_FILES = ['library_600x900_2x.jpg', 'library_600x900.jpg']; const CAPSULE_FILES = ['capsule_467x181.jpg', 'capsule_231x87.jpg']; const HEADER_FILES = ['header.jpg']; const HERO_FILES = ['library_hero_2x.jpg', 'library_hero.jpg']; const LOGO_FILES = ['logo_2x.png', 'logo.png']; const LIB_HEADER_FILES = ['library_header_2x.jpg', 'library_header.jpg']; // 语言变体文件名模板(仅在基础链全部失败时追加,避免老游戏先吃 schinese 404) // 实测 Steam CDN 路径直接支持语言后缀: capsule_467x181_schinese.jpg / logo_schinese.png 等 // 但 2018 前发售的老游戏几乎都没这些变体 → 放链尾节省 4-5 个 404 const LANG_VARIANTS = { poster: ['library_600x900_{v}_2x.jpg', 'library_600x900_{v}.jpg'], capsule: ['capsule_467x181_{v}.jpg', 'capsule_231x87_{v}.jpg'], header: ['header_{v}.jpg'], hero: ['library_hero_{v}_2x.jpg', 'library_hero_{v}.jpg'], logo: ['logo_{v}_2x.png', 'logo_{v}.png'], library_header: ['library_header_{v}_2x.jpg', 'library_header_{v}.jpg'], }; const KINDS = ['poster', 'capsule', 'header', 'hero', 'logo', 'library_header']; // SVG placeholder(URL 编码内嵌) const SVG_PLACEHOLDER = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 184 69'%3E%3Crect fill='%231a1f3a' width='184' height='69'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%2364748b' font-family='system-ui' font-size='10'%3ENo Image%3C/text%3E%3C/svg%3E"; // ==================== 内存缓存(同会话跨视图共享) ==================== // 缓存键按 kind(+lang) 隔离 → "kind:appid" / "kind:appid@lang" const _goodMap = new Map(); function goodKey(kind, appId, lang) { const k = kind || 'capsule'; return lang ? k + ':' + appId + '@' + lang : k + ':' + appId; } // ==================== 持久化缓存(跨会话,GM_setValue) ==================== // 与 v1.x 兼容: 键名 'sglv_cover_url_v2', LRU 2000 // v2.0.3: 500 → 2000 —— 大库(1000+ 游戏)下 500 条会频繁淘汰仍在使用的条目, // 被淘汰的条目下次渲染要从链首重爬(最坏 10 次 404)。2000 条约 240KB,远低于 GM 配额。 const PERSISTED_KEY = 'sglv_cover_url_v2'; const PERSISTED_MAX = 2000; // 新鲜期(诊断/迁移用,不再用于批量删除) // v2.0.3: 过期条目**不再删除** —— 渐进式失效: // 旧 URL 继续作为初始 src 使用,命中则 recordGood 刷新 ts(零成本续期); // 404 则只让这一条走回退链。避免"半年后整库同时过期 → 全量重爬"。 // 容量由 PERSISTED_MAX 的 LRU(按最近成功使用时间)控制。 const COVER_GOOD_TTL = 6 * 30 * 24 * 3600 * 1000; // 180 天(仅 v1 迁移门槛) let _persisted = null; function loadPersisted() { if (_persisted !== null) return _persisted; _persisted = {}; try { const raw = GM_getValue(PERSISTED_KEY); if (raw && typeof raw === 'object') _persisted = raw; } catch (e) { /* ignore */ } // 只剪除确定无效的条目(非 http(s) 的 data:/blob: 占位残留)。 // 过期条目保留:它大概率仍然可用,作为初始 src 再试一次比"重新爬整条链"便宜得多。 let pruned = false; for (const key in _persisted) { const ent = _persisted[key]; if (!ent || !isHttpUrl(ent.url)) { delete _persisted[key]; pruned = true; continue; } _goodMap.set(key, ent.url); } if (pruned) { try { GM_setValue(PERSISTED_KEY, _persisted); } catch (e) { /* ignore */ } } // v1.x 兼容: 迁移 v1 旧格式 (appid 直接作键) try { const now = Date.now(); const oldRaw = GM_getValue('sglv_cover_url_v1'); if (oldRaw && typeof oldRaw === 'object') { for (const id in oldRaw) { const ent = oldRaw[id]; if (ent && isHttpUrl(ent.url) && !_persisted['capsule:' + id] && (now - (ent.ts || 0)) <= COVER_GOOD_TTL) { _persisted['capsule:' + id] = { url: ent.url, ts: ent.ts || now }; _goodMap.set('capsule:' + id, ent.url); } } } } catch (e) { /* ignore */ } return _persisted; } let _writeTimer = null; let _writeDirty = false; // 同一 URL 的 ts 刷新落盘节流:ts 只服务 LRU 排序,不需要秒级精度。 // 每次 load 都整表序列化(2000 条约 220KB)会在滚动时造成明显卡顿。 const PERSIST_TOUCH_TTL = 60 * 60 * 1000; // 1h function setPersisted(cacheKey, url) { if (!cacheKey || !url) return; const container = loadPersisted(); const ent = container[cacheKey]; const now = Date.now(); if (ent && ent.url === url && (now - (ent.ts || 0)) < PERSIST_TOUCH_TTL) return; container[cacheKey] = { url, ts: now }; const keys = Object.keys(container); if (keys.length > PERSISTED_MAX) { // LRU: 删最旧的 keys.sort((a, b) => (container[a].ts || 0) - (container[b].ts || 0)); while (keys.length > PERSISTED_MAX) delete container[keys.shift()]; } _writeDirty = true; if (_writeTimer) return; _writeTimer = setTimeout(() => { _writeTimer = null; if (!_writeDirty) return; _writeDirty = false; try { GM_setValue(PERSISTED_KEY, container); } catch (e) { /* 配额/序列化失败静默 */ } }, 50); } function flushPersisted() { if (_writeTimer) { clearTimeout(_writeTimer); _writeTimer = null; } if (_writeDirty && _persisted) { _writeDirty = false; try { GM_setValue(PERSISTED_KEY, _persisted); } catch (e) { /* ignore */ } } } function clearPersisted() { _persisted = {}; _goodMap.clear(); try { GM_setValue(PERSISTED_KEY, {}); } catch (e) { /* ignore */ } try { GM_setValue('sglv_cover_url_v1', {}); } catch (e) { /* ignore */ } } // ==================== 负缓存(API 调用失败避免重试风暴) ==================== // 双 TTL: 确认无资产(API 成功但 data 空)24h;网络/限频失败 10min 快速自愈 // 收敛策略:同一 appid+kind 最多每 10min / 24h 打一次 API, // 网络抖动很快恢复,确认无资产也不会每次渲染都重试 —— 渐进式刷新。 const COVER_NEG_TTL = 24 * 60 * 60 * 1000; const COVER_NETFAIL_TTL = 10 * 60 * 1000; const COVER_NEG_MAX = 800; const _coverNeg = new Map(); // 'kind:appid' → { t, n } (n=1 网络失败,n=0 确认无资产) function _negHit(rec) { if (!rec) return false; const now = Date.now(); return (now - rec.t) < (rec.n ? COVER_NETFAIL_TTL : COVER_NEG_TTL); } function _negKey(k, appId) { return k + ':' + appId; } function _negMark(k, appId, netFail) { const key = _negKey(k, appId); _coverNeg.delete(key); _coverNeg.set(key, { t: Date.now(), n: netFail ? 1 : 0 }); if (_coverNeg.size > COVER_NEG_MAX) { const it = _coverNeg.keys(); while (_coverNeg.size > COVER_NEG_MAX) { const kk = it.next(); if (kk.done) break; _coverNeg.delete(kk.value); } } } // ==================== 链构建 ==================== // v2.0.0: 链首 cdn.cloudflare(老游戏命中率最高),基础文件, schinese 变体放链尾 function buildFiles(kind, lang) { const k = (kind && KINDS.indexOf(kind) >= 0) ? kind : 'capsule'; let base; if (k === 'poster') base = POSTER_FILES; else if (k === 'capsule') base = CAPSULE_FILES; else if (k === 'header') base = HEADER_FILES; else if (k === 'hero') base = HERO_FILES; else if (k === 'logo') base = LOGO_FILES; else base = LIB_HEADER_FILES; const files = base.slice(); // schinese 变体放链尾 — 老游戏(2018 前)基本没这些,放链首是 4-5 个浪费的 404 if (lang) { const tpl = LANG_VARIANTS[k]; if (tpl) for (const t of tpl) files.push(t.replace('{v}', lang)); } return files; } // 链构造: cdn.cloudflare(1) → store_item_assets(2,3,4) → header.jpg 兜底 // 总数 7-8 URL(从 v1.x 的 20 砍掉一半多) function buildFullChain(appId, kind, lang) { const id = String(appId); const files = buildFiles(kind, lang); const full = []; for (const f of files) { // 1. cdn.cloudflare 链首(老游戏最稳) full.push(CDN_CF + id + '/' + f); // 2. shared.akamai store_item_assets(新游戏哈希子目录) full.push(CDN_SHARED_AK + id + '/' + f); // 3. cdn.akamai(部分老 CDN 资源) full.push(CDN_AK + id + '/' + f); // 4. shared.fastly store_item_assets(备用) full.push(CDN_SHARED_FASTLY + id + '/' + f); } // header.jpg 兜底(几乎所有游戏都有,放链尾) if (kind !== 'header' && kind !== 'library_header') { full.push(CDN_CF + id + '/header.jpg'); full.push(CDN_SHARED_AK + id + '/header.jpg'); } return full; } // ==================== 链去重 ==================== // 同一 URL 不重复探测(steamui hash URL 可能跟基础链重复) function _dedupeChain(chain) { if (!Array.isArray(chain)) return chain; const seen = new Set(); const out = []; for (const u of chain) { if (u && !seen.has(u)) { seen.add(u); out.push(u); } } return out; } // ==================== good URL 写入(统一入口) ==================== // v2.0.2: 宿主一直调用 recordGood() 但 v2.0.0 没有导出该 API — // 结果 whole 会话内没有任何 URL 被写进 good 缓存,getCoverUrl 永远只返回链首, // 每张封面每次渲染都要从链首重新试错(这也是"越改越慢/越改越坏"的根源之一)。 function isHttpUrl(u) { return typeof u === 'string' && /^https?:\/\//i.test(u); } // 取 img 当前 src(容错: 测试/桩对象可能没有 getAttribute) function _imgSrc(img) { try { if (img && typeof img.getAttribute === 'function') return img.getAttribute('src') || ''; return (img && img.src) || ''; } catch (e) { return ''; } } // 记录"已验证可加载"的 URL:同会话 Map + 跨会话 GM 持久化 // 只接受 http(s) —— data:/blob: 占位图绝不入缓存 // 统一写无 lang 键(lang 只是读取顺序偏好,写两份会浪费 LRU 配额; // getCoverUrl 会先查 lang 键再回落无 lang 键) function recordGood(appId, url, kind, lang) { if (!appId || !isHttpUrl(url)) return false; const k = (kind && KINDS.indexOf(kind) >= 0) ? kind : 'capsule'; const id = String(appId); _goodMap.set(goodKey(k, id, ''), url); setPersisted(goodKey(k, id, ''), url); if (lang) _goodMap.set(goodKey(k, id, lang), url); // 仅内存别名,不额外占持久化配额 return true; } // ==================== getCoverUrl / resolveCover (兼容 v1.x API) ==================== function getCoverUrl(appId, kind, lang) { if (!appId) return ''; const k = kind || 'capsule'; loadPersisted(); // 1) 同步 good 缓存(同会话 + 跨会话) // v2.0.2: 先查带 lang 的键,再回落到无 lang 键 —— 写入统一走无 lang 键, // 旧实现只查 lang 键导致中文界面下跨会话缓存永远命中不了 const cached = _goodMap.get(goodKey(k, appId, lang)) || _goodMap.get(goodKey(k, appId, '')); if (isHttpUrl(cached)) return cached; // 2) 没缓存 → 链首(基础文件,无 schinese 变体 — 优先快速命中老游戏) const chain = buildFullChain(appId, k, ''); return chain[0] || ''; } // v1.x 兼容: Promise 版封面解析 // v2.0: 同步 good 缓存命中后直接 resolve;未命中走链首(异步 Steam API 仅在显式调用时执行) function resolveCover(appId, kind, lang) { if (!appId) return Promise.resolve(SVG_PLACEHOLDER); const u = getCoverUrl(appId, kind, lang); return Promise.resolve(u || SVG_PLACEHOLDER); } // ==================== Steam Store API 兜底 ==================== // kind 感知: capsule 优先 capsule_image, poster/header/hero/logo/library_header 优先 header_image function fetchFromAPI(appId, kind) { return new Promise((resolve) => { const k = kind || 'capsule'; const apiUrl = `https://store.steampowered.com/api/appdetails?appids=${appId}&filters=basic`; const gmxhr = root.__sglvCoverMockGmXhr || (typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : null); let settled = false; // v2.0.3: 区分「网络失败」(10min 后可重试) 与「确认无资产」(24h) —— // 两者都写负缓存,避免每次渲染都重打 API(重试风暴),又不会永久拉黑。 const finish = (url, netFail) => { if (settled) return; settled = true; if (isHttpUrl(url)) _coverNeg.delete(_negKey(k, appId)); else _negMark(k, appId, !!netFail); resolve(url || ''); }; const pick = (d) => (k === 'capsule') ? (d.capsule_image || d.header_image || '') : (d.header_image || d.capsule_image || ''); if (gmxhr) { gmxhr({ method: 'GET', url: apiUrl, timeout: 8000, onload: (r) => { try { const j = JSON.parse(_safeText(r)); const d = j[appId]; if (d && d.success && d.data) finish(pick(d.data), false); else finish('', false); // API 正常返回但无该游戏资产 → 24h 内不重试 } catch (e) { finish('', true); } }, onerror: () => finish('', true), ontimeout: () => finish('', true), }); } else if (typeof fetch !== 'undefined') { fetch(apiUrl).then(r => r.json()).then(j => { const d = j[appId]; if (d && d.success && d.data) finish(pick(d.data), false); else finish('', false); }).catch(() => finish('', true)); } else { finish('', true); } }); } function _safeText(r) { try { if (!r) return ''; if (typeof r.responseText === 'string') return r.responseText; if (typeof r.response === 'string') return r.response; return ''; } catch (e) { return ''; } } // ==================== chainLoad: JS 驱动的链式加载(v2.0.2 重写)==================== // v2.0.2 修复的关键缺陷: // ① 旧实现同时挂 img.onerror 与 addEventListener('error') —— 同一次失败两套 // handler 各推进一次链(tryFinal 被调两次),一次 error 跳过 2 个 URL, // 直接从链首冲到 placeholder。现在只用一对 addEventListener,单一状态机。 // ② 旧实现忽略调用方当前 src —— bindErrorHandler 明明算了 cur 却没用, // chain[0] 往往就是刚 404 的那个 URL,重设同一 URL 立刻再次失败。 // 现在支持 opts.skipUrl,并且 step() 内部也跳过 curSrc。 // ③ 旧实现只有调用方传 onLoaded 才写 good 缓存,而宿主全部没传 — // 缓存永远是空的。现在加载成功即内部 recordGood()。 // ④ 旧实现的 _sglvChainLoading 一次性锁从不复位 —— 同一 img 节点再次赋值 // src(视图切换复用节点)后 error 全被忽略,永远停在坏图上。现在成功/放弃时复位。 // ⑤ 旧实现链耗尽后还会再试一遍 header.jpg 两连(finalChain),而 buildFullChain // 已经带了 header 兜底 —— 重复请求。现在去重后统一由 chain 覆盖。 // - img 创建时可无 src(不触发浏览器主动加载) // - 链耗尽后异步走 Steam API(不阻塞当前链) // - 失败/超时/API 空: 通知 onGiveUp(宿主替换占位)或设 SVG placeholder function chainLoad(img, appid, kind, opts) { if (!img || !appid) return; const k = (kind && KINDS.indexOf(kind) >= 0) ? kind : 'capsule'; const o = opts || {}; const lang = o.lang || root.__sglvCoverLang || ''; // 1) 同步 good 缓存命中 — 放链首(成功概率最高,避免链首 404) // 只接受 http(s):占位图/脏缓存一律忽略,继续走真实 CDN 链 loadPersisted(); const goodRaw = _goodMap.get(goodKey(k, appid, lang)) || _goodMap.get(goodKey(k, appid, '')); const good = isHttpUrl(goodRaw) ? goodRaw : ''; // 2) 完整链(已含 header.jpg 兜底) const chain = _dedupeChain((good ? [good] : []).concat(buildFullChain(appid, k, lang))); // 3) 从"当前已失败的 src"之后继续,不重复请求同一个 404 const curSrc = (o.skipUrl != null) ? String(o.skipUrl) : _imgSrc(img); let i = 0; const pos = chain.indexOf(curSrc); if (pos >= 0 && pos + 1 < chain.length) i = pos + 1; let done = false; let apiStarted = false; function detach() { img.removeEventListener('error', onError); img.removeEventListener('load', onLoad); img._sglvChainLoading = 0; // 释放一次性锁,允许后续重新接管 } function succeed() { if (done) return; done = true; detach(); const u = img.currentSrc || img.src || ''; recordGood(appid, u, k, lang); if (o.onLoaded) { try { o.onLoaded(u); } catch (e) { /* ignore */ } } } function fail() { if (done) return; done = true; detach(); if (o.onGiveUp) { try { o.onGiveUp(); } catch (e) { /* ignore */ } } // 宿主已替换节点时不再改 src(onGiveUp 内部 replaceChild) if (!o.noPlaceholder && img.isConnected && img.parentElement) img.src = SVG_PLACEHOLDER; } function step() { if (done) return; while (i < chain.length) { const u = chain[i++]; if (!u || u === curSrc) continue; // 刚失败的 URL 不再请求 img.src = u; return; } // 4) 链耗尽 → Steam API 兜底(仅一次) if (!apiStarted) { apiStarted = true; // v2.0.3: 负缓存命中则跳过 API —— 同一 appid+kind 在 TTL 内最多打一次, // 既不会每次渲染都重试(重试风暴),也会在 TTL 后自动恢复(渐进式刷新) if (_negHit(_coverNeg.get(_negKey(k, appid)))) { fail(); return; } Promise.resolve(fetchFromAPI(appid, k)).then(function (url) { if (done) return; if (isHttpUrl(url) && chain.indexOf(url) < 0) { img.src = url; return; } fail(); }, fail); return; } fail(); } function onError() { if (!done) step(); } function onLoad() { if (done) return; // naturalWidth <= 1 视为占位/空图,不写缓存也不算成功 if (!img.naturalWidth || img.naturalWidth < 2) return; succeed(); } img.addEventListener('load', onLoad); img.addEventListener('error', onError); step(); } // ==================== bindErrorHandler (兼容 v1.x API) ==================== // 在 rootEl 上绑定 error/load 委托 — 任何 data-sglv-cover="1" 的 img 出错时自动走 chainLoad // 全局只调一次(bindErrorHandler 内部守卫) function bindErrorHandler(rootEl, onFailed) { if (!rootEl || rootEl.dataset.sglvCoverHandlerBound) return; rootEl.dataset.sglvCoverHandlerBound = '1'; rootEl.addEventListener('error', (e) => { const t = e.target; if (!t || t.tagName !== 'IMG') return; if (t.dataset && t.dataset.sglvCover !== '1') return; if (t._sglvChainLoading) return; const appid = t.dataset.appid; if (!appid) return; t._sglvChainLoading = 1; const kind = t.dataset.kind || 'capsule'; // 记录当前失败 src,从 chain 中跳过(避免对同一个 404 重复发请求) const cur = _imgSrc(t); chainLoad(t, appid, kind, { skipUrl: cur, onGiveUp: () => { if (onFailed) try { onFailed(t); } catch (e) {} }, }); }, true); // capture: 捕获内嵌 img 的 error } // ==================== loadImageWithFallback (兼容 v1.x API) ==================== function loadImageWithFallback(img, onFailed) { if (!img) return; const appid = img.dataset && img.dataset.appid; if (!appid) return; const kind = (img.dataset && img.dataset.kind) || 'capsule'; if (img._sglvChainLoading) return; img._sglvChainLoading = 1; chainLoad(img, appid, kind, { skipUrl: _imgSrc(img), onGiveUp: () => { if (onFailed) try { onFailed(img); } catch (e) {} }, }); } // ==================== v1.x 兼容: steamui 接口(全部返回空)==================== // v2.0 移除 steamui.com 渠道(被 CF managed challenge 拦截,失效) // 保留接口名(返回 null/'')避免宿主调用炸 function getSteamuiAssets(appId, opts) { return Promise.resolve(null); } function getSteamuiGoodUrl(appId, kind, lang) { return ''; } function _resetSteamuiState() { /* no-op */ } // ==================== 公开 API ==================== const api = { // 元信息 version: VERSION, apiVersion: API_VERSION, // 常量 SVG_PLACEHOLDER, CHAIN: ['capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg', 'header.jpg'], KINDS, LANG_VARIANTS, // 链构建 buildFiles, buildFullChain, _dedupeChain, // 同步获取 URL getCoverUrl, resolveCover, // JS 驱动链式加载(主要入口) chainLoad, // 委托绑定 bindErrorHandler, loadImageWithFallback, // 缓存管理 recordGood, // v2.0.2: 宿主成功回调统一写 good 缓存(仅接受 http(s)) flushPersisted, clearPersisted, // v1.x 兼容(steamui 渠道已移除,接口保留) getSteamuiAssets, getSteamuiGoodUrl, _resetSteamuiState, // 内部 API(供宿主 monky-patch / 调试) _goodMap, loadPersisted, setPersisted, isHttpUrl, _safeText, fetchFromAPI, }; root.SGLVCoverFallback = api; if (typeof unsafeWindow !== 'undefined' && unsafeWindow !== root) { try { unsafeWindow.SGLVCoverFallback = api; } catch (e) { /* ignore */ } } return api; })(typeof window !== 'undefined' ? window : globalThis); // ==== SGLV-COVER-FALLBACK-INLINE-END ==== // ==== SGLV-GAME-COLLECTION-INLINE-BEGIN (inline-game-collection.js 自动生成,勿手改) ==== /** * 内联内容由 inline-game-collection.js 生成 —— 请勿手改。 * 来源:libs/sglv-libs-game-collection.lib.js * 改动请编辑库文件后重跑本脚本;verify-game-collection-inline-sync.js 会拦截漏跑。 * * v2.1.0 关键改进: * - seriesCoverImg / seriesCapsuleImg 重写为 data-属性 + 单一 onerror 委托 * (img 创建时无 src,失败时由 _sglvChainFallback → chainLoad JS 接管) * - 3 级下拉面包菜单 (分类 › 供应商 › 系列) 替代 sidebar 列表选择器 * - _bcRenderDetail 统一 4 个 detail 渲染函数 * - 30+ 条 CSS 渐变/圆角/hover 动效 */ // ---------- sglv-libs-game-collection.lib.js (11155 行) ---------- /* * SGLV Game Collection Library v2.1.0 * * ── 版本沿革 ────────────────────────────────────────────── * v2.9.9(PS 会免:独占占位画居中 + 名称显示优化): * ① 占位画居中——蓝色 PS 底纹改绝对定位铺满图片盒 + flex 双向居中,PS Logo 收敛到与卡片 * 协调的尺寸,Logo+名称组稳定落在正中(此前 height:100% 在 aspect-ratio 盒内 + 过大 Logo * 让内容观感偏移、不居中)。 * ② 名称显示——底纹名称降为居中两行省略的小字说明;并给底纹名称挂 data-sglv-psstore, * 让它与卡片正文一样被异步替换成 PS Store 官方名(此前只有正文被替换,底纹残留 * URL slug 拆词产生的 garbled 名,两处不一致)。 * ③ 泛化名守卫——PS Store 页 og:title 整页就是平台名(区域限售回落首页)时解析出 * "PlayStation" 之类泛化词,此前会回填成卡片名;现视为抓取失败保留原名。 * * v2.9.8(年度大作:顶部穿透修复 + TGA 年度游戏殿堂): * ① 顶部内容穿透修复——年度大作滚动区 padding-top:2px 与 sticky 分组标题栏(top:0)组合, * 使吸附后的标题栏停在滚动口顶 +2px,这 2px 缝隙没有栏体遮挡,滚过的彩色封面从中透出。 * 改为滚动区 padding-top:0 + 首元素 margin-top:2px(margin 随内容一起滚走, * 静止时顶部留白不变,吸附时标题栏与滚动口严丝合缝,杜绝穿透)。 * ② TGA 年度游戏殿堂(参考 steam-friend-manager 1.6.2)——年份时间轴最左新增金色「年度游戏」 * 节点,点击进入殿堂:2014–2025 历届 GOTY 冠军竖版海报墙(年份徽标 + 皇冠数=当年获奖次数 + * 在库标记,非 Steam 平台独占用平台配色占位画);点击任一冠军打开「年度游戏 · {year}」提名弹窗 * (左获奖者竖版海报 + 右提名横版网格,Steam 卡点击开商店页,ESC/遮罩/关闭按钮退出)。 * 数据源 SmallFork/json TGA_winners.json(fastly→cdn→raw 三级回退 + IDB 永久缓存 + 24h 后台刷新)。 * ③ 年度大作卡片交叉标记——命中 TGA 年度游戏冠军的卡片加金色 👑 年度游戏 chip,点击直接开该年提名弹窗。 * * v2.9.7(收藏模块:非 Steam 游戏补真实封面): * ① PS 会免 tab 里没有 steamAppId 的 PS 独占条目(实测数据源 1595 条中 293 条) * 此前只有一块蓝色 PS Logo 占位画、没有任何封面——本版给它们补上 PS Store 真实主视觉。 * 做法参考 steam-friend-manager 1.6.2 的非 Steam 卡(sfd-tga-excl-art + sfd-tga-excl-img): * 平台占位画垫底,商店主视觉图绝对定位盖在其上;图加载失败自动移除图,保住占位画。 * ② 封面来源:PS Store 页面内嵌的 schema.org JSON-LD(script#mfe-jsonld-tags → Product.image), * 实测为 1024×1024 方图,CDN image.api.playstation.com 可直链热链(无需 Referer、无需鉴权)。 * 主区域(zh-hans-hk)缺图时自动换 en-us 再试一次(部分地区未发行/限售会缺 SSR 数据)。 * ③ 抓取层重构:fetchPsStoreName 升级为 fetchPsStoreMeta —— 同一次请求同时产出中文名与封面图 * (此前中文名独立抓页,改后二者共用一页,零额外请求);缓存 key 升到 sglvPsStoreMetaCache_v2 * (中文名 + 封面),v1 旧 key 自动迁移,无需清缓存。 * ④ 抓取限流:PS Store 单页 300KB+,加并发闸门(4 路)+ 同 URL 请求去重;缓存整表落盘改 3s 节流 * (单页 49 张卡原本会放大成 49 次整表序列化写入)。 * ⑤ 三个视图统一补封面:卡片(2:3 海报)/ 封面(460×215 横版)/ 列表(92×43 图标); * 方图焦点上移(object-position: center 20%)保住方图上方的游戏 LOGO 区。 * * v2.9.6(年度大作筛选/边距 + KPI 光斑 + Humble 平铺): * ① 年度大作年份时间轴从"滚动定位"改为"真筛选"——点击年份节点只渲染该年份分块 * (gotyState.yearFilter,再点同一年份或「全部」取消);active 由筛选态决定, * 不再被平滑滚动中途漂移;当前筛选下 0 条的年份节点灰显禁用,杜绝"点了没反应"。 * ② 年度大作列表边距收紧——工具栏行/时间轴行留白、年份块(14px)+分组(12px)叠加空隙、 * 分组 header padding 全部压缩;renderGotyScroll 重渲染后回到顶部,消除顶部空白观感。 * ③ KPI 卡片光斑弱化——主脚本 CSS_KPI 的 ::before(全卡渐变 hover:opacity 1)与共享 CSS 库 * 的 ::before(40x40 圆形 blur 8px)层叠成"左上角一团高斯模糊光斑"遮住标签/图标; * sgc-panel 内覆盖为全卡轻渐变(hover 0.5)+数值去发光+选中仅 accent 描边+hover 阴影收敛。 * ④ Humble 月包顶部缩略图全量平铺——去掉"最多 5 个 + +N 折叠块",8 款游戏直接平铺 * (44px 缩略 + flex-wrap 自动换行),右侧空白不再浪费、无需展开即可看全。 * * v2.9.5(DLC 封面绑定修复 + 状态斜切带移除): * ① DLC 分组视图(renderDlcGrouped)从未调用 bindRestrictedCoverLoaders → * wrapper 永远停在 sglv-img-loading(灰色空块)。改为 appendDlcCardsChunked * 每批 append 后统一绑定(幂等),扁平/分组/任意调用方都覆盖。 * ② 移除受限卡片封面右下角的琥珀/绿色状态斜切带(.sglv-own-flag)—— * 7 列紧凑卡片里贴着封面底边,看起来就是"一块黄色背景";拥有态已由徽章表达。 * * v2.9.4(卡片墙竖版化): * ① 系列分类 / PS 会免 / Epic 赠送的默认卡片视图改为竖版海报(2:3)+ 每行 7 列紧凑网格 * ② unifiedCover 新增 preferVertical:poster 走竖版优先链(library_600x900_2x → * library_600x900 → header 兜底),其余 tab 保持原横向链;binder 通过 * data-cover-prefer-vertical 选择同一条链,避免初始 src 与回退链错位 * ③ 竖版图 object-fit:cover 铺满;横版兜底(markLoaded 打 .sglv-img-wide)走 contain letterbox * ④ 三个视图 pageSize 48 → 49(7×7 整排,不留半排) * * v2.9.3(缓存复用 / 渐进式失效 / 增量刷新): * ① 横版封面统一归一到 header 槽位(markLoaded / API 兜底按 URL 形态写,而不是按容器 kind)—— * 受限/PS/Epic/GOTY/主面板共享同一份横版 good URL,不再各探一次 * ② coverGoodUrl 横版类补 header/capsule 交叉读取;restrictedCoverImg 不再把竖版海报塞进横版容器 * ③ API 兜底负缓存 10min(成功结果本会话有效)—— 失败不再永久拉黑,也不会每次渲染重打接口 * ④ GOTY 持久缓存:先读原始值恢复"已被 180 天容器 TTL 清掉"的旧表,容器 TTL 改 10 年, * 条目失效交给"用一次自愈 + LRU 淘汰",避免整表同时过期后全量重爬 * ⑤ markLoaded 同步写宿主共享缓存(Host.recordPosterGood):收藏视图验证过的封面主面板直接复用 * * v2.1.0(从 sglv-libs-core 剥离 + 收藏模块 UI/封面改造): * ① 从 sglv-libs-core IIFE 剥离为独立 lib, 与 sglv-libs-cover-fallback 同步 * ② seriesCoverImg / seriesCapsuleImg 重写为 data-属性 + 单一 onerror 委托 * (img 创建时无 src, 失败时由 _sglvChainFallback → chainLoad JS 接管, * 取代 v1.x 的 20 URL inline string 链; 链长 7-8 砍掉一半多) * * v2.0.0(大版本,配合主脚本 v2.20.0 架构重构): * ① 恢复独立库形态——v2.11.44-hotfix 起收藏模块被内联进主脚本(受限网络下 * 外部 @require 静默失败导致功能全不可用),内联期主脚本侧演进了两个补丁 * (v2.11.49 捆绑包骨架屏 / v2.11.50 Humble Choice 月包分析 Tab)。 * 本版将 1.9.47 + 两个补丁合并收编为独立库正式版本,主脚本 2.20.0 起 * 恢复 @require 外部加载,主脚本仅保留收藏基本界面(菜单/按钮/桥接/降级)。 * ② 代码本体与主脚本 2.11.51 内联块逐字节一致(除本头部),行为零回归。 * ③ apiVersion 保持 1,CollectionHost 桥接契约不变。 * * ── 历史版本(1.9.x,收编自主脚本内联期/原独立库) ────────── * v1.9.50: Humble Choice 月包分析 Tab(数据源 @resource humbleChoiceData, * KPI + 搜索 + 拥有筛选 + 分页网格;借鉴 steam-friend-manager 1.5.2) * v1.9.49: 捆绑包 Tab 骨架屏(数据异步构建中先出骨架,避免闪"暂无进包数据"空态) * v1.9.47: 全面修复绝版游戏/年度大作/锁区/PS/Epic/DLC/捆绑包翻页失效 + 绝版数据兜底链—— * ① 9 处 Host.renderPagination 调用补传第 8 参数 onJump(delisted/psplus/epic/blocked/goty/dlc×2/bundle, * 主脚本 renderPagination 内部所有按钮(首页/上页/下页/末页/跳页)统一走 goPage()→onJump 唯一触发路径, * 缺失 onJump → 翻页按钮全部静默失效 → 用户看到"只能加载第一页/不能翻页"); * ② 绝版数据兜底链:@resource 同步预热失败(Tampermonkey 大文件 3.5MB 偶发静默失败)→ 异步拉远程源三级回退 * (fastly.jsdelivr → cdn.jsdelivr → raw.githubusercontent) + 24h TTL 缓存跨会话秒开 + 6h 后台节流 * 静默刷新;首次进入 tab 显示加载骨架(避免误判为"无数据"),远程源回调内触发 renderDelistedGamesList() 重渲染; * ③ 工具栏新增"从远程源重新加载"按钮(force 走全链路,30s 兜底还原 spinner 防止失败场景卡死); * ④ delistedState.pageSize 50→48 与其他 tab 保持一致; * ⑤ getFilteredDelistedApps 缓存键加入 ownedIds 集合指纹(前 8 个 appid djb2 哈希 + size), * 同数量不同游戏的 owned 集合变化时 subFilter=owned/missing 筛选结果精确失效(此前仅按 size 粗粒度失效, * 新购买/退款的绝版游戏不展示/错误展示) * v1.9.46: 主题层适配(配合主脚本 v2.11.40 典雅紫/经典蓝双主题)——JS 模板中 6 处内联紫色 * (系列徽章/锁区手动徽章/DLC 提示文字/DLC KPI 边与图标/dlcBadgeStyle 常量)改用 * var(--sglv-accent 及派生 token) 并带紫色 fallback,主题切换时随主脚本主题层 :root 即时变色; * 面板 CSS 类级紫色由主脚本主题层 Part E(html[data-sglv-theme="blue"] 前缀高特异性覆盖)处理,本库 CSS 零改动 * v1.9.44: 封面交叉缓存(按页面需求方向取缓存,杜绝样式格式不匹配的缓存地址直用)—— * ① coverGoodUrl 方向严格读取:所有缓存层(宿主会话 good 缓存 / steamui 精确 URL)均以 * _sgcvUrlMatchesKind 校验方向(poster=竖版 library_600x900 族,capsule/header=横版), * 方向不匹配的条目不再直接返回——poster 缓存曾被横版兜底 URL 污染(v1.9.42 起 API 兜底 * 与链尾 header 加载成功即写入 poster 缓存),竖版视图直用导致裁切错位、参差不齐 * ② 竖版缺失标记 + 横版替代交叉缓存:coverVerticalMissing(poster 缓存横版条目 = * 此前 poster 链已耗尽至横版兜底的会话级结论)+ coverHorizontalFallbackUrl * (仅返回 poster 标记条目——capsule/header 缓存存在 ≠ 竖版资产缺失, * 未确认缺失时交叉读取会跳过竖版链探测直接加载错位横图), * 四个 poster 函数(series/blocked/psplus/epic)标记命中时横版替代直用并 letterbox 显示, * 免去每次渲染重爬整条竖版链;竖版未确认缺失时绝不使用横版(保证竖版优先语义) * ③ 竞速兜底方向门控:_sgcvCoverRace 对 API URL 方向校验,poster kind 拒绝横版劫持 * (API filters=basic 恒返回横版 header_image,劫持会压制链上更深处的竖版资产, * 并把横版写入 poster 缓存造成"有竖版资产的游戏显示横版"的会话级污染) * ④ _sgcvPosterOnloadAttr 统一 poster onload(宽图检测 letterbox + good 记录), * blocked/psplus/epic 海报函数补齐 v1.9.43 只加在 seriesCoverImg 的宽图检测 * v1.9.43: 系列封面方向门控 + API 兜底竞速(修复"竖屏封面显示横板封面、横板封面长时间无法加载")—— * ① 根因 A(竖版显示横图):数据源 poster 字段(steam_vediogame_selected.json)实测 340/340 * 全为横版 header URL(并非 v1.9.30 文档假设的竖版 library_600x900_2x),v1.9.30 起 * seriesCoverImg 将其直接作为竖版 kind 初始 src → 2:3 竖版卡片 object-fit:cover 裁切变形, * 且压制了已有竖版资产游戏正常显示。修复:poster 字段方向门控(_sgcvIsVerticalUrl 判定 * library_600x900 族)——竖版 kind 仅接受竖版 URL,横版 kind 仅接受横版 URL; * 竖版 kind 无可用 poster 时走竖版语言变体链(library_600x900_2x 置顶) * ② 根因 B(横版封面黑屏 ~22s):新游戏(未上市/新上架)商店资产仅存在于 * store_item_assets hash 路径,无 hash 传统链(hero 16 + capsule 20 = 36 URL)全部 404, * 串行爬取约 22s 后才触发 appdetails API 兜底。修复:_sgcvCoverRace 竞速兜底—— * 首个 onerror 即后台拉起 Host.fetchCoverApiUrl(宿主三级缓存 + 并发去重),CDN 链 * 继续走,API 先返回且图片尚未加载成功时直接劫持 src(失败场景 ~22s → ~2s) * ③ seriesCapsuleImg 横版 poster 复用:hero 链首未命中后第 2 跳即命中 poster 预验证 * header URL(460x215 与封面视图容器 aspect-ratio 460/215 完全一致) * ④ 竖版无资产 letterbox:poster kind onload 宽图检测(naturalWidth>naturalHeight 追加 * sglv-img-wide 类)→ object-fit:contain + 渐变底,API 兜底返回的横版 header 完整显示不裁切 * ⑤ 竞速兜底同步接入 blocked/psplus/epic 六个封面函数(老游戏无 library 资产场景同样提速) * v1.9.42: 封面高清增强(竖版 2x 默认 + 横版 hero 高清 2x,参考 sglv-cover-fallback.lib.js v1.2.0)—— * ① 横版 capsule 链增强:coverChainLang 对 capsule kind 头部插入 hero 高清增强链 * (library_hero_2x 3840x1240 → library_hero 1920x620 → 原 capsule_467x181 链 → header 兜底), * hero 高清 2x 成为默认横版封面;_heroPrefixChain 去除 hero 链尾 header(保持 capsule 资产 * 优先级高于 header),_dedupeChain 去重;对 8 个封面函数 + GOTY + 系列封面视图全量生效 * ② 新增 heroEnhancedChain(appid):cover-fallback 可用时复用其 hero 链(含中文语言变体 * library_hero_schinese_2x 置顶);不可用时手工构建同序 4CDN 链,可选依赖缺失不降级 * ③ coverGoodUrl:capsule kind 优先 steamui hero 精确 hash URL(image2x 优先), * 其次宿主 good 缓存,最后本 kind steamui URL * ④ 竖版 poster 链保持 2x 置顶断言(library_600x900_2x 高清竖版为默认:cover-fallback 链 / * Host 链 / 数据源 poster 字段三方一致),新增独立测试 sglv-game-collection.lib.test.js 回归 * v1.9.41: 封面链语言变体增强(配合 sglv-cover-fallback.lib.js v1.2.0)—— * ① 新增模块级 coverChainLang(appid, kind) / coverGoodUrl(appid, kind) 辅助: * SGLVCoverFallback 可用时走其完整链(steamui 精确 hash URL 优先 + * 中文界面下语言变体封面 library_600x900_schinese 等置顶 + 4CDN 交叉回退), * 不可用时原样回退 Host.coverChainUrls(向后兼容,行为与 v1.9.40 一致) * ② v2.8.0 起全部封面函数(seriesCoverImg / seriesCapsuleImg / psplusPosterImg / * psplusCapsuleImg / epicPosterImg / epicCapsuleImg / dlcPosterImg / dlcCapsuleImg) * 已统一改走 unifiedCover + bindRestrictedCoverLoaders——good 缓存查询改为同步读取, * 回退链由 addEventListener 驱动(不再依赖跨上下文 inline onerror), * CDN 链换用语言变体链;内联 onerror 链构建逻辑不变 * ③ gotyCoverImgTag / _bindGotyCoverDelegation 同步换链(GOTY good 缓存保持独立) * ④ 可选依赖、零破坏:cover-fallback 未加载或 kind 不受支持时自动降级原 Host 链 * v1.9.40: 系列分类工具栏更新时间提示移除—— * ① 移除常驻状态文本 #sglv-pub-status("更新: 日期"),该文本为不换行 flex 项, * 会压缩供应商/系列切换按钮(flex:1)导致按钮变形 * ② 刷新结果摘要改为悬浮提示 .sglv-pub-refresh-toast:绝对定位于模式切换条下方, * 不参与 flex 布局,3 秒后自动消失,保留刷新反馈但零布局影响 * ③ 移除 pubState.supplierFetchedAt/seriesFetchedAt 及相关赋值(配合宿主 v2.9.113) * v1.9.39: 系列详情工具栏布局重构—— * ① 修复工具栏操作选项超出面板宽度导致横向滚动条的问题:改用 flex-wrap 布局, * 控件按逻辑分组(完成度 / 拥有筛选 / 排序 / 视图),窄面板自动换行 * ② 拥有状态筛选从下拉菜单改为"已拥有"/"未拥有"两个切换标签按钮, * 未选中任何标签表示查询全部;点击已激活按钮可取消筛选回到全部 * v1.9.38: PS会免刷新无数据根因修复 + 移除手动刷新按钮—— * ① 修复刷新后数据消失:_mergeByAppid 按 appid 合并,但 PS会免条目字段为 * recordId/steamAppId(无 appid),非空状态合并时全部条目被丢弃为空数组, * 且空数组写回永久缓存导致后续加载也空白。上游为全量快照(psplus-data-sync.js * 每日全量镜像),改为远程快照整体替换 state 与缓存,不再增量合并 * ② 移除工具栏刷新按钮(数据变更频率低,无需手动更新),保留 6h 后台节流静默刷新 * ③ 删除 force 刷新相关代码:loadPsplusGames force 分支、_refreshPsplusFromRemote * force 参数/spinner/状态文本、psplusState.refreshResult/showResultUntil、 * #sglv-ps-refresh spinner CSS;renderPsplusRefreshStatus 简化为仅显示更新时间 * v1.9.37: PS会免降级数据源—— * ① 新增 PSPLUS_BACKUP_URLS 三级 CDN 回退链(SmallRob/steam-namespace 镜像, * 由 js_script/psplus-data-sync.js 每日同步自主源 oXnMe/psplus-steam-overlay) * ② _refreshPsplusFromRemote 改为主源 + 降级源多级回退(_tryNextUrl 模式): * 主源失败(网络错误/超时/非2xx/格式无效)时依次尝试 3 个镜像源, * 全部失败才报错并尝试缓存恢复(参照 EPIC_GITHUB_URLS _tryNextGithubSource 写法) * v1.9.35: 系列分类筛选与封面视图优化—— * ① 移除点击进度条筛选逻辑,改为筛选下拉菜单(全部/已拥有/未拥有,默认全部), * 进度条保留为纯视觉收集完成度指示器(不可点击) * v1.9.34: 系列详情工具栏布局重构——工具栏操作选项超出面板宽度导致横向滚动条, * 改用 flex-wrap 布局,控件按逻辑分组(完成度 / 拥有筛选 / 排序 / 视图),窄面板自动换行 * v1.9.30: 系列封面竖版化(数据源 poster 字段作为竖版 kind 初始 src) */ window.SGLVCollection = (function () { /** * HTML 转义助手(v2.42.2 新增)。 * 背景:供应商名 / 系列名来自**远程可改写**的 game-db JSON * (@resource steam-namespace@main/data/game-db-v1.2.json), * 之前直接拼进 innerHTML —— 远程数据一旦被投毒,即可在 Steam 页面上执行任意脚本。 * 本库此前**完全没有**转义助手(其它模块各自有 escHtml,这里补齐)。 */ function sgcEsc(v) { return String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } 'use strict'; // ---- 桥接宿主(由 init(ctx) 注入) ---- let Host = null; const apiVersion = 1; // ---- SGLVCore 依赖(sglv-core.lib.js 提供的零业务基础设施;缺失时所有调用走 noop 兜底) ---- // v1.9.1: DLC 渐进式加载复用 makeThrottledProgress / concurrentPool,避免每批数据都触发主线程渲染风暴 const _C = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVCore) || window.SGLVCore || null; const _throttled = _C && typeof _C.makeThrottledProgress === 'function' ? _C.makeThrottledProgress : null; const _StageProgress = _C && _C.StageProgress ? _C.StageProgress : null; // ---- v1.9.41: 封面链语言变体增强(sglv-cover-fallback.lib.js v1.2.0 可选依赖)---- // ---- v1.9.42: 竖版 2x 默认 + 横版 hero 高清 2x 增强 ---- // 惰性解析:cover-fallback 与本库的加载顺序不作假设,每次调用时取 function _coverFb() { try { return (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVCoverFallback) || window.SGLVCoverFallback || null; } catch (e) { return null; } } // 中文界面请求语言变体封面(library_600x900_schinese 等),其余界面用默认资产 function coverLang() { return (Host && Host.isZh) ? 'schinese' : null; } // v2.0.0: 4 CDN 前缀(顺序与 sglv-cover-fallback.lib.js v2.0.0 一致:cdn.cloudflare 链首, // 老游戏最稳;新游戏哈希子目录走 store_item_assets/akamai/fastly 兜底) // v1.9.42 用 shared.akamai 优先,新游戏走哈希子目录命中,但老游戏 4-5 次 404 浪费 const _COVER_CDNS = [ 'https://cdn.cloudflare.steamstatic.com/steam/apps/', 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/', 'https://cdn.akamai.steamstatic.com/steam/apps/', 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/', ]; // v1.9.42: 横版 hero 高清 2x 增强链(library_hero_2x 3840x1240 → library_hero 1920x620) // cover-fallback 可用 → 复用其 hero 链(中文语言变体 library_hero_schinese_2x 置顶 + 2x 先于非 2x + 4CDN + header 兜底); // 不可用 → 手工构建同序链,保证可选依赖缺失时横版增强不降级 function heroEnhancedChain(appid) { try { const fb = _coverFb(); if (fb && typeof fb.buildFullChain === 'function' && Array.isArray(fb.KINDS) && fb.KINDS.indexOf('hero') >= 0) { const chain = fb.buildFullChain(appid, 'hero', coverLang()); if (Array.isArray(chain) && chain.length) return chain; } } catch (e) { /* fallthrough */ } const lang = coverLang(); const files = []; if (lang) files.push('library_hero_' + lang + '_2x.jpg', 'library_hero_' + lang + '.jpg'); files.push('library_hero_2x.jpg', 'library_hero.jpg'); const out = []; for (const f of files) for (const cdn of _COVER_CDNS) out.push(cdn + appid + '/' + f); return out; } // v1.9.42: 链去重(保留首次出现顺序) function _dedupeChain(chain) { const seen = new Set(); const out = []; for (const u of chain) if (!seen.has(u)) { seen.add(u); out.push(u); } return out; } // v1.9.42: hero 前缀链去除尾部 header 兜底(header 统一留在整链末尾, // 保证 capsule_467x181 等横版资产的优先级高于 header) function _heroPrefixChain(appid) { return heroEnhancedChain(appid).filter(u => u.slice(-11) !== '/header.jpg'); } // 封面完整回退链:cover-fallback 链(steamui 精确 URL 语境 + 语言变体置顶 + 4CDN) // → 降级 Host.coverChainUrls(v1.9.40 原行为) // v1.9.42: ① poster 竖版链保持 2x 置顶(library_600x900_2x 高清竖版为默认: // cover-fallback 链 / Host 链均保证 2x 先于 1x,数据源 poster 字段同为 2x URL) // ② capsule 横版链增强:头部插入 hero 高清 2x 增强链(library_hero_2x 为默认横版封面), // 原 capsule_467x181 链降级为 hero 缺失时的回退 function coverChainLang(appid, kind) { try { const fb = _coverFb(); if (fb && typeof fb.buildFullChain === 'function' && Array.isArray(fb.KINDS) && fb.KINDS.indexOf(kind) >= 0) { let chain = fb.buildFullChain(appid, kind, coverLang()); if (kind === 'capsule') chain = _heroPrefixChain(appid).concat(chain); if (Array.isArray(chain) && chain.length) return _dedupeChain(chain); } } catch (e) { /* fallthrough */ } const host = Host.coverChainUrls(appid, kind); return kind === 'capsule' ? _dedupeChain(_heroPrefixChain(appid).concat(host)) : host; } // good 缓存:v1.9.42 起 capsule 横版优先 steamui hero 精确 hash URL(image2x 优先,即 hero 高清 2x), // 其次宿主会话级缓存,最后 cover-fallback 的本 kind steamui 精确 hash URL(同步缓存命中) // v1.9.44: 交叉缓存方向门控——所有读取均校验 URL 方向与 kind 匹配(poster=竖版,其余=横版), // 方向不匹配的缓存条目(poster 缓存被横版兜底写入、capsule 缓存混入竖版)不再直接返回, // 防止竖版视图直用横版缓存地址导致裁切错位(或反向);横版替代走 coverHorizontalFallbackUrl function _sgcvUrlMatchesKind(u, kind) { if (typeof u !== 'string' || !u) return false; const v = _sgcvIsVerticalUrl(u); return (kind === 'poster') ? v : !v; } function coverGoodUrl(appid, kind) { if (kind === 'capsule') { try { const fb = _coverFb(); if (fb && typeof fb.getSteamuiGoodUrl === 'function') { const heroUrl = fb.getSteamuiGoodUrl(appid, 'hero', coverLang()); if (heroUrl && _sgcvUrlMatchesKind(heroUrl, kind)) return heroUrl; } } catch (e) { /* fallthrough */ } } const good = Host.getPosterGood(appid, kind); if (good && _sgcvUrlMatchesKind(good, kind)) return good; // v2.9.3: 横版视图共享 header 槽位 —— 受限/PS/Epic/GOTY/主面板验证过的横版 URL // 统一归一到 header(markLoaded / API 兜底也是这么写的),这里补上交叉读取, // 避免同一张 header.jpg 在每个视图各探一次整链 if (kind !== 'poster') { const alts = (kind === 'header') ? ['capsule'] : ['header', 'capsule']; for (const ak of alts) { let u = ''; try { u = Host.getPosterGood(appid, ak) || ''; } catch (e) { /* ignore */ } if (u && _sgcvUrlMatchesKind(u, kind)) return u; } } try { const fb = _coverFb(); if (fb && typeof fb.getSteamuiGoodUrl === 'function') { const u = fb.getSteamuiGoodUrl(appid, kind, coverLang()) || ''; if (u && _sgcvUrlMatchesKind(u, kind)) return u; } } catch (e) { /* fallthrough */ } // v2.0.0: 不再 fb.getCoverUrl 兜底(会污染"已验证好 URL"语义,且对 capsule kind // 返回 capsule_467x181.jpg 而非 hero 链首,让 seriesCapsuleImg 错失 hero 增强) // 调用方应自己取 coverChainLang(appid, kind)[0] 作为初始 src return ''; } // v1.9.44: 竖版缺失判定——poster 缓存存在横版条目 = 此前 poster 链已耗尽至横版兜底 // (poster 链竖版优先,poster 上下文加载出横版 ⇒ 竖版资产确认缺失的会话级标记) function coverVerticalMissing(appid) { try { const p = Host.getPosterGood(appid, 'poster'); return !!(p && !_sgcvIsVerticalUrl(p)); } catch (e) { return false; } } // v1.9.44: 横版替代交叉缓存——仅竖版确认缺失(poster 缓存横版标记条目)时返回该标记条目 // (链尾 header 兜底,2.14:1 最贴竖版容器 letterbox),未确认缺失时一律为空: // capsule/header 缓存存在 ≠ 竖版资产缺失(横竖是各自独立文件),未确认即交叉读取 // 会令竖版视图跳过竖版链探测、直接加载错位横图 function coverHorizontalFallbackUrl(appid) { try { const p = Host.getPosterGood(appid, 'poster'); return (p && !_sgcvIsVerticalUrl(p)) ? p : ''; } catch (e) { return ''; } } // ---- v1.9.43: 封面方向门控 + API 兜底竞速 ---- // 竖版资产判定:library_600x900 族(含语言/2x 变体);header/capsule/hero/page_bg 均为横版。 // 数据源 poster 字段实测全为横版 header URL,v1.9.30-1.9.42 将其当作竖版初始 src // 导致 2:3 竖版卡片显示横图裁切变形 function _sgcvIsVerticalUrl(u) { return typeof u === 'string' && u.indexOf('library_600x900') >= 0; } // API 兜底竞速:首个 onerror 即后台拉起 appdetails(Host.fetchCoverApiUrl 三级缓存 + // 并发去重),CDN 链继续串行走;API 先返回且图片尚未加载成功时直接劫持 src。 // 新游戏(资产仅存在于 hash 路径、无 hash 链全 404)从 ~36 次串行 404(约 22s) // 缩短到 ~2-3 次请求;老游戏链首即命中,竞速不触发任何请求 function _sgcvCoverRaceImpl(img, appid, kind) { if (!img || img._sgcvRace) return; img._sgcvRace = 1; try { if (!Host || typeof Host.fetchCoverApiUrl !== 'function') return; Host.fetchCoverApiUrl(appid, kind).then(function (url) { if (!url || !img.isConnected || img.src === url) return; // v1.9.44: 方向门控——API URL 与 kind 方向不匹配时不劫持。 // poster kind 的 API 兜底(filters=basic)恒为横版 header_image:劫持会压制 // 链上更深处仍可能命中的竖版资产,并把横版写入 poster 缓存造成 // "有竖版资产的游戏显示横版"的会话级污染;横版兜底由链尾 header + _sglvPosterFallback 承担 if (!_sgcvUrlMatchesKind(url, kind)) return; // CDN 链已加载成功(onload 已加 sglv-img-loaded 类)则不干预 if (img.classList.contains('sglv-img-loaded') || (img.complete && img.naturalWidth > 0)) return; // API URL 失败 → 占位(先替换 onerror,防止链式 handler 死循环) img.onerror = function () { img.onerror = null; _sgcvToPlaceholder(img); }; img.src = url; }).catch(function () { /* API 失败:CDN 链继续走,不干预 */ }); } catch (e) { /* Host 契约缺失:降级为纯链行走 */ } } // 竞速 URL 加载失败的占位(与宿主 _posterImgToPlaceholder 同款,仅替换 img 节点保留徽章浮层) function _sgcvToPlaceholder(img) { if (!img || !img.parentElement) return; const holder = document.createElement('div'); holder.className = img.className; holder.style.cssText = 'display:flex;align-items:center;justify-content:center;color:#5a6a7a;font-size:11px'; holder.textContent = (Host && Host.isZh) ? '无图片' : 'No Image'; img.parentElement.replaceChild(holder, img); } // 挂载到页面上下文(内联 onerror 属性在页面上下文执行,与宿主 _sglvPosterFallback 同机制) try { const _sgcvPageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; _sgcvPageWin._sgcvCoverRace = _sgcvCoverRaceImpl; } catch (e) { /* ignore */ } // v1.9.44: poster kind 通用 onload 属性——宽图检测(横版兜底/替代 letterbox 显示)+ good 记录 function _sgcvPosterOnloadAttr(appid) { return 'this.classList.add(\'sglv-img-loaded\');' + 'if(this.naturalWidth>this.naturalHeight)this.classList.add(\'sglv-img-wide\');' + '_sglvRecordPosterGood(\'' + appid + '\',this.src,\'poster\');'; } // v1.9.44: 竖版缺失标记快速路径 HTML——竖版确认缺失后横版替代直用(交叉缓存), // 免去每次渲染重爬整条竖版链(8 个 CDN 404 + header 兜底) function _sgcvHorizontalFallbackImgHtml(appid, name, cls, fbFn) { const url = coverHorizontalFallbackUrl(appid); if (!url) return ''; return '' + name +
            ''; } // v2.9.22: sgc-panel 紧凑 CSS(仿主面板 v2.9.22 页头风格 + 品牌图标 + 拖动 + 骨架占位) const SGC_PANEL_CSS = ` /* === 紧凑 header:去除"游戏收藏"标题,保留左侧 Steam 图标 + 中间 tab-bar + 右侧关闭 === */ .sgc-panel > .sglv-header { padding: 8px 14px !important; gap: 12px; cursor: move; /* header 作为拖动手柄 */ user-select: none; background: linear-gradient(135deg, rgba(27, 40, 56, 0.95) 0%, rgba(42, 71, 94, 0.95) 100%) !important; border-bottom: 1px solid rgba(102, 192, 244, 0.18) !important; } .sgc-panel > .sglv-header h2 { display: none !important; } .sgc-panel > .sglv-header > .sglv-tab-bar { display: flex; align-items: center; gap: 2px; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; } .sgc-panel > .sglv-header > .sglv-tab-bar::-webkit-scrollbar { display: none; } /* === 保留最左侧 Steam 图标作为品牌标识 === */ .sglv-header-brand { width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(102, 192, 244, 0.18), rgba(139, 92, 246, 0.18)); border: 1px solid rgba(102, 192, 244, 0.25); color: #66c0f4; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); transition: transform 0.2s, box-shadow 0.2s; } .sglv-header-brand:hover { transform: rotate(-8deg) scale(1.05); box-shadow: 0 2px 8px rgba(102, 192, 244, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1); } .sglv-header-brand img { width: 14px; height: 14px; display: block; border-radius: 2px; } /* === tab 按钮紧凑化(覆盖 shared css 的 flex:1 + 大 padding) === */ .sgc-panel .sglv-tab { flex: 0 0 auto !important; padding: 5px 12px !important; font-size: 12.5px !important; font-weight: 600 !important; border-radius: 8px !important; border: 1px solid transparent !important; background: transparent !important; color: #94a3b8 !important; white-space: nowrap !important; gap: 5px !important; transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s !important; } .sgc-panel .sglv-tab:hover { color: #e2e8f0 !important; background: rgba(255, 255, 255, 0.06) !important; } .sgc-panel .sglv-tab.active { color: #fff !important; background: rgba(102, 192, 244, 0.22) !important; border-color: rgba(102, 192, 244, 0.4) !important; box-shadow: 0 0 0 1px rgba(102, 192, 244, 0.15) inset, 0 1px 4px rgba(102, 192, 244, 0.18) !important; } .sgc-panel .sglv-tab svg { width: 14px; height: 14px; flex-shrink: 0; } /* === 隐藏原 .sglv-tabs 单独行(兼容老代码,防止双重显示) === */ .sgc-panel .sglv-tabs { display: none !important; } /* === 关闭按钮位置 === */ .sgc-panel > .sglv-header > .sglv-header-actions { gap: 6px; flex-shrink: 0; margin-left: auto; } /* === v2.9.22: Epic免费标签 → 封面右下角(card + cover 两种视图) === */ .sglv-card-img-wrap > .sglv-epic-free-badge, .sglv-wl-cover-img-wrap > .sglv-epic-free-badge { position: absolute !important; bottom: 6px !important; right: 6px !important; top: auto !important; left: auto !important; margin-top: 0 !important; background: #ff69b4 !important; color: #fff !important; padding: 2px 6px !important; border-radius: 3px !important; font-size: 10px !important; font-weight: 700 !important; z-index: 2 !important; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35) !important; backdrop-filter: blur(4px) !important; -webkit-backdrop-filter: blur(4px) !important; } /* === v2.9.22: GotY"年度大作"标签 → 封面右下角(card + cover)== 风格与 Epic免费一致 === */ .sglv-card-img-wrap > .sglv-goty-goty-badge, .sglv-wl-cover-img-wrap > .sglv-goty-goty-badge { position: absolute !important; bottom: 6px !important; right: 6px !important; top: auto !important; left: auto !important; margin-top: 0 !important; background: linear-gradient(135deg, #f59e0b, #ef4444) !important; color: #fff !important; padding: 2px 6px !important; border-radius: 3px !important; font-size: 10px !important; font-weight: 700 !important; z-index: 2 !important; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35) !important; backdrop-filter: blur(4px) !important; -webkit-backdrop-filter: blur(4px) !important; } /* === v2.9.22: cover 视图下方信息行统一间距(Epic / Delisted / GotY 一致) === */ .sglv-wl-cover-body .sglv-epic-owned-row, .sglv-wl-cover-body .sglv-blocked-owned-row, .sglv-wl-cover-body .sglv-goty-owned-row { margin-top: 2px; gap: 4px; } /* === v2.9.22: 供应商列表视图封面列 === */ .sglv-pub-list-col.cover { padding-right: 8px; } .sglv-pub-list-cover { width: 80px; height: 36px; object-fit: cover; border-radius: 4px; display: block; background: rgba(255, 255, 255, 0.05); } /* === v1.9.16: 供应商/系列游戏 genres 类型标签(可选字段,无 genres 时不渲染) === */ .sglv-pub-genre-tags { display: inline-flex; gap: 3px; margin-left: 6px; vertical-align: 1px; flex-wrap: wrap; } .sglv-pub-genre-tag { font-size: 10px; line-height: 1; padding: 2px 5px; border-radius: 3px; background: rgba(102, 192, 244, 0.12); color: #9ecfea; white-space: nowrap; } .sglv-card-genre-row { display: flex; gap: 3px; flex-wrap: wrap; margin-top: 3px; } /* === v2.9.22: GotY 状态行紧凑布局(owned badge + 🕐 发售日期同行) === */ .sglv-goty-status-row { display: flex !important; align-items: center !important; gap: 6px !important; flex-wrap: wrap !important; margin-top: 4px !important; min-height: 18px !important; } .sglv-goty-status-row .sglv-card-badge { display: inline-flex !important; align-items: center !important; font-size: 10px !important; padding: 2px 7px !important; margin-top: 0 !important; flex-shrink: 0 !important; } .sglv-goty-date { display: inline-flex !important; align-items: center !important; gap: 3px !important; font-size: 10px !important; color: var(--sglv-text-secondary, #94a3b8) !important; font-variant-numeric: tabular-nums !important; margin-left: auto !important; white-space: nowrap !important; } /* v1.0.4: 发售倒计时徽章(参考 friend-manager sfd-stress-featured-countdown) 三态:future(将发售,亮橙)/ today(今日,amber 强调+外发光)/ past(已发售,暗灰) */ .sglv-goty-countdown { display: inline-flex !important; align-items: center !important; gap: 2px !important; margin-left: 4px !important; padding: 1px 6px !important; border-radius: 8px !important; font-size: 9.5px !important; line-height: 1.2 !important; font-variant-numeric: tabular-nums !important; transition: all .18s var(--sglv-transition, ease) !important; } .sglv-goty-countdown-days { font-weight: 800 !important; font-size: 11px !important; letter-spacing: -.3px !important; } .sglv-goty-countdown-lbl { opacity: .85 !important; font-weight: 500 !important; } .sglv-goty-countdown--future { background: linear-gradient(135deg, rgba(245, 158, 11, 0.18), rgba(255, 159, 67, 0.10)) !important; border: 1px solid rgba(245, 158, 11, 0.4) !important; color: #f59e0b !important; box-shadow: 0 0 6px rgba(245, 158, 11, 0.18) !important; } .sglv-goty-countdown--future .sglv-goty-countdown-days { color: #fbbf24 !important; } .sglv-goty-countdown--today { background: linear-gradient(135deg, rgba(34, 197, 94, 0.22), rgba(74, 222, 128, 0.14)) !important; border: 1px solid rgba(34, 197, 94, 0.5) !important; color: #22c55e !important; box-shadow: 0 0 8px rgba(34, 197, 94, 0.3) !important; animation: sglv-goty-today-pulse 1.8s ease-in-out infinite !important; } .sglv-goty-countdown--today .sglv-goty-countdown-days { color: #4ade80 !important; } @keyframes sglv-goty-today-pulse { 0%, 100% { box-shadow: 0 0 8px rgba(34, 197, 94, 0.3); } 50% { box-shadow: 0 0 14px rgba(34, 197, 94, 0.55); } } .sglv-goty-countdown--past { background: rgba(148, 163, 184, 0.08) !important; border: 1px solid rgba(148, 163, 184, 0.18) !important; color: var(--sglv-text-muted, #64748b) !important; } .sglv-goty-countdown--past .sglv-goty-countdown-days { color: var(--sglv-text-muted, #64748b) !important; } .sglv-goty-date .sglv-goty-clock { width: 11px !important; height: 11px !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; opacity: 0.6 !important; flex-shrink: 0 !important; } .sglv-goty-date .sglv-goty-clock svg { width: 11px !important; height: 11px !important; } /* list 视图中的 goty-date 样式(不要 right-push,跟其他 meta 元素 inline) */ .sglv-list-meta .sglv-goty-date { margin-left: 0 !important; gap: 3px !important; } /* === v2.x: GotY 时光机 —— 年份时间轴 + 分组 + 焦点卡 + 网格(参考 steam-friend-manager sfd-stress-*)=== */ /* v2.x: GotY 顶部紧凑 —— KPI 卡片覆盖(只影响 goty,不污染其他 tab 共享的 .sglv-trend-kpi-card) */ #sglv-goty-kpi .sglv-trend-kpi-row { gap: 6px !important; margin: 4px 0 2px !important; } #sglv-goty-kpi .sglv-trend-kpi-card { padding: 6px 10px !important; min-width: 0 !important; } #sglv-goty-kpi .sglv-trend-kpi-header { margin-bottom: 2px !important; gap: 4px !important; } #sglv-goty-kpi .sglv-trend-kpi-value { font-size: 18px !important; line-height: 1.1 !important; } #sglv-goty-kpi .sglv-trend-kpi-label { font-size: 11px !important; } #sglv-goty-kpi .sglv-trend-kpi-sub { font-size: 10px !important; margin-top: 1px !important; } #sglv-goty-kpi .sglv-trend-kpi-icon { font-size: 13px !important; } /* 年份时间轴:渐变主线 + 发光年份节点,点击切换年份(显示/隐藏对应年份块) */ .sglv-goty-timeline { position: relative; display: flex; align-items: flex-start; padding: 6px 20px 2px; margin: 0 6px 4px; flex: none; gap: 0; justify-content: flex-start; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; } .sglv-goty-timeline::-webkit-scrollbar { height: 4px; } .sglv-goty-timeline::-webkit-scrollbar-thumb { background: rgba(102, 192, 244, 0.2); border-radius: 2px; } .sglv-goty-tl-line { position: absolute; top: 10px; left: 20px; right: 20px; height: 2px; background: linear-gradient(90deg, transparent, #06cfbe 12%, #66c0f4 50%, #a78bfa 88%, transparent); opacity: 0.7; pointer-events: none; } .sglv-goty-tl-node { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; background: none; border: none; cursor: pointer; padding: 0 6px; font-family: inherit; color: inherit; flex-shrink: 0; } .sglv-goty-tl-node:hover .sglv-goty-tl-dot { transform: scale(1.4); border-color: #66c0f4; box-shadow: 0 0 12px rgba(102, 192, 244, 0.8); } .sglv-goty-tl-node.sglv-goty-tl-active .sglv-goty-tl-dot { background: #66c0f4; border-color: #66c0f4; box-shadow: 0 0 14px rgba(102, 192, 244, 0.95), 0 0 4px rgba(255, 255, 255, 0.6); } .sglv-goty-tl-dot { width: 7px; height: 7px; border-radius: 50%; background: #0f172a; border: 2px solid rgba(102, 192, 244, 0.55); transition: all 0.2s; box-shadow: 0 0 6px rgba(102, 192, 244, 0.35); } .sglv-goty-tl-year { font-size: 10px; font-weight: 700; color: #64748b; letter-spacing: 0.5px; transition: color 0.2s, text-shadow 0.2s; white-space: nowrap; } .sglv-goty-tl-node:hover .sglv-goty-tl-year { color: #9fd0f5; } .sglv-goty-tl-node.sglv-goty-tl-active .sglv-goty-tl-year { color: #66c0f4; text-shadow: 0 0 10px rgba(102, 192, 244, 0.7); } .sglv-goty-tl-count { font-size: 8px; color: #94a3b8; font-weight: 600; line-height: 1; } .sglv-goty-tl-node.sglv-goty-tl-active .sglv-goty-tl-count { color: #66c0f4; } /* 年份分块容器(可滚动) */ .sglv-goty-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; padding-right: 2px; } .sglv-goty-year-block { margin-bottom: 14px; } /* 分组(未发售 / 已发售)sticky header */ .sglv-goty-group { margin-bottom: 12px; } .sglv-goty-group-header { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: #1a2233; border-radius: 8px; margin-bottom: 8px; position: sticky; top: 0; z-index: 10; border: 1px solid rgba(102, 192, 244, 0.12); cursor: pointer; user-select: none; transition: background 0.15s; } .sglv-goty-group-header:hover { background: #1e2940; } .sglv-goty-group-caret { width: 0; height: 0; border-left: 5px solid currentColor; border-top: 4px solid transparent; border-bottom: 4px solid transparent; color: #94a3b8; transition: transform 0.2s; flex-shrink: 0; } .sglv-goty-group-header.sglv-goty-group-collapsed .sglv-goty-group-caret { transform: rotate(-90deg); } .sglv-goty-group-title { font-size: 14px; font-weight: 700; color: #f8fafc; flex-shrink: 0; } .sglv-goty-group-count { font-size: 12px; color: #94a3b8; flex-shrink: 0; } .sglv-goty-group-bar { flex: 1; height: 8px; background: #0f172a; border-radius: 4px; overflow: hidden; min-width: 60px; } .sglv-goty-group-bar-fill { height: 100%; background: linear-gradient(90deg, #4caf50, #66c0f4); transition: width 0.3s ease; } .sglv-goty-group-pct { font-size: 12px; font-weight: 600; color: #94a3b8; flex-shrink: 0; min-width: 40px; text-align: right; } .sglv-goty-group-body { display: block; } .sglv-goty-group-header.sglv-goty-group-collapsed + .sglv-goty-group-body { display: none; } /* 焦点大卡(未发售分组前 1-2 个,宽海报贴边) */ .sglv-goty-featured-wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px; } .sglv-goty-featured { display: flex; align-items: center; gap: 0; border-radius: 10px; overflow: hidden; background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), rgba(255, 159, 67, 0.04)); border: 1px solid rgba(245, 158, 11, 0.3); cursor: pointer; transition: transform 0.15s, border-color 0.2s, box-shadow 0.2s; padding: 0 10px 0 0; } .sglv-goty-featured:hover { transform: translateY(-2px); border-color: rgba(245, 158, 11, 0.55); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); } .sglv-goty-featured.owned { border-color: rgba(76, 175, 80, 0.4); background: linear-gradient(135deg, rgba(76, 175, 80, 0.08), rgba(102, 187, 106, 0.04)); } .sglv-goty-featured-poster { width: 190px; aspect-ratio: 460/215; object-fit: cover; display: block; flex-shrink: 0; border-radius: 0; } .sglv-goty-featured-info { min-width: 0; display: flex; flex-direction: column; gap: 4px; padding: 10px 14px 10px 12px; flex: 1; } .sglv-goty-featured-badge { display: inline-block; align-self: flex-start; font-size: 11px; font-weight: 700; color: #f59e0b; background: rgba(245, 158, 11, 0.15); border: 1px solid rgba(245, 158, 11, 0.3); padding: 1px 8px; border-radius: 4px; line-height: 1.4; } .sglv-goty-featured.owned .sglv-goty-featured-badge { color: #66bb6a; background: rgba(102, 187, 106, 0.2); border-color: rgba(102, 187, 106, 0.4); } .sglv-goty-featured-name { font-size: 15px; font-weight: 700; color: #f8fafc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1.3; } .sglv-goty-featured-date { font-size: 12px; color: #94a3b8; } .sglv-goty-featured-countdown { display: flex; flex-direction: column; align-items: center; justify-content: center; flex-shrink: 0; padding: 0 4px; min-width: 64px; } .sglv-goty-featured-days { font-size: 26px; font-weight: 800; color: #ff9f43; line-height: 1; } .sglv-goty-featured.owned .sglv-goty-featured-days { color: #66bb6a; } .sglv-goty-featured-days-lbl { font-size: 10px; color: #f59e0b; margin-top: 3px; white-space: nowrap; text-transform: uppercase; letter-spacing: 0.5px; } .sglv-goty-featured.owned .sglv-goty-featured-days-lbl { color: #66bb6a; } /* 网格 5 列(共用 card / cover / list 三种视图) */ .sglv-goty-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; } @media (max-width: 1100px) { .sglv-goty-grid { grid-template-columns: repeat(5, 1fr); } } @media (max-width: 800px) { .sglv-goty-grid { grid-template-columns: repeat(4, 1fr); } } .sglv-goty-card { display: flex; flex-direction: column; border-radius: 9px; background: rgba(15, 23, 42, 0.5); border: 1px solid rgba(102, 192, 244, 0.18); overflow: hidden; cursor: pointer; transition: border-color 0.2s, transform 0.15s, opacity 0.2s, box-shadow 0.2s; } .sglv-goty-card:hover { border-color: rgba(102, 192, 244, 0.55); transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); } .sglv-goty-card-img { width: 100%; aspect-ratio: 2/3; object-fit: cover; display: block; background: #0f172a; } .sglv-goty-card-img-wrap { position: relative; } .sglv-goty-card-body { padding: 4px 6px 5px; display: flex; flex-direction: column; gap: 2px; } .sglv-goty-card-name { font-size: 11px; font-weight: 600; color: #f1f5f9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1.25; } .sglv-goty-card-meta { display: flex; align-items: center; gap: 4px; font-size: 10px; color: #94a3b8; line-height: 1.2; } .sglv-goty-card-owned { display: inline-flex; align-items: center; gap: 2px; font-size: 9px; font-weight: 700; color: #66bb6a; background: rgba(102, 187, 106, 0.18); border: 1px solid rgba(102, 187, 106, 0.35); padding: 1px 5px; border-radius: 3px; line-height: 1.3; } .sglv-goty-card-not-owned { display: inline-flex; align-items: center; font-size: 9px; font-weight: 600; color: #94a3b8; background: rgba(148, 163, 184, 0.1); border: 1px solid rgba(148, 163, 184, 0.18); padding: 1px 5px; border-radius: 3px; line-height: 1.3; } .sglv-goty-card-countdown { font-size: 9.5px; font-weight: 700; line-height: 1.3; } .sglv-goty-card-countdown.future { color: #f59e0b; } .sglv-goty-card-countdown.today { color: #66c0f4; } .sglv-goty-card-countdown.past { color: #64748b; } /* list 视图(行项) */ .sglv-goty-list-view { display: flex; flex-direction: column; gap: 4px; } .sglv-goty-list-item { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 6px; background: rgba(15, 23, 42, 0.4); border: 1px solid rgba(102, 192, 244, 0.12); cursor: pointer; transition: background 0.15s, border-color 0.15s; } .sglv-goty-list-item:hover { background: rgba(15, 23, 42, 0.7); border-color: rgba(102, 192, 244, 0.35); } .sglv-goty-list-icon { width: 60px; height: 30px; object-fit: cover; border-radius: 4px; flex-shrink: 0; } .sglv-goty-list-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .sglv-goty-list-name { font-size: 13px; font-weight: 600; color: #f1f5f9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-goty-list-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #94a3b8; } /* 工具条 + 顶部 chip 行(回归一行 chip 风格,与 sglv 主色一致) */ .sglv-goty-toolbar-row { display: flex; align-items: center; gap: 6px; padding: 4px 8px 6px; flex-wrap: wrap; } .sglv-goty-toolbar-row .sglv-search { flex: 1; min-width: 120px; } .sglv-goty-empty { text-align: center; padding: 40px 16px; color: #94a3b8; font-size: 13px; } /* === v2.9.22: PS会免封面视图 — tier-badge 绝对定位到 cover 视图封面右上角 === */ /* shared CSS 的 .sglv-card-img-wrap .sglv-ps-tier-badge 定位只对 card 视图生效; cover 视图用 .sglv-wl-cover-img-wrap,需要独立覆盖。 */ .sglv-wl-cover-img-wrap > .sglv-ps-tier-badge { position: absolute !important; top: 6px !important; right: 6px !important; z-index: 2 !important; backdrop-filter: blur(4px) !important; -webkit-backdrop-filter: blur(4px) !important; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35) !important; } /* === v2.9.22: PS会免 cover 视图 — 信息行紧凑到 3 行内 === */ /* 状态徽章 + 在库徽章合并到同一行(左右排列),月份/版本单独一行 */ .sglv-wl-cover-body .sglv-ps-status-row { display: flex !important; align-items: center !important; gap: 5px !important; flex-wrap: wrap !important; margin-top: 2px !important; } .sglv-wl-cover-body .sglv-ps-status-row .sglv-card-badge { display: inline-flex !important; align-items: center !important; font-size: 10px !important; padding: 2px 7px !important; margin-top: 0 !important; } /* 月份 + 版本 meta 行紧凑 */ .sglv-wl-cover-body .sglv-ps-meta-row { display: flex !important; align-items: center !important; gap: 6px !important; flex-wrap: wrap !important; font-size: 10px !important; color: var(--sglv-text-secondary) !important; } .sglv-wl-cover-body .sglv-ps-meta-row .sglv-ps-month-item { display: inline-flex !important; align-items: center !important; gap: 3px !important; } .sglv-wl-cover-body .sglv-ps-meta-row .sglv-ps-month-item .sglv-ps-clock { width: 11px !important; height: 11px !important; display: inline-flex !important; align-items: center !important; opacity: 0.65 !important; } .sglv-wl-cover-body .sglv-ps-meta-row .sglv-ps-month-item .sglv-ps-clock svg { width: 11px !important; height: 11px !important; } .sglv-wl-cover-body .sglv-ps-meta-row .sglv-ps-versions { display: inline-flex !important; gap: 3px !important; } .sglv-wl-cover-body .sglv-ps-meta-row .sglv-ps-versions span { font-size: 9px !important; padding: 1px 4px !important; border-radius: 3px !important; background: rgba(255, 255, 255, 0.08) !important; border: 1px solid rgba(255, 255, 255, 0.15) !important; } /* === v2.9.22: 年度大作 chip 强化 — 默认全部展开,active 更明显 === */ .sgc-panel .sglv-goty-year-bar { padding: 8px 12px !important; gap: 6px !important; background: linear-gradient(180deg, rgba(139, 92, 246, 0.08), transparent) !important; border-bottom: 1px solid rgba(139, 92, 246, 0.18) !important; } .sgc-panel .sglv-goty-year-chip { padding: 4px 12px !important; font-size: 12px !important; } .sgc-panel .sglv-goty-year-chip.active { background: linear-gradient(135deg, rgba(139, 92, 246, 0.4), rgba(59, 130, 246, 0.25)) !important; color: #fff !important; border-color: rgba(139, 92, 246, 0.6) !important; box-shadow: 0 0 12px rgba(139, 92, 246, 0.35) !important; } /* === v2.9.22: PS / Epic 占位封面 logo 尺寸(适配新版 viewBox 122.88x95.18 和 100x100) === */ .sglv-ps-cover-placeholder .sglv-ps-logo { width: 72px !important; height: 56px !important; opacity: 0.85 !important; color: #fff !important; flex-shrink: 0 !important; filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.35)); } .sglv-ps-cover-placeholder .sglv-ps-logo svg { width: 100% !important; height: 100% !important; display: block; } /* v2.9.9: PS 独占占位画居中 + 名称显示优化 —— ① 占位画改绝对定位铺满图片盒(aspect-ratio 盒内 height:100% 的解析差异不再影响它), flex 双向居中,PS Logo + 名称组稳定落在正中;z-index:0 让真实封面(z-index:1)仍能盖住它。 ② 名称降为居中、两行省略的小字说明(不再是挤占视觉中心的三行大字),与正文异步官方名同源。 */ .sgc-panel .sglv-card-img-wrap > .sglv-ps-cover-placeholder, .sgc-panel .sglv-wl-cover-img-wrap > .sglv-ps-cover-placeholder { position: absolute !important; inset: 0 !important; z-index: 0 !important; justify-content: center !important; align-items: center !important; gap: 6px !important; padding: 10px !important; } .sgc-panel .sglv-ps-cover-placeholder .sglv-ps-cover-name { max-width: 88% !important; font-size: 10px !important; font-weight: 600 !important; line-height: 1.3 !important; letter-spacing: 0.2px !important; -webkit-line-clamp: 2 !important; opacity: 0.92 !important; } /* Epic 占位容器:覆盖 PS 的蓝色渐变,改为深色盾形风格 */ .sglv-epic-cover-placeholder { background: linear-gradient(135deg, #0d0d0d 0%, #1f1f1f 55%, #2a2a2a 100%) !important; box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.5) !important; } .sglv-epic-cover-placeholder .sglv-epic-logo { width: 96px !important; height: 96px !important; opacity: 1 !important; flex-shrink: 0 !important; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6)); } .sglv-epic-cover-placeholder .sglv-epic-logo svg { width: 100% !important; height: 100% !important; display: block; } /* === v2.9.7: PS 独占(非 Steam 游戏)真实封面层 === 参考 steam-friend-manager 1.6.2 的 .sfd-tga-excl-img:平台占位画垫底, 商店主视觉绝对定位盖在其上;图片加载失败由 JS 直接移除 img,保住占位画。 */ .sglv-ps-excl-cover { position: absolute !important; inset: 0; z-index: 1; width: 100% !important; height: 100% !important; object-fit: cover; /* 主视觉为 1024×1024 方图:2:3 海报位裁上下、460/215 横版裁左右, 焦点上移(center 20%)保住方图上方的游戏 LOGO 区 */ object-position: center 20%; opacity: 0; transition: opacity .18s ease; } .sglv-ps-excl-cover.sglv-ps-excl-cover-loaded { opacity: 1 !important; } /* 列表视图图标位:包一层 relative 容器,封面图绝对定位盖住 PS 图标 */ .sglv-ps-excl-iconwrap { position: relative !important; overflow: hidden !important; } /* === 加载中骨架:body 为空或仅含简单文字时显示 spinner === */ @keyframes sgc-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .sgc-body-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; min-height: 320px; padding: 40px 20px; gap: 14px; color: #94a3b8; font-size: 13px; text-align: center; } .sgc-body-loading .sgc-bl-spinner { width: 36px; height: 36px; border: 3px solid rgba(102, 192, 244, 0.15); border-top-color: #66c0f4; border-radius: 50%; animation: sgc-spin 0.9s linear infinite; } .sgc-body-loading .sgc-bl-title { font-size: 15px; font-weight: 600; color: #cbd5e1; } .sgc-body-loading .sgc-bl-tip { font-size: 12px; color: #64748b; line-height: 1.6; max-width: 380px; } /* body 完全为空时(首次打开/切 tab 瞬间)显示 fallback 提示 */ .sgc-panel .sglv-body:empty::before { content: "加载中…"; display: flex; align-items: center; justify-content: center; height: 200px; color: #94a3b8; font-size: 13px; } /* === v1.9.1: DLC 收藏标签页骨架(KPI shimmer + 卡片占位 + 阶段进度条) === */ @keyframes sglv-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .sglv-skel-line { background: linear-gradient(90deg, rgba(102,192,244,0.06) 0%, rgba(102,192,244,0.20) 50%, rgba(102,192,244,0.06) 100%); background-size: 200% 100%; animation: sglv-skel-shimmer 1.4s ease-in-out infinite; border-radius: 4px; height: 14px; margin: 6px 0; } .sglv-skel-line.lg { height: 22px; width: 55%; } .sglv-skel-line.md { height: 14px; width: 40%; } .sglv-skel-line.sm { height: 10px; width: 28%; } .sglv-trend-kpi-card.skeleton { pointer-events: none; opacity: 0.55; } .sglv-trend-kpi-card.skeleton .sglv-skel-line { margin: 8px 0; } .sglv-skel-card { background: rgba(255,255,255,0.04) !important; border: 1px solid rgba(255,255,255,0.06) !important; position: relative; overflow: hidden; } .sglv-skel-card::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent 0%, rgba(102,192,244,0.10) 50%, transparent 100%); background-size: 200% 100%; animation: sglv-skel-shimmer 1.4s ease-in-out infinite; pointer-events: none; } .sglv-skel-card .sglv-card-img-wrap, .sglv-skel-card .sglv-card-body { visibility: hidden; } .sglv-skel-stage { margin: 14px 18px; display: flex; flex-direction: column; gap: 8px; } .sglv-skel-stage-bar { height: 4px; background: rgba(102,192,244,0.12); border-radius: 2px; overflow: hidden; } .sglv-skel-stage-fill { height: 100%; background: linear-gradient(90deg, #66c0f4, #a78bfa); border-radius: 2px; transition: width 0.4s ease; } .sglv-skel-stage-text { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 8px; } .sglv-skel-stage-text::before { content: ""; width: 12px; height: 12px; border: 2px solid rgba(102,192,244,0.25); border-top-color: #66c0f4; border-radius: 50%; animation: sgc-spin 0.9s linear infinite; flex-shrink: 0; } .sglv-skel-grid { padding: 0 12px 12px; } /* === v1.9.4: DLC 加载等待动画(参考 steam-friend-manager spinner 风格) === */ .sglv-dlc-loading { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:48px 20px; gap:14px; color:#94a3b8; min-height:280px; } .sglv-dlc-loading .sglv-dlc-spin { width:32px; height:32px; animation:sgc-spin 1s linear infinite; color:#a78bfa; } .sglv-dlc-loading-text { font-size:13px; font-weight:600; color:#cbd5e1; } .sglv-dlc-loading-sub { font-size:11px; color:#64748b; } .sglv-dlc-loading-bar { width:220px; height:3px; background:rgba(102,192,244,0.12); border-radius:2px; overflow:hidden; } .sglv-dlc-loading-bar-fill { height:100%; background:linear-gradient(90deg,#66c0f4,#a78bfa); border-radius:2px; transition:width 0.4s ease; } /* === v1.9.4: DLC 工具栏分隔符 + 已拥有筛选 === */ .sglv-toolbar-sep { width:1px; height:24px; background:rgba(148,163,184,0.2); flex-shrink:0; margin:0 2px; } .sglv-dlc-own-filter { display:flex; gap:2px; flex-shrink:0; } .sglv-dlc-own-filter .sglv-btn { padding:4px 10px; font-size:11.5px; } /* === v1.9.35: 系列详情工具栏——flex-wrap 防溢出 + 拥有状态切换标签 === */ .sglv-pub-detail-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-bottom: 8px; font-size: 13px; color: #8a9ba8; } .sglv-pub-completion { flex-shrink: 0; } .sglv-pub-own-filter { display: flex; gap: 2px; flex-shrink: 0; } .sglv-pub-own-filter .sglv-btn { padding: 3px 10px; font-size: 11.5px; } .sglv-pub-sort-group { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .sglv-pub-detail-toolbar .sglv-view-toggle { margin-left: auto; gap: 4px !important; } /* === v1.9.1: DLC 收藏标签页 KPI 紧凑布局(5 列一行, 缩 padding/font, 避免换行) === */ /* shared-css 默认 .sglv-trend-kpi-row 是 4 列,5 个 KPI 必然换行 → 用 id 选择器精准覆盖 */ #sglv-dlc-kpi .sglv-trend-kpi-row { grid-template-columns: repeat(5, 1fr); gap: 6px; margin-bottom: 8px; } #sglv-dlc-kpi .sglv-trend-kpi-card { padding: 5px 8px 4px; min-width: 0; } #sglv-dlc-kpi .sglv-trend-kpi-header { gap: 3px; margin-bottom: 1px; } #sglv-dlc-kpi .sglv-trend-kpi-icon { width: 16px; height: 16px; border-radius: 4px; } #sglv-dlc-kpi .sglv-trend-kpi-icon svg { width: 10px; height: 10px; } #sglv-dlc-kpi .sglv-trend-kpi-label { font-size: 10px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #sglv-dlc-kpi .sglv-trend-kpi-value { font-size: 17px; line-height: 1.15; white-space: nowrap; } #sglv-dlc-kpi .sglv-trend-kpi-sub { font-size: 9px; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* === v1.9.1: DLC groupMode=true 时,视图切换按钮置灰不可点 === */ /* 分组视图天然是 card 布局,viewMode 概念对它无意义 → 视觉禁用 + pointer-events none */ .sgc-panel .sglv-view-toggle.sglv-view-disabled { opacity: 0.35; pointer-events: none; filter: grayscale(0.6); } .sgc-panel .sglv-view-toggle.sglv-view-disabled .sglv-btn.active { /* 即使 active 也不应被强调,保持整体灰态 */ box-shadow: none; } /* === v1.8.2: 供应商/系列模式切换条(系列分类标签页侧边栏顶部) === */ .sgc-panel .sglv-pub-mode-switch { display: flex; gap: 4px; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.06); position: sticky; top: 0; background: rgba(20,30,42,0.96); backdrop-filter: blur(4px); z-index: 2; } .sgc-panel .sglv-pub-mode-btn { flex: 1; padding: 5px 10px; font-size: 12px; font-weight: 600; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03); color: #94a3b8; cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; } .sgc-panel .sglv-pub-mode-btn:hover { color: #e2e8f0; background: rgba(255,255,255,0.07); } .sgc-panel .sglv-pub-mode-btn.active { color: #fff; background: rgba(102,192,244,0.22); border-color: rgba(102,192,244,0.4); box-shadow: 0 0 0 1px rgba(102,192,244,0.15) inset; } /* v2.9.90: 系列刷新按钮——不占 flex:1,固定宽度 */ .sgc-panel .sglv-pub-mode-btn.sglv-pub-series-refresh { flex: 0 0 auto; padding: 5px 8px; color: #94a3b8; } .sgc-panel .sglv-pub-mode-btn.sglv-pub-series-refresh:hover { color: #66c0f4; } .sgc-panel .sglv-pub-mode-btn.sglv-pub-series-refresh svg { width: 14px; height: 14px; } .sgc-panel .sglv-pub-mode-btn.sglv-pub-series-refresh.sglv-spin svg { animation: sgc-spin 0.8s linear infinite; } /* v1.9.47-fix: 绝版游戏刷新按钮 spinner 动画 */ #sglv-delisted-refresh.sglv-spin svg { animation: sgc-spin 0.8s linear infinite; } /* v1.9.40: 刷新结果悬浮提示——绝对定位不参与工具栏 flex 布局,任何内容都不会压缩切换按钮 */ .sgc-panel .sglv-pub-refresh-toast { position: absolute; top: calc(100% + 4px); right: 10px; z-index: 3; display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: 11px; color: #cbd5e1; background: rgba(15,23,35,0.96); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.35); pointer-events: none; white-space: nowrap; } .sglv-pub-status-ok { color: #4caf50; font-weight: 600; } .sglv-pub-status-dim { color: #8f9bb3; } .sglv-pub-status-err { color: #ef5350; font-weight: 600; } /* v2.9.90: 锁区游戏刷新按钮 spinner 动画 */ #sglv-blocked-refresh.sglv-spin svg { animation: sgc-spin 0.8s linear infinite; } .sglv-ps-status { font-size: 11px; color: #8f9bb3; white-space: nowrap; margin-left: 4px; display: inline-flex; align-items: center; gap: 6px; } /* v2.9.91: 锁区游戏工具栏状态文本(更新时间 / 加载进度) */ .sglv-blocked-status { font-size: 11px; color: #8f9bb3; white-space: nowrap; margin-left: 4px; display: inline-flex; align-items: center; } .sglv-blocked-status.loading { color: #66c0f4; } /* === v1.8.2: 系列模式扁平列表项(无二级分类) === */ .sgc-panel .sglv-series-flat-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 12px; font-size: 12.5px; color: #cbd5e1; cursor: pointer; border-left: 2px solid transparent; transition: background 0.15s, border-color 0.15s; } .sgc-panel .sglv-series-flat-item:hover { background: rgba(255,255,255,0.05); } .sgc-panel .sglv-series-flat-item.active { background: rgba(102,192,244,0.12); border-left-color: #66c0f4; color: #fff; } .sgc-panel .sglv-series-flat-item .sglv-series-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgc-panel .sglv-series-empty-hint { padding: 20px 12px; font-size: 12px; color: #8a9ba8; text-align: center; } /* v2.0.0: sidebar 模式提示 */ .sglv-pub-sidebar-hint { padding: 12px 14px; margin-top: 8px; font-size: 11.5px; color: #8a9ba8; text-align: center; background: rgba(102, 192, 244, 0.05); border: 1px dashed rgba(102, 192, 244, 0.2); border-radius: 6px; line-height: 1.5; } /* === v1.9.0: DLC 收藏标签页样式 === */ .sglv-dlc-group { border: 1px solid rgba(102, 192, 244, 0.12); border-radius: 8px; margin-bottom: 8px; overflow: hidden; background: rgba(255, 255, 255, 0.02); } .sglv-dlc-group.collapsed .sglv-dlc-card-row { display: none; } .sglv-dlc-group-head { display: flex !important; align-items: center !important; gap: 8px !important; padding: 8px 12px !important; cursor: pointer !important; background: rgba(102, 192, 244, 0.06) !important; transition: background 0.15s !important; } .sglv-dlc-group-head:hover { background: rgba(102, 192, 244, 0.12) !important; } .sglv-dlc-arrow { font-size: 12px; color: #94a3b8; transition: transform 0.15s; flex-shrink: 0; } .sglv-dlc-pname { font-weight: 600; font-size: 13px; color: #e2e8f0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-dlc-meta { font-size: 11px; color: #94a3b8; flex-shrink: 0; } .sglv-dlc-pct { font-weight: 700; font-size: 13px; color: #66c0f4; font-variant-numeric: tabular-nums; flex-shrink: 0; } /* === v1.9.3: DLC 父游戏 group 展开后,DLC 卡片用横版 4 列 grid(替代原 8 列竖图) === */ .sglv-dlc-card-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 8px 12px 12px; } .sglv-dlc-card-row .sglv-dlc-group-cover { width: 100% !important; } @media (max-width: 1280px) { .sglv-dlc-card-row { grid-template-columns: repeat(3, 1fr) !important; } } @media (max-width: 900px) { .sglv-dlc-card-row { grid-template-columns: repeat(2, 1fr) !important; } } @media (max-width: 600px) { .sglv-dlc-card-row { grid-template-columns: 1fr !important; } } .sglv-dlc-type-badge-music { background: rgba(34, 165, 247, 0.15); color: #22a5f7; border: 1px solid rgba(34, 165, 247, 0.25); padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 700; } .sglv-dlc-type-badge-dlc { background: rgba(139, 92, 246, 0.15); color: #a78bfa; border: 1px solid rgba(139, 92, 246, 0.25); padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 700; } .sglv-dlc-group-toggle { display: flex; gap: 2px; background: rgba(255, 255, 255, 0.06); border-radius: 8px; padding: 2px; } .sglv-dlc-group-toggle .sglv-btn { padding: 4px 10px; font-size: 12px; font-weight: 600; border-radius: 6px; } /* === v1.9.14: 捆绑包标签页专用样式 === */ /* === v1.9.24: 锁区游戏 SVG 锁图案封面占位符 === */ .sglv-blocked-cover-placeholder { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 40%, #0f3460 100%); position: relative; overflow: hidden; } .sglv-blocked-cover-placeholder::before { content: ''; position: absolute; inset: 0; background: radial-gradient(ellipse at 50% 35%, rgba(239,68,68,0.12) 0%, transparent 60%); } .sglv-blocked-cover-placeholder::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(45deg, transparent 0, transparent 12px, rgba(239,68,68,0.03) 12px, rgba(239,68,68,0.03) 24px); pointer-events: none; } .sglv-blocked-cover-placeholder .sglv-blocked-lock-svg { width: 48px; height: 48px; filter: drop-shadow(0 2px 8px rgba(239,68,68,0.4)); z-index: 1; flex-shrink: 0; } .sglv-blocked-cover-placeholder .sglv-blocked-cover-name { margin-top: 8px; padding: 0 8px; font-size: 11px; font-weight: 600; color: rgba(248,113,113,0.8); text-align: center; line-height: 1.3; max-width: 90%; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; z-index: 1; } /* 横版封面视图(cover)适配 */ .sglv-wl-cover-img-wrap .sglv-blocked-cover-placeholder { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); } .sglv-wl-cover-img-wrap .sglv-blocked-cover-placeholder .sglv-blocked-lock-svg { width: 40px; height: 40px; } .sglv-wl-cover-img-wrap .sglv-blocked-cover-placeholder .sglv-blocked-cover-name { font-size: 10px; } /* 列表视图小图标 */ .sglv-list-icon.sglv-blocked-list-icon { width: 45px; height: 21px; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #1a1a2e, #0f3460); border: 1px solid rgba(239,68,68,0.2); border-radius: 3px; } .sglv-blocked-list-icon svg { width: 14px !important; height: 14px !important; } /* === v1.9.24: KPI 卡片炫彩增强 === */ .sgc-panel .sglv-trend-kpi-card { background: linear-gradient(135deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.01) 100%) !important; border-width: 1px !important; position: relative; overflow: hidden; transition: transform 0.25s, box-shadow 0.25s, border-color 0.25s !important; } .sgc-panel .sglv-trend-kpi-card::after { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent); transition: left 0.6s; pointer-events: none; } .sgc-panel .sglv-trend-kpi-card:hover::after { left: 100%; } .sgc-panel .sglv-trend-kpi-card:hover { transform: translateY(-2px) !important; box-shadow: 0 4px 20px rgba(0,0,0,0.3) !important; } .sgc-panel .sglv-trend-kpi-card.active { box-shadow: 0 0 0 1px var(--sglv-accent) inset, 0 4px 16px rgba(102,192,244,0.2) !important; } /* 锁区 KPI 红色卡片增强 */ .sgc-panel .sglv-trend-kpi-card[style*="ef4444"] { background: linear-gradient(135deg, rgba(239,68,68,0.08) 0%, rgba(239,68,68,0.02) 100%) !important; } .sgc-panel .sglv-trend-kpi-card[style*="ef4444"]:hover { box-shadow: 0 4px 20px rgba(239,68,68,0.25) !important; border-color: rgba(239,68,68,0.5) !important; } /* 橙色卡片增强 */ .sgc-panel .sglv-trend-kpi-card[style*="ff9800"] { background: linear-gradient(135deg, rgba(255,152,0,0.08) 0%, rgba(255,152,0,0.02) 100%) !important; } .sgc-panel .sglv-trend-kpi-card[style*="ff9800"]:hover { box-shadow: 0 4px 20px rgba(255,152,0,0.25) !important; border-color: rgba(255,152,0,0.5) !important; } /* 绿色(mine)卡片增强 */ .sgc-panel .sglv-trend-kpi-card.mine { background: linear-gradient(135deg, rgba(34,197,94,0.08) 0%, rgba(34,197,94,0.02) 100%) !important; } .sgc-panel .sglv-trend-kpi-card.mine:hover { box-shadow: 0 4px 20px rgba(34,197,94,0.25) !important; border-color: rgba(34,197,94,0.5) !important; } /* KPI 数值炫彩文字 */ .sgc-panel .sglv-trend-kpi-card .sglv-trend-kpi-value { text-shadow: 0 0 12px currentColor; filter: brightness(1.1); } /* === v1.9.24: 收集进度条炫彩 + 减细 === */ /* 系列分类筛选进度条减细(原 12px → 7px) */ .sgc-panel .sglv-progress-filter { height: 7px !important; border-radius: 4px !important; } .sgc-panel .sglv-progress-filter .sglv-progress-fill { background: linear-gradient(90deg, #4caf50, #22d3ee, #66c0f4) !important; box-shadow: 0 0 6px rgba(34,211,238,0.3) !important; } .sgc-panel .sglv-progress-filter .sglv-progress-fill.active { background: linear-gradient(90deg, #22c55e, #34d399, #22d3ee) !important; box-shadow: 0 0 8px rgba(34,211,238,0.5) !important; } /* 供应商/系列概览进度条也减细并炫彩 */ .sgc-panel .sglv-pub-card .sglv-progress, .sgc-panel .sglv-series-card .sglv-progress { height: 4px !important; margin: 4px 0 2px !important; } .sgc-panel .sglv-pub-card .sglv-progress-fill, .sgc-panel .sglv-series-card .sglv-progress-fill { background: linear-gradient(90deg, #4caf50, #22d3ee) !important; } /* 概览大进度条炫彩 */ .sgc-panel .sglv-stat-row + .sglv-progress { height: 8px !important; border-radius: 4px !important; } .sgc-panel .sglv-stat-row + .sglv-progress .sglv-progress-fill { background: linear-gradient(90deg, #4caf50, #22d3ee, #66c0f4, #a78bfa) !important; box-shadow: 0 0 8px rgba(102,192,244,0.3) !important; } /* === v1.9.33: 图片加载占位背景 + 焦点可见性 === */ /* 图片容器加载背景——封面图加载前显示柔和渐变占位 */ .sgc-panel .sglv-card-img-wrap, .sgc-panel .sglv-wl-cover-img-wrap { background: linear-gradient(135deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 100%); } /* 图片加载完成淡入动画 */ .sgc-panel .sglv-card-img, .sgc-panel .sglv-wl-cover-img { opacity: 0; transition: opacity 0.3s ease; } .sgc-panel .sglv-card-img.sglv-img-loaded, .sgc-panel .sglv-wl-cover-img.sglv-img-loaded { opacity: 1; } /* v1.9.43: 竖版容器内横图 letterbox——无竖版资产的游戏(API 兜底返回横版 header) onload 检测 naturalWidth>naturalHeight 后追加 sglv-img-wide 类,object-fit:contain 完整显示 + 渐变底,替代 object-fit:cover 的居中裁切变形 */ .sgc-panel .sglv-card-img.sglv-img-wide { object-fit: contain !important; background: linear-gradient(160deg, rgba(22, 32, 45, 0.88) 0%, rgba(27, 40, 56, 0.88) 100%); } /* 焦点可见性——所有交互元素 focus-visible */ .sgc-panel .sglv-btn:focus-visible, .sgc-panel .sglv-view-btn:focus-visible, .sgc-panel .sglv-sort-select:focus-visible, .sgc-panel .sglv-search:focus-visible, .sgc-panel .sglv-metric-card:focus-visible { outline: 2px solid var(--sglv-accent, #66c0f4); outline-offset: 2px; } /* select 暗色模式原生样式修复 */ .sgc-panel .sglv-sort-select { background-color: rgba(62, 68, 84, 0.8); color: #c7d5e0; border: 1px solid rgba(255,255,255,0.1); } /* === v1.9.34: 系列横版封面视图增强效果 === */ /* 封面卡片 hover 光晕 + 缩放 */ .sgc-panel .sglv-pub-cover-card { transition: transform 0.28s ease, box-shadow 0.28s ease, border-color 0.28s ease !important; } .sgc-panel .sglv-pub-cover-card:hover { transform: translateY(-3px) !important; box-shadow: 0 6px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(102,192,244,0.15) !important; border-color: rgba(102,192,244,0.35) !important; } /* 已拥有游戏封面卡片——绿色边框微光 */ .sgc-panel .sglv-pub-cover-card.sglv-pub-cover-owned { border-color: rgba(76,175,80,0.2) !important; } .sgc-panel .sglv-pub-cover-card.sglv-pub-cover-owned:hover { border-color: rgba(76,175,80,0.5) !important; box-shadow: 0 6px 24px rgba(0,0,0,0.4), 0 0 12px rgba(76,175,80,0.15) !important; } /* 封面图片 hover 缩放 */ .sgc-panel .sglv-pub-cover-card .sglv-wl-cover-img { transition: opacity 0.3s ease, transform 0.35s ease !important; } .sgc-panel .sglv-pub-cover-card:hover .sglv-wl-cover-img { transform: scale(1.04) !important; } /* 封面底部渐变遮罩——提升文字可读性 */ .sgc-panel .sglv-pub-cover-overlay { position: absolute !important; bottom: 0 !important; left: 0 !important; right: 0 !important; height: 50% !important; background: linear-gradient(to top, rgba(0,0,0,0.55) 0%, transparent 100%) !important; pointer-events: none !important; z-index: 1 !important; } /* 拥有状态徽章——封面右上角毛玻璃 */ .sgc-panel .sglv-pub-cover-badge { position: absolute !important; top: 6px !important; right: 6px !important; z-index: 2 !important; margin-top: 0 !important; backdrop-filter: blur(6px) !important; -webkit-backdrop-filter: blur(6px) !important; box-shadow: 0 1px 4px rgba(0,0,0,0.35) !important; } /* === v1.9.33: prefers-reduced-motion 无障碍降级 === */ @media (prefers-reduced-motion: reduce) { .sgc-panel *, .sgc-panel *::before, .sgc-panel *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } .sgc-panel .sglv-card-img, .sgc-panel .sglv-wl-cover-img { opacity: 1 !important; transition: none !important; } } /* === v2.6.0: 受限游戏板组件样式已迁至下方 v2.6.0 区块(HERO + 卡片重构), 旧的一排等宽 KPI 小卡样式已随版本收敛一并移除。 === */ /* === v2.8.0: 捆绑包「内容墙」重构 === 设计思路反转:捆绑包的价值在于"包里有什么",主游戏封面是干扰项。 卡片 = 顶部主游戏标题条(上下文)+ 3 列 DLC 封面墙(主体)。 每张 DLC 有独立 appid,可直接取真实 header.jpg —— 旧版 24px 社区图标读不出美术细节。 */ .sglv-bundle-wall { display: grid !important; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)) !important; gap: 12px !important; } .sglv-bundle-card { position: relative; display: flex; flex-direction: column; border-radius: 10px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.055); background: linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.015) 100%); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22); transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.18s ease, box-shadow 0.18s ease; } .sglv-bundle-card:hover { transform: translateY(-3px); border-color: rgba(102, 192, 244, 0.36); box-shadow: 0 10px 26px -8px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(102, 192, 244, 0.1); } /* ---- 顶部标题条:主游戏退为上下文 ---- */ .sglv-bundle-head { padding: 8px 10px 7px; border-bottom: 1px solid rgba(255, 255, 255, 0.045); background: rgba(255, 255, 255, 0.018); position: relative; } .sglv-bundle-title-row { display: flex; align-items: baseline; gap: 6px; cursor: pointer; min-width: 0; } .sglv-bundle-title-row:hover .sglv-bundle-title { color: #eef5fb !important; } .sglv-bundle-title { font-size: 12px; font-weight: 700; color: #dce8f4; line-height: 1.3; letter-spacing: 0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1 1 auto; min-width: 0; transition: color 0.15s; } .sglv-bundle-appid { font-size: 9px; color: rgba(148, 163, 184, 0.5); font-variant-numeric: tabular-nums; flex-shrink: 0; } .sglv-bundle-meta { display: flex; align-items: center; gap: 6px; margin-top: 3px; font-size: 9.5px; color: #8296ab; font-variant-numeric: tabular-nums; } .sglv-bundle-stat { display: inline-flex; align-items: center; gap: 3px; } .sglv-bundle-stat-ico { font-size: 9px; opacity: 0.8; } .sglv-bundle-sep { width: 1px; height: 9px; background: rgba(255, 255, 255, 0.12); } /* 已拥有的主游戏:标题条左侧一条绿色标记 */ .sglv-bundle-head.is-owned::before { content: ''; position: absolute; left: 0; top: 7px; bottom: 7px; width: 2.5px; border-radius: 0 2px 2px 0; background: linear-gradient(180deg, #34d399, #22c55e); box-shadow: 0 0 7px rgba(52, 211, 153, 0.5); } /* ---- DLC 封面墙:3 列 ---- */ .sglv-bundle-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; padding: 6px; } .sglv-bundle-tiles-single { grid-template-columns: 1fr; } .sglv-bundle-tile { position: relative; aspect-ratio: 16 / 10; border-radius: 5px; overflow: hidden; cursor: pointer; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.05); transition: transform 0.16s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.16s ease, box-shadow 0.16s ease, z-index 0s; } .sglv-bundle-tile:hover { transform: scale(1.07); border-color: rgba(102, 192, 244, 0.6); box-shadow: 0 6px 16px -4px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(102, 192, 244, 0.25); z-index: 3; } /* 搜索命中的 DLC:琥珀色描边 */ .sglv-bundle-tile.is-hit { border-color: rgba(251, 191, 36, 0.75); box-shadow: 0 0 0 1px rgba(251, 191, 36, 0.3), 0 0 12px -2px rgba(251, 191, 36, 0.35); } /* 已拥有 DLC:左上角小绿点 */ .sglv-bundle-tile.is-owned::after { content: ''; position: absolute; top: 4px; left: 4px; width: 6px; height: 6px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 1.5px rgba(10, 18, 28, 0.85), 0 0 7px rgba(52, 211, 153, 0.75); z-index: 4; pointer-events: none; } /* tile 内的封面:填满 + letterbox */ .sglv-bundle-tile .sglv-img-wrapper { position: absolute; inset: 0; } .sglv-bundle-tile .sglv-img-wrapper > .sglv-card-img { object-fit: cover !important; } /* 进包次数角标 */ .sglv-bundle-tile-num { position: absolute; right: 3px; bottom: 3px; z-index: 4; min-width: 15px; padding: 0 3px; border-radius: 3px; background: rgba(8, 14, 22, 0.82); border: 1px solid rgba(245, 158, 11, 0.4); color: #fbbf24; font-size: 8.5px; font-weight: 700; line-height: 13px; text-align: center; font-variant-numeric: tabular-nums; backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); pointer-events: none; } .sglv-bundle-tile-badge { position: absolute; left: 4px; bottom: 4px; z-index: 4; padding: 1px 5px; border-radius: 3px; background: rgba(8, 14, 22, 0.8); border: 1px solid rgba(52, 211, 153, 0.4); color: #34d399; font-size: 8.5px; font-weight: 700; line-height: 14px; backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); pointer-events: none; } /* ---- 展开按钮:ghost 风格,不用白底黑字 ---- */ .sglv-bundle-expand { display: flex; align-items: center; justify-content: center; gap: 5px; width: calc(100% - 12px); margin: 0 6px 6px; padding: 4px 0; border-radius: 5px; background: rgba(102, 192, 244, 0.07); border: 1px solid rgba(102, 192, 244, 0.18); color: #8fc9e8; font-size: 9.5px; font-weight: 600; cursor: pointer; transition: background 0.15s, border-color 0.15s, color 0.15s; } .sglv-bundle-expand:hover { background: rgba(102, 192, 244, 0.14); border-color: rgba(102, 192, 244, 0.38); color: #b6e0f5; } .sglv-bundle-expand.is-open { background: rgba(255, 255, 255, 0.035); border-color: rgba(255, 255, 255, 0.1); color: #8296ab; } .sglv-bundle-expand-caret { font-size: 8px; opacity: 0.8; transition: transform 0.2s ease; display: inline-block; } .sglv-bundle-expand.is-open .sglv-bundle-expand-caret { transform: rotate(180deg); } /* 折叠区展开时不额外加间距 */ .sglv-bundle-tiles-rest { padding-top: 0; } /* === v2.9.0: 年度大片「大海报」视图 === 此前 cover 视图复用 card 视图的 .sglv-goty-grid + renderGotyGridCard, 渲染结果完全一致 → 点击「大海报」按钮后视觉零变化(用户报"切换没效果")。 这里给出独立的横向宽海报布局:封面铺满 + 底部渐变 + 标题信息叠加。 */ .sglv-goty-cover-grid { display: grid !important; grid-template-columns: repeat(auto-fill, minmax(272px, 1fr)) !important; gap: 12px !important; padding: 10px 0; } .sglv-goty-cover-card { position: relative; border-radius: 10px; overflow: hidden; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.06); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.2s ease, box-shadow 0.2s ease; } .sglv-goty-cover-card:hover { transform: translateY(-4px) scale(1.008); border-color: rgba(102, 192, 244, 0.45); box-shadow: 0 14px 32px -10px rgba(0, 0, 0, 0.72), 0 0 0 1px rgba(102, 192, 244, 0.12); } .sglv-goty-cover-img-wrap { position: relative; aspect-ratio: 460 / 215 !important; /* header.jpg 原生比例 */ overflow: hidden; background: linear-gradient(145deg, rgba(102,192,244,0.06), rgba(139,92,246,0.06)); } .sglv-goty-cover-img-wrap .sglv-img-wrapper { position: absolute; inset: 0; } /* 大海报视图:横图铺满,不留黑边 */ .sglv-goty-cover-img-wrap .sglv-img-wrapper > .sglv-goty-cover-img { object-fit: cover !important; opacity: 0; transition: opacity 0.3s ease; } .sglv-goty-cover-img-wrap .sglv-img-wrapper.sglv-img-loaded > .sglv-goty-cover-img { opacity: 1; } /* 大图上不显示转圈 spinner */ .sglv-goty-cover-img-wrap .sglv-img-wrapper.sglv-img-loading::after { display: none !important; } /* 底部渐变 + 信息叠加 */ .sglv-goty-cover-grad { position: absolute; left: 0; right: 0; bottom: 0; height: 68%; background: linear-gradient(to top, rgba(8, 13, 20, 0.94) 0%, rgba(8, 13, 20, 0.72) 38%, rgba(8, 13, 20, 0.22) 72%, transparent 100%); pointer-events: none; z-index: 2; } .sglv-goty-cover-overlay { position: absolute; left: 0; right: 0; bottom: 0; padding: 12px 14px 11px; z-index: 3; display: flex; flex-direction: column; gap: 3px; pointer-events: none; } .sglv-goty-cover-title { font-size: 14px; font-weight: 700; color: #f2f7fb; line-height: 1.25; letter-spacing: 0.01em; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.85); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .sglv-goty-cover-meta { display: flex; align-items: center; gap: 8px; font-size: 10px; font-weight: 600; } .sglv-goty-cover-owned { color: #34d399; text-shadow: 0 1px 4px rgba(0,0,0,0.8); } .sglv-goty-cover-not-owned { color: #94a3b8; text-shadow: 0 1px 4px rgba(0,0,0,0.8); } .sglv-goty-cover-countdown { padding: 1px 6px; border-radius: 3px; background: rgba(245, 158, 11, 0.16); border: 1px solid rgba(245, 158, 11, 0.32); color: #fbbf24; font-size: 9px; backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); } .sglv-goty-cover-countdown.today { background: rgba(239, 68, 68, 0.18); border-color: rgba(239, 68, 68, 0.4); color: #fca5a5; } .sglv-goty-cover-countdown.past { background: rgba(100, 116, 139, 0.18); border-color: rgba(100, 116, 139, 0.35); color: #94a3b8; } /* 已拥有:左上角绿点 + 细绿边 */ .sglv-goty-cover-card.is-owned { border-color: rgba(52, 211, 153, 0.32); } .sglv-goty-cover-card.is-owned::after { content: ''; position: absolute; top: 9px; left: 9px; width: 7px; height: 7px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 2px rgba(8, 13, 20, 0.85), 0 0 9px rgba(52, 211, 153, 0.8); z-index: 4; } /* === v2.8.0: 工具栏整合筛选 + 分页到一行 === */ /* 分页器移入 toolbar 后,用弹性占位把左侧控件与右侧分页推到两端。 core 已有 .sglv-toolbar .sglv-pagination { margin-left: auto }, 这里补 spacer 作为双保险,并让窄浮窗下分页可换行而不撑破工具栏。 */ .sglv-toolbar-spacer { flex: 1 1 auto; min-width: 8px; height: 1px; } .sglv-toolbar .sglv-pagination { margin-left: auto !important; flex-shrink: 0 !important; } .sglv-toolbar .sglv-toolbar-spacer + .sglv-pagination { margin-left: 0 !important; /* spacer 已占位,去掉 auto 避免双重间隙 */ } /* 状态文本(锁区「更新: 2026-08-10」)与分页之间留出间距 */ .sglv-blocked-status { flex-shrink: 0 !important; } /* === v2.8.0: 大海报视图(Epic / PS会免 / 系列 / GOTY / DLC)封面容器适配 === 这五处原本用裸 直接放进 .sglv-wl-cover-img-wrap, 现在改为 .sglv-img-wrapper 三态结构,需要补容器规则: ① wrapper 撑满父容器(绝对定位),避免图片高度塌陷; ② letterbox 改为 cover —— 大海报视图就是给横图"放大展示"的,contain 会留大片黑边; ③ 骨架/兜底层沿用受限游戏板那套三态样式,无需额外定义。 */ .sglv-wl-cover-img-wrap { position: relative; overflow: hidden; background: rgba(255, 255, 255, 0.04); } .sglv-wl-cover-img-wrap .sglv-img-wrapper { position: absolute; inset: 0; width: 100%; height: 100%; } /* 大海报视图:横图铺满(cover),这是该视图存在的意义 */ .sglv-wl-cover-img-wrap .sglv-img-wrapper > .sglv-wl-cover-img { object-fit: cover !important; opacity: 0; transition: opacity 0.25s ease; } .sglv-wl-cover-img-wrap .sglv-img-wrapper.sglv-img-loaded > .sglv-wl-cover-img { opacity: 1; } /* 该视图中不显示进度 spinner(大图上转圈很扎眼),只保留淡骨架 */ .sglv-wl-cover-img-wrap .sglv-img-wrapper.sglv-img-loading::after { display: none !important; } .sglv-wl-cover-img-wrap .sglv-img-wrapper.sglv-img-loading::before { opacity: 0.5; } /* GOTY 网格卡片内的封面同理 */ .sglv-goty-grid .sglv-img-wrapper { position: relative; width: 100%; height: 100%; } .sglv-goty-grid .sglv-img-wrapper > .sglv-card-img { object-fit: cover !important; } /* 系列分类的 header 视图 */ .sglv-pub-list-cover { object-fit: cover !important; } /* === v2.7.0: HERO 质感打磨 === */ .sglv-hero { position: relative; overflow: hidden; background: radial-gradient(120% 140% at 8% -20%, rgba(102, 192, 244, 0.13) 0%, transparent 55%), linear-gradient(135deg, rgba(28, 42, 60, 0.78) 0%, rgba(18, 28, 42, 0.9) 100%); border-color: rgba(102, 192, 244, 0.2); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 4px 18px -8px rgba(0, 0, 0, 0.5); } /* 右上角极淡网格纹理,呼应 Steam 的"库"隐喻 */ .sglv-hero::after { content: ''; position: absolute; top: 0; right: 0; width: 180px; height: 100%; background-image: linear-gradient(rgba(255, 255, 255, 0.022) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.022) 1px, transparent 1px); background-size: 14px 14px; mask-image: linear-gradient(to left, #000, transparent); -webkit-mask-image: linear-gradient(to left, #000, transparent); pointer-events: none; } .sglv-hero-left, .sglv-hero-right { position: relative; z-index: 1; } .sglv-hero-num { font-size: 38px !important; font-weight: 800 !important; letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums; background: linear-gradient(180deg, #ffffff 20%, #a8c8e0 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; text-shadow: none; } .sglv-hero-progress { height: 6px !important; border-radius: 4px; background: rgba(255, 255, 255, 0.07); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); overflow: hidden; } .sglv-hero-progress-fill { height: 100%; border-radius: 4px; background: linear-gradient(90deg, #22c55e, #4ade80 55%, #66c0f4); box-shadow: 0 0 10px rgba(74, 222, 128, 0.4); } .sglv-hero-scope { padding: 6px 8px; border-radius: 7px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.065); transition: background 0.16s, border-color 0.16s, transform 0.14s; } .sglv-hero-scope:hover { background: rgba(255, 255, 255, 0.085); transform: translateY(-1px); } .sglv-hero-scope.active { background: linear-gradient(180deg, rgba(102, 192, 244, 0.22), rgba(102, 192, 244, 0.12)); border-color: rgba(102, 192, 244, 0.55); box-shadow: 0 0 0 1px rgba(102, 192, 244, 0.18), 0 3px 10px -4px rgba(102, 192, 244, 0.4); } .sglv-hero-scope-val { font-size: 15px !important; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; } .sglv-hero-type { padding: 3.5px 7px; border-radius: 5px; transition: background 0.15s, transform 0.12s; } .sglv-hero-type:hover { background: rgba(255, 255, 255, 0.055); transform: translateX(2px); } .sglv-hero-type.active { background: rgba(102, 192, 244, 0.14); box-shadow: inset 2px 0 0 #66c0f4; } .sglv-hero-type-track { height: 6px !important; border-radius: 4px; background: rgba(255, 255, 255, 0.055); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25); } .sglv-hero-type-fill { height: 100%; border-radius: 4px; box-shadow: 0 0 8px currentColor; } .sglv-hero-type-count { font-size: 11px !important; font-weight: 700; } .sglv-hero-type-pct { font-size: 9.5px !important; } /* === v2.9.2: 三态封面 wrapper 基础规则(补齐缺失的 base)=== 此前工程里只有 ::before/::after 的 override,没有基础定义: - .sglv-img-fallback-layer 是普通 block → 永远跟在图片下方占位; - .sglv-img-broken 无法隐藏图片; - loading 骨架/spinner 没有 content/position,override 全部失效。 这里补齐 loading / loaded / broken 三态的最小闭环。 */ .sglv-img-wrapper { position: relative; display: block; overflow: hidden; } .sglv-img-wrapper > img { display: block; max-width: 100%; } /* 卡片容器内:wrapper 撑满固定比例容器,img 由 core 的 .sglv-card-img(width/height 100% !important)决定尺寸, object-fit:contain 负责 letterbox(横图不硬裁主体) */ .sglv-card-img-wrap > .sglv-img-wrapper { position: absolute; inset: 0; } .sglv-card-img-wrap > .sglv-img-wrapper > img { width: 100%; height: 100%; } .sglv-img-fallback-layer { position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center; z-index: 2; pointer-events: none; } .sglv-img-wrapper.sglv-img-broken > img { visibility: hidden; } .sglv-img-wrapper.sglv-img-broken > .sglv-img-fallback-layer { display: flex; } .sglv-img-wrapper.sglv-img-loading::before { content: ''; position: absolute; inset: 0; z-index: 1; background: linear-gradient(100deg, rgba(255,255,255,0.015) 0%, rgba(255,255,255,0.06) 45%, rgba(255,255,255,0.015) 100%); background-size: 220% 100%; animation: sglv-img-shimmer 1.4s ease-in-out infinite; } .sglv-img-wrapper.sglv-img-loading::after { content: ''; position: absolute; top: 50%; left: 50%; z-index: 2; width: 20px; height: 20px; margin: -10px 0 0 -10px; border-radius: 50%; border: 1.5px solid rgba(102, 192, 244, 0.18); border-top-color: rgba(102, 192, 244, 0.7); animation: sglv-img-spin 0.9s linear infinite; } @keyframes sglv-img-shimmer { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } } @keyframes sglv-img-spin { to { transform: rotate(360deg); } } /* === v2.7.0: broken 占位精致化 —— 去掉粗笨大图标,改为细线图标 + 短标签 === */ .sglv-img-fallback-layer { gap: 7px !important; background: repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.012) 0 6px, transparent 6px 12px), linear-gradient(160deg, rgba(30, 42, 58, 0.9), rgba(18, 26, 38, 0.95)); } .sglv-img-broken-icon { width: 26px !important; height: 26px !important; background: rgba(148, 163, 184, 0.1) !important; border: 1px solid rgba(148, 163, 184, 0.16) !important; color: rgba(148, 163, 184, 0.55) !important; } .sglv-img-broken-icon svg { width: 13px !important; height: 13px !important; } .sglv-img-broken-label { font-size: 9px !important; letter-spacing: 0.04em; opacity: 0.5; font-weight: 500; } .sglv-img-broken-blocked .sglv-img-broken-icon, .sglv-img-wrapper.sglv-img-broken.sglv-img-broken-blocked .sglv-img-broken-icon { background: rgba(239, 68, 68, 0.1) !important; border-color: rgba(239, 68, 68, 0.2) !important; color: rgba(239, 68, 68, 0.55) !important; } /* 骨架:更细腻的扫描线 */ .sglv-img-wrapper.sglv-img-loading::before { background: linear-gradient(100deg, rgba(255,255,255,0.015) 0%, rgba(255,255,255,0.06) 45%, rgba(255,255,255,0.015) 100%); background-size: 220% 100%; } .sglv-img-wrapper.sglv-img-loading::after { width: 20px; height: 20px; margin: -10px 0 0 -10px; border-width: 1.5px; border-color: rgba(102, 192, 244, 0.18); border-top-color: rgba(102, 192, 244, 0.7); } /* === v2.7.0: 质感打磨 —— 层级、材质、微交互 === */ /* 卡片:更克制的边框 + 悬停抬升 + 顶部高光 */ .sglv-card-grid .sglv-game-card { border: 1px solid rgba(255, 255, 255, 0.055) !important; border-radius: 9px !important; background: linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.018) 100%) !important; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22); transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease !important; } .sglv-card-grid .sglv-game-card:hover { transform: translateY(-3px) !important; border-color: rgba(102, 192, 244, 0.38) !important; background: linear-gradient(180deg, rgba(102, 192, 244, 0.075) 0%, rgba(255, 255, 255, 0.022) 100%) !important; box-shadow: 0 8px 22px -6px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(102, 192, 244, 0.12); } /* 顶部 1px 高光,模拟受光边缘 */ .sglv-card-grid .sglv-game-card::before { content: ''; position: absolute; top: 0; left: 8px; right: 8px; height: 1px; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.16), transparent); pointer-events: none; z-index: 7; opacity: 0.75; } .sglv-card-grid .sglv-game-card { position: relative !important; } /* 封面区:更深的底 + 底部渐隐,让横图与卡片体衔接自然 */ .sglv-card-grid .sglv-game-card .sglv-card-img-wrap { position: relative; overflow: hidden; border-bottom: 1px solid rgba(255, 255, 255, 0.045); } .sglv-card-grid .sglv-game-card .sglv-card-img-wrap::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 26px; background: linear-gradient(to top, rgba(18, 26, 38, 0.55), transparent); pointer-events: none; z-index: 5; } /* 游戏名:两行截断(部分长名),hover 显示全名 */ .sglv-card-grid .sglv-game-card .sglv-card-name { font-size: 11px !important; font-weight: 600 !important; line-height: 1.32 !important; color: #d5e2ee !important; letter-spacing: 0.01em; display: -webkit-box !important; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 29px; /* 两行高度,保证同行卡片正文对齐 */ margin-bottom: 2px; } .sglv-card-grid .sglv-game-card:hover .sglv-card-name { color: #eef5fb !important; } .sglv-card-grid .sglv-game-card .sglv-card-id-inline { font-size: 9px !important; opacity: 0.5; font-variant-numeric: tabular-nums; } /* 两栏 meta:细分隔线 + 更克制的排版 */ .sglv-card-meta2 { display: flex !important; align-items: center; justify-content: space-between; gap: 6px; margin-top: 2px; padding-top: 4px; border-top: 1px solid rgba(255, 255, 255, 0.04); min-width: 0; } .sglv-meta-col-left, .sglv-meta-col-right { display: flex; align-items: center; gap: 4px; min-width: 0; } .sglv-meta-col-left { flex: 1 1 auto; overflow: hidden; } .sglv-meta-col-right { flex: 0 0 auto; } /* 分类徽章:更精致 —— 半透明底 + 左侧色点 */ .sglv-type-badge { display: inline-flex !important; align-items: center; font-size: 9px !important; font-weight: 600 !important; padding: 1.5px 5px 1.5px 4px !important; border-radius: 4px !important; border: 1px solid transparent !important; line-height: 1.45; white-space: nowrap; flex-shrink: 0; letter-spacing: 0.01em; } /* 拥有态文字:纯文字,无背景无边框 */ .sglv-own-text { font-size: 9px !important; font-weight: 600 !important; line-height: 1.45; white-space: nowrap; flex-shrink: 0; letter-spacing: 0.01em; } .sglv-own-text.owned { color: #34d399 !important; } .sglv-own-text.missing { color: #fbbf24 !important; } /* 右栏数值:小图标 + tabular 对齐 */ .sglv-meta-col-right .sglv-meta-item { display: inline-flex !important; align-items: center; gap: 2.5px; font-size: 9px !important; color: rgba(148, 163, 184, 0.85) !important; white-space: nowrap; line-height: 1.45; font-variant-numeric: tabular-nums; } .sglv-meta-col-right .sglv-meta-item svg { width: 9px !important; height: 9px !important; opacity: 0.55; flex-shrink: 0; } .sglv-meta-col-right .sglv-m-playtime { color: #66c0f4 !important; font-weight: 700 !important; } /* 状态斜切带:更利落 */ .sglv-own-flag { position: absolute !important; bottom: 0; right: 0; z-index: 8; pointer-events: none; clip-path: polygon(16px 0, 100% 0, 100% 100%, 0 100%); border-top-left-radius: 2px; transition: opacity 0.18s ease; } .sglv-own-flag.missing { width: 54px; height: 4px; background: linear-gradient(90deg, rgba(245, 158, 11, 0.4), #f59e0b 70%); } .sglv-own-flag.owned { width: 30px; height: 3px; clip-path: polygon(12px 0, 100% 0, 100% 100%, 0 100%); background: linear-gradient(90deg, rgba(52, 211, 153, 0.4), #34d399 70%); } .sglv-card-grid .sglv-game-card:hover .sglv-own-flag { opacity: 0.85; } /* 网格间距略增,给抬升留呼吸 */ .sglv-card-grid { gap: 12px !important; } /* === v2.6.0: 受限游戏板 HERO —— 铺满宽度的仪表盘 === */ .sglv-hero { display: flex !important; align-items: stretch; gap: 14px; width: 100%; padding: 12px 14px 13px; margin-bottom: 10px; border-radius: 10px; border: 1px solid rgba(102, 192, 244, 0.16); background: linear-gradient(135deg, rgba(27, 40, 56, 0.72) 0%, rgba(20, 32, 48, 0.86) 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); } /* 左区:主数字 —— flex: 0 0 auto 保证数字不被压缩,宽度随内容自适应 */ .sglv-hero-left { flex: 0 0 auto; min-width: 148px; display: flex; flex-direction: column; } .sglv-hero-title { font-size: 10px; font-weight: 600; letter-spacing: 0.08em; color: #7e93ab; text-transform: uppercase; } .sglv-hero-num { font-size: 34px; font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; color: #e8f1fa; margin: 3px 0 7px; text-shadow: 0 0 22px rgba(102, 192, 244, 0.28); } .sglv-hero-progress { width: 100%; height: 4px; border-radius: 3px; background: rgba(255, 255, 255, 0.08); overflow: hidden; } .sglv-hero-progress-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, #34d399, #66c0f4); transition: width 0.4s ease; } .sglv-hero-progress-cap { font-size: 9px; color: #6f8399; margin-top: 4px; letter-spacing: 0.02em; } /* scope 卡:紧凑双联,取代原 KPI 行里的"在库/未拥有"两张卡 */ .sglv-hero-scope-row { display: flex; gap: 6px; margin-top: 9px; } .sglv-hero-scope { flex: 1; min-width: 62px; padding: 5px 7px; border-radius: 6px; background: rgba(255, 255, 255, 0.045); border: 1px solid rgba(255, 255, 255, 0.07); cursor: pointer; transition: background 0.15s, border-color 0.15s, transform 0.12s; } .sglv-hero-scope:hover { background: rgba(255, 255, 255, 0.09); transform: translateY(-1px); } .sglv-hero-scope.active { background: rgba(102, 192, 244, 0.17); border-color: rgba(102, 192, 244, 0.5); } .sglv-hero-scope-val { display: block; font-size: 14px; font-weight: 700; line-height: 1.2; color: #dbe7f3; } .sglv-hero-scope-label { display: block; font-size: 9px; color: #8296ab; margin-top: 1px; } /* 右区:类型占比列表 —— flex:1 吃掉全部剩余宽度,这是"铺满浮窗"的关键 */ .sglv-hero-right { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; padding-left: 14px; border-left: 1px solid rgba(255, 255, 255, 0.07); } .sglv-hero-right-head { font-size: 9px; font-weight: 600; letter-spacing: 0.08em; color: #6f8399; text-transform: uppercase; margin-bottom: 2px; } .sglv-hero-type { display: flex !important; align-items: center; gap: 7px; padding: 3px 6px; border-radius: 5px; cursor: pointer; transition: background 0.15s; } .sglv-hero-type:hover { background: rgba(255, 255, 255, 0.05); } .sglv-hero-type.active { background: rgba(102, 192, 244, 0.14); } .sglv-hero-type-dot { width: 7px; height: 7px; border-radius: 2px; flex-shrink: 0; } .sglv-hero-type-name { font-size: 10px; color: #b9c9d9; width: 56px; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-hero-type-track { flex: 1 1 auto; min-width: 20px; height: 5px; border-radius: 3px; background: rgba(255, 255, 255, 0.06); overflow: hidden; } .sglv-hero-type-fill { display: block; height: 100%; border-radius: 3px; opacity: 0.85; transition: width 0.35s ease; } .sglv-hero-type-count { font-size: 10px; font-weight: 700; color: #dbe7f3; width: 46px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; } .sglv-hero-type-pct { font-size: 9px; color: #7e93ab; width: 30px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; } .sglv-hero-empty { font-size: 10px; color: #6f8399; padding: 8px 0; } /* 窄浮窗:右区换行到下方,仍保持铺满 */ @media (max-width: 720px) { .sglv-hero { flex-direction: column; gap: 10px; } .sglv-hero-left { min-width: 0; } .sglv-hero-right { padding-left: 0; padding-top: 10px; border-left: none; border-top: 1px solid rgba(255,255,255,0.07); } } /* === v2.6.0: 受限游戏卡片重构 —— letterbox 封面 + 状态斜切带 + 两栏 meta === */ /* 封面容器:横向图 letterbox(居中不裁剪),高度略降给信息区腾空间 */ .sglv-card-grid .sglv-game-card .sglv-card-img-wrap { aspect-ratio: 16 / 11 !important; /* 从 2:3 改为接近 header.jpg 的 460:215 比例 */ background: linear-gradient(145deg, rgba(102,192,244,0.05), rgba(139,92,246,0.05)) !important; } .sglv-img-wrapper > .sglv-card-img { object-fit: contain !important; /* 关键:横向图在容器内完整显示,不再硬裁主体 */ object-position: center; } /* === v2.9.4: 竖版海报卡片墙(系列分类 / PS 会免 / Epic 赠送) === 数据源里竖版 library_600x900 覆盖率更高,卡片默认改用 2:3 海报; 拿不到竖版时链尾回退 header.jpg,img 会被 markLoaded 标上 .sglv-img-wide → 下面单独走 contain letterbox,视觉上不裁切、不变形。 */ .sgc-panel .sglv-card-grid.sglv-card-grid-7 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; gap: 8px !important; } .sgc-panel .sglv-card-grid .sglv-game-card.sglv-poster-card .sglv-card-img-wrap { aspect-ratio: 2 / 3 !important; background: linear-gradient(160deg, rgba(22, 32, 45, 0.85) 0%, rgba(27, 40, 56, 0.85) 100%) !important; } /* 竖版:铺满容器(2:3 容器 + 2:3 图 → 零留白) */ .sgc-panel .sglv-poster-card .sglv-img-wrapper > .sglv-card-img:not(.sglv-img-wide) { object-fit: cover !important; object-position: center top; } /* 横版兜底:完整显示,上下留渐变底(不硬裁主体) */ .sgc-panel .sglv-poster-card .sglv-img-wrapper > .sglv-card-img.sglv-img-wide { object-fit: contain !important; object-position: center; } .sgc-panel .sglv-poster-card .sglv-card-body { padding: 5px 7px 6px !important; } .sgc-panel .sglv-poster-card .sglv-card-name { min-height: 27px !important; } .sgc-panel .sglv-poster-card .sglv-card-id-inline, .sgc-panel .sglv-poster-card .sglv-card-id { font-size: 9px !important; opacity: 0.55; } /* 7 列下卡片内边距/徽章同步收紧 */ .sgc-panel .sglv-poster-card .sglv-card-badge { font-size: 9px !important; padding: 1px 5px !important; } .sgc-panel .sglv-poster-card .sglv-info-row { gap: 4px !important; flex-wrap: wrap; } /* 状态斜切带(signature)—— 封���右下角的一道纯色斜带,无文字。 取代原先信息区里的实心「未拥有」色块,节省一整行高度。 未拥有 = 琥珀(醒目,提示"这个你还没有");已拥有 = 绿(细一些,次要) */ .sglv-own-flag { position: absolute !important; bottom: 0; right: 0; width: 62px; height: 5px; z-index: 6; pointer-events: none; clip-path: polygon(18px 0, 100% 0, 100% 100%, 0 100%); border-top-left-radius: 3px; } .sglv-own-flag.missing { background: linear-gradient(90deg, rgba(245,158,11,0.55), #f59e0b); box-shadow: 0 0 8px rgba(245,158,11,0.45); } .sglv-own-flag.owned { width: 34px; height: 4px; clip-path: polygon(14px 0, 100% 0, 100% 100%, 0 100%); background: linear-gradient(90deg, rgba(52,211,153,0.5), #34d399); } /* 两栏 meta:左 = 分类徽章 + 拥有态;右 = 数值/日期 */ .sglv-card-meta2 { display: flex !important; align-items: center; justify-content: space-between; gap: 6px; margin-top: 3px; min-width: 0; } .sglv-meta-col-left, .sglv-meta-col-right { display: flex; align-items: center; gap: 5px; min-width: 0; } .sglv-meta-col-left { flex: 0 1 auto; overflow: hidden; } .sglv-meta-col-right { flex: 0 0 auto; justify-content: flex-end; } .sglv-type-badge { display: inline-flex !important; align-items: center; font-size: 9px !important; padding: 1px 5px !important; border-radius: 3px !important; border: 1px solid transparent; font-weight: 700; line-height: 1.5; white-space: nowrap; flex-shrink: 0; } /* 拥有态文字:与分类徽章同字号同风格,只是无边框 —— 不再是实心色块 */ .sglv-own-text { font-size: 9px !important; font-weight: 600; line-height: 1.5; white-space: nowrap; flex-shrink: 0; } .sglv-own-text.owned { color: #34d399 !important; } .sglv-own-text.missing { color: #fbbf24 !important; } /* 右栏 meta 项:图标 + 文本,超长省略 */ .sglv-meta-col-right .sglv-meta-item { display: inline-flex !important; align-items: center; gap: 2px; font-size: 9px !important; color: var(--sglv-text-secondary) !important; white-space: nowrap; line-height: 1.5; } .sglv-meta-col-right .sglv-meta-item svg { width: 9px !important; height: 9px !important; opacity: 0.65; flex-shrink: 0; } .sglv-meta-col-right .sglv-m-playtime { color: #66c0f4 !important; font-weight: 700; } /* 卡片正文:两栏后高度收紧 */ .sglv-card-grid .sglv-game-card .sglv-card-body { padding: 5px 7px 6px !important; } .sglv-card-grid .sglv-game-card .sglv-card-name { font-size: 11px !important; line-height: 1.35; margin-bottom: 1px; } /* 列表视图的两栏 meta 同样生效 */ .sglv-list-item .sglv-card-meta2 { margin-top: 2px; } .sglv-list-item .sglv-meta-col-right .sglv-meta-item { font-size: 10px !important; } /* 搜索高亮:改为左标记 + 底色,避免整块抢眼 */ .sglv-highlight { background: rgba(251, 191, 36, 0.16) !important; color: #fbbf24 !important; border-radius: 2px; padding: 0 2px; font-weight: 700; box-shadow: inset 2px 0 0 rgba(251, 191, 36, 0.8); } /* === Humble 专项 v2.4.0: 月卡 + 缩略 + 展开行 === */ .sglv-humble-month-card { padding: 12px 14px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; margin-bottom: 10px; } .sglv-humble-month-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } .sglv-humble-month-title { font-size: 13px; font-weight: 700; color: #e2e8f0; } .sglv-humble-month-count { font-size: 11px; color: #94a3b8; } .sglv-humble-month-owned { margin-left: auto; font-size: 11px; font-weight: 700; color: #4caf50; } .sglv-humble-month-owned:empty, .sglv-humble-month-owned[data-zero="1"] { color: #8f98a0; } /* 月卡顶部 3-5 个游戏缩略 */ .sglv-humble-game-thumbs { display: flex; gap: 6px; margin: 8px 0 0 0; flex-wrap: wrap; } .sglv-humble-game-thumb { position: relative; width: 48px; height: 48px; border-radius: 4px; overflow: hidden; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08); flex-shrink: 0; } .sglv-humble-game-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-humble-game-thumb.no-img { background: linear-gradient(135deg, rgba(102, 192, 244, 0.08), rgba(139, 92, 246, 0.08)); } .sglv-humble-game-thumb.owned { border-color: rgba(76, 175, 80, 0.5); box-shadow: 0 0 0 1px rgba(76, 175, 80, 0.2); } .sglv-humble-game-thumb-mark { position: absolute; top: 1px; right: 1px; width: 14px; height: 14px; background: #4caf50; color: #fff; font-size: 10px; font-weight: 700; border-radius: 50%; display: flex; align-items: center; justify-content: center; line-height: 1; } .sglv-humble-game-thumb-more { width: 48px; height: 48px; border-radius: 4px; background: rgba(255, 255, 255, 0.04); border: 1px dashed rgba(255, 255, 255, 0.15); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; color: #94a3b8; flex-shrink: 0; } /* 展开行 */ .sglv-humble-month-details { margin-top: 8px; } .sglv-humble-month-summary { cursor: pointer; font-size: 11px; color: #66c0f4; user-select: none; padding: 2px 0; } .sglv-humble-month-rows { margin-top: 6px; display: flex; flex-direction: column; gap: 2px; } .sglv-humble-row { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 4px; font-size: 12px; color: #cbd5e1; transition: background 0.15s; } .sglv-humble-row:hover { background: rgba(102, 192, 244, 0.08); } .sglv-humble-row.owned { color: #4caf50; } .sglv-humble-row-icon { width: 24px !important; height: 24px !important; flex-shrink: 0; border-radius: 3px; object-fit: cover; background: rgba(255, 255, 255, 0.05); } .sglv-humble-row-owned, .sglv-humble-row-missing { flex: none; width: 16px; text-align: center; font-weight: 700; } .sglv-humble-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-humble-row-pub { flex: none; font-size: 10px; color: #8f98a0; } .sglv-humble-row-link { flex: none; font-size: 10px; color: #66c0f4; text-decoration: none; } .sglv-humble-row-link:hover { text-decoration: underline; } /* ==================== v2.9.6: 年度大作 列表边距收紧 ==================== */ /* 工具栏上下留白过大(搜索框行与内容区之间出现明显空隙),整体收紧 */ .sgc-panel .sglv-goty-toolbar-row { padding: 2px 8px 4px !important; gap: 5px !important; } /* 滚动区顶部 + 年份块/分组之间的间距收紧(年份块 14px + 分组 12px 叠加出 26px 空隙) */ /* v2.9.8: padding-top 归零——原 2px padding 与 sticky 分组标题栏(top:0)组合,吸附后标题栏停在 滚动口顶 +2px,该 2px 缝隙无栏体遮挡 → 滚过的封面从中透出(顶部穿透)。静止留白改用首元素 margin-top(随内容滚走,不影响吸附),吸附时标题栏与滚动口严丝合缝。 */ .sgc-panel .sglv-goty-scroll { padding-top: 0; } .sgc-panel .sglv-goty-scroll > :first-child { margin-top: 2px; } .sgc-panel .sglv-goty-year-block { margin-bottom: 8px; } .sgc-panel .sglv-goty-group { margin-bottom: 6px; } .sgc-panel .sglv-goty-group-header { margin-bottom: 6px; padding: 6px 10px; } /* 时间轴行自身留白收紧 */ .sgc-panel .sglv-goty-timeline { padding: 4px 14px 2px; margin: 0 2px 2px; } /* 空年份节点(disabled):灰显 + 禁用 hover 发光,消除"点了没反应" */ .sgc-panel .sglv-goty-tl-node[disabled] { cursor: not-allowed; opacity: 0.35; } .sgc-panel .sglv-goty-tl-node[disabled] .sglv-goty-tl-dot { box-shadow: none; } .sgc-panel .sglv-goty-tl-node[disabled]:hover .sglv-goty-tl-dot { transform: none; border-color: rgba(102,192,244,0.55); box-shadow: none; } .sgc-panel .sglv-goty-tl-node[disabled]:hover .sglv-goty-tl-year { color: #64748b; } /* ==================== v2.9.6: KPI 卡片光斑弱化(修复选中/hover 强光斑遮挡文字) ==================== */ /* 根因:主脚本 CSS_KPI 把 .sglv-trend-kpi-card::before 重定义为"全卡渐变 opacity:0/hover:1", 但共享 CSS 库的 ::before 还留着 width:40px/height:40px/border-radius:50%/filter:blur(8px) —— 两者层叠后变成"左上角一团 40x40 圆形高斯模糊光斑",hover/选中时完全遮住标签与图标。 */ .sgc-panel .sglv-trend-kpi-card::before { top: -1px !important; right: -1px !important; bottom: -1px !important; left: -1px !important; width: auto !important; height: auto !important; border-radius: inherit !important; filter: none !important; background: linear-gradient(135deg, rgba(102,192,244,0.10), transparent 52%) !important; opacity: 0; } .sgc-panel .sglv-trend-kpi-card:hover::before { opacity: 0.5; } /* 数值发光(text-shadow 12px currentColor + brightness 滤镜)弱化,避免数字糊成一团 */ .sgc-panel .sglv-trend-kpi-card .sglv-trend-kpi-value { text-shadow: none !important; filter: none !important; } /* 选中态:只保留 accent 描边(去掉 16px 外发光),选中状态用边框表达更清晰 */ .sgc-panel .sglv-trend-kpi-card.active { box-shadow: 0 0 0 1px rgba(102,192,244,0.55) inset !important; } /* hover 抬升 + 多重外发光(含 22px 蓝光晕)收敛为轻阴影 */ .sgc-panel .sglv-trend-kpi-card:hover, .sgc-panel .sglv-trend-kpi-card.clickable:hover { transform: translateY(-1px) !important; box-shadow: 0 3px 10px rgba(0,0,0,0.35) !important; } /* ==================== v2.9.6: Humble 月包缩略图平铺 ==================== */ /* 缩略图不再截断 5 个 + "+N",全量平铺(flex-wrap 自动换行),右侧空白不再浪费 */ .sgc-panel .sglv-humble-game-thumbs { gap: 5px; } .sgc-panel .sglv-humble-game-thumb, .sgc-panel .sglv-humble-game-thumb-more { width: 44px; height: 44px; } /* ==================== v2.9.8: TGA 年度游戏殿堂 ==================== */ /* 金色「年度游戏」时间轴节点(置于年份节点之前) */ .sgc-panel .sglv-goty-tl-node.sglv-goty-tl-goty { padding-right: 12px; margin-right: 4px; } .sgc-panel .sglv-goty-tl-node.sglv-goty-tl-goty::after { content: ''; position: absolute; top: 2px; bottom: 2px; right: 0; width: 1px; background: linear-gradient(180deg, transparent, rgba(245,158,11,0.4), transparent); } .sgc-panel .sglv-goty-tl-node.sglv-goty-tl-goty .sglv-goty-tl-dot { width: 9px; height: 9px; border-color: rgba(245,158,11,0.7); background: radial-gradient(circle, #fde68a 0%, #f59e0b 70%); box-shadow: 0 0 8px rgba(245,158,11,0.6); } .sgc-panel .sglv-goty-tl-node.sglv-goty-tl-goty .sglv-goty-tl-year { color: #fbbf24; } .sgc-panel .sglv-goty-tl-node.sglv-goty-tl-goty:hover .sglv-goty-tl-dot { transform: scale(1.35); border-color: #fde68a; box-shadow: 0 0 14px rgba(245,158,11,0.9); } .sgc-panel .sglv-goty-tl-node.sglv-goty-tl-goty.sglv-goty-tl-active .sglv-goty-tl-dot { background: #fde68a; border-color: #fde68a; box-shadow: 0 0 16px rgba(245,158,11,1), 0 0 5px rgba(255,255,255,0.7); } .sgc-panel .sglv-goty-tl-node.sglv-goty-tl-goty.sglv-goty-tl-active .sglv-goty-tl-year { color: #fde68a; text-shadow: 0 0 10px rgba(245,158,11,0.8); } /* 殿堂 hero 标题条 */ .sglv-tga-hall { padding: 2px 2px 8px; } .sglv-tga-hero { display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin-bottom: 10px; border-radius: 10px; background: linear-gradient(135deg, rgba(245,158,11,0.16), rgba(15,23,42,0.4) 55%); border: 1px solid rgba(245,158,11,0.35); } .sglv-tga-hero-icon { display: inline-flex; color: #fbbf24; flex-shrink: 0; } .sglv-tga-hero-icon svg { width: 26px; height: 26px; } .sglv-tga-hero-txt { min-width: 0; } .sglv-tga-hero-title { font-size: 16px; font-weight: 800; color: #fde68a; letter-spacing: 0.5px; line-height: 1.2; } .sglv-tga-hero-sub { font-size: 10px; font-weight: 700; color: rgba(251,191,36,0.7); letter-spacing: 1.5px; } .sglv-tga-hero-count { margin-left: auto; flex-shrink: 0; font-size: 12px; font-weight: 700; color: #fbbf24; white-space: nowrap; } /* 殿堂冠军墙 */ .sglv-tga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 8px; } .sglv-tga-cell { position: relative; display: flex; flex-direction: column; border-radius: 9px; overflow: hidden; cursor: pointer; background: rgba(15,23,42,0.5); border: 1px solid rgba(245,158,11,0.22); transition: transform 0.15s, border-color 0.2s, box-shadow 0.2s; } .sglv-tga-cell:hover { transform: translateY(-3px); border-color: rgba(245,158,11,0.75); box-shadow: 0 8px 22px rgba(0,0,0,0.45), 0 0 14px rgba(245,158,11,0.25); } .sglv-tga-cell.not-owned { opacity: 0.72; } .sglv-tga-cell.not-owned:hover { opacity: 1; } .sglv-tga-poster { position: relative; width: 100%; aspect-ratio: 2/3; background: #0f172a; overflow: hidden; } .sglv-tga-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-tga-year-badge { position: absolute; top: 5px; left: 5px; z-index: 3; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 800; color: #1a1206; letter-spacing: 0.5px; background: linear-gradient(135deg, #fde68a, #f59e0b); box-shadow: 0 1px 5px rgba(0,0,0,0.5); } .sglv-tga-crown { position: absolute; top: 4px; right: 5px; z-index: 3; display: flex; align-items: center; gap: 1px; color: #fbbf24; filter: drop-shadow(0 1px 3px rgba(0,0,0,0.7)); font-size: 10px; font-weight: 800; } .sglv-tga-crown svg { width: 15px; height: 15px; } .sglv-tga-crown.multi svg { width: 13px; height: 13px; } .sglv-tga-cell-info { padding: 5px 7px 6px; display: flex; flex-direction: column; gap: 3px; } .sglv-tga-cell-name { font-size: 11.5px; font-weight: 700; color: #f1f5f9; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-tga-tag { display: inline-flex; align-items: center; gap: 2px; align-self: flex-start; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; line-height: 1.4; color: #94a3b8; background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.2); } .sglv-tga-tag.owned { color: #66bb6a; background: rgba(102,187,106,0.18); border-color: rgba(102,187,106,0.35); } .sglv-tga-excl-type { display: inline-flex; align-items: center; align-self: flex-start; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; line-height: 1.4; border: 1px solid currentColor; background: rgba(255,255,255,0.05); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 非 Steam 平台占位画(平台主色 SVG + 平台名 + 提示;有商店图则叠加) */ .sglv-tga-excl-art { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 6px; text-align: center; background: radial-gradient(circle at 50% 35%, rgba(255,255,255,0.08), rgba(15,23,42,0.85)); } .sglv-tga-excl-art svg { width: 34px; height: 34px; opacity: 0.9; } .sglv-tga-excl-name { font-size: 10px; font-weight: 700; color: currentColor; line-height: 1.2; } .sglv-tga-excl-hint { font-size: 9px; color: rgba(148,163,184,0.75); } .sglv-tga-excl-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 2; } /* 年度大作卡片上的金色 👑 年度游戏 chip */ .sglv-goty-tga-chip { display: inline-flex; align-items: center; gap: 2px; cursor: pointer; font-size: 9px; font-weight: 800; padding: 1px 5px; border-radius: 3px; line-height: 1.4; color: #1a1206; background: linear-gradient(135deg, #fde68a, #f59e0b); border: 1px solid rgba(245,158,11,0.6); white-space: nowrap; } .sglv-goty-tga-chip:hover { filter: brightness(1.12); box-shadow: 0 0 8px rgba(245,158,11,0.6); } /* 提名弹窗 */ .sglv-tga-modal { position: fixed; inset: 0; z-index: 2147483000; display: flex; align-items: center; justify-content: center; background: rgba(4,8,16,0.72); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); padding: 20px; } .sglv-tga-modal-win { display: flex; flex-direction: column; width: 100%; max-width: 720px; max-height: 86vh; background: #141d2e; border: 1px solid rgba(245,158,11,0.4); border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 30px rgba(245,158,11,0.12); overflow: hidden; } .sglv-tga-modal-head { display: flex; align-items: center; gap: 8px; padding: 12px 16px; flex-shrink: 0; background: linear-gradient(135deg, rgba(245,158,11,0.18), rgba(20,29,46,0.2)); border-bottom: 1px solid rgba(245,158,11,0.25); } .sglv-tga-modal-trophy { display: inline-flex; color: #fbbf24; } .sglv-tga-modal-trophy svg { width: 20px; height: 20px; } .sglv-tga-modal-title { font-size: 15px; font-weight: 800; color: #fde68a; white-space: nowrap; } .sglv-tga-modal-sub { font-size: 11px; font-weight: 600; color: rgba(251,191,36,0.8); white-space: nowrap; } .sglv-tga-modal-close { margin-left: auto; display: inline-flex; cursor: pointer; color: #94a3b8; padding: 2px; border-radius: 4px; transition: color 0.15s, background 0.15s; } .sglv-tga-modal-close:hover { color: #f8fafc; background: rgba(255,255,255,0.1); } .sglv-tga-modal-close svg { width: 18px; height: 18px; } .sglv-tga-modal-body { display: flex; gap: 14px; padding: 14px 16px; overflow-y: auto; } .sglv-tga-noms-side { flex-shrink: 0; width: 168px; display: flex; flex-direction: column; gap: 6px; } .sglv-tga-noms-side-top { display: flex; align-items: center; gap: 6px; } .sglv-tga-side-year { font-size: 13px; font-weight: 800; color: #fde68a; } .sglv-tga-side-cat { font-size: 10px; font-weight: 700; color: rgba(251,191,36,0.75); } .sglv-tga-winner-poster { position: relative; width: 100%; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #0f172a; border: 2px solid rgba(245,158,11,0.65); box-shadow: 0 4px 16px rgba(0,0,0,0.4); cursor: pointer; } .sglv-tga-winner-poster.excl { cursor: default; } .sglv-tga-winner-img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-tga-noms-side-name { font-size: 13px; font-weight: 700; color: #f1f5f9; line-height: 1.3; } .sglv-tga-noms-side-own { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .sglv-tga-winner-badge { display: inline-flex; align-items: center; font-size: 10px; font-weight: 800; padding: 2px 8px; border-radius: 4px; color: #1a1206; background: linear-gradient(135deg, #fde68a, #f59e0b); } .sglv-tga-noms-grid { flex: 1; min-width: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-content: start; } .sglv-tga-nom-card { display: flex; flex-direction: column; gap: 4px; padding: 6px; border-radius: 8px; cursor: pointer; background: rgba(15,23,42,0.5); border: 1px solid rgba(102,192,244,0.18); transition: border-color 0.2s, transform 0.15s; } .sglv-tga-nom-card:hover { border-color: rgba(102,192,244,0.55); transform: translateY(-2px); } .sglv-tga-nom-card.excl { cursor: default; } .sglv-tga-nom-card.excl:hover { transform: none; border-color: rgba(102,192,244,0.18); } .sglv-tga-nom-img-wrap { position: relative; width: 100%; aspect-ratio: 467/181; border-radius: 5px; overflow: hidden; background: #0f172a; } .sglv-tga-nom-img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-tga-nom-name { font-size: 11.5px; font-weight: 600; color: #f1f5f9; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-tga-noms-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: #64748b; font-size: 13px; min-height: 120px; } @media (max-width: 620px) { .sglv-tga-modal-body { flex-direction: column; } .sglv-tga-noms-side { width: 100%; flex-direction: row; align-items: flex-end; gap: 12px; } .sglv-tga-winner-poster { width: 120px; } } `; // ==================== v2.8.0: 统一封面渲染(按 kind) ==================== // 背景:Epic / PS会免 / 系列分类 / 年度大作 / DLC 五个 tab 的「大海报视图」全部加载失败, // 但它们的「网格视图」和「列表视图」正常 —— 说明问题不在数据、不在网络, // 而在这几个 tab 特有的渲染路径。 // // 排查出的三个叠加原因: // ① 一次性锁不可复位:_sgcvChainFallbackImpl 首次进入时置 img._sglvChainLoading = 1 且永不复位。 // 同一 appid 在视图间切换(网格→大海报→网格)时,第二次 onerror 直接被锁死, // chainLoad 永不执行 → 永远停在坏 src 上。用户「切到大海报视图」恰好触发这个路径。 // ② kind 缓存污染:网格视图写入的是竖版 good URL(recordPosterGood(appid, url, 'poster')), // 大海报视图按 'capsule' 读缓存时被 _sgcvUrlMatchesKind 判为方向不符而丢弃, // 于是既拿不到缓存、又被迫走链首的 library_hero(对非新游戏命中率低)。 // ③ 跨上下文 inline onerror:这五处都依赖挂在 unsafeWindow 上的 // _sglvChainFallback / _sglvRecordPosterGood 才能回退,一旦某次注入失败或被覆盖, // onerror 就是个 undefined 函数,图片静默停在坏 src 上(无任何可见错误)。 // // 统一方案:全部改走 v2.7.0 的 bindRestrictedCoverLoaders(addEventListener + 闭包链索引, // 无跨上下文依赖、无一次性锁、每次渲染重新绑定),按 kind 给出不同的 URL 链与容器类。 // ==================================================================== /** 按 kind 返回统一封面链(横向优先,命中率高) * - 'poster' → 卡片视图用,横图 letterbox * - 'capsule' → 大海报视图用,横向大图铺满 * - 'hero' → 系列/供应商视图用,超宽横图 */ function unifiedCoverChain(appid, kind, preferVertical) { const id = String(appid); const cdn = [ 'https://cdn.cloudflare.steamstatic.com/steam/apps/' + id, 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/' + id, 'https://cdn.akamai.steamstatic.com/steam/apps/' + id, 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/' + id, ]; if (kind === 'poster' && preferVertical) { // v2.9.4: 竖版优先链(与主脚本 coverChainUrls('poster') 保持一致)—— // library_600x900_2x → library_600x900 → header 兜底 // 拿不到竖版时自动落到 header.jpg(横版 letterbox),逻辑不变 const files = ['library_600x900_2x.jpg', 'library_600x900.jpg', 'header.jpg']; const out = []; for (const f of files) for (let i = 0; i < cdn.length; i++) out.push(cdn[i] + '/' + f); return out; } if (kind === 'hero') { // hero:优先 library_hero(系列视图专属,超宽比例) return [ cdn[0] + '/library_hero.jpg', cdn[1] + '/library_hero.jpg', cdn[2] + '/library_hero.jpg', cdn[3] + '/library_hero.jpg', cdn[0] + '/header.jpg', cdn[1] + '/header.jpg', cdn[2] + '/header.jpg', cdn[3] + '/header.jpg', cdn[0] + '/capsule_467x181.jpg', cdn[1] + '/capsule_467x181.jpg', ]; } // poster / capsule:统一横向链(header 命中率最高,capsule_467x181 兜底) return [ cdn[0] + '/header.jpg', cdn[1] + '/header.jpg', cdn[2] + '/header.jpg', cdn[3] + '/header.jpg', cdn[0] + '/capsule_467x181.jpg', cdn[1] + '/capsule_467x181.jpg', cdn[0] + '/capsule_231x87.jpg', cdn[1] + '/capsule_231x87.jpg', ]; } /** * 统一封面组件(替代 epicCapsuleImg / psplusCapsuleImg / seriesCapsuleImg / gotyCoverImg / dlcPosterImg) * @param {number|string} appid * @param {string} name * @param {'poster'|'capsule'|'hero'} kind * @param {Object} [opts] * @param {string} [opts.imgCls] - img 类名(默认按 kind 推断) * @param {boolean} [opts.isBlocked] - 锁区红色锁占位 * @returns {string} HTML */ function unifiedCover(appid, name, kind, opts) { const o = opts || {}; const k = kind || 'poster'; if (!appid) { // 无 appid(PS 独占游戏)—— 显示占位容器,不生成坏 img return '
' + Host.wlEscape(name || (Host.isZh ? '无封面' : 'No Cover')) + '
'; } const imgCls = o.imgCls || (k === 'poster' ? 'sglv-card-img' : 'sglv-wl-cover-img'); const safeName = Host.wlEscape(name || ''); const brokenCls = 'sglv-img-broken' + (o.isBlocked ? ' sglv-img-broken-blocked' : ''); // v2.9.4: preferVertical —— 竖版海报卡片墙(系列/PS/Epic) const preferVertical = !!o.preferVertical; const chain = unifiedCoverChain(appid, k, preferVertical); // 已有 good 缓存则优先用(跨视图共享): // ① v2.9.3: coverGoodUrl —— 方向门控 + 横版 header/capsule 交叉槽位, // 同一张图不再由主面板/收藏/PS/Epic 各探一次 // ② v2.9.2: cover-fallback 的跨会话缓存 let first = ''; try { first = coverGoodUrl(String(appid), k) || ''; } catch (e) { /* ignore */ } // 竖版槽位里的横版条目 = 上次已确认该游戏无竖版资产(链耗尽标记): // 直接拿来 letterbox,省掉每次重新吃 8 个 404 if (!first && k === 'poster') { try { first = coverHorizontalFallbackUrl(String(appid)) || ''; } catch (e) { /* ignore */ } } try { const fb = _coverFb(); if (!first && fb && typeof fb.getCoverUrl === 'function') { first = fb.getCoverUrl(String(appid), k, coverLang()) || ''; } } catch (e) { /* ignore */ } first = first || chain[0]; // v2.9.2: wrapper 必须带 data-cover-appid —— bindRestrictedCoverLoaders 用 // `.sglv-img-wrapper[data-cover-appid]` 选择;此前只写 data-appid, // 选择器一个都匹配不到 → 从不绑定 → sglv-img-loading 永不摘除 // → .sglv-wl-cover-img-wrap 下 opacity:0,大海报视图全是空白。 return '
' + '' + safeName + '' + '
' + _restrictedBrokenInner(name, o.isBlocked) + '
' + '
'; } // v1.9.24: 锁区游戏 SVG 锁图案——用于标签页图标和 KPI 卡片图标 // 全新样式:渐变锁体 + 弧形锁扣 + 钥匙孔,带红色警示色调 const BLOCKED_LOCK_ICON_SVG = '' + '' + '' + '' + '' + ''; // v1.9.24: 锁区游戏 SVG 锁图案——最终兜底占位符 // 使用递增序号生成唯一 gradient ID 避免多实例冲突 let _blockedCoverSeq = 0; function blockedCoverSvg() { const sid = '_blk' + (_blockedCoverSeq++); return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; } // v1.9.24: 锁区游戏封面兜底 DOM(CDN + API 全部失败时展示) function blockedPlaceholderDom(name) { const holder = document.createElement('div'); holder.className = 'sglv-blocked-cover-placeholder'; holder.innerHTML = blockedCoverSvg() + '
' + Host.wlEscape(name || '') + '
'; return holder; } // v1.9.24: 锁区游戏封面全局回退——Steam CDN 全部失败时先用 Store API 取真实封面 URL, // API URL 仍加载失败时替换为 SVG 锁占位符(参照 _sglvPsCoverFallback 逻辑) // v1.9.29: 传递 kind 参数,使 API 兜底按图片类型选择最佳 URL unsafeWindow._sglvBlockedCoverFallback = function(img, appid, kind) { if (!img || !img.parentElement) return; Host.fetchCoverApiUrl(appid, kind).then(url => { if (url) { img.onerror = function() { img.onerror = null; img.parentElement.replaceChild(blockedPlaceholderDom(img.alt), img); }; img.src = url; return; } img.parentElement.replaceChild(blockedPlaceholderDom(img.alt), img); }); }; // v1.9.24: 锁区游戏列表视图图标回退——CDN 图标失败时替换为锁图标占位 div unsafeWindow._sglvBlockedListIconFallback = function(img) { if (!img || !img.parentElement) return; const holder = document.createElement('div'); holder.className = 'sglv-list-icon sglv-blocked-list-icon'; holder.innerHTML = ''; img.parentElement.replaceChild(holder, img); }; // v1.9.24: 锁区游戏竖版封面——Steam 多 CDN 回退链 + Store API + SVG 锁兜底 // v2.0.0: 统一 onerror 委托 _sglvChainFallback(失败时由 chainLoad JS 接管整链 + API 兜底) // 锁占位 SVG 由 _sglvChainFallbackImpl → onGiveUp(若 chainLoad 完全失败) → 后续由 blocked 专用回调接管 // v2.9.22: sgc-panel 拖动(参考 steam-friend-manager 1.2.5 makeDraggable) // 用 !important 覆盖 shared CSS 中 sglv-panel 的 top/left/transform 定位 function makeSgcDraggable(el, handle) { if (!el || !handle) return; let isDragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0; const onDown = (e) => { const t = e.target; if (t.closest('button, input, select, textarea, label, a')) return; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = el.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; el.style.cursor = 'grabbing'; e.preventDefault(); }; const onMove = (e) => { if (!isDragging) return; el.style.setProperty('left', (startLeft + e.clientX - startX) + 'px', 'important'); el.style.setProperty('top', (startTop + e.clientY - startY) + 'px', 'important'); el.style.setProperty('right', 'auto', 'important'); el.style.setProperty('bottom', 'auto', 'important'); el.style.setProperty('transform', 'none', 'important'); }; const onUp = () => { if (!isDragging) return; isDragging = false; el.style.cursor = ''; }; handle.addEventListener('mousedown', onDown); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } function init(ctx) { if (!ctx || typeof ctx !== 'object') { console.warn('[SGLVCollection] init: invalid ctx'); return false; } if (ctx.apiVersion !== apiVersion) { console.warn('[SGLVCollection] Host apiVersion mismatch: expected', apiVersion, 'got', ctx.apiVersion); // 尝试用宿主的 showToast 提示(可能不可用) try { if (ctx.showToast) { const zh = ctx.isZh; ctx.showToast(zh ? '收藏模块版本不兼容,请更新主脚本或收藏模块' : 'Collection module version mismatch, please update host script or collection module'); } } catch (e) { /* ignore */ } return false; } Host = ctx; // v1.9.47-fix: 同步预热绝版游戏远程源 TTL 缓存(避免开 tab 才发起请求,首屏秒开) try { _warmDelistedRemoteCache(); } catch (e) { /* 忽略 */ } return true; } // ==================== 游戏收藏模块 ==================== // v1.9.5: 永久缓存层——绕过宿主 TTL 缓存 // v1.9.6: 从 GM_getValue/GM_setValue 迁移到 SGLVCore.IDB // 原因:GM_setValue 在多数环境有 5MB 限制,大收藏数据(数千条 + 元数据)可能撑爆 // IDB 容量无限制,且与主脚本 PCC 持久化层同源,统一管理 // 旧 key 'sglv_perm_xxx' 兼容:首次读取时尝试从旧 GM key 迁移,迁移后清旧 key const PERM_CACHE_PREFIX = 'sglv_idb_perm_'; const PERM_CACHE_GM_PREFIX = 'sglv_perm_'; // v1.9.5 旧 key,用于一次性迁移 const _C_IDB = _C && _C.IDB; function _permCacheGet(key) { // 1) 优先从 IDB 读 if (_C_IDB) { try { const v = _C_IDB.get(PERM_CACHE_PREFIX + key, null); if (v != null) return v; } catch (e) { /* IDB 读失败,降级到旧 GM */ } } // 2) 旧 GM 兜底(一次性迁移)— 读旧 key 并迁移到 IDB try { const raw = GM_getValue(PERM_CACHE_GM_PREFIX + key, ''); if (!raw) return null; const parsed = JSON.parse(raw); // 立即迁移到 IDB if (_C_IDB && parsed != null) { try { _C_IDB.set(PERM_CACHE_PREFIX + key, parsed); } catch (e) { /* ignore */ } // 迁移成功后清旧 key(可选,这里保留以防 IDB 写入失败) // 不主动清,让 IDB 读优先 + 下次读时也会再次尝试迁移 } return parsed; } catch { return null; } } function _permCacheSet(key, data) { if (_C_IDB) { try { _C_IDB.set(PERM_CACHE_PREFIX + key, data); return; } catch (e) { /* IDB 失败,降级 */ } } // 降级到旧 GM try { GM_setValue(PERM_CACHE_GM_PREFIX + key, JSON.stringify(data)); } catch (e) { /* ignore quota */ } } // v1.9.5: 按 appid 增量合并——新数据覆盖同 appid 条目,旧数据中独有的条目保留 function _mergeByAppid(oldItems, newItems) { if (!Array.isArray(oldItems) || oldItems.length === 0) return newItems || []; if (!Array.isArray(newItems) || newItems.length === 0) return oldItems; const map = new Map(); oldItems.forEach(g => { if (g && g.appid) map.set(g.appid, g); }); newItems.forEach(g => { if (g && g.appid) map.set(g.appid, g); }); return Array.from(map.values()); } // v1.9.5: appid 数组增量合并(去重) function _mergeAppidArray(oldIds, newIds) { const set = new Set(oldIds || []); (newIds || []).forEach(id => set.add(id)); return Array.from(set); } // v1.9.5: 后台刷新节流检查——intervalMs 内已刷新过则跳过 function _shouldThrottle(lastTs, intervalMs) { return lastTs > 0 && (Date.now() - lastTs) < intervalMs; } // v1.9.5: debounce 工具函数——避免搜索框每次按键触发完整重渲染 function _debounce(fn, delay = 200) { let timer = null; const debounced = function(...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); }; return debounced; } // v1.9.5: 缓存 Intl.Collator 实例,避免每次过滤都重新构造 const _nameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); let _collectionActiveTab = 'publisher'; // v1.0.4: 默认标签改为"供应商/系列"(与新 tab 顺序一致) let _collectionBodyInitialized = false; // v2.9.22: 跟踪 body 是否已首次渲染——避免首次打开闪一帧骨架 let collectionOverlayEl = null; let collectionPanelEl = null; // ---------- 模块本地状态(v2.6.4 起不再挂主 state,库化后天然隔离) ---------- // v1.8.2: mode = 'publisher' | 'series' — 供应商/系列双模式切换(系列数据独立展示不合并) // v2.9.92: 新增 supplierResult/seriesResult 跟踪各自刷新结果(updated/nochange/error) // v1.9.40: 移除 supplierFetchedAt/seriesFetchedAt——更新时间文本不再常驻工具栏(曾压缩切换按钮致变形), // 刷新结果改为悬浮提示(不参与 flex 布局) const pubState = { mode: 'publisher', expandedPublishers: new Set(), selectedPublisher: null, selectedSeries: null, viewMode: 'card', filter: 'all', dataLoading: false, refreshStage: '', supplierResult: '', seriesResult: '', showResultUntil: 0, // v1.9.31: 系列详情排序——sortField: order|date|name|appid,sortDir: asc|desc sortField: 'order', sortDir: 'asc' }; // v1.9.19: 系列详情分页状态(供应商/系列详情共用)——pageSize 与其他 tab 保持一致(48) // pageCache: { key: 页码 } 记忆每个系列的浏览页码,切换回已浏览分类时恢复进度 // v2.9.4: 系列分类卡片墙改 7 列 → 每页 49(7×7)刚好铺满,不留半排 const pubDetailState = { page: 1, pageSize: 49, cacheKey: null, pageCache: new Map() }; const delistedState = { search: '', typeFilter: 'all', subFilter: 'all', page: 1, pageSize: 48, viewMode: 'card', // v1.9.32: 绝版游戏排序——appidDesc|appidAsc|dateDesc|dateAsc // v1.9.47-fix: pageSize 50→48——与其他 tab(psplus/epic/blocked/goty/dlc/bundle)保持一致 sort: 'appidDesc' }; const psplusState = { games: [], loaded: false, loading: false, search: '', tierFilter: 'all', statusFilter: 'all', sort: 'dateDesc', page: 1, pageSize: 49, viewMode: 'card', updatedAt: '', loadError: null }; // v2.7.9: Epic 赠送模块状态(数据仅为 Steam appid 数组,名称异步加载) const epicState = { appids: [], loaded: false, loading: false, search: '', ownFilter: 'all', sort: 'appidDesc', page: 1, pageSize: 49, viewMode: 'card', updatedAt: '', loadError: null, source: '' }; // v2.9.7: 年度大作模块状态(数据含 name/date/appid,名称无需异步加载) // v2.x: "GotY 时光机"——年份时间轴 + 分组(未发售/已发售)+ 焦点卡 + 网格 5 列 // 移除 year 单选字段(改用 expandedYears Set 默认全部展开),移除 page/pageSize(网格 5 列滚动) // v2.9.6: 新增 yearFilter —— 年份时间轴节点点击后"真筛选"(null=全部年份); // 旧实现只是 scrollIntoView 滚动定位,平滑滚动中途 active 会漂移到中间年份, // 且停在年份块之间的 margin 空隙上,表现为"点击不生效/位置不准/顶部留白"。 // v2.9.8: 新增 tgaView —— 时间轴金色「年度游戏」节点开启后,滚动区改渲染 TGA 历届 GOTY 殿堂 // (与年份筛选互斥:进入殿堂清 yearFilter,选年份则退出殿堂)。 const gotyState = { games: [], loaded: false, loading: false, availableYears: [], expandedYears: null, search: '', ownFilter: 'all', yearFilter: null, tgaView: false, sort: 'dateDesc', viewMode: 'card', updatedAt: '', loadError: null, source: '', lastFetchAt: 0 }; // v1.9.0: DLC 收藏模块状态——三级渐进加载(Barter.vg DLC DB → Steam 全库存 type → DLC 子类型) // v1.9.1: 加 stage / level*Done 字段——支持 L1∥L2 并行 + 中间阶段增量 rebuild(不阻塞 UI 进程) // v1.9.2: 加 dlcTypeCache 字段——本地 IDB 永久缓存 DLC 子类型映射(只增不减),实现真正增量 const dlcState = { dlcs: [], parentTotalMap: {}, loaded: false, loading: false, loadError: null, search: '', typeFilter: 'all', sort: 'parentAsc', page: 1, pageSize: 48, viewMode: 'card', groupMode: true, expandedParents: new Set(), ownFilter: 'all', // v1.9.4: 全部/已拥有 筛选 stage: 0, // 0=未开始 1=Barter.vg 2=appType 3=DLC 子类型 level1Done: false, // Barter.vg DLC DB 完成 level2Done: false, // Steam 全库存 appType 完成 rebuildQueued: false, // 防抖:同一帧多次 scheduleRebuild 合并 // v1.9.2: 本地 IDB 永久缓存,key=appId,value=type('dlc'/'music'/'video'...) // 加载时用此缓存立即填充 buildDlcList,只 enrich 缓存里没有的 DLC(真正的"只增不减"增量) dlcTypeCache: {}, dlcTypeCacheLoaded: false, // IDB 异步加载完成后置 true idbLoadPromise: null, // 第一次 IDB.loadAll 的 promise(避免重复) // v1.9.3: 后台静默刷新节流(避免短时间内重复 enrich) lastBgRefreshAt: 0, // v1.9.8: 后台 DLC 子类型增量获取中(不阻塞 UI 渲染) dlcTypeEnriching: false, // v1.9.8: 缓存 filteredDlc 结果(filters/search/sort 未变时跳过重算) _filteredCache: null, _filteredCacheKey: null, }; // v1.9.2: IDB 缓存 schema 版本号(用于 cache invalidation 升级) // v1.9.3: 版本号稳定为 1——业务 key(dlc_subtype_map)+ 内容 schema 都没变,lib 升级不应清缓存 const _DLC_CACHE_VERSION = { dlc_subtype: 1 }; // v1.9.14: 捆绑包标签页状态——复用 DLC 进包数据(Host.getBundleCount + Host.getDlcsByParentMap) const bundleState = { data: [], // [{appid,name,isOwned,totalBundle,dlcCount,dlcs:[{appid,name,bundleCount,isOwned,playtime}]}] loaded: false, loading: false, // v2.5.0: Bundle 数据库加载中 loadError: false, // v2.5.0: Bundle 数据库加载失败 search: '', sort: 'bundleDesc', page: 1, pageSize: 48, }; const _DLC_TYPE_CACHE_IDB_KEY = 'dlc_subtype_map'; // v1.9.3: 后台静默刷新节流——6h(与 cloud-save 同步,避免短时间内重复跑 L1+L2+L3) const _DLC_BG_REFRESH_INTERVAL = 6 * 3600 * 1000; // v1.9.1: DLC 三级加载阶段文案(loading 骨架中央显示当前阶段) const DLC_STAGES = [ null, // 0 占位 { pct: 15, zh: '加载 Barter.vg DLC 数据库…', en: 'Loading Barter.vg DLC DB…' }, { pct: 55, zh: '补全 Steam 全库存应用类型…', en: 'Enriching Steam app types…' }, { pct: 85, zh: '获取 DLC 子类型(音乐/视频…)…', en: 'Fetching DLC subtypes…' }, ]; // ==================== v2.2.0: 受限游戏板通用组件 ==================== // 仿 steam-friend-manager buildDelistedBoardData + makeDelistedCardsRow 风格, // 抽象出"受限游戏板(绝版/锁区/将来可能的 wishlist 锁区 等)"通用数据层 + UI 组件, // 替代每个 tab 各自维护一套内联模板字符串。 // // 用法: // const board = makeRestrictedBoardData(myGames, restrictedList, { typeKey, typeLabelMap }); // const heroHtml = makeRestrictedHero({ total, ownedCount, scopeBase, typeKeys, ... }); // const cardHtml = makeRestrictedCard({ appid, name, isOwned, typeLabel, ... }); // const listHtml = makeRestrictedListItem({ appid, name, isOwned, ... }); // ============================================================ /** 构建受限游戏板数据:来源游戏 ∩ 受限数据库 * @param {Array} sourceEntries - 来源游戏 [{appid, name, ...}] * @param {Array} restrictedList - 受限数据库 [{appid, name, type, changed, ...}] * @param {Object} [opts] * @param {Function} [opts.typeKey] - 标准化 type 字段(默认直接取 a.type) * @returns {{owned: Array, missing: Array, allApps: Array, total: number}} */ function makeRestrictedBoardData(sourceEntries, restrictedList, opts) { const o = opts || {}; const typeKeyFn = o.typeKey || ((a) => a.type || 'default'); const ownedIds = new Set(); const ownedEntryMap = new Map(); (sourceEntries || []).forEach(s => { if (s && s.appid != null) { const id = Number(s.appid); ownedIds.add(id); ownedEntryMap.set(id, s); } }); const owned = []; const missing = []; const allApps = []; (restrictedList || []).forEach(app => { if (!app || app.appid == null) return; const appid = Number(app.appid); if (!appid) return; const entry = { appid, name: app.name || ('App ' + appid), type: typeKeyFn(app) || 'default', changed: app.changed || app.delistedAt || '', _meta: app // 保留原始数据,调用方可读取附加字段(achievements / owners 等) }; allApps.push(entry); if (ownedIds.has(appid)) { owned.push(Object.assign({ sourceEntry: ownedEntryMap.get(appid) }, entry)); } else { missing.push(entry); } }); return { owned, missing, allApps, total: restrictedList ? restrictedList.length : 0 }; } /** KPI 卡片行:总数 / 已拥有 / 未拥有 + 类型筛选 * @param {Object} opts * @param {number} opts.total * @param {number} opts.ownedCount * @param {number} opts.missingCount * @param {Array} opts.scopeBase - 范围筛选基础列表('all' / 'owned' / 'missing' 切换) * @param {string} opts.scope - 'all' | 'owned' | 'missing' * @param {string} opts.typeFilter - 'all' | * @param {string[]} opts.labels - [总数, 拥有, 未拥有] 三档文案 * @param {string[]} [opts.typeKeys] - 类型 key 列表(决定渲染几个类型卡) * @param {Object} [opts.typeColors] - { typeKey → 颜色 } * @param {Object} [opts.typeLabels] - { typeKey → 标签 } * @param {string} opts.tabId - 用于事件委托的 data-attr 前缀 */ /** * 受限游戏板 HERO —— v2.6.0 重设计 * * 旧设计的问题(截图实证):一行 7 张等宽小卡横排,每个数字都是同一视觉权重, * 浮窗再宽也铺不满 → 大片留白;而真正有决策价值的"哪类占多数"完全看不出来。 * * 新设计:左大右小的仪表盘结构,天然铺满任意宽度。 * 左区 主数字(锁区总数)+ 拥有率细进度条 + 两个 scope 卡(在库/未拥有) * 右区 类型占比条形列表,每行 = 分类色块 + 名称 + 占比条 + 数量 + 百分比 * 交互:scope 卡切换拥有态筛选;类型行点击切换类型筛选(再次点击同一行回到全部)。 * * @param {Object} opts 含 total / ownedCount / scopeBase / scope / typeFilter / typeKeys / typeColors / typeLabels / tabId / title */ function makeRestrictedHero(opts) { const o = opts || {}; const tabId = o.tabId || 'sgr'; const scope = o.scope || 'all'; const typeFilter = o.typeFilter || 'all'; const typeKeys = o.typeKeys || []; const typeColors = o.typeColors || {}; const typeLabels = o.typeLabels || {}; const total = o.total || 0; const ownedCount = o.ownedCount || 0; const scopeBase = o.scopeBase || []; const title = o.title || (Host.isZh ? '受限总数' : 'Total'); // 类型计数(基于当前 scope 收窄后的列表)+ 占比 const typeStats = typeKeys.map(function (key) { const count = scopeBase.filter(function (g) { return g.type === key; }).length; return { key: key, count: count, color: typeColors[key] || '#94a3b8', label: typeLabels[key] || key }; }).filter(function (t) { return t.count > 0; }); const statSum = typeStats.reduce(function (s, t) { return s + t.count; }, 0) || 1; const ownedPct = total > 0 ? Math.round(ownedCount / total * 100) : 0; // 左区:scope 卡(在库 / 未拥有)—— 主数字已表达"全部",这里不再重复一个"全部"卡 const scopeCards = '
' + '
' + '' + ownedCount.toLocaleString() + '' + '' + (Host.isZh ? '在库' : 'Owned') + '' + '
' + '
' + '' + (total - ownedCount).toLocaleString() + '' + '' + (Host.isZh ? '未拥有' : 'Missing') + '' + '
' + '
'; // 右区:类型占比条形列表 const typeRows = typeStats.map(function (t) { const pct = Math.round(t.count / statSum * 100); const active = typeFilter === t.key; return '
' + '' + '' + Host.wlEscape(t.label) + '' + '' + '' + t.count.toLocaleString() + '' + '' + pct + '%' + '
'; }).join(''); return '
' + '
' + '
' + Host.wlEscape(title) + '
' + '
' + total.toLocaleString() + '
' + '
' + '
' + '
' + '
' + (Host.isZh ? '已收集' : 'Collected') + ' ' + ownedPct + '%
' + scopeCards + '
' + '
' + '
' + (Host.isZh ? '分类占比' : 'By category') + '
' + (typeRows || '
' + (Host.isZh ? '无分类数据' : 'No category data') + '
') + '
' + '
'; } // v2.6.0: 筛选区统一由 makeRestrictedHero 渲染(HERO 仪表盘), // 此处不再保留旧的一排等宽 KPI 小卡实现。 /** 受限游戏卡片(卡片视图)— 封面 + 名称 + 类型徽章 + 拥有徽章 + meta 项 * @param {Object} opts * @param {number} opts.appid * @param {string} opts.name * @param {boolean} opts.isOwned * @param {string} [opts.typeLabel] - 顶部类型徽章文本 * @param {string} [opts.typeKey] - 用于 class="sglv-type-" 着色 * @param {string} [opts.typeColor] - 徽章主色(默认 #f44336 红色系) * @param {Array} [opts.metaItems] - 附加 meta 项(每个是 HTML 片段) * @param {string} [opts.clickUrl] - 自定义跳转 URL * @param {string} [opts.coverHtml] - 自定义封面 HTML(默认 restrictedCoverImg) * @returns {string} HTML */ // v2.5.0: 徽章配色工具——typeColor 是实色(如 #fb923c),直接拿来做 background 会得到 // 与文字同色的实心方块(截图里的橙色/紫色块,文字完全不可见)。 // 这里把实色转成低透明度背景 + 实色文字 + 实色描边,保证文字始终可读。 function _badgeColors(hex) { const c = hex || '#f44336'; const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(c); if (!m) return { bg: 'rgba(244,67,54,0.15)', fg: '#f44336', border: 'rgba(244,67,54,0.28)' }; const r = parseInt(m[1], 16), g = parseInt(m[2], 16), b = parseInt(m[3], 16); return { bg: 'rgba(' + r + ',' + g + ',' + b + ',0.14)', fg: '#' + m[1] + m[2] + m[3], border: 'rgba(' + r + ',' + g + ',' + b + ',0.32)' }; } // v2.6.0: 状态斜切带(signature element)——取代原先的「未拥有/已拥有」实心标签块。 // 旧方案的问题:彩色实心底 + 同色文字,视觉上就是一个读不出内容的色块(截图里的紫色方块), // 而且每张卡都挂一个色块,卡片信息区被挤成两行。 // 新方案:把状态做成封���右下角的一道斜切色带(纯色无文字),状态文字移入下方 meta 栏, // 卡片因此省下一整行,信息区得以压缩成两栏。 // 未拥有 → 琥珀色带(#f59e0b),提示"这个你还没有" // 已拥有 → 绿色细带(#34d399),细一些表示次要 function _stateRibbon(isOwned) { // v2.9.5: 移除封面右下角的琥珀色/绿色斜切带 —— // 7 列紧凑卡片里它贴着封面底边,视觉上就是"一块黄色背景", // 而卡片下方已有「购买已禁用/未拥有/已拥有」徽章表达同一信息。 // 保留函数 + CSS(.sglv-own-flag)以便随时回滚。 void isOwned; return ''; } /** * 受限游戏卡片 —— v2.6.0 重构版式 * 封面(横向图 letterbox + 右下角状态斜切带) * 名称行(游戏名 + appid) * 两栏 meta:左 = 分类徽章 + 拥有态文字;右 = 日期 / 数值类信息 */ function makeRestrictedCard(opts) { const o = opts || {}; const appid = Number(o.appid); const name = o.name || ('App ' + appid); const isOwned = !!o.isOwned; const clickUrl = o.clickUrl || ('https://store.steampowered.com/app/' + appid); const coverHtml = o.coverHtml != null ? o.coverHtml : restrictedCoverImg(appid, name, 'poster', { isBlocked: !!o.isBlocked }); // 两栏布局:left = 分类徽章 + 拥有态;right = 日期 / 统计 const metaLeft = []; if (o.typeLabel && o.typeKey) { const bc = _badgeColors(o.typeColor); metaLeft.push('' + Host.wlEscape(o.typeLabel) + ''); } if (o.showOwnText !== false) { metaLeft.push('' + (isOwned ? '✓ ' + Host.wlEscape(Host.T.owned || '已拥有') : Host.wlEscape(Host.T.missing || '未拥有')) + ''); } const metaRight = (o.metaItems || []).filter(Boolean); const twoCol = '
' + '
' + metaLeft.join('') + '
' + '
' + metaRight.join('') + '
' + '
'; return '
' + '
' + coverHtml + _stateRibbon(isOwned) + '
' + '
' + '
' + '' + Host.wlEscape(name) + '' + '' + appid + '' + '
' + twoCol + '
' + '
'; } function makeRestrictedListItem(opts) { const o = opts || {}; const appid = Number(o.appid); const name = o.name || ('App ' + appid); const isOwned = !!o.isOwned; const clickUrl = o.clickUrl || ('https://store.steampowered.com/app/' + appid); const iconUrl = o.iconUrl || (typeof Host.getGameIconUrl === 'function' ? Host.getGameIconUrl(appid, '') : ''); // v2.6.0: 列表视图同步去掉实心拥有态块 —— 左列 = 分类徽章 + 拥有态文字,右列 = 日期/统计 const bc = _badgeColors(o.typeColor); const left = []; if (o.typeLabel && o.typeKey) { left.push('' + Host.wlEscape(o.typeLabel) + ''); } left.push('' + (isOwned ? '✓ ' + Host.wlEscape(Host.T.owned || '已拥有') : Host.wlEscape(Host.T.missing || '未拥有')) + ''); const right = (o.metaItems || []).filter(Boolean); return '
' + '' + Host.wlEscape(name) + '' + '
' + '
' + '' + Host.wlEscape(name) + '' + '' + appid + '' + '
' + '
' + '
' + left.join('') + '
' + '
' + right.join('') + '
' + '
' + '
' + '
'; } // ==================== v2.4.0: 收藏标签页壳(统一工具栏 + KPI + 网格 + 翻页) ==================== // 抽象 4 个 tab 共用的"容器 + 工具栏 + KPI + 内容 + 翻页"四段式结构, // 各 tab 通过回调定制工具栏内容(搜索/排序/视图切换/刷新按钮) // ================================================================ /** * 创建收藏标签页外壳 * @param {Object} opts * @param {HTMLElement} opts.parent - 父元素(body) * @param {string} opts.tabId - 唯一 ID 前缀(用于元素 ID) * @param {string} [opts.wrapCls] - wrap 容器 class(默认 'sglv-collection-tab') * @param {string} [opts.kpiHtml] - KPI 区域 HTML(可选) * @param {string} [opts.toolbarHtml] - 工具栏 HTML(可选) * @param {string} [opts.filterBarHtml] - 筛选栏 HTML(可选) * @returns {{ wrap: HTMLElement, kpi: HTMLElement|null, toolbar: HTMLElement, content: HTMLElement, pagination: HTMLElement }} */ function createCollectionTabShell(opts) { const o = opts || {}; const parent = o.parent; if (!parent) throw new Error('createCollectionTabShell: parent required'); const tabId = o.tabId || 'sgr'; const wrapCls = o.wrapCls || 'sglv-collection-tab'; const wrap = document.createElement('div'); wrap.className = wrapCls; wrap.dataset.tab = tabId; let kpi = null; if (o.kpiHtml) { kpi = document.createElement('div'); kpi.id = tabId + '-kpi'; kpi.innerHTML = o.kpiHtml; wrap.appendChild(kpi); } const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar'; toolbar.innerHTML = o.toolbarHtml || ''; if (toolbar.children.length > 0) wrap.appendChild(toolbar); if (o.filterBarHtml) { const filterBar = document.createElement('div'); filterBar.className = 'sglv-filter-bar'; filterBar.innerHTML = o.filterBarHtml; wrap.appendChild(filterBar); } const content = document.createElement('div'); content.className = 'sglv-content'; content.id = tabId + '-content'; wrap.appendChild(content); const pagination = document.createElement('div'); pagination.className = 'sglv-pagination'; pagination.id = tabId + '-pagination'; wrap.appendChild(pagination); parent.appendChild(wrap); return { wrap: wrap, kpi: kpi, toolbar: toolbar, content: content, pagination: pagination }; } // ==================== v2.4.0: 多源数据加载器(MultiSourceLoader) ==================== // 抽象"缓存命中 → 后台静默刷新 → 多源回退 → 负缓存"通用组件 // 锁区/绝版/捆绑包等数据加载均可接入 // ================================================================ // ---- MultiSourceLoader 底层工具:统一委托 SGLVCore(_C) ---- // 修复:本组件初版直接使用裸标识符 nsKey / negCacheGet / negCacheSet / // negCacheClear / gmFetch,但这几个名字在本 lib 内从未定义(预期来自 SGLVCore)。 // 运行时抛 ReferenceError 的后果: // · gmFetch —— 同步抛出落在 Promise executor 内 → load() 永远 reject, // 串行多源回退全部走不通 → 锁区/绝版/捆绑包标签页永远拿不到远程数据; // · negCache* —— 被 try/catch 吞掉 → 负缓存从未生效 → 源挂掉时反复重试风暴。 const _mslNsKey = (k) => (_C && typeof _C.nsKey === 'function') ? _C.nsKey(k) : ('sglv_' + k); const _mslNegGet = (k, ttl) => { try { return (_C && _C.negCache) ? _C.negCache.get(k, ttl) : null; } catch (e) { return null; } }; const _mslNegSet = (k, err) => { try { if (_C && _C.negCache) _C.negCache.set(k, err); } catch (e) { /* ignore */ } }; const _mslNegClear = (k) => { try { if (_C && _C.negCache) _C.negCache.clear(k); } catch (e) { /* ignore */ } }; const _mslFetch = (url) => (_C && typeof _C.gmFetchText === 'function') ? _C.gmFetchText(url) : fetch(url).then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }); /** * 创建多源加载器实例 * @param {Object} cfg * @param {string} cfg.key - 缓存 key(同时作为 nsKey 前缀) * @param {string[]} cfg.sources - URL 列表(按顺序回退) * @param {Function} cfg.parse - 解析函数 (text) => { apps, source, fetchedAt } * @param {number} [cfg.ttl] - TTL 毫秒(默认 6h) * @param {number} [cfg.negCacheTtl] - 负缓存 TTL 毫秒(默认 30min) * @param {Function} [cfg.onUpdate] - 数据更新回调 (result) => void * @returns {Object} loader instance { load(force), getCache(), clearCache() } */ function createMultiSourceLoader(cfg) { const c = cfg || {}; const key = c.key; if (!key) throw new Error('createMultiSourceLoader: key required'); const sources = Array.isArray(c.sources) ? c.sources.slice() : []; const parse = c.parse || function (t) { return JSON.parse(t); }; const ttl = c.ttl || (6 * 3600 * 1000); const negTtl = c.negCacheTtl || (30 * 60 * 1000); const onUpdate = c.onUpdate || function () {}; let _memCache = null; let _loading = false; let _lastBgRefresh = 0; let _pendingForce = false; const _permKey = 'msl_' + key; // 永久缓存读写(基于 SGLVCore.IDB) function _permGet() { try { if (_C_IDB) return _C_IDB.get(PERM_CACHE_PREFIX + _permKey, null); } catch (e) { /* ignore */ } return null; } function _permSet(v) { try { if (_C_IDB) _C_IDB.set(PERM_CACHE_PREFIX + _permKey, v); } catch (e) { /* ignore */ } _memCache = v; } // 负缓存(防止短时间内重复打挂掉的源) function _negGet() { return _mslNegGet(_mslNsKey('msl_neg_' + key), negTtl); } function _negSet(msg) { _mslNegSet(_mslNsKey('msl_neg_' + key), msg || ''); } function _negClear() { _mslNegClear(_mslNsKey('msl_neg_' + key)); } function _backgroundRefresh(force) { if (_loading) { if (force) _pendingForce = true; return; } _loading = true; const restore = () => { _loading = false; if (_pendingForce) { _pendingForce = false; setTimeout(() => load(true), 0); } }; // 串行回退多源 const trySources = async (idx) => { if (idx >= sources.length) { _loading = false; if (!force) _negSet('all sources failed'); throw new Error('MultiSourceLoader[' + key + ']: all sources failed'); } try { const text = await _mslFetch(sources[idx]); const parsed = parse(text); _permSet({ data: parsed, source: 'remote', fetchedAt: Date.now() }); _negClear(); _lastBgRefresh = Date.now(); onUpdate({ data: parsed, source: 'remote', fetchedAt: _lastBgRefresh }); _loading = false; return parsed; } catch (e) { console.warn('[MSL ' + key + '] source ' + idx + ' failed:', e.message); return trySources(idx + 1); } }; trySources(0).catch(e => { _loading = false; if (!force) _negSet(e.message); }); } function load(force) { if (!force) { if (_memCache) return Promise.resolve({ data: _memCache.data, source: _memCache.source || 'memory', fetchedAt: _memCache.fetchedAt || 0, fromCache: true }); const perm = _permGet(); if (perm && perm.data) { _memCache = perm; // 后台静默刷新节流 if (Date.now() - _lastBgRefresh > ttl) _backgroundRefresh(false); return Promise.resolve({ data: perm.data, source: perm.source || 'permCache', fetchedAt: perm.fetchedAt || 0, fromCache: true }); } if (_negGet()) return Promise.resolve({ data: null, source: 'negCache', fetchedAt: 0, fromCache: true }); } return new Promise((resolve, reject) => { if (_loading) { _pendingForce = !!force; return; } _loading = true; const trySources = (idx) => { if (idx >= sources.length) { _loading = false; if (!force) _negSet('all sources failed'); return reject(new Error('all sources failed')); } _mslFetch(sources[idx]).then(text => { const parsed = parse(text); _permSet({ data: parsed, source: 'remote', fetchedAt: Date.now() }); _negClear(); _lastBgRefresh = Date.now(); _loading = false; onUpdate({ data: parsed, source: 'remote', fetchedAt: _lastBgRefresh }); resolve({ data: parsed, source: 'remote', fetchedAt: _lastBgRefresh, fromCache: false }); }).catch(e => { console.warn('[MSL ' + key + '] source ' + idx + ' failed:', e.message); trySources(idx + 1); }); }; trySources(0); }); } return { load: load, getCache: function () { return _memCache || _permGet(); }, clearCache: function () { _memCache = null; try { if (_C_IDB) _C_IDB.set(PERM_CACHE_PREFIX + _permKey, null); } catch (e) {} _negClear(); }, invalidate: function () { _memCache = null; } }; } // ==================== v2.6.0: 受限游戏封面加载器(重写) ==================== // 参考 steam-friend-manager-1.5.3.js 的 dashLoadPosterImg + steam-activity-modern.user.js // 的 coverChainUrls / fetchCapsuleFromAPI,重写要点: // // ① 【关键修复】链首改为横向 header.jpg 而非竖版 library_600x900。 // 旧链把 library_600x900 放链首且竖版变体有 8 个 URL。绝版 / 锁区收录的绝大多数是 // 2015 年前的老游戏,Steam 早已不再为其生成竖版 library_600x900 —— 这 8 个 URL 必然 // 逐个 404,串行等待把首屏拖到数秒后才落到 header.jpg,表现为"封面加载失败"。 // header.jpg 是 Steam 对所有已发售游戏的通用横图,命中率接近 100%,应当放链首。 // ② 链长从 12 收敛到 6(4 CDN × header/capsule 各一),配合 ① 大幅缩短失败路径。 // ③ 新增 appdetails API 兜底(filters=basic 同时返回 capsule_image 与 header_image), // 这是老游戏和非英语区游戏唯一可靠的取图途径(old API 由 steam-activity 验证有效)。 // ④ 成功 URL 统一写跨会话缓存(recordGood),下次直接链首命中,省掉全部探测请求。 // ⑤ 容器改为 letterbox:横向图在竖版容器内居中显示(上下留白), // 不再用 object-fit:cover 硬裁 —— 旧游戏横图被裁成竖版时主体常被切掉。 // ================================================================ // 通用"无封面"占位(Steam 图标) const RESTRICTED_BROKEN_ICON_SVG = ''; // 锁区"被锁"占位(红色锁) const RESTRICTED_BLOCKED_ICON_SVG = ''; /** 受限游戏封面短链(横向优先)。 * chain[0] = cdn.cloudflare header.jpg(命中率最高) * chain[1] = shared.akamai store_item_assets header.jpg * chain[2] = cdn.akamai header.jpg * chain[3] = shared.fastly store_item_assets header.jpg * chain[4] = shared.akamai store_item_assets capsule_467x181.jpg(更新游戏的大横幅) * chain[5] = cdn.cloudflare capsule_467x181.jpg * 全部失败 → appdetails API 兜底(见 restrictedCoverImg 的 onerror) */ function restrictedCoverChain(appid) { const id = String(appid); return [ `https://cdn.cloudflare.steamstatic.com/steam/apps/${id}/header.jpg`, `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/header.jpg`, `https://cdn.akamai.steamstatic.com/steam/apps/${id}/header.jpg`, `https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/${id}/header.jpg`, `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/capsule_467x181.jpg`, `https://cdn.cloudflare.steamstatic.com/steam/apps/${id}/capsule_467x181.jpg`, ]; } // appdetails API 兜底结果缓存(同 appid 只发一次请求,多张卡共享) // v2.9.3: 成功结果本会话长期有效;失败结果只缓存 10 分钟(渐进式重试), // 避免一次网络抖动把该 appid 在整个会话里永久拉黑 const _coverApiCache = new Map(); // appid → { url, ts } const _coverApiPending = new Set(); const COVER_API_NEG_TTL = 10 * 60 * 1000; const COVER_API_CACHE_MAX = 1200; function _coverApiCached(id) { const e = _coverApiCache.get(id); if (!e) return undefined; if (!e.url && (Date.now() - e.ts) > COVER_API_NEG_TTL) { _coverApiCache.delete(id); return undefined; } return e.url; } function _coverApiStore(id, url) { _coverApiCache.delete(id); _coverApiCache.set(id, { url: url || '', ts: Date.now() }); if (_coverApiCache.size > COVER_API_CACHE_MAX) { const it = _coverApiCache.keys(); while (_coverApiCache.size > COVER_API_CACHE_MAX) { const n = it.next(); if (n.done) break; _coverApiCache.delete(n.value); } } } /** appdetails?filters=basic 兜底取图 —— 老游戏/非英语区游戏唯一可靠途径。 * 参考 steam-activity-modern.user.js 的 fetchCapsuleFromAPI(含 in-flight 去重)。 */ function fetchCoverFromApi(appid) { const id = String(appid); const cachedNow = _coverApiCached(id); if (cachedNow !== undefined) return Promise.resolve(cachedNow); if (_coverApiPending.has(id)) return Promise.resolve(''); // 并发中不再重复打接口 _coverApiPending.add(id); const apiUrl = `https://store.steampowered.com/api/appdetails?appids=${id}&filters=basic`; const finish = (url) => { _coverApiStore(id, url); _coverApiPending.delete(id); if (url) { // v2.9.3: appdetails 的 capsule_image/header_image 都是横版,归 header 槽位; // 写成 poster 会让"竖版缺失"标记被误触(主线竖版链还没跑过)。 // 真正的归槽以 markLoaded 按实际加载 URL 形态写入为准。 try { const fb = _coverFb(); if (fb && typeof fb.recordGood === 'function') fb.recordGood(id, url, 'header'); } catch (e) { /* ignore */ } } return url || ''; }; return new Promise((resolve) => { const doReq = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest : null; const onFail = () => resolve(finish('')); if (doReq) { let settled = false; const timer = setTimeout(onFail, 8000); doReq({ method: 'GET', url: apiUrl, timeout: 8000, onload: (resp) => { if (settled) return; settled = true; clearTimeout(timer); let url = ''; try { const json = JSON.parse(resp.responseText); const d = json && json[id]; if (d && d.success && d.data) url = d.data.capsule_image || d.data.header_image || ''; } catch (e) { url = ''; } resolve(finish(url)); }, onerror: () => { if (!settled) { settled = true; clearTimeout(timer); onFail(); } }, ontimeout: () => { if (!settled) { settled = true; clearTimeout(timer); onFail(); } }, }); } else { fetch(apiUrl).then(r => r.json()).then(json => { const d = json && json[id]; const url = (d && d.success && d.data) ? (d.data.capsule_image || d.data.header_image || '') : ''; resolve(finish(url)); }).catch(() => resolve(finish(''))); } }); } /** * 三态封面渲染后的同步校正补丁。 * 背景:受限游戏列表一次性 innerHTML 写入上千张卡,浏览器对命中 HTTP 缓存的图 * 可能在 DOM 解析阶段就完成解码 —— 此时 inline onload 事件的监听器尚未挂上, * 导致 wrapper 永远停在 sglv-img-loading(opacity:0 → 封面全白)。 */ // v2.7.0: 已并入 bindRestrictedCoverLoaders(链式回退 + API 兜底 + 缓存命中补状态), // 保留同名薄封装,避免 3 个渲染函数里的调用点需要同步改名。 function syncRestrictedCoverStates(root) { bindRestrictedCoverLoaders(root); } /** * 受限游戏三态封面加载器(绝版 / 锁区 / 捆绑包 共用)—— v2.7.0 重写 * * v2.6.0 的实现把回退链塞进 inline onerror,用 `.replace(/\d+/, ci)` 拼下一个 URL。 * 这是错的:appid 本身含数字,replace 会把 appid 替换成链索引, * 实测 chain[5].replace(/\d+/, 1) → `.../apps/1/header.jpg`, * 于是整条链全是 404,永远走不到 API 兜底 —— 表现为"所有封面全挂"。 * * v2.7.0 改为「渲染时只输出干净标记 + 渲染后统一绑定事件」: * - HTML 里只有 ,没有任何拼接的 JS; * - bindRestrictedCoverLoaders() 渲染后扫一遍,用 addEventListener 绑定链式回退; * 链数组在闭包里,索引直接取自 dataset,不会有字符串替换的坑; * - 链穷尽 → fetchCoverFromApi() 兜底 → 仍失败才 broken。 */ function restrictedCoverImg(appid, name, kind, opts) { const o = opts || {}; const isBlocked = !!o.isBlocked; const safeName = Host.wlEscape(name || ''); const brokenCls = 'sglv-img-broken' + (isBlocked ? ' sglv-img-broken-blocked' : ''); // 1) 跨会话缓存命中 —— 命中即链首,后续全部跳过 // v2.9.3: 受限封面是横版容器,只读横版槽位: // 竖版缺失标记(poster 槽位里的横版 URL)→ header → capsule → 本会话 API 结果 // 旧实现直接读 poster 槽位,可能把竖版海报塞进 16:9 容器 let url = ''; try { url = coverHorizontalFallbackUrl(String(appid)) || ''; } catch (e) { /* ignore */ } if (!url) { try { url = coverGoodUrl(String(appid), 'header') || ''; } catch (e) { /* ignore */ } } if (!url) { try { url = coverGoodUrl(String(appid), 'capsule') || ''; } catch (e) { /* ignore */ } } if (!url) { try { const fb = _coverFb(); if (fb && typeof fb.getCoverUrl === 'function') { url = fb.getCoverUrl(String(appid), 'header', Host.isZh ? 'schinese' : '') || ''; } } catch (e) { /* ignore */ } } if (!url) { const cachedApi = _coverApiCached(String(appid)); if (cachedApi) url = cachedApi; } const chain = restrictedCoverChain(appid); const firstUrl = url || chain[0]; if (!firstUrl) { return '
' + _restrictedBrokenInner(name, isBlocked) + '
'; } // 2) 干净的 HTML:只带 data 标记,回退逻辑交给 bindRestrictedCoverLoaders // v2.9.2: 必须带 data-cover-appid(见 unifiedCover 同名注释) return '
' + '' + safeName + '' + '
' + _restrictedBrokenInner(name, isBlocked) + '
' + '
'; } /** * 渲染后绑定封面回退链(v2.7.0 核心) * 每个 wrapper 只绑定一次(用 __bound 标记),内部维护链索引, * 链穷尽后走一次 appdetails API 兜底。 */ function bindRestrictedCoverLoaders(root) { const scope = root || document; // v2.9.2: 选择器同时接受 data-cover-appid / data-appid —— // 旧选择器只认 wrapper 上的 data-cover-appid,而 unifiedCover / restrictedCoverImg // 生成的 wrapper 只有 data-appid,一个都匹配不到 → 从不绑定事件 // → sglv-img-loading 永不摘除(大海报视图 opacity:0 全空白) // → 图片 404 后也没有任何回退链。 scope.querySelectorAll('.sglv-img-wrapper[data-appid]:not([data-bound]), .sglv-img-wrapper[data-cover-appid]:not([data-bound])').forEach(function (wrapper) { const img = wrapper.querySelector('img'); if (!img) return; wrapper.setAttribute('data-bound', '1'); const appid = img.getAttribute('data-cover-appid') || wrapper.getAttribute('data-cover-appid') || wrapper.getAttribute('data-appid'); if (!appid) return; const isBlocked = wrapper.getAttribute('data-cover-blocked') === '1'; const brokenCls = 'sglv-img-broken' + (isBlocked ? ' sglv-img-broken-blocked' : ''); const name = img.getAttribute('alt') || ''; const kind = wrapper.getAttribute('data-cover-kind') || 'poster'; const preferVertical = wrapper.getAttribute('data-cover-prefer-vertical') === '1'; // hero(大海报视图)走独立长链;竖版卡片墙走竖版优先链(链尾同样是 header 兜底); // 其余(受限封面/锁区/捆绑包)统一横向链 let chain; if (kind === 'hero') chain = unifiedCoverChain(appid, 'hero'); else if (kind === 'poster' && preferVertical) chain = unifiedCoverChain(appid, 'poster', true); else chain = restrictedCoverChain(appid); // 初始 src 可能是跨会话 good 缓存(不在 chain 里): // 命中 chain → 从下一条继续;未命中 → 从链首开始尝试 let ci = chain.indexOf(img.getAttribute('src') || ''); let apiTried = false; img.setAttribute('data-cover-appid', appid); const markLoaded = () => { wrapper.classList.remove('sglv-img-loading'); wrapper.classList.add('sglv-img-loaded'); // v2.9.2: 图片本身也要挂 sglv-img-loaded —— // `.sgc-panel .sglv-card-img { opacity: 0 }` 的显示态是 // `.sglv-card-img.sglv-img-loaded`(img 级),旧 inline onload 会加。 // 改用事件委托后没人加 → 卡片/列表视图封面全员 opacity:0 不可见。 if (img.classList) { img.classList.add('sglv-img-loaded'); if (img.naturalWidth > img.naturalHeight) img.classList.add('sglv-img-wide'); } // v2.9.3: 按图片实际形态归槽(竖版 → poster,横版 → header), // 而不是按容器 kind —— 受限/大海报容器加载的常是 header.jpg, // 写成 capsule 会污染对应槽位;归一到 header 则各视图共享。 // 例外:竖版容器最终加载了横图 = "竖版资产缺失"标记,仍需写 poster 槽位, // 否则 coverVerticalMissing 永远为假,竖版视图每次都要重吃 8 个 404。 try { const src = img.src || ''; if (src && src.indexOf('data:') !== 0) { const vertical = _sgcvIsVerticalUrl(src); const slots = vertical ? ['poster'] : ['header']; if (!vertical && kind === 'poster') slots.push('poster'); // 缺失标记 const fb = _coverFb(); for (const slot of slots) { if (fb && typeof fb.recordGood === 'function') fb.recordGood(appid, src, slot); // 同步写入宿主共享缓存(跨面板复用:收藏 → 主面板) if (Host && typeof Host.recordPosterGood === 'function') Host.recordPosterGood(appid, src, slot); } } } catch (e) { /* ignore */ } }; const markBroken = () => { wrapper.classList.remove('sglv-img-loading'); wrapper.classList.add(brokenCls); const layer = wrapper.querySelector('.sglv-img-fallback-layer'); if (layer) layer.innerHTML = _restrictedBrokenInner(name, isBlocked); }; // 推进到链上第一个"不是当前失败 URL"的候选 const advance = () => { const cur = img.getAttribute('src') || ''; for (ci++; ci < chain.length; ci++) { const u = chain[ci]; if (u && u !== cur) { img.src = u; return true; } } return false; }; const onError = () => { if (advance()) return; if (!apiTried) { apiTried = true; fetchCoverFromApi(appid).then(url => { if (url) { img.src = url; return; } markBroken(); }).catch(() => markBroken()); return; } markBroken(); }; img.addEventListener('load', () => { // naturalWidth<=1 视为占位/空图(同步校正时也会走到这里) if (!img.naturalWidth || img.naturalWidth < 2) return; markLoaded(); }); img.addEventListener('error', onError); // load/error 早于绑定(HTTP 缓存命中 / 首图在 DOM 解析阶段就 404) // → 立即补状态,否则 wrapper 会永远停在 sglv-img-loading if (img.complete) { if (img.naturalWidth > 1) markLoaded(); else if (img.getAttribute('src')) onError(); } }); } // broken 态内容(同时用作 wrapper 内的兜底层,loaded 态下被 CSS 隐藏) function _restrictedBrokenInner(name, isBlocked) { return '
' + (isBlocked ? RESTRICTED_BLOCKED_ICON_SVG : RESTRICTED_BROKEN_ICON_SVG) + '
' + '
' + Host.wlEscape(isBlocked ? (Host.isZh ? '锁区游戏' : 'Region Locked') : (Host.isZh ? '无封面' : 'No Cover')) + (name ? '
' + Host.wlEscape(name) : '') + '
'; } // ==================== 供应商分类标签页 ==================== // v1.9.19: 统计缓存——数据安装期注入后低频更新,按 GAME_DB 引用缓存计算结果; // @resource 更新后 getGameDb() 返回新引用 → Map key 变化自动失效(Map 最多保留 2 代引用,防内存泄漏) const _pubStatsCache = new Map(); // dbRef → { sig, inner: Map(pubName → { total, owned }) } const _seriesStatsCache = new Map(); // dbRef → { sig, inner: Map(pubName + '\u0000' + seriesName → { total, owned }) } function _getStatsCacheForDb(map) { const db = Host.getGameDb(); if (!db) return null; // v1.9.19-fix: sig = 拥有集合规模 + 家庭共享开关——面板打开期间重新同步家庭库 // (sglv:games-updated)时 db 引用不变但拥有集合变化,需整代失效; // sig 随每代缓存独立存放(不共用全局表),避免两个缓存重建时序互相干扰 const sig = (Host.getOwnedGames ? Host.getOwnedGames().length : -1) + '|' + !!Host.getShowFamilyShared(); let entry = map.get(db); if (entry && entry.sig !== sig) entry = null; // sig 变化 → 整代失效 if (!entry) { entry = { sig, inner: new Map() }; map.set(db, entry); if (map.size > 2) map.delete(map.keys().next().value); } return entry.inner; } function computePublisherStats(pubName) { const db = Host.getGameDb(); const pub = db?.[pubName]; if (!pub) return { total: 0, owned: 0 }; const inner = _getStatsCacheForDb(_pubStatsCache); if (inner) { const hit = inner.get(pubName); if (hit) return hit; } let total = 0, owned = 0; const activeOwnedIds = Host.getActiveOwnedAppIds(); for (const series of Object.values(pub)) { total += series.length; for (const g of series) if (activeOwnedIds.has(g.id)) owned++; } const res = { total, owned }; if (inner) inner.set(pubName, res); return res; } function computeSeriesStats(pubName, seriesName) { const db = Host.getGameDb(); const series = db?.[pubName]?.[seriesName]; if (!series) return { total: 0, owned: 0 }; const inner = _getStatsCacheForDb(_seriesStatsCache); const key = pubName + '\u0000' + seriesName; if (inner) { const hit = inner.get(key); if (hit) return hit; } let owned = 0; const activeOwnedIds = Host.getActiveOwnedAppIds(); for (const g of series) if (activeOwnedIds.has(g.id)) owned++; const res = { total: series.length, owned }; if (inner) inner.set(key, res); return res; } // v1.9.16: genres 类型标签 HTML(game-db-v1.2.json 可选字段;旧数据无 genres 时返回空串,安全降级) function genreTagsHtml(g, max = 3) { if (!Array.isArray(g.genres) || !g.genres.length) return ''; const tags = g.genres.slice(0, max).map(t => `${Host.wlEscape(t)}`).join(''); const more = g.genres.length > max ? `+${g.genres.length - max}` : ''; return tags + more; } // 统一计算游戏拥有状态徽章信息(供供应商分类列表/卡片视图复用) function getGameBadgeInfo(g, ownedAppIds) { const isOwned = ownedAppIds.has(g.id); if (!isOwned) return { isOwned: false, badgeClass: 'missing', badgeText: Host.T.missing, tooltip: '' }; const gameObj = Host.getOwnedGames().find(og => og.appid === g.id); if (gameObj && !Host.isGameOwnedByMe(gameObj)) { return { isOwned: true, badgeClass: 'shared', badgeText: Host.isZh ? '共享' : 'Shared', tooltip: `title="共享自: ${Host.getGameOwnerNames(gameObj)}"` }; } return { isOwned: true, badgeClass: 'owned', badgeText: '✓ ' + Host.T.owned, tooltip: '' }; } function renderPublisherTab(parent) { const body = document.createElement('div'); body.className = 'sglv-pub-layout'; // 左侧边栏(v1.8.2: 顶部含供应商/系列模式切换) const sidebar = document.createElement('div'); sidebar.className = 'sglv-pub-sidebar'; sidebar.id = 'sglv-pub-sidebar'; body.appendChild(sidebar); // 右侧内容 const main = document.createElement('div'); main.className = 'sglv-pub-main'; main.id = 'sglv-pub-main'; body.appendChild(main); parent.appendChild(body); renderPubSidebar(); if (pubState.mode === 'series') { // v1.8.2: 系列模式——默认展开第一个系列 ensureSeriesSelected(); if (pubState.selectedSeries) renderSeriesModeDetail(pubState.selectedSeries); else renderSeriesModeEmpty(); } else { // v1.9.19: 供应商模式——默认展开首个供应商并选中其首个系列,按需渲染(不再默认停留 Overview) ensurePublisherSelected(); if (pubState.selectedSeries) { renderPubSeriesDetail(pubState.selectedPublisher, pubState.selectedSeries); } else if (pubState.selectedPublisher) { renderPubPublisherDetail(pubState.selectedPublisher); } else { renderPubOverview(); // GAME_DB 为空时保持总览(空态) } } } /** v1.9.19: 确保供应商模式下有选中项:未选中/选中项已不存在时默认展开第一个供应商并选中其第一个系列(参照 ensureSeriesSelected) */ function ensurePublisherSelected() { const db = Host.getGameDb() || {}; const pubNames = Object.keys(db); if (!pubNames.length) { pubState.selectedPublisher = null; pubState.selectedSeries = null; return; } if (!pubState.selectedPublisher || !db[pubState.selectedPublisher]) { pubState.selectedPublisher = pubNames[0]; pubState.selectedSeries = null; } pubState.expandedPublishers.add(pubState.selectedPublisher); if (!pubState.selectedSeries || !db[pubState.selectedPublisher][pubState.selectedSeries]) { const seriesNames = Object.keys(db[pubState.selectedPublisher]); pubState.selectedSeries = seriesNames.length ? seriesNames[0] : null; } } // ---- v1.8.2: 系列数据访问(宿主未提供 getSeriesDb 时安全降级) ---- function getSeriesDb() { return (typeof Host.getSeriesDb === 'function' && Host.getSeriesDb()) || {}; } function computeSeriesModeStats(seriesName) { const games = getSeriesDb()[seriesName] || []; let owned = 0; const activeOwnedIds = Host.getActiveOwnedAppIds(); for (const g of games) if (activeOwnedIds.has(g.id)) owned++; return { total: games.length, owned }; } /** 确保系列模式下有选中项:未选中/选中项已不存在时默认展开第一个系列 */ function ensureSeriesSelected() { const db = getSeriesDb(); const names = Object.keys(db); if (!names.length) { pubState.selectedSeries = null; return; } if (!pubState.selectedSeries || !db[pubState.selectedSeries]) { pubState.selectedSeries = names[0]; } } /** v1.8.2: 供应商/系列模式切换条 */ function renderPubModeSwitch() { const wrap = document.createElement('div'); wrap.className = 'sglv-pub-mode-switch'; const modes = [ { key: 'publisher', label: Host.isZh ? '供应商' : 'Publisher' }, { key: 'series', label: Host.isZh ? '系列' : 'Series' }, ]; for (const m of modes) { const btn = document.createElement('button'); btn.className = 'sglv-pub-mode-btn' + (pubState.mode === m.key ? ' active' : ''); btn.textContent = m.label; btn.addEventListener('click', () => { if (pubState.mode === m.key) return; pubState.mode = m.key; pubState.filter = 'all'; renderPubSidebar(); if (m.key === 'series') { ensureSeriesSelected(); if (pubState.selectedSeries) renderSeriesModeDetail(pubState.selectedSeries); else renderSeriesModeEmpty(); } else { // v1.9.19: 切回供应商模式——同样默认进入首个供应商的首个系列(选中项有效则保留) ensurePublisherSelected(); if (pubState.selectedSeries) { renderPubSeriesDetail(pubState.selectedPublisher, pubState.selectedSeries); } else if (pubState.selectedPublisher) { renderPubPublisherDetail(pubState.selectedPublisher); } else { renderPubOverview(); } } }); wrap.appendChild(btn); } // v2.9.91: 两种模式均显示刷新按钮——同时刷新供应商+系列数据 const hasRefresh = typeof Host.loadSeriesData === 'function' || typeof Host.loadSupplierData === 'function'; if (hasRefresh) { const refreshBtn = document.createElement('button'); refreshBtn.className = 'sglv-pub-mode-btn sglv-pub-series-refresh'; refreshBtn.id = 'sglv-pub-refresh'; refreshBtn.title = Host.isZh ? '刷新供应商和系列数据' : 'Refresh supplier & series data'; refreshBtn.innerHTML = Host.ICONS.refresh; // 修复:原调用 loadAllCollectionData(true),但该函数在模块 4 抽离后已无定义 // (悬空引用 → 点刷新即抛 ReferenceError)。按上方守卫条件直接调用宿主加载器, // 数据到位后由 sglv:series-data-loaded / sglv:supplier-data-loaded 事件触发重绘。 refreshBtn.addEventListener('click', () => { if (typeof Host.loadSeriesData === 'function') Host.loadSeriesData(true); if (typeof Host.loadSupplierData === 'function') Host.loadSupplierData(true); }); wrap.appendChild(refreshBtn); // v1.9.40: 不再常驻状态文本(更新时间曾压缩切换按钮致变形)——刷新结果由悬浮提示显示 } return wrap; } /** 刷新供应商/系列标签页内容(不重建整个面板,避免骨架闪烁) * 修复:sglv:series-data-loaded / sglv:supplier-data-loaded 事件回调引用了本函数, * 但该函数从未定义 → 事件触发时抛 ReferenceError,远程数据更新后界面不刷新。 * 逻辑与 renderPubModeSwitch 的模式切换分支保持一致。 */ function refreshPublisherTabContent() { renderPubSidebar(); if (pubState.mode === 'series') { ensureSeriesSelected(); if (pubState.selectedSeries) renderSeriesModeDetail(pubState.selectedSeries); else renderSeriesModeEmpty(); } else { ensurePublisherSelected(); if (pubState.selectedSeries) renderPubSeriesDetail(pubState.selectedPublisher, pubState.selectedSeries); else if (pubState.selectedPublisher) renderPubPublisherDetail(pubState.selectedPublisher); else renderPubOverview(); } } function renderPubOverview() { const main = document.getElementById('sglv-pub-main'); if (!main) return; let totalAll = 0, ownedAll = 0; for (const pubName of Object.keys(Host.getGameDb())) { const s = computePublisherStats(pubName); totalAll += s.total; ownedAll += s.owned; } const pctAll = totalAll > 0 ? Math.round(ownedAll / totalAll * 100) : 0; const showFamilyShared = Host.getShowFamilyShared(); const activeTotalGames = showFamilyShared ? Host.getOwnedGames().length : Host.getOwnedGames().filter(g => Host.isGameOwnedByMe(g)).length; let html = `
${Host.ICONS.library}${Host.T.totalGames}
${activeTotalGames}
${Host.T.kpiOfTotal}
${Host.ICONS.heart}${Host.T.ownedInDb}
${ownedAll}
${Host.T.kpiRate} ${pctAll}%
${Host.ICONS.shield}${Host.T.missingInDb}
${totalAll - ownedAll}
${Host.T.kpiRate} ${100 - pctAll}%
${Host.ICONS.barChart}${Host.T.ownership}
${pctAll}%
${Host.T.kpiCollectionProgress}
`; for (const [pubName] of Object.entries(Host.getGameDb())) { const pStats = computePublisherStats(pubName); const pct = pStats.total > 0 ? Math.round(pStats.owned / pStats.total * 100) : 0; html += `
${sgcEsc(pubName)} ${pStats.owned}/${pStats.total} (${pct}%)
`; } main.innerHTML = html; main.querySelectorAll('.sglv-pub-card').forEach(card => { card.addEventListener('click', () => { const pubName = card.dataset.pub; pubState.expandedPublishers.add(pubName); pubState.selectedPublisher = pubName; pubState.selectedSeries = null; renderPubSidebar(); renderPubPublisherDetail(pubName); }); }); } // v1.8.2: 系列模式侧边栏——扁平系列列表(无供应商二级分类) function renderSeriesSidebarList(sidebar) { const db = getSeriesDb(); const names = Object.keys(db); // v1.9.37: 移除'系列数据加载中…'提示,数据未就绪时直接返回(避免界面变形) if (!names.length) return; for (const seriesName of names) { const sStats = computeSeriesModeStats(seriesName); const completionPct = sStats.total > 0 ? Math.round(sStats.owned / sStats.total * 100) : 0; const item = document.createElement('div'); item.className = 'sglv-series-flat-item' + (pubState.selectedSeries === seriesName ? ' active' : ''); item.innerHTML = '' + seriesName + '' + '' + sStats.owned + '/' + sStats.total + ''; item.addEventListener('click', () => { pubState.selectedSeries = seriesName; renderPubSidebar(); renderSeriesModeDetail(seriesName); }); sidebar.appendChild(item); } } function renderPubSidebar() { const sidebar = document.getElementById('sglv-pub-sidebar'); if (!sidebar) return; sidebar.innerHTML = ''; // v1.8.2: 顶部模式切换(供应商 / 系列) sidebar.appendChild(renderPubModeSwitch()); // v1.8.2: 系列模式——扁平系列列表,无二级分类 if (pubState.mode === 'series') { renderSeriesSidebarList(sidebar); return; } for (const [pubName, pubData] of Object.entries(Host.getGameDb())) { const pStats = computePublisherStats(pubName); const isExpanded = pubState.expandedPublishers.has(pubName); const pubEl = document.createElement('div'); pubEl.className = 'sglv-pub-item'; const header = document.createElement('div'); header.className = `sglv-pub-header${pubState.selectedPublisher === pubName && !pubState.selectedSeries ? ' active' : ''}`; header.innerHTML = ` ${sgcEsc(pubName)} ${pStats.owned}/${pStats.total} `; header.addEventListener('click', () => { if (pubState.expandedPublishers.has(pubName)) pubState.expandedPublishers.delete(pubName); else pubState.expandedPublishers.add(pubName); pubState.selectedPublisher = pubName; pubState.selectedSeries = null; renderPubSidebar(); renderPubPublisherDetail(pubName); }); pubEl.appendChild(header); const seriesList = document.createElement('div'); seriesList.className = `sglv-series-list${isExpanded ? ' expanded' : ''}`; for (const [seriesName, games] of Object.entries(pubData)) { const sStats = computeSeriesStats(pubName, seriesName); const completionPct = sStats.total > 0 ? Math.round(sStats.owned / sStats.total * 100) : 0; const isSelected = pubState.selectedPublisher === pubName && pubState.selectedSeries === seriesName; const seriesHeader = document.createElement('div'); seriesHeader.className = `sglv-series-header${isSelected ? ' active' : ''}`; seriesHeader.innerHTML = ` ${sgcEsc(seriesName)} ${sStats.owned}/${sStats.total} `; seriesHeader.addEventListener('click', e => { e.stopPropagation(); pubState.selectedPublisher = pubName; pubState.selectedSeries = seriesName; renderPubSidebar(); renderPubSeriesDetail(pubName, seriesName); }); seriesList.appendChild(seriesHeader); } pubEl.appendChild(seriesList); sidebar.appendChild(pubEl); } } // v2.0.0: 选 pub 后显示该 pub 下所有系列的合并游戏列表 // 用户可通过面包菜单二级下拉进一步选具体 series function renderPubPublisherDetail(pubName) { const main = document.getElementById('sglv-pub-main'); if (!main) return; const pStats = computePublisherStats(pubName); const pct = pStats.total > 0 ? Math.round(pStats.owned / pStats.total * 100) : 0; let html = `
${sgcEsc(pubName)}
${Host.ICONS.library}${Host.isZh ? '游戏总数' : 'Total'}
${pStats.total}
${Host.ICONS.heart}${Host.T.owned}
${pStats.owned}
${Host.ICONS.shield}${Host.T.missing}
${pStats.total - pStats.owned}
${Host.ICONS.barChart}${Host.T.ownership}
${pct}%
`; for (const [seriesName] of Object.entries(Host.getGameDb()[pubName] || {})) { const sStats = computeSeriesStats(pubName, seriesName); const sPct = sStats.total > 0 ? Math.round(sStats.owned / sStats.total * 100) : 0; html += `
${sgcEsc(seriesName)} ${sStats.owned}/${sStats.total} (${sPct}%)
`; } main.innerHTML = html; main.querySelectorAll('.sglv-series-card').forEach(card => { card.addEventListener('click', () => { const p = card.dataset.pub, s = card.dataset.series; pubState.selectedPublisher = p; pubState.selectedSeries = s; pubState.expandedPublishers.add(p); renderPubSidebar(); renderPubSeriesDetail(p, s); }); }); } function renderPubSeriesDetail(pubName, seriesName) { const games = Host.getGameDb()[pubName]?.[seriesName] || []; const breadcrumbHtml = `
${sgcEsc(pubName)} › ${sgcEsc(seriesName)}
`; renderSeriesDetailCommon(breadcrumbHtml, games, () => renderPubSeriesDetail(pubName, seriesName), 'pub:' + pubName + '\u0000' + seriesName); } // v2.0.0: 系列模式 — 面包菜单 + 完成度 + 网格 function renderSeriesModeDetail(seriesName) { const games = getSeriesDb()[seriesName] || []; const breadcrumbHtml = `
${Host.isZh ? '系列' : 'Series'} ${sgcEsc(seriesName)}
`; renderSeriesDetailCommon(breadcrumbHtml, games, () => renderSeriesModeDetail(seriesName), 'series:' + seriesName); } function renderSeriesModeEmpty() { const main = document.getElementById('sglv-pub-main'); if (!main) return; main.innerHTML = ''; } /** * v1.9.31: 系列游戏排序辅助——日期解析为可排序字符串 * 支持所有数据源日期格式(兼容 game_series.json 中文格式 + vediogame_filtered.json ISO 格式): * "YYYY年M月D日" / "YYYY年M月" / "YYYY年"(中文格式,game_series.json) * "YYYY-MM-DD" / "YYYY-MM-__" / "YYYY-MM" / "YYYY"(ISO 格式,vediogame_filtered.json) * "Q[1-4] YYYY"(季度格式) * "待定" / "TBD" / "" → 返回空串(排序时排到末尾) * @param {string} dateStr - 日期字符串 * @returns {string} 可排序字符串(如 "20260901"),无效/未知日期返回 '' */ function seriesDateToSortable(dateStr) { if (!dateStr) return ''; var s = String(dateStr).trim(); if (!s || s === '待定' || s === 'TBD' || s === 'tbd') return ''; // 中文完整日期: "2026年01月30日" / "2026 年7月8日" var m = s.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日/); if (m) return m[1] + m[2].padStart(2, '0') + m[3].padStart(2, '0'); // 中文年月: "2028年3月" var m2 = s.match(/(\d{4})\s*年\s*(\d{1,2})\s*月/); if (m2) return m2[1] + m2[2].padStart(2, '0') + '99'; // 中文年份: "2026年" var m3 = s.match(/^(\d{4})\s*年/); if (m3) return m3[1] + '1299'; // ISO 完整日期: "2026-09-01" var m4 = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s); if (m4) return m4[1] + m4[2].padStart(2, '0') + m4[3].padStart(2, '0'); // ISO 部分日期(日未知): "2026-01-__" var m5 = /^(\d{4})-(\d{1,2})-__$/.exec(s); if (m5) return m5[1] + m5[2].padStart(2, '0') + '99'; // ISO 年月: "2026-08" var m6 = /^(\d{4})-(\d{1,2})$/.exec(s); if (m6) return m6[1] + m6[2].padStart(2, '0') + '99'; // 纯年份: "2026" var m7 = /^(\d{4})$/.exec(s); if (m7) return m7[1] + '1299'; // 季度格式: "Q4 2026" → 该季度末月 var m8 = /^Q([1-4])\s+(\d{4})$/i.exec(s); if (m8) { var qMon = String(parseInt(m8[1]) * 3).padStart(2, '0'); return m8[2] + qMon + '99'; } return ''; } /** * v1.9.31: 系列游戏排序——按 sortField + sortDir 排序,未知/空日期始终排到末尾 * @param {Array} games - 游戏列表 [{id, name, date, order, poster?}] * @param {string} sortField - 'order'|'date'|'name'|'appid' * @param {string} sortDir - 'asc'|'desc' * @returns {Array} 排序后的新数组(不修改原数组) */ function sortSeriesGames(games, sortField, sortDir) { var sorted = games.slice(); var desc = sortDir === 'desc'; try { sorted.sort(function (a, b) { var r; switch (sortField) { case 'name': r = _nameCollator.compare(a.name || '~', b.name || '~'); return desc ? -r : r; case 'appid': r = (a.id || 0) - (b.id || 0); return desc ? -r : r; case 'date': // 未知/空日期始终排到末尾,与 sortDir 无关 var sa = seriesDateToSortable(a.date); var sb = seriesDateToSortable(b.date); if (!sa && !sb) return 0; if (!sa) return 1; // a 未知 → 排后面 if (!sb) return -1; // b 未知 → 排后面 return desc ? sb.localeCompare(sa) : sa.localeCompare(sb); case 'order': default: r = (a.order || 0) - (b.order || 0); return desc ? -r : r; } }); } catch (e) { console.warn('[SGLV-Series] sortSeriesGames 排序失败,降级为原始顺序:', e); } return sorted; } /** * v1.9.30: 系列封面图生成——优先使用数据源 poster 字段(预验证URL,避免CDN试错), * 失败后走三级 CDN 降级链 + API 兜底。向后兼容:poster 为空时走原 Host 逻辑。 * @param {Object} g - 游戏条目 {id, name, poster?} * @param {string} kind - 图片类型 'poster'|'header' * @param {string} cls - img class * @returns {string} img 标签 HTML */ // v2.0.0 (cover-fallback v2.0): 重写为 data-属性 + 单一 onerror 委托 // 之前: inline 链式 onerror 串 7-20 URL, 浏览器主动加载链首 (常 404), HTML attribute 冗长 // 现在: img 创建时 src = SGLVCoverFallback.getCoverUrl() (链首 cdn.cloudflare,老游戏命中), // 失败时委托 _sglvChainFallback → SGLVCoverFallback.chainLoad (JS 接管, addEventListener 单 listener) // 链长度: 7-8 (从 20 砍掉一半多), schinese 变体放链尾 (避免老游戏先吃 4-5 个 404) // v2.0.0 (cover-fallback v2.0): 重写为 data-属性 + 单一 onerror 委托 // 之前: inline 链式 onerror 串 7-20 URL, 浏览器主动加载链首 (常 404), HTML attribute 冗长 // 现在: img 创建时 src = SGLVCoverFallback.getCoverUrl() (链首 cdn.cloudflare, 老游戏命中), // 失败时委托 _sglvChainFallback → SGLVCoverFallback.chainLoad (JS 接管, addEventListener 单 listener) // 链长度: 7-8 (从 20 砍掉一半多), schinese 变体放链尾 (避免老游戏先吃 4-5 个 404) // v2.8.0: 统一走 unifiedCover(addEventListener 绑定,无跨上下文 inline onerror、无一次性锁)。 // kind 'header' 归一到 'hero' 链(系列视图专属的超宽横图链)。 function seriesCoverImg(g, kind, cls, opts) { const k = (kind === 'header') ? 'hero' : (kind || 'poster'); return unifiedCover(g.id, g.name || '', k, Object.assign({ imgCls: cls || 'sglv-card-img' }, opts || {})); } function seriesCapsuleImg(g) { return unifiedCover(g.id, g.name || '', 'capsule', { imgCls: 'sglv-wl-cover-img' }); } function _sgcvChainFallbackImpl(img) { if (!img || !img.dataset || !img.dataset.appid) return; var appid = img.dataset.appid; var kind = img.dataset.kind || 'capsule'; if (img._sglvChainLoading) return; img._sglvChainLoading = 1; try { var fb = _coverFb(); if (fb && typeof fb.chainLoad === 'function') { fb.chainLoad(img, String(appid), kind, { onGiveUp: function () { if (img && img.parentElement) { var ph = document.createElement('div'); ph.className = (img.className || '') + ' sglv-cover-broken'; ph.textContent = (Host && Host.isZh) ? '无封面' : 'No Cover'; img.parentElement.replaceChild(ph, img); } }, }); } else { if (img.parentElement) { var ph = document.createElement('div'); ph.className = (img.className || '') + ' sglv-cover-broken'; ph.textContent = (Host && Host.isZh) ? '无封面' : 'No Cover'; img.parentElement.replaceChild(ph, img); } } } catch (e) { /* 静默降级 */ } } try { var _sgcvPageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; _sgcvPageWin._sglvChainFallback = _sgcvChainFallbackImpl; } catch (e) { /* ignore */ } // 挂载到页面上下文(内联 onerror 属性在页面上下文执行) try { var _sgcvPageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; _sgcvPageWin._sglvChainFallback = _sgcvChainFallbackImpl; } catch (e) { /* ignore */ } // v2.0.0: 删掉旧版 seriesCapsuleImg(2838 旧实现)——已统一到 _sglvChainFallback 单委托 // 旧版 inline 链式 onerror 串 7-20 URL + 竞速兜底,与新 design 重复,会让 2838 旧版覆盖 // 上面的 2767 新版,导致单委托 _sglvChainFallback 永远不会生效 /** * 系列/供应商游戏列表公共渲染(KPI + 进度筛选 + 卡片/列表视图) * @param {string} breadcrumbHtml - 顶部面包屑 HTML * @param {Array} games - [{id, name, date}] * @param {function} rerender - 重渲染回调 * @param {string} cacheKey - v1.9.19: 分类标识(含 filter),用于分页进度记忆/恢复 */ function renderSeriesDetailCommon(breadcrumbHtml, games, rerender, cacheKey) { const main = document.getElementById('sglv-pub-main'); if (!main) return; cacheKey = cacheKey ? cacheKey + '\u0000' + pubState.filter + '\u0000' + pubState.sortField + '\u0000' + pubState.sortDir : null; // v1.9.19: 切换分类时保存旧页码、恢复新分类页码(filter/viewMode 变化仍走 rerender,页码由 cacheKey 区分) if (pubDetailState.cacheKey !== cacheKey) { if (pubDetailState.cacheKey) pubDetailState.pageCache.set(pubDetailState.cacheKey, pubDetailState.page); pubDetailState.page = (cacheKey && pubDetailState.pageCache.get(cacheKey)) || 1; pubDetailState.cacheKey = cacheKey; } const ownedAppIds = Host.getActiveOwnedAppIds(); let ownedCount = 0; for (const g of games) if (ownedAppIds.has(g.id)) ownedCount++; const sStats = { total: games.length, owned: ownedCount }; const pct = sStats.total > 0 ? Math.round(sStats.owned / sStats.total * 100) : 0; const viewMode = pubState.viewMode; // v1.9.34: 'card' | 'cover' | 'list' let html = breadcrumbHtml + `
${Host.isZh ? '收集完成度' : 'Completion'}: ${pct}% (${sStats.owned}/${sStats.total})
${viewMode === 'list' ? '
' : viewMode === 'cover' ? '
' : '
'} `; // 按拥有状态筛选游戏 let displayGames = games; if (pubState.filter === 'owned') { displayGames = games.filter(g => ownedAppIds.has(g.id)); } else if (pubState.filter === 'unowned') { displayGames = games.filter(g => !ownedAppIds.has(g.id)); } // v1.9.31: 排序——按 sortField + sortDir 排序,未知/空日期排末尾 displayGames = sortSeriesGames(displayGames, pubState.sortField, pubState.sortDir); // v1.9.19: 分页——只渲染当前页的游戏卡片/封面(页大小与其他 tab 一致) const { pageItems, totalPages, page: _clampedPage } = Host.paginate(displayGames, pubDetailState.page, pubDetailState.pageSize); pubDetailState.page = _clampedPage; if (displayGames.length === 0) { html += `
${pubState.filter === 'owned' ? (Host.isZh ? '该系列没有已拥有的游戏' : 'No owned games in this series') : pubState.filter === 'unowned' ? (Host.isZh ? '该系列没有未拥有的游戏' : 'All games in this series are owned') : (Host.isZh ? '该分类暂无游戏' : 'No games in this category')}
`; } else if (viewMode === 'list') { // 列表视图:表头(含封面列) html += `
${Host.isZh ? '封面' : 'Cover'} ${Host.isZh ? '状态' : 'Status'} ${Host.isZh ? '游戏名称' : 'Game Name'} AppID
`; for (const g of pageItems) { const { badgeClass, badgeText, tooltip } = getGameBadgeInfo(g, ownedAppIds); // v1.9.30: 封面优先使用数据源 poster 字段(预验证URL),失败走 CDN 降级链 + API 兜底 const coverImg = seriesCoverImg(g, 'header', 'sglv-pub-list-cover'); html += ` ${coverImg} ${badgeText} ${g.name}${(() => { const tags = genreTagsHtml(g); return tags ? `${tags}` : ''; })()} ${g.id} `; } } else if (viewMode === 'cover') { // v1.9.34: 横版封面视图(capsule 大图) for (const g of pageItems) { const { isOwned, badgeClass, badgeText, tooltip } = getGameBadgeInfo(g, ownedAppIds); html += `
${seriesCapsuleImg(g)}
${badgeText}
${g.name}${g.id}
${(() => { const tags = genreTagsHtml(g); return tags ? `
${tags}
` : ''; })()}
`; } } else { // 卡片视图 for (const g of pageItems) { const { isOwned, badgeClass, badgeText, tooltip } = getGameBadgeInfo(g, ownedAppIds); html += `
${seriesCoverImg(g, 'poster', 'sglv-card-img', { preferVertical: true })}
${g.name}
AppID: ${g.id}
${(() => { const tags = genreTagsHtml(g); return tags ? `
${tags}
` : ''; })()} ${badgeText}
`; } } html += '
'; // v1.9.19: 分页导航(与其他 tab 同一套 Host.renderPagination) html += `
`; main.innerHTML = html; // v2.8.0: 统一封面链绑定(系列视图的三种卡片都走 unifiedCover) bindRestrictedCoverLoaders(main); // v2.3.33:异步加载游戏中文名 main.querySelectorAll('[data-sglv-appid]').forEach(el => { Host.loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); // 统一事件绑定 // v1.9.35: 已拥有/未拥有 切换标签——点击已激活的标签则取消(回到全部) const ownOwnedBtn = main.querySelector('#sglv-pub-own-owned'); const ownUnownedBtn = main.querySelector('#sglv-pub-own-unowned'); if (ownOwnedBtn) ownOwnedBtn.addEventListener('click', () => { pubState.filter = pubState.filter === 'owned' ? 'all' : 'owned'; pubDetailState.page = 1; rerender(); }); if (ownUnownedBtn) ownUnownedBtn.addEventListener('click', () => { pubState.filter = pubState.filter === 'unowned' ? 'all' : 'unowned'; pubDetailState.page = 1; rerender(); }); // v1.9.31: 排序事件——字段切换 + 方向切换 const sortFieldSelect = main.querySelector('#sglv-pub-sort-field'); if (sortFieldSelect) sortFieldSelect.addEventListener('change', (e) => { pubState.sortField = e.target.value; pubDetailState.page = 1; rerender(); }); const sortDirBtn = main.querySelector('#sglv-pub-sort-dir'); if (sortDirBtn) sortDirBtn.addEventListener('click', (e) => { e.stopPropagation(); pubState.sortDir = pubState.sortDir === 'asc' ? 'desc' : 'asc'; pubDetailState.page = 1; rerender(); }); // v1.9.31: data-view 属性区分视图按钮与排序方向按钮 main.querySelectorAll('.sglv-view-btn[data-view]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); pubState.viewMode = btn.dataset.view; rerender(); }); }); // v1.9.19: 分页事件(onJump 驱动首/上/下/末页与跳页输入) Host.renderPagination(document.getElementById('sglv-pub-series-pagination'), 'sglv-pub-series', pubDetailState.page, totalPages, displayGames.length, () => { pubDetailState.page--; rerender(); }, () => { pubDetailState.page++; rerender(); }, (p) => { pubDetailState.page = p; rerender(); }); } // ==================== 绝版游戏标签页 ==================== // v1.9.47-fix: 绝版游戏数据兜底链—— // ① Host.getDelistedDb() 同步取(来自主脚本 @resource delistedData;3.5MB 大文件,Tampermonkey // 偶发静默加载失败 + 解析慢导致"暂无绝版游戏数据"), // ② 若 ① 为空 → 异步拉远程源三级回退(fastly.jsdelivr → cdn.jsdelivr → raw.githubusercontent), // ③ 写回 Host.cacheSet('delistedDbRemote', ...) 供后续会话秒开(6h 节流后台刷新)。 // 选 _refreshDelistedFromRemote 而非替换 Host.getDelistedDb(闭包不可改),改为缓存到 // _delistedRemoteCache,并让 getDelistedApps() 优先取它,保留 @resource 作为快速通道。 const _DELISTED_REMOTE_SOURCES = [ 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/steam_delisted_apps.json', 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/steam_delisted_apps.json', 'https://raw.githubusercontent.com/SmallRob/steam-namespace/main/data/steam_delisted_apps.json' ]; let _delistedRemoteCache = null; // 远程源完整 JSON {version, source, fetchedAt, totalCount, data} let _delistedRemoteLoading = false; // 防止并发请求 let _delistedRemoteLoaded = false; // 远程已成功加载过(避免每次开 tab 重新拉) const _DELISTED_REMOTE_BG_INTERVAL = 6 * 3600 * 1000; // 6h 后台节流 let _delistedLastBgRefreshAt = 0; let _delistedForceRemote = false; // 工具栏刷新按钮触发(force 走全链路) function _normalizeDelistedRaw(raw) { if (!raw || typeof raw !== 'object') return []; let rawApps = []; if (Array.isArray(raw.data)) rawApps = raw.data; else if (raw.delisted && Array.isArray(raw.delisted.apps)) rawApps = raw.delisted.apps; return rawApps.map(app => ({ appid: app.appId || app.appid, name: app.name, type: app.delistType || app.type || 'Delisted', changed: app.delistedAt || app.changed, owners: app.ownerPercentage != null ? app.ownerPercentage + '%' : (app.owners || '-'), achievements: app.achievementCount || app.achievements, keyshopPrice: app.keyshopPrice, source: app.source })); } // 解析远程响应文本,校验为合法绝版 JSON(data 数组 / totalCount > 0) function _parseDelistedResponseText(txt) { try { const obj = JSON.parse(txt); if (!obj || typeof obj !== 'object') return null; const okData = Array.isArray(obj.data) && obj.data.length > 0; const okLegacy = obj.delisted && Array.isArray(obj.delisted.apps) && obj.delisted.apps.length > 0; if (!okData && !okLegacy) return null; return obj; } catch (e) { return null; } } function _refreshDelistedFromRemote(force) { if (typeof GM_xmlhttpRequest !== 'function') return; if (_delistedRemoteLoading) return; _delistedRemoteLoading = true; function _tryNext(idx) { if (idx >= _DELISTED_REMOTE_SOURCES.length) { _delistedRemoteLoading = false; return; } GM_xmlhttpRequest({ method: 'GET', url: _DELISTED_REMOTE_SOURCES[idx], timeout: 20000, onload(r) { try { const txt = _safeRespText(r); const obj = _parseDelistedResponseText(txt); if (obj) { _delistedRemoteCache = obj; _delistedRemoteLoaded = true; _delistedLastBgRefreshAt = Date.now(); // 写 TTL 缓存(24h),后续会话秒开 try { Host.cacheSet('delistedDbRemote', obj, 24 * 3600 * 1000); } catch (e) { /* 忽略 */ } _delistedRemoteLoading = false; // 触发 UI 重渲染(若当前已开 delisted tab) // v1.9.47-fix: 数据到达时,若 wrap 还是"加载骨架占位"状态(无 toolbar/KPI), // 清空 body 重建 tab 一次性同步 KPI/分类汇总/工具栏(否则仅刷新列表内容)。 if (_collectionActiveTab === 'delisted' && collectionPanelEl && collectionPanelEl.classList.contains('sglv-show')) { const body = collectionPanelEl.querySelector('#sgc-body'); if (body) { const wrap = body.querySelector('.sglv-delisted-wrap'); const hasFullUi = wrap && wrap.querySelector('#sglv-delisted-pagination'); if (hasFullUi) { renderDelistedGamesList(); } else { // 清空 body 重建,避免旧 wrap 与新 wrap 重复 body.innerHTML = ''; renderDelistedTab(body); } } } return; } } catch (e) { /* fallthrough */ } _tryNext(idx + 1); }, onerror() { _tryNext(idx + 1); }, ontimeout() { _tryNext(idx + 1); } }); } _tryNext(0); } // 启动时尝试从 TTL 缓存预热远程源(避免开 tab 才发起请求) function _warmDelistedRemoteCache() { try { const cached = Host.cacheGet('delistedDbRemote'); if (cached && typeof cached === 'object') { if ((Array.isArray(cached.data) && cached.data.length > 0) || (cached.delisted && Array.isArray(cached.delisted.apps) && cached.delisted.apps.length > 0)) { _delistedRemoteCache = cached; _delistedRemoteLoaded = true; } } } catch (e) { /* 忽略 */ } } function getDelistedApps() { // ① 优先 _delistedRemoteCache(已通过远程源/TTL 缓存预热),正常情况 @resource 加载也走这条分支 if (_delistedRemoteCache) return _normalizeDelistedRaw(_delistedRemoteCache); // ② @resource 数据(主脚本 GM_getResourceText('delistedData') 同步返回) const DELISTED_DB = Host.getDelistedDb(); if (DELISTED_DB && ((Array.isArray(DELISTED_DB.data) && DELISTED_DB.data.length > 0) || (DELISTED_DB.delisted && Array.isArray(DELISTED_DB.delisted.apps) && DELISTED_DB.delisted.apps.length > 0))) { return _normalizeDelistedRaw(DELISTED_DB); } // ③ 远程源未触发过 → 触发(force 模式跳过 @resource 兜底走全链路) if (!_delistedRemoteLoaded && !_delistedRemoteLoading) { _refreshDelistedFromRemote(_delistedForceRemote); } return []; } // 工具栏"刷新"按钮触发的强制刷新入口 function forceRefreshDelistedFromRemote() { _delistedForceRemote = true; _delistedRemoteLoaded = false; // 强制重走源 _delistedRemoteCache = null; _delistedRemoteLoading = false; _refreshDelistedFromRemote(true); } // v2.2.0: 绝版类型 → 颜色映射(KPI 行 + 卡片徽章共用) const DELISTED_TYPE_COLORS = { 'delisted': '#f44336', // 已下架 'purchase_disabled': '#fb923c', // 购买禁用 'f2p_unavailable': '#a78bfa', // F2P 不可用 'retail_only': '#60a5fa', // 仅零售 'test_app': '#94a3b8' // 测试应用 }; const DELISTED_TYPE_KEYS = ['delisted', 'purchase_disabled', 'f2p_unavailable', 'retail_only', 'test_app']; function getTypeKey(type) { if (!type) return 'delisted'; // 已经是小写下划线格式,直接返回 if (type.includes('_')) return type; // 大写格式 -> 小写下划线格式 const map = { 'Delisted': 'delisted', 'Purchase disabled': 'purchase_disabled', 'F2P (unavailable)': 'f2p_unavailable', 'Retail only': 'retail_only', 'Test app': 'test_app', }; return map[type] || 'delisted'; } function getTypeLabel(type) { if (!type) return Host.T.typeDelisted; const map = { 'Delisted': Host.T.typeDelisted, 'delisted': Host.T.typeDelisted, 'Purchase disabled': Host.T.typePurchaseDisabled, 'purchase_disabled': Host.T.typePurchaseDisabled, 'F2P (unavailable)': Host.T.typeF2P, 'f2p_unavailable': Host.T.typeF2P, 'Retail only': Host.T.typeRetailOnly, 'retail_only': Host.T.typeRetailOnly, 'Test app': Host.T.typeTestApp, 'test_app': Host.T.typeTestApp, }; return map[type] || type; } function getFilteredDelistedApps() { const allApps = getDelistedApps(); // v2.3.14: 使用 getActiveOwnedAppIds() 替代 ownedAppIds, // 以尊重"显示家庭共享"开关:关闭时仅匹配自己拥有的游戏,不匹配家庭组共享游戏 const ownedIds = Host.getActiveOwnedAppIds(); // v1.9.47-fix: 缓存键加入 ownedIds 集合指纹—— // 此前仅用 ownedIds.size(拥有游戏数量)做粗粒度失效,同数量但不同游戏的 owned 集合变化会导致 // subFilter=owned/missing 筛选结果过时(新购买的不展示,已退款的仍展示)。 // 用前 8 个 appid 的 djb2 哈希 + 数量作指纹,既能精确失效又避免 ownedIds 全量入键(缓存键爆炸)。 let _ownedSig = 0; if (ownedIds && ownedIds.size) { const sample = Array.from(ownedIds).slice(0, 8); let h = ownedIds.size * 2654435761; for (const n of sample) { h = ((h << 5) + h + Number(n) | 0) >>> 0; } _ownedSig = h.toString(36); } const cacheK = Host.cacheKey('delisted-filter', allApps.length, delistedState.subFilter, delistedState.typeFilter, delistedState.search, delistedState.sort, ownedIds.size, _ownedSig); return Host.getCached(cacheK, () => { let apps = allApps; // 子分类筛选:全部 / 已拥有 / 未拥有 if (delistedState.subFilter === 'owned') { apps = apps.filter(a => ownedIds.has(Number(appidFix(a.appid))) || ownedIds.has(String(a.appid))); } else if (delistedState.subFilter === 'missing') { apps = apps.filter(a => !ownedIds.has(Number(appidFix(a.appid))) && !ownedIds.has(String(a.appid))); } // Type filter if (delistedState.typeFilter !== 'all') { apps = apps.filter(a => getTypeKey(a.type) === delistedState.typeFilter); } // Search filter if (delistedState.search) { const q = delistedState.search.toLowerCase(); apps = apps.filter(a => (a.name || '').toLowerCase().includes(q) || String(a.appid).includes(q) ); } // v1.9.32: 排序——按 appid 或下架时间(delistedAt/changed),空日期排末尾 apps = apps.slice().sort((a, b) => { let r = 0; switch (delistedState.sort) { case 'appidAsc': r = (a.appid || 0) - (b.appid || 0); break; case 'dateDesc': { const sa = seriesDateToSortable(a.changed), sb = seriesDateToSortable(b.changed); if (!sa && !sb) { r = 0; break; } if (!sa) return 1; if (!sb) return -1; r = sb.localeCompare(sa); break; } case 'dateAsc': { const sa = seriesDateToSortable(a.changed), sb = seriesDateToSortable(b.changed); if (!sa && !sb) { r = 0; break; } if (!sa) return 1; if (!sb) return -1; r = sa.localeCompare(sb); break; } case 'appidDesc': default: r = (b.appid || 0) - (a.appid || 0); } return r || ((a.appid || 0) - (b.appid || 0)); }); return apps; }); } function appidFix(v) { const n = Number(v); return isNaN(n) ? v : n; } function getOwnedGameInfo(appid) { // v2.3.14: 关闭"显示家庭共享"时,仅在自己拥有的游戏中查找,避免命中家庭组共享游戏 const showFamilyShared = Host.getShowFamilyShared(); const pool = showFamilyShared ? Host.getOwnedGames() : Host.getOwnedGames().filter(g => Host.isGameOwnedByMe(g)); return pool.find(g => g.appid === appidFix(appid)) || null; } function renderDelistedGamesList() { const content = document.getElementById('sglv-delisted-content'); if (!content) return; const apps = getFilteredDelistedApps(); // v2.4.0: 复用 paginate 纯函数统一分页计算 const { pageItems: pageApps, totalPages, page: _clampedPage } = Host.paginate(apps, delistedState.page, delistedState.pageSize); delistedState.page = _clampedPage; // v2.3.14: 使用 getActiveOwnedAppIds() 尊重"显示家庭共享"开关 const ownedIds = Host.getActiveOwnedAppIds(); // v2.2.0: 抽出受限游戏板通用组件(绝版 / 锁区 / 将来可能扩展共用一套) // 卡片元数据构造:owners / changed / achievements / lastPlayed / playtime const ownersSvg = ''; const dateSvg = ''; const clockSvg = ''; if (delistedState.viewMode === 'card') { content.innerHTML = '
' + pageApps.map(function (app) { const appid = appidFix(app.appid); const isOwned = ownedIds.has(Number(appid)) || ownedIds.has(String(appid)); const typeKey = getTypeKey(app.type); const ownedInfo = isOwned ? getOwnedGameInfo(appid) : null; const playtimeH = ownedInfo && ownedInfo.playtime > 0 ? (ownedInfo.playtime / 60).toFixed(1) + 'h' : ''; const lastPlayed = ownedInfo && ownedInfo.lastPlayed ? Host.formatLastPlayedShort(ownedInfo.lastPlayed) : ''; // v2.6.0 关键修复:此前这里传的是 coverHtml = Host.posterImg(...), // 导致绝版 tab 完全绕开了新写的 restrictedCoverImg(横向优先短链 + API 兜底), // 仍在走旧的长链(8 个 library_600x900 竖版 URL)——绝版收录的都是老游戏, // 这 8 个竖版图必然全 404,这就是"绝版封面加载失败"的直接原因。 // 现在删掉 coverHtml 覆盖,让 makeRestrictedCard 走默认的 restrictedCoverImg。 // 游玩时长从封面 overlay 移入右栏 meta(overlay 会与新的状态斜切带抢右下角位置)。 const metaItems = []; if (playtimeH) metaItems.push('' + clockSvg + Host.wlEscape(playtimeH) + ''); if (app.owners) metaItems.push('' + ownersSvg + Host.wlEscape(app.owners) + ''); if (app.changed) metaItems.push('' + dateSvg + Host.wlEscape(app.changed) + ''); return makeRestrictedCard({ appid: appid, name: app.name, isOwned: isOwned, typeLabel: getTypeLabel(app.type), typeKey: typeKey, typeColor: DELISTED_TYPE_COLORS[typeKey] || '#f44336', metaItems: metaItems }); }).join('') + '
'; } else { content.innerHTML = '
' + pageApps.map(function (app) { const appid = appidFix(app.appid); const isOwned = ownedIds.has(Number(appid)) || ownedIds.has(String(appid)); const typeKey = getTypeKey(app.type); const ownedInfo = isOwned ? getOwnedGameInfo(appid) : null; const playtimeH = ownedInfo && ownedInfo.playtime > 0 ? (ownedInfo.playtime / 60).toFixed(1) + 'h' : ''; const lastPlayed = ownedInfo && ownedInfo.lastPlayed ? Host.formatLastPlayedShort(ownedInfo.lastPlayed) : ''; // v2.6.0: 列表视图右栏保留 3 项(拥有率 / 下架 / 最后游玩),比卡片多一项是因为横向空间充裕 const metaItems = []; if (app.owners) metaItems.push('' + ownersSvg + Host.wlEscape(app.owners) + ''); if (app.changed) metaItems.push('' + dateSvg + Host.wlEscape(app.changed) + ''); if (lastPlayed) metaItems.push('' + clockSvg + Host.wlEscape(lastPlayed) + ''); if (playtimeH) metaItems.push('' + Host.wlEscape(playtimeH) + ''); return makeRestrictedListItem({ appid: appid, name: app.name, isOwned: isOwned, typeLabel: getTypeLabel(app.type), typeKey: typeKey, typeColor: DELISTED_TYPE_COLORS[typeKey] || '#f44336', metaItems: metaItems }); }).join('') + '
'; } // v2.3.33:异步加载游戏中文名 content.querySelectorAll('[data-sglv-appid]').forEach(el => { Host.loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); // v2.5.0: 补齐缓存命中图的 loaded 态(onload 早于监听器挂载时不会触发) syncRestrictedCoverStates(content); // v1.9.47-fix: 主脚本 renderPagination 所有按钮(首页/上页/下页/末页/跳页)统一走 onJump, // 缺失第 8 参数 → 翻页按钮全部失效("只能加载第一页")。补传 onJump。 Host.renderPagination(document.getElementById('sglv-delisted-pagination'), 'sglv-delisted', delistedState.page, totalPages, apps.length, () => { delistedState.page--; renderDelistedGamesList(); }, () => { delistedState.page++; renderDelistedGamesList(); }, (p) => { delistedState.page = p; renderDelistedGamesList(); }); } // v1.9.47-fix: 加载骨架占位——构建完整工具栏 + 工具栏事件绑定(刷新按钮), // 内容区域只放加载骨架条。数据到达时由 _refreshDelistedFromRemote 回调触发 // renderDelistedGamesList() 仅更新 #sglv-delisted-content,工具栏/状态保留。 // 这样既保证用户看到工具栏(可点刷新),又不会因数据未到而无法操作。 function _renderDelistedLoadingScaffold(wrap) { // 工具栏(只放搜索框 + 视图按钮 + 刷新按钮,无数据时类型/排序/子筛选无意义,留空避免误操作) const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar sglv-delisted-toolbar'; toolbar.innerHTML = `
`; wrap.appendChild(toolbar); // 内容区(加载骨架) const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-delisted-content'; const loadingText = _delistedRemoteLoading ? (Host.isZh ? '正在加载绝版游戏数据…' : 'Loading delisted game data…') : (Host.isZh ? '暂无绝版游戏数据' : 'No delisted game data available'); content.innerHTML = '
' + loadingText + '
'; wrap.appendChild(content); // 刷新按钮事件(复用正常路径的事件签名,只是数据为空时点击依然可走 force 全链路) const refreshBtn = toolbar.querySelector('#sglv-delisted-refresh'); if (refreshBtn) { refreshBtn.addEventListener('click', () => { if (refreshBtn.classList.contains('sglv-spin')) return; refreshBtn.classList.add('sglv-spin'); const restore = () => refreshBtn.classList.remove('sglv-spin'); forceRefreshDelistedFromRemote(); setTimeout(restore, 30000); const poll = setInterval(() => { if (!_delistedRemoteLoading) { clearInterval(poll); restore(); } }, 200); }); } } // v2.5.0: 重渲染绝版 KPI 卡片行——筛选态(scope / type)变化后需要刷新 active 态与各类型可见计数。 // scope 变化会改变每个类型卡显示的计数(scopeBase 随 scope 收窄),所以必须整体重算而非切 class。 function renderDelistedTabKpi(kpiEl) { if (!kpiEl) return; const apps = getDelistedApps(); if (!apps.length) { kpiEl.innerHTML = ''; return; } const ownedIds = Host.getActiveOwnedAppIds(); const isOwnedId = (id) => ownedIds.has(Number(id)) || ownedIds.has(String(id)); const totalDelisted = apps.length; let ownedDelisted = 0; apps.forEach(app => { if (isOwnedId(app.appid)) ownedDelisted++; }); const scope = (delistedState.subFilter === 'owned') ? 'owned' : (delistedState.subFilter === 'missing') ? 'missing' : 'all'; const scopeBase = (scope === 'owned') ? apps.filter(a => isOwnedId(a.appid)) : (scope === 'missing') ? apps.filter(a => !isOwnedId(a.appid)) : apps; const typeLabels = { 'delisted': Host.T.typeDelisted, 'purchase_disabled': Host.T.typePurchaseDisabled, 'f2p_unavailable': Host.T.typeF2P, 'retail_only': Host.T.typeRetailOnly, 'test_app': Host.T.typeTestApp }; // v2.6.0: KPI 小卡行 → HERO 仪表盘(与锁区同款,铺满浮窗宽度 + 分类占比可视化) kpiEl.innerHTML = makeRestrictedHero({ total: totalDelisted, ownedCount: ownedDelisted, scopeBase: scopeBase.map(function (a) { return { type: getTypeKey(a.type) }; }), scope: scope, typeFilter: delistedState.typeFilter, title: Host.T.delistedTotal, typeKeys: DELISTED_TYPE_KEYS, typeColors: DELISTED_TYPE_COLORS, typeLabels: typeLabels, tabId: 'delisted' }); } function renderDelistedTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-delisted-wrap'; // v1.9.47-fix: 首次进入触发远程源兜底预热(@resource 加载失败/同步阻塞时仍能加载) if (!_delistedRemoteLoaded && !_delistedRemoteLoading) { _warmDelistedRemoteCache(); // 先尝试 TTL 缓存(秒开) if (!_delistedRemoteCache) _refreshDelistedFromRemote(false); // 无缓存 → 远程源 } // v1.9.47-fix: 后台静默刷新节流(6h) else if (_delistedRemoteLoaded && !_delistedRemoteLoading && (Date.now() - _delistedLastBgRefreshAt) > _DELISTED_REMOTE_BG_INTERVAL) { _refreshDelistedFromRemote(false); } const apps = getDelistedApps(); if (apps.length === 0) { // v1.9.47-fix: 构建完整工具栏(可点刷新) + 加载骨架占位。 // 数据到达时 _refreshDelistedFromRemote 回调 → renderDelistedGamesList() // 仅更新 #sglv-delisted-content,工具栏/状态保留(避免重新打开 tab 闪烁)。 parent.appendChild(wrap); _renderDelistedLoadingScaffold(wrap); return; } // v2.3.14: 使用 getActiveOwnedAppIds() 尊重"显示家庭共享"开关, // 关闭家庭共享时 KPI 与分类统计均仅计自己拥有的绝版游戏 const ownedIds = Host.getActiveOwnedAppIds(); const totalDelisted = apps.length; let ownedDelisted = 0; apps.forEach(app => { if (ownedIds.has(Number(app.appid)) || ownedIds.has(String(app.appid))) ownedDelisted++; }); const missingDelisted = totalDelisted - ownedDelisted; // v2.5.0 收敛:原布局有 5 层筛选 UI(KPI 行 / 类型徽章汇总栏 / 独立进度条 / // 工具栏类型按钮组 / 工具栏拥有状态按钮组),后三层与前两层表达同一组维度,视觉噪音极大。 // 改为 2 层:① KPI 卡片行(3 scope + 5 类型,全部可点即筛选)② 工具栏(搜索 / 排序 / 视图 / 刷新)。 // 拥有率进度条的信息不再单独占一行。 const kpiSection = document.createElement('div'); kpiSection.id = 'sglv-delisted-kpi'; wrap.appendChild(kpiSection); renderDelistedTabKpi(kpiSection); // Toolbar(v2.5.0: 移除类型/拥有状态按钮组——已由 KPI 卡片承担;此处只留搜索 / 排序 / 视图 / 刷新) // v2.8.0: 分页器并入工具栏右侧同一行(此前独立成行,多占一整行垂直空间) const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar sglv-delisted-toolbar'; toolbar.innerHTML = ` `; wrap.appendChild(toolbar); // Content area const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-delisted-content'; wrap.appendChild(content); parent.appendChild(wrap); // Event bindings // v2.5.0: 筛选职责已收敛到 KPI 卡片行 —— 用事件委托替代原先分散在 // 「分类汇总徽章 / 工具栏类型按钮 / 工具栏拥有状态按钮」三处的监听。 // - data-delisted-scope="all|owned|missing" → subFilter // - data-delisted-type="" → typeFilter(再次点击同一类型回到「全部」) const scopeToSub = { all: 'all', owned: 'owned', missing: 'missing' }; kpiSection.addEventListener('click', e => { // v2.6.0: 点击 HERO 左侧主数字区 → 一键清空 type + sub 两个筛选维度 if (!e.target.closest('[data-delisted-scope],[data-delisted-type]') && e.target.closest('.sglv-hero-left')) { if (delistedState.subFilter !== 'all' || delistedState.typeFilter !== 'all') { delistedState.subFilter = 'all'; delistedState.typeFilter = 'all'; delistedState.page = 1; renderDelistedTabKpi(kpiSection); renderDelistedGamesList(); } return; } const scopeCard = e.target.closest('[data-delisted-scope]'); if (scopeCard) { delistedState.subFilter = scopeToSub[scopeCard.dataset.delistedScope] || 'all'; delistedState.page = 1; renderDelistedTabKpi(kpiSection); renderDelistedGamesList(); return; } const typeCard = e.target.closest('[data-delisted-type]'); if (typeCard) { const t = typeCard.dataset.delistedType; delistedState.typeFilter = (delistedState.typeFilter === t) ? 'all' : t; delistedState.page = 1; renderDelistedTabKpi(kpiSection); renderDelistedGamesList(); } }); toolbar.querySelector('#sglv-delisted-search').addEventListener('input', _debounce((e) => { delistedState.search = e.target.value; delistedState.page = 1; renderDelistedGamesList(); }, 200)); // v1.9.32: 排序下拉事件 toolbar.querySelector('#sglv-delisted-sort').addEventListener('change', (e) => { delistedState.sort = e.target.value; delistedState.page = 1; renderDelistedGamesList(); }); toolbar.querySelector('#sglv-delisted-card').addEventListener('click', () => { delistedState.viewMode = 'card'; toolbar.querySelector('#sglv-delisted-card').classList.add('active'); toolbar.querySelector('#sglv-delisted-list').classList.remove('active'); renderDelistedGamesList(); }); toolbar.querySelector('#sglv-delisted-list').addEventListener('click', () => { delistedState.viewMode = 'list'; toolbar.querySelector('#sglv-delisted-list').classList.add('active'); toolbar.querySelector('#sglv-delisted-card').classList.remove('active'); renderDelistedGamesList(); }); // v1.9.47-fix: 远程源手动刷新(@resource 加载失败时可点此走三级回退) const refreshBtn = toolbar.querySelector('#sglv-delisted-refresh'); if (refreshBtn) { refreshBtn.addEventListener('click', () => { if (refreshBtn.classList.contains('sglv-spin')) return; refreshBtn.classList.add('sglv-spin'); const restore = () => refreshBtn.classList.remove('sglv-spin'); const prevLoading = _delistedRemoteLoading; forceRefreshDelistedFromRemote(); // 监听 30s 后兜底还原(防止失败场景按钮卡在 spinning 状态) setTimeout(restore, 30000); // 轮询直到 loading=false 再还原 const poll = setInterval(() => { if (!_delistedRemoteLoading) { clearInterval(poll); restore(); } }, 200); }); } renderDelistedGamesList(); } // v1.9.27: 安全读取 GM_xmlhttpRequest 响应文本 // 浏览器扩展(如 inspector.js) hook XMLHttpRequest.prototype.send 并修改 responseType 为 arraybuffer, // 导致 r.responseText 抛 InvalidStateError。此函数先尝试 responseText,失败后尝试 response(ArrayBuffer→TextDecoder) function _safeRespText(r) { try { if (r.responseText) return r.responseText; } catch (e) { /* responseType 被外部修改为 arraybuffer */ } try { if (typeof r.response === 'string') return r.response; if (r.response instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(r.response)); } catch (e2) { /* ignore */ } return ''; } // ==================== PS 会免标签页 (v2.6.0) ==================== // 远程数据源:psplus-games.json(由 oXnMe/psplus-steam-overlay 维护) // 数据结构:{ updatedAt, count, games: [{ recordId, steamAppId, tier, month, monthSort, statusName, statusDisplay, statusDate, versions, psstore, color }] } // tier: 1=Essential(必备) 2=Extra(升级) 3=Premium(高级) // statusName: 在库/领取中/截止领取/已出库/即将出库 const PSPLUS_DATA_URL = 'https://fastly.jsdelivr.net/gh/oXnMe/psplus-steam-overlay@main/data/psplus-games.json'; // v1.9.37: 降级数据源——由 js_script/psplus-data-sync.js 每日同步镜像到 SmallRob/steam-namespace 仓库 // 主源(oXnMe)不可用时依次尝试三级 CDN 回退链(参照 EPIC_GITHUB_URLS 写法) const PSPLUS_BACKUP_URLS = [ 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/psplus-games.json', 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/psplus-games.json', 'https://raw.githubusercontent.com/SmallRob/steam-namespace/main/data/psplus-games.json' ]; // PS Logo SVG(白色,用于占位封面) // v2.9.22: 真实 PlayStation 标识(官方蓝紫色 P + S 经典组合,参考 PS 官方 logo SVG) const PS_LOGO_SVG = ''; // 从 PS Store URL 提取游戏名称(slug → Title Case)——v2.6.2 增强支持 playstation.com games slug 与 store.playstation.com product ID function psExtractName(psstore) { if (!psstore) return ''; // 1. playstation.com /games/{slug} 形式 const m = psstore.match(/\/games\/([^\/\?]+)/); if (m) { return m[1].split('-').filter(Boolean).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); } // 2. store.playstation.com /product/{id}_{name} 形式——name 在 ID 之后可能含字母+数字 // 例如 UP1642-CUSA04798_00-SLUS211960000001 → SLUS211960000001 // JP0106-PPSA34548_00-GITAROOMAN000000 → GITAROOMAN000000 const p = psstore.match(/\/product\/[A-Z0-9\-]+_00-([A-Za-z0-9]+)/); if (p) { // ID 经常是大写字母+数字混合,去掉前 1-2 字母前缀尝试拆分:SLUS21196 → SLUS-21196;GITAROOMAN → GITAROO MAN const raw = p[1]; // 尝试按"大写+小写"分词(AstroBot → Astro Bot) const spaced = raw.replace(/([a-z])([A-Z])/g, '$1 $2'); // 尝试按"数字+字母"分词(SLUS21196 → SLUS 21196) const spaced2 = spaced.replace(/([A-Z]+)(\d)/g, '$1 $2').replace(/(\d)([A-Z])/g, '$1 $2'); // 尝试按纯连续大写字母拆:GITAROOMAN → GITAROO MAN (按 4+ 字母) let result = spaced2.replace(/([A-Z]{4,})/g, m => m.replace(/([A-Z]{2})([A-Z]+)/, '$1 $2').trim()); // 收尾:把数字与字母之间加空格 result = result.replace(/([A-Za-z])(\d)/g, '$1 $2').replace(/(\d)([A-Za-z])/g, '$1 $2'); return result.split(/\s+/).filter(Boolean).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); } return ''; } // v2.6.2: PS 独占异步抓取 psstore 页面获取中文名(基于 product ID 解析 ) // v2.9.7: 升级为「PS Store 元数据」抓取——同一次请求同时解析中文名与真实封面主视觉。 // 背景:收藏模块里唯一的非 Steam 游戏是「PS 会免」里无 steamAppId 的 PS 独占条目 // (实测数据源 1595 条中 293 条无 steamAppId),此前只有一块蓝色 PS Logo 占位画,没有封面。 // 做法参考 steam-friend-manager 1.6.2 的非 Steam 卡(sfd-tga-excl-art + sfd-tga-excl-img): // 平台占位画垫底,商店主视觉图覆盖其上;图加载失败自动移除图,保住占位画。 // 主视觉来源:PS Store 页面内嵌的 schema.org JSON-LD(script#mfe-jsonld-tags → Product.image), // 实测为 1024×1024 方图,CDN image.api.playstation.com 可直链热链(无需 Referer)。 // 输入 psstore URL;输出 Promise<{name, cover}>,空字符串表示获取失败 const _psStoreMetaPending = new Map(); const _PS_STORE_NAME_TTL = 6 * 3600 * 1000; // 6h,与 PS 会免数据 TTL 对齐 const _PS_STORE_NAME_NEG_TTL = 30 * 60 * 1000; // 失败 30 分钟内不再请求 const _PS_STORE_COVER_TTL = 30 * 24 * 3600 * 1000; // 封面主视觉 30 天(静态资源,几乎不变) const _PS_STORE_COVER_NEG_TTL = 6 * 3600 * 1000; // 封面抓取失败 6h 后再试 const SGLV_PS_NAME_KEY = 'sglvPsStoreNameCache_v1'; // v1 旧 key(仅中文名,用于一次性迁移) const SGLV_PS_META_KEY = 'sglvPsStoreMetaCache_v2'; // v2 新 key(中文名 + 封面图) const _PS_META_MAX_CONCURRENCY = 4; // PS Store 页面 300KB+,限并发避免打满带宽 const _PS_STORE_PRIMARY_LOCALE = 'zh-hans-hk'; // 主区域(数据源多为该区域) const _PS_STORE_ALT_LOCALE = 'en-us'; // 兜底区域(主区域缺图时再试一次) // 内存镜像 —— 避免每次读缓存都 GM_getValue + JSON.parse(单页 49 张卡会放大成 49 次解析) let _psMetaCacheMap = null; let _psMetaFlushTimer = null; let _psMetaDirty = false; function sglvPsStoreMetaCacheLoad() { if (_psMetaCacheMap) return _psMetaCacheMap; let map = {}; try { map = JSON.parse(GM_getValue(SGLV_PS_META_KEY, '{}') || '{}') || {}; } catch (e) { map = {}; } if (!map || typeof map !== 'object') map = {}; // 迁移 v1(仅中文名)——封面字段留空,等首次抓取补齐 try { const old = JSON.parse(GM_getValue(SGLV_PS_NAME_KEY, '{}') || '{}') || {}; Object.keys(old).forEach(u => { if (map[u]) return; const o = old[u]; if (!o) return; map[u] = { name: o.name || '', nameTs: o.ts || 0 }; }); } catch (e) { /* 忽略迁移失败 */ } _psMetaCacheMap = map; return map; } // 落盘节流——一页 49 张卡会连续写 49 次,整表 JSON 序列化放大成几十 MB 写入 function sglvPsStoreMetaCacheSave() { _psMetaDirty = true; if (_psMetaFlushTimer) return; _psMetaFlushTimer = setTimeout(() => { _psMetaFlushTimer = null; if (!_psMetaDirty) return; _psMetaDirty = false; try { GM_setValue(SGLV_PS_META_KEY, JSON.stringify(_psMetaCacheMap || {})); } catch (e) { /* 忽略失败 */ } }, 3000); } // 旧接口(仅中文名)保留,内部并入元数据抓取 function sglvPsStoreNameCacheLoad() { return sglvPsStoreMetaCacheLoad(); } function sglvPsStoreNameCacheSave() { sglvPsStoreMetaCacheSave(); } // 区域化 URL——数据源里 psstore 多数是 zh-hant-hk,统一到主区域再抓 // 只替换「语言-地区」段(如 zh-hans-hk / en-us),不动 www.playstation.com/games/{slug} 这类无区域段 URL function psStoreLocaleUrl(url, locale) { if (!url) return ''; if (!/playstation\.com/.test(url)) return url; if (!locale) return url; if (url.indexOf('/' + locale + '/') >= 0) return url; return url.replace(/(playstation\.com)\/[a-z]{2}(?:-[a-z]{2,4}){0,2}\//, '$1/' + locale + '/'); } // PS Store 页面 → 官方名(og:title → <title> → twitter:title,与 v2.6.2 既有口径一致) function parsePsStoreName(html) { let name = ''; try { if (!html) return ''; let m = html.match(/<meta\s+(?:[^>]*?)property=["']og:title["']\s+(?:[^>]*?)content=["']([^"']+)/i); if (!m) m = html.match(/<title[^>]*>([^<]+)<\/title>/i); if (!m) m = html.match(/<meta\s+(?:[^>]*?)name=["']twitter:title["']\s+(?:[^>]*?)content=["']([^"']+)/i); if (m) { name = m[1] .replace(/\s*\|\s*PlayStation.*$/i, '') .replace(/\s*-\s*PlayStation.*$/i, '') .replace(/\s*·\s*PlayStation.*$/i, '') .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'") .trim(); } } catch (e) { /* 忽略失败 */ } // v2.9.9: og:title 可能整页就是平台/商店名(区域限售页回落到首页), // 此时解析出的"name"是 "PlayStation" 之类泛化词,回填会把卡片名改成平台名 —— 视为抓取失败。 if (/^(playstation(\s+(store|official\s+site))?|ps\s*store|games?)$/i.test(name)) return ''; return name; } // PS Store 页面 → 真实封面主视觉(只认 image.api.playstation.com 主视觉域) function isPsCoverUrl(u) { return typeof u === 'string' && /^https:\/\/image\.api\.playstation\.com\/vulcan\//.test(u); } function parsePsStoreCover(html) { if (!html) return ''; try { // ① schema.org JSON-LD(PS Store SSR 直出 Product.image) const m = html.match(/<script[^>]+id=["']mfe-jsonld-tags["'][^>]*>([\s\S]*?)<\/script>/i); if (m) { try { const o = JSON.parse(m[1]); const img = o && typeof o.image === 'string' ? o.image : ''; if (isPsCoverUrl(img)) return img; } catch (e) { /* JSON 解析失败走兜底 */ } } // ② 兜底——页面里第一张非评分图标的主视觉 const all = html.match(/https:\/\/image\.api\.playstation\.com\/vulcan\/[^"'\\\s]+?\.(?:jpg|jpeg|png)/gi); if (all) { for (let i = 0; i < all.length; i++) if (isPsCoverUrl(all[i])) return all[i]; } } catch (e) { /* 忽略失败 */ } return ''; } // 页面抓取(GM_xmlhttpRequest 包装) function psStoreRequest(url) { return new Promise(resolve => { if (typeof GM_xmlhttpRequest !== 'function') { resolve(''); return; } GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 15000, onload(resp) { let html = ''; try { html = _safeRespText(resp); } catch (e) { html = ''; } resolve(html || ''); }, onerror() { resolve(''); }, ontimeout() { resolve(''); } }); }); } // 并发闸门——PS Store 页面体积大,限制同时在飞的请求数 let _psMetaQueue = []; let _psMetaActive = 0; function psMetaEnqueue(task) { return new Promise(resolve => { _psMetaQueue.push({ task, resolve }); psMetaPump(); }); } function psMetaPump() { while (_psMetaActive < _PS_META_MAX_CONCURRENCY && _psMetaQueue.length) { const job = _psMetaQueue.shift(); _psMetaActive++; const release = () => { _psMetaActive--; psMetaPump(); }; Promise.resolve().then(job.task) .then(meta => job.resolve(meta || { name: '', cover: '' }), () => job.resolve({ name: '', cover: '' })) .then(release, release); } } // 读缓存里的封面 URL(同步,供渲染时直接贴 src,避免"先空后闪") function psStoreCoverCacheGet(url) { if (!url) return ''; try { const e = sglvPsStoreMetaCacheLoad()[url]; if (!e || !e.cover || !e.coverTs) return ''; if (Date.now() - e.coverTs > _PS_STORE_COVER_TTL) return ''; return e.cover; } catch (e) { return ''; } } // 抓取 + 缓存:一次请求同时产出中文名与封面主视觉 async function fetchPsStoreMetaNow(psstoreUrl) { const cache = sglvPsStoreMetaCacheLoad(); const entry = cache[psstoreUrl] || {}; const now = Date.now(); const nameFresh = !!(entry.name && entry.nameTs && (now - entry.nameTs) < _PS_STORE_NAME_TTL); const coverFresh = !!(entry.cover && entry.coverTs && (now - entry.coverTs) < _PS_STORE_COVER_TTL); const coverFailed = !!(entry.coverFailTs && (now - entry.coverFailTs) < _PS_STORE_COVER_NEG_TTL); // 缓存全命中 → 不发请求 if ((!Host.isZh || nameFresh) && (coverFresh || coverFailed)) { return { name: entry.name || '', cover: entry.cover || '' }; } const primaryUrl = psStoreLocaleUrl(psstoreUrl, _PS_STORE_PRIMARY_LOCALE); const html = await psStoreRequest(primaryUrl); let name = nameFresh ? (entry.name || '') : parsePsStoreName(html); let cover = coverFresh ? (entry.cover || '') : parsePsStoreCover(html); // 主区域没 SSR 出主视觉(部分地区未发行/限售)→ 换美区再试一次 if (!cover && !coverFresh && !coverFailed) { const altUrl = psStoreLocaleUrl(psstoreUrl, _PS_STORE_ALT_LOCALE); if (altUrl && altUrl !== primaryUrl) { const altHtml = await psStoreRequest(altUrl); if (altHtml) { cover = parsePsStoreCover(altHtml); if (!name && !nameFresh) name = parsePsStoreName(altHtml); } } } const next = Object.assign({}, entry); if (name && name !== entry.name) { next.name = name; next.nameTs = now; } if (cover) { next.cover = cover; next.coverTs = now; delete next.coverFailTs; } else if (!coverFresh) { next.coverFailTs = now; } cache[psstoreUrl] = next; sglvPsStoreMetaCacheSave(); return { name: name || '', cover: cover || '' }; } // 对外入口:同 URL 并发去重 + 并发闸门 function fetchPsStoreMeta(psstoreUrl) { if (!psstoreUrl) return Promise.resolve({ name: '', cover: '' }); if (_psStoreMetaPending.has(psstoreUrl)) return _psStoreMetaPending.get(psstoreUrl); const p = psMetaEnqueue(() => fetchPsStoreMetaNow(psstoreUrl)).then(meta => { _psStoreMetaPending.delete(psstoreUrl); return meta; }, () => { _psStoreMetaPending.delete(psstoreUrl); return { name: '', cover: '' }; }); _psStoreMetaPending.set(psstoreUrl, p); return p; } // v2.6.2 旧接口:只取中文名(中文环境才有意义) function fetchPsStoreName(psstoreUrl) { if (!Host.isZh) return Promise.resolve(''); if (!psstoreUrl) return Promise.resolve(''); const cache = sglvPsStoreMetaCacheLoad(); const cached = cache[psstoreUrl]; if (cached) { if (cached.name && Date.now() - (cached.nameTs || 0) < _PS_STORE_NAME_TTL) return Promise.resolve(cached.name); if (!cached.name && Date.now() - (cached.nameTs || 0) < _PS_STORE_NAME_NEG_TTL) return Promise.resolve(''); } return fetchPsStoreMeta(psstoreUrl).then(meta => meta.name || ''); } // PS 会免占位封面 DOM(蓝色渐变 + 真实 PS 标识 + 名称),供运行时回退统一调用 function psPlaceholderDom(name) { const holder = document.createElement('div'); holder.className = 'sglv-ps-cover-placeholder'; holder.innerHTML = '<div class="sglv-ps-logo">' + PS_LOGO_SVG + '</div><div class="sglv-ps-cover-name">' + Host.wlEscape(name || 'PS Exclusive') + '</div>'; return holder; } // PS 会免封面图全局回退:Steam CDN 全部失败时先用 Store API 取真实封面 URL, // API URL 仍加载失败时替换为蓝色 PS 占位(v1.1.1:修复 API URL 二次失败无兜底导致破图) // v1.9.29: 传递 kind 参数,使 API 兜底按图片类型选择最佳 URL unsafeWindow._sglvPsCoverFallback = function(img, appid, kind) { if (!img || !img.parentElement) return; Host.fetchCoverApiUrl(appid, kind).then(url => { if (url) { img.onerror = function() { img.onerror = null; img.parentElement.replaceChild(psPlaceholderDom(img.alt), img); }; img.src = url; return; } img.parentElement.replaceChild(psPlaceholderDom(img.alt), img); }); }; // PS 会免列表视图图标回退:CDN 图标失败时替换为 PS 蓝色占位 div // (保持 92x43 尺寸,避免旧版 this.style.display='none' 导致整行左对齐错位) unsafeWindow._sglvPsListIconFallback = function(img) { if (!img || !img.parentElement) return; const holder = document.createElement('div'); holder.className = 'sglv-list-icon'; holder.style.cssText = 'display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#003791,#0070D1);color:#fff;border-radius:4px;padding:4px'; holder.innerHTML = Host.ICONS.playstation; img.parentElement.replaceChild(holder, img); }; // PS 会免封面 —— v2.8.0 统一走 unifiedCover。 // 原实现是本项目最严重的封面反模式:每次渲染都用循环拼出 12 层嵌套的 // `this.src='...';this.onerror=function(){...}` 字符串再塞进 inline onerror, // 还叠加 _sgcvCoverRace 竞速 + 跨上下文 _sglvPsCoverFallback 兜底。 // 后果:① HTML 体积暴涨 ② 内联属性里的多层闭包极易被 CSP/扩展拦截 // ③ 跨视图切换撞上一次性锁后永久失效(用户反馈的"大海报视图无封面")。 // v2.9.7: 无 appid 的 PS 独占游戏不再只有占位画——占位画下垫一层真实封面主视觉 // (data-sglv-ps-cover 由 bindPsExclusiveCovers 异步补 src)。 function psplusPosterImg(appid, name, psstore) { if (!appid) return _psExclusivePlaceholder(name, psstore); // v2.9.4: 卡片墙默认竖版海报(竖版优先链,拿不到自动回退横版 header) return unifiedCover(appid, name, 'poster', { preferVertical: true }); } function psplusCapsuleImg(appid, name, psstore) { if (!appid) return _psExclusivePlaceholder(name, psstore); return unifiedCover(appid, name, 'capsule', { imgCls: 'sglv-wl-cover-img' }); } // PS 独占占位画 + 真实封面图层 // 占位画(蓝色 PS Logo + 名称)垫底,封面图绝对定位覆盖其上; // 封面抓不到/加载失败 → 图被移除,占位画原样保留(与 friend-manager 非 Steam 卡同构)。 function _psExclusivePlaceholder(name, psstore) { const cover = psstore ? '<img class="sglv-ps-excl-cover" data-sglv-ps-cover="' + Host.wlEscape(psstore) + '" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">' : ''; return '<div class="sglv-ps-cover-placeholder"><div class="sglv-ps-logo">' + PS_LOGO_SVG + '</div><div class="sglv-ps-cover-name"' + (psstore ? ' data-sglv-psstore="' + Host.wlEscape(psstore) + '"' : '') + '>' + Host.wlEscape(name || 'PS Exclusive') + '</div></div>' + cover; } // PS 独占封面绑定:给占位画上的 img 补真实主视觉 // ① 缓存命中 → 同步贴 src(无闪烁) // ② 未命中 → 异步抓 PS Store 页(与中文名共用同一次请求),拿到后补 src // ③ 加载失败/无图 → 直接移除 img,占位画不受影响 function bindPsExclusiveCovers(root) { if (!root || typeof root.querySelectorAll !== 'function') return; const imgs = root.querySelectorAll('img[data-sglv-ps-cover]'); if (!imgs.length) return; Array.prototype.forEach.call(imgs, img => { const url = img.getAttribute('data-sglv-ps-cover'); if (!url) { img.remove(); return; } if (img.getAttribute('data-sglv-ps-bound') === '1') return; img.setAttribute('data-sglv-ps-bound', '1'); img.addEventListener('load', () => img.classList.add('sglv-ps-excl-cover-loaded'), { once: true }); img.addEventListener('error', () => img.remove(), { once: true }); const cached = psStoreCoverCacheGet(url); if (cached) { img.src = cached; return; } fetchPsStoreMeta(url).then(meta => { if (!meta || !meta.cover) { img.remove(); return; } if (!img.isConnected) return; img.src = meta.cover; }); }); } function psTierLabel(tier) { if (tier === 1) return Host.T.psTierEssential; if (tier === 2) return Host.T.psTierExtra; if (tier === 3) return Host.T.psTierPremium; return '?'; } function psTierClass(tier) { if (tier === 1) return 'tier-essential'; if (tier === 2) return 'tier-extra'; if (tier === 3) return 'tier-premium'; return ''; } // 状态颜色映射 function psStatusColor(statusName) { if (statusName === '在库') return '#4caf50'; if (statusName === '领取中') return '#2196f3'; if (statusName === '截止领取' || statusName === '已出库') return '#ff9800'; if (statusName === '即将出库') return '#ff5722'; return '#888'; } // 获取游戏显示名称——v2.6.2 优先用 psstore 提取的英文名作为初始显示,中文名异步替换 // 有 steamAppId:先用 psExtractName 提取英文名(如果有 psstore),再异步 fetchGameZhName 替换为中文名 // 无 steamAppId (PS 独占):用 psExtractName 提取英文名,再异步 fetchPsStoreName 抓页面拿中文名 function psGameName(g) { const fromPs = psExtractName(g.psstore); if (fromPs) return fromPs; if (g.steamAppId) return Host.isZh ? `App ${g.steamAppId}` : `App ${g.steamAppId}`; return Host.isZh ? 'PS 独占' : 'PS Exclusive'; } // 数据加载(@resource 兜底 + 远程 JSON 异步更新 + 永久缓存) // v1.9.5: 缓存永不过期——优先读永久缓存秒开,后台静默全量刷新(6h 节流) // v2.7.1: @require 库中 GM_xmlhttpRequest 可能因 Tampermonkey 沙箱隔离而静默挂起, // 改为优先用 @resource(GM_getResourceText)同步兜底,后台异步更新缓存。 const _PSPLUS_BG_INTERVAL = 6 * 3600 * 1000; // v1.9.5: 后台刷新 6h 节流 let _psplusLastBgRefreshAt = 0; // v1.9.24: 简单字符串哈希(djb2 变体),用于检测远程数据是否变化 function _psplusDataHash(str) { let hash = 5381; const len = str.length; // 采样哈希:前500字符 + 后500字符 + 长度,避免对超大 JSON 全量遍历 const sample = str.slice(0, 500) + str.slice(Math.max(0, len - 500)) + len; for (let i = 0; i < sample.length; i++) { hash = ((hash << 5) + hash) + sample.charCodeAt(i); hash = hash & hash; } return String(hash); } async function loadPsplusGames() { // v1.9.38: 移除 force 刷新路径(刷新按钮已移除),保留缓存/资源秒开 + 后台静默刷新 if (psplusState.loading) return; // v1.9.5: 优先读永久缓存(永不过期) const cached = _permCacheGet('psplusGames'); if (cached && Array.isArray(cached.games) && cached.games.length > 0) { psplusState.games = cached.games; psplusState.updatedAt = cached.updatedAt || ''; psplusState.loaded = true; renderPsplusSections(); // 后台静默刷新(6h 节流) if (_shouldThrottle(_psplusLastBgRefreshAt, _PSPLUS_BG_INTERVAL)) return; _refreshPsplusFromRemote(false); return; } // 2) 永久缓存无 → 用 @resource 同步兜底(GM_getResourceText,零网络依赖) let resourceData = null; try { const raw = Host.getResourceText ? Host.getResourceText('psplusData') : null; if (raw) { const parsed = JSON.parse(raw); if (parsed && Array.isArray(parsed.games) && parsed.games.length > 0) { resourceData = parsed; } } } catch (e) { /* resource 解析失败,忽略 */ } if (resourceData) { psplusState.games = resourceData.games; psplusState.updatedAt = resourceData.updatedAt || ''; psplusState.loaded = true; psplusState.loading = false; // v1.9.26: 写入永久缓存(含 dataHash) _permCacheSet('psplusGames', { games: resourceData.games, updatedAt: resourceData.updatedAt, dataHash: _psplusDataHash(JSON.stringify(resourceData)) }); renderPsplusSections(); // 后台异步更新(获取最新全量快照) _refreshPsplusFromRemote(false); return; } // 3) @resource 也不可用 → 回退到 GM_xmlhttpRequest 异步加载 _refreshPsplusFromRemote(true); } // 内部:后台异步从远程 JSON 更新 PS会免数据 // v1.9.5: 远程数据到达后全量替换(上游为每日全量快照) // v1.9.26: 增加 _psplusDataHash 哈希对比——远程数据未变化则跳过更新/缓存写入 // v1.9.38: 移除 force 刷新(刷新按钮/spinner/状态文本已删除) // showLoading=true 时显示 loading 状态(用于无缓存的首次加载) function _refreshPsplusFromRemote(showLoading) { if (typeof GM_xmlhttpRequest !== 'function') return; // 沙箱中不可用则跳过 // v1.9.38: 无手动刷新入口,loading 中直接返回避免并发请求 if (psplusState.loading) return; // v1.9.26: 获取缓存中的数据哈希,用于远程数据变化检测 const cached = _permCacheGet('psplusGames'); const cachedHash = (cached && cached.dataHash) || ''; if (showLoading) { psplusState.loading = true; psplusState.loadError = null; renderPsplusGamesList(); } // v1.9.27: loading 安全超时——30s 后强制清除 loading(防止浏览器扩展阻断回调导致永久卡死) let _psplusLoadingTimer = null; if (showLoading) { _psplusLoadingTimer = setTimeout(() => { if (psplusState.loading) { psplusState.loading = false; // 尝试从永久缓存恢复数据 const cacheFallback = _permCacheGet('psplusGames'); if (cacheFallback && Array.isArray(cacheFallback.games) && cacheFallback.games.length > 0) { psplusState.games = cacheFallback.games; psplusState.updatedAt = cacheFallback.updatedAt || ''; psplusState.loaded = true; psplusState.loadError = null; } else { psplusState.loadError = 'Timeout (loading safety)'; } renderPsplusGamesList(); } }, 30000); } // v1.9.27: 统一清理 loading 超时定时器 const _clearLoadingTimer = () => { if (_psplusLoadingTimer) { clearTimeout(_psplusLoadingTimer); _psplusLoadingTimer = null; } }; // v1.9.27: 错误时尝试从永久缓存恢复数据(避免刷新失败导致数据全部丢失) const _restoreCacheOnError = () => { if (psplusState.games.length === 0) { const cacheFallback = _permCacheGet('psplusGames'); if (cacheFallback && Array.isArray(cacheFallback.games) && cacheFallback.games.length > 0) { psplusState.games = cacheFallback.games; psplusState.updatedAt = cacheFallback.updatedAt || ''; psplusState.loaded = true; psplusState.loadError = null; return true; } } return false; }; // v1.9.37: 主源 + 降级数据源多级回退——主源(oXnMe)失败时依次尝试 SmallRob/steam-namespace 镜像 const _psplusAllUrls = [PSPLUS_DATA_URL].concat(PSPLUS_BACKUP_URLS); function _tryNextUrl(idx) { if (idx >= _psplusAllUrls.length) { // 所有源均失败 → 尝试从缓存恢复数据 _clearLoadingTimer(); if (showLoading) { psplusState.loading = false; if (_restoreCacheOnError()) { renderPsplusSections(); } else { psplusState.loadError = 'All sources failed'; renderPsplusGamesList(); } } return; } GM_xmlhttpRequest({ method: 'GET', url: _psplusAllUrls[idx], timeout: 20000, onload(r) { try { // v1.9.27: 使用 _safeRespText 防止浏览器扩展 hook XHR 导致 responseText 抛 InvalidStateError const rawText = _safeRespText(r); // v1.9.37: 非 2xx 响应 → 尝试下一个源 if (r.status < 200 || r.status >= 300 || !rawText) { _tryNextUrl(idx + 1); return; } // v1.9.26: 计算远程数据哈希,与缓存哈希对比 const remoteHash = _psplusDataHash(rawText); // v1.9.26: 哈希对比——远程数据未变化则跳过更新,保留现有缓存和状态 if (cachedHash && remoteHash === cachedHash) { _clearLoadingTimer(); psplusState.loading = false; _psplusLastBgRefreshAt = Date.now(); return; } const parsed = JSON.parse(rawText); if (parsed && Array.isArray(parsed.games) && parsed.games.length > 0) { _clearLoadingTimer(); // v1.9.38: 上游为全量快照,直接整体替换 state(修复 _mergeByAppid 按 appid // 合并、但 PS会免条目无 appid 字段导致非空状态合并后数据被清空的根因) psplusState.games = parsed.games; psplusState.updatedAt = parsed.updatedAt || ''; psplusState.loaded = true; psplusState.loading = false; _psplusLastBgRefreshAt = Date.now(); // v1.9.26: 写入永久缓存(含 dataHash) _permCacheSet('psplusGames', { games: psplusState.games, updatedAt: psplusState.updatedAt, dataHash: remoteHash }); renderPsplusSections(); return; } // 格式无效 → 尝试下一个源 _tryNextUrl(idx + 1); } catch (e) { // 解析异常 → 尝试下一个源 _tryNextUrl(idx + 1); } }, onerror() { _tryNextUrl(idx + 1); }, ontimeout() { _tryNextUrl(idx + 1); }, }); } _tryNextUrl(0); } // 筛选 + 排序 function getFilteredPsplus() { const q = psplusState.search.toLowerCase().trim(); const ownedIds = Host.getActiveOwnedAppIds(); const isPsOwned = g => g.steamAppId && (ownedIds.has(Number(g.steamAppId)) || ownedIds.has(String(g.steamAppId))); let games = psplusState.games.filter(g => { if (q) { const name = psExtractName(g.psstore).toLowerCase(); const appid = String(g.steamAppId || ''); if (!name.includes(q) && !appid.includes(q) && !(g.month || '').includes(q)) return false; } if (psplusState.tierFilter !== 'all') { // v1.6.0: catalog 档位匹配 tier 2(升级) + 3(高级) if (psplusState.tierFilter === 'catalog') { if (g.tier !== 2 && g.tier !== 3) return false; } else if (String(g.tier) !== psplusState.tierFilter) return false; } if (psplusState.statusFilter !== 'all') { if (psplusState.statusFilter === 'expired') { if (g.statusName !== '截止领取' && g.statusName !== '已出库') return false; } else if (psplusState.statusFilter === 'steamOwned') { // v2.7.9: 直接筛选 Steam 家庭共享库已有的匹配游戏 if (!isPsOwned(g)) return false; } else if (g.statusName !== psplusState.statusFilter) return false; } return true; }); const tierOrder = { 1: 1, 2: 2, 3: 3 }; games.sort((a, b) => { switch (psplusState.sort) { case 'dateAsc': return (a.monthSort || '').localeCompare(b.monthSort || ''); case 'tierAsc': return (tierOrder[a.tier] || 9) - (tierOrder[b.tier] || 9) || (b.monthSort || '').localeCompare(a.monthSort || ''); case 'tierDesc': return (tierOrder[b.tier] || 9) - (tierOrder[a.tier] || 9) || (b.monthSort || '').localeCompare(a.monthSort || ''); case 'nameAsc': return _nameCollator.compare(psExtractName(a.psstore) || '~', psExtractName(b.psstore) || '~'); case 'dateDesc': default: return (b.monthSort || '').localeCompare(a.monthSort || ''); } }); return games; } // ----- 渲染 ----- function renderPsplusTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-psplus-wrap'; // KPI 区域 const kpi = document.createElement('div'); kpi.id = 'sglv-psplus-kpi'; wrap.appendChild(kpi); // 工具栏(v2.6.1:紧凑两行——第1行 搜索+排序+视图;第2行 筛选按钮+分页) const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar'; const sortOptions = [ { key: 'dateDesc', label: Host.T.psSortDateDesc }, { key: 'dateAsc', label: Host.T.psSortDateAsc }, { key: 'tierAsc', label: Host.T.psSortTierAsc }, { key: 'tierDesc', label: Host.T.psSortTierDesc }, { key: 'nameAsc', label: Host.T.psSortNameAsc }, ]; toolbar.innerHTML = ` <input class="sglv-search" aria-label="搜索" id="sglv-psplus-search" placeholder="${Host.T.psSearch}" value="${Host.wlEscape(psplusState.search)}"> <select class="sglv-sort-select" aria-label="排序方式" id="sglv-psplus-sort"> ${sortOptions.map(o => `<option value="${o.key}"${psplusState.sort === o.key ? ' selected' : ''}>${o.label}</option>`).join('')} </select> <div class="sglv-view-toggle"> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${psplusState.viewMode === 'card' ? ' active' : ''}" id="sglv-ps-card" title="${Host.T.cardView}">${Host.ICONS.grid}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${psplusState.viewMode === 'cover' ? ' active' : ''}" id="sglv-ps-cover" title="${Host.T.coverView}">${Host.ICONS.cover}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${psplusState.viewMode === 'list' ? ' active' : ''}" id="sglv-ps-list" title="${Host.T.listView}">${Host.ICONS.list}</button> </div> <span class="sglv-ps-status" id="sglv-ps-status"></span> `; wrap.appendChild(toolbar); // 筛选栏(档位 + 状态)——分页也放这一行(v2.6.1 紧凑操作栏) const filterBar = document.createElement('div'); filterBar.className = 'sglv-filter-bar'; const tierBtns = [ { key: 'all', label: Host.T.all }, { key: '1', label: Host.T.psTierEssential }, { key: '2', label: Host.T.psTierExtra }, { key: '3', label: Host.T.psTierPremium }, ]; const statusBtns = [ { key: 'all', label: Host.T.psStatusAll }, { key: '在库', label: Host.T.psStatusIn }, { key: '领取中', label: Host.T.psStatusActive }, { key: 'expired', label: Host.T.psStatusExpired }, { key: '即将出库', label: Host.T.psStatusLeaving }, { key: 'steamOwned', label: Host.T.steamInLib }, ]; filterBar.innerHTML = ` <div class="sglv-filter-bar-group"> ${tierBtns.map(b => `<button class="sglv-btn sglv-btn-secondary sglv-ps-tier-btn${psplusState.tierFilter === b.key ? ' active' : ''}" data-tier="${b.key}">${b.label}</button>`).join('')} </div> <span class="sglv-filter-sep"></span> <div class="sglv-filter-bar-group"> ${statusBtns.map(b => `<button class="sglv-btn sglv-btn-secondary sglv-ps-status-btn${psplusState.statusFilter === b.key ? ' active' : ''}" data-status="${b.key}">${b.label}</button>`).join('')} </div> <div class="sglv-pagination sglv-pagination-inline" id="sglv-psplus-pagination"></div> `; wrap.appendChild(filterBar); // 游戏列表容器 const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-psplus-content'; wrap.appendChild(content); parent.appendChild(wrap); // 事件绑定 toolbar.querySelector('#sglv-psplus-search').addEventListener('input', _debounce((e) => { psplusState.search = e.target.value; psplusState.page = 1; renderPsplusGamesList(); }, 200)); toolbar.querySelector('#sglv-psplus-sort').addEventListener('change', (e) => { psplusState.sort = e.target.value; psplusState.page = 1; renderPsplusGamesList(); }); const setPsViewMode = (mode) => { psplusState.viewMode = mode; ['card', 'cover', 'list'].forEach(m => { toolbar.querySelector('#sglv-ps-' + m)?.classList.toggle('active', m === mode); }); renderPsplusGamesList(); }; toolbar.querySelector('#sglv-ps-card').addEventListener('click', () => setPsViewMode('card')); toolbar.querySelector('#sglv-ps-cover').addEventListener('click', () => setPsViewMode('cover')); toolbar.querySelector('#sglv-ps-list').addEventListener('click', () => setPsViewMode('list')); filterBar.querySelectorAll('.sglv-ps-tier-btn').forEach(btn => { btn.addEventListener('click', () => { psplusState.tierFilter = btn.dataset.tier; psplusState.page = 1; filterBar.querySelectorAll('.sglv-ps-tier-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderPsplusKpi(); // v1.6.0: 同步 KPI active 高亮 renderPsplusGamesList(); }); }); filterBar.querySelectorAll('.sglv-ps-status-btn').forEach(btn => { btn.addEventListener('click', () => { psplusState.statusFilter = btn.dataset.status; psplusState.page = 1; filterBar.querySelectorAll('.sglv-ps-status-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderPsplusKpi(); // v1.6.0: 同步 KPI active 高亮 renderPsplusGamesList(); }); }); // v1.6.0: 筛选改为点击 KPI 卡片切换(再次点击同一卡片回到「全部」),与年度大作/Epic赠送标签页一致 kpi.addEventListener('click', e => { const card = e.target.closest('[data-psplus-filter]'); if (!card) return; const f = card.dataset.psplusFilter; const activeKpi = getPsplusActiveKpi(); if (activeKpi === f) { // 再次点击同一卡片 → 回到「全部」 psplusState.tierFilter = 'all'; psplusState.statusFilter = 'all'; } else { switch (f) { case 'all': psplusState.tierFilter = 'all'; psplusState.statusFilter = 'all'; break; case 'essential': psplusState.tierFilter = '1'; psplusState.statusFilter = 'all'; break; case 'catalog': psplusState.tierFilter = 'catalog'; psplusState.statusFilter = 'all'; break; case 'steamOwned': psplusState.tierFilter = 'all'; psplusState.statusFilter = 'steamOwned'; break; } } psplusState.page = 1; syncPsplusFilterBtns(filterBar); // 同步筛选栏按钮 active 状态 renderPsplusKpi(); // 刷新 KPI active 高亮 renderPsplusGamesList(); }); if (!psplusState.loaded && !psplusState.loading) { loadPsplusGames(); } else { renderPsplusSections(); } } function renderPsplusSections() { if (_collectionActiveTab !== 'psplus' || !document.getElementById('sglv-psplus-content')) return; renderPsplusKpi(); renderPsplusGamesList(); renderPsplusRefreshStatus(); } /** v1.9.38: 渲染 PS会免工具栏状态文本(仅显示更新时间,手动刷新已移除) */ function renderPsplusRefreshStatus() { const el = document.getElementById('sglv-ps-status'); if (!el) return; const zh = Host.isZh; const date = psplusState.updatedAt ? psplusState.updatedAt.slice(0, 10) : ''; el.textContent = date ? (zh ? '更新: ' : 'Updated: ') + date : ''; el.className = 'sglv-ps-status'; } // v1.6.0: 根据 tierFilter + statusFilter 推断当前激活的 KPI 卡片 function getPsplusActiveKpi() { if (psplusState.tierFilter === 'all' && psplusState.statusFilter === 'all') return 'all'; if (psplusState.tierFilter === '1' && psplusState.statusFilter === 'all') return 'essential'; if (psplusState.tierFilter === 'catalog' && psplusState.statusFilter === 'all') return 'catalog'; if (psplusState.tierFilter === 'all' && psplusState.statusFilter === 'steamOwned') return 'steamOwned'; return ''; // 自定义组合,无 KPI 高亮 } // v1.6.0: KPI 点击后同步筛选栏按钮 active 状态 function syncPsplusFilterBtns(filterBar) { filterBar.querySelectorAll('.sglv-ps-tier-btn').forEach(b => { b.classList.toggle('active', b.dataset.tier === psplusState.tierFilter); }); filterBar.querySelectorAll('.sglv-ps-status-btn').forEach(b => { b.classList.toggle('active', b.dataset.status === psplusState.statusFilter); }); } function renderPsplusKpi() { const el = document.getElementById('sglv-psplus-kpi'); if (!el) return; if (!psplusState.loaded) { el.innerHTML = ''; return; } const games = psplusState.games; const total = games.length; const essential = games.filter(g => g.tier === 1).length; const catalog = games.filter(g => g.tier === 2 || g.tier === 3).length; const steamLinked = games.filter(g => g.steamAppId).length; const ownedIds = Host.getActiveOwnedAppIds(); const steamOwned = games.filter(g => g.steamAppId && (ownedIds.has(Number(g.steamAppId)) || ownedIds.has(String(g.steamAppId)))).length; const updatedStr = psplusState.updatedAt ? (Host.isZh ? '更新于 ' : 'Updated ') + psplusState.updatedAt.slice(0, 10) : ''; const activeKpi = getPsplusActiveKpi(); const tipAll = Host.isZh ? '点击筛选:全部' : 'Click to filter: All'; const tipEssential = Host.isZh ? '点击筛选:必备档' : 'Click to filter: Essential'; const tipCatalog = Host.isZh ? '点击筛选:升级/高级档' : 'Click to filter: Extra/Premium'; const tipSteam = Host.isZh ? '点击筛选:Steam 已拥有' : 'Click to filter: Owned on Steam'; el.innerHTML = ` <div class="sglv-trend-kpi-row"> <div class="sglv-trend-kpi-card blue clickable${activeKpi === 'all' ? ' active' : ''}" data-psplus-filter="all" title="${tipAll}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon">${Host.ICONS.playstation}</span><span class="sglv-trend-kpi-label">${Host.T.psKpiTotal}</span></div> <div class="sglv-trend-kpi-value">${total.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${updatedStr}</div> </div> <div class="sglv-trend-kpi-card clickable${activeKpi === 'essential' ? ' active' : ''}" style="border-left:3px solid #FFCD3C" data-psplus-filter="essential" title="${tipEssential}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:#FFCD3C">${Host.ICONS.gift}</span><span class="sglv-trend-kpi-label">${Host.T.psKpiMonthly}</span></div> <div class="sglv-trend-kpi-value">${essential.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${Host.T.psTierEssential}</div> </div> <div class="sglv-trend-kpi-card clickable${activeKpi === 'catalog' ? ' active' : ''}" style="border-left:3px solid #2196f3" data-psplus-filter="catalog" title="${tipCatalog}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:#2196f3">${Host.ICONS.library}</span><span class="sglv-trend-kpi-label">${Host.T.psKpiCatalog}</span></div> <div class="sglv-trend-kpi-value">${catalog.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${Host.T.psTierExtra} / ${Host.T.psTierPremium}</div> </div> <div class="sglv-trend-kpi-card mine clickable${activeKpi === 'steamOwned' ? ' active' : ''}" data-psplus-filter="steamOwned" title="${tipSteam}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon">${Host.ICONS.heart}</span><span class="sglv-trend-kpi-label">${Host.T.psKpiSteamOwned}</span></div> <div class="sglv-trend-kpi-value">${steamOwned.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${Host.T.psKpiSteamTotal.replace('{n}', steamLinked)}</div> </div> </div> `; } function renderPsplusGamesList() { const content = document.getElementById('sglv-psplus-content'); if (!content) return; const clearPagination = () => { const p = document.getElementById('sglv-psplus-pagination'); if (p) p.innerHTML = ''; }; if (psplusState.loading) { content.innerHTML = '<div class="sglv-progress" style="margin:16px 0"><div class="sglv-progress-fill" style="width:45%"></div></div><div class="sglv-empty">' + Host.T.psLoading + '</div>'; clearPagination(); return; } if (psplusState.loadError) { content.innerHTML = '<div class="sglv-empty">' + Host.T.psLoadError + '</div>'; clearPagination(); return; } if (!psplusState.loaded || psplusState.games.length === 0) { content.innerHTML = '<div class="sglv-empty">' + Host.T.psLoading + '</div>'; clearPagination(); return; } const games = getFilteredPsplus(); if (games.length === 0) { content.innerHTML = '<div class="sglv-empty">' + (Host.isZh ? '无匹配结果' : 'No matching results') + '</div>'; clearPagination(); return; } const { pageItems, totalPages, page: _clampedPage } = Host.paginate(games, psplusState.page, psplusState.pageSize); psplusState.page = _clampedPage; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = g => g.steamAppId && (ownedIds.has(Number(g.steamAppId)) || ownedIds.has(String(g.steamAppId))); const tierBadgeHtml = g => '<span class="sglv-ps-tier-badge ' + psTierClass(g.tier) + '">' + psTierLabel(g.tier) + '</span>'; const statusBadgeHtml = g => '<span class="sglv-card-badge" style="background:' + psStatusColor(g.statusName) + ';color:#fff;font-size:10px;margin-top:0;padding:2px 6px;border-radius:3px">' + Host.wlEscape(g.statusDisplay || g.statusName) + '</span>'; const versionsHtml = g => (g.versions || []).map(v => '<span style="font-size:10px;padding:1px 4px;border-radius:2px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2)">' + v + '</span>').join(' '); const ownershipBadgeHtml = g => isOwned(g) ? '<span class="sglv-card-badge owned" style="margin-top:0">✓ ' + Host.T.steamInLib + '</span>' : ''; // 卡片视图 if (psplusState.viewMode === 'card') { content.innerHTML = '<div class="sglv-card-grid sglv-card-grid-7">' + pageItems.map(g => { const appid = Number(g.steamAppId) || 0; const name = psGameName(g); const clickUrl = appid ? 'https://store.steampowered.com/app/' + appid : (g.psstore || '#'); // v2.6.2: 名称 span 加 data-sglv-psstore 以便异步替换 PS 独占中文名 const nameSpan = appid ? '<span data-sglv-appid="' + appid + '" data-sglv-psstore="' + Host.wlEscape(g.psstore || '') + '">' + Host.wlEscape(name) + '</span><span class="sglv-card-id-inline">' + appid + '</span>' : '<span data-sglv-psstore="' + Host.wlEscape(g.psstore || '') + '">' + Host.wlEscape(name) + '</span>'; return '<div class="sglv-game-card sglv-poster-card" data-appid="' + (appid || g.recordId) + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + '<div class="sglv-card-img-wrap">' + tierBadgeHtml(g) + psplusPosterImg(appid, name, g.psstore) + '</div>' + '<div class="sglv-card-body">' + '<div class="sglv-card-name" title="' + Host.wlEscape(name) + '">' + nameSpan + '</div>' + '<div class="sglv-info-row sglv-wl-price-row">' + statusBadgeHtml(g) + ownershipBadgeHtml(g) + '</div>' + '<div class="sglv-info-row sglv-ps-meta-row"><span class="sglv-meta-item sglv-ps-month-item"><span class="sglv-ps-clock">' + Host.ICONS.clock + '</span><span>' + Host.wlEscape(g.month || '') + '</span></span><span class="sglv-ps-versions">' + versionsHtml(g) + '</span></div>' + '</div>' + '</div>'; }).join('') + '</div>'; } else if (psplusState.viewMode === 'cover') { // 封面视图(横板大图) // v2.9.22: 信息行紧凑到 3 行:标题 / [状态+在库] / [月份+版本] content.innerHTML = '<div class="sglv-wl-cover-grid">' + pageItems.map(g => { const appid = Number(g.steamAppId) || 0; const name = psGameName(g); const clickUrl = appid ? 'https://store.steampowered.com/app/' + appid : (g.psstore || '#'); const nameSpan = appid ? '<span data-sglv-appid="' + appid + '" data-sglv-psstore="' + Host.wlEscape(g.psstore || '') + '">' + Host.wlEscape(name) + '</span><span class="sglv-card-id-inline">' + appid + '</span>' : '<span data-sglv-psstore="' + Host.wlEscape(g.psstore || '') + '">' + Host.wlEscape(name) + '</span>'; return '<div class="sglv-wl-cover-card" data-appid="' + (appid || g.recordId) + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + '<div class="sglv-wl-cover-img-wrap">' + tierBadgeHtml(g) + (appid ? unifiedCover(appid, name, 'capsule') : psplusCapsuleImg(0, name, g.psstore)) + '</div>' + '<div class="sglv-wl-cover-body">' + '<div class="sglv-card-name" title="' + Host.wlEscape(name) + '">' + nameSpan + '</div>' + '<div class="sglv-info-row sglv-wl-price-row sglv-ps-status-row">' + statusBadgeHtml(g) + ownershipBadgeHtml(g) + '</div>' + '<div class="sglv-info-row sglv-ps-meta-row"><span class="sglv-meta-item sglv-ps-month-item"><span class="sglv-ps-clock">' + Host.ICONS.clock + '</span><span>' + Host.wlEscape(g.month || '') + '</span></span><span class="sglv-ps-versions">' + versionsHtml(g) + '</span></div>' + '</div>' + '</div>'; }).join('') + '</div>'; } else { // 列表视图 content.innerHTML = '<div class="sglv-list-view">' + pageItems.map(g => { const appid = Number(g.steamAppId) || 0; const name = psGameName(g); const clickUrl = appid ? 'https://store.steampowered.com/app/' + appid : (g.psstore || '#'); const iconHtml = appid ? '<img class="sglv-list-icon" src="' + Host.getGameIconUrl(appid, '') + '" alt="' + Host.wlEscape(name) + '" loading="lazy" decoding="async" onerror="_sglvPsListIconFallback(this)">' // v2.9.7: PS 独占——PS 图标垫底 + 真实封面图覆盖其上(失败自动移除图) : '<div class="sglv-list-icon sglv-ps-excl-iconwrap" style="display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#003791,#0070D1);color:#fff;border-radius:4px;padding:4px">' + Host.ICONS.playstation + (g.psstore ? '<img class="sglv-ps-excl-cover" data-sglv-ps-cover="' + Host.wlEscape(g.psstore) + '" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">' : '') + '</div>'; const nameSpan = appid ? '<span data-sglv-appid="' + appid + '" data-sglv-psstore="' + Host.wlEscape(g.psstore || '') + '">' + Host.wlEscape(name) + '</span><span class="sglv-card-id-inline">' + appid + '</span>' : '<span data-sglv-psstore="' + Host.wlEscape(g.psstore || '') + '">' + Host.wlEscape(name) + '</span>'; return '<div class="sglv-list-item" data-appid="' + (appid || g.recordId) + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + iconHtml + '<div class="sglv-list-info">' + '<div class="sglv-list-name">' + nameSpan + '</div>' + '<div class="sglv-list-meta">' + tierBadgeHtml(g) + statusBadgeHtml(g) + ownershipBadgeHtml(g) + '<span style="font-size:11px;color:var(--sglv-text-secondary)">' + Host.wlEscape(g.month || '') + '</span><span style="display:flex;gap:2px">' + versionsHtml(g) + '</span></div>' + '</div>' + '</div>'; }).join('') + '</div>'; } // v1.9.47-fix: 补 onJump——PS会免翻页 5 个按钮全部走 onJump(参见主脚本 renderPagination) const pagination = document.getElementById('sglv-psplus-pagination'); if (pagination) { Host.renderPagination(pagination, 'sglv-psplus', psplusState.page, totalPages, games.length, () => { psplusState.page--; renderPsplusGamesList(); }, () => { psplusState.page++; renderPsplusGamesList(); }, (p) => { psplusState.page = p; renderPsplusGamesList(); }); } // v2.6.2: 异步加载中文名——同时支持 steam appid 路径与 PS 独占 psstore 路径 content.querySelectorAll('[data-sglv-appid]').forEach(el => { Host.loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); // v2.6.2: PS 独占(无 steamAppId)——异步抓 psstore 页面替换为中文名 // v2.9.7: 改走 fetchPsStoreMeta —— 与封面主视觉共用同一次请求(不再二次抓页) content.querySelectorAll('[data-sglv-psstore]').forEach(el => { const psUrl = el.getAttribute('data-sglv-psstore'); if (!psUrl) return; // 已经有 appid 的由 loadGameZhName 处理,这里跳过 if (el.hasAttribute('data-sglv-appid')) return; const original = el.textContent; fetchPsStoreMeta(psUrl).then(meta => { // 英文环境沿用既有命名(psExtractName 的 slug 标题化),不做替换 if (!Host.isZh) return; const zhName = meta && meta.name; if (zhName && zhName !== original) { el.textContent = zhName; el.title = `${zhName} (${original})`; } }); }); // v2.8.0: 统一封面链绑定(addEventListener,无跨上下文 inline onerror) bindRestrictedCoverLoaders(content); // v2.9.7: PS 独占(非 Steam 游戏)真实封面绑定——占位画上叠商店主视觉 bindPsExclusiveCovers(content); } // ==================== Epic 赠送标签页 (v1.1.0) ==================== // 数据来源:GitHub 自建数据源 epic_freebies.json(epic-freebies-weekly-sync.js 周同步生产,jsdelivr 三级回退)。 // v1.9.19: 已移除 keylol 抓取兜底(帖子需登录阅读权限,无法获取)。 // 与 "E宝的爱标识助手" 脚本共享 GM 存储 key 'epic_freebie_ids',可互相补充。 // 数据仅为 Steam appid 集合(无名称/日期/档位),名称经 Steam 中文名异步加载。 // 多级兜底:① IDB 永久缓存 → ② GM 存储 epic_freebie_ids + 内置名单 → ③ 远程抓取(GitHub 数据源三级回退)→ ④ 手动 JSON 导入。 // 拥有判定:getActiveOwnedAppIds()(尊重家庭组开关),匹配当前 Steam 用户家庭共享游戏库。 // v1.9.17: GitHub 自建数据源三级回退链(参照年度大作 GOTY_DATA_SOURCES 写法)—— // 优先 jsdelivr(国内稳定),raw.githubusercontent 兜底;v1.9.19 起全部失败即视为刷新失败。 const EPIC_GITHUB_URLS = [ 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/epic_freebies.json', // ① fastly 节点(优先) 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/epic_freebies.json', // ② jsdelivr 主节点 'https://raw.githubusercontent.com/SmallRob/steam-namespace/main/data/epic_freebies.json' // ③ raw 兜底 ]; const EPIC_GM_KEY = 'epic_freebie_ids'; // 与 E宝的爱标识助手 共享 // 内置兜底名单(取自 E宝的爱标识助手 MANUAL_FREEBIE_IDS) const EPIC_MANUAL_IDS = ['690640','1564220','822094','750920','391220','203160','362960','346940','234650','300550','49520','261640']; // v2.9.22: Epic Games 盾形标识(深色盾形 + 白色 EPIC/GAMES 文字,参考 Epic 官方 logo) const EPIC_LOGO_SVG = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M8 18 L8 52 C8 70 26 86 50 94 C74 86 92 70 92 52 L92 18 Z" fill="#1a1a1a" stroke="#3a3a3a" stroke-width="1.2"/><text x="50" y="47" text-anchor="middle" font-family="Arial Black, Helvetica, sans-serif" font-size="20" font-weight="900" fill="#fff" letter-spacing="0.5">EPIC</text><text x="50" y="65" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700" fill="#fff" letter-spacing="1.2">GAMES</text></svg>'; // 名称缓存(会话级,供搜索/排序使用;中文复用宿主持久化缓存;英文复用宿主 fetchGameEnName // (filters=basic + 会话缓存,与橱窗别名/SGIS 别名路径共享,v2.45.1 收编 _epicFetchEnName) const _epicNameCache = new Map(); const _epicNamePending = new Map(); function epicLoadName(appid) { const id = Number(appid); if (!id) return Promise.resolve(''); if (_epicNameCache.has(id)) return Promise.resolve(_epicNameCache.get(id)); if (_epicNamePending.has(id)) return _epicNamePending.get(id); // 默认中文名:中文环境优先复用宿主持久化中文名缓存(含 30 天 TTL + 负缓存); // 中文名获取失败(游戏无中文名 / 请求失败)→ 回退英文名,确保至少展示一种语言名称。 const p = (async () => { if (Host.isZh && typeof Host.fetchGameZhName === 'function') { const zh = await Host.fetchGameZhName(id); if (zh) { _epicNameCache.set(id, zh); _epicNamePending.delete(id); return zh; } } // 英文名走宿主桥(老宿主无此导出时降级为空名 → 显示 'App xxx' 占位) const en = (Host && typeof Host.fetchGameEnName === 'function') ? await Host.fetchGameEnName(id) : ''; _epicNameCache.set(id, en || ''); _epicNamePending.delete(id); return en || ''; })(); _epicNamePending.set(id, p); return p; } function epicDisplayName(appid) { const cached = _epicNameCache.get(Number(appid)); if (cached) return cached; return 'App ' + appid; } function epicSourceLabel(source) { if (source === 'github') return Host.T.epicSourceGithub || (Host.isZh ? 'GitHub 数据源' : 'GitHub'); if (source === 'keylol') return Host.T.epicSourceKeylol; if (source === 'local') return Host.T.epicSourceLocal; if (source === 'import') return Host.T.epicSourceImport; return Host.T.epicSourceManual; } // Epic 占位封面(HTML 字符串,用于无 appid 时初始渲染)——深色盾形 + EPIC 标识 + 名称 function epicPlaceholder(name) { return '<div class="sglv-ps-cover-placeholder sglv-epic-cover-placeholder" style="background:linear-gradient(135deg,#0d0d0d,#1f1f1f)"><div class="sglv-epic-logo">' + EPIC_LOGO_SVG + '</div><div class="sglv-ps-cover-name">' + Host.wlEscape(name || 'Epic') + '</div></div>'; } // Epic 占位封面 DOM(深色渐变 + 真实 Epic 盾形标识 + 名称),供运行时回退统一调用 function epicPlaceholderDom(name) { const holder = document.createElement('div'); holder.className = 'sglv-ps-cover-placeholder sglv-epic-cover-placeholder'; holder.style.background = 'linear-gradient(135deg,#0d0d0d,#1f1f1f)'; holder.innerHTML = '<div class="sglv-epic-logo">' + EPIC_LOGO_SVG + '</div><div class="sglv-ps-cover-name">' + Host.wlEscape(name || 'Epic') + '</div>'; return holder; } // Epic 封面全局回退:Steam CDN 全部失败时先用 Store API 取真实封面 URL, // API URL 仍加载失败时替换为深色 Epic 占位(v1.1.1:修复 API URL 二次失败无兜底导致破图) // v1.9.29: 传递 kind 参数,使 API 兜底按图片类型选择最佳 URL unsafeWindow._sglvEpicCoverFallback = function(img, appid, kind) { if (!img || !img.parentElement) return; Host.fetchCoverApiUrl(appid, kind).then(url => { if (url) { img.onerror = function() { img.onerror = null; img.parentElement.replaceChild(epicPlaceholderDom(img.alt), img); }; img.src = url; return; } img.parentElement.replaceChild(epicPlaceholderDom(img.alt), img); }); }; // 竖版封面(卡片视图) // v1.9.29: ① 传递 kind 给 _sglvEpicCoverFallback ② onerror 链跳过 chain[0] // v1.9.41: good/链走 coverGoodUrl/coverChainLang function epicPosterImg(appid, name) { // v2.8.0: 统一封面(原实现走 coverGoodUrl + inline _sglvChainFallback, // 跨视图切换时会撞上 _sglvChainLoading 一次性锁导致永久失效) // v2.9.4: 卡片墙默认竖版海报(竖版优先链,拿不到自动回退横版 header) return unifiedCover(appid, name, 'poster', { preferVertical: true }); } // 徽章 function epicFreeBadgeHtml() { return '<span class="sglv-card-badge sglv-epic-free-badge">' + Host.T.epicBadgeFree + '</span>'; } function epicOwnedBadgeHtml(owned) { return owned ? '<span class="sglv-card-badge owned" style="margin-top:0">✓ ' + Host.T.steamInLib + '</span>' : '<span class="sglv-card-badge" style="background:rgba(255,255,255,0.12);color:#8f98a0;margin-top:0;padding:2px 6px;border-radius:3px;font-size:10px">' + Host.T.epicBadgeNotOwned + '</span>'; } // 合并多个来源 → 去重数字数组 function mergeEpicIds(...sources) { const set = new Set(); for (const src of sources) { if (!Array.isArray(src)) continue; for (const id of src) { const n = Number(id); if (n > 0) set.add(n); } } return Array.from(set); } // 加载 Epic 赠送数据(多级兜底) // v1.9.5: 缓存永不过期——优先读永久缓存秒开,后台增量合并刷新(6h 节流) const _EPIC_BG_INTERVAL = 6 * 3600 * 1000; let _epicLastBgRefreshAt = 0; function loadEpicGames(force = false) { // v1.9.27: force 时重置可能卡死的 loading 标志 if (epicState.loading) { if (!force) return; epicState.loading = false; } // v1.9.5: 优先读永久缓存(永不过期) if (!force) { const cached = _permCacheGet('epicFreeGames'); if (cached && Array.isArray(cached.appids) && cached.appids.length > 0) { epicState.appids = cached.appids; epicState.updatedAt = cached.updatedAt || ''; epicState.source = cached.source || ''; epicState.loaded = true; renderEpicSections(); // 后台增量刷新(6h 节流) if (_shouldThrottle(_epicLastBgRefreshAt, _EPIC_BG_INTERVAL)) return; _refreshEpicFromRemote(false, false); return; } } // 2) GM 存储 + 内置名单(即时兜底) let gmIds = []; try { gmIds = GM_getValue(EPIC_GM_KEY, []) || []; } catch (e) { gmIds = []; } const merged = mergeEpicIds(gmIds, EPIC_MANUAL_IDS); if (merged.length > 0) { epicState.appids = merged; epicState.updatedAt = ''; epicState.source = gmIds.length > 0 ? 'local' : 'manual'; epicState.loaded = true; // v1.9.5: 写入永久缓存 _permCacheSet('epicFreeGames', { appids: merged, updatedAt: '', source: epicState.source }); if (!force) renderEpicSections(); } // 3) 远程抓取(GitHub 数据源三级回退;force 时显示 loading,失败回退到本地数据) _refreshEpicFromRemote(force, force); } // 远程抓取失败时的统一处理(有本地数据则保留并提示,无数据则显示错误) function _epicFailLoading(msg) { epicState.loading = false; if (epicState.appids.length === 0) { epicState.loadError = msg; } else { epicState.loadError = null; Host.showToast(Host.isZh ? '更新失败,已使用本地数据' : 'Update failed, using local data'); } renderEpicGamesList(); } // 内部:远程刷新 Epic 数据(v1.9.17 改造,v1.9.19 移除 keylol 兜底)—— // 仅走 GitHub 数据源三级回退链(fastly.jsdelivr → cdn.jsdelivr → raw.githubusercontent, // 参照年度大作 _refreshGotyFromRemote)。每个请求均带 timeout, // onerror/ontimeout 一律降级到下一个 URL,绝不挂起 // (参照 v2.7.1 沙箱挂起历史教训:任何分支都必须走到 settle)。 function _refreshEpicFromRemote(force, showLoading) { if (typeof GM_xmlhttpRequest !== 'function') { if (showLoading) _epicFailLoading('noXhr'); return; } // v1.9.27: force 时重置可能卡死的 loading 标志 if (epicState.loading) { if (!force) return; epicState.loading = false; } if (showLoading) { epicState.loading = true; epicState.loadError = null; renderEpicGamesList(); } // 兼容两种 JSON 格式:数组 appid 列表 / 对象 { _meta?, updatedAt?, appids: number[], games?: [...] } function _extractEpicAppids(data) { if (Array.isArray(data)) return data; if (data && typeof data === 'object') { if (Array.isArray(data.appids)) return data.appids; if (Array.isArray(data.games)) return data.games.map(g => (g && g.appid)).filter(Boolean); } return null; } // 成功响应公共逻辑:提取 appid → mergeEpicIds 增量合并(只增不减)→ 写缓存 → 渲染 function _processEpicJson(data) { const incoming = mergeEpicIds(_extractEpicAppids(data)); if (incoming.length === 0) return false; let gmIds = []; try { gmIds = GM_getValue(EPIC_GM_KEY, []) || []; } catch (e) { /* 忽略失败,不影响主流程 */ } // v1.9.18: 并入当前缓存历史(只增不减),远程数据变小也不丢本地历史 appid const merged = mergeEpicIds(incoming, epicState.appids, gmIds, EPIC_MANUAL_IDS); epicState.appids = merged; epicState.updatedAt = (data && typeof data === 'object' && data.updatedAt) ? String(data.updatedAt).slice(0, 10) : new Date().toISOString().slice(0, 10); epicState.source = 'github'; epicState.loaded = true; epicState.loading = false; epicState.loadError = null; _epicLastBgRefreshAt = Date.now(); // v1.9.5: 记录后台刷新时间戳 try { GM_setValue(EPIC_GM_KEY, incoming); } catch (e) { /* 忽略失败,不影响主流程 */ } // v1.9.5: 写入永久缓存(永不过期)+ TTL 缓存(兼容) _permCacheSet('epicFreeGames', { appids: merged, updatedAt: epicState.updatedAt, source: 'github' }); try { Host.cacheSet('epicFreeGames', { appids: merged, updatedAt: epicState.updatedAt, source: 'github' }, Host.cacheTTL.epic); } catch (e) { /* 忽略失败 */ } renderEpicSections(); return true; } // 依次尝试 EPIC_GITHUB_URLS,任一成功即停止;全部失败 → 视为刷新失败(保留本地缓存数据) function _tryNextGithubSource(idx) { if (idx >= EPIC_GITHUB_URLS.length) { // v1.9.19: keylol 兜底已移除(帖子需登录阅读权限)——force 刷新提示失败并用本地数据,后台刷新静默 if (showLoading) _epicFailLoading('network'); return; } GM_xmlhttpRequest({ method: 'GET', url: EPIC_GITHUB_URLS[idx], timeout: 15000, onload(r) { try { if (r.status !== 404 && _safeRespText(r)) { if (_processEpicJson(JSON.parse(_safeRespText(r)))) return; // 成功,停止 } } catch (e) { /* 解析异常,交给下一个源 */ } _tryNextGithubSource(idx + 1); // 失败,尝试下一个源 }, onerror() { _tryNextGithubSource(idx + 1); }, ontimeout() { _tryNextGithubSource(idx + 1); }, }); } _tryNextGithubSource(0); } // 手动 JSON 导入(兜底:远程抓取失败时) function importEpicJson() { const raw = prompt(Host.T.epicImportPrompt, ''); if (!raw || !raw.trim()) return; try { const data = JSON.parse(raw); let ids = []; if (Array.isArray(data)) ids = data; else if (Array.isArray(data.appids)) ids = data.appids; else if (Array.isArray(data.games)) ids = data.games.map(g => g.appid || g.steamAppId || g.id).filter(Boolean); const incoming = mergeEpicIds(ids); if (incoming.length === 0) { Host.showToast(Host.T.epicImportNoData); return; } let gmIds = []; try { gmIds = GM_getValue(EPIC_GM_KEY, []) || []; } catch (e) { /* 忽略失败,不影响主流程 */ } const merged = mergeEpicIds(incoming, gmIds, EPIC_MANUAL_IDS); try { GM_setValue(EPIC_GM_KEY, incoming); } catch (e) { /* 忽略失败,不影响主流程 */ } try { Host.cacheSet('epicFreeGames', { appids: merged, updatedAt: new Date().toISOString().slice(0, 10), source: 'import' }, Host.cacheTTL.epic); } catch (e) { /* 忽略失败,不影响主流程 */ } epicState.appids = merged; epicState.updatedAt = new Date().toISOString().slice(0, 10); epicState.source = 'import'; epicState.loaded = true; epicState.loading = false; epicState.loadError = null; epicState.page = 1; Host.showToast(Host.T.epicImportOk.replace('{n}', merged.length)); renderEpicSections(); } catch (e) { Host.showToast(Host.T.epicImportFail + e.message); } } // v1.9.18: 一键导出全量 JSON(含缓存历史)——epic_freebies.json 兼容格式, // 可经 js_script/epic-freebies-weekly-sync.js --import 回流 GitHub 数据源维护全量名单 function exportEpicJson() { const ids = mergeEpicIds(epicState.appids).sort((a, b) => a - b); if (ids.length === 0) { Host.showToast(Host.T.epicExportEmpty || (Host.isZh ? '没有可导出的 Epic 赠送数据' : 'No Epic data to export')); return; } const payload = { _meta: { source: 'sglv-epic-export', version: 1 }, updatedAt: new Date().toISOString(), appids: ids }; const text = JSON.stringify(payload, null, 2); const filename = 'epic_freebies_export_' + new Date().toISOString().slice(0, 10) + '.json'; try { const blob = new Blob([text], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); Host.showToast((Host.T.epicExportOk || (Host.isZh ? '已导出 {n} 款 Epic 赠送游戏' : 'Exported {n} Epic free games')).replace('{n}', String(ids.length))); } catch (e) { // 兜底:Blob 下载不可用时弹窗展示 JSON 供手工复制保存 prompt(Host.T.epicExportFail || (Host.isZh ? '自动下载失败,请手工复制以下 JSON:' : 'Auto download failed. Copy JSON below:'), text); } } // 筛选 + 排序 function getFilteredEpic() { const q = epicState.search.toLowerCase().trim(); const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); let list = epicState.appids.map(Number).filter(n => n > 0); if (q) { list = list.filter(n => { if (String(n).includes(q)) return true; const name = (_epicNameCache.get(n) || '').toLowerCase(); return name.includes(q); }); } if (epicState.ownFilter === 'owned') list = list.filter(isOwned); else if (epicState.ownFilter === 'notOwned') list = list.filter(n => !isOwned(n)); list.sort((a, b) => { switch (epicState.sort) { case 'appidAsc': return a - b; case 'nameAsc': return _nameCollator.compare(_epicNameCache.get(a) || '~', _epicNameCache.get(b) || '~') || (a - b); case 'appidDesc': default: return b - a; } }); return list; } // ----- 渲染 ----- function renderEpicTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-psplus-wrap'; // KPI 区域 const kpi = document.createElement('div'); kpi.id = 'sglv-epic-kpi'; wrap.appendChild(kpi); // 工具栏:搜索 + 排序 + 视图 + 导入 + 刷新 const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar'; const sortOptions = [ { key: 'appidDesc', label: Host.T.epicSortAppidDesc }, { key: 'appidAsc', label: Host.T.epicSortAppidAsc }, { key: 'nameAsc', label: Host.T.epicSortNameAsc }, ]; toolbar.innerHTML = ` <input class="sglv-search" aria-label="搜索" id="sglv-epic-search" placeholder="${Host.T.epicSearch}" value="${Host.wlEscape(epicState.search)}"> <select class="sglv-sort-select" aria-label="排序方式" id="sglv-epic-sort"> ${sortOptions.map(o => `<option value="${o.key}"${epicState.sort === o.key ? ' selected' : ''}>${o.label}</option>`).join('')} </select> <div class="sglv-view-toggle"> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${epicState.viewMode === 'card' ? ' active' : ''}" id="sglv-epic-card" title="${Host.T.cardView}">${Host.ICONS.grid}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${epicState.viewMode === 'cover' ? ' active' : ''}" id="sglv-epic-cover" title="${Host.T.coverView}">${Host.ICONS.cover}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${epicState.viewMode === 'list' ? ' active' : ''}" id="sglv-epic-list" title="${Host.T.listView}">${Host.ICONS.list}</button> </div> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon" id="sglv-epic-import" title="${Host.T.epicImportTip}">${Host.ICONS.tag}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon" id="sglv-epic-export" title="${Host.T.epicExportTip || (Host.isZh ? '一键导出 Epic 赠送数据 JSON(含本地缓存历史全量)' : 'Export Epic free games JSON (incl. local history)')}">${Host.ICONS.download || '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>'}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon" id="sglv-epic-refresh" title="${Host.T.epicRefreshTip}">${Host.ICONS.refresh}</button> <div class="sglv-pagination" id="sglv-epic-pagination"></div> `; wrap.appendChild(toolbar); // 游戏列表容器 const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-epic-content'; wrap.appendChild(content); parent.appendChild(wrap); // 事件绑定 toolbar.querySelector('#sglv-epic-search').addEventListener('input', _debounce((e) => { epicState.search = e.target.value; epicState.page = 1; renderEpicGamesList(); }, 200)); toolbar.querySelector('#sglv-epic-sort').addEventListener('change', (e) => { epicState.sort = e.target.value; epicState.page = 1; renderEpicGamesList(); }); const setEpicViewMode = (mode) => { epicState.viewMode = mode; ['card', 'cover', 'list'].forEach(m => { toolbar.querySelector('#sglv-epic-' + m)?.classList.toggle('active', m === mode); }); renderEpicGamesList(); }; toolbar.querySelector('#sglv-epic-card').addEventListener('click', () => setEpicViewMode('card')); toolbar.querySelector('#sglv-epic-cover').addEventListener('click', () => setEpicViewMode('cover')); toolbar.querySelector('#sglv-epic-list').addEventListener('click', () => setEpicViewMode('list')); toolbar.querySelector('#sglv-epic-import').addEventListener('click', () => importEpicJson()); toolbar.querySelector('#sglv-epic-export').addEventListener('click', () => exportEpicJson()); toolbar.querySelector('#sglv-epic-refresh').addEventListener('click', () => loadEpicGames(true)); // v1.5.0: 筛选改为点击 KPI 卡片切换(再次点击同一卡片回到「全部」),与年度大作标签页一致 kpi.addEventListener('click', e => { const card = e.target.closest('[data-epic-filter]'); if (!card) return; const f = card.dataset.epicFilter; epicState.ownFilter = epicState.ownFilter === f ? 'all' : f; epicState.page = 1; renderEpicKpi(); // 刷新 KPI active 高亮 renderEpicGamesList(); }); if (!epicState.loaded && !epicState.loading) { loadEpicGames(); } else { renderEpicSections(); } } function renderEpicSections() { if (_collectionActiveTab !== 'epic' || !document.getElementById('sglv-epic-content')) return; renderEpicKpi(); renderEpicGamesList(); } function renderEpicKpi() { const el = document.getElementById('sglv-epic-kpi'); if (!el) return; if (!epicState.loaded) { el.innerHTML = ''; return; } const total = epicState.appids.length; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); const owned = epicState.appids.filter(n => isOwned(Number(n))).length; const notOwned = total - owned; const srcLabel = epicSourceLabel(epicState.source); const updatedStr = epicState.updatedAt ? (Host.isZh ? '更新于 ' : 'Updated ') + epicState.updatedAt + ' · ' + srcLabel : (Host.isZh ? '来源:' : 'Source: ') + srcLabel; el.innerHTML = ` <div class="sglv-trend-kpi-row"> <div class="sglv-trend-kpi-card blue clickable${epicState.ownFilter === 'all' ? ' active' : ''}" data-epic-filter="all" title="${Host.isZh ? '点击筛选:全部' : 'Click to filter: All'}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon">${Host.ICONS.epic}</span><span class="sglv-trend-kpi-label">${Host.T.epicKpiTotal}</span></div> <div class="sglv-trend-kpi-value">${total.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${updatedStr}</div> </div> <div class="sglv-trend-kpi-card mine clickable${epicState.ownFilter === 'owned' ? ' active' : ''}" data-epic-filter="owned" title="${Host.isZh ? '点击筛选:steam在库' : 'Click to filter: In Library'}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon">${Host.ICONS.heart}</span><span class="sglv-trend-kpi-label">${Host.T.epicKpiOwned}</span></div> <div class="sglv-trend-kpi-value">${owned.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${Host.T.epicKpiOwnedSub}</div> </div> <div class="sglv-trend-kpi-card clickable${epicState.ownFilter === 'notOwned' ? ' active' : ''}" style="border-left:3px solid #ff9800" data-epic-filter="notOwned" title="${Host.isZh ? '点击筛选:未拥有' : 'Click to filter: Not Owned'}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:#ff9800">${Host.ICONS.tag}</span><span class="sglv-trend-kpi-label">${Host.T.epicKpiNotOwned}</span></div> <div class="sglv-trend-kpi-value">${notOwned.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${Host.isZh ? '可在 Epic 领取' : 'Claimable on Epic'}</div> </div> </div> `; } function renderEpicGamesList() { const content = document.getElementById('sglv-epic-content'); if (!content) return; const clearPagination = () => { const p = document.getElementById('sglv-epic-pagination'); if (p) p.innerHTML = ''; }; if (epicState.loading) { content.innerHTML = '<div class="sglv-progress" style="margin:16px 0"><div class="sglv-progress-fill" style="width:45%"></div></div><div class="sglv-empty">' + Host.T.epicLoading + '</div>'; clearPagination(); return; } if (epicState.loadError) { content.innerHTML = '<div class="sglv-empty">' + Host.T.epicLoadError + '</div>'; clearPagination(); return; } if (!epicState.loaded || epicState.appids.length === 0) { content.innerHTML = '<div class="sglv-empty">' + Host.T.epicNoData + '</div>'; clearPagination(); return; } const list = getFilteredEpic(); if (list.length === 0) { content.innerHTML = '<div class="sglv-empty">' + (Host.isZh ? '无匹配结果' : 'No matching results') + '</div>'; clearPagination(); return; } const { pageItems, totalPages, page: _clampedPage } = Host.paginate(list, epicState.page, epicState.pageSize); epicState.page = _clampedPage; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); const freeBadge = epicFreeBadgeHtml(); // 卡片视图 if (epicState.viewMode === 'card') { content.innerHTML = '<div class="sglv-card-grid sglv-card-grid-7">' + pageItems.map(appid => { const name = epicDisplayName(appid); const clickUrl = 'https://store.steampowered.com/app/' + appid; const nameSpan = '<span data-sglv-epic-appid="' + appid + '">' + Host.wlEscape(name) + '</span><span class="sglv-card-id-inline">' + appid + '</span>'; return '<div class="sglv-game-card sglv-poster-card" data-appid="' + appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + '<div class="sglv-card-img-wrap">' + freeBadge + epicPosterImg(appid, name) + '</div>' + '<div class="sglv-card-body">' + '<div class="sglv-card-name" title="' + Host.wlEscape(name) + '">' + nameSpan + '</div>' + '<div class="sglv-info-row sglv-wl-price-row">' + epicOwnedBadgeHtml(isOwned(appid)) + '</div>' + '</div>' + '</div>'; }).join('') + '</div>'; } else if (epicState.viewMode === 'cover') { // 封面视图(横板大图) // v2.9.22: 标签统一规则——封面上不放任何徽章(freeBadge 在右下角,owned badge 在标题下方) content.innerHTML = '<div class="sglv-wl-cover-grid">' + pageItems.map(appid => { const name = epicDisplayName(appid); const clickUrl = 'https://store.steampowered.com/app/' + appid; const nameSpan = '<span data-sglv-epic-appid="' + appid + '">' + Host.wlEscape(name) + '</span><span class="sglv-card-id-inline">' + appid + '</span>'; return '<div class="sglv-wl-cover-card" data-appid="' + appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + '<div class="sglv-wl-cover-img-wrap">' + freeBadge + unifiedCover(appid, name, 'capsule') + '</div>' + '<div class="sglv-wl-cover-body">' + '<div class="sglv-card-name" title="' + Host.wlEscape(name) + '">' + nameSpan + '</div>' + '<div class="sglv-info-row sglv-wl-price-row sglv-epic-owned-row">' + epicOwnedBadgeHtml(isOwned(appid)) + '</div>' + '</div>' + '</div>'; }).join('') + '</div>'; } else { // 列表视图 content.innerHTML = '<div class="sglv-list-view">' + pageItems.map(appid => { const name = epicDisplayName(appid); const clickUrl = 'https://store.steampowered.com/app/' + appid; const iconHtml = '<img class="sglv-list-icon" src="' + Host.getGameIconUrl(appid, '') + '" alt="' + Host.wlEscape(name) + '" loading="lazy" decoding="async" onerror="this.style.display=\'none\'">'; const nameSpan = '<span data-sglv-epic-appid="' + appid + '">' + Host.wlEscape(name) + '</span><span class="sglv-card-id-inline">' + appid + '</span>'; return '<div class="sglv-list-item" data-appid="' + appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + iconHtml + '<div class="sglv-list-info">' + '<div class="sglv-list-name">' + nameSpan + '</div>' + '<div class="sglv-list-meta">' + freeBadge + epicOwnedBadgeHtml(isOwned(appid)) + '</div>' + '</div>' + '</div>'; }).join('') + '</div>'; } // 分页 // v1.9.47-fix: 补 onJump——Epic 翻页 5 个按钮全部走 onJump(参见主脚本 renderPagination) const pagination = document.getElementById('sglv-epic-pagination'); if (pagination) { Host.renderPagination(pagination, 'sglv-epic', epicState.page, totalPages, list.length, () => { epicState.page--; renderEpicGamesList(); }, () => { epicState.page++; renderEpicGamesList(); }, (p) => { epicState.page = p; renderEpicGamesList(); }); } // 异步加载中文名 + 写入本地缓存供搜索/排序 content.querySelectorAll('[data-sglv-epic-appid]').forEach(el => { const appid = el.getAttribute('data-sglv-epic-appid'); const original = el.textContent; epicLoadName(appid).then(name => { if (name && name !== original) { el.textContent = name; el.title = name + ' (' + original + ')'; // v2.7.9: 清除占位符内联样式,允许 CSS class 生效(项目约定) el.style.color = ''; el.style.fontStyle = ''; el.style.fontWeight = ''; } }); }); // v2.8.0: 统一封面链绑定(addEventListener,无跨上下文 inline onerror) bindRestrictedCoverLoaders(content); } // ==================== 锁区游戏标签页 (v2.9.12) ==================== // 数据源: GitHub 静态源优先(fastly.jsdelivr.net/gh/SmallRob/steam-namespace steam_blocked_apps.json) // → steam-tracker.com API (Banned + Purchase disabled) → HTML (Regional variant) + 手动导入 // 宿主桥接: Host.loadBlockedApps(force), Host.getBlockedApps(), Host.isBlockedLoaded(), // Host.parseManualBlockedJson(text), Host.getBlockedSource() const blockedState = { apps: [], loaded: false, loading: false, search: '', ownFilter: 'all', catFilter: 'all', sort: 'appidDesc', page: 1, pageSize: 48, viewMode: 'card', source: '', loadError: null, fetchedAt: '', // v2.9.90: 远程数据集更新时间 pendingForce: false, // v2.9.90: loading中时排队的force刷新请求 // v2.9.91: 好评500分类单独加载(独立于主锁区数据,确保 categoryId=500 条目始终可用) topRatedApps: [], topRatedLoaded: false, topRatedLoading: false, topRatedFetchedAt: '', refreshStage: '', // v2.9.91: 刷新进度文本 }; function blockedCatBadgeHtml(catId) { if (catId === 20) return '<span class="sglv-badge" style="background:rgba(239,68,68,0.15);color:#f87171;border:1px solid rgba(239,68,68,0.25)">' + Host.T.blockedBadgeBanned + '</span>'; if (catId === 3) return '<span class="sglv-badge" style="background:rgba(245,158,11,0.15);color:#fbbf24;border:1px solid rgba(245,158,11,0.25)">' + Host.T.blockedBadgePurchase + '</span>'; if (catId === 10) return '<span class="sglv-badge" style="background:rgba(59,130,246,0.15);color:#60a5fa;border:1px solid rgba(59,130,246,0.25)">' + Host.T.blockedBadgeRegional + '</span>'; if (catId === 0) return '<span class="sglv-badge" style="background:var(--sglv-accent-bg, rgba(168,85,247,0.15));color:var(--sglv-accent-light, #c084fc);border:1px solid var(--sglv-accent-border, rgba(168,85,247,0.25))">' + Host.T.blockedBadgeManual + '</span>'; // v1.9.15: 好评500 分类(人工整理锁区游戏,categoryId=500) if (catId === 500) return '<span class="sglv-badge" style="background:rgba(16,185,129,0.15);color:#34d399;border:1px solid rgba(16,185,129,0.25)">' + (Host.isZh ? '好评500' : 'Positive 500+') + '</span>'; return ''; } function blockedOwnedBadgeHtml(owned) { if (owned) return '<span class="sglv-badge" style="background:rgba(75,181,79,0.15);color:#4bb54f;border:1px solid rgba(75,181,79,0.25)">' + Host.T.blockedBadgeOwned + '</span>'; return ''; } // v2.9.91: 好评500分类数据远程URL(与主脚本 BLOCKED_STATIC_URL 同源,独立加载确保 categoryId=500 条目可用) const BLOCKED_TOP_RATED_URL = 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/steam_blocked_apps.json'; const BLOCKED_TOP_RATED_FALLBACK_URL = 'https://raw.githubusercontent.com/SmallRob/steam-namespace/refs/heads/main/data/steam_blocked_apps.json'; /** * v2.9.91: 单独加载好评500分类数据 * 直接从远程 JSON 筛选 categoryId=500 条目,独立于主锁区数据缓存 * @param {boolean} force - 强制刷新(忽略永久缓存) * @returns {Promise<Array>} 好评500条目数组 */ function _fetchTopRatedFromRemote() { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('GM_xmlhttpRequest unavailable')); return; } const tryFetch = (url, isFallback) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'Accept': 'application/json' }, timeout: 15000, onload(r) { if (r.status < 200 || r.status >= 300) { if (!isFallback) { tryFetch(BLOCKED_TOP_RATED_FALLBACK_URL, true); return; } reject(new Error(`HTTP ${r.status}`)); return; } try { const data = JSON.parse(_safeRespText(r)); const apps = Array.isArray(data) ? data : (Array.isArray(data.data) ? data.data : []); const topRated = apps .filter(a => a.categoryId === 500 || a.category_id === 500) .map(a => ({ appid: Number(a.appid) || 0, name: (a.name || '').trim(), category: a.category || '好评500', categoryId: 500, type: a.type || 'game', source: 'static', changedAt: a.changedAt || a.changed_at || '', positiveRating: a.positiveRating || 0, reviews: a.reviews || 0, cheapestRegion: a.cheapestRegion || '', })); const fetchedAt = (!Array.isArray(data) && data.fetchedAt) ? data.fetchedAt : ''; resolve({ apps: topRated, fetchedAt }); } catch (e) { if (!isFallback) { tryFetch(BLOCKED_TOP_RATED_FALLBACK_URL, true); return; } reject(new Error('JSON parse fail')); } }, onerror: () => { if (!isFallback) { tryFetch(BLOCKED_TOP_RATED_FALLBACK_URL, true); return; } reject(new Error('Network error')); }, ontimeout: () => { if (!isFallback) { tryFetch(BLOCKED_TOP_RATED_FALLBACK_URL, true); return; } reject(new Error('Timeout')); }, }); }; tryFetch(BLOCKED_TOP_RATED_URL, false); }); } /** * v2.9.91: 加载好评500分类数据(含永久缓存 + 后台静默刷新) * @param {boolean} force - 强制刷新 */ function loadTopRatedApps(force) { if (blockedState.topRatedLoading) return Promise.resolve(); // 非强制时优先从永久缓存秒开 if (!force) { const permCached = _permCacheGet('blockedTopRated'); if (permCached && Array.isArray(permCached.apps) && permCached.apps.length > 0) { blockedState.topRatedApps = permCached.apps; blockedState.topRatedFetchedAt = permCached.fetchedAt || ''; blockedState.topRatedLoaded = true; // 后台静默刷新 _fetchTopRatedFromRemote().then(result => { if (result.fetchedAt && result.fetchedAt !== blockedState.topRatedFetchedAt) { blockedState.topRatedApps = result.apps; blockedState.topRatedFetchedAt = result.fetchedAt; _permCacheSet('blockedTopRated', { apps: result.apps, fetchedAt: result.fetchedAt }); if (_collectionActiveTab === 'blocked') renderBlockedSections(); } }).catch(() => {}); return Promise.resolve(); } } blockedState.topRatedLoading = true; return _fetchTopRatedFromRemote().then(result => { blockedState.topRatedApps = result.apps || []; blockedState.topRatedFetchedAt = result.fetchedAt || ''; blockedState.topRatedLoaded = true; blockedState.topRatedLoading = false; _permCacheSet('blockedTopRated', { apps: blockedState.topRatedApps, fetchedAt: blockedState.topRatedFetchedAt }); }).catch(() => { blockedState.topRatedLoading = false; }); } /** * v2.9.91: 将好评500条目合并到主锁区列表(按 appid 去重,已有条目不覆盖) * @returns {Array} 合并后的完整锁区列表 */ function getMergedBlockedApps() { if (!blockedState.topRatedApps || blockedState.topRatedApps.length === 0) { return blockedState.apps; } const existing = new Set(blockedState.apps.map(a => a.appid)); const additions = blockedState.topRatedApps.filter(a => !existing.has(a.appid)); return blockedState.apps.concat(additions); } function loadBlockedGames(force) { // v2.9.90: loading中时,force请求排队(pendingForce),避免静默丢弃用户刷新操作 // v1.9.27: force 时直接重置可能卡死的 loading 标志(浏览器扩展阻断回调时 loading 可能永久卡 true) if (blockedState.loading) { if (force) { blockedState.loading = false; blockedState.pendingForce = false; } else { return; } } blockedState.loading = true; blockedState.loadError = null; blockedState.refreshStage = Host.isZh ? '正在加载锁区数据…' : 'Loading blocked data…'; // v2.9.90: 刷新按钮 spinner 反馈 const refreshBtn = document.getElementById('sglv-blocked-refresh'); if (refreshBtn) refreshBtn.classList.add('sglv-spin'); renderBlockedSections(); renderBlockedToolbarStatus(); if (typeof Host.loadBlockedApps !== 'function') { blockedState.loading = false; blockedState.loadError = 'Host loadBlockedApps unavailable'; blockedState.refreshStage = ''; if (refreshBtn) refreshBtn.classList.remove('sglv-spin'); renderBlockedSections(); renderBlockedToolbarStatus(); return; } // v2.9.91: 同时加载好评500分类数据(force 时同步强制刷新) loadTopRatedApps(force); Host.loadBlockedApps(force, (stage, text) => { // v2.9.91: 进度回调更新工具栏状态 blockedState.refreshStage = text || ''; renderBlockedToolbarStatus(); }).then(result => { blockedState.apps = result.apps || []; blockedState.source = result.source || 'api'; // v2.9.90: 存储 fetchedAt 用于显示数据更新时间 blockedState.fetchedAt = result.fetchedAt || ''; blockedState.loaded = true; blockedState.loading = false; blockedState.page = 1; blockedState.refreshStage = ''; // v1.9.5: 写入永久缓存(永不过期)+ v2.9.90: 含 fetchedAt _permCacheSet('blockedApps', { apps: blockedState.apps, source: blockedState.source, fetchedAt: blockedState.fetchedAt }); if (refreshBtn) refreshBtn.classList.remove('sglv-spin'); renderBlockedSections(); renderBlockedToolbarStatus(); // v2.9.90: 若有排队的 force 请求,立即执行 if (blockedState.pendingForce) { blockedState.pendingForce = false; loadBlockedGames(true); } }).catch(e => { blockedState.loading = false; blockedState.loadError = e.message || 'Load failed'; blockedState.refreshStage = ''; if (refreshBtn) refreshBtn.classList.remove('sglv-spin'); renderBlockedSections(); renderBlockedToolbarStatus(); // v2.9.90: 若有排队的 force 请求,立即执行 if (blockedState.pendingForce) { blockedState.pendingForce = false; loadBlockedGames(true); } }); } function importBlockedJson() { const text = prompt(Host.T.blockedImportPrompt); if (!text || !text.trim()) return; try { const apps = Host.parseManualBlockedJson(text); if (!apps.length) { Host.showToast(Host.T.blockedImportNoData); return; } // 合并到现有数据(手动导入的不覆盖已有条目) const existing = new Map(blockedState.apps.map(a => [a.appid, a])); apps.forEach(a => { if (!existing.has(a.appid)) existing.set(a.appid, a); }); blockedState.apps = Array.from(existing.values()); blockedState.loaded = true; blockedState.source = 'import'; blockedState.page = 1; // v1.9.5: 写入永久缓存(永不过期)+ TTL 缓存(兼容主脚本加载路径) // v2.9.90: 含 fetchedAt(手动导入无远程时间戳,置空) _permCacheSet('blockedApps', { apps: blockedState.apps, source: 'import', fetchedAt: '' }); try { Host.cacheSet('blockedApps', { apps: blockedState.apps, source: 'import', fetchedAt: '' }, Host.cacheTTL.blockedApps); } catch (e) { /* 忽略失败 */ } Host.showToast(Host.T.blockedImportOk.replace('{n}', apps.length)); renderBlockedSections(); } catch (e) { Host.showToast(Host.T.blockedImportFail + e.message); } } function getFilteredBlocked() { const q = blockedState.search.toLowerCase().trim(); const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); // v2.9.91: 使用合并后的锁区列表(含好评500单独加载的条目) let list = getMergedBlockedApps().filter(a => a.appid > 0); if (q) { list = list.filter(a => { if (String(a.appid).includes(q)) return true; return (a.name || '').toLowerCase().includes(q); }); } if (blockedState.ownFilter === 'owned') list = list.filter(a => isOwned(a.appid)); else if (blockedState.ownFilter === 'notOwned') list = list.filter(a => !isOwned(a.appid)); if (blockedState.catFilter !== 'all') { const catMap = { banned: 20, purchase: 3, regional: 10, manual: 0, topRated: 500 }; const targetId = catMap[blockedState.catFilter]; list = list.filter(a => a.categoryId === targetId); } // v1.9.5: 使用缓存的 _nameCollator,避免每次渲染重新构造 list.sort((a, b) => { let r = 0; switch (blockedState.sort) { case 'appidAsc': r = a.appid - b.appid; break; case 'nameAsc': r = _nameCollator.compare(a.name || '~', b.name || '~') || (a.appid - b.appid); break; case 'dateDesc': r = (b.changedAt || '').localeCompare(a.changedAt || ''); break; case 'dateAsc': r = (a.changedAt || '').localeCompare(b.changedAt || ''); break; case 'appidDesc': default: r = b.appid - a.appid; } return r || (a.appid - b.appid); }); return list; } function renderBlockedTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-psplus-wrap'; const kpi = document.createElement('div'); kpi.id = 'sglv-blocked-kpi'; wrap.appendChild(kpi); const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar'; const sortOptions = [ { key: 'appidDesc', label: Host.T.blockedSortAppidDesc }, { key: 'appidAsc', label: Host.T.blockedSortAppidAsc }, { key: 'nameAsc', label: Host.T.blockedSortNameAsc }, { key: 'dateDesc', label: Host.T.blockedSortDateDesc }, { key: 'dateAsc', label: Host.T.blockedSortDateAsc }, ]; toolbar.innerHTML = ` <input class="sglv-search" aria-label="搜索" id="sglv-blocked-search" placeholder="${Host.T.blockedSearch}" value="${Host.wlEscape(blockedState.search)}"> <select class="sglv-sort-select" aria-label="排序方式" id="sglv-blocked-sort"> ${sortOptions.map(o => `<option value="${o.key}"${blockedState.sort === o.key ? ' selected' : ''}>${o.label}</option>`).join('')} </select> <div class="sglv-view-toggle"> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${blockedState.viewMode === 'card' || blockedState.viewMode === 'cover' ? ' active' : ''}" id="sglv-blocked-card" title="${Host.T.cardView}">${Host.ICONS.grid}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${blockedState.viewMode === 'list' ? ' active' : ''}" id="sglv-blocked-list" title="${Host.T.listView}">${Host.ICONS.list}</button> </div> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon" id="sglv-blocked-import" title="${Host.T.blockedImportTip}">${Host.ICONS.tag}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon" id="sglv-blocked-refresh" title="${Host.T.blockedRefreshTip}">${Host.ICONS.refresh}</button> <span class="sglv-blocked-status" id="sglv-blocked-status"></span> <div class="sglv-toolbar-spacer"></div> <div class="sglv-pagination sglv-pagination-inline" id="sglv-blocked-pagination"></div> `; wrap.appendChild(toolbar); // v2.5.0: 移除独立筛选栏——own/cat 筛选统一由 KPI 卡片行承担(3 scope + 4 类型), // 原筛选栏与 KPI 行表达同一组维度,双排并存造成界面逻辑混乱。 // v2.8.0: 分页器也并入工具栏右侧,工具栏独占一行即可完成「搜索+排序+视图+分页」全部操作。 const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-blocked-content'; wrap.appendChild(content); parent.appendChild(wrap); toolbar.querySelector('#sglv-blocked-search').addEventListener('input', _debounce((e) => { blockedState.search = e.target.value; blockedState.page = 1; renderBlockedGamesList(); }, 200)); toolbar.querySelector('#sglv-blocked-sort').addEventListener('change', (e) => { blockedState.sort = e.target.value; blockedState.page = 1; renderBlockedGamesList(); }); const setBlockedViewMode = (mode) => { blockedState.viewMode = mode; ['card', 'list'].forEach(m => { toolbar.querySelector('#sglv-blocked-' + m)?.classList.toggle('active', m === mode || (m === 'card' && blockedState.viewMode === 'cover')); }); renderBlockedGamesList(); }; toolbar.querySelector('#sglv-blocked-card').addEventListener('click', () => setBlockedViewMode('card')); toolbar.querySelector('#sglv-blocked-list').addEventListener('click', () => setBlockedViewMode('list')); toolbar.querySelector('#sglv-blocked-import').addEventListener('click', () => importBlockedJson()); toolbar.querySelector('#sglv-blocked-refresh').addEventListener('click', () => loadBlockedGames(true)); // v2.6.0: HERO 承担全部筛选职责(事件委托) // - data-blocked-scope="owned|missing" → ownFilter(再点同一卡回到「全部」) // - data-blocked-type="banned|purchase_disabled|regional|top_rated" → catFilter(同样 toggle 回「all」) // - 点击 HERO 左侧主数字区 → 一键清空两个维度(替代原先两个「全部」按钮的"重置"语义) kpi.addEventListener('click', e => { if (!e.target.closest('[data-blocked-scope],[data-blocked-type]') && e.target.closest('.sglv-hero-left')) { if (blockedState.ownFilter !== 'all' || blockedState.catFilter !== 'all') { blockedState.ownFilter = 'all'; blockedState.catFilter = 'all'; blockedState.page = 1; renderBlockedKpi(); renderBlockedGamesList(); } return; } const scopeCard = e.target.closest('[data-blocked-scope]'); if (scopeCard) { const f = scopeCard.dataset.blockedScope; blockedState.ownFilter = (blockedState.ownFilter === f && f !== 'all') ? 'all' : f; blockedState.page = 1; renderBlockedKpi(); renderBlockedGamesList(); return; } const typeCard = e.target.closest('[data-blocked-type]'); if (typeCard) { const t = typeCard.dataset.blockedType; // typeKey(banned/purchase_disabled/regional/top_rated) → catFilter(banned/purchase/regional/topRated) const toCat = { banned: 'banned', purchase_disabled: 'purchase', regional: 'regional', top_rated: 'topRated' }; const cat = toCat[t]; if (!cat) return; blockedState.catFilter = (blockedState.catFilter === cat) ? 'all' : cat; blockedState.page = 1; renderBlockedKpi(); renderBlockedGamesList(); } }); if (!blockedState.loaded && !blockedState.loading) { // 优先从宿主缓存读取 if (Host.isBlockedLoaded && Host.isBlockedLoaded() && Host.getBlockedApps) { blockedState.apps = Host.getBlockedApps(); blockedState.source = Host.getBlockedSource ? Host.getBlockedSource() : 'api'; // v2.9.90: 读取宿主的 fetchedAt if (Host.getBlockedFetchedAt) blockedState.fetchedAt = Host.getBlockedFetchedAt(); blockedState.loaded = true; renderBlockedSections(); } else { // v1.9.5: 宿主缓存无数据 → 尝试永久缓存秒开,后台静默刷新 const permCached = _permCacheGet('blockedApps'); if (permCached && Array.isArray(permCached.apps) && permCached.apps.length > 0) { blockedState.apps = permCached.apps; blockedState.source = permCached.source || 'api'; // v2.9.90: 读取 permCache 中的 fetchedAt blockedState.fetchedAt = permCached.fetchedAt || ''; blockedState.loaded = true; renderBlockedSections(); loadBlockedGames(false); // 后台静默刷新 } else { loadBlockedGames(false); } } } else { renderBlockedSections(); } // v2.9.91: 单独加载好评500分类数据(独立于主锁区数据缓存,确保 categoryId=500 条目始终可用) if (!blockedState.topRatedLoaded && !blockedState.topRatedLoading) { loadTopRatedApps(false).then(() => { if (_collectionActiveTab === 'blocked') renderBlockedSections(); }); } } function renderBlockedSections() { if (_collectionActiveTab !== 'blocked' || !document.getElementById('sglv-blocked-content')) return; renderBlockedKpi(); renderBlockedToolbarStatus(); renderBlockedGamesList(); } // v2.9.91: 渲染工具栏刷新按钮右侧的状态文本(更新时间 / 加载进度) function renderBlockedToolbarStatus() { const el = document.getElementById('sglv-blocked-status'); if (!el) return; if (blockedState.refreshStage) { // 加载中:显示进度文本 + spinner el.innerHTML = `<svg style="width:11px;height:11px;animation:sgc-spin 0.8s linear infinite;vertical-align:middle;margin-right:4px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>${Host.wlEscape(blockedState.refreshStage)}`; el.className = 'sglv-blocked-status loading'; } else if (blockedState.fetchedAt) { // 空闲:显示数据更新时间 const date = blockedState.fetchedAt.slice(0, 10); el.textContent = (Host.isZh ? '更新: ' : 'Updated: ') + date; el.className = 'sglv-blocked-status'; } else { el.textContent = ''; el.className = 'sglv-blocked-status'; } } // v2.5.0: 锁区筛选栏已移除——筛选职责统一收敛到 KPI 卡片行(3 scope 卡 + 4 类型卡), // 避免"顶部 KPI 行 + 下方筛选栏"两处重复表达同一组筛选维度。 // 保留 syncBlockedFilterBtns 仅为兼容旧调用点(空实现,不再有筛选栏可同步)。 function syncBlockedFilterBtns() { /* v2.5.0: 筛选栏已移除,KPI 卡片自带 active 态 */ } function renderBlockedKpi() { const el = document.getElementById('sglv-blocked-kpi'); if (!el) return; if (!blockedState.loaded) { el.innerHTML = ''; return; } // v2.9.91: 使用合并后的锁区列表(含好评500单独加载的条目) const mergedApps = getMergedBlockedApps(); const total = mergedApps.length; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); const owned = mergedApps.filter(a => isOwned(a.appid)).length; // v2.2.0: 用通用受限游戏板 KPI 行组件(scope 卡 + 类型卡合并展示) const typeColors = { banned: '#ef4444', purchase_disabled: '#fb923c', regional: '#a78bfa', top_rated: '#fbbf24' }; const typeLabels = {}; BLOCKED_TYPE_KEYS.forEach(k => { const info = Object.values(BLOCKED_TYPE_INFO).find(v => v.key === k); typeLabels[k] = Host.isZh ? info.label.zh : info.label.en; }); // v2.5.0: 筛选统一收敛到 KPI 卡片行——scope 由 ownFilter 映射,type 由 catFilter 反向映射。 // catFilter 的历史值(banned/purchase/regional/manual/topRated)与 typeKey(banned/purchase_disabled/regional/top_rated)命名不一致, // 这里做双向映射,保证 KPI 卡 active 态与实际筛选一致。manual 无对应类型卡 → 视为未选中任何类型。 const scope = blockedState.ownFilter === 'notOwned' ? 'missing' : blockedState.ownFilter; const catToType = { banned: 'banned', purchase: 'purchase_disabled', regional: 'regional', topRated: 'top_rated' }; const typeFilter = catToType[blockedState.catFilter] || 'all'; const scopeBase = (scope === 'owned') ? mergedApps.filter(a => isOwned(a.appid)) : (scope === 'missing') ? mergedApps.filter(a => !isOwned(a.appid)) : mergedApps; // v2.6.0: KPI 小卡行 → HERO 仪表盘(大数字 + 类型占比条,铺满浮窗宽度) // 顺带解决旧版"两个全部":主数字即"全部",scope 卡只有「在库/未拥有」,类型行点击即筛选。 el.innerHTML = makeRestrictedHero({ total: total, ownedCount: owned, scopeBase: scopeBase.map(function (a) { return { type: _blockedTypeKey(a) }; }), scope: scope, typeFilter: typeFilter, title: Host.T.blockedKpiTotal, typeKeys: BLOCKED_TYPE_KEYS, typeColors: typeColors, typeLabels: typeLabels, tabId: 'blocked' }); } // v2.2.0: 锁区类型标准化——categoryId → typeKey + 颜色映射 // 真实 categoryId:20=下架 / 3=停售 / 10=锁区 / 500=好评500 const BLOCKED_TYPE_INFO = { 20: { key: 'banned', color: '#ef4444', label: { zh: '下架', en: 'Banned' } }, 3: { key: 'purchase_disabled', color: '#fb923c', label: { zh: '停售', en: 'Purchase disabled' } }, 10: { key: 'regional', color: '#a78bfa', label: { zh: '锁区', en: 'Regional' } }, 500: { key: 'top_rated', color: '#fbbf24', label: { zh: '好评500', en: 'Positive 500+' } } }; const BLOCKED_TYPE_KEYS = ['banned', 'purchase_disabled', 'regional', 'top_rated']; function _blockedCategoryInfo(app) { const cid = Number(app.categoryId || 0); return BLOCKED_TYPE_INFO[cid] || { key: 'unknown', color: '#94a3b8', label: { zh: '其他', en: 'Other' } }; } function _blockedTypeLabel(app) { const info = _blockedCategoryInfo(app); return Host.isZh ? info.label.zh : info.label.en; } function _blockedTypeKey(app) { return _blockedCategoryInfo(app).key; } function _blockedTypeColor(app) { return _blockedCategoryInfo(app).color; } function renderBlockedGamesList() { const content = document.getElementById('sglv-blocked-content'); if (!content) return; const clearPagination = () => { const p = document.getElementById('sglv-blocked-pagination'); if (p) p.innerHTML = ''; }; if (blockedState.loading) { // v2.9.91: 显示刷新进度文本(若有) const stageText = blockedState.refreshStage || Host.T.blockedLoading; content.innerHTML = '<div class="sglv-progress" style="margin:16px 0"><div class="sglv-progress-fill" style="width:45%"></div></div><div class="sglv-empty">' + Host.wlEscape(stageText) + '</div>'; clearPagination(); return; } if (blockedState.loadError) { content.innerHTML = '<div class="sglv-empty">' + Host.T.blockedLoadError + '</div>'; clearPagination(); return; } if (!blockedState.loaded || blockedState.apps.length === 0) { content.innerHTML = '<div class="sglv-empty">' + Host.T.blockedNoData + '</div>'; clearPagination(); return; } const list = getFilteredBlocked(); if (list.length === 0) { content.innerHTML = '<div class="sglv-empty">' + (Host.isZh ? '无匹配结果' : 'No matching results') + '</div>'; clearPagination(); return; } const { pageItems, totalPages, page: _clampedPage } = Host.paginate(list, blockedState.page, blockedState.pageSize); blockedState.page = _clampedPage; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); // v2.2.0: 移除 cover viewMode,统一为 card / list 两视图(与绝版对齐) if (blockedState.viewMode === 'list') { content.innerHTML = '<div class="sglv-list-view">' + pageItems.map(function (a) { const name = a.name || ('App ' + a.appid); const owned = isOwned(a.appid); const metaItems = []; if (a.changedAt) metaItems.push('<span style="color:#8f98a0">' + Host.T.blockedChangedAt + ': ' + Host.wlEscape(a.changedAt) + '</span>'); return makeRestrictedListItem({ appid: a.appid, name: name, isOwned: owned, typeLabel: _blockedTypeLabel(a), typeKey: _blockedTypeKey(a), typeColor: _blockedTypeColor(a), metaItems: metaItems, clickUrl: 'https://store.steampowered.com/app/' + a.appid }); }).join('') + '</div>'; } else { content.innerHTML = '<div class="sglv-card-grid">' + pageItems.map(function (a) { const name = a.name || ('App ' + a.appid); const owned = isOwned(a.appid); const metaItems = []; if (a.changedAt) metaItems.push('<span class="sglv-meta-item">' + Host.T.blockedChangedAt + ': ' + Host.wlEscape(a.changedAt) + '</span>'); return makeRestrictedCard({ appid: a.appid, name: name, isOwned: owned, isBlocked: true, typeLabel: _blockedTypeLabel(a), typeKey: _blockedTypeKey(a), typeColor: _blockedTypeColor(a), metaItems: metaItems, clickUrl: 'https://store.steampowered.com/app/' + a.appid }); }).join('') + '</div>'; } // v2.5.0: 补齐缓存命中图的 loaded 态 + 异步加载中文名 syncRestrictedCoverStates(content); content.querySelectorAll('[data-sglv-appid]').forEach(el => { Host.loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); const pagination = document.getElementById('sglv-blocked-pagination'); if (pagination) { // v1.9.47-fix: 补 onJump——锁区翻页 5 个按钮全部走 onJump(参见主脚本 renderPagination) Host.renderPagination(pagination, 'sglv-blocked', blockedState.page, totalPages, list.length, () => { blockedState.page--; renderBlockedGamesList(); }, () => { blockedState.page++; renderBlockedGamesList(); }, (p) => { blockedState.page = p; renderBlockedGamesList(); }); } } // ==================== 年度大作标签页 (v2.9.7, v1.7.x 按年数据源) ==================== // v1.8.1: 数据源改用 jsdelivr CDN 优先(国内稳定),raw.githubusercontent 兜底。 // raw.githubusercontent.com 在国内经常连接超时/被墙,导致年度大作加载失败。 // jsdelivr CDN 对 GitHub 仓库有镜像缓存,国内访问稳定且速度快。 // 采用三级回退链:fastly.jsdelivr → cdn.jsdelivr → raw.githubusercontent // 缓存延长至 6 天,刷新节流延长至 24h(数据基本不变,偶尔新增待发售游戏)。 // v1.7.1: GitHub repo 实际只存了一个 2026.json 全量文件(含 2020-2026 所有年度), // 按年探测 2024/2025.json 都会 404 导致大量数据被静默丢弃。 // 改为"单文件全量拉 2026.json + 从 date 字段动态提取 availableYears"。 // JSON 格式:[{ name: "游戏名", date: "2026年01月30日", appid: 2362060 }, ...] // v1.7.0: 增加按年度筛选——availableYears 从数据里动态提取(去重 + 倒序) // 默认展示全部年度 + 按时间降序(dateDesc) const GOTY_YEAR_MIN = 2020; // 数据里允许的最小年份(用于过滤异常数据) const GOTY_DATA_FILE = '2026.json'; // 全量文件名(repo 实际只这一个) // v1.8.1: CDN 回退链——优先 jsdelivr(国内稳定),raw.githubusercontent 兜底 const GOTY_DATA_SOURCES = [ 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/' + GOTY_DATA_FILE, // ① fastly 节点(优先) 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/' + GOTY_DATA_FILE, // ② jsdelivr 主节点 'https://raw.githubusercontent.com/SmallRob/steam-namespace/refs/heads/main/data/' + GOTY_DATA_FILE, // ③ raw 兜底 ]; function _gotyCurrentYear() { return new Date().getFullYear(); } // "2026年01月30日" / "2026 年7月8日" → "20260130"(可排序字符串) function gotyDateToSortable(dateStr) { if (!dateStr) return '99999999'; const m = String(dateStr).match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日/); if (!m) return '99999999'; return m[1] + m[2].padStart(2, '0') + m[3].padStart(2, '0'); } // 从可排序字符串中提取年份("20260130" → 2026) function gotyDateYear(sortable) { if (!sortable || sortable === '99999999') return null; return parseInt(sortable.slice(0, 4), 10); } // v2.9.22: "2026年01月30日" → "2026-01-30"(完整年月日,紧凑显示用) function gotyDateFull(dateStr) { const s = gotyDateToSortable(dateStr); if (s === '99999999') return dateStr || ''; return s.slice(0, 4) + '-' + s.slice(4, 6) + '-' + s.slice(6, 8); } // v1.0.4: 发售日期 → Date 对象(无效返回 null,区别于空字符串) // 参考 friend-manager 精品游戏 sfd-stress-featured-countdown (steam-friend-manager.user.js:11489) function gotyDateToDate(dateStr) { const s = gotyDateToSortable(dateStr); if (s === '99999999') return null; const y = +s.slice(0, 4), m = +s.slice(4, 6) - 1, d = +s.slice(6, 8); if (!y || m < 0 || d < 1) return null; const dt = new Date(y, m, d); return isNaN(dt.getTime()) ? null : dt; } // v1.0.4: 发售倒计时 HTML // 状态机:未发售(亮橙)/ 今日发售(amber 强调)/ 已发售(暗灰)/ 发售日期待定(null) // 参考 steam-friend-manager sfd-stress-featured-countdown / sfd-stress-featured-days function gotyCountdownHtml(dateStr) { const releaseDate = gotyDateToDate(dateStr); if (!releaseDate) return ''; const now = new Date(); // 抹掉当天时分秒,按"自然日"差计算(与 friend-manager parseStressDate 一致) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const relStart = new Date(releaseDate.getFullYear(), releaseDate.getMonth(), releaseDate.getDate()); const daysLeft = Math.round((relStart - todayStart) / 86400000); if (daysLeft > 0) { return '<span class="sglv-goty-countdown sglv-goty-countdown--future" title="' + (Host.isZh ? '距离发售还有 ' : 'Releases in ') + daysLeft + (Host.isZh ? ' 天' : ' days') + '">' + '<span class="sglv-goty-countdown-days">' + daysLeft + '</span>' + '<span class="sglv-goty-countdown-lbl">' + (Host.isZh ? '天后发售' : 'days left') + '</span>' + '</span>'; } if (daysLeft === 0) { return '<span class="sglv-goty-countdown sglv-goty-countdown--today" title="' + (Host.isZh ? '今日发售' : 'Releases today') + '">' + '<span class="sglv-goty-countdown-days">★</span>' + '<span class="sglv-goty-countdown-lbl">' + (Host.isZh ? '今日发售' : 'today') + '</span>' + '</span>'; } // 已发售:仅显示"X 天前发售"作历史时间感(不强调) const ago = -daysLeft; return '<span class="sglv-goty-countdown sglv-goty-countdown--past" title="' + (Host.isZh ? '已发售 ' : 'Released ') + ago + (Host.isZh ? ' 天' : ' days ago') + '">' + '<span class="sglv-goty-countdown-days">' + ago + '</span>' + '<span class="sglv-goty-countdown-lbl">' + (Host.isZh ? '天前发售' : 'days ago') + '</span>' + '</span>'; } // ==================== v1.4.0: 年度大作封面获取优化 ==================== // 参考 Steam-Wishlist-Sidebar v1.2.0(SWE)的「持久化 good URL 缓存 + 多 CDN 回退 + // appdetails API 兜底」模式。年度大作多为新发售/未发售的 2026 游戏,部分胶囊图在单一 // CDN 上尚未就绪,需多 CDN 回退 + API 兜底才能稳定获取;首次会话解析成功的 URL 持久化 // 到 GM 存储(跨会话 LRU),后续会话直接命中最终 URL,跳过整条回退链,减少封面请求与闪烁。 const GOTY_COVER_CACHE_KEY = 'sglv_goty_cover_good_v1'; const GOTY_COVER_MAX = 600; // LRU 容量上限 // v2.9.3: 不再按条目 ts 判断是否有效,也不用 180 天容器 TTL(旧实现到期后 // cacheGet 会整表丢弃 → 所有 GOTY 封面同时重爬)。 // 容器 TTL 拉长到 10 年,失效交给"用一次自愈(404 → 回退链)+ LRU 600 淘汰"。 const GOTY_COVER_CONTAINER_TTL = 10 * 365 * 24 * 3600 * 1000; // 10 年(容器不过期) const _gotyCoverGood = new Map(); // `${kind}:${appid}` → 已验证可用 URL(内存) const _gotyCoverNext = new WeakMap(); // img → 下一个待尝试的 chain 索引 const _gotyCoverApiTried = new WeakMap();// img → 是否已尝试 API 兜底 let _gotyCoverPersisted = null; // null=未加载, {key:{url,ts}} let _gotyCoverWriteTimer = null; let _gotyCoverDirty = false; const _GOTY_PLACEHOLDER = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 300'%3E%3Crect fill='%231a1f3a' width='200' height='300'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%2364748b' font-family='system-ui' font-size='16'%3E🎮%3C/text%3E%3C/svg%3E"; function _gotyCoverLoadPersisted() { if (_gotyCoverPersisted !== null) return; _gotyCoverPersisted = {}; let data = null; // v2.9.3: 直接读原始值(不经 cacheGet 的 expires 检查)—— // 旧版本写的是 180 天容器 TTL,过期后 cacheGet 会整表清空; // 这里把内容完好的旧表恢复出来,并马上用 10 年 TTL 重写。 try { if (typeof GM_getValue === 'function') { const s = GM_getValue('sglv_cache_' + GOTY_COVER_CACHE_KEY, ''); if (s) { const obj = JSON.parse(s); if (obj && obj.data && typeof obj.data === 'object') data = obj.data; } } } catch (e) { /* ignore */ } if (!data) { try { const raw = Host.cacheGet(GOTY_COVER_CACHE_KEY); if (raw && typeof raw === 'object') data = raw; } catch (e) { /* ignore */ } } if (!data) return; let n = 0; for (const k in data) { const ent = data[k]; // 只收 http(s)(历史版本可能写入过 data: 占位图) if (ent && typeof ent.url === 'string' && /^https?:\/\//i.test(ent.url)) { _gotyCoverPersisted[k] = ent; _gotyCoverGood.set(k, ent.url); n++; } } if (n) { // 立刻用长 TTL 重写,避免下一次读取又被整表过期 try { Host.cacheSet(GOTY_COVER_CACHE_KEY, _gotyCoverPersisted, GOTY_COVER_CONTAINER_TTL); } catch (e) { /* ignore */ } } } function _gotyCoverRecordGood(appid, url, kind) { if (!appid || !url) return; // v2.9.2: 占位 data URI 绝不入持久缓存(否则下次渲染直接以占位图当封面) if (!/^https?:\/\//i.test(url)) return; const key = `${kind || 'poster'}:${appid}`; if (_gotyCoverGood.get(key) === url) return; // 已记录,跳过 _gotyCoverGood.set(key, url); _gotyCoverLoadPersisted(); _gotyCoverPersisted[key] = { url, ts: Date.now() }; // LRU 淘汰 const keys = Object.keys(_gotyCoverPersisted); if (keys.length > GOTY_COVER_MAX) { keys.sort((a, b) => (_gotyCoverPersisted[a].ts || 0) - (_gotyCoverPersisted[b].ts || 0)); while (keys.length > GOTY_COVER_MAX) delete _gotyCoverPersisted[keys.shift()]; } _gotyCoverDirty = true; // 节流落盘(80ms),避免高频 load 事件触发 GM 写入风暴 if (_gotyCoverWriteTimer) return; _gotyCoverWriteTimer = setTimeout(() => { _gotyCoverWriteTimer = null; if (!_gotyCoverDirty) return; _gotyCoverDirty = false; try { Host.cacheSet(GOTY_COVER_CACHE_KEY, _gotyCoverPersisted, GOTY_COVER_CONTAINER_TTL); } catch (e) { /* ignore */ } }, 80); } function _gotyCoverGetGood(appid, kind) { _gotyCoverLoadPersisted(); const v = _gotyCoverGood.get(`${kind || 'poster'}:${appid}`) || ''; // v2.9.2: 历史版本可能把 data: 占位图写进过缓存 —— 读到也当没有 return /^https?:\/\//i.test(v) ? v : ''; } // 生成年度大作封面 <img>(无内联 onerror,回退由 _bindGotyCoverDelegation 委托处理) // kind: 'poster'(竖版卡片) | 'capsule'(横版封面) | 'header'(列表图标) // v1.9.41: 链走 coverChainLang(语言变体链;good 缓存仍用 GOTY 专用持久缓存) function gotyCoverImgTag(appid, name, kind, cls) { const good = _gotyCoverGetGood(appid, kind) || coverGoodUrl(appid, kind); const chain = coverChainLang(appid, kind); const firstUrl = good || chain[0]; return `<img class="${cls}" data-goty-img="${kind}" data-appid="${appid}" src="${firstUrl}" alt="${Host.wlEscape(name)}" loading="lazy" decoding="async" referrerpolicy="no-referrer">`; } function gotyBadgeHtml() { return '<span class="sglv-card-badge sglv-goty-goty-badge">' + Host.T.gotyBadgeGoty + '</span>'; } function gotyOwnedBadgeHtml(owned) { return owned ? '<span class="sglv-card-badge owned" style="margin-top:0">✓ ' + Host.T.steamInLib + '</span>' : '<span class="sglv-card-badge" style="background:rgba(255,255,255,0.12);color:#8f98a0;margin-top:0;padding:2px 6px;border-radius:3px;font-size:10px">' + Host.T.gotyBadgeNotOwned + '</span>'; } // v2.9.8: 命中 TGA 年度游戏冠军的年度大作卡片 → 金色 👑 chip(点击开该年提名弹窗) function gotyTgaChipHtml(appid) { const info = _tgaState.index && _tgaState.index.byAppid[Number(appid)]; if (!info) return ''; return '<span class="sglv-goty-tga-chip" data-tga-chip="' + info.year + '" title="' + (Host.isZh ? 'TGA 年度游戏冠军 · ' + info.year + '(点击查看提名)' : 'TGA Game of the Year ' + info.year + ' (click for nominees)') + '">👑 ' + (Host.isZh ? '年度游戏' : 'GOTY') + '</span>'; } function _gotyCoverPlaceholder(img) { img.removeAttribute('data-goty-img'); // 占位图不再触发回退 img.src = _GOTY_PLACEHOLDER; } // 在年度大作内容容器上绑定图片回退委托(img 的 error/load 不冒泡,用捕获阶段) function _bindGotyCoverDelegation(contentEl) { if (!contentEl || contentEl.dataset.gotyCoverBound === '1') return; contentEl.dataset.gotyCoverBound = '1'; contentEl.addEventListener('error', (e) => { const img = e.target; if (!img || img.tagName !== 'IMG' || !img.dataset.gotyImg) return; const appid = img.dataset.appid; const kind = img.dataset.gotyImg; const chain = coverChainLang(appid, kind); let next = _gotyCoverNext.get(img); if (next === undefined) next = 0; // 跳过与当前已失败 src 相同的 chain 条目,避免重复请求 const curSrc = img.getAttribute('src'); while (next < chain.length && chain[next] === curSrc) next++; if (next < chain.length) { _gotyCoverNext.set(img, next + 1); img.src = chain[next]; return; } // 回退链耗尽 → appdetails API 兜底(仅尝试一次),成功会触发 load → 记录 good URL // v1.9.29: 传递 kind 参数,使 API 兜底按图片类型选择最佳 URL if (!_gotyCoverApiTried.get(img)) { _gotyCoverApiTried.set(img, true); Host.fetchCoverApiUrl(appid, kind).then(url => { if (url) img.src = url; else _gotyCoverPlaceholder(img); }).catch(() => _gotyCoverPlaceholder(img)); } else { _gotyCoverPlaceholder(img); } }, true); contentEl.addEventListener('load', (e) => { const img = e.target; if (!img || img.tagName !== 'IMG' || !img.dataset.gotyImg) return; if (img.naturalWidth && img.naturalWidth > 1) { _gotyCoverRecordGood(img.dataset.appid, img.getAttribute('src'), img.dataset.gotyImg); } }, true); } // v1.9.5: 缓存永不过期——优先读永久缓存秒开,后台增量合并刷新(24h 节流) const _GOTY_BG_INTERVAL = 24 * 3600 * 1000; // 年度大作数据变化慢,24h 节流足够 let _gotyLastBgRefreshAt = 0; function loadGotyGames(force) { // v1.9.27: force 时重置可能卡死的 loading 标志 if (gotyState.loading) { if (!force) return; gotyState.loading = false; } if (!force) { // v1.9.5: 优先读永久缓存(永不过期) const cached = _permCacheGet('gotyGames_v2'); if (cached && Array.isArray(cached.games) && cached.games.length > 0) { gotyState.games = cached.games; gotyState.updatedAt = cached.updatedAt || ''; gotyState.source = cached.source || Host.T.gotySourceCache; gotyState.availableYears = Array.isArray(cached.availableYears) ? cached.availableYears : []; gotyState.lastFetchAt = cached.lastFetchAt || 0; gotyState.loaded = true; renderGotySections(); // v1.9.5: 后台增量刷新(24h 节流) if (_shouldThrottle(_gotyLastBgRefreshAt, _GOTY_BG_INTERVAL)) return; _refreshGotyFromRemote(false, false); return; } } // v1.9.47-fix: 无缓存首次进入 → showLoading=true 展示 loading 骨架,避免 // "打开 GOTY tab → 远程源未返回时看到"暂无数据" → 数据到达后突然刷出" 的闪烁/误判。 _refreshGotyFromRemote(force, true); } function _gotyFailLoading(msg) { gotyState.loading = false; if (gotyState.games.length === 0) { gotyState.loadError = msg; } else { gotyState.loadError = null; Host.showToast(Host.isZh ? '更新失败,已使用本地数据' : 'Update failed, using local data'); } renderGotySections(); } // v1.8.1: 远程获取——CDN 三级回退链(fastly.jsdelivr → cdn.jsdelivr → raw.githubusercontent) // 依次尝试每个数据源,任一成功即停止;全部失败才报错。 // raw.githubusercontent.com 在国内经常超时/被墙,jsdelivr CDN 更稳定。 // v1.7.1: 单文件全量拉取 2026.json(包含 2020-currentYear 所有年度) // 从每个 game 的 date 字段动态提取 availableYears(去重 + 倒序) function _refreshGotyFromRemote(force, showLoading) { if (typeof GM_xmlhttpRequest !== 'function') { if (showLoading) _gotyFailLoading('noXhr'); return; } // v1.9.27: force 时重置可能卡死的 loading 标志 if (gotyState.loading) { if (!force) return; gotyState.loading = false; } if (showLoading) { gotyState.loading = true; gotyState.loadError = null; renderGotySections(); } const targetYear = gotyState.year; // 用户已选年度(null = 全部) // v1.8.1: 处理成功响应的公共逻辑(解析 + 过滤 + 去重 + 缓存 + 渲染) function _processGotyResponse(r) { const _gotyTxt = _safeRespText(r); if (r.status === 404 || !_gotyTxt) return false; // 交给下一个源 const data = JSON.parse(_gotyTxt); if (!Array.isArray(data) || data.length === 0) return false; // 解析 + 过滤无效数据 const games = data .filter(g => g && g.appid && Number(g.appid) > 0) .map(g => { const sortable = gotyDateToSortable(g.date || ''); return { appid: Number(g.appid), name: g.name || ('App ' + g.appid), date: g.date || '', year: gotyDateYear(sortable) }; }) .filter(g => g.year && g.year >= GOTY_YEAR_MIN && g.year <= _gotyCurrentYear() + 2); if (games.length === 0) return false; // v1.9.5: 增量合并去重(按 appid)——使用统一工具函数 gotyState.games = _mergeByAppid(gotyState.games, games); // 从所有 games 的 date 字段动态提取 availableYears(去重 + 倒序) const yearSet = new Set(); gotyState.games.forEach(g => { if (g.year) yearSet.add(g.year); }); gotyState.availableYears = Array.from(yearSet).sort((a, b) => b - a); // v1.9.24: 默认展开全部年度供用户筛选(null = 全部),仅在用户已选年度不在列表时回退到全部 if (targetYear && !gotyState.availableYears.includes(targetYear)) { gotyState.year = null; } gotyState.updatedAt = new Date().toISOString().slice(0, 10); gotyState.source = Host.T.gotySourceGithub; gotyState.loaded = true; gotyState.loadError = null; gotyState.loading = false; gotyState.lastFetchAt = Date.now(); _gotyLastBgRefreshAt = Date.now(); // v1.9.5: 记录后台刷新时间戳 // v1.9.5: 写入永久缓存(永不过期) _permCacheSet('gotyGames_v2', { games: gotyState.games, availableYears: gotyState.availableYears, updatedAt: gotyState.updatedAt, source: gotyState.source, lastFetchAt: gotyState.lastFetchAt }); renderGotySections(); return true; } // v1.8.1: 依次尝试 GOTY_DATA_SOURCES 中的每个数据源 function _tryNextSource(idx) { if (idx >= GOTY_DATA_SOURCES.length) { // 所有数据源都失败 gotyState.loading = false; if (gotyState.games.length === 0) _gotyFailLoading('networkError'); return; } GM_xmlhttpRequest({ method: 'GET', url: GOTY_DATA_SOURCES[idx], timeout: 15000, onload(r) { try { if (_processGotyResponse(r)) return; // 成功,停止 } catch (e) { /* 解析异常,交给下一个源 */ } _tryNextSource(idx + 1); // 失败,尝试下一个源 }, onerror() { _tryNextSource(idx + 1); }, ontimeout() { _tryNextSource(idx + 1); }, }); } _tryNextSource(0); } // v2.x: "GotY 时光机" — 按游戏发售日期拆为"未发售 / 已发售"两组; // 搜索 + 拥有筛选 + 排序在整个游戏集上过滤,年份时间轴按显示/隐藏年份块 function getGotyByYearAndSearch() { const q = (gotyState.search || '').toLowerCase().trim(); const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); let list = gotyState.games.filter(g => g.appid > 0); if (q) { list = list.filter(g => String(g.appid).includes(q) || (g.name || '').toLowerCase().includes(q)); } if (gotyState.ownFilter === 'owned') list = list.filter(g => isOwned(g.appid)); else if (gotyState.ownFilter === 'notOwned') list = list.filter(g => !isOwned(g.appid)); // 默认 sort='dateDesc' —— 最新在前 list.sort((a, b) => { const da = gotyDateToSortable(a.date); const db = gotyDateToSortable(b.date); switch (gotyState.sort) { case 'dateDesc': return db.localeCompare(da) || (b.appid - a.appid); case 'dateAsc': return da.localeCompare(db) || (a.appid - b.appid); case 'nameAsc': return _nameCollator.compare(a.name || '~', b.name || '~') || da.localeCompare(db); case 'appidAsc': return a.appid - b.appid; default: return db.localeCompare(da) || (b.appid - a.appid); } }); // 按 year 分组(已排序,空 year 跳过) const byYear = {}; for (const g of list) { if (!g.year) continue; if (!byYear[g.year]) byYear[g.year] = []; byYear[g.year].push(g); } return byYear; } // v2.x: GotY 焦点大卡(未发售分组前 2 个,宽海报贴边 + 倒计时大数字) // 参考 steam-friend-manager 1.5.3 sfd-stress-featured-* (steam-friend-manager-1.5.3.js:11448) function renderGotyFeaturedCard(g) { const appid = g.appid; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = ownedIds.has(appid) || ownedIds.has(String(appid)); const releaseDate = gotyDateToDate(g.date); const now = new Date(); let daysLeft = null; let state = 'future'; if (releaseDate) { const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const relStart = new Date(releaseDate.getFullYear(), releaseDate.getMonth(), releaseDate.getDate()); const diff = Math.round((relStart - todayStart) / 86400000); if (diff > 0) { daysLeft = diff; state = 'future'; } else if (diff === 0) { daysLeft = 0; state = 'today'; } else { daysLeft = diff; state = 'past'; } } const cls = 'sglv-goty-featured' + (isOwned ? ' owned' : ''); const badgeText = isOwned ? (state === 'past' ? (Host.isZh ? '已拥有' : 'Owned') : (Host.isZh ? '已预购' : 'Wishlisted')) : (state === 'future' ? (Host.isZh ? '即将发售' : 'Upcoming') : (state === 'today' ? (Host.isZh ? '今日发售' : 'Today') : (Host.isZh ? '未拥有' : 'Not Owned'))); const daysText = daysLeft == null ? '—' : (state === 'past' ? '−' + Math.abs(daysLeft) : String(daysLeft)); const daysLbl = daysLeft == null ? (Host.isZh ? '待定' : 'TBD') : (state === 'future' ? (Host.isZh ? '天后' : 'd') : (state === 'today' ? (Host.isZh ? '今天' : 'today') : (Host.isZh ? '天前' : 'd ago'))); const clickUrl = 'https://store.steampowered.com/app/' + appid; const posterHtml = gotyCoverImgTag(appid, g.name, 'capsule', 'sglv-goty-featured-poster'); return '<div class="' + cls + '" data-appid="' + appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + posterHtml + '<div class="sglv-goty-featured-info">' + '<span class="sglv-goty-featured-badge">' + badgeText + '</span>' + '<div class="sglv-goty-featured-name" title="' + Host.wlEscape(g.name) + '">' + Host.wlEscape(g.name) + '</div>' + '<div class="sglv-goty-featured-date">' + Host.wlEscape(gotyDateFull(g.date)) + '</div>' + '</div>' + '<div class="sglv-goty-featured-countdown">' + '<div class="sglv-goty-featured-days">' + daysText + '</div>' + '<div class="sglv-goty-featured-days-lbl">' + daysLbl + '</div>' + '</div>' + '</div>'; } // v2.x: GotY 网格卡片(card 视图: 竖版海报 + 名称 + 拥有 + 倒计时) /** 年度大片「大海报」卡片 —— v2.9.0 * 此前 cover 视图与 card 视图共用 renderGotyGridCard + .sglv-goty-grid, * 渲染结果完全一致 → 用户点「大海报」按钮视觉零变化,表现为"切换没效果"。 * 这里给 cover 视图独立的渲染路径:横向宽海报(.sglv-goty-cover-card), * 封面用 unifiedCover 的 capsule 链铺满,标题与元信息叠在封面下半部分。 */ function renderGotyCoverCard(g) { const appid = g.appid; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = ownedIds.has(appid) || ownedIds.has(String(appid)); const releaseDate = gotyDateToDate(g.date); const now = new Date(); let countdownHtml = ''; let countdownCls = ''; if (releaseDate) { const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const relStart = new Date(releaseDate.getFullYear(), releaseDate.getMonth(), releaseDate.getDate()); const diff = Math.round((relStart - todayStart) / 86400000); if (diff > 0) { countdownHtml = '<span class="sglv-goty-cover-countdown">' + (Host.isZh ? '还有 ' + diff + ' 天' : diff + 'd') + '</span>'; countdownCls = ' future'; } else if (diff === 0) { countdownHtml = '<span class="sglv-goty-cover-countdown">' + (Host.isZh ? '今日发售' : 'Today') + '</span>'; countdownCls = ' today'; } else if (diff > -60) { countdownHtml = '<span class="sglv-goty-cover-countdown">' + (Host.isZh ? Math.abs(diff) + ' 天前' : Math.abs(diff) + 'd ago') + '</span>'; countdownCls = ' past'; } } const clickUrl = 'https://store.steampowered.com/app/' + appid; return '<div class="sglv-goty-cover-card' + (isOwned ? ' is-owned' : '') + '" data-appid="' + appid + '"' + ' onclick="window.open(\'' + clickUrl + '\',\'_blank\')" title="' + Host.wlEscape(g.name) + '">' + '<div class="sglv-goty-cover-img-wrap">' + unifiedCover(appid, g.name, 'capsule', { imgCls: 'sglv-goty-cover-img' }) + '<div class="sglv-goty-cover-grad"></div>' + '<div class="sglv-goty-cover-overlay">' + '<div class="sglv-goty-cover-title">' + Host.wlEscape(g.name) + '</div>' + '<div class="sglv-goty-cover-meta">' + (isOwned ? '<span class="sglv-goty-cover-owned">\u2713 ' + (Host.isZh ? '在库' : 'Owned') + '</span>' : '<span class="sglv-goty-cover-not-owned">' + (Host.isZh ? '未拥有' : 'Not Owned') + '</span>') + countdownHtml + gotyTgaChipHtml(appid) + '</div>' + '</div>' + '</div>' + '</div>'; } function renderGotyGridCard(g) { const appid = g.appid; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = ownedIds.has(appid) || ownedIds.has(String(appid)); const releaseDate = gotyDateToDate(g.date); const now = new Date(); let countdownHtml = ''; if (releaseDate) { const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const relStart = new Date(releaseDate.getFullYear(), releaseDate.getMonth(), releaseDate.getDate()); const diff = Math.round((relStart - todayStart) / 86400000); if (diff > 0) countdownHtml = '<span class="sglv-goty-card-countdown future">还有 ' + diff + ' 天</span>'; else if (diff === 0) countdownHtml = '<span class="sglv-goty-card-countdown today">今日发售</span>'; else if (diff > -60) countdownHtml = '<span class="sglv-goty-card-countdown past">' + Math.abs(diff) + ' 天前</span>'; } const ownedHtml = isOwned ? '<span class="sglv-goty-card-owned">✓ ' + (Host.isZh ? '在库' : 'Owned') + '</span>' : '<span class="sglv-goty-card-not-owned">' + (Host.isZh ? '未拥有' : 'Not Owned') + '</span>'; const clickUrl = 'https://store.steampowered.com/app/' + appid; return '<div class="sglv-goty-card" data-appid="' + appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')" title="' + Host.wlEscape(g.name) + '">' + '<div class="sglv-goty-card-img-wrap">' + gotyCoverImgTag(appid, g.name, 'poster', 'sglv-goty-card-img') + gotyBadgeHtml() + '</div>' + '<div class="sglv-goty-card-body">' + '<div class="sglv-goty-card-name">' + Host.wlEscape(g.name) + '</div>' + '<div class="sglv-goty-card-meta">' + ownedHtml + countdownHtml + gotyTgaChipHtml(appid) + '</div>' + '</div>' + '</div>'; } // v2.x: GotY 列表行项 function renderGotyListItem(g) { const appid = g.appid; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = ownedIds.has(appid) || ownedIds.has(String(appid)); const ownedHtml = gotyOwnedBadgeHtml(isOwned); const dateHtml = g.date ? '<span class="sglv-goty-date"><span class="sglv-goty-clock">' + Host.ICONS.clock + '</span>' + Host.wlEscape(gotyDateFull(g.date)) + gotyCountdownHtml(g.date) + '</span>' : ''; const clickUrl = 'https://store.steampowered.com/app/' + appid; return '<div class="sglv-goty-list-item" data-appid="' + appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + gotyCoverImgTag(appid, g.name, 'header', 'sglv-goty-list-icon') + '<div class="sglv-goty-list-info">' + '<div class="sglv-goty-list-name">' + Host.wlEscape(g.name) + ' <span class="sglv-card-id-inline">' + appid + '</span></div>' + '<div class="sglv-goty-list-meta">' + gotyBadgeHtml() + ' ' + ownedHtml + ' ' + dateHtml + ' ' + gotyTgaChipHtml(appid) + '</div>' + '</div>' + '</div>'; } // v2.x: 单个分组(未发售 / 已发售)HTML // sticky header(可点击折叠) + 焦点卡(0-2 个, 仅未发售组有) + 网格 5 列 function renderGotyGroup(year, title, groupGames, isUnreleased) { const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); const ownedCount = groupGames.filter(g => isOwned(g.appid)).length; const total = groupGames.length; const pct = total > 0 ? Math.round(ownedCount / total * 100) : 0; const groupKey = year + ':' + (isUnreleased ? 'unreleased' : 'released'); const collapsed = gotyState.expandedYears && gotyState.expandedYears.has('collapsed:' + groupKey); const headerCls = 'sglv-goty-group-header' + (collapsed ? ' sglv-goty-group-collapsed' : ''); const bodyCls = 'sglv-goty-group-body'; let body = ''; // 焦点卡: 仅未发售组 + >=1 款时,前 2 个 if (isUnreleased && groupGames.length >= 1) { const featured = groupGames.slice(0, 2); const rest = groupGames.slice(2); body += '<div class="sglv-goty-featured-wrap">' + featured.map(renderGotyFeaturedCard).join('') + '</div>'; if (rest.length > 0) { body += (gotyState.viewMode === 'cover') ? '<div class="sglv-goty-cover-grid">' + rest.map(renderGotyCoverCard).join('') + '</div>' : '<div class="sglv-goty-grid">' + rest.map(renderGotyGridCard).join('') + '</div>'; } } else if (gotyState.viewMode === 'cover') { // v2.9.0: cover 视图改为独立的横向宽海报卡(此前与 card 视图渲染完全一致,切换无视觉变化) body += '<div class="sglv-goty-cover-grid">' + groupGames.map(renderGotyCoverCard).join('') + '</div>'; } else if (gotyState.viewMode === 'list') { body += '<div class="sglv-goty-list-view">' + groupGames.map(renderGotyListItem).join('') + '</div>'; } else { body += '<div class="sglv-goty-grid">' + groupGames.map(renderGotyGridCard).join('') + '</div>'; } const titleHtml = (isUnreleased ? '🕐 ' : '✓ ') + title; return '<div class="sglv-goty-group" data-group-key="' + groupKey + '">' + '<div class="' + headerCls + '" data-toggle-group="' + groupKey + '">' + '<span class="sglv-goty-group-caret"></span>' + '<span class="sglv-goty-group-title">' + titleHtml + '</span>' + '<span class="sglv-goty-group-count">' + ownedCount + ' / ' + total + (Host.isZh ? ' 已拥有' : ' owned') + '</span>' + '<div class="sglv-goty-group-bar"><div class="sglv-goty-group-bar-fill" style="width:' + pct + '%"></div></div>' + '<span class="sglv-goty-group-pct">' + pct + '%</span>' + '</div>' + '<div class="' + bodyCls + '">' + body + '</div>' + '</div>'; } // v2.x: 单个年份分块(包含"未发售" + "已发售"两个子分组) // 按当前日期切分; 时间轴节点 active = 当前 visible 年份 function renderGotyYearBlock(year, games) { const now = new Date(); const nowYear = now.getFullYear(); const nowMonth = now.getMonth(); const nowDay = now.getDate(); // 按"未发售" / "已发售"切分 const unreleased = []; const released = []; games.forEach(g => { const d = gotyDateToDate(g.date); if (!d) { // 无日期: 视作已发售(展示) released.push(g); return; } const isFuture = d.getFullYear() > nowYear || (d.getFullYear() === nowYear && (d.getMonth() > nowMonth || (d.getMonth() === nowMonth && d.getDate() > nowDay))); if (isFuture) unreleased.push(g); else released.push(g); }); // 未发售组: 按日期升序(最近的发售在前) unreleased.sort((a, b) => gotyDateToSortable(a.date).localeCompare(gotyDateToSortable(b.date))); // 已发售组: 按日期降序 released.sort((a, b) => gotyDateToSortable(b.date).localeCompare(gotyDateToSortable(a.date))); const unreleasedTitle = (Host.isZh ? '即将发售' : 'Upcoming'); const releasedTitle = (Host.isZh ? '已发售' : 'Released'); let html = '<div class="sglv-goty-year-block" data-year="' + year + '">'; if (unreleased.length > 0) html += renderGotyGroup(year, year + ' · ' + unreleasedTitle, unreleased, true); if (released.length > 0) html += renderGotyGroup(year, year + ' · ' + releasedTitle, released, false); html += '</div>'; return html; } // v2.x: GotY 标签页(主入口) function renderGotyTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-psplus-wrap sglv-goty-wrap'; wrap.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0;'; const kpi = document.createElement('div'); kpi.id = 'sglv-goty-kpi'; wrap.appendChild(kpi); // 年份时间轴(横向滚动) const timeline = document.createElement('div'); timeline.id = 'sglv-goty-timeline'; timeline.className = 'sglv-goty-timeline'; wrap.appendChild(timeline); // 工具条(搜索 + 排序 + 视图 + 刷新) const toolbar = document.createElement('div'); toolbar.className = 'sglv-goty-toolbar-row'; const sortOptions = [ { key: 'dateDesc', label: Host.T.gotySortDateDesc }, { key: 'dateAsc', label: Host.T.gotySortDateAsc }, { key: 'nameAsc', label: Host.T.gotySortNameAsc }, { key: 'appidAsc', label: Host.T.gotySortAppidAsc }, ]; toolbar.innerHTML = '<input class="sglv-search" aria-label="搜索" id="sglv-goty-search" placeholder="' + Host.T.gotySearch + '" value="' + Host.wlEscape(gotyState.search) + '">' + '<select class="sglv-sort-select" aria-label="排序方式" id="sglv-goty-sort">' + sortOptions.map(o => '<option value="' + o.key + '"' + (gotyState.sort === o.key ? ' selected' : '') + '>' + o.label + '</option>').join('') + '</select>' + '<div class="sglv-view-toggle">' + '<button class="sglv-btn sglv-btn-secondary sglv-btn-icon' + (gotyState.viewMode === 'card' ? ' active' : '') + '" id="sglv-goty-card" title="' + Host.T.cardView + '">' + Host.ICONS.grid + '</button>' + '<button class="sglv-btn sglv-btn-secondary sglv-btn-icon' + (gotyState.viewMode === 'cover' ? ' active' : '') + '" id="sglv-goty-cover" title="' + Host.T.coverView + '">' + Host.ICONS.cover + '</button>' + '<button class="sglv-btn sglv-btn-secondary sglv-btn-icon' + (gotyState.viewMode === 'list' ? ' active' : '') + '" id="sglv-goty-list" title="' + Host.T.listView + '">' + Host.ICONS.list + '</button>' + '</div>' + '<button class="sglv-btn sglv-btn-secondary sglv-btn-icon" id="sglv-goty-refresh" title="' + Host.T.gotyRefreshTip + '">' + Host.ICONS.refresh + '</button>'; wrap.appendChild(toolbar); // 滚动区(年份分块) const scroll = document.createElement('div'); scroll.id = 'sglv-goty-scroll'; scroll.className = 'sglv-goty-scroll'; wrap.appendChild(scroll); parent.appendChild(wrap); // ---- 事件 ---- toolbar.querySelector('#sglv-goty-search').addEventListener('input', _debounce(e => { gotyState.search = e.target.value; renderGotySections(); }, 200)); toolbar.querySelector('#sglv-goty-sort').addEventListener('change', e => { gotyState.sort = e.target.value; renderGotySections(); }); const setView = m => { gotyState.viewMode = m; ['card', 'cover', 'list'].forEach(x => toolbar.querySelector('#sglv-goty-' + x)?.classList.toggle('active', x === m)); renderGotySections(); }; toolbar.querySelector('#sglv-goty-card').addEventListener('click', () => setView('card')); toolbar.querySelector('#sglv-goty-cover').addEventListener('click', () => setView('cover')); toolbar.querySelector('#sglv-goty-list').addEventListener('click', () => setView('list')); // v2.9.8: 刷新同时强拉 TGA 冠军数据(殿堂 + 👑 chip) toolbar.querySelector('#sglv-goty-refresh').addEventListener('click', () => { loadGotyGames(true); loadTgaWinners(true); }); // KPI 卡片点击切换拥有筛选 kpi.addEventListener('click', e => { const card = e.target.closest('[data-goty-filter]'); if (!card) return; const f = card.dataset.gotyFilter; gotyState.ownFilter = gotyState.ownFilter === f ? 'all' : f; renderGotyKpi(); renderGotySections(); }); // v2.9.6: 时间轴节点点击 = 切换年份筛选(真筛选,不再滚动定位) // 点击年份 → 只显示该年份分块;再次点击同一年份或点「全部」→ 回到全部。 // (disabled 节点为原生 button,不会派发 click,无需额外判断) timeline.addEventListener('click', e => { const node = e.target.closest('.sglv-goty-tl-node'); if (!node) return; // v2.9.8: 金色「年度游戏」节点 → 切换 TGA 殿堂视图(与年份筛选互斥) if (node.dataset.tgaView) { gotyState.tgaView = !gotyState.tgaView; if (gotyState.tgaView) gotyState.yearFilter = null; renderGotySections(); return; } gotyState.tgaView = false; const year = node.dataset.year; const y = (year === 'all' || year == null) ? null : parseInt(year, 10); gotyState.yearFilter = (gotyState.yearFilter === y) ? null : y; renderGotySections(); }); // 分组折叠切换 + v2.9.8 TGA 殿堂冠军卡点击 → 开该年提名弹窗 scroll.addEventListener('click', e => { const cell = e.target.closest('[data-tga-cell]'); if (cell) { const yr = parseInt(cell.dataset.tgaCell, 10); if (yr) openTgaNomsModal(yr); return; } const header = e.target.closest('[data-toggle-group]'); if (!header) return; const key = header.dataset.toggleGroup; const fullKey = 'collapsed:' + key; if (!gotyState.expandedYears) gotyState.expandedYears = new Set(); if (gotyState.expandedYears.has(fullKey)) { gotyState.expandedYears.delete(fullKey); } else { gotyState.expandedYears.add(fullKey); } // 只重渲染该分组 const groupEl = header.parentElement; const year = groupEl.dataset.groupKey.split(':')[0]; const gamesArr = getGotyByYearAndSearch()[+year] || []; const now = new Date(); const nowY = now.getFullYear(), nowM = now.getMonth(), nowD = now.getDate(); const isUnr = key.endsWith(':unreleased'); const filtered = isUnr ? gamesArr.filter(g => { const d = gotyDateToDate(g.date); if (!d) return false; return d.getFullYear() > nowY || (d.getFullYear() === nowY && (d.getMonth() > nowM || (d.getMonth() === nowM && d.getDate() > nowD))); }) : gamesArr.filter(g => { const d = gotyDateToDate(g.date); if (!d) return true; return !(d.getFullYear() > nowY || (d.getFullYear() === nowY && (d.getMonth() > nowM || (d.getMonth() === nowM && d.getDate() > nowD)))); }); // 简单做法: 整个 scroll 重渲染(分组小,开销可接受) renderGotySections(); }); // 滚动监听: 时间轴 active 跟随(v2.9.6: 年份筛选锁定时不跟随,active 由筛选态决定) scroll.addEventListener('scroll', () => { if (gotyState.yearFilter != null || gotyState.tgaView) return; const blocks = scroll.querySelectorAll('.sglv-goty-year-block'); let current = null; blocks.forEach(b => { const r = b.getBoundingClientRect(); const scrollTop = scroll.getBoundingClientRect().top; if (r.top - scrollTop <= 80) current = b.dataset.year; }); timeline.querySelectorAll('.sglv-goty-tl-node').forEach(n => { n.classList.toggle('sglv-goty-tl-active', n.dataset.year === String(current || 'all')); }); }); // v2.9.8: 年度大作卡片上的金色 👑 年度游戏 chip —— capture 阶段拦截点击开提名弹窗, // stopPropagation 阻止冒泡到卡片自身的 inline onclick(开商店页) scroll.addEventListener('click', e => { const chip = e.target.closest('[data-tga-chip]'); if (!chip) return; e.preventDefault(); e.stopPropagation(); const yr = parseInt(chip.dataset.tgaChip, 10); if (yr) openTgaNomsModal(yr); }, true); // 首次进入:加载数据 or 已加载则直接渲染 // v2.9.0: 绑定已移入 renderGotyScroll() —— 年度大片的视图切换/排序/年份筛选 // 都走 renderGotySections → renderGotyScroll 重渲染内容, // 绑定放在 renderGotyTab 里会早于内容生成执行(等于绑定空容器), // 导致「切视图后新卡片封面不加载」。 if (!gotyState.loaded && !gotyState.loading) { loadGotyGames(); } else { renderGotySections(); } // v2.9.8: 预载 TGA 冠军索引(缓存命中同步重渲染补 👑 chip;未命中后台拉取,供殿堂视图) loadTgaWinners(); } // v2.x: 重建时间轴 + scroll 区 function renderGotySections() { if (_collectionActiveTab !== 'goty' || !document.getElementById('sglv-goty-scroll')) return; renderGotyKpi(); renderGotyTimeline(); renderGotyScroll(); // v2.9.0: 内容渲染完成后再绑定封面链(每次重渲染都要重新绑定新生成的 wrapper) bindRestrictedCoverLoaders(document.getElementById('sglv-goty-scroll')); } // v2.x: 顶部 3 KPI(全部 / 已拥有 / 未拥有,clickable 切换 ownFilter) function renderGotyKpi() { const el = document.getElementById('sglv-goty-kpi'); if (!el) return; if (!gotyState.loaded) { el.innerHTML = ''; return; } const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); const total = gotyState.games.length; const owned = gotyState.games.filter(g => isOwned(g.appid)).length; const notOwned = total - owned; const srcLabel = gotyState.source || Host.T.gotySourceGithub; const updatedStr = gotyState.updatedAt ? (Host.isZh ? '更新于 ' : 'Updated ') + gotyState.updatedAt + ' · ' + srcLabel : (Host.isZh ? '来源:' : 'Source: ') + srcLabel; el.innerHTML = '<div class="sglv-trend-kpi-row">' + '<div class="sglv-trend-kpi-card blue clickable' + (gotyState.ownFilter === 'all' ? ' active' : '') + '" data-goty-filter="all" title="' + (Host.isZh ? '点击筛选:全部' : 'Click to filter: All') + '">' + '<div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon">' + Host.ICONS.trophy + '</span><span class="sglv-trend-kpi-label">' + Host.T.gotyKpiTotal + '</span></div>' + '<div class="sglv-trend-kpi-value">' + total.toLocaleString() + '</div>' + '<div class="sglv-trend-kpi-sub">' + updatedStr + '</div>' + '</div>' + '<div class="sglv-trend-kpi-card mine clickable' + (gotyState.ownFilter === 'owned' ? ' active' : '') + '" data-goty-filter="owned" title="' + (Host.isZh ? '点击筛选:steam在库' : 'Click to filter: In Library') + '">' + '<div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon">' + Host.ICONS.heart + '</span><span class="sglv-trend-kpi-label">' + Host.T.gotyKpiOwned + '</span></div>' + '<div class="sglv-trend-kpi-value">' + owned.toLocaleString() + '</div>' + '<div class="sglv-trend-kpi-sub">' + Host.T.gotyKpiOwnedSub + '</div>' + '</div>' + '<div class="sglv-trend-kpi-card clickable' + (gotyState.ownFilter === 'notOwned' ? ' active' : '') + '" style="border-left:3px solid #ff9800" data-goty-filter="notOwned" title="' + (Host.isZh ? '点击筛选:未拥有' : 'Click to filter: Not Owned') + '">' + '<div class="sglv-trend-kpi-header"><span class="sglv-goty-trend-kpi-icon" style="color:#ff9800">' + Host.ICONS.tag + '</span><span class="sglv-trend-kpi-label">' + Host.T.gotyKpiNotOwned + '</span></div>' + '<div class="sglv-trend-kpi-value">' + notOwned.toLocaleString() + '</div>' + '<div class="sglv-trend-kpi-sub">' + (Host.isZh ? '可在 Steam 购买' : 'Available on Steam') + '</div>' + '</div>' + '</div>'; } // v2.x: 年份时间轴(渐变主线 + 发光年份节点,首节点 "全部") function renderGotyTimeline() { const el = document.getElementById('sglv-goty-timeline'); if (!el) return; if (!gotyState.loaded) { el.innerHTML = ''; return; } // 收集各年游戏数(在当前 search/filter/sort 之后) const byYear = getGotyByYearAndSearch(); const yearStats = (gotyState.availableYears || []).slice().sort((a, b) => b - a).map(y => ({ year: y, count: (byYear[y] || []).length, })); // 总数(全部) const totalCount = Object.values(byYear).reduce((s, arr) => s + arr.length, 0); // v2.9.6: active 由 yearFilter 决定(不再依赖滚动位置)——「全部」仅在未筛选时点亮 // v2.9.8: 殿堂视图(tgaView)开启时「全部」不点亮,改点亮金色「年度游戏」节点 const isAllActive = !gotyState.tgaView && gotyState.yearFilter == null && !gotyState.search && gotyState.ownFilter === 'all'; let html = '<div class="sglv-goty-tl-line"></div>' + '<button type="button" class="sglv-goty-tl-node sglv-goty-tl-goty' + (gotyState.tgaView ? ' sglv-goty-tl-active' : '') + '" data-tga-view="1" title="' + (Host.isZh ? 'TGA 年度游戏殿堂(历届冠军)' : 'TGA Game of the Year Hall') + '">' + '<span class="sglv-goty-tl-dot"></span>' + '<span class="sglv-goty-tl-year">' + (Host.isZh ? '年度游戏' : 'GOTY') + '</span>' + '<span class="sglv-goty-tl-count">' + (Host.isZh ? '殿堂' : 'Hall') + '</span>' + '</button>' + '<button type="button" class="sglv-goty-tl-node' + (isAllActive ? ' sglv-goty-tl-active' : '') + '" data-year="all" title="' + (Host.isZh ? '全部年度' : 'All Years') + '">' + '<span class="sglv-goty-tl-dot"></span>' + '<span class="sglv-goty-tl-year">' + (Host.isZh ? '全部' : 'All') + '</span>' + '<span class="sglv-goty-tl-count">' + totalCount + '</span>' + '</button>'; yearStats.forEach(s => { // v2.9.6: 当前搜索/筛选下该年 0 条 → 禁用点击(灰显,避免"点了没反应") const empty = s.count === 0; const active = gotyState.yearFilter === s.year; const title = empty ? (Host.isZh ? '该年度暂无匹配游戏' : 'No matching games in ' + s.year) : (Host.isZh ? (active ? '取消筛选,显示全部年度' : '只看 ' + s.year + ' 年游戏') : (active ? 'Clear year filter' : 'Show only ' + s.year + ' games')); html += '<button type="button" class="sglv-goty-tl-node' + (active ? ' sglv-goty-tl-active' : '') + '"' + (empty ? ' disabled' : ' data-year="' + s.year + '"') + ' title="' + title + '">' + '<span class="sglv-goty-tl-dot"></span>' + '<span class="sglv-goty-tl-year">' + s.year + '</span>' + '<span class="sglv-goty-tl-count">' + s.count + '</span>' + '</button>'; }); el.innerHTML = html; } // v2.x: scroll 区(所有年份分块) function renderGotyScroll() { const el = document.getElementById('sglv-goty-scroll'); if (!el) return; // v2.9.8: TGA 年度游戏殿堂视图(时间轴金色节点开启时优先渲染,覆盖常规年份分块) if (gotyState.tgaView) { renderTgaHall(el); return; } if (gotyState.loading) { el.innerHTML = '<div class="sglv-progress" style="margin:16px 0"><div class="sglv-progress-fill" style="width:45%"></div></div><div class="sglv-goty-empty">' + Host.T.gotyLoading + '</div>'; return; } if (gotyState.loadError && gotyState.games.length === 0) { el.innerHTML = '<div class="sglv-goty-empty">' + Host.T.gotyLoadError + '</div>'; return; } if (!gotyState.loaded || gotyState.games.length === 0) { el.innerHTML = '<div class="sglv-goty-empty">' + Host.T.gotyNoData + '</div>'; return; } const byYear = getGotyByYearAndSearch(); let yearKeys = Object.keys(byYear).map(Number).filter(y => byYear[y].length > 0).sort((a, b) => b - a); // v2.9.6: 年份筛选 —— 时间轴选中某年时只渲染该年份分块 if (gotyState.yearFilter != null) yearKeys = yearKeys.filter(y => y === gotyState.yearFilter); if (yearKeys.length === 0) { el.innerHTML = '<div class="sglv-goty-empty">' + (gotyState.yearFilter != null ? (Host.isZh ? '该年度暂无匹配游戏' : 'No matching games in this year') : (Host.isZh ? '无匹配结果' : 'No matching results')) + '</div>'; return; } _bindGotyCoverDelegation(el); let html = ''; yearKeys.forEach(y => { html += renderGotyYearBlock(y, byYear[y]); }); el.innerHTML = html; // v2.9.6: 重渲染后回到顶部(避免残留上次滚动位置造成"顶部空白"观感) el.scrollTop = 0; } // ==================== v2.9.8: TGA 年度游戏殿堂(参考 steam-friend-manager 1.6.2)==================== // 数据源 SmallFork/json TGA_winners.json: // { _meta:{categories:{goty:'年度游戏',…}}, years:[{year, games:[{awards, appid, name, url?, nominees:[{appid,name,url?}]}]}] } // appid=number → Steam 游戏;appid=string → 非 Steam 平台独占("任天堂独占"/"PlayStation 独占"/…) // awards==='goty' 标记当年年度游戏冠军;同名游戏同年多条获奖记录 → 皇冠数=当年获奖次数 const TGA_WINNERS_SOURCES = [ 'https://fastly.jsdelivr.net/gh/SmallFork/json@main/TGA_winners.json', // ① fastly(优先,国内稳定) 'https://cdn.jsdelivr.net/gh/SmallFork/json@main/TGA_winners.json', // ② jsdelivr 主节点 'https://raw.githubusercontent.com/SmallFork/json/main/TGA_winners.json',// ③ raw 兜底 ]; const _TGA_BG_INTERVAL = 24 * 3600 * 1000; // TGA 历史数据几乎不变,24h 后台刷新足够 let _tgaLastBgRefreshAt = 0; const _tgaState = { data: null, index: null, loaded: false, loading: false, loadError: null, updatedAt: '', source: '', lastFetchAt: 0 }; // 非 Steam 平台占位画配置(appid 字符串即平台名;平台主色 + 内联 SVG 图标,取自 friend-manager) const TGA_PLATFORMS = { '任天堂独占': { color: '#ff6b6b', icon: '<rect x="2.5" y="3.5" width="8" height="17" rx="4"/><rect x="13.5" y="3.5" width="8" height="17" rx="4"/><circle cx="6.5" cy="8.5" r="1.5"/><circle cx="17.5" cy="16" r="1.5"/>' }, 'PlayStation 独占': { color: '#5b9cf8', icon: '<path d="M7.5 4.8l3.2 5.4H4.3z"/><circle cx="16.5" cy="7.5" r="2.5"/><path d="M5.2 14l4.6 4.6M9.8 14l-4.6 4.6"/><rect x="14.4" y="14.2" width="4.4" height="4.4" rx=".7"/>' }, 'Xbox/微软商店': { color: '#4ade80', icon: '<circle cx="12" cy="12" r="8.2"/><path d="M6.6 6.4c3 2.6 7.8 7.4 10.8 11M17.4 6.4c-3 2.6-7.8 7.4-10.8 11"/>' }, '暴雪战网': { color: '#38bdf8', icon: '<path d="M12 3.2a8.8 8.8 0 1 0 8.8 8.8"/><path d="M12 6.6a5.4 5.4 0 1 1-5.4 5.4"/><path d="M12 10a2 2 0 1 0 2 2"/>' }, 'Epic 独占': { color: '#cbd5e1', icon: '<path d="M12 3l7.5 2.6v6.2c0 4.6-3.5 7.6-7.5 8.7-4-1.1-7.5-4.1-7.5-8.7V5.6z"/><path d="M9.4 8.6h5.2M9.4 12h4.2M9.4 15.4h5.2"/>' }, '手机游戏': { color: '#34d399', icon: '<rect x="7" y="2.6" width="10" height="18.8" rx="2.4"/><path d="M10.5 5.6h3"/>' }, 'VR 独占': { color: '#a78bfa', icon: '<path d="M3.2 8.4a2.6 2.6 0 0 1 2.6-2.6h12.4a2.6 2.6 0 0 1 2.6 2.6v4.2a2.6 2.6 0 0 1-2.6 2.6h-2.6L12 18.4l-3.6-3.2H5.8a2.6 2.6 0 0 1-2.6-2.6z"/>' }, }; const TGA_PLATFORM_FALLBACK = { color: '#66c0f4', icon: '<rect x="2.4" y="7" width="19.2" height="9.4" rx="2.4"/><path d="M7 9.6v4.2M4.9 11.7h4.2"/><circle cx="16.6" cy="10.6" r="1"/><circle cx="18.6" cy="12.8" r="1"/>' }; const TGA_CROWN_SVG = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.8 7l4.7 3.4L12 4.5l4.5 5.9L21.2 7l-1.5 9.5a1.6 1.6 0 0 1-1.6 1.4H5.9a1.6 1.6 0 0 1-1.6-1.4z"/><rect x="5.2" y="19.3" width="13.6" height="1.8" rx="0.9"/></svg>'; // 纯函数:TGA 原始数据 → 冠军索引(无副作用,供单测) // byYear[year] = { year, winner, count(当年获奖次数), nominees[] };byAppid[steamAppid] = { year, count } // years = 有 GOTY 冠军的年份倒序;cats = 奖项 key→中文名映射 function tgaBuildWinnerIndex(data) { const idx = { byYear: {}, byAppid: {}, years: [], cats: (data && data._meta && data._meta.categories) || {} }; const years = (data && Array.isArray(data.years)) ? data.years : []; years.forEach(y => { if (!y || y.year == null || !Array.isArray(y.games)) return; const yr = Number(y.year); if (!yr) return; const winner = y.games.find(g => g && g.awards === 'goty'); if (!winner) return; const count = Math.max(1, y.games.filter(g => g && g.name === winner.name).length); idx.byYear[yr] = { year: yr, winner: winner, count: count, nominees: Array.isArray(winner.nominees) ? winner.nominees : [] }; idx.years.push(yr); if (typeof winner.appid === 'number') idx.byAppid[winner.appid] = { year: yr, count: count }; }); idx.years.sort((a, b) => b - a); return idx; } function tgaOwned(appid) { try { const ids = Host.getActiveOwnedAppIds(); return !!(ids && (ids.has(appid) || ids.has(String(appid)))); } catch (e) { return false; } } function tgaPlatform(type) { return TGA_PLATFORMS[String(type)] || TGA_PLATFORM_FALLBACK; } function tgaOpenStore(appid) { try { window.open('https://store.steampowered.com/app/' + appid, '_blank'); } catch (e) { /* ignore */ } } function tgaOwnedChipHtml(owned) { return owned ? '<span class="sglv-tga-tag owned">✓ ' + (Host.isZh ? '在库' : 'Owned') + '</span>' : '<span class="sglv-tga-tag">' + (Host.isZh ? '未拥有' : 'Not owned') + '</span>'; } function tgaExclTypeHtml(type) { return '<span class="sglv-tga-excl-type" style="color:' + tgaPlatform(type).color + '">' + Host.wlEscape(String(type)) + '</span>'; } // 非 Steam 平台占位画(vertical=true 竖版省略平台名);有商店图(game.url)则叠加海报层(失败自动移除) function tgaExclArtHtml(game, vertical) { const type = String(game.appid); const p = tgaPlatform(type); let html = '<div class="sglv-tga-excl-art" style="color:' + p.color + '">' + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + p.icon + '</svg>' + (vertical ? '' : '<div class="sglv-tga-excl-name">' + Host.wlEscape(type) + '</div>') + '<div class="sglv-tga-excl-hint">' + (Host.isZh ? '封面待补充' : 'No cover yet') + '</div>' + '</div>'; if (game.url) html += '<img class="sglv-tga-excl-img" src="' + Host.wlEscape(game.url) + '" loading="lazy" decoding="async" alt="" referrerpolicy="no-referrer">'; return html; } function tgaBindExclImgs(root) { if (!root || !root.querySelectorAll) return; root.querySelectorAll('.sglv-tga-excl-img').forEach(img => img.addEventListener('error', () => img.remove(), { once: true })); } // 皇冠(数量=当年获奖次数,最多显示 3 顶,超出以 ×N 标注) function tgaCrownHtml(count) { const n = Math.max(1, count || 1); const shown = TGA_CROWN_SVG.repeat(Math.min(3, n)); return '<div class="sglv-tga-crown' + (n > 1 ? ' multi' : '') + '" title="' + (Host.isZh ? '当年获 ' + n + ' 项奖' : n + ' awards that year') + '">' + shown + (n > 3 ? '<span>×' + n + '</span>' : '') + '</div>'; } // 殿堂单卡:竖版海报 + 金色年份徽标 + 皇冠 + 名称 + 在库/平台标记(点击开该年提名弹窗) function tgaHallCellHtml(info) { const w = info.winner, year = info.year; const isSteam = typeof w.appid === 'number'; const owned = isSteam ? tgaOwned(w.appid) : false; const cls = 'sglv-tga-cell' + (isSteam ? (owned ? ' owned' : ' not-owned') : ' excl'); const poster = '<div class="sglv-tga-poster">' + (isSteam ? gotyCoverImgTag(w.appid, w.name, 'poster', 'sglv-tga-poster-img') : tgaExclArtHtml(w, true)) + '</div>'; const ownChip = isSteam ? tgaOwnedChipHtml(owned) : tgaExclTypeHtml(w.appid); return '<div class="' + cls + '" data-tga-cell="' + year + '" title="' + Host.wlEscape(w.name) + ' · ' + year + '">' + poster + '<div class="sglv-tga-year-badge">' + year + '</div>' + tgaCrownHtml(info.count) + '<div class="sglv-tga-cell-info"><div class="sglv-tga-cell-name">' + Host.wlEscape(w.name) + '</div>' + ownChip + '</div>' + '</div>'; } // 殿堂视图(hero + 历届冠军墙);数据未就绪时触发加载并显示占位 function renderTgaHall(el) { const loadingHtml = '<div class="sglv-progress" style="margin:16px 0"><div class="sglv-progress-fill" style="width:45%"></div></div><div class="sglv-goty-empty">' + (Host.isZh ? '正在获取 TGA 年度游戏数据…' : 'Loading TGA data…') + '</div>'; if (_tgaState.loading) { el.innerHTML = loadingHtml; return; } if (!_tgaState.loaded || !_tgaState.index || !_tgaState.index.years.length) { if (_tgaState.loadError) { el.innerHTML = '<div class="sglv-goty-empty">' + (Host.isZh ? 'TGA 数据获取失败,请点击右上角刷新重试' : 'Failed to load TGA data, click refresh to retry') + '</div>'; return; } el.innerHTML = loadingHtml; loadTgaWinners(); return; } const idx = _tgaState.index; _bindGotyCoverDelegation(el); const cells = idx.years.map(y => tgaHallCellHtml(idx.byYear[y])).join(''); const newest = idx.years[0], oldest = idx.years[idx.years.length - 1]; el.innerHTML = '<div class="sglv-tga-hall">' + '<div class="sglv-tga-hero">' + '<span class="sglv-tga-hero-icon">' + Host.ICONS.trophy + '</span>' + '<div class="sglv-tga-hero-txt"><div class="sglv-tga-hero-title">' + (Host.isZh ? '年度游戏殿堂' : 'Game of the Year Hall') + '</div>' + '<div class="sglv-tga-hero-sub">TGA · GAME OF THE YEAR</div></div>' + '<span class="sglv-tga-hero-count">' + oldest + '–' + newest + ' · ' + idx.years.length + (Host.isZh ? ' 届冠军' : ' champions') + '</span>' + '</div>' + '<div class="sglv-tga-grid">' + cells + '</div>' + '</div>'; tgaBindExclImgs(el); el.scrollTop = 0; } // ---- 提名弹窗(单例):左获奖者竖版海报 + 右提名横版网格;ESC/遮罩/关闭按钮退出 ---- let _tgaNomsModal = null, _tgaNomsEsc = null; function closeTgaNomsModal() { if (_tgaNomsEsc) { document.removeEventListener('keydown', _tgaNomsEsc, true); _tgaNomsEsc = null; } if (_tgaNomsModal) { _tgaNomsModal.remove(); _tgaNomsModal = null; } } function openTgaNomsModal(year) { const idx = _tgaState.index; const info = idx && idx.byYear[year]; if (!info) return; closeTgaNomsModal(); const w = info.winner; const catLabel = (idx.cats && idx.cats.goty) || (Host.isZh ? '年度游戏' : 'Game of the Year'); const noms = info.nominees.filter(n => n && n.name !== w.name); const isSteam = typeof w.appid === 'number'; const owned = isSteam ? tgaOwned(w.appid) : false; // 左栏:获奖者竖版海报(Steam 可点开商店;非 Steam 占位画)+ 名称 + 获奖徽章 + 在库/平台标记 const winPoster = isSteam ? '<div class="sglv-tga-winner-poster" data-tga-store="' + w.appid + '">' + gotyCoverImgTag(w.appid, w.name, 'poster', 'sglv-tga-winner-img') + '</div>' : '<div class="sglv-tga-winner-poster excl">' + tgaExclArtHtml(w, true) + '</div>'; const winOwn = isSteam ? tgaOwnedChipHtml(owned) : tgaExclTypeHtml(w.appid); // 右栏:提名横版网格(不含获奖者本人) let gridHtml = ''; noms.forEach(n => { const nSteam = typeof n.appid === 'number'; if (nSteam) { const nOwned = tgaOwned(n.appid); gridHtml += '<div class="sglv-tga-nom-card' + (nOwned ? ' owned' : '') + '" data-tga-store="' + n.appid + '" title="' + Host.wlEscape(n.name) + '">' + '<div class="sglv-tga-nom-img-wrap">' + gotyCoverImgTag(n.appid, n.name, 'capsule', 'sglv-tga-nom-img') + '</div>' + '<div class="sglv-tga-nom-name">' + Host.wlEscape(n.name) + '</div>' + (nOwned ? tgaOwnedChipHtml(true) : '') + '</div>'; } else { gridHtml += '<div class="sglv-tga-nom-card excl" title="' + Host.wlEscape(n.name) + '">' + '<div class="sglv-tga-nom-img-wrap">' + tgaExclArtHtml(n, false) + '</div>' + '<div class="sglv-tga-nom-name">' + Host.wlEscape(n.name) + '</div>' + tgaExclTypeHtml(n.appid) + '</div>'; } }); const html = '<div class="sglv-tga-modal-win">' + '<div class="sglv-tga-modal-head">' + '<span class="sglv-tga-modal-trophy">' + Host.ICONS.trophy + '</span>' + '<span class="sglv-tga-modal-title">' + Host.wlEscape(catLabel) + ' · ' + year + '</span>' + '<span class="sglv-tga-modal-sub">' + (Host.isZh ? '获奖 1 · 提名 ' + noms.length + ' 款' : 'Winner 1 · ' + noms.length + ' nominees') + '</span>' + '<span class="sglv-tga-modal-close" title="' + (Host.isZh ? '关闭' : 'Close') + '">' + Host.ICONS.close + '</span>' + '</div>' + '<div class="sglv-tga-modal-body">' + '<div class="sglv-tga-noms-side">' + '<div class="sglv-tga-noms-side-top"><span class="sglv-tga-side-year">' + year + '</span><span class="sglv-tga-side-cat">' + Host.wlEscape(catLabel) + '</span></div>' + winPoster + '<div class="sglv-tga-noms-side-name">' + Host.wlEscape(w.name) + '</div>' + '<div class="sglv-tga-noms-side-own"><span class="sglv-tga-winner-badge">' + (Host.isZh ? '获奖' : 'Winner') + '</span>' + winOwn + '</div>' + '</div>' + (gridHtml ? '<div class="sglv-tga-noms-grid">' + gridHtml + '</div>' : '<div class="sglv-tga-noms-empty">' + (Host.isZh ? '暂无提名数据' : 'No nominees') + '</div>') + '</div>' + '</div>'; const modal = document.createElement('div'); modal.className = 'sglv-tga-modal'; modal.innerHTML = html; modal.addEventListener('click', ev => { if (ev.target === modal || ev.target.closest('.sglv-tga-modal-close')) { closeTgaNomsModal(); return; } const storeEl = ev.target.closest('[data-tga-store]'); if (storeEl) tgaOpenStore(storeEl.dataset.tgaStore); }); _tgaNomsEsc = ev => { if (ev.key === 'Escape') closeTgaNomsModal(); }; document.addEventListener('keydown', _tgaNomsEsc, true); document.body.appendChild(modal); _tgaNomsModal = modal; _bindGotyCoverDelegation(modal); tgaBindExclImgs(modal); } // ---- 数据加载:IDB 永久缓存优先秒开 + 24h 后台刷新(镜像 loadGotyGames)---- function _tgaFailLoading(msg) { _tgaState.loading = false; if (!_tgaState.loaded) _tgaState.loadError = msg; if (gotyState.tgaView) renderGotySections(); } function loadTgaWinners(force) { if (_tgaState.loading) { if (!force) return; _tgaState.loading = false; } if (!force) { const cached = _permCacheGet('tgaWinners_v1'); if (cached && cached.data && Array.isArray(cached.data.years) && cached.data.years.length) { _tgaState.data = cached.data; _tgaState.index = tgaBuildWinnerIndex(cached.data); _tgaState.updatedAt = cached.updatedAt || ''; _tgaState.source = cached.source || (Host.isZh ? '本地缓存' : 'local cache'); _tgaState.lastFetchAt = cached.lastFetchAt || 0; _tgaState.loaded = true; _tgaState.loadError = null; // 缓存命中后重渲染:殿堂视图 或 常规视图(补上 👑 年度游戏 chip) if (gotyState.tgaView || gotyState.loaded) renderGotySections(); if (_shouldThrottle(_tgaLastBgRefreshAt, _TGA_BG_INTERVAL)) return; _refreshTgaFromRemote(false, false); return; } } _refreshTgaFromRemote(force, true); } function _refreshTgaFromRemote(force, showLoading) { if (typeof GM_xmlhttpRequest !== 'function') { if (showLoading) _tgaFailLoading('noXhr'); return; } if (_tgaState.loading) { if (!force) return; _tgaState.loading = false; } if (showLoading) { _tgaState.loading = true; _tgaState.loadError = null; if (gotyState.tgaView) renderGotySections(); } function _processTga(r) { const txt = _safeRespText(r); if (r.status === 404 || !txt) return false; const data = JSON.parse(txt); if (!data || !Array.isArray(data.years) || !data.years.length) return false; _tgaState.data = data; _tgaState.index = tgaBuildWinnerIndex(data); _tgaState.updatedAt = new Date().toISOString().slice(0, 10); _tgaState.source = 'TGA · GitHub'; _tgaState.loaded = true; _tgaState.loadError = null; _tgaState.loading = false; _tgaState.lastFetchAt = Date.now(); _tgaLastBgRefreshAt = Date.now(); _permCacheSet('tgaWinners_v1', { data: data, updatedAt: _tgaState.updatedAt, source: _tgaState.source, lastFetchAt: _tgaState.lastFetchAt }); if (gotyState.tgaView || gotyState.loaded) renderGotySections(); return true; } function _tryNextTga(i) { if (i >= TGA_WINNERS_SOURCES.length) { _tgaState.loading = false; if (!_tgaState.loaded) _tgaFailLoading('networkError'); else if (gotyState.tgaView) renderGotySections(); return; } GM_xmlhttpRequest({ method: 'GET', url: TGA_WINNERS_SOURCES[i], timeout: 15000, onload(r) { try { if (_processTga(r)) return; } catch (e) { /* 解析异常,交给下一个源 */ } _tryNextTga(i + 1); }, onerror() { _tryNextTga(i + 1); }, ontimeout() { _tryNextTga(i + 1); }, }); } _tryNextTga(0); } // ==================== DLC 收藏标签页 (v1.9.0) ==================== // 数据源: Host.isDlc (双源: Barter.vg DLC DB + Steam type=1/6) // Host.getDlcParent (DLC→父游戏 appID, 仅 Barter.vg 提供) // Host.getDlcType (DLC 子类型: music/dlc/video, 需 enrichDlcTypes) // Host.getBundleCount (进包次数) // 加载时序: loadDlcDatabase → enrichOwnedAppTypes → enrichDlcTypes (三级渐进) // 宿主桥接: Host.isDlc, Host.isDlcDbReady, Host.loadDlcDatabase, // Host.enrichOwnedAppTypes, Host.enrichDlcTypes, Host.getDlcType, // Host.getBundleCount, Host.getDlcParent, Host.getParentDlcTotalMap function dlcTypeBadgeHtml(type) { if (type === 'music') return '<span class="sglv-dlc-type-badge-music">' + Host.T.dlcBadgeMusic + '</span>'; return '<span class="sglv-dlc-type-badge-dlc">DLC</span>'; } function dlcBundleBadgeHtml(count) { if (!count || count <= 0) return ''; return '<span class="sglv-badge" style="background:rgba(245,158,11,0.15);color:#fbbf24;border:1px solid rgba(245,158,11,0.25);font-size:9px;padding:1px 5px;border-radius:3px;font-weight:700">📦' + count + '</span>'; } function dlcPlayedBadgeHtml(playtime) { if (!playtime || playtime <= 0) return ''; return '<span class="sglv-badge" style="background:rgba(75,181,79,0.15);color:#4bb54f;border:1px solid rgba(75,181,79,0.25);font-size:9px;padding:1px 5px;border-radius:3px;font-weight:700">' + Math.round(playtime / 60) + 'h</span>'; } // v1.9.4: 已拥有/未拥有 徽章 function dlcOwnedBadgeHtml(isOwned) { if (isOwned) return '<span class="sglv-badge" style="background:rgba(102,192,244,0.15);color:#66c0f4;border:1px solid rgba(102,192,244,0.25);font-size:9px;padding:1px 5px;border-radius:3px;font-weight:700">' + (Host.isZh ? '已拥有' : 'Owned') + '</span>'; return '<span class="sglv-badge" style="background:rgba(148,163,184,0.1);color:#64748b;border:1px solid rgba(148,163,184,0.15);font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600">' + (Host.isZh ? '未拥有' : 'Not Owned') + '</span>'; } function dlcPosterImg(appid, name) { return unifiedCover(appid, name, 'poster'); } function dlcCapsuleImg(appid, name) { // v2.8.0: 统一封面(Host.capsuleImg 走旧 inline onerror 链,跨视图切换时会锁死) return unifiedCover(appid, name, 'capsule', { imgCls: 'sglv-wl-cover-img' }); } function dlcLookupParentName(parentApp) { if (!parentApp) return null; const found = Host.getOwnedGames().find(g => g.appid === parentApp || String(g.appid) === String(parentApp)); return found ? found.name : null; } // ==================== v1.9.2: DLC 子类型 IDB 永久缓存(只增不减) ==================== // 首次 enrichDlcTypes 后,所有已 enrich 的 DLC 写入 IDB。 // 下次加载:从 IDB 同步预热 dlcTypeCache,只 enrich 缓存里没有的 DLC("只增不减"增量)。 // 即使主脚本 GM 缓存丢失(用户清理脚本存储),本缓存仍能秒返已知子类型。 async function _ensureDlcTypeCacheLoaded() { if (dlcState.dlcTypeCacheLoaded) return dlcState.dlcTypeCache; if (dlcState.idbLoadPromise) return dlcState.idbLoadPromise; dlcState.idbLoadPromise = (async () => { if (!_C || !_C.IDB || typeof _C.IDB.loadAll !== 'function') { console.log('[SGLV-DLC] SGLVCore.IDB 不可用,跳过 IDB 缓存(fallback to in-memory only)'); dlcState.dlcTypeCacheLoaded = true; return dlcState.dlcTypeCache; } try { await _C.IDB.loadAll(); // 缓存版本号管理:ensureCacheVersions 升级时自动失效旧版本 if (typeof _C.ensureCacheVersions === 'function') { _C.ensureCacheVersions(_DLC_CACHE_VERSION); } const raw = _C.IDB.get(_DLC_TYPE_CACHE_IDB_KEY, null); if (raw && typeof raw === 'object' && !Array.isArray(raw)) { dlcState.dlcTypeCache = raw; console.log(`[SGLV-DLC] IDB dlcTypeCache 命中: ${Object.keys(raw).length} 个 DLC 子类型已缓存`); } else { console.log('[SGLV-DLC] IDB dlcTypeCache 空,首次使用'); } } catch (e) { console.warn('[SGLV-DLC] IDB 加载失败,使用空缓存:', e); } dlcState.dlcTypeCacheLoaded = true; return dlcState.dlcTypeCache; })(); return dlcState.idbLoadPromise; } // 持久化到 IDB(异步写,内存立即可见) function _persistDlcTypeCache() { if (!_C || !_C.IDB) return; try { _C.IDB.set(_DLC_TYPE_CACHE_IDB_KEY, dlcState.dlcTypeCache); } catch (e) { /* quota */ } } // 构建已拥有 DLC 列表(v1.9.4: 全量加载所有已知 DLC,匹配 isOwned) // v1.9.2: 同步版本保留,供"快速终态 rebuild"用;大库存场景请用 buildDlcListAsync function buildDlcList() { const t0 = performance.now(); const showFamilyShared = Host.getShowFamilyShared(); const pool = showFamilyShared ? Host.getOwnedGames() : Host.getOwnedGames().filter(g => Host.isGameOwnedByMe(g)); const ownedMap = {}; pool.forEach(g => { ownedMap[String(g.appid)] = g; }); // v1.9.11: 预构建 parentAppId → name 映射(O(1)查找),替代 dlcLookupParentName 的 O(n) 线性扫描 const parentNameMap = {}; Host.getOwnedGames().forEach(g => { if (g && g.appid) parentNameMap[String(g.appid)] = g.name; }); const dlcsByParent = (typeof Host.getDlcsByParentMap === 'function') ? Host.getDlcsByParentMap() : {}; const allDlcAppIds = new Set(); for (const dlcIds of Object.values(dlcsByParent)) { for (const id of dlcIds) allDlcAppIds.add(id); } for (const g of pool) { if (Host.isDlc(g.appid)) allDlcAppIds.add(g.appid); } console.log(`[SGLV-DLC] buildDlcList — 库存池 ${pool.length} 款 (familyShared=${showFamilyShared}), 全量 DLC ${allDlcAppIds.size} 个, isDlcDbReady=${Host.isDlcDbReady()}`); dlcState.dlcs = Array.from(allDlcAppIds).map(appid => { const owned = ownedMap[String(appid)]; const parentApp = Host.getDlcParent(appid); const type = (dlcState.dlcTypeCache && dlcState.dlcTypeCache[appid]) || Host.getDlcType(appid) || 'dlc'; return { appid, name: owned ? owned.name : ('DLC ' + appid), playtime: owned ? (owned.playtime || 0) : 0, parentApp: parentApp, parentName: parentApp ? (parentNameMap[String(parentApp)] || null) : null, type: type, bundleCount: Host.getBundleCount(appid), isOwned: !!owned, }; }); const withParent = dlcState.dlcs.filter(d => d.parentApp).length; const withType = dlcState.dlcs.filter(d => d.type !== 'dlc').length; const withBundle = dlcState.dlcs.filter(d => d.bundleCount > 0).length; console.log(`[SGLV-DLC] buildDlcList — 完成: ${dlcState.dlcs.length} 个 DLC, 父游戏=${withParent}, 子类型=${withType}, 进包=${withBundle}, 耗时 ${Math.round(performance.now() - t0)}ms`); } // v1.9.2: buildDlcList 的 async 分片版本——每 300 个 yield 一次(setTimeout 0 让出主线程) // 大库存(2k+)场景避免单次同步 200-500ms 卡顿;每个 chunk 后用 setTimeout 让浏览器处理用户输入 // v1.9.4: 全量加载——遍历所有已拥有父游戏的全部 DLC(含未拥有),匹配 isOwned async function buildDlcListAsync() { const t0 = performance.now(); const showFamilyShared = Host.getShowFamilyShared(); const allOwned = Host.getOwnedGames(); const pool = showFamilyShared ? allOwned : allOwned.filter(g => Host.isGameOwnedByMe(g)); // v1.9.4: 构建 appid → ownedGame 映射,用于匹配已拥有 DLC 和父游戏名称 const ownedMap = {}; pool.forEach(g => { ownedMap[String(g.appid)] = g; }); // v1.9.11: 预构建 parentAppId → name 映射(O(1)查找),替代 dlcLookupParentName 的 O(n) 线性扫描 // 71371 DLC × 10891 games 线性扫描 = ~7.8亿次比较,导致 buildDlcListAsync 永远无法完成 const parentNameMap = {}; allOwned.forEach(g => { if (g && g.appid) parentNameMap[String(g.appid)] = g.name; }); // v1.9.4: 获取父游戏→DLC列表 反向映射(Host.getDlcsByParentMap) const dlcsByParent = (typeof Host.getDlcsByParentMap === 'function') ? Host.getDlcsByParentMap() : {}; const parentKeys = Object.keys(dlcsByParent); // 收集所有需要处理的 DLC appid(全量 = 所有已知 DLC) const allDlcAppIds = new Set(); // 从父游戏反向映射收集 for (const dlcIds of Object.values(dlcsByParent)) { for (const id of dlcIds) allDlcAppIds.add(id); } // 同时保留原有逻辑:已拥有且 isDlc 的也加入(可能有些 DLC 不在 Barter.vg 数据库) for (const g of pool) { if (Host.isDlc(g.appid)) allDlcAppIds.add(g.appid); } const dlcCandidates = Array.from(allDlcAppIds); console.log(`[SGLV-DLC] buildDlcListAsync — 库存池 ${pool.length} 款, 父游戏 ${parentKeys.length} 个, 全量 DLC ${dlcCandidates.length} 个 (含未拥有)`); const out = []; const CHUNK = 300; for (let i = 0; i < dlcCandidates.length; i += CHUNK) { const slice = dlcCandidates.slice(i, i + CHUNK); slice.forEach(appid => { const owned = ownedMap[String(appid)]; const parentApp = Host.getDlcParent(appid); let type = (dlcState.dlcTypeCache && dlcState.dlcTypeCache[appid]) || Host.getDlcType(appid) || 'dlc'; out.push({ appid, name: owned ? owned.name : ('DLC ' + appid), playtime: owned ? (owned.playtime || 0) : 0, parentApp, parentName: parentApp ? (parentNameMap[String(parentApp)] || null) : null, type, bundleCount: Host.getBundleCount(appid), isOwned: !!owned, }); }); // 每片后让出主线程——浏览器可处理 UI 事件(点击、滚动等) if (i + CHUNK < dlcCandidates.length) { await new Promise(r => setTimeout(r, 0)); } } dlcState.dlcs = out; // v1.9.8: 清除过滤缓存(dlcs 内容已变,需重算 filtered/sorted 结果) dlcState._filteredCache = null; dlcState._filteredCacheKey = null; const withParent = out.filter(d => d.parentApp).length; const withType = out.filter(d => d.type !== 'dlc').length; const withBundle = out.filter(d => d.bundleCount > 0).length; console.log(`[SGLV-DLC] buildDlcListAsync — 完成: ${out.length} 个 DLC, 父游戏=${withParent}, 子类型=${withType}, 进包=${withBundle}, 耗时 ${Math.round(performance.now() - t0)}ms`); } // 三级渐进加载: Barter.vg DLC DB → Steam 全库存 type → DLC 子类型 // v1.9.1: L1 + L2 并行启动,任一完成立即 schedule buildDlcList + render(不阻塞 UI 进程) // v1.9.2: 四阶段加载——阶段 0 同步预热(IDB + 主脚本闭包),秒出;无缓存才走阶段 1+2+3 冷启 // L3 真正"只增不减"增量:只 enrich dlcTypeCache 里没有的 DLC // v1.9.8: 渐进式渲染——L1+L2 完成后立即 loaded=true 渲染数据,L3 后台异步获取子类型 // DLC 数据只增不减,缓存永久有效,支持增量更新;修复加载完成但一直显示加载中的问题 async function loadDlcData(force) { if (dlcState.loading) { // v1.9.27: force 时重置可能卡死的 loading 标志 if (!force) { console.log('[SGLV-DLC] loadDlcData — 已在加载中, 跳过'); return; } console.log('[SGLV-DLC] loadDlcData — force 重置卡死的 loading 标志'); dlcState.loading = false; } if (dlcState.loaded && !force) { console.log('[SGLV-DLC] loadDlcData — 已加载且非强制, 直接渲染'); renderDlcSections(); return; } const t0 = performance.now(); console.log(`[SGLV-DLC] loadDlcData — 开始加载 (force=${!!force}, dlcState.loaded=${dlcState.loaded})`); dlcState.loading = true; dlcState.loadError = null; dlcState.stage = 0; dlcState.level1Done = false; dlcState.level2Done = false; dlcState.rebuildQueued = false; // ==================== 阶段 0: 同步预热(关键!) ==================== // 1) IDB 永久缓存 → 立即填 dlcTypeCache(只增不减,即使主脚本 GM 缓存丢失也能秒返) // 2) Host.loadDlcDatabaseFromCacheSync → 同步填主脚本 dlcDbData / appTypeMap 闭包 // 3) buildDlcListAsync → 立即 build(分片,非阻塞) // 4) 如果拿到数据(dlcState.dlcs.length > 0),直接 loaded=true → 秒出 UI // 5) 后台异步跑 L1+L2(刷新可能新增的 DLC)+ L3 增量 enrich try { await _ensureDlcTypeCacheLoaded(); if (typeof Host.loadDlcDatabaseFromCacheSync === 'function') { try { Host.loadDlcDatabaseFromCacheSync(); } catch (e) { /* noop */ } } await buildDlcListAsync(); if (dlcState.dlcs.length > 0) { dlcState.loaded = true; dlcState.loading = false; dlcState.stage = 0; try { dlcState.parentTotalMap = Host.getParentDlcTotalMap() || {}; } catch (e) { dlcState.parentTotalMap = {}; } renderDlcSections(); console.log(`[SGLV-DLC] 阶段 0 缓存命中,立即出 ${dlcState.dlcs.length} 个 DLC,耗时 ${Math.round(performance.now() - t0)}ms`); // 后台异步刷新(不阻塞,fire-and-forget) _backgroundDlcRefresh(); return; } console.log('[SGLV-DLC] 阶段 0 无缓存(dlcs=0),降级到阶段 1+2+3 冷启'); } catch (e) { console.warn('[SGLV-DLC] 阶段 0 预热失败,降级到冷启:', e); } // ==================== 阶段 1+2: 冷启(无缓存场景) ==================== dlcState.stage = 1; renderDlcSections(); if (_StageProgress && typeof _StageProgress.ensure === 'function') { try { _StageProgress.ensure(); _StageProgress.setStage(1, 3, 'DLC'); } catch (e) { /* noop */ } } // ---- 工具:scheduleRebuild ---- const scheduleRebuild = (reason) => { if (dlcState.rebuildQueued) return; dlcState.rebuildQueued = true; setTimeout(() => { dlcState.rebuildQueued = false; if (_collectionActiveTab !== 'dlc') return; (async () => { try { const t = performance.now(); await buildDlcListAsync(); renderDlcSections(); console.log(`[SGLV-DLC] scheduleRebuild(${reason}) — dlcs=${dlcState.dlcs.length}, 渲染耗时 ${Math.round(performance.now() - t)}ms`); } catch (e) { console.warn('[SGLV-DLC] scheduleRebuild failed:', e); } })(); }, 0); }; try { // ==================== Level 1 ∥ Level 2 并行 ==================== const p1 = (async () => { const t1 = performance.now(); try { await Host.loadDlcDatabase(); dlcState.level1Done = true; console.log(`[SGLV-DLC] [Level 1/3] Barter.vg 就绪, 耗时 ${Math.round(performance.now() - t1)}ms`); scheduleRebuild('L1-done'); } catch (e) { console.warn('[SGLV-DLC] [Level 1/3] 失败:', e); } })(); const p2 = (async () => { const t2 = performance.now(); try { await Host.enrichOwnedAppTypes(); dlcState.level2Done = true; dlcState.stage = 2; if (_StageProgress) try { _StageProgress.setStage(2, 3, 'appType'); } catch (e) {} console.log(`[SGLV-DLC] [Level 2/3] appType 就绪, 耗时 ${Math.round(performance.now() - t2)}ms`); scheduleRebuild('L2-done'); } catch (e) { console.warn('[SGLV-DLC] [Level 2/3] 失败:', e); } })(); await Promise.all([p1, p2]); // v1.9.8: L1+L2 完成后立即 build + 渐进式渲染(loaded=true),L3 后台异步 dlcState.stage = 2; await buildDlcListAsync(); try { dlcState.parentTotalMap = Host.getParentDlcTotalMap() || {}; } catch (e) { dlcState.parentTotalMap = {}; } // v1.9.8: 关键改变 — L1+L2 完成后立即标记 loaded=true,用户可以看到 DLC 列表 // L3 (DLC 子类型) 在后台异步获取,不阻塞 UI dlcState.loaded = true; dlcState.loading = false; dlcState.page = 1; dlcState.stage = 0; if (_StageProgress) try { _StageProgress.hide(); } catch (e) {} renderDlcSections(); console.log(`[SGLV-DLC] L1+L2 完成, 渐进式渲染 ${dlcState.dlcs.length} 个 DLC, 耗时 ${Math.round(performance.now() - t0)}ms`); // v1.9.8: L3 后台异步获取 DLC 子类型(fire-and-forget,不阻塞 UI) _backgroundDlcTypeEnrich(); } catch (e) { dlcState.loadError = e.message || 'Load failed'; console.warn(`[SGLV-DLC] loadDlcData — 加载失败: ${e.message}, 已耗时 ${Math.round(performance.now() - t0)}ms`, e); dlcState.loading = false; if (_StageProgress) try { _StageProgress.hide(); } catch (e) {} renderDlcSections(); } } // v1.9.8: 后台 DLC 子类型增量获取 — L1+L2 完成后 fire-and-forget // 从 dlcTypeCache 中筛选未获取类型的 DLC,调用 Host.enrichDlcTypes 增量获取 // 获取完成后 rebuild + re-render,更新 KPI 和卡片类型显示 // DLC 数据只增不减,dlcTypeCache 永久缓存(IDB),支持增量更新 async function _backgroundDlcTypeEnrich() { if (dlcState.dlcTypeEnriching) { console.log('[SGLV-DLC] [bg L3] 已在获取中, 跳过'); return; } const dlcIds = dlcState.dlcs.map(d => d.appid); if (dlcIds.length === 0) return; // 增量:只获取 dlcTypeCache 中没有的 DLC const dlcIdsToEnrich = dlcIds.filter(id => !dlcState.dlcTypeCache[id]); if (dlcIdsToEnrich.length === 0) { console.log(`[SGLV-DLC] [bg L3] 跳过: 全部 ${dlcIds.length} 个 DLC 已在本地 IDB 缓存中`); return; } dlcState.dlcTypeEnriching = true; const t3 = performance.now(); console.log(`[SGLV-DLC] [bg L3] 增量 enrich ${dlcIdsToEnrich.length}/${dlcIds.length} 个 DLC(命中 ${dlcIds.length - dlcIdsToEnrich.length})`); try { // v2.9.68: 使用 onProgress 回调实现渐进式 re-render let renderTimer = null; await Host.enrichDlcTypes(dlcIdsToEnrich, { onProgress: (done, total) => { // 节流渲染:每 1.5s 最多刷新一次 UI if (!renderTimer) { renderTimer = setTimeout(() => { renderTimer = null; if (_collectionActiveTab !== 'dlc') return; (async () => { try { // 从主脚本拉取已获取的类型,合并到 dlcTypeCache let newCount = 0; for (const id of dlcIdsToEnrich) { const t = Host.getDlcType(id); if (t && !dlcState.dlcTypeCache[id]) { dlcState.dlcTypeCache[id] = t; newCount++; } } if (newCount > 0) { _persistDlcTypeCache(); // 清除过滤缓存,让新类型生效 dlcState._filteredCache = null; dlcState._filteredCacheKey = null; await buildDlcListAsync(); renderDlcSections(); } } catch (e) { /* noop */ } })(); }, 1500); } } }); // 最终:拉取所有类型,rebuild + re-render let newCount = 0; for (const id of dlcIdsToEnrich) { const t = Host.getDlcType(id); if (t && !dlcState.dlcTypeCache[id]) { dlcState.dlcTypeCache[id] = t; newCount++; } } if (newCount > 0) { _persistDlcTypeCache(); dlcState._filteredCache = null; dlcState._filteredCacheKey = null; await buildDlcListAsync(); } try { dlcState.parentTotalMap = Host.getParentDlcTotalMap() || {}; } catch (e) {} if (_collectionActiveTab === 'dlc') renderDlcSections(); console.log(`[SGLV-DLC] [bg L3] 增量完成: 新增 ${newCount} 个 type, 累计 ${Object.keys(dlcState.dlcTypeCache).length} 个, 耗时 ${Math.round(performance.now() - t3)}ms`); } catch (e) { console.warn('[SGLV-DLC] [bg L3] 失败:', e); } finally { dlcState.dlcTypeEnriching = false; } } // v1.9.2: 后台异步刷新——阶段 0 缓存命中后,fire-and-forget 跑 L1+L2 + L3 增量 // v1.9.3: 节流(6h)——避免短时间内重复刷新 // 目标:在不阻塞 UI 的前提下,让 dlcTypeCache 持续累计(只增不减,后续秒返) async function _backgroundDlcRefresh() { // v1.9.3: 节流——6h 内已刷过则跳过(多次打开 DLC tab 不会重复跑) if (Date.now() - (dlcState.lastBgRefreshAt || 0) < _DLC_BG_REFRESH_INTERVAL) { console.log(`[SGLV-DLC] 后台刷新跳过(节流): 上次 ${new Date(dlcState.lastBgRefreshAt).toLocaleString()}`); return; } dlcState.lastBgRefreshAt = Date.now(); const tb = performance.now(); try { // L1 ∥ L2 并行(主脚本缓存命中时秒返,否则走网络) const p1 = Host.loadDlcDatabase() .then(() => { dlcState.level1Done = true; }) .catch(e => console.warn('[SGLV-DLC] [bg L1] 失败:', e)); const p2 = Host.enrichOwnedAppTypes() .then(() => { dlcState.level2Done = true; }) .catch(e => console.warn('[SGLV-DLC] [bg L2] 失败:', e)); await Promise.all([p1, p2]); console.log(`[SGLV-DLC] [bg] L1+L2 完成, 耗时 ${Math.round(performance.now() - tb)}ms`); // 重建(可能新增 DLC) await buildDlcListAsync(); dlcState._filteredCache = null; dlcState._filteredCacheKey = null; if (_collectionActiveTab === 'dlc') renderDlcSections(); // v1.9.8: L3 增量 enrich 委托给 _backgroundDlcTypeEnrich(复用渐进式渲染逻辑) await _backgroundDlcTypeEnrich(); console.log(`[SGLV-DLC] 后台刷新完成, 总耗时 ${Math.round(performance.now() - tb)}ms`); } catch (e) { console.warn('[SGLV-DLC] 后台刷新失败:', e); } } // 筛选 + 排序后的扁平 DLC 列表 // v1.9.8: 缓存结果——当 dlcs/search/typeFilter/ownFilter/sort 未变时跳过重算,减少点击卡顿 function getFilteredDlc() { const cacheKey = `${dlcState.dlcs.length}|${dlcState.search}|${dlcState.typeFilter}|${dlcState.ownFilter}|${dlcState.sort}`; if (dlcState._filteredCacheKey === cacheKey && dlcState._filteredCache) { return dlcState._filteredCache; } const q = dlcState.search.toLowerCase().trim(); let list = dlcState.dlcs.slice(); if (q) { list = list.filter(d => { if (String(d.appid).includes(q)) return true; if ((d.name || '').toLowerCase().includes(q)) return true; if (d.parentName && d.parentName.toLowerCase().includes(q)) return true; return false; }); } if (dlcState.typeFilter === 'dlc') list = list.filter(d => d.type === 'dlc'); else if (dlcState.typeFilter === 'music') list = list.filter(d => d.type === 'music'); else if (dlcState.typeFilter === 'bundled') list = list.filter(d => d.bundleCount > 0); else if (dlcState.typeFilter === 'played') list = list.filter(d => d.playtime > 0); // v1.9.4: 已拥有筛选 if (dlcState.ownFilter === 'owned') list = list.filter(d => d.isOwned); // v1.9.5: 使用缓存的 _nameCollator,避免每次渲染重新构造 // v1.9.13: 激进防御——sort 出错时降级为 appid 排序,绝不抛异常中断渲染 try { list.sort((a, b) => { let r = 0; switch (dlcState.sort) { case 'appidAsc': r = a.appid - b.appid; break; case 'appidDesc': r = b.appid - a.appid; break; case 'nameAsc': r = _nameCollator.compare(a.name || '~', b.name || '~') || (a.appid - b.appid); break; case 'typeAsc': r = (a.type || 'z').localeCompare(b.type || 'z') || (a.appid - b.appid); break; case 'parentAsc': default: r = _nameCollator.compare(a.parentName || a.parentApp || '~', b.parentName || b.parentApp || '~') || (a.appid - b.appid); } return r || (a.appid - b.appid); }); } catch (sortErr) { console.warn('[SGLV-DLC] getFilteredDlc sort 失败,降级 appid 排序:', sortErr); list.sort((a, b) => a.appid - b.appid); } // v1.9.8: 缓存结果 dlcState._filteredCache = list; dlcState._filteredCacheKey = cacheKey; return list; } // 按父游戏分组(用于分组视图) function getGroupedDlc(filtered) { const groups = {}; const noParent = []; filtered.forEach(d => { if (d.parentApp) { const key = String(d.parentApp); if (!groups[key]) groups[key] = { parentApp: d.parentApp, parentName: d.parentName, dlcs: [] }; groups[key].dlcs.push(d); } else { noParent.push(d); } }); let groupList = Object.values(groups); // v1.9.5: 使用缓存的 _nameCollator // v1.9.13: 激进防御——sort 出错时降级为不排序 try { groupList.sort((a, b) => _nameCollator.compare(a.parentName || String(a.parentApp), b.parentName || String(b.parentApp))); } catch (sortErr) { console.warn('[SGLV-DLC] getGroupedDlc sort 失败,跳过排序:', sortErr); } if (noParent.length > 0) { groupList.push({ parentApp: null, parentName: Host.T.dlcUnknownParent, dlcs: noParent }); } // 计算完成度(v1.9.4: ownedDlc 只统计已拥有,totalDlc 为全部已知 DLC) groupList.forEach(g => { g.totalDlc = g.dlcs.length; g.ownedDlc = g.dlcs.filter(d => d.isOwned).length; g.pct = g.totalDlc > 0 ? Math.round(g.ownedDlc / g.totalDlc * 100) : 100; }); return groupList; } function renderDlcTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-psplus-wrap'; const kpi = document.createElement('div'); kpi.id = 'sglv-dlc-kpi'; wrap.appendChild(kpi); const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar'; const sortOptions = [ { key: 'parentAsc', label: Host.T.dlcSortParent }, { key: 'appidDesc', label: Host.T.dlcSortAppidDesc }, { key: 'appidAsc', label: Host.T.dlcSortAppidAsc }, { key: 'nameAsc', label: Host.T.dlcSortNameAsc }, { key: 'typeAsc', label: Host.T.dlcSortType }, ]; // v1.9.1: groupMode=true 时视图按钮置灰(分组视图天然是 card 布局,viewMode 概念对它无意义) const viewToggleDisabledCls = dlcState.groupMode ? ' sglv-view-disabled' : ''; toolbar.innerHTML = ` <input class="sglv-search" aria-label="搜索" id="sglv-dlc-search" placeholder="${Host.T.dlcSearch}" value="${Host.wlEscape(dlcState.search)}"> <select class="sglv-sort-select" aria-label="排序方式" id="sglv-dlc-sort"> ${sortOptions.map(o => `<option value="${o.key}"${dlcState.sort === o.key ? ' selected' : ''}>${o.label}</option>`).join('')} </select> <div class="sglv-view-toggle${viewToggleDisabledCls}"> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${dlcState.viewMode === 'card' ? ' active' : ''}" id="sglv-dlc-card" title="${Host.T.cardView}">${Host.ICONS.grid}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${dlcState.viewMode === 'cover' ? ' active' : ''}" id="sglv-dlc-cover" title="${Host.T.coverView}">${Host.ICONS.cover}</button> <button class="sglv-btn sglv-btn-secondary sglv-btn-icon${dlcState.viewMode === 'list' ? ' active' : ''}" id="sglv-dlc-list" title="${Host.T.listView}">${Host.ICONS.list}</button> </div> <div class="sglv-toolbar-sep"></div> <div class="sglv-dlc-own-filter"> <button class="sglv-btn sglv-btn-secondary${dlcState.ownFilter === 'all' ? ' active' : ''}" id="sglv-dlc-own-all">${Host.isZh ? '全部' : 'All'}</button> <button class="sglv-btn sglv-btn-secondary${dlcState.ownFilter === 'owned' ? ' active' : ''}" id="sglv-dlc-own-owned">${Host.isZh ? '已拥有' : 'Owned'}</button> </div> <div class="sglv-dlc-group-toggle"> <button class="sglv-btn sglv-btn-secondary${dlcState.groupMode ? ' active' : ''}" id="sglv-dlc-grouped">${Host.T.dlcGroupByParent}</button> <button class="sglv-btn sglv-btn-secondary${!dlcState.groupMode ? ' active' : ''}" id="sglv-dlc-flat">${Host.T.dlcFlatList}</button> </div> <div class="sglv-pagination" id="sglv-dlc-pagination"></div> `; wrap.appendChild(toolbar); const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-dlc-content'; wrap.appendChild(content); parent.appendChild(wrap); // 事件绑定 toolbar.querySelector('#sglv-dlc-search').addEventListener('input', _debounce((e) => { dlcState.search = e.target.value; dlcState.page = 1; renderDlcGamesList(); }, 200)); toolbar.querySelector('#sglv-dlc-sort').addEventListener('change', (e) => { dlcState.sort = e.target.value; dlcState.page = 1; renderDlcGamesList(); }); const setDlcViewMode = (mode) => { // v1.9.1: groupMode=true 时视图按钮被禁用,直接返回(双保险,防止 pointer-events bypass) if (dlcState.groupMode) return; dlcState.viewMode = mode; ['card', 'cover', 'list'].forEach(m => { toolbar.querySelector('#sglv-dlc-' + m)?.classList.toggle('active', m === mode); }); renderDlcGamesList(); }; toolbar.querySelector('#sglv-dlc-card').addEventListener('click', () => setDlcViewMode('card')); toolbar.querySelector('#sglv-dlc-cover').addEventListener('click', () => setDlcViewMode('cover')); toolbar.querySelector('#sglv-dlc-list').addEventListener('click', () => setDlcViewMode('list')); // v1.9.4: 已拥有筛选切换 const setDlcOwnFilter = (filter) => { dlcState.ownFilter = filter; dlcState.page = 1; toolbar.querySelector('#sglv-dlc-own-all')?.classList.toggle('active', filter === 'all'); toolbar.querySelector('#sglv-dlc-own-owned')?.classList.toggle('active', filter === 'owned'); renderDlcGamesList(); }; toolbar.querySelector('#sglv-dlc-own-all').addEventListener('click', () => setDlcOwnFilter('all')); toolbar.querySelector('#sglv-dlc-own-owned').addEventListener('click', () => setDlcOwnFilter('owned')); // v1.9.1: 统一 grouped/flat 切换逻辑,同步视图按钮置灰状态 const applyDlcGroupMode = (grouped) => { dlcState.groupMode = grouped; dlcState.page = 1; toolbar.querySelector('#sglv-dlc-grouped').classList.toggle('active', grouped); toolbar.querySelector('#sglv-dlc-flat').classList.toggle('active', !grouped); toolbar.querySelector('.sglv-view-toggle')?.classList.toggle('sglv-view-disabled', grouped); renderDlcGamesList(); }; toolbar.querySelector('#sglv-dlc-grouped').addEventListener('click', () => applyDlcGroupMode(true)); toolbar.querySelector('#sglv-dlc-flat').addEventListener('click', () => applyDlcGroupMode(false)); // KPI 卡片点击筛选 kpi.addEventListener('click', e => { const card = e.target.closest('[data-dlc-filter]'); if (!card) return; const f = card.dataset.dlcFilter; dlcState.typeFilter = dlcState.typeFilter === f ? 'all' : f; dlcState.page = 1; renderDlcKpi(); renderDlcGamesList(); }); // v1.9.3: 用户点击 tab 立即进入——先无条件渲染骨架(KPI + content 都是 skeleton) // 后台 loadDlcData 完成后再 renderDlcSections() 替换为真实数据 // 这样无论冷启/热数据都能"秒进" renderDlcSections(); if (!dlcState.loaded && !dlcState.loading) { // fire-and-forget:后台加载,不阻塞 UI loadDlcData(); } } function renderDlcSections() { if (_collectionActiveTab !== 'dlc' || !document.getElementById('sglv-dlc-content')) return; // v1.9.13: 激进防御——任何渲染异常都不应中断 loadDlcData 流程 try { renderDlcKpi(); renderDlcGamesList(); } catch (e) { console.warn('[SGLV-DLC] renderDlcSections 渲染异常(已降级):', e); const content = document.getElementById('sglv-dlc-content'); if (content && !content.innerHTML.trim()) { content.innerHTML = '<div class="sglv-empty">' + (Host.isZh ? 'DLC 渲染异常,数据已加载,请尝试切换排序或刷新页面' : 'DLC render error, data loaded. Try switching sort or refresh') + '</div>'; } } } // v1.9.1: 阶段进度条 + DLC 卡片骨架 HTML 工厂 // 用于 loading 状态:KPI 区 5 个 shimmer 卡 + content 区阶段条 + 12 个 card 占位 // 数据稳定(loaded=true)前不展示真实 KPI 数字,避免数字跳变 // v1.9.4: spinner 加载等待动画(参考 steam-friend-manager 的 spinner 风格) // v2.9.67: 修复 wlEscape 三元表达式 bug(两分支都调用了 Host.wlEscape) function renderDlcLoadingHtml() { const stage = DLC_STAGES[dlcState.stage] || { pct: 30, zh: '加载中…', en: 'Loading…' }; const pct = stage.pct; const text = Host && Host.isZh ? stage.zh : stage.en; const safeText = Host && Host.wlEscape ? Host.wlEscape(text) : text; const spinSvg = '<svg class="sglv-dlc-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>'; return `<div class="sglv-dlc-loading"> ${spinSvg} <div class="sglv-dlc-loading-text">${safeText}</div> <div class="sglv-dlc-loading-bar"><div class="sglv-dlc-loading-bar-fill" style="width:${pct}%"></div></div> <div class="sglv-dlc-loading-sub">${Host && Host.isZh ? '正在加载所有已知 DLC 数据…' : 'Loading all known DLC data…'}</div> </div>`; } function renderDlcKpiSkeletonHtml() { const skel = '<div class="sglv-skel-line lg"></div><div class="sglv-skel-line md"></div><div class="sglv-skel-line sm"></div>'; const cards = Array.from({ length: 5 }, () => `<div class="sglv-trend-kpi-card skeleton">${skel}</div>`).join(''); return `<div class="sglv-trend-kpi-row">${cards}</div>`; } function renderDlcKpi() { const el = document.getElementById('sglv-dlc-kpi'); if (!el) return; // v1.9.1: 加载中 / 未加载完成统一显示骨架(数字会跳变,体验差) if (dlcState.loading || !dlcState.loaded) { el.innerHTML = renderDlcKpiSkeletonHtml(); return; } const total = dlcState.dlcs.length; const owned = dlcState.dlcs.filter(d => d.isOwned).length; const regular = dlcState.dlcs.filter(d => d.type === 'dlc').length; const music = dlcState.dlcs.filter(d => d.type === 'music').length; const bundled = dlcState.dlcs.filter(d => d.bundleCount > 0).length; const played = dlcState.dlcs.filter(d => d.playtime > 0).length; const parentCount = new Set(dlcState.dlcs.filter(d => d.parentApp).map(d => String(d.parentApp))).size; const noParent = dlcState.dlcs.filter(d => !d.parentApp).length; // v1.9.8: 后台子类型获取中时,在 KPI 区底部显示进度提示 const enrichHint = dlcState.dlcTypeEnriching ? `<div style="text-align:center;padding:4px 0 2px;font-size:11px;color:var(--sglv-accent, #a78bfa);display:flex;align-items:center;justify-content:center;gap:6px"> <svg style="width:12px;height:12px;animation:sgc-spin 1s linear infinite" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> ${Host.isZh ? '后台获取 DLC 子类型中…' : 'Fetching DLC subtypes in background…'} </div>` : ''; const ownPct = total > 0 ? Math.round(owned / total * 100) : 0; const subText = Host.T.dlcKpiSub.replace('{n}', parentCount) + (noParent > 0 ? ' · ' + Host.T.dlcKpiNoParent + ' ' + noParent : ''); el.innerHTML = ` <div class="sglv-trend-kpi-row"> <div class="sglv-trend-kpi-card blue clickable${dlcState.typeFilter === 'all' ? ' active' : ''}" data-dlc-filter="all" title="${Host.isZh ? '点击筛选:全部' : 'Click to filter: All'}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon">${Host.ICONS.puzzle}</span><span class="sglv-trend-kpi-label">${Host.T.dlcKpiTotal}</span></div> <div class="sglv-trend-kpi-value">${total.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${subText}</div> <div class="sglv-trend-kpi-sub" style="color:#4bb54f;font-weight:600">${Host.isZh ? '已拥有 ' + owned.toLocaleString() + ' (' + ownPct + '%)' : 'Owned ' + owned.toLocaleString() + ' (' + ownPct + '%)'}</div> </div> <div class="sglv-trend-kpi-card clickable${dlcState.typeFilter === 'dlc' ? ' active' : ''}" style="border-left:3px solid var(--sglv-accent-deep, #8b5cf6)" data-dlc-filter="dlc" title="${Host.isZh ? '点击筛选:常规 DLC' : 'Click to filter: Regular DLC'}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:var(--sglv-accent, #a78bfa)">${Host.ICONS.package}</span><span class="sglv-trend-kpi-label">${Host.T.dlcKpiRegular}</span></div> <div class="sglv-trend-kpi-value">${regular.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">type=1</div> </div> <div class="sglv-trend-kpi-card clickable${dlcState.typeFilter === 'music' ? ' active' : ''}" style="border-left:3px solid #22a5f7" data-dlc-filter="music" title="${Host.isZh ? '点击筛选:音乐包' : 'Click to filter: Music'}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:#22a5f7">${Host.ICONS.tag}</span><span class="sglv-trend-kpi-label">${Host.T.dlcKpiMusic}</span></div> <div class="sglv-trend-kpi-value">${music.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">type=6</div> </div> <div class="sglv-trend-kpi-card clickable${dlcState.typeFilter === 'bundled' ? ' active' : ''}" style="border-left:3px solid #f59e0b" data-dlc-filter="bundled" title="${Host.isZh ? '点击筛选:进包 DLC' : 'Click to filter: Bundled'}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:#f59e0b">${Host.ICONS.package}</span><span class="sglv-trend-kpi-label">${Host.T.dlcKpiBundled}</span></div> <div class="sglv-trend-kpi-value">${bundled.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${Host.isZh ? '含 Bundle' : 'In Bundles'}</div> </div> <div class="sglv-trend-kpi-card clickable${dlcState.typeFilter === 'played' ? ' active' : ''}" style="border-left:3px solid #4bb54f" data-dlc-filter="played" title="${Host.isZh ? '点击筛选:已游玩' : 'Click to filter: Played'}"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:#4bb54f">${Host.ICONS.trend}</span><span class="sglv-trend-kpi-label">${Host.T.dlcKpiPlayed}</span></div> <div class="sglv-trend-kpi-value">${played.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">playtime>0</div> </div> </div> ${enrichHint} `; } // v1.9.1: 大网格 rAF 分片 append,避免一次性 innerHTML 写入上千卡片卡顿 // 用 DocumentFragment 多次 append,rAF 让出浏览器绘制 const _DLC_RENDER_CHUNK = 24; // 每片卡片数 const _DLC_RENDER_RAF_THRESHOLD = 30; // 超过此数才走分片(少量直接 innerHTML 更快) function appendDlcCardsChunked(grid, htmls, done) { if (!grid || !htmls || htmls.length === 0) { if (done) done(); return; } if (htmls.length <= _DLC_RENDER_RAF_THRESHOLD || typeof requestAnimationFrame !== 'function') { grid.innerHTML = htmls.join(''); // v2.9.5: DLC 分组视图从不绑定封面回退 → wrapper 永远停在 sglv-img-loading(灰块) bindRestrictedCoverLoaders(grid); if (done) done(); return; } let i = 0; const total = htmls.length; const step = () => { const frag = document.createDocumentFragment(); const t0 = performance.now(); const end = Math.min(i + _DLC_RENDER_CHUNK, total); for (; i < end; i++) { const tpl = document.createElement('template'); tpl.innerHTML = htmls[i].trim(); const node = tpl.content.firstChild; if (node) frag.appendChild(node); } grid.appendChild(frag); // v2.9.5: 每批 append 后就绑定新卡片(幂等,只处理未绑定的 wrapper)—— // DLC 分组视图(renderDlcGrouped)此前完全没有调用 binder, // 封面 404 后无回退链,wrapper 永远停在 loading 灰块。 bindRestrictedCoverLoaders(grid); if (i < total) { // 切片之间让出主线程:rAF 等同 16ms,若耗时 > 12ms 也强制让出 if (performance.now() - t0 > 12) { setTimeout(step, 0); } else { requestAnimationFrame(step); } } else { if (done) done(); } }; step(); } function renderDlcGamesList() { const content = document.getElementById('sglv-dlc-content'); if (!content) return; const clearPagination = () => { const p = document.getElementById('sglv-dlc-pagination'); if (p) p.innerHTML = ''; }; // v1.9.4: loading 阶段显示 spinner 加载动画(参考 steam-friend-manager 风格) // v2.9.67: 尚未加载(!loaded && !loading)时也显示加载动画,避免首次进入空白 if (dlcState.loading || (!dlcState.loaded && !dlcState.loadError)) { content.innerHTML = renderDlcLoadingHtml(); clearPagination(); return; } if (dlcState.loadError) { content.innerHTML = '<div class="sglv-empty">' + Host.T.dlcLoadError + '</div>'; clearPagination(); return; } if (!dlcState.loaded || dlcState.dlcs.length === 0) { content.innerHTML = '<div class="sglv-empty">' + Host.T.dlcNoData + '</div>'; clearPagination(); return; } // v1.9.13: 激进防御——getFilteredDlc 出错时降级为未排序原始列表,绝不白屏 let list; try { list = getFilteredDlc(); } catch (e) { console.warn('[SGLV-DLC] getFilteredDlc 异常,降级未排序列表:', e); list = dlcState.dlcs.slice(); } if (list.length === 0) { content.innerHTML = '<div class="sglv-empty">' + (Host.isZh ? '无匹配结果' : 'No matching results') + '</div>'; clearPagination(); return; } // 分组视图 if (dlcState.groupMode) { let groups; try { groups = getGroupedDlc(list); } catch (e) { console.warn('[SGLV-DLC] getGroupedDlc 异常,降级扁平视图:', e); dlcState.groupMode = false; groups = null; } if (groups) { const { pageItems, totalPages, page: _clampedPage } = Host.paginate(groups, dlcState.page, dlcState.pageSize); dlcState.page = _clampedPage; renderDlcGrouped(content, pageItems); const pagination = document.getElementById('sglv-dlc-pagination'); if (pagination) { // v1.9.47-fix: 补 onJump——DLC 翻页 5 个按钮全部走 onJump Host.renderPagination(pagination, 'sglv-dlc', dlcState.page, totalPages, groups.length, () => { dlcState.page--; renderDlcGamesList(); }, () => { dlcState.page++; renderDlcGamesList(); }, (p) => { dlcState.page = p; renderDlcGamesList(); }); } return; } } // 扁平视图 const { pageItems, totalPages, page: _clampedPage } = Host.paginate(list, dlcState.page, dlcState.pageSize); dlcState.page = _clampedPage; // v1.9.1: 先建空 grid 容器,再 rAF 分片 append(避免上千卡片 innerHTML 解析卡顿) let gridClass = 'sglv-card-grid'; let cardHtmls; if (dlcState.viewMode === 'card') { gridClass = 'sglv-card-grid'; cardHtmls = pageItems.map(d => { const clickUrl = 'https://store.steampowered.com/app/' + d.appid; const badges = dlcOwnedBadgeHtml(d.isOwned) + dlcTypeBadgeHtml(d.type) + dlcBundleBadgeHtml(d.bundleCount) + dlcPlayedBadgeHtml(d.playtime); const dimCls = d.isOwned ? '' : ' sglv-dlc-not-owned'; return '<div class="sglv-game-card' + dimCls + '" data-appid="' + d.appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + '<div class="sglv-card-img-wrap">' + dlcPosterImg(d.appid, d.name) + '</div>' + '<div class="sglv-card-body">' + '<div class="sglv-card-name" title="' + Host.wlEscape(d.name) + '">' + Host.wlEscape(d.name) + '</div>' + '<div class="sglv-card-id">AppID: ' + d.appid + '</div>' + '<div class="sglv-badge-row">' + badges + '</div>' + (d.parentName ? '<div class="sglv-card-id" style="margin-top:2px">' + Host.wlEscape(d.parentName) + '</div>' : '') + '</div>' + '</div>'; }); } else if (dlcState.viewMode === 'cover') { gridClass = 'sglv-wl-cover-grid'; cardHtmls = pageItems.map(d => { const clickUrl = 'https://store.steampowered.com/app/' + d.appid; const badges = dlcOwnedBadgeHtml(d.isOwned) + dlcTypeBadgeHtml(d.type) + dlcBundleBadgeHtml(d.bundleCount) + dlcPlayedBadgeHtml(d.playtime); const dimCls = d.isOwned ? '' : ' sglv-dlc-not-owned'; return '<div class="sglv-wl-cover-card' + dimCls + '" data-appid="' + d.appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + '<div class="sglv-wl-cover-img-wrap">' + dlcCapsuleImg(d.appid, d.name) + '</div>' + '<div class="sglv-wl-cover-body">' + '<div class="sglv-card-name" title="' + Host.wlEscape(d.name) + '">' + Host.wlEscape(d.name) + ' <span class="sglv-card-id-inline">' + d.appid + '</span></div>' + '<div class="sglv-info-row sglv-wl-price-row">' + badges + '</div>' + '</div>' + '</div>'; }); } else { gridClass = 'sglv-list-view'; cardHtmls = pageItems.map(d => { const clickUrl = 'https://store.steampowered.com/app/' + d.appid; const iconHtml = '<img class="sglv-list-icon" src="' + Host.getGameIconUrl(d.appid, '') + '" alt="' + Host.wlEscape(d.name) + '" loading="lazy" decoding="async" onerror="this.style.display=\'none\'">'; const badges = dlcOwnedBadgeHtml(d.isOwned) + dlcTypeBadgeHtml(d.type) + dlcBundleBadgeHtml(d.bundleCount) + dlcPlayedBadgeHtml(d.playtime); const dimCls = d.isOwned ? '' : ' sglv-dlc-not-owned'; return '<div class="sglv-list-item' + dimCls + '" data-appid="' + d.appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + iconHtml + '<div class="sglv-list-info">' + '<div class="sglv-list-name">' + Host.wlEscape(d.name) + ' <span class="sglv-card-id-inline">' + d.appid + '</span></div>' + '<div class="sglv-list-meta">' + badges + (d.parentName ? ' · ' + Host.wlEscape(d.parentName) : '') + '</div>' + '</div>' + '</div>'; }); } content.innerHTML = `<div class="${gridClass}" id="sglv-dlc-grid"></div>`; const grid = document.getElementById('sglv-dlc-grid'); appendDlcCardsChunked(grid, cardHtmls); const pagination = document.getElementById('sglv-dlc-pagination'); if (pagination) { // v1.9.47-fix: 补 onJump——DLC 翻页 5 个按钮全部走 onJump Host.renderPagination(pagination, 'sglv-dlc', dlcState.page, totalPages, list.length, () => { dlcState.page--; renderDlcGamesList(); }, () => { dlcState.page++; renderDlcGamesList(); }, (p) => { dlcState.page = p; renderDlcGamesList(); }); } // v2.8.0: 统一封面链绑定(addEventListener,无跨上下文 inline onerror) bindRestrictedCoverLoaders(content); } // 分组视图渲染:父游戏折叠头 + DLC 卡片子网格 // v1.9.1: group 头一次性 innerHTML 写入(数量小),展开的 group 内卡片用 rAF 分片 append // 解决"几百个父游戏全展开 → 几千卡片 innerHTML 解析"卡死 function renderDlcGrouped(content, groups) { // 1. 先构造所有 group 的外层骨架(头 + 空 card-row 占位),用 data-group-idx 关联 const groupHtmls = groups.map((g, gi) => { const expanded = dlcState.expandedParents.has(String(g.parentApp)); const arrow = expanded ? '▼' : '▶'; return '<div class="sglv-dlc-group' + (expanded ? '' : ' collapsed') + '" data-group-idx="' + gi + '">' + '<div class="sglv-dlc-group-head" data-parent="' + (g.parentApp || 'null') + '">' + '<span class="sglv-dlc-arrow">' + arrow + '</span>' + '<span class="sglv-dlc-pname" title="' + Host.wlEscape(g.parentName || String(g.parentApp)) + '">' + Host.wlEscape(g.parentName || String(g.parentApp)) + '</span>' + '<span class="sglv-dlc-meta">' + g.ownedDlc + '/' + g.totalDlc + ' DLC</span>' + '<span class="sglv-dlc-pct">' + g.pct + '%</span>' + '</div>' + (expanded ? '<div class="sglv-dlc-card-row"></div>' : '') + '</div>'; }); content.innerHTML = groupHtmls.join(''); // 2. 绑定折叠/展开 content.querySelectorAll('.sglv-dlc-group-head').forEach(head => { head.addEventListener('click', () => { const p = head.dataset.parent; if (dlcState.expandedParents.has(p)) dlcState.expandedParents.delete(p); else dlcState.expandedParents.add(p); renderDlcGamesList(); }); }); // 3. 展开的 group:把 DLC 卡片 HTML 准备好,分片 append 到对应 card-row // 各 group 独立分片,互不阻塞(DOM 解析局部化) // v1.9.3: 横版封面布局,每行 4 列(替代原 8 列窄图) groups.forEach((g, gi) => { if (!dlcState.expandedParents.has(String(g.parentApp))) return; if (!g.dlcs || g.dlcs.length === 0) return; const cardHtmls = g.dlcs.map(d => { const clickUrl = 'https://store.steampowered.com/app/' + d.appid; const badges = dlcTypeBadgeHtml(d.type) + dlcBundleBadgeHtml(d.bundleCount) + dlcPlayedBadgeHtml(d.playtime); return '<div class="sglv-wl-cover-card sglv-dlc-group-cover" data-appid="' + d.appid + '" onclick="window.open(\'' + clickUrl + '\',\'_blank\')">' + '<div class="sglv-wl-cover-img-wrap">' + dlcCapsuleImg(d.appid, d.name) + '</div>' + '<div class="sglv-wl-cover-body">' + '<div class="sglv-card-name" title="' + Host.wlEscape(d.name) + '">' + Host.wlEscape(d.name) + ' <span class="sglv-card-id-inline">' + d.appid + '</span></div>' + '<div class="sglv-info-row sglv-wl-price-row">' + badges + '</div>' + '</div>' + '</div>'; }); const row = content.querySelector('.sglv-dlc-group[data-group-idx="' + gi + '"] .sglv-dlc-card-row'); if (row) appendDlcCardsChunked(row, cardHtmls); }); } // ==================== 捆绑包标签页 (v1.9.14) ==================== // 数据源: Host.getBundleCount(appid) — DLC 进包次数 (Barter.vg) // Host.getDlcParent(appid) — DLC→父游戏 appID // Host.getDlcsByParentMap() — 父游戏→DLC列表 反向映射 // 展示: 已拥有游戏中,包含进包 DLC 的父游戏列表,以及直接进包的游戏 // 构建捆绑包数据: 扫描所有已拥有父游戏的 DLC, 找出进包 DLC, 按父游戏分组 // ==================== 捆绑包标签页 ==================== // v2.5.0 关键修复:捆绑包数据源(Barter.vg bundle 数据库)原先**只能被动消费**—— // buildBundleData() 直接调 Host.getBundleCount(),而它在 bundleDbData 为 null 时恒返回 0。 // 原先只有「我的库存」tab 才会触发 loadBundleDatabase(),于是用户直接点捆绑包 tab // (不先进库存 tab)时:dlcsByParent 空 + bundleCount 全 0 → KPI 全 0 + 「暂无进包数据」。 // 修复:renderBundleTab 进入时主动 await Host.loadBundleDatabase(), // 并区分 loading / loadError / loaded 三态分别给出进度条、失败提示+重试按钮、真实空态。 const BUNDLE_LOAD_STAGES = { zh: { start: '正在加载进包数据…', fail: '进包数据加载失败', retry: '重试', notReady: '进包数据源未就绪,请点击重试' }, en: { start: 'Loading bundle data…', fail: 'Failed to load bundle data', retry: 'Retry', notReady: 'Bundle data source not ready, click retry' } }; const _bundleLang = () => (Host.isZh ? BUNDLE_LOAD_STAGES.zh : BUNDLE_LOAD_STAGES.en); /** 确保 Bundle 数据库就绪——已就绪直接返回 true;否则主动触发宿主加载并等待完成。 */ function ensureBundleDbReady() { // 已就绪 if (typeof Host.isBundleDbReady === 'function' ? Host.isBundleDbReady() : true) return Promise.resolve(true); // 宿主未暴露主动加载能力 → 降级:只能等「我的库存」tab 预热 if (typeof Host.loadBundleDatabase !== 'function') return Promise.resolve(false); bundleState.loading = true; bundleState.loadError = false; return Promise.resolve() .then(() => Host.loadBundleDatabase()) .then(() => { const ok = (typeof Host.isBundleDbReady === 'function') ? Host.isBundleDbReady() : true; bundleState.loading = false; bundleState.loadError = !ok; return ok; }) .catch(e => { console.warn('[SGLV-Bundle] loadBundleDatabase 失败:', e); bundleState.loading = false; bundleState.loadError = true; return false; }); } /** 强制重新加载 Bundle 数据库(重试按钮)——先清缓存再拉,避免负缓存 30min 内直接跳过。 */ function reloadBundleDb() { bundleState.loading = true; bundleState.loadError = false; bundleState.loaded = false; renderBundleKpi(); renderBundleList(); if (typeof Host.loadBundleDatabase !== 'function') { bundleState.loading = false; bundleState.loadError = true; renderBundleKpi(); renderBundleList(); return; } Promise.resolve().then(() => Host.loadBundleDatabase()).then(() => { bundleState.loading = false; const ok = (typeof Host.isBundleDbReady === 'function') ? Host.isBundleDbReady() : true; bundleState.loadError = !ok; if (ok) buildBundleData(); renderBundleKpi(); renderBundleList(); }).catch(e => { console.warn('[SGLV-Bundle] reloadBundleDb 失败:', e); bundleState.loading = false; bundleState.loadError = true; renderBundleKpi(); renderBundleList(); }); } function buildBundleData() { const t0 = performance.now(); const allOwned = Host.getOwnedGames() || []; const showFamilyShared = Host.getShowFamilyShared(); const pool = showFamilyShared ? allOwned : allOwned.filter(g => Host.isGameOwnedByMe(g)); // 构建 appid → game 映射 (O(1) 查找游戏名称/拥有状态) const gameMap = {}; pool.forEach(g => { if (g && g.appid) gameMap[String(g.appid)] = g; }); // 全量 owned 用于查父游戏名称 (即使父游戏不在当前 pool) allOwned.forEach(g => { if (g && g.appid && !gameMap[String(g.appid)]) gameMap[String(g.appid)] = g; }); const hasBundleCount = typeof Host.getBundleCount === 'function'; const dlcsByParent = (typeof Host.getDlcsByParentMap === 'function') ? Host.getDlcsByParentMap() : {}; const parentBundleMap = {}; // parentAppid → bundle item // 1. 遍历父游戏→DLC列表, 找出进包 DLC for (const [parentApp, dlcIds] of Object.entries(dlcsByParent)) { if (!Array.isArray(dlcIds)) continue; let totalBundle = 0; const bundledDlcs = []; for (const dlcId of dlcIds) { const bc = hasBundleCount ? Host.getBundleCount(dlcId) : 0; if (bc > 0) { totalBundle += bc; const dlcGame = gameMap[String(dlcId)]; bundledDlcs.push({ appid: dlcId, // v2.8.0: 未在库存里找到名字时不再回退成"DLC <id>"这种占位文案—— // 卡片上直接显示 appid + 无封面骨架,比"DLC 347150"更干净。 name: dlcGame ? dlcGame.name : '', bundleCount: bc, isOwned: !!dlcGame, playtime: dlcGame ? (dlcGame.playtime || 0) : 0, }); } } if (totalBundle > 0) { const parentGame = gameMap[String(parentApp)]; parentBundleMap[parentApp] = { appid: Number(parentApp), name: parentGame ? parentGame.name : ('App ' + parentApp), isOwned: !!parentGame && pool.some(g => String(g.appid) === String(parentApp)), totalBundle: totalBundle, dlcCount: bundledDlcs.length, dlcs: bundledDlcs, }; } } // 2. 检查已拥有游戏本身是否也进包 (getBundleCount 对主游戏可能也有效) for (const g of pool) { if (!g || !g.appid) continue; const bc = hasBundleCount ? Host.getBundleCount(g.appid) : 0; if (bc <= 0) continue; const key = String(g.appid); if (!parentBundleMap[key]) { // 主游戏直接进包, 且没有子 DLC 进包 parentBundleMap[key] = { appid: g.appid, name: g.name, isOwned: true, totalBundle: bc, dlcCount: 0, dlcs: [], }; } else { // 主游戏既自身进包, 子 DLC 也进包——把本体作为第一条 DLC 记录 parentBundleMap[key].totalBundle += bc; parentBundleMap[key].dlcs.unshift({ appid: g.appid, name: g.name + ' ' + (Host.isZh ? '(本体)' : '(Base)'), bundleCount: bc, isOwned: true, playtime: g.playtime || 0, }); parentBundleMap[key].dlcCount++; } } // 按累计进包数降序作为默认排序 bundleState.data = Object.values(parentBundleMap).sort((a, b) => b.totalBundle - a.totalBundle); bundleState.loaded = true; console.log(`[SGLV-Bundle] buildBundleData — ${bundleState.data.length} 个含进包游戏, 总进包 DLC ${bundleState.data.reduce((s, g) => s + g.dlcCount, 0)} 个, 耗时 ${Math.round(performance.now() - t0)}ms`); } // 过滤+排序 function getFilteredBundle() { let list = bundleState.data.slice(); // 搜索过滤 (游戏名 / appid / DLC名) if (bundleState.search) { const q = bundleState.search.toLowerCase(); list = list.filter(item => String(item.name).toLowerCase().includes(q) || String(item.appid).includes(q) || item.dlcs.some(d => String(d.name).toLowerCase().includes(q)) ); } // 排序 const sort = bundleState.sort; try { list.sort((a, b) => { if (sort === 'bundleDesc') return b.totalBundle - a.totalBundle || b.dlcCount - a.dlcCount; if (sort === 'bundleAsc') return a.totalBundle - b.totalBundle || a.dlcCount - b.dlcCount; if (sort === 'nameAsc') return _nameCollator.compare(a.name, b.name); if (sort === 'appidDesc') return b.appid - a.appid; if (sort === 'appidAsc') return a.appid - b.appid; return 0; }); } catch (e) { // 降级: 按 appid 排序 list.sort((a, b) => b.appid - a.appid); } return list; } // 渲染 KPI 卡片 function renderBundleKpi() { const el = document.getElementById('sglv-bundle-kpi'); if (!el) return; // v2.5.0: 加载中 / 失败态不再复用 DLC 的 KPI 骨架(原骨架与捆绑包字段无关,且失败时显示骨架误导用户) if (bundleState.loading) { el.innerHTML = ''; return; } if (bundleState.loadError) { el.innerHTML = ''; return; } if (!bundleState.loaded) { el.innerHTML = renderDlcKpiSkeletonHtml(); return; } const data = bundleState.data; const totalGames = data.length; const totalDlcs = data.reduce((s, g) => s + g.dlcCount, 0); const totalBundles = data.reduce((s, g) => s + g.totalBundle, 0); const ownedGames = data.filter(g => g.isOwned).length; const ownPct = totalGames > 0 ? Math.round(ownedGames / totalGames * 100) : 0; // v2.8.0: KPI 小卡 → HERO 仪表盘(与锁区/绝版同款,铺满浮窗宽度) // 捆绑包没有"分类"维度,右区改放两个规模指标(进包 DLC / 累计进包次数)的占比条。 const stats = [ { key: 'dlcs', label: Host.isZh ? '进包 DLC' : 'Bundled DLCs', count: totalDlcs, color: '#a78bfa' }, { key: 'times', label: Host.isZh ? '累计进包' : 'Bundle entries', count: totalBundles, color: '#f59e0b' }, ].filter(t => t.count > 0); const statSum = stats.reduce((s, t) => s + t.count, 0) || 1; el.innerHTML = '<div class="sglv-hero">' + '<div class="sglv-hero-left">' + '<div class="sglv-hero-title">' + (Host.isZh ? '含进包游戏' : 'Games w/ Bundles') + '</div>' + '<div class="sglv-hero-num">' + totalGames.toLocaleString() + '</div>' + '<div class="sglv-hero-progress"><div class="sglv-hero-progress-fill" style="width:' + ownPct + '%"></div></div>' + '<div class="sglv-hero-progress-cap">' + (Host.isZh ? '已拥有' : 'Owned') + ' ' + ownPct + '%</div>' + '<div class="sglv-hero-scope-row">' + '<div class="sglv-hero-scope' + (ownPct === 100 ? ' active' : '') + '">' + '<span class="sglv-hero-scope-val">' + ownedGames.toLocaleString() + '</span>' + '<span class="sglv-hero-scope-label">' + (Host.isZh ? '已拥有' : 'Owned') + '</span>' + '</div>' + '<div class="sglv-hero-scope' + (ownPct < 100 ? ' active' : '') + '">' + '<span class="sglv-hero-scope-val">' + (totalGames - ownedGames).toLocaleString() + '</span>' + '<span class="sglv-hero-scope-label">' + (Host.isZh ? '家庭共享' : 'Family') + '</span>' + '</div>' + '</div>' + '</div>' + '<div class="sglv-hero-right">' + '<div class="sglv-hero-right-head">' + (Host.isZh ? '进包规模' : 'Bundle scale') + '</div>' + stats.map(function (t) { const pct = Math.round(t.count / statSum * 100); return '<div class="sglv-hero-type">' + '<span class="sglv-hero-type-dot" style="background:' + t.color + '"></span>' + '<span class="sglv-hero-type-name">' + Host.wlEscape(t.label) + '</span>' + '<span class="sglv-hero-type-track"><span class="sglv-hero-type-fill" style="width:' + pct + '%;background:' + t.color + '"></span></span>' + '<span class="sglv-hero-type-count">' + t.count.toLocaleString() + '</span>' + '<span class="sglv-hero-type-pct">' + pct + '%</span>' + '</div>'; }).join('') + '</div>' + '</div>'; } // 渲染游戏列表 (卡片视图) function renderBundleList() { const content = document.getElementById('sglv-bundle-content'); if (!content) return; const clearPagination = () => { const p = document.getElementById('sglv-bundle-pagination'); if (p) p.innerHTML = ''; }; // v2.5.0: 三态判断顺序调整为 加载中 → 失败 → DLC 未就绪 → 构建中骨架 → 空态 → 列表。 // 原先「DLC 未就绪」在前且没有 loading 态,导致加载中直接闪「暂无进包数据」。 if (bundleState.loading) { content.innerHTML = '<div class="sglv-bundle-state">' + '<div class="sglv-progress" style="margin:0 0 10px"><div class="sglv-progress-fill sglv-progress-indeterminate"></div></div>' + '<div class="sglv-empty">' + Host.wlEscape(_bundleLang().start) + '</div>' + '</div>'; clearPagination(); return; } if (bundleState.loadError) { content.innerHTML = '<div class="sglv-bundle-state">' + '<div class="sglv-empty sglv-empty-error">' + Host.wlEscape(_bundleLang().fail) + '</div>' + '<div class="sglv-bundle-state-actions">' + '<button class="sglv-btn sglv-btn-secondary" id="sglv-bundle-retry">' + Host.wlEscape(_bundleLang().retry) + '</button>' + '</div>' + '</div>'; clearPagination(); const retry = content.querySelector('#sglv-bundle-retry'); if (retry) retry.addEventListener('click', () => reloadBundleDb()); return; } // 检查 DLC 数据库是否可用(进包数据依赖 Barter.vg DLC 反向映射) if (typeof Host.isDlcDbReady === 'function' && !Host.isDlcDbReady()) { content.innerHTML = '<div class="sglv-bundle-state">' + '<div class="sglv-empty">' + (Host.isZh ? 'DLC 数据库尚未加载,请先打开「DLC 收藏」标签页加载数据后返回。' : 'DLC database not loaded. Please open the "DLC" tab first to load data, then come back.') + '</div></div>'; clearPagination(); return; } if (!bundleState.loaded) { // 数据异步构建中——骨架占位(避免闪"暂无进包数据"空态) const skelCards = Array.from({ length: 12 }, () => '<div class="sglv-skel-game-card"><div class="sglv-skel-cover"></div><div class="sglv-skel-bar sglv-skel-card-name"></div><div class="sglv-skel-bar sglv-skel-card-meta"></div></div>' ).join(''); content.innerHTML = '<div class="sglv-skel-grid">' + skelCards + '</div>'; clearPagination(); return; } if (bundleState.data.length === 0) { content.innerHTML = '<div class="sglv-bundle-state">' + '<div class="sglv-empty">' + (Host.isZh ? '暂无进包数据' : 'No bundle data available') + '</div>' + '<div class="sglv-bundle-state-actions">' + '<button class="sglv-btn sglv-btn-secondary" id="sglv-bundle-retry">' + Host.wlEscape(_bundleLang().retry) + '</button>' + '</div></div>'; clearPagination(); const retry = content.querySelector('#sglv-bundle-retry'); if (retry) retry.addEventListener('click', () => reloadBundleDb()); return; } const list = getFilteredBundle(); if (list.length === 0) { content.innerHTML = '<div class="sglv-empty">' + (Host.isZh ? '无匹配结果' : 'No matching results') + '</div>'; clearPagination(); return; } const { pageItems, totalPages, page: _clampedPage } = Host.paginate(list, bundleState.page, bundleState.pageSize); bundleState.page = _clampedPage; const bundleBadgeStyle = 'background:rgba(245,158,11,0.15);color:#fbbf24;border:1px solid rgba(245,158,11,0.25);font-size:10px;padding:2px 6px;border-radius:3px;font-weight:700'; const dlcBadgeStyle = 'background:var(--sglv-accent-bg, rgba(167,139,250,0.15));color:var(--sglv-accent, #a78bfa);border:1px solid var(--sglv-accent-border, rgba(167,139,250,0.25));font-size:10px;padding:2px 6px;border-radius:3px;font-weight:700'; content.innerHTML = '<div class="sglv-bundle-wall">' + pageItems.map(item => { const q = (bundleState.search || '').toLowerCase(); const parentName = item.name || ('App ' + item.appid); const parentNameCls = q && parentName.toLowerCase().includes(q) ? ' sglv-highlight' : ''; const restId = 'sglv-bundle-rest-' + item.appid; // ---- 顶部标题条:主游戏退为上下文(左侧色条表示已拥有) ---- const head = '<div class="sglv-bundle-head' + (item.isOwned ? ' is-owned' : '') + '">' + '<div class="sglv-bundle-title-row" onclick="event.stopPropagation();window.open(\'https://store.steampowered.com/app/' + item.appid + '\',\'_blank\')" title="' + Host.wlEscape(parentName) + '">' + '<span class="sglv-bundle-title' + parentNameCls + '">' + Host.wlEscape(parentName) + '</span>' + '<span class="sglv-bundle-appid">' + item.appid + '</span>' + '</div>' + '<div class="sglv-bundle-meta">' + '<span class="sglv-bundle-stat"><span class="sglv-bundle-stat-ico">📦</span>' + item.totalBundle + (Host.isZh ? ' 进包' : ' bundles') + '</span>' + (item.dlcCount > 0 ? '<span class="sglv-bundle-sep"></span><span class="sglv-bundle-stat">' + item.dlcCount + ' DLC</span>' : '') + '</div>' + '</div>'; // ---- 主体:DLC 封面墙(3 列) ---- // 每个 DLC 有独立 appid,可直接走 restrictedCoverImg 拿真实封面; // 旧版用 24px 社区图标,DLC 的美术资源基本读不出来。 const renderTile = (d) => { const dlcUrl = 'https://store.steampowered.com/app/' + d.appid; const label = d.name || ('DLC ' + d.appid); const hitCls = q && d.name && d.name.toLowerCase().includes(q) ? ' is-hit' : ''; return '<div class="sglv-bundle-tile' + hitCls + (d.isOwned ? ' is-owned' : '') + '"' + ' onclick="event.stopPropagation();window.open(\'' + dlcUrl + '\',\'_blank\')"' + ' title="' + Host.wlEscape(label) + '\n📦 ' + d.bundleCount + (Host.isZh ? ' 进包' : ' bundles') + ' · ' + d.appid + '">' + restrictedCoverImg(d.appid, d.name, 'poster') + '<span class="sglv-bundle-tile-num">' + d.bundleCount + '</span>' + '</div>'; }; let wallHtml = ''; if (item.dlcs.length > 0) { const PREVIEW = 6; // 2 行 × 3 列 const preview = item.dlcs.slice(0, PREVIEW); const rest = item.dlcs.slice(PREVIEW); wallHtml = '<div class="sglv-bundle-tiles">' + preview.map(renderTile).join('') + '</div>' + (rest.length > 0 ? '<div class="sglv-bundle-tiles sglv-bundle-tiles-rest" id="' + restId + '" hidden>' + rest.map(renderTile).join('') + '</div>' + '<button class="sglv-bundle-expand" data-target="' + restId + '" data-count="' + rest.length + '">' + '<span class="sglv-bundle-expand-txt">' + (Host.isZh ? '展开其余 ' + rest.length + ' 个' : 'Show ' + rest.length + ' more') + '</span>' + '<span class="sglv-bundle-expand-caret">▾</span>' + '</button>' : ''); } else { // 主游戏本体直接进包、无子 DLC —— 用主游戏封面占位,保持卡片视觉完整 wallHtml = '<div class="sglv-bundle-tiles sglv-bundle-tiles-single">' + '<div class="sglv-bundle-tile is-owned" onclick="event.stopPropagation();window.open(\'https://store.steampowered.com/app/' + item.appid + '\',\'_blank\')" title="' + Host.wlEscape(parentName) + '">' + restrictedCoverImg(item.appid, parentName, 'poster') + '<span class="sglv-bundle-tile-badge">' + (Host.isZh ? '本体进包' : 'Base') + '</span>' + '</div></div>'; } return '<div class="sglv-bundle-card">' + head + wallHtml + '</div>'; }).join('') + '</div>'; // v2.8.0: DLC 墙折叠展开(事件委托,避免上千个监听器) content.querySelectorAll('.sglv-bundle-expand').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const box = document.getElementById(btn.dataset.target); if (!box) return; const show = box.hasAttribute('hidden'); if (show) box.removeAttribute('hidden'); else box.setAttribute('hidden', ''); const n = box.querySelectorAll('.sglv-bundle-tile').length; btn.querySelector('.sglv-bundle-expand-txt').textContent = show ? (Host.isZh ? '收起' : 'Show less') : (Host.isZh ? '展开其余 ' + n + ' 个' : 'Show ' + n + ' more'); btn.querySelector('.sglv-bundle-expand-caret').textContent = show ? '▴' : '▾'; btn.classList.toggle('is-open', show); }); }); bindRestrictedCoverLoaders(content); // 分页 // v1.9.47-fix: 补 onJump——捆绑包翻页 5 个按钮全部走 onJump(参见主脚本 renderPagination) const pagination = document.getElementById('sglv-bundle-pagination'); if (pagination) { Host.renderPagination(pagination, 'sglv-bundle', bundleState.page, totalPages, list.length, () => { bundleState.page--; renderBundleList(); }, () => { bundleState.page++; renderBundleList(); }, (p) => { bundleState.page = p; renderBundleList(); }); } } // 捆绑包标签页主渲染入口 function renderBundleTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-psplus-wrap'; // KPI 区域 const kpi = document.createElement('div'); kpi.id = 'sglv-bundle-kpi'; wrap.appendChild(kpi); // 工具栏: 搜索 + 排序 const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar'; const sortOptions = [ { key: 'bundleDesc', label: Host.isZh ? '进包数↓' : 'Bundles↓' }, { key: 'bundleAsc', label: Host.isZh ? '进包数↑' : 'Bundles↑' }, { key: 'nameAsc', label: Host.isZh ? '名称 A-Z' : 'Name A-Z' }, { key: 'appidDesc', label: Host.isZh ? 'AppID↓' : 'AppID↓' }, { key: 'appidAsc', label: Host.isZh ? 'AppID↑' : 'AppID↑' }, ]; toolbar.innerHTML = ` <input class="sglv-search" aria-label="搜索" id="sglv-bundle-search" placeholder="${Host.isZh ? '搜索游戏 / DLC…' : 'Search game / DLC…'}" value="${Host.wlEscape(bundleState.search)}"> <select class="sglv-sort-select" aria-label="排序方式" id="sglv-bundle-sort"> ${sortOptions.map(o => `<option value="${o.key}"${bundleState.sort === o.key ? ' selected' : ''}>${o.label}</option>`).join('')} </select> <div class="sglv-pagination" id="sglv-bundle-pagination"></div> `; wrap.appendChild(toolbar); // 游戏列表容器 const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-bundle-content'; wrap.appendChild(content); parent.appendChild(wrap); // 事件绑定 toolbar.querySelector('#sglv-bundle-search').addEventListener('input', _debounce((e) => { bundleState.search = e.target.value; bundleState.page = 1; renderBundleList(); }, 200)); toolbar.querySelector('#sglv-bundle-sort').addEventListener('change', (e) => { bundleState.sort = e.target.value; bundleState.page = 1; renderBundleList(); }); // v2.5.0: 数据异步加载链路—— // ① 先同步渲染 loading 态(进度条),不闪空态; // ② 主动确保 Bundle 数据库就绪(原先只在「我的库存」tab 触发,直接点捆绑包必然空); // ③ 同步读一次缓存,命中则立即出列表(秒开路径); // ④ 让出主线程后构建派生数据(buildBundleData 遍历库存×DLC 进包映射,是长任务)。 // 容器已随 tab 切换被移出文档时跳过本次构建。 renderBundleKpi(); renderBundleList(); ensureBundleDbReady().then(() => { // Bundle 数据库就绪后,DLC 反向映射也可能尚未就绪 → 触发一次 DLC 加载再重建 const dlcPromise = (typeof Host.isDlcDbReady === 'function' && !Host.isDlcDbReady() && typeof Host.loadDlcDatabase === 'function') ? Promise.resolve().then(() => Host.loadDlcDatabase()).catch(() => null) : Promise.resolve(); return dlcPromise.then(() => { if (!document.getElementById('sglv-bundle-kpi')) return; // 容器已卸载 buildBundleData(); renderBundleKpi(); renderBundleList(); }); }); } // ==================== 收藏浮窗管理(v2.7.0) ==================== function ensureCollectionPanel() { if (collectionPanelEl) return; collectionOverlayEl = document.createElement('div'); collectionOverlayEl.className = 'sglv-overlay sgc-overlay'; collectionOverlayEl.addEventListener('click', closeCollection); document.body.appendChild(collectionOverlayEl); collectionPanelEl = document.createElement('div'); collectionPanelEl.className = 'sglv-panel sgc-panel'; // v2.9.22: 重排页头——保留最左侧 Steam 图标作为品牌标识,6 个 tab 移入 header 内部(仿主面板 v2.9.22 紧凑风格) collectionPanelEl.innerHTML = ` <div class="sglv-header"> <div class="sglv-header-brand" title="${Host.isZh ? '游戏收藏' : 'Game Collection'}"><img src="https://steamcommunity.com/favicon.ico" alt="Steam" onerror="this.replaceWith(document.createTextNode('🎮'))"></div> <div class="sglv-tab-bar"> <button class="sglv-tab sglv-tab-btn active" data-tab="publisher">${Host.ICONS.grid}<span>${Host.T.tabPublisher}</span></button> <button class="sglv-tab sglv-tab-btn" data-tab="goty">${Host.ICONS.trophy}<span>${Host.T.tabGoty}</span></button> <button class="sglv-tab sglv-tab-btn" data-tab="delisted">${Host.ICONS.shield}<span>${Host.T.tabDelisted}</span></button> <button class="sglv-tab sglv-tab-btn" data-tab="blocked">${BLOCKED_LOCK_ICON_SVG}<span>${Host.T.tabBlocked}</span></button> <button class="sglv-tab sglv-tab-btn" data-tab="psplus">${Host.ICONS.playstation}<span>${Host.T.tabPsplus}</span></button> <button class="sglv-tab sglv-tab-btn" data-tab="epic">${Host.ICONS.epic}<span>${Host.T.tabEpic}</span></button> <button class="sglv-tab sglv-tab-btn" data-tab="dlc">${Host.ICONS.puzzle}<span>${Host.T.tabDlc}</span></button> <button class="sglv-tab sglv-tab-btn" data-tab="bundle">${Host.ICONS.package}<span>${Host.isZh ? '捆绑包' : 'Bundles'}</span></button> <button class="sglv-tab sglv-tab-btn" data-tab="humble">${Host.ICONS.gift}<span>${Host.T.tabHumble}</span></button> </div> <div class="sglv-header-actions"> <button class="sglv-header-btn sglv-home-btn" title="${Host.isZh ? '返回游戏库' : 'Back to Library'}">${Host.ICONS.home || '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'}</button> <button class="sglv-header-btn sglv-close-btn" title="✕">${Host.ICONS.close}</button> </div> </div> <div class="sglv-body" id="sgc-body"></div> `; document.body.appendChild(collectionPanelEl); collectionPanelEl.querySelector('.sglv-close-btn').addEventListener('click', closeCollection); // v1.8.0: home 按钮 → 关闭收藏浮窗并返回游戏库浮窗 collectionPanelEl.querySelector('.sglv-home-btn').addEventListener('click', () => { closeCollection(); document.dispatchEvent(new CustomEvent('sglv:open-library')); }); collectionPanelEl.querySelectorAll('.sglv-tab').forEach(tab => { tab.addEventListener('click', () => { _collectionActiveTab = tab.dataset.tab; collectionPanelEl.querySelectorAll('.sglv-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); renderCollectionBody(); }); }); // v1.8.0: 系列分类数据异步加载完成后,若当前正在展示系列分类标签页则自动刷新 // v1.9.40: 移除 seriesFetchedAt 更新(更新时间文本已移除) // v1.9.37: 不再调用 renderCollectionBody(会闪骨架),改为直接刷新侧边栏+详情 document.addEventListener('sglv:series-data-loaded', () => { if (_collectionActiveTab === 'publisher' && collectionPanelEl && collectionPanelEl.classList.contains('sglv-show')) { refreshPublisherTabContent(); } }); // v2.9.91: 供应商数据远程更新事件——运行时检查发现远程版本有更新时自动刷新 // v1.9.37: 同上,改为直接刷新侧边栏+详情 document.addEventListener('sglv:supplier-data-loaded', () => { if (_collectionActiveTab === 'publisher' && collectionPanelEl && collectionPanelEl.classList.contains('sglv-show')) { refreshPublisherTabContent(); } }); // v2.9.90: 锁区数据远程更新事件——后台时间戳检查发现新版本或强制刷新后, // 自动更新收藏浮窗的 blockedState 并重新渲染(解决主面板刷新后收藏浮窗仍显示旧数据的问题) document.addEventListener('sglv:blocked-data-updated', (e) => { const detail = e.detail || {}; if (Array.isArray(detail.apps) && detail.apps.length > 0) { blockedState.apps = detail.apps; blockedState.source = detail.source || 'static'; blockedState.fetchedAt = detail.fetchedAt || ''; blockedState.loaded = true; // 同步写入永久缓存 _permCacheSet('blockedApps', { apps: blockedState.apps, source: blockedState.source, fetchedAt: blockedState.fetchedAt }); // v2.9.91: 同时刷新好评500分类数据(确保与主数据同步) loadTopRatedApps(true).finally(() => { if (_collectionActiveTab === 'blocked' && collectionPanelEl && collectionPanelEl.classList.contains('sglv-show')) { renderBlockedSections(); } }); // 若收藏浮窗打开且当前在锁区标签页,重新渲染 if (_collectionActiveTab === 'blocked' && collectionPanelEl && collectionPanelEl.classList.contains('sglv-show')) { renderBlockedSections(); } } }); // v2.9.22: 注入 sgc-panel 紧凑 CSS(仿主面板 v2.9.22:紧凑 header、tab 按钮圆角化、品牌图标、拖动 cursor、加载骨架) if (typeof GM_addStyle === 'function' && !collectionPanelEl._sgcStyled) { GM_addStyle(SGC_PANEL_CSS); collectionPanelEl._sgcStyled = true; } // v2.9.22: 绑定拖动(仅一次) if (!collectionPanelEl._sgcDraggable) { makeSgcDraggable(collectionPanelEl, collectionPanelEl.querySelector('.sglv-header')); collectionPanelEl._sgcDraggable = true; } // v2.7.0: 监听主脚本库存更新事件,收藏浮窗打开时自动刷新 document.addEventListener('sglv:games-updated', () => { if (collectionPanelEl && collectionPanelEl.classList.contains('sglv-show')) { renderCollectionBody(); } }); } // ==================== v2.11.50: Humble Choice 月包分析(借鉴 steam-friend-manager 1.5.2) ==================== // 数据源:@resource humbleChoiceData(SmallFork JSON,{months:[{choice,month,year,games:[{name,appid,date,publisher}]}]})。 // 性能:月份解析一次缓存;游戏列表放 <details> 内惰性展开(不渲染海报,纯文本行); // 分页 12 月/页;搜索/筛选走内存过滤 + 局部重渲。 const humbleState = { search: '', filter: 'all', page: 1, pageSize: 12 }; let _humbleMonthsCache = null; const HUMBLE_MON_ZH = { January: 1, February: 2, March: 3, April: 4, May: 5, June: 6, July: 7, August: 8, September: 9, October: 10, November: 11, December: 12 }; function humbleLoadMonths() { if (_humbleMonthsCache !== null) return _humbleMonthsCache; const raw = (typeof Host.getResourceText === 'function') ? Host.getResourceText('humbleChoiceData') : null; if (!raw) { _humbleMonthsCache = []; return _humbleMonthsCache; } try { const json = JSON.parse(raw); _humbleMonthsCache = Array.isArray(json.months) ? json.months : []; } catch (e) { console.warn('[SGLV-Humble] 数据解析失败:', e); _humbleMonthsCache = []; } return _humbleMonthsCache; } function renderHumbleTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-psplus-wrap'; parent.appendChild(wrap); const months = humbleLoadMonths(); if (!months.length) { wrap.innerHTML = `<div class="sglv-empty">${Host.T.humbleNoData}</div>`; return; } const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = id => ownedIds.has(Number(id)) || ownedIds.has(String(id)); // 预计算每月统计与展示标签(一次遍历) months.forEach(m => { const games = Array.isArray(m.games) ? m.games : []; m._games = games; m._owned = games.filter(g => isOwned(g.appid)).length; const zh = HUMBLE_MON_ZH[m.month] || ''; m._label = Host.isZh ? `#${m.choice} ${m.year}年${zh}月` : `#${m.choice} ${m.month} ${m.year}`; }); wrap.innerHTML = ` <div id="sglv-humble-kpi"></div> <div class="sglv-toolbar"> <input class="sglv-search" aria-label="搜索" id="sglv-humble-search" placeholder="${Host.T.humbleSearch}" value="${Host.wlEscape(humbleState.search)}"> <div class="sglv-filter-bar" style="margin:0"> ${['all', 'owned', 'unowned'].map(f => `<button class="sglv-btn sglv-btn-secondary${humbleState.filter === f ? ' active' : ''}" data-humble-filter="${f}">${Host.T['humbleFilter' + f[0].toUpperCase() + f.slice(1)]}</button>`).join('')} </div> <button class="sglv-btn sglv-btn-secondary" id="sglv-humble-expand-all" title="${Host.isZh ? '全部展开' : 'Expand all'}">${Host.isZh ? '展开' : 'Expand'}</button> <button class="sglv-btn sglv-btn-secondary" id="sglv-humble-collapse-all" title="${Host.isZh ? '全部收起' : 'Collapse all'}">${Host.isZh ? '收起' : 'Collapse'}</button> <div class="sglv-pagination" id="sglv-humble-pagination"></div> </div> <div class="sglv-content" id="sglv-humble-content"></div> `; const kpiEl = wrap.querySelector('#sglv-humble-kpi'); const contentEl = wrap.querySelector('#sglv-humble-content'); const pagiEl = wrap.querySelector('#sglv-humble-pagination'); const totalGames = months.reduce((s, m) => s + m._games.length, 0); const totalOwned = months.reduce((s, m) => s + m._owned, 0); const ownedPct = totalGames > 0 ? Math.round(totalOwned / totalGames * 100) : 0; const unowned = totalGames - totalOwned; // v2.4.0: 填充 KPI sub 文本(之前 "游戏数" 和 "未拥有" sub 为空) kpiEl.innerHTML = ` <div class="sglv-trend-kpi-row"> <div class="sglv-trend-kpi-card blue"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon">${Host.ICONS.gift}</span><span class="sglv-trend-kpi-label">${Host.T.humbleKpiMonths}</span></div> <div class="sglv-trend-kpi-value">${months.length}</div> <div class="sglv-trend-kpi-sub">Choice #1 ~ #${months[months.length - 1].choice}</div> </div> <div class="sglv-trend-kpi-card" style="border-left:3px solid #a78bfa"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:#a78bfa">${Host.ICONS.package}</span><span class="sglv-trend-kpi-label">${Host.T.humbleKpiGames}</span></div> <div class="sglv-trend-kpi-value">${totalGames.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${Host.isZh ? '历年总数' : 'Total all years'}</div> </div> <div class="sglv-trend-kpi-card" style="border-left:3px solid #4bb54f"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:#4bb54f;font-weight:700">✓</span><span class="sglv-trend-kpi-label">${Host.T.humbleKpiOwned}</span></div> <div class="sglv-trend-kpi-value">${totalOwned.toLocaleString()}</div> <div class="sglv-trend-kpi-sub" style="color:#4bb54f;font-weight:600">${ownedPct}%</div> </div> <div class="sglv-trend-kpi-card" style="border-left:3px solid #f59e0b"> <div class="sglv-trend-kpi-header"><span class="sglv-trend-kpi-icon" style="color:#f59e0b">${Host.ICONS.clock}</span><span class="sglv-trend-kpi-label">${Host.T.humbleKpiUnowned}</span></div> <div class="sglv-trend-kpi-value">${unowned.toLocaleString()}</div> <div class="sglv-trend-kpi-sub">${Host.isZh ? '待探索 ' + (totalGames ? Math.round(unowned / totalGames * 100) : 0) + '%' : 'To explore ' + (totalGames ? Math.round(unowned / totalGames * 100) : 0) + '%'}</div> </div> </div>`; const q = () => (humbleState.search || '').toLowerCase(); // v2.4.0: 月卡顶部展示 3-5 个游戏缩略 + 搜索高亮 + <details> 行升级(加封面 + 拥有徽章) const monthCardHtml = m => { const games = m._games; const pct = games.length ? Math.round(m._owned / games.length * 100) : 0; const titleCls = q() && m._label.toLowerCase().includes(q()) ? ' sglv-highlight' : ''; // v2.9.6: 顶部游戏缩略 —— 全量平铺(去掉"最多 5 个 + "+N" 截断", // 月包 8 款远没占满卡片宽度,右侧大片空白却让用户多点一次展开才看得全) const gameThumbsHtml = games.length > 0 ? '<div class="sglv-humble-game-thumbs">' + games.map(g => { const owned = isOwned(g.appid); const thumbCls = owned ? 'sglv-humble-game-thumb owned' : 'sglv-humble-game-thumb'; return '<div class="' + thumbCls + '" title="' + Host.wlEscape(g.name || '') + '">' + '<img src="' + Host.getGameIconUrl(g.appid, '') + '" alt="" loading="lazy" onerror="this.parentElement.classList.add(\'no-img\')">' + (owned ? '<span class="sglv-humble-game-thumb-mark">✓</span>' : '') + '</div>'; }).join('') + '</div>' : ''; // 展开行:每行加 24x24 封面 + 拥有徽章 + 搜索高亮 const rows = games.map(g => { const owned = isOwned(g.appid); const nameCls = q() && (g.name || '').toLowerCase().includes(q()) ? ' sglv-highlight' : ''; const icon = '<img class="sglv-humble-row-icon" src="' + Host.getGameIconUrl(g.appid, '') + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'; const ownedMark = owned ? '<span class="sglv-humble-row-owned">✓</span>' : '<span class="sglv-humble-row-missing">·</span>'; return '<div class="sglv-humble-row' + (owned ? ' owned' : '') + '">' + icon + ownedMark + '<span class="sglv-humble-row-name' + nameCls + '" title="' + Host.wlEscape(g.name || '') + '">' + Host.wlEscape(g.name || '') + '</span>' + (g.publisher ? '<span class="sglv-humble-row-pub">' + Host.wlEscape(g.publisher) + '</span>' : '') + (g.appid ? '<a class="sglv-humble-row-link" href="https://store.steampowered.com/app/' + g.appid + '" target="_blank" rel="noopener" onclick="event.stopPropagation()">' + g.appid + '</a>' : '') + '</div>'; }).join(''); return '<div class="sglv-humble-month-card">' + '<div class="sglv-humble-month-head">' + '<span class="sglv-humble-month-title' + titleCls + '">' + Host.wlEscape(m._label) + '</span>' + '<span class="sglv-humble-month-count">' + games.length + ' games</span>' + '<span class="sglv-humble-month-owned">' + Host.T.humbleOwnedIn + ' ' + m._owned + '/' + games.length + ' (' + pct + '%)</span>' + '</div>' + gameThumbsHtml + '<details class="sglv-humble-month-details">' + '<summary class="sglv-humble-month-summary">' + (Host.isZh ? '展开游戏列表' : 'Show games') + '</summary>' + '<div class="sglv-humble-month-rows">' + rows + '</div>' + '</details>' + '</div>'; }; const filteredMonths = () => { const qStr = q(); return months.filter(m => { if (humbleState.filter === 'owned' && m._owned === 0) return false; if (humbleState.filter === 'unowned' && m._owned > 0) return false; if (!qStr) return true; if (m._label.toLowerCase().includes(qStr)) return true; return m._games.some(g => (g.name || '').toLowerCase().includes(qStr) || (g.publisher || '').toLowerCase().includes(qStr) || String(g.appid) === qStr); }); }; const renderList = () => { const list = filteredMonths().sort((a, b) => b.choice - a.choice); const pg = Host.paginate(list, humbleState.page, humbleState.pageSize); humbleState.page = pg.page; contentEl.innerHTML = pg.pageItems.map(monthCardHtml).join(''); if (typeof Host.renderPagination === 'function') { Host.renderPagination(pagiEl, 'sglv-humble', pg.page, pg.totalPages, list.length, () => { humbleState.page--; renderList(); }, () => { humbleState.page++; renderList(); }, p => { humbleState.page = p; renderList(); }); } }; renderList(); // 搜索(防抖 200ms,复用 lib 内 _debounce) wrap.querySelector('#sglv-humble-search').addEventListener('input', _debounce(e => { humbleState.search = e.target.value; humbleState.page = 1; renderList(); }, 200)); // 拥有状态筛选 wrap.querySelectorAll('[data-humble-filter]').forEach(btn => { btn.addEventListener('click', () => { humbleState.filter = btn.dataset.humbleFilter; humbleState.page = 1; wrap.querySelectorAll('[data-humble-filter]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderList(); }); }); // v2.4.0: 批量展开/收起 wrap.querySelector('#sglv-humble-expand-all').addEventListener('click', () => { contentEl.querySelectorAll('details.sglv-humble-month-details').forEach(d => { d.open = true; }); }); wrap.querySelector('#sglv-humble-collapse-all').addEventListener('click', () => { contentEl.querySelectorAll('details.sglv-humble-month-details').forEach(d => { d.open = false; }); }); } function renderCollectionBody() { const body = collectionPanelEl?.querySelector('#sgc-body'); if (!body) return; // v1.9.37: 移除切 tab 骨架屏,直接渲染内容(避免界面变形/闪烁) body.innerHTML = ''; if (_collectionActiveTab === 'delisted') renderDelistedTab(body); else if (_collectionActiveTab === 'blocked') renderBlockedTab(body); else if (_collectionActiveTab === 'goty') renderGotyTab(body); else if (_collectionActiveTab === 'publisher') renderPublisherTab(body); else if (_collectionActiveTab === 'psplus') renderPsplusTab(body); else if (_collectionActiveTab === 'epic') renderEpicTab(body); else if (_collectionActiveTab === 'dlc') renderDlcTab(body); else if (_collectionActiveTab === 'bundle') renderBundleTab(body); else if (_collectionActiveTab === 'humble') renderHumbleTab(body); _collectionBodyInitialized = true; } function openCollection() { ensureCollectionPanel(); collectionOverlayEl.classList.add('sglv-show'); collectionPanelEl.classList.add('sglv-show'); // v2.11.49: 打开性能优化——先同步显示面板+骨架占位,把当前 tab 的重构建 // (DOM 批量插入 + 数据遍历)推迟到下一帧,避免长任务阻塞打开动画造成卡顿 const body = collectionPanelEl && collectionPanelEl.querySelector('#sgc-body'); if (body && !body.childElementCount) { const skelLines = '<div class="sglv-skel-line lg"></div><div class="sglv-skel-line md"></div><div class="sglv-skel-line sm"></div>'; const kpiCards = Array.from({ length: 5 }, () => `<div class="sglv-trend-kpi-card skeleton">${skelLines}</div>`).join(''); const cards = Array.from({ length: 8 }, () => '<div class="sglv-skel-game-card"><div class="sglv-skel-cover"></div><div class="sglv-skel-bar sglv-skel-card-name"></div><div class="sglv-skel-bar sglv-skel-card-meta"></div></div>' ).join(''); body.innerHTML = `<div style="padding:16px 18px"><div class="sglv-trend-kpi-row">${kpiCards}</div><div class="sglv-skel-grid">${cards}</div></div>`; } requestAnimationFrame(() => requestAnimationFrame(renderCollectionBody)); } function closeCollection() { closeTgaNomsModal(); // v2.9.8: 关闭收藏面板时清理遗留的 TGA 提名弹窗 if (collectionOverlayEl) collectionOverlayEl.classList.remove('sglv-show'); if (collectionPanelEl) collectionPanelEl.classList.remove('sglv-show'); } function toggleCollection() { if (collectionPanelEl && collectionPanelEl.classList.contains('sglv-show')) closeCollection(); else openCollection(); } return { init, apiVersion, openCollection, closeCollection, toggleCollection, renderPublisherTab, renderDelistedTab, renderPsplusTab, renderEpicTab, renderBlockedTab, renderGotyTab, renderDlcTab, renderBundleTab, renderHumbleTab, // v2.6.0: 供封面 <img> 的 inline onerror 调用(appdetails API 兜底取图)。 // 必须在 window 上可见 —— inline handler 运行在页面上下文,只能通过 window.SGLVCollection 访问。 _fetchCoverFromApi: fetchCoverFromApi, restrictedCoverChain: restrictedCoverChain }; })(); // ==== SGLV-GAME-COLLECTION-INLINE-END ==== // ============================================================================ // PTW(游戏数据浮窗)加载方式 // ============================================================================ // ★ 当前加载方式:远程 @require —— 见头部【B. 浮窗层】 // v2.42.2:PTW 此前内联在本文件里(框架库 + Tab 集库,8,500+ 行 / 480 KB), // 同时头部又留着两条 @require → 同一份代码下载两遍、解析两遍、同名全局赋值两次。 // 现状:两库已发布到 scriptcat(8427/3.0.0 框架、8430/3.0.0 Tab 集),且与 libs/ 真源 // 逐行一致(verify-remote-deps.js 实测 sha 相同)→ 改为纯远程加载,内联块整体移除。 // 本文件因此减少约 480 KB(-15%)。 // // ⚠️ 应急回退(scriptcat 不可达时):node test-scripts/build-ptw-inline.js // 会重新生成内联块**并自动停用远程 @require**(两者必须互斥,不能只做一半)。 // 切回远程:node test-scripts/build-ptw-inline.js --remote // 两种形态都会由 verify-remote-deps.js 的「加载方式互斥」检查守住。 // ============================================================================ (function () { 'use strict'; //顶层环境守卫 — keylol 等论坛帖子内的 Steam 商店链接卡片以 iframe 形式嵌入 // store.steampowered.com 页面,@match 会命中 iframe 内脚本上下文,导致侧边栏侧滑按钮(sgis-fab) // 检测 window.self !== window.top 时直接退出,跳过所有 UI 初始化。 if (window.self !== window.top) return; const _v = typeof GM_info !== 'undefined' ? GM_info.script.version : 'unknown'; // v2.32.0 插件版本清单注册(共享宿主 v0.3.0)—— 让 Steam AI Agent 能读到本插件版本号。 // ⚠️ 共享宿主缺失时静默跳过,绝不影响本插件主流程。 try { var __PH = (typeof unsafeWindow !== 'undefined' && unsafeWindow.__PLUGIN_HOST__) || (typeof window !== 'undefined' && window.__PLUGIN_HOST__); if (__PH && typeof __PH.registerPlugin === 'function') { __PH.registerPlugin({ id: 'sglv', name: 'Steam 游戏库展示', family: 'core' }); } } catch (e) { /* ignore */ } //主题层——启动日志移至 init()(需读 storage.getUiTheme,此处尚在 TDZ 之前) // ==================== Toast 非阻塞通知系统 ==================== const sglvToast = { _el: null, _timer: null, _show(msg, type) { if (!this._el) { this._el = document.createElement('div'); this._el.id = 'sglv-toast'; this._el.setAttribute('data-sglv-plugin-ui', '1'); document.body.appendChild(this._el); } this._el.textContent = msg; this._el.className = 'sglv-toast' + (type ? ' sglv-toast-' + type : ''); requestAnimationFrame(() => this._el.classList.add('sglv-toast-show')); if (this._timer) clearTimeout(this._timer); this._timer = setTimeout(() => this._el.classList.remove('sglv-toast-show'), 2500); }, success(msg) { this._show(msg, 'success'); }, error(msg) { this._show(msg, 'error'); }, warning(msg) { this._show(msg, 'warning'); }, info(msg) { this._show(msg, 'info'); } }; // ==================== 全局 HTML 转义函数(防 XSS) ==================== // 统一 HTML 转义工具,所有 innerHTML 拼接外部数据时必须调用 function escHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } //货币代码→符号映射(参考 steam-family-game-analysis faCurrencySymbol) // SGLVAppDetail.loadDetail 用 cc=cn 请求 appdetails,但 Steam 可能对登录用户忽略 cc= 参数, // 返回用户实际所在地区的价格(如 INR),需根据 d.cnPrice.currency 字段选择正确符号, // 而非旧代码硬编码的 ¥。置于主作用域,供搜索详情浮窗(_renderDetailContent)直接调用。 //起优先委托 sglv-currency.lib.js 的 SGLVCurrency.symbolOf,lib 缺失时回退下方内置 map(行为不变)。 // 注意: 本函数定义早于下方 CUR 探测声明,故在函数内部直接探测全局,不依赖外层变量。 function sglvCurrencySymbol(code) { try { const lib = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVCurrency) || window.SGLVCurrency; if (lib && lib.apiVersion === 1) return lib.symbolOf(code); } catch (e) { console.debug('[SGLV] SGLVCurrency.symbolOf 异常,降级内置 map', e); } const map = { 'CNY': '¥', 'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'KRW': '₩', 'INR': '₹', 'RUB': '₽', 'BRL': 'R$', 'TRY': '₺', 'AUD': 'A$', 'CAD': 'C$', 'HKD': 'HK$', 'TWD': 'NT$', 'SGD': 'S$', 'THB': '฿', 'MYR': 'RM', 'PHP': '₱', 'IDR': 'Rp', 'VND': '₫', 'MXN': 'Mex$', 'ARS': 'ARS$', 'CLP': 'CLP$', 'COP': 'COL$', 'PEN': 'S/', 'ZAR': 'R', 'UAH': '₴', 'PLN': 'zł', 'NOK': 'kr', 'CHF': 'CHF ', 'NZD': 'NZ$', 'SAR': 'SR', 'AED': 'AED ', 'ILS': '₪', 'KZT': '₸', 'QAR': 'QR', 'KWD': 'KD', 'CRC': '₡', 'UYU': '$U' }; return map[String(code || '').toUpperCase()] || '¥'; } // ==================== SGLVCore 依赖注入 ==================== // @require 外部库加载失败即终止核心功能(无内置降级实现), // 不再尝试用内置最小化实现以减少同步成本。 // 业务代码继续按原 const/function 名字使用,零改动。IDB / 负缓存 / StageProgress / // concurrentPool / gmFetch / notifyNetFail 统一来自 sglv-core.lib.js。 let C = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVCore) || (typeof window !== 'undefined' ? window.SGLVCore : null) || null; //SGLVCore 缺失时优雅早退。用户实测场景:ScriptCat(脚本猫)下 @require 的 // sglv-core.lib.js 拉取失败/被网络拦截 → C === undefined → 原代码 `C.notifyNetFail` // 顶层抛 TypeError("Cannot read properties of undefined (reading 'notifyNetFail')"), // 整个主 IIFE 崩溃,导航栏菜单/入库按钮全不加载且报错文案看不出根因。 // 改为:明确的致命日志 + return 早退(不初始化 UI,不产生错误风暴)。 if (!C) { console.error('[SGLV] 致命: SGLVCore 基础库未加载 (@require sglv-core.lib.js 拉取失败或被网络拦截)。脚本本次不初始化 — 请检查脚本管理器外部依赖/网络(可访问 update.greasyfork.org)后刷新重试。'); return; } //终态失败通知包装——保留兜底以兼容 core 旧版本 < 1.0.5 (远端发布滞后场景), // 缺失时回退到 console.warn 静默输出,保证 catch 块内调用永不抛错。 const sglvNotifyNetFail = (C && typeof C.notifyNetFail === 'function') ? C.notifyNetFail.bind(C) : (label, err) => { try { console.warn('[SGLV] 数据获取失败(系统通知未接入):', label, (err && err.message) || err); } catch (e2) {} }; //SGLV Currency 货币库软降级探测(增强能力,非基础设施:缺失仅降级不早退) const CUR = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVCurrency) || window.SGLVCurrency; const hasSglvCurrency = !!(CUR && CUR.apiVersion === 1); if (!hasSglvCurrency) console.warn('[SGLV] SGLVCurrency 未加载或版本不匹配,货币增强功能降级为内置实现'); // ----- 命名空间 ----- const nsKey = C.nsKey; // ==================== v2.45.0: API Key 同意门 ==================== // 为什么要这道门(三条纪律): // ① 开场不催填 —— 首次使用就弹窗催 API Key 属于打扰,v2.45.0 已移除开场引导卡片; // ② 但 Key 是敏感凭据 —— 用户有权在**知道用途与风险**的前提下,自己决定要不要填; // ③ 所以改成「按需触发」:只有当用户点了需要 Key 的功能(拉库存 / 看成就), // 才当场把「为什么需要 · 数据流向 · 有无风险」摊开讲,勾选同意后才允许填 Key。 // 状态机(GM 键 sglv_apikey_consent_v1): // '' 未判定 // 'legacy' 存量用户(升级前就在用)→ 直接放行,一个字都不打扰 // 'agreed' 用户已勾选同意 → 放行 // 'pending' 全新用户且尚未同意 → 需要 Key 的操作会被门拦住 const APIKEY_CONSENT_GM_KEY = nsKey('apikey_consent_v1'); let _apiKeyConsentState = ''; let _pendingFetchAfterKey = false; // 同意后送去设置页 → 填好 Key 保存后自动续跑那次加载 function _readApiKeyConsent() { if (!_apiKeyConsentState) { try { _apiKeyConsentState = GM_getValue(APIKEY_CONSENT_GM_KEY, '') || ''; } catch (e) { _apiKeyConsentState = ''; } } return _apiKeyConsentState; } function _writeApiKeyConsent(v) { _apiKeyConsentState = v; try { GM_setValue(APIKEY_CONSENT_GM_KEY, v); } catch (e) { /* ignore */ } } /** * 存量用户识别 —— 升级前就在用的老用户不该被同意门拦住。 * 判据只有一个:**GM 存储里已经存在任意 sglv_* 键**(配过 Key / 换过主题 / 写过缓存)。 * · 老用户几乎必然有键(缓存键前缀 sglv_cache_*,配置键 sglv_api_key …)→ 判为 legacy; * · 全新安装一个键都没有 → 判为 pending,走同意门。 * ⚠️ 只判定一次并落盘:否则新用户自己产生的缓存键会反过来把门「洗掉」—— * 判定必须发生在**本脚本任何一次 GM 写入之前**,所以这个函数在模块加载期就跑。 */ function _initApiKeyConsent() { if (_readApiKeyConsent()) return; let legacy = false; try { if (typeof GM_listValues === 'function') { const keys = GM_listValues() || []; legacy = keys.some(k => typeof k === 'string' && k.indexOf('sglv') === 0); } } catch (e) { /* ignore */ } _writeApiKeyConsent(legacy ? 'legacy' : 'pending'); console.log('[SGLV] API Key 同意门初始化:', _apiKeyConsentState); } _initApiKeyConsent(); /** 是否已获授权(用户勾过同意,或存量用户直接放行) */ function _hasApiKeyConsent() { const s = _readApiKeyConsent(); return s === 'agreed' || s === 'legacy'; } /** * 同意弹窗 —— 把「为什么需要 / 数据流向 / 有无风险」摊开讲,用户勾选同意才放行。 * @returns {Promise<boolean>} true=同意,false=不同意 */ function _openApiKeyConsent() { return new Promise((resolve) => { const old = document.getElementById('sglv-apikey-consent'); if (old) { try { old.remove(); } catch (e) { /* ignore */ } } const whyZh = [ 'Steam 官方 Web API 要求每个调用方携带一枚属于自己的 Key。没有 Key 就拿不到完整库存、游玩时长、家庭组归属与成就数据,只能退化到不完整的页面抓取。', 'Key 由 Steam 官方免费发放:<b>steamcommunity.com/dev/apikey</b> → 填一个域名 → 注册,即刻拿到。' ]; const whyEn = [ 'Steam\'s official Web API requires every caller to carry its own key. Without one you only get incomplete page scraping — no full inventory, playtime, family attribution or achievements.', 'The key is free from Steam: <b>steamcommunity.com/dev/apikey</b> → enter a domain → register.' ]; const riskZh = [ 'Key 只保存在你本机的浏览器脚本存储里(GM 存储),不上传、不跨设备同步。', 'Key 只会发往 Steam 官方接口 <b>api.steampowered.com</b>。本插件没有服务端,不做任何数据回流,也不会发往任何第三方。', '这枚 Key 的权限是「读取公开资料」(库存 / 时长 / 成就 / 公开档案),拿不到密码、邮箱、付款信息,也动不了你的库存资产。', '想撤销随时去 <b>steamcommunity.com/dev/apikey</b> 重置,重置后本插件立即失效。', '填 Key 是可选的:不填也能继续用,只是部分数据缺失。' ]; const riskEn = [ 'The key is stored only in your own browser (GM storage) — never uploaded, never synced across devices.', 'It is sent only to Steam\'s official endpoint <b>api.steampowered.com</b>. This script has no server, performs no data backhaul, and never talks to third parties.', 'Its scope is reading public profile data (library / playtime / achievements / public profile). It cannot read your password, email or payment details, and cannot touch your inventory.', 'Revoke it any time at <b>steamcommunity.com/dev/apikey</b> — this script stops working immediately.', 'Configuring a key is optional: you can keep using the script without it, with some data missing.' ]; const listZh = isZh, listEn = !isZh; const why = listZh ? whyZh : whyEn; const risk = listZh ? riskZh : riskEn; const ov = document.createElement('div'); ov.id = 'sglv-apikey-consent'; ov.className = 'sglv-apikey-consent'; ov.setAttribute('data-sglv-plugin-ui', '1'); ov.innerHTML = ` <div class="sglv-apikey-consent-card" role="dialog" aria-modal="true"> <div class="sglv-apikey-consent-head"> <span class="sglv-apikey-consent-title">🔐 ${isZh ? '配置 Steam API Key 前的说明' : 'Before you configure a Steam API Key'}</span> <button class="sglv-apikey-consent-close" title="✕" type="button">✕</button> </div> <div class="sglv-apikey-consent-body"> <div class="sglv-apikey-consent-sec"> <div class="sglv-apikey-consent-sec-title">${isZh ? '为什么需要' : 'Why it is needed'}</div> ${why.map(t => `<p class="sglv-apikey-consent-p">${t}</p>`).join('')} </div> <div class="sglv-apikey-consent-sec"> <div class="sglv-apikey-consent-sec-title">${isZh ? '风险与边界' : 'Risks & boundaries'}</div> <ul class="sglv-apikey-consent-ul"> ${risk.map(t => `<li>${t}</li>`).join('')} </ul> </div> <label class="sglv-apikey-consent-check"> <input type="checkbox" id="sglv-apikey-consent-check"> <span>${isZh ? '我已阅读并理解上述用途与风险,自行配置 API Key' : 'I have read and understood the above purpose and risks, and I choose to configure the key myself'}</span> </label> </div> <div class="sglv-apikey-consent-actions"> <button class="sglv-apikey-consent-btn ghost" id="sglv-apikey-consent-no" type="button">${isZh ? '不同意,不配置' : 'Decline'}</button> <button class="sglv-apikey-consent-btn primary" id="sglv-apikey-consent-yes" type="button" disabled>${isZh ? '同意并前往设置' : 'Agree & open settings'}</button> </div> </div>`; document.body.appendChild(ov); const chk = ov.querySelector('#sglv-apikey-consent-check'); const yes = ov.querySelector('#sglv-apikey-consent-yes'); const no = ov.querySelector('#sglv-apikey-consent-no'); const close = ov.querySelector('.sglv-apikey-consent-close'); let done = false; function finish(ok) { if (done) return; done = true; try { ov.remove(); } catch (e) { /* ignore */ } try { document.removeEventListener('keydown', onKey); } catch (e) { /* ignore */ } resolve(ok); } function onKey(e) { if (e.key === 'Escape') finish(false); } document.addEventListener('keydown', onKey); chk.addEventListener('change', () => { yes.disabled = !chk.checked; }); yes.addEventListener('click', () => { if (!chk.checked) return; _writeApiKeyConsent('agreed'); finish(true); }); no.addEventListener('click', () => finish(false)); close.addEventListener('click', () => finish(false)); ov.addEventListener('click', (e) => { if (e.target === ov) finish(false); }); }); } /** 跳到设置页并聚焦 Steam API Key 输入框 */ function _openApiKeySettings() { // ⚠️ openGlobalSettings 定义在**嵌套 IIFE**(设置/取数区)内 —— 本模块在外层作用域, // 直接调用会是 ReferenceError(verify-main-scope.js 会拦)。走 SGLV_API 桥接, // 这也正是本仓处理跨闭包访问的既有约定(见 SGLV_API.openGlobalSettings 导出)。 const openSettings = SGLV_API.openGlobalSettings; if (typeof openSettings === 'function') openSettings(); else showToast(isZh ? '设置页暂不可用,请刷新页面重试' : 'Settings unavailable — please reload'); setTimeout(() => { const el = document.getElementById('sglv-gs-apikey'); if (el) { try { el.scrollIntoView({ block: 'center' }); el.focus(); } catch (e) { /* ignore */ } } }, 80); } /** * 需要 API Key 的操作统一走这道门。 * 放行规则(三档,缺一不可): * ① 已有 Key → 放行; * ② 'legacy' 存量用户 → 放行,**完全保持旧行为**(免 Key 抓取照旧),升级不改变任何东西; * ③ 'agreed' 已同意未填 Key → 放行(用户已拿到授权,剩下的由他决定填不填;免 Key 降级路径仍在); * ④ 'pending' 全新未同意 → 弹同意门:同意则送去设置页填 Key(填好保存后自动续跑这次加载), * 不同意则**中止加载**,既不给设置入口也不继续取数。 * @returns {Promise<boolean>} true=可以继续;false=已被门拦住,调用方必须中止后续动作。 */ async function _requireApiKeyConsent(opts) { opts = opts || {}; if (storage.getApiKey()) return true; const state = _readApiKeyConsent(); if (state === 'legacy' || state === 'agreed') return true; const ok = await _openApiKeyConsent(); if (!ok) { showToast(isZh ? '未同意配置 API Key,已停止加载数据' : 'Consent declined — data loading stopped'); return false; } // 已同意 → 送去设置页填 Key;填好保存后自动续跑这一次加载 _pendingFetchAfterKey = !!opts.thenFetch; _openApiKeySettings(); return false; } // ----- 捆绑包数据库 GM 缓存键 ----- // v2.42.2:从 SGLV 子闭包(21920 IIFE)提升到主闭包顶层。 // 原先定义在子闭包内,而主闭包的 init() 要用它清理旧版本缓存 → 跨闭包引用 // → ReferenceError 被空 catch 吞掉 → CACHE_VERSION.bundle_db 升级到 2 时 // 旧 GM v2 缓存**不会被失效**(版本号升了但缓存没清)。 const BUNDLE_DB_CACHE_KEY_V2 = nsKey('bundle_db_cache_v2'); // { data, appIds, all, timestamp } // ----- IDB KV 库 ----- const sglvIDB = C.IDB; // ----- 缓存 schema 版本号(业务特定默认值,本脚本独有) ----- const CACHE_VERSION = { bundle_db: 2, // 旧版大体积缓存需要 v2 精简 dlc_db: 1, app_types: 1, cover_good_url: 1, game_name: 1, blocked_apps: 1, }; function ensureCacheVersions() { return C.ensureCacheVersions(CACHE_VERSION); } // ----- 负缓存(直接转发 core 暴露的 API) ----- const negCacheGet = C.negCache.get.bind(C.negCache); const negCacheSet = C.negCache.set.bind(C.negCache); const negCacheClear = C.negCache.clear.bind(C.negCache); // ----- 阶段进度 / 节流 / 并发 / 抓取 / 占位 ----- const StageProgress = C.StageProgress; const concurrentPool = C.concurrentPool; // ----- GM 网络包装(原 sglvGmFetchRetry / sglvGmFetchTextRetry) ----- const sglvGmFetchRetry = C.gmFetchJson; const sglvGmFetchTextRetry = C.gmFetchText; // ==================== 通用 UI 组件函数 ==================== //spinner 段生成器——默认形态(#a78bfa/自带自旋动画)与 // 专属 class 形态(currentColor/圆角端点,尺寸与动画交给配套 CSS,如 .sglv-wl-spin); // 供 createLoadingHtml 工厂与详情弹窗加载态共用,消除手写 svg 重复 function spinSvg(size = 34, cls = '') { if (cls) { return `<svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`; } return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="#a78bfa" stroke-width="2" style="animation:sglv-spin 1s linear infinite;"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`; } //阶段条段生成器——自 SGIS renderProgressLoading 收编 // (class 序列逐字节等价,含非激活态 class 属性的尾随空格;wrapId 供增量更新器按 id 定位) function stageBarsHtml(total, active, wrapId = '') { const bars = Array.from({ length: total }, (_, i) => `<div class="sgis-load-stage-bar ${i + 1 < active ? 'done' : i + 1 === active ? 'active' : ''}"></div>` ).join(''); return `<div class="sgis-load-stages"${wrapId ? ` id="${wrapId}"` : ''}>${bars}</div>`; } //加载态工厂扩展——单一加载态入口,opts 全部可选 // cls 外层附加 class(如 'sglv-wl-loading';给定时外层不再输出内联布局样式,布局交给 CSS) // svgCls spinner 段专属 class(如 'sglv-wl-spin',currentColor 形态,尺寸由 CSS 决定) // spin 'none' 时不渲染 spinner 段 // textCls 文案附加 class(给定时文案用 <div class>,否则维持内联 <span style>) // stages [{key,label}] + activeStage(+可选 stagesId) → 阶段条(数组长度即阶段数,label 预留) // progress {pct} 或 {current,total} → 进度条(total=0 除零保护为 0%;宽度公式与愿望单现状一致) // sub 进度条下副文案 // 进度条/副文案复用愿望单 .sglv-wl-loading-bar/-fill/-sub 既有 class 体系(CSS 不动) function createLoadingHtml(text, size = 34, opts = null) { const svg = (!opts || opts.spin !== 'none') ? spinSvg(size, opts ? (opts.svgCls || '') : '') : ''; const textEl = opts && opts.textCls ? `<div class="${opts.textCls}">${text}</div>` : `<span style="font-size:13px;">${text}</span>`; let inner = svg + textEl; if (opts && opts.stages) { const active = opts.activeStage != null ? opts.activeStage : opts.stages.length; inner += stageBarsHtml(opts.stages.length, active, opts.stagesId || ''); } if (opts && opts.progress) { const p = opts.progress; const raw = p.pct != null ? p.pct : (p.total > 0 ? Math.round(p.current / p.total * 100) : 0); const pct = Math.max(0, Math.min(100, raw)); inner += `<div class="sglv-wl-loading-bar"><div class="sglv-wl-loading-bar-fill" style="width:${pct}%"></div></div>`; } if (opts && opts.sub != null) inner += `<div class="sglv-wl-loading-sub">${opts.sub}</div>`; if (opts && opts.cls) return `<div class="${opts.cls}">${inner}</div>`; return `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:50px 20px;gap:12px;color:#8a9ba8;">${inner}</div>`; } // ==================== 供应商-系列-游戏数据库 ==================== // 从远程 @resource 加载,数据源:Azure Blob Storage //数据升级 game-db-v1.2.json——光荣特库摩重编为 19 个系列分类(数据源小黑盒帖子), // 游戏条目新增可选 genres 字段(Steam appdetails 类型标签),由收藏模块 渲染展示 const GAME_DB = (function() { try { const raw = JSON.parse(GM_getResourceText('gameDb') || '{}'); // 移除 _meta 元数据,避免被解析为供应商分类 const { _meta, ...db } = raw; return db; } catch(e) { console.warn('[SGLV] GAME_DB 加载失败:', e); return {}; } })(); // v2.42.2 性能修复:绝版游戏库改为**惰性解析**。 // 原先是顶层 IIFE:脚本求值期就 JSON.parse(GM_getResourceText('delistedData')), // 而该 @resource 实测 3.46 MiB —— 解析一次 ≈ 6~21 ms + 约 10 MB 堆, // 且这份数据**只有用户打开「绝版游戏」面板时才用得上**(唯一消费点是 // Host.getDelistedDb())。改成惰性 getter:首屏不再背这笔账。 let _sglvDelistedDbCache = null; function _getDelistedDb() { if (_sglvDelistedDbCache !== null) return _sglvDelistedDbCache; try { _sglvDelistedDbCache = JSON.parse(GM_getResourceText('delistedData') || '{}'); } catch (e) { console.warn('[SGLV] DELISTED_DB 加载失败:', e); _sglvDelistedDbCache = {}; } return _sglvDelistedDbCache; } // ==================== 游戏系列数据(game_series.json) ==================== // 数据源: https://raw.githubusercontent.com/SmallRob/steam-namespace/main/data/game_series.json // 经 jsdelivr CDN 加速: https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/game_series.json // (数据由 py_scripts/import_heybox_series.py 等脚本维护,与锁区数据同仓库) // 兼容两种格式: 对象 { 系列名: [{序号, date, appid, name}] }(当前线上格式) // / 扁平数组 [{name, date, appid, 序号, 系列}](旧格式,按"系列"字段分组) const SERIES_DATA_URL = 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/game_series.json'; //互动影游数据源(steam_vediogame_selected.json,扁平数组 [{序号, date, appid, name}]) // 数据源: https://raw.githubusercontent.com/SmallRob/steam-namespace/main/data/steam_vediogame_selected.json // 经 jsdelivr CDN 加速(与系列数据同仓库同管线) const VEDIO_SERIES_DATA_URL = 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/steam_vediogame_selected.json'; //GitHub 原始源(CDN 失败时回退;force 刷新绕过 jsdelivr 边缘缓存,确保拿到最新分类数据) const SERIES_DATA_URL_RAW = 'https://raw.githubusercontent.com/SmallRob/steam-namespace/main/data/game_series.json'; const VEDIO_SERIES_DATA_URL_RAW = 'https://raw.githubusercontent.com/SmallRob/steam-namespace/main/data/steam_vediogame_selected.json'; //独立系列库 { seriesName: [{id, name, date}] },与 GAME_DB(供应商)分开展示 let seriesDb = {}; //最近一次成功拉取/缓存的合并原始数据——某一数据源失败时, // 从内存兜底恢复,避免系列侧边栏因单源失败而塌缩/丢系列 let _lastSeriesRaw = null; //供应商数据远程覆盖(运行时检查 @resource gameDb 远程版本,有更新时覆盖 GAME_DB) const GAME_DB_REMOTE_URL = 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/game-db-v1.2.json'; //供应商数据 GitHub 原始源(CDN 失败回退) const GAME_DB_REMOTE_URL_RAW = 'https://raw.githubusercontent.com/SmallRob/steam-namespace/main/data/game-db-v1.2.json'; let supplierDbOverride = null; let supplierFetchedAt = ''; /** * 将 game_series.json 规范化为独立系列库(不做二级分类,不与供应商合并) * 兼容对象格式 { 系列名: [{序号, date, appid, name}] } 与扁平数组 [{name, date, appid, 序号, 系列}] * 返回: { seriesName: [{id, name, date, order}, ...] }(系列内按序号升序) */ function groupSeriesData(seriesData) { const grouped = {}; const pushGame = (seriesName, item) => { const id = Number(item.appid) || 0; if (!id) return; if (!grouped[seriesName]) grouped[seriesName] = []; // 同系列内按 appid 去重 if (grouped[seriesName].some(g => g.id === id)) return; grouped[seriesName].push({ id, name: item.name || '', date: item.date || '', order: Number(item['序号'] || item.order) || 0, //保留数据源 poster 字段(预验证封面URL,为空时走 CDN 降级链) poster: item.poster || '', }); }; if (Array.isArray(seriesData)) { // 扁平数组格式:按"系列"字段分组 for (const item of seriesData) { const seriesName = String(item['系列'] || item.series || '').trim() || '未分类'; pushGame(seriesName, item); } } else if (seriesData && typeof seriesData === 'object') { // 对象格式:键即系列名 for (const [seriesName, games] of Object.entries(seriesData)) { const name = String(seriesName).trim(); if (!name || !Array.isArray(games)) continue; for (const item of games) pushGame(name, item); } } // 系列内按序号升序(无序号的排后,保持稳定排序) for (const games of Object.values(grouped)) { games.sort((a, b) => (a.order || Infinity) - (b.order || Infinity)); } return grouped; } /** *合并系列数据源——互动影游数据(扁平数组)作为独立"互动影游"系列并入 game_series 数据 * 归一为 groupSeriesData 可直接消费的格式(对象 { 系列名: [...] } / 旧扁平数组打"系列"标记), * 使互动影游走与既有系列完全相同的缓存/签名/事件管线(7 天 TTL、签名对比、强制刷新、状态三态)。 * @param {any} mainData - game_series.json 原始数据(对象或旧扁平数组) * @param {any} vedioData - steam_vediogame_selected.json 原始数据(扁平数组) * @returns {any} 合并后的原始数据(未归一化) */ function mergeVedioSeriesData(mainData, vedioData) { if (!Array.isArray(vedioData) || !vedioData.length) return mainData; if (Array.isArray(mainData)) { // 旧扁平数组格式:给条目打上"系列"标记后并入(与旧数据兼容) return mainData.concat(vedioData.map(item => Object.assign({}, item, { 系列: '互动影游' }))); } if (mainData && typeof mainData === 'object') { const out = Object.assign({}, mainData); // 影游专用文件为互动影游权威数据源,覆盖主文件同名键(若有) out['互动影游'] = vedioData; return out; } // 主数据源为空/异常但影游数据有效:包装成对象格式兜底 return { '互动影游': vedioData }; } /** *djb2 轻量哈希——将 appid 列表折叠为紧凑签名,避免超长字符串 */ function _sigHash(str) { let h = 5381; for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0; return (h >>> 0).toString(36); } /** *计算系列数据内容签名(用于检测远程数据是否有变化) *签名从"系列数:游戏总数"粗粒度升级为逐系列 appid 集合哈希—— * 此前互动影游等分类的条目级变更(游戏新增/移除/跨系列移动而总数恰好不变)无法检测, * 导致手动刷新后分类数据"看似成功实则未更新" */ function _computeSeriesSignature(data) { if (Array.isArray(data)) { const ids = data.map(i => String(i && (i.appid != null ? i.appid : i.name) || '')).sort(); return `arr:${data.length}:${_sigHash(ids.join(','))}`; } else if (data && typeof data === 'object') { const keys = Object.keys(data).sort(); const parts = keys.map(k => { const arr = Array.isArray(data[k]) ? data[k] : []; const ids = arr.map(i => String(i && (i.appid != null ? i.appid : i.name) || '')).sort(); return `${k}(${arr.length}):${_sigHash(ids.join(','))}`; }); let totalGames = 0; for (const k of keys) if (Array.isArray(data[k])) totalGames += data[k].length; return `obj:${keys.length}:${totalGames}:${_sigHash(parts.join('|'))}`; } return 'empty'; } /** *GM GET JSON(无重试)——raw.githubusercontent 原始源回退用 *迁移至 sglvGmFetchRetry(retries:0 保持无重试语义,Accept 头透传) */ function _gmGetJsonRaw(url, timeout = 15000) { return sglvGmFetchRetry(url, { timeout, retries: 0, headers: { 'Accept': 'application/json' } }); } /** *收藏分类数据源抓取——CDN 主源 + GitHub 原始源回退 * force 时附加时间戳查询参数绕过 jsdelivr 边缘缓存(不同查询串为不同缓存键, * 强制回源拉最新数据,解决手动刷新拿到 CDN 旧快照导致互动影游等分类不更新的问题); * 非 force 走普通 URL 复用 CDN 缓存(快、省流量)。 * @returns {Promise<any>} 解析后的 JSON */ async function _fetchCollectionJson(cdnUrl, rawUrl, force) { const bust = force ? `?_sglv=${Date.now()}` : ''; try { return await sglvGmFetchRetry(cdnUrl + bust, { timeout: 15000 }); } catch (e) { return await _gmGetJsonRaw(rawUrl + bust, 15000); } } /** * 异步加载游戏系列数据(带缓存,先读缓存同步分组,再后台拉取最新) *添加 force 参数 + 内容签名比较,避免无变化时重复写缓存/派发事件 *force 时先移出过期缓存,返回结果状态 { status: 'updated'|'nochange'|'error', msg } *缓存策略重写—— * ① force 不再先删缓存:拉取失败时旧缓存原样保留(此前先删后拉,失败即丢缓存, * 内存兜底 _lastSeriesRaw 页面刷新后即失效) * ② 拉取成功且签名变化时 cacheSet 覆盖写缓存(旧版本缓存被新版本替换) * ③ force 附加时间戳绕 CDN 边缘缓存 + raw.githubusercontent 回退,确保拿到最新分类数据 * @param {boolean} force - 强制刷新(跳过缓存读取,始终拉取远程数据,绕 CDN 缓存) * @param {function} onProgress - (text) 进度回调 * @returns {Promise<{status:string, msg:string}>} */ async function loadSeriesData(force = false, onProgress) { const report = (text) => { if (typeof onProgress === 'function') onProgress(text); }; // 1. 非强制刷新时先读缓存,同步分组(确保 UI 首次渲染即有系列数据) if (!force) { const cached = cacheGet('seriesData'); if (cached) { const cachedDb = groupSeriesData(cached); if (Object.keys(cachedDb).length > 0) { seriesDb = cachedDb; _lastSeriesRaw = cached; document.dispatchEvent(new CustomEvent('sglv:series-data-loaded')); } } } // 2. 拉取最新远程数据(game_series.json 与 steam_vediogame_selected.json 并行, // 互动影游并入同一 seriesDb,共享缓存/签名/事件管线,更新逻辑与既有系列完全一致; // 网络层统一走 SGLVCore gmFetchJson(自动重试 + 状态码校验 + JSON 解析), //失败回退 raw.githubusercontent 原始源) try { report(isZh ? '正在加载系列数据…' : 'Fetching series data…'); const [mainRes, vedioRes] = await Promise.allSettled([ _fetchCollectionJson(SERIES_DATA_URL, SERIES_DATA_URL_RAW, force), _fetchCollectionJson(VEDIO_SERIES_DATA_URL, VEDIO_SERIES_DATA_URL_RAW, force), ]); let mainData = mainRes.status === 'fulfilled' ? mainRes.value : null; let vedioData = vedioRes.status === 'fulfilled' ? vedioRes.value : null; if (!mainData && !vedioData) { const err = (mainRes.status === 'rejected' ? mainRes.reason : vedioRes.reason); throw (err instanceof Error ? err : new Error('All series sources failed')); } if (mainRes.status === 'rejected') { // 主源失败:优先复用缓存主数据,其次内存兜底 console.warn('[SGLV] 系列分类主数据源加载失败:', mainRes.reason && mainRes.reason.message); const cachedMain = cacheGet('seriesData') || _lastSeriesRaw; if (cachedMain) mainData = cachedMain; else if (vedioData) mainData = {}; } if (vedioRes.status === 'rejected') { console.warn('[SGLV] 互动影游数据加载失败:', vedioRes.reason && vedioRes.reason.message); // 影游源失败:复用缓存/内存兜底中的互动影游数据(保留上次成功拉取内容,避免刷新导致该系列消失) const cachedRaw = cacheGet('seriesData') || _lastSeriesRaw; if (cachedRaw && !Array.isArray(cachedRaw) && Array.isArray(cachedRaw['互动影游'])) { vedioData = cachedRaw['互动影游']; } } const data = mergeVedioSeriesData(mainData, vedioData); const freshDb = groupSeriesData(data); if (Object.keys(freshDb).length > 0) { //内存兜底随每次成功拉取更新(后续单源失败时可用) _lastSeriesRaw = data; //内容签名比较——签名相同时仅刷新TTL,不派发事件(避免不必要的UI重渲染) //签名为逐系列 appid 哈希,条目级变更也能检测;force 与否均按签名判定 const newSig = _computeSeriesSignature(data); const cachedRaw = cacheGet('seriesData'); const oldSig = cachedRaw ? _computeSeriesSignature(cachedRaw) : ''; if (newSig !== oldSig) { // 成功获取最新版本:新数据覆盖写缓存(旧版本缓存被替换清除) cacheSet('seriesData', data, CACHE_TTL.seriesData); seriesDb = freshDb; document.dispatchEvent(new CustomEvent('sglv:series-data-loaded')); let totalGames = 0; for (const games of Object.values(freshDb)) totalGames += games.length; console.log(`[SGLV] 系列分类数据${force ? '强制' : ''}加载成功: ${totalGames} 条,共 ${Object.keys(freshDb).length} 个系列 (签名: ${newSig})`); return { status: 'updated', msg: isZh ? `系列: ${Object.keys(freshDb).length} 项已更新` : `Series: ${Object.keys(freshDb).length} items updated` }; } else { // 签名相同,仅刷新缓存TTL,不派发事件 cacheSet('seriesData', data, CACHE_TTL.seriesData); console.log('[SGLV] 系列分类数据远程无变化,跳过更新'); return { status: 'nochange', msg: isZh ? '系列: 已是最新' : 'Series: up to date' }; } } return { status: 'nochange', msg: isZh ? '系列: 数据为空' : 'Series: empty' }; } catch (e) { // 拉取失败:缓存不动(旧缓存保留,UI 继续用缓存/内存数据) console.warn('[SGLV] 系列分类数据加载失败:', e.message); sglvNotifyNetFail(isZh ? '收藏分类数据' : 'Collection data', e); // v2.9.114: 终态失败系统通知(60s 节流) return { status: 'error', msg: isZh ? `系列: ${e.message}` : `Series: ${e.message}` }; } } /** *异步加载供应商数据(远程版本检查,有更新时覆盖 @resource GAME_DB) *force 时先移出过期缓存,返回结果状态 { status: 'updated'|'nochange'|'error', msg } *缓存策略与系列数据同步重写——force 不再先删缓存(失败保留旧缓存)、 * 附加时间戳绕 CDN 边缘缓存 + raw.githubusercontent 回退 * @param {boolean} force - 强制刷新(跳过缓存读取,始终拉取远程数据,绕 CDN 缓存) * @param {function} onProgress - (text) 进度回调 * @returns {Promise<{status:string, msg:string}>} */ async function loadSupplierData(force = false, onProgress) { const report = (text) => { if (typeof onProgress === 'function') onProgress(text); }; // 非强制时检查缓存 if (!force) { const cached = cacheGet('supplierData'); if (cached && cached.db) { // 缓存命中:检查是否已有 override 或 GAME_DB 签名一致 const cachedSig = _computeSupplierSignature(cached.raw || {}); const currentSig = _computeSupplierSignature(GAME_DB); if (cachedSig !== currentSig) { supplierDbOverride = cached.db; supplierFetchedAt = cached.fetchedAt || ''; document.dispatchEvent(new CustomEvent('sglv:supplier-data-loaded')); } } } // 拉取最新远程数据 try { report(isZh ? '正在加载供应商数据…' : 'Fetching supplier data…'); const data = await _fetchCollectionJson(GAME_DB_REMOTE_URL, GAME_DB_REMOTE_URL_RAW, force); // 移除 _meta 元数据 const { _meta, ...db } = data; const newSig = _computeSupplierSignature(data); const oldSig = _computeSupplierSignature(GAME_DB); if (force || newSig !== oldSig) { supplierDbOverride = db; supplierFetchedAt = new Date().toISOString().slice(0, 10); cacheSet('supplierData', { db, raw: data, fetchedAt: supplierFetchedAt }, CACHE_TTL.seriesData); document.dispatchEvent(new CustomEvent('sglv:supplier-data-loaded')); const totalGames = Object.values(db).reduce((s, v) => { try { return s + Object.values(v).reduce((ss, arr) => ss + (Array.isArray(arr) ? arr.length : 0), 0); } catch { return s; } }, 0); console.log(`[SGLV] 供应商数据${force ? '强制' : ''}加载成功: ${Object.keys(db).length} 个供应商, ${totalGames} 个游戏 (签名: ${newSig})`); return { status: 'updated', msg: isZh ? `供应商: ${Object.keys(db).length} 项已更新` : `Supplier: ${Object.keys(db).length} items updated` }; } else { console.log('[SGLV] 供应商数据远程无变化,跳过更新'); return { status: 'nochange', msg: isZh ? '供应商: 已是最新' : 'Supplier: up to date' }; } } catch (e) { // 拉取失败:缓存与 override 不动 console.warn('[SGLV] 供应商数据加载失败:', e.message); sglvNotifyNetFail(isZh ? '供应商分类数据' : 'Supplier data', e); // v2.9.114: 终态失败系统通知(60s 节流) return { status: 'error', msg: isZh ? `供应商: ${e.message}` : `Supplier: ${e.message}` }; } } /** 计算供应商数据内容签名(供应商数:游戏总数) */ function _computeSupplierSignature(data) { if (!data || typeof data !== 'object') return 'empty'; const keys = Object.keys(data).filter(k => k !== '_meta'); let totalGames = 0; for (const k of keys) { if (typeof data[k] === 'object' && data[k] !== null) { for (const sk of Object.keys(data[k])) { if (Array.isArray(data[k][sk])) totalGames += data[k][sk].length; } } } return `pub:${keys.length}:${totalGames}`; } // ==================== SVG 图标 ==================== const _S = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; const ICONS = { game: `<svg ${_S}><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M12 12h.01"/><path d="M17 10h.01"/><path d="M17 14h.01"/><path d="M7 12h4"/><path d="M9 10v4"/></svg>`, grid: `<svg ${_S}><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>`, list: `<svg ${_S}><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`, close: `<svg ${_S}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`, settings: `<svg ${_S}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`, refresh: `<svg ${_S}><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`, package: `<svg ${_S}><path d="M16.5 9.4L12 12 3.5 7.4"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.27 6.96L12 12.01l8.73-5.05"/><path d="M12 22.08V12"/></svg>`, trend: `<svg ${_S}><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>`, download: `<svg ${_S}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`, barChart: `<svg ${_S}><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>`, shield: `<svg ${_S}><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`, clock: `<svg ${_S}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>`, share: `<svg ${_S}><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>`, //家庭共享图标(双人群组,用于愿望单家庭共享标签) familyShare: `<svg ${_S}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`, heart: `<svg ${_S}><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`, dollar: `<svg ${_S}><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>`, tag: `<svg ${_S}><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>`, gift: `<svg ${_S}><polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>`, library: `<svg ${_S}><path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M2 10h20"/></svg>`, calendar: `<svg ${_S}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`, //即将发售 KPI 图标(火箭,区别于日历图标的年份卡片) rocket: `<svg ${_S}><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.27 9.65-7 13-1.04.5-2 1-3 1z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.1 5-.5 5-.5"/><path d="M12 15v5s3.03-.55 4-2c1.1-1.62.5-5 .5-5"/></svg>`, //横板封面视图图标(宽横幅卡片) cover: `<svg ${_S}><rect x="2" y="5" width="20" height="14" rx="2"/><circle cx="7.5" cy="10" r="1.5"/><polyline points="22 15 16 9 6 19"/></svg>`, //PlayStation 四符号图标(三角/圆/叉/方)用于 PS 会免标签页 //真实 PlayStation 标识(官方蓝紫色 P + S 经典组合),替代原 4 符号简笔 playstation: `<svg viewBox="0 0 122.88 95.18" xmlns="http://www.w3.org/2000/svg" fill="#0070D1"><path d="M2.49,69.39c-4.61,3.07-3.07,8.9,6.75,11.67c10.13,3.38,21.18,4.3,31.93,2.46c0.61,0,1.23-0.31,1.54-0.31V72.76l-10.44,3.38c-3.99,1.23-7.98,1.54-11.97,0.61c-3.07-0.92-2.46-2.76,1.23-4.3l21.18-7.37V53.73L13.23,63.86C9.55,65.09,5.86,66.93,2.49,69.39L2.49,69.39z M73.71,23.33v29.78c12.59,6.14,22.41,0,22.41-15.96c0-16.27-5.83-23.64-22.72-29.47C64.5,4.6,55.29,1.84,46.08,0v88.73l21.49,6.45V20.57c0-3.38,0-5.83,2.46-4.91C73.41,16.58,73.71,19.96,73.71,23.33L73.71,23.33z M113.63,62.32c-8.9-3.07-18.42-4.3-27.63-3.38c-4.91,0.31-9.52,1.54-13.82,3.07l-0.92,0.31V74.3l19.96-7.37c3.99-1.23,7.98-1.54,11.97-0.61c3.07,0.92,2.46,2.76-1.23,4.3l-30.7,11.36v11.67l42.37-15.66c3.07-1.23,5.83-2.76,8.29-5.22C124.07,69.69,123.15,65.39,113.63,62.32L113.63,62.32z"/></svg>`, //Epic 赠送标签页图标("E" 字形) //Epic Games 盾形标识(深色盾形 + 白色 EPIC/GAMES 文字),替代原"E"字简笔 epic: `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M8 18 L8 52 C8 70 26 86 50 94 C74 86 92 70 92 52 L92 18 Z" fill="#1a1a1a" stroke="#3a3a3a" stroke-width="1.2"/><text x="50" y="47" text-anchor="middle" font-family="Arial Black, Helvetica, sans-serif" font-size="20" font-weight="900" fill="#fff" letter-spacing="0.5">EPIC</text><text x="50" y="65" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700" fill="#fff" letter-spacing="1.2">GAMES</text></svg>`, //集换式卡牌图标 card: `<svg ${_S}><rect x="3" y="6" width="18" height="13" rx="2"/><line x1="3" y1="11" x2="21" y2="11"/></svg>`, //年度大作标签页图标(奖杯) trophy: `<svg ${_S}><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>`, //资料受限图标(用户轮廓 + 警告三角) profileLimited: `<svg ${_S}><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 14-5.3"/><path d="M18 14l3 5.5h-6z" fill="currentColor" fill-opacity="0.3"/><path d="M18 16.5v2" stroke-width="2"/></svg>`, //我的成就标签页图标(奖杯 + 星芒) achievement: `<svg ${_S}><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>`, //云存档标签页图标(云+下载箭头) cloud: `<svg ${_S}><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/><polyline points="12 13 12 21"/><polyline points="8 17 12 21 16 17"/></svg>`, //DLC 收藏标签页图标(拼图块) puzzle: `<svg ${_S}><path d="M19.439 7.85c-.049.318.14.547.457.615a1.5 1.5 0 1 1-1.29 2.73c-.317-.117-.6-.005-.74.293a1.5 1.5 0 1 1-2.73-1.29c.117-.317.005-.6-.293-.74a1.5 1.5 0 1 1 1.29-2.73c.318.117.6.005.74-.293a1.5 1.5 0 1 1 2.73 1.29c-.117.317-.005.6.293.74z"/><path d="M2 6a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v12a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4z" opacity="0.3"/></svg>`, //全局搜索图标(放大镜) search: `<svg ${_S}><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`, //主题层——外观设置卡调色板图标 palette: `<svg ${_S}><path d="M12 22a10 10 0 1 1 10-10c0 1.66-1.34 3-3 3h-1.6a1.4 1.4 0 0 0-1 2.39c.36.36.6.86.6 1.41A2.2 2.2 0 0 1 12 22z"/><circle cx="7.5" cy="11.5" r="1" fill="#a78bfa"/><circle cx="10.5" cy="7.5" r="1" fill="#22d3ee"/><circle cx="15" cy="7.5" r="1" fill="#f472b6"/><circle cx="17" cy="11.5" r="1" fill="#fbbf24"/></svg>`, //搜索结果跳转图标(外链箭头) external: `<svg ${_S}><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`, //个人展柜标签页图标(展柜/陈列窗) showcase: `<svg ${_S}><path d="M3 9l1-5h16l1 5"/><path d="M4 9v11a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V9"/><line x1="9" y1="21" x2="9" y2="9"/><line x1="15" y1="21" x2="15" y2="9"/><line x1="3" y1="9" x2="21" y2="9"/></svg>`, //展柜编辑图标(铅笔) edit: `<svg ${_S}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`, //徽章图标(六角勋章 + 飘带) medal: `<svg ${_S}><circle cx="12" cy="9" r="6" fill="currentColor" fill-opacity="0.15"/><circle cx="12" cy="9" r="6"/><circle cx="12" cy="9" r="2" fill="currentColor"/><path d="M8.5 14L7 21l5-2.5L17 21l-1.5-7"/></svg>`, }; // ==================== CSS 样式 ==================== //CSS 经 @require 外部库 sglv-shared-css 加载,库未加载时为空字符串(UI 无样式但功能可用) const CSS_STYLES = (typeof window.SGLVSharedCSS === 'string' && window.SGLVSharedCSS) || ''; GM_addStyle(CSS_STYLES); // ==================== 分散 CSS 集中提取 ==================== // GM_addStyle(`...`) 块统一提取为命名常量, // 一次性合并注入(CSS_INLINE)。 const CSS_ACHIEVEMENT =` /* ====== 主布局:左右分栏 ====== */ .sglv-ach-layout { display: flex; height: 100%; min-height: 400px; background: linear-gradient(180deg, #0d1117 0%, #161b22 100%); border-radius: 8px; overflow: hidden; } /* ====== 左侧边栏 ====== */ .sglv-ach-sidebar { width: 240px; flex-shrink: 0; display: flex; flex-direction: column; background: linear-gradient(180deg, rgba(22,27,34,0.95) 0%, rgba(13,17,23,0.95) 100%); border-right: 1px solid rgba(48,54,61,0.6); overflow-y: auto; } .sglv-ach-sidebar-header { display: flex; align-items: center; gap: 8px; padding: 16px 16px 12px; border-bottom: 1px solid rgba(48,54,61,0.4); } .sglv-ach-sidebar-title { font-size: 15px; font-weight: 700; color: #e6edf3; display: flex; align-items: center; gap: 6px; } .sglv-ach-sidebar-title svg { width: 20px; height: 20px; } /*用户信息区 — 参考设计渐变背景+径向光晕 */ .sglv-ach-user-card { padding: 20px 16px; display: flex; flex-direction: column; align-items: center; gap: 6px; border-bottom: 1px solid rgba(48,54,61,0.4); background: linear-gradient(160deg, rgba(99,102,241,0.08), rgba(168,85,247,0.04)); position: relative; overflow: hidden; } .sglv-ach-user-card::after { content: ''; position: absolute; inset: -50%; background: radial-gradient(circle, rgba(168,85,247,0.12), transparent 60%); pointer-events: none; } .sglv-ach-user-avatar { width: 64px; height: 64px; border-radius: 50%; background: linear-gradient(135deg, #fde047, #d97706); display: flex; align-items: center; justify-content: center; flex-shrink: 0; position: relative; box-shadow: 0 8px 24px rgba(251,191,36,0.35); z-index: 1; } .sglv-ach-user-avatar svg { width: 32px; height: 32px; } .sglv-ach-user-level { position: absolute; bottom: -4px; right: -4px; background: #1c2128; border: 2px solid #fde047; border-radius: 10px; padding: 0 5px; font-size: 10px; font-weight: 700; color: #fde047; line-height: 1.4; } .sglv-ach-user-name { font-size: 14px; font-weight: 700; color: #e6edf3; position: relative; z-index: 1; } .sglv-ach-user-pts { font-size: 11px; color: #8b949e; position: relative; z-index: 1; } .sglv-ach-user-pts strong { color: #fbbf24; font-weight: 700; } /*分类导航 — 参考设计图标背景+激活渐变 */ .sglv-ach-nav { padding: 8px 0; flex: 1; } .sglv-ach-nav-label { font-size: 10px; font-weight: 700; color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em; padding: 8px 16px 4px; } .sglv-ach-nav-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; margin: 0 8px; cursor: pointer; transition: all 0.15s; border-radius: 10px; } .sglv-ach-nav-item:hover { background: rgba(99,102,241,0.08); } .sglv-ach-nav-item.active { background: linear-gradient(90deg, rgba(99,102,241,0.2), transparent); color: #fff; } .sglv-ach-nav-item-icon { width: 28px; height: 28px; border-radius: 8px; overflow: hidden; flex-shrink: 0; background: rgba(99,102,241,0.12); display: flex; align-items: center; justify-content: center; } .sglv-ach-nav-item-icon svg { width: 18px; height: 18px; } .sglv-ach-nav-item-name { font-size: 12px; font-weight: 600; color: #c9d1d9; flex: 1; } .sglv-ach-nav-item.active .sglv-ach-nav-item-name { color: #fff; font-weight: 700; } .sglv-ach-nav-item-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; flex-shrink: 0; background: rgba(99,102,241,0.15); color: #c7d2fe; } .sglv-ach-nav-item.active .sglv-ach-nav-item-badge { background: #6366f1; color: #fff; } .sglv-ach-nav-item-badge.complete { background: rgba(63,185,80,0.15); color: #3fb950; } .sglv-ach-nav-item-badge.partial { background: rgba(56,139,253,0.15); color: #58a6ff; } .sglv-ach-nav-item-badge.none { background: rgba(255,255,255,0.06); color: #6e7681; } /*侧边栏最近解锁 — 参考设计mini-icon渐变 */ .sglv-ach-sidebar-recent { padding: 8px 0 12px; border-top: 1px solid rgba(48,54,61,0.4); } .sglv-ach-sidebar-recent-label { font-size: 10px; font-weight: 700; color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em; padding: 8px 16px 4px; } .sglv-ach-sidebar-recent-item { display: flex; align-items: center; gap: 10px; padding: 6px 16px; transition: background 0.15s; cursor: pointer; border-radius: 8px; margin: 0 8px; } .sglv-ach-sidebar-recent-item:hover { background: rgba(99,102,241,0.06); } .sglv-ach-sidebar-recent-icon { width: 32px; height: 32px; border-radius: 8px; overflow: hidden; flex-shrink: 0; background: linear-gradient(135deg, #6366f1, #a78bfa); display: flex; align-items: center; justify-content: center; } .sglv-ach-sidebar-recent-icon svg { width: 20px; height: 20px; } .sglv-ach-sidebar-recent-info { flex: 1; min-width: 0; } .sglv-ach-sidebar-recent-name { font-size: 11px; font-weight: 600; color: #d2a8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-ach-sidebar-recent-time { font-size: 9px; color: #6e7681; margin-top: 1px; } .sglv-ach-sidebar-recent-empty { font-size: 11px; color: #6e7681; padding: 8px 16px; opacity: 0.6; } /* ====== 右侧主内容区 ====== */ .sglv-ach-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; } /*统计概览区 — 参考设计玻璃态容器 */ .sglv-ach-header { display: flex; align-items: center; gap: 20px; padding: 20px 24px; background: linear-gradient(135deg, rgba(30,41,59,0.6), rgba(15,23,42,0.6)); backdrop-filter: blur(20px); border-bottom: 1px solid rgba(99,102,241,0.12); flex-shrink: 0; } .sglv-ach-ring-wrap { position: relative; width: 88px; height: 88px; flex-shrink: 0; } .sglv-ach-ring-svg { width: 100%; height: 100%; transform: rotate(-90deg); } .sglv-ach-ring-bg { fill: none; stroke: rgba(99,102,241,0.12); stroke-width: 6; } .sglv-ach-ring-fill { fill: none; stroke: url(#ach-ring-grad); stroke-width: 6; stroke-linecap: round; transition: stroke-dashoffset 0.8s cubic-bezier(0.4,0,0.2,1); } .sglv-ach-ring-text { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; } .sglv-ach-ring-val { font-size: 22px; font-weight: 800; background: linear-gradient(135deg, #22d3ee, #a78bfa, #fbbf24); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; line-height: 1; } .sglv-ach-ring-label { font-size: 8px; color: #64748b; margin-top: 2px; letter-spacing: 0.02em; white-space: nowrap; } /* === 成就 KPI 卡片复用 sglv-playtime-kpi-card 统一风格 === */ .sglv-ach-kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; flex: 1; min-width: 0; } /* ====== v2.44.0:Hero 总览(真实口径) + 游戏成就矩阵 + 自定义徽章架 ====== */ .sglv-ach-header-v2 { flex-direction: column; align-items: stretch; gap: 12px; padding: 16px 24px 14px; } .sglv-ach-hero-top { display: flex; align-items: center; gap: 20px; } .sglv-ach-kpi-row-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); } .sglv-ach-hero-strip { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; padding-top: 10px; border-top: 1px solid rgba(48,54,61,0.45); } .sglv-ach-hero-item { display: flex; align-items: baseline; gap: 7px; font-size: 11px; min-width: 0; } .sglv-ach-hero-item .lbl { color: #6e7681; font-weight: 600; white-space: nowrap; } .sglv-ach-hero-item .val { color: #e6edf3; font-weight: 700; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; } .sglv-ach-nav-item-icon { color: #a5b4fc; } .sglv-ach-sidebar-recent-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin: 0 4px; } /* 子视图切换(游戏成就 / 聚合分析) */ .sglv-ach-subview { display: none; } .sglv-ach-subview.active { display: block; } /* 游戏成就矩阵 —— v2.44.0 新增的主视图 */ .sglv-ach-matrix { display: flex; flex-direction: column; gap: 6px; } /* 空态卡片 —— 空态必须读成「下一步做什么」,不能只给一行淡灰小字 */ .sglv-ach-empty-card { padding: 24px 20px; border-radius: 14px; background: rgba(13,17,23,0.5); border: 1px dashed rgba(74,85,104,0.7); text-align: center; } .sglv-ach-empty-title { font-size: 13.5px; font-weight: 700; color: #c9d1d9; margin-bottom: 6px; } .sglv-ach-empty-hint { font-size: 11.5px; color: #8b949e; line-height: 1.7; } .sglv-ach-empty-cta { margin-top: 14px; padding: 8px 20px; border-radius: 10px; border: 1px solid rgba(34,211,238,0.42); background: rgba(34,211,238,0.12); color: #67e8f9; font-size: 12px; font-weight: 700; cursor: pointer; transition: all 0.15s; font-family: inherit; } .sglv-ach-empty-cta:hover { background: rgba(34,211,238,0.22); border-color: rgba(34,211,238,0.65); } .sglv-ach-matrix-row { display: flex; align-items: center; gap: 12px; padding: 9px 12px; border-radius: 10px; background: rgba(13,17,23,0.45); border: 1px solid rgba(48,54,61,0.45); transition: background 0.15s, border-color 0.15s; cursor: pointer; } .sglv-ach-matrix-row:hover { background: rgba(34,211,238,0.07); border-color: rgba(34,211,238,0.32); } .sglv-ach-matrix-row:focus-visible { outline: 2px solid rgba(34,211,238,0.6); outline-offset: 1px; } /* v2.44.2:矩阵工具栏(借鉴游戏收藏模块的「完成度 / 排序 / 视图」工具栏) */ .sglv-ach-mx { display: flex; flex-direction: column; gap: 10px; } .sglv-ach-mx-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .sglv-ach-mx-group { display: flex; align-items: center; gap: 3px; padding: 3px; border-radius: 9px; background: rgba(13,17,23,0.5); border: 1px solid rgba(48,54,61,0.6); } .sglv-ach-mx-seg { padding: 5px 11px; border-radius: 7px; border: none; background: transparent; color: #8b949e; font-size: 11.5px; font-weight: 700; cursor: pointer; transition: all 0.15s; font-family: inherit; white-space: nowrap; } .sglv-ach-mx-seg:hover { color: #e6edf3; background: rgba(99,102,241,0.12); } .sglv-ach-mx-seg.active { color: #fff; background: linear-gradient(135deg, rgba(34,211,238,0.2), rgba(99,102,241,0.18)); box-shadow: inset 0 1px 0 rgba(255,255,255,0.06); } .sglv-ach-mx-search { flex: 1; min-width: 130px; max-width: 260px; padding: 6px 12px; border-radius: 9px; border: 1px solid rgba(48,54,61,0.7); background: rgba(13,17,23,0.55); color: #e6edf3; font-size: 12px; outline: none; font-family: inherit; } .sglv-ach-mx-search:focus { border-color: rgba(34,211,238,0.5); } .sglv-ach-mx-search::placeholder { color: #6e7681; } .sglv-ach-mx-meta { font-size: 11px; color: #6e7681; } /* 列表行:游戏封面缩略图(capsule 231x87 比例) */ .sglv-ach-matrix-ico { width: 92px; height: 34px; flex-shrink: 0; border-radius: 6px; overflow: hidden; background: rgba(13,17,23,0.6); } .sglv-ach-matrix-ico img { width: 100%; height: 100%; object-fit: cover; display: block; } /* 卡片视图 */ .sglv-ach-mx-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; } .sglv-ach-mx-card { display: flex; flex-direction: column; gap: 6px; padding: 8px; border-radius: 12px; background: rgba(13,17,23,0.5); border: 1px solid rgba(48,54,61,0.5); cursor: pointer; transition: background 0.15s, border-color 0.15s; } .sglv-ach-mx-card:hover { background: rgba(34,211,238,0.06); border-color: rgba(34,211,238,0.3); } .sglv-ach-mx-card:focus-visible { outline: 2px solid rgba(34,211,238,0.6); outline-offset: 1px; } .sglv-ach-mx-card-cover { width: 100%; aspect-ratio: 2 / 3; border-radius: 8px; overflow: hidden; background: rgba(13,17,23,0.7); } .sglv-ach-mx-card-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-ach-mx-card-name { font-size: 12px; font-weight: 600; color: #e6edf3; line-height: 1.35; max-height: 33px; overflow: hidden; } .sglv-ach-mx-card-bar { height: 4px; border-radius: 3px; background: rgba(48,54,61,0.7); overflow: hidden; } .sglv-ach-mx-card-bar i { display: block; height: 100%; background: linear-gradient(90deg, #22d3ee, #6366f1); border-radius: 3px; } .sglv-ach-mx-card-meta { font-size: 10px; color: #8b949e; } .sglv-ach-mx-card .sglv-ach-matrix-tags { justify-content: flex-start; margin-top: 2px; } .sglv-ach-matrix-ring { position: relative; width: 38px; height: 38px; flex-shrink: 0; } .sglv-ach-matrix-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); } .sglv-ach-matrix-ring .bg { fill: none; stroke: rgba(99,102,241,0.15); stroke-width: 3; } .sglv-ach-matrix-ring .fill { fill: none; stroke: url(#ach-ring-grad) #22d3ee; stroke-width: 3; stroke-linecap: round; } .sglv-ach-matrix-ring-t { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: #e6edf3; } .sglv-ach-matrix-info { flex: 1; min-width: 0; } .sglv-ach-matrix-name { font-size: 13px; font-weight: 600; color: #e6edf3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-ach-matrix-meta { font-size: 11px; color: #8b949e; margin-top: 2px; } .sglv-ach-matrix-tags { display: flex; align-items: center; gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; } .sglv-ach-matrix-tag { font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 5px; letter-spacing: 0.04em; white-space: nowrap; } .sglv-ach-matrix-tag.perfect { background: rgba(63,185,80,0.16); color: #3fb950; } .sglv-ach-matrix-tag.rare { background: rgba(168,85,247,0.16); color: #c084fc; } .sglv-ach-matrix-tag.done { background: rgba(251,191,36,0.14); color: #fbbf24; } .sglv-ach-matrix-tag.muted { background: rgba(110,118,129,0.18); color: #8b949e; } /* 自定义成就徽章架(弱化后的位置:页面底部、可折叠) */ .sglv-ach-shelf { flex-shrink: 0; border-top: 1px solid rgba(48,54,61,0.5); background: rgba(13,17,23,0.5); } .sglv-ach-shelf-head { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 24px; background: none; border: none; cursor: pointer; text-align: left; color: #8b949e; font-family: inherit; } .sglv-ach-shelf-head:hover { background: rgba(99,102,241,0.06); } .sglv-ach-shelf-title { font-size: 12px; font-weight: 700; color: #c9d1d9; } .sglv-ach-shelf-summary { font-size: 11px; color: #6e7681; flex: 1; } .sglv-ach-shelf-caret { font-size: 11px; color: #8b949e; } .sglv-ach-shelf-body { padding: 0 24px 14px; display: none; } .sglv-ach-shelf.open .sglv-ach-shelf-body { display: block; } .sglv-ach-shelf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 8px; } .sglv-ach-badge { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 6px; border-radius: 10px; background: rgba(13,17,23,0.5); border: 1px solid rgba(48,54,61,0.4); opacity: 0.5; } .sglv-ach-badge.on { opacity: 1; border-color: rgba(99,102,241,0.35); } .sglv-ach-badge-icon { width: 40px; height: 40px; } .sglv-ach-badge-icon svg { width: 100%; height: 100%; } .sglv-ach-badge-name { font-size: 10px; color: #c9d1d9; text-align: center; line-height: 1.3; max-height: 26px; overflow: hidden; } .sglv-ach-badge-bar { width: 100%; height: 3px; border-radius: 2px; background: rgba(48,54,61,0.7); overflow: hidden; } .sglv-ach-badge-bar i { display: block; height: 100%; background: linear-gradient(90deg, #22d3ee, #6366f1); border-radius: 2px; } .sglv-ach-shelf-foot { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 10px; color: #6e7681; } .sglv-ach-shelf-foot span { flex: 1; } @media (max-width: 900px) { .sglv-ach-kpi-row-6 { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 720px) { .sglv-ach-header-v2 { padding: 12px 14px 10px; } .sglv-ach-hero-top { flex-direction: column; align-items: stretch; gap: 12px; } .sglv-ach-shelf-head, .sglv-ach-shelf-body { padding-left: 14px; padding-right: 14px; } } .sglv-ach-reset-btn { padding: 8px 16px; border-radius: 10px; border: 1px solid rgba(239,68,68,0.3); background: rgba(239,68,68,0.08); color: #fca5a5; cursor: pointer; font-size: 12px; transition: all 0.2s; flex-shrink: 0; } .sglv-ach-reset-btn:hover { background: rgba(239,68,68,0.18); border-color: rgba(239,68,68,0.5); } /*Steam 平台成就/徽章数横幅 */ .sglv-ach-steam-banner { display: flex; align-items: center; gap: 12px; padding: 10px 16px; margin-bottom: 10px; background: linear-gradient(135deg, rgba(34,211,238,0.08), rgba(251,191,36,0.06), rgba(139,92,246,0.04)); border: 1px solid rgba(34,211,238,0.15); border-radius: 12px; position: relative; overflow: hidden; } .sglv-ach-steam-banner::after { content: ''; position: absolute; top: -20px; right: -20px; width: 80px; height: 80px; background: radial-gradient(circle, rgba(251,191,36,0.12), transparent 60%); pointer-events: none; } .sglv-ach-steam-banner-icon { width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(251,191,36,0.2), rgba(34,211,238,0.12)); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06); } .sglv-ach-steam-banner-icon svg { width: 18px; height: 18px; color: #fbbf24; } .sglv-ach-steam-banner-info { flex: 1; } .sglv-ach-steam-banner-label { font-size: 11px; color: #8b949e; font-weight: 600; } .sglv-ach-steam-banner-val { font-size: 24px; font-weight: 800; line-height: 1.1; background: linear-gradient(135deg, #22d3ee, #fbbf24); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; font-variant-numeric: tabular-nums; } .sglv-ach-steam-banner-note { font-size: 10px; color: #6e7681; flex-shrink: 0; } /*成就内容区 — 参考设计容器圆角 */ .sglv-ach-content { flex: 1; padding: 16px 24px; overflow-y: auto; } .sglv-ach-content-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .sglv-ach-content-title { font-size: 15px; font-weight: 700; color: #e6edf3; } .sglv-ach-content-count { font-size: 11px; color: #22d3ee; background: rgba(34,211,238,0.1); padding: 3px 10px; border-radius: 8px; font-weight: 600; } .sglv-ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; } /* ====== 主视图切换:Steam 成就 / 自定义成就 ====== */ .sglv-ach-switch { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 12px 24px 0; flex-shrink: 0; } .sglv-ach-switch-btn { display: inline-flex; align-items: center; gap: 7px; padding: 7px 14px; border-radius: 10px; border: 1px solid rgba(48,54,61,0.8); background: rgba(13,17,23,0.55); color: #8b949e; font-size: 12px; font-weight: 700; cursor: pointer; transition: all 0.16s; } .sglv-ach-switch-btn:hover { color: #e6edf3; border-color: rgba(99,102,241,0.4); } .sglv-ach-switch-btn.active { color: #e6edf3; background: linear-gradient(135deg, rgba(34,211,238,0.16), rgba(99,102,241,0.14)); border-color: rgba(34,211,238,0.5); box-shadow: inset 0 1px 0 rgba(255,255,255,0.06); } .sglv-ach-switch-btn b { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 6px; background: rgba(148,163,184,0.16); color: #94a3b8; } .sglv-ach-switch-btn.active b { background: rgba(34,211,238,0.24); color: #a5f3fc; } /* 两个视图各自独立滚动:切标签不重建 DOM,也不丢滚动位置 */ .sglv-ach-tracks { flex: 1; min-height: 0; display: flex; flex-direction: column; } .sglv-ach-track { flex: 1; min-height: 0; overflow-y: auto; display: none; } .sglv-ach-track.active { display: block; } .sglv-ach-track-steam { padding: 14px 24px 16px; } .sglv-ach-track-custom > .sglv-ach-content { flex: none; height: auto; overflow: visible; } .sglv-ach-track::-webkit-scrollbar { width: 8px; } .sglv-ach-track::-webkit-scrollbar-thumb { background: rgba(99,102,241,0.32); border-radius: 4px; } @media (max-width: 720px) { .sglv-ach-switch { padding: 10px 14px 0; } .sglv-ach-track-steam { padding: 12px 14px 14px; } } /* ===== ACH-DEEP-CSS:BEGIN ===== 真实成就(S1 数据层 + S2/S3/S4/S5 深度面板) 这段 CSS 会被 test-scripts/build-preview.js 原样抽到本地预览页,改样式只改这里 ===== */ /*真实成就区块 — 与自定义成就并列的第二轨(S1 数据层)*/ .sglv-ach-real { margin-bottom: 16px; padding: 14px 18px; border-radius: 14px; background: linear-gradient(135deg, rgba(15,23,42,0.72), rgba(30,41,59,0.42)); border: 1px solid rgba(34,211,238,0.18); } .sglv-ach-real-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } .sglv-ach-real-title { font-size: 14px; font-weight: 700; color: #e6edf3; display: flex; align-items: center; gap: 8px; } .sglv-ach-real-tag { font-size: 9px; font-weight: 700; padding: 2px 6px; border-radius: 5px; background: rgba(34,211,238,0.15); color: #22d3ee; letter-spacing: 0.05em; } .sglv-ach-real-sub { font-size: 11px; color: #8b949e; text-align: right; } .sglv-ach-real-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } .sglv-ach-real-kpi { padding: 9px 12px; border-radius: 10px; background: rgba(13,17,23,0.55); border: 1px solid rgba(48,54,61,0.5); min-width: 0; } .sglv-ach-real-kpi-v { font-size: 19px; font-weight: 700; color: #e6edf3; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-ach-real-kpi-l { font-size: 10px; color: #8b949e; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-ach-real-kpi.gold .sglv-ach-real-kpi-v { color: #fbbf24; } .sglv-ach-real-kpi.cyan .sglv-ach-real-kpi-v { color: #22d3ee; } .sglv-ach-real-kpi.violet .sglv-ach-real-kpi-v { color: #a78bfa; } .sglv-ach-real-bar { height: 6px; border-radius: 4px; background: rgba(48,54,61,0.6); overflow: hidden; margin-top: 12px; } .sglv-ach-real-bar > div { height: 100%; background: linear-gradient(90deg, #22d3ee, #6366f1); border-radius: 4px; transition: width 0.3s; } .sglv-ach-real-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; flex-wrap: wrap; } .sglv-ach-real-hint { font-size: 10px; color: #6e7681; line-height: 1.5; min-width: 0; } .sglv-ach-real-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .sglv-ach-real-btn { padding: 6px 14px; border-radius: 8px; border: 1px solid rgba(34,211,238,0.35); background: rgba(34,211,238,0.12); color: #67e8f9; font-size: 11px; font-weight: 700; cursor: pointer; transition: all 0.15s; flex-shrink: 0; white-space: nowrap; } .sglv-ach-real-btn:hover:not(:disabled) { background: rgba(34,211,238,0.22); border-color: rgba(34,211,238,0.55); } .sglv-ach-real-btn:disabled { opacity: 0.45; cursor: default; } .sglv-ach-real-btn.ghost { border-color: rgba(148,163,184,0.28); background: rgba(148,163,184,0.08); color: #94a3b8; } .sglv-ach-real-btn.ghost:hover:not(:disabled) { background: rgba(148,163,184,0.16); border-color: rgba(148,163,184,0.45); color: #cbd5e1; } .sglv-ach-real-hint.sglv-ach-real-loading { color: #22d3ee; animation: sglv-ach-pulse 2s ease-in-out infinite; } @keyframes sglv-ach-pulse { 0%,100% { opacity:1; } 50% { opacity:0.6; } } /*真实成就 · 深度面板(S2 完美墙 / S3 稀有收藏 / S4 缺口)*/ .sglv-ach-deep { margin-bottom: 16px; padding: 14px 18px 16px; border-radius: 14px; background: linear-gradient(135deg, rgba(15,23,42,0.62), rgba(30,41,59,0.34)); border: 1px solid rgba(99,102,241,0.16); } .sglv-ach-deep-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; } .sglv-ach-deep-tabs { display: flex; gap: 6px; flex-wrap: wrap; } .sglv-ach-deep-tab { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 9px; border: 1px solid rgba(48,54,61,0.8); background: rgba(13,17,23,0.5); color: #8b949e; font-size: 11.5px; font-weight: 600; cursor: pointer; transition: all 0.15s; } .sglv-ach-deep-tab:hover { color: #e6edf3; border-color: rgba(99,102,241,0.4); } .sglv-ach-deep-tab.active { color: #e6edf3; background: rgba(99,102,241,0.16); border-color: rgba(129,140,248,0.55); box-shadow: inset 0 1px 0 rgba(255,255,255,0.06); } .sglv-ach-deep-tab b { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 6px; background: rgba(148,163,184,0.16); color: #94a3b8; } .sglv-ach-deep-tab.active b { background: rgba(129,140,248,0.28); color: #c7d2fe; } .sglv-ach-deep-hunter { display: flex; align-items: baseline; gap: 8px; padding: 5px 12px; border-radius: 9px; background: linear-gradient(135deg, rgba(251,191,36,0.14), rgba(244,63,94,0.1)); border: 1px solid rgba(251,191,36,0.32); } .sglv-ach-deep-hunter-l { font-size: 10px; color: #d6bc8a; letter-spacing: 0.04em; } .sglv-ach-deep-hunter-v { font-size: 17px; font-weight: 800; color: #fbbf24; line-height: 1; } .sglv-ach-deep-body { min-height: 60px; } .sglv-ach-sec { display: flex; align-items: center; gap: 8px; margin: 0 0 8px; font-size: 11px; font-weight: 700; color: #8b949e; letter-spacing: 0.03em; } .sglv-ach-sec b { font-size: 10px; padding: 1px 6px; border-radius: 6px; background: rgba(148,163,184,0.14); color: #94a3b8; font-weight: 700; } .sglv-ach-sec + .sglv-ach-wall, .sglv-ach-sec + .sglv-ach-list { margin-bottom: 14px; } .sglv-ach-empty { padding: 18px 4px; font-size: 11.5px; color: #6e7681; line-height: 1.7; } /*完美通关墙 — 卡片网格*/ .sglv-ach-wall { display: grid; grid-template-columns: repeat(auto-fill, minmax(206px, 1fr)); gap: 9px; } .sglv-ach-wall-card { position: relative; text-align: left; padding: 11px 13px 12px; border-radius: 11px; border: 1px solid rgba(48,54,61,0.7); background: linear-gradient(135deg, rgba(13,17,23,0.72), rgba(30,41,59,0.3)); cursor: pointer; transition: all 0.16s; overflow: hidden; font-family: inherit; color: inherit; } .sglv-ach-wall-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--tw, #6366f1); opacity: 0.85; } .sglv-ach-wall-card:hover { transform: translateY(-2px); border-color: rgba(129,140,248,0.5); box-shadow: 0 10px 26px rgba(0,0,0,0.36); } .sglv-ach-wall-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; } .sglv-ach-wall-name { flex: 1; min-width: 0; font-size: 12.5px; font-weight: 700; color: #e6edf3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-ach-game-ico { width: 48px; height: 22px; object-fit: cover; object-position: center; border-radius: 4px; flex-shrink: 0; display: block; background: rgba(255,255,255,0.06); } .sglv-ach-wall-card .sglv-ach-game-ico { width: 36px; height: 36px; border-radius: 6px; } .sglv-ach-wall-badge { flex-shrink: 0; font-size: 9px; font-weight: 800; padding: 2px 6px; border-radius: 5px; color: var(--tw, #6366f1); background: color-mix(in srgb, var(--tw, #6366f1) 16%, transparent); border: 1px solid color-mix(in srgb, var(--tw, #6366f1) 38%, transparent); letter-spacing: 0.04em; } .sglv-ach-wall-sub { font-size: 10px; color: #8b949e; } .sglv-ach-wall-ach { margin-top: 5px; font-size: 10.5px; color: #6e7681; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /*稀有度分桶条形*/ .sglv-ach-buckets { display: grid; gap: 6px; margin-bottom: 14px; } .sglv-ach-bucket { display: grid; grid-template-columns: 44px 1fr 74px; align-items: center; gap: 10px; font-size: 10.5px; } .sglv-ach-bucket-l { color: var(--bc, #94a3b8); font-weight: 700; } .sglv-ach-bucket-bar { height: 8px; border-radius: 5px; background: rgba(48,54,61,0.55); overflow: hidden; position: relative; } .sglv-ach-bucket-bar > i { position: absolute; inset: 0 auto 0 0; min-width: 2px; background: color-mix(in srgb, var(--bc, #94a3b8) 70%, transparent); border-radius: 5px; } .sglv-ach-bucket-c { color: #8b949e; text-align: right; font-variant-numeric: tabular-nums; } .sglv-ach-bucket-c b { color: #e6edf3; } /*稀有列表 / 缺口列表 — 通用行*/ .sglv-ach-list { display: grid; gap: 4px; } .sglv-ach-row { display: grid; grid-template-columns: 8px 48px 1fr auto; align-items: center; gap: 10px; width: 100%; padding: 7px 10px; border-radius: 8px; border: 1px solid transparent; background: rgba(13,17,23,0.42); cursor: pointer; text-align: left; font-family: inherit; color: inherit; transition: all 0.13s; } .sglv-ach-row:hover { background: rgba(30,41,59,0.66); border-color: rgba(99,102,241,0.32); } .sglv-ach-row-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dc, #94a3b8); box-shadow: 0 0 7px color-mix(in srgb, var(--dc, #94a3b8) 55%, transparent); } .sglv-ach-row-main { min-width: 0; } /* 这两个是 <span>,必须显式块级化:否则成就名与游戏名会挤在同一行、互相切断 */ .sglv-ach-row-t { display: block; font-size: 11.5px; color: #d8e0e8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-ach-row-s { display: block; font-size: 10px; color: #6e7681; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; } .sglv-ach-row-p { font-size: 11.5px; font-weight: 700; color: var(--dc, #94a3b8); font-variant-numeric: tabular-nums; white-space: nowrap; } .sglv-ach-row-p small { font-size: 9px; font-weight: 600; color: #6e7681; margin-left: 2px; } /*成就详情弹窗(S5)*/ .sglv-ach-modal { position: fixed; inset: 0; z-index: 2147483000; display: flex; align-items: center; justify-content: center; padding: 24px; } .sglv-ach-modal-mask { position: absolute; inset: 0; background: rgba(2,6,14,0.74); backdrop-filter: blur(4px); } .sglv-ach-modal-panel { position: relative; width: min(680px, 100%); max-height: min(78vh, 720px); display: flex; flex-direction: column; border-radius: 16px; background: linear-gradient(160deg, rgba(17,24,39,0.98), rgba(11,15,25,0.99)); border: 1px solid rgba(99,102,241,0.28); box-shadow: 0 24px 70px rgba(0,0,0,0.6); overflow: hidden; animation: sglv-ach-modal-in 0.16s ease-out; } @keyframes sglv-ach-modal-in { from { opacity: 0; transform: translateY(10px) scale(0.985); } to { opacity: 1; transform: none; } } .sglv-ach-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 16px 18px 13px; border-bottom: 1px solid rgba(48,54,61,0.6); } .sglv-ach-modal-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 800; color: #e6edf3; margin-bottom: 4px; } .sglv-ach-modal-title .sglv-ach-game-ico { width: 64px; height: 30px; } .sglv-ach-modal-sub { font-size: 11px; color: #8b949e; } .sglv-ach-modal-sub b { color: #22d3ee; font-weight: 700; } .sglv-ach-modal-close { flex-shrink: 0; width: 26px; height: 26px; border-radius: 8px; border: 1px solid rgba(48,54,61,0.8); background: rgba(13,17,23,0.6); color: #8b949e; font-size: 15px; line-height: 1; cursor: pointer; transition: all 0.14s; } .sglv-ach-modal-close:hover { color: #e6edf3; border-color: rgba(239,68,68,0.45); background: rgba(239,68,68,0.12); } .sglv-ach-modal-body { padding: 10px 14px 16px; overflow-y: auto; overscroll-behavior: contain; } .sglv-ach-modal-body::-webkit-scrollbar { width: 8px; } .sglv-ach-modal-body::-webkit-scrollbar-thumb { background: rgba(99,102,241,0.32); border-radius: 4px; } .sglv-ach-mrow { display: grid; grid-template-columns: 8px 1fr auto; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; } .sglv-ach-mrow:nth-child(odd) { background: rgba(30,41,59,0.3); } .sglv-ach-mrow.off .sglv-ach-row-t { color: #6e7681; } .sglv-ach-mrow.off .sglv-ach-row-dot { opacity: 0.42; } .sglv-ach-mrow-p { font-size: 11px; font-weight: 700; color: var(--dc, #94a3b8); font-variant-numeric: tabular-nums; white-space: nowrap; } .sglv-ach-mrow-p small { font-size: 9px; color: #6e7681; font-weight: 600; margin-left: 3px; } @media (max-width: 720px) { .sglv-ach-real-kpis { grid-template-columns: repeat(2, 1fr); } .sglv-ach-deep-head { align-items: flex-start; } .sglv-ach-bucket { grid-template-columns: 38px 1fr 64px; } .sglv-ach-modal { padding: 12px; } } /* ===== ACH-DEEP-CSS:END ===== */ /*成就卡片 — 参考设计稀有度左边框+hover提升+图标光泽 */ .sglv-ach-card { display: flex; align-items: flex-start; gap: 14px; padding: 16px 18px; border-radius: 14px; background: linear-gradient(135deg, rgba(15,23,42,0.6), rgba(30,41,59,0.4)); border: 1px solid rgba(99,102,241,0.1); transition: all 0.25s; position: relative; overflow: hidden; } .sglv-ach-card::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--ach-rarity, #6366f1); opacity: 0.8; } .sglv-ach-card:hover { transform: translateY(-2px); border-color: rgba(99,102,241,0.3); box-shadow: 0 8px 24px rgba(0,0,0,0.3); } .sglv-ach-card.unlocked { --ach-rarity: #22c55e; border-color: rgba(34,197,94,0.15); background: linear-gradient(135deg, rgba(34,197,94,0.05), rgba(13,17,23,0.4)); } .sglv-ach-card.unlocked.rare { --ach-rarity: #22d3ee; } .sglv-ach-card.unlocked.epic { --ach-rarity: #a78bfa; } .sglv-ach-card.unlocked.legendary { --ach-rarity: #fbbf24; } .sglv-ach-card.locked { --ach-rarity: #6366f1; opacity: 0.6; } .sglv-ach-card.locked.rare { --ach-rarity: #22d3ee; } .sglv-ach-card.locked.epic { --ach-rarity: #a78bfa; } .sglv-ach-card.locked.legendary { --ach-rarity: #fbbf24; } /*图标 — 参考设计渐变背景+内阴影+光泽叠加 */ .sglv-ach-card-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; position: relative; background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08)); box-shadow: inset 0 1px 0 rgba(255,255,255,0.1), inset 0 -1px 0 rgba(0,0,0,0.2); } .sglv-ach-card-icon::after { content: ''; position: absolute; inset: 0; border-radius: 14px; background: linear-gradient(135deg, rgba(255,255,255,0.12) 0%, transparent 40%); pointer-events: none; } .sglv-ach-card-icon svg { width: 100%; height: 100%; display: block; } .sglv-ach-card.unlocked .sglv-ach-card-icon { box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), inset 0 -1px 0 rgba(0,0,0,0.2), 0 4px 12px rgba(99,102,241,0.25); } .sglv-ach-card.locked .sglv-ach-card-icon { filter: grayscale(70%) brightness(0.5); } .sglv-ach-card-rarity { position: absolute; top: -2px; right: -2px; width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid #0d1117; z-index: 2; } .sglv-ach-card-rarity.common { background: #8b949e; } .sglv-ach-card-rarity.rare { background: #22d3ee; } .sglv-ach-card-rarity.epic { background: #a78bfa; } .sglv-ach-card-rarity.legendary { background: #fbbf24; } .sglv-ach-card-body { flex: 1; min-width: 0; } .sglv-ach-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; } .sglv-ach-card-name { font-size: 14px; font-weight: 700; color: #e6edf3; display: flex; align-items: center; gap: 6px; } .sglv-ach-card.unlocked .sglv-ach-card-name { color: #4ade80; } /*NEW 标签 */ .sglv-ach-card-new-tag { font-size: 9px; background: #22c55e; color: #fff; padding: 1px 6px; border-radius: 4px; font-weight: 700; line-height: 1.4; } /*点数徽章 — 参考设计渐变背景 */ .sglv-ach-card-pts { font-size: 11px; font-weight: 700; padding: 4px 10px; border-radius: 8px; flex-shrink: 0; white-space: nowrap; background: linear-gradient(135deg, rgba(251,191,36,0.15), rgba(245,158,11,0.1)); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; } .sglv-ach-card.unlocked .sglv-ach-card-pts { background: rgba(34,197,94,0.1); border-color: rgba(34,197,94,0.3); color: #4ade80; } .sglv-ach-card-desc { font-size: 11px; color: #94a3b8; line-height: 1.4; margin-bottom: 8px; } /*进度条 — 参考设计微光动画 */ .sglv-ach-card-progress-wrap { display: flex; align-items: center; gap: 8px; } .sglv-ach-card-progress { flex: 1; height: 6px; background: rgba(34,211,238,0.08); border-radius: 3px; overflow: hidden; position: relative; } .sglv-ach-card-progress-fill { height: 100%; border-radius: 3px; transition: width 0.6s cubic-bezier(0.4,0,0.2,1); position: relative; } .sglv-ach-card.unlocked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #22d3ee, #34d399, #fbbf24); box-shadow: 0 0 8px rgba(34,211,238,0.4); } .sglv-ach-card.locked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #38bdf8, #a78bfa, #f59e0b); box-shadow: 0 0 8px rgba(56,189,248,0.25); } .sglv-ach-card-progress-fill::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: sglv-ach-shimmer 2.5s infinite; } @keyframes sglv-ach-shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } .sglv-ach-card-progress-val { font-size: 10px; color: #64748b; white-space: nowrap; font-variant-numeric: tabular-nums; } .sglv-ach-card.unlocked .sglv-ach-card-progress-val { color: #22d3ee; font-weight: 600; } .sglv-ach-card-unlock-time { font-size: 9px; color: #6e7681; margin-top: 4px; } .sglv-ach-footer { font-size: 10px; color: #6e7681; text-align: center; padding: 10px 0; margin-top: 8px; border-top: 1px solid rgba(48,54,61,0.3); }`; const CSS_SHOWCASE =` /* ====== 展柜主布局 ====== */ .sglv-sc-wrap { display: flex; flex-direction: column; height: 100%; min-height: 400px; background: linear-gradient(180deg, #0d1117 0%, #161b22 100%); border-radius: 8px; overflow: hidden; } .sglv-sc-scroll { flex: 1; overflow-y: auto; padding: 20px; } /* ====== 英雄区(玩家档案横幅 — 紧凑版 v2) ====== */ .sglv-sc-hero { position: relative; display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 10px; margin-bottom: 14px; overflow: hidden; background: linear-gradient(135deg, rgba(99,102,241,0.10) 0%, rgba(168,85,247,0.06) 50%, rgba(6,182,212,0.04) 100%); border: 1px solid rgba(99,102,241,0.15); } .sglv-sc-hero::before { content: ''; position: absolute; inset: 0; background: radial-gradient(ellipse at top right, rgba(168,85,247,0.10), transparent 60%); pointer-events: none; } .sglv-sc-avatar-wrap { position: relative; flex-shrink: 0; z-index: 1; } .sglv-sc-avatar { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; border: 2px solid rgba(255,255,255,0.12); box-shadow: 0 1px 8px rgba(0,0,0,0.35), 0 0 0 1px rgba(168,85,247,0.25); background: linear-gradient(135deg, #1a1a2e, #16213e); } .sglv-sc-avatar-placeholder { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; font-size: 18px; font-weight: 700; border: 2px solid rgba(255,255,255,0.12); box-shadow: 0 1px 8px rgba(0,0,0,0.35); } .sglv-sc-level-badge { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 8px; background: linear-gradient(135deg, #f59e0b, #d97706); color: #fff; font-size: 10px; font-weight: 800; border: 1px solid rgba(245,158,11,0.4); box-shadow: 0 1px 4px rgba(245,158,11,0.25); font-variant-numeric: tabular-nums; line-height: 1.3; white-space: nowrap; flex-shrink: 0; } .sglv-sc-hero-info { flex: 1; min-width: 0; z-index: 1; } .sglv-sc-hero-name-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .sglv-sc-hero-name { font-size: 15px; font-weight: 800; color: #f1f5f9; line-height: 1.2; text-shadow: 0 1px 3px rgba(0,0,0,0.4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } .sglv-sc-hero-country { font-size: 10px; color: #94a3b8; display: inline-flex; align-items: center; gap: 3px; padding: 1px 6px; border-radius: 8px; background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.1); white-space: nowrap; } .sglv-sc-hero-country img { width: 14px; height: 10px; border-radius: 1px; } .sglv-sc-hero-stats { display: flex; gap: 0; margin-top: 8px; flex-wrap: wrap; } .sglv-sc-hero-stat { display: flex; align-items: center; gap: 6px; padding: 0 14px; border-right: 1px solid rgba(148,163,184,0.10); } .sglv-sc-hero-stat:first-child { padding-left: 0; } .sglv-sc-hero-stat:last-child { border-right: none; } .sglv-sc-hero-stat svg { width: 14px; height: 14px; color: #818cf8; flex-shrink: 0; } .sglv-sc-hero-stat-val { font-size: 14px; font-weight: 700; color: #e2e8f0; font-variant-numeric: tabular-nums; line-height: 1; } .sglv-sc-hero-stat-label { font-size: 10px; color: #64748b; line-height: 1; margin-top: 2px; } /* ====== 双栏布局 ====== */ .sglv-sc-cols { display: flex; gap: 20px; align-items: flex-start; } .sglv-sc-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 20px; } .sglv-sc-right { width: 340px; flex-shrink: 0; } /* ====== 区块卡片通用 ====== */ .sglv-sc-card { background: rgba(22,27,34,0.6); border: 1px solid rgba(48,54,61,0.5); border-radius: 10px; overflow: hidden; backdrop-filter: blur(8px); } .sglv-sc-card-header { display: flex; align-items: center; gap: 8px; padding: 14px 16px 10px; border-bottom: 1px solid rgba(48,54,61,0.3); } .sglv-sc-card-header svg { width: 18px; height: 18px; color: #a78bfa; flex-shrink: 0; } .sglv-sc-card-title { font-size: 14px; font-weight: 700; color: #e6edf3; } .sglv-sc-card-body { padding: 14px 16px; } /* ====== 游戏封面网格(4×2 竖版) ====== */ .sglv-sc-cover-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } .sglv-sc-cover-item { position: relative; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #1a1a2e; cursor: pointer; border: 1px solid rgba(255,255,255,0.06); transition: border-color 0.2s, box-shadow 0.2s; } .sglv-sc-cover-item:hover { border-color: rgba(168,85,247,0.5); box-shadow: 0 4px 16px rgba(168,85,247,0.2); } .sglv-sc-cover-item img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-sc-cover-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px; background: linear-gradient(0deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.2) 50%, transparent 100%); opacity: 0; transition: opacity 0.2s; } .sglv-sc-cover-item:hover .sglv-sc-cover-overlay { opacity: 1; } .sglv-sc-cover-name { font-size: 11px; font-weight: 600; color: #f1f5f9; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .sglv-sc-cover-hours { font-size: 10px; color: #a5b4fc; margin-top: 2px; font-variant-numeric: tabular-nums; } .sglv-sc-cover-placeholder { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: #475569; font-size: 11px; } /*骨架屏样式 — shimmer 动画占位块 */ .sglv-sc-sk-block { background: linear-gradient(90deg, rgba(99,102,241,0.06) 25%, rgba(99,102,241,0.15) 37%, rgba(99,102,241,0.06) 63%); background-size: 400% 100%; animation: sglv-sc-shimmer 1.4s ease infinite; } @keyframes sglv-sc-shimmer { 0% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } } .sglv-sc-skeleton-wrap .sglv-sc-playtime-item, .sglv-sc-skeleton-wrap .sglv-sc-ach-item { background: transparent; border-left: none; } /* ====== 编辑按钮 ====== */ .sglv-sc-edit-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600; background: rgba(99,102,241,0.15); border: 1px solid rgba(99,102,241,0.3); color: #a5b4fc; cursor: pointer; transition: background 0.15s, border-color 0.15s; margin-left: auto; } .sglv-sc-edit-btn:hover { background: rgba(99,102,241,0.25); border-color: rgba(99,102,241,0.5); } .sglv-sc-edit-btn svg { width: 14px; height: 14px; } /* ====== 最近游玩时长列表 ====== */ .sglv-sc-playtime-list { display: flex; flex-direction: column; gap: 8px; } .sglv-sc-playtime-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; background: rgba(13,17,23,0.5); transition: background 0.15s; } .sglv-sc-playtime-item:hover { background: rgba(99,102,241,0.08); } .sglv-sc-playtime-icon { width: 32px; height: 32px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: #1a1a2e; } .sglv-sc-playtime-icon-ph { width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; background: #1a1a2e; color: #475569; font-size: 10px; flex-shrink: 0; } .sglv-sc-playtime-info { flex: 1; min-width: 0; } .sglv-sc-playtime-name { font-size: 12px; font-weight: 600; color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-sc-playtime-meta { font-size: 10px; margin-top: 2px; display: flex; align-items: center; gap: 0; } .sglv-sc-playtime-bar { height: 4px; background: rgba(99,102,241,0.1); border-radius: 2px; margin-top: 4px; overflow: hidden; } .sglv-sc-playtime-bar-fill { height: 100%; border-radius: 2px; background: linear-gradient(90deg, #6366f1, #a855f7); transition: width 0.6s ease; } .sglv-sc-playtime-empty { text-align: center; padding: 24px; color: #64748b; font-size: 12px; } /* ====== 最近解锁成就列表 ====== */ .sglv-sc-ach-list { display: flex; flex-direction: column; gap: 8px; } .sglv-sc-ach-item { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 8px; background: rgba(13,17,23,0.5); border-left: 3px solid transparent; transition: background 0.15s, border-color 0.15s; } .sglv-sc-ach-item:hover { background: rgba(99,102,241,0.08); } .sglv-sc-ach-item.rarity-common { border-left-color: #64748b; } .sglv-sc-ach-item.rarity-rare { border-left-color: #58a6ff; } .sglv-sc-ach-item.rarity-epic { border-left-color: #a855f7; } .sglv-sc-ach-item.rarity-legendary { border-left-color: #fbbf24; } .sglv-sc-ach-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; background: rgba(99,102,241,0.1); } .sglv-sc-ach-icon svg { width: 22px; height: 22px; } .sglv-sc-ach-info { flex: 1; min-width: 0; } .sglv-sc-ach-name { font-size: 12px; font-weight: 600; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-sc-ach-meta { font-size: 10px; color: #64748b; margin-top: 2px; display: flex; align-items: center; gap: 6px; } .sglv-sc-ach-pts { font-size: 11px; font-weight: 700; color: #fbbf24; flex-shrink: 0; font-variant-numeric: tabular-nums; } .sglv-sc-ach-empty { text-align: center; padding: 24px; color: #64748b; font-size: 12px; } /* ====== 玩家画像标签 ====== */ .sglv-sc-tags { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 16px; } .sglv-sc-tag { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px; border-radius: 16px; font-size: 11px; font-weight: 600; border: 1px solid rgba(255,255,255,0.08); } .sglv-sc-tag-collector { background: rgba(139,92,246,0.12); border-color: rgba(139,92,246,0.3); color: #c4b5fd; } .sglv-sc-tag-playtime { background: rgba(6,182,212,0.12); border-color: rgba(6,182,212,0.3); color: #67e8f9; } .sglv-sc-tag-mastery { background: rgba(16,185,129,0.12); border-color: rgba(16,185,129,0.3); color: #6ee7b7; } .sglv-sc-tag-diversity { background: rgba(249,115,22,0.12); border-color: rgba(249,115,22,0.3); color: #fdba74; } .sglv-sc-tag-loyalty { background: rgba(244,114,182,0.12); border-color: rgba(244,114,182,0.3); color: #f9a8d4; } .sglv-sc-tag-special { background: rgba(251,191,36,0.12); border-color: rgba(251,191,36,0.3); color: #fcd34d; } /* ====== 封面选择器弹窗(内嵌于游戏库浮窗内,不遮挡浮窗本身) ====== */ .sglv-panel .sglv-sc-picker-overlay { position: absolute; inset: 0; z-index: 1000002; background: rgba(0,0,0,0.65); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; border-radius: inherit; animation: sglv-sc-fade-in 0.2s ease; } .sglv-sc-picker { width: 720px; max-width: calc(100% - 40px); max-height: calc(100% - 40px); background: #161b22; border: 1px solid rgba(48,54,61,0.6); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.6); } .sglv-sc-picker-header { display: flex; align-items: center; gap: 10px; padding: 16px 20px; border-bottom: 1px solid rgba(48,54,61,0.4); } .sglv-sc-picker-title { font-size: 15px; font-weight: 700; color: #e6edf3; flex: 1; } .sglv-sc-picker-search { width: 200px; padding: 6px 10px; border-radius: 6px; border: 1px solid rgba(48,54,61,0.6); background: #0d1117; color: #e2e8f0; font-size: 12px; outline: none; } .sglv-sc-picker-search:focus { border-color: rgba(99,102,241,0.5); } .sglv-sc-picker-close { width: 28px; height: 28px; border-radius: 6px; border: none; background: rgba(255,255,255,0.06); color: #94a3b8; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; } .sglv-sc-picker-close:hover { background: rgba(255,255,255,0.12); color: #f1f5f9; } .sglv-sc-picker-body { flex: 1; overflow-y: auto; padding: 16px 20px; overscroll-behavior: contain; } .sglv-sc-picker-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; } .sglv-sc-picker-item { position: relative; aspect-ratio: 2/3; border-radius: 6px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s, transform 0.15s; } .sglv-sc-picker-item:hover { transform: scale(1.03); } .sglv-sc-picker-item.selected { border-color: #22c55e; } .sglv-sc-picker-item img { width: 100%; height: 100%; object-fit: cover; } .sglv-sc-picker-item-name { position: absolute; bottom: 0; left: 0; right: 0; padding: 4px 6px; font-size: 9px; color: #fff; background: linear-gradient(0deg, rgba(0,0,0,0.85), transparent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-sc-picker-check { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 50%; background: #22c55e; color: #fff; display: none; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; } .sglv-sc-picker-item.selected .sglv-sc-picker-check { display: flex; } .sglv-sc-picker-footer { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-top: 1px solid rgba(48,54,61,0.4); } .sglv-sc-picker-count { font-size: 12px; color: #94a3b8; } .sglv-sc-picker-count strong { color: #22c55e; } .sglv-sc-picker-save { padding: 7px 18px; border-radius: 6px; border: none; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; margin-left: auto; transition: opacity 0.15s; } .sglv-sc-picker-save:hover { opacity: 0.9; } .sglv-sc-picker-save:disabled { opacity: 0.4; cursor: not-allowed; } /* ====== 英雄区徽章展示区 ====== */ .sglv-sc-hero-divider { width: 1px; height: 44px; background: rgba(148,163,184,0.12); flex-shrink: 0; z-index: 1; } .sglv-sc-hero-badges { display: flex; gap: 8px; align-items: center; flex-shrink: 0; z-index: 1; } .sglv-sc-hero-badge { width: 48px; height: 48px; border-radius: 8px; background: rgba(255,255,255,0.04); border: 1.5px solid rgba(139,92,246,0.3); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: border-color 0.16s, transform 0.16s, box-shadow 0.16s; position: relative; overflow: visible; } .sglv-sc-hero-badge:hover { border-color: rgba(139,92,246,0.6); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(139,92,246,0.2); } .sglv-sc-hero-badge img { width: 36px; height: 36px; object-fit: contain; border-radius: 4px; } .sglv-sc-hero-badge-fallback { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: #818cf8; } .sglv-sc-hero-badge-fallback svg { width: 22px; height: 22px; } .sglv-sc-hero-badge-lvl { position: absolute; bottom: -5px; right: -5px; font-size: 9px; font-weight: 700; color: #fff; background: linear-gradient(135deg, #8b5cf6, #6366f1); border-radius: 4px; padding: 0 4px; line-height: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.3); white-space: nowrap; } .sglv-sc-hero-badge-foil-tag { position: absolute; top: -5px; left: -5px; font-size: 8px; font-weight: 700; color: #1a1a2e; background: linear-gradient(135deg, #fbbf24, #f59e0b); border-radius: 3px; padding: 0 3px; line-height: 12px; white-space: nowrap; } .sglv-sc-hero-badge-empty { width: 48px; height: 48px; border-radius: 8px; border: 1.5px dashed rgba(148,163,184,0.2); display: flex; align-items: center; justify-content: center; color: rgba(148,163,184,0.4); font-size: 20px; cursor: pointer; transition: border-color 0.16s, color 0.16s; } .sglv-sc-hero-badge-empty:hover { border-color: rgba(139,92,246,0.4); color: rgba(139,92,246,0.6); } .sglv-sc-hero-badge-edit { width: 28px; height: 28px; border-radius: 6px; background: rgba(139,92,246,0.15); border: 1px solid rgba(139,92,246,0.25); color: #a78bfa; cursor: pointer; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: background 0.16s, border-color 0.16s; } .sglv-sc-hero-badge-edit:hover { background: rgba(139,92,246,0.25); border-color: rgba(139,92,246,0.5); } .sglv-sc-hero-badge-edit svg { width: 14px; height: 14px; } /* ====== 徽章选择器弹窗 ====== */ .sglv-sc-badge-picker { max-width: 680px; } .sglv-sc-badge-tabs { display: flex; gap: 6px; padding: 10px 20px; border-bottom: 1px solid rgba(48,54,61,0.3); flex-wrap: wrap; } .sglv-sc-badge-tab { padding: 5px 12px; border-radius: 6px; border: 1px solid rgba(48,54,61,0.5); background: transparent; color: #94a3b8; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.16s; white-space: nowrap; } .sglv-sc-badge-tab:hover { background: rgba(255,255,255,0.04); color: #c4b5fd; } .sglv-sc-badge-tab.active { background: rgba(139,92,246,0.15); border-color: rgba(139,92,246,0.4); color: #c4b5fd; } .sglv-sc-badge-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } .sglv-sc-badge-pick-item { position: relative; border-radius: 8px; overflow: hidden; background: rgba(22,27,34,0.6); border: 1.5px solid rgba(48,54,61,0.5); cursor: pointer; transition: border-color 0.16s, transform 0.16s; display: flex; flex-direction: column; gap: 6px; padding: 8px; } .sglv-sc-badge-pick-item:hover { border-color: rgba(139,92,246,0.4); transform: scale(1.02); } .sglv-sc-badge-pick-item.selected { border-color: #22c55e; } .sglv-sc-badge-pick-icon { width: 100%; height: 64px; display: flex; align-items: center; justify-content: center; background: rgba(13,17,23,0.4); border-radius: 6px; overflow: hidden; } .sglv-sc-badge-pick-icon img { max-width: 56px; max-height: 56px; object-fit: contain; } .sglv-sc-badge-pick-icon.no-icon { } .sglv-sc-badge-pick-noicon { font-size: 24px; opacity: 0.5; } .sglv-sc-badge-pick-info { flex: 1; min-width: 0; } .sglv-sc-badge-pick-name { font-size: 11px; font-weight: 600; color: #e2e8f0; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-sc-badge-pick-meta { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 3px; } .sglv-sc-badge-pick-meta span { font-size: 9px; padding: 0 4px; border-radius: 3px; line-height: 14px; } .sglv-sc-badge-foil { background: rgba(245,158,11,0.15); color: #fbbf24; } .sglv-sc-badge-lvl { background: rgba(139,92,246,0.15); color: #c4b5fd; } .sglv-sc-badge-pick-xp { background: rgba(34,197,94,0.1); color: #86efac; } .sglv-sc-badge-pick-item .sglv-sc-picker-check { top: 4px; right: 4px; } @keyframes sglv-sc-fade-in { from { opacity: 0; } to { opacity: 1; } } /* ====== 加载/空状态 ====== */ .sglv-sc-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; color: #8a9ba8; } .sglv-sc-loading svg { animation: sglv-spin 1s linear infinite; } @media (prefers-reduced-motion: reduce) { .sglv-sc-cover-overlay, .sglv-sc-cover-item, .sglv-sc-playtime-item, .sglv-sc-ach-item, .sglv-sc-picker-item, .sglv-sc-hero-badge, .sglv-sc-hero-badge-empty, .sglv-sc-badge-pick-item { transition: none !important; } .sglv-panel .sglv-sc-picker-overlay { animation: none !important; } .sglv-sc-loading svg { animation: none !important; } }`; const CSS_CLOUDSAVE =` /* ====== 云存档主布局 ====== */ .sglv-cs-wrap { display: flex; gap: 0; height: 100%; min-height: 400px; } .sglv-cs-side { width: 540px; flex-shrink: 0; border-right: 1px solid rgba(255,255,255,0.06); padding: 14px; overflow-y: auto; } .sglv-cs-list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; } /* ====== 状态视图(加载/错误/空) ====== */ .sglv-cs-state-box { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; padding: 40px; text-align: center; width: 100%; } .sglv-cs-state-icon { font-size: 36px; } .sglv-cs-state-msg { font-size: 14px; color: var(--sglv-text-1, #e2e8f0); max-width: 360px; line-height: 1.5; } .sglv-cs-state-tip { font-size: 11px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-action-btn { padding: 8px 20px; border-radius: 8px; border: 1px solid rgba(139,92,246,0.4); background: rgba(139,92,246,0.12); color: #c4b5fd; cursor: pointer; font-size: 13px; transition: all 0.2s; } .sglv-cs-action-btn:hover { background: rgba(139,92,246,0.25); border-color: rgba(139,92,246,0.6); } /* ====== KPI 卡片(炫彩边框 + 色彩变体) ====== */ /* === 云存档 KPI 卡片复用 sglv-playtime-kpi-card 统一风格(4 列均分) === */ .sglv-cs-kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; } @media (max-width: 1100px) { .sglv-cs-kpi-row { grid-template-columns: repeat(2, 1fr) !important; } } /* ====== 仪表盘区块 ====== */ .sglv-cs-section { margin-bottom: 16px; } .sglv-cs-section-title { font-size: 12px; font-weight: 600; color: var(--sglv-text-1, #e2e8f0); margin-bottom: 8px; } /* ====== TOP 10 ====== */ .sglv-cs-top10 { display: flex; flex-direction: column; gap: 6px; } .sglv-cs-top10-item { display: flex; align-items: center; gap: 6px; font-size: 11px; } .sglv-cs-top10-rank { width: 16px; text-align: center; font-weight: 700; color: var(--sglv-text-2, #94a3b8); flex-shrink: 0; } .sglv-cs-top10-name { flex-shrink: 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--sglv-text-1, #e2e8f0); width: 140px; } .sglv-cs-top10-bar-track { flex: 1; height: 8px; background: rgba(255,255,255,0.05); border-radius: 4px; overflow: hidden; min-width: 30px; } .sglv-cs-top10-bar { height: 100%; background: linear-gradient(90deg, #8b5cf6, #6366f1); border-radius: 4px; transition: width 0.4s ease; } .sglv-cs-top10-size { flex-shrink: 0; color: var(--sglv-text-2, #94a3b8); font-size: 10px; min-width: 44px; text-align: right; } /* ====== 分布图 ====== */ /*大小分布 + 文件数分布 并排两栏 */ .sglv-cs-dist-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; } .sglv-cs-dist-card { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; padding: 10px 8px 8px; } .sglv-cs-dist-card .sglv-cs-section-title { font-size: 11px; margin-bottom: 6px; text-align: center; } .sglv-cs-dist { display: flex; align-items: flex-end; gap: 8px; height: 130px; } .sglv-cs-dist-col { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; min-width: 0; } .sglv-cs-dist-bar-track { width: 100%; flex: 1; display: flex; align-items: flex-end; justify-content: center; min-height: 2px; } .sglv-cs-dist-bar { width: 50%; max-width: 32px; background: linear-gradient(180deg, #8b5cf6, #6366f1); border-radius: 3px 3px 0 0; min-height: 2px; transition: height 0.4s ease; } .sglv-cs-dist-bar-2 { background: linear-gradient(180deg, #3b82f6, #06b6d4); } .sglv-cs-dist-val { font-size: 10px; font-weight: 600; color: var(--sglv-text-1, #e2e8f0); margin-top: 3px; } .sglv-cs-dist-label { font-size: 9px; color: var(--sglv-text-2, #94a3b8); text-align: center; margin-top: 1px; } /* ====== Diff 徽章 ====== */ .sglv-cs-diff-box { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; } .sglv-cs-diff-badge { font-size: 10px; padding: 3px 8px; border-radius: 6px; font-weight: 600; } .sglv-cs-diff-badge.new { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); } .sglv-cs-diff-badge.changed { background: rgba(251,191,36,0.15); color: #fbbf24; border: 1px solid rgba(251,191,36,0.3); } .sglv-cs-diff-badge.removed { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); } /* ====== 底部 ====== */ .sglv-cs-footer { display: flex; align-items: center; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.04); flex-wrap: wrap; } .sglv-cs-source-badge { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: rgba(139,92,246,0.12); color: #c4b5fd; border: 1px solid rgba(139,92,246,0.2); } .sglv-cs-updated { font-size: 10px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-refresh-btn { margin-left: auto; background: none; border: none; color: var(--sglv-text-2, #94a3b8); cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; transition: all 0.2s; } .sglv-cs-refresh-btn:hover { color: #c4b5fd; background: rgba(139,92,246,0.12); } .sglv-cs-refresh-btn svg { width: 14px; height: 14px; } /* ====== 工具栏 ====== */ .sglv-cs-toolbar { display: flex; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; } .sglv-cs-search { flex: 1; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 6px 12px; color: var(--sglv-text-1, #e2e8f0); font-size: 12px; outline: none; transition: border-color 0.2s; } .sglv-cs-search:focus { border-color: rgba(139,92,246,0.5); } .sglv-cs-search::placeholder { color: var(--sglv-text-2, #94a3b8); } .sglv-cs-sort { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 6px 8px; color: var(--sglv-text-1, #e2e8f0); font-size: 12px; outline: none; cursor: pointer; } /* ====== 列表头 ====== */ .sglv-cs-list-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; font-size: 12px; font-weight: 600; color: var(--sglv-text-1, #e2e8f0); flex-shrink: 0; } .sglv-cs-list-count { font-size: 11px; color: var(--sglv-text-2, #94a3b8); background: rgba(255,255,255,0.05); padding: 2px 8px; border-radius: 6px; } /* ====== 游戏列表 ====== */ .sglv-cs-game-list { flex: 1; overflow-y: auto; padding: 0 12px 12px; } .sglv-cs-game-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; transition: background 0.15s; margin-bottom: 4px; } .sglv-cs-game-row:hover { background: rgba(255,255,255,0.04); } .sglv-cs-game-cover { width: 46px; height: 22px; border-radius: 4px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.04); } /*通用 cover failed 样式(sglv-cover-fallback.lib.js 添加) */ .sglv-cs-cover-failed, .sglv-cover-failed { object-fit: contain !important; padding: 4px; opacity: 0.6; } .sglv-cs-game-cover-fallback { width: 46px; height: 22px; border-radius: 4px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(139,92,246,0.08)); font-size: 9px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-game-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } .sglv-cs-game-name { font-size: 12px; color: var(--sglv-text-1, #e2e8f0); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-cs-game-appid { font-size: 10px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-game-stats { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .sglv-cs-game-files { font-size: 11px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-game-size { font-size: 11px; color: var(--sglv-text-1, #e2e8f0); font-weight: 600; min-width: 60px; text-align: right; } .sglv-cs-game-dl { font-size: 10px; color: #66c0f4; text-decoration: none; flex-shrink: 0; opacity: 0.7; transition: opacity 0.15s; } .sglv-cs-game-dl:hover { opacity: 1; text-decoration: underline; } .sglv-cs-status-badge { font-size: 9px; padding: 2px 6px; border-radius: 4px; font-weight: 700; } .sglv-cs-status-badge.new { background: rgba(34,197,94,0.15); color: #4ade80; } .sglv-cs-status-badge.changed { background: rgba(251,191,36,0.15); color: #fbbf24; } .sglv-cs-list-empty { text-align: center; padding: 40px 20px; font-size: 13px; color: var(--sglv-text-2, #94a3b8); }`; const CSS_STAGE =` #sglv-stage-progress { padding: 8px 16px; background: linear-gradient(180deg, rgba(167,139,250,0.08), transparent); border-bottom: 1px solid rgba(167,139,250,0.15); display: block; } #sglv-stage-progress .sglv-stage-progress-row { display: flex; align-items: center; gap: 12px; } #sglv-stage-progress .sglv-stage-progress-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.06); border-radius: 3px; overflow: hidden; position: relative; } #sglv-stage-progress .sglv-stage-progress-fill { height: 100%; background: linear-gradient(90deg, #a78bfa 0%, #38bdf8 100%); width: 0%; transition: width 0.25s ease-out; border-radius: 3px; } #sglv-stage-progress .sglv-stage-progress-text { flex-shrink: 0; font-size: 12px; color: #94a3b8; min-width: 200px; text-align: right; font-variant-numeric: tabular-nums; }`; const CSS_HEADER =` /* === 紧凑页头:删除 h2 标题占用空间,padding 收紧 === */ .sglv-panel > .sglv-header { padding: 8px 14px !important; gap: 12px; cursor: move; /* 整个 header 作为拖动手柄 */ user-select: none; } .sglv-panel > .sglv-header h2 { display: none !important; } /* === 保留最左侧 Steam 图标作为品牌标识(去掉"游戏库"文字) === */ .sglv-header-brand { width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(102, 192, 244, 0.18), rgba(139, 92, 246, 0.18)); border: 1px solid rgba(102, 192, 244, 0.25); color: #66c0f4; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); transition: transform 0.2s, box-shadow 0.2s; } .sglv-header-brand:hover { transform: rotate(-8deg) scale(1.05); box-shadow: 0 2px 8px rgba(102, 192, 244, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1); } .sglv-header-brand img { width: 14px; height: 14px; display: block; border-radius: 2px; } .sglv-header-brand svg { width: 14px; height: 14px; } .sglv-panel > .sglv-header > .sglv-tab-bar { display: flex; align-items: center; gap: 2px; flex: 1; min-width: 0; overflow: visible; position: relative; } /* === tab 按钮紧凑化(等宽等距布局,flex:1 使各标签宽度一致) === */ /*紧凑——padding 4px 5px,font-size 11.5px,为右侧全局搜索框腾出空间 */ .sglv-panel .sglv-tab { flex: 1 1 0 !important; padding: 4px 5px !important; font-size: 11.5px !important; font-weight: 600 !important; border-radius: 6px !important; border: 1px solid transparent !important; background: transparent !important; color: #94a3b8 !important; white-space: nowrap !important; justify-content: center !important; gap: 4px !important; min-width: 0 !important; overflow: hidden !important; text-overflow: ellipsis !important; transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s !important; height: 26px !important; } .sglv-panel .sglv-tab:hover { color: #e2e8f0 !important; background: rgba(255, 255, 255, 0.06) !important; } .sglv-panel .sglv-tab.active { color: #fff !important; background: rgba(102, 192, 244, 0.22) !important; border-color: rgba(102, 192, 244, 0.4) !important; box-shadow: 0 0 0 1px rgba(102, 192, 244, 0.15) inset, 0 1px 4px rgba(102, 192, 244, 0.18) !important; } .sglv-panel .sglv-tab svg { width: 12px; height: 12px; flex-shrink: 0; } .sglv-panel .sglv-tab span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } /* === 头部 action 按钮 hover 反馈 + 状态点 === */ .sglv-panel > .sglv-header > .sglv-header-actions { gap: 6px; flex-shrink: 0; margin-left: auto; } .sglv-panel .sglv-header-btn { width: 30px !important; height: 30px !important; } /* === ★v2.36.0 导出下拉菜单(合并原 CSV/JSON 两个 header 按钮) === */ .sglv-export-menu { position: fixed; z-index: 1000012; min-width: 176px; padding: 6px; background: var(--sglv-panel-bg, rgba(23,26,33,0.98)); border: 1px solid var(--sglv-border, rgba(255,255,255,0.1)); border-radius: 10px; box-shadow: 0 12px 36px rgba(0,0,0,0.55); display: none; opacity: 0; transform: translateY(-4px); transition: opacity .15s ease, transform .15s ease; } .sglv-export-menu.show { display: block; opacity: 1; transform: translateY(0); } .sglv-export-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; border: none; background: transparent; color: var(--sglv-text, #c7d5e0); font-size: 12.5px; border-radius: 7px; cursor: pointer; text-align: left; font-family: inherit; } .sglv-export-menu button:hover { background: rgba(102,192,244,0.12); color: #fff; } .sglv-export-menu .fmt-badge { flex-shrink: 0; min-width: 38px; text-align: center; padding: 2px 6px; border-radius: 6px; font-size: 10px; font-weight: 700; letter-spacing: .04em; } .sglv-export-menu .fmt-badge.csv { background: rgba(52,211,153,0.14); color: #34d399; } .sglv-export-menu .fmt-badge.json { background: rgba(102,192,244,0.14); color: #66c0f4; } /* === 头部开关紧凑(家庭组) === */ .sglv-panel .sglv-switch-container { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: #94a3b8; user-select: none; cursor: pointer; height: 30px; padding: 0 4px 0 6px; border-radius: 6px; transition: background 0.15s, color 0.15s; } .sglv-panel .sglv-switch-container:hover { background: rgba(255, 255, 255, 0.05); color: #e2e8f0; } /* === 头部渐变微调,让 tab active 高光更明显 === */ .sglv-panel > .sglv-header { background: linear-gradient(135deg, rgba(27, 40, 56, 0.95) 0%, rgba(42, 71, 94, 0.95) 100%) !important; border-bottom: 1px solid rgba(102, 192, 244, 0.18) !important; } /* === tab 菜单紧凑化(保留),并接入全局搜索框 + 下拉结果浮层 === */ /* 全局搜索框:紧贴 wishlist tab 右侧,flex:0 0 auto 不抢占 tab 等宽空间 */ /*审查:移除 flex-grow:1(与 flex:0 0 auto 冲突,会抢 tab 空间导致布局错乱) */ .sglv-panel .sglv-global-search { flex: 0 0 200px !important; position: relative !important; display: flex !important; align-items: center !important; gap: 4px !important; height: 26px !important; padding: 0 6px 0 8px !important; margin-left: 6px !important; background: rgba(255, 255, 255, 0.06) !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 6px !important; color: #c7d5e0 !important; transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, width 0.15s !important; min-width: 120px !important; max-width: 240px !important; } .sglv-panel .sglv-global-search:hover { background: rgba(255, 255, 255, 0.09) !important; } .sglv-panel .sglv-global-search:focus-within { border-color: rgba(102, 192, 244, 0.55) !important; background: rgba(255, 255, 255, 0.1) !important; box-shadow: 0 0 0 1px rgba(102, 192, 244, 0.25) !important; } .sglv-panel .sglv-global-search-icon { display: inline-flex !important; align-items: center !important; color: #8a9ba8 !important; flex-shrink: 0 !important; } .sglv-panel .sglv-global-search-icon svg { width: 12px !important; height: 12px !important; } .sglv-panel .sglv-global-search input { flex: 1 1 auto !important; min-width: 0 !important; border: none !important; background: transparent !important; color: #fff !important; font-size: 11.5px !important; line-height: 1 !important; outline: none !important; padding: 0 !important; height: 100% !important; } .sglv-panel .sglv-global-search input::placeholder { color: #6b7c8c !important; font-size: 11px !important; } .sglv-panel .sglv-global-search-clear { flex: 0 0 auto !important; display: none !important; align-items: center !important; justify-content: center !important; width: 16px !important; height: 16px !important; padding: 0 !important; border: none !important; border-radius: 4px !important; background: rgba(255, 255, 255, 0.1) !important; color: #c7d5e0 !important; cursor: pointer !important; transition: background 0.15s, color 0.15s !important; } .sglv-panel .sglv-global-search-clear:hover { background: rgba(255, 255, 255, 0.2) !important; color: #fff !important; } .sglv-panel .sglv-global-search-clear svg { width: 9px !important; height: 9px !important; } .sglv-panel .sglv-global-search.has-text .sglv-global-search-clear { display: inline-flex !important; } /*搜索结果浮层 — 右对齐搜索框,动画显隐,动态 max-height 防溢出面板 */ .sglv-panel .sglv-global-search-pop { position: absolute !important; top: calc(100% + 6px) !important; right: 0 !important; left: auto !important; min-width: 300px !important; max-width: 400px !important; max-height: calc(80vh - 80px) !important; overflow-y: auto !important; background: rgba(15, 23, 42, 0.98) !important; border: 1px solid rgba(102, 192, 244, 0.35) !important; border-radius: 10px !important; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(0, 0, 0, 0.3) !important; z-index: 1000001 !important; padding: 4px 0 !important; backdrop-filter: blur(12px) !important; display: block !important; opacity: 0; transform: translateY(-6px) scale(0.98); visibility: hidden; transition: opacity 0.16s ease, transform 0.16s ease, visibility 0s linear 0.16s; transform-origin: top right; } .sglv-panel .sglv-global-search-pop::-webkit-scrollbar { width: 6px; } .sglv-panel .sglv-global-search-pop::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 3px; } .sglv-panel .sglv-global-search-pop.show { opacity: 1; transform: translateY(0) scale(1); visibility: visible; transition: opacity 0.16s ease, transform 0.16s ease, visibility 0s; } .sglv-panel .sglv-gs-header { padding: 8px 12px 5px !important; font-size: 10px !important; font-weight: 700 !important; color: #6e7681 !important; text-transform: uppercase !important; letter-spacing: 0.05em !important; display: flex !important; align-items: center !important; justify-content: space-between !important; border-bottom: 1px solid rgba(255, 255, 255, 0.04) !important; } .sglv-panel .sglv-gs-header-count { background: rgba(102, 192, 244, 0.12) !important; color: #66c0f4 !important; font-weight: 700 !important; text-transform: none !important; letter-spacing: 0 !important; padding: 1px 7px !important; border-radius: 10px !important; font-size: 10px !important; } .sglv-panel .sglv-gs-item { display: flex !important; align-items: center !important; gap: 10px !important; padding: 7px 12px !important; cursor: pointer !important; color: #c7d5e0 !important; font-size: 12px !important; transition: background 0.12s, color 0.12s !important; border-left: 2px solid transparent !important; } .sglv-panel .sglv-gs-item:hover, .sglv-panel .sglv-gs-item.focus { background: rgba(102, 192, 244, 0.12) !important; color: #fff !important; border-left-color: rgba(102, 192, 244, 0.6) !important; } .sglv-panel .sglv-gs-item-thumb { width: 48px !important; height: 18px !important; flex-shrink: 0 !important; background: rgba(255, 255, 255, 0.04) !important; border-radius: 3px !important; overflow: hidden !important; display: flex !important; align-items: center !important; justify-content: center !important; transition: transform 0.15s ease !important; } .sglv-panel .sglv-gs-item:hover .sglv-gs-item-thumb, .sglv-panel .sglv-gs-item.focus .sglv-gs-item-thumb { transform: scale(1.08) !important; } .sglv-panel .sglv-gs-item-thumb img { width: 100% !important; height: 100% !important; object-fit: cover !important; } .sglv-panel .sglv-gs-item-body { flex: 1 1 auto !important; min-width: 0 !important; } .sglv-panel .sglv-gs-item-name { font-size: 12px !important; font-weight: 600 !important; color: #e2e8f0 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } .sglv-panel .sglv-gs-item.focus .sglv-gs-item-name { color: #fff !important; } .sglv-panel .sglv-gs-item-name mark { background: rgba(102, 192, 244, 0.35) !important; color: #fff !important; padding: 0 1px !important; border-radius: 2px !important; } .sglv-panel .sglv-gs-item-sub { font-size: 10px !important; color: #8a9ba8 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; margin-top: 1px !important; } /*右侧徽章+图标容器,统一收紧间距 */ .sglv-panel .sglv-gs-item-right { display: flex !important; align-items: center !important; gap: 4px !important; flex-shrink: 0 !important; } .sglv-panel .sglv-gs-item-type { flex-shrink: 0 !important; font-size: 9px !important; font-weight: 700 !important; padding: 1px 5px !important; border-radius: 3px !important; text-transform: uppercase !important; letter-spacing: 0.04em !important; } .sglv-panel .sglv-gs-item-type.owned { background: rgba(63, 185, 80, 0.15) !important; color: #3fb950 !important; } .sglv-panel .sglv-gs-item-type.wishlist { background: rgba(245, 158, 11, 0.15) !important; color: #f59e0b !important; } .sglv-panel .sglv-gs-item-type.store { background: rgba(102, 192, 244, 0.15) !important; color: #66c0f4 !important; } /*精准匹配(AppID直接命中)行高亮 */ .sglv-panel .sglv-gs-item-exact { border-left: 3px solid #4ade80 !important; background: rgba(74, 222, 128, 0.04) !important; } .sglv-panel .sglv-gs-item-exact:hover, .sglv-panel .sglv-gs-item-exact.focus { background: rgba(74, 222, 128, 0.08) !important; } /*家庭共享次级徽章(紫色,小尺寸,紧贴主 type 徽章右侧) */ .sglv-panel .sglv-gs-item-shared { display: inline-flex !important; align-items: center !important; padding: 1px 6px !important; margin-left: 0 !important; font-size: 10px !important; font-weight: 600 !important; border-radius: 3px !important; background: rgba(139, 92, 246, 0.18) !important; color: #c4b5fd !important; border: 1px solid rgba(139, 92, 246, 0.35) !important; cursor: help !important; } .sglv-panel .sglv-gs-item-ext { display: inline-flex !important; align-items: center !important; color: #6e7681 !important; } .sglv-panel .sglv-gs-item-ext svg { width: 10px !important; height: 10px !important; } .sglv-panel .sglv-gs-empty { padding: 20px 12px !important; text-align: center !important; color: #6e7681 !important; font-size: 11px !important; display: flex !important; flex-direction: column !important; align-items: center !important; gap: 8px !important; } .sglv-panel .sglv-gs-empty-icon { opacity: 0.4 !important; } .sglv-panel .sglv-gs-empty-icon svg { width: 28px !important; height: 28px !important; } .sglv-panel .sglv-gs-footer { padding: 6px 12px !important; border-top: 1px solid rgba(255, 255, 255, 0.05) !important; font-size: 10px !important; color: #6e7681 !important; display: flex !important; align-items: center !important; gap: 10px !important; background: rgba(15, 23, 42, 0.5) !important; } .sglv-panel .sglv-gs-footer-hint { display: flex !important; align-items: center !important; gap: 3px !important; } .sglv-panel .sglv-gs-footer kbd { background: rgba(255, 255, 255, 0.08) !important; border: 1px solid rgba(255, 255, 255, 0.12) !important; border-radius: 3px !important; padding: 0 4px !important; font-size: 9px !important; font-family: monospace !important; color: #c7d5e0 !important; } /* === 游戏详情浮窗(点击搜索结果后弹出) === */ .sglv-panel .sglv-detail-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000002; border-radius: inherit; opacity: 0; visibility: hidden; transition: opacity 0.16s ease, visibility 0s linear 0.16s; } .sglv-panel .sglv-detail-overlay.show { opacity: 1; visibility: visible; transition: opacity 0.16s ease, visibility 0s; } .sglv-panel .sglv-detail-pop { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -48%) scale(0.96); width: calc(100% - 48px); max-width: 560px; max-height: calc(85vh - 60px); background: linear-gradient(160deg, rgba(23,33,45,0.99), rgba(15,23,42,0.99)); border: 1px solid rgba(102,192,244,0.25); border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(0,0,0,0.3); z-index: 1000003; overflow: hidden; display: flex; flex-direction: column; opacity: 0; visibility: hidden; transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s linear 0.2s; } .sglv-panel .sglv-detail-pop.show { opacity: 1; visibility: visible; transform: translate(-50%, -50%) scale(1); transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s; } .sglv-detail-close { position: absolute; top: 10px; right: 10px; z-index: 5; width: 30px; height: 30px; border-radius: 50%; border: none; background: rgba(0,0,0,0.4); color: #c7d5e0; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.16s, color 0.16s; } .sglv-detail-close:hover { background: rgba(244,67,54,0.5); color: #fff; } .sglv-detail-close svg { width: 16px; height: 16px; } .sglv-detail-scroll { overflow-y: auto; overflow-x: hidden; } .sglv-detail-scroll::-webkit-scrollbar { width: 6px; } .sglv-detail-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } .sglv-detail-hero { position: relative; width: 100%; aspect-ratio: 231/87; overflow: hidden; background: #1a2332; } .sglv-detail-hero img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-detail-hero::after { content:''; position:absolute; inset:0; background: linear-gradient(180deg, transparent 40%, rgba(15,23,42,0.95)); } .sglv-detail-body { padding: 0 18px 18px; } .sglv-detail-name { font-size: 18px; font-weight: 700; color: #e2e8f0; margin: -20px 0 4px; position: relative; z-index: 1; text-shadow: 0 2px 8px rgba(0,0,0,0.8); } .sglv-detail-desc { font-size: 12px; color: #8b949e; line-height: 1.6; margin: 10px 0; } .sglv-detail-meta { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 11px; margin: 10px 0; } .sglv-detail-meta dt { color: #6e7681; font-weight: 600; } .sglv-detail-meta dd { color: #c7d5e0; margin: 0; } .sglv-detail-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 10px 0; } .sglv-detail-tag { font-size: 10px; padding: 2px 8px; border-radius: 4px; background: rgba(102,192,244,0.1); color: #66c0f4; border: 1px solid rgba(102,192,244,0.15); } .sglv-detail-price { display: flex; align-items: center; gap: 8px; margin: 10px 0; } .sglv-detail-price-val { font-size: 16px; font-weight: 700; color: #4ade80; } .sglv-detail-price-discount { font-size: 12px; color: #fbbf24; } .sglv-detail-price-free { font-size: 16px; font-weight: 700; color: #4ade80; } .sglv-detail-shots { display: flex; gap: 6px; overflow-x: auto; margin: 10px 0; padding-bottom: 4px; } .sglv-detail-shots::-webkit-scrollbar { height: 4px; } .sglv-detail-shots::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; } .sglv-detail-shot { flex-shrink: 0; width: 200px; height: 113px; border-radius: 6px; overflow: hidden; background: #1a2332; } .sglv-detail-shot img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-detail-actions { display: flex; gap: 8px; margin-top: 14px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,0.06); } .sglv-detail-btn { display: inline-flex; align-items: center; gap: 5px; padding: 8px 16px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; border: none; transition: all 0.16s; text-decoration: none; } .sglv-detail-btn-primary { background: linear-gradient(135deg, #66c0f4, #1a9fff); color: #fff; } .sglv-detail-btn-primary:hover { filter: brightness(1.1); } .sglv-detail-btn-secondary { background: rgba(255,255,255,0.08); color: #c7d5e0; } .sglv-detail-btn-secondary:hover { background: rgba(255,255,255,0.14); } .sglv-detail-btn-ai { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; } .sglv-detail-btn-ai:hover { filter: brightness(1.15); } .sglv-detail-btn svg { width: 14px; height: 14px; } .sglv-detail-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; gap: 12px; color: #6e7681; font-size: 12px; } .sglv-detail-error { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; gap: 8px; color: #f87171; font-size: 12px; } .sglv-detail-metacritic { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 2px 8px; border-radius: 4px; font-weight: 700; } /* === body 首屏骨架占位(避免空数据时出现完全空白) === */ .sglv-body-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; min-height: 320px; padding: 40px 20px; gap: 16px; color: #94a3b8; font-size: 13px; text-align: center; } .sglv-body-loading .sglv-bl-title { font-size: 15px; font-weight: 600; color: #cbd5e1; } .sglv-body-loading .sglv-bl-tip { font-size: 12px; color: #64748b; line-height: 1.6; max-width: 420px; } .sglv-body-loading .sglv-bl-spinner { width: 36px; height: 36px; border: 3px solid rgba(102, 192, 244, 0.15); border-top-color: #66c0f4; border-radius: 50%; animation: sglv-spin 0.9s linear infinite; } .sglv-body-loading .sglv-bl-actions { display: flex; gap: 8px; margin-top: 4px; } .sglv-body-loading .sglv-bl-btn { padding: 6px 14px; font-size: 12px; font-weight: 600; border-radius: 6px; border: 1px solid rgba(102, 192, 244, 0.3); background: rgba(102, 192, 244, 0.1); color: #66c0f4; cursor: pointer; transition: background 0.15s, border-color 0.15s; } .sglv-body-loading .sglv-bl-btn:hover { background: rgba(102, 192, 244, 0.2); border-color: rgba(102, 192, 244, 0.5); } .sglv-body-loading .sglv-bl-btn.primary { background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(102, 192, 244, 0.2)); border-color: rgba(139, 92, 246, 0.4); color: #c4b5fd; } .sglv-body-loading .sglv-bl-btn.primary:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.35), rgba(102, 192, 244, 0.3)); } /* === 统计仪表布局——热力图行改自适应内容高度(不再撑满 wrap),6月增量SVG加高减少留白 === */ /*sglv-body 需 min-height:0 确保 flex 子项正确滚动(移动端关键修复) */ .sglv-body { min-height: 0 !important; } /* wrap: flex:1 填满 sglv-body 可用高度,内部 flex column 纵向排列各区块 */ .sglv-playtime-wrap { display: flex !important; flex-direction: column !important; flex: 1 1 0 !important; min-height: 0 !important; gap: 10px !important; overflow-y: auto !important; padding: 12px !important; } /*图表行(甜甜圈+趋势) 使用内容高度,不再 flex-grow 撑满 wrap 剩余空间。 低分屏下 flex-grow:1 会吞掉热力图行的可用高度,导致下方热力图/增量图被挤压变形。 改为 flex:0 0 auto 后,各行按内容自然排列,wrap 的 overflow-y:auto 负责纵向滚动。 */ .sglv-playtime-row { flex: 0 0 auto !important; min-height: 0 !important; } /*图表列最小高度,确保甜甜圈/趋势图有足够渲染空间 */ .sglv-playtime-col { min-height: 220px !important; } /*雷达行紧凑:雷达卡内容居中、右侧统计面板均匀分布填满高度,消除底部空白失衡 */ .sglv-radar-card { justify-content: center; } .sglv-radar-stats { justify-content: space-between; } /*图表行(甜甜圈+趋势)适度紧凑:限制列高与甜甜圈尺寸,下方热力图/增量区更快进入视野。 注意只限 svg 高度——覆写宽度会让 CDN 库的 viewBox SVG 宽度塌缩消失 */ .sglv-playtime-row { max-height: 320px !important; } .sglv-playtime-col { max-height: 320px !important; overflow: hidden !important; } .sglv-playtime-col > #sglv-pt-donut { flex: 1 1 auto; min-height: 0; overflow: hidden; display: flex; flex-direction: column; justify-content: center; } .sglv-playtime-col > #sglv-pt-donut svg { max-height: 190px !important; } .sglv-playtime-col > #sglv-pt-donut .sglv-donut-chart { flex-shrink: 1; min-height: 0; } /*热力图行 grid 7:3——改成自适应内容高度 + 最小高度,不再撑满 wrap 剩余高度,避免盖住上方 KPI/图表区 */ /*overflow 从 hidden 改为 visible,允许内容自然溢出由 wrap 的 overflow-y:auto 滚动,而非裁剪变形 */ .sglv-heatmap-acquire-row { display: grid !important; grid-template-columns: 7fr 3fr !important; align-items: stretch !important; min-height: 320px !important; flex: 0 0 auto !important; gap: 10px !important; overflow: visible !important; } .sglv-heatmap-acquire-row .sglv-heatmap-section, .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { flex: none !important; min-width: 0 !important; max-width: none !important; min-height: 280px !important; display: flex !important; flex-direction: column !important; overflow: visible !important; } /*热力图 block 撑满 section 高度 */ .sglv-heatmap-acquire-row .sglv-heatmap-block, .sglv-heatmap-acquire-row .sglv-heatmap-mini-skeleton, .sglv-heatmap-acquire-row .sglv-hm-mini-inner { flex: 1 1 0 !important; min-height: 0 !important; } /*增量柱图——SVG 用 auto 高度而非 100%,避免容器被压缩时 SVG 跟着变形 */ .sglv-heatmap-acquire-row .sglv-hm-mini-chart { flex: 1 1 auto !important; min-height: 120px !important; } .sglv-heatmap-acquire-row .sglv-hm-mini-chart svg { height: auto !important; max-height: 100% !important; } /*热力图 SVG 保持原始尺寸,由 scroll 容器水平滚动展示完整内容 */ .sglv-heatmap-block svg { max-width: 100% !important; height: auto !important; } .sglv-heatmap-scroll svg { max-width: none !important; height: auto !important; display: block !important; } .sglv-heatmap-acquire-row .sglv-heatmap-scroll { overflow-x: auto !important; } /*热力图 section 紧凑 padding + scroll 容器居中(SVG 内容垂直居中) */ .sglv-heatmap-acquire-row .sglv-heatmap-section { padding: 10px 12px 8px !important; } .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { padding: 10px 12px 8px !important; } .sglv-heatmap-section > #sglv-pt-heatmap { flex: 1 1 0 !important; min-height: 0 !important; display: flex !important; flex-direction: column !important; justify-content: flex-start !important; } /*热力图 scroll 紧凑 padding-bottom */ .sglv-heatmap-scroll { padding-bottom: 2px !important; } /*heatmap-body 擑满 block 剩余高度,scroll 区域可滚动 + 居中 SVG */ .sglv-heatmap-acquire-row .sglv-heatmap-body { flex: 1 1 0 !important; min-height: 0 !important; display: flex !important; flex-direction: column !important; } .sglv-heatmap-acquire-row .sglv-heatmap-body .sglv-heatmap-scroll { flex: 1 1 0 !important; min-height: 0 !important; display: flex !important; flex-direction: column !important; justify-content: center !important; } .sglv-heatmap-acquire-row .sglv-heatmap-body .sglv-heatmap-scroll > svg { flex: 0 0 auto !important; align-self: flex-start !important; } /* 趋势图折线等比缩放(meet)后居中 */ .sglv-pt-trend-chart { align-items: center !important; } /*增量柱图底部统计栏样式 */ .sglv-hm-mini-summary { display: flex; flex-wrap: wrap; gap: 8px; padding: 6px 4px 0; flex-shrink: 0; } .sglv-hm-mini-stat { font-size: 10px; color: var(--sglv-text-secondary); white-space: nowrap; } .sglv-hm-mini-stat b { font-size: 13px; font-weight: 800; color: #e0e0e0; } /* 移动端热力图行恢复上下排列 */ @media (max-width: 900px) { .sglv-heatmap-acquire-row { grid-template-columns: 1fr !important; } } /* === 移动端统计仪表适配——KPI 卡片防换行 + 热力图高度收缩 === */ /* KPI 卡片内部文字截断,防止窄屏换行变形 */ .sglv-playtime-kpi-value { white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } .sglv-playtime-kpi-sub { white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } .sglv-playtime-kpi-label { white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } @media (max-width: 768px) { /* KPI 卡片:2 列布局时进一步紧凑化 */ .sglv-playtime-kpi-row { gap: 5px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-card { padding: 5px 8px 4px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-value { font-size: 15px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-label { font-size: 9px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-sub { font-size: 8px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-icon { width: 14px !important; height: 14px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-icon svg { width: 9px !important; height: 9px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-unit { font-size: 10px !important; } /*热力图行降低最小高度,但仍保持足够渲染空间,由 wrap 滚动而非压缩 */ .sglv-heatmap-acquire-row { min-height: 260px !important; } .sglv-heatmap-acquire-row .sglv-heatmap-section, .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { padding: 8px 10px 6px !important; min-height: 220px !important; } /* 图表行(甜甜圈+趋势) 单列堆叠时限制高度,避免无限增高 */ .sglv-playtime-row { flex: 0 0 auto !important; } .sglv-playtime-col { min-height: 200px !important; max-height: 280px !important; } .sglv-playtime-col > #sglv-pt-donut { min-height: 160px !important; } .sglv-pt-trend-chart { min-height: 160px !important; } } @media (max-width: 480px) { /* 超窄屏:KPI 保持 2 列但进一步缩小 */ .sglv-playtime-kpi-row { gap: 4px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-card { padding: 4px 6px 3px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-value { font-size: 14px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-label { font-size: 8px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-sub { font-size: 7px !important; } /*热力图行降低高度但保持最小渲染空间 */ .sglv-heatmap-acquire-row { min-height: 220px !important; } .sglv-heatmap-acquire-row .sglv-heatmap-section, .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { min-height: 180px !important; } /* 图表列限制高度 */ .sglv-playtime-col { min-height: 180px !important; max-height: 240px !important; } /* wrap 内边距收紧 */ .sglv-playtime-wrap { padding: 8px !important; gap: 8px !important; } } /* === 分页导航——简化单行布局,首页|上页|下页|末页 + 跳页输入 === */ .sglv-tl-pagination, .sglv-pagination, .sglv-pt-pagination { display: flex !important; align-items: center !important; justify-content: center !important; flex-wrap: nowrap !important; gap: 4px !important; padding: 8px 0 2px !important; overflow: hidden !important; } /* 箭头/文本按钮 */ .sglv-page-arrow { min-width: 24px; height: 24px; padding: 0 2px; border: 1px solid rgba(255,255,255,0.1); border-radius: 5px; background: rgba(255,255,255,0.04); color: #c7d5e0; font-size: 13px; line-height: 1; cursor: pointer; transition: var(--sglv-transition); text-align: center; flex-shrink: 0; } .sglv-page-arrow:hover:not(:disabled) { background: rgba(102,192,244,0.15); border-color: rgba(102,192,244,0.4); color: #fff; } .sglv-page-arrow:disabled { opacity: 0.3; cursor: not-allowed; } /* 中文文本按钮——加宽适配文字 */ .sglv-page-text-btn { min-width: auto !important; padding: 0 8px !important; font-size: 11px !important; white-space: nowrap !important; } /* 页码按钮组(已废弃,隐藏) */ .sglv-page-btns { display: none !important; } .sglv-page-btn { display: none !important; } .sglv-page-ellipsis { display: none !important; } /* 跳页输入框——紧凑,回车跳转 */ .sglv-page-jump-input { width: 38px; height: 24px; padding: 0 2px; text-align: center; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; color: #fff; font-size: 11px; outline: none; flex-shrink: 0; -moz-appearance: textfield; } .sglv-page-jump-input::-webkit-inner-spin-button, .sglv-page-jump-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .sglv-page-jump-input:focus { border-color: rgba(102,192,244,0.5); } /* 页码信息——极简 */ .sglv-page-info { font-size: 10px !important; color: var(--sglv-text-secondary) !important; white-space: nowrap !important; flex-shrink: 0 !important; } /* 工具栏内嵌分页——更紧凑,隐藏箭头和跳页 */ .sglv-pagination-inline .sglv-page-arrow { min-width: 20px; height: 20px; font-size: 11px; } .sglv-pagination-inline .sglv-page-btn { min-width: 20px; height: 20px; font-size: 10px; } .sglv-pagination-inline .sglv-page-jump-input { display: none; } .sglv-pagination-inline .sglv-page-info { display: none; } /* === 统计仪表 KPI 卡片 6 列 1 行紧凑布局 + 扁平化(参考设计图) === */ .sglv-playtime-kpi-row { grid-template-columns: repeat(6, 1fr) !important; gap: 6px !important; } @media (max-width: 1280px) { .sglv-playtime-kpi-row { grid-template-columns: repeat(3, 1fr) !important; } } @media (max-width: 700px) { .sglv-playtime-kpi-row { grid-template-columns: repeat(2, 1fr) !important; } } /* 6 列 1 行时各元素字号紧凑 */ .sglv-playtime-kpi-row .sglv-playtime-kpi-value { font-size: 17px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-label { font-size: 10px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-sub { font-size: 9px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-icon { width: 16px !important; height: 16px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-icon svg { width: 10px !important; height: 10px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-unit { font-size: 11px !important; } /*KPI 卡片扁平化——弱化边框,靠左侧色条 + 背景色区分,参考设计图 */ .sglv-playtime-kpi-card { border-width: 1px !important; border-left-width: 3px !important; } /* === 游玩率/完成度 KPI 卡片颜色变体(cyan/rose)+ % 单位字号 === */ .sglv-playtime-kpi-card.cyan { border-color: rgba(34, 211, 238, 0.35); } .sglv-playtime-kpi-card.cyan::before { background: #22d3ee; } .sglv-playtime-kpi-card.cyan .sglv-playtime-kpi-value { color: #22d3ee; } .sglv-playtime-kpi-card.cyan .sglv-playtime-kpi-icon { background: rgba(34, 211, 238, 0.15); color: #22d3ee; } .sglv-playtime-kpi-card.rose { border-color: rgba(244, 63, 94, 0.35); } .sglv-playtime-kpi-card.rose::before { background: #f43f5e; } .sglv-playtime-kpi-card.rose .sglv-playtime-kpi-value { color: #fb7185; } .sglv-playtime-kpi-card.rose .sglv-playtime-kpi-icon { background: rgba(244, 63, 94, 0.15); color: #fb7185; } .sglv-playtime-kpi-card.gold { border-color: rgba(251, 191, 36, 0.3); } .sglv-playtime-kpi-card.gold::before { background: #fbbf24; } .sglv-playtime-kpi-card.gold .sglv-playtime-kpi-value { color: #fbbf24; } .sglv-playtime-kpi-card.gold .sglv-playtime-kpi-icon { background: rgba(251, 191, 36, 0.12); color: #fbbf24; } .sglv-playtime-kpi-unit { font-size: 13px; font-weight: 700; opacity: 0.85; margin-left: 2px; } /* === 入库趋势页 KPI 紧凑布局(4 卡放左半侧顶部)+ 时间线撑到顶部 === */ .sglv-trend-kpi-row-compact { gap: 6px !important; margin-bottom: 8px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-card { padding: 7px 10px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-value { font-size: 17px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-label { font-size: 10px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-sub { font-size: 9px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-icon { width: 16px !important; height: 16px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-icon svg { width: 10px !important; height: 10px !important; } /* 时间线顶到页面顶部:rightCol 整体高度撑满 */ .sglv-trend-split-right { align-self: stretch; } /* === KPI 两行布局 + 新色彩变体 === */ .sglv-trend-kpi-wrap { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; flex-shrink: 0; } .sglv-trend-kpi-wrap .sglv-trend-kpi-row { margin-bottom: 0 !important; } .sglv-trend-kpi-card.green { border-color: rgba(52,211,153,0.3); } .sglv-trend-kpi-card.green::before { background: #34d399; } .sglv-trend-kpi-card.green .sglv-trend-kpi-value { color: #34d399; } .sglv-trend-kpi-card.green .sglv-trend-kpi-icon { background: rgba(52,211,153,0.15); color: #34d399; } .sglv-trend-kpi-card.orange { border-color: rgba(245,158,11,0.3); } .sglv-trend-kpi-card.orange::before { background: #f59e0b; } .sglv-trend-kpi-card.orange .sglv-trend-kpi-value { color: #f59e0b; } .sglv-trend-kpi-card.orange .sglv-trend-kpi-icon { background: rgba(245,158,11,0.15); color: #f59e0b; } .sglv-trend-kpi-card.cyan { border-color: rgba(6,182,212,0.3); } .sglv-trend-kpi-card.cyan::before { background: #06b6d4; } .sglv-trend-kpi-card.cyan .sglv-trend-kpi-value { color: #22d3ee; } .sglv-trend-kpi-card.cyan .sglv-trend-kpi-icon { background: rgba(6,182,212,0.15); color: #22d3ee; } .sglv-trend-kpi-card.purple { border-color: rgba(167,139,250,0.3); } .sglv-trend-kpi-card.purple::before { background: #a78bfa; } .sglv-trend-kpi-card.purple .sglv-trend-kpi-value { color: #a78bfa; } .sglv-trend-kpi-card.purple .sglv-trend-kpi-icon { background: rgba(167,139,250,0.15); color: #a78bfa; } .sglv-trend-kpi-unit { font-size: 10px; font-weight: 600; opacity: 0.7; margin-left: 2px; } /* === 右侧标签切换 === */ .sglv-trend-right-tabs { display: flex; gap: 2px; margin-bottom: 10px; flex-shrink: 0; border-bottom: 1px solid rgba(148,163,184,0.12); } .sglv-trend-right-tab { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; font-size: 12px; font-weight: 600; color: #8a9ba8; background: transparent; border: none; border-bottom: 2px solid transparent; cursor: pointer; transition: color 0.15s, border-color 0.15s; white-space: nowrap; } .sglv-trend-right-tab svg { width: 14px; height: 14px; } .sglv-trend-right-tab:hover { color: #cbd5e1; } .sglv-trend-right-tab.active { color: var(--sglv-accent, #66c0f4); border-bottom-color: var(--sglv-accent, #66c0f4); } .sglv-trend-right-content { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } /* === 频率分析图表 === */ .sglv-freq-scroll { flex: 1; overflow-y: auto; min-height: 0; padding-right: 2px; } .sglv-freq-scroll::-webkit-scrollbar { width: 5px; } .sglv-freq-scroll::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.2); border-radius: 3px; } .sglv-freq-summary-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 14px; } .sglv-freq-summary-card { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 6px; background: rgba(255,255,255,0.03); border: 1px solid rgba(148,163,184,0.1); border-radius: 8px; text-align: center; } .sglv-freq-summary-label { font-size: 9px; color: #64748b; font-weight: 600; } .sglv-freq-summary-value { font-size: 16px; font-weight: 700; color: #e2e8f0; } .sglv-freq-summary-sub { font-size: 9px; color: #475569; } .sglv-freq-chart-section { margin-bottom: 14px; } .sglv-freq-chart-title { font-size: 11px; font-weight: 700; color: #94a3b8; margin-bottom: 6px; } .sglv-freq-chart svg rect { transition: opacity 0.15s; } .sglv-freq-chart svg rect[data-v] { cursor: pointer; } .sglv-freq-chart-labels { display: flex; font-size: 9px; color: #475569; margin-top: 3px; } .sglv-freq-chart-labels span { flex: 1; text-align: center; } .sglv-freq-chart-labels-hr span { font-size: 8px; } /* === 游玩数据页——家庭成员对比紧凑到右半侧底部,Top 20 撑到更高视野 === */ .sglv-playtime-split-right { gap: 0; } /* 家庭对比放右半侧后紧凑显示:限制高度 + 内部滚动 */ .sglv-playtime-split-right > .sglv-family-compare-section { flex-shrink: 0; margin: 10px 0 0; padding: 10px 12px; max-height: 200px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; } .sglv-playtime-split-right > .sglv-family-compare-section .sglv-playtime-section-title { margin-bottom: 6px; font-size: 12px; } .sglv-playtime-split-right > .sglv-family-compare-section .sglv-family-compare-container { margin-top: 0; max-height: 140px; overflow-y: auto; } /*游戏橱窗 KPI 扁平化——与统计仪表/入库趋势页一致,左侧色条+紧凑 padding */ .sglv-stats-dashboard .sglv-stat-dash-card { padding: 6px 10px 5px !important; border-left-width: 3px !important; } @keyframes sglv-skel-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 0.9; } } @keyframes sglv-skel-shimmer-x { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } .sglv-heatmap-skeleton svg rect { animation: sglv-skel-pulse 1.6s ease-in-out infinite; } .sglv-heatmap-skeleton svg rect:nth-child(odd) { animation-delay: 0.15s; } .sglv-heatmap-skeleton svg rect:nth-child(3n) { animation-delay: 0.3s; } .sglv-heatmap-mini-skeleton rect { animation: sglv-skel-pulse 1.4s ease-in-out infinite; } .sglv-heatmap-mini-skeleton rect:nth-child(odd) { animation-delay: 0.2s; } .sglv-family-bar-skel .sglv-family-bar-track { background: rgba(255, 255, 255, 0.04) !important; } .sglv-family-bar-skel .sglv-family-bar-name, .sglv-family-bar-skel .sglv-family-bar-hours { background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.04) 100%) !important; background-size: 200% 100%; animation: sglv-skel-shimmer-x 1.6s linear infinite; } .sglv-family-bar-skel:nth-child(2) .sglv-family-bar-name, .sglv-family-bar-skel:nth-child(2) .sglv-family-bar-hours { animation-delay: 0.15s; } .sglv-family-bar-skel:nth-child(3) .sglv-family-bar-name, .sglv-family-bar-skel:nth-child(3) .sglv-family-bar-hours { animation-delay: 0.3s; } .sglv-family-bar-skel:nth-child(4) .sglv-family-bar-name, .sglv-family-bar-skel:nth-child(4) .sglv-family-bar-hours { animation-delay: 0.45s; }`; const CSS_SGIS_ENHANCE =` /* === tab 容器:增加底部高光 + tab 之间分隔感 === */ .sgis-tabs { position: relative; } .sgis-tabs::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: -1px; height: 1px; background: linear-gradient(90deg, transparent, rgba(139, 92, 246, 0.35), transparent); pointer-events: none; } /* === tab 本身:图标上浮 + 文字微微缩放 === */ .sgis-tab .sgis-svg { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } .sgis-tab:hover .sgis-svg { transform: translateY(-1px) scale(1.08); } .sgis-tab.active .sgis-svg { transform: translateY(-0.5px) scale(1.12); filter: drop-shadow(0 1px 4px rgba(139, 92, 246, 0.45)); } .sgis-tab span { transition: font-weight 0.2s, letter-spacing 0.2s; } .sgis-tab.active span { font-weight: 700; letter-spacing: 0.3px; } /* 连点标签时只是在等最后一次点击落定,鼠标给出反馈,点击仍然有效 */ .sgis-tabs.is-arming .sgis-tab { cursor: progress; } /* === 标题图标:更精致渐变 + 内阴影 === */ .sgis-title-icon { background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 45%, #3b82f6 100%) !important; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 -1px 0 rgba(0, 0, 0, 0.2) !important; } .sgis-title-icon::after { background: linear-gradient(180deg, rgba(255, 255, 255, 0.18) 0%, transparent 45%) !important; } /* === 右上角 action 按钮:加 active 反馈 === */ .sgis-icon-btn:active { transform: translateY(0) scale(0.94) !important; } .sgis-icon-btn.sgis-spin svg { animation: sgis-spin 0.8s linear infinite, sgis-pulse 1.4s ease-in-out infinite; } @keyframes sgis-pulse { 0%, 100% { box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25); } 50% { box-shadow: 0 2px 14px rgba(139, 92, 246, 0.6); } } /* === SGIS 整体滚动条更精致 === */ .sgis-body::-webkit-scrollbar-thumb { background: linear-gradient(180deg, rgba(139, 92, 246, 0.45), rgba(59, 130, 246, 0.45)) !important; } .sgis-pane { min-width: 0; } .sgis-pane.is-active { animation: sgis-pane-in .18s ease; } @keyframes sgis-pane-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } .sgis-tab-pending { min-height: 160px; } .sgis-social-friend { content-visibility: auto; contain-intrinsic-size: auto 64px; } /* === SGIS 状态指示器(用于加载/错误/空状态) === */ .sgis-state-icon { width: 48px; height: 48px; border-radius: 14px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.08)); border: 1px solid rgba(139, 92, 246, 0.2); display: inline-flex; align-items: center; justify-content: center; margin-bottom: 10px; color: #a78bfa; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); } .sgis-state-icon svg { width: 22px; height: 22px; } /* === 错误状态用红 === */ .sgis-state.error .sgis-state-icon { background: linear-gradient(135deg, rgba(244, 63, 94, 0.15), rgba(244, 63, 94, 0.05)); border-color: rgba(244, 63, 94, 0.3); color: #f87171; } /* === 徽章标签页英雄区(顶部总览) === */ .sgis-badge-hero { position: relative; background: linear-gradient(135deg, rgba(139, 92, 246, 0.18) 0%, rgba(59, 130, 246, 0.1) 50%, var(--sgis-bg-2) 100%); border: 1px solid rgba(139, 92, 246, 0.3); border-radius: 12px; padding: 14px 14px 12px; margin-bottom: 10px; overflow: hidden; box-shadow: 0 4px 16px rgba(139, 92, 246, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.05); } .sgis-badge-hero::before { content: ""; position: absolute; top: -40px; right: -30px; width: 140px; height: 140px; background: radial-gradient(circle, rgba(139, 92, 246, 0.25) 0%, transparent 65%); pointer-events: none; } .sgis-badge-hero::after { content: ""; position: absolute; bottom: -30px; left: -20px; width: 100px; height: 100px; background: radial-gradient(circle, rgba(6, 182, 212, 0.15) 0%, transparent 60%); pointer-events: none; } .sgis-badge-hero > * { position: relative; z-index: 1; } .sgis-badge-hero-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 10px; } .sgis-badge-hero-stat { text-align: center; padding: 6px 4px; background: rgba(255, 255, 255, 0.04); border-radius: 7px; border: 1px solid rgba(255, 255, 255, 0.05); } .sgis-badge-hero-stat.highlight { background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(20, 184, 166, 0.08)); border-color: rgba(16, 185, 129, 0.3); } .sgis-badge-hero-stat-val { font-size: 18px; font-weight: 800; color: #fff; line-height: 1.1; font-variant-numeric: tabular-nums; letter-spacing: -0.5px; } .sgis-badge-hero-stat.highlight .sgis-badge-hero-stat-val { color: #34d399; } .sgis-badge-hero-stat-lbl { font-size: 9px; color: var(--sgis-text-2); margin-top: 2px; font-weight: 600; } .sgis-badge-hero-stat.highlight .sgis-badge-hero-stat-lbl { color: #6ee7b7; } .sgis-badge-hero-progress { margin-bottom: 12px; } .sgis-badge-hero-progress-label { display: flex; justify-content: space-between; font-size: 10px; color: var(--sgis-text-2); margin-bottom: 4px; font-weight: 600; } .sgis-badge-hero-progress-label span:last-child { color: #c4b5fd; } .sgis-badge-hero-progress-bar { height: 6px; background: rgba(255, 255, 255, 0.06); border-radius: 3px; overflow: hidden; box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); } .sgis-badge-hero-progress-fill { height: 100%; background: linear-gradient(90deg, #10b981 0%, #34d399 100%); border-radius: 3px; transition: width 0.5s; box-shadow: 0 0 6px rgba(16, 185, 129, 0.4); } .sgis-badge-hero-levels { padding-top: 10px; border-top: 1px dashed rgba(255, 255, 255, 0.08); } .sgis-badge-hero-levels-label { font-size: 10px; color: var(--sgis-text-2); margin-bottom: 6px; font-weight: 600; } .sgis-badge-hero-levels-bars { display: flex; align-items: flex-end; justify-content: space-around; gap: 4px; height: 50px; } .sgis-badge-hero-level-cell { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; } .sgis-badge-hero-level-bar { width: 100%; min-height: 3px; max-width: 24px; background: linear-gradient(180deg, #8b5cf6 0%, #6366f1 100%); border-radius: 3px 3px 0 0; flex: 1; align-self: flex-end; display: flex; align-items: flex-start; justify-content: center; padding-top: 2px; box-shadow: 0 0 6px rgba(139, 92, 246, 0.3); transition: height 0.4s; } .sgis-badge-hero-level-count { font-size: 9px; color: #fff; font-weight: 700; font-variant-numeric: tabular-nums; } .sgis-badge-hero-level-num { font-size: 9px; color: var(--sgis-text-2); font-weight: 600; } /* === 章节标题 meta(右侧次要信息) === */ .sgis-section-title-meta { font-size: 10px; color: var(--sgis-text-2); font-weight: 500; text-transform: none; letter-spacing: 0; margin-left: auto; } /* === 洞察页成就引导入口(替代旧折叠列表) === */ .sgis-insight-ach-entry { display: flex; align-items: center; gap: 12px; padding: 12px 14px; background: linear-gradient(135deg, rgba(251, 191, 36, 0.08), rgba(245, 158, 11, 0.04)); border: 1px solid rgba(251, 191, 36, 0.18); border-radius: 10px; transition: all 0.25s ease; margin-top: 2px; } .sgis-insight-ach-entry:hover { background: linear-gradient(135deg, rgba(251, 191, 36, 0.14), rgba(245, 158, 11, 0.08)); border-color: rgba(251, 191, 36, 0.35); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(251, 191, 36, 0.12); } .sgis-insight-ach-entry > svg { width: 36px; height: 36px; flex-shrink: 0; filter: drop-shadow(0 2px 4px rgba(251, 191, 36, 0.25)); } .sgis-insight-ach-entry-info { flex: 1; min-width: 0; } .sgis-insight-ach-entry-title { font-size: 12px; font-weight: 700; color: #fef3c7; margin-bottom: 2px; line-height: 1.3; } .sgis-insight-ach-entry-sub { font-size: 10px; color: var(--sgis-text-2); line-height: 1.4; } .sgis-insight-ach-entry-btn { flex-shrink: 0; padding: 5px 10px; background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #1c0a01; border: none; border-radius: 6px; font-size: 10px; font-weight: 700; cursor: pointer; transition: all 0.2s ease; white-space: nowrap; } .sgis-insight-ach-entry-btn:hover { background: linear-gradient(135deg, #fcd34d, #fbbf24); transform: translateY(-1px); box-shadow: 0 2px 6px rgba(251, 191, 36, 0.4); } .sgis-insight-ach-entry-btn:active { transform: translateY(0); } /* === 徽章 group 头增强(加进度条 + 已合成数) === */ .sgis-badge-group-head { gap: 10px; padding: 9px 12px; } .sgis-badge-group-head:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.05), rgba(59, 130, 246, 0.02)); } .sgis-badge-group-progress { width: 100%; margin-top: 6px; height: 3px; background: rgba(255, 255, 255, 0.05); border-radius: 2px; overflow: hidden; } .sgis-badge-group-progress-fill { height: 100%; background: linear-gradient(90deg, #10b981, #34d399); border-radius: 2px; } .sgis-badge-group-stats { flex-direction: column; align-items: flex-end; gap: 3px; } .sgis-badge-group-stat-val { font-size: 11px; font-weight: 700; color: #c4b5fd; } .sgis-badge-group-stat-lbl { font-size: 9px; color: var(--sgis-text-3); margin-left: 4px; } /* === 徽章 footer 折叠说明 === */ .sgis-badge-footer-details { margin-top: 14px; padding: 8px 10px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 7px; font-size: 10px; color: var(--sgis-text-2); } .sgis-badge-footer-details summary { cursor: pointer; font-weight: 600; user-select: none; list-style: none; display: flex; align-items: center; gap: 5px; } .sgis-badge-footer-details summary::-webkit-details-marker { display: none; } .sgis-badge-footer-details summary::before { content: "▸"; display: inline-block; transition: transform 0.2s; font-size: 9px; color: var(--sgis-text-3); } .sgis-badge-footer-details[open] summary::before { transform: rotate(90deg); } .sgis-badge-footer-content { padding-top: 8px; line-height: 1.6; } .sgis-badge-footer-formula { margin-bottom: 4px; } .sgis-badge-footer-formula b { color: #c4b5fd; } .sgis-badge-footer-breakdown { font-size: 9px; color: var(--sgis-text-3); } .sgis-badge-footer-meta { font-size: 9px; color: var(--sgis-text-3); margin-top: 2px; } /* === 价值卡片单列布局(共享库默认 flex 单列,在窄侧边栏里 2 列挤压变形) === v2.9.18 尝试 2 列 grid + minmax(0,1fr) + 三层 min-width:0 链式压制,但实际渲染出单卡仍被竖向挤压 (游戏名截断为 S..、Lv./50/XP 竖排)—— 在 300px 宽的 SGIS 侧边栏里 2 列宽度仍不够放下 56px img + 38px score + 名字 + market。 v2.9.21 退回单列布局,删掉所有过紧的子项压缩样式,卡片按 flex 自然撑开,信息完整可读。 历史: 共享库 sglv-shared-css.lib.js:1467 的 .sgis-value-cards { display:flex; flex-direction:column; gap:6px; } 直接可用。 这里只追加 !important 防止共享库默认被覆盖即可,不再做 2 列。 */ .sgis-value-cards { display: flex !important; flex-direction: column !important; gap: 6px !important; width: 100% !important; max-width: 100% !important; box-sizing: border-box; } /* 单元本身保持原样,不压制 min-width,让 flex 子项(img 56 + score 38 + market 文本)自然撑开 */ /* === 愿望单首次加载骨架屏(避免页面紧缩) === */ @keyframes sglv-skel-shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } .sglv-skel-bar { background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0%, rgba(139, 92, 246, 0.12) 50%, rgba(255, 255, 255, 0.04) 100%); background-size: 200% 100%; animation: sglv-skel-shimmer 1.6s linear infinite; border-radius: 4px; } .sglv-wl-kpi { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; margin-bottom: 10px; } .sglv-wl-kpi-cell { position: relative; background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.04)), var(--sglv-surface-1, rgba(255, 255, 255, 0.025)); border: 1px solid rgba(139, 92, 246, 0.18); border-radius: 8px; padding: 10px 8px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 6px; box-shadow: var(--sglv-shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.35)), inset 0 1px 0 rgba(139, 92, 246, 0.12); transition: transform 0.18s var(--sglv-ease, ease), box-shadow 0.18s var(--sglv-ease, ease), border-color 0.18s var(--sglv-ease, ease); overflow: hidden; } .sglv-wl-kpi-cell::before { content: ''; position: absolute; inset: -1px; border-radius: 8px; pointer-events: none; background: linear-gradient(135deg, rgba(139, 92, 246, 0.35), transparent 60%); opacity: 0; transition: opacity 0.18s var(--sglv-ease, ease); } .sglv-wl-kpi-cell:hover { transform: translateY(-2px); border-color: rgba(139, 92, 246, 0.4); box-shadow: var(--sglv-shadow-md, 0 6px 20px rgba(0, 0, 0, 0.45)), 0 0 0 1px rgba(139, 92, 246, 0.25), 0 0 22px rgba(139, 92, 246, 0.2), inset 0 1px 0 rgba(139, 92, 246, 0.18); } .sglv-wl-kpi-cell:hover::before { opacity: 1; } .sglv-wl-kpi-cell > * { position: relative; z-index: 1; } .sglv-skel-num { width: 50%; height: 18px; } .sglv-skel-lbl { width: 70%; height: 9px; } .sglv-skel-section-title { width: 40%; height: 12px; margin-bottom: 8px; } .sglv-skel-row { width: 100%; height: 10px; margin-bottom: 6px; opacity: 0.85; } .sglv-skel-row-short { width: 60%; opacity: 0.6; } .sglv-skel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; padding: 4px 0; } .sglv-skel-game-card { background: rgba(255, 255, 255, 0.025); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 8px; } .sglv-skel-cover { width: 100%; aspect-ratio: 184 / 69; background: linear-gradient(90deg, rgba(139, 92, 246, 0.08) 0%, rgba(139, 92, 246, 0.18) 50%, rgba(139, 92, 246, 0.08) 100%); background-size: 200% 100%; animation: sglv-skel-shimmer 1.6s linear infinite; border-radius: 4px; } .sglv-skel-card-name { width: 80%; height: 11px; } .sglv-skel-card-meta { width: 50%; height: 9px; } .sglv-wl-skel-tip { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 18px 12px; margin-top: 12px; color: var(--sglv-text-secondary, #94a3b8); font-size: 12px; font-weight: 500; } .sglv-wl-skel-tip .sglv-spinner { width: 16px; height: 16px; border-width: 2px; } /* === 愿望单加载等待动画(参考 DLC spinner 风格) === */ .sglv-wl-loading { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:48px 20px; gap:14px; color:#94a3b8; min-height:280px; } .sglv-wl-loading .sglv-wl-spin { width:32px; height:32px; animation:sglv-spin 1s linear infinite; color:#a78bfa; } .sglv-wl-loading-text { font-size:13px; font-weight:600; color:#cbd5e1; } .sglv-wl-loading-sub { font-size:11px; color:#64748b; } .sglv-wl-loading-bar { width:220px; height:3px; background:rgba(102,192,244,0.12); border-radius:2px; overflow:hidden; } .sglv-wl-loading-bar-fill { height:100%; background:linear-gradient(90deg,#66c0f4,#a78bfa); border-radius:2px; transition:width 0.4s ease; }`; const CSS_TOAST =` @keyframes sglv-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .sglv-toast { position: fixed; bottom: 50px; left: 50%; transform: translateX(-50%) translateY(20px); background: rgba(15,23,42,0.95); color: #fff; border: 1px solid rgba(167,139,250,0.5); padding: 10px 24px; border-radius: 20px; font-size: 13px; font-weight: 500; box-shadow: 0 4px 16px rgba(0,0,0,0.3); z-index: 999999; opacity: 0; pointer-events: none; transition: opacity 0.3s, transform 0.3s; } .sglv-toast.sglv-toast-show { opacity: 1; transform: translateX(-50%) translateY(0); } .sglv-toast.sglv-toast-success { border-color: #2ed573; } .sglv-toast.sglv-toast-error { border-color: #ff6b6b; } .sglv-toast.sglv-toast-warning { border-color: #f59e0b; } .sglv-toast.sglv-toast-info { border-color: #54a0ff; }`; // ==================== v2.45.0: API Key 同意门弹窗 ==================== const CSS_APIKEY_CONSENT =` @keyframes sglv-consent-in { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } .sglv-apikey-consent { position: fixed; inset: 0; z-index: 10000050; background: rgba(2,6,23,0.72); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; } .sglv-apikey-consent-card { width: min(560px, 100%); max-height: 86vh; display: flex; flex-direction: column; background: linear-gradient(160deg, rgba(30,41,59,0.99), rgba(15,23,42,0.99)); border: 1px solid rgba(167,139,250,0.4); border-radius: 14px; box-shadow: 0 24px 64px rgba(0,0,0,0.6); color: #e2e8f0; font-size: 13px; line-height: 1.7; animation: sglv-consent-in 0.22s ease-out; } .sglv-apikey-consent-head { display: flex; align-items: center; gap: 10px; padding: 16px 18px 12px; border-bottom: 1px solid rgba(148,163,184,0.16); } .sglv-apikey-consent-title { flex: 1; font-size: 15px; font-weight: 700; color: #fff; } .sglv-apikey-consent-close { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 15px; line-height: 1; padding: 4px 8px; border-radius: 6px; } .sglv-apikey-consent-close:hover { color: #fff; background: rgba(148,163,184,0.15); } .sglv-apikey-consent-body { padding: 16px 18px; overflow-y: auto; } .sglv-apikey-consent-sec + .sglv-apikey-consent-sec { margin-top: 16px; } .sglv-apikey-consent-sec-title { font-size: 12px; font-weight: 700; color: #a78bfa; margin-bottom: 8px; letter-spacing: 0.04em; } .sglv-apikey-consent-p { margin: 0 0 8px; color: #cbd5e1; font-size: 12.5px; } .sglv-apikey-consent-p b, .sglv-apikey-consent-ul b { color: #e9d5ff; } .sglv-apikey-consent-ul { margin: 0; padding-left: 18px; color: #cbd5e1; font-size: 12.5px; } .sglv-apikey-consent-ul li { margin-bottom: 6px; } .sglv-apikey-consent-check { display: flex; align-items: flex-start; gap: 9px; margin-top: 16px; padding: 11px 13px; border: 1px solid rgba(167,139,250,0.32); border-radius: 10px; background: rgba(167,139,250,0.07); cursor: pointer; font-size: 12.5px; color: #e2e8f0; } .sglv-apikey-consent-check:hover { border-color: rgba(167,139,250,0.55); } .sglv-apikey-consent-check input { margin-top: 3px; accent-color: #a78bfa; flex: none; cursor: pointer; } .sglv-apikey-consent-actions { display: flex; gap: 10px; padding: 14px 18px 18px; border-top: 1px solid rgba(148,163,184,0.16); } .sglv-apikey-consent-btn { flex: 1; padding: 10px 0; border-radius: 9px; font-size: 13px; font-weight: 600; cursor: pointer; border: 1px solid transparent; transition: filter 0.15s, opacity 0.15s; } .sglv-apikey-consent-btn.primary { background: linear-gradient(90deg, #8b5cf6, #3b82f6); color: #fff; } .sglv-apikey-consent-btn.ghost { background: transparent; border-color: rgba(148,163,184,0.3); color: #cbd5e1; } .sglv-apikey-consent-btn:hover:not(:disabled) { filter: brightness(1.15); } .sglv-apikey-consent-btn:disabled { opacity: 0.42; cursor: not-allowed; } .sglv-set-locked { padding: 12px 14px; border: 1px dashed rgba(167,139,250,0.4); border-radius: 10px; background: rgba(167,139,250,0.06); } .sglv-set-locked-title { font-size: 13px; font-weight: 700; color: #e9d5ff; margin-bottom: 6px; } .sglv-set-locked-desc { font-size: 12px; color: #94a3b8; line-height: 1.65; margin-bottom: 10px; } .sglv-set-locked-btn { padding: 7px 16px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; border: 1px solid transparent; background: linear-gradient(90deg, #8b5cf6, #3b82f6); color: #fff; } .sglv-set-locked-btn:hover { filter: brightness(1.15); }`; const CSS_SETTINGS =` .sglv-panel .sglv-settings-btn.sglv-active { background: rgba(102,192,244,0.2) !important; color: #66c0f4 !important; box-shadow: inset 0 0 0 1px rgba(102,192,244,0.5); border-radius: 6px; } .sglv-set-wrap { display: flex; flex-direction: column; height: 100%; min-height: 0; } .sglv-set-topbar { display: flex; align-items: center; gap: 12px; padding: 14px 18px 10px; flex: none; border-bottom: 1px solid rgba(148,163,184,0.12); } .sglv-set-back { display: inline-flex; align-items: center; gap: 6px; background: rgba(148,163,184,0.1); border: 1px solid rgba(148,163,184,0.2); color: #cbd5e1; font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: 8px; cursor: pointer; transition: background 0.15s, color 0.15s; } .sglv-set-back:hover { background: rgba(148,163,184,0.22); color: #fff; } .sglv-set-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; color: #fff; } .sglv-set-title svg { width: 16px; height: 16px; color: #66c0f4; } .sglv-set-grid { flex: 1; min-height: 0; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; padding: 16px 18px; align-content: start; } .sglv-set-card { background: rgba(15,23,42,0.55); border: 1px solid rgba(148,163,184,0.14); border-radius: 12px; padding: 16px; transition: border-color 0.15s; } .sglv-set-card:hover { border-color: rgba(102,192,244,0.35); } .sglv-set-card.wide { grid-column: 1 / -1; } .sglv-set-card-title { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; color: #e2e8f0; margin-bottom: 8px; } .sglv-set-chip { display: inline-flex; width: 26px; height: 26px; align-items: center; justify-content: center; border-radius: 8px; flex: none; } .sglv-set-chip svg { width: 14px; height: 14px; } .sglv-set-chip.blue { background: rgba(102,192,244,0.15); color: #66c0f4; } .sglv-set-chip.purple { background: rgba(167,139,250,0.15); color: #a78bfa; } .sglv-set-chip.amber { background: rgba(245,158,11,0.15); color: #f59e0b; } .sglv-set-desc { font-size: 12px; color: #94a3b8; line-height: 1.7; margin-bottom: 12px; } .sglv-set-desc a { color: #66c0f4; text-decoration: none; } .sglv-set-desc a:hover { text-decoration: underline; } .sglv-set-field { margin-bottom: 12px; } .sglv-set-field:last-child { margin-bottom: 0; } .sglv-set-field > label { display: block; font-size: 12px; font-weight: 600; color: #cbd5e1; margin-bottom: 6px; } .sglv-set-input { width: 100%; box-sizing: border-box; background: rgba(2,6,23,0.6); border: 1px solid rgba(148,163,184,0.22); border-radius: 8px; padding: 9px 12px; color: #e2e8f0; font-size: 13px; transition: border-color 0.15s, box-shadow 0.15s; } .sglv-set-input::placeholder { color: #475569; } .sglv-set-input:focus { outline: none; border-color: #66c0f4; box-shadow: 0 0 0 3px rgba(102,192,244,0.15); } .sglv-set-hint { font-size: 11px; color: #64748b; margin-top: 4px; line-height: 1.5; } .sglv-set-mode-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 10px; } .sglv-set-mode-item { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid rgba(148,163,184,0.16); border-radius: 10px; cursor: pointer; transition: border-color 0.15s, background 0.15s; } .sglv-set-mode-item:hover { border-color: rgba(167,139,250,0.4); background: rgba(167,139,250,0.06); } .sglv-set-mode-item input { margin-top: 3px; accent-color: #a78bfa; cursor: pointer; } .sglv-set-mode-item input:checked ~ .sglv-set-mode-info .sglv-set-mode-name { color: #a78bfa; } .sglv-set-mode-name { font-size: 12.5px; font-weight: 700; color: #e2e8f0; } .sglv-set-mode-desc { font-size: 11.5px; color: #94a3b8; margin-top: 2px; line-height: 1.5; } /*设置页面开关样式 */ .sglv-set-chip.green { background: rgba(75,181,79,0.15); color: #4bb54f; } .sglv-set-toggle-row { display: flex !important; align-items: center; justify-content: space-between; font-size: 12.5px; font-weight: 600; color: #cbd5e1; cursor: pointer; user-select: none; } .sglv-set-switch { position: relative; display: inline-block; width: 36px; height: 20px; flex: none; } .sglv-set-switch input { opacity: 0; width: 0; height: 0; } .sglv-set-slider { position: absolute; cursor: pointer; inset: 0; background-color: rgba(255,255,255,0.1); transition: .2s; border-radius: 20px; border: 1px solid rgba(255,255,255,0.05); } .sglv-set-slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 2px; bottom: 2px; background-color: #8a9ba8; transition: .2s; border-radius: 50%; } .sglv-set-switch input:checked + .sglv-set-slider { background-color: rgba(75,181,79,0.2); border-color: rgba(75,181,79,0.4); } .sglv-set-switch input:checked + .sglv-set-slider:before { transform: translateX(16px); background-color: #4bb54f; } /*外观主题选择卡(sglv-set-theme-swatch 为主题预览色板,颜色刻意固定不随主题切换——生成器跳过) */ .sglv-set-theme-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } .sglv-set-theme-opt { position: relative; display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid rgba(148,163,184,0.16); border-radius: 10px; cursor: pointer; transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; } .sglv-set-theme-opt:hover { border-color: rgba(167,139,250,0.4); } .sglv-set-theme-opt input { position: absolute; opacity: 0; width: 0; height: 0; } .sglv-set-theme-swatch { height: 34px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.08); } .sglv-set-theme-swatch.purple { background: linear-gradient(135deg, #171a21 0%, #4c1d95 55%, #a78bfa 100%); } .sglv-set-theme-swatch.blue { background: linear-gradient(135deg, #171a21 0%, #2a475e 55%, #66c0f4 100%); } .sglv-set-theme-name { display: flex; align-items: center; justify-content: space-between; font-size: 12.5px; font-weight: 700; color: #e2e8f0; } .sglv-set-theme-name .sglv-set-theme-check { color: #4bb54f; font-size: 12px; opacity: 0; } .sglv-set-theme-opt.selected { border-color: rgba(167,139,250,0.55); background: rgba(167,139,250,0.07); box-shadow: 0 0 0 1px rgba(167,139,250,0.25); } .sglv-set-theme-opt.selected .sglv-set-theme-check { opacity: 1; } .sglv-set-theme-desc { font-size: 11px; color: #94a3b8; line-height: 1.5; } .sglv-set-savebar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex: none; border-top: 1px solid rgba(148,163,184,0.12); background: rgba(2,6,23,0.35); } .sglv-set-save-btn { display: inline-flex; align-items: center; gap: 6px; background: linear-gradient(90deg, #8b5cf6, #3b82f6); color: #fff; border: none; border-radius: 8px; padding: 9px 22px; font-size: 13px; font-weight: 700; cursor: pointer; transition: filter 0.15s, transform 0.1s; } .sglv-set-save-btn:hover { filter: brightness(1.15); } .sglv-set-save-btn:active { transform: scale(0.97); } .sglv-set-status { font-size: 12px; color: #2ed573; font-weight: 600; }`; const CSS_KPI =` .sglv-stat-dash-card.orange { border-color: rgba(245, 158, 11, 0.3); background: rgba(245, 158, 11, 0.08); } .sglv-stat-dash-card.orange .sglv-stat-dash-icon { color: #f59e0b; } .sglv-stat-dash-card.orange .sglv-stat-dash-value { color: #f59e0b; } .sglv-stat-dash-card.teal { border-color: rgba(20, 184, 166, 0.3); background: rgba(20, 184, 166, 0.08); } .sglv-stat-dash-card.teal .sglv-stat-dash-icon { color: #14b8a6; } .sglv-stat-dash-card.teal .sglv-stat-dash-value { color: #14b8a6; } .sglv-stat-dash-card.pink { border-color: rgba(236, 72, 153, 0.3); background: rgba(236, 72, 153, 0.08); } .sglv-stat-dash-card.pink .sglv-stat-dash-icon { color: #ec4899; } .sglv-stat-dash-card.pink .sglv-stat-dash-value { color: #ec4899; } .sglv-bundle-badge { display: inline-block; margin-left: 4px; padding: 1px 5px; font-size: 10px; font-weight: 600; border-radius: 3px; background: #f59e0b; color: #fff; vertical-align: middle; cursor: help; } /*游戏橱窗 KPI 改为 grid 7 列扁平布局,与统计仪表/趋势页一致 */ .sglv-stats-dashboard { display: grid !important; grid-template-columns: repeat(7, 1fr) !important; gap: 6px !important; } @media (max-width: 1280px) { .sglv-stats-dashboard { grid-template-columns: repeat(4, 1fr) !important; } } @media (max-width: 768px) { .sglv-stats-dashboard { grid-template-columns: repeat(3, 1fr) !important; } } @media (max-width: 480px) { .sglv-stats-dashboard { grid-template-columns: repeat(2, 1fr) !important; } } .sglv-stats-dashboard .sglv-stat-dash-card { min-width: 0; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; } .sglv-stats-dashboard .sglv-stat-dash-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); } .sglv-stats-dashboard .sglv-stat-dash-card.active { box-shadow: 0 0 0 2px rgba(102,192,244,0.4); border-left-width: 4px !important; } .sglv-stats-dashboard .sglv-stat-dash-card.amber { cursor: default; } .sglv-stats-dashboard .sglv-stat-dash-card.amber:hover { transform: none; box-shadow: none; } /* v1.0.3: sglv-stat-dash-card / sglv-trend-kpi-card / sglv-wl-kpi-cell 共享炫彩效果 — 渐变背景 + shadow-sm 基线 + 抬升 -2px + accent-glow 外发光 */ .sglv-stat-dash-card, .sglv-trend-kpi-card { position: relative; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.04); } .sglv-stat-dash-card::before, .sglv-trend-kpi-card::before { content: ''; position: absolute; inset: -1px; pointer-events: none; background: linear-gradient(135deg, rgba(102,192,244,.32), transparent 60%); opacity: 0; transition: opacity .18s ease; } .sglv-stat-dash-card:hover::before, .sglv-trend-kpi-card:hover::before { opacity: 1; } .sglv-stat-dash-card:hover, .sglv-trend-kpi-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.45), 0 0 0 1px rgba(102,192,244,.25), 0 0 22px rgba(102,192,244,.18), inset 0 1px 0 rgba(255,255,255,.06); border-color: rgba(102,192,244,.45) !important; } .sglv-stat-dash-value, .sglv-trend-kpi-value { text-shadow: 0 0 14px rgba(102,192,244,.25); } .sglv-stat-dash-card > *, .sglv-trend-kpi-card > * { position: relative; z-index: 1; }`; const CSS_SUBSTATUS =` .sgis-sub-status { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .sgis-sub-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 600; border-radius: 4px; } /*年度大作 KPI 卡片可点击筛选样式 */ .sglv-trend-kpi-card.clickable { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; } .sglv-trend-kpi-card.clickable:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); } .sglv-trend-kpi-card.active { border-width: 2px; box-shadow: 0 0 0 2px rgba(102,192,244,0.4); }`; const CSS_COVER =` /* ====== 横版封面视图(3列,467/181 胶囊横幅) ====== */ .sglv-cover-grid { display: grid !important; grid-template-columns: repeat(3, 1fr) !important; gap: 12px !important; } @media (max-width: 1100px) { .sglv-cover-grid { grid-template-columns: repeat(2, 1fr) !important; } } @media (max-width: 700px) { .sglv-cover-grid { grid-template-columns: 1fr !important; } } .sglv-cover-card { background: linear-gradient(135deg, rgba(255,255,255,0.06) 0%, rgba(255,255,255,0.02) 100%) !important; border: 1px solid rgba(102,192,244,0.1) !important; border-radius: 10px !important; overflow: hidden !important; transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s !important; cursor: pointer !important; display: flex !important; flex-direction: column !important; } .sglv-cover-card:hover { border-color: rgba(102,192,244,0.35) !important; transform: translateY(-3px) !important; box-shadow: 0 6px 20px rgba(102,192,244,0.12), 0 2px 8px rgba(0,0,0,0.3) !important; } .sglv-cover-img-wrap { position: relative !important; width: 100% !important; aspect-ratio: 467/181 !important; overflow: hidden !important; background: rgba(255,255,255,0.05) !important; flex-shrink: 0 !important; } .sglv-cover-img { width: 100% !important; height: 100% !important; object-fit: cover !important; display: block !important; transition: transform 0.3s !important; } .sglv-cover-card:hover .sglv-cover-img { transform: scale(1.04) !important; } .sglv-cover-gradient { position: absolute !important; bottom: 0 !important; left: 0 !important; right: 0 !important; height: 45% !important; background: linear-gradient(to top, rgba(13,18,35,0.85) 0%, transparent 100%) !important; pointer-events: none !important; z-index: 1 !important; } .sglv-cover-badges { position: absolute !important; top: 6px !important; left: 6px !important; display: flex !important; gap: 4px !important; z-index: 2 !important; } .sglv-cover-badge { display: inline-flex !important; align-items: center !important; gap: 3px !important; font-size: 10px !important; font-weight: 700 !important; padding: 2px 7px !important; border-radius: 4px !important; backdrop-filter: blur(6px) !important; -webkit-backdrop-filter: blur(6px) !important; background: rgba(0,0,0,0.55) !important; color: #c4b5fd !important; line-height: 1.3 !important; } .sglv-cover-badge svg { width: 10px !important; height: 10px !important; opacity: 0.85 !important; } .sglv-cover-badge.shared { background: rgba(139,92,246,0.55) !important; color: #ddd6fe !important; } .sglv-cover-badge.bundle { background: rgba(255,152,0,0.55) !important; color: #ffd591 !important; } .sglv-cover-playtime { position: absolute !important; bottom: 6px !important; right: 6px !important; display: inline-flex !important; align-items: center !important; gap: 3px !important; font-size: 11px !important; font-weight: 700 !important; padding: 2px 8px !important; border-radius: 4px !important; background: rgba(0,0,0,0.65) !important; color: #66c0f4 !important; backdrop-filter: blur(6px) !important; -webkit-backdrop-filter: blur(6px) !important; z-index: 2 !important; line-height: 1.3 !important; } .sglv-cover-playtime svg { width: 10px !important; height: 10px !important; opacity: 0.85 !important; } .sglv-cover-body { padding: 8px 10px 9px !important; display: flex !important; flex-direction: column !important; gap: 3px !important; } .sglv-cover-name { font-size: 13px !important; font-weight: 600 !important; color: #e0e0e0 !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } .sglv-cover-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; } .sglv-cover-id { font-size: 10px !important; color: var(--sglv-text-secondary) !important; font-variant-numeric: tabular-nums !important; } /* ====== 卡片视图增强 ====== */ .sglv-game-card { border-radius: 10px !important; } .sglv-game-card:hover { box-shadow: 0 6px 20px rgba(102,192,244,0.1), 0 2px 8px rgba(0,0,0,0.3) !important; } .sglv-card-img { transition: transform 0.3s !important; } .sglv-game-card:hover .sglv-card-img { transform: scale(1.05) !important; } /* ====== 工具栏排版优化 ====== */ .sglv-toolbar { gap: 8px !important; }`; const CSS_PRICE =` /* === 9区完整价格对比表 === */ .sgis-price-tbl { display:flex; flex-direction:column; gap:1px; } .sgis-price-tbl-header { display:grid; grid-template-columns: 1.6fr 0.9fr 1fr 0.6fr 0.9fr; gap:2px; padding:4px 6px; font-size:9px; color:var(--sgis-text-2); text-transform:uppercase; letter-spacing:0.3px; border-bottom:1px solid rgba(255,255,255,0.06); } .sgis-price-tbl-row { display:grid; grid-template-columns: 1.6fr 0.9fr 1fr 0.6fr 0.9fr; gap:2px; padding:5px 6px; font-size:11px; align-items:center; border-radius:4px; transition:background 0.15s; } .sgis-price-tbl-row:hover { background:rgba(255,255,255,0.04); } .sgis-price-tbl-row-lowest { background:rgba(75,181,79,0.12) !important; border-left:3px solid #4bb54f; } .sgis-price-tbl-row-highest { background:rgba(230,57,70,0.10) !important; border-left:3px solid #e63946; } .sgis-price-tbl-row.user-region { box-shadow: inset 0 0 0 1px rgba(167,139,250,0.3); } .sgis-price-tbl-cell { display:flex; align-items:center; gap:3px; overflow:hidden; } .sgis-price-tbl-region { gap:4px; } .sgis-price-tbl-initial { color:var(--sgis-text-2); font-size:10px; } .sgis-price-tbl-price { font-weight:600; } .sgis-price-tbl-cny { font-weight:700; color:#66c0f4; } .sgis-price-tbl-badge-low { font-size:8px; background:#4bb54f; color:#fff; padding:1px 4px; border-radius:3px; margin-left:2px; } .sgis-price-tbl-badge-high { font-size:8px; background:#e63946; color:#fff; padding:1px 4px; border-radius:3px; margin-left:2px; } /* === 国旗完整显示 — flagcdn w40 各国旗帜宽高比不一(US/GB/KZ/CO 为 2:1 宽旗, BR 为 10:7 等),共享库 .sgis-price-flag 固定 30×20(3:2)+cover 会把宽旗左右裁掉约 1/4; 此处以更高优先级覆盖为 contain,旗帜图案完整呈现,比例空隙由行背景自然过渡 === */ .sgis-price-tbl .sgis-price-flag { object-fit: contain; } .sgis-price-featured-img { object-fit: contain; } /* === 比价目标货币切换(多地区低价排行页头) === */ .sgis-price-cur-switch { margin-left:auto; display:inline-flex; align-items:center; gap:4px; flex-shrink:0; text-transform:none; letter-spacing:0; } .sgis-price-cur-switch-label { font-size:9px; color:var(--sgis-text-2); font-weight:500; } .sgis-price-cur-select { font-size:10px; font-weight:600; color:#66c0f4; font-family:inherit; background:rgba(102,192,244,0.08); border:1px solid rgba(102,192,244,0.25); border-radius:6px; padding:2px 4px; cursor:pointer; outline:none; max-width:132px; text-transform:none; letter-spacing:0; transition:border-color 0.15s, background 0.15s; } .sgis-price-cur-select:hover { border-color:rgba(102,192,244,0.5); background:rgba(102,192,244,0.14); } .sgis-price-cur-select:focus { border-color:rgba(102,192,244,0.6); } .sgis-price-cur-select option { background:#1b2838; color:#fff; } .sgis-price-cur-fallback { font-size:9px; color:#c0a050; margin:-4px 0 6px; line-height:1.4; } /* === 评测标签页 === */ /* === 评测摘要(Steam 风格大字 + 渐变进度条) === */ .sgis-review-summary { display:flex; flex-direction:column; gap:10px; padding:10px 12px; background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.05); border-radius:8px; } .sgis-review-summary-head { display:flex; align-items:center; gap:12px; } .sgis-review-score { display:flex; align-items:center; gap:12px; flex:1; min-width:0; } .sgis-review-score-icon { font-size:30px; line-height:1; flex-shrink:0; filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3)); } .sgis-review-score-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex:1; } .sgis-review-score-text { font-size:14px; font-weight:700; line-height:1.2; } .sgis-review-score-desc { font-size:10px; color:var(--sgis-text-2); line-height:1.3; } .sgis-review-pct-big { font-size:24px; font-weight:800; font-variant-numeric:tabular-nums; line-height:1; flex-shrink:0; letter-spacing:-0.5px; } .sgis-review-pct-big.pos { color:#4bb54f; } .sgis-review-pct-big.mix { color:#a78bfa; } .sgis-review-pct-big.neg { color:#e63946; } .sgis-review-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:rgba(230,57,70,0.3); box-shadow:inset 0 1px 2px rgba(0,0,0,0.3); } .sgis-review-bar-positive { background:linear-gradient(90deg,#4bb54f 0%,#66c0f4 100%); height:100%; transition:width 0.5s cubic-bezier(0.4,0,0.2,1); box-shadow:0 0 6px rgba(75,181,79,0.4); } .sgis-review-bar-positive.mix { background:linear-gradient(90deg,#a78bfa 0%,#54a0ff 100%); box-shadow:0 0 6px rgba(167,139,250,0.4); } .sgis-review-bar-positive.neg { background:linear-gradient(90deg,#e63946 0%,#ff6b6b 100%); box-shadow:0 0 6px rgba(230,57,70,0.4); } .sgis-review-counts { display:flex; justify-content:space-between; font-size:10px; font-weight:600; } .sgis-review-counts .pos { color:#4bb54f; } .sgis-review-counts .neg { color:#e63946; } .sgis-review-trend { display:flex; gap:10px; font-size:10px; flex-wrap:wrap; padding:6px 8px; background:rgba(255,255,255,0.02); border-radius:6px; } .sgis-review-trend-item { display:flex; flex-direction:column; gap:2px; flex:1; min-width:80px; } .sgis-review-trend-label { color:var(--sgis-text-2); font-size:9px; } .sgis-review-trend-val { font-weight:600; font-variant-numeric:tabular-nums; } .sgis-review-filters { display:flex; flex-wrap:wrap; gap:4px; padding:6px 0; border-top:1px solid rgba(255,255,255,0.06); border-bottom:1px solid rgba(255,255,255,0.06); } .sgis-review-filter-group { display:flex; align-items:center; gap:2px; } .sgis-review-filter-label { font-size:9px; color:var(--sgis-text-2); margin-right:2px; } .sgis-review-filter-btn { font-size:10px; padding:2px 7px; border-radius:10px; border:1px solid rgba(255,255,255,0.1); background:transparent; color:var(--sgis-text-2); cursor:pointer; transition:all 0.15s; } .sgis-review-filter-btn:hover { background:rgba(255,255,255,0.06); } .sgis-review-filter-btn.active { background:rgba(102,192,244,0.2); color:#66c0f4; border-color:rgba(102,192,244,0.4); } .sgis-review-list { display:flex; flex-direction:column; gap:8px; padding:4px 0; } /*评测项卡片化——左右色条标识好评/差评 */ .sgis-review-item { position:relative; padding:10px 10px 8px 12px; border-radius:8px; background:rgba(255,255,255,0.025); border:1px solid rgba(255,255,255,0.05); transition:all 0.2s ease; overflow:hidden; } .sgis-review-item::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:linear-gradient(180deg,transparent,rgba(75,181,79,0.5),transparent); opacity:0; transition:opacity 0.2s; } .sgis-review-item.is-positive::before { background:linear-gradient(180deg,transparent,#4bb54f,transparent); opacity:0.7; } .sgis-review-item.is-negative::before { background:linear-gradient(180deg,transparent,#e63946,transparent); opacity:0.7; } .sgis-review-item:hover { background:rgba(255,255,255,0.045); border-color:rgba(139,92,246,0.15); transform:translateY(-1px); box-shadow:0 2px 8px rgba(0,0,0,0.15); } .sgis-review-item.is-positive:hover { border-color:rgba(75,181,79,0.25); } .sgis-review-item.is-negative:hover { border-color:rgba(230,57,70,0.25); } .sgis-review-item-header { display:flex; align-items:center; gap:8px; margin-bottom:6px; } .sgis-review-avatar { width:32px; height:32px; min-width:32px; border-radius:50%; background:rgba(255,255,255,0.08); display:flex; align-items:center; justify-content:center; object-fit:cover; flex-shrink:0; border:1px solid rgba(255,255,255,0.06); } .sgis-review-avatar-link { display:inline-flex; line-height:0; flex-shrink:0; border-radius:50%; transition:transform 0.2s; } .sgis-review-avatar-link:hover { transform:scale(1.08); } .sgis-review-avatar-fallback { background:linear-gradient(135deg,rgba(139,92,246,0.4),rgba(59,130,246,0.3)); color:#fff; font-weight:700; font-size:13px; letter-spacing:0.5px; text-transform:uppercase; } .sgis-review-author-wrap { display:flex; flex-direction:column; gap:1px; min-width:0; flex:1; } .sgis-review-author { font-size:12px; font-weight:600; color:#66c0f4; text-decoration:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color 0.15s; } a.sgis-review-author:hover { color:#93c5fd; } .sgis-review-date { font-size:10px; color:var(--sgis-text-3); font-weight:400; } .sgis-review-rec { font-size:10px; padding:2px 7px; border-radius:4px; font-weight:700; flex-shrink:0; } .sgis-review-rec-yes { background:rgba(75,181,79,0.18); color:#4bb54f; border:1px solid rgba(75,181,79,0.3); } .sgis-review-rec-no { background:rgba(230,57,70,0.18); color:#f87171; border:1px solid rgba(230,57,70,0.3); } .sgis-review-meta { font-size:10px; color:var(--sgis-text-2); display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 4px; } .sgis-review-meta-item { padding:1px 6px; background:rgba(255,255,255,0.04); border-radius:3px; } /*计数摘要条——图标 + 数字 + 已筛选徽章 */ .sgis-review-count-summary { display:flex; align-items:center; gap:6px; margin-top:8px; padding:5px 8px; font-size:10px; color:var(--sgis-text-2); background:rgba(139,92,246,0.06); border:1px solid rgba(139,92,246,0.12); border-radius:5px; } .sgis-review-count-summary b { color:#c4b5fd; font-weight:700; } .sgis-review-count-icon { width:12px; height:12px; color:#a78bfa; flex-shrink:0; } .sgis-review-count-icon svg { width:12px; height:12px; } .sgis-review-count-filtered { margin-left:auto; font-size:9px; font-weight:700; padding:1px 6px; background:rgba(167,139,250,0.2); color:#a78bfa; border:1px solid rgba(167,139,250,0.3); border-radius:8px; } .sgis-review-text { font-size:12px; line-height:1.6; color:var(--sgis-text); margin-top:4px; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; cursor:pointer; transition:all 0.15s; padding:4px 0; word-break:break-word; } .sgis-review-text:hover { color:#f8fafc; } .sgis-review-text.expanded { -webkit-line-clamp:unset; overflow:visible; } /*评测项操作行(展开/复制)——紧凑胶囊按钮 */ .sgis-review-actions { display:flex; align-items:center; gap:6px; margin-top:6px; } .sgis-review-action-btn { display:inline-flex; align-items:center; gap:4px; padding:2px 8px; font-size:10px; font-weight:600; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); color:var(--sgis-text-2); border-radius:10px; cursor:pointer; transition:all 0.15s; } .sgis-review-action-btn:hover { background:rgba(139,92,246,0.15); border-color:rgba(139,92,246,0.3); color:#c4b5fd; } .sgis-review-action-btn svg { width:11px; height:11px; transition:transform 0.25s; } .sgis-review-expand-btn.is-expanded svg { transform:rotate(180deg); } .sgis-review-action-btn.copied { background:rgba(75,181,79,0.2); border-color:rgba(75,181,79,0.4); color:#4bb54f; } .sgis-review-votes { font-size:10px; color:var(--sgis-text-3); margin-top:6px; display:flex; gap:10px; padding-top:6px; border-top:1px dashed rgba(255,255,255,0.06); } /*评测空状态——精致状态点 + 文字 */ .sgis-review-empty { text-align:center; padding:30px 16px; font-size:12px; color:var(--sgis-text-2); display:flex; flex-direction:column; align-items:center; gap:6px; } .sgis-review-empty-icon { width:42px; height:42px; border-radius:12px; background:linear-gradient(135deg,rgba(139,92,246,0.12),rgba(59,130,246,0.06)); border:1px solid rgba(139,92,246,0.18); display:flex; align-items:center; justify-content:center; color:var(--sgis-text-2); margin-bottom:4px; } .sgis-review-empty-icon svg { width:20px; height:20px; } /* === 跨区送礼推荐 === */ .sgis-gift-card { padding:10px; border-radius:8px; background:linear-gradient(135deg, rgba(167,139,250,0.12), rgba(102,192,244,0.08)); border:1px solid rgba(167,139,250,0.2); margin-top:8px; } .sgis-gift-title { font-size:12px; font-weight:700; margin-bottom:6px; display:flex; align-items:center; gap:4px; } .sgis-gift-scheme { font-size:14px; font-weight:700; color:#66c0f4; margin:4px 0; } .sgis-gift-save { font-size:20px; font-weight:800; color:#4bb54f; } .sgis-gift-pct { font-size:11px; color:#4bb54f; font-weight:600; } .sgis-gift-prices { display:flex; gap:12px; margin:6px 0; font-size:10px; } .sgis-gift-price-item { display:flex; flex-direction:column; } .sgis-gift-price-label { color:var(--sgis-text-2); } .sgis-gift-price-val { font-weight:600; } .sgis-gift-warn { font-size:9px; color:#e6a839; margin-top:6px; padding:4px 6px; background:rgba(230,168,57,0.08); border-radius:4px; } .sgis-gift-rule { font-size:9px; color:var(--sgis-text-2); margin-top:4px; } .sgis-gift-alt { font-size:10px; color:var(--sgis-text-2); margin-top:6px; padding-top:6px; border-top:1px dashed rgba(255,255,255,0.06); } /* === SVG 价格走势图 === */ .sgis-price-chart-wrap { margin-top:8px; border-radius:8px; background:rgba(255,255,255,0.02); padding:8px 6px 4px; border:1px solid rgba(255,255,255,0.05); } .sgis-price-chart-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:4px; } .sgis-price-chart-title { font-size:10px; color:var(--sgis-text-2); font-weight:500; } .sgis-chart-range-group { display:flex; gap:2px; } .sgis-chart-range-btn { font-size:9px; padding:1px 6px; border-radius:8px; border:1px solid rgba(255,255,255,0.08); background:transparent; color:var(--sgis-text-2); cursor:pointer; transition:all 0.15s; } .sgis-chart-range-btn:hover { background:rgba(255,255,255,0.06); } .sgis-chart-range-btn.active { background:rgba(102,192,244,0.2); color:#66c0f4; border-color:rgba(102,192,244,0.4); } /* === DRM 警告徽章 === */ .sgis-drm-badge { display:inline-flex; align-items:center; gap:3px; font-size:10px; font-weight:600; padding:2px 8px; border-radius:4px; background:rgba(239,68,68,0.15); color:#f87171; border:1px solid rgba(239,68,68,0.25); } .sgis-drm-badge.warn { background:rgba(245,158,11,0.15); color:#fbbf24; border-color:rgba(245,158,11,0.25); } /* === 中文语音标记 === */ .sgis-audio-badge { display:inline-flex; align-items:center; gap:3px; font-size:10px; font-weight:600; padding:2px 8px; border-radius:4px; background:rgba(75,181,79,0.15); color:#4bb54f; border:1px solid rgba(75,181,79,0.25); } /* === 资料受限/Steam正在了解 状态徽章 (参考 steam-restricted-game-realtime-userscript) === */ .sgis-profile-badge { display:inline-flex; align-items:center; gap:3px; font-size:10px; font-weight:600; padding:2px 8px; border-radius:4px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.06); color:var(--sgis-text); } .sgis-profile-badge svg { width:12px; height:12px; flex-shrink:0; } .sgis-profile-badge.restricted { background:rgba(239,68,68,0.15); color:#f87171; border-color:rgba(239,68,68,0.25); } .sgis-profile-badge.learning { background:rgba(245,158,11,0.15); color:#fbbf24; border-color:rgba(245,158,11,0.25); } .sgis-profile-badge.normal { background:rgba(34,197,94,0.12); color:#22c55e; border-color:rgba(34,197,94,0.2); } .sgis-profile-badge.loading { background:rgba(255,255,255,0.04); color:var(--sgis-text-2); border-color:rgba(255,255,255,0.08); } /* === gamestatus.info 破解状态徽章 === */ .sgis-crack-badge { display:inline-flex; align-items:center; gap:3px; font-size:10px; font-weight:600; padding:2px 8px; border-radius:4px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.06); color:var(--sgis-text); } .sgis-crack-badge svg { width:12px; height:12px; flex-shrink:0; } .sgis-crack-badge.cracked { background:rgba(34,197,94,0.15); color:#22c55e; border-color:rgba(34,197,94,0.25); } .sgis-crack-badge.not-cracked { background:rgba(239,68,68,0.15); color:#f87171; border-color:rgba(239,68,68,0.25); } .sgis-crack-badge.bypass { background:rgba(245,158,11,0.15); color:#fbbf24; border-color:rgba(245,158,11,0.25); } .sgis-crack-badge.release-today { background:rgba(59,130,246,0.15); color:#60a5fa; border-color:rgba(59,130,246,0.25); } .sgis-crack-badge.aaa { background:rgba(168,85,247,0.15); color:#c084fc; border-color:rgba(168,85,247,0.25); } .sgis-crack-protection { display:inline-flex; align-items:center; font-size:9px; font-weight:500; padding:1px 6px; border-radius:3px; background:rgba(255,255,255,0.05); color:var(--sgis-text-2); border:1px solid rgba(255,255,255,0.08); } .sgis-crack-group { display:inline-flex; align-items:center; gap:4px; font-size:9px; font-weight:500; padding:1px 6px; border-radius:3px; background:rgba(20,184,166,0.12); color:#2dd4bf; border:1px solid rgba(20,184,166,0.2); } .sgis-crack-group .sgis-crack-group-key { opacity:0.65; font-weight:400; } .sgis-crack-group .sgis-crack-group-val { font-weight:600; } .sgis-crack-date { font-size:9px; color:var(--sgis-text-2); } .sgis-crack-score { display:inline-flex; align-items:center; gap:2px; font-size:9px; font-weight:600; padding:1px 6px; border-radius:3px; background:rgba(250,204,21,0.12); color:#facc15; border:1px solid rgba(250,204,21,0.2); } .sgis-crack-score svg { width:10px; height:10px; } .sgis-crack-hardware { margin-top:6px; padding:6px 8px; border-radius:6px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.06); } .sgis-crack-hardware-title { font-size:10px; font-weight:600; color:var(--sgis-text-2); margin-bottom:4px; } .sgis-crack-hw-row { display:flex; gap:6px; font-size:10px; line-height:1.5; } .sgis-crack-hw-key { color:var(--sgis-text-2); min-width:32px; font-weight:500; } .sgis-crack-hw-val { color:var(--sgis-text); flex:1; word-break:break-word; } /* === 评测搜索(flex 布局,按钮不再溢出) === */ .sgis-review-search-wrap { display:flex; align-items:center; gap:6px; margin-bottom:6px; } .sgis-review-search-input { flex:1; min-width:0; width:auto; box-sizing:border-box; font-size:11px; padding:5px 9px; border-radius:6px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:var(--sgis-text); transition:border-color 0.15s; } .sgis-review-search-input:focus { outline:none; border-color:rgba(102,192,244,0.4); } .sgis-review-search-input::placeholder { color:var(--sgis-text-2); font-size:10px; } .sgis-review-regex-toggle { flex-shrink:0; font-size:10px; padding:4px 8px; border-radius:6px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:var(--sgis-text-2); cursor:pointer; transition:all 0.15s; font-family:monospace; font-weight:600; letter-spacing:0.5px; } .sgis-review-regex-toggle:hover { background:rgba(167,139,250,0.1); border-color:rgba(167,139,250,0.3); } .sgis-review-regex-toggle.active { background:rgba(167,139,250,0.2); color:#a78bfa; border-color:rgba(167,139,250,0.4); box-shadow:0 0 0 1px rgba(167,139,250,0.2); } .sgis-review-search-clear { flex-shrink:0; width:22px; height:22px; padding:0; border-radius:50%; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:var(--sgis-text-2); cursor:pointer; transition:all 0.15s; display:flex; align-items:center; justify-content:center; font-size:13px; line-height:1; } .sgis-review-search-clear:hover { background:rgba(230,57,70,0.15); color:#e63946; border-color:rgba(230,57,70,0.4); }`; const CSS_LOAD =` /* === 加载容器(替代裸 spinner,给用户结构感) === */ .sgis-load-block { display:flex; flex-direction:column; align-items:stretch; justify-content:flex-start; padding:18px 16px 14px; gap:10px; } .sgis-load-head { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--sgis-text); } .sgis-load-head .sgis-spinner { width:18px; height:18px; margin:0; flex-shrink:0; } .sgis-load-stage { flex:1; font-weight:600; letter-spacing:0.2px; } .sgis-load-pct { font-size:11px; color:var(--sgis-text-2); font-variant-numeric:tabular-nums; flex-shrink:0; } /* === 主进度条(带渐变 + 流光) === */ .sgis-load-bar { position:relative; height:6px; background:rgba(255,255,255,0.05); border-radius:3px; overflow:hidden; box-shadow:inset 0 1px 2px rgba(0,0,0,0.3); } .sgis-load-bar-fill { position:absolute; left:0; top:0; bottom:0; width:0%; border-radius:3px; background:linear-gradient(90deg,#06b6d4,#3b82f6,#8b5cf6); transition:width 0.35s cubic-bezier(0.4,0,0.2,1); box-shadow:0 0 10px rgba(59,130,246,0.5); } .sgis-load-bar-fill::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,transparent,rgba(255,255,255,0.45),transparent); animation:sgis-load-shimmer 1.4s linear infinite; } @keyframes sgis-load-shimmer { 0%{transform:translateX(-100%)} 100%{transform:translateX(100%)} } /* === 阶段指示条(4 段小条,阶段切换时高亮) === */ .sgis-load-stages { display:flex; gap:3px; } .sgis-load-stage-bar { flex:1; height:3px; border-radius:2px; background:rgba(255,255,255,0.06); transition:all 0.3s; } .sgis-load-stage-bar.active { background:linear-gradient(90deg,#06b6d4,#3b82f6); box-shadow:0 0 6px rgba(59,130,246,0.6); } .sgis-load-stage-bar.done { background:rgba(16,185,129,0.55); } /* === 骨架屏:与真实游戏行 1:1 布局,避免加载完成后跳变 === */ .sgis-skel-list { display:flex; flex-direction:column; gap:6px; margin-top:4px; } .sgis-skel-row { display:flex; align-items:center; gap:10px; padding:8px 10px; background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.04); border-radius:8px; } .sgis-skel-icon { width:32px; height:32px; border-radius:4px; flex-shrink:0; background:linear-gradient(90deg,rgba(139,92,246,0.08),rgba(139,92,246,0.18),rgba(139,92,246,0.08)); background-size:200% 100%; animation:sgis-skel-shimmer 1.4s linear infinite; } .sgis-skel-lines { flex:1; min-width:0; display:flex; flex-direction:column; gap:6px; } .sgis-skel-line { height:8px; border-radius:3px; background:linear-gradient(90deg,rgba(255,255,255,0.04),rgba(255,255,255,0.10),rgba(255,255,255,0.04)); background-size:200% 100%; animation:sgis-skel-shimmer 1.4s linear infinite; } .sgis-skel-line.l1 { width:55%; } .sgis-skel-line.l2 { width:30%; height:7px; opacity:0.6; } .sgis-skel-row:nth-child(odd) .sgis-skel-icon, .sgis-skel-row:nth-child(odd) .sgis-skel-line { animation-delay:0s; } .sgis-skel-row:nth-child(even) .sgis-skel-icon, .sgis-skel-row:nth-child(even) .sgis-skel-line { animation-delay:0.2s; } .sgis-skel-row:nth-child(3n) .sgis-skel-icon, .sgis-skel-row:nth-child(3n) .sgis-skel-line { animation-delay:0.4s; } @keyframes sgis-skel-shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} } /* === 加载中已完成计数(用户能看到 "已处理 X / 约 Y" 真实进度) === */ .sgis-load-counter { font-size:10px; color:var(--sgis-text-2); text-align:center; font-variant-numeric:tabular-nums; margin-top:2px; }`; //游戏别名 (Steam EN + 小黑盒中文别名) —— 行内样式 const CSS_ALIAS =` /* === v2.11.48: 游戏别名行 (Steam EN + 小黑盒中文别名,异步填充) === */ .sgis-alias-row { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; margin-top: 4px; min-height: 0; line-height: 1; } .sgis-alias-row:empty { display: none; } .sgis-alias-pill { display: inline-flex; align-items: center; gap: 4px; font-size: 10.5px; line-height: 1.4; padding: 2px 7px; border-radius: 4px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); max-width: 100%; } .sgis-alias-pill .sgis-alias-source { font-size: 9px; font-weight: 500; opacity: 0.6; letter-spacing: 0.2px; } .sgis-alias-pill .sgis-alias-value { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; } .sgis-alias-en { border-color: rgba(147, 197, 253, 0.25); background: rgba(96, 165, 250, 0.08); }`; //主题层——原 CSS_SGIS_OVERRIDE(无条件注入的紫色全局覆盖)重构为典雅紫主题层, // 由 applySglvTheme() 按用户主题动态注入,不再进入 CSS_INLINE。内容保持不变(默认主题零视觉回归)。 const CSS_THEME_PURPLE =` /* ====== 1. 全局 CSS 变量覆盖 → 紫色主调 ====== */ :root { --sglv-accent: #a78bfa !important; --sglv-accent-light: #c4b5fd !important; --sglv-accent-deep: #8b5cf6 !important; --sglv-accent-glow: rgba(139, 92, 246, 0.4) !important; --sglv-accent-bg: rgba(139, 92, 246, 0.12) !important; --sglv-accent-border: rgba(139, 92, 246, 0.3) !important; } /* ====== 2. 面板头部 → 紫色调 ====== */ .sglv-panel > .sglv-header { background: linear-gradient(135deg, rgba(27, 40, 56, 0.95) 0%, rgba(42, 71, 94, 0.95) 100%) !important; border-bottom: 1px solid rgba(139, 92, 246, 0.2) !important; } .sglv-header-brand { background: linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(99, 102, 241, 0.12)) !important; border: 1px solid rgba(139, 92, 246, 0.25) !important; color: #a78bfa !important; } .sglv-header-brand:hover { box-shadow: 0 2px 8px rgba(139, 92, 246, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important; } /* ====== 3. Tab 激活态 → 紫色 ====== */ .sglv-panel .sglv-tab.active { color: #fff !important; background: rgba(139, 92, 246, 0.18) !important; border-color: rgba(139, 92, 246, 0.4) !important; box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.15) inset, 0 1px 4px rgba(139, 92, 246, 0.2) !important; } .sglv-panel .sglv-tab:hover { background: rgba(139, 92, 246, 0.08) !important; } /* ====== 4. 全局搜索框聚焦 → 紫色 ====== */ .sglv-panel .sglv-global-search:focus-within { border-color: rgba(139, 92, 246, 0.55) !important; box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.25) !important; } /* ====== 5. KPI 卡片 — 恢复紫色变体 ====== */ /* amber 变体保持金色(原版设计) */ .sglv-playtime-kpi-card.amber { border-color: rgba(245, 158, 11, 0.35) !important; } .sglv-playtime-kpi-card.amber::before { background: #f59e0b !important; } .sglv-playtime-kpi-card.amber .sglv-playtime-kpi-value { color: #fbbf24 !important; } .sglv-playtime-kpi-card.amber .sglv-playtime-kpi-icon { background: rgba(245, 158, 11, 0.15) !important; color: #fbbf24 !important; } /* purple 变体 → 恢复紫色 */ .sglv-playtime-kpi-card.purple { border-color: rgba(139, 92, 246, 0.35) !important; } .sglv-playtime-kpi-card.purple::before { background: #8b5cf6 !important; } .sglv-playtime-kpi-card.purple .sglv-playtime-kpi-value { color: #c4b5fd !important; } .sglv-playtime-kpi-card.purple .sglv-playtime-kpi-icon { background: rgba(139, 92, 246, 0.15) !important; color: #c4b5fd !important; } /* blue/cyan/green/rose 语义色保持不变 */ /* ====== 6. 统计仪表 — 区块标题 → 紫色 ====== */ .sglv-playtime-section-title { color: #a78bfa !important; border-bottom: 1px solid rgba(139, 92, 246, 0.12) !important; } .sglv-playtime-section-title svg { color: #a78bfa !important; } /* ====== 7. 趋势筛选按钮 → 紫色激活 ====== */ .sglv-trend-btn.active { background: rgba(139, 92, 246, 0.18) !important; color: #c4b5fd !important; border-color: rgba(139, 92, 246, 0.3) !important; } .sglv-trend-btn:hover { background: rgba(139, 92, 246, 0.1) !important; } /* ====== 8. 进度条 → 青蓝紫渐变 ====== */ #sglv-stage-progress .sglv-stage-progress-fill { background: linear-gradient(90deg, #06b6d4 0%, #3b82f6 50%, #8b5cf6 100%) !important; } #sglv-stage-progress { background: linear-gradient(180deg, rgba(139, 92, 246, 0.06), transparent) !important; border-bottom: 1px solid rgba(139, 92, 246, 0.12) !important; } /* ====== 9. 仪表盘 — 新增雷达图区块 ====== */ .sglv-radar-row { display: grid; grid-template-columns: 280px 1fr; gap: 10px; flex: 0 0 auto; min-height: 0; } @media (max-width: 900px) { .sglv-radar-row { grid-template-columns: 1fr; } } .sglv-radar-card { background: linear-gradient(135deg, rgba(34, 211, 238, 0.05), rgba(139, 92, 246, 0.04), rgba(244, 114, 182, 0.04), rgba(13, 17, 23, 0.4)); border: 1px solid rgba(139, 92, 246, 0.12); border-radius: 10px; padding: 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; } .sglv-radar-card-title { font-size: 12px; font-weight: 700; background: linear-gradient(90deg, #22d3ee, #34d399, #a78bfa, #f472b6, #f59e0b); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; display: flex; align-items: center; gap: 6px; align-self: flex-start; } .sglv-radar-card-title svg { width: 14px; height: 14px; color: #a78bfa; } .sglv-radar-svg { width: 220px; height: 220px; } .sglv-radar-svg .radar-grid { fill: none; stroke: rgba(255, 255, 255, 0.08); stroke-width: 1; } .sglv-radar-svg .radar-axis { stroke-width: 1; } .sglv-radar-svg .radar-area { stroke-width: 2; stroke-linejoin: round; transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); } .sglv-radar-svg .radar-point { stroke: #0d1117; stroke-width: 2; transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); } .sglv-radar-svg .radar-label { font-size: 10px; font-weight: 700; text-anchor: middle; } .sglv-radar-svg .radar-value { font-size: 11px; font-weight: 800; text-anchor: middle; } /* 雷达图右侧统计面板 */ .sglv-radar-stats { display: flex; flex-direction: column; gap: 6px; padding: 12px; background: rgba(13, 17, 23, 0.4); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 10px; } .sglv-radar-stat-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.04); transition: background 0.15s; } .sglv-radar-stat-item:hover { background: rgba(255, 255, 255, 0.05); } .sglv-radar-stat-icon { width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; } .sglv-radar-stat-icon svg { width: 14px; height: 14px; } .sglv-radar-stat-info { flex: 1; min-width: 0; } .sglv-radar-stat-label { font-size: 10px; color: #8b949e; font-weight: 600; } .sglv-radar-stat-bar { height: 4px; background: rgba(255, 255, 255, 0.06); border-radius: 2px; overflow: hidden; margin-top: 3px; } .sglv-radar-stat-bar-fill { height: 100%; border-radius: 2px; transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); } .sglv-radar-stat-val { font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; } /* ====== 10. 仪表盘 KPI 行增强 — hover 微光 ====== */ .sglv-playtime-kpi-card { position: relative; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; } .sglv-playtime-kpi-card:hover { transform: translateY(-1px); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); } .sglv-playtime-kpi-card.amber:hover { box-shadow: 0 3px 10px rgba(245, 158, 11, 0.15); } /* v1.0.3: playtime-kpi 卡片炫彩升级——叠加 accent 渐变背景 + shadow-sm 基线 + hover 抬升 + 外发光 */ .sglv-playtime-kpi-card { overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.04); } .sglv-playtime-kpi-card::before { content: ''; position: absolute; inset: -1px; pointer-events: none; background: linear-gradient(135deg, rgba(102,192,244,.3), transparent 60%); opacity: 0; transition: opacity .18s ease; } .sglv-playtime-kpi-card:hover::before { opacity: 1; } .sglv-playtime-kpi-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.45), 0 0 0 1px rgba(102,192,244,.25), 0 0 22px rgba(102,192,244,.18), inset 0 1px 0 rgba(255,255,255,.06); } .sglv-playtime-kpi-value { text-shadow: 0 0 14px rgba(102,192,244,.25); } .sglv-playtime-kpi-card > * { position: relative; z-index: 1; } /* ====== 11. 仪表盘 KPI 行 — 7 列 grid (含雷达图入口) ====== */ .sglv-stats-dashboard { display: grid !important; grid-template-columns: repeat(7, 1fr) !important; gap: 6px !important; } /* ====== 12. 甜甜圈图中心文字 → 紫色 ====== */ .sglv-pt-donut-center-val { fill: #a78bfa !important; } .sglv-pt-donut-center-label { fill: #8b949e !important; } /* ====== 13. 热力图标题 → 紫色 ====== */ .sglv-heatmap-section .sglv-playtime-section-title { color: #a78bfa !important; } .sglv-hm-mini-title { color: #a78bfa !important; } /* ====== 14. 成就页 — 环形进度图背景 → 紫色 ====== */ .sglv-ach-ring-bg { stroke: rgba(139, 92, 246, 0.1) !important; } /* 注意: gradient 定义在 SVG defs 中,需在 JS 中修改 stop colors */ /* ====== 15. 成就页 — 侧边栏用户卡片紫色调 ====== */ .sglv-ach-user-card { background: linear-gradient(160deg, rgba(139, 92, 246, 0.1), rgba(99, 102, 241, 0.04)) !important; } .sglv-ach-user-card::after { background: radial-gradient(circle, rgba(139, 92, 246, 0.12), transparent 60%) !important; } .sglv-ach-user-avatar { background: linear-gradient(135deg, #8b5cf6, #6366f1) !important; box-shadow: 0 8px 24px rgba(139, 92, 246, 0.35) !important; } .sglv-ach-user-level { border: 2px solid #a78bfa !important; color: #c4b5fd !important; } .sglv-ach-user-pts strong { color: #c4b5fd !important; } /* ====== 16. 成就页 — 分类导航紫色激活 ====== */ .sglv-ach-nav-item:hover { background: rgba(139, 92, 246, 0.08) !important; } .sglv-ach-nav-item.active { background: linear-gradient(90deg, rgba(139, 92, 246, 0.2), transparent) !important; } .sglv-ach-nav-item-icon { background: rgba(139, 92, 246, 0.12) !important; } .sglv-ach-nav-item.active .sglv-ach-nav-item-icon { background: rgba(139, 92, 246, 0.2) !important; } .sglv-ach-nav-item-badge { background: rgba(139, 92, 246, 0.15) !important; color: #c4b5fd !important; } .sglv-ach-nav-item.active .sglv-ach-nav-item-badge { background: #8b5cf6 !important; color: #0d1117 !important; } /* ====== 17. 成就页 — 侧边栏最近解锁紫色调 ====== */ .sglv-ach-sidebar-recent-icon { background: linear-gradient(135deg, #8b5cf6, #6366f1) !important; } .sglv-ach-sidebar-recent-item:hover { background: rgba(139, 92, 246, 0.06) !important; } .sglv-ach-sidebar-recent-name { color: #e2e8f0 !important; } /* ====== 18. 成就页 — 统计概览区紫色调 ====== */ .sglv-ach-header { background: linear-gradient(135deg, rgba(30, 41, 59, 0.6), rgba(15, 23, 42, 0.6)) !important; border-bottom: 1px solid rgba(139, 92, 246, 0.12) !important; gap: 14px !important; padding: 16px 20px !important; } /*环形进度数值使用炫彩渐变,不强制紫色 */ /* ====== 19. 成就页 — 成就卡片紫色调 ====== */ .sglv-ach-card { background: linear-gradient(135deg, rgba(13, 17, 23, 0.6), rgba(30, 41, 59, 0.4)) !important; border: 1px solid rgba(139, 92, 246, 0.08) !important; } .sglv-ach-card:hover { border-color: rgba(139, 92, 246, 0.3) !important; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3) !important; } .sglv-ach-card.unlocked { background: linear-gradient(135deg, rgba(139, 92, 246, 0.05), rgba(13, 17, 23, 0.4)) !important; border-color: rgba(139, 92, 246, 0.15) !important; } .sglv-ach-card.unlocked .sglv-ach-card-name { color: #c4b5fd !important; } .sglv-ach-card.unlocked .sglv-ach-card-pts { background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(99, 102, 241, 0.1)) !important; border-color: rgba(139, 92, 246, 0.3) !important; color: #c4b5fd !important; } .sglv-ach-card.unlocked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #22d3ee, #34d399, #fbbf24) !important; box-shadow: 0 0 8px rgba(34, 211, 238, 0.4) !important; } .sglv-ach-card.locked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #38bdf8, #a78bfa, #f59e0b) !important; box-shadow: 0 0 8px rgba(56, 189, 248, 0.25) !important; } .sglv-ach-card-icon { background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(99, 102, 241, 0.08)) !important; } /* 稀有度色保持不变(legendary 已是金色, common/rare/epic 语义色保留) */ .sglv-ach-card.unlocked .sglv-ach-card-icon { box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), inset 0 -1px 0 rgba(0,0,0,0.2), 0 4px 12px rgba(139, 92, 246, 0.2) !important; } /* ====== 20. 成就页 — 内容标题紫色调 ====== */ .sglv-ach-content-title { color: #a78bfa !important; } .sglv-ach-content-count { background: rgba(139, 92, 246, 0.1) !important; color: #c4b5fd !important; } .sglv-ach-reset-btn { border: 1px solid rgba(239, 68, 68, 0.3) !important; background: rgba(239, 68, 68, 0.08) !important; } /* ====== 21. 个人展柜 — 英雄区紫青色调(恢复原版色彩平衡) ====== */ .sglv-sc-hero { background: linear-gradient(135deg, rgba(99,102,241,0.10) 0%, rgba(168,85,247,0.06) 50%, rgba(6,182,212,0.04) 100%) !important; border: 1px solid rgba(99,102,241,0.15) !important; } .sglv-sc-hero::before { background: radial-gradient(ellipse at top right, rgba(168,85,247,0.10), transparent 60%) !important; } .sglv-sc-avatar { border: 2px solid rgba(255, 255, 255, 0.12) !important; box-shadow: 0 1px 8px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(168, 85, 247, 0.25) !important; } .sglv-sc-avatar-placeholder { background: linear-gradient(135deg, #6366f1, #8b5cf6) !important; } /* 用户名保持白色,不加金色 */ .sglv-sc-hero-name { color: #f1f5f9 !important; } /* 国家标签保持中性灰 */ .sglv-sc-hero-country { background: rgba(148, 163, 184, 0.08) !important; color: #94a3b8 !important; border: 1px solid rgba(148, 163, 184, 0.1) !important; } /* Hero 统计图标 → 恢复靛蓝色 */ .sglv-sc-hero-stat svg { color: #818cf8 !important; } /* Hero 统计数值 → 恢复浅色 */ .sglv-sc-hero-stat-val { color: #e2e8f0 !important; } /* ====== 22. 展柜卡片标题 → 恢复紫色 ====== */ .sglv-sc-card-header svg { color: #a78bfa !important; } .sglv-sc-card-title { color: #e6edf3 !important; } /* ====== 23. 展柜封面网格 → 恢复紫色 hover ====== */ .sglv-sc-cover-item { border: 1px solid rgba(255, 255, 255, 0.06) !important; } .sglv-sc-cover-item:hover { border-color: rgba(168, 85, 247, 0.5) !important; box-shadow: 0 4px 16px rgba(168, 85, 247, 0.2) !important; } /* ====== 24. 展柜最近游玩 → 恢复靛蓝紫色 ====== */ .sglv-sc-playtime-item:hover { background: rgba(99, 102, 241, 0.06) !important; } .sglv-sc-playtime-bar { background: rgba(99, 102, 241, 0.1) !important; } .sglv-sc-playtime-bar-fill { background: linear-gradient(90deg, #6366f1, #a855f7) !important; } /* ====== 25. 展柜标签徽章 → 移除金色覆盖,恢复原版多彩 ====== */ /* 原版已有: collector(紫), playtime(青), mastery(绿), diversity(橙), loyalty(粉), special(金) */ /* ====== 27. Toast 通知 → 紫色 ====== */ #sglv-toast.sglv-toast-success { border-left-color: #8b5cf6 !important; } #sglv-toast.sglv-toast-info { border-left-color: #66c0f4 !important; } /* ====== 28. 页码按钮 → 紫色 hover ====== */ .sglv-page-arrow:hover:not(:disabled) { background: rgba(139, 92, 246, 0.15) !important; border-color: rgba(139, 92, 246, 0.4) !important; } /* ====== 29. 仪表盘图表行卡片容器 → 紫色微边 ====== */ .sglv-playtime-col { background: rgba(13, 17, 23, 0.3); border: 1px solid rgba(139, 92, 246, 0.06); border-radius: 10px; padding: 10px; } .sglv-heatmap-section, .sglv-heatmap-mini-section { background: rgba(13, 17, 23, 0.3) !important; border: 1px solid rgba(139, 92, 246, 0.06) !important; border-radius: 10px !important; } /* ====== 30. SGIS 侧边栏标签 → 恢复紫色激活态 ====== */ .sgis-tab.active .sgis-svg { filter: drop-shadow(0 1px 4px rgba(139, 92, 246, 0.45)) !important; } .sgis-tab.active { border-bottom-color: #8b5cf6 !important; } .sgis-tabs::after { background: linear-gradient(90deg, transparent, rgba(139, 92, 246, 0.35), transparent) !important; } /* ====== 31. SGIS 标题图标 → 恢复紫蓝渐变 ====== */ .sgis-title-icon { background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 50%, #3b82f6 100%) !important; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 -1px 0 rgba(0, 0, 0, 0.2) !important; } .sgis-fab { background: linear-gradient(135deg, #8b5cf6, #6366f1) !important; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4) !important; } /* ====== 32. 加载进度条 → 恢复青蓝紫渐变 ====== */ .sgis-load-bar-fill { background: linear-gradient(90deg, #06b6d4, #3b82f6, #8b5cf6) !important; box-shadow: 0 0 10px rgba(59, 130, 246, 0.5) !important; } .sgis-load-stage-bar.active { background: linear-gradient(90deg, #06b6d4, #3b82f6) !important; box-shadow: 0 0 6px rgba(59, 130, 246, 0.6) !important; } .sgis-skel-icon { background: linear-gradient(90deg, rgba(139, 92, 246, 0.08), rgba(139, 92, 246, 0.18), rgba(139, 92, 246, 0.08)) !important; background-size: 200% 100% !important; } /* ====== 34. 云存档 KPI 卡片 → 紫色调 ====== */ .sglv-cs-source-badge { background: rgba(139, 92, 246, 0.12) !important; color: #c4b5fd !important; border: 1px solid rgba(139, 92, 246, 0.2) !important; } .sglv-cs-refresh-btn:hover { color: #a78bfa !important; background: rgba(139, 92, 246, 0.12) !important; } .sglv-cs-top10-bar { background: linear-gradient(90deg, #6366f1, #a855f7) !important; } /* ====== 35. 集合工具栏刷新按钮 → 紫色 hover ====== */ .sglv-col-refresh-btn:hover { color: #a78bfa !important; background: rgba(139, 92, 246, 0.1) !important; } /* ====== 36. 移动端适配 ====== */ @media (max-width: 768px) { .sglv-radar-row { grid-template-columns: 1fr; } .sglv-radar-svg { width: 180px; height: 180px; } } @media (max-width: 480px) { .sglv-radar-svg { width: 160px; height: 160px; } .sglv-radar-card { padding: 8px; } } /* ====== SGIS Hero — 紫青平衡 + 紧凑布局 + 保留背景图 ====== */ /* 主卡片:恢复紫色渐变基调,紧凑 padding 保留 */ .sgis-profile-main-card { background: linear-gradient(160deg, #1a1633 0%, #16142b 40%, #0f0d20 100%) !important; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(139, 92, 246, 0.15) !important; padding: 14px 14px 12px !important; border-radius: 16px !important; } /* 右上角径向光晕 → 紫色 */ .sgis-profile-main-card::before { background: radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, transparent 60%) !important; } /* 底部高光线 → 紫色 */ .sgis-profile-main-card::after { background: linear-gradient(90deg, transparent 0%, rgba(139, 92, 246, 0.6) 50%, transparent 100%) !important; } /* 背景装饰图 → 恢复原始 game-library.png + 紫色滤镜 + mask */ .sgis-profile-main-card-bg { background: url('https://leanisssharedstorage.blob.core.windows.net/copilot/asdm-files/game-library.png') no-repeat center center !important; background-size: contain !important; opacity: 0.18 !important; filter: saturate(1.2) drop-shadow(0 0 20px rgba(139, 92, 246, 0.25)) !important; mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,1) 100%) !important; -webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,1) 100%) !important; } /* Hero 头部行 → 紧凑(保留) */ .sgis-profile-head { gap: 12px !important; margin-bottom: 12px !important; } /* 头像 → 64px + 青色边框(原版色调) */ .sgis-profile-avatar { width: 64px !important; height: 64px !important; border-radius: 18px !important; border: 2px solid rgba(102, 192, 244, 0.5) !important; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4), 0 0 0 3px rgba(102, 192, 244, 0.08) !important; } /* 在线状态徽章 → 恢复紫蓝渐变 */ .sgis-profile-avatar-badge { background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 50%, #3b82f6 100%) !important; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.5), 0 0 0 2px #16142b !important; } /* 等级徽章 → 保持金色(原版即为金色,作为 premium accent) */ .sgis-profile-level-badge { background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%) !important; box-shadow: 0 2px 6px rgba(249, 115, 22, 0.4) !important; } /* 用户名 → 白色,不加金色 text-shadow */ .sgis-profile-name { text-shadow: none !important; } /* SteamID → 恢复灰蓝色 */ .sgis-profile-steamid { color: rgba(148, 163, 184, 0.9) !important; } /* 验证徽章 → 恢复紫蓝渐变 */ .sgis-profile-verified { background: linear-gradient(135deg, #8b5cf6, #3b82f6) !important; box-shadow: 0 2px 6px rgba(139, 92, 246, 0.4) !important; } /* 好友数进度环 → 恢复绿色 */ .sgis-friend-gauge-text b { color: #10b981 !important; } .sgis-friend-gauge:hover { background: rgba(255, 255, 255, 0.05) !important; } /* ====== 38. SGIS KPI 统计卡片 — 恢复多彩语义色 ====== */ /* 默认状态:恢复中性背景,移除大面积金色 */ .sgis-profile-stat { background: linear-gradient(160deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.01) 100%) !important; border: 1px solid rgba(255, 255, 255, 0.06) !important; border-radius: 12px !important; transition: all 0.2s ease !important; } /* 顶部高光线 → 恢复 currentColor 语义色 */ .sgis-profile-stat::after { opacity: 0.35 !important; } /* hover → 紫色光晕 + 上浮 */ .sgis-profile-stat:hover { border-color: rgba(139, 92, 246, 0.3) !important; background: linear-gradient(160deg, rgba(139, 92, 246, 0.08) 0%, rgba(59, 130, 246, 0.03) 100%) !important; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3) !important; transform: translateY(-2px) !important; } /* 默认图标背景 → 恢复紫蓝渐变 */ .sgis-profile-stat-icon { background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(59, 130, 246, 0.08)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04) !important; } .sgis-profile-stat-icon svg { color: #c4b5fd !important; } /* t-blue / t-purple / t-cyan / t-green / t-rose → 全部恢复原版语义色 */ .sgis-profile-stat-icon.t-blue { background: linear-gradient(135deg, rgba(59, 130, 246, 0.22), rgba(37, 99, 235, 0.08)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04) !important; } .sgis-profile-stat-icon.t-blue svg { color: #93c5fd !important; } .sgis-profile-stat-icon.t-purple { background: linear-gradient(135deg, rgba(168, 85, 247, 0.22), rgba(139, 92, 246, 0.08)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04) !important; } .sgis-profile-stat-icon.t-purple svg { color: #c4b5fd !important; } .sgis-profile-stat-val.t-purple { color: #c4b5fd !important; } /* KPI 值文字 → 移除金色光泽 */ .sgis-profile-stat-val { text-shadow: none !important; } /* 5列模式 KPI 紧凑(保留布局优化) */ .sgis-profile-stats.cols-5 { gap: 6px !important; } .sgis-profile-stats.cols-5 .sgis-profile-stat { padding: 10px 4px 8px !important; border-radius: 10px !important; } /* ====== 39. SGIS 区块标题 — 恢复紫色 ====== */ .sgis-section-title { color: #a78bfa !important; border-bottom: 1px solid rgba(139, 92, 246, 0.08) !important; } .sgis-section-title svg { color: #a78bfa !important; } /* ====== 40. SGIS 战绩/游戏人生 双大卡 — 恢复紫蓝色调 ====== */ .sgis-feature-card { background: linear-gradient(160deg, rgba(139, 92, 246, 0.12) 0%, rgba(59, 130, 246, 0.04) 50%, rgba(15, 17, 35, 0.6) 100%) !important; border: 1px solid rgba(139, 92, 246, 0.2) !important; transition: all 0.25s ease !important; } .sgis-feature-card::before { background: radial-gradient(circle, rgba(139, 92, 246, 0.2) 0%, transparent 60%) !important; } .sgis-feature-card::after { background: linear-gradient(90deg, transparent 0%, rgba(139, 92, 246, 0.5) 50%, transparent 100%) !important; } .sgis-feature-card:hover { border-color: rgba(139, 92, 246, 0.4) !important; box-shadow: 0 8px 20px rgba(139, 92, 246, 0.2) !important; transform: translateY(-2px) !important; } /* alt 变体 → 恢复靛蓝色 */ .sgis-feature-card.alt { background: linear-gradient(160deg, rgba(99, 102, 241, 0.12) 0%, rgba(59, 130, 246, 0.04) 50%, rgba(15, 17, 35, 0.6) 100%) !important; border-color: rgba(99, 102, 241, 0.2) !important; } .sgis-feature-card.alt::before { background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 60%) !important; } .sgis-feature-card.alt::after { background: linear-gradient(90deg, transparent 0%, rgba(99, 102, 241, 0.5) 50%, transparent 100%) !important; } /* 特征卡片图标 → 恢复紫蓝色 */ .sgis-feature-icon { background: linear-gradient(135deg, rgba(139, 92, 246, 0.22), rgba(59, 130, 246, 0.12)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 6px rgba(139, 92, 246, 0.15) !important; } .sgis-feature-icon svg { color: #c4b5fd !important; } .sgis-feature-card.alt .sgis-feature-icon { background: linear-gradient(135deg, rgba(99, 102, 241, 0.22), rgba(59, 130, 246, 0.12)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 6px rgba(99, 102, 241, 0.15) !important; } .sgis-feature-card.alt .sgis-feature-icon svg { color: #a5b4fc !important; } /* hover 箭头 → 紫色 */ .sgis-feature-card:hover .sgis-feature-arrow { color: #c4b5fd !important; } /* 特征卡片数值 → 恢复紫色 */ .sgis-feature-stat-val.t-purple { color: #c4b5fd !important; } /* ====== 40b. SGIS 双大卡紧凑优化 — 3列统计行 + 压缩高度 ====== */ .sis-feature-grid, .sgis-feature-grid { gap: 8px !important; margin-bottom: 8px !important; } .sgis-feature-card { padding: 10px 10px 8px !important; } .sgis-feature-head { gap: 8px !important; margin-bottom: 8px !important; } .sgis-feature-icon { width: 30px !important; height: 30px !important; border-radius: 8px !important; } .sgis-feature-icon svg { width: 15px !important; height: 15px !important; } .sgis-feature-title { font-size: 13px !important; } .sgis-feature-sub { font-size: 9px !important; margin-top: 1px !important; } .sgis-feature-arrow { width: 14px !important; height: 14px !important; } /* 3列统计行 — 战绩总览 */ .sgis-feature-stats-3 { grid-template-columns: repeat(3, 1fr) !important; gap: 4px !important; } .sgis-feature-stats-3 .sgis-feature-stat { padding: 5px 4px !important; } .sgis-feature-stats-3 .sgis-feature-stat-lbl { font-size: 8px !important; margin-bottom: 1px !important; } .sgis-feature-stats-3 .sgis-feature-stat-val { font-size: 13px !important; } /* 游戏人生卡片紧凑 */ .sgis-feature-card.alt .sgis-feature-stats { gap: 4px !important; } .sgis-feature-card.alt .sgis-feature-stat { padding: 5px 4px !important; } .sgis-feature-card.alt .sgis-feature-stat-lbl { font-size: 8px !important; margin-bottom: 1px !important; } .sgis-feature-card.alt .sgis-feature-stat-val { font-size: 13px !important; } .sgis-feature-extra { margin-top: 6px !important; padding: 5px 8px !important; font-size: 9px !important; } .sgis-feature-extra svg { width: 11px !important; height: 11px !important; } /* ====== 41. SGIS XP 卡片 — 保持金色进度条(原版设计),恢复中性背景 ====== */ .sgis-xp-card { border: 1px solid rgba(255, 255, 255, 0.06) !important; background: linear-gradient(160deg, rgba(255, 255, 255, 0.035) 0%, rgba(15, 17, 35, 0.5) 100%) !important; } /* XP 进度条和图标保持原版金色系,不做覆盖 */ /* XP 等级 next → 恢复紫色 */ .sgis-xp-levels .next { color: #c4b5fd !important; } /* XP 累计数值 → 恢复紫色 */ .sgis-xp-total b { color: #c4b5fd !important; } /* ====== 42. SGIS 社交标题 ====== */ .sgis-section-title.sgis-social-title { border-bottom-color: rgba(139, 92, 246, 0.08) !important; } /* ====== 43. SGIS copy 按钮 hover ====== */ .sgis-copy-btn:hover { background: rgba(255, 255, 255, 0.1) !important; color: #fff !important; }`; //经典蓝主题层——由 scripts/gen-sglv-theme-blue.js 生成(典雅紫层色值镜像映射 // + 主脚本 CSS 常量/共享库中原生紫色规则的换色覆盖),请勿手改,重新运行生成器即可再生。 const CSS_THEME_BLUE =` /* ===================================================================== *经典蓝主题层 —— scripts/gen-sglv-theme-blue.js 自动生成,勿手改(重跑再生) * Part A: 典雅紫主题层(CSS_THEME_PURPLE)色值镜像映射 —— 覆盖面板/侧边栏全部可见表面 * Part B: 主脚本 CSS 常量原生紫色规则 → 蓝色瘦身覆盖(仅换色声明 + !important) * Part C: 共享样式库(sglv-shared-css)原生紫色规则 → 蓝色瘦身覆盖 * Part D: 侧边栏手工精调(靛蓝暗基底→Steam 深蓝/标题栏柔化,自动映射盲区) * Part E: 收藏库(sglv-game-collection)面板 CSS 紫色覆盖(html[data-sglv-theme] 前缀, * 特异性压过其懒注入且带 220 处 !important 的 <style>,顺序无关) * 映射: #a78bfa→#66c0f4 #c4b5fd→#a3d5f7 #8b5cf6→#1a9fff #a855f7→#3b82f6 * #6366f1→#2563eb #818cf8→#60a5fa + 深紫背景系;语义色(金/绿/红/玫/青)保持 * 例外: .sglv-set-theme-swatch 主题预览色板固定不随主题切换 * ===================================================================== */ /* ########## Part A: 紫色主题层镜像(共 663 行) ########## */ /* ====== 1. 全局 CSS 变量覆盖 → 紫色主调 ====== */ :root { --sglv-accent: #66c0f4 !important; --sglv-accent-light: #a3d5f7 !important; --sglv-accent-deep: #1a9fff !important; --sglv-accent-glow: rgba(102, 192, 244, 0.4) !important; --sglv-accent-bg: rgba(102, 192, 244, 0.12) !important; --sglv-accent-border: rgba(102, 192, 244, 0.3) !important; } /* ====== 2. 面板头部 → 紫色调 ====== */ .sglv-panel > .sglv-header { background: linear-gradient(135deg, rgba(27, 40, 56, 0.95) 0%, rgba(42, 71, 94, 0.95) 100%) !important; border-bottom: 1px solid rgba(26,159,255, 0.2) !important; } .sglv-header-brand { background: linear-gradient(135deg, rgba(26,159,255, 0.18), rgba(37,99,235, 0.12)) !important; border: 1px solid rgba(26,159,255, 0.25) !important; color: #66c0f4 !important; } .sglv-header-brand:hover { box-shadow: 0 2px 8px rgba(26,159,255, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important; } /* ====== 3. Tab 激活态 → 紫色 ====== */ .sglv-panel .sglv-tab.active { color: #fff !important; background: rgba(26,159,255, 0.18) !important; border-color: rgba(26,159,255, 0.4) !important; box-shadow: 0 0 0 1px rgba(26,159,255, 0.15) inset, 0 1px 4px rgba(26,159,255, 0.2) !important; } .sglv-panel .sglv-tab:hover { background: rgba(26,159,255, 0.08) !important; } /* ====== 4. 全局搜索框聚焦 → 紫色 ====== */ .sglv-panel .sglv-global-search:focus-within { border-color: rgba(26,159,255, 0.55) !important; box-shadow: 0 0 0 1px rgba(26,159,255, 0.25) !important; } /* ====== 5. KPI 卡片 — 恢复紫色变体 ====== */ /* amber 变体保持金色(原版设计) */ .sglv-playtime-kpi-card.amber { border-color: rgba(245, 158, 11, 0.35) !important; } .sglv-playtime-kpi-card.amber::before { background: #f59e0b !important; } .sglv-playtime-kpi-card.amber .sglv-playtime-kpi-value { color: #fbbf24 !important; } .sglv-playtime-kpi-card.amber .sglv-playtime-kpi-icon { background: rgba(245, 158, 11, 0.15) !important; color: #fbbf24 !important; } /* purple 变体 → 恢复紫色 */ .sglv-playtime-kpi-card.purple { border-color: rgba(26,159,255, 0.35) !important; } .sglv-playtime-kpi-card.purple::before { background: #1a9fff !important; } .sglv-playtime-kpi-card.purple .sglv-playtime-kpi-value { color: #a3d5f7 !important; } .sglv-playtime-kpi-card.purple .sglv-playtime-kpi-icon { background: rgba(26,159,255, 0.15) !important; color: #a3d5f7 !important; } /* blue/cyan/green/rose 语义色保持不变 */ /* ====== 6. 统计仪表 — 区块标题 → 紫色 ====== */ .sglv-playtime-section-title { color: #66c0f4 !important; border-bottom: 1px solid rgba(26,159,255, 0.12) !important; } .sglv-playtime-section-title svg { color: #66c0f4 !important; } /* ====== 7. 趋势筛选按钮 → 紫色激活 ====== */ .sglv-trend-btn.active { background: rgba(26,159,255, 0.18) !important; color: #a3d5f7 !important; border-color: rgba(26,159,255, 0.3) !important; } .sglv-trend-btn:hover { background: rgba(26,159,255, 0.1) !important; } /* ====== 8. 进度条 → 青蓝紫渐变 ====== */ #sglv-stage-progress .sglv-stage-progress-fill { background: linear-gradient(90deg, #06b6d4 0%, #3b82f6 50%, #1a9fff 100%) !important; } #sglv-stage-progress { background: linear-gradient(180deg, rgba(26,159,255, 0.06), transparent) !important; border-bottom: 1px solid rgba(26,159,255, 0.12) !important; } /* ====== 9. 仪表盘 — 新增雷达图区块 ====== */ .sglv-radar-row { display: grid; grid-template-columns: 280px 1fr; gap: 10px; flex: 0 0 auto; min-height: 0; } @media (max-width: 900px) { .sglv-radar-row { grid-template-columns: 1fr; } } .sglv-radar-card { background: linear-gradient(135deg, rgba(34, 211, 238, 0.05), rgba(26,159,255, 0.04), rgba(244, 114, 182, 0.04), rgba(13, 17, 23, 0.4)); border: 1px solid rgba(26,159,255, 0.12); border-radius: 10px; padding: 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; } .sglv-radar-card-title { font-size: 12px; font-weight: 700; background: linear-gradient(90deg, #22d3ee, #34d399, #66c0f4, #f472b6, #f59e0b); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; display: flex; align-items: center; gap: 6px; align-self: flex-start; } .sglv-radar-card-title svg { width: 14px; height: 14px; color: #66c0f4; } .sglv-radar-svg { width: 220px; height: 220px; } .sglv-radar-svg .radar-grid { fill: none; stroke: rgba(255, 255, 255, 0.08); stroke-width: 1; } .sglv-radar-svg .radar-axis { stroke-width: 1; } .sglv-radar-svg .radar-area { stroke-width: 2; stroke-linejoin: round; transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); } .sglv-radar-svg .radar-point { stroke: #0d1117; stroke-width: 2; transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); } .sglv-radar-svg .radar-label { font-size: 10px; font-weight: 700; text-anchor: middle; } .sglv-radar-svg .radar-value { font-size: 11px; font-weight: 800; text-anchor: middle; } /* 雷达图右侧统计面板 */ .sglv-radar-stats { display: flex; flex-direction: column; gap: 6px; padding: 12px; background: rgba(13, 17, 23, 0.4); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 10px; } .sglv-radar-stat-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.04); transition: background 0.15s; } .sglv-radar-stat-item:hover { background: rgba(255, 255, 255, 0.05); } .sglv-radar-stat-icon { width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; } .sglv-radar-stat-icon svg { width: 14px; height: 14px; } .sglv-radar-stat-info { flex: 1; min-width: 0; } .sglv-radar-stat-label { font-size: 10px; color: #8b949e; font-weight: 600; } .sglv-radar-stat-bar { height: 4px; background: rgba(255, 255, 255, 0.06); border-radius: 2px; overflow: hidden; margin-top: 3px; } .sglv-radar-stat-bar-fill { height: 100%; border-radius: 2px; transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); } .sglv-radar-stat-val { font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; } /* ====== 10. 仪表盘 KPI 行增强 — hover 微光 ====== */ .sglv-playtime-kpi-card { position: relative; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; } .sglv-playtime-kpi-card:hover { transform: translateY(-1px); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); } .sglv-playtime-kpi-card.amber:hover { box-shadow: 0 3px 10px rgba(245, 158, 11, 0.15); } /* v1.0.3: playtime-kpi 卡片炫彩升级——叠加 accent 渐变背景 + shadow-sm 基线 + hover 抬升 + 外发光 */ .sglv-playtime-kpi-card { overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.04); } .sglv-playtime-kpi-card::before { content: ''; position: absolute; inset: -1px; pointer-events: none; background: linear-gradient(135deg, rgba(102,192,244,.3), transparent 60%); opacity: 0; transition: opacity .18s ease; } .sglv-playtime-kpi-card:hover::before { opacity: 1; } .sglv-playtime-kpi-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.45), 0 0 0 1px rgba(102,192,244,.25), 0 0 22px rgba(102,192,244,.18), inset 0 1px 0 rgba(255,255,255,.06); } .sglv-playtime-kpi-value { text-shadow: 0 0 14px rgba(102,192,244,.25); } .sglv-playtime-kpi-card > * { position: relative; z-index: 1; } /* ====== 11. 仪表盘 KPI 行 — 7 列 grid (含雷达图入口) ====== */ .sglv-stats-dashboard { display: grid !important; grid-template-columns: repeat(7, 1fr) !important; gap: 6px !important; } /* ====== 12. 甜甜圈图中心文字 → 紫色 ====== */ .sglv-pt-donut-center-val { fill: #66c0f4 !important; } .sglv-pt-donut-center-label { fill: #8b949e !important; } /* ====== 13. 热力图标题 → 紫色 ====== */ .sglv-heatmap-section .sglv-playtime-section-title { color: #66c0f4 !important; } .sglv-hm-mini-title { color: #66c0f4 !important; } /* ====== 14. 成就页 — 环形进度图背景 → 紫色 ====== */ .sglv-ach-ring-bg { stroke: rgba(26,159,255, 0.1) !important; } /* 注意: gradient 定义在 SVG defs 中,需在 JS 中修改 stop colors */ /* ====== 15. 成就页 — 侧边栏用户卡片紫色调 ====== */ .sglv-ach-user-card { background: linear-gradient(160deg, rgba(26,159,255, 0.1), rgba(37,99,235, 0.04)) !important; } .sglv-ach-user-card::after { background: radial-gradient(circle, rgba(26,159,255, 0.12), transparent 60%) !important; } .sglv-ach-user-avatar { background: linear-gradient(135deg, #1a9fff, #2563eb) !important; box-shadow: 0 8px 24px rgba(26,159,255, 0.35) !important; } .sglv-ach-user-level { border: 2px solid #66c0f4 !important; color: #a3d5f7 !important; } .sglv-ach-user-pts strong { color: #a3d5f7 !important; } /* ====== 16. 成就页 — 分类导航紫色激活 ====== */ .sglv-ach-nav-item:hover { background: rgba(26,159,255, 0.08) !important; } .sglv-ach-nav-item.active { background: linear-gradient(90deg, rgba(26,159,255, 0.2), transparent) !important; } .sglv-ach-nav-item-icon { background: rgba(26,159,255, 0.12) !important; } .sglv-ach-nav-item.active .sglv-ach-nav-item-icon { background: rgba(26,159,255, 0.2) !important; } .sglv-ach-nav-item-badge { background: rgba(26,159,255, 0.15) !important; color: #a3d5f7 !important; } .sglv-ach-nav-item.active .sglv-ach-nav-item-badge { background: #1a9fff !important; color: #0d1117 !important; } /* ====== 17. 成就页 — 侧边栏最近解锁紫色调 ====== */ .sglv-ach-sidebar-recent-icon { background: linear-gradient(135deg, #1a9fff, #2563eb) !important; } .sglv-ach-sidebar-recent-item:hover { background: rgba(26,159,255, 0.06) !important; } .sglv-ach-sidebar-recent-name { color: #e2e8f0 !important; } /* ====== 18. 成就页 — 统计概览区紫色调 ====== */ .sglv-ach-header { background: linear-gradient(135deg, rgba(30, 41, 59, 0.6), rgba(15, 23, 42, 0.6)) !important; border-bottom: 1px solid rgba(26,159,255, 0.12) !important; gap: 14px !important; padding: 16px 20px !important; } /*环形进度数值使用炫彩渐变,不强制紫色 */ /* ====== 19. 成就页 — 成就卡片紫色调 ====== */ .sglv-ach-card { background: linear-gradient(135deg, rgba(13, 17, 23, 0.6), rgba(30, 41, 59, 0.4)) !important; border: 1px solid rgba(26,159,255, 0.08) !important; } .sglv-ach-card:hover { border-color: rgba(26,159,255, 0.3) !important; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3) !important; } .sglv-ach-card.unlocked { background: linear-gradient(135deg, rgba(26,159,255, 0.05), rgba(13, 17, 23, 0.4)) !important; border-color: rgba(26,159,255, 0.15) !important; } .sglv-ach-card.unlocked .sglv-ach-card-name { color: #a3d5f7 !important; } .sglv-ach-card.unlocked .sglv-ach-card-pts { background: linear-gradient(135deg, rgba(26,159,255, 0.15), rgba(37,99,235, 0.1)) !important; border-color: rgba(26,159,255, 0.3) !important; color: #a3d5f7 !important; } .sglv-ach-card.unlocked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #22d3ee, #34d399, #fbbf24) !important; box-shadow: 0 0 8px rgba(34, 211, 238, 0.4) !important; } .sglv-ach-card.locked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #38bdf8, #66c0f4, #f59e0b) !important; box-shadow: 0 0 8px rgba(56, 189, 248, 0.25) !important; } .sglv-ach-card-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.15), rgba(37,99,235, 0.08)) !important; } /* 稀有度色保持不变(legendary 已是金色, common/rare/epic 语义色保留) */ .sglv-ach-card.unlocked .sglv-ach-card-icon { box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), inset 0 -1px 0 rgba(0,0,0,0.2), 0 4px 12px rgba(26,159,255, 0.2) !important; } /* ====== 20. 成就页 — 内容标题紫色调 ====== */ .sglv-ach-content-title { color: #66c0f4 !important; } .sglv-ach-content-count { background: rgba(26,159,255, 0.1) !important; color: #a3d5f7 !important; } .sglv-ach-reset-btn { border: 1px solid rgba(239, 68, 68, 0.3) !important; background: rgba(239, 68, 68, 0.08) !important; } /* ====== 21. 个人展柜 — 英雄区紫青色调(恢复原版色彩平衡) ====== */ .sglv-sc-hero { background: linear-gradient(135deg, rgba(37,99,235, 0.10) 0%, rgba(59,130,246, 0.06) 50%, rgba(6,182,212,0.04) 100%) !important; border: 1px solid rgba(37,99,235, 0.15) !important; } .sglv-sc-hero::before { background: radial-gradient(ellipse at top right, rgba(59,130,246, 0.10), transparent 60%) !important; } .sglv-sc-avatar { border: 2px solid rgba(255, 255, 255, 0.12) !important; box-shadow: 0 1px 8px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(59,130,246, 0.25) !important; } .sglv-sc-avatar-placeholder { background: linear-gradient(135deg, #2563eb, #1a9fff) !important; } /* 用户名保持白色,不加金色 */ .sglv-sc-hero-name { color: #f1f5f9 !important; } /* 国家标签保持中性灰 */ .sglv-sc-hero-country { background: rgba(148, 163, 184, 0.08) !important; color: #94a3b8 !important; border: 1px solid rgba(148, 163, 184, 0.1) !important; } /* Hero 统计图标 → 恢复靛蓝色 */ .sglv-sc-hero-stat svg { color: #60a5fa !important; } /* Hero 统计数值 → 恢复浅色 */ .sglv-sc-hero-stat-val { color: #e2e8f0 !important; } /* ====== 22. 展柜卡片标题 → 恢复紫色 ====== */ .sglv-sc-card-header svg { color: #66c0f4 !important; } .sglv-sc-card-title { color: #e6edf3 !important; } /* ====== 23. 展柜封面网格 → 恢复紫色 hover ====== */ .sglv-sc-cover-item { border: 1px solid rgba(255, 255, 255, 0.06) !important; } .sglv-sc-cover-item:hover { border-color: rgba(59,130,246, 0.5) !important; box-shadow: 0 4px 16px rgba(59,130,246, 0.2) !important; } /* ====== 24. 展柜最近游玩 → 恢复靛蓝紫色 ====== */ .sglv-sc-playtime-item:hover { background: rgba(37,99,235, 0.06) !important; } .sglv-sc-playtime-bar { background: rgba(37,99,235, 0.1) !important; } .sglv-sc-playtime-bar-fill { background: linear-gradient(90deg, #2563eb, #3b82f6) !important; } /* ====== 25. 展柜标签徽章 → 移除金色覆盖,恢复原版多彩 ====== */ /* 原版已有: collector(紫), playtime(青), mastery(绿), diversity(橙), loyalty(粉), special(金) */ /* ====== 27. Toast 通知 → 紫色 ====== */ #sglv-toast.sglv-toast-success { border-left-color: #1a9fff !important; } #sglv-toast.sglv-toast-info { border-left-color: #66c0f4 !important; } /* ====== 28. 页码按钮 → 紫色 hover ====== */ .sglv-page-arrow:hover:not(:disabled) { background: rgba(26,159,255, 0.15) !important; border-color: rgba(26,159,255, 0.4) !important; } /* ====== 29. 仪表盘图表行卡片容器 → 紫色微边 ====== */ .sglv-playtime-col { background: rgba(13, 17, 23, 0.3); border: 1px solid rgba(26,159,255, 0.06); border-radius: 10px; padding: 10px; } .sglv-heatmap-section, .sglv-heatmap-mini-section { background: rgba(13, 17, 23, 0.3) !important; border: 1px solid rgba(26,159,255, 0.06) !important; border-radius: 10px !important; } /* ====== 30. SGIS 侧边栏标签 → 恢复紫色激活态 ====== */ .sgis-tab.active .sgis-svg { filter: drop-shadow(0 1px 4px rgba(26,159,255, 0.45)) !important; } .sgis-tab.active { border-bottom-color: #1a9fff !important; } .sgis-tabs::after { background: linear-gradient(90deg, transparent, rgba(26,159,255, 0.35), transparent) !important; } /* ====== 31. SGIS 标题图标 → 恢复紫蓝渐变 ====== */ .sgis-title-icon { background: linear-gradient(135deg, #1a9fff 0%, #2563eb 50%, #3b82f6 100%) !important; box-shadow: 0 4px 12px rgba(26,159,255, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 -1px 0 rgba(0, 0, 0, 0.2) !important; } .sgis-fab { background: linear-gradient(135deg, #1a9fff, #2563eb) !important; box-shadow: 0 4px 12px rgba(26,159,255, 0.4) !important; } /* ====== 32. 加载进度条 → 恢复青蓝紫渐变 ====== */ .sgis-load-bar-fill { background: linear-gradient(90deg, #06b6d4, #3b82f6, #1a9fff) !important; box-shadow: 0 0 10px rgba(59, 130, 246, 0.5) !important; } .sgis-load-stage-bar.active { background: linear-gradient(90deg, #06b6d4, #3b82f6) !important; box-shadow: 0 0 6px rgba(59, 130, 246, 0.6) !important; } .sgis-skel-icon { background: linear-gradient(90deg, rgba(26,159,255, 0.08), rgba(26,159,255, 0.18), rgba(26,159,255, 0.08)) !important; background-size: 200% 100% !important; } /* ====== 34. 云存档 KPI 卡片 → 紫色调 ====== */ .sglv-cs-source-badge { background: rgba(26,159,255, 0.12) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.2) !important; } .sglv-cs-refresh-btn:hover { color: #66c0f4 !important; background: rgba(26,159,255, 0.12) !important; } .sglv-cs-top10-bar { background: linear-gradient(90deg, #2563eb, #3b82f6) !important; } /* ====== 35. 集合工具栏刷新按钮 → 紫色 hover ====== */ .sglv-col-refresh-btn:hover { color: #66c0f4 !important; background: rgba(26,159,255, 0.1) !important; } /* ====== 36. 移动端适配 ====== */ @media (max-width: 768px) { .sglv-radar-row { grid-template-columns: 1fr; } .sglv-radar-svg { width: 180px; height: 180px; } } @media (max-width: 480px) { .sglv-radar-svg { width: 160px; height: 160px; } .sglv-radar-card { padding: 8px; } } /* ====== SGIS Hero — 紫青平衡 + 紧凑布局 + 保留背景图 ====== */ /* 主卡片:恢复紫色渐变基调,紧凑 padding 保留 */ .sgis-profile-main-card { background: linear-gradient(160deg, #16202d 0%, #121a25 40%, #0d131c 100%) !important; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(26,159,255, 0.15) !important; padding: 14px 14px 12px !important; border-radius: 16px !important; } /* 右上角径向光晕 → 紫色 */ .sgis-profile-main-card::before { background: radial-gradient(circle, rgba(26,159,255, 0.4) 0%, transparent 60%) !important; } /* 底部高光线 → 紫色 */ .sgis-profile-main-card::after { background: linear-gradient(90deg, transparent 0%, rgba(26,159,255, 0.6) 50%, transparent 100%) !important; } /* 背景装饰图 → 恢复原始 game-library.png + 紫色滤镜 + mask */ .sgis-profile-main-card-bg { background: url('https://leanisssharedstorage.blob.core.windows.net/copilot/asdm-files/game-library.png') no-repeat center center !important; background-size: contain !important; opacity: 0.18 !important; filter: saturate(1.2) drop-shadow(0 0 20px rgba(26,159,255, 0.25)) !important; mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,1) 100%) !important; -webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,1) 100%) !important; } /* Hero 头部行 → 紧凑(保留) */ .sgis-profile-head { gap: 12px !important; margin-bottom: 12px !important; } /* 头像 → 64px + 青色边框(原版色调) */ .sgis-profile-avatar { width: 64px !important; height: 64px !important; border-radius: 18px !important; border: 2px solid rgba(102, 192, 244, 0.5) !important; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4), 0 0 0 3px rgba(102, 192, 244, 0.08) !important; } /* 在线状态徽章 → 恢复紫蓝渐变 */ .sgis-profile-avatar-badge { background: linear-gradient(135deg, #1a9fff 0%, #2563eb 50%, #3b82f6 100%) !important; box-shadow: 0 4px 12px rgba(26,159,255, 0.5), 0 0 0 2px #121a25 !important; } /* 等级徽章 → 保持金色(原版即为金色,作为 premium accent) */ .sgis-profile-level-badge { background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%) !important; box-shadow: 0 2px 6px rgba(249, 115, 22, 0.4) !important; } /* 用户名 → 白色,不加金色 text-shadow */ .sgis-profile-name { text-shadow: none !important; } /* SteamID → 恢复灰蓝色 */ .sgis-profile-steamid { color: rgba(148, 163, 184, 0.9) !important; } /* 验证徽章 → 恢复紫蓝渐变 */ .sgis-profile-verified { background: linear-gradient(135deg, #1a9fff, #3b82f6) !important; box-shadow: 0 2px 6px rgba(26,159,255, 0.4) !important; } /* 好友数进度环 → 恢复绿色 */ .sgis-friend-gauge-text b { color: #10b981 !important; } .sgis-friend-gauge:hover { background: rgba(255, 255, 255, 0.05) !important; } /* ====== 38. SGIS KPI 统计卡片 — 恢复多彩语义色 ====== */ /* 默认状态:恢复中性背景,移除大面积金色 */ .sgis-profile-stat { background: linear-gradient(160deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.01) 100%) !important; border: 1px solid rgba(255, 255, 255, 0.06) !important; border-radius: 12px !important; transition: all 0.2s ease !important; } /* 顶部高光线 → 恢复 currentColor 语义色 */ .sgis-profile-stat::after { opacity: 0.35 !important; } /* hover → 紫色光晕 + 上浮 */ .sgis-profile-stat:hover { border-color: rgba(26,159,255, 0.3) !important; background: linear-gradient(160deg, rgba(26,159,255, 0.08) 0%, rgba(59, 130, 246, 0.03) 100%) !important; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3) !important; transform: translateY(-2px) !important; } /* 默认图标背景 → 恢复紫蓝渐变 */ .sgis-profile-stat-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.2), rgba(59, 130, 246, 0.08)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04) !important; } .sgis-profile-stat-icon svg { color: #a3d5f7 !important; } /* t-blue / t-purple / t-cyan / t-green / t-rose → 全部恢复原版语义色 */ .sgis-profile-stat-icon.t-blue { background: linear-gradient(135deg, rgba(59, 130, 246, 0.22), rgba(37, 99, 235, 0.08)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04) !important; } .sgis-profile-stat-icon.t-blue svg { color: #93c5fd !important; } .sgis-profile-stat-icon.t-purple { background: linear-gradient(135deg, rgba(59,130,246, 0.22), rgba(26,159,255, 0.08)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04) !important; } .sgis-profile-stat-icon.t-purple svg { color: #a3d5f7 !important; } .sgis-profile-stat-val.t-purple { color: #a3d5f7 !important; } /* KPI 值文字 → 移除金色光泽 */ .sgis-profile-stat-val { text-shadow: none !important; } /* 5列模式 KPI 紧凑(保留布局优化) */ .sgis-profile-stats.cols-5 { gap: 6px !important; } .sgis-profile-stats.cols-5 .sgis-profile-stat { padding: 10px 4px 8px !important; border-radius: 10px !important; } /* ====== 39. SGIS 区块标题 — 恢复紫色 ====== */ .sgis-section-title { color: #66c0f4 !important; border-bottom: 1px solid rgba(26,159,255, 0.08) !important; } .sgis-section-title svg { color: #66c0f4 !important; } /* ====== 40. SGIS 战绩/游戏人生 双大卡 — 恢复紫蓝色调 ====== */ .sgis-feature-card { background: linear-gradient(160deg, rgba(26,159,255, 0.12) 0%, rgba(59, 130, 246, 0.04) 50%, rgba(15, 17, 35, 0.6) 100%) !important; border: 1px solid rgba(26,159,255, 0.2) !important; transition: all 0.25s ease !important; } .sgis-feature-card::before { background: radial-gradient(circle, rgba(26,159,255, 0.2) 0%, transparent 60%) !important; } .sgis-feature-card::after { background: linear-gradient(90deg, transparent 0%, rgba(26,159,255, 0.5) 50%, transparent 100%) !important; } .sgis-feature-card:hover { border-color: rgba(26,159,255, 0.4) !important; box-shadow: 0 8px 20px rgba(26,159,255, 0.2) !important; transform: translateY(-2px) !important; } /* alt 变体 → 恢复靛蓝色 */ .sgis-feature-card.alt { background: linear-gradient(160deg, rgba(37,99,235, 0.12) 0%, rgba(59, 130, 246, 0.04) 50%, rgba(15, 17, 35, 0.6) 100%) !important; border-color: rgba(37,99,235, 0.2) !important; } .sgis-feature-card.alt::before { background: radial-gradient(circle, rgba(37,99,235, 0.2) 0%, transparent 60%) !important; } .sgis-feature-card.alt::after { background: linear-gradient(90deg, transparent 0%, rgba(37,99,235, 0.5) 50%, transparent 100%) !important; } /* 特征卡片图标 → 恢复紫蓝色 */ .sgis-feature-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.22), rgba(59, 130, 246, 0.12)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 6px rgba(26,159,255, 0.15) !important; } .sgis-feature-icon svg { color: #a3d5f7 !important; } .sgis-feature-card.alt .sgis-feature-icon { background: linear-gradient(135deg, rgba(37,99,235, 0.22), rgba(59, 130, 246, 0.12)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 6px rgba(37,99,235, 0.15) !important; } .sgis-feature-card.alt .sgis-feature-icon svg { color: #93c5fd !important; } /* hover 箭头 → 紫色 */ .sgis-feature-card:hover .sgis-feature-arrow { color: #a3d5f7 !important; } /* 特征卡片数值 → 恢复紫色 */ .sgis-feature-stat-val.t-purple { color: #a3d5f7 !important; } /* ====== 40b. SGIS 双大卡紧凑优化 — 3列统计行 + 压缩高度 ====== */ .sis-feature-grid, .sgis-feature-grid { gap: 8px !important; margin-bottom: 8px !important; } .sgis-feature-card { padding: 10px 10px 8px !important; } .sgis-feature-head { gap: 8px !important; margin-bottom: 8px !important; } .sgis-feature-icon { width: 30px !important; height: 30px !important; border-radius: 8px !important; } .sgis-feature-icon svg { width: 15px !important; height: 15px !important; } .sgis-feature-title { font-size: 13px !important; } .sgis-feature-sub { font-size: 9px !important; margin-top: 1px !important; } .sgis-feature-arrow { width: 14px !important; height: 14px !important; } /* 3列统计行 — 战绩总览 */ .sgis-feature-stats-3 { grid-template-columns: repeat(3, 1fr) !important; gap: 4px !important; } .sgis-feature-stats-3 .sgis-feature-stat { padding: 5px 4px !important; } .sgis-feature-stats-3 .sgis-feature-stat-lbl { font-size: 8px !important; margin-bottom: 1px !important; } .sgis-feature-stats-3 .sgis-feature-stat-val { font-size: 13px !important; } /* 游戏人生卡片紧凑 */ .sgis-feature-card.alt .sgis-feature-stats { gap: 4px !important; } .sgis-feature-card.alt .sgis-feature-stat { padding: 5px 4px !important; } .sgis-feature-card.alt .sgis-feature-stat-lbl { font-size: 8px !important; margin-bottom: 1px !important; } .sgis-feature-card.alt .sgis-feature-stat-val { font-size: 13px !important; } .sgis-feature-extra { margin-top: 6px !important; padding: 5px 8px !important; font-size: 9px !important; } .sgis-feature-extra svg { width: 11px !important; height: 11px !important; } /* ====== 41. SGIS XP 卡片 — 保持金色进度条(原版设计),恢复中性背景 ====== */ .sgis-xp-card { border: 1px solid rgba(255, 255, 255, 0.06) !important; background: linear-gradient(160deg, rgba(255, 255, 255, 0.035) 0%, rgba(15, 17, 35, 0.5) 100%) !important; } /* XP 进度条和图标保持原版金色系,不做覆盖 */ /* XP 等级 next → 恢复紫色 */ .sgis-xp-levels .next { color: #a3d5f7 !important; } /* XP 累计数值 → 恢复紫色 */ .sgis-xp-total b { color: #a3d5f7 !important; } /* ====== 42. SGIS 社交标题 ====== */ .sgis-section-title.sgis-social-title { border-bottom-color: rgba(26,159,255, 0.08) !important; } /* ====== 43. SGIS copy 按钮 hover ====== */ .sgis-copy-btn:hover { background: rgba(255, 255, 255, 0.1) !important; color: #fff !important; } /* ########## Part B: 主脚本 CSS 常量原生紫色覆盖(120 条) ########## */ .sglv-ach-user-card { background: linear-gradient(160deg, rgba(37,99,235, 0.08), rgba(59,130,246, 0.04)) !important; } .sglv-ach-user-card::after { background: radial-gradient(circle, rgba(59,130,246, 0.12), transparent 60%) !important; } .sglv-ach-nav-item:hover { background: rgba(37,99,235, 0.08) !important; } .sglv-ach-nav-item.active { background: linear-gradient(90deg, rgba(37,99,235, 0.2), transparent) !important; } .sglv-ach-nav-item-icon { background: rgba(37,99,235, 0.12) !important; } .sglv-ach-nav-item-badge { background: rgba(37,99,235, 0.15) !important; color: #dbeafe !important; } .sglv-ach-nav-item.active .sglv-ach-nav-item-badge { background: #2563eb !important; } .sglv-ach-sidebar-recent-item:hover { background: rgba(37,99,235, 0.06) !important; } .sglv-ach-sidebar-recent-icon { background: linear-gradient(135deg, #2563eb, #66c0f4) !important; } .sglv-ach-header { border-bottom: 1px solid rgba(37,99,235, 0.12) !important; } .sglv-ach-ring-bg { stroke: rgba(37,99,235, 0.12) !important; } .sglv-ach-ring-val { background: linear-gradient(135deg, #22d3ee, #66c0f4, #fbbf24) !important; -webkit-background-clip: text !important; background-clip: text !important; } .sglv-ach-steam-banner { background: linear-gradient(135deg, rgba(34,211,238,0.08), rgba(251,191,36,0.06), rgba(26,159,255, 0.04)) !important; } .sglv-ach-card { border: 1px solid rgba(37,99,235, 0.1) !important; } .sglv-ach-card::before { background: var(--ach-rarity, #2563eb) !important; } .sglv-ach-card:hover { border-color: rgba(37,99,235, 0.3) !important; } .sglv-ach-card.unlocked.epic { --ach-rarity: #66c0f4 !important; } .sglv-ach-card.locked { --ach-rarity: #2563eb !important; } .sglv-ach-card.locked.epic { --ach-rarity: #66c0f4 !important; } .sglv-ach-card-icon { background: linear-gradient(135deg, rgba(37,99,235, 0.15), rgba(26,159,255, 0.08)) !important; } .sglv-ach-card.unlocked .sglv-ach-card-icon { box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), inset 0 -1px 0 rgba(0,0,0,0.2), 0 4px 12px rgba(37,99,235, 0.25) !important; } .sglv-ach-card-rarity.epic { background: #66c0f4 !important; } .sglv-ach-card.locked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #38bdf8, #66c0f4, #f59e0b) !important; } .sglv-sc-hero { background: linear-gradient(135deg, rgba(37,99,235, 0.10) 0%, rgba(59,130,246, 0.06) 50%, rgba(6,182,212,0.04) 100%) !important; border: 1px solid rgba(37,99,235, 0.15) !important; } .sglv-sc-hero::before { background: radial-gradient(ellipse at top right, rgba(59,130,246, 0.10), transparent 60%) !important; } .sglv-sc-avatar { box-shadow: 0 1px 8px rgba(0,0,0,0.35), 0 0 0 1px rgba(59,130,246, 0.25) !important; } .sglv-sc-avatar-placeholder { background: linear-gradient(135deg, #2563eb, #1a9fff) !important; } .sglv-sc-hero-stat svg { color: #60a5fa !important; } .sglv-sc-card-header svg { color: #66c0f4 !important; } .sglv-sc-cover-item:hover { border-color: rgba(59,130,246, 0.5) !important; box-shadow: 0 4px 16px rgba(59,130,246, 0.2) !important; } .sglv-sc-cover-hours { color: #93c5fd !important; } .sglv-sc-sk-block { background: linear-gradient(90deg, rgba(37,99,235, 0.06) 25%, rgba(37,99,235, 0.15) 37%, rgba(37,99,235, 0.06) 63%) !important; } .sglv-sc-edit-btn { background: rgba(37,99,235, 0.15) !important; border: 1px solid rgba(37,99,235, 0.3) !important; color: #93c5fd !important; } .sglv-sc-edit-btn:hover { background: rgba(37,99,235, 0.25) !important; border-color: rgba(37,99,235, 0.5) !important; } .sglv-sc-playtime-item:hover { background: rgba(37,99,235, 0.08) !important; } .sglv-sc-playtime-bar { background: rgba(37,99,235, 0.1) !important; } .sglv-sc-playtime-bar-fill { background: linear-gradient(90deg, #2563eb, #3b82f6) !important; } .sglv-sc-ach-item:hover { background: rgba(37,99,235, 0.08) !important; } .sglv-sc-ach-item.rarity-epic { border-left-color: #3b82f6 !important; } .sglv-sc-ach-icon { background: rgba(37,99,235, 0.1) !important; } .sglv-sc-tag-collector { background: rgba(26,159,255, 0.12) !important; border-color: rgba(26,159,255, 0.3) !important; color: #a3d5f7 !important; } .sglv-sc-picker-search:focus { border-color: rgba(37,99,235, 0.5) !important; } .sglv-sc-picker-save { background: linear-gradient(135deg, #2563eb, #1a9fff) !important; } .sglv-sc-hero-badge { border: 1.5px solid rgba(26,159,255, 0.3) !important; } .sglv-sc-hero-badge:hover { border-color: rgba(26,159,255, 0.6) !important; box-shadow: 0 4px 12px rgba(26,159,255, 0.2) !important; } .sglv-sc-hero-badge-fallback { color: #60a5fa !important; } .sglv-sc-hero-badge-lvl { background: linear-gradient(135deg, #1a9fff, #2563eb) !important; } .sglv-sc-hero-badge-empty:hover { border-color: rgba(26,159,255, 0.4) !important; color: rgba(26,159,255, 0.6) !important; } .sglv-sc-hero-badge-edit { background: rgba(26,159,255, 0.15) !important; border: 1px solid rgba(26,159,255, 0.25) !important; color: #66c0f4 !important; } .sglv-sc-hero-badge-edit:hover { background: rgba(26,159,255, 0.25) !important; border-color: rgba(26,159,255, 0.5) !important; } .sglv-sc-badge-tab:hover { color: #a3d5f7 !important; } .sglv-sc-badge-tab.active { background: rgba(26,159,255, 0.15) !important; border-color: rgba(26,159,255, 0.4) !important; color: #a3d5f7 !important; } .sglv-sc-badge-pick-item:hover { border-color: rgba(26,159,255, 0.4) !important; } .sglv-sc-badge-lvl { background: rgba(26,159,255, 0.15) !important; color: #a3d5f7 !important; } .sglv-cs-action-btn { border: 1px solid rgba(26,159,255, 0.4) !important; background: rgba(26,159,255, 0.12) !important; color: #a3d5f7 !important; } .sglv-cs-action-btn:hover { background: rgba(26,159,255, 0.25) !important; border-color: rgba(26,159,255, 0.6) !important; } .sglv-cs-top10-bar { background: linear-gradient(90deg, #1a9fff, #2563eb) !important; } .sglv-cs-dist-bar { background: linear-gradient(180deg, #1a9fff, #2563eb) !important; } .sglv-cs-source-badge { background: rgba(26,159,255, 0.12) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.2) !important; } .sglv-cs-refresh-btn:hover { color: #a3d5f7 !important; background: rgba(26,159,255, 0.12) !important; } .sglv-cs-search:focus { border-color: rgba(26,159,255, 0.5) !important; } .sglv-cs-game-cover-fallback { background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(26,159,255, 0.08)) !important; } #sglv-stage-progress { background: linear-gradient(180deg, rgba(102,192,244, 0.08), transparent) !important; border-bottom: 1px solid rgba(102,192,244, 0.15) !important; } #sglv-stage-progress .sglv-stage-progress-fill { background: linear-gradient(90deg, #66c0f4 0%, #38bdf8 100%) !important; } .sglv-header-brand { background: linear-gradient(135deg, rgba(102, 192, 244, 0.18), rgba(26,159,255, 0.18)) !important; } .sglv-panel .sglv-gs-item-shared { background: rgba(26,159,255, 0.18) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.35) !important; } .sglv-detail-btn-ai { background: linear-gradient(135deg, #2563eb, #1a9fff) !important; } .sglv-body-loading .sglv-bl-btn.primary { background: linear-gradient(135deg, rgba(26,159,255, 0.25), rgba(102, 192, 244, 0.2)) !important; border-color: rgba(26,159,255, 0.4) !important; color: #a3d5f7 !important; } .sglv-body-loading .sglv-bl-btn.primary:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.35), rgba(102, 192, 244, 0.3)) !important; } .sglv-trend-kpi-card.purple { border-color: rgba(102,192,244, 0.3) !important; } .sglv-trend-kpi-card.purple::before { background: #66c0f4 !important; } .sglv-trend-kpi-card.purple .sglv-trend-kpi-value { color: #66c0f4 !important; } .sglv-trend-kpi-card.purple .sglv-trend-kpi-icon { background: rgba(102,192,244, 0.15) !important; color: #66c0f4 !important; } .sgis-tabs::after { background: linear-gradient(90deg, transparent, rgba(26,159,255, 0.35), transparent) !important; } .sgis-tab.active .sgis-svg { filter: drop-shadow(0 1px 4px rgba(26,159,255, 0.45)) !important; } .sgis-title-icon { background: linear-gradient(135deg, #1a9fff 0%, #2563eb 45%, #3b82f6 100%) !important; box-shadow: 0 4px 12px rgba(26,159,255, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 -1px 0 rgba(0, 0, 0, 0.2) !important; } .sgis-body::-webkit-scrollbar-thumb { background: linear-gradient(180deg, rgba(26,159,255, 0.45), rgba(59, 130, 246, 0.45)) !important; } .sgis-state-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.15), rgba(59, 130, 246, 0.08)) !important; border: 1px solid rgba(26,159,255, 0.2) !important; color: #66c0f4 !important; } .sgis-badge-hero { background: linear-gradient(135deg, rgba(26,159,255, 0.18) 0%, rgba(59, 130, 246, 0.1) 50%, var(--sgis-bg-2) 100%) !important; border: 1px solid rgba(26,159,255, 0.3) !important; box-shadow: 0 4px 16px rgba(26,159,255, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.05) !important; } .sgis-badge-hero::before { background: radial-gradient(circle, rgba(26,159,255, 0.25) 0%, transparent 65%) !important; } .sgis-badge-hero-progress-label span:last-child { color: #a3d5f7 !important; } .sgis-badge-hero-level-bar { background: linear-gradient(180deg, #1a9fff 0%, #2563eb 100%) !important; box-shadow: 0 0 6px rgba(26,159,255, 0.3) !important; } .sgis-badge-group-head:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.05), rgba(59, 130, 246, 0.02)) !important; } .sgis-badge-group-stat-val { color: #a3d5f7 !important; } .sgis-badge-footer-formula b { color: #a3d5f7 !important; } .sglv-skel-bar { background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0%, rgba(26,159,255, 0.12) 50%, rgba(255, 255, 255, 0.04) 100%) !important; } .sglv-wl-kpi-cell { background: linear-gradient(135deg, rgba(26,159,255, 0.06), rgba(59, 130, 246, 0.03)) !important; border: 1px solid rgba(26,159,255, 0.15) !important; } .sglv-skel-cover { background: linear-gradient(90deg, rgba(26,159,255, 0.08) 0%, rgba(26,159,255, 0.18) 50%, rgba(26,159,255, 0.08) 100%) !important; } .sglv-wl-loading .sglv-wl-spin { color:#66c0f4 !important; } .sglv-wl-loading-bar-fill { background:linear-gradient(90deg,#66c0f4,#66c0f4) !important; } .sglv-toast { border: 1px solid rgba(102,192,244, 0.5) !important; } .sglv-set-chip.purple { background: rgba(102,192,244, 0.15) !important; color: #66c0f4 !important; } .sglv-set-mode-item:hover { border-color: rgba(102,192,244, 0.4) !important; background: rgba(102,192,244, 0.06) !important; } .sglv-set-mode-item input { accent-color: #66c0f4 !important; } .sglv-set-mode-item input:checked ~ .sglv-set-mode-info .sglv-set-mode-name { color: #66c0f4 !important; } .sglv-set-theme-opt:hover { border-color: rgba(102,192,244, 0.4) !important; } .sglv-set-theme-opt.selected { border-color: rgba(102,192,244, 0.55) !important; background: rgba(102,192,244, 0.07) !important; box-shadow: 0 0 0 1px rgba(102,192,244, 0.25) !important; } .sglv-set-save-btn { background: linear-gradient(90deg, #1a9fff, #3b82f6) !important; } .sglv-cover-badge { color: #a3d5f7 !important; } .sglv-cover-badge.shared { background: rgba(26,159,255, 0.55) !important; color: #d3ecfd !important; } .sgis-price-tbl-row.user-region { box-shadow: inset 0 0 0 1px rgba(102,192,244, 0.3) !important; } .sgis-review-pct-big.mix { color:#66c0f4 !important; } .sgis-review-bar-positive.mix { background:linear-gradient(90deg,#66c0f4 0%,#54a0ff 100%) !important; box-shadow:0 0 6px rgba(102,192,244, 0.4) !important; } .sgis-review-item:hover { border-color:rgba(26,159,255, 0.15) !important; } .sgis-review-avatar-fallback { background:linear-gradient(135deg,rgba(26,159,255, 0.4),rgba(59,130,246,0.3)) !important; } .sgis-review-count-summary { background:rgba(26,159,255, 0.06) !important; border:1px solid rgba(26,159,255, 0.12) !important; } .sgis-review-count-summary b { color:#a3d5f7 !important; } .sgis-review-count-icon { color:#66c0f4 !important; } .sgis-review-count-filtered { background:rgba(102,192,244, 0.2) !important; color:#66c0f4 !important; border:1px solid rgba(102,192,244, 0.3) !important; } .sgis-review-action-btn:hover { background:rgba(26,159,255, 0.15) !important; border-color:rgba(26,159,255, 0.3) !important; color:#a3d5f7 !important; } .sgis-review-empty-icon { background:linear-gradient(135deg,rgba(26,159,255, 0.12),rgba(59,130,246,0.06)) !important; border:1px solid rgba(26,159,255, 0.18) !important; } .sgis-gift-card { background:linear-gradient(135deg, rgba(102,192,244, 0.12), rgba(102,192,244,0.08)) !important; border:1px solid rgba(102,192,244, 0.2) !important; } .sgis-crack-badge.aaa { background:rgba(59,130,246, 0.15) !important; color:#60a5fa !important; border-color:rgba(59,130,246, 0.25) !important; } .sgis-review-regex-toggle:hover { background:rgba(102,192,244, 0.1) !important; border-color:rgba(102,192,244, 0.3) !important; } .sgis-review-regex-toggle.active { background:rgba(102,192,244, 0.2) !important; color:#66c0f4 !important; border-color:rgba(102,192,244, 0.4) !important; box-shadow:0 0 0 1px rgba(102,192,244, 0.2) !important; } .sgis-load-bar-fill { background:linear-gradient(90deg,#06b6d4,#3b82f6,#1a9fff) !important; } .sgis-skel-icon { background:linear-gradient(90deg,rgba(26,159,255, 0.08),rgba(26,159,255, 0.18),rgba(26,159,255, 0.08)) !important; } /* ########## Part C: 共享样式库原生紫色覆盖(163 条) ########## */ .sglv-stat-card.purple, .sglv-stat-dash-card.purple, .sglv-playtime-kpi-card.purple { border-color: rgba(26,159,255, 0.35) !important; } .sglv-stat-card.purple::before, .sglv-stat-dash-card.purple::before, .sglv-playtime-kpi-card.purple::before { background: #66c0f4 !important; } .sglv-card-badge.shared { background: rgba(26,159,255, 0.2) !important; color: #66c0f4 !important; } .sglv-ps-tier-badge.tier-premium { background:rgba(102,192,244, 0.95) !important; } .sglv-ps-card.sglv-ps-exclusive:hover { border-color:rgba(102,192,244, 0.4) !important; } .sglv-wl-kpi-card.kpi-free::before { background: #2563eb !important; } .sglv-wl-kpi-card.kpi-year::before { background: #3b82f6 !important; } .sglv-wl-kpi-card.kpi-free .sglv-wl-kpi-icon { background: rgba(37,99,235, 0.15) !important; color: #2563eb !important; } .sglv-wl-kpi-card.kpi-year .sglv-wl-kpi-icon { background: rgba(59,130,246, 0.15) !important; color: #3b82f6 !important; } .sglv-wl-year-bar { background: linear-gradient(180deg, rgba(37,99,235, 0.85), rgba(37,99,235, 0.35)) !important; } .sglv-list-badge.shared { background: rgba(26,159,255, 0.2) !important; color: #66c0f4 !important; } .sglv-stat-card.purple .sglv-stat-icon { background: rgba(26,159,255, 0.15) !important; color: #66c0f4 !important; } .sglv-stat-dash-card.purple .sglv-stat-dash-value { color: #66c0f4 !important; } .sglv-stat-dash-card.purple .sglv-stat-dash-icon { background: rgba(26,159,255, 0.15) !important; color: #66c0f4 !important; } .sglv-trend-kpi-card.total { border-color: rgba(96,165,250, 0.35) !important; } .sglv-trend-kpi-card.total .sglv-trend-kpi-value { color: #60a5fa !important; } .sglv-trend-kpi-card.total::before { background: #60a5fa !important; } .sglv-trend-kpi-card.total .sglv-trend-kpi-icon { background: rgba(96,165,250, 0.15) !important; color: #60a5fa !important; } .sglv-playtime-kpi-card.purple .sglv-playtime-kpi-value { color: #66c0f4 !important; } .sglv-playtime-kpi-card.purple .sglv-playtime-kpi-icon { background: rgba(26,159,255, 0.15) !important; color: #66c0f4 !important; } :root { --sgis-border: rgba(26,159,255, 0.18) !important; --sgis-purple: #1a9fff !important; --sgis-purple-2: #66c0f4 !important; --sgis-purple-text: #a3d5f7 !important; --sgis-shadow: 0 16px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(26,159,255, 0.08) !important; --sgis-glow-purple: 0 0 24px rgba(26,159,255, 0.18) !important; } #sgis-fab { background: linear-gradient(135deg, #1a9fff 0%, #3b82f6 50%, #06b6d4 100%) !important; box-shadow: -4px 0 18px rgba(26,159,255, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.15) !important; } #sgis-fab:hover { box-shadow: -6px 0 24px rgba(26,159,255, 0.55), inset 0 0 0 1px rgba(255, 255, 255, 0.2) !important; } #sgis-panel { border-left: 1px solid rgba(26,159,255, 0.22) !important; } #sgis-panel::before { background: linear-gradient(90deg, transparent 0%, rgba(26,159,255, 0.5) 50%, transparent 100%) !important; } .sgis-header { background: linear-gradient(135deg, rgba(26,159,255, 0.22) 0%, rgba(59, 130, 246, 0.12) 50%, var(--sgis-bg-2) 100%) !important; border-bottom: 1px solid rgba(26,159,255, 0.2) !important; } .sgis-header::before { background: radial-gradient(circle, rgba(26,159,255, 0.22) 0%, transparent 65%) !important; } .sgis-header-decoration { background: linear-gradient(90deg, transparent 0%, rgba(26,159,255, 0.4) 50%, transparent 100%) !important; } .sgis-title-text { background: linear-gradient(135deg, #fff 0%, #a3d5f7 100%) !important; -webkit-background-clip: text !important; background-clip: text !important; } .sgis-title-icon { background: linear-gradient(135deg, #1a9fff 0%, #2563eb 50%, #3b82f6 100%) !important; box-shadow: 0 4px 12px rgba(26,159,255, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2) !important; } .sgis-icon-btn:hover { background: rgba(26,159,255, 0.18) !important; border-color: rgba(26,159,255, 0.3) !important; box-shadow: 0 2px 8px rgba(26,159,255, 0.25) !important; } .sgis-tabs { border-bottom: 1px solid rgba(26,159,255, 0.12) !important; } .sgis-tab::before { background: linear-gradient(135deg, rgba(26,159,255, 0.3), rgba(59, 130, 246, 0.2)) !important; } .sgis-tab:hover { color: #a3d5f7 !important; } .sgis-tab.active { box-shadow: 0 2px 8px rgba(26,159,255, 0.25), inset 0 0 0 1px rgba(26,159,255, 0.5) !important; } .sgis-body { scrollbar-color: rgba(26,159,255, 0.4) transparent !important; } .sgis-body::-webkit-scrollbar-thumb { background: linear-gradient(180deg, rgba(26,159,255, 0.4), rgba(59, 130, 246, 0.4)) !important; } .sgis-body::-webkit-scrollbar-thumb:hover { background: linear-gradient(180deg, rgba(26,159,255, 0.6), rgba(59, 130, 246, 0.6)) !important; } .sgis-retry-btn { border: 1px solid rgba(26,159,255, 0.4) !important; background: linear-gradient(135deg, rgba(26,159,255, 0.15), rgba(59, 130, 246, 0.1)) !important; color: #a3d5f7 !important; } .sgis-retry-btn:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.3), rgba(59, 130, 246, 0.2)) !important; box-shadow: 0 4px 12px rgba(26,159,255, 0.25) !important; } .sgis-spinner { border: 3px solid rgba(26,159,255, 0.15) !important; border-top-color: #1a9fff !important; box-shadow: 0 0 16px rgba(26,159,255, 0.3) !important; } .sgis-hero { background: linear-gradient(135deg, rgba(102, 192, 244, 0.15) 0%, rgba(26,159,255, 0.1) 50%, var(--sgis-bg-2) 100%) !important; } .sgis-hero::after { background: radial-gradient(circle, rgba(26,159,255, 0.15) 0%, transparent 60%) !important; } .sgis-section:hover { border-color: rgba(26,159,255, 0.12) !important; } .sgis-section-title { color: #66c0f4 !important; border-bottom: 1px solid rgba(26,159,255, 0.08) !important; } .sgis-section-title svg { color: #66c0f4 !important; } .sgis-row { border-bottom: 1px dashed rgba(26,159,255, 0.08) !important; } .sgis-tag { background: linear-gradient(135deg, rgba(26,159,255, 0.12), rgba(59, 130, 246, 0.06)) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.18) !important; } .sgis-tag:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.22), rgba(59, 130, 246, 0.12)) !important; border-color: rgba(26,159,255, 0.35) !important; } .sgis-link { background: linear-gradient(135deg, rgba(26,159,255, 0.1), rgba(59, 130, 246, 0.06)) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.2) !important; } .sgis-link:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.2), rgba(59, 130, 246, 0.12)) !important; border-color: rgba(26,159,255, 0.4) !important; } .sgis-taste-tag.theme { background: rgba(26,159,255, 0.12) !important; color: #a3d5f7 !important; border-color: rgba(26,159,255, 0.22) !important; } .sgis-owned-hero.shared { background: linear-gradient(135deg, rgba(26,159,255, 0.18) 0%, rgba(37,99,235, 0.08) 50%, var(--sgis-bg-2) 100%) !important; border-color: rgba(26,159,255, 0.35) !important; } .sgis-owned-hero.shared::after { background: radial-gradient(circle, rgba(26,159,255, 0.2) 0%, transparent 65%) !important; } .sgis-owned-hero.shared .sgis-owned-icon { background: linear-gradient(135deg, #1a9fff, #2563eb) !important; box-shadow: 0 2px 6px rgba(26,159,255, 0.4) !important; } .sgis-btn { background: linear-gradient(135deg, rgba(26,159,255, 0.18), rgba(59, 130, 246, 0.1)) !important; border: 1px solid rgba(26,159,255, 0.3) !important; color: #a3d5f7 !important; } .sgis-btn:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.3), rgba(59, 130, 246, 0.18)) !important; border-color: rgba(26,159,255, 0.45) !important; box-shadow: 0 4px 10px rgba(26,159,255, 0.2) !important; } .sgis-stat-box:hover { border-color: rgba(26,159,255, 0.3) !important; background: linear-gradient(135deg, rgba(26,159,255, 0.08), rgba(59, 130, 246, 0.04)) !important; } .sgis-owner-item.sharer { border-color: rgba(26,159,255, 0.35) !important; background: rgba(26,159,255, 0.08) !important; } .sgis-owner-tag.sharer { background: rgba(26,159,255, 0.25) !important; } .sgis-card-toggle button.active { background: linear-gradient(135deg, rgba(26,159,255, 0.3), rgba(59, 130, 246, 0.2)) !important; box-shadow: inset 0 0 0 1px rgba(26,159,255, 0.4) !important; } .sgis-card-item:hover { background: rgba(26,159,255, 0.12) !important; border-color: rgba(26,159,255, 0.3) !important; box-shadow: 0 4px 12px rgba(26,159,255, 0.15) !important; } .sgis-card-icon-fallback { background: linear-gradient(135deg, rgba(26,159,255, 0.15), rgba(59, 130, 246, 0.08)) !important; } .sgis-card-badge { background: rgba(26,159,255, 0.2) !important; } .sgis-family-share-row.sharing { background: rgba(26,159,255, 0.12) !important; border: 1px solid rgba(26,159,255, 0.3) !important; color: #66c0f4 !important; } .sgis-family-share-row.sharing .sgis-family-share-icon { background: rgba(26,159,255, 0.3) !important; } .sgis-predict-btn { border: 1px solid rgba(26,159,255, 0.4) !important; background: linear-gradient(135deg, rgba(26,159,255, 0.18), rgba(59, 130, 246, 0.1)) !important; color: #a3d5f7 !important; } .sgis-predict-btn:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.3), rgba(59, 130, 246, 0.18)) !important; box-shadow: 0 4px 14px rgba(26,159,255, 0.3) !important; } .sgis-profile-main-card { background: linear-gradient(160deg, #16202d 0%, #121a25 40%, #0d131c 100%) !important; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(26,159,255, 0.15) !important; } .sgis-profile-main-card::before { background: radial-gradient(circle, rgba(26,159,255, 0.4) 0%, transparent 60%) !important; } .sgis-profile-main-card::after { background: linear-gradient(90deg, transparent 0%, rgba(26,159,255, 0.6) 50%, transparent 100%) !important; } .sgis-profile-main-card-bg { filter: saturate(1.2) drop-shadow(0 0 20px rgba(26,159,255, 0.25)) !important; } .sgis-profile-avatar-badge { background: linear-gradient(135deg, #1a9fff 0%, #2563eb 50%, #3b82f6 100%) !important; box-shadow: 0 4px 12px rgba(26,159,255, 0.5), 0 0 0 3px #121a25 !important; } .sgis-profile-verified { background: linear-gradient(135deg, #1a9fff, #3b82f6) !important; box-shadow: 0 2px 6px rgba(26,159,255, 0.4) !important; } .sgis-profile-stat:hover { border-color: rgba(26,159,255, 0.3) !important; background: linear-gradient(160deg, rgba(26,159,255, 0.08) 0%, rgba(59, 130, 246, 0.03) 100%) !important; } .sgis-profile-stat-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.2), rgba(59, 130, 246, 0.08)) !important; } .sgis-profile-stat-icon svg { color: #a3d5f7 !important; } .sgis-profile-stat-icon.t-purple { background: linear-gradient(135deg, rgba(59,130,246, 0.22), rgba(26,159,255, 0.08)) !important; } .sgis-profile-stat-icon.t-purple svg { color: #a3d5f7 !important; } .sgis-profile-stat-val.t-purple { color: #a3d5f7 !important; } .sgis-xp-levels .next { color: #a3d5f7 !important; } .sgis-xp-total b { color: #a3d5f7 !important; } .sgis-feature-card { background: linear-gradient(160deg, rgba(26,159,255, 0.12) 0%, rgba(59, 130, 246, 0.04) 50%, rgba(15, 17, 35, 0.6) 100%) !important; border: 1px solid rgba(26,159,255, 0.2) !important; } .sgis-feature-card::before { background: radial-gradient(circle, rgba(26,159,255, 0.2) 0%, transparent 60%) !important; } .sgis-feature-card::after { background: linear-gradient(90deg, transparent 0%, rgba(26,159,255, 0.5) 50%, transparent 100%) !important; } .sgis-feature-card:hover { border-color: rgba(26,159,255, 0.4) !important; box-shadow: 0 8px 20px rgba(26,159,255, 0.2) !important; } .sgis-feature-card.alt { background: linear-gradient(160deg, rgba(37,99,235, 0.12) 0%, rgba(59, 130, 246, 0.04) 50%, rgba(15, 17, 35, 0.6) 100%) !important; border-color: rgba(37,99,235, 0.2) !important; } .sgis-feature-card.alt::before { background: radial-gradient(circle, rgba(37,99,235, 0.2) 0%, transparent 60%) !important; } .sgis-feature-card.alt::after { background: linear-gradient(90deg, transparent 0%, rgba(37,99,235, 0.5) 50%, transparent 100%) !important; } .sgis-feature-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.22), rgba(37,99,235, 0.12)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 6px rgba(26,159,255, 0.15) !important; } .sgis-feature-card.alt .sgis-feature-icon { background: linear-gradient(135deg, rgba(37,99,235, 0.22), rgba(59, 130, 246, 0.12)) !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 6px rgba(37,99,235, 0.15) !important; } .sgis-feature-icon svg { color: #a3d5f7 !important; } .sgis-feature-card.alt .sgis-feature-icon svg { color: #93c5fd !important; } .sgis-feature-card:hover .sgis-feature-arrow { color: #a3d5f7 !important; } .sgis-feature-stat-val.t-purple { color: #a3d5f7 !important; } .sgis-activity-type.news { background: rgba(102,192,244, 0.2) !important; } .sgis-badge-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.12), rgba(59, 130, 246, 0.06)) !important; } .sgis-badge-card:hover .sgis-badge-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.2), rgba(59, 130, 246, 0.1)) !important; } .sgis-badge-icon svg { color: #a3d5f7 !important; } .sgis-user-ach.unlocked .sgis-user-ach-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.12), rgba(59,130,246,0.08)) !important; box-shadow: 0 0 12px rgba(26,159,255, 0.2) !important; } .sgis-persona-section { background: linear-gradient(135deg, rgba(26,159,255, 0.12) 0%, rgba(59, 130, 246, 0.06) 50%, var(--sgis-bg-2) 100%) !important; border: 1px solid rgba(26,159,255, 0.22) !important; box-shadow: 0 2px 8px rgba(26,159,255, 0.08) !important; } .sgis-persona-section::before { background: radial-gradient(circle, rgba(26,159,255, 0.2) 0%, transparent 60%) !important; } .sgis-persona-title { color: #a3d5f7 !important; } .sgis-persona-title svg { color: #a3d5f7 !important; } .sgis-persona-type { background: linear-gradient(135deg, rgba(26,159,255, 0.25), rgba(37,99,235, 0.15)) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.3) !important; } .sgis-persona-trait { background: linear-gradient(135deg, rgba(26,159,255, 0.18), rgba(37,99,235, 0.08)) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.2) !important; } .sgis-insight-kpi.purple { border-color: rgba(26,159,255, 0.3) !important; border-left-color: #1a9fff !important; } .sgis-insight-kpi.purple::before { background: #1a9fff !important; } .sgis-insight-kpi.purple:hover { border-color: rgba(26,159,255, 0.5) !important; } .sgis-insight-persona { border: 1px solid rgba(26,159,255, 0.25) !important; } .sgis-insight-persona-head { background: linear-gradient(135deg, rgba(26,159,255, 0.35), rgba(59, 130, 246, 0.18)) !important; } .sgis-insight-persona-rarity { background: linear-gradient(135deg, rgba(26,159,255, 0.4), rgba(37,99,235, 0.25)) !important; color: #d3ecfd !important; border: 1px solid rgba(26,159,255, 0.4) !important; } .sgis-insight-persona-trait { background: linear-gradient(135deg, rgba(26,159,255, 0.18), rgba(59,130,246,0.08)) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.22) !important; } .sgis-insight-persona-trait:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.3), rgba(59,130,246,0.15)) !important; border-color: rgba(26,159,255, 0.4) !important; } .sgis-insight-metric:hover { border-color: rgba(26,159,255, 0.2) !important; } .sgis-insight-dim:hover { border-color: rgba(26,159,255, 0.3) !important; } .sgis-insight-ai { border: 1px solid rgba(26,159,255, 0.22) !important; } .sgis-insight-ai-head { background: linear-gradient(135deg, rgba(26,159,255, 0.18), rgba(59, 130, 246, 0.08)) !important; border-bottom: 1px solid rgba(26,159,255, 0.15) !important; } .sgis-insight-ai-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.35), rgba(37,99,235, 0.2)) !important; } .sgis-insight-ai-icon svg { color: #a3d5f7 !important; } .sgis-insight-ai-btn { background: linear-gradient(135deg, rgba(26,159,255, 0.45), rgba(59, 130, 246, 0.3)) !important; border: 1px solid rgba(26,159,255, 0.4) !important; } .sgis-insight-ai-btn:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.6), rgba(59, 130, 246, 0.45)) !important; box-shadow: 0 3px 10px rgba(26,159,255, 0.25) !important; } .sgis-insight-ai-oneliner { background: linear-gradient(135deg, rgba(26,159,255, 0.15), rgba(59, 130, 246, 0.08)) !important; border: 1px solid rgba(26,159,255, 0.2) !important; } .sgis-insight-ai-oneliner svg { color: #a3d5f7 !important; } .sgis-insight-ach-toggle:hover { color: #a3d5f7 !important; } .sgis-social-friend:hover { border-color: rgba(26,159,255, 0.25) !important; background: linear-gradient(135deg, rgba(26,159,255, 0.06) 0%, rgba(59,130,246,0.03) 100%) !important; } .sgis-social-friend-days { background: rgba(26,159,255, 0.12) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.18) !important; } .sgis-social-group-head { color: #66c0f4 !important; border-bottom: 1px solid rgba(26,159,255, 0.1) !important; } .sgis-social-group-head .count { background: rgba(26,159,255, 0.15) !important; color: #a3d5f7 !important; } .sgis-gc-fill { background: linear-gradient(90deg, #38bdf8, #60a5fa) !important; } .sgis-news-item { background: linear-gradient(135deg, rgba(255, 255, 255, 0.025), rgba(26,159,255, 0.015)) !important; } .sgis-news-item:hover { background: linear-gradient(135deg, rgba(26,159,255, 0.08), rgba(59, 130, 246, 0.04)) !important; border-color: rgba(26,159,255, 0.25) !important; box-shadow: 0 2px 8px rgba(26,159,255, 0.1) !important; } .sgis-news-feed { background: linear-gradient(135deg, rgba(26,159,255, 0.18), rgba(59, 130, 246, 0.1)) !important; color: #a3d5f7 !important; border: 1px solid rgba(26,159,255, 0.18) !important; } .sgis-card-item.sgis-card-with-bg:hover::after { background: linear-gradient(90deg, rgba(15, 17, 35, 0.85) 0%, rgba(26,159,255, 0.3) 50%, rgba(59, 130, 246, 0.2) 100%) !important; } .sgis-scroll-area { scrollbar-color: rgba(26,159,255, 0.4) transparent !important; } .sgis-scroll-area::-webkit-scrollbar-thumb { background: linear-gradient(180deg, rgba(26,159,255, 0.4), rgba(59, 130, 246, 0.4)) !important; } .sgis-badge-group { background: linear-gradient(160deg, rgba(255, 255, 255, 0.025) 0%, rgba(26,159,255, 0.02) 100%) !important; } .sgis-badge-group:hover { border-color: rgba(26,159,255, 0.18) !important; } .sgis-badge-group-head::after { background: linear-gradient(90deg, transparent, rgba(26,159,255, 0.15), transparent) !important; } .sgis-badge-group-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.18), rgba(59, 130, 246, 0.08)) !important; } .sgis-badge-group-icon svg { color: #a3d5f7 !important; } .sgis-badge-group-stat { color: #a3d5f7 !important; background: rgba(26,159,255, 0.12) !important; } .sgis-badge-group-expanded .sgis-badge-group-arrow { color: #a3d5f7 !important; } .sgis-badge-row:hover { background: rgba(26,159,255, 0.08) !important; border-color: rgba(26,159,255, 0.2) !important; } .sgis-badge-row-icon { background: linear-gradient(135deg, rgba(26,159,255, 0.15), rgba(59, 130, 246, 0.06)) !important; } .sgis-badge-row-icon svg { color: #a3d5f7 !important; } .sgis-badge-row-lv { color: #66c0f4 !important; } .sgis-badge-row-rar.r3 { background: rgba(26,159,255, 0.2) !important; color: #a3d5f7 !important; } .sgis-badge-row-link { background: rgba(26,159,255, 0.12) !important; color: #a3d5f7 !important; } .sgis-badge-row-link:hover { background: rgba(26,159,255, 0.25) !important; } .sgis-dist-label { color: #a3d5f7 !important; } .sgis-dist-bar-fill { background: linear-gradient(90deg, #1a9fff, #3b82f6) !important; box-shadow: 0 0 6px rgba(26,159,255, 0.3) !important; } .sgis-value-card { background: linear-gradient(160deg, rgba(255, 255, 255, 0.04) 0%, rgba(26,159,255, 0.025) 100%) !important; } .sgis-value-card:hover { background: linear-gradient(160deg, rgba(26,159,255, 0.1) 0%, rgba(59, 130, 246, 0.04) 100%) !important; border-color: rgba(26,159,255, 0.3) !important; } .sgis-value-card-market { color: #a3d5f7 !important; } .sgis-badge-row-icon-wrap { background: radial-gradient(circle at 50% 35%, rgba(26,159,255, 0.12), transparent 65%), linear-gradient(135deg, rgba(15, 23, 42, 0.6) 0%, rgba(30, 41, 59, 0.5) 100%) !important; } .sgis-badge-tip { border: 1px solid rgba(26,159,255, 0.35) !important; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(26,159,255, 0.1) !important; } .sgis-badge-tip-head { border-bottom: 1px solid rgba(26,159,255, 0.15) !important; } .sgis-badge-tip-bd-bar > div { background: linear-gradient(90deg, #1a9fff, #3b82f6) !important; } .sgis-tagrec-tab:hover { background: rgba(26,159,255, 0.1) !important; } .sgis-tagrec-tab.active { background: rgba(26,159,255, 0.2) !important; color: #a3d5f7 !important; border-color: rgba(26,159,255, 0.4) !important; } .sglv-goty-year-chip:hover { background: rgba(26,159,255, 0.12) !important; border-color: rgba(26,159,255, 0.3) !important; } .sglv-goty-year-chip.active { background: linear-gradient(135deg, rgba(26,159,255, 0.25), rgba(59, 130, 246, 0.15)) !important; color: #a3d5f7 !important; border-color: rgba(26,159,255, 0.5) !important; box-shadow: 0 0 8px rgba(26,159,255, 0.2) !important; } /* ########## Part E: 收藏库面板 CSS 紫色覆盖(8 条,attr 前缀顺序无关) ########## */ html[data-sglv-theme="blue"] .sglv-header-brand { background: linear-gradient(135deg, rgba(102, 192, 244, 0.18), rgba(26,159,255, 0.18)) !important; } html[data-sglv-theme="blue"] .sgc-panel .sglv-goty-year-bar { background: linear-gradient(180deg, rgba(26,159,255, 0.08), transparent) !important; border-bottom: 1px solid rgba(26,159,255, 0.18) !important; } html[data-sglv-theme="blue"] .sgc-panel .sglv-goty-year-chip.active { background: linear-gradient(135deg, rgba(26,159,255, 0.4), rgba(59, 130, 246, 0.25)) !important; border-color: rgba(26,159,255, 0.6) !important; box-shadow: 0 0 12px rgba(26,159,255, 0.35) !important; } html[data-sglv-theme="blue"] .sglv-skel-stage-fill { background: linear-gradient(90deg, #66c0f4, #66c0f4) !important; } html[data-sglv-theme="blue"] .sglv-dlc-loading .sglv-dlc-spin { color:#66c0f4 !important; } html[data-sglv-theme="blue"] .sglv-dlc-loading-bar-fill { background:linear-gradient(90deg,#66c0f4,#66c0f4) !important; } html[data-sglv-theme="blue"] .sglv-dlc-type-badge-dlc { background: rgba(26,159,255, 0.15) !important; color: #66c0f4 !important; border: 1px solid rgba(26,159,255, 0.25) !important; } html[data-sglv-theme="blue"] .sgc-panel .sglv-stat-row + .sglv-progress .sglv-progress-fill { background: linear-gradient(90deg, #4caf50, #22d3ee, #66c0f4, #66c0f4) !important; } /* ########## Part D: 手工精调(侧边栏靛蓝暗基底→Steam 深蓝 + 标题栏柔化 + 收藏库渐变保深度) ########## */ :root { --sgis-bg: linear-gradient(180deg, rgba(23, 26, 33, 0.98) 0%, rgba(13, 19, 30, 0.99) 100%) !important; --sgis-bg-solid: #10151d !important; --sgis-bg-2: rgba(19, 25, 36, 0.85) !important; --sgis-bg-3: rgba(24, 32, 48, 0.7) !important; } .sgis-tabs { background: linear-gradient(180deg, rgba(23, 26, 33, 0.7) 0%, rgba(13, 19, 30, 0.85) 100%) !important; } .sgis-header { background: linear-gradient(135deg, rgba(102, 192, 244, 0.12) 0%, rgba(59, 130, 246, 0.07) 50%, var(--sgis-bg-2) 100%) !important; border-bottom: 1px solid rgba(102, 192, 244, 0.18) !important; } .sgis-header::before { background: radial-gradient(circle, rgba(102, 192, 244, 0.12) 0%, transparent 65%) !important; } .sgis-header-decoration { background: linear-gradient(90deg, transparent 0%, rgba(102, 192, 244, 0.3) 50%, transparent 100%) !important; } #sgis-panel { border-left: 1px solid rgba(102, 192, 244, 0.2) !important; } #sgis-panel::before { background: linear-gradient(90deg, transparent 0%, rgba(102, 192, 244, 0.35) 50%, transparent 100%) !important; } html[data-sglv-theme="blue"] .sglv-skel-stage-fill { background: linear-gradient(90deg, #1a9fff, #66c0f4) !important; } html[data-sglv-theme="blue"] .sglv-dlc-loading-bar-fill { background: linear-gradient(90deg, #1a9fff, #66c0f4) !important; } html[data-sglv-theme="blue"] .sgc-panel .sglv-stat-row + .sglv-progress .sglv-progress-fill { background: linear-gradient(90deg, #4caf50, #22d3ee, #1a9fff, #66c0f4) !important; } /* ---- D5. 成就页 → 经典蓝统一视觉 ---- * 痛点: 页面原为 GitHub 暗底+多彩语义第三风格,蓝色铬件下绿色稀有度条/彩虹进度条/金青横幅 * 混杂扎眼。收敛策略: 页面底转 Steam 深蓝;稀有度只保留角标圆点与低饱和左条(信息不丢); * 进度/徽章/NEW/横幅/环形数值统一蓝系;已解锁名称与计数文字换柔和浅蓝(降刺眼度)。 */ .sglv-ach-layout, .sglv-sc-wrap { background: linear-gradient(180deg, #141c28 0%, #0e141d 100%) !important; } .sglv-ach-sidebar { background: linear-gradient(180deg, rgba(18, 25, 36, 0.95) 0%, rgba(12, 17, 25, 0.95) 100%) !important; border-right-color: rgba(102, 192, 244, 0.08) !important; } .sglv-ach-sidebar-header { border-bottom-color: rgba(102, 192, 244, 0.08) !important; } .sglv-ach-user-card { border-bottom-color: rgba(102, 192, 244, 0.06) !important; } .sglv-ach-card { background: linear-gradient(135deg, rgba(16, 24, 36, 0.6), rgba(24, 34, 50, 0.4)) !important; border-color: rgba(102, 192, 244, 0.08) !important; } .sglv-ach-card:hover { border-color: rgba(102, 192, 244, 0.3) !important; } .sglv-ach-card::before { opacity: 0.45 !important; } .sglv-ach-card.unlocked { border-color: rgba(102, 192, 244, 0.18) !important; background: linear-gradient(135deg, rgba(102, 192, 244, 0.05), rgba(13, 17, 23, 0.4)) !important; } .sglv-ach-card.unlocked .sglv-ach-card-name { color: #d9e8f7 !important; } .sglv-ach-card.unlocked .sglv-ach-card-pts { background: rgba(102, 192, 244, 0.1) !important; border-color: rgba(102, 192, 244, 0.25) !important; color: #a9d4f5 !important; } .sglv-ach-card-new-tag { background: #1a9fff !important; } .sglv-ach-card-progress { background: rgba(102, 192, 244, 0.08) !important; } .sglv-ach-card.unlocked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #1a9fff, #66c0f4) !important; box-shadow: 0 0 8px rgba(102, 192, 244, 0.3) !important; } .sglv-ach-card.locked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #4c6b87, #66c0f4) !important; box-shadow: none !important; } .sglv-ach-card.unlocked .sglv-ach-card-progress-val { color: #7fb8e0 !important; } .sglv-ach-content-count { background: rgba(102, 192, 244, 0.1) !important; color: #a9d4f5 !important; } .sglv-ach-ring-bg { stroke: rgba(102, 192, 244, 0.1) !important; } .sglv-ach-ring-val { background: linear-gradient(135deg, #1a9fff, #a3d5f7) !important; -webkit-background-clip: text !important; background-clip: text !important; } .sglv-ach-steam-banner { background: linear-gradient(135deg, rgba(102, 192, 244, 0.08), rgba(26, 159, 255, 0.04)) !important; border-color: rgba(102, 192, 244, 0.15) !important; } .sglv-ach-steam-banner::after { background: radial-gradient(circle, rgba(102, 192, 244, 0.1), transparent 60%) !important; } .sglv-ach-steam-banner-icon { background: linear-gradient(135deg, rgba(102, 192, 244, 0.18), rgba(26, 159, 255, 0.1)) !important; } .sglv-ach-steam-banner-icon svg { color: #66c0f4 !important; } .sglv-ach-steam-banner-val { background: linear-gradient(135deg, #66c0f4, #22d3ee) !important; -webkit-background-clip: text !important; background-clip: text !important; } .sglv-ach-user-pts strong { color: #a9d4f5 !important; } `; //列表视图固定 2 列等宽修复 —— 共享库 .sglv-list-view 用 repeat(2, 1fr), // 1fr 轨道的自动最小尺寸为 min-content,长游戏名/不可收缩徽章会把轨道撑破, // 导致两列宽度不等、整行溢出容器(右列被面板边缘裁切)。 // minmax(0,1fr) 将轨道下限钉死为 0,配合 .sglv-list-item min-width:0 + overflow:hidden, // 列宽恒等且永不超出容器;内部文字经既有的 ellipsis 规则正常截断。 const CSS_LIST_FIX = ` /* ====== 列表视图固定 2 列等宽(愿望单等所有 list 视图共用) ====== */ .sglv-list-view { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; width: 100% !important; max-width: 100% !important; } .sglv-list-view > .sglv-list-item { min-width: 0 !important; max-width: 100% !important; overflow: hidden !important; } .sglv-list-view .sglv-list-info { min-width: 0 !important; max-width: 100% !important; overflow: hidden !important; } .sglv-list-view .sglv-list-meta { min-width: 0 !important; max-width: 100% !important; overflow: hidden !important; } /* ====== v2.31.6 长列表渲染治理:游戏库屏外条目降级渲染 ====== 背景:一页 48 项(横版 24 项),每项含封面图 + 标题 + 徽章, 切视图/筛选/排序/翻页都会整块重建 DOM,屏外条目白白参与布局与绘制。 实测(Chromium,48 张卡片):布局 17.5ms → 1.1ms,绝对节省 16.4ms(超过一帧预算)。 写法要点: - 用 auto(不用 hidden):hidden 会让条目不可被 Ctrl+F 搜到; - contain-intrinsic-size 的 auto 前缀让浏览器自己记住上次真实高度, 因此无需 JS 测高——也就没有"重复测量导致高度越改越小"的老问题, 面板 resize 导致列宽变化时浏览器也会自动重新学习; - 作用域限定 .sglv-content(游戏库主滚动容器), 不影响愿望单/系列/云存档等共用 renderGameView 工厂的其他页面; - 刻意不加 contain:条目内含 position:absolute 的浮层(徽章/时长角标/斜切带), 加了会被 paint containment 裁剪。 */ .sglv-content .sglv-game-card { content-visibility: auto; contain-intrinsic-size: auto var(--sglv-h, 200px); } .sglv-content .sglv-cover-card { content-visibility: auto; contain-intrinsic-size: auto var(--sglv-h, 190px); } .sglv-content .sglv-list-item { content-visibility: auto; contain-intrinsic-size: auto var(--sglv-h, 62px); } `; //导航栏二级菜单——点击接管 + fixed 停靠(修复"游戏收藏二级菜单无法打开") // 根因: ① 共享库纯 CSS :hover 下拉在触屏/Steam 客户端 CEF 无 hover 事件不可达, fab 也无下拉 // 箭头零可供性; ② 降级分支(无 .supernav_container 的社区页/React 新布局)曾用 // style.cssText 写 position:fixed, 被共享库 .sglv-menu-wrap{position:relative!important} // 完全覆盖(stylesheet !important 胜 inline 非 important), 菜单被钉在文档流末尾。 // 方案: 点击 ▾ 开关切换 .sglv-open 类显隐; 停靠态 position:fixed 摆脱祖先 overflow 裁剪; // !important 冲突用 CSS 自定义属性绕开(var 供值, !important 留在样式表侧); // 开态的 (0,4,0)/(0,3,0) 特异性均不低于 hover 关闭规则, 开+悬停不会闪隐。 const CSS_MENU = ` /* ====== 导航栏二级菜单——点击接管 + fixed 停靠 ====== */ /*子菜单竖排 + 自带底板(不再依赖共享库)。根因: .sglv-submenu-item{display:block} 只存在于 @require 外部库 sglv-shared-css, 该库加载失败时 CSS_STYLES 为空串(见 v2.7.1 注释), 两个 <a> 退回 inline 默认值 → 「游戏收藏 / 游戏数据」横排成一行, 且 ▾ 开关因 Steam .menuitem{float:left} 换行堆到「游戏库」上方。此处在主脚本内以 !important 兜底: ① menuWrap 改 inline-flex + 子元素 float:none —— 游戏库与 ▾ 恒在同一行; ② 子菜单项强制 display:block + width:100% —— 恒为竖排, 任何加载顺序都不横排; ③ 子菜单自带 min-width/底板/圆角/阴影 —— 共享库缺失时也不会"裸奔"贴到页面上。 停靠坐标仍由 JS 按 fab.getBoundingClientRect() 写入 --sglv-sub-*(见 dockSubmenu)。 */ .sglv-menu-wrap { display: inline-flex !important; align-items: center !important; vertical-align: middle !important; } .sglv-menu-wrap > .sglv-fab { float: none !important; } /*完美复刻 Steam 原生 supernav 下拉(对照 雅俊杰汐→主页/讨论/创意工坊/市场/实况直播) 面板: rgba(58,79,96,.95) = Steam supernav dropdown token / 无圆角无描边 / 轻投影; 菜单项: 14px 常规字重 + #c7d5e0 + 行高 30px + 左右 16px 内边距(与 Steam 实测留白一致); 悬停: rgba(255,255,255,.12) 浅色高亮 = Steam 原生 hover, 不是蓝底也不是白色块; 父项「游戏库」展开时**不做**激活底色 —— Steam 展开父项不变色, 此前误加的 rgba(255,255,255,.12) 父项底 + .is-active 纯白块是"看起来不一样"的主因, 均已移除。 选中态(is-active)改为白字 + 600 字重 + 极浅底, 与 Steam 视觉体系不冲突。 */ .sglv-submenu { display: none !important; box-sizing: border-box !important; width: max-content !important; min-width: 120px !important; max-width: 260px !important; padding: 4px 0 !important; background: rgba(58, 79, 96, 0.95) !important; border: none !important; border-radius: 0 !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important; overflow: hidden !important; z-index: 9999 !important; } .sglv-submenu-item { display: block !important; float: none !important; width: 100% !important; box-sizing: border-box !important; height: 30px !important; line-height: 30px !important; padding: 0 16px !important; font-size: 14px !important; font-weight: 400 !important; color: #c7d5e0 !important; background: transparent !important; white-space: nowrap !important; text-decoration: none !important; } .sglv-submenu-item:hover { background: rgba(255, 255, 255, 0.12) !important; color: #ffffff !important; } .sglv-submenu-item.is-active { background: rgba(255, 255, 255, 0.06) !important; color: #ffffff !important; font-weight: 600 !important; } .sglv-menu-wrap.sglv-open .sglv-submenu { display: block !important; } .sglv-menu-wrap.sglv-open:hover .sglv-submenu { display: block !important; } .sglv-menu-wrap:hover .sglv-submenu { display: none !important; } .sglv-submenu.sglv-submenu-docked { position: fixed !important; top: var(--sglv-sub-top, 100%) !important; left: var(--sglv-sub-left, 0) !important; overflow: visible !important; } /* 热区桥:向上盖住按钮与菜单之间的缝。overflow:hidden 会把这段裁掉,停靠态必须改回 visible。 */ .sglv-submenu.sglv-submenu-docked::before { content: '' !important; position: absolute !important; left: 0 !important; right: 0 !important; top: -10px !important; height: 10px !important; display: block !important; background: transparent !important; } .sglv-menu-wrap.sglv-menu-fixed { position: fixed !important; top: 14px !important; right: 160px !important; z-index: 99999 !important; display: inline-flex !important; } /* React 商店头(愿望单等页没有 .supernav_container):入口插在「客服」同一条 flex 行里, 外观由运行时复制的头部链接 class 决定。这里只取消浮动定位,避免再钉到右上角。 */ .sglv-menu-wrap.sglv-menu-in-header { position: relative !important; top: auto !important; right: auto !important; margin: 0 !important; flex: 0 0 auto !important; float: none !important; z-index: 2 !important; align-items: stretch !important; align-self: stretch !important; } .sglv-menu-wrap.sglv-menu-in-header > .sglv-fab { height: 100% !important; box-sizing: border-box !important; } `; //游玩数据页样式 —— 总览(HERO + Top20)/ 游玩分析(集中度/活跃度/分档/雷达)共用基础面板 / // 全部游戏排行全屏浮窗。注意: 浮窗样式必须写在主脚本(共享库 .sglv-overlay 为 display:none, // 且库经 GreasyFork @require 加载,本地改库文件不生效)。 const CSS_PLAYDATA = ` /* ====== 游玩数据页(排行 + 洞察) ====== */ .sglv-pt-hero-kpi { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; flex-shrink: 0; } /* HERO KPI 卡宽松化:Top20 可内部滚动后不再极限压缩——恢复呼吸感 padding/字号 */ .sglv-pt-hero-kpi .sglv-playtime-kpi-card { padding: 8px 12px 6px; } .sglv-pt-hero-kpi .sglv-playtime-kpi-value { font-size: 20px; } .sglv-pt-hero-kpi .sglv-playtime-kpi-label { font-size: 11.5px; } .sglv-pt-hero-kpi .sglv-playtime-kpi-sub { font-size: 10px; } .sglv-pt-panel { background: var(--sglv-section-bg, rgba(255,255,255,0.02)); border: 1px solid var(--sglv-border, rgba(255,255,255,0.08)); border-radius: var(--sglv-radius, 10px); padding: 14px 16px; min-width: 0; } .sglv-pt-panel-body { display: flex; flex-direction: column; gap: 8px; min-width: 0; } /* v2.32.0 起总览瘦身为「HERO + Top20」直排结构(.sglv-pt-main-row 双栏已废弃), 时长集中度/活跃度分层迁入「游玩分析」Tab——旧双栏选择器一并清除 */ .sglv-pt-conc-summary { display: flex; flex-wrap: wrap; gap: 6px; } .sglv-pt-conc-chip { font-size: 11.5px; padding: 3px 10px; border-radius: 999px; background: rgba(102,192,244,0.1); border: 1px solid rgba(102,192,244,0.22); color: #c7d5e0; } .sglv-pt-conc-chip b { color: var(--sglv-accent, #66c0f4); font-weight: 700; } .sglv-pt-conc-note { font-size: 11.5px; color: var(--sglv-text-secondary, #8a9ba8); line-height: 1.5; } .sglv-pt-conc-chart { width: 100%; height: 160px; display: flex; align-items: center; } .sglv-pt-conc-chart .sglv-trend-svg { height: auto !important; max-height: 160px; width: 100% !important; } .sglv-pt-layer-row { cursor: pointer; border-radius: 4px; padding: 2px 6px; transition: background .15s; } .sglv-pt-layer-row:hover { background: rgba(102,192,244,0.08); } /* 活跃度分层面板隐藏"点击任一分层查看该层排行"提示行(远端 PTW 库尚未更新, 主脚本 CSS 兜底,避免面板体溢出容器) */ #sglv-pt-act-body > .sglv-pt-conc-note { display: none !important; } .sglv-pt-dust { margin-top: 8px; padding-top: 10px; border-top: 1px dashed rgba(255,255,255,0.08); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11.5px; color: var(--sglv-text-secondary, #8a9ba8); } .sglv-pt-dust b { color: #fbbf24; } .sglv-pt-dust-btn { border: 1px solid rgba(251,191,36,0.35); background: rgba(251,191,36,0.1); color: #fbbf24; border-radius: 4px; font-size: 11.5px; padding: 2px 9px; cursor: pointer; } .sglv-pt-dust-btn:hover { background: rgba(251,191,36,0.2); } /*Top20 单列长列表——列表自身滚动后放开压缩:行高 ~34px, icon 28px / padding 6-10px / gap 10px / rank 24×24,呼吸感优先于"一屏看完" */ .sglv-pt-top-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; } .sglv-pt-top-grid .sglv-pt-top-item { margin-bottom: 0; min-width: 0; padding: 6px 10px; gap: 10px; } .sglv-pt-top-col .sglv-pt-icon { width: 28px; height: 28px; border-radius: 4px; } .sglv-pt-top-col .sglv-pt-rank { width: 24px; height: 24px; font-size: 11px; border-radius: 5px; } .sglv-pt-top-col .sglv-pt-name { font-size: 13px; line-height: 1.3; } .sglv-pt-top-col .sglv-pt-meta { font-size: 11px; line-height: 1.3; margin-top: 2px; } .sglv-pt-top-col .sglv-pt-info { display: flex; flex-direction: column; gap: 0; justify-content: center; } .sglv-pt-top-col .sglv-pt-hours { min-width: 48px; font-size: 12.5px; } .sglv-pt-top-col .sglv-pt-bar-wrap { height: 5px; } /* Top20 段标题:14px 字号 + 10px 下间距 */ .sglv-pt-top-col > .sglv-playtime-section-title { font-size: 14px; margin-bottom: 10px; } /* panel 内 section title(集中度/活跃度分层):14px 字号 + 10px 下间距 */ .sglv-pt-panel > .sglv-playtime-section-title { font-size: 14px; margin-bottom: 10px; } /* 活跃度分层行:行距/轨道放宽,与面板留白节奏一致(游玩分析 Tab) */ .sglv-dist-bar-row { margin-bottom: 6px; gap: 10px; } .sglv-dist-bar-track { height: 16px; } .sglv-dist-bar-label { width: 72px; font-size: 11.5px; } .sglv-dist-bar-value { width: 84px; font-size: 11.5px; } .sglv-ptwin-open { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 6px; border: 1px solid rgba(102,192,244,0.3); background: rgba(102,192,244,0.1); color: var(--sglv-accent, #66c0f4); font-size: 11px; font-weight: 600; cursor: pointer; transition: var(--sglv-transition); } .sglv-ptwin-open:hover { background: rgba(102,192,244,0.2); } .sglv-ptwin-open svg { width: 12px; height: 12px; } /* 主区布局:HERO 固定 → Top20 卡片占满剩余高度 → 列表自身纵向滚动(sglv-pt-tab 仅游玩数据页,勿波及统计仪表页) */ .sglv-body > .sglv-pt-tab { overflow: hidden !important; padding: 12px 14px !important; gap: 12px !important; } .sglv-body > .sglv-pt-tab > .sglv-playtime-wrap { flex: 1 1 0 !important; min-height: 0 !important; overflow: hidden !important; gap: 12px !important; } /* v2.33.1 修复:v2.32.0 重构后总览 DOM 变为 .sglv-pt-overview 直排(HERO + Top20), 旧 .sglv-pt-main-row 双栏选择器失配 → 面板退化 block、#sglv-pt-top-list 无高度约束, 溢出被内层 overflow:hidden 裁掉且无滚动条。改为直接锚定 .sglv-pt-overview 新结构: 面板 flex column 撑满剩余高度,滚动交还列表自身;不锚定 .sglv-body, 全屏浮窗 overview Tab 同构 DOM 同规则受益 */ .sglv-pt-overview > .sglv-pt-top-col { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } .sglv-pt-top-col > .sglv-playtime-section-title { flex-shrink: 0; } .sglv-pt-top-col > #sglv-pt-top-list { flex: 1 1 auto; min-height: 140px; max-height: none; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; scrollbar-gutter: stable; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.14) transparent; } .sglv-pt-top-col > #sglv-pt-top-list::-webkit-scrollbar { width: 6px; } .sglv-pt-top-col > #sglv-pt-top-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.14); border-radius: 3px; } .sglv-pt-top-col > #sglv-pt-top-list::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.24); } /* 列表不足一屏(游戏 <20 款)时行块顶部对齐,不随 flex 拉伸出空隙 */ .sglv-pt-top-grid { align-content: start; } /* ≤900px:单列流式,恢复整页滚动 */ @media (max-width: 900px) { .sglv-body > .sglv-pt-tab, .sglv-body > .sglv-pt-tab > .sglv-playtime-wrap { overflow-y: auto !important; } .sglv-pt-overview > .sglv-pt-top-col { flex: 0 0 auto; } .sglv-pt-top-col > #sglv-pt-top-list { overflow-y: visible; min-height: 0; } } /* ====== 全部游戏排行遗留壳样式 ====== .sglv-ptwin-overlay/.sglv-ptwin/.sglv-ptwin-head/tabs/body/pane 系列已废弃(排行已改走 SGLVPTW 框架浮窗), 仍在使用的:.sglv-ptwin-open(Top20 卡片头「全部游戏排行」按钮)、 .sglv-pt-all-loading / .sglv-pt-all-hint(月历/排行 Tab 加载态)。 */ .sglv-pt-all-loading { padding: 24px 0; text-align: center; font-size: 12px; color: var(--sglv-text-secondary, #8a9ba8); } .sglv-pt-all-hint { margin: 0 0 8px; font-size: 11px; line-height: 1.5; color: #fbbf24; } /* ====== 全部游戏排行过滤 chips(隐藏从未游玩/隐藏家庭共享/正倒序切换) ====== */ .sglv-pt-filter-chip { border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); color: #8a9ba8; border-radius: 999px; font-size: 11px; padding: 3px 10px; cursor: pointer; white-space: nowrap; } .sglv-pt-filter-chip:hover { color: #fff; } .sglv-pt-filter-chip.active { color: var(--sglv-accent, #66c0f4); border-color: rgba(102,192,244,0.35); background: rgba(102,192,244,0.1); } /* ====== 全部游戏列表行首游戏图标 ====== */ /* 紧凑 22px(共享库 .sglv-pt-icon 为 Top20 的 40px;本列表行密 padding 6px/字号 12px, 40px 会把行高从 ~29px 撑到 ~52px,密集排行可读性下降)。固定尺寸 + flex-shrink:0 保证图标懒加载过程零布局抖动;object-fit:cover 兼容竖版/横版资产裁切一致。 */ .sglv-pt-all-icon { width: 22px; height: 22px; border-radius: 3px; flex-shrink: 0; object-fit: cover; background: rgba(255,255,255,0.05); } /* ====== 家庭成员对比(对比条 + 可展开 Top20) ====== */ .sglv-fam-list { margin-top: 8px; } .sglv-fam-item { background: rgba(255,255,255,0.025); border: 1px solid var(--sglv-border, rgba(255,255,255,0.08)); border-radius: 8px; margin-bottom: 8px; overflow: hidden; } .sglv-fam-item.is-me { border-color: rgba(102,192,244,0.28); background: rgba(102,192,244,0.05); } .sglv-fam-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; cursor: pointer; } .sglv-fam-head:hover { background: rgba(255,255,255,0.04); } .sglv-fam-rank { width: 22px; height: 22px; flex: 0 0 22px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; background: rgba(255,255,255,0.06); color: #94a3b8; } .sglv-fam-item.is-me .sglv-fam-rank { background: rgba(102,192,244,0.2); color: #66c0f4; } .sglv-fam-name { flex: 1; min-width: 0; font-size: 13px; font-weight: 600; color: #e0e0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-fam-item.is-me .sglv-fam-name { color: #66c0f4; } .sglv-fam-hours { flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #fbbf24; white-space: nowrap; } .sglv-fam-chev { flex: 0 0 auto; font-size: 11px; color: #8a9ba8; transition: transform .2s ease; } .sglv-fam-item.open .sglv-fam-chev { transform: rotate(180deg); } .sglv-fam-track { height: 5px; margin: 0 10px 8px; background: rgba(255,255,255,0.06); border-radius: 3px; overflow: hidden; } .sglv-fam-fill { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg, #4caf50, #2e7d32); transition: width .4s ease; min-width: 2px; } .sglv-fam-item.is-me .sglv-fam-fill { background: linear-gradient(90deg, #66c0f4, #4a9eda); } .sglv-fam-games { display: none; padding: 6px 10px 10px; border-top: 1px dashed rgba(255,255,255,0.08); } .sglv-fam-item.open .sglv-fam-games { display: block; } .sglv-fam-game { display: flex; align-items: center; gap: 8px; padding: 3px 0; } .sglv-fam-game-rank { flex: 0 0 20px; font-size: 10px; color: #64748b; text-align: right; } /*家庭对比 Top20 行首游戏图标——16px 紧凑尺寸(行高由 12px 名称行 ~17px 决定, 16px 图标不撑高行;固定尺寸 + flex-shrink:0 保证懒加载零布局抖动) */ .sglv-fam-game-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; object-fit: cover; background: rgba(255,255,255,0.05); } .sglv-fam-game-name { flex: 1; min-width: 0; font-size: 12px; color: #c7d5e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-fam-game-bar { flex: 0 0 90px; height: 4px; border-radius: 2px; background: rgba(255,255,255,0.06); overflow: hidden; } .sglv-fam-game-bar > i { display: block; height: 100%; border-radius: 2px; background: linear-gradient(90deg, #66c0f4, #4caf50); } .sglv-fam-game-hours { flex: 0 0 58px; text-align: right; font-size: 11px; font-weight: 600; color: #fbbf24; } .sglv-fam-empty { padding: 8px 2px; font-size: 11px; color: var(--sglv-text-secondary, #8a9ba8); } .sglv-fam-refresh { flex: 0 0 auto; width: 24px; height: 24px; border-radius: 5px; border: 1px solid rgba(102,192,244,0.3); background: rgba(102,192,244,0.1); color: var(--sglv-accent, #66c0f4); cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; } .sglv-fam-refresh:hover { background: rgba(102,192,244,0.2); } .sglv-fam-refresh svg { width: 12px; height: 12px; } @media (max-width: 1100px) { .sglv-pt-hero-kpi { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 600px) { .sglv-pt-hero-kpi { grid-template-columns: repeat(2, minmax(0, 1fr)); } } /* ====== 全部游戏浮窗 · 入库月历 Tab ====== */ /* v2.22.6 布局最终形态:**选择器固定 + 详情自带滚动条**(用户明确要求两条): pane(不滚) = toolbar(固定) + grid(固定·完整 12 月卡) + timeline(唯一滚动区) —— 走过的弯路与各自的失效点: ① grid 收缩 + timeline min-height → timeline 保底生效后,flex 剩余收缩压力 全部转嫁 grid,选择器被压到 ~140px(用户截图:日期行被切半、7-12 月整行消失); ② grid 固定 + timeline{flex:1 1 0} 吃剩余 → 选择器完整,但 800px 视口下 timeline 只剩 4px,详情几乎不可见; ③ 滚动上移到 pane → 选择器/详情都正常,但**滚动条跑到了整个 Tab 区域的最右缘** (贴着窗口边框),用户反馈「滚动条消失、无法滚动查看」—— 技术上可滚,但滚动入口视觉上不可见 = 等于没有。 现方案:滚动条还给详情区(.sglv-ptcal-timeline overflow-y:auto), grid 固定不压缩,空间冲突由「矮视口紧凑月历」化解(见下方 @media)。*/ .sglv-ptcal-pane { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; overscroll-behavior: contain; } .sglv-ptcal-toolbar { display: flex; align-items: center; gap: 10px 8px; flex-wrap: wrap; flex: 0 0 auto; } .sglv-ptcal-yearnav { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .sglv-ptcal-yearnav-btn { width: 24px; height: 24px; border: 1px solid rgba(102,192,244,0.3); background: rgba(102,192,244,0.1); color: var(--sglv-accent, #66c0f4); border-radius: 5px; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center; padding: 0; } .sglv-ptcal-yearnav-btn:hover { background: rgba(102,192,244,0.2); } .sglv-ptcal-yearnav-btn:disabled { opacity: 0.35; cursor: default; } .sglv-ptcal-year-label { min-width: 52px; text-align: center; font-size: 15px; font-weight: 700; color: #fff; } /* 年份胶囊:单行横向滚动,但宽度有下限保护。 v2.22.6修正:原写 min-width:0,flex 收缩时容器被压到 ~145px, 13 个胶囊(flex:0 0 auto 不可压)反而把内容挤成「一排小点」; 下限 min(200px,45%) 保证至少放得下 4 个胶囊,多出的年份走横向滚动。 上限 min(52%,460px) 取代旧硬编码 46%(46% 在 1080px 窗口只有 ~500px, 叠加右侧 summary 后必然溢出被裁)。*/ .sglv-ptcal-year-chips { display: flex; gap: 4px; flex-wrap: nowrap; flex: 0 1 auto; min-width: min(200px, 45%); max-width: min(52%, 460px); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 1px; } .sglv-ptcal-year-chip { flex: 0 0 auto; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); color: #8a9ba8; border-radius: 999px; font-size: 11px; padding: 2px 10px; cursor: pointer; } .sglv-ptcal-year-chip:hover { color: #fff; } .sglv-ptcal-year-chip.active { color: var(--sglv-accent, #66c0f4); border-color: rgba(102,192,244,0.35); background: rgba(102,192,244,0.1); } /*「今年」胶囊——当前年即使在筛选集内无数据(如吃灰榜入库超 1 年)也固定占位, 绿点标识自然年"此刻",与入库数量绿色阶同语义但仅为标记点 */ .sglv-ptcal-year-chip.now::after { content: ''; display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: #39d353; margin-left: 5px; vertical-align: 1px; } .sglv-ptcal-year-chip.now.active::after { box-shadow: 0 0 5px rgba(57,211,83,0.9); } /* 统计区:允许整块换行到下一行,不再与年份胶囊抢同一行导致右侧被裁 */ .sglv-ptcal-summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; margin-left: auto; min-width: 0; } .sglv-ptcal-chip { font-size: 11px; padding: 2px 9px; border-radius: 999px; background: rgba(102,192,244,0.08); border: 1px solid rgba(102,192,244,0.2); color: #c7d5e0; white-space: nowrap; } .sglv-ptcal-chip b { color: var(--sglv-accent, #66c0f4); font-weight: 700; } .sglv-ptcal-chip.peak b { color: #fbbf24; } /*「仅个人游戏」过滤 chip(复用 ptcal-chip 底样, active 高亮 accent) */ .sglv-ptcal-chip.tgl { cursor: pointer; } .sglv-ptcal-chip.tgl:hover { color: #fff; } .sglv-ptcal-chip.tgl.active { color: var(--sglv-accent, #66c0f4); border-color: rgba(102,192,244,0.45); background: rgba(102,192,244,0.16); } .sglv-ptcal-legend { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; color: #8a9ba8; flex-shrink: 0; } .sglv-ptcal-legend .swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } /*6+6 月历组合(每行 6 个月 × 2 行, 替代原 8+4)—— 卡片宽度封顶 240px 确保宽度适中(全屏下不过度拉伸、日格保持可点尺寸), space-between 均匀分配余白; <1200px 退化 4 列, <760px 退化 2 列(小屏卡片可用性优先) v2.22.6:flex:0 0 auto —— 12 个月卡**永不收缩、永不内部滚动**, 月历选择区整体固定在顶部,时间线只能从它下方开始。 */ .sglv-ptcal-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 240px)); justify-content: space-between; gap: 10px 8px; flex: 0 0 auto; } @media (max-width: 1400px) { .sglv-ptcal-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } } /* v2.22.6:4 列断点 1200→1000px。1200px 以下就退 4 列时,12 张月卡排成 3 行(~680px), 在「窄而高」的窗口(如 1280×1000)里会把详情压到百来像素;收到 1000px 后 1100~1200px 的常见宽度保持 6 列 2 行,详情可视区明显更充裕。 */ @media (max-width: 1000px) { .sglv-ptcal-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } @media (max-width: 760px) { .sglv-ptcal-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .sglv-ptcal-month { background: rgba(255,255,255,0.025); border: 1px solid var(--sglv-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 8px 8px 6px; min-width: 0; } .sglv-ptcal-month-head { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; margin-bottom: 5px; } .sglv-ptcal-month-name { font-size: 12px; font-weight: 700; color: #c7d5e0; } .sglv-ptcal-month-count { font-size: 10px; color: #8a9ba8; } .sglv-ptcal-month-count b { color: var(--sglv-accent, #66c0f4); } .sglv-ptcal-week, .sglv-ptcal-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; } .sglv-ptcal-wd { font-size: 9px; color: #64748b; text-align: center; padding-bottom: 1px; } .sglv-ptcal-day { aspect-ratio: 1; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 9px; line-height: 1; color: #8a9ba8; background: rgba(255,255,255,0.03); min-width: 0; } .sglv-ptcal-day.empty { background: transparent; } .sglv-ptcal-day.future { background: rgba(255,255,255,0.012); color: #3d4a5a; } .sglv-ptcal-day.has { cursor: pointer; color: #fff; font-weight: 600; } .sglv-ptcal-day.has:hover { outline: 1px solid var(--sglv-accent, #66c0f4); } .sglv-ptcal-day.sel { outline: 2px solid var(--sglv-accent, #66c0f4); } .sglv-ptcal-day.lv1 { background: #0e4429; } .sglv-ptcal-day.lv2 { background: #006832; } .sglv-ptcal-day.lv3 { background: #26a641; } .sglv-ptcal-day.lv4 { background: #39d353; color: #062b13; } /*当月入库时间线(常驻展示,倒序:月末 → 月初) —— 选中态背景色与该日在月历中的「入库数量色阶」同色(lv1~lv4), --ptcal-c 为该级实色(描边/左侧色条),--ptcal-c-soft 为同色低透明背景。 */ /*tl-body 不设 max-height——滚动由 .sglv-ptcal-timeline 容器承担 (v2.20.1 曾由外层 pane 统一滚动, 现日历区固定后时间线独立滚动), 分组不再被截断。 */ /* 时间线 = **详情区自己的滚动容器**(滚动条就在详情区内,用户可直接看到并悬停滚动)。 min-height:140px 是「详情绝不被压成几像素」的硬保证:极窄而高的窗口 (如 1080×1000 命中 max-width:1200px 的 4 列断行,月历变3 行 ~680px) 也不会把详情挤没 —— 此时由 pane 滚动承载,超出部分仍可达。 scrollbar-gutter:stable 让滚动条常驻占位,避免内容随滚动条出现/消失抖动。 */ .sglv-ptcal-timeline { flex: 1 1 auto; min-height: 140px; overflow-y: auto; overscroll-behavior: contain; border-top: 1px dashed var(--sglv-border-subtle, rgba(102,192,244,.13)); padding: 0 2px 4px 0; scrollbar-gutter: stable; } /* 矮视口紧凑月历(视口高 ≤900px):12 张标准月卡(~450px) + 详情可视区无法在 800px 视口的 ~598px pane 内共存。这里把月卡切紧凑档(卡宽 240→170px、 内距/日格间距收紧),两行总高降到 ~390px,释放的高度全部给详情区, 实现「选择器完整 + 详情可滚动」同时成立;日格仍有 ~22px,鼠标点击可用。 */ @media (max-height: 900px) { .sglv-ptcal-grid { grid-template-columns: repeat(6, minmax(0, 170px)); gap: 8px 6px; } .sglv-ptcal-month { padding: 6px 6px 4px; } .sglv-ptcal-month-head { margin-bottom: 3px; } .sglv-ptcal-week, .sglv-ptcal-days { gap: 1px; } .sglv-ptcal-day { font-size: 8.5px; border-radius: 2px; } .sglv-ptcal-wd { font-size: 8px; padding-bottom: 0; } .sglv-ptcal-group-games { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); } } /*分组头吸顶——时间线滚动时「当月入库时间线」标题钉在详情区顶部 (自带底色遮住滚过的内容, 底部投影提示分层) v2.22.6: 底色改走 token(原为硬编码 #16202d,浅色主题下是一块深色补丁) */ .sglv-ptcal-tl-head { position: sticky; top: 0; z-index: 1; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; padding: 6px 0 8px; margin-bottom: 0; background: var(--sglv-surface-deep, #16202d); box-shadow: 0 10px 12px -10px rgba(0,0,0,0.65); } .sglv-ptcal-tl-title { font-size: 12px; font-weight: 700; color: #fff; display: inline-flex; align-items: center; gap: 6px; } .sglv-ptcal-tl-title svg { width: 13px; height: 13px; color: var(--sglv-accent, #66c0f4); } .sglv-ptcal-tl-month { font-variant-numeric: tabular-nums; color: var(--sglv-accent, #66c0f4); } .sglv-ptcal-tl-hint { font-size: 11px; color: var(--sglv-text-secondary, #8a9ba8); line-height: 1.5; } .sglv-ptcal-tl-body { display: flex; flex-direction: column; gap: 6px; } .sglv-ptcal-group { border: 1px solid rgba(255,255,255,0.06); border-left: 3px solid transparent; border-radius: 8px; background: rgba(255,255,255,0.02); padding: 6px 8px; transition: var(--sglv-transition); } .sglv-ptcal-group.lv1 { --ptcal-c: #0e4429; --ptcal-c-soft: rgba(14,68,41,0.55); } .sglv-ptcal-group.lv2 { --ptcal-c: #006832; --ptcal-c-soft: rgba(0,104,50,0.52); } .sglv-ptcal-group.lv3 { --ptcal-c: #26a641; --ptcal-c-soft: rgba(38,166,65,0.34); } .sglv-ptcal-group.lv4 { --ptcal-c: #39d353; --ptcal-c-soft: rgba(57,211,83,0.28); } .sglv-ptcal-group.sel { background: var(--ptcal-c-soft); border-color: var(--ptcal-c); box-shadow: 0 0 0 1px var(--ptcal-c); } .sglv-ptcal-group-head { display: flex; align-items: center; gap: 7px; cursor: pointer; } .sglv-ptcal-group-date { font-size: 12px; font-weight: 700; color: var(--ptcal-c, #fff); font-variant-numeric: tabular-nums; } .sglv-ptcal-group.lv4 .sglv-ptcal-group-date { color: #6ee87a; } .sglv-ptcal-group-wd { font-size: 10px; color: #64748b; } .sglv-ptcal-group-count { font-size: 10px; font-weight: 700; color: var(--ptcal-c, #c7d5e0); border: 1px solid var(--ptcal-c, rgba(255,255,255,0.2)); background: var(--ptcal-c-soft, rgba(255,255,255,0.06)); border-radius: 999px; padding: 0 8px; margin-left: auto; } .sglv-ptcal-group.lv4 .sglv-ptcal-group-count { color: #eafff0; } /*游戏条目轨道 200px→190px:单条可用宽度从 ~150px 提到 ~180px, 「永远消失的幻想乡 ~The Story of...」这类长名不再被压到只剩三四个字。 */ .sglv-ptcal-group-games { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 2px 10px; margin-top: 5px; } .sglv-ptcal-detail-item { display: flex; align-items: center; gap: 7px; padding: 3px 6px; border-radius: 5px; font-size: 12px; color: #c7d5e0; cursor: pointer; min-width: 0; } .sglv-ptcal-detail-item:hover { background: rgba(102,192,244,0.14); color: #fff; } .sglv-ptcal-detail-item img { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; } .sglv-ptcal-detail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-ptcal-detail-hours { flex-shrink: 0; font-size: 10px; font-weight: 600; color: #fbbf24; font-variant-numeric: tabular-nums; } .sglv-ptcal-detail-empty { font-size: 11px; color: var(--sglv-text-secondary, #8a9ba8); padding: 4px 0; } @media (max-width: 900px) { .sglv-ptcal-summary { margin-left: 0; } } `; const CSS_INLINE = [ CSS_ACHIEVEMENT, CSS_SHOWCASE, CSS_CLOUDSAVE, CSS_STAGE, CSS_HEADER, CSS_SGIS_ENHANCE, CSS_TOAST, CSS_APIKEY_CONSENT, CSS_SETTINGS, CSS_KPI, CSS_SUBSTATUS, CSS_COVER, CSS_PRICE, CSS_LOAD, CSS_ALIAS, CSS_PLAYDATA, CSS_LIST_FIX, CSS_MENU ].join(''); GM_addStyle(CSS_INLINE); // ==================== 主题层运行时(注册表 + 应用函数) ==================== // 设计: token 基座(sglv-libs-theme.lib.js,已内联进 PTW 库 → <style id="sglv-theme-base">) // → 共享库蓝基底(:root --sglv-*) → CSS_INLINE 模块样式 // → 主题覆盖层(本函数注入, <style id="sglv-theme-style"> 挂 head 末尾)。 // ⚠️ v2.22.6 id 分工(曾发生线上样式塌陷):本函数整体替换 id="sglv-theme-style" 的内容, // 因此该 id 归「主题覆盖层」独占;token 基座另用 id="sglv-theme-base"。 // 二者曾共用一个 id → applySglvTheme() 把基座(:root 全量 token + PTW 基座样式)整体抹掉, // padding/gap/border-radius/font-size/transition/box-shadow 集体失效,浮窗整体塌陷。 // getThemeId 依赖 storage(定义于下方 L~5800),仅在调用时求值——boot 调用位于 storage 定义之后。 const SGLV_THEMES = { purple: { id: 'purple', zh: '典雅紫', en: 'Elegant Purple', css: () => CSS_THEME_PURPLE }, blue: { id: 'blue', zh: '经典蓝', en: 'Classic Blue', css: () => CSS_THEME_BLUE }, }; const DEFAULT_SGLV_THEME = 'purple'; function getThemeId() { try { const v = storage.getUiTheme(); return SGLV_THEMES[v] ? v : DEFAULT_SGLV_THEME; } catch (e) { return DEFAULT_SGLV_THEME; } } function applySglvTheme(id) { const theme = SGLV_THEMES[id] || SGLV_THEMES[DEFAULT_SGLV_THEME]; document.documentElement.setAttribute('data-sglv-theme', theme.id); let el = document.getElementById('sglv-theme-style'); if (!el) { el = document.createElement('style'); el.id = 'sglv-theme-style'; } el.textContent = theme.css(); //每次应用都重挂 head 末尾——收藏库(sglv-game-collection)首次开面板才懒注入 // 其 <style>(物理位置晚于本元素),appendChild 移动节点保证主题层在层叠顺序上始终最后 document.head.appendChild(el); window.dispatchEvent(new CustomEvent('sglv-theme-changed', { detail: { themeId: theme.id } })); return theme.id; } //我的成就标签页 — 左右布局重构(参考设计图3) //参考成就优化版设计,增强玻璃态/渐变/微光动画/稀有度边框 // CSS 已提取到顶部: CSS_ACHIEVEMENT //个人展柜标签页样式 // CSS 已提取到顶部: CSS_SHOWCASE //云存档标签页样式 // CSS 已提取到顶部: CSS_CLOUDSAVE //StageProgress 多阶段进度条样式 // CSS 已提取到顶部: CSS_STAGE //紧凑页头 + tabs 移入 header(参考 steam-friend-manager sfd-tab-bar / sfd-tab-btn 风格) // CSS 已提取到顶部: CSS_HEADER //SGIS 侧边栏视觉增强(tab 动画 + 标题图标精致化 + 状态点) // CSS 已提取到顶部: CSS_SGIS_ENHANCE //Toast 通知 + spinner 动画样式 // CSS 已提取到顶部: CSS_TOAST // ==================== 设置页(集成在游戏库主面板内)样式 ==================== // CSS 已提取到顶部: CSS_SETTINGS //进包/卡牌 KPI 卡片颜色 + badge 样式 //卡片一行不换行 + 可点击筛选 + active 高亮 // CSS 已提取到顶部: CSS_KPI // CSS 已提取到顶部: CSS_SUBSTATUS //游戏橱窗——横版封面视图 + 卡片增强 + 排版优化 // CSS 已提取到顶部: CSS_COVER // ==================== I18N ==================== const isZh = (navigator.language || '').toLowerCase().startsWith('zh'); const T = { title: isZh ? '游戏库' : 'Game Library', tabOwned: isZh ? '游戏橱窗' : 'Game Showcase', //个人展柜标签页 tabShowcase: isZh ? '个人展柜' : 'Showcase', tabPublisher: isZh ? '系列分类' : 'By Series', tabTrend: isZh ? '入库趋势' : 'Acquisition Trends', //入库趋势动态(趋势图 + 入库时间线) trendChartTitle: isZh ? '入库趋势图' : 'Trend Chart', trendTimelineTitle: isZh ? '入库时间线' : 'Acquisition Timeline', trendTimelineTotal: isZh ? '入库总数' : 'Total', //入库频率分析 trendFreqTitle: isZh ? '入库频率分析' : 'Acquisition Frequency', trendTabTimeline: isZh ? '入库时间线' : 'Timeline', trendTabFrequency: isZh ? '频率分析' : 'Frequency', trendKpiThisYear: isZh ? '今年入库' : 'This Year', trendKpiAvgYear: isZh ? '年均入库' : 'Avg / Year', trendKpiPeakYear: isZh ? '峰值年份' : 'Peak Year', trendKpiRecent30: isZh ? '近30天' : 'Last 30 Days', trendKpiRecent90: isZh ? '近90天' : 'Last 90 Days', trendKpiActiveDays: isZh ? '活跃天数' : 'Active Days', trendKpiWeeklyAvg: isZh ? '周均入库' : 'Weekly Avg', trendKpiGames: isZh ? '款' : 'games', trendFreqByMonth: isZh ? '月度入库频率' : 'Monthly Frequency', trendFreqByWeekday: isZh ? '星期分布' : 'Weekday Distribution', trendFreqByHour: isZh ? '时段分布' : 'Hourly Distribution', trendFreqSummary: isZh ? '频率摘要' : 'Frequency Summary', trendFreqPeakMonth: isZh ? '最活跃月份' : 'Peak Month', trendFreqPeakWeekday: isZh ? '最活跃星期' : 'Peak Weekday', trendFreqPeakHour: isZh ? '最活跃时段' : 'Peak Hour', trendFreqStreak: isZh ? '最长连续天数' : 'Longest Streak', trendFreqDays: isZh ? '天' : 'days', trendFreqWeekdayLong: isZh ? '周日,周一,周二,周三,周四,周五,周六' : 'Sun,Mon,Tue,Wed,Thu,Fri,Sat', trendFreqHrLabel: isZh ? '时' : 'h', tabSettings: isZh ? '设置' : 'Settings', searchPlaceholder: isZh ? '搜索游戏名称...' : 'Search game name...', noData: isZh ? '暂无游戏数据,请点击右上角刷新按钮' : 'No game data. Click the refresh button at the top right.', noApiKey: isZh ? '请先登录 Steam 商店,或在设置中配置 API Key' : 'Login to Steam Store or configure API Key in Settings.', apiKeyLabel: 'Steam Web API Key', steamIdLabel: isZh ? 'SteamID64(留空自动检测)' : 'SteamID64 (auto-detect if empty)', save: isZh ? '保存设置' : 'Save', saved: isZh ? '已保存' : 'Saved', totalGames: isZh ? '总游戏数' : 'Total Games', ownedInDb: isZh ? '数据库已拥有' : 'Owned in DB', missingInDb: isZh ? '数据库未拥有' : 'Missing in DB', ownership: isZh ? '拥有率' : 'Ownership', page: isZh ? '页' : 'Page', prev: isZh ? '上一页' : 'Prev', next: isZh ? '下一页' : 'Next', first: isZh ? '首页' : 'First', last: isZh ? '末页' : 'Last', jumpTo: isZh ? '跳至' : 'Go to', pageOf: isZh ? '共' : 'of', owned: isZh ? '已拥有' : 'Owned', missing: isZh ? '未拥有' : 'Missing', all: isZh ? '全部' : 'All', cardView: isZh ? '卡片' : 'Card', coverView: isZh ? '横版封面' : 'Wide Cover', listView: isZh ? '列表' : 'List', fetchSuccess: isZh ? '获取成功,共 {n} 款游戏' : 'Fetched {n} games', fetchFail: isZh ? '获取失败' : 'Fetch failed', apiKeyHelp: isZh ? '脚本会自动从 Steam 商店页面获取登录凭证(access_token),无需配置 API Key 即可使用。如需获取更多数据(如游戏时长),可配置 <b>API Key</b>:打开 <a href="https://steamcommunity.com/dev/apikey" target="_blank">steamcommunity.com/dev/apikey</a> → 填写域名 → 注册即可。' : 'The script auto-detects login credentials from the Steam store page. API Key is optional for extra data (playtime). Get a free <b>API Key</b>: Visit <a href="https://steamcommunity.com/dev/apikey" target="_blank">steamcommunity.com/dev/apikey</a> → Enter domain → Register.', tabDelisted: isZh ? '绝版游戏' : 'Delisted Games', delistedTotal: isZh ? '绝版总数' : 'Total Delisted', delistedOwned: isZh ? '已拥有绝版' : 'Owned Delisted', delistedMissing: isZh ? '未拥有绝版' : 'Missing Delisted', delistedOwnership: isZh ? '绝版拥有率' : 'Delisted Ownership', delistedSearch: isZh ? '搜索绝版游戏...' : 'Search delisted games...', typeAll: isZh ? '全部类型' : 'All Types', typeDelisted: isZh ? '已下架' : 'Delisted', typePurchaseDisabled: isZh ? '购买已禁用' : 'Purchase Disabled', typeF2P: isZh ? 'F2P不可用' : 'F2P Unavailable', typeRetailOnly: isZh ? '仅零售' : 'Retail Only', typeTestApp: isZh ? '测试应用' : 'Test App', delistedOwners: isZh ? '拥有占比' : 'Owner %', delistedDate: isZh ? '下架时间' : 'Delisted Date', delistedAchievements: isZh ? '成就数' : 'Achievements', delistedKeyshops: isZh ? 'Keyshop价格' : 'Keyshop Price', // 游玩时长标签页 tabPlaytime: isZh ? '统计仪表' : 'Stats', tabPlaydata: isZh ? '游玩数据' : 'Play Data', //我的成就标签页 — 重构版 tabAchievements: isZh ? '我的成就' : 'Achievements', achCategoryCollector: isZh ? '收藏成就' : 'Collection', achCategoryPlaytime: isZh ? '时长成就' : 'Playtime', achCategoryMastery: isZh ? '专精成就' : 'Mastery', achCategoryDiversity: isZh ? '多元成就' : 'Diversity', achCategoryLoyalty: isZh ? '忠诚成就' : 'Loyalty', achCategorySpecial: isZh ? '特殊成就' : 'Special', achUnlocked: isZh ? '已解锁' : 'Unlocked', achLocked: isZh ? '未解锁' : 'Locked', achProgress: isZh ? '进度' : 'Progress', achNoData: isZh ? '暂无成就数据,请先获取游戏库' : 'No achievement data. Please fetch your library first.', achSummary: isZh ? '成就总览' : 'Achievement Summary', achSummaryDesc: isZh ? '已解锁' : 'Unlocked', achPoints: isZh ? '成就点数' : 'Points', achRarityCommon: isZh ? '普通' : 'Common', achRarityRare: isZh ? '稀有' : 'Rare', achRarityEpic: isZh ? '史诗' : 'Epic', achRarityLegendary: isZh ? '传奇' : 'Legendary', achRecentUnlocks: isZh ? '最近解锁' : 'Recent Unlocks', achTotalPoints: isZh ? '总成就点数' : 'Total Points', achCompletionRate: isZh ? '完成率' : 'Completion', achUnlockedAt: isZh ? '解锁于' : 'Unlocked at', achNoUnlocks: isZh ? '暂无已解锁成就' : 'No achievements unlocked yet', achResetConfirm: isZh ? '确定要重置所有成就解锁记录吗?此操作不可撤销。' : 'Reset all achievement unlock records? This cannot be undone.', achResetBtn: isZh ? '重置记录' : 'Reset', achAllCats: isZh ? '全部' : 'All', //全局游戏搜索(中文/拼音缩写/英文子串) globalSearchPh: isZh ? '搜索游戏(中文/拼音/英文)' : 'Search games (CN/Pinyin/EN)', gsHeader: isZh ? '搜索结果' : 'Search Results', gsEmpty: isZh ? '未匹配到游戏,试试中文/拼音首字母/英文子串' : 'No matches. Try CN/pinyin/English substring', gsMinLen: isZh ? '至少输入 1 个字符' : 'Type at least 1 character', gsTypeOwned: isZh ? '已拥有' : 'Owned', gsTypeWishlist: isZh ? '愿望单' : 'Wishlist', gsTypeStore: isZh ? '商店' : 'Store', gsType: isZh ? '类型' : 'Type', gsFooterHint: isZh ? '↑↓ 选择 · Enter 跳转 · Esc 关闭' : '↑↓ Navigate · Enter Open · Esc Close', gsOpenStore: isZh ? '在 Steam 商店打开' : 'Open in Steam Store', gsJumpToOwned: isZh ? '跳转到游戏库' : 'Jump to Library', gsJumpToWishlist: isZh ? '跳转到愿望单' : 'Jump to Wishlist', //游戏详情浮窗 dpLoading: isZh ? '正在获取游戏详情…' : 'Loading game details…', dpError: isZh ? '获取详情失败' : 'Failed to load details', dpRetry: isZh ? '重试' : 'Retry', dpDeveloper: isZh ? '开发商' : 'Developer', dpPublisher: isZh ? '发行商' : 'Publisher', dpRelease: isZh ? '发行日期' : 'Release Date', dpPlatforms: isZh ? '平台' : 'Platforms', dpGenres: isZh ? '类型' : 'Genres', dpCategories: isZh ? '特色' : 'Features', dpPrice: isZh ? '价格' : 'Price', dpFree: isZh ? '免费' : 'Free', dpScreenshots: isZh ? '截图' : 'Screenshots', dpStorePage: isZh ? '商店页' : 'Store Page', dpJumpLib: isZh ? '跳转到游戏库' : 'Jump to Library', dpJumpWl: isZh ? '跳转到愿望单' : 'Jump to Wishlist', ptTotalHours: isZh ? '总游玩时长' : 'Total Hours', ptAvgHours: isZh ? '平均时长' : 'Avg Hours', ptPlayedCount: isZh ? '已游玩' : 'Played', ptUnplayedCount: isZh ? '从未游玩' : 'Unplayed', //统计仪表新增 KPI 卡片——游玩率 + 完成度 ptPlayRate: isZh ? '游玩率' : 'Play Rate', ptCompletion: isZh ? '完成度' : 'Completion', ptDistTitle: isZh ? '时长分布' : 'Playtime Distribution', ptTopTitle: isZh ? '游玩时长 Top 20' : 'Top 20 by Playtime', ptAllTitle: isZh ? '全部游戏(按时长排序)' : 'All Games (by Playtime)', ptSearchPlaceholder: isZh ? '搜索游戏名称...' : 'Search game name...', //游玩数据页重构——HERO 摘要 / 时长集中度 / 活跃度分层 / 全屏排行浮窗 ptHeroRecent: isZh ? '近2周时长' : '2-Week Hours', ptHeroRecentSub: isZh ? '需配置 API Key' : 'API key required', ptHeroLongest: isZh ? '最长单游' : 'Longest Game', ptHeroMedian: isZh ? '中位数' : 'Median', //中位数改按已游玩口径(全库中位在未游玩过半的真实库恒为 0),sub 标注样本量 ptHeroMedianSub: isZh ? '已游玩 {n} 款的中位' : 'median of {n} played', ptHeroMedianNone: isZh ? '从未游玩' : 'never played', ptConcTitle: isZh ? '时长集中度' : 'Playtime Concentration', ptConcTop: isZh ? '前 {n} 款占全部游玩时长的 {p}%' : 'Top {n} games account for {p}% of total playtime', ptConcHalf: isZh ? ',前 {n} 款即吃掉一半时长' : ', top {n} reach 50%', ptActTitle: isZh ? '活跃度分层(按最近游玩)' : 'Activity Layers (by Last Played)', ptActHint: isZh ? '点击任一分层查看该层排行' : 'Click a layer to open its ranking', ptDustScanning: isZh ? '正在统计吃灰游戏…' : 'Scanning idle games…', ptDustView: isZh ? '查看吃灰榜' : 'View idle list', ptDustText: isZh ? '入库超 1 年却从未启动' : 'Owned over a year, never launched', //──── v2.32.0 「游玩分析」Tab 文案(PTW 分析层) ──── ptPlayTabLabel: isZh ? '游玩分析' : 'Play Analysis', ptPlayDistTitle: isZh ? '时长分档分布' : 'Playtime Distribution', ptPlayDistModeCount: isZh ? '按游戏数' : 'By count', ptPlayDistModeHours: isZh ? '按时长' : 'By hours', ptPlayInsightTitle: isZh ? '玩家画像(5 维)' : 'Player Profile (5 dims)', ptPlayInsightSub: isZh ? '本地规则推断,非 AI 评分' : 'Local rule-based, not AI scoring', ptPlayRecentTitle: isZh ? '最近两周游玩' : 'Last 2 Weeks', ptPlayRecentEmpty: isZh ? '最近两周没有游玩记录' : 'No play in the last 2 weeks', ptPlayRecentNeedKey: isZh ? '需要 Steam Web API Key 才能读取近两周时长' : 'Steam Web API Key required for 2-week playtime', ptPlaySciTitle: isZh ? '剧情完成度(SCI)' : 'Story Completion (SCI)', ptPlaySciSub: isZh ? '累计时长 ÷ 剧情基准时长,衡量你玩到了主线的多少' : 'Total playtime / story baseline', ptPlaySciNoData: isZh ? '暂无剧情时长数据(未接入 HLTB 索引)' : 'No story-length data (HLTB index not loaded)', ptPlaySciSample: isZh ? '基于 {n} 款有剧情数据的游戏' : 'Based on {n} games with story data', ptPlayRadarEmpty: isZh ? '洞察数据不可用' : 'Insight data unavailable', ptPlayTitle: isZh ? '游玩分析' : 'Play Analysis', //──── v2.32.0 「库分析」Tab 文案 ──── ptLibTabLabel: isZh ? '库分析' : 'Library Analysis', ptLibEraTitle: isZh ? '发行年代分布' : 'Release Era Distribution', ptLibEraScope: isZh ? 'Steam 可用部分' : 'Steam-available subset', ptLibEraRef: isZh ? 'HLTB 全量参考' : 'HLTB full population', ptLibEraBias: isZh ? '样本偏差 {n}pt —— 主机独占游戏未计入,实际年代更早' : 'Sample bias {n}pt — console-exclusive titles excluded', ptLibGenTitle: isZh ? '类型分布' : 'Genre Distribution', ptLibSilTitle: isZh ? '沉默库存' : 'Silent Stock', ptLibSilDesc: isZh ? '已入库但从未启动 · 按剧情时长从短到长(最容易救回)· 前 10 款' : 'Never launched · shortest story first · top 10', ptPotTabLabel: isZh ? '潜力清单' : 'Potentials', ptPotFastTitle: isZh ? '最快通关' : 'Fastest to Finish', ptPotTasteTitle: isZh ? '口味最对' : 'Best Taste Match', ptPotSilentTitle: isZh ? '沉默库存' : 'Silent Stock', ptPotEmpty: isZh ? '暂无匹配结果(口味画像或标签数据不足)' : 'No matches (insufficient taste/tag data)', ptPotEstHours: isZh ? '预计 {h} 小时' : '~{h}h est.', ptAllOpenBtn: isZh ? '全部游戏排行' : 'All Games', ptAllOpenTip: isZh ? '打开全部游戏时长排行(全屏浮窗)' : 'Open full playtime ranking (fullscreen)', ptWinTabAll: isZh ? '全部游戏' : 'All Games', ptWinTabFamily: isZh ? '家庭对比' : 'Family Compare', //全部游戏浮窗「入库月历」Tab ptWinTabCalendar: isZh ? '入库月历' : 'Entry Calendar', ptCalLoading: isZh ? '正在获取入库时间…' : 'Loading acquisition times…', ptCalNoData: isZh ? '未获取到入库时间数据' : 'No acquisition data available', ptCalNoDataHint: isZh ? '入库月历依赖家庭组共享库/个人许可页的入库时间,请先登录 Steam 后重试' : 'The calendar needs acquisition times from the family library / licenses page. Sign in to Steam and retry.', ptCalYearTotal: isZh ? '本年入库' : 'Acquired this year', ptCalPeakMonth: isZh ? '最猛月份' : 'Peak month', ptCalPeakDay: isZh ? '单日峰值' : 'Peak day', ptCalMonthsCovered: isZh ? '覆盖月份' : 'Months covered', ptCalUnit: isZh ? '款' : 'games', //年份 chips 上「今年」胶囊的 title(当前年即使无数据也固定占位) ptCalThisYear: isZh ? '今年' : 'This year', ptCalNoDataThisYear: isZh ? '当前筛选集在今年暂无入库记录' : 'No entries this year in the current filter', //导航栏二级菜单「游戏数据」入口(直接打开全部游戏浮窗) ptMenuGameData: isZh ? '游戏数据' : 'Game Data', //取消 ▾ 箭头后, 面板入口迁入下拉首项(默认选中高亮) ptMenuOpenPanel: isZh ? '打开游戏库面板' : 'Open Library Panel', ptMenuOpenPanelTip: isZh ? '打开中央游戏库面板(橱窗 / 时长 / 洞察 / 愿望单等)' : 'Open the central library panel', ptMenuGameDataTip: isZh ? '全部游戏排行 / 家庭对比 / 入库月历(全屏浮窗)' : 'All games ranking / Family compare / Entry calendar (fullscreen)', ptMenuDataEmpty: isZh ? '游戏库数据尚未加载,请先打开面板等待数据加载' : 'Library data not loaded yet — open the panel and wait', //月历下方常驻「当月入库时间线」——默认展示当月、按日倒序(月末→月初), // 点日历色块即高亮对应日期分组(高亮底色 = 该日入库数量色阶) ptCalTimeline: isZh ? '当月入库时间线' : 'This Month Timeline', ptCalTimelineHint: isZh ? '按时间倒序(月末 → 月初)· 点击日历中的色块日期可高亮当天记录' : 'Reverse-chronological (month-end → month-start) · click a colored day to highlight its records', ptCalTimelineEmpty: isZh ? '本月暂无入库记录' : 'No entries this month', ptCalTimelineEmptyHint: isZh ? '点击月历中有色块的日期,即可查看并高亮当天记录' : 'Click a colored day in the calendar to view and highlight its records', ptCalOpenStore: isZh ? '在 Steam 商店打开' : 'Open in Steam Store', ptCalPrevYear: isZh ? '上一年' : 'Previous year', ptCalNextYear: isZh ? '下一年' : 'Next year', ptCalFuture: isZh ? '未到时间' : 'Not yet', ptSortPlaytime: isZh ? '时长降序' : 'Playtime', ptSortRecent: isZh ? '最近游玩' : 'Last Played', ptSortAcquired: isZh ? '入库时间' : 'Acquired', ptSortName: isZh ? '名称' : 'Name', ptSortAcquiredLoading: isZh ? '正在获取入库时间…' : 'Loading acquisition times…', //全部游戏排行过滤 chips + 排序方向切换 ptFilterUnplayed: isZh ? '隐藏从未游玩' : 'Hide unplayed', ptFilterShared: isZh ? '隐藏家庭共享' : 'Hide family shared', ptSortAsc: isZh ? '↑ 正序' : '↑ Asc', ptSortDesc: isZh ? '↓ 倒序' : '↓ Desc', //入库月历「仅个人游戏」过滤(排除家庭共享入库) ptCalPersonalOnly: isZh ? '仅个人游戏' : 'Personal only', ptCalPersonalOnlyTip: isZh ? '月历与时间线仅统计本人拥有的游戏(排除家庭共享入库)' : 'Count only games owned by me (exclude family-shared entries)', ptCalPersonalEmpty: isZh ? '该筛选集内个人游戏暂无入库记录' : 'No personal-only entries in this filter', //全 0 时长列表的排序兜底说明 + 入库时间缺失回退 ptZeroPtHint: isZh ? '该列表内游戏均为 0 时长,时长列无法区分先后——按入库时间/最近游玩倒序兜底排列' : 'All games here have 0 playtime; fallback order (acquired / last played) is applied.', ptSortAcquiredEmpty: isZh ? '未获取到入库时间,已回退时长排序' : 'No acquisition data — fell back to playtime sorting', //持久缓存 / 按周增量 / 家庭成员 Top20 ptWeeklyUpdated: isZh ? '游玩时长已按周增量更新({n} 款变化)' : 'Playtime updated weekly ({n} changed)', ptFamRefreshTip: isZh ? '刷新家庭成员数据(默认按周缓存)' : 'Refresh family data (weekly cache)', ptFamTop20: isZh ? 'Top 20 游玩时长' : 'Top 20 Playtime', ptFamNoGames: isZh ? '暂无游玩时长数据' : 'No playtime data', ptFamPrivate: isZh ? '获取失败,该成员资料可能为私密' : 'Failed — profile may be private', ptRange0: isZh ? '从未游玩' : 'Never Played', ptRange1: isZh ? '0-1 小时' : '0-1 hours', ptRange2: isZh ? '1-10 小时' : '1-10 hours', ptRange3: isZh ? '10-50 小时' : '10-50 hours', ptRange4: isZh ? '50-100 小时' : '50-100 hours', ptRange5: isZh ? '100-500 小时' : '100-500 hours', ptRange6: isZh ? '500+ 小时' : '500+ hours', ptHours: isZh ? '小时' : 'hours', ptLastPlayed: isZh ? '最近游玩' : 'Last Played', ptNever: isZh ? '从未' : 'Never', ptNoData: isZh ? '暂无游玩时长数据,请先获取游戏库' : 'No playtime data. Fetch library first.', ptDays: isZh ? '天前' : 'days ago', ptFamilyCompare: isZh ? '家庭成员游玩时长对比' : 'Family Playtime Comparison', ptFetchingFamily: isZh ? '正在获取家庭成员数据…' : 'Fetching family data…', ptNoFamily: isZh ? '未加入家庭组或未配置 API Key' : 'No family group or API Key', ptNoApiKey: isZh ? '请先在设置中配置 API Key' : 'Configure API Key in settings first', ptNoToken: isZh ? '请先点击面板刷新按钮获取家庭组信息' : 'Click refresh to fetch family info first', ptRetryBtn: isZh ? '重试' : 'Retry', //入库热力图与家庭组占位 ptLibHeatmap: isZh ? '入库热力图' : 'Library Heatmap', ptHeatmapNoData: isZh ? '暂无入库时间数据' : 'No acquisition time data', ptHeatmapLoading: isZh ? '加载中…' : 'Loading…', ptPending: isZh ? '待加入' : 'Pending', ptHeatmapTotal: isZh ? '总' : 'Total', ptHeatmapPeak: isZh ? '峰值' : 'Peak', ptHeatmapAvg: isZh ? '日均' : 'Avg/day', ptHeatmapLess: isZh ? '少' : 'Less', ptHeatmapMore: isZh ? '多' : 'More', ptHeatmapAcquired: isZh ? '款入库' : 'acquired', //热力图按年分割 ptHeatmapAll: isZh ? '全部' : 'All', //热力图右下角近6月入库增量 ptHeatmapMini6m: isZh ? '近6月入库增量' : '6-Month Acquisitions', // 新增:刷新与分类 refreshBtnTitle: isZh ? '通过 API Key 刷新游戏库与时长' : 'Refresh library & playtime via API Key', refreshSuccess: isZh ? '游戏库与时长已刷新' : 'Library & playtime refreshed', categoryAll: isZh ? '全部' : 'All', categoryPlayed: isZh ? '已游玩' : 'Played', categoryUnplayed: isZh ? '未游玩' : 'Unplayed', categoryShared: isZh ? '共享' : 'Shared', categoryOwned: isZh ? '仅自己' : 'Owned Only', //进包/卡牌筛选与 KPI categoryBundled: isZh ? '进过包' : 'Bundled', categoryCards: isZh ? '有卡牌' : 'Has Cards', statsBundled: isZh ? '进包游戏' : 'Bundled Games', statsCards: isZh ? '有卡游戏' : 'Card Games', kpiInBundles: isZh ? '曾出现在 bundle 中' : 'appeared in bundles', kpiHasCards: isZh ? '含集换式卡牌' : 'has trading cards', sortBundleDesc: isZh ? '进包次数 多→少' : 'Bundle count ↓', bundleBadgeTip: isZh ? '曾出现在' : 'Appeared in', bundleBadgeUnit: isZh ? '个 bundle 中' : ' bundles', //DLC 分离统计与仅 DLC 筛选 categoryDlcOnly: isZh ? '仅 DLC' : 'DLC Only', statsDlcTotal: isZh ? 'DLC 总数' : 'DLC Total', kpiDlcMusic: isZh ? '音乐' : 'Music', kpiDlcBundled: isZh ? '进包' : 'Bundled', //库存排序 sortLabel: isZh ? '排序方式' : 'Sort by', sortAppidAsc: isZh ? 'AppID 小→大' : 'AppID ↑', sortAppidDesc: isZh ? 'AppID 大→小' : 'AppID ↓', sortNameAsc: isZh ? '名称 A→Z' : 'Name A–Z', sortAcquiredDesc: isZh ? '入库 新→旧' : 'Newest first', sortAcquiredAsc: isZh ? '入库 旧→新' : 'Oldest first', sortPlaytimeDesc: isZh ? '时长 多→少' : 'Most played', sortLastPlayedDesc: isZh ? '最近游玩' : 'Recently played', statsTotalGames: isZh ? '总游戏数' : 'Total Games', statsTotalHours: isZh ? '总时长 (h)' : 'Total Hours', statsPlayed: isZh ? '已游玩' : 'Played', statsUnplayed: isZh ? '未游玩' : 'Unplayed', statsShared: isZh ? '共享游戏' : 'Shared Games', //KPI 卡片子文本 kpiAvgHours: isZh ? '均' : 'Avg', kpiPerGame: isZh ? '/款' : '/game', kpiRate: isZh ? '占比' : 'Rate', kpiOfTotal: isZh ? '占总数' : 'of total', //游玩率/完成度 KPI 副标题 kpiPlayRateSub: isZh ? '已启动 ÷ 全部' : 'started ÷ total', kpiCompletionSub: isZh ? '深度游玩 ≥10h' : 'deep play ≥10h', kpiFromFamily: isZh ? '来自家庭组' : 'from family', kpiHours: isZh ? '小时' : 'hours', kpiMyCollection: isZh ? '我的收藏' : 'My Collection', kpiDedupMerge: isZh ? '合并去重' : 'Deduplicated', kpiFamilyMembers: isZh ? '位家庭成员' : 'family members', kpiYearSpan: isZh ? '年跨度' : 'year span', kpiDelisted: isZh ? '款绝版' : 'delisted', kpiCollected: isZh ? '已收藏' : 'collected', kpiToCollect: isZh ? '待收藏' : 'to collect', kpiCollectionProgress: isZh ? '收藏进度' : 'Collection Progress', delistedSubAll: isZh ? '全部绝版' : 'All Delisted', delistedSubOwned: isZh ? '已拥有' : 'Owned', delistedSubMissing: isZh ? '未拥有' : 'Missing', ptSourceApi: isZh ? 'API' : 'API', ptSourceScrape: isZh ? '页面抓取' : 'Scraped', ptTrendTitle: isZh ? '时长趋势' : 'Playtime Trend', ptTrendMonth: isZh ? '月' : 'Month', ptTrendQuarter: isZh ? '季' : 'Quarter', ptTrendYear: isZh ? '年' : 'Year', // AI 翻译总结 aiSettingsTitle: isZh ? 'AI 模型配置' : 'AI Model Settings', aiApiUrlLabel: isZh ? 'AI API 地址' : 'AI API URL', aiApiKeyLabel: isZh ? 'AI API Key' : 'AI API Key', aiModelLabel: isZh ? '模型名称' : 'Model Name', aiApiUrlHelp: isZh ? '默认为 DeepSeek 官方 API 地址,也可填入其他兼容 OpenAI 格式的 API 地址' : 'Default is DeepSeek official API URL. Also supports OpenAI-compatible API endpoints.', aiModelHelp: isZh ? '默认为 deepseek-v4-pro,可修改为其他模型名称' : 'Default is deepseek-v4-pro. Can be changed to other model names.', aiNotConfigured: isZh ? '未配置 AI API Key,请在设置中配置' : 'AI API Key not configured. Set it in Settings.', aiTranslating: isZh ? 'AI 翻译总结中…' : 'AI translating & summarizing…', aiTranslateFail: isZh ? 'AI 翻译失败' : 'AI translation failed', aiToggleOriginal: isZh ? '原文' : 'Original', aiToggleSummary: isZh ? 'AI翻译总结' : 'AI Summary', //AI 配置与 Steam AI Agent 插件共享 aiSharedHint: isZh ? '检测到 Steam AI Agent 插件,AI 配置已自动共享' : 'Steam AI Agent detected, AI config is shared automatically', ptNoActivity: isZh ? '近期无游玩活动' : 'No recent activity', globalSettings: isZh ? '全局设置' : 'Global Settings', //外观主题切换(典雅紫/经典蓝,主题名取自 SGLV_THEMES 注册表) themeSection: isZh ? '外观主题' : 'Appearance', themeDesc: isZh ? '选择界面配色主题,保存后立即生效(无需刷新页面)。' : 'Pick the UI color theme. Applies instantly on save (no reload needed).', themePurpleDesc: isZh ? '紫青炫彩主调 · 默认' : 'Violet accent · default', themeBlueDesc: isZh ? 'Steam 经典蓝主调' : 'Steam classic blue accent', //设置集成进主面板——返回按钮文案 settingsBack: isZh ? '返回游戏库' : 'Back to Library', steamApiSection: isZh ? 'Steam API 配置' : 'Steam API Settings', //ITAD 价格增强配置 itadSection: isZh ? 'ITAD 价格增强' : 'ITAD Price Enhancement', itadApiKeyLabel: 'ITAD API Key', itadDesc: isZh ? '配置 ITAD (IsThereAnyDeal) API 密钥后,价格页面将增强为<b style="color:#a7d54f">15+ 区域比价</b>、<b style="color:#a7d54f">多商店价格对比</b>(GOG/Fanatical/Humble 等)、<b style="color:#a7d54f">活跃 Bundle</b> 展示及<b style="color:#a7d54f">历史最低价</b>徽章。未配置时自动回退到基础模式。' : 'With an ITAD API key, the price tab enhances to <b style="color:#a7d54f">15+ region comparison</b>, <b style="color:#a7d54f">multi-store pricing</b> (GOG/Fanatical/Humble), <b style="color:#a7d54f">active bundles</b>, and <b style="color:#a7d54f">historical low</b> badges. Falls back to basic mode if not configured.', itadKeyPlaceholder: '32位十六进制密钥 (如 a9d468...14b725)', itadTestConn: isZh ? '测试连接' : 'Test Connection', itadTesting: isZh ? '测试中…' : 'Testing…', itadConnSuccess: isZh ? '连接成功' : 'Connected', itadConnFail: isZh ? '连接失败' : 'Failed', itadConnNoKey: isZh ? '未配置' : 'Not configured', itadHelpTitle: isZh ? '密钥申请方法' : 'How to get a key', itadHelpSteps: isZh ? '① 访问 <a href="https://isthereanydeal.com" target="_blank" style="color:#66c0f4">isthereanydeal.com</a> → 注册账号并<b>验证邮箱</b>(影响速率限额)<br>② 进入 <a href="https://isthereanydeal.com/apps" target="_blank" style="color:#66c0f4">isthereanydeal.com/apps</a> → 点击 <b>Register App</b><br>③ 填写应用名称(不可与他人重复)→ <b>Submit</b><br>④ 在 <b>API Keys</b> 区域复制 32 位密钥 → 粘贴到上方输入框<br>⑤ 点击「测试连接」验证 → 保存设置即可生效<br><span style="color:#555">限额:每 5 分钟 1000 次请求 · 可商用但需标注 ITAD 来源</span>' : '① Visit <a href="https://isthereanydeal.com" target="_blank" style="color:#66c0f4">isthereanydeal.com</a> → Register & <b>verify email</b> (affects rate limit)<br>② Go to <a href="https://isthereanydeal.com/apps" target="_blank" style="color:#66c0f4">isthereanydeal.com/apps</a> → Click <b>Register App</b><br>③ Enter app name (must be unique) → <b>Submit</b><br>④ Copy the 32-char key from <b>API Keys</b> → Paste above<br>⑤ Click "Test Connection" → Save Settings<br><span style="color:#555">Limit: 1000 requests / 5 min · Commercial use OK with ITAD attribution</span>', // AI 价格预测 aiPredictSection: isZh ? 'AI 价格预测配置' : 'AI Price Prediction Settings', aiPredictModes: isZh ? '预测模式' : 'Prediction Modes', aiPredictModesHelp: isZh ? '选择一种或多种预测模式,AI将基于所选模式进行分析' : 'Select one or more prediction modes for AI analysis', aiPredictModeSeasonal: isZh ? '季节性折扣预测' : 'Seasonal Discount', aiPredictModeSeasonalDesc: isZh ? '基于Steam促销活动节点(夏促/秋促/冬促/春促)预测折扣时机' : 'Predict based on Steam sale events (Summer/Autumn/Winter/Spring)', aiPredictModeTrend: isZh ? '价格趋势预测' : 'Price Trend', aiPredictModeTrendDesc: isZh ? '基于历史折扣间隔和幅度趋势预测下次折扣' : 'Predict next discount based on historical intervals and depth trends', aiPredictModeLowest: isZh ? '史低突破预测' : 'Historical Low', aiPredictModeLowestDesc: isZh ? '预测达到或突破历史最低价的可能性与时机' : 'Predict probability and timing of reaching or breaking historical low', aiPredictModeReview: isZh ? '评价动量预测' : 'Review Momentum', aiPredictModeReviewDesc: isZh ? '结合游戏口碑变化趋势评估折扣力度' : 'Assess discount depth based on review momentum changes', aiPredictModeSimilar: isZh ? '相似历史预测' : 'Similar History', aiPredictModeSimilarDesc: isZh ? '匹配相似价格历史片段预测折扣模式' : 'Match similar price history patterns to predict discounts', aiPredictBtn: isZh ? 'AI预测' : 'AI Predict', aiPredicting: isZh ? 'AI预测分析中…' : 'AI predicting…', aiPredictFail: isZh ? 'AI预测失败' : 'AI prediction failed', aiPredictNoData: isZh ? '需要先获取历史价格数据' : 'Need price history data first', aiPredictNoKey: isZh ? '未配置AI API Key,请在设置中配置' : 'AI API Key not configured. Set it in Settings.', aiPredictResult: isZh ? 'AI价格预测分析' : 'AI Price Prediction', aiPredictConfidence: isZh ? '把握度' : 'Confidence', aiPredictDays: isZh ? '天后' : 'days later', aiPredictBestTime: isZh ? '最佳入手时机' : 'Best Time to Buy', aiPredictSaleEvent: isZh ? '促销节点' : 'Sale Event', aiPredictRecommendation: isZh ? '购买建议' : 'Recommendation', // ====== 愿望单标签页 ====== tabWishlist: isZh ? '愿望单' : 'Wishlist', wlTotal: isZh ? '愿望单总数' : 'Wishlist Total', wlTotalValue: isZh ? '愿望单总价值' : 'Total Value', wlDiscounted: isZh ? '打折中' : 'On Sale', wlInLibrary: isZh ? '已在库' : 'In Library', wlAvgDiscount: isZh ? '平均折扣' : 'Avg Discount', wlSearch: isZh ? '搜索愿望单游戏...' : 'Search wishlist...', wlNoData: isZh ? '暂无愿望单数据' : 'No wishlist data', wlFetching: isZh ? '正在获取愿望单数据…' : 'Fetching wishlist…', wlFetchFail: isZh ? '愿望单获取失败,请点击右上角刷新按钮重试' : 'Wishlist fetch failed, click refresh to retry', wlFetchSuccess: isZh ? '愿望单获取成功,共 {n} 款' : 'Fetched {n} wishlist items', wlLoadStage1: isZh ? '正在抓取愿望单页面…' : 'Fetching wishlist page…', wlLoadStage2: isZh ? '正在补全商店数据…' : 'Enriching store data…', wlLoadStage3: isZh ? '正在补全游戏详情…' : 'Enriching game details…', wlLoadSub: isZh ? '正在从 Steam 获取愿望单数据,请稍候' : 'Loading wishlist data from Steam, please wait', wlRefreshTip: isZh ? '重新获取愿望单' : 'Refetch wishlist', // ====== 打折曲线弹窗 + 新史低预测(借鉴 steam-friend-manager) ====== wlCurveTip: isZh ? '价格历史与史低预测' : 'Price history & low prediction', wlCurveTitle: isZh ? '打折曲线' : 'Price Curve', wlCurveLoading: isZh ? '正在获取历史价格…' : 'Fetching price history…', wlCurveNoData: isZh ? '暂无历史价格数据(可在设置配置 ITAD API Key 获得完整数据)' : 'No price history (configure ITAD API key in settings for full data)', wlCurveLowest: isZh ? '史低' : 'Lowest', wlCurveNow: isZh ? '现价' : 'Now', wlPredictTitle: isZh ? '下次新史低预测' : 'Next Historical Low Prediction', wlPredictDue: isZh ? '已到预测窗口,随时可能' : 'Due now', wlPredictStalled: isZh ? '节奏停滞或已达底线' : 'Stalled / floor reached', wlPredictIn: isZh ? '约' : 'in', wlPredictDays: isZh ? '天' : 'd', wlPredictLast: isZh ? '上次史低' : 'Last low', wlPredictGap: isZh ? '史低间隔' : 'Low interval', wlPredictNextCut: isZh ? '预计折扣' : 'Predicted cut', wlConfHigh: isZh ? '可信度高' : 'High confidence', wlConfMid: isZh ? '可信度中' : 'Medium confidence', wlConfLow: isZh ? '可信度低' : 'Low confidence', wlCurveSource: isZh ? '数据源' : 'Source', wlFilterDiscount: isZh ? '打折中' : 'On Sale', wlFilterInLibrary: isZh ? '已在库' : 'In Library', wlFilterFree: isZh ? '免费' : 'Free', wlFilterComingSoon: isZh ? '即将发售' : 'Coming Soon', wlComingSoon: isZh ? '即将发售' : 'Coming Soon', wlKpiComingSoon: isZh ? '即将发售' : 'Coming Soon', wlTypeDist: isZh ? '类型分布' : 'Type Distribution', wlUpcoming: isZh ? '待上市游戏' : 'Upcoming Games', wlUpcoming30d: isZh ? '30天内' : '<30d', wlUpcoming90d: isZh ? '90天内' : '<90d', wlUpcomingFar: isZh ? '更远' : 'Far', wlUpcomingNoDate: isZh ? '未定日期' : 'No Date', wlDiscountDist: isZh ? '折扣分布' : 'Discount Distribution', wlTopDiscount: isZh ? 'TOP 折扣' : 'TOP Discounts', wlFree: isZh ? '免费' : 'Free', wlNoPrice: isZh ? '暂无价格' : 'N/A', wlTypeGame: isZh ? '游戏' : 'Games', wlTypeDlc: 'DLC', wlTypeSoftware: isZh ? '软件' : 'Software', wlTypeOther: isZh ? '其他' : 'Other', wlDiscOff: isZh ? '无折扣' : 'No Discount', wlDateAdded: isZh ? '添加于' : 'Added', wlTopTags: isZh ? '热门标签 TOP 15' : 'Top 15 Tags', wlTimeline: isZh ? '添加时间线' : 'Added Timeline', // ====== 愿望单仪表盘增强 ====== wlPriceDist: isZh ? '价格区间分布' : 'Price Range Distribution', wlAddedByYear: isZh ? '按年添加趋势' : 'Added by Year', wlKpiTotalValue: isZh ? '总价值' : 'Total Value', wlKpiOnSale: isZh ? '打折中' : 'On Sale', //混合币种聚合提示(部分条目缺汇率被排除时在 KPI 副文案提示) wlMultiCurHint: isZh ? '部分条目币种无汇率,已按可折算部分统计' : 'Some currencies lack rates; partial total shown', wlKpiFree: isZh ? '免费游戏' : 'Free Games', wlKpiInLib: isZh ? '已入库' : 'In Library', wlEarliest: isZh ? '最早于' : 'Earliest', wlAvgPrice: isZh ? '均价' : 'Avg', wlMedian: isZh ? '中位' : 'Median', wlRate: isZh ? '占比' : 'Rate', wlAvgDisc: isZh ? '均折' : 'Avg', wlMaxDisc: isZh ? '最高' : 'Max', wlPurchaseRate: isZh ? '购入率' : 'Purchase Rate', wlPriceFree: isZh ? '免费' : 'Free', wlPriceLt50: '<50', wlPrice50_100: '50-100', wlPrice100_200: '100-200', wlPrice200_500: '200-500', wlPriceGte500: '500+', wlUncatEmpty: isZh ? '无分类 {n} 款' : '{n} uncategorized', wlRankTip: isZh ? '愿望单排序位次(可在 Steam 愿望单页面拖拽调整)' : 'Wishlist rank (drag to reorder on your Steam wishlist page)', // ====== 愿望单我的类别统计 ====== wlCatStats: isZh ? '我的类别统计' : 'Category Stats', wlCatStatsCats: isZh ? '共 {n} 个类别' : '{n} categories', wlCatStatsNoCat: isZh ? '未分类' : 'Uncategorized', wlCatStatsClick: isZh ? '点击筛选此类别' : 'Click to filter by category', wlCatStatsActive: isZh ? '类别: ' : 'Category: ', wlFamilyShared: isZh ? '家庭共享' : 'Family Shared', wlDisc0: isZh ? '无折扣' : 'None', wlDisc1_25: '1-25%', wlDisc26_50: '26-50%', wlDisc51_75: '51-75%', wlDisc76plus: '76%+', // ====== PS 会免标签页 ====== tabPsplus: isZh ? 'PS 会免' : 'PS Plus', // ====== Humble Choice 月包分析(借鉴 steam-friend-manager) ====== tabHumble: isZh ? 'Humble月包' : 'Humble Choice', humbleSearch: isZh ? '搜索游戏 / 发行商…' : 'Search game / publisher…', humbleKpiMonths: isZh ? '月包数' : 'Months', humbleKpiGames: isZh ? '游戏总数' : 'Total Games', humbleKpiOwned: isZh ? '已拥有' : 'Owned', humbleKpiUnowned: isZh ? '未拥有' : 'Not Owned', humbleMonthLabel: isZh ? '第' : 'Choice #', humbleOwnedIn: isZh ? '已收' : 'Owned', humbleFilterAll: isZh ? '全部' : 'All', humbleFilterOwned: isZh ? '已拥有' : 'Owned', humbleFilterUnowned: isZh ? '未拥有' : 'Not Owned', humbleNoData: isZh ? '月包数据加载失败(@resource 不可用)' : 'Failed to load bundle data (@resource unavailable)', psSearch: isZh ? '搜索游戏名称 / AppID...' : 'Search name / AppID...', psSortDateDesc: isZh ? '日期 ↓(新到旧)' : 'Date ↓ (new to old)', psSortDateAsc: isZh ? '日期 ↑(旧到新)' : 'Date ↑ (old to new)', psSortTierAsc: isZh ? '档位 ↑(必备→高级)' : 'Tier ↑ (Essential→Premium)', psSortTierDesc: isZh ? '档位 ↓(高级→必备)' : 'Tier ↓ (Premium→Essential)', psSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', psRefreshTip: isZh ? '重新获取 PS 会免数据' : 'Reload PS Plus data', psTierEssential: isZh ? '必备' : 'Essential', psTierExtra: isZh ? '升级' : 'Extra', psTierPremium: isZh ? '高级' : 'Premium', psStatusAll: isZh ? '全部状态' : 'All Status', psStatusIn: isZh ? '在库' : 'In Library', psStatusActive: isZh ? '领取中' : 'Claimable', psStatusExpired: isZh ? '已截止/出库' : 'Expired/Out', psStatusLeaving: isZh ? '即将出库' : 'Leaving Soon', psKpiTotal: isZh ? '会免总数' : 'Total Titles', psKpiMonthly: isZh ? '每月可领' : 'Monthly', psKpiCatalog: isZh ? '游戏目录' : 'Catalog', psKpiSteamOwned: isZh ? 'Steam 已拥有' : 'Owned on Steam', psKpiSteamTotal: isZh ? '共 {n} 款有 Steam 版' : '{n} on Steam', psUpdated: isZh ? '更新于' : 'Updated', psLoading: isZh ? '正在获取 PS 会免数据...' : 'Loading PS Plus data...', psLoadError: isZh ? 'PS 会免数据获取失败,请稍后点击刷新重试' : 'Failed to load PS Plus data. Click refresh to retry.', // ====== 通用:Steam 在库标识(区分 PS 在库状态) ====== steamInLib: isZh ? 'steam在库' : 'Steam Owned', // ====== Epic 赠送标签页 ====== tabEpic: isZh ? 'Epic赠送' : 'Epic Free', epicSearch: isZh ? '搜索名称 / AppID...' : 'Search name / AppID...', epicSortAppidDesc: isZh ? 'AppID ↓(大→小)' : 'AppID ↓ (high to low)', epicSortAppidAsc: isZh ? 'AppID ↑(小→大)' : 'AppID ↑ (low to high)', epicSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', epicRefreshTip: isZh ? '从 GitHub 数据源更新 Epic 赠送名单' : 'Refresh Epic free list from GitHub data source', epicImportTip: isZh ? '手动导入 Epic 赠送数据 JSON' : 'Import Epic free games JSON', epicExportTip: isZh ? '一键导出 Epic 赠送数据 JSON(含本地缓存历史全量)' : 'Export Epic free games JSON (incl. local history)', epicExportOk: isZh ? '已导出 {n} 款 Epic 赠送游戏' : 'Exported {n} Epic free games', epicExportEmpty: isZh ? '没有可导出的 Epic 赠送数据' : 'No Epic data to export', epicExportFail: isZh ? '自动下载失败,请手工复制以下 JSON:' : 'Auto download failed. Copy JSON below:', epicKpiTotal: isZh ? 'Epic赠送总数' : 'Epic Free Titles', epicKpiOwned: isZh ? 'steam在库' : 'Steam Owned', epicKpiNotOwned: isZh ? '未拥有' : 'Not Owned', epicKpiOwnedSub: isZh ? '已在 Steam 家庭共享库' : 'In Steam family library', epicLoading: isZh ? '正在获取 Epic 赠送数据...' : 'Loading Epic free data...', epicLoadError: isZh ? 'Epic 数据获取失败,可点击导入按钮手动导入 JSON,或用「一键导出/导入」在设备间同步' : 'Failed to load Epic data. Try manual JSON import.', epicNoData: isZh ? '暂无 Epic 赠送数据,请点击刷新或导入' : 'No Epic data. Click refresh or import.', epicImportPrompt: isZh ? '请粘贴 Epic 赠送数据 JSON:\n支持 [appid,...] / {"appids":[...]} / {"games":[{"appid":...}]}\n(与「E宝的爱标识助手」共享存储,自动合并)' : 'Paste Epic free games JSON:\n[appid,...] / {"appids":[...]} / {"games":[{"appid":...}]}', epicImportOk: isZh ? '导入成功,共 {n} 款' : 'Imported {n} games', epicImportNoData: isZh ? '未识别到有效 appid' : 'No valid appid found', epicImportFail: isZh ? 'JSON 解析失败:' : 'JSON parse failed: ', epicBadgeFree: isZh ? 'Epic免费' : 'Epic Free', epicBadgeNotOwned: isZh ? '未拥有' : 'Not Owned', epicSourceKeylol: isZh ? '其乐论坛' : 'keylol', epicSourceLocal: isZh ? '本地缓存' : 'Local', epicSourceManual: isZh ? '内置名单' : 'Built-in', epicSourceImport: isZh ? '手动导入' : 'Imported', // ====== 年度大作标签页 ====== tabGoty: isZh ? '年度大作' : 'GotY', gotySearch: isZh ? '搜索游戏名称 / AppID...' : 'Search name / AppID...', gotySortDateAsc: isZh ? '发售日 ↑(早→晚)' : 'Date ↑ (early to late)', gotySortDateDesc: isZh ? '发售日 ↓(晚→早)' : 'Date ↓ (late to early)', gotySortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', gotySortAppidAsc: isZh ? 'AppID ↑(小→大)' : 'AppID ↑ (low to high)', gotyRefreshTip: isZh ? '重新获取年度大作数据' : 'Reload GotY data', gotyFilterAll: isZh ? '全部' : 'All', gotyFilterOwned: isZh ? 'steam在库' : 'Steam Owned', gotyFilterNotOwned: isZh ? '未拥有' : 'Not Owned', gotyKpiTotal: isZh ? '年度大作总数' : 'GotY Total', gotyKpiOwned: isZh ? 'steam在库' : 'Steam Owned', gotyKpiNotOwned: isZh ? '未拥有' : 'Not Owned', gotyKpiOwnedSub: isZh ? '已在 Steam 家庭共享库' : 'In Steam family library', gotyLoading: isZh ? '正在获取年度大作数据...' : 'Loading GotY data...', gotyLoadError: isZh ? '年度大作数据获取失败,请点击刷新重试' : 'Failed to load GotY data. Click refresh to retry.', gotyNoData: isZh ? '暂无年度大作数据' : 'No GotY data', gotyBadgeGoty: isZh ? '年度大作' : 'GotY', gotyBadgeNotOwned: isZh ? '未拥有' : 'Not Owned', gotyReleaseDate: isZh ? '发售' : 'Release', gotySourceGithub: isZh ? 'GitHub' : 'GitHub', gotySourceCache: isZh ? '本地缓存' : 'Local', gotyFilterAllYears: isZh ? '全部年度' : 'All Years', // ====== 锁区游戏标签页 ====== tabBlocked: isZh ? '锁区游戏' : 'Region Blocked', blockedSearch: isZh ? '搜索名称 / AppID...' : 'Search name / AppID...', blockedSortAppidDesc: isZh ? 'AppID ↓(大→小)' : 'AppID ↓ (high to low)', blockedSortAppidAsc: isZh ? 'AppID ↑(小→大)' : 'AppID ↑ (low to high)', blockedSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', blockedSortDateDesc: isZh ? '变更日期 ↓(新→旧)' : 'Changed ↓ (new to old)', blockedSortDateAsc: isZh ? '变更日期 ↑(旧→新)' : 'Changed ↑ (old to new)', blockedRefreshTip: isZh ? '重新获取锁区游戏数据' : 'Reload blocked data', blockedImportTip: isZh ? '手动导入锁区游戏 JSON' : 'Import blocked games JSON', blockedFilterAll: isZh ? '全部' : 'All', blockedFilterOwned: isZh ? 'steam在库' : 'Steam Owned', blockedFilterNotOwned: isZh ? '未拥有' : 'Not Owned', blockedKpiTotal: isZh ? '锁区总数' : 'Blocked Total', blockedKpiOwned: isZh ? 'steam在库' : 'Steam Owned', blockedKpiNotOwned: isZh ? '未拥有' : 'Not Owned', blockedKpiSubBanned: isZh ? 'Banned N · Purchase M · Regional K' : 'Banned N · Purchase M · Regional K', blockedLoading: isZh ? '正在获取锁区游戏数据...' : 'Loading blocked data...', blockedLoadError: isZh ? '锁区数据获取失败,可点击导入按钮手动导入,或稍后点击刷新重试' : 'Failed to load blocked data. Try import or retry later.', blockedNoData: isZh ? '暂无锁区游戏数据,请点击刷新或导入' : 'No blocked data. Click refresh or import.', blockedImportPrompt: isZh ? '请粘贴锁区游戏数据 JSON:\n支持 [{appid,name,...}] 数组格式\n或 srbb_blocked_apps 格式 {"appid":{"name":"...","at":...}}' : 'Paste blocked games JSON:\n[{appid,name,...}] array\nor srbb format {"appid":{"name":"...","at":...}}', blockedImportOk: isZh ? '导入成功,共 {n} 款' : 'Imported {n} games', blockedImportNoData: isZh ? '未识别到有效 appid' : 'No valid appid found', blockedImportFail: isZh ? 'JSON 解析失败:' : 'JSON parse failed: ', blockedBadgeOwned: isZh ? '在库' : 'Owned', blockedBadgeBanned: isZh ? '封禁' : 'Banned', blockedBadgePurchase: isZh ? '购买禁用' : 'Purchase Disabled', blockedBadgeRegional: isZh ? '区域变体' : 'Regional Variant', blockedBadgeManual: isZh ? '手动' : 'Manual', blockedChangedAt: isZh ? '变更' : 'Changed', // ====== 云存档标签页 ====== tabCloudSave: isZh ? '云存档' : 'Cloud Saves', csTotalGames: isZh ? '云存档游戏' : 'Cloud Games', csTotalSize: isZh ? '总占用' : 'Total Size', csTotalFiles: isZh ? '文件总数' : 'Total Files', csAvgSize: isZh ? '平均大小' : 'Avg Size', csLargestSave: isZh ? '最大存档' : 'Largest Save', csTopSizes: isZh ? 'TOP 10 存档大小' : 'TOP 10 Save Sizes', csSizeDist: isZh ? '大小分布' : 'Size Distribution', csFileDist: isZh ? '文件数分布' : 'File Count Distribution', csGameList: isZh ? '游戏列表' : 'Game List', csSearch: isZh ? '搜索游戏名称...' : 'Search game name...', csSortSizeDesc: isZh ? '大小 ↓(大→小)' : 'Size ↓ (large to small)', csSortSizeAsc: isZh ? '大小 ↑(小→大)' : 'Size ↑ (small to large)', csSortFilesDesc: isZh ? '文件数 ↓' : 'Files ↓', csSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', csRefreshTip: isZh ? '重新获取云存档数据及文件数' : 'Reload cloud save data & file counts', csFetching: isZh ? '正在获取云存档数据…' : 'Fetching cloud saves…', csFetchFail: isZh ? '云存档数据获取失败,请点击刷新重试' : 'Cloud save fetch failed. Click refresh to retry.', csFetchSuccess: isZh ? '获取成功,共 {n} 款游戏' : 'Fetched {n} games', csNoData: isZh ? '暂无云存档数据,请点击刷新获取' : 'No cloud save data. Click refresh to fetch.', csBadgeNew: isZh ? '新增' : 'NEW', csBadgeChanged: isZh ? '变化' : 'CHANGED', csBadgeRemoved: isZh ? '移除' : 'REMOVED', csUpdated: isZh ? '存档更新' : 'Saves updated', csFileCountUpdated: isZh ? '文件数更新' : 'File count updated', csFiles: isZh ? '文件' : 'files', csSizeLt1: '<1MB', csSize1_10: '1-10MB', csSize10_50: '10-50MB', csSize50_100: '50-100MB', csSize100plus: '100MB+', csFilesLt3: '<3', csFiles3_5: '3-5', csFiles5_10: '5-10', csFiles10plus: '10+', csLoginRequired: isZh ? '需要 Steam 登录态,请在 store.steampowered.com 登录后重试' : 'Steam login required. Please login on store.steampowered.com.', csSourceWeb: isZh ? '网页抓取' : 'Web scrape', csSourceCache: isZh ? '本地缓存' : 'Local cache', //后台静默刷新状态 csBgRefreshing: isZh ? '后台更新中…' : 'Background updating…', csBgRefreshDone: isZh ? '后台更新完成,新增 {n} 款' : 'Background update done, +{n}', // ====== DLC 收藏标签页 ====== tabDlc: isZh ? 'DLC收藏' : 'DLC Collection', dlcKpiTotal: isZh ? 'DLC 总数' : 'DLC Total', dlcKpiRegular: isZh ? '常规 DLC' : 'Regular DLC', dlcKpiMusic: isZh ? '音乐包' : 'Music Packs', dlcKpiBundled: isZh ? '进包 DLC' : 'Bundled DLC', dlcKpiPlayed: isZh ? '已游玩' : 'Played', dlcKpiSub: isZh ? '覆盖 {n} 款游戏' : 'Across {n} games', dlcKpiNoParent: isZh ? '未关联父游戏' : 'No parent', dlcSearch: isZh ? '搜索 DLC 或父游戏...' : 'Search DLC or parent...', dlcSortParent: isZh ? '按父游戏' : 'By Parent', dlcSortAppidDesc: isZh ? 'AppID ↓(大→小)' : 'AppID ↓ (high to low)', dlcSortAppidAsc: isZh ? 'AppID ↑(小→大)' : 'AppID ↑ (low to high)', dlcSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', dlcSortType: isZh ? '按类型' : 'By Type', dlcFilterAll: isZh ? '全部' : 'All', dlcFilterDlc: isZh ? '常规' : 'Regular', dlcFilterMusic: isZh ? '音乐' : 'Music', dlcFilterBundled: isZh ? '进包' : 'Bundled', dlcFilterPlayed: isZh ? '已游玩' : 'Played', dlcGroupByParent: isZh ? '按父游戏' : 'By Parent', dlcFlatList: isZh ? '扁平' : 'Flat', dlcLoading: isZh ? '正在加载 DLC 数据库...' : 'Loading DLC database...', dlcLoadError: isZh ? 'DLC 数据加载失败,请稍后重试' : 'DLC data load failed. Please retry later.', dlcNoData: isZh ? '暂无 DLC,请先加载游戏库' : 'No DLC found. Load your library first.', dlcBadgeMusic: isZh ? '音乐' : 'Music', dlcBadgeBundle: isZh ? '包' : 'Bundle', dlcBadgePlayed: isZh ? '已玩' : 'Played', dlcComplete: isZh ? '收集完成度' : 'Completion', dlcUnknownParent: isZh ? '未知父游戏' : 'Unknown Parent', }; // ==================== 状态管理 ==================== const state = { activeTab: 'owned', viewMode: 'card', ownedFilter: 'all', ownedSort: 'appidAsc', // v2.4.5: 库存排序方式(默认 AppID 升序) ownedGames: [], ownedAppIds: new Set(), isLoading: false, showSettings: false, searchQuery: '', page: 1, pageSize: 48, // ====== 愿望单标签页状态 ====== wishlistGames: [], wishlistLoaded: false, wishlistLoading: false, wishlistLoadStage: 0, // v2.9.47: 加载阶段 0=未开始 1=页面抓取 2=API补全 3=资料富集 wishlistLoadProgress: 0, // v2.9.47: 加载进度百分比 wishlistSearch: '', wishlistFilter: 'all', // 'all' | 'discount' | 'inlibrary' | 'coming' | 'category' wishlistPage: 1, wishlistPageSize: 48, wishlistViewMode: 'card', wishlistCategoryNames: {}, // v2.3.23: 用户自定义类别 ID -> 名称映射(我的类别) wishlistCategoryFilter: null, // v2.8.0: 当前选中的类别筛选 ID(null=未筛选,'__uncat__'=未分类) // ====== 我的成就标签页状态 ====== achMainTab: '', // 主视图:'steam'(真实成就) / 'custom'(自定义成就) / ''=自动(v2.44.0 起默认 Steam) achSelectedCat: 'all', // 自定义成就分类(仅底部徽章架内部使用) achDeepTab: 'perfect', // 真实成就深度面板当前标签 ('perfect'/'rare'/'gap') achSteamView: 'games', // v2.44.0:Steam 成就子视图 'games'(游戏成就矩阵) / 'insights'(聚合分析) achNavView: 'all', // v2.44.0:左栏游戏维度导航 'all'/'perfect'/'rare'/'gap'/'private'/'unsynced' achShelfOpen: false, // v2.44.0:自定义成就徽章架是否展开 achShelfTouched: false, // v2.44.0:用户是否手动操作过徽章架(手动过就以用户为准) // v2.44.2:游戏成就矩阵工具栏(借鉴收藏模块的「完成度 / 排序 / 视图」工具栏) achMatrixPage: 1, // 当前页码 achMatrixPageSize: 50, // 每页行数 achMatrixSort: 'rate', // 排序键:rate/rare/recent/count/name achMatrixView: 'list', // 视图:list(行) / card(卡片) achMatrixQuery: '', // 矩阵内搜索词 // ====== 锁区游戏标签页状态 ====== blockedApps: [], // 锁区游戏列表 [{appid, name, category, categoryId, type, source, changedAt}] blockedLoaded: false, blockedLoading: false, blockedError: null, blockedSource: null, // 'api' | 'html' | 'manual' | 'mixed' // ====== 云存档标签页状态 ====== cloudSaveGames: [], // [{appid, name, fileCount, totalSize, sizeBytes, _status}] cloudSaveLoaded: false, cloudSaveLoading: false, cloudSaveError: null, cloudSaveSource: null, // 'web' | 'cache' cloudSaveLastUpdate: 0, // 上次更新时间戳 cloudSaveSearch: '', cloudSaveSort: 'sizeDesc', // 'sizeDesc' | 'sizeAsc' | 'filesDesc' | 'nameAsc' cloudSaveDiff: null, // {new:[], changed:[], removed:[]} 增量比对结果 cloudSaveBgRefresh: false, // v2.9.39: 后台静默增量刷新中(已有缓存数据时使用) cloudSaveBgRefreshAt: 0, // v2.9.39: 上次后台静默刷新尝试时间戳(节流 2 天) cloudSaveFileCountEnriching: false, // v2.9.67: 文件数后台填充中 cloudSaveFileCountAt: 0, // v2.9.74: 上次文件数填充时间戳(5天 TTL,从 IDB 缓存恢复) // ====== 个人展柜标签页状态 ====== showcaseProfile: null, // { avatar, name, level, steamId } 玩家档案缓存 showcaseProfileLoading: false, showcaseProfileAt: 0, // 上次获取时间戳(30 分钟 TTL) showcaseConfig: null, // { selectedAppIds: [appid,...], selectedBadgeIds: [{appid,badgeid,isFoil}] } 用户自定义展柜 showcaseConfigLoaded: false, showcasePickerOpen: false, // 封面选择器弹窗状态 //徽章图标缓存(从社区徽章页面抓取) showcaseBadgeIcons: null, // { "appid|foil": iconUrl } 徽章图标URL映射 showcaseBadgeIconsLoading: false, showcaseBadgeIconsAt: 0, // 上次获取时间戳(1 小时 TTL) showcaseBadges: null, // 合并后的徽章数据缓存 [{ appid, badgeid, level, xp, iconUrl, name, badgeType, isFoil }] //展柜 SWR 缓存状态 — 持久化跨 session 复用,未变化不失效 //移除死字段 showcaseCacheHash/showcaseCacheReady(从未被读取, // 缓存有效性实际由每次渲染时 _computeShowcaseCacheHash 现算签名保证) showcasePreheating: false, // 预热进行中 showcasePreheated: false, // 预热已完成 showcaseRenderToken: 0, // 渲染令牌,防止并发渲染竞态 }; // ==================== 通用工具模块(借鉴 steam-friend-manager) ==================== // 轻量级 DOM 构建函数 h():替代 innerHTML 拼接,支持事件绑定/CSS变量/布尔属性,降低 XSS 风险 const BOOL_ATTRS = new Set(['disabled', 'checked', 'selected', 'readonly', 'required', 'hidden', 'multiple', 'autofocus', 'open']); function h(tag, props = {}, children = []) { const el = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (v == null) continue; if (k === 'style' && typeof v === 'object') { for (const [sk, sv] of Object.entries(v)) { if (sk.startsWith('--')) el.style.setProperty(sk, sv); else { el.style[sk] = sv; } } } else if (k === 'dataset') { Object.assign(el.dataset, v); } else if (k.startsWith('on') && typeof v === 'function') { el.addEventListener(k.slice(2).toLowerCase(), v); } else if (k === 'text') { el.textContent = v; } else if (k === 'html') { el.innerHTML = v; } else if (BOOL_ATTRS.has(k)) { v ? el.setAttribute(k, '') : el.removeAttribute(k); } else { el.setAttribute(k, v); } } for (const c of children) { if (c == null || c === false) continue; el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } return el; } // 统一 GM 存储 JSON 读取容错(替代 storage 内多处重复的 try{JSON.parse}catch) function parseStored(key, fallback) { const str = GM_getValue(key, ''); if (!str) return fallback; try { return JSON.parse(str); } catch (e) { return fallback; } } // 选择器快捷函数 function $(selector, parent = document) { return parent.querySelector(selector); } //统一 debounce 工具函数——避免搜索框每次按键触发完整重渲染 function debounce(fn, delay = 200) { let timer = null; const debounced = function(...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); }; debounced.cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; return debounced; } //缓存 Intl.Collator 实例,避免每次 renderGamesList 重新构造 const _nameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); //KPI 卡工厂——统一 5 套同构 KPI 体系的卡片拼装 // 结构: header(icon+label) + value(+unit) + sub;wl 体系顺序不同(icon+value+label+sub); // sgis 体系 icon 带渐变底色(val 类名无 -ue 后缀,sub 可选)。 // 迁移来源(命名冻结,CSS 不动): // dash → sglv-stat-dash-card (owned 统计仪表板 7 卡,含 active/data-filter) // trend → sglv-trend-kpi-card (入库趋势 8 卡) // playtime → sglv-playtime-kpi-card (游玩仪表 6 卡/成就 4 卡/云存档 4 卡) // wl → sglv-wl-kpi-card (愿望单 6 卡) // sgis → sgis-insight-kpi (SGIS 社交 6 卡 + 用户成就 6 卡) // 文本字段(label/value/unit/sub)经 escHtml;icon/grad/suffixHtml 为内部受信 HTML 原样输出。 const KPI_TONE_CLASS = { dash: 'sglv-stat-dash', trend: 'sglv-trend-kpi', playtime: 'sglv-playtime-kpi', wl: 'sglv-wl-kpi', sgis: 'sgis-insight-kpi', }; function kpiCards(items, opts = {}) { const prefix = KPI_TONE_CLASS[opts.tone]; if (!prefix) throw new TypeError(`kpiCards: 未知 tone "${opts.tone}"(可用: ${Object.keys(KPI_TONE_CLASS).join('/')})`); return (items || []).map(it => { const icon = it.icon || ''; const label = escHtml(it.label); const value = escHtml(it.value); const unit = (it.unit != null && it.unit !== '') ? `<span class="${prefix}-unit">${escHtml(it.unit)}</span>` : ''; const suffixHtml = it.suffixHtml || ''; const subHtml = (it.sub != null && it.sub !== '') ? `<div class="${prefix}-sub">${escHtml(it.sub)}${it.subSuffixHtml || ''}</div>` : ''; const colorCls = it.color || ''; if (opts.tone === 'wl') { return `<div class="${prefix}-card ${colorCls}"><div class="${prefix}-icon">${icon}</div><div class="${prefix}-value">${value}${unit}${suffixHtml}</div><div class="${prefix}-label">${label}</div>${subHtml}</div>`; } if (opts.tone === 'sgis') { return `<div class="${prefix} ${colorCls}"><div class="${prefix}-head"><div class="${prefix}-icon" style="background:${it.grad || ''}">${icon}</div><div class="${prefix}-label">${label}</div></div><div class="${prefix}-val">${value}${unit}${suffixHtml}</div>${subHtml}</div>`; } if (opts.tone === 'dash') { return `<div class="${prefix}-card ${colorCls}${it.active ? ' active' : ''}"${it.dataFilter ? ` data-filter="${it.dataFilter}"` : ''}><div class="${prefix}-header"><span class="${prefix}-icon">${icon}</span><span class="${prefix}-label">${label}</span></div><div class="${prefix}-value">${value}${unit}${suffixHtml}</div>${subHtml}</div>`; } return `<div class="${prefix}-card ${colorCls}"><div class="${prefix}-header"><span class="${prefix}-icon">${icon}</span><span class="${prefix}-label">${label}</span></div><div class="${prefix}-value">${value}${unit}${suffixHtml}</div>${subHtml}</div>`; }).join(''); } //空态工厂——统一 31 处内联 `<div class="sglv-empty">` 拼接 // text 经 escHtml;opts.style 透传内联样式(如 padding:8px/font-size:11px); // 专属空态外壳(sglv-gs-empty/sglv-cs-list-empty/sgis-state)为独立 class 体系,保持原样不经本工厂 function emptyHtml(text, opts = {}) { const style = opts.style ? ` style="${opts.style}"` : ''; return `<div class="sglv-empty"${style}>${escHtml(text)}</div>`; } // ==================== 骨架屏统一工厂 ==================== // createSkeleton(kind, arg) 唯一骨架入口,6 种 kind 表驱动映射,原 5 个独立构建器实现整体迁入本表: // showcase 展柜骨架(固定形态) heatmap 入库热力图骨架(SVG 灰格) // monthly 近6月入库增量骨架(SVG) family 家庭对比骨架行(arg=行数,默认4) // wishlist 愿望单整页骨架(arg=wrap 容器;DOM 副作用型:仅填充空容器,返回 undefined) // sgis SGIS 侧栏骨架行(arg=行数,默认8,自 renderProgressLoading 收编) // 本表位于外层共享区:子闭包 A(SGLV UI)与 B(SGIS)均可访问;表内实现引用 T/isZh/state // 均在调用时求值,不受定义顺序影响 const SKELETONS = { showcase: () => { const sk = (w, h, r) => `<div class="sglv-sc-sk-block" style="width:${w};height:${h};border-radius:${r || '6px'};"></div>`; return ` <div class="sglv-sc-wrap sglv-sc-skeleton-wrap"> <div class="sglv-sc-scroll"> <div class="sglv-sc-hero"> <div class="sglv-sc-avatar-wrap">${sk('44px', '44px', '50%')}</div> <div class="sglv-sc-hero-info"> <div class="sglv-sc-hero-name-row">${sk('140px', '18px')}</div> <div class="sglv-sc-hero-stats"> <div class="sglv-sc-hero-stat">${sk('60px', '32px')}</div> <div class="sglv-sc-hero-stat">${sk('60px', '32px')}</div> <div class="sglv-sc-hero-stat">${sk('60px', '32px')}</div> </div> </div> </div> <div class="sglv-sc-cols"> <div class="sglv-sc-left"> <div class="sglv-sc-card"> <div class="sglv-sc-card-header">${sk('120px', '16px')}</div> <div class="sglv-sc-card-body"><div class="sglv-sc-cover-grid"> ${Array(8).fill(sk('100%', '170px', '8px')).join('')} </div></div> </div> <div class="sglv-sc-card"> <div class="sglv-sc-card-header">${sk('120px', '16px')}</div> <div class="sglv-sc-card-body"> ${Array(4).fill(`<div class="sglv-sc-playtime-item">${sk('32px', '32px', '50%')}<div class="sglv-sc-playtime-info">${sk('100%', '14px')}${sk('70%', '12px')}</div></div>`).join('')} </div> </div> </div> <div class="sglv-sc-right"> <div class="sglv-sc-card" style="margin-bottom:20px;"> <div class="sglv-sc-card-header">${sk('120px', '16px')}</div> <div class="sglv-sc-card-body"> ${Array(4).fill(`<div class="sglv-sc-ach-item">${sk('32px', '32px', '50%')}<div class="sglv-sc-ach-info">${sk('100%', '14px')}${sk('60%', '12px')}</div></div>`).join('')} </div> </div> <div class="sglv-sc-card"> <div class="sglv-sc-card-header">${sk('80px', '16px')}</div> <div class="sglv-sc-tags">${sk('70px', '24px', '12px')}${sk('70px', '24px', '12px')}</div> </div> </div> </div> </div> </div>`; }, heatmap: () => { const cellSize = 12, cellGap = 3, cellStep = cellSize + cellGap; const labelW = 26, monthH = 16; const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; const dayNames = ['', '一', '', '三', '', '五', '']; const totalWeeks = 53; // 单年最多 53 周 const baseColor = '#21262d'; // 骨架占位色(GitHub theme-1),比 #161b22 略亮以区分背景 //topH 去掉 yearH,与实际单年渲染一致(showYearRow=false),消除加载完成时 14px 垂直跳动 const topH = monthH; const svgW = labelW + totalWeeks * cellStep + 8; const svgH = topH + 7 * cellStep + 8; // 月份标签:每 4 周一个(避免重叠) const monthParts = monthNames.map((n, i) => { const w = Math.floor(i * totalWeeks / 12); return `<text x="${labelW + w * cellStep}" y="${topH - 5}" fill="#475569" font-size="10">${n}</text>`; }).join(''); // 星期标签 const dayParts = dayNames.map((n, i) => n ? `<text x="0" y="${topH + i * cellStep + 10}" fill="#475569" font-size="10">${n}</text>` : '').join(''); // 灰格:7 行 × totalWeeks 列 const cellParts = []; for (let w = 0; w < totalWeeks; w++) { for (let d = 0; d < 7; d++) { const x = labelW + w * cellStep, y = topH + d * cellStep; cellParts.push(`<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="2" fill="${baseColor}"/>`); } } // 顶部骨架图例(与真实渲染图例位置一致) const legendSwatches = ['#161b22', '#0e4429', '#006832', '#26a641', '#39d353'] .map(c => `<span class="swatch" style="background:${c};opacity:0.6"></span>`).join(''); const skeleton = `<svg width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}" style="display:block;max-width:100%;height:auto;">${monthParts}${dayParts}${cellParts.join('')}</svg> <div class="sglv-heatmap-summary" style="opacity:0.5"> <span class="sglv-heatmap-legend"><span>${T.ptHeatmapLess}</span>${legendSwatches}<span>${T.ptHeatmapMore}</span></span> <span>${T.ptHeatmapTotal}: <b>—</b></span> <span>${T.ptHeatmapPeak}: <b>—</b></span> <span>${T.ptHeatmapAvg}: <b>—</b></span> <span>${isZh ? '加载中…' : 'Loading…'}</span> </div>`; return `<div class="sglv-heatmap-block sglv-heatmap-skeleton">${skeleton}</div>`; }, monthly: () => { const now = new Date(); const months = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); months.push({ y: d.getFullYear(), m: d.getMonth(), key: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(-2)}` }); } //与 renderMonthlyAcquireChart 完全一致的尺寸(加高 SVG 减少留白) const W = 280, H = 200, lw = 32, topPad = 20, botPad = 24; const plotW = W - lw - 2, plotH = H - topPad - botPad; const step = plotW / 6, barW = Math.min(26, step * 0.6); let parts = ''; // 网格线 const gridVals = [0, 1]; for (const v of gridVals) { const y = topPad + plotH - (v / 1) * plotH; parts += `<line x1="${lw}" y1="${y}" x2="${W - 2}" y2="${y}" stroke="rgba(255,255,255,0.04)" stroke-width="1" stroke-dasharray="3,3"/>`; parts += `<text x="${lw - 4}" y="${y + 3}" fill="#475569" font-size="9" text-anchor="end">${v}</text>`; } months.forEach((mo, i) => { const cx = lw + step * i + step / 2; const bx = cx - barW / 2, by = topPad + plotH - 2; parts += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${barW}" height="2" rx="3" fill="#334155" opacity="0.25"/>`; const lbl = String(mo.y).slice(2) + '.' + String(mo.m + 1).padStart(2, '0'); parts += `<text x="${cx}" y="${H - 4}" fill="#475569" font-size="9" text-anchor="middle">${lbl}</text>`; }); const svg = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%;">${parts}</svg>`; //结构与实际渲染一致——inner + title + chart + summary const summaryHtml = `<div class="sglv-hm-mini-summary" style="opacity:0.5"> <span class="sglv-hm-mini-stat"><b>—</b> ${isZh ? '总计' : 'total'}</span> <span class="sglv-hm-mini-stat">${isZh ? '加载中…' : 'Loading…'}</span> </div>`; return `<div class="sglv-hm-mini-inner"> <div class="sglv-hm-mini-title">${T.ptHeatmapMini6m}</div> <div class="sglv-hm-mini-chart">${svg}</div> ${summaryHtml} </div>`; }, family: (rows = 4) => { // 不同宽度的 bar 让骨架看起来更接近真实数据分布(最上面"我"略长,其他成员递减) const widths = [72, 55, 38, 22]; const rowHtml = []; for (let i = 0; i < rows; i++) { const w = widths[i] || 18; rowHtml.push( `<div class="sglv-family-bar-row sglv-family-bar-skel">` + `<span class="sglv-family-bar-name" style="background:linear-gradient(90deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02));border-radius:3px;width:60px;height:12px;display:inline-block"></span>` + `<div class="sglv-family-bar-track"><div class="sglv-family-bar-fill" style="width:${w}%;background:linear-gradient(90deg,rgba(255,255,255,0.12),rgba(255,255,255,0.06))"></div></div>` + `<span class="sglv-family-bar-hours" style="background:linear-gradient(90deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02));border-radius:3px;width:48px;height:12px;display:inline-block"></span>` + `</div>` ); } return rowHtml.join(''); }, wishlist: (wrap) => { //愿望单首次加载骨架屏——KPI 数字 + 8 section + 游戏网格,避免页面紧缩(DOM 副作用型) // KPI 区:4 个数字骨架 const kpiEl = wrap.querySelector('#sglv-wishlist-kpi'); if (kpiEl && !kpiEl.innerHTML) { kpiEl.className = 'sglv-wl-kpi'; kpiEl.innerHTML = Array.from({ length: 4 }, () => `<div class="sglv-wl-kpi-cell"> <div class="sglv-skel-bar sglv-skel-num"></div> <div class="sglv-skel-bar sglv-skel-lbl"></div> </div>` ).join(''); } // 8 个 section:每 section 一行骨架 const sectionIds = ['sglv-wl-upcoming', 'sglv-wl-catstats', 'sglv-wl-tags', 'sglv-wl-year-trend', 'sglv-wl-price-dist', 'sglv-wl-disc-dist', 'sglv-wl-timeline']; sectionIds.forEach(id => { const el = wrap.querySelector('#' + id); if (!el || el.innerHTML) return; el.innerHTML = `<div class="sglv-skel-bar sglv-skel-section-title"></div> <div class="sglv-skel-bar sglv-skel-row"></div> <div class="sglv-skel-bar sglv-skel-row"></div> <div class="sglv-skel-bar sglv-skel-row sglv-skel-row-short"></div>`; }); // 右侧 content:12 个卡片网格骨架 const content = wrap.querySelector('#sglv-wishlist-content'); if (content && !content.innerHTML) { content.innerHTML = `<div class="sglv-skel-grid">${Array.from({ length: 12 }, () => `<div class="sglv-skel-game-card"> <div class="sglv-skel-cover"></div> <div class="sglv-skel-bar sglv-skel-card-name"></div> <div class="sglv-skel-bar sglv-skel-card-meta"></div> </div>` ).join('')}</div> ${wishlistLoadingHtml()}`; } }, sgis: (rows = 8) => Array.from({ length: Math.max(0, rows) }, () => `<div class="sgis-skel-row"> <div class="sgis-skel-icon"></div> <div class="sgis-skel-lines"> <div class="sgis-skel-line l1"></div> <div class="sgis-skel-line l2"></div> </div> </div>` ).join(''), }; function createSkeleton(kind, arg) { const build = SKELETONS[kind]; if (!build) throw new TypeError(`createSkeleton: 未知 kind "${kind}"(可用: ${Object.keys(SKELETONS).join('/')})`); return build(arg); } // ==================== 水平条形行工厂 ==================== // 收编 15 处 name+track+fill(+val) 结构重复; // 前缀表驱动各命名空间现状 class 映射(不改 CSS);row 缺省的命名空间产裸三段(外层行由调用方持有); // 宽度归一化/锚定公式与内容转义(wlEscape/escHtml)留在调用方——各处管线现状不一,工厂不二次转义。 // 豁免清单(非数据条形行): 骨架灰块(family skel)、加载动画条(wl-loading/sgis-load)、 // 竖向柱(cs-dist height 布局 / wl-vbar val 上 label 下)——竖柱结构与水平行不同构,不强行收编。 const HBAR_NS = { family: { row: 'sglv-family-bar-row', name: 'sglv-family-bar-name', track: 'sglv-family-bar-track', fill: 'sglv-family-bar-fill', val: 'sglv-family-bar-hours' }, wl: { row: 'sglv-wl-bar-row', name: 'sglv-wl-bar-name', track: 'sglv-wl-bar-track', fill: 'sglv-wl-bar-fill', val: 'sglv-wl-bar-val' }, taste: { row: 'sgis-taste-bar-row', name: 'sgis-taste-bar-label', track: 'sgis-taste-bar-track', fill: 'sgis-taste-bar-fill', val: 'sgis-taste-bar-val' }, cs10: { name: 'sglv-cs-top10-name', track: 'sglv-cs-top10-bar-track', fill: 'sglv-cs-top10-bar', val: 'sglv-cs-top10-size' }, sc: { track: 'sglv-sc-playtime-bar', fill: 'sglv-sc-playtime-bar-fill' }, pt: { track: 'sglv-pt-bar-wrap', fill: 'sglv-pt-bar-fill' }, ptAll: { track: 'sglv-pt-all-bar', fill: 'sglv-pt-all-bar-fill' }, wlBare: { track: 'sglv-wl-bar-track', fill: 'sglv-wl-bar-fill' }, ach: { track: 'sgis-ach-bar', fill: 'sgis-ach-bar-fill' }, xp: { track: 'sgis-xp-bar', fill: 'sgis-xp-bar-fill' }, radar: { track: 'sglv-radar-stat-bar', fill: 'sglv-radar-stat-bar-fill' }, }; function hbarRow({ cls, name, nameTitle, pct, color, val, rowClass, nameTag = 'span', valTag = 'span' }) { const NS = HBAR_NS[cls]; if (!NS) return ''; const nameHtml = name != null ? `<${nameTag} class="${NS.name}"${nameTitle != null ? ` title="${nameTitle}"` : ''}>${name}</${nameTag}>` : ''; const valHtml = val != null ? `<${valTag} class="${NS.val}">${val}</${valTag}>` : ''; const fill = pct != null && pct !== '' ? `<div class="${NS.fill}" style="width:${pct}%${color ? `;background:${color}` : ''}"></div>` : ''; const track = `<div class="${NS.track}">${fill}</div>`; return NS.row ? `<div class="${NS.row}${rowClass ? ' ' + rowClass : ''}">${nameHtml}${track}${valHtml}</div>` : nameHtml + track + valHtml; } //三视图统一渲染器——card/list 骨架全文唯一份(owned/wishlist 双消费)。 // 插槽: imgHtml 头图 / bodyHtml 正文(list 为信息列) / trailingHtml 列表尾部徽章串 / itemAttrs 卡片 // 额外属性(wl: data-appid+onclick) / wrapAttrs 容器属性(wl: data-ids-key)。 // cover 视图豁免: owned(sglv-cover-* 带 gradient/时长角标) 与 wishlist(sglv-wl-cover-* 带价格浮层) // DOM 结构与 CSS 命名空间均不同,参数化合并收益为负,各自保留(等价基线见 TASK-001 验证记录)。 // 事件绑定遵循「渲染后绑定」现状: 工厂只产 HTML,owned 侧渲染后 querySelectorAll 循环 // addEventListener(文档序=map 生成序),wl 侧经 itemAttrs 注入内联 onclick。 // card 分支扩展位(时间线卡第三类消费): gridCls/cardCls/bodyCls // 自定义命名空间(默认值与 owned/wishlist 现状逐字节一致)、bareImg 裸头图(不包 img-wrap)、 // itemPrefixHtml 项前插槽(时间线日期分隔线,divider 为 grid 直接子元素故置于卡片之前)。 // list 分支不扩(无消费方,避免推测性设计)。 function renderGameView(items, opts = {}) { const { viewMode, imgHtml, bodyHtml, trailingHtml = () => '', itemAttrs = () => '', wrapAttrs = '', gridCls = 'sglv-card-grid', cardCls = 'sglv-game-card', bodyCls = 'sglv-card-body', bareImg = false, itemPrefixHtml = () => '', listItemCls = 'sglv-list-item', listInfoCls = 'sglv-list-info', rowsOnly = false, } = opts; if (viewMode === 'card') { return `<div class="${gridCls}"${wrapAttrs}>${items.map(g => ` ${itemPrefixHtml(g)}<div class="${cardCls}"${itemAttrs(g)}> ${bareImg ? imgHtml(g) : `<div class="sglv-card-img-wrap">${imgHtml(g)}</div>`} <div class="${bodyCls}">${bodyHtml(g)}</div> </div>`).join('')}</div>`; } //list 分支参数化(listItemCls/listInfoCls,云存档行消费)+ // rowsOnly 挂载模式——调用方持有容器时只产出行序列(云存档 .sglv-cs-game-list 为 listWrap // 直接 flex 子元素承担 flex:1 滚动区,工厂包裹层会成为额外孙节点破坏该布局) const rows = items.map(g => ` <div class="${listItemCls}"${itemAttrs(g)}> ${imgHtml(g)} <div class="${listInfoCls}">${bodyHtml(g)}</div> ${trailingHtml(g)} </div>`).join(''); return rowsOnly ? rows : `<div class="sglv-list-view"${wrapAttrs}>${rows}</div>`; } //列表工具栏工厂——搜索/排序/视图切换/尾部插槽按配置注入, // 事件经 onChange({type:'input'|'sort'|'view', value}) 分发(input 走共享 debounce 200ms,与各处现状一致)。 // listToolbarHtml 纯字符串构建器(A13 快照断言直接消费);buildListToolbar 负责 createElement+渲染后绑定 // (SGLV 工具栏均为面板构建期一次性 DOM,非每次渲染重建);当前值由调用方 state 提供。 // 签名扩展位(供 FEAT-014 SGIS 侧消费): filters 按钮组等后续按需增配。 // 现状差异保留: owned sort 选中值走「渲染后 .value=」路径(不传 sort.value 则 option 无 selected 属性, // owned 形态保持此行为);云存档传 sort.value 走内联 selected(与其现状一致)。 // views.idPrefix(wl 形态: id=前缀+mode)与 views.useDataView(owned 形态: data-view 属性)二选一。 function listToolbarHtml(cfg = {}) { const { search = null, // {id, cls, placeholder, value, style} sort = null, // {id, cls, title, options:[{value,label}], value} views = null, // {modes:[{mode,title,icon}], value, idPrefix, useDataView} extraHtml = '', } = cfg; let html = ''; if (search) { html += `<input type="text" class="${search.cls || 'sglv-search'}"${search.id ? ` id="${search.id}"` : ''} placeholder="${search.placeholder}"${search.value != null ? ` value="${search.value}"` : ''}${search.style ? ` style="${search.style}"` : ''}>`; } if (sort) { html += `<select class="${sort.cls || 'sglv-sort-select'}"${sort.id ? ` id="${sort.id}"` : ''}${sort.title ? ` title="${sort.title}"` : ''}>${sort.options.map(o => `<option value="${o.value}"${sort.value === o.value ? ' selected' : ''}>${o.label}</option>`).join('')}</select>`; } if (views) { html += `<div class="sglv-view-toggle">${views.modes.map(m => `<button class="sglv-btn sglv-btn-secondary sglv-btn-icon${views.value === m.mode ? ' active' : ''}"${views.idPrefix ? ` id="${views.idPrefix}${m.mode}"` : ''}${views.useDataView ? ` data-view="${m.mode}"` : ''} title="${m.title}">${m.icon}</button>` ).join('')}</div>`; } return html + extraHtml; } function buildListToolbar(cfg = {}) { const { cls = 'sglv-toolbar', search = null, sort = null, views = null, onChange = null } = cfg; const el = document.createElement('div'); el.className = cls; el.innerHTML = listToolbarHtml(cfg); if (onChange) { if (search) { const input = search.id ? el.querySelector(`#${search.id}`) : el.querySelector(`input.${search.cls || 'sglv-search'}`); if (input) input.addEventListener('input', debounce(e => onChange({ type: 'input', value: e.target.value }), 200)); } if (sort) { const sel = sort.id ? el.querySelector(`#${sort.id}`) : el.querySelector(`select.${sort.cls || 'sglv-sort-select'}`); if (sel) sel.addEventListener('change', e => onChange({ type: 'sort', value: e.target.value })); } if (views) { const btns = views.idPrefix ? views.modes.map(m => el.querySelector(`#${views.idPrefix}${m.mode}`)).filter(Boolean) : [...el.querySelectorAll('[data-view]')]; btns.forEach(btn => { btn.addEventListener('click', () => { const mode = views.idPrefix ? btn.id.slice(views.idPrefix.length) : btn.dataset.view; btns.forEach(b => b.classList.toggle('active', b === btn)); onChange({ type: 'view', value: mode }); }); }); } } return el; } //通用选择器弹窗工厂——封面/徽章选择器两份骨架重复实现收敛为单实现。 // pickerModalHtml 纯字符串构建器(A14 快照断言直接消费);openPickerModal 负责 DOM 挂载与事件绑定。 // 骨架契约(与原两实现逐字一致): overlay 点击关闭/搜索 debounce 200ms/已选计数 <strong>N</strong> / max/ // 保存/关闭按钮/ESC 关闭/打开 100ms 后自动聚焦搜索框;ESC/关闭/保存统一走 close() 集中解绑(修复原保存后 // keydown 监听悬挂)。差异经插槽注入: getItems(过滤+分页在调用方)/renderItem(条目模板)/itemKey+keyFromItem // (选中键: 封面 appid / 徽章 appid|badgeid|foil 复合键)/onGridRendered(渲染后钩子: 封面链/徽章分页条)/ // bindExtra(自定义区块事件: 徽章分类 tabs)/canSave/buildResult(保存载荷映射)。 function pickerModalHtml(cfg = {}) { const { modalCls = 'sglv-sc-picker', idBase, // 内部元素 id 前缀: sglv-sc-picker | sglv-sc-badge title, searchPlaceholder, searchAria = '', extraHtml = '', // header 与 body 之间插槽(徽章分类 tabs) gridCls, afterGridHtml = '', // body 内 grid 之后插槽(徽章分页条) countInit, maxSelect, saveText, } = cfg; return ` <div class="${modalCls}"> <div class="sglv-sc-picker-header"> <span class="sglv-sc-picker-title">${title}</span> <input type="text" class="sglv-sc-picker-search" id="${idBase}-search" placeholder="${searchPlaceholder}" autocomplete="off" spellcheck="false"${searchAria ? ` aria-label="${searchAria}"` : ''}> <button class="sglv-sc-picker-close" id="${idBase}-close" aria-label="${isZh ? '关闭' : 'Close'}">✕</button> </div> ${extraHtml} <div class="sglv-sc-picker-body"> <div class="${gridCls}" id="${idBase}-grid"></div> ${afterGridHtml} </div> <div class="sglv-sc-picker-footer"> <span class="sglv-sc-picker-count" id="${idBase}-count"><strong>${countInit}</strong> / ${maxSelect}</span> <button class="sglv-sc-picker-save" id="${idBase}-save">${saveText}</button> </div> </div> `; } function openPickerModal(cfg = {}) { const { overlayId, idBase, maxSelect, currentSelected = [], getItems, // (searchValue) => 条目数组(过滤/分页由调用方负责) itemKey, // (item) => 选中集合键 renderItem, // (item, isSelected) => 条目 HTML(外层 class 自带 selected 注入位) itemSelector, // '.sglv-sc-picker-item' | '.sglv-sc-badge-pick-item' keyFromItem, // (el) => DOM 取键(封面 parseInt(dataset.appid) / 徽章 dataset.key) onGridRendered = null, // (grid, rerender) => 渲染后钩子(封面 _applyCoverChain / 徽章分页条) bindExtra = null, // (overlay, rerender) => 自定义区块事件(徽章 tabs) canSave = size => size > 0, buildResult, // (selectedSet) => onSave 载荷 maxWarningText, savedToastText, onSave, } = cfg; const existing = document.getElementById(overlayId); if (existing) existing.remove(); const mountTarget = (SGLV_API.getPanelEl && SGLV_API.getPanelEl()) || document.body; const selected = new Set(currentSelected); const overlay = document.createElement('div'); overlay.className = 'sglv-sc-picker-overlay'; overlay.id = overlayId; overlay.innerHTML = pickerModalHtml(cfg); mountTarget.appendChild(overlay); const grid = overlay.querySelector(`#${idBase}-grid`); const searchInput = overlay.querySelector(`#${idBase}-search`); const countEl = overlay.querySelector(`#${idBase}-count`); const saveBtn = overlay.querySelector(`#${idBase}-save`); function updateCount() { countEl.innerHTML = `<strong>${selected.size}</strong> / ${maxSelect}`; saveBtn.disabled = !canSave(selected.size); } function renderGrid() { const items = getItems(searchInput.value); grid.innerHTML = items.map(it => renderItem(it, selected.has(itemKey(it)))).join(''); grid.querySelectorAll(itemSelector).forEach(item => { item.addEventListener('click', () => { const key = keyFromItem(item); if (selected.has(key)) { selected.delete(key); item.classList.remove('selected'); } else { if (selected.size >= maxSelect) { sglvToast.warning(maxWarningText); return; } selected.add(key); item.classList.add('selected'); } updateCount(); }); }); if (onGridRendered) onGridRendered(grid, renderGrid); } renderGrid(); updateCount(); // 搜索(debounce 200ms,与原两实现一致) const debouncedSearch = debounce(() => renderGrid(), 200); searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value)); if (bindExtra) bindExtra(overlay, renderGrid); // 关闭路径集中: 按钮/overlay 点击/ESC/保存后均经 close() 解绑 keydown,避免监听泄漏 function close() { overlay.remove(); document.removeEventListener('keydown', escHandler); } overlay.querySelector(`#${idBase}-close`).addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); saveBtn.addEventListener('click', () => { const result = buildResult(selected); close(); if (onSave) onSave(result); sglvToast.success(savedToastText); }); const escHandler = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('keydown', escHandler); setTimeout(() => searchInput.focus(), 100); } //愿望单加载阶段计算——消除 renderWishlistLoadingHtml // 与 renderWishlistGamesList 增量更新两处重复的阶段表;progress>0 时优先用实际进度 function getWishlistLoadStage() { const stages = [ { pct: 15, text: T.wlLoadStage1 }, { pct: 45, text: T.wlLoadStage2 }, { pct: 75, text: T.wlLoadStage3 }, ]; const stage = stages[state.wishlistLoadStage - 1] || { pct: 10, text: T.wlFetching }; const pct = state.wishlistLoadProgress > 0 ? state.wishlistLoadProgress : stage.pct; return { text: stage.text, pct }; } //愿望单加载块——改经 createLoadingHtml 工厂(cls/svgCls/textCls/progress/sub 全量透传) function wishlistLoadingHtml() { const s = getWishlistLoadStage(); return createLoadingHtml(s.text, 32, { cls: 'sglv-wl-loading', svgCls: 'sglv-wl-spin', textCls: 'sglv-wl-loading-text', progress: { pct: s.pct }, sub: T.wlLoadSub, }); } //统一 Tab 切换函数——消除 5 处重复的 "设置 activeTab + 更新 DOM active 类 + renderBody" 逻辑 // 调用方:_gsJumpToGame(owned/wishlist 跳转)、tab click handler、_onOpenModal 事件 //本函数位于子闭包之外,panelEl/renderBody 处于子闭包内,故经 SGLV_API 桥接访问 function switchToTab(tabName) { if (!tabName) return; state.activeTab = tabName; state.showSettings = false; const _panel = SGLV_API.getPanelEl && SGLV_API.getPanelEl(); if (_panel) { _panel.querySelectorAll('.sglv-tab').forEach(t => t.classList.remove('active')); const tabBtn = _panel.querySelector(`.sglv-tab[data-tab="${tabName}"]`); if (tabBtn) tabBtn.classList.add('active'); } if (SGLV_API.renderBody) SGLV_API.renderBody(); } // ==================== 全局游戏搜索(中文/拼音缩写/英文子串匹配) ==================== // 参考 https://github.com/sys1em/Steam_Buff 的搜索联想思路,结合中文名 + 拼音首字母 + 英文子串, // 本实现完全本地运行,索引从已加载的 state.ownedGames + state.wishlistGames + gameDb 离线数据库 + 持久化自定义别名构建。 // 设计要点: // 1) 启动时一次性构建 _searchIndex(每条 {appid, name, nameLower, nameNoSpace, pinyinAbbr, pinyinAbbrNoSpace, type, sources}) // 2) 搜索时按子串+拼音子串 双向匹配,排序时优先 substring 完全匹配 > 拼音完全匹配 > 短名称 // 3) 命中渲染在浮层中,每条带"类型徽章"(已拥有/愿望单/商店),点击按来源跳转到对应 tab + 滚动定位 // 4) 中文/英文字符串小写化 + 去空格预存,搜索时同样预处理;空格和特殊字符被忽略 // 常用汉字→拼音首字母查表已抽到独立脚本 sglv-pinyin.lib.js(@require 引入)。 // 主脚本通过 _getPinyinLib() 委托 searchByPinyin,不再需要本地 toPinyinAbbr 包装。 // - 查表来源:SGLVPinyin._TABLE(~3857 常用汉字,ASCII 数字/英文直通) // - 行为契约:空格/标点保留、英文小写、CJK 查表、未收录字符保留原字符(允许 substring 兜底匹配) //从一次性求值的 const 改为实时求值函数 _getPinyinLib(), // 解决 @require 脚本因网络延迟晚于主脚本执行时拼音库永久不可用的问题 function _getPinyinLib() { return (typeof window !== 'undefined' && window.SGLVPinyin) || (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVPinyin) || null; } //移除 toPinyinAbbr 包装函数——buildSearchIndex 不再预计算 pinyinAbbr 字段, // searchByPinyin 内部通过 getName(it) 实时调用 _getPinyinLib().toPinyinAbbr,包装层已无调用方 // 搜索索引项: {appid, name, type: 'owned'|'wishlist'|'store', owned, wishlist, store, _scoreBias} const _searchIndex = []; // 所有游戏条目(去重,优先级 owned > wishlist > store) const _searchIndexByAppid = new Map(); // appid -> index entry(用于去重) let _searchIndexBuilt = false; //统一标点/空格剥离正则——与 sglv-pinyin.lib.js 的 _PUNCT_STRIP_RE 保持一致 // 消除 buildSearchIndex 和 highlightMatch 中 5 处重复的内联正则字面量 const _PUNCT_STRIP_RE = /[\s\-_::·、,,.\/\\()()\[\]【】''""]/g; // 非 global 版本供 .test() 使用——避免 g 标志在循环中 lastIndex 状态泄漏 const _PUNCT_TEST_RE = /[\s\-_::·、,,.\/\\()()\[\]【】''""]/; // 提取已加载 game-db 数据库的所有游戏项(供"商店"维度匹配) // game-db 格式: { "publisher": { "series": [{id, name}, ...] } } function _extractGameDbEntries() { // 优先从 _gameDbCache 拿(如果已加载),否则解析当前 game-db try { let db = null; try { const txt = GM_getResourceText('gameDb'); if (txt) db = JSON.parse(txt); } catch (e) { /* ignore */ } if (!db) return []; const entries = []; for (const publisher of Object.keys(db)) { if (publisher === '_meta') continue; const seriesMap = db[publisher]; if (!seriesMap || typeof seriesMap !== 'object') continue; for (const series of Object.keys(seriesMap)) { const items = seriesMap[series]; if (!Array.isArray(items)) continue; for (const it of items) { if (it && it.id && it.name) { entries.push({ appid: Number(it.id), name: it.name }); } } } } return entries; } catch (e) { return []; } } // 构建/重建搜索索引 function buildSearchIndex() { _searchIndex.length = 0; _searchIndexByAppid.clear(); const ownedSet = new Set(); // 1) 已拥有(优先级最高) //保留 _gameObj 引用,渲染时通过 isGameOwnedByMe(_gameObj) 区分"自己拥有"与"家庭共享" if (Array.isArray(state.ownedGames)) { for (const g of state.ownedGames) { if (!g || !g.appid) continue; const appid = Number(g.appid); if (ownedSet.has(appid)) continue; ownedSet.add(appid); _searchIndexByAppid.set(appid, { appid, name: g.name || '', type: 'owned', owned: true, wishlist: false, store: false, _gameObj: g, // v2.9.53: 供徽章渲染时区分 mine/shared }); } } // 2) 愿望单 if (Array.isArray(state.wishlistGames)) { for (const g of state.wishlistGames) { if (!g || !g.appid) continue; const appid = Number(g.appid); if (ownedSet.has(appid)) { const ex = _searchIndexByAppid.get(appid); if (ex) { ex.wishlist = true; ex.type = 'owned'; /* 优先 owned */ } continue; } if (_searchIndexByAppid.has(appid)) continue; _searchIndexByAppid.set(appid, { appid, name: g.name || '', type: 'wishlist', owned: false, wishlist: true, store: false, }); } } // 3) game-db 数据库(商店维度) const dbEntries = _extractGameDbEntries(); for (const e of dbEntries) { if (_searchIndexByAppid.has(e.appid)) { _searchIndexByAppid.get(e.appid).store = true; continue; } _searchIndexByAppid.set(e.appid, { appid: e.appid, name: e.name, type: 'store', owned: false, wishlist: false, store: true, }); } // 4) 转数组,仅预计算来源权重 _scoreBias //移除 nameLower/nameNoSpace/pinyinAbbr/pinyinAbbrNoSpace 预计算—— // searchByPinyin 内部通过 getName(it) 实时重算这些字段,预存值从未被读取(死代码) for (const entry of _searchIndexByAppid.values()) { entry._scoreBias = entry.owned ? 30 : entry.wishlist ? 20 : 0; _searchIndex.push(entry); } _searchIndexBuilt = true; console.log(`[Steam 游戏库展示] 全局搜索索引已构建: ${_searchIndex.length} 条 (owned=${ownedSet.size}, wishlist=${(state.wishlistGames||[]).length}, store=${dbEntries.length})`); } // 搜索:返回匹配项数组(按优先级排序),最多 30 条 //优化:4 级匹配逻辑(100/95/80/75)+ 来源权重(_scoreBias)+ 名称长度排序 // 全部委托给 sglv-pinyin.lib.js 的 searchByPinyin(),sglv-pinyin.lib.test.js 已覆盖 10 个 case。 // 降级路径:拼音库未加载时(理论不会发生,@require 强制)返回空数组并 console.warn。 function searchGames(query, limit = 30) { if (!_searchIndexBuilt) buildSearchIndex(); injectSGLVAppDetailHost(); // v2.9.64: 惰性注入 host API,确保 getOwnershipBadge 可用 const lib = _getPinyinLib(); if (!lib) { console.warn('[Steam 游戏库展示] 拼音库未加载,搜索不可用'); return []; } return lib.searchByPinyin(query, _searchIndex, { limit, getName: e => e.name, }); } // 高亮匹配片段:用 <mark> 包裹 //增加拼音首字母匹配高亮——用户输入"wzry"匹配"王者荣耀"时高亮对应字符 function highlightMatch(name, query) { if (!name || !query) return escHtml(name || ''); const escaped = escHtml(name); const q = String(query).trim(); if (!q) return escaped; // 优先匹配原文字符串(忽略大小写),再尝试拼音首字母 const lowerName = name.toLowerCase(); const lowerQ = q.toLowerCase(); const idx = lowerName.indexOf(lowerQ); if (idx >= 0) { const before = escHtml(name.slice(0, idx)); const hit = escHtml(name.slice(idx, idx + q.length)); const after = escHtml(name.slice(idx + q.length)); return before + '<mark>' + hit + '</mark>' + after; } // 去空格匹配 const nameNoSpace = lowerName.replace(_PUNCT_STRIP_RE, ''); const qNoSpace = lowerQ.replace(_PUNCT_STRIP_RE, ''); const idx2 = nameNoSpace.indexOf(qNoSpace); if (idx2 >= 0) { // 反推原始字符串中的大致位置 let count = 0, startOrig = -1, endOrig = -1; for (let i = 0; i < name.length; i++) { const c = name[i].toLowerCase(); if (!_PUNCT_TEST_RE.test(c)) { if (count === idx2) startOrig = i; if (count === idx2 + qNoSpace.length - 1) { endOrig = i + 1; break; } count++; } } if (startOrig >= 0 && endOrig > startOrig) { return escHtml(name.slice(0, startOrig)) + '<mark>' + escHtml(name.slice(startOrig, endOrig)) + '</mark>' + escHtml(name.slice(endOrig)); } } //拼音首字母匹配高亮——toPinyinAbbr 与原名字符 1:1 对应,可直接映射位置 const lib = _getPinyinLib(); if (lib && typeof lib.toPinyinAbbr === 'function') { const pinyinAbbr = lib.toPinyinAbbr(name).toLowerCase(); // 先尝试直接子串匹配(含空格) const idx3 = pinyinAbbr.indexOf(lowerQ); if (idx3 >= 0) { return escHtml(name.slice(0, idx3)) + '<mark>' + escHtml(name.slice(idx3, idx3 + q.length)) + '</mark>' + escHtml(name.slice(idx3 + q.length)); } // 再尝试去空格后的拼音缩写匹配 const abbrNoSpace = pinyinAbbr.replace(_PUNCT_STRIP_RE, ''); const idx4 = abbrNoSpace.indexOf(qNoSpace); if (idx4 >= 0) { let count2 = 0, startOrig2 = -1, endOrig2 = -1; for (let i = 0; i < pinyinAbbr.length; i++) { const c = pinyinAbbr[i]; if (!_PUNCT_TEST_RE.test(c)) { if (count2 === idx4) startOrig2 = i; if (count2 === idx4 + qNoSpace.length - 1) { endOrig2 = i + 1; break; } count2++; } } if (startOrig2 >= 0 && endOrig2 > startOrig2) { return escHtml(name.slice(0, startOrig2)) + '<mark>' + escHtml(name.slice(startOrig2, endOrig2)) + '</mark>' + escHtml(name.slice(endOrig2)); } } } return escaped; } // ==================== 全局游戏搜索 - 事件绑定/下拉渲染/键盘导航/游戏跳转 ==================== // 设计:搜索框绑定防抖输入/键盘/外部点击事件;下拉浮层渲染前 30 条匹配项, // 支持 ↑↓ 选择、Enter 跳转、Esc 关闭;游戏条目带 owned/wishlist/store 徽标。 // 跳转策略:owned → 切到 owned tab 并滚动定位;wishlist → 切到 wishlist tab 并定位; // store-only → 直接打开 Steam 商店页。 let _gsFocusIdx = -1; // 当前键盘聚焦的项索引 let _gsLastResults = []; // 最近一次搜索结果 let _gsLastQuery = ''; // 最近一次搜索关键词 let _gsInputEl = null; // 搜索输入框 let _gsPopEl = null; // 下拉浮层 let _gsSearchWrap = null; // 整个搜索容器 let _gsClearBtn = null; // 清除按钮 // 标记搜索索引需重建(数据变化时调用) function markSearchIndexDirty() { _searchIndexBuilt = false; } // 切换搜索下拉浮层显隐 //用 classList.toggle 简化 if/else function _gsShowPop(show) { if (!_gsPopEl) return; _gsPopEl.classList.toggle('show', show); } // 关闭搜索下拉(清空聚焦索引) function closeSearchPop() { _gsShowPop(false); _gsFocusIdx = -1; } //Steam 商店搜索建议 (异步合并到本地搜索结果) let _gsStoreSearchToken = 0; // 竞态保护: 每次新查询递增, 旧请求返回时丢弃 // ---- 商店搜索增强(借鉴 GreasyFork #576594「Steam 链接助手」)---- // ① demo/prologue 排污:非 demo 查询下把 demo/prologue 项降权排尾(借其 selectBest // 思路;下拉是列表不是单选,故降权不直接过滤 —— 搜得到,只是不让它挤掉本体) // ② cc/l 区域:跟随页面自身商店语言/地区(#application_config data-config), // 不再硬编码 l=schinese&cc=cn(非国区账号看到的价格/结果会错) // ③ 删词回退:0 结果才放宽,最多 2 级(对齐 mv-search.shrinkQuery)。 // 只剥掉尾部标点,或删掉最后一个空白分隔的词。响应异常不回退。 // 中文 / 日文 / 连字符整体算一个词。v2.46.0 用 \w 判断,CJK 每个字都不是 \w, // 「艾尔登法环」会被逐字削成「艾」并把不相干的商店结果灌进下拉。 function _gsIsWordChar(ch) { return /[\p{L}\p{N}_]/u.test(ch); } function _gsShrinkStoreQuery(q) { const s = String(q || '').trim(); if (!s) return ''; let end = s.length; while (end > 0 && !_gsIsWordChar(s[end - 1])) end--; const stripped = s.slice(0, end).trimEnd(); if (stripped !== s) return stripped; const m = stripped.match(/^(.*)\s+\S+$/); if (!m) return ''; return m[1].trimEnd(); } // 词边界匹配,避免 Demolition 之类误伤(参考脚本用 includes('demo') 会误伤) const _GS_STORE_PROHIBITED_RE = /\bdemo\b|\bprologue\b|试玩|体验版|序章/i; function _gsStoreIsProhibited(name) { return _GS_STORE_PROHIBITED_RE.test(String(name || '')); } function _gsDemoteStoreItems(items, query) { if (_gsStoreIsProhibited(query)) return items; // 用户本就在搜 demo, 不动 const clean = [], demoted = []; for (const it of items) (_gsStoreIsProhibited(it.name) ? demoted : clean).push(it); return clean.concat(demoted); } function _gsStoreLocale() { let cfg = null; try { const el = document.querySelector('#application_config'); cfg = JSON.parse((el && el.getAttribute('data-config')) || 'null'); } catch (e) { cfg = null; } return { lang: (cfg && cfg.LANGUAGE) ? String(cfg.LANGUAGE).toLowerCase() : (isZh ? 'schinese' : 'english'), cc: (cfg && cfg.COUNTRY) ? String(cfg.COUNTRY).toLowerCase() : 'cn', }; } async function searchSteamStore(query) { const token = ++_gsStoreSearchToken; try { const loc = _gsStoreLocale(); let q = String(query || '').trim(); let items = []; let shrinks = 0; while (q) { const url = `https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(q)}&l=${loc.lang}&cc=${loc.cc}&max_results=15`; const data = await sglvGmFetchRetry(url, { timeout: 5000, retries: 0 }); if (token !== _gsStoreSearchToken) return []; // 旧请求, 丢弃 if (!data || !Array.isArray(data.items)) return []; // 响应异常, 不回退 if (data.items.length) { items = data.items; break; } if (shrinks >= 2) break; // 原查询 + 最多 2 级,对齐 mv-search.shrinkQuery const next = _gsShrinkStoreQuery(q); if (!next || next === q) break; q = next; shrinks++; } return _gsDemoteStoreItems(items, query).map(item => ({ appid: Number(item.id), name: item.name || '', type: 'store', owned: false, wishlist: false, store: true, _storeResult: true, _storeImg: item.tiny_image || '', _storePrice: item.prices ? { final: (Number(item.prices.final) || 0) / 100, initial: (Number(item.prices.initial) || 0) / 100, discount: Number(item.prices.discount_percentage) || 0, currency: item.prices.currency || '', } : null, _storeReview: item.review_desc || '', })); } catch (e) { return []; // 商店搜索失败, 静默降级 } } //合并本地+商店搜索结果 (去重, 本地优先) function mergeSearchResults(localResults, storeResults) { if (!storeResults || storeResults.length === 0) return localResults; const localAppids = new Set(localResults.map(r => r.appid)); const merged = [...localResults]; for (const sr of storeResults) { if (!sr.appid || localAppids.has(sr.appid)) continue; // 检查是否已在已拥有/愿望单中 (更新标志) if (_searchIndexByAppid.has(sr.appid)) { const existing = _searchIndexByAppid.get(sr.appid); if (existing.owned || existing.wishlist) continue; // 本地已有, 跳过 } merged.push(sr); localAppids.add(sr.appid); if (merged.length >= 30) break; } return merged; } //精准匹配检测 (查询为纯数字时匹配 AppID) function detectAppidExactMatch(query) { const num = Number(query); if (!Number.isInteger(num) || num <= 0) return null; // 先检查搜索索引 if (_searchIndexByAppid.has(num)) { const entry = _searchIndexByAppid.get(num); return { ...entry, _exactMatch: true }; } // 不在索引中, 也返回一个 store-only 精准匹配项 return { appid: num, name: `App ${num}`, type: 'store', owned: false, wishlist: false, store: true, _exactMatch: true }; } //搜索结果项单一构建器——renderSearchResults 与 // renderSearchResults_merged 原各自内联 ~50 行项模板 + 页脚 + 事件绑定逐字重复, // 抽取 buildSearchItem/gsFooterHtml/renderGsResults 三件套共用。 // 差异清单(2026-08-18 通读原 L4872-5086 实测): 两路径的项模板/页脚/事件绑定三段 // 完全一致,唯一差异在调用侧——主路径多 searchGames/精准匹配置顶/空态/商店异步合并, // _merged 路径仅对既有结果列表重渲染(不触发商店搜索,防递归)。 // 纯字符串拼装零 DOM 查询,不增加 200ms debounce 高频交互开销 function buildSearchItem(entry, idx, q) { const owned = !!entry.owned, wishlist = !!entry.wishlist, store = !!entry.store; // 多个来源时主徽标优先: owned > wishlist > store let typeKey, typeText; if (owned) { typeKey = 'owned'; typeText = T.gsTypeOwned; } else if (wishlist) { typeKey = 'wishlist'; typeText = T.gsTypeWishlist; } else { typeKey = 'store'; typeText = T.gsTypeStore; } // 次要徽标 const badges = []; if (owned && wishlist) badges.push(`<span class="sglv-gs-item-type wishlist" style="opacity:.6">${escHtml(T.gsTypeWishlist)}</span>`); if (owned && store) badges.push(`<span class="sglv-gs-item-type store" style="opacity:.6">${escHtml(T.gsTypeStore)}</span>`); //精准匹配徽章 if (entry._exactMatch) badges.push(`<span class="sglv-gs-item-type" style="background:rgba(74,222,128,0.15);color:#4ade80;border:1px solid rgba(74,222,128,0.25)">${isZh ? '精准匹配' : 'Exact'}</span>`); //拥有/共享状态徽章(基于 SGLVAppDetail.getOwnershipBadge) // 直接传 entry._gameObj(O(1) 替代库内 Array.find,30 条结果渲染 30x 提速) // _gameObj 仅在 owned 条目上存在,wishlist/store-only 跳过即可 let ownershipBadge = ''; try { if (unsafeWindow.SGLVAppDetail && entry._gameObj) { const b = unsafeWindow.SGLVAppDetail.getOwnershipBadge(entry.appid, entry._gameObj); if (b && b.kind === 'shared') { // 家庭共享:紫色徽章 + tooltip 显示共享者 const tip = b.tip ? escHtml(b.tip) : (isZh ? '家庭库中共享' : 'Family shared'); const lbl = isZh ? '共享' : 'Shared'; ownershipBadge = `<span class="sglv-gs-item-shared" title="${tip}">${lbl}</span>`; } } } catch (e) { /* ignore */ } const subText = owned ? T.gsJumpToOwned : wishlist ? T.gsJumpToWishlist : T.gsOpenStore; const htmlName = highlightMatch(entry.name, q); //getGameIconUrl 定义于下方渲染子闭包(7718 行 IIFE),此处 bare 引用不可见—— // 旧 typeof 守卫恒为 false,缩略图从未真正接通过 capsule 兜底;改经 SGLV_API 桥接 // (渲染子闭包求值时已填充,全局搜索渲染时必可用) const capSrc = entry._storeImg || (SGLV_API.getGameIconUrl ? SGLV_API.getGameIconUrl(entry.appid, '') : ''); //外链图标仅在 store-only 时显示(owned/wishlist 是面板内跳转) const extIcon = (!owned && !wishlist) ? `<span class="sglv-gs-item-ext">${ICONS.external}</span>` : ''; //商店搜索结果附加价格/评测信息 let storeInfoHtml = ''; if (entry._storeResult && entry._storePrice && entry._storePrice.final > 0) { const sp = entry._storePrice; //¥ 硬编码替换为 sglvCurrencySymbol,优先走 SGLVCurrency lib(sp.currency 缺失时 fallback '¥') const sym = sglvCurrencySymbol(sp.currency); const priceStr = `${sym}${sp.final.toFixed(2)}`; const discountStr = sp.discount > 0 ? `<span style="color:#4ade80;font-weight:700">-${sp.discount}%</span>` : ''; const origStr = sp.discount > 0 && sp.initial > sp.final ? `<span style="text-decoration:line-through;color:var(--sgis-text-2);font-size:9px">${sym}${sp.initial.toFixed(2)}</span>` : ''; storeInfoHtml = ` · ${discountStr} ${priceStr} ${origStr}`; } else if (entry._storeResult && entry._storeReview) { storeInfoHtml = ` · ${escHtml(entry._storeReview)}`; } return `<div class="sglv-gs-item${entry._exactMatch ? ' sglv-gs-item-exact' : ''}" data-idx="${idx}" data-appid="${entry.appid}" data-type="${typeKey}"> <div class="sglv-gs-item-thumb">${capSrc ? `<img src="${capSrc}" alt="" loading="lazy" onerror="this.onerror=null;_sglvIconFallback(this,'${entry.appid}','')">` : ''}</div> <div class="sglv-gs-item-body"> <div class="sglv-gs-item-name">${htmlName}</div> <div class="sglv-gs-item-sub">${escHtml(subText)} · AppID ${entry.appid}${storeInfoHtml}</div> </div> <div class="sglv-gs-item-right"> <span class="sglv-gs-item-type ${typeKey}">${escHtml(typeText)}</span> ${ownershipBadge} ${badges.join('')} ${extIcon} </div> </div>`; } //搜索页脚键盘提示(两路径共用) function gsFooterHtml() { return isZh ? `<div class="sglv-gs-footer"> <span class="sglv-gs-footer-hint"><kbd>↑</kbd><kbd>↓</kbd> 选择</span> <span class="sglv-gs-footer-hint"><kbd>Enter</kbd> 跳转</span> <span class="sglv-gs-footer-hint"><kbd>Esc</kbd> 关闭</span> </div>` : `<div class="sglv-gs-footer"> <span class="sglv-gs-footer-hint"><kbd>↑</kbd><kbd>↓</kbd> Navigate</span> <span class="sglv-gs-footer-hint"><kbd>Enter</kbd> Open</span> <span class="sglv-gs-footer-hint"><kbd>Esc</kbd> Close</span> </div>`; } //头部+列表+页脚渲染与条目事件绑定(两路径共用;调用方保证 _gsPopEl 与结果数组就绪) function renderGsResults(q, results) { const header = `<div class="sglv-gs-header"><span>${escHtml(T.gsHeader)}</span><span class="sglv-gs-header-count">${results.length}</span></div>`; const items = results.map((entry, idx) => buildSearchItem(entry, idx, q)).join(''); _gsPopEl.innerHTML = header + items + gsFooterHtml(); _gsShowPop(true); // 绑定每项点击 _gsPopEl.querySelectorAll('.sglv-gs-item').forEach(itemEl => { const idx = Number(itemEl.dataset.idx); itemEl.addEventListener('mouseenter', () => { _gsFocusIdx = idx; _gsUpdateFocusClass(); }); itemEl.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); const target = _gsLastResults[idx]; if (target) _gsJumpToGame(target); }); }); } // 渲染搜索结果下拉 function renderSearchResults(query) { if (!_gsPopEl) return; const q = String(query || '').trim(); if (!q) { _gsLastQuery = ''; // 清空后,在途的商店响应不得把下拉再弹出来 _gsShowPop(false); _gsLastResults = []; return; } const results = searchGames(q, 30); _gsLastQuery = q; _gsFocusIdx = -1; //精准匹配检测 const exactMatch = detectAppidExactMatch(q); if (exactMatch) { // 精准匹配项放在第一位 (去重: 如果已在结果中则提升到第一位) const idx = results.findIndex(r => r.appid === exactMatch.appid); if (idx >= 0) { results[idx]._exactMatch = true; results.unshift(results.splice(idx, 1)[0]); } else { results.unshift(exactMatch); } } _gsLastResults = results; // 空结果先出空态,但不 return:0 条同样要问商店 // (v2.46.0 及更早在这里 return,库里没有的游戏永远不会发商店请求) if (results.length === 0) { _gsPopEl.innerHTML = `<div class="sglv-gs-empty"> <div class="sglv-gs-empty-icon">${ICONS.search}</div> <span>${escHtml(T.gsEmpty)}</span> </div>`; _gsShowPop(true); } else { //头部+列表+页脚渲染与事件绑定经 renderGsResults 共用(原内联段删除) renderGsResults(q, results); } //本地结果不足 8 条时,异步合并 Steam 商店搜索建议(含 0 条) if (results.length < 8 && !exactMatch) { searchSteamStore(q).then(storeResults => { if (storeResults.length === 0) return; if (_gsLastQuery !== q) return; // 用户已输入新查询, 丢弃旧结果 const merged = mergeSearchResults(_gsLastResults, storeResults); if (merged.length === _gsLastResults.length) return; // 无新增, 不重渲染 _gsLastResults = merged; _gsFocusIdx = -1; // 重新渲染合并后的结果 (递归调用, 但此次 results.length >= 8 不会再次触发商店搜索) renderSearchResults_merged(q, merged); }).catch(() => {}); } } //合并后重渲染 (不触发商店搜索, 避免递归) function renderSearchResults_merged(query, results) { if (!_gsPopEl || results.length === 0) return; renderGsResults(query, results); } // 更新键盘聚焦项高亮 function _gsUpdateFocusClass() { if (!_gsPopEl) return; const items = _gsPopEl.querySelectorAll('.sglv-gs-item'); items.forEach((el, i) => { if (i === _gsFocusIdx) el.classList.add('focus'); else el.classList.remove('focus'); }); // 滚动到可视区 if (_gsFocusIdx >= 0 && items[_gsFocusIdx]) { items[_gsFocusIdx].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } //游戏详情浮窗 —— 点击搜索结果后弹出详情卡片 // 依赖 SGLVAppDetail.loadDetail() 获取 appdetails 数据(内存5min+磁盘24h缓存) let _detailEscHandler = null; let _detailToken = 0; // v2.9.64: 异步竞态保护——快速连续点击不同搜索结果时丢弃旧请求结果 function closeDetailPopup() { _detailToken++; // 使任何在途的详情请求失效 const overlay = document.getElementById('sglv-detail-overlay'); const pop = document.getElementById('sglv-detail-pop'); if (overlay) overlay.classList.remove('show'); if (pop) { pop.classList.remove('show'); pop.innerHTML = ''; } if (_detailEscHandler) { document.removeEventListener('keydown', _detailEscHandler); _detailEscHandler = null; } } function _bindDetailClose(pop) { const overlay = document.getElementById('sglv-detail-overlay'); const closeBtn = pop.querySelector('#sglv-detail-close-btn'); if (closeBtn) closeBtn.addEventListener('click', closeDetailPopup); if (overlay) overlay.onclick = closeDetailPopup; // 覆盖式绑定,避免重复 if (_detailEscHandler) document.removeEventListener('keydown', _detailEscHandler); _detailEscHandler = (e) => { if (e.key === 'Escape') closeDetailPopup(); }; document.addEventListener('keydown', _detailEscHandler); } function showGameDetailPopup(appid, entry) { const overlay = document.getElementById('sglv-detail-overlay'); const pop = document.getElementById('sglv-detail-pop'); if (!overlay || !pop) { console.warn('[SGLV] 详情浮窗容器未找到'); return; } injectSGLVAppDetailHost(); // v2.9.64: 惰性注入 host API,确保 loadDetail 可用 const token = ++_detailToken; // 每次打开递增,旧请求 resolve 后比对发现不一致则丢弃 // 加载态 pop.innerHTML = `<button class="sglv-detail-close" id="sglv-detail-close-btn">${ICONS.close}</button> <div class="sglv-detail-scroll"><div class="sglv-detail-loading"> ${spinSvg(34)} <span>${escHtml(T.dpLoading)}</span> </div></div>`; overlay.classList.add('show'); pop.classList.add('show'); _bindDetailClose(pop); _loadAndRenderDetail(appid, entry, pop, token); } async function _loadAndRenderDetail(appid, entry, pop, token) { const A = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail) || (typeof window !== 'undefined' && window.SGLVAppDetail); if (!A || typeof A.loadDetail !== 'function') { if (token !== _detailToken) return; // 已被新请求取代 _renderDetailError(pop, appid, entry, 'SGLVAppDetail 库未加载'); return; } try { const d = await A.loadDetail(appid, { useCache: true }); if (token !== _detailToken) return; // 已被新请求取代,丢弃旧结果 if (!d) { _renderDetailError(pop, appid, entry, 'API 无数据返回'); return; } _renderDetailContent(pop, appid, entry, d, token); } catch (e) { if (token !== _detailToken) return; // 已被新请求取代 _renderDetailError(pop, appid, entry, e.message || String(e)); } } function _renderDetailError(pop, appid, entry, msg) { pop.innerHTML = `<button class="sglv-detail-close" id="sglv-detail-close-btn">${ICONS.close}</button> <div class="sglv-detail-scroll"><div class="sglv-detail-error"> <span>${escHtml(T.dpError)}</span> <span style="font-size:10px;color:#6e7681">${escHtml(msg)}</span> <button class="sglv-detail-btn sglv-detail-btn-secondary" id="sglv-detail-retry">${escHtml(T.dpRetry)}</button> </div></div>`; _bindDetailClose(pop); const retry = pop.querySelector('#sglv-detail-retry'); if (retry) retry.addEventListener('click', () => showGameDetailPopup(appid, entry)); } //异步填充资料受限状态徽章到搜索详情浮窗 // 参考 Nailoooooong/steam-restricted-game-realtime-userscript 的状态检测逻辑: // 获取商店页 HTML → 解析 .game_area_details_specs_ctn.learning_about → 判断资料受限/正在了解/正常 async function _fillProfileStatusBadge(pop, appid, token) { const statusEl = pop.querySelector('#sglv-detail-profile-status'); if (!statusEl) return; const _renderBadge = (info) => { if (!info || info.status === 'normal') return; const title = info.status === 'restricted' ? 'Profile Features Limited:该游戏的卡片、成就、游玩时长等资料不会显示在 Steam 个人资料页' : 'Steam 正在了解该游戏,部分资料功能可能暂时受限'; statusEl.innerHTML = `<span class="sgis-profile-badge ${escHtml(info.status)}" title="${escHtml(title)}">${ICONS.profileLimited} ${escHtml(info.label)}</span>`; }; // 1. 先看缓存 const cached = cacheGet('profileFeatures_' + appid); if (cached) { _renderBadge(cached); return; } // 2. 异步获取商店页 HTML //经 sglvGmFetchTextRetry 发送(headers 透传依赖 core 1.0.6; // 年龄验证 Cookie/Accept-Language 是功能正确性依赖——丢失则返回年龄验证页/本地语言页致误判) try { const url = `https://store.steampowered.com/app/${appid}/?l=english&cc=us`; const html = await sglvGmFetchTextRetry(url, { timeout: 15000, headers: { 'Accept': 'text/html', 'Accept-Language': 'en-US,en;q=0.9', 'Cookie': 'mature_content=1; birthtime=441734400; lastagecheckage=1-January-1900; Steam_Language=english', }, }); if (token !== _detailToken) return; // 已被新请求取代 const doc = new DOMParser().parseFromString(html, 'text/html'); const learningEl = doc.querySelector('.game_area_details_specs_ctn.learning_about'); let result; if (!learningEl) { result = { status: 'normal', label: '资料正常', source: 'fetch' }; } else { const labelEl = learningEl.querySelector('.label'); const labelText = (labelEl ? labelEl.textContent : '') || learningEl.textContent || ''; if (/Profile\s+Features\s+Limited/i.test(labelText)) { result = { status: 'restricted', label: '资料受限', source: 'fetch' }; } else { result = { status: 'learning', label: 'Steam正在了解', source: 'fetch' }; } } cacheSet('profileFeatures_' + appid, result, CACHE_TTL.profileFeatures); if (token !== _detailToken) return; _renderBadge(result); } catch (e) { // 静默失败,不影响浮窗正常使用 //补终态失败通知(60s 节流,此前完全静默) sglvNotifyNetFail('资料受限徽章检测', e); } } function _renderDetailContent(pop, appid, entry, d, token) { const platforms = []; if (d.platforms) { if (d.platforms.win) platforms.push('Windows'); if (d.platforms.mac) platforms.push('macOS'); if (d.platforms.linux) platforms.push('SteamOS+Linux'); } // 价格 //修复货币符号 — 优先使用 entry._gameObj 中的用户地区价格(detectWlPriceSymbol 符号), // 其次根据 d.cnPrice.currency 选择正确符号(Steam 可能对登录用户忽略 cc=cn, // 返回用户实际所在地区价格如 INR,但旧代码硬编码 ¥ 导致符号与价格不符) let priceHtml = ''; if (d.isFree) { priceHtml = `<span class="sglv-detail-price-free">${escHtml(T.dpFree)}</span>`; } else { const gi = (entry && entry._gameObj) ? entry._gameObj : null; if (gi && Number(gi.finalPrice) > 0) { const sym = SGLV_API.detectWlPriceSymbol ? SGLV_API.detectWlPriceSymbol() : '¥'; priceHtml = `<span class="sglv-detail-price-val">${sym}${escHtml(String(gi.finalPrice))}</span>`; if (gi.discountPct > 0) priceHtml += `<span class="sglv-detail-price-discount">-${escHtml(String(gi.discountPct))}%</span>`; } else if (d.cnPrice) { const curSym = sglvCurrencySymbol(d.cnPrice.currency); priceHtml = `<span class="sglv-detail-price-val">${curSym}${escHtml(String(d.cnPrice.price))}</span>`; if (d.cnPrice.discount > 0) priceHtml += `<span class="sglv-detail-price-discount">-${escHtml(String(d.cnPrice.discount))}%</span>`; } } // Metacritic let metaHtml = ''; if (d.metacritic && d.metacritic.score) { const s = d.metacritic.score; const c = s >= 75 ? '#4ade80' : s >= 50 ? '#fbbf24' : '#f87171'; metaHtml = `<a class="sglv-detail-metacritic" href="${escHtml(d.metacritic.url || '#')}" target="_blank" rel="noopener" style="background:${c}22;color:${c};border:1px solid ${c}55">Metacritic ${escHtml(String(s))}</a>`; } // 评测摘要 let reviewHtml = ''; if (d.reviews && d.reviews.summary) { reviewHtml = `<div style="font-size:11px;color:#8b949e;margin:6px 0">${escHtml(d.reviews.summary)}${d.reviews.count ? ` (${escHtml(d.reviews.count)})` : ''}</div>`; } // 类型标签 const genresHtml = (d.genres && d.genres.length) ? `<div class="sglv-detail-tags">${d.genres.map(g => `<span class="sglv-detail-tag">${escHtml(g)}</span>`).join('')}</div>` : ''; // 截图 const shotsHtml = (d.screenshots && d.screenshots.length) ? `<div class="sglv-detail-shots">${d.screenshots.slice(0, 6).map(s => `<div class="sglv-detail-shot"><img src="${escHtml(s.thumbnail || s.full)}" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"></div>` ).join('')}</div>` : ''; // 操作按钮 const storeUrl = `https://store.steampowered.com/app/${appid}`; let actionsHtml = `<a class="sglv-detail-btn sglv-detail-btn-primary" href="${storeUrl}" target="_blank" rel="noopener">${escHtml(T.dpStorePage)}</a>`; if (entry && entry.owned) { actionsHtml += `<button class="sglv-detail-btn sglv-detail-btn-secondary" id="sglv-detail-jump-lib">${escHtml(T.dpJumpLib)}</button>`; } else if (entry && entry.wishlist) { actionsHtml += `<button class="sglv-detail-btn sglv-detail-btn-secondary" id="sglv-detail-jump-wl">${escHtml(T.dpJumpWl)}</button>`; } //AI 对话入口(检测到 SteamAgent 插件时显示) const _saAgent = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SteamAgent) || window.SteamAgent; if (_saAgent && typeof _saAgent.isInstalled === 'function' && _saAgent.isInstalled()) { actionsHtml += `<button class="sglv-detail-btn sglv-detail-btn-ai" id="sglv-detail-ask-ai">🤖 问 AI</button>`; } pop.innerHTML = `<button class="sglv-detail-close" id="sglv-detail-close-btn">${ICONS.close}</button> <div class="sglv-detail-scroll"> ${d.cover ? `<div class="sglv-detail-hero"><img src="${escHtml(d.cover)}" alt="" onerror="this.parentElement.style.display='none'"></div>` : ''} <div class="sglv-detail-body"> <div class="sglv-detail-name">${escHtml(d.name || (entry && entry.name) || '')}</div> <div style="display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-top:4px">${metaHtml}<span id="sglv-detail-profile-status"></span></div> ${reviewHtml} ${d.shortDesc ? `<div class="sglv-detail-desc">${escHtml(d.shortDesc)}</div>` : ''} <dl class="sglv-detail-meta"> ${d.developers && d.developers.length ? `<dt>${escHtml(T.dpDeveloper)}</dt><dd>${escHtml(d.developers.join(', '))}</dd>` : ''} ${d.publishers && d.publishers.length ? `<dt>${escHtml(T.dpPublisher)}</dt><dd>${escHtml(d.publishers.join(', '))}</dd>` : ''} ${d.releaseDate ? `<dt>${escHtml(T.dpRelease)}</dt><dd>${escHtml(d.releaseDate)}</dd>` : ''} ${platforms.length ? `<dt>${escHtml(T.dpPlatforms)}</dt><dd>${escHtml(platforms.join(', '))}</dd>` : ''} ${priceHtml ? `<dt>${escHtml(T.dpPrice)}</dt><dd>${priceHtml}</dd>` : ''} </dl> ${genresHtml} ${shotsHtml} <div class="sglv-detail-actions">${actionsHtml}</div> </div> </div>`; _bindDetailClose(pop); // 跳转到游戏库/愿望单按钮 const jumpLib = pop.querySelector('#sglv-detail-jump-lib'); if (jumpLib) jumpLib.addEventListener('click', () => { closeDetailPopup(); if (state.activeTab !== 'owned') switchToTab('owned'); else if (SGLV_API.renderBody) SGLV_API.renderBody(); requestAnimationFrame(() => _gsScrollToAppid(entry.appid, '#sglv-body')); showToast(`🎮 ${entry.name}`); }); const jumpWl = pop.querySelector('#sglv-detail-jump-wl'); if (jumpWl) jumpWl.addEventListener('click', () => { closeDetailPopup(); if (state.activeTab !== 'wishlist') switchToTab('wishlist'); else if (SGLV_API.renderBody) SGLV_API.renderBody(); requestAnimationFrame(() => _gsScrollToAppid(entry.appid, '#sglv-body')); showToast(`💖 ${entry.name}`); }); //问 AI 按钮 const askAi = pop.querySelector('#sglv-detail-ask-ai'); if (askAi) askAi.addEventListener('click', () => { const sa = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SteamAgent) || window.SteamAgent; if (!sa || !sa.open) return; const gameName = d.name || (entry && entry.name) || ''; const prompt = `请帮我分析 Steam 游戏「${gameName}」(AppID: ${appid}),包括:\n1. 游戏评价和口碑\n2. 当前价格是否值得入手\n3. 类型和玩法特点\n4. 适合什么样的玩家`; closeDetailPopup(); sa.open({ message: prompt }); }); //异步获取资料受限状态并填充到浮窗 (参考 steam-restricted-game-realtime-userscript) // 搜索详情浮窗不在游戏商店页,需通过 GM_xmlhttpRequest 获取商店页 HTML 检测 _fillProfileStatusBadge(pop, appid, token); } // 跳转游戏核心逻辑 //统一弹出游戏详情浮窗(owned/wishlist/store-only 均显示详情) // 浮窗内提供"跳转到游戏库/愿望单"按钮实现原有跳转功能 function _gsJumpToGame(entry) { if (!entry) return; closeSearchPop(); // 关闭下拉后清空输入框 if (_gsInputEl) { _gsInputEl.value = ''; _gsInputEl.dispatchEvent(new Event('input')); } showGameDetailPopup(entry.appid, entry); } // 在已渲染的列表中滚动定位 appid function _gsScrollToAppid(appid, containerSel = '#sglv-body') { try { const container = document.querySelector(containerSel); if (!container) return false; // 尝试多种选择器(cover/card/list 三种视图) const selectors = [ `.sglv-cover-name[data-sglv-appid="${appid}"]`, `.sglv-card-name[data-sglv-appid="${appid}"]`, `.sglv-list-name[data-sglv-appid="${appid}"]`, `.sglv-game-card[data-appid="${appid}"]`, `.sglv-wl-cover-card[data-appid="${appid}"]`, `.sglv-list-item[data-appid="${appid}"]`, `[data-sglv-appid="${appid}"]`, `[data-appid="${appid}"]`, ]; for (const sel of selectors) { const el = container.querySelector(sel); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); // 视觉高亮 const card = el.closest('.sglv-game-card, .sglv-wl-cover-card, .sglv-list-item, .sglv-cover-card, .sglv-cover-item, .sglv-card-grid > div, .sglv-list-view > div') || el; if (card && card.style) { const orig = card.style.boxShadow; card.style.boxShadow = '0 0 0 3px rgba(102, 192, 244, 0.8), 0 0 24px rgba(102, 192, 244, 0.6)'; card.style.transition = 'box-shadow 0.3s ease'; setTimeout(() => { card.style.boxShadow = orig; }, 2400); } return true; } } // 列表里没找到——可能受分页/搜索过滤影响,提示用户 showToast(isZh ? '该游戏在当前视图未显示(可能受分页或搜索过滤影响)' : 'Game not visible (pagination/search filter?)'); return false; } catch (e) { console.warn('[SGLV] 搜索跳转滚动失败:', e); return false; } } // 输入防抖 const _gsDebouncedRender = debounce((q) => renderSearchResults(q), 150); // 绑定全局搜索框全部事件 function bindGlobalSearchEvents() { const _panel = SGLV_API.getPanelEl && SGLV_API.getPanelEl(); // v2.9.61: panelEl 在子闭包内,经 SGLV_API 桥接 _gsInputEl = _panel?.querySelector('#sglv-global-search-input'); _gsPopEl = _panel?.querySelector('#sglv-global-search-pop'); _gsSearchWrap = _panel?.querySelector('#sglv-global-search'); _gsClearBtn = _panel?.querySelector('#sglv-global-search-clear'); if (!_gsInputEl || !_gsPopEl) return; // 输入事件 const onInput = () => { const v = _gsInputEl.value; // 切换清除按钮显隐 if (v) _gsSearchWrap.classList.add('has-text'); else _gsSearchWrap.classList.remove('has-text'); if (!v.trim()) { _gsShowPop(false); _gsLastResults = []; _gsFocusIdx = -1; return; } _gsDebouncedRender(v); }; _gsInputEl.addEventListener('input', onInput); addDisposer(() => _gsInputEl.removeEventListener('input', onInput)); // 焦点聚焦 → 如有内容则打开下拉 const onFocus = () => { if (_gsInputEl.value.trim() && _gsLastResults.length) _gsShowPop(true); }; _gsInputEl.addEventListener('focus', onFocus); addDisposer(() => _gsInputEl.removeEventListener('focus', onFocus)); // 键盘事件 const onKeydown = (e) => { const key = e.key; if (key === 'ArrowDown') { e.preventDefault(); if (!_gsLastResults.length) return; _gsShowPop(true); _gsFocusIdx = (_gsFocusIdx + 1) % _gsLastResults.length; _gsUpdateFocusClass(); } else if (key === 'ArrowUp') { e.preventDefault(); if (!_gsLastResults.length) return; _gsShowPop(true); _gsFocusIdx = _gsFocusIdx <= 0 ? _gsLastResults.length - 1 : _gsFocusIdx - 1; _gsUpdateFocusClass(); } else if (key === 'Enter') { e.preventDefault(); if (_gsFocusIdx >= 0 && _gsLastResults[_gsFocusIdx]) { _gsJumpToGame(_gsLastResults[_gsFocusIdx]); } else if (_gsLastResults.length > 0) { _gsJumpToGame(_gsLastResults[0]); } } else if (key === 'Escape') { e.preventDefault(); if (_gsPopEl.classList.contains('show')) { closeSearchPop(); } else { _gsInputEl.value = ''; _gsInputEl.dispatchEvent(new Event('input')); _gsInputEl.blur(); } } }; _gsInputEl.addEventListener('keydown', onKeydown); addDisposer(() => _gsInputEl.removeEventListener('keydown', onKeydown)); // 清除按钮 if (_gsClearBtn) { const onClear = (e) => { e.preventDefault(); e.stopPropagation(); _gsInputEl.value = ''; _gsInputEl.dispatchEvent(new Event('input')); _gsInputEl.focus(); }; _gsClearBtn.addEventListener('click', onClear); addDisposer(() => _gsClearBtn.removeEventListener('click', onClear)); } // 点击外部关闭下拉 const onDocClick = (e) => { if (!_gsSearchWrap) return; if (_gsSearchWrap.contains(e.target)) return; closeSearchPop(); }; // 用 mousedown 比 click 早,避免点击下拉项后被外部关闭抢断 document.addEventListener('mousedown', onDocClick, true); addDisposer(() => document.removeEventListener('mousedown', onDocClick, true)); // 切换 tab 时关闭下拉 const onTabClick = (e) => { if (e.target.closest('.sglv-tab')) closeSearchPop(); }; _panel?.addEventListener('click', onTabClick); addDisposer(() => _panel?.removeEventListener('click', onTabClick)); } // 暴露搜索函数到全局,便于其他模块(如侧边栏)调用 unsafeWindow.SGLVSearch = { search: searchGames, build: buildSearchIndex, dirty: markSearchIndexDirty, jump: (appid) => { // 由 appid 跳转(兼容调用方) if (!_searchIndexBuilt) buildSearchIndex(); const entry = _searchIndexByAppid.get(Number(appid)); if (entry) { _gsJumpToGame(entry); return true; } return false; }, }; //把主脚本的拥有/共享判定 API 注入到 sglv-app-detail 库, // 这样搜索结果下拉和其他上层都能用 SGLVAppDetail.getOwnershipBadge(appid) 拿到统一状态 //从 IIFE 改为惰性注入——@require 脚本可能因网络延迟晚于主脚本执行, // 启动时注入会静默失败。改为在 searchGames/showGameDetailPopup 首次调用时尝试注入。 let _hostApiInjected = false; function injectSGLVAppDetailHost() { if (_hostApiInjected) return; try { const A = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail) || (typeof window !== 'undefined' && window.SGLVAppDetail); if (!A || typeof A.setHostApi !== 'function') return; A.setHostApi({ getOwnedGames: () => state.ownedGames, isGameOwnedByMe: (g) => isGameOwnedByMe(g), getGameOwnerNames: (g) => getGameOwnerNames(g), getActiveSteamId: () => getActiveSteamId(), }); _hostApiInjected = true; } catch (e) { /* ignore - lib 未加载时静默,下次调用会重试 */ } } // 分页切片纯函数(统一各标签页分页计算) function paginate(items, page, size) { const totalPages = Math.max(1, Math.ceil(items.length / size)); const clampedPage = Math.min(Math.max(1, page), totalPages); const start = (clampedPage - 1) * size; return { pageItems: items.slice(start, start + size), totalPages, page: clampedPage }; } // ==================== 智能并发池(借鉴 steam-friend-manager _concurrentPool) ==================== // 429 限流自动降速(并发 ×0.7、下限 1)+ 指数退避重试 + in-flight 动态闸门 + 幻影任务复查。 // tasks: [() => Promise<any>];resolve 后返回 results 数组(成功位为返回值,最终失败位为 undefined)。 // 错误对象 message 需含 "429"/"rate" 才会触发降速(requestSteamAPI 的 reject 为 `HTTP 429` 格式,天然兼容)。 function sglvSmartPool(tasks, concurrency, opts = {}) { const MAX_RETRY = opts.maxRetry != null ? opts.maxRetry : 2; const cap = Math.max(1, Math.min(concurrency, tasks.length)); const results = new Array(tasks.length); let nextIndex = 0; let inFlight = 0; let activeConcurrency = cap; const isRateLimited = (e) => { const m = (e && e.message) ? String(e.message) : String(e || ''); return m.includes('429') || m.toLowerCase().includes('rate'); }; async function runTask(index) { for (let retry = 0; retry <= MAX_RETRY; retry++) { try { results[index] = await tasks[index](); return; } catch (e) { if (retry === MAX_RETRY) return; // 最终失败:结果位保持空,不污染进度 // 退避重试:首次 1s、二次 2s;429 额外 +1s 并立即降并发 const rate = isRateLimited(e); await new Promise(r => setTimeout(r, (retry === 0 ? 1000 : 2000) + (rate ? 1000 : 0))); if (rate) activeConcurrency = Math.max(1, Math.floor(activeConcurrency * 0.7)); } } } async function worker() { while (nextIndex < tasks.length) { // 动态闸门:429 降速后在途数须低于当前上限才取新任务 while (inFlight >= activeConcurrency) await new Promise(r => setTimeout(r, 100)); // 唤醒后复查:等待期间任务可能已被其他 worker 取完(避免 undefined 幻影任务) if (nextIndex >= tasks.length) break; const idx = nextIndex++; inFlight++; try { await runTask(idx); } finally { inFlight--; } } } const workers = []; for (let i = 0; i < cap; i++) workers.push(worker()); return Promise.all(workers).then(() => results); } // 资源清理机制:统一收集事件监听器/MutationObserver/定时器,卸载时清理,避免内存泄漏 const disposers = []; function addDisposer(fn) { if (typeof fn === 'function') disposers.push(fn); } function runDisposers() { while (disposers.length) { try { (disposers.pop())(); } catch (e) {} } } // 封面图成功 URL 缓存:记录已成功加载的封面地址,下次优先使用,减少回退探测请求 //缓存键按图片种类(poster/capsule/header)隔离,避免竖版海报 URL 被横版封面复用导致裁切变形 //三级持久缓存(借鉴 steam-friend-manager)——内存 Map 即时生效 + // GM_setValue 整体镜像(防抖 2s 批量写 + beforeunload 冲刷防丢)+ 启动惰性恢复, // 刷新页面后无需对 CDN 404 封面重新逐个探测 Store API。 const _posterGoodUrl = new Map(); const SGLV_COVER_BK_KEY = 'sglv_bk_cover_map'; const SGLV_COVER_BK_MAX = 2 * 1024 * 1024; // GM 配额保护上限(2MB) let _coverBkTimer = null; let _coverBkLoaded = false; function _coverBkRestore() { if (_coverBkLoaded) return; _coverBkLoaded = true; try { const s = GM_getValue(SGLV_COVER_BK_KEY, null); if (s) { const obj = JSON.parse(s); let n = 0; // v2.31.2: 只跳过 data:/blob: 垃圾条目(历史版本可能写入过占位图), // http(s) 条目一律恢复 —— 旧缓存能用的绝不重新探测 Object.keys(obj).forEach(k => { const u = obj[k]; if (typeof u === 'string' && /^https?:\/\//i.test(u) && !_posterGoodUrl.has(k)) { _posterGoodUrl.set(k, u); n++; } }); if (n) console.log(`[SGLV] 封面 URL 持久缓存已恢复 ${n} 条`); } } catch (e) { /* 备份损坏时静默重建 */ } } function _coverBkFlush() { if (_coverBkTimer) { clearTimeout(_coverBkTimer); _coverBkTimer = null; } try { let obj = {}; _posterGoodUrl.forEach((url, k) => { obj[k] = url; }); let s = JSON.stringify(obj); if (s.length > SGLV_COVER_BK_MAX) { // 超限:仅保留最近 8000 条(Map 迭代序≈插入序,老旧条目先淘汰) // v2.31.2: 4000 → 8000 —— 大库(2000+ 游戏 × poster/header/capsule) // 4000 条会在 LRU 边缘反复淘汰仍在用的 URL;8000 条约 1MB,仍在 2MB 保护线内 obj = {}; const entries = Array.from(_posterGoodUrl.entries()); entries.slice(-8000).forEach(([k, u]) => { obj[k] = u; }); s = JSON.stringify(obj); } if (s.length <= SGLV_COVER_BK_MAX) GM_setValue(SGLV_COVER_BK_KEY, s); } catch (e) { /* 配额/序列化失败静默 */ } } function _coverBkSchedule() { if (_coverBkTimer) return; _coverBkTimer = setTimeout(() => { _coverBkTimer = null; _coverBkFlush(); }, 2000); } try { window.addEventListener('beforeunload', _coverBkFlush); } catch (e) { /* 非页面环境忽略 */ } function recordPosterGood(appid, url, kind) { if (!appid || !url) return; // v2.31.1: 只记录 http(s)。占位用的 data:image/svg+xml / blob: 一旦进缓存, // 下次渲染就以占位图作为初始 src(永远"加载成功"但永远没有封面)。 if (!/^https?:\/\//i.test(url)) return; _coverBkRestore(); const key = `${kind || 'poster'}:${appid}`; // v2.31.2: URL 未变化则不重复调度整表落盘 —— 每次 onload 都写会放大 GM 写入量 if (_posterGoodUrl.get(key) === url) return; _posterGoodUrl.set(key, url); _coverBkSchedule(); } function getPosterGood(appid, kind) { _coverBkRestore(); const u = _posterGoodUrl.get(`${kind || 'poster'}:${appid}`); // v2.31.1: 历史版本可能把 data: 占位图写进过持久缓存 —— 读到也当没有,强制重新探测 return (u && /^https?:\/\//i.test(u)) ? u : ''; } // 暴露给内联 onload 使用 unsafeWindow._sglvRecordPosterGood = function(appid, url, kind) { recordPosterGood(appid, url, kind); // v2.31.2: 横版图额外写 header 槽位 —— 收藏/受限/PS/Epic 视图可以直接复用, // 同一张 header.jpg 不再在每个面板各探一次链 try { if (url && /^https?:\/\//i.test(url) && url.indexOf('library_600x900') < 0) { recordPosterGood(appid, url, 'header'); } } catch (e) { /* ignore */ } }; //AI 配置共享 — 优先读取 steam-ai-agent 插件的配置,避免重复配置 apiKey function _getSharedAiConfig() { try { const sa = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SteamAgent) || window.SteamAgent; if (sa && typeof sa.getActiveModel === 'function') { const m = sa.getActiveModel(); if (m && m.apiKey) return { apiKey: m.apiKey, apiUrl: m.apiUrl, model: m.model }; } } catch {} try { const raw = GM_getValue('sa_model_configs', null); const models = typeof raw === 'string' ? JSON.parse(raw) : raw; if (Array.isArray(models) && models.length > 0) { const activeId = GM_getValue('sa_active_model', ''); const m = models.find(x => x.id === activeId) || models[0]; if (m && m.apiKey) return { apiKey: m.apiKey, apiUrl: m.apiUrl, model: m.model }; } } catch {} try { const agentKey = GM_getValue('sa_ai_api_key', ''); if (agentKey) return { apiKey: agentKey, apiUrl: GM_getValue('sa_ai_api_url', '') || 'https://api.deepseek.com/v1/chat/completions', model: GM_getValue('sa_ai_api_model', '') || 'deepseek-v4-pro', }; } catch {} return null; } const storage = { // ---- 小对象:保持 GM_setValue(同步、零延迟、几 KB 级别)---- getApiKey: () => GM_getValue(nsKey('api_key'), ''), setApiKey: v => GM_setValue(nsKey('api_key'), v), getSteamId: () => GM_getValue(nsKey('steamid'), ''), setSteamId: v => GM_setValue(nsKey('steamid'), v), getShowFamilyShared: () => GM_getValue(nsKey('show_family_shared'), true), setShowFamilyShared: v => GM_setValue(nsKey('show_family_shared'), v), // ---- 数据回流(relay):v2.45.0 已整体下线,存储开关一并移除 ---- // ---- API Key 同意门(v2.45.0)---- // '' = 未判定;'agreed' = 用户已勾选同意;'legacy' = 存量用户(升级前就在用)直接放行。 // 为什么要有第三种:老用户升级不该被同意门拦住,判定只做一次并落盘(见 _initApiKeyConsent)。 getApiKeyConsent: () => _readApiKeyConsent(), setApiKeyConsent: v => _writeApiKeyConsent(v), // ---- 库存排序偏好 ---- getOwnedSort: () => GM_getValue(nsKey('owned_sort'), 'appidAsc'), setOwnedSort: v => GM_setValue(nsKey('owned_sort'), v), // ---- UI 主题偏好(典雅紫 purple / 经典蓝 blue,默认典雅紫) ---- getUiTheme: () => GM_getValue(nsKey('ui_theme'), 'purple'), setUiTheme: v => GM_setValue(nsKey('ui_theme'), v), // ---- 价格比价目标货币偏好(价格 Tab 多地区低价排行,默认 CNY,记忆最近一次选择) ---- getPriceTargetCurrency: () => GM_getValue(nsKey('price_target_currency'), 'CNY'), setPriceTargetCurrency: v => GM_setValue(nsKey('price_target_currency'), v), // ---- AI 模型配置 (优先共享 Steam AI Agent 插件配置) ---- getAiApiUrl: () => _getSharedAiConfig()?.apiUrl || GM_getValue(nsKey('ai_api_url'), 'https://api.deepseek.com/v1/chat/completions'), setAiApiUrl: v => GM_setValue(nsKey('ai_api_url'), v), getAiApiKey: () => _getSharedAiConfig()?.apiKey || GM_getValue(nsKey('ai_api_key'), ''), setAiApiKey: v => GM_setValue(nsKey('ai_api_key'), v), getAiModel: () => _getSharedAiConfig()?.model || GM_getValue(nsKey('ai_model'), 'deepseek-v4-pro'), setAiModel: v => GM_setValue(nsKey('ai_model'), v), // ---- ITAD API Key 配置 (价格增强) ---- getItadApiKey: () => GM_getValue(nsKey('itad_api_key'), ''), setItadApiKey: v => GM_setValue(nsKey('itad_api_key'), v), // ---- AI 价格预测模式配置 ---- getPredictModes: () => { const arr = parseStored(nsKey('predict_modes'), ['seasonal', 'trend', 'lowest']); return Array.isArray(arr) && arr.length ? arr : ['seasonal', 'trend', 'lowest']; }, setPredictModes: v => GM_setValue(nsKey('predict_modes'), JSON.stringify(v)), // ---- 大对象存储于 IndexedDB(容量无 5MB 限制)---- // 旧 GM key 兼容:第一次读取时若 IDB 无值,尝试从 GM_setValue 迁移 // // ★ v2.42.2 性能修复:改为**惰性兜底**。 // 原写法 `sglvIDB.get(k, parseStored(k, parseStored(k2, dflt)))` 的实参是**先求值**的: // 每次调用都会先跑 2 次 GM_getValue + 对整份旧数据的 JSON.parse, // 然后在 IDB 命中时把算好的兜底**整个丢弃** —— 白算。 // 现在改成「先取、没取到才兜底」,命中 IDB 时零额外开销。 getCachedGames: () => { const k = nsKey('cached_games'); const v = sglvIDB.get(k, undefined); return v !== undefined ? v : parseStored(k, parseStored('sglv_cached_games', [])); }, setCachedGames: v => sglvIDB.set(nsKey('cached_games'), v), getFamilyInfo: () => { const k = nsKey('family_info'); const v = sglvIDB.get(k, undefined); return v !== undefined ? v : parseStored(k, parseStored('sglv_family_info', {})); }, setFamilyInfo: v => sglvIDB.set(nsKey('family_info'), v), // ---- 云存档数据(大对象,IDB 存储)---- getCloudSaveData: () => { const k = nsKey('cloud_save_data'); const v = sglvIDB.get(k, undefined); return v !== undefined ? v : parseStored(k, null); }, setCloudSaveData: v => sglvIDB.set(nsKey('cloud_save_data'), v), }; //主题层 boot——storage 定义后立即同步应用用户主题(早于任何面板渲染); // 异常时兜底注入默认典雅紫,保证布局类规则(雷达图区块/7列 grid 等,现已随主题层下发)不缺失 try { applySglvTheme(getThemeId()); } catch (e) { console.warn('[SGLV] 主题应用失败,回退默认主题:', e); try { applySglvTheme(DEFAULT_SGLV_THEME); } catch (e2) {} } // 启动时一次性把老 GM 大对象迁到 IDB,避免下次升级重复走 fallback function migrateLegacyGmKeysToIDB() { const legacyKeys = [ ['sglv_cached_games', nsKey('cached_games')], ['sglv_family_info', nsKey('family_info')], ]; for (const [oldKey, newKey] of legacyKeys) { try { if (sglvIDB.has(newKey)) continue; // 已迁过 const raw = GM_getValue(oldKey, null); if (raw == null) continue; const v = typeof raw === 'string' ? JSON.parse(raw) : raw; if (v != null && (Array.isArray(v) ? v.length : Object.keys(v).length)) { sglvIDB.set(newKey, v); console.log(`[SGLV] 迁移 ${oldKey} → IDB:${newKey} (${Array.isArray(v) ? v.length : Object.keys(v).length} 项)`); } } catch (e) { console.warn(`[SGLV] 迁移 ${oldKey} 失败:`, e); } } } // ==================== TTL 缓存层 ==================== // 基于 GM_setValue 的带 TTL 缓存,支持侧边栏各标签页数据缓存 const CACHE_TTL = { overview: 24 * 3600 * 1000, // 概览:24h(DOM 提取,刷新页面即变) prices: 2 * 3600 * 1000, // 多地区价格:2h historyPrices: 6 * 3600 * 1000, // ITAD 历史价格:6h achievements: 1 * 3600 * 1000, // 成就数据:1h globalAchievements: 12 * 3600 * 1000, // 全球成就:12h news: 30 * 60 * 1000, // 新闻动态:30min familyShare: 6 * 3600 * 1000, // 家庭共享支持:6h appDetailsExtra: 24 * 3600 * 1000, // v2.3.16: appdetails 增强信息(工坊/截图/分类/捆绑包等):24h friendsList: 6 * 3600 * 1000, // v2.3.8: 好友完整数据(列表+摘要+VAC):6h friendsVac: 24 * 3600 * 1000, // v2.3.8: VAC 封禁状态:24h(变化慢) friendsLevels: 7 * 24 * 3600 * 1000, // v2.3.8: 好友等级:7天(等级变化慢) psplus: 6 * 3600 * 1000, // v2.6.0: PS 会免数据(远程 json):6h epic: 12 * 3600 * 1000, // v2.7.9: Epic 赠送名单(其乐抓取):12h goty: 6 * 24 * 3600 * 1000, // v2.9.27: 年度大作名单(jsdelivr CDN json):6 天(数据基本不变,偶尔新增待发售游戏,5-7 天缓存足够) reviews: 1 * 3600 * 1000, // v2.8.0: 游戏评测数据:1h giftRec: 2 * 3600 * 1000, // v2.8.0: 跨区送礼推荐:2h(依赖价格数据) gameStatus: 6 * 3600 * 1000, // v2.9.10: gamestatus.info 破解状态:6h profileFeatures: 24 * 3600 * 1000, // v2.9.93: 资料受限状态(Profile Features Limited):24h blockedApps: 24 * 3600 * 1000, // v2.9.12: steam-tracker.com 锁区数据:24h seriesData: 6 * 30 * 24 * 3600 * 1000, // v2.9.27: 游戏系列 + 供应商数据(GitHub json):6 个月(数据极低频变化,靠供应商视图的刷新按钮手动强刷) wishlist: 3 * 24 * 3600 * 1000, // v2.9.30: 愿望单数据:3 天(价格/折扣等动态字段后台静默刷新,6h 节流) cloudSave: 2 * 24 * 3600 * 1000, // v2.9.39: 云存档数据:2 天(后台静默增量更新,6h 节流;只增不减,保留已有游戏列表) gameAlias: 30 * 24 * 3600 * 1000, // v2.11.48: 游戏别名(Steam EN + 小黑盒中文名):30 天(极低变化,限流友好) gameAliasMiss: 6 * 3600 * 1000, // v2.11.48: 游戏别名 miss 缓存:6h(限流保护,成功后才走 30 天长缓存) }; //云存档后台静默刷新节流(与 wishlist 一致,6h) const CLOUDSAVE_BG_REFRESH_INTERVAL = 6 * 3600 * 1000; //云存档文件数填充节流(12h → 5天,云存档相对固定,避免频繁请求单App页面) const CLOUDSAVE_FILECOUNT_INTERVAL = 5 * 24 * 3600 * 1000; function cacheGet(key) { try { const raw = GM_getValue('sglv_cache_' + key, ''); if (!raw) return null; const obj = JSON.parse(raw); if (obj.expires && Date.now() > obj.expires) { GM_setValue('sglv_cache_' + key, ''); return null; } return obj.data; } catch { return null; } } // 过期也不删。价格页要先画出上次结果再后台刷新,失败时还得留着旧数据。 function cachePeek(key) { try { const raw = GM_getValue('sglv_cache_' + key, ''); if (!raw) return null; const obj = JSON.parse(raw); if (obj.data == null) return null; return { data: obj.data, fresh: !(obj.expires && Date.now() > obj.expires) }; } catch { return null; } } function cacheSet(key, data, ttl) { try { GM_setValue('sglv_cache_' + key, JSON.stringify({ data, expires: Date.now() + ttl })); } catch { /* ignore quota errors */ } } /** 删除指定缓存项(移出过期缓存) */ function cacheDelete(key) { try { GM_setValue('sglv_cache_' + key, ''); } catch { /* ignore */ } } function cacheClear(prefix) { try { const keys = GM_listValues ? GM_listValues() : []; for (const k of keys) { if (k.startsWith('sglv_cache_' + (prefix || ''))) { GM_setValue(k, ''); } } } catch { /* ignore */ } } // ==================== AI 翻译总结 ==================== const AI_PROMPT_URL = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/prompts/ai-prompt-template.json'; // ==================== AI 价格预测 ==================== const AI_PRICE_PREDICT_PROMPT_URL = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/prompts/ai-price-predict-template.json'; // ==================== AI 深度分析 ==================== const AI_INSIGHT_PROMPT_URL = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/prompts/ai-insight-template.json'; // 内置预测提示词模板 (远程加载失败时的 fallback) const FALLBACK_PREDICT_PROMPT = `# 角色\n你是一位精通Steam平台价格分析和游戏行业趋势的数据科学家。基于游戏的历史折扣数据和Steam促销活动节点,使用多种预测模型对游戏价格走势进行预测分析。\n\n# 任务\n根据输入的游戏价格历史数据、折扣记录和发行信息,使用用户选定的预测模式进行分析,输出结构化的预测结果。\n\n## 预测模式说明\n用户已启用以下预测模式:\n{PREDICT_MODES}\n\n### seasonal(季节性折扣预测)\n基于Steam官方促销活动日历(夏促6-7月/秋促11月底/冬促12月底/春促3月)预测下一次折扣时机。\n\n### trend(价格趋势预测)\n基于历史折扣的间隔周期和幅度趋势,预测下次折扣的可能百分比和价格。\n\n### lowest(史低突破预测)\n预测游戏达到或突破历史最低价的可能性和时机。\n\n### review(评价动量预测)\n结合游戏口碑变化趋势评估未来折扣力度。\n\n### similar(相似历史预测)\n匹配游戏价格历史中的相似片段预测折扣模式。\n\n# 输出格式\n严格按以下JSON格式输出,不要输出任何其他文字。\n\n{"models":[{"mode":"seasonal","mode_name":"模式名称","prediction":"预测摘要","discount_percent":50,"predicted_price":134.00,"currency":"CNY","days_until":46,"target_date":"2026-08-27","confidence":92,"sale_event":"秋季特卖","detail":"详细分析","indicators":{}}],"best_buy":{"recommendation":"购买建议","best_time":"最佳时间","best_price":134.00,"wait_days":46,"urgency":"medium"}}\n\n# 输入数据\n- 游戏名称:{GAME_NAME}\n- 应用ID:{APP_ID}\n- 发行日期:{RELEASE_DATE}\n- 当前日期:{CURRENT_DATE}\n- 当前价格:{CURRENT_PRICE} {CURRENCY}\n- 原价:{ORIGINAL_PRICE} {CURRENCY}\n\n## 历史折扣记录:\n{DISCOUNT_RECORDS}\n\n## 完整价格历史:\n{PRICE_HISTORY}\n\n# 注意事项\n- 基于数据客观分析,不要编造数据。\n- confidence反映数据支撑程度:样本充足且规律明显时高分,不足时低分。\n- best_buy应综合所有模式给出最优建议。\n- 输出必须是合法JSON。`; // 预测模式定义 const PREDICT_MODES = [ { key: 'seasonal', name: T.aiPredictModeSeasonal, desc: T.aiPredictModeSeasonalDesc }, { key: 'trend', name: T.aiPredictModeTrend, desc: T.aiPredictModeTrendDesc }, { key: 'lowest', name: T.aiPredictModeLowest, desc: T.aiPredictModeLowestDesc }, { key: 'review', name: T.aiPredictModeReview, desc: T.aiPredictModeReviewDesc }, { key: 'similar', name: T.aiPredictModeSimilar, desc: T.aiPredictModeSimilarDesc }, ]; // ---- 统一提示词模板加载器(带缓存 + 可选 fallback)---- const _promptCache = new Map(); //迁移至 sglvGmFetchRetry(retries:0 保持原无重试快速降级语义—— // 3 个调用点均带 fallback,网络失败立即兑底;非 2xx 原不检查状态码直接 parse(失败→fallback), // 新 404 确定性立即 reject→fallback,语义等价) function loadPromptTemplate(url, fallback = null) { if (_promptCache.has(url)) return Promise.resolve(_promptCache.get(url)); return sglvGmFetchRetry(url, { timeout: 15000, retries: 0 }).then(data => { if (data.prompt && data.prompt.trim()) { _promptCache.set(url, data.prompt.trim()); return _promptCache.get(url); } if (fallback) { _promptCache.set(url, fallback); return fallback; } throw new Error(isZh ? '提示词模板内容为空' : 'Prompt template is empty'); }).catch(e => { if (fallback) { _promptCache.set(url, fallback); return fallback; } throw e; }); } //翻译模板也增加 fallback, 防止远程加载失败时翻译功能完全不可用 const FALLBACK_TRANSLATE_PROMPT = `# 角色\n你是一位精通游戏行业的翻译与内容分析专家。对Steam游戏动态新闻进行智能翻译与归纳总结。\n\n# 任务\n对输入的游戏动态新闻列表,逐条执行:\n1. 非中文内容翻译为简体中文,中文保留原文。游戏专有名词保留英文后接括号中文释义。\n2. 提炼核心信息,1-3句话总结,不超过100字。\n3. 严格按JSON格式输出。\n\n# 输出格式\n{"items":[{"title":"标题翻译或原文","summary":"1-3句话总结","is_translated":true}]}\n\n# 输入数据\n- 游戏名称:{GAME_NAME}\n- 应用ID:{APP_ID}\n\n新闻列表:\n{NEWS_DATA}\n\n# 注意事项\n- items数组长度必须与输入新闻条数一致。\n- 输出必须是合法JSON。`; const getAiPromptTemplate = () => loadPromptTemplate(AI_PROMPT_URL, FALLBACK_TRANSLATE_PROMPT); const getPredictPromptTemplate = () => loadPromptTemplate(AI_PRICE_PREDICT_PROMPT_URL, FALLBACK_PREDICT_PROMPT); //深度分析提示词模板 (远程加载 + fallback) const FALLBACK_INSIGHT_PROMPT = `# 角色\n你是一位游戏行为分析专家和 Steam 库存评论家, 以犀利幽默的风格点评玩家的游戏库。\n\n# 任务\n基于玩家的 Steam 游戏数据和个人口味画像, 从六个维度生成深度分析报告。语言要犀利但不恶意, 数据支撑, 避免空话套话。\n\n# 输入数据\n- 玩家昵称: {PERSONA_NAME}\n- Steam 等级: {STEAM_LEVEL}\n- 游戏总数: {GAME_COUNT}\n- 总时长: {TOTAL_HOURS} 小时\n- 已启动游戏: {PLAYED_COUNT} ({PLAYED_PERCENT}%)\n- 未启动游戏: {UNPLAYED_COUNT} (吃灰率 {DUST_RATE}%)\n- 超 100h 游戏: {OVER_100H_COUNT} 款\n- 超 500h 游戏: {OVER_500H_COUNT} 款\n- 深度游玩(≥10h): {LONG_GAMES_COUNT} 款\n- 平均时长: {AVG_HOURS}h/款\n- 玩家类型: {PERSONA_TYPE} ({PERSONA_RARITY})\n- 玩家特征: {TRAITS}\n- 口味标签: {TASTE_TAGS}\n- 愿望单热门标签: {WISHLIST_TAGS}\n- 维度评分: {DIM_SCORES}\n- 维度详情:\n{DIM_DETAILS}\n- 游玩最多 Top10: {TOP_GAMES}\n- 最近游玩: {RECENT_GAMES}\n- 吃灰游戏示例: {DUST_EXAMPLES}\n\n# 输出格式\n严格按以下 JSON 格式输出, 不要输出任何其他文字。\n{\n "oneLiner": "一句话犀利总结, 30字以内, 直击要害",\n "sections": [\n {"title": "库存概况", "content": "2-4句, 评价游戏数量、规模、收藏习惯"},\n {"title": "游玩习惯", "content": "2-4句, 分析时长投入、深度游玩、吃灰情况"},\n {"title": "偏好画像", "content": "2-4句, 从 Top10 游戏和口味标签推断品味偏好、游戏类型倾向"},\n {"title": "亮点槽点", "content": "2-4句, 先肯定亮点再点出槽点, 犀利但建设性"},\n {"title": "购买建议", "content": "2-4句, 基于现状给出未来购买/游玩的具体建议"},\n {"title": "犀利毒舌", "content": "2-3句, 最犀利最毒舌的终极点评, 可使用比喻和讽刺但不人身攻击, 直击玩家最核心的矛盾"}\n ]\n}`; const getInsightPromptTemplate = () => loadPromptTemplate(AI_INSIGHT_PROMPT_URL, FALLBACK_INSIGHT_PROMPT); // ---- 统一 AI API POST 调用 ---- //新增 maxTokens (默认 4096, 可按场景提高) 和 returnMeta (返回 {content, finishReason, truncated}) //经 sglvGmFetchRetry 发送(POST/headers/data 透传,依赖 core 1.0.6 // onHttpError 回调保留非 2xx 服务端错误详情提取;401/400 确定性 4xx 立即失败不重试) function callAiApi(prompt, { temperature = 0.3, timeout = 60000, maxTokens = 4096, returnMeta = false } = {}) { const apiKey = storage.getAiApiKey(); if (!apiKey) return Promise.reject(new Error(T.aiNotConfigured)); const apiUrl = storage.getAiApiUrl(); return sglvGmFetchRetry(apiUrl, { method: 'POST', timeout, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, data: JSON.stringify({ model: storage.getAiModel(), messages: [{ role: 'user', content: prompt }], temperature, max_tokens: maxTokens }), //非 2xx 时提取实际错误信息, 避免 "AI 返回为空" 误导(经 core 1.0.6 onHttpError 定制错误消息) onHttpError(status, text) { let errDetail = ''; try { const errData = JSON.parse(text || ''); errDetail = errData?.error?.message || errData?.message || errData?.error || ''; } catch { errDetail = (text || '').slice(0, 200); } return `AI API HTTP ${status}${errDetail ? ': ' + errDetail : ''}`; }, }).then(respData => { const choice = respData?.choices?.[0] || {}; const content = choice.message?.content || ''; const finishReason = choice.finish_reason || ''; if (!content) { //记录完整响应便于排查空内容问题 console.error('[SGIS] AI 返回为空, finishReason:', finishReason, '| respData:', JSON.stringify(respData).slice(0, 500)); throw new Error('AI 返回为空 (finishReason: ' + finishReason + ')'); } if (returnMeta) { return { content, finishReason, truncated: finishReason === 'length' }; } return content; }).catch(e => { console.error('[SGIS] callAiApi error:', e.message, '| url:', apiUrl, '| model:', storage.getAiModel()); throw e; }); } //修复被截断的 JSON (AI 响应因 max_tokens 限制被截断, 缺少闭合括号) // 策略: 1. 关闭未闭合的字符串 2. 移除尾部不完整的元素 3. 关闭所有未闭合的括号 function repairTruncatedJson(str) { let s = str; // 1. 正向扫描: 跟踪字符串状态、括号栈, 记录最后一个"安全边界"位置 // 安全边界 = 数组内最后一个完整元素 (} 或 ]) 之后的位置, 可在此处安全截断 let inStr = false, esc = false; const stk = []; let lastSafeBoundary = -1; for (let i = 0; i < s.length; i++) { const c = s[i]; if (esc) { esc = false; continue; } if (c === '\\') { esc = true; continue; } if (c === '"') { inStr = !inStr; continue; } if (inStr) continue; if (c === '{' || c === '[') stk.push(c); else if (c === '}' || c === ']') { if (stk.length) { stk.pop(); // 弹出后若栈顶是 '[', 说明刚闭合的是数组内一个完整元素 if (stk.length > 0 && stk[stk.length - 1] === '[') { lastSafeBoundary = i + 1; } } } } // 2. 关闭未闭合的字符串 if (inStr) s += '"'; // 3. 如果截断在元素中间, 裁剪到最后一个安全边界 (移除不完整的尾部元素) s = s.replace(/[\s]+$/, ''); if (s.length > 0) { const lastCh = s[s.length - 1]; if (lastCh !== '}' && lastCh !== ']' && lastCh !== ',') { if (lastSafeBoundary >= 0) { s = s.slice(0, lastSafeBoundary); } } } // 4. 移除尾部逗号 s = s.replace(/,\s*$/, ''); // 5. 关闭所有未闭合的括号 (重新扫描获取当前栈状态) inStr = false; esc = false; const finalStk = []; for (let i = 0; i < s.length; i++) { const c = s[i]; if (esc) { esc = false; continue; } if (c === '\\') { esc = true; continue; } if (c === '"') { inStr = !inStr; continue; } if (inStr) continue; if (c === '{' || c === '[') finalStk.push(c); else if (c === '}' || c === ']') { if (finalStk.length) finalStk.pop(); } } while (finalStk.length > 0) { const open = finalStk.pop(); s += (open === '{' ? '}' : ']'); } return s; } // 鲁棒 JSON 解析 (处理 AI 返回的 markdown/截断/未转义) //增加 max_tokens 截断修复 (第 3 次尝试: repairTruncatedJson) // 流程: 剥离 markdown -> 提取最外层 { ... } -> 清理尾部逗号 -> JSON.parse -> 截断修复 function safeParseAiJson(content, expectKey) { if (!content || typeof content !== 'string') throw new Error('AI 返回内容为空'); // 1. 去除 <think>...</think> 块 let cleaned = content.replace(/<think>[\s\S]*?<\/think>/gi, '').trim(); // 2. 剥离 markdown 代码块 ```json ... ``` 或 ``` ... ``` cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim(); // 3. 提取最外层 JSON: 根据 expectKey 找匹配的 { 或 [ const openChar = expectKey && expectKey.startsWith('[') ? '[' : '{'; const closeChar = openChar === '{' ? '}' : ']'; const firstIdx = cleaned.indexOf(openChar); if (firstIdx === -1) throw new Error('AI 返回中找不到 JSON 起始符'); //保存从起始符到字符串末尾的完整内容 (用于截断修复) const fullContent = cleaned.slice(firstIdx); // 4. 用括号配对法找匹配的闭合位置 (处理字符串/转义) let depth = 0, inString = false, escape = false, endIdx = -1; for (let i = firstIdx; i < cleaned.length; i++) { const ch = cleaned[i]; if (escape) { escape = false; continue; } if (ch === '\\') { escape = true; continue; } if (ch === '"') { inString = !inString; continue; } if (inString) continue; if (ch === openChar) depth++; else if (ch === closeChar) { depth--; if (depth === 0) { endIdx = i + 1; break; } } } if (endIdx === -1) { //找不到匹配闭合符 — 极可能是 max_tokens 截断 // 先尝试用 repairTruncatedJson 修复完整内容 try { const repaired = repairTruncatedJson(fullContent); const result = JSON.parse(repaired); // 标记为截断恢复结果 (括号配对失败 = 确定被截断) if (result && typeof result === 'object') { result._truncated = true; } return result; } catch (eRepair) { // 修复仍失败, 回退到贪婪匹配 const m = cleaned.match(/\{[\s\S]*\}/); if (m) endIdx = m.index + m[0].length; else throw new Error('AI 返回中找不到 JSON 闭合符 (疑似响应被截断)'); } } let jsonStr = cleaned.slice(firstIdx, endIdx); // 5. 清理 JSON 中常见错误 //移除尾部多余的 }, ] (AI 经常多打) jsonStr = jsonStr.replace(/[\s,]+$/, ''); //移除 // 单行注释 jsonStr = jsonStr.replace(/^\s*\/\/.*$/gm, ''); //移除 /* */ 注释 jsonStr = jsonStr.replace(/\/\*[\s\S]*?\*\//g, ''); //修复未闭合的字符串(去除末尾未闭合的引号) const quoteCount = (jsonStr.match(/"/g) || []).length; if (quoteCount % 2 !== 0) { jsonStr = jsonStr.replace(/"([^"]*)$/, '$1'); } //修复 trailing comma jsonStr = jsonStr.replace(/,(\s*[}\]])/g, '$1'); //修复单引号为双引号 (字段名必须是双引号) jsonStr = jsonStr.replace(/'([a-zA-Z_]\w*)'(\s*:)/g, '"$1"$2'); // 6. 解析 try { return JSON.parse(jsonStr); } catch (e1) { // 7. 二次尝试: 用更激进的清理 try { // 修复模型: 去掉控制字符 const fixed = jsonStr.replace(/[\u0000-\u001F\u007F]/g, (m) => { if (m === '\n' || m === '\r' || m === '\t') return m; return ''; }); return JSON.parse(fixed); } catch (e2) { //三次尝试 — 截断修复 (max_tokens 限制导致响应被截断) // 典型症状: "Expected ',' or ']' after array element" 且错误位置 ≈ 字符串末尾 try { const repaired = repairTruncatedJson(fullContent); const result = JSON.parse(repaired); // 标记为截断恢复结果 (调用方可据此提示用户) if (result && typeof result === 'object') { result._truncated = true; } return result; } catch (e3) { throw new Error('AI 返回 JSON 解析失败: ' + e1.message.slice(0, 80) + ' (位置 ' + jsonStr.length + ', 截断修复亦失败)'); } } } } // 调用 AI API 进行翻译总结 // newsItems: [{ title, contents, ... }], gameName: string, appId: string // 返回: [{ title, summary, is_translated, _truncated? }] //max_tokens 提升至 8192 防截断; 限制最多 15 条新闻降低 token 压力; // 通过 returnMeta 检测 finish_reason=length; 截断时通过 repairTruncatedJson 恢复部分结果 async function callAiTranslateSummary(newsItems, gameName, appId) { let prompt = await getAiPromptTemplate(); //限制最多 15 条新闻 (原 20 条), 减少 token 消耗防止输出截断 const cappedNews = newsItems.slice(0, 15); const newsData = cappedNews.map((n, i) => `[${i + 1}] 标题: ${n.title || '(无标题)'}\n内容: ${n.contents || '(无内容)'}` ).join('\n\n'); prompt = prompt.replace(/\{GAME_NAME\}/g, gameName || '未知游戏') .replace(/\{APP_ID\}/g, appId || '') .replace(/\{NEWS_DATA\}/g, newsData); //maxTokens 8192 + returnMeta 检测截断 const { content, finishReason, truncated } = await callAiApi(prompt, { temperature: 0.3, timeout: 60000, maxTokens: 8192, returnMeta: true }); const parsed = safeParseAiJson(content, '{'); const items = parsed.items || []; // 截断标记: API 层 (finish_reason=length) 或 JSON 修复层 (_truncated) 任一触发即标记 const wasTruncated = truncated || parsed._truncated === true || items.length < cappedNews.length; if (wasTruncated && items.length > 0) { // 部分结果恢复成功 — 在首条 item 上打标记供 UI 提示 items.forEach(it => { it._truncated = true; }); } return items; } // 调用 AI API 进行价格预测 async function callAiPricePredict(historyData, gameName, appId, currentPrice, originalPrice) { console.log('[SGIS] AI价格预测开始, gameName:', gameName, 'appId:', appId); let prompt = await getPredictPromptTemplate(); console.log('[SGIS] 提示词模板加载完成, length:', prompt.length); // 获取用户选择的预测模式 const enabledModes = storage.getPredictModes(); const modesText = enabledModes.map(m => { const mode = PREDICT_MODES.find(p => p.key === m); return mode ? `- ${mode.key}(${mode.name}):${mode.desc}` : ''; }).filter(Boolean).join('\n'); // 构建折扣记录文本 //安全数值转换, 修复 price?.toFixed is not a function const _safeNum = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; const discounts = historyData.discounts || []; const discountRecords = discounts.map(d => { const dateStr = d.date ? new Date(d.date).toLocaleDateString('zh-CN') : '—'; const daysStr = d.daysFromRelease != null ? `距发行${d.daysFromRelease}天` : ''; return `- ${dateStr} | -${d.cut}% | ${d.currency}${_safeNum(d.price).toFixed(2)} | 原价${d.currency}${_safeNum(d.regular).toFixed(2)} | ${daysStr}`; }).join('\n') || '无折扣记录'; // 构建价格历史文本 const history = historyData.history || []; const priceHistory = history.map(h => { const dateStr = h.date ? new Date(h.date).toLocaleDateString('zh-CN') : '—'; return `- ${dateStr} | ${h.cut > 0 ? '-' + h.cut + '%' : '原价'} | ${h.currency}${_safeNum(h.price).toFixed(2)} | 原价${h.currency}${_safeNum(h.regular).toFixed(2)}`; }).join('\n') || '无价格历史'; // 填充提示词模板 const currentDate = new Date().toISOString().split('T')[0]; const currency = (history[0] || discounts[0] || {}).currency || 'CNY'; prompt = prompt.replace(/\{PREDICT_MODES\}/g, modesText) .replace(/\{GAME_NAME\}/g, gameName || historyData.gameTitle || '未知游戏') .replace(/\{APP_ID\}/g, appId || '') .replace(/\{RELEASE_DATE\}/g, historyData.releaseDate || '未知') .replace(/\{CURRENT_DATE\}/g, currentDate) .replace(/\{CURRENT_PRICE\}/g, String(currentPrice || '—')) .replace(/\{ORIGINAL_PRICE\}/g, String(originalPrice || '—')) .replace(/\{CURRENCY\}/g, currency) .replace(/\{DISCOUNT_RECORDS\}/g, discountRecords) .replace(/\{PRICE_HISTORY\}/g, priceHistory); //maxTokens 提升至 8192 防止多模型预测结果被截断 (与翻译函数一致) console.log('[SGIS] AI API 调用中, model:', storage.getAiModel(), 'url:', storage.getAiApiUrl()); const content = await callAiApi(prompt, { temperature: 0.4, timeout: 90000, maxTokens: 8192 }); console.log('[SGIS] AI API 返回, content length:', content.length); // 使用鲁棒 JSON 解析 (处理 markdown/截断/未转义) const result = safeParseAiJson(content, '{'); console.log('[SGIS] AI 价格预测解析完成, models:', (result.models || []).length); return result; } // ==================== 跨产品共享数据访问层(供 SGLV/SGIS 共用) ==================== // 事件总线契约:SGLV 通过 document.dispatchEvent(new CustomEvent("sglv:games-updated")) 通知 SGIS 刷新 // --- 中文名缓存 --- // ==================== 游戏中文名获取(参考 steam-friend-manager fetchGameZhName) ==================== // 通过 store.steampowered.com/api/appdetails?l=schinese 获取简体中文名称, // 带跨会话持久缓存(30天 TTL)和内存级防重复请求(pending Map), // 供游戏橱窗列表、游玩仪表、家庭愿望单、绝版游戏等面板异步替换英文名。 // 仅在中文环境(isZh)下生效,非中文环境直接返回空字符串。 const SGLV_GAME_NAME_KEY = 'sglv_game_name_cache'; const SGLV_NAME_CACHE_TTL = 30 * 864e5; // 30天过期 let _sglvGameNameCache = null; const _sglvGameNamePending = new Map(); // ==================== v2.31.5 Steam AI Agent 缓存桥(saa-bridge)==================== // GM 存储按脚本沙箱隔离,别的脚本读不到;但同源 localStorage 是共享的。 // 这里把「汇率 + 游戏中文名」两个 GM 键镜像到 store 域的 localStorage, // 供 Steam AI Agent 的 readPluginCache 工具只读消费(省一次网络请求、不耗 Steam API 配额)。 // // 铁律:桥永远不能影响宿主。 // - 全程 try/catch 静默,任何异常都不得冒泡到 SGLV 主流程; // - names 可能 MB 级,写入前先估体积,超阈值或 QuotaExceeded 时降级为 names=null // 并标 namesSkipped='quota',绝不因为桥挤爆 SGLV 自己的 localStorage 配额; // - 2s 防抖合并高频写入(中文名缓存是逐个 appid 增量写的)。 const SAA_BRIDGE_KEY = '__saa_bridge_sglv__'; const SAA_BRIDGE_NAMES_MAX_CHARS = 1.2 * 1024 * 1024; // 桥内中文名体积上限,超出只带汇率 let _saaBridgeTimer = null; function scheduleSaaBridge() { if (_saaBridgeTimer) return; _saaBridgeTimer = setTimeout(() => { _saaBridgeTimer = null; try { writeSaaBridge(); } catch (e) { /* 桥失败静默,绝不影响宿主 */ } }, 2000); } function writeSaaBridge() { try { const bridge = { v: 1, updatedAt: Date.now(), rates: null, names: null, namesSkipped: null }; // 汇率:GM 键 'sgis-rates'(1 外币 = X CNY)+ 时间戳,体量小(~2KB)原样放行 try { const rates = GM_getValue('sgis-rates', null); const ts = Number(GM_getValue('sgis-rates-ts', 0)) || 0; if (rates && typeof rates === 'object') bridge.rates = { ts, map: rates }; } catch (e) { /* 汇率读不到就不带,不影响 names */ } // 中文名:全量尝试,超体积阈值直接跳过(宁可没有,也不要挤占宿主配额) try { const raw = GM_getValue(SGLV_GAME_NAME_KEY, null); if (raw && typeof raw === 'object') { const names = {}; for (const k in raw) { const v = raw[k]; names[k] = (v && typeof v === 'object') ? (v.name || '') : v; } const nKeys = Object.keys(names).length; if (nKeys) { let approx = 0; try { approx = JSON.stringify(names).length; } catch (e) { approx = Infinity; } if (approx <= SAA_BRIDGE_NAMES_MAX_CHARS) bridge.names = names; else bridge.namesSkipped = 'quota'; } } } catch (e) { bridge.namesSkipped = 'quota'; } // 写入:先试带 names 的完整包,QuotaExceeded 则退化为仅汇率再试一次 const full = JSON.stringify(bridge); if (sglvLocalSet(SAA_BRIDGE_KEY, full)) return; if (bridge.names) { bridge.names = null; bridge.namesSkipped = 'quota'; sglvLocalSet(SAA_BRIDGE_KEY, JSON.stringify(bridge)); } } catch (e) { /* 顶层兜底:桥不影响宿主 */ } } // 启动补调一次:把上次会话未落盘的更新(页面关闭前的最后一批写入)补上 try { scheduleSaaBridge(); } catch (e) {} function sglvGameNameCacheLoad() { if (_sglvGameNameCache !== null) return _sglvGameNameCache; _sglvGameNameCache = {}; try { const raw = GM_getValue(SGLV_GAME_NAME_KEY); if (raw && typeof raw === 'object') _sglvGameNameCache = raw; } catch (e) { console.warn('[SGLV] 游戏名称缓存读取失败:', e); } return _sglvGameNameCache; } function sglvGameNameCacheSave() { try { GM_setValue(SGLV_GAME_NAME_KEY, _sglvGameNameCache || {}); } catch (e) { console.warn('[SGLV] 游戏名称缓存写入失败:', e); } // v2.31.5:同步触发 AI Agent 桥(2s 防抖,中文名逐个写入已合并) try { scheduleSaaBridge(); } catch (e) {} } //中文名获取失败负缓存 TTL(30 分钟)——失败结果短期内直接返回, // 防止增量渲染(每次资料补全批次都会重渲列表)反复重发单 appid 请求触发 Steam 限流, // 这曾是中文名大面积获取失败(停留在英文名)的根因 const SGLV_NAME_NEG_TTL = 30 * 60 * 1000; // 获取游戏中文名(异步,返回 Promise<string>,空字符串表示非中文环境或获取失败) function fetchGameZhName(appid) { if (!isZh) return Promise.resolve(''); const id = String(appid); const cache = sglvGameNameCacheLoad(); const cached = cache[id]; if (cached && cached.name && Date.now() - (cached.ts || 0) < SGLV_NAME_CACHE_TTL) { return Promise.resolve(cached.name); } // 负缓存命中:近期已失败过,不再重发请求 if (cached && !cached.name && Date.now() - (cached.ts || 0) < SGLV_NAME_NEG_TTL) { return Promise.resolve(''); } if (_sglvGameNamePending.has(id)) return _sglvGameNamePending.get(id); //filters=name 是无效过滤值,appdetails 返回 data:[](空),导致中文名始终获取不到 // (库存页因自带英文名未暴露此问题,Epic赠送页仅有 appid 无名称故大面积显示 "App xxxxxx")。 // 改用 filters=basic(含 name 字段,响应约 3KB,远小于无过滤的 15KB)。 const p = new Promise(resolve => { const settle = (name) => { cache[id] = { name, ts: Date.now() }; // 成功/失败均落缓存(空名=负缓存) try { sglvGameNameCacheSave(); } catch (e) { console.warn('[SGLV] 游戏名称缓存保存失败:', id, e); } _sglvGameNamePending.delete(id); resolve(name); }; //使用统一 API 调用层(带重试),失败时走负缓存 sglvGmFetchRetry(`https://store.steampowered.com/api/appdetails?appids=${id}&filters=basic&l=schinese`, { timeout: 10000, retries: 1 }) .then(json => { let name = ''; try { const d = json && json[id]; if (d && d.success && d.data && d.data.name) name = d.data.name; } catch (e) { console.warn('[SGLV] 中文名解析失败:', id, e); } settle(name); }) .catch(() => settle('')); }); _sglvGameNamePending.set(id, p); return p; } // 异步加载中文名并更新 DOM 元素(参考 steam-friend-manager loadGameZhName) // el: 显示游戏名的元素;appid: 游戏ID;originalName: 当前显示的名称 function loadGameZhName(el, appid, originalName) { if (!el || !appid) return; fetchGameZhName(appid).then(zhName => { if (zhName && zhName !== originalName) { el.textContent = zhName; el.title = `${zhName} (${originalName})`; } }); } //获取游戏英文名(参考 Steam_Show_English_Name.js,filters=basic 含 name 字段) const _sglvEnNameCache = new Map(); const _sglvEnNamePending = new Map(); function fetchGameEnName(appid) { const id = String(appid); if (_sglvEnNameCache.has(id)) return Promise.resolve(_sglvEnNameCache.get(id)); if (_sglvEnNamePending.has(id)) return _sglvEnNamePending.get(id); const p = new Promise(resolve => { //使用统一 API 调用层(带重试) sglvGmFetchRetry(`https://store.steampowered.com/api/appdetails?appids=${id}&filters=basic&l=english`, { timeout: 10000, retries: 1 }) .then(json => { let name = ''; try { const d = json && json[id]; if (d && d.success && d.data && d.data.name) name = d.data.name; } catch (e) { console.warn('[SGLV] 英文名解析失败:', id, e); } if (name) _sglvEnNameCache.set(id, name); _sglvEnNamePending.delete(id); resolve(name); }) .catch(() => { _sglvEnNamePending.delete(id); resolve(''); }); }); _sglvEnNamePending.set(id, p); return p; } //异步加载游戏名称——默认显示中文名(如有),原始英文名作为灰色别名追加在后 // 逻辑:① 主名是 "App xxx" 占位符 → 获取中文名替换主名,英文名作为别名(需额外请求) // ② 主名是英文名 → 获取中文名替换主名,原始英文名作为灰色别名 // ③ 中文名与主名相同(主名已是中文):不追加别名 // ④ 无中文名(主名为英文且无中文名):不追加别名,避免英文名重复/雷同 // ⑤ 占位符主名 + 无中文名 → 获取英文名替换主名,不追加别名 // 参考steam-friend-manager:静默失败不显示任何别名 const _APP_PLACEHOLDER_RE = /^App\s+\d+$/i; function loadGameNameAlias(el, appid, originalName) { if (!el || !appid || !originalName) return; const isPlaceholder = _APP_PLACEHOLDER_RE.test(originalName.trim()); fetchGameZhName(appid).then(zhName => { if (zhName && zhName !== originalName) { // 获取到中文名 → 主名替换为中文 el.textContent = zhName; el.title = `${zhName} (${originalName})`; // 仅当原始名是真实名称(非 App xxx 占位符)时才作为灰色别名 if (!isPlaceholder) { _appendGrayAlias(el, originalName); } return; } // 中文名为空或与主名相同 if (!isPlaceholder) return; // 主名是 App xxx 占位符且无中文名 → 尝试获取英文名替换占位符 fetchGameEnName(appid).then(enName => { if (enName && enName !== originalName) { el.textContent = enName; el.title = enName; // 不追加别名(已是英文名,无中文名可对照) } }); }); } function _appendGrayAlias(el, alias) { if (el.querySelector('.sglv-name-alias')) return; const span = document.createElement('span'); span.className = 'sglv-name-alias'; span.textContent = alias; el.appendChild(span); } //中文名回填统一入口——收编 9 处重复的 // 「querySelectorAll('[data-sglv-appid]') → loadGameZhName(el, appid, textContent)」循环 // 差异矩阵(2026-08-18 grep 实测): // 常规 7 处: 趋势时间线/游玩 top/游玩全部/愿望单主列表/愿望单增量(existView)/SGIS 最近游玩/SGIS 动态/SGIS 市场洞察 // → 全部走 loadGameZhName(textContent 替换 + title 拼原始名) // 橱窗列表 1 处: .sglv-list-name 元素走 loadGameNameAlias(中文名+灰色别名+App 占位符英文回退),其余 loadGameZhName // SGIS 3 处容器为 document: 选择器需 '#sgis-body ' 前缀限定作用域 // opts.aliasFor: CSS 选择器,匹配元素改走 loadGameNameAlias(橱窗列表视图语义) // opts.scope: 作用域选择器前缀(如 '#sgis-body'),配合 container=document 使用 // 每元素经 data-sglv-alias-done 标记幂等:二次调用(愿望单增量更新场景)零新增请求; // 失败负缓存由 fetchGameZhName 层承担(30 分钟 TTL),本工厂不重试 function applyGameAliases(container, opts = {}) { if (!container || typeof container.querySelectorAll !== 'function') return; const sel = opts.scope ? `${opts.scope} [data-sglv-appid]` : '[data-sglv-appid]'; container.querySelectorAll(sel).forEach(el => { if (el.getAttribute('data-sglv-alias-done')) return; el.setAttribute('data-sglv-alias-done', '1'); const appid = el.getAttribute('data-sglv-appid'); if (opts.aliasFor && typeof el.matches === 'function' && el.matches(opts.aliasFor)) { loadGameNameAlias(el, appid, el.textContent); } else { loadGameZhName(el, appid, el.textContent); } }); } // --- getActiveSteamId --- let _activeSteamId = null; function getActiveSteamId() { if (_activeSteamId) return _activeSteamId; _activeSteamId = storage.getSteamId() || detectSteamId(); return _activeSteamId; } // --- isGameOwnedByMe --- function isGameOwnedByMe(g) { const steamId = getActiveSteamId(); if (!g.owners || g.owners.length === 0) return true; if (!steamId) return true; return g.owners.includes(steamId); } // --- 家庭组成员名(appid → 拥有者昵称列表)--- // v2.42.2:从 SGLV 子闭包(21920 IIFE)提升到主闭包顶层。 // 原先它定义在子闭包内,而 injectSGLVAppDetailHost(主闭包)要把它注入给 // 外部的 sglv-app-detail 库 → 跨闭包引用 → ReferenceError 被 catch 吞掉, // 表现为「归属人名永远不显示」且毫无报错。 // 它只依赖 storage(主闭包顶层),提升后对子闭包内的调用点同样可见。 function getGameOwnerNames(g) { if (!g.owners || g.owners.length === 0) return ''; const familyInfo = storage.getFamilyInfo(); const nameMap = familyInfo?.steamIdtoName || {}; return g.owners.map(sid => nameMap[sid] || 'ID:' + sid.slice(-4)).join(', '); } // --- localStorage 写入助手(主闭包自带)--- // v2.42.2:原先误用 shelf IIFE 的私有 lsSet → 跨闭包 ReferenceError, // 被外层 catch 吞掉后,SAA 数据桥的写盘率恒为 0(桥从未落地过)。 function sglvLocalSet(key, val) { try { window.localStorage.setItem(key, val); return true; } catch (e) { return false; } } //获取排除家庭组共享游戏和 DLC 后的游戏列表(用于个人信息面板和洞察页面统计准确性) //修复 isDlc 跨作用域不可见问题——isDlc 定义在 SGLV 子闭包内,通过 SGLV_API 桥接访问 //加 session 内缓存 + 持久化缓存(签名:dlcReady + ownedGames count) // - session 内:已加载的 isDlc 结果直接复用 // - 跨 session:游戏数 + dlcReady 未变时直接命中(避免每次开面板都遍历全库存) let _cachedStatFiltered = null; let _cachedStatFilteredSig = ''; function getStatFilteredGames() { const allGames = (state.ownedGames && state.ownedGames.length) ? state.ownedGames : storage.getCachedGames(); const _isDlc = SGLV_API.isDlc; const dlcReady = (SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()) ? '1' : '0'; const sig = `${allGames.length}|${dlcReady}`; if (_cachedStatFiltered && _cachedStatFilteredSig === sig) return _cachedStatFiltered; const result = allGames.filter(g => isGameOwnedByMe(g) && !(_isDlc && _isDlc(g.appid))); _cachedStatFiltered = result; _cachedStatFilteredSig = sig; return result; } //DLC 数量缓存(避免个人信息面板每次渲染都重新 filter + isDlc 遍历) // 缓存 key 基于 ownedGames 数量 + dlcDbData 是否已加载,任一变化时自动失效 let _cachedDlcCount = null; let _cachedDlcCountKey = ''; function getOwnedDlcCount() { const allGames = (state.ownedGames && state.ownedGames.length) ? state.ownedGames : storage.getCachedGames(); const _isDlc = SGLV_API.isDlc; // 缓存 key 需区分 DLC 数据库加载前后(加载前 isDlc 恒返回 false,DLC 计数为 0) const dlcReady = (SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()) ? '1' : '0'; const key = `${allGames.length}|${dlcReady}`; if (_cachedDlcCount !== null && _cachedDlcCountKey === key) return _cachedDlcCount; if (!_isDlc || dlcReady === '0') { _cachedDlcCount = 0; _cachedDlcCountKey = key; return 0; } _cachedDlcCount = allGames.filter(g => isGameOwnedByMe(g) && _isDlc(g.appid)).length; _cachedDlcCountKey = key; return _cachedDlcCount; } // --- ownedAppIds缓存变量 --- // 缓存 getActiveOwnedAppIds 结果,避免供应商分类标签页重复计算导致卡死 let _cachedActiveOwnedAppIds = null; let _cachedActiveOwnedAppIdsKey = ''; // --- getActiveOwnedAppIds --- function getActiveOwnedAppIds() { const showFamilyShared = storage.getShowFamilyShared(); const cacheKey = `${state.ownedGames.length}|${showFamilyShared}`; if (_cachedActiveOwnedAppIds && _cachedActiveOwnedAppIdsKey === cacheKey) { return _cachedActiveOwnedAppIds; } const appIds = new Set(); state.ownedGames.forEach(g => { if (showFamilyShared || isGameOwnedByMe(g)) { appIds.add(g.appid); } }); _cachedActiveOwnedAppIds = appIds; _cachedActiveOwnedAppIdsKey = cacheKey; return appIds; } // --- invalidateActiveOwnedAppIdsCache --- // 当 ownedGames 或家庭组开关变化时,使缓存失效 function invalidateActiveOwnedAppIdsCache() { _cachedActiveOwnedAppIds = null; _cachedActiveOwnedAppIdsKey = ''; } // --- Token&SteamID检测 --- // ==================== Token & SteamID 自动检测 ==================== let _cachedWebApiToken = null; function getAccessTokenSync() { try { const appConfig = document.getElementById('application_config'); if (appConfig) { const storeConfig = JSON.parse(appConfig.getAttribute('data-store_user_config') || '{}'); if (storeConfig.webapi_token) return storeConfig.webapi_token; } } catch (e) { /* ignore */ } try { const m = document.documentElement.innerHTML.match(/"webapi_token"\s*:\s*"([^"]+)"/); if (m && m[1]) return m[1]; } catch (e) { /* ignore */ } return null; } //迁移至 sglvGmFetchTextRetry(永不 reject 语义保留—— // 解析失败/非 2xx/网络错误/超时均 resolve(null),调用方 getAccessToken 继续下一级兑底) function fetchWebApiTokenFromStore() { if (_cachedWebApiToken) return Promise.resolve(_cachedWebApiToken); return sglvGmFetchTextRetry('https://store.steampowered.com/', { timeout: 10000 }).then(html => { try { const m1 = html.match(/id="application_config"[^>]*data-store_user_config="([^"]*)"/); if (m1 && m1[1]) { const decoded = m1[1].replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); const config = JSON.parse(decoded); if (config.webapi_token) { _cachedWebApiToken = config.webapi_token; return config.webapi_token; } } const m2 = html.match(/"webapi_token"\s*:\s*"([^"]+)"/); if (m2 && m2[1]) { _cachedWebApiToken = m2[1]; return m2[1]; } } catch (e) { /* ignore */ } return null; }).catch(() => null); } async function getAccessToken() { const syncToken = getAccessTokenSync(); if (syncToken) return syncToken; const storeToken = await fetchWebApiTokenFromStore(); if (storeToken) return storeToken; return storage.getApiKey() || null; } function detectSteamId() { try { if (unsafeWindow.g_steamID) return unsafeWindow.g_steamID; } catch { /* g_steamID may not exist in all page contexts */ } try { const appConfig = document.getElementById('application_config'); if (appConfig) { const userInfo = JSON.parse(appConfig.getAttribute('data-userinfo') || '{}'); if (userInfo.steamid) return String(userInfo.steamid); } } catch (e) { /* ignore */ } const m = document.documentElement.innerHTML.match(/g_steamID\s*=\s*"(\d{17})"/); return m ? m[1] : ''; } // --- showToast --- //showToast 升级为委托 sglvToast(保持向后兼容) function showToast(msg) { sglvToast.info(msg); } // --- 剪贴板工具 --- //复制文本到剪贴板(Clipboard API + textarea 兜底) function copyTextToClipboard(text) { try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text)); return; } } catch { /* ignore */ } fallbackCopyText(text); } function fallbackCopyText(text) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } catch { /* ignore */ } } // --- fetchDynamicStoreOwnedAppIds --- let _dynamicStoreOwnedAppIds = null; // Set<number> | null(模块级缓存) //迁移至 sglvGmFetchRetry(非 2xx/parse 失败统一进 catch,补终态失败通知) async function fetchDynamicStoreOwnedAppIds() { if (_dynamicStoreOwnedAppIds) return _dynamicStoreOwnedAppIds; try { const data = await sglvGmFetchRetry('https://store.steampowered.com/dynamicstore/userdata/', { timeout: 15000 }); const ownedApps = data?.rgOwnedApps; if (Array.isArray(ownedApps)) { _dynamicStoreOwnedAppIds = new Set(ownedApps.map(Number).filter(n => Number.isFinite(n) && n > 0)); console.log(`[SGLV] dynamicstore/userdata 获取 ${_dynamicStoreOwnedAppIds.size} 个 owned appids`); } } catch (e) { console.warn('[SGLV] dynamicstore/userdata 获取失败:', e); sglvNotifyNetFail('DynamicStore 拥有游戏列表获取', e); // v2.11.36: 终态失败通知(60s 节流) } return _dynamicStoreOwnedAppIds; } // --- getDynamicStoreAppIds --- // 参考 SteamPeek标记库存状态:从页面 GDynamicStore 读取愿望单/库存 appid 集合(即时、零请求) function getDynamicStoreAppIds(kind) { try { const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; let raw = null; if (kind === 'wishlist') raw = win.GDynamicStore?.s_rgWishlist || win.g_rgWishlist; else if (kind === 'owned') raw = win.GDynamicStore?.s_rgOwnedApps; if (!raw) return new Set(); const arr = Array.isArray(raw) ? raw : Object.keys(raw); return new Set(arr.map(Number).filter(n => Number.isFinite(n) && n > 0)); } catch { return new Set(); } } // ==================== SGLV 子模块(子闭包封装,仅导出 initUI/autoScan) ==================== const SGLV_API = {}; (function () { // ==================== SGLV 专属工具函数 ==================== // ==================== 工具函数 ==================== function requestSteamAPI(url) { //增加 timeout(30s)和 ontimeout 处理,避免请求挂起导致 loading 卡住 //检测 HTML 响应(未登录/API Key 失效时 Steam 返回登录页),提前抛出清晰错误 //迁移至 sglvGmFetchTextRetry——文本通道保留 HTML 检测与本地 JSON.parse // (core 文本通道 2xx 才返回,非 2xx/网络错误/超时均 reject;HTML 检测文案冻结) return sglvGmFetchTextRetry(url, { timeout: 30000 }).then(text => { // 检测 HTML 响应(登录页/错误页),避免 JSON.parse 报模糊错误 if (text.startsWith('<') || text.startsWith('<!DOCTYPE')) { throw new Error('Steam API 返回 HTML 而非 JSON(可能未登录或 API Key 失效)'); } return JSON.parse(text); }); } // 解析 Steam 游戏库页面中的时长文本(参考 steam-activity-modern.js) function parsePlaytimeTextToMinutes(text) { if (!text) return 0; const t = text.trim(); const m1 = t.match(/([\d.]+)\s*hrs?/i); if (m1) return Math.round(parseFloat(m1[1]) * 60); const m2 = t.match(/([\d.]+)\s*小时/); if (m2) { const h = parseFloat(m2[1]); const minM = t.match(/(\d+)\s*分钟/); const min = minM ? parseInt(minM[1], 10) : 0; return Math.round(h * 60 + min); } const m3 = t.match(/(\d+)\s*minutes?/i); if (m3) return parseInt(m3[1], 10); return 0; } function parseLastPlayedToTs(text) { if (!text) return 0; const t = text.trim(); if (!/last|最后|上次|play|今天|昨天|前天|\d{4}年|days?|hours?/i.test(t)) return 0; const now = Math.floor(Date.now() / 1000); if (/今天|just now|几分钟前|a few minutes ago/i.test(t)) return now; if (/昨天|yesterday/i.test(t)) return now - 86400; if (/前天/i.test(t)) return now - 2 * 86400; const dayM = t.match(/(\d+)\s*天前|(\d+)\s*days?\s*ago/i); if (dayM) return now - parseInt(dayM[1] || dayM[2], 10) * 86400; return now; } // 无 API Key 时抓取 Steam 社区游戏库页面获取时长(参考 steam-activity-modern.js) //迁移至 sglvGmFetchTextRetry(原非 2xx resolve {ok:false}→空结果, // 新非 2xx reject→catch→空结果,语义等价) async function fetchGamesPage(steamId, tab = 'all') { if (!steamId) return { games: [], total: 0 }; const url = `https://steamcommunity.com/profiles/${steamId}/games/?tab=${tab}`; try { const text = await sglvGmFetchTextRetry(url, { timeout: 20000 }); const doc = new DOMParser().parseFromString(text, 'text/html'); const rows = doc.querySelectorAll('.gameListRow'); const games = []; rows.forEach(row => { const appid = parseInt(row.id?.replace('game_', '') || row.dataset?.appid || '0', 10); if (!appid) return; const name = row.querySelector('.gameListRowItemName, .gameListRowItem h2, .gameListRowItem .gameListRowItemName')?.textContent?.trim() || row.querySelector('a[href*="/app/"]')?.textContent?.trim() || ''; const logoImg = row.querySelector('img.gameListLogo, img[src*="capsule"], img[src*="header"]'); const logoSrc = logoImg?.getAttribute('src') || ''; const playtimeEl = row.querySelector('.gameListRowItem .gameListRowItemHours, .hours_played, .gameListRowItem .gameListRowItemTime, .gameListRowItem .gameListRowItemStat'); const playtimeText = playtimeEl?.textContent?.trim() || ''; const playtime = parsePlaytimeTextToMinutes(playtimeText); const lastPlayedText = row.querySelector('.gameListRowItem .gameListRowItemLastPlayed, .last_played, .gameListRowItem .gameListRowItemTime')?.textContent?.trim() || ''; const lastPlayed = parseLastPlayedToTs(lastPlayedText); games.push({ appid, name: name || `App ${appid}`, playtime_forever: playtime, playtime_2weeks: 0, rtime_last_played: lastPlayed, img_icon_url: '', logoSrc }); }); const totalEl = doc.querySelector('.gameListRowItem .gameListRowItemCount, .gameListRow .gameListRowItem .gameListRowItemCount'); const totalText = totalEl?.textContent || ''; const totalMatch = totalText.match(/(\d+)/); const total = totalMatch ? parseInt(totalMatch[1], 10) : games.length; return { games, total }; } catch (e) { console.warn('[SGLV] games 页抓取失败:', e); return { games: [], total: 0 }; } } // 注:getGameOwnerNames 已提升到主闭包顶层(见主闭包 isGameOwnedByMe 附近)—— // v2.42.2 修复跨闭包不可见问题。 //通用计算缓存(持久化版)— 内存镜像 + IDB 持久化(通过 SGLVCore.PersistentComputeCache) // 旧实现仅 session 内 Map,关闭浏览器/升级脚本即全部失效。 // 新增 PCC IDB 持久化层,关键计算结果(年度统计 / 家庭组累计 / 成就指标 / wishlist 统计)跨 session 复用。 // // 工作流: // 1) getCached(key, computeFn) — 同步读 _computedCache,未命中则 compute 后写内存 + 异步落 PCC // 2) hydrateComputedCache() — 启动时从 PCC IDB 同步预填充到 _computedCache // 3) clearComputedCache() — 清内存 + 清 PCC const _computedCache = new Map(); //精简: 复用外层主闭包已探测的 SGLVCore 实例 C,移除重复的全局探测(行为不变) const _PCC_CACHE = C.PersistentComputeCache || null; //通用计算缓存 schema 版本(对应 _computedCache 这层,business 单独用自己 schema 升级) const _COMPUTE_CACHE_SCHEMA_VER = 1; let _pccHydrated = false; function cacheKey(...parts) { return parts.map(p => JSON.stringify(p)).join('|'); } function getCached(key, computeFn) { if (_computedCache.has(key)) return _computedCache.get(key); const v = computeFn(); _computedCache.set(key, v); //同步写内存 + 异步落 PCC(IDB 持久化,跨 session 复用) if (_PCC_CACHE) { try { _PCC_CACHE.set(key, _COMPUTE_CACHE_SCHEMA_VER, key, v); } catch (e) { /* quota */ } } return v; } //启动时从 PCC IDB 同步预填充到 _computedCache(必须在 sglvIDB.loadAll() 完成后调用) // 返回:已 hydrate 的 key 数量 function hydrateComputedCache() { if (_pccHydrated) return 0; if (!_PCC_CACHE) { _pccHydrated = true; return 0; } try { const pccMem = _PCC_CACHE._mem || {}; let count = 0; for (const k of Object.keys(pccMem)) { if (!_computedCache.has(k)) { const entry = pccMem[k]; if (entry && entry.value !== undefined && entry._ver === _COMPUTE_CACHE_SCHEMA_VER) { _computedCache.set(k, entry.value); count++; } } } _pccHydrated = true; if (count > 0) console.log(`[SGLV-Compute] PCC IDB 预热 ${count} 条已缓存计算结果到 _computedCache`); return count; } catch (e) { console.warn('[SGLV-Compute] PCC hydrate 失败:', e); _pccHydrated = true; return 0; } } //按"游戏库版本"选择性地失效 PCC 缓存(避免整个 clear 导致其他无关数据丢失) // 旧逻辑:_computedCache.clear() 把所有内存缓存清空,下次渲染全部重算 // 新逻辑:仅失效 sig 与 ownedGames 关联的 key,其他不依赖 ownedGames 的(搜索结果等)保留 // 当前简单实现:游戏库变化时整体清空(保持安全),但仍通过 PCC 落盘给下次复用 function clearComputedCache() { console.log('[SGLV-Compute] clearComputedCache 被调用 — 失效所有计算缓存'); _computedCache.clear(); if (_PCC_CACHE) { try { _PCC_CACHE.clear(); } catch (e) { /* ignore */ } } _pccHydrated = true; // 已清,无需再 hydrate _cachedActiveOwnedAppIds = null; _cachedActiveOwnedAppIdsKey = ''; _cachedDlcCount = null; _cachedDlcCountKey = ''; _cachedStatFiltered = null; // v2.9.50: 同步失效 getStatFilteredGames 缓存 _cachedStatFilteredSig = ''; if (SGLV_API.invalidateAppTypeCache) SGLV_API.invalidateAppTypeCache(); const hadDlcTypeMap = !!dlcTypeMap; dlcTypeMap = null; _dlcsByParentCache = null; console.log(`[SGLV-Compute] clearComputedCache 完成 — dlcTypeMap ${hadDlcTypeMap ? '已失效 (之前有数据)' : '本就为空'},appTypeCache 已失效,DLC 数量缓存已失效,statFiltered 已失效,dlcsByParent 已失效`); } //业务级持久化计算结果工具(给 computeYearlyStats / computeInsightData 等大数据用) // 与 getCached 不同:不依赖 _computedCache 内存层,而是单独命名空间(biz_xxx)便于跨域管理 // key: 业务名,如 'biz_yearly_stats' // schemaVer: schema 版本号,升级时强制重算 // inputSig: 输入签名(如 "123|456" = 游戏数 + 总acquiredTime) // computeFn: 同步重算函数,返回新值 // 返回:同步值(命中缓存直接返回,未命中同步重算并落盘) function getBizCached(key, schemaVer, inputSig, computeFn) { if (!_PCC_CACHE) return computeFn(); // 1) 同步从 PCC 读 + 元数据 const meta = _PCC_CACHE.getSyncWithMeta(key); if (meta && meta._ver === schemaVer && meta._sig === inputSig) { return meta.value; } // 2) 同步重算 + 落盘 const v = computeFn(); try { _PCC_CACHE.set(key, schemaVer, inputSig, v); } catch (e) { /* ignore */ } return v; } function getGameDBMeta(appid) { for (const [pubName, pubData] of Object.entries(GAME_DB)) { for (const [seriesName, games] of Object.entries(pubData)) { if (games.some(g => g.id === appid)) { return { publisher: pubName, series: seriesName }; } } } return { publisher: '', series: '' }; } //Steam 社区小图标 CDN 路径迁移适配 // 新游戏(含未发售,如 app 4292950)的社区图标仅存在于新路径 // shared.{fastly,akamai}.steamstatic.com/community_assets/images/apps/{appid}/{hash}.jpg, // 旧路径 cdn.{cloudflare,akamai}.steamstatic.com/steamcommunity/public/images/apps/… 对其 404; // 实测新路径对老游戏同样可用(TF2/440 新旧均 200)= 超集 → 新路径优先,旧路径留作网络兜底。 // 另:此类新游戏常无任何商店封面资产(capsule/header 全 404),社区图标是唯一可用图。 function communityIconChainUrls(appid, iconHash) { const cf = 'https://cdn.cloudflare.steamstatic.com'; const ak = 'https://cdn.akamai.steamstatic.com'; const shAk = 'https://shared.akamai.steamstatic.com'; const shF = 'https://shared.fastly.steamstatic.com'; const urls = []; if (iconHash) { urls.push(`${shF}/community_assets/images/apps/${appid}/${iconHash}.jpg`); // 新路径(超集,新老通吃) urls.push(`${shAk}/community_assets/images/apps/${appid}/${iconHash}.jpg`); urls.push(`${cf}/steamcommunity/public/images/apps/${appid}/${iconHash}.jpg`); // 旧路径(老游戏;shared.* 不可达时兜底) urls.push(`${ak}/steamcommunity/public/images/apps/${appid}/${iconHash}.jpg`); } // 无 hash / hash 全 404 → 商店小胶囊(多 CDN) urls.push(`${cf}/steam/apps/${appid}/capsule_sm_120.jpg`); urls.push(`${ak}/steam/apps/${appid}/capsule_sm_120.jpg`); urls.push(`${shAk}/store_item_assets/steam/apps/${appid}/capsule_sm_120.jpg`); urls.push(`${shF}/store_item_assets/steam/apps/${appid}/capsule_sm_120.jpg`); return urls; } function getGameIconUrl(appid, iconHash) { //命中成功缓存直接返回;否则新路径优先(注释见 communityIconChainUrls) const good = getPosterGood(appid, 'icon'); if (good) return good; if (iconHash) return `https://shared.fastly.steamstatic.com/community_assets/images/apps/${appid}/${iconHash}.jpg`; return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_sm_120.jpg`; } //小图标统一回退链(对齐封面 _sglvPosterFallback 三段式) // 成功缓存 → communityIconChainUrls 链式回退 → header.jpg 兜底 → 隐藏;成功 onload 记录缓存避免重复试错 function sglvIconFallback(img, appid, iconHash) { if (!img || !appid) return; const cur = img.getAttribute('src') || ''; const good = getPosterGood(appid, 'icon'); if (good && good !== cur) { // 缓存 URL 仍失败时重新进入本函数,此时 good===cur 会落入链式回退,无死循环 img.onerror = function () { img.onerror = null; sglvIconFallback(img, appid, iconHash); }; img.onload = function () { img.onload = null; }; img.src = good; return; } const chain = communityIconChainUrls(appid, iconHash); // 当前 src 已是链中某个 URL(初始加载失败触发)→ 从其下一个继续,避免重复请求 let start = chain.indexOf(cur) + 1; const tryNext = (i) => { if (i >= chain.length) { // 最后一搏:header.jpg(几乎所有已发售游戏都有) img.onerror = function () { img.onerror = null; img.style.display = 'none'; }; img.src = `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`; return; } img.onerror = function () { img.onerror = null; tryNext(i + 1); }; img.onload = function () { recordPosterGood(appid, img.src, 'icon'); img.onload = null; }; img.src = chain[i]; }; tryNext(start); } // 暴露给内联 onerror 使用(页面上下文) unsafeWindow._sglvIconFallback = sglvIconFallback; //多 CDN 封面回退链(参考 steam-family-game-analysis faLoadCover 多 CDN fallback) // 修复 cdn.cloudflare.steamstatic.com 不可达(部分网络环境被阻断/抽风)时封面大面积获取不到的问题: // 依次尝试 cloudflare → cdn.akamai → shared.akamai → shared.fastly(store_item_assets),全部失败再走 appdetails API //新增 shared.fastly.steamstatic.com 作为第四 CDN 源(部分新游戏仅 fastly 有图) function coverChainUrls(appid, kind) { const cf = `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}`; const ak = `https://cdn.akamai.steamstatic.com/steam/apps/${appid}`; const sh = `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}`; const sf = `https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/${appid}`; if (kind === 'poster') { //补充 shared.akamai store_item_assets 路径的 library_600x900(部分新游戏仅在此路径有图) //追加 shared.fastly store_item_assets 路径 return [ `${cf}/library_600x900_2x.jpg`, // HD 竖版 `${cf}/library_600x900.jpg`, // 标准竖版 `${ak}/library_600x900_2x.jpg`, // Akamai HD 竖版 `${ak}/library_600x900.jpg`, // Akamai 标准竖版 `${sh}/library_600x900_2x.jpg`, // shared.akamai HD 竖版 `${sh}/library_600x900.jpg`, // shared.akamai 标准竖版 `${sf}/library_600x900_2x.jpg`, // v2.9.106: shared.fastly HD 竖版 `${sf}/library_600x900.jpg`, // v2.9.106: shared.fastly 标准竖版 `${cf}/header.jpg`, // 宽版(几乎所有游戏都有) `${ak}/header.jpg`, `${sh}/header.jpg`, `${sf}/header.jpg`, ]; } if (kind === 'capsule') { return [ `${cf}/capsule_467x181.jpg`, // 大横幅封面(最清晰) `${cf}/capsule_231x87.jpg`, // 中等横幅 `${cf}/header.jpg`, // 头图兜底 `${ak}/capsule_231x87.jpg`, `${ak}/header.jpg`, `${sh}/capsule_184x69.jpg`, // shared akamai store_item_assets `${sh}/header.jpg`, `${sf}/capsule_184x69.jpg`, // v2.9.106: shared fastly store_item_assets `${sf}/header.jpg`, ]; } return [`${cf}/header.jpg`, `${ak}/header.jpg`, `${sh}/header.jpg`, `${sf}/header.jpg`]; // header } // API 图片回退:当所有 CDN 路径都失败时,通过 Steam Store API 获取真实图片 URL(含 hash) //重构:成功/进行中/失败三态分离——旧实现用同一 Map 的 '' 同时表示"请求中"与"失败", // 导致同 appid 并发回退时后者直接占位、且瞬时失败后永不再重试(封面获取不到的另一根因) //缓存键按 kind 隔离(poster/capsule/header),避免横版 URL 被竖版 poster 复用导致裁切变形 const _posterApiCache = new Map(); // appid:kind → API 获取成功的图片 URL const _posterApiPending = new Map(); // appid:kind → 进行中的 API 请求 Promise(并发共享) // v2.31.2: appdetails 失败负缓存 —— 避免同一游戏每次渲染都重打 API(重试风暴)。 // 10 分钟后再试:瞬时网络抖动很快恢复,也不会永久拉黑一个游戏。 const _posterApiNeg = new Map(); // appid:kind → 失败时间戳 const POSTER_API_NEG_TTL = 10 * 60 * 1000; const POSTER_API_NEG_MAX = 600; function _markPosterApiNeg(cacheKey) { _posterApiNeg.delete(cacheKey); _posterApiNeg.set(cacheKey, Date.now()); if (_posterApiNeg.size > POSTER_API_NEG_MAX) { const it = _posterApiNeg.keys(); while (_posterApiNeg.size > POSTER_API_NEG_MAX) { const n = it.next(); if (n.done) break; _posterApiNeg.delete(n.value); } } } function _posterApiNegHit(cacheKey) { const t = _posterApiNeg.get(cacheKey); if (!t) return false; if ((Date.now() - t) < POSTER_API_NEG_TTL) return true; _posterApiNeg.delete(cacheKey); return false; } //占位替换仅替换 <img> 节点本身,不再覆盖父容器 innerHTML—— // 封面视图中父容器还含有徽章浮层(sglv-wl-cover-badges),需保留 //继承原 img 的 className(sglv-list-icon / sglv-card-img), // 使占位 div 获得与原图相同的固定尺寸(92x43 / 100%×100%),避免布局塌陷 function _posterImgToPlaceholder(img) { if (!img || !img.parentElement) return; const holder = document.createElement('div'); holder.className = img.className; holder.style.cssText = 'display:flex;align-items:center;justify-content:center;color:#5a6a7a;font-size:11px'; holder.textContent = isZh ? '无图片' : 'No Image'; img.parentElement.replaceChild(holder, img); } //kind 感知——poster 优先 header_image(更宽,适合竖版裁切), // capsule 优先 capsule_image(横幅原生),header 优先 header_image //迁移至 sglvGmFetchRetry(永不 reject 语义保留—— // parse 失败/非 2xx/网络错误/超时均 resolve('');pending map 清理收敛到 .finally) //kind 感知——poster 优先 header_image(更宽,适合竖版裁切), // capsule 优先 capsule_image(横幅原生),header 优先 header_image //迁移至 sglvGmFetchRetry(永不 reject 语义保留—— // parse 失败/非 2xx/网络错误/超时均 resolve('');pending map 清理收敛到 .finally) // // v2.23.1 重构:原实现引用未声明的 _coverNegCache / _coverBatchEnqueue / COVER_NEG_TTL // (v2.22.2 引入负缓存/batch 队列时声明被遗漏),API 兜底触发即 ReferenceError, // 导致所有 CDN 链失败后无法走 Steam appdetails / steamui.com hash 渠道。 // 现统一委托 sglv-cover-fallback.lib.js:resolveCover 已自带负缓存(确认无资产/网络 // 抖动双 TTL)+ 并发去重(pending Map) + steamui hash URL 直通 + 语言变体链,按 kind 选图。 // 参考 steam-friend-manager 1.5.3 dashFetchCapsuleFromStore 同架构(pending 去重 + // 双 TTL 负缓存 + 三层持久化)。 function _fetchCoverApiUrl(appid, kind) { const k = kind || 'poster'; // 同步 good 缓存命中直接 resolve —— 避免每次重渲染都重复打 appdetails const good = getPosterGood(appid, k); if (good) return Promise.resolve(good); // v2.31.1 关键修复:cover-fallback v2.0.x 的 resolveCover 是纯同步实现 // (good 缓存 → 否则链首),根本不做网络解析。旧实现把它当"API 兜底", // 拿回来的 URL 就是刚刚 404 的那个链首 → img.src 被重设成同一个坏 URL → // 立刻再次 error → 直接进「无图片」占位。这就是"封面全都加载不出来"的直接原因。 // 这里一律走真实的 appdetails 查询(in-flight 去重 + 成功结果缓存)。 return _legacyFetchCoverApiUrl(appid, k); } //内联 appdetails 直查兜底(保留原 v2.22 语义:永不 reject;不引用任何未声明变量) // 仅在 SGLVCoverFallback 不可用时启用,正常路径不会进入 function _legacyFetchCoverApiUrl(appid, kind) { const k = kind || 'poster'; const cacheKey = `${appid}:${k}`; if (_posterApiCache.has(cacheKey)) return Promise.resolve(_posterApiCache.get(cacheKey)); if (_posterApiPending.has(cacheKey)) return _posterApiPending.get(cacheKey); // v2.31.2: 负缓存命中 → 直接返回空,不再打 API(10 分钟后自动重试) if (_posterApiNegHit(cacheKey)) return Promise.resolve(''); const url = `https://store.steampowered.com/api/appdetails?appids=${appid}&l=${isZh ? 'schinese' : 'english'}&filters=basic`; const p = sglvGmFetchRetry(url, { timeout: 10000, retries: 1 }) .then(data => { const entry = data && data[appid]; const d = entry && entry.success && entry.data; if (!d) { _markPosterApiNeg(cacheKey); return ''; } const u = (k === 'capsule' && d.capsule_image) ? d.capsule_image : (d.header_image || d.capsule_image || ''); const full = u && (u.startsWith('http') ? u : `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${u}`); // v2.31.1: 只缓存成功结果。旧实现失败/空也写 '' → // 一次瞬时网络抖动就让该 appid 在整个会话内永远拿不到 API 兜底。 if (full) { _posterApiCache.set(cacheKey, full); _posterApiNeg.delete(cacheKey); // 方向归一:按 URL 本身的形态写槽位(竖版 → poster,横版 → header), // 既不会把 460x215 的 header_image 写进 poster 槽位污染竖版视图, // 又能让受限/PS/Epic/收藏视图共享同一份横版 good URL。 const naturalKind = full.indexOf('library_600x900') >= 0 ? 'poster' : 'header'; recordPosterGood(appid, full, naturalKind); try { const fb = window.SGLVCoverFallback; if (fb && typeof fb.recordGood === 'function') fb.recordGood(String(appid), full, naturalKind); } catch (e) { /* ignore */ } } else { _markPosterApiNeg(cacheKey); } return full || ''; }) .catch(() => { _markPosterApiNeg(cacheKey); return ''; }); _posterApiPending.set(cacheKey, p); p.then(() => {}, () => {}).then(() => { _posterApiPending.delete(cacheKey); }); return p; } //传递 kind 参数,使 API 兜底能按图片类型选择最佳 URL //v2.31.1: ① cover-fallback 已接管(data-sglv-cover + bindErrorHandler)时直接返回, // 否则会用 resolveCover 拿到的同一个坏 URL 抢 src,打断 lib 的链推进; // ② API 也给不出新 URL 时直接占位,不再重设同一个 src 形成"error→同 URL→error"死循环。 unsafeWindow._sglvPosterFallback = function(img, appid, kind) { if (!img || !img.isConnected) return; const k = kind || 'poster'; let managed = false; try { managed = !!img._sglvChainLoading || !!(img.closest && img.closest('[data-sglv-cover-handler-bound]')); } catch (e) { /* ignore */ } if (managed) return; const cur = img.getAttribute('src') || ''; _fetchCoverApiUrl(appid, k).then(url => { if (url && url !== cur) { // API URL 加载仍失败 → 占位(先替换 onerror,防止链式 handler 死循环) img.onerror = function() { img.onerror = null; _posterImgToPlaceholder(img); }; img.src = url; return; } _posterImgToPlaceholder(img); }, () => _posterImgToPlaceholder(img)); }; // 统一封面 <img> 标签生成:成功URL缓存 → 多 CDN 直链回退 → API 获取 → 占位 //增加成功URL内存缓存;多 CDN 回退链 + 按 kind 隔离缓存 //① 传递 kind 给 _sglvPosterFallback 使 API 兜底按类型选图 // ② onerror 链跳过 chain[0](与初始 src 相同,避免重复请求) // // v2.23.1: 优先使用 SGLVCoverFallback.getCoverUrl() 作为初始 src—— // 1) 命中 good 缓存同步返回(跨视图跨会话);2) steamui hash URL 直通免 CDN 探测; // 3) 语言变体(如 schinese)链前部插入,优先返回中文变体; // 4) data-sglv-cover 属性 + bindErrorHandler 接管 error 链(替代内联 onerror 字符串, // URL 长度不再受 HTML 属性限制,且 lib 内部维护 CDN 全链+steamui+API+placeholder); // 5) 双缓存层(_posterGoodUrl 旧会话缓存 + SGLVCoverFallback 跨会话缓存)协同, // 一次加载成功后两个缓存同时写入,下次访问秒开。 function coverImgTag(appid, name, kind, cls) { const k = kind || 'poster'; const fb = window.SGLVCoverFallback; // lib 可用 = 链式回退由 panelEl 上的 bindErrorHandler 全权接管 const managed = !!(fb && typeof fb.chainLoad === 'function' && typeof fb.bindErrorHandler === 'function'); // 初始 src:宿主 good 缓存(已验证/跨会话) → lib good 缓存(跨会话) → 宿主 CDN 链首 // v2.31.2: 宿主缓存必须放第一位 —— getCoverUrl 无缓存时会返回链首, // 若放在前面,sglv_bk_cover_map 里几千条已验证 URL 永远读不到(旧缓存等于白存) let first = getPosterGood(appid, k) || ''; // 横版类共享 header 槽位(收藏/PS/Epic/受限视图写入的横版 URL) if (!first && k !== 'poster' && k !== 'header') first = getPosterGood(appid, 'header') || ''; if (!first && fb && typeof fb.getCoverUrl === 'function') { try { first = fb.getCoverUrl(String(appid), k, isZh ? 'schinese' : '') || ''; } catch (e) { /* ignore */ } } if (!first) first = coverChainUrls(appid, k)[0] || ''; const base = `class="${cls}" src="${first}" alt="${name}" loading="lazy" decoding="async" referrerpolicy="no-referrer"` + ` data-sglv-cover="1" data-appid="${appid}" data-kind="${k}"` + ` onload="_sglvRecordPosterGood('${appid}',this.src,'${k}')"`; // v2.31.1 关键修复:lib 已接管时绝不能同时挂内联 onerror。 // 旧实现两条链路互相抢 src:内联 onerror → _sglvPosterFallback → 拿回同一个坏 URL // → 立刻再次 error → 把 img 换成「无图片」div,链上后面本可成功的 URL 全被掐断。 if (managed) return `<img ${base}>`; // lib 不可用 → 保留旧的内联 API 兜底(仅此一条链路) return `<img ${base} onerror="if(this.dataset.fb){this.style.display='none'}else{this.dataset.fb='1';_sglvPosterFallback(this,'${appid}','${k}')}">`; } function posterImg(appid, name) { return coverImgTag(appid, name, 'poster', 'sglv-card-img'); } // 横版封面图片(header.jpg),用于游戏橱窗列表 function headerImg(appid, name) { return coverImgTag(appid, name, 'header', 'sglv-card-img'); } //横板封面图片(capsule 大横幅),用于愿望单封面视图 function capsuleImg(appid, name) { return coverImgTag(appid, name, 'capsule', 'sglv-wl-cover-img'); } // 注册 SGLVCoverFallback 全局 error 委托——用于主脚本自渲染的封面(CS 模块等) // 仅注册一次,多次调用幂等(lib 内部维护注册表) // 委托函数职责:捕获带 data-sglv-cover / data-kind / data-appid 的 img,自动走 CDN 链 // → steamui → appdetails → SVG placeholder 全链路,无需每张图单独写 onerror function _ensureCoverFallbackDelegation(root) { if (!root || root.__sglvCoverDelegated) return; const fb = window.SGLVCoverFallback; if (!fb || typeof fb.bindErrorHandler !== 'function') return; root.__sglvCoverDelegated = true; fb.bindErrorHandler(root, (img) => { if (img.nextElementSibling) img.nextElementSibling.style.display = 'flex'; try { const ph = document.createElement('div'); ph.className = (img.className || '') + ' sglv-cover-broken'; ph.textContent = isZh ? '无封面' : 'No Cover'; if (img.parentElement) img.parentElement.replaceChild(ph, img); } catch (e) { /* 静默 */ } }); } //分页导航——仅保留 首页|上页|下页|末页 + 跳页输入,无快捷页码按钮 function renderPagination(container, prefix, page, totalPages, totalItems, onPrev, onNext, onJump) { if (!container) return; container.innerHTML = ` <button class="sglv-page-arrow sglv-page-text-btn" id="${prefix}-first" ${page <= 1 ? 'disabled' : ''}>${T.first}</button> <button class="sglv-page-arrow sglv-page-text-btn" id="${prefix}-prev" ${page <= 1 ? 'disabled' : ''}>${T.prev}</button> <button class="sglv-page-arrow sglv-page-text-btn" id="${prefix}-next" ${page >= totalPages ? 'disabled' : ''}>${T.next}</button> <button class="sglv-page-arrow sglv-page-text-btn" id="${prefix}-last" ${page >= totalPages ? 'disabled' : ''}>${T.last}</button> <input type="number" class="sglv-page-jump-input" id="${prefix}-jump" min="1" max="${totalPages}" value="${page}" title="${T.jumpTo}"> <span class="sglv-page-info">${page}/${totalPages} (${totalItems})</span> `; const goPage = (p) => { const target = Math.min(Math.max(1, p), totalPages); if (target === page) return; if (onJump) onJump(target); }; container.querySelector(`#${prefix}-first`)?.addEventListener('click', () => goPage(1)); container.querySelector(`#${prefix}-prev`)?.addEventListener('click', () => goPage(page - 1)); container.querySelector(`#${prefix}-next`)?.addEventListener('click', () => goPage(page + 1)); container.querySelector(`#${prefix}-last`)?.addEventListener('click', () => goPage(totalPages)); const jumpInput = container.querySelector(`#${prefix}-jump`); jumpInput?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); const p = parseInt(jumpInput.value); if (!isNaN(p)) goPage(p); } }); jumpInput?.addEventListener('blur', () => { const p = parseInt(jumpInput.value); if (!isNaN(p) && p >= 1 && p <= totalPages && p !== page) goPage(p); else jumpInput.value = page; }); } // ==================== 数据获取 ==================== // 方式1: 通过 access_token 获取家庭共享库(含自身游戏,include_own=true) //onProgress(stage, percent, text) 进度回调,UI 可选接入避免"界面不动" async function fetchFamilyGameList(authToken, onProgress) { const report = (stage, percent, text) => { if (typeof onProgress === 'function') onProgress(stage, percent, text); }; try { // 阶段 1:获取家庭组基本信息 report(1, 25, '正在获取家庭组信息…'); const familyData = await requestSteamAPI( `https://api.steampowered.com/IFamilyGroupsService/GetFamilyGroupForUser/v1/?access_token=${authToken}&include_family_group_response=true` ); if (!familyData?.response?.family_groupid) { report(1, 100, '家庭组信息为空'); return null; } const familyGroupId = familyData.response.family_groupid; // 阶段 2:拉取共享库(主耗时 API,amount 不可知,给个递进动画) report(2, 50, '正在获取家庭组共享游戏库…'); const gameData = await requestSteamAPI( `https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/?access_token=${authToken}&family_groupid=${familyGroupId}&include_own=true&include_excluded=false&include_non_games=false` ); report(3, 80, '正在整理共享游戏数据…'); if (gameData?.response?.apps) { const apps = gameData.response.apps; const games = apps .filter(app => app.exclude_reason === 0) .map(app => ({ appid: app.appid, name: app.name || `App ${app.appid}`, playtime: 0, icon: app.img_icon_hash || '', lastPlayed: app.rt_time_acquired || 0, acquiredTime: app.rt_time_acquired || 0, owners: app.owner_steamids || [], })); report(4, 100, `已获取 ${games.length} 款家庭组共享游戏`); return games; } } catch (e) { console.warn('[SGLV] fetchFamilyGameList error:', e); } return null; } // 方式2: 获取拥有游戏(优先 API Key,尝试 access_token,最后 fallback 页面抓取) async function fetchOwnedGames(steamId, authToken) { const apiKey = storage.getApiKey(); //优先 API Key if (apiKey) { try { const data = await requestSteamAPI( `https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${apiKey}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json` ); if (data?.response?.games) { console.log(`[SGLV] 通过 API Key 获取 ${data.response.games.length} 款游戏(含时长)`); return { games: data.response.games.map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtime: g.playtime_forever || 0, icon: g.img_icon_url || '', lastPlayed: g.rtime_last_played || 0, _source: 'api', })), source: 'api' }; } } catch (e) { console.warn('[SGLV] API Key 获取失败:', e); } } //尝试用 access_token 调用 IPlayerService(部分账号可用) if (authToken && steamId) { try { const data = await requestSteamAPI( `https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?access_token=${authToken}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json` ); if (data?.response?.games) { console.log(`[SGLV] 通过 access_token 获取 ${data.response.games.length} 款游戏(含时长)`); return { games: data.response.games.map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtime: g.playtime_forever || 0, icon: g.img_icon_url || '', lastPlayed: g.rtime_last_played || 0, _source: 'api', })), source: 'api' }; } } catch (e) { console.warn('[SGLV] access_token 获取游戏时长失败:', e); } } // fallback:抓取社区游戏库页面(无需 Key,可获取时长) if (steamId) { try { const scraped = await fetchGamesPage(steamId, 'all'); if (scraped.games.length > 0) { console.log(`[SGLV] 通过页面抓取获取 ${scraped.games.length} 款游戏(含时长)`); return { games: scraped.games.map(g => ({ appid: g.appid, name: g.name, playtime: g.playtime_forever, icon: g.img_icon_url || '', lastPlayed: g.rtime_last_played, _source: 'scrape', })), source: 'scrape' }; } } catch (e) { console.warn('[SGLV] 页面抓取 fallback 失败:', e); } } return { games: [], source: null }; } async function fetchFamilyInfo(authToken) { try { const data = await requestSteamAPI( `https://api.steampowered.com/IFamilyGroupsService/GetFamilyGroupForUser/v1/?access_token=${authToken}&include_family_group_response=true` ); if (data?.response?.family_group) { const fg = data.response.family_group; const members = fg.members || []; const nameMap = {}; if (members.length > 0) { const steamids = members.map(m => m.steamid); const batchSize = 100; for (let i = 0; i < steamids.length; i += batchSize) { const batch = steamids.slice(i, i + batchSize); const params = batch.map((sid, idx) => `steamids[${idx}]=${sid}`).join('&'); try { const pData = await requestSteamAPI( `https://api.steampowered.com/IPlayerService/GetPlayerLinkDetails/v1/?access_token=${authToken}&${params}` ); if (pData?.response?.accounts) { pData.response.accounts.forEach(acc => { if (acc.public_data) { const sid = acc.public_data.steamid || acc.steamid; if (sid && acc.public_data.persona_name) { nameMap[sid] = acc.public_data.persona_name; } } }); } } catch (e) { /* ignore */ } } } return { family_groupid: data.response.family_groupid, family_name: fg.name || 'Steam Family', family_member: members.map(m => ({ steamid: m.steamid, userName: nameMap[m.steamid] || '' })), steamIdtoName: nameMap }; } } catch (e) { console.warn('[SGLV] fetchFamilyInfo error:', e); } return null; } //获取 dynamicstore/userdata——Steam 商店动态数据,返回完整 owned appids 列表 // 该端点比 GetOwnedGames 更可靠:包含 CD key 激活、促销许可、免费领取等所有类型的已入库游戏 // 参考 Steam-License-Classifier 的思路:license 数据是入库的 ground truth // 合并两种数据源 async function fetchAllGames(onProgress) { const gamesMap = new Map(); //进度回调——sub-step 1-3 映射到阶段 1 的 0-100% const report = (sub, pct, text) => { if (typeof onProgress === 'function') onProgress(sub, pct, text); }; report(1, 10, isZh ? '正在准备访问令牌…' : 'Preparing access token…'); const authToken = await getAccessToken(); const steamId = getActiveSteamId(); // 步骤1:优先获取个人拥有游戏(含时长) if (steamId) { report(1, 20, isZh ? '正在拉取个人游戏…' : 'Fetching owned games…'); const owned = await fetchOwnedGames(steamId, authToken); if (owned.games.length > 0) { owned.games.forEach(g => { gamesMap.set(g.appid, { appid: g.appid, name: g.name, playtime: g.playtime || 0, icon: g.icon || '', lastPlayed: g.lastPlayed || 0, owners: [], acquiredTime: 0, _source: g._source || owned.source, }); }); console.log(`[SGLV] 个人游戏来源: ${owned.source || 'none'}, 共 ${owned.games.length} 款`); } } // 步骤2:通过 access_token 获取家庭共享库(补充共享游戏和归属信息) if (authToken) { report(2, 50, isZh ? '正在拉取家庭组…' : 'Fetching family…'); const familyInfo = await fetchFamilyInfo(authToken); if (familyInfo) storage.setFamilyInfo(familyInfo); const familyGames = await fetchFamilyGameList(authToken); if (familyGames && familyGames.length > 0) { familyGames.forEach(g => { const existing = gamesMap.get(g.appid); if (!existing) { gamesMap.set(g.appid, { appid: g.appid, name: g.name, playtime: 0, icon: g.icon || '', lastPlayed: g.lastPlayed || 0, owners: g.owners || [], acquiredTime: g.acquiredTime || 0, _source: 'family', }); } else { // 用家庭组数据补充 owners 和 acquiredTime if (g.owners && g.owners.length) existing.owners = g.owners; if (g.acquiredTime) existing.acquiredTime = g.acquiredTime; } }); //个人入库时间校准——家人新入库共拥游戏会刷新 rt_time_acquired, // 趋势图年度柱/今年入库 KPI/SGIS 面板均消费 ownedGames.acquiredTime,须按个人许可页修正 try { const all = Array.from(gamesMap.values()); if (all.some(g => (g.owners || []).length > 1)) { const licenseMap = await fetchPersonalLicenseMap(); const corrections = licenseCorrectionsFor(all, licenseMap || {}); let n = 0; gamesMap.forEach(g => { const c = corrections[g.appid]; if (c) { g.acquiredTime = c; n++; } }); if (n > 0) console.log(`[SGLV] 个人入库时间校准:${n} 款共拥游戏已按个人许可页日期修正`); //我拥有但家人后买的共拥游戏(非末位拥有者)且无许可页日期 → 时间戳 // 是家人的购买时间,清零回退到 lastPlayed 估算口径,趋势/年度统计不再错误计入 // 家人购买日。家人独有的共享游戏不受影响(保留家庭组时间) const sid0 = String(steamId || '').trim(); if (sid0) { let zeroed = 0; gamesMap.forEach(g => { const owners = g.owners || []; if (owners.length < 2 || g.acquiredTime <= 0) return; const mine = owners.some(o => String(o).trim() === sid0); const lastIsMe = String(owners[owners.length - 1]).trim() === sid0; if (mine && !lastIsMe && !corrections[g.appid]) { g.acquiredTime = 0; zeroed++; } }); if (zeroed > 0) console.log(`[SGLV] ${zeroed} 款家人购买的共拥游戏入库时间已清零(无本人购买时间,回退估算口径)`); } } } catch (e) { console.warn('[SGLV] 入库时间校准失败(保留家庭组时间):', e); } console.log(`[SGLV] 通过 access_token 获取家庭共享 ${familyGames.length} 款游戏`); } } // 步骤3:通过 dynamicstore/userdata 补充 GetOwnedGames API 遗漏的游戏 // 某些通过 CD key 激活、促销许可或免费领取的游戏可能不在 GetOwnedGames 返回结果中 // dynamicstore/userdata 是 Steam 商店客户端使用的真实入库数据,包含所有许可类型 report(3, 80, isZh ? '正在补充遗漏游戏…' : 'Supplementing missing games…'); const dsOwnedAppIds = await fetchDynamicStoreOwnedAppIds(); if (dsOwnedAppIds && dsOwnedAppIds.size > 0) { let supplemented = 0; dsOwnedAppIds.forEach(appid => { if (!gamesMap.has(appid)) { gamesMap.set(appid, { appid, name: `App ${appid}`, playtime: 0, icon: '', lastPlayed: 0, owners: [], acquiredTime: 0, _source: 'dynamicstore', }); supplemented++; } }); if (supplemented > 0) { console.log(`[SGLV] dynamicstore/userdata 补充 ${supplemented} 款 GetOwnedGames 遗漏的游戏(CD key/促销许可等)`); } } report(3, 100, isZh ? '库存拉取完成' : 'Done'); return Array.from(gamesMap.values()); } //动态页专用数据源——始终拉取完整家庭组库(含个人 acquiredTime/owners,10 分钟会话级缓存)。 // 原因:acquiredTime 仅家庭组 API 提供;state.ownedGames 可能是缺少入库时间的缓存数据, // 导致个人入库动态为空。此处与"显示家庭共享"开关无关,保证两个入库动态都能正确加载。 let _timelineFamilyGamesCache = null; // { games, ts, appidSet } //入库热力图渲染缓存——避免每次切换游玩仪表标签页都重新计算 SVG //缓存结构扩展为 { signature, html, ts, views }——views 预存各年份/全部视图 HTML,供年份切换直接复用 let _heatmapRenderCache = null; // ==================== 个人许可页入库时间校准 ==================== // GetSharedLibraryApps 的 rt_time_acquired 是每个 app 的单一时间戳:家庭成员新入库一款 // 自己已拥有的游戏时,该时间会被刷新为家人的入库时间。"个人入库动态/入库趋势/热力图" // 等"我的入库"语境若直接采信,会把很早购买的游戏统计到家人的入库日期上(当天展示并未 // 购买的共拥游戏)。修复参考 steam-friend-manager crossVerifyLicenses:抓取个人许可页 // (store.steampowered.com/account/licenses,仅精确到日)的真实入库日期做日级覆盖。 // 家庭组入库动态保持原始 rt_time_acquired 不校准(家人入库事件时间本就是家庭组时间)。 const LICENSE_MAP_TTL = 24 * 60 * 60 * 1000; // 许可页入库日期基本不变,24h 会话级缓存 const MAX_LICENSE_PAGES = 50; // AjaxLoadMoreLicenses 分页上限(与 steam-friend-manager 一致) let _licenseMapCache = null; // { map, ts },map: appid -> { ts(ms 本地零点), dateStr } // 解析许可页日期字符串为本地零点时间戳(ms);无法识别返回 0 function parseLicenseDate(dateStr) { if (!dateStr) return 0; // 中文格式: "2024年6月13日"(l=schinese) let m = dateStr.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/); if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime(); const months = { Jan:0, Feb:1, Mar:2, Apr:3, May:4, Jun:5, Jul:6, Aug:7, Sep:8, Oct:9, Nov:10, Dec:11 }; // 英文格式: "13 Jun 2024" m = dateStr.match(/(\d{1,2})\s+(\w{3})\s+(\d{4})/); if (m && months[m[2]] != null) return new Date(Number(m[3]), months[m[2]], Number(m[1])).getTime(); // 英文格式: "Jun 13, 2024" m = dateStr.match(/(\w{3})\s+(\d{1,2}),?\s+(\d{4})/); if (m && months[m[1]] != null) return new Date(Number(m[3]), months[m[1]], Number(m[2])).getTime(); return 0; } // 从许可页 HTML 片段解析 .license_row 行到 map(appid -> { ts, dateStr }) function parseLicenseRows(html, map) { try { const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('.license_row').forEach(row => { const link = row.querySelector('a[href*="/app/"]'); if (!link) return; const appMatch = (link.getAttribute('href') || '').match(/\/app\/(\d+)/); if (!appMatch) return; const dateEl = row.querySelector('.license_date_col'); if (!dateEl) return; const label = dateEl.querySelector('.license_date_col_label'); if (label) label.remove(); const dateText = (dateEl.textContent || '').trim(); const ts = parseLicenseDate(dateText); const appid = Number(appMatch[1]); if (appid && ts) map[appid] = { dateStr: dateText, ts }; }); } catch (e) { console.warn('[SGLV] 许可页 HTML 解析失败:', e); } } // 抓取个人许可页全量入库日期(首屏 + AjaxLoadMoreLicenses AJAX 分页)。 // 未登录(重定向登录页)/网络失败返回空 map,调用方按"无法校准"降级保留家庭组时间。 let _licenseMapPromise = null; // 并发去重 function fetchPersonalLicenseMap() { if (_licenseMapCache && Date.now() - _licenseMapCache.ts < LICENSE_MAP_TTL) return Promise.resolve(_licenseMapCache.map); if (_licenseMapPromise) return _licenseMapPromise; _licenseMapPromise = (async () => { const map = {}; let fetchFailed = false; try { const html = await sglvGmFetchTextRetry('https://store.steampowered.com/account/licenses/?l=schinese', { timeout: 15000, retries: 1 }); parseLicenseRows(html, map); const sidMatch = (html || '').match(/g_sessionID\s*=\s*"([^"]+)"/); const sessionid = sidMatch ? sidMatch[1] : null; const hasMore = html.includes('load_more_licenses') || html.includes('btn_load_more'); if (hasMore && sessionid) { let cursor = ''; for (let pages = 0; pages < MAX_LICENSE_PAGES; pages++) { const data = await sglvGmFetchRetry('https://store.steampowered.com/account/AjaxLoadMoreLicenses/', { method: 'POST', timeout: 15000, retries: 1, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `sessionid=${encodeURIComponent(sessionid)}&cursor=${encodeURIComponent(cursor)}`, }); if (data && data.html) parseLicenseRows(data.html, map); if (!(data && data.has_more && data.cursor)) break; cursor = data.cursor; } } } catch (e) { fetchFailed = true; console.warn('[SGLV] 个人许可页获取失败(跳过入库时间校准):', e.message || e); } finally { _licenseMapPromise = null; } // 网络异常不缓存,下次调用自动重试;未登录(重定向登录页)属稳定态,空 map 也缓存 24h // 避免每次渲染个人入库动态都重打许可页 if (!fetchFailed) { _licenseMapCache = { map, ts: Date.now() }; if (Object.keys(map).length > 0) console.log(`[SGLV] 个人许可页入库日期已获取:${Object.keys(map).length} 条`); } return map; })(); return _licenseMapPromise; } // 校准核心(纯函数,供单测):许可页日期与 rt_time_acquired 不同日的条目 → 许可页日期(秒)。 // 许可页仅列本人许可,appid 命中即为"我的游戏",无需再比对 owners;同日保留家庭组时间(真实时刻)。 function licenseCorrectionsFor(games, licenseMap) { const corrections = {}; if (!Array.isArray(games) || !licenseMap) return corrections; for (const g of games) { if (!(g.acquiredTime > 0)) continue; const lic = licenseMap[g.appid]; if (!lic || !lic.ts) continue; const d1 = new Date(g.acquiredTime * 1000); d1.setHours(0, 0, 0, 0); const d2 = new Date(lic.ts); d2.setHours(0, 0, 0, 0); if (d1.getTime() !== d2.getTime()) corrections[g.appid] = Math.floor(lic.ts / 1000); } return corrections; } //判断 rt_time_acquired 是否为"我的购买事件"。 // GetSharedLibraryApps 的 owner_steamids 按入库顺序排列:末位=最新入库者(购买者), // 首位=最早购入者——参考 steam-family-game-analysis 购买动态的购买者归属 // (其购买者筛选即 owners.at(-1))。家人后买一款自己已拥有的游戏时 // owners=[我,家人]、rt_time_acquired=家人的购买时间,该时间戳不是"我"的购买事件。 function isMyAcquisitionEvent(g, mySteamId) { const owners = (g && g.owners) || []; if (!mySteamId || owners.length === 0) return true; // 无归属信息(回退路径)视为自己 const sid = String(mySteamId).trim(); if (!owners.some(o => String(o).trim() === sid)) return false; // 不是我的游戏 if (owners.length === 1) return true; // 独占拥有,时间即我的购买时间 return String(owners[owners.length - 1]).trim() === sid; // 末位=最新购买者 } // "我的入库"语境统一出口:家庭组原始 rt_time_acquired + 个人许可页校准。 // 返回 { games, map, corrected }——map 含全部游戏的入库时间(秒,我的游戏已校准), // corrected 为被校准 appid 集合。消费方:个人入库动态/入库趋势时间线/频率分析/排序补齐/热力图。 async function buildCalibratedAcquiredMap() { const familyGames = await fetchTimelineFamilyGames(); if (!familyGames || familyGames.length === 0) return { games: null, map: {}, corrected: new Set() }; const map = {}; for (const g of familyGames) { if (g.acquiredTime > 0) map[g.appid] = g.acquiredTime; } // 仅当存在共拥游戏(owners > 1)时才可能发生"被家人的新入库刷新",才需要抓许可页; // 无家庭组数据或全为单独拥有时 rt_time_acquired 就是自己的入库时间,零额外请求 const hasCoOwned = familyGames.some(g => (g.owners || []).length > 1); let corrected = new Set(); if (hasCoOwned) { const licenseMap = await fetchPersonalLicenseMap(); const corrections = licenseCorrectionsFor(familyGames, licenseMap || {}); for (const appid in corrections) { map[appid] = corrections[appid]; corrected.add(Number(appid)); } if (corrected.size > 0) console.log(`[SGLV] 个人入库时间校准:${corrected.size} 款共拥游戏已按个人许可页日期修正`); } //我拥有但家人后买的共拥游戏(非末位拥有者)且无许可页日期 → rt_time_acquired // 是家人的购买时间,从"我的入库"时间映射中移除(热力图/频率/趋势/排序不再按家人的购买日期 // 计数)。仅影响"本人拥有"的游戏;家人独有的共享游戏保留家庭组时间——热力图/趋势在开启 // "显示家庭共享"时本就统计它们,不受此清理影响 const mySid = String(getActiveSteamId() || '').trim(); if (mySid && hasCoOwned) { let dropped = 0; for (const g of familyGames) { const owners = g.owners || []; if (owners.length < 2) continue; const mine = owners.some(o => String(o).trim() === mySid); const lastIsMe = String(owners[owners.length - 1]).trim() === mySid; if (mine && !lastIsMe && !corrected.has(g.appid)) { if (map[g.appid] !== undefined) { delete map[g.appid]; dropped++; } } } if (dropped > 0) console.log(`[SGLV] 个人入库动态:${dropped} 款家人购买的共拥游戏已排除(无本人购买时间)`); } return { games: familyGames, map, corrected }; } //onProgress(stage, percent, text) 进度回调,UI 可选接入避免"界面不动" //增量更新——缓存有效时先返回缓存数据,后台静默拉取最新记录合并(不清空已有数据) async function fetchTimelineFamilyGames(onProgress) { const report = (stage, percent, text) => { if (typeof onProgress === 'function') onProgress(stage, percent, text); }; //缓存有效期内直接返回,不做增量更新(10 分钟 TTL) if (_timelineFamilyGamesCache && Date.now() - _timelineFamilyGamesCache.ts < 10 * 60 * 1000) { report(4, 100, `已从缓存载入 ${_timelineFamilyGamesCache.games.length} 款家庭组游戏`); return _timelineFamilyGamesCache.games; } //缓存已过期但仍有数据——先返回旧数据(秒开),后台增量拉取最新记录 const hasStaleCache = _timelineFamilyGamesCache && _timelineFamilyGamesCache.games.length > 0; if (hasStaleCache) { // 立即返回旧数据,UI 秒开 report(4, 100, `已从缓存载入 ${_timelineFamilyGamesCache.games.length} 款家庭组游戏`); // 后台静默增量更新(不阻塞 UI) _refreshTimelineCacheInBackground(); return _timelineFamilyGamesCache.games; } // 无缓存——首次加载,走完整流程 try { report(1, 8, '正在准备访问令牌…'); const authToken = await getAccessToken(); if (authToken) { const games = await fetchFamilyGameList(authToken, (stage, percent, text) => { const mapped = 10 + percent * 0.9; report(Math.min(4, stage + 1), Math.min(99, mapped), text); }); if (games && games.length > 0) { const appidSet = new Set(games.map(g => g.appid)); _timelineFamilyGamesCache = { games, ts: Date.now(), appidSet }; report(4, 100, `已获取 ${games.length} 款家庭组共享游戏`); return games; } } } catch (e) { console.warn('[SGLV] 动态页家庭组数据获取失败:', e); } const fb = (state.ownedGames && state.ownedGames.length > 0) ? state.ownedGames : null; report(4, 100, fb ? `已使用本地游戏库(${fb.length} 款)作为回退` : '家庭组数据不可用'); return fb; } //后台增量刷新——拉取家庭组数据,与缓存合并(只新增不删除),更新时间戳 async function _refreshTimelineCacheInBackground() { try { const authToken = await getAccessToken(); if (!authToken) return; const freshGames = await fetchFamilyGameList(authToken); if (!freshGames || freshGames.length === 0) return; const oldCache = _timelineFamilyGamesCache; if (!oldCache) { const appidSet = new Set(freshGames.map(g => g.appid)); _timelineFamilyGamesCache = { games: freshGames, ts: Date.now(), appidSet }; return; } // 增量合并:新数据中 appid 不在旧缓存中的才新增(保留已有数据不清空) const oldSet = oldCache.appidSet || new Set(oldCache.games.map(g => g.appid)); const newGames = freshGames.filter(g => !oldSet.has(g.appid)); if (newGames.length > 0) { // 更新旧缓存中已有游戏的数据(acquiredTime 可能更新) const freshMap = new Map(freshGames.map(g => [g.appid, g])); const mergedGames = oldCache.games.map(g => { const fresh = freshMap.get(g.appid); return fresh ? { ...g, ...fresh } : g; }); // 追加新游戏 mergedGames.push(...newGames); const mergedSet = new Set(mergedGames.map(g => g.appid)); _timelineFamilyGamesCache = { games: mergedGames, ts: Date.now(), appidSet: mergedSet }; console.log(`[SGLV] 时间线增量更新:新增 ${newGames.length} 款,总计 ${mergedGames.length} 款`); } else { // 无新增,仅更新时间戳 _timelineFamilyGamesCache.ts = Date.now(); } } catch (e) { console.warn('[SGLV] 时间线后台增量刷新失败:', e); // 失败时仍更新时间戳,避免频繁重试 if (_timelineFamilyGamesCache) _timelineFamilyGamesCache.ts = Date.now(); } } //个人入库动态(优先使用已缓存的 state.ownedGames,不判断"显示家庭共享"开关,直接显示所有个人入库动态) async function buildPersonalTimeline(steamId) { //统一走动态页专用数据源(始终含完整家庭组 acquiredTime),与"显示家庭共享"开关无关 //入库时间经个人许可页校准——家人新入库自己已拥有的游戏会刷新 rt_time_acquired, // 个人入库动态须用自己的许可页日期,否则旧游戏会被当成家人入库当天的"我的购买"展示 const { games: familyGames, map: acquiredMap, corrected } = await buildCalibratedAcquiredMap(); if (!familyGames || familyGames.length === 0) return null; const mySteamId = String(steamId || getActiveSteamId() || '').trim(); const items = []; for (const g of familyGames) { const acquired = acquiredMap[g.appid] || 0; if (!acquired) continue; // v2.3.15: 跳过无入库时间的游戏,避免显示无效条目 // 个人入库:owners 包含 mySteamId,或 owners 为空(视为个人拥有) const owners = g.owners || []; if (mySteamId && owners.length > 0 && !owners.some(sid => String(sid).trim() === mySteamId)) continue; //家人后买的共拥游戏,rt_time_acquired 是家人的购买时间(owner_steamids // 末位=购买者)——不是我的购买事件:有个人许可页日期则展示我的真实购买日期 // (corrected),否则不出现在我的个人入库动态(宁缺毋错) if (!isMyAcquisitionEvent(g, mySteamId) && !corrected.has(g.appid)) continue; const ts = acquired * 1000; const dateStr = new Date(ts).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); items.push({ appid: g.appid, name: g.name || `App ${g.appid}`, ts, dateStr, icon: g.icon || '', playtime: g.playtime || 0, //许可页校准的条目仅日级精度(本地零点),渲染时不显示伪 HH:MM 时刻 licenseCorrected: corrected.has(g.appid), }); } items.sort((a, b) => (b.ts || 0) - (a.ts || 0)); return { items, total: items.length, corrected: corrected.size }; } //家庭组入库动态(优先使用已缓存的 state.ownedGames,不判断"显示家庭共享"开关,直接显示所有入库动态) //onProgress(stage, percent, text) 进度回调,UI 可选接入避免"界面不动" // 同时把循环拆成异步 yield,避免处理 1k+ 款游戏时阻塞主线程 async function buildFamilyTimeline(steamId, onProgress) { const report = (stage, percent, text) => { if (typeof onProgress === 'function') onProgress(stage, percent, text); }; //统一走动态页专用数据源(始终含完整家庭组 acquiredTime),与"显示家庭共享"开关无关 // 阶段 1-4:token → 家庭组信息 → 共享库 → 整理(在 fetchTimelineFamilyGames 内部完成) const familyGames = await fetchTimelineFamilyGames((stage, percent, text) => { report(stage, percent, text); }); if (!familyGames || familyGames.length === 0) return null; //购买者归属——owner_steamids 末位=最新入库者(参考 steam-family-game-analysis // 购买动态表的购买者列)。名称映射优先取已缓存的家庭组信息,缺失时拉取一次并持久化。 let nameMap = (storage.getFamilyInfo() || {}).steamIdtoName || null; if (!nameMap) { try { const token = await getAccessToken(); if (token) { const fi = await fetchFamilyInfo(token); if (fi) { storage.setFamilyInfo(fi); nameMap = fi.steamIdtoName || {}; } } } catch (e) { console.warn('[SGLV] 家庭组成员名称获取失败(购买者显示降级为仅时间):', e); } } nameMap = nameMap || {}; // 阶段 4:分批整理游戏数据,避免长任务阻塞渲染 const items = []; const total = familyGames.length; const BATCH = 200; // 每批 200 条,配合 requestAnimationFrame 让出主线程 const yieldFrame = () => new Promise(r => requestAnimationFrame(() => r())); for (let i = 0; i < total; i++) { const g = familyGames[i]; const acquired = g.acquiredTime || 0; if (acquired) { const ts = acquired * 1000; const dateStr = new Date(ts).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); const owners = g.owners || []; items.push({ appid: g.appid, name: g.name || `App ${g.appid}`, ts, dateStr, icon: g.icon || '', playtime: g.playtime || 0, owners, //购买者=owner_steamids 末位(最新入库者),名称缺失时留空仅显示时间 purchaserName: owners.length ? String(nameMap[owners[owners.length - 1]] || '').trim() : '', }); } // 每批结束让出一次主线程(数据量大时保持 UI 响应) if ((i + 1) % BATCH === 0 && i + 1 < total) { const pct = 50 + Math.round((i + 1) / total * 45); report(4, pct, `已整理 ${i + 1} / ${total} 款游戏…`); await yieldFrame(); } } items.sort((a, b) => (b.ts || 0) - (a.ts || 0)); report(4, 100, `整理完成:共 ${items.length} 条入库记录`); return { items, total: items.length }; } // ==================== 游戏收藏模块(@require 外部库 sglv-game-collection) ==================== //收藏模块核心实现位于独立库, // 主脚本仅加载收藏基本界面——本桥接区 + 子菜单/心型/SGIS 三个入口 + 主题层 Part E。 // 供应商分类 / 绝版收藏 / PS会免 / Epic赠送 / 锁区 / 年度大作 / DLC收藏 / 捆绑包 / // Humble Choice 全部实现位于库内,经 CollectionHost 桥访问主脚本数据/工具/i18n。 // 库未加载时(网络失败/TM缓存损坏),二级菜单项置灰提示,主浮窗功能不受影响。 // 桥接契约:宿主需 @grant GM_xmlhttpRequest/GM_getValue/GM_setValue, // 并 @connect fastly.jsdelivr.net(PS会免数据)与 store.playstation.com(PS独占中文名)。 const CollectionHost = { apiVersion: 1, // ---- 库存数据访问(只读,尊重家庭组开关) ---- getOwnedGames: () => state.ownedGames, getActiveOwnedAppIds, isGameOwnedByMe, getGameOwnerNames, getShowFamilyShared: () => storage.getShowFamilyShared(), // ---- 收藏数据源(@resource 声明保留在主脚本 metadata) ---- //供应商(GAME_DB)与系列(seriesDb)分离提供,不再合并 //getGameDb 优先返回远程覆盖版本(如有) getGameDb: () => supplierDbOverride || GAME_DB, getSeriesDb: () => seriesDb, //暴露 loadSeriesData 供收藏浮窗调用强制刷新 loadSeriesData, //暴露 loadSupplierData + getSupplierFetchedAt 供收藏浮窗调用 loadSupplierData, getSupplierFetchedAt: () => supplierFetchedAt, getDelistedDb: () => _getDelistedDb(), getResourceText: (name) => { try { return GM_getResourceText(name); } catch (e) { return null; } }, // ---- 通用工具 ---- fetchGameZhName, fetchGameEnName, loadGameZhName, coverChainUrls, posterImg, capsuleImg, getGameIconUrl, getPosterGood, // v2.31.2: 收藏模块加载成功的封面也写入共享 good 缓存(跨面板复用,避免各探一遍) recordPosterGood, fetchCoverApiUrl: _fetchCoverApiUrl, renderPagination, paginate, showToast, wlEscape, escHtml, h, formatLastPlayedShort, cacheKey, getCached, // ---- TTL 缓存 ---- cacheGet, cacheSet, cacheDelete, cacheTTL: CACHE_TTL, // ---- i18n / 图标 ---- T, ICONS, isZh, // ---- 锁区游戏数据访问(供收藏模块调用) ---- loadBlockedApps, isBlockedApp, isBlockedLoaded: () => state.blockedLoaded, getBlockedApps: () => state.blockedApps, getBlockedSource: () => state.blockedSource, //暴露 fetchedAt 供收藏浮窗显示数据更新时间(延迟查找,因 SGLV_API.getBlockedFetchedAt 在锁区IIFE中赋值) getBlockedFetchedAt: () => (typeof SGLV_API.getBlockedFetchedAt === 'function') ? SGLV_API.getBlockedFetchedAt() : '', parseManualBlockedJson, // ---- DLC 收集数据访问(供收藏模块调用,闭包直达无需 SGLV_API 中转) ---- //loadDlcDatabaseFromCacheSync 暴露给收藏模块——冷启动时同步预热 dlcDbData / appTypeMap 闭包, // 避免 collection 库每次 await 异步加载导致"假卡死" isDlc, isDlcDbReady: () => !!(dlcDbData || appTypeMap), loadDlcDatabase, loadDlcDatabaseFromCacheSync, // v1.9.2: 新增,同步读 GM 缓存填充闭包 enrichOwnedAppTypes, enrichDlcTypes, getDlcType, getBundleCount, // v2.5.0: 捆绑包数据源就绪态 + 主动加载入口—— // 原先捆绑包 tab 只能被动消费 getBundleCount()(bundleDbData 为 null 时全返 0), // 且没有任何地方暴露 loadBundleDatabase,导致「不先点『我的库存』tab 就打不开捆绑包」。 // 这里补两个桥接:isBundleDbReady 让收藏模块判断是否需要等待,loadBundleDatabase 让它能主动触发。 isBundleDbReady: () => !!bundleDbData, loadBundleDatabase, getDlcParent: (dlcAppId) => { const e = dlcDbData && dlcDbData[String(dlcAppId)]; return e ? e.base_appID : null; }, //构建 父游戏 appID → 全部 DLC 数 反向映射(用于 DLC 收集完成度计算) getParentDlcTotalMap: () => { const map = {}; if (dlcDbData) { for (const info of Object.values(dlcDbData)) { if (info && info.base_appID) { map[info.base_appID] = (map[info.base_appID] || 0) + 1; } } } console.log(`[SGLV-DLC] getParentDlcTotalMap — 构建 ${Object.keys(map).length} 个父游戏的 DLC 总数映射 (dlcDbData=${dlcDbData ? Object.keys(dlcDbData).length + '条' : 'null'})`); return map; }, //构建 父游戏 appID → [dlcAppId, ...] 反向映射(用于 DLC 收藏全量加载) getDlcsByParentMap: () => { if (_dlcsByParentCache) return _dlcsByParentCache; const map = {}; if (dlcDbData) { for (const [dlcAppId, info] of Object.entries(dlcDbData)) { if (info && info.base_appID) { const key = String(info.base_appID); if (!map[key]) map[key] = []; map[key].push(Number(dlcAppId)); } } } _dlcsByParentCache = map; console.log(`[SGLV-DLC] getDlcsByParentMap — 构建 ${Object.keys(map).length} 个父游戏的 DLC 列表映射`); return map; }, }; //收藏模块经 @require 外部库加载,握手初始化 let _collectionReady = false; if (window.SGLVCollection && typeof window.SGLVCollection.init === 'function') { _collectionReady = window.SGLVCollection.init(CollectionHost); if (!_collectionReady) { console.warn('[SGLV] 收藏模块握手失败 (apiVersion 不匹配)'); } } else { console.warn('[SGLV] 收藏模块库未加载 — 收藏功能不可用 (请检查 @require 或 Tampermonkey 外部资源设置)'); } //回写握手结果到库全局对象——侧边栏(SGIS)收藏快捷按钮无法访问主脚本闭包内 // 的 _collectionReady,经 window.SGLVCollection._hostReady 判定模块可用性 if (window.SGLVCollection) window.SGLVCollection._hostReady = _collectionReady; // ==================== 暂存箱模块(sglv-shelf)桥接 ==================== // 暂存箱与「Steam 购物车增强」(Steam-Cart-Enhanced.js) 共用 localStorage['sce_cart_shelf_v1'], // 购物类记录(app/bundle/package)双向互通;愿望单/备忘等 SGLV 独享类型走独立桶, // 避免被 SCE 的 saveShelf() 整体重写 items 时静默丢弃(详见库头注释)。 // 库以内联形式随主脚本同步执行(见 SGLV-SHELF-INLINE 区),加载失败则整块降级。 let _shelfReady = false; if (window.SGLVShelf && typeof window.SGLVShelf.init === 'function') { const ShelfHost = { apiVersion: 1, isZh: isZh, getSteamId: () => storage.getSteamId(), toast: sglvToast, // 名称补全:优先走 app-detail 库,失败时库内回退 GetItems resolveName: async (appid) => { try { const AD = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail) || window.SGLVAppDetail; if (AD && typeof AD.loadDetail === 'function') { const d = await AD.loadDetail(appid); return (d && d.name) || ''; } } catch (e) { /* 回退库内实现 */ } return ''; }, openUrl: (url) => { try { window.open(url, '_blank', 'noopener'); } catch (e) { location.href = url; } }, }; _shelfReady = window.SGLVShelf.init(ShelfHost); if (!_shelfReady) console.warn('[SGLV] 暂存箱握手失败 (apiVersion 不匹配)'); } else { console.warn('[SGLV] 暂存箱库未加载 — 暂存箱功能不可用'); } //回写握手结果:侧边栏(SGIS)按钮在主闭包之外,需经 window.SGLVShelf._hostReady 判定可用性 if (window.SGLVShelf) window.SGLVShelf._hostReady = _shelfReady; //云存档模块经 @require 外部库加载,握手初始化 let _cloudSaveReady = false; if (window.SGLVCloudSave && typeof window.SGLVCloudSave.init === 'function') { const CloudSaveHost = { apiVersion: 1, isZh, showToast, cacheGet, cacheSet, cacheDelete, cacheTTL: CACHE_TTL, storage: { getCloudSaveData: storage.getCloudSaveData, setCloudSaveData: storage.setCloudSaveData, }, }; _cloudSaveReady = window.SGLVCloudSave.init(CloudSaveHost); if (!_cloudSaveReady) { console.warn('[SGLV] 云存档模块握手失败 (apiVersion 不匹配)'); } } else { console.warn('[SGLV] 云存档模块库未加载 — 云存档功能不可用 (请检查 @require 或 Tampermonkey 外部资源设置)'); } //ITAD 价格增强库初始化 // 硬编码 Key 作为兜底 (用户未配置时使用共享额度) const ITAD_API_KEY_FALLBACK = '58eb2271a08c1cf60bd812d701d09d5c694f502e'; function getEffectiveItadKey() { const userKey = storage.getItadApiKey(); return userKey || ITAD_API_KEY_FALLBACK; } let _itadReady = false; if (window.SGLVITAD && typeof window.SGLVITAD.init === 'function') { window.SGLVITAD.init({ apiKey: getEffectiveItadKey() }); _itadReady = true; console.log('[SGLV] ITAD 价格增强库已加载' + (storage.getItadApiKey() ? ' (用户密钥)' : ' (兜底密钥)')); } else { console.warn('[SGLV] ITAD 库未加载 — 价格增强功能不可用 (请检查 @require)'); } //每日快照历史常量与内存镜像上移(原位于本 IIFE 后段)—— // PTW_HOST 对象字面量的 maxDays 以及 _inspect() 的成员遍历都会**急切读取** PT_HISTORY_DAYS, // 声明必须先于这些消费点,否则命中 TDZ 抛 ReferenceError → 主 IIFE 崩溃、UI 全不加载。 // 依赖:nsKey 在本 IIFE 前段(命名空间区)定义,先于此处 ✓ const PT_HISTORY_KEY = nsKey('pt_daily_history'); const PT_HISTORY_DAYS = 90; // v2.46.2 采样密度下限:每日采集前,时长本体(PT_SNAP_KEY)超过此年龄就先刷一次。 // 理由见 ptHistoryCollect:周刷新 + 逐日快照 = 六天差值恒 0、第七天爆发。 // 只在「今天还没采过」时才可能触发 → 最多 1 次/天。 const PT_HISTORY_FRESH_MS = 6 * 3600 * 1000; let _ptHistory = null; let _ptHistoryInflight = null; const SGLVCharts = { // 通用颜色板(与现有 SGLV 视觉一致;purple/blue 等为命名色,供语义使用,不随主题切换) PALETTE: { purple: '#a78bfa', blue: '#3b82f6', cyan: '#06b6d4', teal: '#14b8a6', green: '#22c55e', amber: '#f59e0b', red: '#ef4444', pink: '#ec4899', gray: '#94a3b8', text: '#cbd5e1', textMuted: '#64748b', bgGrid: 'rgba(255,255,255,0.06)', bgCard: 'rgba(255,255,255,0.03)' }, //主题层图表色——渲染时读当前主题(每次调用求值,主题热切换后下一次渲染即生效) accent() { return getThemeId() === 'blue' ? '#66c0f4' : '#a78bfa'; }, accentDeep() { return getThemeId() === 'blue' ? '#1a9fff' : '#8b5cf6'; }, accentGradient() { return getThemeId() === 'blue' ? 'linear-gradient(135deg, #1a9fff, #66c0f4)' : 'linear-gradient(135deg, #8b5cf6, #a78bfa)'; }, // 默认系列色:主题强调色优先,另一主题色作次色,语义绿/金/粉不变 seriesColors() { return getThemeId() === 'blue' ? ['#66c0f4', '#8b5cf6', '#22c55e', '#f59e0b', '#ec4899'] : ['#a78bfa', '#38bdf8', '#22c55e', '#f59e0b', '#ec4899']; }, //实例自增序列——渐变/clipPath id 防同页多图冲突 _seq: 0, _uid(type) { SGLVCharts._seq += 1; return `sglv-chart-${type}-${SGLVCharts._seq}`; }, // 通用 SVG 容器生成 svg(w, h, attrs = '') { return `<svg width="100%" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" ${attrs}>`; }, // 网格线(横向); fmt 可选: 刻度值→标签字符串, 默认原样数字(向后兼容) //minVal 域下限可选(价格历史图 y 域迁移引入); fmt 改收未取整刻度值(默认通道取整不变) //opts 可选(combo 引入): {labelFill 刻度文本色覆写, rightMax/rightFmt/rightLabelFill 右轴刻度} gridLinesY(pL, pR, pT, cH, maxVal, gridCount = 5, fmt, minVal = 0, opts = null) { const o = opts || {}; const labelFill = o.labelFill || SGLVCharts.PALETTE.textMuted; const rightFill = o.rightLabelFill || labelFill; const lines = []; const hasMin = Number.isFinite(minVal) && minVal !== 0; for (let i = 0; i <= gridCount; i++) { const raw = hasMin ? minVal + (maxVal - minVal) * i / gridCount : maxVal / gridCount * i; const y = pT + cH - (cH * i / gridCount); const label = typeof fmt === 'function' ? fmt(raw) : Math.round(raw); lines.push(`<line x1="${pL}" y1="${y}" x2="${pR}" y2="${y}" stroke="${SGLVCharts.PALETTE.bgGrid}" />`); lines.push(`<text x="${pL - 8}" y="${y + 4}" text-anchor="end" fill="${labelFill}" font-size="11">${label}</text>`); if (o.rightMax != null) { const raw2 = o.rightMax / gridCount * i; const label2 = typeof o.rightFmt === 'function' ? o.rightFmt(raw2) : Math.round(raw2); lines.push(`<text x="${pR + 8}" y="${y + 4}" text-anchor="start" fill="${rightFill}" font-size="11">${label2}</text>`); } } return lines.join(''); }, // 通用转义 esc(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }, // 16 进制 → rgba hexToRgba(hex, a) { const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); return `rgba(${r},${g},${b},${a})`; }, // 多档热力色阶(0..1 → 5 档) heatColor(r, hex = '#38bdf8') { if (r <= 0) return 'rgba(255,255,255,0.03)'; if (r < 0.2) return SGLVCharts.hexToRgba(hex, 0.18); if (r < 0.4) return SGLVCharts.hexToRgba(hex, 0.35); if (r < 0.6) return SGLVCharts.hexToRgba(hex, 0.55); if (r < 0.8) return SGLVCharts.hexToRgba(hex, 0.78); return hex; }, // 折线图(单/多序列) //扩展: area(渐变面积)/dots(数据点)/rangeButtons(区间按钮组)/ // title+yFormatter(轴与头部); 不传新 options 时保持默认输出(回归保护由单测固化)。 // 手法来源: area 渐变↔renderPlaytimeTrend(sglvTrendGradient 0.4→0.02), dots/rangeButtons↔ // renderPriceHistoryChart(sgis-chart-range-btn), 库保持无状态——按钮 data-range 由调用方绑事件。 //补充: // svgAttrs(svg 根属性透传)/gridCount(刻度数)/xLabelEvery+xLabelLast(末位标签)/ // dots.stroke+strokeWidth(描边)/annotations(refLine 参考线 + point 数据点标注) //补充: // xValues+xMinSpan(值域比例 x 轴,承接时间戳横轴与 86400000 跨度护栏)/yMin+yMax(y 域覆写)/ // strokeWidth+strokeRound(折线描边)/xLabelAt(指定索引标签)/dots.fill+dots.itemStyle(逐点样式)/ // wrapId(区间按钮容器 id) lineChart(series, options = {}) { const { width = 600, height = 240, pL = 40, pR = 16, pT = 16, pB = 28, smooth = true, colors = SGLVCharts.seriesColors(), area, dots, rangeButtons, activeRange, title, yFormatter, svgAttrs, gridCount = 5, xLabelEvery = 8, xLabelLast = false, } = options; if (!series || !series.length) return ''; // 双系列长度不一致: 以最短为准截断(warnOnMismatch:false 静默) const dataLens = series.map(s => (s && Array.isArray(s.data)) ? s.data.length : 0); const n = Math.min(...dataLens); if (new Set(dataLens).size > 1 && options.warnOnMismatch !== false) { console.warn('SGLVCharts.lineChart: series 数据长度不一致,已按最短截断', dataLens); } const norm = series.map(s => ({ ...s, data: (s && Array.isArray(s.data) ? s.data : []).slice(0, n) })); const allY = norm.flatMap(s => s.data.map(v => Number(v) || 0)); const maxV = Math.max(1, ...allY); const cW = width - pL - pR, cH = height - pT - pB; //y 域覆写(价格历史图 ±15% padding); 默认 0..maxV const yMin = Number.isFinite(options.yMin) ? options.yMin : 0; const yMax = Number.isFinite(options.yMax) ? options.yMax : maxV; const ySpan = Math.max(yMax - yMin, 1e-12); // n=1 且 centerSinglePoint:true 时数据点水平居中(对齐 renderPlaytimeTrend 原语义);默认靠左不变 const centered = n === 1 && options.centerSinglePoint === true; //xValues 提供时按值域比例映射 x(时间比例横轴, 长度不足 n 回退索引等距); // xMinSpan 最小跨度护栏(承接 86400000 单日语义); 默认索引等距 const xvSrc = Array.isArray(options.xValues) ? options.xValues : null; const xv = xvSrc && xvSrc.length >= n ? xvSrc.slice(0, n).map(Number) : null; let xOf; if (xv) { const x0 = xv[0], x1 = xv[xv.length - 1]; const xSpan = Math.max(x1 - x0, Number(options.xMinSpan) > 0 ? Number(options.xMinSpan) : 0, 1e-12); xOf = i => pL + ((xv[i] - x0) / xSpan) * cW; } else { const stepX = n > 1 ? cW / (n - 1) : 0; xOf = i => (centered ? pL + cW / 2 : pL + i * stepX); } const baseY = pT + cH; const uid = SGLVCharts._uid('line'); // area: true → 系列色默认渐变(op 0.4→0.02); {fill, opacity} 可覆写; 非法类型宽容忽略 const areaOn = area === true || (area !== null && typeof area === 'object'); const areaCfg = areaOn && area !== true ? area : {}; const topOp = areaCfg.opacity != null ? Number(areaCfg.opacity) : 0.4; // dots: 默认画 r=3 无 title; false 关闭; {r, title(v,i,sIdx), stroke, strokeWidth} 定制 //增补: fill(统一填充覆写)/itemStyle(v,i,sIdx)→{r?,fill?}(逐点样式, 最低价高亮迁移引入) const dotsOn = dots !== false; const dotsCfg = (dots && typeof dots === 'object') ? dots : {}; const dotR = dotsCfg.r != null ? dotsCfg.r : 3; const dotFill = typeof dotsCfg.fill === 'string' ? dotsCfg.fill : null; const dotStyle = typeof dotsCfg.itemStyle === 'function' ? dotsCfg.itemStyle : null; const dotTitle = typeof dotsCfg.title === 'function' ? dotsCfg.title : null; const dotStroke = dotsCfg.stroke ? ` stroke="${dotsCfg.stroke}" stroke-width="${dotsCfg.strokeWidth != null ? dotsCfg.strokeWidth : 1}"` : ''; // 渐变 stop: 系列色为 #rrggbb 时 hexToRgba 融合透明度,否则(rgba()/命名色)用 stop-opacity const stopOf = (col, a) => /^#[0-9a-f]{6}$/i.test(col) ? `stop-color="${SGLVCharts.hexToRgba(col, a)}"` : `stop-color="${col}" stop-opacity="${a}"`; const parts = []; const defs = []; const allPts = []; norm.forEach((s, idx) => { const col = s.color || colors[idx % colors.length]; const pts = s.data.map((v, i) => { const x = xOf(i); const y = pT + cH - ((Math.max(yMin, Number(v) || 0) - yMin) / ySpan) * cH; return [x, y]; }); allPts[idx] = pts; const d = pts.map((p, i) => (i === 0 ? 'M' : (smooth ? 'C' : 'L')) + p.join(' ')).join(' '); if (areaOn && pts.length) { const gid = `${uid}-g${idx}`; const fill = areaCfg.fill || col; defs.push(`<linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">` + `<stop offset="0%" ${stopOf(fill, topOp)}/>` + `<stop offset="100%" ${stopOf(fill, 0.02)}/></linearGradient>`); const areaD = d + ` L ${pts[pts.length - 1][0]} ${baseY} L ${pts[0][0]} ${baseY} Z`; parts.push(`<path d="${areaD}" fill="url(#${gid})" stroke="none"/>`); } //strokeWidth/strokeRound 可配; 默认 2px const strokeW = Number.isFinite(options.strokeWidth) ? options.strokeWidth : 2; const roundCap = options.strokeRound === true ? ' stroke-linejoin="round" stroke-linecap="round"' : ''; parts.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="${strokeW}"${roundCap} />`); // 端点圆 if (dotsOn) { pts.forEach(([x, y], i) => { const st = dotStyle ? (dotStyle(s.data[i], i, idx) || {}) : {}; const r = st.r != null ? st.r : dotR; const fill = st.fill || dotFill || col; const t = dotTitle ? dotTitle(s.data[i], i, idx) : null; parts.push(t ? `<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}"${dotStroke}><title>${SGLVCharts.esc(t)}` : ``); }); } }); // annotations: refLine 画在数据下层(参考线), point 画在数据上层(标注),文本均转义 const refLines = []; const pointAnns = []; if (Array.isArray(options.annotations)) { options.annotations.forEach(a => { if (!a || typeof a !== 'object') return; if (a.type === 'refLine') { const y = pT + cH - ((Math.max(yMin, Number(a.value) || 0) - yMin) / ySpan) * cH; refLines.push(``); if (a.label) { refLines.push(`${SGLVCharts.esc(a.label)}`); } } else if (a.type === 'point') { const pts = allPts[a.sIdx || 0] || []; const idx = Math.min(Math.max(0, a.idx | 0), pts.length - 1); const p = pts[idx]; if (!p) return; pointAnns.push(`${SGLVCharts.esc(a.text || '')}`); } }); } const defsHtml = defs.length ? `${defs.join('')}` : ''; let svg = SGLVCharts.svg(width, height, svgAttrs) + defsHtml + SGLVCharts.gridLinesY(pL, width - pR, pT, cH, yMax, gridCount, yFormatter, yMin) + refLines.join('') + parts.join(''); // X 轴标签(labelStep 除数与末位标签可配) //xLabelAt 指定索引渲染(价格历史图智能取位迁移引入); 位置一律走 xOf(时间比例轴兼容) const xLabels = options.xLabels || norm[0].data.map((_, i) => String(i)); const xl = []; const pushLbl = i => { if (!Number.isInteger(i) || i < 0 || i >= xLabels.length) return; xl.push(`${SGLVCharts.esc(xLabels[i])}`); }; if (Array.isArray(options.xLabelAt) && options.xLabelAt.length) { options.xLabelAt.forEach(i => pushLbl(Math.round(Number(i)))); } else { const labelStep = Math.max(1, Math.floor(xLabels.length / Math.max(1, xLabelEvery))); xLabels.forEach((lbl, i) => { if (i % labelStep !== 0 && !(xLabelLast && i === xLabels.length - 1)) return; pushLbl(i); }); } svg += xl.join('') + pointAnns.join('') + ''; // rangeButtons: 仅产出按钮 DOM(data-range),点击行为由调用方绑定(库无状态) if (Array.isArray(rangeButtons) && rangeButtons.length) { const btns = rangeButtons.map(r => `` ).join(''); const titleHtml = title ? `${SGLVCharts.esc(title)}` : ''; const wrapAttr = options.wrapId ? ` id="${SGLVCharts.esc(options.wrapId)}"` : ''; return `
${titleHtml}
${btns}
${svg}
`; } return svg; }, // 堆叠条形图 stackedBar(categories, series, options = {}) { const { width = 600, height = 240, pL = 40, pR = 16, pT = 20, pB = 40, colors = SGLVCharts.seriesColors() } = options; if (!categories.length) return ''; const totals = categories.map((_, i) => series.reduce((s, ser) => s + (Number(ser.data[i]) || 0), 0)); const maxV = Math.max(1, ...totals); const cW = width - pL - pR, cH = height - pT - pB; const gap = cW / categories.length; const barW = Math.min(Math.max(gap * 0.6, 16), 40); let svg = SGLVCharts.svg(width, height) + SGLVCharts.gridLinesY(pL, width - pR, pT, cH, maxV); categories.forEach((cat, i) => { const cx = pL + gap * i + gap / 2; let yAcc = pT + cH; series.forEach((ser, sIdx) => { const v = Number(ser.data[i]) || 0; if (v <= 0) return; const h = (v / maxV) * cH; yAcc -= h; const col = ser.color || colors[sIdx % colors.length]; svg += ``; }); svg += `${SGLVCharts.esc(cat)}`; }); return svg + ''; }, // 圆环/饼图 //补齐手写 renderPlaytimeDonut 迁移缺口(全可选,默认形态不变): // bg=false 关背景环 / title(sl,i) 每段 (esc 转义) / segStyle 段 circle style 透传 / // center {text,sub,fontSize,weight,fill,y,subFontSize,subFill,subY} 双行中心文本 / // legend {value(sl)} 图例行 / wrap=true 产出 sglv-donut-wrap 布局 donutChart(slices, options = {}) { const { size = 160, thickness = 22, centerText = '', bg = true, title = null, segStyle = '', center = null, legend = null, wrap = false, // v2.32.0 内环(双环对照):outer = 主数据,inner = 参照数据 // 用途:年代分布要同时呈现「Steam 可用部分」与「HLTB 全量参考」, // 单环会掩盖样本偏差(实测最大偏 37.7pt)→ 双环让差异肉眼可见。 inner = null, innerGap = 6, } = options; const total = slices.reduce((s, sl) => s + (sl.value || 0), 0); if (total <= 0) return ''; const r = size / 2 - thickness / 2; const cx = size / 2, cy = size / 2; const C = 2 * Math.PI * r; let acc = 0; let svg = SGLVCharts.svg(size, size); // 背景环 if (bg) svg += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${SGLVCharts.PALETTE.bgCard}" stroke-width="${thickness}" />`; slices.forEach((sl, i) => { if (!sl.value) return; const len = (sl.value / total) * C; const offset = -acc; const t = title ? title(sl, i) : null; const attrs = `cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${sl.color}" stroke-width="${thickness}" stroke-dasharray="${len} ${C - len}" stroke-dashoffset="${offset}" transform="rotate(-90 ${cx} ${cy})"${segStyle ? ` style="${segStyle}"` : ''}`; if (t) svg += `<circle ${attrs}><title>${SGLVCharts.esc(t)}
`; else svg += ``; acc += len; }); // v2.32.0 内环:半径 = 外环半径 - 外环厚度 - 间隙 if (inner && Array.isArray(inner.slices) && inner.slices.length) { const ir = r - thickness - innerGap; if (ir > 12) { const ith = Math.min(inner.thickness || Math.max(8, Math.round(thickness * 0.6)), ir); const ir2 = ir - ith / 2; const iC = 2 * Math.PI * ir2; const iTotal = inner.slices.reduce((s2, sl) => s2 + (sl.value || 0), 0); if (iTotal > 0) { let iacc = 0; if (inner.bg !== false) { svg += ``; } inner.slices.forEach((sl, i) => { if (!sl.value) return; const len = (sl.value / iTotal) * iC; const off = -iacc; const t = inner.title ? inner.title(sl, i) : (title ? title(sl, i) : null); const attrs = `cx="${cx}" cy="${cy}" r="${ir2}" fill="none" stroke="${sl.color}" stroke-width="${ith}" stroke-dasharray="${len} ${iC - len}" stroke-dashoffset="${off}" transform="rotate(-90 ${cx} ${cy})"${segStyle ? ` style="${segStyle}"` : ''}`; if (t) svg += `${SGLVCharts.esc(t)}`; else svg += ``; iacc += len; }); } } } if (center) { const { text = '', sub = '', fontSize = 13, weight = 600, fill = SGLVCharts.PALETTE.text, y = cy + 4, subFontSize = 11, subFill = SGLVCharts.PALETTE.textMuted, subY = y + 14, } = center; if (text) svg += `${SGLVCharts.esc(text)}`; if (sub) svg += `${SGLVCharts.esc(sub)}`; } else if (centerText) { svg += `${SGLVCharts.esc(centerText)}`; } svg += ''; let legendHtml = ''; if (legend) { legendHtml = `
` + slices.filter(sl => sl.value > 0).map(sl => `
${SGLVCharts.esc(sl.label || '')}${legend.value ? legend.value(sl) : SGLVCharts.esc(sl.value)}
` ).join('') + '
'; } if (wrap) { return `
${svg}
${legendHtml}
`; } return svg + legendHtml; }, // 热力图(行 × 列)——「矩阵热力图」: 数值矩阵 + 连续单色色阶(heatColor 插值) + 静态轴标签。 //分工边界: 与 renderAcquiredHeatmap(GitHub 风格「日历热力图」: // 日期驱动周网格/离散5级绿阶+未来占位色/月边界动态标注+年份分隔线)为两种不同图表形态, // 五维能力矩阵核查判定非重复实现,互不收编——勿再以"图表重复"为由合并(矩阵记录见 TASK-007 PRD)。 heatmap(matrix, options = {}) { const { cellSize = 18, gap = 3, xLabels = [], yLabels = [], color = '#38bdf8' } = options; const rows = matrix.length, cols = matrix[0]?.length || 0; if (!rows || !cols) return ''; const w = (xLabels.length ? 30 : 0) + cols * (cellSize + gap) - gap + 6; const h = (yLabels.length ? 16 : 0) + rows * (cellSize + gap) - gap + 4; let maxV = 0; matrix.forEach(r => r.forEach(v => { if (v > maxV) maxV = v; })); let svg = SGLVCharts.svg(w, h); matrix.forEach((row, ri) => { row.forEach((v, ci) => { const x = (xLabels.length ? 30 : 0) + ci * (cellSize + gap); const y = (yLabels.length ? 16 : 0) + ri * (cellSize + gap); const r = maxV > 0 ? v / maxV : 0; svg += `${SGLVCharts.esc(v)}`; }); }); if (xLabels.length) { const step = Math.max(1, Math.floor(xLabels.length / 12)); xLabels.forEach((lbl, i) => { if (i % step !== 0) return; const x = 30 + i * (cellSize + gap) + cellSize / 2; svg += `${SGLVCharts.esc(lbl)}`; }); } if (yLabels.length) { yLabels.forEach((lbl, ri) => { const y = 16 + ri * (cellSize + gap) + cellSize / 2 + 3; svg += `${SGLVCharts.esc(lbl)}`; }); } return svg + ''; }, //柱+折线组合图——趋势 Tab 年度组合图(renderTrendChart)收编。 // 双 y 轴(左轴=柱、右轴=线共用同一组网格线); 柱几何(v→高度)由库按 leftMax 计算, // x 偏移/宽度属业务布局规则留在调用方 bars 回调; 系列按 rightAxis 缩放。 comboChart(cfg = {}) { const { width = 800, height = 440, pL = 52, pR = 62, pT = 20, pB = 50, n = 0, leftMax = 1, rightMax = null, gridCount = 5, labelFill = '#8a9ba8', bars = null, // (i, cx, gap) => [{ v, x, w, fill, rx=3, cls, opacity, attrs }] — v 按左轴 series = [], // [{ data, stroke, strokeWidth=2.5, polyAttrs, dots:{r=3, fill=stroke, attrs} }] — 按右轴 xLabel = null, // (i, cx) => 文本|null baseStroke = 'rgba(255,255,255,.13)', svgAttrs = '', } = cfg; const cW = width - pL - pR, cH = height - pT - pB; const gap = n > 0 ? cW / n : 0; const cxOf = i => pL + gap * i + gap / 2; let out = SGLVCharts.gridLinesY(pL, width - pR, pT, cH, leftMax, gridCount, undefined, 0, { labelFill, rightMax, rightLabelFill: labelFill }); if (typeof bars === 'function') { for (let i = 0; i < n; i++) { (bars(i, cxOf(i), gap) || []).forEach(b => { if (!b) return; const h = leftMax ? (b.v / leftMax) * cH : 0; const y = pT + cH - h; out += ``; }); } } series.forEach(s => { const data = s.data || []; if (!data.length) return; const sw = s.strokeWidth != null ? s.strokeWidth : 2.5; const pts = data.map((v, i) => `${cxOf(i)},${pT + cH - (rightMax ? (v / rightMax) * cH : 0)}`).join(' '); out += ``; const dots = s.dots; if (dots) { const r = dots.r != null ? dots.r : 3; const fill = dots.fill || s.stroke; const a = dots.attrs; data.forEach((v, i) => { // 闭合符随有无 attrs 切换: (成员点 opacity=".8"/> 无空格) out += `` : ' />'}`; }); } }); if (typeof xLabel === 'function') { for (let i = 0; i < n; i++) { const t = xLabel(i, cxOf(i)); if (t != null && t !== '') out += `${t}`; } } out += ``; return `${out}`; } }; //暴露给 SGIS / Collection 库复用 unsafeWindow.SGLVCharts = SGLVCharts; // SGLVCharts 已整体上移至 PTW_HOST 之前(PTW_HOST 的 SGLVCharts/accent 成员会急切求值, // 声明留在后段会命中 TDZ → ReferenceError → 主 IIFE 崩溃、UI 全不加载)。 // ==================== PTW_HOST 游戏数据浮窗桥接对象 ==================== // PTW = PlayTimeWindow,游戏数据浮窗框架(sglv-ptw-framework.lib.js)的唯一数据入口。 // // 【设计约束 · 勿改】 // ① 本对象只做「转发」,不含任何业务逻辑 —— 主脚本借 PTW 实现的瘦身。 // ② SGIS 闭包能力一律经 SGLV_API 中转并用 `?.` + `typeof` 双重防御。 // 历史事故:getGameIconUrl(:19440) / xpPercent —— 均为裸引用他层闭包导致 ReferenceError。 // 本处所有 SGIS 来源均为可选调用,SGIS 闭包未就绪时返回 undefined 而非抛错。 // ③ services 层的取数函数**无内部限流保护**,Tab 库必须用 sglvSmartPool 约束并发 ≤3。 // ④ apiVersion 与框架库握手校验,不匹配即拒绝 init(框架侧降级提示)。 // ⑤ 新增 Tab 一律在此挂能力,禁止让 Tab 直连 SGLV_API 或主 IIFE 内部变量。 // ═══ v2.32.0 HLTB 索引加载器(发行年份 + 剧情时长)═══ // ⚠️ 刻意放在 PTW_HOST **之外**:PTW_HOST 只引用主 IIFE 内部定义的符号 // (框架纪律:桥接层不直接依赖 GM API)。GM_xmlhttpRequest / GM_getValue // 在这里用是安全的 —— 主脚本全篇都是这么调的。 // PTW_HOST 内只做「hltb: PTW_HLTB」的暴露。 const PTW_HLTB = (function () { const DEFAULT_BASE = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data'; // v2.34.2 多源回落链:cdn.jsdelivr.net 在部分网络(如大陆)被阻断(SSL 握手失败), // 而同仓镜像 fastly/gcore 通常可达。逐源用 manifest 探测,成功源固定给本次会话。 // GM 值 'sglv_hltb_base' 仍是最优先(用户显式换源/自建镜像)。 const FALLBACK_BASES = [ 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data', 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data', 'https://gcore.jsdelivr.net/gh/SmallRob/steam-namespace@main/data', ]; const state = { ready: false, loading: false, err: '', shards: [], // [{ids:Int32Array, years:Int32Array, story:Float64Array}] reference: null, // 参考分布(人口基准) base: '', // v2.34.2 部分成功诊断:missingShards=缺失分片序号;lastProbe=各源探测结果 missingShards: [], lastProbe: [], }; const shardCount = 8; function base() { try { return String(GM_getValue('sglv_hltb_base', '') || DEFAULT_BASE).replace(/\/+$/, ''); } catch (e) { return DEFAULT_BASE; } } function fetchJson(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 20000, onload: (r) => { if (r.status < 200 || r.status >= 300) { reject(new Error('HTTP ' + r.status)); return; } try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } /** 逐源探测 manifest,返回 { base, manifest };全失败抛出最后一次错误 */ async function probeBase() { const candidates = []; const gmBase = base(); // GM 显式配置排在最前;去重避免重复探测 for (const b of [gmBase, ...FALLBACK_BASES]) { if (!candidates.includes(b)) candidates.push(b); } state.lastProbe = []; let lastErr = new Error('无可用源'); for (const b of candidates) { try { const manifest = await fetchJson(b + '/hltb-index-v1.manifest.json'); if (!manifest || !manifest.shards) throw new Error('manifest 格式异常'); state.lastProbe.push({ base: b, ok: true }); return { base: b, manifest }; } catch (e) { state.lastProbe.push({ base: b, ok: false, err: e.message || String(e) }); lastErr = e; } } throw lastErr; } async function load() { if (state.ready) return true; if (state.loading) return false; state.loading = true; state.err = ''; try { // ① 探测可用源(v2.34.2:不再写死单一 CDN) const { base: okBase, manifest } = await probeBase(); state.base = okBase; // ② 参考分布(失败不阻断主索引) try { const ref = await fetchJson(state.base + '/hltb-index-v1.reference.json'); state.reference = ref; } catch (e) { console.warn('[SGLV-HLTB] 参考分布加载失败(年代图将只显示匹配部分)', e.message); } // 数据新鲜度提示:manifest.builtAt 是构建时刻,超过 30 天说明索引陈旧 // ⚠️ jsDelivr 对 @main 分支有最长 12h 的 CDN 缓存;索引更新后可能拉到旧版。 // 需要立即生效时把 GM 值 'sglv_hltb_base' 指向 commit SHA 或 tag 的固定 URL。 if (manifest.builtAt) { const ageDays = (Date.now() - Date.parse(manifest.builtAt)) / 86400000; if (ageDays > 30) { console.warn('[SGLV-HLTB] 索引已 ' + ageDays.toFixed(0) + ' 天未更新(builtAt=' + manifest.builtAt + ')'); } } // ③ 并行拉取分片 + 失败片重试一轮(v2.34.2:部分成功不再全盘失败) async function fetchShard(i) { const url = state.base + '/hltb-index-v1.' + i + '.json'; try { return await fetchJson(url); } catch (e) { return fetchJson(url); } // 单片重试一次 } const settled = await Promise.allSettled( Array.from({ length: shardCount }, (_, i) => fetchShard(i)) ); const shards = []; const missing = []; settled.forEach((r, i) => { if (r.status === 'fulfilled') { const f = r.value; shards[i] = { // ⚠️ story 必须用 Float64Array,不能用 Float32Array: // Float32 精度约 7 位有效数字,3.12 会被存成 3.119999885559082。 // 实测端到端 16,439/35,272 条与构建产物比对"不一致"(全是精度问题), // 且 SCI 分档边界(如 0.35)会因此错档 → 必须保 float64。 // 内存代价:35,272 × 8B = 282KB,相对 141KB 可忽略。 ids: Int32Array.from(f.ids || []), years: Int32Array.from(f.years || []), story: Float64Array.from(f.story || []), }; } else { shards[i] = null; // 缺失片的 appid 查询自然返回 null missing.push(i); } }); if (missing.length === shardCount) throw new Error('全部分片拉取失败(' + (settled[0] && settled[0].reason && settled[0].reason.message || '未知') + ')'); if (missing.length) { console.warn('[SGLV-HLTB] 分片部分缺失,缺失片内的游戏将查不到剧情时长:', missing.join(',')); } state.shards = shards; state.missingShards = missing; state.ready = true; state.loading = false; const entries = shards.reduce((s, sh) => s + (sh ? sh.ids.length : 0), 0); console.log('[SGLV-HLTB] 索引就绪(源 ' + state.base.replace(/^https:\/\//, '').split('/')[0] + '):' + entries + ' 条 / ' + (shardCount - missing.length) + '/' + shardCount + ' 片' + (missing.length ? '(缺 ' + missing.join(',') + ')' : '')); return true; } catch (e) { state.err = e.message || String(e); state.loading = false; console.warn('[SGLV-HLTB] 索引加载失败(库分析/剧情完成度降级)', state.err, '探测结果:', JSON.stringify(state.lastProbe)); return false; } } // 分片键与构建脚本一致:shard = (appid >> 1) % 8 // ⚠️ Steam appid 99.90% 是偶数,用 appid % 8 会让一半分片永远为空。 function get(appid) { if (!state.ready) return null; const id = Number(appid); if (!Number.isFinite(id)) return null; const sh = state.shards[(id >> 1) % shardCount]; if (!sh || !sh.ids.length) return null; let lo = 0, hi = sh.ids.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (sh.ids[mid] === id) return { y: sh.years[mid], m: sh.story[mid] }; if (sh.ids[mid] < id) lo = mid + 1; else hi = mid - 1; } return null; } return { isReady: () => state.ready, isLoading: () => state.loading, error: () => state.err, load, get, reference: () => state.reference, stats: () => ({ ready: state.ready, loading: state.loading, err: state.err, entries: state.shards.reduce((s, sh) => s + (sh ? sh.ids.length : 0), 0), hasReference: !!state.reference, // v2.34.2 部分成功诊断 missingShards: state.missingShards.slice(), probe: state.lastProbe.slice(), }), }; })(); const PTW_HOST = { apiVersion: 1, // ==================== 一、数据访问(只读) ==================== // ---- 主 IIFE 原生能力(直连,零风险)---- //getOwnedGames 语义修正——原实现返回未过滤的全库, // 而 renderPlaydataTab / openGameDataWindow 都要按 show_family_shared // 过滤后使用。Tab 库若直连全库,会与迁移前口径不一致(多算家庭共享游戏)。 // 改为返回「口径正确」的游戏集,并保留 rawOwnedGames 供需要全库的调用方。 getOwnedGames() { const all = state.ownedGames || []; const showFamilyShared = storage.getShowFamilyShared(); return showFamilyShared ? all.slice() : all.filter(g => isGameOwnedByMe(g)); }, rawOwnedGames: () => state.ownedGames, getActiveSteamId, isGameOwnedByMe, getShowFamilyShared: () => storage.getShowFamilyShared(), getPlaytimeSnapshot: () => ptSnapLoad(), applySnapshotToGames: ptSnapApplyToGames, // ---- 每日快照历史(数据层职责,留主脚本)---- // 与 familyCache 同理:Tab 库只调collect/load,不自行持久化,保持格式单点维护。 playtimeHistory: { /** 采集今日快照(跨日才写;并发去重;滚动 90 天) */ collect: (games) => ptHistoryCollect(games), /** 读取原始历史 { v, days: { 'YYYY-MM-DD': { appid: minutes } } } */ load: () => ptHistoryLoad(), /** 采样日键,升序(旧 → 新) */ days: () => Object.keys(ptHistoryLoad().days).sort(), dayKey: (ts) => ptDayKey(ts), maxDays: PT_HISTORY_DAYS, /** 设置卡「清除历史」入口(同步重置内存镜像 + IDB) */ clear: () => { _ptHistory = { v: 1, days: {} }; try { sglvIDB.set(PT_HISTORY_KEY, _ptHistory); } catch (e) { /* quota */ } return true; }, }, getAcquiredMap: (force) => ptGetAcquiredMap(force), refreshPlaytime: (force) => ptRefreshPlaytime(force), // ---- v2.32.0 HLTB 索引(发行年份 + 剧情时长)---- // ⚠️ 刻意**不用 @resource**:索引文件尚未托管到 jsDelivr 时,@resource 拉取失败会 // 连带影响整个脚本的元数据解析。改走 GM_xmlhttpRequest 远程加载 + 失败静默降级, // 「库分析」Tab 检测不到就整块隐藏。 // 配置:GM 值 'sglv_hltb_base' 可覆盖 CDN 根路径(便于自建镜像 / 换源)。 // 加载器实现见 PTW_HLTB(主 IIFE 顶层,PTW_HOST 只做暴露) hltb: PTW_HLTB, getFamilyInfo: () => fetchFamilyInfo(), computePlaytimeStats, computeWishlistStats, // ---- 总览 Tab 依赖(探测发现,原契约未登记)---- kpiCards, ptPlaytimeMedian, fetchRecentPlayedGames: () => _fetchRecentPlayedGames(), // ---- v2.32.0「游玩分析」Tab 依赖(原契约未登记,Tab 拿不到)---- // ① calcPlaytimeDist:时长分档 donut 的数据源(count ⇄ hours 双模)。 // 早已实现(:28137)但从未挂到 host → Tab 库无法调用,一直是「写了没用」。 calcPlaytimeDist: (games, mode) => calcPlaytimeDist(games, mode), // ② isDlc:库分析/潜力清单算「大型游戏数」必须用它过滤 DLC, // 否则与 computePlaytimeStats 的口径不一致(后者内部已过滤)。 // 挂在 SGLV_API 上(:22700),此前 PTW 侧够不着。 isDlc: (appid) => (SGLV_API && typeof SGLV_API.isDlc === 'function') ? SGLV_API.isDlc(appid) : false, // ③ computeInsightData:5 维画像(收藏广度/沉浸深度/完成度/偏好多样性/活跃度) // + 玩家画像(类型/稀有度/标签)。走 getBizCached 持久缓存,避免每次重算。 computeInsightData: () => (typeof computeInsightDataCachedSgis === 'function' ? computeInsightDataCachedSgis() : null), // ---- 家庭成员缓存(数据层职责,留在主脚本;Tab 库只调 load/save/isStale)---- // 设计:避免 Tab 库各自实现持久化,保持缓存格式单点维护。 familyCache: { get data() { ptFamLoad(); return _ptFamCache; }, load() { ptFamLoad(); }, save() { ptFamSave(_ptFamCache); }, isStale: (rec) => ptFamIsStale(rec), }, // ---- 家庭组持久化读写 ---- // ⚠️ 修复的真实缺陷:sglv-ptw-tab-family 的 requires 声明了 'storage', // 但 PTW_HOST 从未挂出 storage → H.storage 为 undefined, // Tab 内 `H.storage.getFamilyInfo()` 抛 TypeError。 // 由于该调用在 async loadFamily 里且被 try/catch 包裹, // 冒烟的同步断言抓不到 → 家庭对比 Tab 一直静默空白(家庭组数据永远拉不到)。 // 这里按最小必要面挂出:Tab 只用到这3 个方法,不整体暴露 storage。 storage: { getApiKey: () => storage.getApiKey(), getFamilyInfo: () => storage.getFamilyInfo(), setFamilyInfo: (v) => storage.setFamilyInfo(v), }, // 带 access_token 的家庭组实时拉取(fetchFamilyInfo 本身需要 token 实参) fetchFamilyInfo: (authToken) => fetchFamilyInfo(authToken), // ---- SGIS 闭包能力(经 SGLV_API 中转 + 防御)---- // 成就 getPlayerAchievements: (appId) => SGLV_API.fetchPlayerAchievements?.(appId), getGlobalAchievements: (appId) => SGLV_API.fetchGlobalAchievements?.(appId), // 评测 getReviews: (appId) => SGLV_API.fetchReviews?.(appId), getReviewSummary: (appId) => SGLV_API.fetchReviewSummary?.(appId), // 元数据 getAppDetailsExtra: (appId) => SGLV_API.fetchAppDetailsExtra?.(appId), getDlcNames: (appId) => SGLV_API.fetchDlcNames?.(appId), getGameNews: (appId) => SGLV_API.fetchGameNews?.(appId), getFamilyShareSupport: (appId) => SGLV_API.fetchFamilyShareSupport?.(appId), getGameStatus: (appId) => SGLV_API.fetchGameStatus?.(appId), // 价格 getMultiRegionPrices: (appId) => SGLV_API.fetchMultiRegionPrices?.(appId), getSteamRegionPrice: (appId) => SGLV_API.fetchSteamRegionPrice?.(appId), getAugRegionPrice: (appId) => SGLV_API.fetchAugRegionPrice?.(appId), getHistoryPrices: (appId) => SGLV_API.fetchHistoryPrices?.(appId), getHistoryPricesCheapShark: (appId) => SGLV_API.fetchHistoryPricesCheapShark?.(appId), refreshRates: () => SGLV_API.refreshRates?.(), // ---- v2.37.0「库价行情」Tab 依赖:ITAD 批量价格原语(白名单代理)---- // ⚠️ 刻意**不挂整包 window.SGLVITAD**: // ① 整包暴露会让 Tab 摸到 fetchPriceHistory(逐游戏 GET /games/history/v2)。 // 对 2000 款库逐个拉历史既慢又必然撞 ITAD 限额(5 分钟 1000 次)—— // 库价行情的曲线一律靠「每天批量扫一次全库 → 本地日快照」自累积。 // ② 这里只代理两个**批量**端点 + 能力自检,Tab 拿不到逐游戏接口。 // ⚠️ 这几个成员在 ITAD 库未加载时**依然存在**(available() 返回 false / 返回空结果), // 所以 Tab 能正常注册并给出「价格服务不可用」的明确说明,而不是整页签凭空消失。 // (若挂进 requires 且库缺失 → Tab 按钮不渲染,用户完全无从判断发生了什么。) itad: { available: () => !!(window.SGLVITAD && typeof window.SGLVITAD.fetchPriceOverview === 'function'), maxBatch: () => (window.SGLVITAD && window.SGLVITAD.MAX_BATCH) || 200, lookupItadIds: (appIds) => (window.SGLVITAD && typeof window.SGLVITAD.lookupItadIds === 'function') ? window.SGLVITAD.lookupItadIds(appIds) : Promise.resolve({}), fetchPriceOverview: (itadIds, opts) => (window.SGLVITAD && typeof window.SGLVITAD.fetchPriceOverview === 'function') ? window.SGLVITAD.fetchPriceOverview(itadIds, opts) : Promise.resolve(null), fetchPrices: (itadIds, opts) => (window.SGLVITAD && typeof window.SGLVITAD.fetchPrices === 'function') ? window.SGLVITAD.fetchPrices(itadIds, opts) : Promise.resolve(null), }, // ---- v2.37.0 Steam 区域探测(cookie steamCountry,缺失回落 CN)---- // 主脚本内已有两处同源的内联 cookie 读取(入库趋势 / 价值统计); // 这里挂成 host 能力,避免 Tab 自己摸 document.cookie(沙箱内不存在)。 getUserSteamCountry: () => { try { const m = document.cookie.match(/steamCountry=(\w{2})/); return (m && m[1]) ? String(m[1]).toUpperCase() : 'CN'; } catch (e) { return 'CN'; } }, // 徽章 / 卡牌 //修复「配置了 API Key 仍提示需要 Key」——fetchUserBadges(steamId) 需要 // steamId 实参,原桥接无参调用 → !steamId 恒返回 null → 价值 Tab 恒走无 Key 分支 getUserBadges: (steamId) => SGLV_API.fetchUserBadges?.(steamId || getActiveSteamId()), getBadgeValueScores: (badges, markets) => SGLV_API.computeBadgeValueScores?.(badges, markets), //契约修正:底层 fetchBadgeMarkets(appIds, opts) 首行即 appIds.slice(0, maxApps), // 只接受数组;原桥接参数名单写 appId,直传单值会抛 TypeError。 // 这里做双向兼容归一:单值包成数组,数组透传,缺省值返回 {}。 getBadgeMarkets: (appIds, opts) => { const list = Array.isArray(appIds) ? appIds : (appIds == null ? [] : [appIds]); if (!list.length) return Promise.resolve({}); return SGLV_API.fetchBadgeMarkets?.(list, opts) || Promise.resolve({}); }, getCardPrices: (appId) => SGLV_API.fetchCardPrices?.(appId), loadCardDatabase: () => SGLV_API.loadCardDatabase?.(), // 社交 / 玩法数据 getSteamLevel: (steamid) => SGLV_API.fetchSteamLevel?.(steamid), getCurrentPlayers: (appId) => SGLV_API.fetchCurrentPlayers?.(appId), getOwnersPlaytime: (appId) => SGLV_API.fetchOwnersPlaytime?.(appId), getTagRecommendations: (appId) => SGLV_API.fetchTagRecommendations?.(appId), getProfileFeaturesStatus: () => SGLV_API.fetchProfileFeaturesStatus?.(), // ==================== 二、services(新增 Tab 自取数据) ==================== // ⚠️ 全部无内部限流保护 → Tab 库必须 sglvSmartPool 并发 ≤3 // ⚠️ 各源 TTL 建议见 ptw-host-contract.v1.json services.cache services: { // v2.42.2:原先用 fetchJson —— 它定义在 PTW_HLTB 子闭包内, // PTW_HOST 所在作用域取不到 → ReferenceError(调用即炸)。 // 改用主闭包顶层的 sglvGmFetchRetry(= C.gmFetchJson),语义等价。 getAppDetails: (appId) => sglvGmFetchRetry( `https://store.steampowered.com/api/appdetails?appids=${appId}&l=schinese&filters=categories`, { timeout: 15000 } ), getStoreReviews: (appId) => sglvGmFetchRetry( `https://store.steampowered.com/appreviews/${appId}?json=1&filter=summary&language=schinese&purchase_type=all&day_range=999999`, { timeout: 12000 } ), getGlobalAchvPercents: (appId) => sglvGmFetchRetry( `https://api.steampowered.com/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v0002/?gameid=${appId}`, { timeout: 15000 } ), getAugmentedAppData: (appId) => sglvGmFetchRetry( `https://api.augmentedsteam.com/app/${appId}/v2`, { timeout: 12000 } ), getSimilarGames: (appId) => sglvGmFetchRetry( `https://api.augmentedsteam.com/similar/${appId}/v2?count=15`, { timeout: 12000 } ), getCheapSharkDeals: (appId) => sglvGmFetchRetry( `https://www.cheapshark.com/api/1.0/games?steamAppID=${appId}`, { timeout: 12000 } ), }, // ==================== 三、工具 ==================== h, escHtml, T, ICONS, isZh, SGLVCharts, showToast: sglvToast, cacheGet, cacheSet, cacheDelete, cacheTTL: CACHE_TTL, getCached, cacheKey, getGameIconUrl, getPosterGood, formatLastPlayedShort, renderPagination, paginate, buildListToolbar, hbarRow, createSkeleton, emptyHtml, sglvSmartPool, applyGameAliases, getAccessToken, requestSteamAPI, storage, // ---- 拼音搜索 ---- // 委托既有 sglv-pinyin.lib.js(@require),主脚本不重复实现查表逻辑。 // 契约: // toPinyinAbbr(name) → 'csgo'(中文取首字母;英文/数字直通;空格标点保留) // matchQuery(name, q) → 归一化后的「子串 OR 拼音首字母子串」双向匹配结果 // 库不可用时 matchQuery 退化为纯英文子串匹配(软降级,不阻断搜索)。 _getPinyinLib: () => _getPinyinLib(), toPinyinAbbr: (name) => { const lib = _getPinyinLib(); try { return lib && typeof lib.toPinyinAbbr === 'function' ? lib.toPinyinAbbr(String(name || '')) : String(name || '').toLowerCase(); } catch (e) { return String(name || '').toLowerCase(); } }, /** 搜索统一入口:中文名 / 英文名 / 拼音首字母 三路匹配 */ matchGameName(name, query) { const q = String(query == null ? '' : query).trim().toLowerCase(); if (!q) return true; const raw = String(name == null ? '' : name); const lower = raw.toLowerCase(); if (lower.includes(q)) return true; // 拼音首字母匹配(如 "cs" 命中 "传送门") const py = this.toPinyinAbbr(raw); if (py && py.toLowerCase().includes(q)) return true; return false; }, // ==================== 四、主题 ==================== getThemeId, applySglvTheme, getThemes: () => SGLV_THEMES, accent: SGLVCharts?.accent, // ==================== 五、能力自检 ==================== /** * Tab 库注册与用户排障共用。列出 PTW_HOST 全部成员的值类型, * 值为 function 的标记 `fn`,SGIS 来源标记 `sgis`(可能为 undefined)。 */ _inspect() { const out = {}; const mark = (v) => (typeof v === 'function' ? 'fn' : typeof v); const walk = (obj, prefix) => { for (const k of Object.keys(obj)) { if (k.startsWith('_') || k === 'apiVersion') continue; //读取成员值加防御——任何成员命中 TDZ / 抛错时标记 unavailable, // 不允许诊断函数自身炸掉主 IIFE(历史事故:PT_HISTORY_DAYS TDZ) let v; try { v = obj[k]; } catch (e) { out[prefix + k] = 'unavailable'; continue; } if (typeof v === 'function') out[prefix + k] = 'fn'; else if (v && typeof v === 'object' && !Array.isArray(v)) walk(v, prefix + k + '.'); else out[prefix + k] = mark(v); } }; walk(this, ''); return out; }, }; // ---- 能力探测(供框架与设置面板诊断)---- const _ptwInspect = PTW_HOST._inspect(); const _ptwFnCount = Object.values(_ptwInspect).filter(v => v === 'fn').length; // ---- 框架库握手(起 window.SGLVPTW 存在)---- let _ptwReady = false; if (window.SGLVPTW && typeof window.SGLVPTW.init === 'function') { _ptwReady = window.SGLVPTW.init(PTW_HOST); if (!_ptwReady) { console.warn('[PTW] 浮窗框架握手失败 (apiVersion 不匹配)'); } else { console.log(`[PTW] 浮窗框架已就绪 (apiVersion=1, 宿主能力 ${_ptwFnCount} 项)`); } } else { console.warn('[PTW] 浮窗框架库未加载 — 游戏数据浮窗不可用 (请检查 @require 或 Tampermonkey 外部资源设置)'); } // 回写握手结果,供 SGIS / 设置面板判定浮窗可用性(无法访问本闭包 _ptwReady) if (window.SGLVPTW) window.SGLVPTW._hostReady = _ptwReady; //浮窗隐藏「总览」Tab——内容与主面板「游玩数据」页完全重复 //(两者共用 SGLVPTW.mountInto('overview') 同一份渲染实现,库仍需 @require 加载)。 // hideTab 只影响浮窗 Tab 栏与 view 恢复,mountInto 主面板路径不受影响。 try { if (window.SGLVPTW && typeof window.SGLVPTW.hideTab === 'function') { window.SGLVPTW.hideTab('overview', true); } else { console.warn('[PTW] 框架库版本过旧,无 hideTab——浮窗「总览」Tab 未隐藏(不影响功能)'); } } catch (e) { /* 隐藏失败不阻断 */ } //PTW 面板标题/状态图标尺寸约束(覆盖层,随主脚本走)—— // ICONS 系列 SVG 仅有 viewBox 无 width/height,默认 width:100% 撑满整行; // 除总览 Tab(复用 .sglv-playtime-section-title,共享 CSS 已约束)外, // 其余 Tab 库的面板标题类均无 svg 尺寸规则 → 成就/价格/价值/足迹/日历/家庭图标全部炸开。 // 此处统一约束;stroke/fill 等外观属性已内联在 ICONS 的 ${_S} 中,无需重复声明。 // ★v2.42.0 补 .sglv-pt-quote-panel-title:库价行情 Tab(v2.37.0 新增)建好后没被加进这份名单, // 结果曲线面板标题的折线图标被撑成 691×691(实测),整块版面变成一条巨大的「M」。 // ——新 Tab 上线时,记得把它的面板标题类补进这份名单。 try { GM_addStyle(` .sglv-ptw-ach-panel-title svg,.sglv-ptw-price-panel-title svg,.sglv-ptw-value-panel-title svg, .sglv-ptw-strk-panel-title svg,.sglv-ptcal-tl-title svg,.sglv-fam-title>svg,.sglv-fam-refresh svg, .sglv-pt-quote-panel-title svg, .sglv-ptwin-open svg{width:14px;height:14px;flex-shrink:0;} .sglv-ptw-ach-state-icon svg,.sglv-ptw-price-state-icon svg{width:34px;height:34px;flex-shrink:0;} /* 图表宽度约束——SGLVCharts.svg 输出 width=100%+viewBox(设计宽 640/680), 超宽窗口下被等比放大近 3 倍(字号/线宽随之爆炸,成就时间线变成全屏巨图)。 按各图表设计宽封顶并居中,高度随 viewBox 等比,不再随容器拉伸 */ .sglv-ptw-ach-chart{max-width:680px;margin:0 auto;} .sglv-ptw-ach-chart svg{width:100%;height:auto;} .sglv-ptw-value-chart{max-width:720px;margin:0 auto;} .sglv-ptw-value-chart svg{width:100%;height:auto;} /* 库价行情曲线图同纪律:设计宽 560,面板宽 ~980 时会被等比放大到 1.7 倍 (轴字号 10px→17px,比 11.5px 的面板标题还大)。按设计宽封顶并居中。 */ .sglv-pt-quote-panel-body>svg{max-width:640px;height:auto;display:block;margin:0 auto;} /* 全部游戏 Tab——列表吃满 pane 高度(共享库 .sglv-pt-all-list max-height:500px 只能看 ~13 行),分页钉在 pane 底部;行距紧凑保证 18 行/页完整可视 */ .sglv-ptw-pane.active{display:flex;flex-direction:column;} .sglv-ptw-pane > .sglv-pt-all-list{flex:1 1 0;min-height:0;max-height:none;} .sglv-ptw-pane > .sglv-pt-pagination{flex-shrink:0;margin-top:8px;} .sglv-ptw-pane .sglv-pt-all-item{padding:4px 8px;margin-bottom:2px;} /* 成就 Tab 网格重排(替代居中窄列)——Hero 通栏; 「稀有度分布 + 解锁时间线」双栏;「成就明细」通栏 */ .sglv-ptw-ach-body{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,3fr);gap:12px;align-items:start;} .sglv-ptw-ach-body > div:nth-child(1){grid-column:1/-1;} .sglv-ptw-ach-body > div:nth-child(2){grid-column:1;grid-row:2;} .sglv-ptw-ach-body > div:nth-child(3){grid-column:2;grid-row:2;} .sglv-ptw-ach-body > div:nth-child(4){grid-column:1/-1;grid-row:3;} /* 足迹 Tab 布局 —— v2.22.6 起**移除**本覆盖层: 原 grid 重排(grid-row:3 硬指定行号 + auto auto minmax(0,1fr) 行模板)会把 「采样日历 / 年度游戏」两面板压扁,面板内容溢出网格行后与下方通栏的 「首次上手 / 里程碑」互相压盖(用户截图:两个标题叠在同一格、note 横穿面板), 且 pane 是 overflow:hidden 导致整页无法滚动。 现由 PTW 库统一用 flex-wrap 负责(banner/hero 通栏 + panel 两列平铺 + body 自滚), 此处保留注释以免后续再被加回来。 */ /* 入库月历微调——月卡间距与内边距收紧(年份胶囊 nowrap / 宽度上限 / 时间线高度下限 已在 CSS_PLAYDATA 基线统一治理,此处不再重复覆盖: 原覆盖层的 max-width:46% 是「右侧图例被裁掉」的元凶之一——1080px 窗口下年份胶囊被钉死 在 ~500px,叠加右侧 summary(5 chip + 图例) 后必然溢出,被 pane 的 overflow 裁切。) */ .sglv-ptcal-grid{gap:10px;} .sglv-ptcal-month{padding:7px 7px 5px;} `); } catch (e) { /* 样式失败不阻断功能 */ } // ---- 降级路径:框架缺失时给用户明确提示,绝不静默白屏 ---- // 背景:受限网络下 @require 可能静默失败导致功能整体不可用且无任何提示。 // 此处保证「功能不可用」时用户一定看得到原因与处置方法。 // //拆出 ptwFallbackHtml() 供主面板「游玩数据」Tab 内联复用—— // showPtwFallback 是模态卡片形态,不适合塞进 Tab 面板。 const PTW_FALLBACK_REASON = window.SGLVPTW ? 'apiVersion 不匹配(脚本与功能库版本不一致)' : '外部资源 scriptcat.org 未返回脚本内容'; function ptwFallbackHtml() { const NSS = 'sglv-ptw-fb'; try { if (!document.getElementById(NSS + '-style')) { const st = document.createElement('style'); st.id = NSS + '-style'; st.textContent = ` .${NSS}-inline{padding:22px 18px;border:1px dashed rgba(232,163,61,.4);border-radius:10px;background:rgba(232,163,61,.06);color:#c7d5e0;font-size:13px;line-height:1.75;} .${NSS}-inline .${NSS}-t{font-size:14px;font-weight:600;color:#e8a33d;margin-bottom:10px;} .${NSS}-k{display:inline-block;padding:1px 7px;margin:0 2px;border-radius:5px;background:rgba(102,192,244,.14);color:#66c0f4;font-family:ui-monospace,monospace;font-size:11px;} .${NSS}{position:fixed;z-index:1000004;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.5);backdrop-filter:blur(3px);} .${NSS}-box{max-width:520px;padding:28px 30px;border-radius:14px;background:#1a2733;border:1px solid rgba(232,163,61,.35);box-shadow:0 18px 60px rgba(0,0,0,.55);color:#c7d5e0;font-size:13px;line-height:1.75;} .${NSS}-t{font-size:15px;font-weight:600;color:#e8a33d;margin-bottom:12px;} .${NSS}-d{opacity:.85;margin-bottom:8px;} .${NSS}-k{display:inline-block;padding:1px 7px;margin:0 2px;border-radius:5px;background:rgba(102,192,244,.14);color:#66c0f4;font-family:ui-monospace,monospace;font-size:11px;} .${NSS}-btn{margin-top:18px;padding:8px 20px;border-radius:8px;border:1px solid rgba(102,192,244,.4);background:rgba(102,192,244,.14);color:#66c0f4;font-size:13px;cursor:pointer;} .${NSS}-btn:hover{background:rgba(102,192,244,.26);} `; document.head.appendChild(st); } return `
游戏数据浮窗功能库未加载
原因:${escHtml(PTW_FALLBACK_REASON)}
常见处置:
1. Tampermonkey → 编辑脚本 → 确认「外部资源设置」允许访问 scriptcat.org
2. 检查网络能否访问该域名,必要时切换 CDN
3. 其余功能不受影响,可正常继续使用
`; } catch (e) { return `
游戏数据浮窗功能库未加载(${escHtml(PTW_FALLBACK_REASON)})
`; } } function showPtwFallback(reason) { try { if (document.getElementById('sglv-ptw-fallback')) return; const NSS = 'sglv-ptw-fb'; const el = document.createElement('div'); el.id = 'sglv-ptw-fallback'; el.className = NSS; el.innerHTML = `
游戏数据浮窗功能库未加载
原因:${escHtml(reason)}
常见处置:
1. Tampermonkey → 编辑脚本 → 确认「外部资源设置」允许访问 scriptcat.org
2. 检查网络能否访问该域名,必要时切换 CDN
3. 其余功能不受影响,可正常继续使用
`; document.body.appendChild(el); } catch (e) { /* 降级再失败则静默,不阻塞主流程 */ } } if (!_ptwReady) { // 延迟到 DOM 就绪后再提示,避免过早插入被后续 DOM 操作覆盖 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => showPtwFallback( window.SGLVPTW ? 'apiVersion 不匹配(脚本与功能库版本不一致)' : '外部资源 scriptcat.org 未返回脚本内容' ), { once: true }); } else { showPtwFallback( window.SGLVPTW ? 'apiVersion 不匹配(脚本与功能库版本不一致)' : '外部资源 scriptcat.org 未返回脚本内容' ); } } //修复 Steam 客户端中"测试 ITAD 连接"报 // ❌ 连接失败: Cannot read properties of undefined (reading 'match') // 根因链: ① Steam 客户端 CEF 环境下 GM_xmlhttpRequest 网络层失败(浏览器正常); // ② SGLVCore.gmFetchJson 的 onerror(err) 在重试耗尽后 reject(err)——err 是 GM 传入的 // 原始响应/事件对象,没有 .message 属性; // ③ SGLVITAD.testConnection 的 catch 里 e.message.match(/HTTP (\d+)/) 对 undefined 调 // .match 抛 TypeError,其消息被外层 catch 显示为上述报错。 // 修复两处(均在主脚本内做,覆盖层方式,不改 @require 库文件): // A) 包装 SGLVCore.gmFetchJson:任何非 Error 拒绝值归一化为带 message 的 Error(全局受益); // B) 主脚本侧 _sglvItadTestConnection:GM 网络层失败时用原生 fetch 直连兜底 // (ITAD API 返回 Access-Control-Allow-Origin: *,页面上下文 fetch 可用)。 function _sglvNormalizeFetchError(e) { if (e instanceof Error && e.message) return e; let msg = ''; if (e && typeof e === 'object') { if (typeof e.error === 'string' && e.error) msg = e.error; else if (e.status) msg = 'HTTP ' + e.status + (e.statusText ? ' ' + e.statusText : ''); else try { msg = JSON.stringify(e); } catch (_) { msg = ''; } } if (!msg) msg = String(e || 'network error'); if (msg === '{}' || msg === 'undefined' || msg === 'null') msg = 'network error'; return new Error(msg); } (function _sglvPatchGmFetchJson() { try { if (!window.SGLVCore || typeof window.SGLVCore.gmFetchJson !== 'function') return; if (window.SGLVCore.gmFetchJson.__sglvErrNormalized) return; const orig = window.SGLVCore.gmFetchJson; const wrapped = function (url, options) { return orig.call(this, url, options).catch(e => { throw _sglvNormalizeFetchError(e); }); }; wrapped.__sglvErrNormalized = true; window.SGLVCore.gmFetchJson = wrapped; } catch (e) { /* 包装失败不影响原链路 */ } })(); async function _sglvItadTestConnection(testKey) { try { return await window.SGLVITAD.testConnection(testKey); } catch (e) { const msg = (e && e.message) ? String(e.message) : String(e || 'unknown error'); // GM 网络层不可用/网络错误 → 原生 fetch 直连 ITAD 兜底(Steam 客户端 CEF 场景) if (typeof fetch === 'function' && /network error|timeout|unavailable|Failed to fetch|NetworkError|网络/i.test(msg)) { try { const resp = await fetch('https://api.isthereanydeal.com/service/shops/v1?key=' + encodeURIComponent(testKey), { method: 'GET' }); if (resp.ok) { const data = await resp.json().catch(() => null); return { success: true, shopCount: Array.isArray(data) ? data.length : 0 }; } if (resp.status === 401 || resp.status === 403) { return { success: false, error: 'Invalid API key (HTTP ' + resp.status + ')' }; } return { success: false, error: 'HTTP ' + resp.status }; } catch (e2) { return { success: false, error: '网络请求失败 (GM 与 fetch 均不可达): ' + ((e2 && e2.message) || e2) }; } } return { success: false, error: msg }; } } // ==================== UI 构建 ==================== let overlayEl, panelEl; //导航栏二级菜单构建器(修复"游戏收藏二级菜单无法打开" + 新增「游戏数据」入口) // ——从 initUI 抽出为独立函数, 便于 vm 执行级回归(见单测 H 组)。 //对齐 Steam 原生下拉——取消 ▾ 箭头开关,「游戏库」悬停展开、点击直接打开主浮窗, // 菜单三项竖排(打开面板 / 游戏收藏 / 游戏数据), 首项 is-active 选中高亮, 停靠锚点仍是 fab。 // 历史坑: ① 纯 CSS :hover 下拉在触屏/Steam 客户端 CEF 无 hover 不可达, fab 无下拉箭头零可供性; // ② 降级分支 style.cssText 的 position:fixed 被共享库 .sglv-menu-wrap{position:relative!important} // 覆盖(stylesheet !important 胜 inline), 社区页菜单被钉在文档流末尾"打不开"; // ③ 箭头作为独立 .menuitem 受 Steam float:left 影响换行堆到 fab 上方, 且与菜单割裂。 // 方案: 有 hover 时「游戏库」点击直接 togglePanel, 悬停才展开菜单(打开有短延迟, 离开有宽限, // 避免划过导航栏闪一下、或还没移到选项就收起); 无 hover(触屏/Steam 客户端 CEF)仍点击开合。 // 展开时子菜单 position:fixed 停靠到 fab 下方(滚动 rAF 节流重定位, resize 关闭); // 外点/Esc/移出关闭; 降级分支改走 .sglv-menu-fixed class(class 选择器可与 !important 同权竞争)。 // 返回 menuWrap, 由调用方插入 supernav 或 body(本函数内已按 .supernav_container 是否存在自分流)。 function buildSglvMenu() { const menuWrap = document.createElement('span'); menuWrap.className = 'sglv-menu-wrap'; menuWrap.setAttribute('data-sglv-plugin-ui', '1'); const fab = document.createElement('a'); fab.className = 'menuitem sglv-fab'; fab.href = 'javascript:void(0)'; fab.textContent = T.title; // 不用 title:原生提示会盖在第一项「打开游戏库」上,鼠标追过去时菜单已经收起 fab.setAttribute('aria-label', T.title); menuWrap.appendChild(fab); //二级菜单 —— 「打开游戏库面板 / 游戏收藏 / 游戏数据」三项竖排。 //变更(对齐 Steam 原生下拉):① 取消 ▾ 箭头开关, 「游戏库」悬停开合、点击进主浮窗, // 箭头曾因 Steam .menuitem{float:left} 换行堆到「游戏库」上方且与菜单割裂; // ② 面板入口从 fab 移入菜单首项(带 is-active 选中高亮), 取消箭头后仍可一键开面板; // ③ 停靠锚点仍是 fab(游戏库), 子菜单恒在其正下方。 const submenu = document.createElement('div'); submenu.className = 'sglv-submenu'; // 首项: 打开游戏库面板(原先由 fab 点击承担, 取消箭头后迁入菜单, 默认选中高亮) const panelLink = document.createElement('a'); panelLink.className = 'menuitem sglv-submenu-item is-active'; panelLink.href = 'javascript:void(0)'; panelLink.textContent = T.ptMenuOpenPanel; panelLink.title = T.ptMenuOpenPanelTip; panelLink.addEventListener('click', (e) => { e.preventDefault(); closeSubmenu(); togglePanel(); }); submenu.appendChild(panelLink); const collectionLink = document.createElement('a'); collectionLink.className = 'menuitem sglv-submenu-item'; collectionLink.href = 'javascript:void(0)'; collectionLink.textContent = isZh ? '游戏收藏' : 'Game Collection'; collectionLink.title = isZh ? '供应商分类 / 绝版收藏 / PS会免 / Epic赠送 / 年度大作' : 'Publisher / Delisted / PS Plus / Epic Free / GotY'; collectionLink.addEventListener('click', (e) => { e.preventDefault(); //点击接管后 hover 不再自动收起子菜单, 菜单项点击需显式关闭 closeSubmenu(); //点击链路异常可见化——此前任何运行时错误只进控制台,用户感知为"点击无反应"。 // 握手失败(库已加载但 apiVersion 不匹配)时 Host 未注入,toggleCollection 渲染面板必然抛 // TypeError,统一在入口兜住并给出可操作的提示(模块状态+版本号),不再依赖用户开控制台排查。 try { if (!window.SGLVCollection) { sglvToast.warning(isZh ? '收藏模块未加载(请检查 @require 外部库或网络)' : 'Collection module not loaded'); console.warn('[SGLV] 游戏收藏不可用: window.SGLVCollection 未定义(收藏库 @require 加载失败?) 主脚本 v' + _v); return; } if (!_collectionReady) { sglvToast.warning(isZh ? '收藏模块握手失败(版本不兼容,请更新主脚本与收藏库)' : 'Collection module handshake failed'); console.warn('[SGLV] 游戏收藏不可用: init 握手失败(apiVersion 不匹配) 主脚本 v' + _v, 'lib apiVersion=', SGLVCollection.apiVersion); return; } if (typeof SGLVCollection.toggleCollection !== 'function') { sglvToast.warning(isZh ? '收藏模块接口异常' : 'Collection module interface error'); console.warn('[SGLV] 游戏收藏不可用: SGLVCollection.toggleCollection 非函数', SGLVCollection); return; } SGLVCollection.toggleCollection(); } catch (err) { console.error('[SGLV] 游戏收藏菜单点击失败(主脚本 v' + _v + '):', err); sglvToast.error(isZh ? '收藏模块异常,详见控制台' : 'Collection error, see console'); } }); submenu.appendChild(collectionLink); //「游戏数据」——位于游戏收藏下方, 点击直接打开全部游戏排行全屏浮窗 // (全部游戏 / 家庭对比 / 入库月历三 Tab), 无需先开面板再切游玩数据 Tab。 const gameDataLink = document.createElement('a'); gameDataLink.className = 'menuitem sglv-submenu-item'; gameDataLink.href = 'javascript:void(0)'; gameDataLink.textContent = T.ptMenuGameData; gameDataLink.title = T.ptMenuGameDataTip; gameDataLink.addEventListener('click', (e) => { e.preventDefault(); closeSubmenu(); openGameDataWindow(); }); submenu.appendChild(gameDataLink); menuWrap.appendChild(submenu); //「游戏库」:有 hover 时点击直接进主浮窗,悬停才出菜单。 // 子菜单是 position:fixed,和按钮之间有一条缝;指针穿过缝时不算还在 menuWrap 里, // 立刻 mouseleave 收起就是「还没点到就消失」。打开稍等一下,离开再留一段宽限, // 移进菜单(含 ::before 热区)就取消收起。 const _hoverUnsupported = (typeof window.matchMedia === 'function') ? window.matchMedia('(hover: none)').matches : false; const SUB_OPEN_DELAY = 140; const SUB_CLOSE_DELAY = 380; let _subOpenTimer = 0; let _subCloseTimer = 0; function _clearSubTimer(which) { if (which === 'open' && _subOpenTimer) { clearTimeout(_subOpenTimer); _subOpenTimer = 0; } if (which === 'close' && _subCloseTimer) { clearTimeout(_subCloseTimer); _subCloseTimer = 0; } } function _cancelSubTimers() { _clearSubTimer('open'); _clearSubTimer('close'); } function _scheduleSubOpen() { if (_hoverUnsupported || isSubOpen() || _subOpenTimer) return; _clearSubTimer('close'); _subOpenTimer = setTimeout(() => { _subOpenTimer = 0; openSubmenu(); }, SUB_OPEN_DELAY); } function _scheduleSubClose() { _clearSubTimer('open'); if (_hoverUnsupported || !isSubOpen()) return; _clearSubTimer('close'); _subCloseTimer = setTimeout(() => { _subCloseTimer = 0; closeSubmenu(); }, SUB_CLOSE_DELAY); } function _subPointerIn() { _clearSubTimer('close'); if (!isSubOpen()) _scheduleSubOpen(); } function _subPointerOut() { _scheduleSubClose(); } fab.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); _cancelSubTimers(); // 无 hover 时点击仍负责开合,否则触屏进不了「游戏收藏 / 游戏数据」 if (_hoverUnsupported) { toggleSubmenu(); return; } closeSubmenu(); togglePanel(); }); menuWrap.addEventListener('mouseenter', _subPointerIn); menuWrap.addEventListener('mouseleave', _subPointerOut); submenu.addEventListener('mouseenter', _subPointerIn); submenu.addEventListener('mouseleave', _subPointerOut); function isSubOpen() { return menuWrap.classList.contains('sglv-open'); } function closeSubmenu() { _cancelSubTimers(); menuWrap.classList.remove('sglv-open'); submenu.classList.remove('sglv-submenu-docked'); } // 停靠: 子菜单 position:fixed 到 fab 正下方——摆脱 Steam 头部/面板祖先 overflow:hidden // 裁剪(共享库 absolute 定位在降级浮浮场景会被裁)。坐标经 CSS 自定义属性传值, // 避开共享库 top/left 的 !important(CSS_MENU 内 var 供值 + !important 留样式表侧)。 function dockSubmenu() { // 锚点 = 游戏库(fab)本身, 子菜单因此恒停靠在「游戏库」正下方(而非 ▾ 或菜单容器右缘)。 // fab 尚未参与布局时(首帧 / 祖先 display:none)矩形全 0, 退回 menuWrap 自身矩形, // 避免 --sglv-sub-* 拿到 0 把子菜单钉到视口左上角——即"加载位置有问题"的兜底。 let r = (typeof fab.getBoundingClientRect === 'function') ? fab.getBoundingClientRect() : null; if (!r || !(r.bottom > 0)) { r = (typeof menuWrap.getBoundingClientRect === 'function') ? menuWrap.getBoundingClientRect() : null; } if (r && submenu.style && typeof submenu.style.setProperty === 'function') { submenu.style.setProperty('--sglv-sub-top', r.bottom + 'px'); submenu.style.setProperty('--sglv-sub-left', r.left + 'px'); } submenu.classList.add('sglv-submenu-docked'); } function openSubmenu() { menuWrap.classList.add('sglv-open'); dockSubmenu(); } function toggleSubmenu() { isSubOpen() ? closeSubmenu() : openSubmenu(); } // 页面滚动时子菜单跟着 fab 走(rAF 节流; passive 不阻塞滚动) let _subScrollRaf = 0; window.addEventListener('scroll', () => { if (!isSubOpen() || _subScrollRaf) return; _subScrollRaf = requestAnimationFrame(() => { _subScrollRaf = 0; if (isSubOpen()) dockSubmenu(); }); }, { passive: true }); // 视口变化时停靠几何失效, 直接关闭避免错位 window.addEventListener('resize', closeSubmenu); // 外点 / Esc 关闭(menuWrap.contains 在测试桩上不存在, 可选链兜底) document.addEventListener('click', (e) => { if (!isSubOpen()) return; if (menuWrap.contains && menuWrap.contains(e.target)) return; closeSubmenu(); }); document.addEventListener('keydown', (e) => { if (e && e.key === 'Escape') closeSubmenu(); }); // 客服锚点:旧商店头走 .supernav_container;愿望单等 React 头没有这个节点, // 顶栏是 nav > ul 里的 help.steampowered.com 链接(页脚「联系客服」和折叠菜单要排除)。 function findHeaderSupportLink() { const supernav = document.querySelector('.supernav_container'); if (supernav) { const classic = supernav.querySelector('a.menuitem[href*="help.steampowered.com"]') || Array.from(supernav.querySelectorAll('a.menuitem')).pop(); if (classic) return { link: classic, mode: 'classic' }; } const links = document.querySelectorAll ? document.querySelectorAll('a[href*="help.steampowered.com"]') : []; for (const a of links) { const href = (a.getAttribute && a.getAttribute('href')) || ''; if (href.indexOf('/faqs/') !== -1) continue; if (typeof a.closest === 'function') { if (a.closest('details[name="responsive-menu"]')) continue; if (a.closest('footer')) continue; if (a.closest('[popover]')) continue; } const text = (a.textContent || '').replace(/\s+/g, ' ').trim(); if (!text || text.length > 16) continue; const parent = a.parentElement; if (!parent || (parent.tagName !== 'UL' && parent.tagName !== 'NAV' && parent.tagName !== 'LI')) continue; if (typeof a.closest === 'function' && !a.closest('nav')) continue; return { link: a, mode: 'react' }; } return null; } function syncReactHeaderClasses(helpLink) { const classes = String(helpLink.className || '').split(/\s+/).filter(Boolean); for (const c of classes) { if (c && !fab.classList.contains(c)) fab.classList.add(c); } if (helpLink.style && fab.style && typeof helpLink.style.getPropertyValue === 'function') { const cursor = helpLink.style.getPropertyValue('--cursor'); if (cursor) fab.style.setProperty('--cursor', cursor); } } let _menuObs = []; let _seating = false; let _seatMoves = []; let _seatTimer = 0; function _nodeInForeignUi(node) { const el = node && node.nodeType === 1 ? node : (node && node.parentElement); if (!el || typeof el.closest !== 'function') return false; return !!el.closest('#familyAnalysisPanel, #sgis-panel, #fa-wl-sibling, #setting_btn, #fa_dd_item, #swe-panel, #swe-fab, .swe-nav-entry, .swe-settings-overlay, .swe-loading-overlay, .swe-toast, [data-fa-plugin-ui], [data-swe-plugin-ui]'); } function _menuMutationIsOurs(mutation) { const t = mutation && mutation.target; if (!t) return true; if (menuWrap.contains && (t === menuWrap || menuWrap.contains(t))) return true; if (_nodeInForeignUi(t)) return true; const lists = [mutation.addedNodes, mutation.removedNodes]; let saw = false; for (let li = 0; li < lists.length; li++) { const list = lists[li]; if (!list) continue; for (let j = 0; j < list.length; j++) { const n = list[j]; if (!n || (n.nodeType !== 1 && n.nodeType !== 3)) continue; saw = true; const inMenu = n !== menuWrap && menuWrap.contains && menuWrap.contains(n); if (!inMenu && !_nodeInForeignUi(n)) return false; } } return saw; } function watchMenuHost(node, opts) { if (!node || typeof MutationObserver !== 'function') return; if (_menuObs.some(o => o.target === node)) return; const obs = new MutationObserver((mutations) => { for (let i = 0; i < mutations.length; i++) { if (_menuMutationIsOurs(mutations[i])) continue; if (_seatTimer) return; _seatTimer = setTimeout(() => { _seatTimer = 0; seatMenu(); }, 200); return; } }); obs.target = node; obs.observe(node, opts); _menuObs.push(obs); } function seatMenu() { if (_seating) return false; const now = Date.now(); _seatMoves = _seatMoves.filter(t => now - t < 10000); if (_seatMoves.length >= 6) return !!(menuWrap.isConnected); const found = findHeaderSupportLink(); if (!found) return false; _seating = true; try { const helpLink = found.link; const nav = (typeof helpLink.closest === 'function' && helpLink.closest('nav')) || helpLink.parentElement; const already = !!(menuWrap.isConnected && nav && typeof nav.contains === 'function' && nav.contains(menuWrap)); if (!already) { if (found.mode === 'react') { syncReactHeaderClasses(helpLink); menuWrap.classList.add('sglv-menu-in-header'); } menuWrap.classList.remove('sglv-menu-fixed'); const anchor = (helpLink.parentElement && helpLink.parentElement.tagName === 'LI') ? helpLink.parentElement : helpLink; if (anchor.nextElementSibling !== menuWrap) { _seatMoves.push(Date.now()); if (typeof anchor.after === 'function') anchor.after(menuWrap); else if (anchor.parentNode) anchor.parentNode.insertBefore(menuWrap, anchor.nextSibling); } } if (nav) { watchMenuHost(nav, { childList: true, subtree: true }); if (nav.parentElement) watchMenuHost(nav.parentElement, { childList: true }); } } finally { _seating = false; } return !!(menuWrap.isConnected || (menuWrap.parentNode)); } if (!seatMenu()) { // 头部还没出来时先挂浮动按钮,避免入口消失;挂上后由 seatMenu 挪到客服后面。 menuWrap.classList.add('sglv-menu-fixed'); if (document.body) document.body.appendChild(menuWrap); let tries = 0; const retry = setInterval(() => { tries++; if (seatMenu() || tries >= 24) clearInterval(retry); }, 250); } return menuWrap; } function initUI() { //主题热切换——CSS 即时生效,已渲染的 SVG 图表/条件色需重渲染取新主题色板; // 设置视图打开时跳过(保存流程自身持有 DOM 引用,且设置界面随 CSS 已即时换肤) window.addEventListener('sglv-theme-changed', () => { if (panelEl && panelEl.isConnected && !state.showSettings) { try { renderBody(); } catch (e) { console.warn('[SGLV] 主题切换重渲染失败:', e); } } }); // 库存展示按钮 + 二级菜单(游戏收藏 / 游戏数据)——插入到全局导航栏"客服"后方 buildSglvMenu(); // 遮罩层 overlayEl = document.createElement('div'); overlayEl.className = 'sglv-overlay'; overlayEl.addEventListener('click', closePanel); document.body.appendChild(overlayEl); // 主面板 panelEl = document.createElement('div'); panelEl.className = 'sglv-panel'; //页头紧凑化——移除"游戏库"标题,保留最左侧 Steam 图标作为品牌标识,tabs 移入 header 内部(参考 steam-friend-manager sfd-tab-bar 风格) //tab 菜单紧凑化 + 愿望单 tab 右侧加入全局搜索框(中文/拼音缩写/英文子串匹配) panelEl.innerHTML = `
Steam
`; document.body.appendChild(panelEl); // v2.23.1: 注册 SGLVCoverFallback 全局 error 委托——data-sglv-cover 属性的 // 出错时由 lib 接管降级链(4 CDN → steamui hash → appdetails API → SVG placeholder), // 覆盖所有 tab(owned/showcase/wishlist/cloudsave/...)和未来新增视图 _ensureCoverFallbackDelegation(panelEl); //header 整行作为拖动手柄(参考 steam-friend-manager makeDraggable) makeDraggable(panelEl, panelEl.querySelector('.sglv-header')); // 事件绑定 panelEl.querySelector('.sglv-close-btn').addEventListener('click', closePanel); panelEl.querySelector('.sglv-refresh-btn').addEventListener('click', () => startFetch(true)); panelEl.querySelector('.sglv-settings-btn').addEventListener('click', () => { //设置已集成进主面板——再点一次可退出设置视图 if (state.showSettings) closeGlobalSettings(); else openGlobalSettings(); }); //心型按钮 → 打开游戏收藏浮窗 //同子菜单项——握手/异常可见化(见 collectionLink 点击处注释) panelEl.querySelector('.sglv-collection-btn').addEventListener('click', () => { try { if (!window.SGLVCollection || !_collectionReady || typeof SGLVCollection.openCollection !== 'function') { showToast(isZh ? '收藏模块未加载或版本不兼容' : 'Collection module not loaded or incompatible'); console.warn('[SGLV] 游戏收藏不可用(心型按钮): SGLVCollection=', window.SGLVCollection, 'ready=', _collectionReady, '主脚本 v' + _v); return; } SGLVCollection.openCollection(); } catch (err) { console.error('[SGLV] 游戏收藏打开失败(心型按钮, 主脚本 v' + _v + '):', err); showToast(isZh ? '收藏模块异常,详见控制台' : 'Collection error, see console'); } }); // ★v2.36.0 导出合并为一个下拉菜单(原 CSV/JSON 两个文本按钮)+ 新增数据分析直达入口 const exportBtn = panelEl.querySelector('#sglv-export-btn'); let _exportMenu = null; const _closeExportMenu = () => { if (_exportMenu) { _exportMenu.remove(); _exportMenu = null; } document.removeEventListener('mousedown', _onExportOutside, true); }; const _onExportOutside = (e) => { if (!_exportMenu) return; if (_exportMenu.contains(e.target) || exportBtn.contains(e.target)) return; _closeExportMenu(); }; exportBtn.addEventListener('click', () => { if (_exportMenu) { _closeExportMenu(); return; } _exportMenu = document.createElement('div'); _exportMenu.className = 'sglv-export-menu'; _exportMenu.innerHTML = ` `; document.body.appendChild(_exportMenu); const rect = exportBtn.getBoundingClientRect(); const mw = _exportMenu.offsetWidth || 170; _exportMenu.style.left = Math.min(rect.left, (window.innerWidth || document.documentElement.clientWidth) - mw - 8) + 'px'; _exportMenu.style.top = (rect.bottom + 6) + 'px'; _exportMenu.classList.add('show'); _exportMenu.addEventListener('click', (e) => { const b = e.target.closest('button'); if (!b) return; _closeExportMenu(); if (b.dataset.fmt === 'csv') exportCSV(); else exportJSON(); }); setTimeout(() => document.addEventListener('mousedown', _onExportOutside, true), 0); }); // 数据分析按钮——直达「游戏数据分析」全屏浮窗(库未加载时 toast 引导,与导航菜单同口径) panelEl.querySelector('#sglv-data-analysis-btn').addEventListener('click', () => { try { openGameDataWindow(); } catch (e) { console.error('[SGLV] 打开游戏数据分析失败:', e); showToast(isZh ? '打开失败,详见控制台' : 'Failed to open, see console'); } }); panelEl.querySelectorAll('.sglv-tab').forEach(tab => { tab.addEventListener('click', () => { //统一使用 switchToTab switchToTab(tab.dataset.tab); }); }); //绑定全局游戏搜索框事件(输入防抖/键盘导航/外部点击关闭/清除按钮) bindGlobalSearchEvents(); //首次绑定时也构建一次搜索索引(已有缓存的情况下,UI 即可用) if (!_searchIndexBuilt) { try { buildSearchIndex(); } catch (e) { console.warn('[SGLV] 搜索索引预构建失败:', e); } } //供侧边栏 KPI 卡片跨模块打开游戏库浮窗 //注册到 addDisposer 确保卸载时清理,避免内存泄漏 const _onOpenLibrary = () => { openPanel(); }; document.addEventListener('sglv:open-library', _onOpenLibrary); addDisposer(() => document.removeEventListener('sglv:open-library', _onOpenLibrary)); //监听 SGIS 侧边栏触发的事件, 支持跳转指定 tab const _onOpenModal = (e) => { openPanel(); const targetTab = e?.detail?.tab; if (targetTab) { //统一使用 switchToTab (内含 DOM 存在性检查) const tabBtn = panelEl.querySelector(`.sglv-tab[data-tab="${targetTab}"]`); if (tabBtn) switchToTab(targetTab); } }; document.addEventListener('sglv:open-modal', _onOpenModal); addDisposer(() => document.removeEventListener('sglv:open-modal', _onOpenModal)); // 加载缓存(GM_getValue 同步读取,体积小,不阻塞绘制) const cached = storage.getCachedGames(); if (cached.length) { cached.sort((a, b) => a.name.localeCompare(b.name)); state.ownedGames = cached; state.ownedAppIds = new Set(cached.map(g => g.appid)); markSearchIndexDirty(); // v2.9.51: 缓存恢复,索引需要重建 //同步从 PCC IDB 预热已计算的 KPI 结果到 _computedCache // 旧逻辑 clearComputedCache 把所有缓存清空,下次开面板全量重算(每次) // 新逻辑 hydrateComputedCache 把已持久化的计算结果(年度统计/家庭组/insight 等)从 IDB 同步填充 // 启动时 IDB.loadAll 已完成,内存镜像 _mem 可用 hydrateComputedCache(); } else { // 首次使用/无缓存:确保缓存清空 clearComputedCache(); } //DLC 数据库同步预加载延迟到下一事件循环,确保菜单入口优先渲染 // DLC 缓存体积较大(数万条),同步 JSON.parse 会阻塞浏览器首次绘制 setTimeout(() => { loadDlcDatabaseFromCacheSync(); loadDlcDatabase(); //异步加载游戏系列数据(game_series.json),合并到系列分类数据库 //同时拉取 steam_vediogame_selected.json,作为"互动影游"系列并入同一管线 loadSeriesData(); //异步加载供应商数据远程版本检查(覆盖 @resource GAME_DB) loadSupplierData(); // v2.45.0:不再有「首次未配置 API Key」的开场引导卡片 —— // 配置 Key 必须由用户在「需要它的功能」上主动触发,并先看到用途与风险说明(见 _openApiKeyConsent)。 // 页面加载阶段保持零打扰:不弹窗、不引导、不加载。 }, 0); } function togglePanel() { if (panelEl.classList.contains('sglv-show')) closePanel(); else openPanel(); } function openPanel() { panelEl.classList.add('sglv-show'); overlayEl.classList.add('sglv-show'); //renderBody 包裹 try-catch,渲染失败不阻断面板打开 try { renderBody(); } catch (e) { console.error('[SGLV] 面板渲染失败:', e); } //展柜预热 — 面板打开时后台预取展柜数据,用户点击展柜标签时即可秒开 try { _preheatShowcase(); } catch (e) { /* ignore */ } } //展柜预热 — 在后台静默预取展柜数据并写入持久化缓存 // 不阻塞当前标签页渲染,仅在后台完成数据获取 function _preheatShowcase() { if (state.showcasePreheating || state.showcasePreheated) return; if (state.ownedGames.length === 0) return; // 无游戏库数据时不预热 state.showcasePreheating = true; // 异步预取,不阻塞 UI (async () => { try { const configHash = _computeShowcaseCacheHash(); // 先检查持久化缓存是否仍然有效 const swr = _swrReadShowcaseCache(configHash); if (swr.data && !swr.stale && !swr.configChanged) { // 缓存有效,跳过预取,仅恢复内存缓存 if (!state.showcaseProfile && swr.data.profile) { state.showcaseProfile = swr.data.profile; state.showcaseProfileAt = swr.timestamp || 0; } state.showcasePreheating = false; state.showcasePreheated = true; return; } //与标签页渲染共享 in-flight 抓取,避免重复网络请求 const data = await _fetchShowcaseDataAll(); _writeShowcaseCache({ ...data, achievements: _showcaseUnlockedOnly(data.achievements) }, configHash); state.showcasePreheating = false; state.showcasePreheated = true; } catch { //失败仅复位 preheating,允许下次 openPanel 重试预热 // (旧实现失败后置 preheated=true,本 session 内永久跳过预热) state.showcasePreheating = false; } })(); } //展柜数据 in-flight Promise 共享 — 预热与标签页渲染复用同一次抓取 // (Profile/成就/最近游玩/徽章),并发进入时只发一轮网络请求;结算后清空槽位允许重试 let _showcaseFetchInflight = null; function _fetchShowcaseDataAll() { if (_showcaseFetchInflight) return _showcaseFetchInflight; const p = (async () => { const [profile, achievements, recentGames, allBadges] = await Promise.all([ _fetchShowcaseProfile(), Promise.resolve(computeMyAchievementsCached()), _fetchRecentPlayedGames(), _getShowcaseBadges().catch(() => []), ]); const allGames = getStatFilteredGames(); const gameCount = allGames.length; const totalPlaytimeMin = allGames.reduce((s, g) => s + (g.playtime || 0), 0); const totalHours = Math.floor(totalPlaytimeMin / 60); const unlockedAchs = (achievements || []).filter(a => a.unlocked); const stats = { gameCount, totalHours, achCount: unlockedAchs.length, totalAchPts: unlockedAchs.reduce((s, a) => s + a.pts, 0), steamBadgeCount: (allBadges && allBadges.length) || profile.badgeCount || 0, }; return { profile, recentGames, badges: allBadges, achievements, stats }; })(); _showcaseFetchInflight = p.then( data => { _showcaseFetchInflight = null; return data; }, err => { _showcaseFetchInflight = null; throw err; } ); return _showcaseFetchInflight; } function closePanel() { panelEl.classList.remove('sglv-show'); overlayEl.classList.remove('sglv-show'); //清理搜索/详情浮窗状态,防止面板重开后浮窗残留 + 监听器泄漏 + 防抖 timer 泄漏 closeDetailPopup(); // 关闭详情浮窗 + 移除 _detailEscHandler + 递增 _detailToken 使在途请求失效 closeSearchPop(); // 关闭搜索下拉 + 重置聚焦索引 _gsDebouncedRender.cancel(); // 取消 pending 防抖 timer if (_gsInputEl) _gsInputEl.value = ''; // 清空搜索框 _gsLastResults = []; //同步关闭"全部游戏排行"全屏浮窗,避免面板关闭后浮窗残留(含 ESC 监听泄漏) const ptWin = document.getElementById('sglv-pt-all-overlay'); if (ptWin) ptWin.remove(); } //允许拖动面板(参考 steam-friend-manager makeDraggable) // 用 !important 覆盖 shared CSS 中 sglv-panel 的 top/left 定位, // 否则拖动后内联 top 会被样式表压制,导致窗口只能左右移动、无法上下移动。 function makeDraggable(el, handle) { if (!el || !handle) return; let isDragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0; const onDown = (e) => { // 忽略按钮 / 链接 / 输入控件 / label 上的 mousedown,避免与 tab/操作按钮冲突 const t = e.target; if (t.closest('button, input, select, textarea, label, a')) return; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = el.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; el.style.cursor = 'grabbing'; e.preventDefault(); }; const onMove = (e) => { if (!isDragging) return; el.style.setProperty('left', (startLeft + e.clientX - startX) + 'px', 'important'); el.style.setProperty('top', (startTop + e.clientY - startY) + 'px', 'important'); el.style.setProperty('right', 'auto', 'important'); el.style.setProperty('bottom', 'auto', 'important'); el.style.setProperty('transform', 'none', 'important'); }; const onUp = () => { if (!isDragging) return; isDragging = false; el.style.cursor = ''; }; handle.addEventListener('mousedown', onDown); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); addDisposer(() => { handle.removeEventListener('mousedown', onDown); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }); } // ==================== 真实成就数据层(S1)==================== // 通路:GET https://steamcommunity.com/profiles//achievements/?l=schinese // →