/* ===================================================================== * SGLV 基础能力层(10 库合并) * 由 script_libs 下 10 个单库文件按原 @require 顺序合并而来。 * 各库均为自包含 IIFE,按序拼接即等价于逐个 @require 加载。 * 单库源码已删除;如需拆回,按各分段头注释切分即可。 * ===================================================================== */ /* ---------- sglv-core.lib.js ---------- */ // ==UserScript== // @name SGLV Core Library (SGLV-Suite) // @name:en SGLV Core Library (SGLV-Suite) // @namespace https://greasyfork.org/scripts/588485 // @version 2.0.2 // @description SGLV 核心依赖库(v1.0.6):跨脚本复用的基础设施工具集。包含 NS 命名空间、IndexedDB KV 持久化、负缓存、StageProgress 多阶段进度条、makeThrottledProgress 节流、concurrentPool 智能并发池(429 降并发 + 指数退避)、twoPhaseFetch 两阶段批量抓取、fillPendingSlots 占位、gmFetchJson/gmFetchText GM_xmlhttpRequest 包装、PersistentComputeCache 持久化计算缓存。v1.0.5 gmFetch 状态码语义化:确定性 4xx(404/401 等)立即失败不再重试(防 404 重试风暴),瞬态错误(403/429/5xx)按退避重试,403/429 解析 x-ratelimit-remaining/reset 响应头并在错误信息中携带剩余额度;新增 parseRateLimitHeaders 与 notifyNetFail(60s 节流的终态失败系统通知,需宿主 @grant GM_notification)。v1.0.6 参数扩展:gmFetchText 支持 options.headers 透传(与 gmFetchJson 对齐,如商店页年龄验证 Cookie);gmFetchJson 新增 options.onHttpError(status, responseText) 可选回调(非 2xx 时提取响应体详情自定义错误消息,如 AI 端点提取 error.message)。均向后兼容,不传新参数时行为与 1.0.5 完全一致。供主脚本 @require 引用,不可单独使用。 // @description:en SGLV Core Library v1.0.6 — Cross-script infrastructure: NS namespace, IndexedDB KV, neg cache, StageProgress, throttled progress, smart concurrentPool (429 backoff), twoPhaseFetch, fillPendingSlots, GM fetch wrappers, PersistentComputeCache. **v1.0.5** HTTP status semantics: deterministic 4xx (404/401) fail fast without retry (anti 404-retry-storm); transient errors (403/429/5xx) retry with backoff; 403/429 parse x-ratelimit headers into error message. New parseRateLimitHeaders + notifyNetFail (throttled 60s final-failure notification, host must @grant GM_notification). **v1.0.6** options: gmFetchText headers passthrough (aligned with gmFetchJson, e.g. store page age-gate Cookie); gmFetchJson onHttpError(status, responseText) optional callback (custom error message from response body on non-2xx, e.g. AI endpoint error.message). Both backward compatible. For host script @require only. // @author hshow // @license MIT // @match *://*/* // @grant GM_xmlhttpRequest // @noframes // ==/UserScript== /* * SGLV Core Library v1.0.0 * * 设计目标:从 SGLV 业务脚本中抽出"零业务依赖"的基础设施工具, * 供所有 SGLV 系列脚本(steam-game-library-viewer / wishlist / badges / economy / family ...) * 通过 @require 共享,避免每次新脚本都重写一遍 IDB/进度/并发/负缓存。 * * API 暴露方式:window.SGLVCore (IIFE 直接挂全局,无依赖注入) * * 设计原则: * 1. 零业务依赖 — 不引入任何 Steam / GM 业务概念,纯工具 * 2. GM_* 兼容 — gmFetchJson / gmFetchText 用 GM_xmlhttpRequest(脚本侧); * 测试侧可注入 mockGmXhr 覆盖 * 3. IDB 兼容 — 全部走原生 indexedDB;测试侧可注入 mockIdb 覆盖 * 4. DOM 兼容 — StageProgress 依赖 document;测试侧用 jsdom * 5. 渐进式破坏 — API 内部 try/catch 兜底,任何一步失败不影响宿主 * * 桥接契约(测试/宿主需要注入): * window.__sglvCoreMockIdb = { DB_NAME, STORE, open, ... } 可选,默认走原生 indexedDB * window.__sglvCoreMockGmXhr = function(method, url, opts, cb) 可选,默认走 GM_xmlhttpRequest * * API: * version: '1.0.3' * apiVersion: 1 * NS: 'sglv' * nsKey(...parts): 拼接命名空间 key * IDB: { loadAll, get, set, remove, has, keys } 内存镜像 + IDB 异步写 * negCache: { get, set, clear } 通用负缓存(30min TTL,封装在调用方) * StageProgress: { ensure, setStage, setProgress, finish, fail, hide, show } * makeThrottledProgress(onProgress, throttleMs=800): 节流的 onProgress * concurrentPool(tasks, concurrency, onProgress): 智能并发池(429 降并发 + 指数退避) * twoPhaseFetch(items, fastFn, carefulFn, options): 两阶段批量抓取 * fillPendingSlots(items, max=6, pendingMarker): pending slot 占位 * gmFetchJson(url, options): GM_xmlhttpRequest JSON 包装(重试) * gmFetchText(url, options): GM_xmlhttpRequest 文本包装(重试) * ensureCacheVersions(defaultVersions): 缓存 schema 版本号升级工具 * **v1.0.3 NEW** PersistentComputeCache: { getOrCompute, getSync, has, invalidate, invalidateByInput, clear, hydrate } * 持久化计算缓存(IDB+内存镜像+输入签名+schema版本号) */ (function (root) { 'use strict'; // ==================== 1. NS 命名空间 ==================== // 所有 GM key / IDB key 统一前缀,便于重命名 / 拆分 / 合并 const NS = 'sglv'; const nsKey = (...parts) => [NS, ...parts].join('_'); // ==================== 2. IndexedDB KV 库 ==================== // 替代 GM_setValue 大对象,容量无 5MB 限制,启动更快 // 设计参考 steam-friend-manager-1.2.2:48-150 const sglvIDB = (function () { const DB_NAME = 'sglv_kv', STORE = 'kv', VERSION = 1; let _db = null; const _mem = {}; // 内存镜像:key → value let _loaded = false; const _writing = new Set(); // 正在写 IDB 的 key 集合(防抖) function open() { return new Promise((resolve, reject) => { const idb = root.indexedDB; if (!idb) { reject(new Error('indexedDB unavailable')); return; } const req = idb.open(DB_NAME, VERSION); req.onerror = () => reject(req.error); req.onsuccess = () => { _db = req.result; resolve(_db); }; req.onupgradeneeded = e => { if (!e.target.result.objectStoreNames.contains(STORE)) { e.target.result.createObjectStore(STORE); } }; }); } function loadAll() { if (_loaded) return Promise.resolve(_mem); return open().then(() => new Promise(resolve => { const tx = _db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).openCursor(); req.onsuccess = () => { const c = req.result; if (c) { _mem[c.key] = c.value; c.continue(); } else { _loaded = true; resolve(_mem); } }; req.onerror = () => { _loaded = true; resolve(_mem); }; })); } function get(key, fallback) { return _mem[key] !== undefined ? _mem[key] : fallback; } function set(key, val) { _mem[key] = val; if (!_db) return; if (_writing.has(key)) return; // 防抖:避免重复写 _writing.add(key); const tx = _db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).put(val, key); tx.oncomplete = () => _writing.delete(key); tx.onerror = () => _writing.delete(key); } function remove(key) { delete _mem[key]; if (!_db) return; const tx = _db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).delete(key); } function has(key) { return _mem[key] !== undefined; } function keys() { return Object.keys(_mem); } return { loadAll, get, set, remove, has, keys }; })(); // ==================== 3. 负缓存 ==================== // 失败结果短期不再重试(TTL 由调用方控制,常见 30min) // 设计参考 steam-friend-manager fetchGameZhName 负缓存模式 const negCache = { get(key, ttlMs) { const v = sglvIDB.get(key, null); if (!v || v._ok !== false) return null; if (Date.now() - (v._ts || 0) > ttlMs) return null; return v._err || true; }, set(key, err) { sglvIDB.set(key, { _ok: false, _err: err, _ts: Date.now() }); }, clear(key) { sglvIDB.remove(key); }, }; // ==================== 4. 缓存 schema 版本号工具 ==================== // 升级时自动失效旧版本缓存(调用方传入默认 version map) // 设计参考 steam-friend-manager-1.2.2:1675-1680 MUTUAL_CACHE_VERSION const CACHE_VER_KEY = nsKey('cache_versions'); function ensureCacheVersions(defaultVersions) { const versions = defaultVersions || {}; const cur = sglvIDB.get(CACHE_VER_KEY, {}) || {}; const updates = {}; let changed = false; for (const [name, ver] of Object.entries(versions)) { if ((cur[name] || 0) < ver) { updates[name] = ver; changed = true; } } if (changed) sglvIDB.set(CACHE_VER_KEY, { ...cur, ...updates }); return updates; // 返回"已升级的 cache 名称集合" } // ==================== 4.5 v1.0.3: 持久化计算缓存 ==================== // 解决"大批量计算结果无法跨 session 复用"的问题: // - IDB 持久化:计算结果写入 IndexedDB,关闭浏览器后下次启动仍可用 // - 输入签名:每个缓存 key 关联一个"输入指纹"(games count + 关键字段 hash), // 数据源变更时自动失效(避免渲染陈旧数据) // - schema 版本:业务 schema 升级时强制全部失效 // - 同步预热:getSync() 同步返回当前内存镜像(0 延迟),启动流程可同步填充 KPI // - 细粒度失效:invalidateByInput(predicate) 按输入签名谓词批量失效 // // 典型使用: // // 1) 启动时同步预热 — 已有缓存直接填充,无则返回 null(不触发重算) // const cached = PCC.getSync('yearly_stats'); // if (cached) state.yearlyStats = cached; // // // 2) 渲染时按需计算 — 命中 IDB 跳过,未命中异步重算 // async function getYearlyStats(games) { // const sig = `${games.length}|${games.reduce((s,g)=>s+(g.acquiredTime||0),0)}`; // return PCC.getOrCompute('yearly_stats', 1, sig, () => computeYearly(games)); // } // // // 3) 数据刷新时 — 失效旧缓存 // PCC.invalidate('yearly_stats'); const PersistentComputeCache = (function () { const STORE_KEY_PREFIX = 'pcc_'; const _mem = {}; // { [key]: { _sig, _ver, value, _ts } } const _writing = new Set(); // 防抖:同 key 短时间内多次写合并 const _writeTimers = new Map(); function _strip(key) { return key.startsWith(STORE_KEY_PREFIX) ? key.slice(STORE_KEY_PREFIX.length) : key; } function _now() { return Date.now(); } // 同步获取当前缓存(已 hydrate 完成后),不触发任何异步操作 // 启动流程可同步读取,0 延迟填充 UI function getSync(key) { const entry = _mem[key]; if (!entry) return null; return entry.value; } // 同步获取带元数据({ value, _sig, _ver, _ts }),便于业务层做输入签名匹配 function getSyncWithMeta(key) { const entry = _mem[key]; if (!entry) return null; return { value: entry.value, _sig: entry._sig, _ver: entry._ver, _ts: entry._ts }; } function has(key) { return _mem[key] !== undefined; } // 同步写入内存(不落盘) — 启动 hydrate 时使用 function _setMemOnly(key, value, sig, ver) { _mem[key] = { _sig: sig, _ver: ver, value, _ts: _now() }; } // 异步落盘(防抖 100ms 合并同 key 多次写) function _flush(key) { const entry = _mem[key]; if (!entry) return; // 直接走 sglvIDB(继承其 _writing 防抖 + IDB 异步写) sglvIDB.set(STORE_KEY_PREFIX + key, { _sig: entry._sig, _ver: entry._ver, value: entry.value, _ts: entry._ts, }); } // 核心:获取或异步计算 // key: 业务唯一 key // schemaVer: 业务 schema 版本号,变更时强制重算 // inputSig: 输入签名(调用方负责计算,如 "123|456") // computeFn: 缓存未命中时执行的计算函数(同步/异步均可) // 返回:Promise async function getOrCompute(key, schemaVer, inputSig, computeFn) { // 1) 内存命中 + 输入签名一致 + schema 版本匹配 → 直接返回 const mem = _mem[key]; if (mem && mem._ver === schemaVer && mem._sig === inputSig) { return mem.value; } // 2) IDB 命中(可能 hydrate 未完成,这里读 IDB 兜底) try { const persisted = sglvIDB.get(STORE_KEY_PREFIX + key, null); if (persisted && persisted._ver === schemaVer && persisted._sig === inputSig) { _mem[key] = { _sig: inputSig, _ver: schemaVer, value: persisted.value, _ts: persisted._ts || _now() }; return persisted.value; } } catch (e) { /* IDB 读取失败,继续重算 */ } // 3) 未命中:执行计算 + 写入 const value = await computeFn(); _mem[key] = { _sig: inputSig, _ver: schemaVer, value, _ts: _now() }; // 防抖落盘(100ms 内同 key 多次写合并) if (_writeTimers.has(key)) clearTimeout(_writeTimers.get(key)); _writeTimers.set(key, setTimeout(() => { _writeTimers.delete(key); _flush(key); }, 100)); return value; } // 同步变体 — 适用于"已 hydrate 但用户同步访问"场景 // 若内存有则返回,无则同步执行 computeFn 并落盘 function getOrComputeSync(key, schemaVer, inputSig, computeFn) { const mem = _mem[key]; if (mem && mem._ver === schemaVer && mem._sig === inputSig) return mem.value; const value = computeFn(); _mem[key] = { _sig: inputSig, _ver: schemaVer, value, _ts: _now() }; if (_writeTimers.has(key)) clearTimeout(_writeTimers.get(key)); _writeTimers.set(key, setTimeout(() => { _writeTimers.delete(key); _flush(key); }, 100)); return value; } // 直接设置值(已有计算结果时,跳过 compute 步骤) function set(key, schemaVer, inputSig, value) { _mem[key] = { _sig: inputSig, _ver: schemaVer, value, _ts: _now() }; if (_writeTimers.has(key)) clearTimeout(_writeTimers.get(key)); _writeTimers.set(key, setTimeout(() => { _writeTimers.delete(key); _flush(key); }, 100)); } // 失效单个 key function invalidate(key) { delete _mem[key]; sglvIDB.remove(STORE_KEY_PREFIX + key); } // 失效所有 key function clear() { const keys = Object.keys(_mem); for (const k of keys) sglvIDB.remove(STORE_KEY_PREFIX + k); for (const k of keys) delete _mem[k]; } // 批量失效:按输入签名谓词(predicate(sig, key) => bool)选择性失效 // 例如:游戏库数据变更时,失效所有 sig 以新 ownedGames.length 为前缀的 key function invalidateByInput(predicate) { for (const k of Object.keys(_mem)) { const entry = _mem[k]; if (entry && predicate(entry._sig, k)) { delete _mem[k]; sglvIDB.remove(STORE_KEY_PREFIX + k); } } } // 启动时从 IDB hydrate 全部 PCC 缓存到内存 // 返回 Promise<{ hydrated: number }> // 注意:必须在 sglvIDB.loadAll() 之后调用,否则 _writing 防抖可能导致 set 期间被读 function hydrate() { try { // sglvIDB._mem 已 loadAll 到内存,扫描所有 pcc_ 前缀的 key 解析回来 // sglvIDB._mem 不是 sglvIDB API 暴露的,但 loadAll 时是 sglvIDB 内部 _mem // 我们通过 sglvIDB.keys() 拿到所有 key,逐个 get 即可 const keys = sglvIDB.keys(); let count = 0; for (const k of keys) { if (typeof k === 'string' && k.startsWith(STORE_KEY_PREFIX)) { const v = sglvIDB.get(k, null); if (v && v._sig != null && v._ver != null && v.value !== undefined) { _mem[_strip(k)] = { _sig: v._sig, _ver: v._ver, value: v.value, _ts: v._ts || Date.now() }; count++; } } } return { hydrated: count }; } catch (e) { console.warn('[SGLV-Core] PersistentComputeCache hydrate failed:', e); return { hydrated: 0 }; } } return { getSync, getSyncWithMeta, has, getOrCompute, getOrComputeSync, set, invalidate, invalidateByInput, clear, hydrate, _flush, _mem, // 暴露给测试 }; })(); // ==================== 5. StageProgress 多阶段进度条 ==================== // 取代单一 spinner,4 阶段状态机:库存 → 详情 → 补充数据 → 完成 // 设计参考 steam-friend-manager-1.2.2:8174 dashSetStage const StageProgress = { _el: null, _stage: 0, _totalStages: 0, _text: '', _pct: 0, ensure() { if (this._el) return; const doc = root.document; if (!doc) return; this._el = doc.createElement('div'); this._el.id = 'sglv-stage-progress'; this._el.innerHTML = `
`; const panel = doc.getElementById('sglv-panel') || doc.body; const header = panel.querySelector ? panel.querySelector('.sglv-panel-header') : null; if (header && header.parentNode) header.parentNode.insertBefore(this._el, header.nextSibling); else panel.appendChild(this._el); }, setStage(n, total, text) { this.ensure(); this._stage = n; this._totalStages = total; this._text = text || ''; // 阶段切换时跳到该阶段起始百分比 this._pct = total > 1 ? Math.max(this._pct, ((n - 1) / total) * 100) : this._pct; this._render(); }, setProgress(pct, text) { this.ensure(); this._pct = Math.min(100, Math.max(0, pct)); if (text != null) this._text = text; this._render(); }, finish(text) { this.setProgress(100, text || '完成'); setTimeout(() => { if (this._el) this._el.style.display = 'none'; }, 1500); }, fail(text) { this.setProgress(0, '失败: ' + text); }, hide() { if (this._el) this._el.style.display = 'none'; }, show() { this.ensure(); if (this._el) this._el.style.display = ''; }, _render() { if (!this._el) return; const fill = this._el.querySelector('.sglv-stage-progress-fill'); const textEl = this._el.querySelector('.sglv-stage-progress-text'); if (fill) fill.style.width = this._pct + '%'; const stagePrefix = this._totalStages > 1 ? `[${this._stage}/${this._totalStages}] ` : ''; if (textEl) textEl.textContent = `${stagePrefix}${this._text} (${Math.round(this._pct)}%)`; } }; // ==================== 6. 节流 onProgress ==================== // 批量补全时主线程不被压垮,默认 800ms 节流 // 设计参考 steam-friend-manager-1.2.2:40 RENDER_THROTTLE_MS = 800 function makeThrottledProgress(onProgress, throttleMs = 800) { if (!onProgress) return null; let last = 0; let pendingArgs = null; let timer = null; const flush = () => { timer = null; if (pendingArgs) { onProgress(...pendingArgs); last = Date.now(); pendingArgs = null; } }; return function (...args) { const now = Date.now(); if (now - last >= throttleMs) { if (timer) { clearTimeout(timer); timer = null; } last = now; onProgress(...args); } else { pendingArgs = args; if (!timer) timer = setTimeout(flush, throttleMs - (now - last)); } }; } // ==================== 7. 智能并发池 ==================== // 滑动窗口模式,完成一个立刻填入下一个,保持并发数恒定 // 遇到 429 或网络错误自动指数退避重试(最多 2 次:1s→2s),429 时自动降低并发数 // 设计参考 steam-friend-manager _concurrentPool // // 契约:task() throw 视为 rejected,return 任意值视为 fulfilled // results[i] = { index, status: 'fulfilled' | 'rejected', value?, reason? } async function concurrentPool(tasks, concurrency, onProgress) { const results = new Array(tasks.length); let nextIndex = 0; let completed = 0; let activeConcurrency = concurrency; const MAX_RETRY = 2; async function runTask(index) { const task = tasks[index]; let retryCount = 0; while (retryCount <= MAX_RETRY) { try { const result = await task(); results[index] = { index, status: 'fulfilled', value: result }; return; } catch (e) { retryCount++; if (retryCount <= MAX_RETRY) { const delayMs = retryCount === 1 ? 1000 : 2000; await new Promise(r => setTimeout(r, delayMs)); if (e.message && (e.message.includes('429') || e.message.includes('rate'))) { activeConcurrency = Math.max(3, Math.floor(activeConcurrency * 0.7)); } } else { results[index] = { index, status: 'rejected', reason: e }; return; } } } } async function worker() { while (nextIndex < tasks.length) { const idx = nextIndex++; await runTask(idx); completed++; if (onProgress) onProgress(completed, tasks.length); } } const workerCount = Math.min(activeConcurrency, tasks.length); await Promise.all(Array.from({ length: workerCount }, () => worker())); return results; } // ==================== 8. 两阶段批量抓取 ==================== // 快查失败 → 精查重试,适用 Steam API、商店页抓取等限流场景 // 设计参考 steam-friend-manager-1.2.2:1707-1793 共同好友两阶段查询 // // 契约:concurrentPool 把 task() throw 视为 rejected,return 任意值视为 fulfilled。 // 本函数 task 失败时 throw,成功时 return { item, value },在 results 里读 r.value。 // failedItems 用 {item, origIndex} 形式,保留原 items 索引以便精查失败时回查。 async function twoPhaseFetch(items, fastFn, carefulFn, options = {}) { const { concurrency = 8, fastTimeout = 3000, carefulTimeout = 8000, onProgress, getKey } = options; const total = items.length; if (total === 0) return { success: [], failed: [] }; const keyOf = getKey || ((x) => x); // 阶段1:快查 const fastResults = await concurrentPool( items.map((item, i) => async () => { const value = await Promise.race([ fastFn(item), new Promise((_, r) => setTimeout(() => r(new Error('fast-timeout')), fastTimeout)) ]); return { item, origIndex: i, value }; }), concurrency, onProgress ? makeThrottledProgress((done, _t) => onProgress(done, total * 2, '快查')) : null ); const success = []; const failedItems = []; // [{ item, origIndex }] fastResults.forEach(r => { if (r.status === 'fulfilled') { const { item, value } = r.value; success.push({ key: keyOf(item), item, value }); } else { failedItems.push({ item: items[r.index], origIndex: r.index }); } }); if (failedItems.length === 0) { if (onProgress) onProgress(total, total * 2, '快查完成'); return { success, failed: [] }; } // 阶段2:精查(对失败项,降并发避免再次限流) const carefulResults = await concurrentPool( failedItems.map(fi => async () => { const value = await Promise.race([ carefulFn(fi.item), new Promise((_, r) => setTimeout(() => r(new Error('careful-timeout')), carefulTimeout)) ]); return { item: fi.item, origIndex: fi.origIndex, value }; }), Math.max(3, Math.floor(concurrency / 2)), onProgress ? makeThrottledProgress((done, _t) => onProgress(total + done, total * 2, '精查')) : null ); const careSuccess = []; const careFailed = []; carefulResults.forEach(r => { if (r.status === 'fulfilled') { const { item, value } = r.value; careSuccess.push({ key: keyOf(item), item, value }); } else { careFailed.push({ key: keyOf(items[r.index]), item: items[r.index], error: r.reason }); } }); if (onProgress) onProgress(total * 2, total * 2, '完成'); return { success: [...success, ...careSuccess], failed: careFailed }; } // ==================== 9. fillPendingSlots 占位 ==================== // pending slot 设计,空位也占位,布局稳定 // 设计参考 steam-friend-manager-1.2.2:6385 function fillPendingSlots(items, max = 6, pendingMarker = (i) => ({ pending: true, slot: i })) { const out = (items || []).slice(0, max); for (let i = out.length; i < max; i++) out.push(pendingMarker(i)); return out; } // ==================== 10. GM_xmlhttpRequest 包装 ==================== // v1.0.4: 安全读取响应文本——浏览器扩展 hook XHR.send 并修改 responseType 为 arraybuffer 时, // resp.responseText 会抛 InvalidStateError,此函数先尝试 responseText,失败后尝试 response function _safeRespText(resp) { try { if (resp.responseText) return resp.responseText; } catch (e) { /* responseType 被外部修改为 arraybuffer */ } try { if (typeof resp.response === 'string') return resp.response; if (resp.response instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(resp.response)); } catch (e2) { /* ignore */ } return ''; } // v1.0.5: 限流响应头解析——GM_xmlhttpRequest 的 responseHeaders 是纯文本, // 正则提取 x-ratelimit-remaining / x-ratelimit-reset(GitHub API 等服务)。 // 返回 { remaining, resetAt } 或 null(无该头)。 function parseRateLimitHeaders(responseHeaders) { if (!responseHeaders || typeof responseHeaders !== 'string') return null; const rem = responseHeaders.match(/x-ratelimit-remaining:\s*(\d+)/i); const rst = responseHeaders.match(/x-ratelimit-reset:\s*(\d+)/i); if (!rem && !rst) return null; const out = {}; if (rem) out.remaining = parseInt(rem[1], 10); if (rst) out.resetAt = parseInt(rst[1], 10) * 1000; return out; } // v1.0.5: HTTP 状态码是否值得重试—— // 确定性 4xx(400/401/404/410 等)重试无意义,立即失败(防 404 重试风暴, // 参见 SGLV 2.9.112 未上市游戏 404 重试风暴教训); // 403/429(限流,如 GitHub API)与 5xx(瞬时故障)按退避重试。 function _httpCanRetry(status) { if (status === 403 || status === 429) return true; if (status >= 500) return true; return false; } function _ratelimitSuffix(r, url) { if (r.status !== 403 && r.status !== 429) return ''; const rl = parseRateLimitHeaders(r.responseHeaders); if (!rl) return ''; const resetTxt = rl.resetAt ? new Date(rl.resetAt).toLocaleTimeString() : ''; console.warn(`[SGLVCore] 限流响应 ${r.status}: 剩余 ${rl.remaining}${resetTxt ? ', 重置于 ' + resetTxt : ''} (${url || ''})`); return rl.remaining !== undefined ? ` (rate-limit remaining=${rl.remaining})` : ''; } // v1.0.5: 网络终态失败系统通知——60s 全局节流防批量失败刷屏; // 宿主脚本未 @grant GM_notification 时静默跳过。返回是否实际发出。 let _lastNetFailNotifyAt = 0; function notifyNetFail(label, err) { try { const now = Date.now(); if (now - _lastNetFailNotifyAt < 60 * 1000) return false; if (typeof GM_notification !== 'function') return false; _lastNetFailNotifyAt = now; GM_notification({ title: 'SGLV 数据获取失败', text: `${label}: ${(err && err.message) || err}\n将继续使用本地缓存数据`, timeout: 6000, }); return true; } catch (e) { return false; } } // JSON 包装:自动 parse + 失败重试 // 测试侧可通过 root.__sglvCoreMockGmXhr 覆盖 // v1.0.5: 瞬态 HTTP 错误(403/429/5xx)改为退避重试(此前一律立即失败); // 确定性 4xx 保持立即失败 // v1.0.6: 新增 options.onHttpError(status, responseText) 可选回调—— // 非 2xx 最终失败(确定性 4xx 或重试耗尽)时调用,返回非空字符串则作为 // 错误消息(宿主提取响应体详情,如 AI 端点 error.message);回调异常回退默认消息 function gmFetchJson(url, options = {}) { const timeout = options.timeout || 20000; const maxRetries = options.retries || 2; const method = options.method || 'GET'; const headers = options.headers || null; const data = options.data || null; const onHttpError = options.onHttpError || null; const gmxhr = root.__sglvCoreMockGmXhr || (typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : null); function httpError(r) { let msg = 'HTTP ' + r.status; if (typeof onHttpError === 'function') { try { const custom = onHttpError(r.status, _safeRespText(r)); if (typeof custom === 'string' && custom) msg = custom; } catch { /* 定制回调异常回退默认消息 */ } } return new Error(msg); } return new Promise((resolve, reject) => { if (!gmxhr) { reject(new Error('GM_xmlhttpRequest unavailable')); return; } let attempt = 0; function doRequest() { const opts = { method, url, timeout, onload(r) { // v1.0.2: 增加 HTTP 状态码校验,避免对错误响应体(404/429/500)做无效 JSON 解析 if (r.status < 200 || r.status >= 300) { // v1.0.5: 瞬态错误退避重试;确定性 4xx 立即失败 if (_httpCanRetry(r.status)) { const suffix = _ratelimitSuffix(r, url); attempt++; if (attempt <= maxRetries) { setTimeout(doRequest, attempt * 1000); return; } const e = httpError(r); if (suffix) e.message += suffix; reject(e); return; } reject(httpError(r)); return; } try { resolve(JSON.parse(_safeRespText(r))); } catch (e) { reject(new Error('JSON parse failed: ' + e.message)); } }, onerror(err) { attempt++; // v1.0.7: 拒绝值必须归一化为 Error——GM onerror 传入的原始对象没有 .message, // 上游若直接 e.message.match(...) 会抛 TypeError 掩盖真实原因 if (attempt <= maxRetries) setTimeout(doRequest, attempt * 1000); else { const msg = (err && typeof err.error === 'string' && err.error) ? err.error : 'network error'; reject(new Error(msg)); } }, ontimeout() { attempt++; if (attempt <= maxRetries) setTimeout(doRequest, attempt * 1000); else reject(new Error('timeout')); } }; if (headers) opts.headers = headers; if (data) opts.data = data; gmxhr(opts); } doRequest(); }); } // 文本包装:返回 raw text(大 JSON 场景避免主线程 parse) // v1.0.5: 瞬态 HTTP 错误(403/429/5xx)改为退避重试;确定性 4xx 保持立即失败 // v1.0.6: 支持 options.headers 透传(与 gmFetchJson 对齐,如商店页年龄验证 Cookie) function gmFetchText(url, options = {}) { const timeout = options.timeout || 20000; const maxRetries = options.retries || 2; const headers = options.headers || null; const gmxhr = root.__sglvCoreMockGmXhr || (typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : null); return new Promise((resolve, reject) => { if (!gmxhr) { reject(new Error('GM_xmlhttpRequest unavailable')); return; } let attempt = 0; function doRequest() { const opts = { method: 'GET', url, timeout, onload(r) { if (r.status >= 200 && r.status < 300) { const text = _safeRespText(r); if (text) resolve(text); else reject(new Error('HTTP ' + r.status)); } else if (_httpCanRetry(r.status)) { const suffix = _ratelimitSuffix(r, url); attempt++; if (attempt <= maxRetries) { setTimeout(doRequest, attempt * 1000); return; } reject(new Error('HTTP ' + r.status + suffix)); } else reject(new Error('HTTP ' + r.status)); }, onerror(err) { attempt++; // v1.0.7: 同 gmFetchJson,拒绝值归一化为 Error(GM 原始对象无 .message) if (attempt <= maxRetries) setTimeout(doRequest, attempt * 1000); else { const msg = (err && typeof err.error === 'string' && err.error) ? err.error : 'network error'; reject(new Error(msg)); } }, ontimeout() { attempt++; if (attempt <= maxRetries) setTimeout(doRequest, attempt * 1000); else reject(new Error('timeout')); } }; if (headers) opts.headers = headers; gmxhr(opts); } doRequest(); }); } // ==================== 暴露 API ==================== const api = { version: '1.0.7', apiVersion: 1, NS, nsKey, IDB: sglvIDB, negCache, ensureCacheVersions, StageProgress, makeThrottledProgress, concurrentPool, twoPhaseFetch, fillPendingSlots, gmFetchJson, gmFetchText, parseRateLimitHeaders, // v1.0.5: 限流响应头解析 notifyNetFail, // v1.0.5: 网络终态失败系统通知(60s 节流) PersistentComputeCache, // v1.0.3: 持久化计算缓存 }; // 暴露到全局(GM 脚本风格) root.SGLVCore = api; // 同时挂 unsafeWindow(某些 sandbox 环境) if (typeof unsafeWindow !== 'undefined' && unsafeWindow !== root) { try { unsafeWindow.SGLVCore = api; } catch (e) { /* ignore */ } } return api; })(typeof window !== 'undefined' ? window : globalThis); /* ---------- sglv-shared-css.lib.js ---------- */ // ==UserScript== // @name SGLV 共享样式 (Library) // @name:en SGLV Shared CSS (Library) // @namespace https://greasyfork.org/scripts/588483 // @version 1.1.10 // @description Steam 游戏库查看器 - 共享 CSS 样式库。供主脚本与收藏模块 @require 引用。 // @description:en Steam Game Library Viewer - Shared CSS styles library. For host script and collection module @require. // @author SmallRob // @license MIT // @noframes // ==/UserScript== /* * SGLV Shared CSS Library v1.0.0 * * 包含主面板、收藏浮窗、侧边栏等全部 CSS 样式。 * 通过 window.SGLVSharedCSS 暴露 CSS 字符串,主脚本经 GM_addStyle 注入。 * 更新此库后,所有 @require 引用方在 Tampermonkey 外部资源检查周期(默认~1天)内自动获取新样式。 */ window.SGLVSharedCSS = `:root { --sglv-bg: rgba(21, 30, 46, 0.97); --sglv-bg-card: rgba(30, 41, 59, 0.95); --sglv-border: rgba(102, 192, 244, 0.2); --sglv-border-focus: rgba(102, 192, 244, 0.6); --sglv-text: #c7d5e0; --sglv-text-secondary: #8a9ba8; --sglv-accent: #66c0f4; --sglv-green: #4caf50; --sglv-red: #f44336; --sglv-orange: #ff9800; --sglv-radius: 12px; --sglv-shadow: 0 8px 32px rgba(0,0,0,0.5); --sglv-transition: all 0.2s ease; --sglv-kpi-bg: rgba(15, 23, 42, 0.5); --sglv-section-bg: rgba(15, 23, 42, 0.4); --sglv-card-bg: rgba(255,255,255,0.04); --sglv-card-border: rgba(255,255,255,0.06); --sglv-card-radius: 8px; --sglv-scrollbar-width: 6px; --sglv-scrollbar-thumb: rgba(255,255,255,0.12); } /* v2.3.28: KPI 卡片共享基类——合并 stat-card/stat-dash-card/trend-kpi-card/playtime-kpi-card/wl-kpi-card 的重复定义 */ .sglv-stat-card, .sglv-stat-dash-card, .sglv-trend-kpi-card, .sglv-playtime-kpi-card, .sglv-wl-kpi-card { position: relative; background: var(--sglv-kpi-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 6px 10px 5px; text-align: left; overflow: hidden; transition: border-color .2s, transform .2s; } .sglv-stat-card:hover, .sglv-stat-dash-card:hover, .sglv-trend-kpi-card:hover, .sglv-playtime-kpi-card:hover, .sglv-wl-kpi-card:hover { border-color: rgba(102,192,244,0.3); transform: translateY(-1px); } .sglv-stat-card::before, .sglv-stat-dash-card::before, .sglv-trend-kpi-card::before, .sglv-playtime-kpi-card::before, .sglv-wl-kpi-card::before { content: ''; position: absolute; top: -12px; right: -12px; width: 40px; height: 40px; border-radius: 50%; opacity: 0.12; filter: blur(8px); } /* v2.3.28: KPI 颜色修饰符——合并各卡片类型的 blue/green/amber/purple/red 颜色变体 */ .sglv-stat-card.blue, .sglv-stat-dash-card.blue, .sglv-trend-kpi-card.blue, .sglv-playtime-kpi-card.blue { border-color: rgba(102,192,244,0.35); } .sglv-stat-card.blue::before, .sglv-stat-dash-card.blue::before, .sglv-trend-kpi-card.blue::before, .sglv-playtime-kpi-card.blue::before { background: #66c0f4; } .sglv-stat-card.green, .sglv-stat-dash-card.green, .sglv-trend-kpi-card.mine, .sglv-playtime-kpi-card.green { border-color: rgba(34,197,94,0.35); } .sglv-stat-card.green::before, .sglv-stat-dash-card.green::before, .sglv-trend-kpi-card.mine::before, .sglv-playtime-kpi-card.green::before { background: #22c55e; } .sglv-stat-card.amber, .sglv-stat-dash-card.amber, .sglv-playtime-kpi-card.amber { border-color: rgba(245,158,11,0.35); } .sglv-stat-card.amber::before, .sglv-stat-dash-card.amber::before, .sglv-playtime-kpi-card.amber::before { background: #fbbf24; } .sglv-stat-card.purple, .sglv-stat-dash-card.purple, .sglv-playtime-kpi-card.purple { border-color: rgba(139,92,246,0.35); } .sglv-stat-card.purple::before, .sglv-stat-dash-card.purple::before, .sglv-playtime-kpi-card.purple::before { background: #a78bfa; } .sglv-stat-card.red, .sglv-stat-dash-card.red, .sglv-trend-kpi-card.red { border-color: rgba(244,67,54,0.35); } .sglv-stat-card.red::before, .sglv-stat-dash-card.red::before, .sglv-trend-kpi-card.red::before { background: #f44336; } /* v2.3.28: 滚动条共享样式——统一所有 ::-webkit-scrollbar 宽度和拇指颜色 */ .sglv-content::-webkit-scrollbar, .sglv-pub-main::-webkit-scrollbar, .sglv-pub-sidebar::-webkit-scrollbar, .sglv-gs-panel::-webkit-scrollbar, .sglv-pt-all-list::-webkit-scrollbar, .sglv-playtime-split-left #sglv-pt-top-list::-webkit-scrollbar { width: var(--sglv-scrollbar-width); } .sglv-content::-webkit-scrollbar-thumb, .sglv-pub-main::-webkit-scrollbar-thumb, .sglv-pub-sidebar::-webkit-scrollbar-thumb, .sglv-gs-panel::-webkit-scrollbar-thumb, .sglv-pt-all-list::-webkit-scrollbar-thumb, .sglv-playtime-split-left #sglv-pt-top-list::-webkit-scrollbar-thumb { background: var(--sglv-scrollbar-thumb); border-radius: 3px; } .sglv-fab { cursor: pointer !important; } .sglv-panel { position: fixed !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%) !important; width: 1200px !important; max-width: calc(100vw - 40px) !important; height: 85vh !important; max-height: 860px !important; background: var(--sglv-bg) !important; border: 1px solid var(--sglv-border) !important; border-radius: var(--sglv-radius) !important; box-shadow: var(--sglv-shadow) !important; z-index: 1000000 !important; display: none !important; flex-direction: column !important; overflow: hidden !important; font-family: "Motiva Sans", Arial, Helvetica, sans-serif !important; color: var(--sglv-text) !important; } .sglv-panel.sglv-show { display: flex !important; } .sglv-overlay { position: fixed !important; inset: 0 !important; background: rgba(11, 17, 32, 0.85) !important; z-index: 999999 !important; display: none !important; } .sglv-overlay.sglv-show { display: block !important; } .sglv-header { display: flex !important; align-items: center !important; justify-content: space-between !important; padding: 12px 20px !important; background: linear-gradient(135deg, #1b2838, #2a475e) !important; border-bottom: 1px solid var(--sglv-border) !important; flex-shrink: 0 !important; } .sglv-header h2 { margin: 0 !important; font-size: 16px !important; font-weight: 600 !important; color: #fff !important; display: flex !important; align-items: center !important; gap: 8px !important; white-space: nowrap !important; } .sglv-header-actions { display: flex; align-items: center; gap: 4px; } .sglv-header-btn { width: 30px !important; height: 30px !important; border-radius: 6px !important; border: none !important; background: rgba(255,255,255,0.06) !important; color: #8a9ba8 !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; transition: var(--sglv-transition) !important; } .sglv-header-btn:hover { background: rgba(255,255,255,0.12) !important; color: #fff !important; } .sglv-header-btn.sglv-active { background: rgba(102,192,244,0.2) !important; color: var(--sglv-accent) !important; } .sglv-header-btn svg { width: 14px; height: 14px; } .sglv-tabs { display: flex !important; background: rgba(15, 23, 42, 0.6) !important; padding: 4px !important; gap: 4px !important; border-bottom: 1px solid var(--sglv-border) !important; flex-shrink: 0 !important; } .sglv-tab { flex: 1 !important; border: none !important; background: transparent !important; color: var(--sglv-text-secondary) !important; padding: 8px 16px !important; font-size: 13px !important; font-weight: 600 !important; border-radius: 6px !important; cursor: pointer !important; transition: var(--sglv-transition) !important; display: flex !important; align-items: center !important; justify-content: center !important; gap: 6px !important; } .sglv-tab:hover { color: #fff !important; } .sglv-tab.active { background: rgba(102, 192, 244, 0.2) !important; color: var(--sglv-accent) !important; } .sglv-tab svg { width: 14px; height: 14px; flex-shrink: 0 !important; } .sglv-tab { white-space: nowrap !important; } /* v2.5.0: 游戏库工具栏——两行紧凑布局,全部 !important 强制生效 */ /* 第1行:搜索 + 排序 + 视图切换 + 分页(nowrap 紧凑,overflow hidden 防溢出) */ .sglv-toolbar { display: flex !important; align-items: center !important; padding: 6px 12px !important; background: var(--sglv-section-bg) !important; border-bottom: 1px solid var(--sglv-border) !important; gap: 8px !important; flex-shrink: 0 !important; flex-wrap: nowrap !important; overflow: hidden !important; } .sglv-search { flex: 0 1 200px !important; min-width: 100px !important; max-width: 280px !important; padding: 5px 10px !important; background: rgba(255,255,255,0.08) !important; border: 1px solid rgba(255,255,255,0.1) !important; border-radius: 6px !important; color: #fff !important; font-size: 12px !important; outline: none !important; } .sglv-search:focus { border-color: rgba(102,192,244,0.5) !important; } .sglv-search::placeholder { color: #5a6a7a !important; } /* v2.4.5: 库存排序下拉框 */ .sglv-sort-select { padding: 5px 8px !important; background: rgba(255,255,255,0.08) !important; border: 1px solid rgba(255,255,255,0.1) !important; border-radius: 6px !important; color: #fff !important; font-size: 12px !important; outline: none !important; cursor: pointer !important; flex-shrink: 0 !important; max-width: 140px !important; } .sglv-sort-select:focus { border-color: rgba(102,192,244,0.5) !important; } .sglv-sort-select option { background: #1b2838 !important; color: #fff !important; } .sglv-btn { padding: 6px 14px !important; border: none !important; border-radius: 6px !important; font-size: 12px !important; font-weight: 600 !important; cursor: pointer !important; transition: var(--sglv-transition) !important; display: inline-flex !important; align-items: center !important; gap: 5px !important; white-space: nowrap !important; } .sglv-btn svg { width: 13px; height: 13px; } .sglv-btn-primary { background: var(--sglv-accent) !important; color: #fff !important; } .sglv-btn-primary:hover { filter: brightness(1.15) !important; } .sglv-btn-primary:disabled { opacity: 0.5 !important; cursor: not-allowed !important; } .sglv-btn-secondary { background: rgba(255,255,255,0.08) !important; color: var(--sglv-text) !important; border: 1px solid rgba(255,255,255,0.1) !important; } .sglv-btn-secondary:hover { background: rgba(255,255,255,0.15) !important; } .sglv-btn-secondary.active { background: rgba(102,192,244,0.2) !important; border-color: rgba(102,192,244,0.4) !important; color: var(--sglv-accent) !important; } .sglv-btn-icon { width: 30px !important; height: 30px !important; padding: 0 !important; display: flex !important; align-items: center !important; justify-content: center !important; } /* v2.5.0: 视图切换按钮组——紧贴排序右侧,统一尺寸 */ .sglv-view-toggle { display: flex !important; gap: 2px !important; flex-shrink: 0 !important; } .sglv-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; } .sglv-content { flex: 1; overflow-y: auto; padding: 16px; } /* v2.5.0: 独立分页栏(底部全宽) */ .sglv-pagination { display: flex !important; align-items: center !important; justify-content: center !important; gap: 8px !important; padding: 10px !important; background: var(--sglv-section-bg) !important; border-top: 1px solid var(--sglv-border) !important; flex-shrink: 0 !important; } /* v2.5.0: 工具栏内嵌分页——紧凑无背景,推到右侧,按钮缩小 */ .sglv-toolbar .sglv-pagination { padding: 0 !important; background: transparent !important; border: none !important; margin-left: auto !important; gap: 4px !important; flex-shrink: 0 !important; } .sglv-toolbar .sglv-pagination .sglv-btn { padding: 2px 8px !important; font-size: 11px !important; min-width: auto !important; } .sglv-page-info { font-size: 11px !important; color: var(--sglv-text-secondary) !important; white-space: nowrap !important; flex-shrink: 0 !important; } /* v2.5.0: 筛选标签栏——独占第2行,允许换行,视觉区分 */ .sglv-filter-bar { display: flex !important; align-items: center !important; gap: 6px !important; padding: 5px 12px !important; background: rgba(15, 23, 42, 0.3) !important; border-bottom: 1px solid var(--sglv-border) !important; flex-shrink: 0 !important; flex-wrap: wrap !important; } /* v2.6.2: 筛选栏分组 + 内联分页——筛选按钮与分页放一行,右侧自动分页 */ .sglv-filter-bar-group { display:inline-flex !important; align-items:center !important; gap:4px !important; flex-wrap:wrap !important; } /* v2.6.2: 类别按钮(全部/已游玩/未游玩/共享/仅自己)——胶囊形,padding 4px 10px 紧凑 */ .sglv-filter-bar-group .sglv-category-btn { padding: 3px 10px !important; font-size: 11px !important; } .sglv-filter-sep { width:1px !important; height:18px !important; background:var(--sglv-border) !important; margin:0 4px !important; flex-shrink:0 !important; } /* v2.6.2: 内联分页——紧凑按钮 (padding 3px 10px),与筛选按钮同一行,靠近右边缘 */ .sglv-pagination-inline { display:flex !important; align-items:center !important; gap:4px !important; padding:0 !important; background:transparent !important; border:none !important; flex-shrink:0 !important; margin-left:auto !important; } .sglv-pagination-inline .sglv-btn { padding: 3px 10px !important; font-size: 11px !important; min-width:auto !important; } .sglv-pagination-inline .sglv-page-info { font-size: 11px !important; padding: 0 4px !important; } .sglv-card-grid { display: grid !important; grid-template-columns: repeat(auto-fill, minmax(135px, 1fr)) !important; gap: 10px !important; } .sglv-game-card { background: rgba(255,255,255,0.04) !important; border: 1px solid rgba(255,255,255,0.06) !important; border-radius: 8px !important; overflow: hidden !important; transition: var(--sglv-transition) !important; cursor: pointer !important; } .sglv-game-card:hover { background: rgba(255,255,255,0.08) !important; border-color: rgba(102,192,244,0.3) !important; transform: translateY(-2px) !important; } .sglv-card-img-wrap { position: relative !important; width: 100% !important; aspect-ratio: 2/3 !important; overflow: hidden !important; background: rgba(255,255,255,0.05) !important; } .sglv-card-img { width: 100% !important; height: 100% !important; object-fit: cover !important; display: block !important; } /* v2.3.22: 游玩时长浮层——置于封面右下角,灰色遮罩背景 */ .sglv-card-playtime-overlay { position: absolute !important; bottom: 4px !important; right: 4px !important; font-size: 10px !important; font-weight: 700 !important; padding: 2px 6px !important; border-radius: 4px !important; background: rgba(0,0,0,0.65) !important; color: #66c0f4 !important; backdrop-filter: blur(4px) !important; -webkit-backdrop-filter: blur(4px) !important; line-height: 1.2 !important; pointer-events: none !important; z-index: 2 !important; display: inline-flex !important; align-items: center !important; gap: 3px !important; } .sglv-card-playtime-overlay svg { width: 9px !important; height: 9px !important; opacity: 0.85 !important; } .sglv-card-body { padding: 6px 8px !important; } .sglv-card-name { font-size: 12px !important; font-weight: 600 !important; color: #e0e0e0 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } .sglv-card-id { font-size: 10px !important; color: var(--sglv-text-secondary) !important; margin-top: 2px !important; } .sglv-card-id-inline { font-size: 10px !important; color: var(--sglv-text-secondary) !important; margin-left: 6px !important; font-weight: 400 !important; } .sglv-list-id-inline { font-size: 11px !important; color: var(--sglv-text-secondary) !important; margin-left: 6px !important; font-weight: 400 !important; flex-shrink: 0 !important; } .sglv-meta-item { display: inline-flex !important; align-items: center !important; gap: 3px !important; white-space: nowrap !important; } .sglv-meta-item svg { width: 10px !important; height: 10px !important; color: var(--sglv-text-secondary) !important; flex-shrink: 0 !important; opacity: 0.7 !important; } .sglv-card-badge { display: inline-block !important; font-size: 10px !important; font-weight: 600 !important; padding: 1px 6px !important; border-radius: 3px !important; margin-top: 4px !important; } .sglv-card-badge.owned { background: rgba(76,175,80,0.2) !important; color: #4caf50 !important; } .sglv-card-badge.missing { background: rgba(244,67,54,0.15) !important; color: #f44336 !important; } .sglv-card-badge.shared { background: rgba(139, 92, 246, 0.2) !important; color: #a78bfa !important; } /* v1.9.34: 移除 .sglv-game-card.unowned 灰色滤镜——所有封面正常显示 */ /* v2.4.3: 愿望单横板封面视图(每行3个,参考 steam-family-game-analysis v1.58 fa-wl-cover) */ .sglv-wl-cover-grid { display: grid !important; grid-template-columns: repeat(3, 1fr) !important; gap: 12px !important; } .sglv-wl-cover-card { background: rgba(255,255,255,0.04) !important; border: 1px solid rgba(255,255,255,0.06) !important; border-radius: 8px !important; overflow: hidden !important; transition: var(--sglv-transition) !important; cursor: pointer !important; display: flex !important; flex-direction: column !important; } .sglv-wl-cover-card:hover { background: rgba(255,255,255,0.08) !important; border-color: rgba(102,192,244,0.3) !important; transform: translateY(-2px) !important; } .sglv-wl-cover-img-wrap { position: relative !important; width: 100% !important; aspect-ratio: 460/215 !important; overflow: hidden !important; background: rgba(255,255,255,0.05) !important; flex-shrink: 0 !important; } .sglv-wl-cover-img { width: 100% !important; height: 100% !important; object-fit: cover !important; display: block !important; } .sglv-wl-cover-badges { position: absolute !important; top: 6px !important; left: 6px !important; display: flex !important; gap: 4px !important; z-index: 2 !important; } .sglv-wl-cover-badges .sglv-card-badge { backdrop-filter: blur(4px) !important; } .sglv-wl-cover-badges .sglv-wl-family-badge { margin-left: 0 !important; } .sglv-wl-cover-body { padding: 8px 10px 9px !important; display: flex !important; flex-direction: column !important; gap: 5px !important; } @media (max-width: 1100px) { .sglv-wl-cover-grid { grid-template-columns: repeat(2, 1fr) !important; } } @media (max-width: 700px) { .sglv-wl-cover-grid { grid-template-columns: 1fr !important; } } /* ====== PS 会免标签页 (v2.6.0) ====== */ /* PS 蓝色占位封面:steamAppId 缺失或全部 CDN 失败时使用 */ .sglv-ps-cover-placeholder { width:100% !important; height:100% !important; display:flex !important; flex-direction:column !important; align-items:center !important; justify-content:center !important; gap:8px !important; padding:12px !important; background:linear-gradient(135deg,#003791 0%,#0070D1 55%,#1b3a7a 100%) !important; color:rgba(255,255,255,0.95) !important; text-align:center !important; box-shadow:inset 0 0 30px rgba(0,0,0,0.25) !important; } .sglv-ps-logo { width:50px !important; height:20px !important; opacity:0.55 !important; color:#fff !important; flex-shrink:0 !important; } .sglv-ps-cover-name { font-size:11px !important; font-weight:600 !important; line-height:1.35 !important; display:-webkit-box !important; -webkit-line-clamp:3 !important; -webkit-box-orient:vertical !important; overflow:hidden !important; text-shadow:0 1px 2px rgba(0,0,0,0.5) !important; word-break:break-word !important; } /* 档位徽章(卡片封面右上角)——必备=金 / 升级=蓝 / 高级=紫 */ .sglv-ps-tier-badge { display:inline-block !important; font-size:10px !important; font-weight:700 !important; padding:2px 8px !important; border-radius:10px !important; letter-spacing:0.3px !important; } .sglv-card-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; } .sglv-ps-tier-badge.tier-essential { background:rgba(255,205,60,0.95) !important; color:#3d2c00 !important; } .sglv-ps-tier-badge.tier-extra { background:rgba(33,150,243,0.95) !important; color:#fff !important; } .sglv-ps-tier-badge.tier-premium { background:rgba(179,136,255,0.95) !important; color:#fff !important; } /* 平台标签(封面左下角:Steam / PS 独占) */ .sglv-ps-platform-tag { position:absolute !important; bottom:6px !important; left:6px !important; font-size:9px !important; font-weight:700 !important; padding:1px 6px !important; border-radius:4px !important; z-index:2 !important; letter-spacing:0.3px !important; } .sglv-ps-platform-tag.steam { background:rgba(0,0,0,0.62) !important; color:#66c0f4 !important; } .sglv-ps-platform-tag.ps-only { background:rgba(0,112,209,0.88) !important; color:#fff !important; } .sglv-ps-owned-badge { position:absolute !important; bottom:6px !important; right:6px !important; z-index:2 !important; margin-top:0 !important; backdrop-filter:blur(4px) !important; } /* 状态徽章 */ .sglv-ps-status-badge { display:inline-block !important; font-size:10px !important; font-weight:600 !important; padding:1px 6px !important; border-radius:3px !important; white-space:nowrap !important; } .sglv-ps-status-badge.ps-status-in { background:rgba(76,175,80,0.2) !important; color:#4caf50 !important; } .sglv-ps-status-badge.ps-status-active { background:rgba(255,205,60,0.2) !important; color:#FFCD3C !important; } .sglv-ps-status-badge.ps-status-expired { background:rgba(158,158,158,0.2) !important; color:#b0b0b0 !important; } .sglv-ps-status-badge.ps-status-leaving { background:rgba(255,152,0,0.2) !important; color:#ff9800 !important; } .sglv-ps-status-badge.ps-status-out { background:rgba(244,67,54,0.2) !important; color:#f44336 !important; } .sglv-ps-month { font-size:10px !important; color:var(--sglv-text-secondary) !important; white-space:nowrap !important; } /* v2.6.1: PS 卡片底部"时间+月份+版本"行——inline-flex 居中,时钟 svg 固定尺寸避免被文字行高压扁 */ /* v2.6.1: PS 卡片底部"时间+月份+版本"行——inline-flex 居中,时钟 svg 固定尺寸避免被文字行高压扁 */ .sglv-ps-meta-row { font-size:11px !important; color:var(--sglv-text-secondary) !important; display:flex !important; align-items:center !important; gap:6px !important; flex-wrap:nowrap !important; overflow:hidden !important; min-width:0 !important; } .sglv-ps-month-item { display:inline-flex !important; align-items:center !important; gap:3px !important; white-space:nowrap !important; flex-shrink:0 !important; } .sglv-ps-clock { display:inline-flex !important; align-items:center !important; justify-content:center !important; width:11px !important; height:11px !important; flex-shrink:0 !important; line-height:1 !important; opacity:0.85 !important; } .sglv-ps-clock svg { width:11px !important; height:11px !important; display:block !important; vertical-align:top !important; } .sglv-ps-versions { display:inline-flex !important; gap:2px !important; flex-wrap:wrap !important; min-width:0 !important; } /* 筛选栏分组与分隔 */ .sglv-ps-filter-group { display:flex !important; gap:6px !important; flex-wrap:wrap !important; } .sglv-ps-filter-sep { width:1px !important; align-self:stretch !important; background:var(--sglv-border) !important; margin:0 4px !important; } /* 列表视图 PS 独占图标 */ .sglv-ps-list-icon { display:flex !important; align-items:center !important; justify-content:center !important; background:linear-gradient(135deg,#003791,#0070D1) !important; color:#fff !important; font-weight:700 !important; font-size:12px !important; letter-spacing:0.5px !important; } /* PS 独占卡片去掉默认灰底,由蓝色占位填满 */ .sglv-ps-exclusive .sglv-card-img-wrap { background:transparent !important; } /* PS 卡片悬停时强化蓝色边框 */ .sglv-ps-card:hover { border-color:rgba(0,112,209,0.45) !important; } .sglv-ps-card.sglv-ps-exclusive:hover { border-color:rgba(179,136,255,0.4) !important; } /* PS 会免标签页容器布局 */ .sglv-psplus-wrap { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:10px; } .sglv-header-btn.sglv-export-csv-btn, .sglv-header-btn.sglv-export-json-btn { font-size: 10px !important; font-weight: bold !important; width: auto !important; padding: 0 8px !important; } .sglv-pub-card { margin-bottom: 12px; padding: 12px 16px; border-radius: 8px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); cursor: pointer; } .sglv-series-card { margin-bottom: 10px; padding: 10px 14px; border-radius: 8px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); cursor: pointer; } .sglv-card-row { display: flex; align-items: center; justify-content: space-between; } .sglv-card-row.gap6 { margin-bottom: 6px; } .sglv-card-row.gap4 { margin-bottom: 4px; } .sglv-pub-card-name { font-size: 14px; font-weight: 600; color: #fff; } .sglv-series-card-name { font-size: 13px; font-weight: 600; color: #e0e0e0; } .sglv-card-stats { font-size: 12px; color: #8f98a0; } .sglv-card-stats.complete { color: #4caf50; } .sglv-badge-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin-top: 4px; } .sglv-info-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; } /* v2.9.49: 移除 !important——这些定义在 CSS 中位于共享基类之后,同特异性下自然覆盖 */ .sglv-trend-kpi-card.red { border-color: rgba(244,67,54,0.35); } .sglv-trend-kpi-card.red .sglv-trend-kpi-value { color: #f44336; } .sglv-trend-kpi-card.red::before { background: #f44336; } .sglv-trend-kpi-card.red .sglv-trend-kpi-icon { background: rgba(244,67,54,0.15); color: #f44336; } .sglv-trend-kpi-card.orange { border-color: rgba(255,152,0,0.35); } .sglv-trend-kpi-card.orange .sglv-trend-kpi-value { color: #ff9800; } .sglv-trend-kpi-card.orange::before { background: #ff9800; } .sglv-trend-kpi-card.orange .sglv-trend-kpi-icon { background: rgba(255,152,0,0.15); color: #ff9800; } .sglv-trend-kpi-card.blue { border-color: rgba(96,165,250,0.35); } .sglv-trend-kpi-card.blue .sglv-trend-kpi-value { color: #60a5fa; } .sglv-trend-kpi-card.blue::before { background: #60a5fa; } .sglv-trend-kpi-card.blue .sglv-trend-kpi-icon { background: rgba(96,165,250,0.15); color: #60a5fa; } .sglv-trend-kpi-card.gray { border-color: rgba(148,163,184,0.35); } .sglv-trend-kpi-card.gray .sglv-trend-kpi-value { color: #94a3b8; } .sglv-trend-kpi-card.gray::before { background: #94a3b8; } .sglv-trend-kpi-card.gray .sglv-trend-kpi-icon { background: rgba(148,163,184,0.15); color: #94a3b8; } /* 愿望单标签页 (v2.3.19):左侧仪表 + 右侧游戏视图 */ .sglv-wishlist-wrap { flex: 1; overflow: hidden; padding: 12px; display: flex; flex-direction: row; gap: 12px; min-height: 0; } .sglv-wl-side { width: 320px; flex-shrink: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; min-height: 0; padding-right: 4px; } .sglv-wl-main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } .sglv-wl-main .sglv-content { flex: 1; overflow-y: auto; min-height: 0; } @media (max-width: 900px) { .sglv-wishlist-wrap { flex-direction: column; overflow-y: auto; } .sglv-wl-side { width: 100%; overflow: visible; } } .sglv-wl-section { background: var(--sglv-section-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 12px; flex-shrink: 0; } .sglv-wl-bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; } .sglv-wl-bar-name { width: 84px; text-align: right; font-size: 11px; color: var(--sglv-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; } .sglv-wl-bar-track { flex: 1; height: 14px; background: rgba(255,255,255,0.05); border-radius: 4px; overflow: hidden; } .sglv-wl-bar-fill { height: 100%; border-radius: 4px; transition: width .3s; } .sglv-wl-bar-val { width: 36px; font-size: 11px; color: var(--sglv-text-secondary); flex-shrink: 0; } /* v2.8.0: 我的类别统计卡片 */ .sglv-wl-catstats-list { display: flex; flex-direction: column; gap: 6px; } .sglv-wl-cat-card { padding: 6px 8px; border-radius: 6px; background: rgba(255,255,255,0.03); border: 1px solid var(--sglv-border); cursor: pointer; transition: background .2s, border-color .2s; } .sglv-wl-cat-card:hover { background: rgba(255,255,255,0.07); border-color: var(--cat-color, var(--sglv-accent)); } .sglv-wl-cat-card.active { background: rgba(99,179,237,0.12); border-color: var(--sglv-accent); box-shadow: 0 0 0 1px var(--sglv-accent) inset; } .sglv-wl-cat-header { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; } .sglv-wl-cat-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cat-color, #66c0f4); flex-shrink: 0; } .sglv-wl-cat-name { flex: 1; font-size: 12px; font-weight: 600; color: var(--sglv-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-wl-cat-count { font-size: 12px; font-weight: 700; color: var(--sglv-text); flex-shrink: 0; display: inline-flex; align-items: baseline; gap: 3px; } .sglv-wl-cat-pct { font-size: 10px; font-weight: 400; color: var(--sglv-text-secondary); } .sglv-wl-cat-stats { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } .sglv-wl-cat-stat { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; color: var(--sglv-text-secondary); } .sglv-wl-cat-stat svg { width: 11px; height: 11px; flex-shrink: 0; opacity: 0.8; } .sglv-wl-cat-card .sglv-wl-bar-track { height: 4px; } /* 愿望单增强 KPI 卡片 (v2.3.21):带图标+数值+标签+子文本 */ .sglv-wl-kpi-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; flex-shrink: 0; } /* v2.3.28: wl-kpi-card 基类/hover/::before 已合并至共享 KPI 样式,仅保留差异化 padding */ .sglv-wl-kpi-card { padding: 10px 10px 8px; } .sglv-wl-kpi-card.kpi-total::before { background: #f43f5e; } .sglv-wl-kpi-card.kpi-value::before { background: #10b981; } .sglv-wl-kpi-card.kpi-sale::before { background: #f59e0b; } .sglv-wl-kpi-card.kpi-free::before { background: #6366f1; } .sglv-wl-kpi-card.kpi-lib::before { background: #06b6d4; } .sglv-wl-kpi-card.kpi-year::before { background: #a855f7; } .sglv-wl-kpi-icon { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 6px; margin-bottom: 6px; font-size: 12px; } .sglv-wl-kpi-card.kpi-total .sglv-wl-kpi-icon { background: rgba(244,63,94,0.15); color: #f43f5e; } .sglv-wl-kpi-card.kpi-value .sglv-wl-kpi-icon { background: rgba(16,185,129,0.15); color: #10b981; } .sglv-wl-kpi-card.kpi-sale .sglv-wl-kpi-icon { background: rgba(245,158,11,0.15); color: #f59e0b; } .sglv-wl-kpi-card.kpi-free .sglv-wl-kpi-icon { background: rgba(99,102,241,0.15); color: #6366f1; } .sglv-wl-kpi-card.kpi-lib .sglv-wl-kpi-icon { background: rgba(6,182,212,0.15); color: #06b6d4; } .sglv-wl-kpi-card.kpi-year .sglv-wl-kpi-icon { background: rgba(168,85,247,0.15); color: #a855f7; } .sglv-wl-kpi-value { font-size: 18px; font-weight: 800; color: #fff; line-height: 1.2; } .sglv-wl-kpi-label { font-size: 10px; color: var(--sglv-text-secondary); margin-top: 2px; } .sglv-wl-kpi-sub { font-size: 9px; color: var(--sglv-text-secondary); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.8; } /* 愿望单竖向条形图(价格区间/折扣分布) v2.3.23: 修复柱子不可见——列需拉伸至容器高度,轨道 flex:1 才有确定高度,填充百分比才生效 */ .sglv-wl-vbar-chart { display: flex; align-items: stretch; gap: 4px; height: 92px; padding: 4px 0; } .sglv-wl-vbar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; min-width: 0; height: 100%; } .sglv-wl-vbar-val { font-size: 10px; color: var(--sglv-text); font-weight: 600; flex-shrink: 0; } .sglv-wl-vbar-track { width: 100%; max-width: 28px; min-height: 2px; background: rgba(255,255,255,0.06); border-radius: 4px 4px 0 0; position: relative; flex: 1; display: flex; align-items: flex-end; justify-content: center; } .sglv-wl-vbar-fill { width: 100%; border-radius: 4px 4px 0 0; transition: height .3s; min-height: 2px; } .sglv-wl-vbar-label { font-size: 9px; color: var(--sglv-text-secondary); text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; flex-shrink: 0; } /* 按年添加趋势迷你柱状图 v2.3.23: 修复柱子塌陷——新增轨道包裹,柱子按轨道高度百分比渲染,峰值高亮突出重点 */ .sglv-wl-year-line { display: flex; align-items: stretch; gap: 2px; height: 76px; padding: 4px 0; } .sglv-wl-year-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; height: 100%; } .sglv-wl-year-track { flex: 1; width: 100%; min-height: 2px; display: flex; align-items: flex-end; justify-content: center; } .sglv-wl-year-bar { width: 100%; max-width: 20px; min-height: 2px; border-radius: 3px 3px 0 0; background: linear-gradient(180deg, rgba(99,102,241,0.85), rgba(99,102,241,0.35)); transition: height .3s; } .sglv-wl-year-bar.peak { background: linear-gradient(180deg, rgba(102,192,244,0.95), rgba(102,192,244,0.4)); box-shadow: 0 0 8px rgba(102,192,244,0.35); } .sglv-wl-year-val { font-size: 9px; color: var(--sglv-text); font-weight: 600; flex-shrink: 0; } .sglv-wl-year-label { font-size: 9px; color: var(--sglv-text-secondary); flex-shrink: 0; } .sglv-wl-price { font-size: 12px; font-weight: 700; color: #aee435; } /* v2.3.23: 愿望单排序位次徽章(priority 排名,TOP3 金银铜高亮) */ .sglv-wl-rank { display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; line-height: 1; padding: 2px 6px; border-radius: 4px; background: rgba(102,192,244,0.12); color: var(--sglv-accent); flex-shrink: 0; cursor: help; } .sglv-wl-rank.sglv-rank-top1 { background: rgba(255,215,0,0.16); color: #ffd700; } .sglv-wl-rank.sglv-rank-top2 { background: rgba(192,192,192,0.16); color: #c0c0c0; } .sglv-wl-rank.sglv-rank-top3 { background: rgba(205,127,50,0.2); color: #e09a5f; } /* v2.3.23: 愿望单卡片精简布局——价格+在库同行、时间图标+日期 */ /* v2.9.67: 价格行强制不换行,徽章固定最右侧不收缩 */ .sglv-wl-price-row { justify-content: space-between; flex-wrap: nowrap; align-items: center; } .sglv-wl-price-group { display: inline-flex; align-items: baseline; min-width: 0; white-space: nowrap; overflow: hidden; flex: 1 1 auto; } .sglv-wl-added { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; } .sglv-wl-added svg { width: 11px; height: 11px; flex-shrink: 0; } .sglv-wl-orig { font-size: 11px; color: var(--sglv-text-secondary); text-decoration: line-through; margin-left: 4px; } .sglv-card-badge.discount { background: rgba(255,152,0,0.15); color: #ff9800; } /* v2.9.67: 家庭共享徽章——图标+文字内联,固定最右侧不换行不收缩 */ .sglv-wl-family-badge { display: inline-flex !important; align-items: center !important; gap: 3px !important; flex-shrink: 0 !important; margin-left: auto !important; white-space: nowrap !important; line-height: 1 !important; } .sglv-wl-family-badge svg { width: 11px !important; height: 11px !important; flex-shrink: 0 !important; } .sglv-wl-topdisc-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; } .sglv-wl-topdisc-name { flex: 1; min-width: 0; font-size: 12px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } .sglv-wl-topdisc-name:hover { color: var(--sglv-accent); } .sglv-wl-topdisc-val { font-size: 12px; font-weight: 700; color: #ff9800; white-space: nowrap; } .sglv-list-view { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px; } .sglv-list-item { display: flex !important; align-items: center !important; gap: 10px !important; padding: 8px 12px !important; background: rgba(255,255,255,0.02) !important; border: 1px solid rgba(255,255,255,0.04) !important; border-radius: 6px !important; transition: var(--sglv-transition) !important; } .sglv-list-item:hover { background: rgba(255,255,255,0.06) !important; border-color: rgba(102,192,244,0.2) !important; } .sglv-list-icon { width: 92px !important; height: 43px !important; border-radius: 4px !important; object-fit: cover !important; flex-shrink: 0 !important; object-fit: cover !important; background: rgba(255,255,255,0.05) !important; } .sglv-list-info { flex: 1; min-width: 0; } .sglv-list-name { font-size: 13px !important; font-weight: 600 !important; color: #e0e0e0 !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } .sglv-name-alias { color: var(--sglv-text-secondary) !important; font-weight: 400 !important; font-size: 11px !important; margin-left: 6px !important; } .sglv-list-meta { font-size: 11px !important; color: var(--sglv-text-secondary) !important; display: flex !important; align-items: center !important; flex-wrap: wrap !important; gap: 6px !important; margin-top: 2px !important; } .sglv-list-badge { font-size: 11px !important; font-weight: 600 !important; padding: 2px 8px !important; border-radius: 4px !important; flex-shrink: 0 !important; } .sglv-list-badge.owned { background: rgba(76,175,80,0.2) !important; color: #4caf50 !important; } .sglv-list-badge.missing { background: rgba(244,67,54,0.15) !important; color: #f44336 !important; } .sglv-list-badge.shared { background: rgba(139, 92, 246, 0.2) !important; color: #a78bfa !important; } .sglv-switch-container { display: flex !important; align-items: center !important; gap: 6px !important; font-size: 11px !important; color: var(--sglv-text-secondary) !important; cursor: pointer !important; user-select: none !important; margin-right: 8px !important; } .sglv-switch { position: relative !important; display: inline-block !important; width: 32px !important; height: 18px !important; } .sglv-switch input { opacity: 0 !important; width: 0 !important; height: 0 !important; } .sglv-slider { position: absolute !important; cursor: pointer !important; inset: 0 !important; background-color: rgba(255,255,255,0.1) !important; transition: .2s !important; border-radius: 18px !important; border: 1px solid rgba(255,255,255,0.05) !important; } .sglv-slider:before { position: absolute !important; content: "" !important; height: 12px !important; width: 12px !important; left: 2px !important; bottom: 2px !important; background-color: #8a9ba8 !important; transition: .2s !important; border-radius: 50% !important; } input:checked + .sglv-slider { background-color: rgba(102,192,244,0.2) !important; border-color: rgba(102,192,244,0.4) !important; } input:checked + .sglv-slider:before { transform: translateX(14px) !important; background-color: var(--sglv-accent) !important; } .sglv-pub-layout { display: flex !important; height: 100% !important; overflow: hidden !important; } .sglv-pub-sidebar { width: 260px !important; min-width: 260px !important; background: rgba(22, 32, 44, 0.8) !important; border-right: 1px solid var(--sglv-border) !important; overflow-y: auto !important; } .sglv-pub-main { flex: 1 !important; overflow-y: auto !important; padding: 16px !important; } .sglv-pub-list { display: flex; flex-direction: column; } .sglv-pub-list-header { display: flex; align-items: center; padding: 8px 12px; font-size: 11px; font-weight: 600; color: var(--sglv-text-secondary); border-bottom: 1px solid rgba(255,255,255,0.06); text-transform: uppercase; letter-spacing: 0.5px; } .sglv-pub-list-row { display: flex; align-items: center; padding: 9px 12px; text-decoration: none !important; color: inherit !important; border-bottom: 1px solid rgba(255,255,255,0.03); transition: background 0.15s; cursor: pointer; } .sglv-pub-list-row:hover { background: rgba(102, 192, 244, 0.06); } .sglv-pub-list-col { display: flex; align-items: center; } .sglv-pub-list-col.name { flex: 1; min-width: 0; font-size: 13px; color: #e0e0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-pub-list-col.appid { font-size: 11px; color: var(--sglv-text-secondary); text-align: right; } .sglv-view-toggle { display: flex; align-items: center; gap: 2px; } .sglv-view-btn { display: flex; align-items: center; justify-content: center; width: 32px; height: 28px; border: 1px solid rgba(255,255,255,0.1); border-radius: 5px; background: rgba(255,255,255,0.04); color: var(--sglv-text-secondary); cursor: pointer; transition: all 0.15s; padding: 0; } .sglv-view-btn:hover { background: rgba(255,255,255,0.08); color: #e0e0e0; } .sglv-view-btn.active { background: rgba(102, 192, 244, 0.15); border-color: rgba(102, 192, 244, 0.4); color: #66c0f4; } .sglv-view-btn svg { width: 16px; height: 16px; } .sglv-pub-item { border-bottom: 1px solid rgba(255,255,255,0.03) !important; } .sglv-pub-header { display: flex !important; align-items: center !important; padding: 10px 14px !important; cursor: pointer !important; transition: background 0.2s !important; gap: 8px !important; user-select: none !important; } .sglv-pub-header:hover { background: rgba(255,255,255,0.05) !important; } .sglv-pub-header.active { background: rgba(102,192,244,0.1) !important; } .sglv-pub-arrow { width: 14px !important; height: 14px !important; transition: transform 0.2s !important; flex-shrink: 0 !important; fill: #8f98a0 !important; } .sglv-pub-arrow.expanded { transform: rotate(90deg) !important; } .sglv-pub-name { flex: 1; font-size: 13px; font-weight: 600; color: #e0e0e0; } .sglv-pub-stats { font-size: 11px; color: #8f98a0; background: rgba(255,255,255,0.05); padding: 2px 8px; border-radius: 10px; } .sglv-series-list { display: none; background: rgba(0,0,0,0.15); } .sglv-series-list.expanded { display: block; } .sglv-series-header { display: flex !important; align-items: center !important; padding: 8px 14px 8px 32px !important; cursor: pointer !important; transition: background 0.2s !important; gap: 6px !important; user-select: none !important; } .sglv-series-header:hover { background: rgba(255,255,255,0.05) !important; } .sglv-series-header.active { background: rgba(102,192,244,0.08) !important; } .sglv-series-name { flex: 1; font-size: 12px; color: #c7d5e0; } .sglv-series-stats { font-size: 11px; color: #8f98a0; } .sglv-stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } /* v2.3.22: 供应商分类 KPI 卡片——与其他页面统一精美样式(图标+数值+光晕+边框色),保留标签+数值展示布局 */ .sglv-stat-card { flex: 1 !important; min-width: 100px !important; padding: 6px 10px 5px !important; border-radius: var(--sglv-radius) !important; background: var(--sglv-kpi-bg) !important; border: 1px solid var(--sglv-border) !important; } .sglv-stat-header { display: flex; align-items: center; gap: 5px; margin-bottom: 2px; } .sglv-stat-label { font-size: 11px; color: var(--sglv-text-secondary); text-transform: uppercase; margin-bottom: 0; } .sglv-stat-icon { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 5px; margin-bottom: 0; flex-shrink: 0; } .sglv-stat-icon svg { width: 11px; height: 11px; } .sglv-stat-dash-sub { font-size: 9px; color: var(--sglv-text-secondary); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.8; } .sglv-stat-val { font-size: 20px; font-weight: 800; color: #fff; line-height: 1.2; } /* v2.3.28: stat-card 颜色变体 border-color/::before 已合并至共享 KPI 颜色修饰符,仅保留图标样式 */ .sglv-stat-card.blue .sglv-stat-icon { background: rgba(102,192,244,0.15); color: #66c0f4; } .sglv-stat-val.green { color: #4caf50; } .sglv-stat-val.red { color: #f44336; } .sglv-stat-val.blue { color: #66c0f4; } .sglv-stat-card.green .sglv-stat-icon { background: rgba(34,197,94,0.15); color: #22c55e; } .sglv-stat-card.red .sglv-stat-icon { background: rgba(244,67,54,0.15); color: #f44336; } .sglv-stat-card.amber .sglv-stat-icon { background: rgba(245,158,11,0.15); color: #fbbf24; } .sglv-stat-card.purple .sglv-stat-icon { background: rgba(139,92,246,0.15); color: #a78bfa; } .sglv-progress { width: 100%; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; margin: 8px 0; } .sglv-progress-fill { height: 100%; background: linear-gradient(90deg, #4caf50, #66c0f4); border-radius: 3px; transition: width 0.5s; } .sglv-progress-filter { display: flex !important; height: 12px !important; cursor: pointer !important; } .sglv-progress-filter .sglv-progress-fill { cursor: pointer !important; transition: width 0.4s, filter 0.2s !important; } .sglv-progress-filter .sglv-progress-fill:hover { filter: brightness(1.35) !important; } .sglv-progress-filter .sglv-progress-fill.active { filter: brightness(1.4) !important; } .sglv-progress-rest { flex: 1; height: 100%; background: rgba(255,255,255,0.08); cursor: pointer !important; transition: background 0.2s !important; } .sglv-progress-rest:hover { background: rgba(244,67,54,0.25) !important; } .sglv-progress-rest.active { background: rgba(244,67,54,0.4) !important; } .sglv-settings { padding: 16px; } .sglv-form-group { margin-bottom: 16px; } .sglv-form-group label { display: block; font-size: 12px; color: var(--sglv-text-secondary); margin-bottom: 6px; font-weight: 600; } .sglv-form-input { width: 100% !important; padding: 8px 12px !important; background: rgba(255,255,255,0.08) !important; border: 1px solid rgba(255,255,255,0.1) !important; border-radius: 6px !important; color: #fff !important; font-size: 13px !important; box-sizing: border-box !important; } .sglv-form-input:focus { border-color: rgba(102,192,244,0.5) !important; outline: none !important; } .sglv-tip { background: rgba(102,192,244,0.08) !important; padding: 12px !important; border-radius: 8px !important; font-size: 12px !important; color: var(--sglv-text-secondary) !important; margin-bottom: 16px !important; line-height: 1.6 !important; border: 1px solid rgba(102,192,244,0.15) !important; } .sglv-tip a { color: #60a5fa; text-decoration: none; } .sglv-tip a:hover { text-decoration: underline; } .sglv-toast { position: fixed !important; bottom: 50px !important; left: 50% !important; transform: translateX(-50%) translateY(20px) !important; background: rgba(15,23,42,0.95) !important; color: #fff !important; border: 1px solid var(--sglv-accent) !important; padding: 10px 24px !important; border-radius: 20px !important; font-size: 13px !important; font-weight: 500 !important; box-shadow: 0 4px 16px rgba(0,0,0,0.3) !important; z-index: 10000000 !important; opacity: 0 !important; pointer-events: none !important; transition: opacity 0.3s, transform 0.3s !important; } .sglv-toast.show { opacity: 1 !important; transform: translateX(-50%) translateY(0) !important; } .sglv-gs-overlay { position: fixed !important; inset: 0 !important; background: rgba(11, 17, 32, 0.85) !important; z-index: 10000001 !important; display: none !important; align-items: center !important; justify-content: center !important; } .sglv-gs-overlay.sglv-show { display: flex !important; } .sglv-gs-panel { width: 520px !important; max-width: calc(100vw - 40px) !important; max-height: 85vh !important; overflow-y: auto !important; background: var(--sglv-bg) !important; border: 1px solid var(--sglv-border) !important; border-radius: var(--sglv-radius) !important; box-shadow: var(--sglv-shadow) !important; font-family: "Motiva Sans", Arial, Helvetica, sans-serif !important; color: var(--sglv-text) !important; } .sglv-gs-header { display: flex !important; align-items: center !important; justify-content: space-between !important; padding: 14px 20px !important; background: linear-gradient(135deg, #1b2838, #2a475e) !important; border-bottom: 1px solid var(--sglv-border) !important; border-radius: var(--sglv-radius) var(--sglv-radius) 0 0 !important; flex-shrink: 0 !important; } .sglv-gs-header h3 { margin: 0 !important; font-size: 15px !important; font-weight: 600 !important; color: #fff !important; display: flex !important; align-items: center !important; gap: 8px !important; white-space: nowrap !important; min-width: 0 !important; } .sglv-gs-close { width: 28px !important; height: 28px !important; border-radius: 6px !important; border: none !important; background: rgba(255,255,255,0.08) !important; color: #8a9ba8 !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; } .sglv-gs-close:hover { background: rgba(255,255,255,0.15) !important; color: #fff !important; } .sglv-gs-close svg { width: 14px; height: 14px; } .sglv-gs-body { padding: 20px !important; } .sglv-gs-section { margin-bottom: 20px !important; padding-bottom: 16px !important; border-bottom: 1px solid rgba(255,255,255,0.06) !important; } .sglv-gs-section:last-child { border-bottom: none !important; margin-bottom: 0 !important; } .sglv-gs-section-title { font-size: 13px !important; font-weight: 700 !important; color: var(--sglv-accent) !important; margin-bottom: 12px !important; display: flex !important; align-items: center !important; gap: 6px !important; } .sglv-gs-section-title svg { width: 16px; height: 16px; } .sgis-ai-toggle-bar { display: flex !important; align-items: center !important; gap: 8px !important; margin-bottom: 10px !important; padding: 6px 10px !important; background: rgba(59, 130, 246, 0.08) !important; border-radius: 8px !important; border: 1px solid rgba(59, 130, 246, 0.15) !important; } .sgis-ai-toggle { display: inline-flex !important; border-radius: 6px !important; overflow: hidden !important; border: 1px solid rgba(255,255,255,0.1) !important; } .sgis-ai-toggle-btn { padding: 4px 12px !important; font-size: 11px !important; font-weight: 600 !important; border: none !important; background: rgba(255,255,255,0.04) !important; color: var(--sgis-text-2) !important; cursor: pointer !important; transition: all 0.2s !important; } .sgis-ai-toggle-btn.active { background: rgba(59, 130, 246, 0.3) !important; color: #93c5fd !important; } .sgis-ai-toggle-btn:hover:not(.active) { background: rgba(255,255,255,0.08) !important; color: #fff !important; } .sgis-ai-status { font-size: 10px !important; color: var(--sgis-text-2) !important; margin-left: auto !important; } .sgis-ai-summary-item { padding: 8px 10px !important; background: rgba(59, 130, 246, 0.04) !important; border: 1px solid rgba(59, 130, 246, 0.1) !important; border-radius: 6px !important; margin-bottom: 6px !important; } .sgis-ai-summary-title { font-size: 12px !important; font-weight: 600 !important; color: #93c5fd !important; margin-bottom: 4px !important; line-height: 1.4 !important; } .sgis-ai-summary-text { font-size: 11px !important; color: var(--sgis-text) !important; line-height: 1.6 !important; } .sgis-ai-summary-badge { display: inline-block !important; font-size: 9px !important; padding: 1px 5px !important; border-radius: 3px !important; background: rgba(59, 130, 246, 0.2) !important; color: #93c5fd !important; font-weight: 600 !important; margin-left: 4px !important; } .sglv-loading { display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; padding: 60px 20px !important; color: var(--sglv-text-secondary) !important; gap: 12px !important; } .sglv-spinner { width: 32px !important; height: 32px !important; border: 3px solid rgba(255,255,255,0.1) !important; border-top-color: var(--sglv-accent) !important; border-radius: 50% !important; animation: sglv-spin 0.8s linear infinite !important; } @keyframes sglv-spin { to { transform: rotate(360deg); } } .sglv-refresh-btn.sglv-spin svg { animation: sglv-spin 1s linear infinite; } .sglv-stats-dashboard { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; padding: 12px 16px; background: var(--sglv-section-bg); border-bottom: 1px solid var(--sglv-border); flex-shrink: 0; } /* v2.3.28: .sglv-stat-dash-card 基类/hover/::before 已合并至顶部共享 KPI 卡片样式 */ .sglv-stat-dash-header { display: flex; align-items: center; gap: 5px; margin-bottom: 2px; } .sglv-stat-dash-value { font-size: 17px; font-weight: 800; color: #fff; line-height: 1.2; } .sglv-stat-dash-label { font-size: 10px; color: var(--sglv-text-secondary); margin-top: 0; } .sglv-stat-dash-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 5px; margin-bottom: 0; flex-shrink: 0; } .sglv-stat-dash-icon svg { width: 10px; height: 10px; } .sglv-stat-dash-sub { font-size: 9px; color: var(--sglv-text-secondary); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.8; } /* v2.3.28: stat-dash-card 颜色变体 border-color/::before 已合并至共享 KPI 颜色修饰符,仅保留数值+图标样式 */ .sglv-stat-dash-card.blue .sglv-stat-dash-value { color: #66c0f4; } .sglv-stat-dash-card.blue .sglv-stat-dash-icon { background: rgba(102,192,244,0.15); color: #66c0f4; } .sglv-stat-dash-card.green .sglv-stat-dash-value { color: #22c55e; } .sglv-stat-dash-card.green .sglv-stat-dash-icon { background: rgba(34,197,94,0.15); color: #22c55e; } .sglv-stat-dash-card.amber .sglv-stat-dash-value { color: #fbbf24; } .sglv-stat-dash-card.amber .sglv-stat-dash-icon { background: rgba(245,158,11,0.15); color: #fbbf24; } .sglv-stat-dash-card.purple .sglv-stat-dash-value { color: #a78bfa; } .sglv-stat-dash-card.purple .sglv-stat-dash-icon { background: rgba(139,92,246,0.15); color: #a78bfa; } .sglv-stat-dash-card.red .sglv-stat-dash-value { color: #f44336; } .sglv-stat-dash-card.red .sglv-stat-dash-icon { background: rgba(244,67,54,0.15); color: #f44336; } @media (max-width: 768px) { .sglv-stats-dashboard { grid-template-columns: repeat(3, 1fr); } } @media (max-width: 480px) { .sglv-stats-dashboard { grid-template-columns: repeat(2, 1fr); } } .sglv-category-bar { display: flex; align-items: center; gap: 6px; padding: 5px 12px; background: rgba(15, 23, 42, 0.3); border-bottom: 1px solid var(--sglv-border); flex-shrink: 0; flex-wrap: wrap; } .sglv-category-btn { padding: 2px 7px; border-radius: 11px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.05); color: var(--sglv-text-secondary); font-size: 11px; font-weight: 600; cursor: pointer; transition: var(--sglv-transition); white-space: nowrap; } .sglv-category-btn:hover { background: rgba(255,255,255,0.1); color: #fff; } .sglv-category-btn.active { background: rgba(102,192,244,0.2); border-color: rgba(102,192,244,0.4); color: var(--sglv-accent); } .sglv-delisted-subtabs { display: flex; gap: 6px; padding: 0 16px 10px; flex-shrink: 0; flex-wrap: wrap; } .sglv-delisted-subtab { padding: 5px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.04); color: var(--sglv-text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; transition: var(--sglv-transition); } .sglv-delisted-subtab:hover { background: rgba(255,255,255,0.08); color: #fff; } .sglv-delisted-subtab.active { background: rgba(244,67,54,0.15); border-color: rgba(244,67,54,0.35); color: #f87171; } .sglv-delisted-type-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; } .sglv-delisted-type-row .sglv-delisted-pagination { padding: 4px 8px !important; background: transparent !important; border: none !important; margin-left: auto !important; flex-shrink: 0; } .sglv-delisted-summary-bar .sglv-delisted-pagination { padding: 4px 8px !important; background: transparent !important; border: none !important; margin-left: auto !important; flex-shrink: 0; } .sglv-empty { text-align: center !important; padding: 40px 20px !important; color: var(--sglv-text-secondary) !important; font-size: 13px !important; } .sglv-trend-wrap { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; min-height: 0; } .sglv-trend-section { background: var(--sglv-section-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 12px; margin-bottom: 12px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; } .sglv-trend-section-main { flex: 1 1 auto; margin-bottom: 0; min-height: 0; overflow: hidden; } .sglv-trend-kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 12px; flex-shrink: 0; } /* v2.3.28: .sglv-trend-kpi-card 基类/hover/::before 已合并至顶部共享 KPI 卡片样式 */ .sglv-trend-kpi-header { display: flex; align-items: center; gap: 5px; margin-bottom: 2px; } .sglv-trend-kpi-value { font-size: 20px; font-weight: 800; color: #fff; line-height: 1.2; } .sglv-trend-kpi-label { font-size: 11px; color: var(--sglv-text-secondary); margin-top: 0; } .sglv-trend-kpi-icon { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 5px; margin-bottom: 0; flex-shrink: 0; } .sglv-trend-kpi-icon svg { width: 11px; height: 11px; } .sglv-trend-kpi-sub { font-size: 9px; color: var(--sglv-text-secondary); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.8; } .sglv-trend-kpi-card.mine { border-color: rgba(34,197,94,0.35); } .sglv-trend-kpi-card.mine .sglv-trend-kpi-value { color: #22c55e; } .sglv-trend-kpi-card.mine::before { background: #22c55e; } .sglv-trend-kpi-card.mine .sglv-trend-kpi-icon { background: rgba(34,197,94,0.15); color: #22c55e; } .sglv-trend-kpi-card.total { border-color: rgba(129,140,248,0.35); } .sglv-trend-kpi-card.total .sglv-trend-kpi-value { color: #818cf8; } .sglv-trend-kpi-card.total::before { background: #818cf8; } .sglv-trend-kpi-card.total .sglv-trend-kpi-icon { background: rgba(129,140,248,0.15); color: #818cf8; } @media (max-width: 600px) { .sglv-trend-kpi-row { grid-template-columns: repeat(2, 1fr); } } .sglv-trend-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } .sglv-trend-header svg { width: 18px; height: 18px; flex-shrink: 0; color: var(--sglv-accent); } .sglv-trend-title { font-size: 15px; font-weight: 700; color: #fff; flex-shrink: 0; } .sglv-trend-subtitle { font-size: 12px; color: var(--sglv-text-secondary); margin-bottom: 14px; } .sglv-trend-chart { position: relative; width: 100%; flex: 1; min-height: 320px; display: flex; flex-direction: column; } .sglv-trend-chart svg { display: block; width: 100%; flex: 1; min-height: 0; } .sglv-trend-bar { transition: opacity .15s; cursor: pointer; } /* v2.4.3: 入库趋势动态——左趋势图 + 右入库时间线 分栏布局 */ .sglv-trend-split { display: flex; gap: 12px; flex: 1 1 auto; min-height: 0; } .sglv-trend-split-left { flex: 1.6; min-width: 0; display: flex; flex-direction: column; } .sglv-trend-split-left .sglv-trend-section { flex: 1; margin-bottom: 0; } .sglv-trend-split-right { flex: 1; min-width: 0; display: flex; flex-direction: column; } .sglv-tl-section { background: var(--sglv-section-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 12px; display: flex; flex-direction: column; flex: 1; min-height: 0; } .sglv-tl-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-shrink: 0; } .sglv-tl-count { display: flex; align-items: center; gap: 6px; background: rgba(102,192,244,0.1); border: 1px solid rgba(102,192,244,0.25); border-radius: 8px; padding: 5px 10px; white-space: nowrap; } .sglv-tl-count-num { font-size: 16px; font-weight: 700; color: var(--sglv-accent); } .sglv-tl-count-label { font-size: 10px; color: var(--sglv-text-secondary); } .sglv-tl-search { flex: 1; min-width: 0; padding: 6px 12px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: #fff; font-size: 12px; outline: none; } .sglv-tl-search:focus { border-color: rgba(102,192,244,0.5); } .sglv-tl-search::placeholder { color: #5a6a7a; } .sglv-tl-scroll { flex: 1; overflow-y: auto; min-height: 0; padding-right: 2px; } .sglv-tl-scroll::-webkit-scrollbar { width: var(--sglv-scrollbar-width); } .sglv-tl-scroll::-webkit-scrollbar-thumb { background: var(--sglv-scrollbar-thumb); border-radius: 3px; } .sglv-tl-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } .sglv-tl-divider { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin: 8px 0 2px; } .sglv-tl-grid > .sglv-tl-divider:first-child { margin-top: 0; } .sglv-tl-divider-line { flex: 1; height: 1px; background: linear-gradient(90deg, transparent, rgba(102,192,244,0.45), transparent); } .sglv-tl-date { font-size: 11px; font-weight: 600; color: var(--sglv-accent); white-space: nowrap; letter-spacing: 0.5px; } .sglv-tl-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07); border-radius: 8px; overflow: hidden; cursor: pointer; transition: var(--sglv-transition); min-width: 0; } .sglv-tl-card:hover { background: rgba(255,255,255,0.08); border-color: rgba(102,192,244,0.35); transform: translateY(-1px); } .sglv-tl-card .sglv-card-img { height: auto !important; aspect-ratio: 460 / 175; background: rgba(255,255,255,0.05); } .sglv-tl-card-info { padding: 5px 8px 6px; } .sglv-tl-card-name { font-size: 11px; color: var(--sglv-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-tl-card-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 2px; font-size: 10px; } .sglv-tl-card-time { color: var(--sglv-text-secondary); } .sglv-tl-card-check { color: #34d399; font-weight: 700; } .sglv-tl-pagination { padding: 8px 0 0 !important; background: transparent !important; border-top: none !important; } @media (max-width: 900px) { .sglv-trend-split { flex-direction: column; overflow-y: auto; } .sglv-trend-split-left, .sglv-trend-split-right { flex: none; } .sglv-trend-split-left .sglv-trend-section { min-height: 340px; } .sglv-tl-section { min-height: 420px; } } .sglv-trend-bar:hover { opacity: 0.78; } .sglv-trend-bar-member { transition: opacity .15s; } .sglv-trend-bar-member:hover { opacity: 1 !important; } .sglv-trend-tooltip { position: absolute !important; pointer-events: none !important; background: rgba(15, 23, 42, 0.96) !important; border: 1px solid rgba(102, 192, 244, 0.3) !important; border-radius: 8px !important; padding: 10px 14px !important; font-size: 12px !important; color: #fff !important; box-shadow: 0 8px 24px rgba(0,0,0,0.4) !important; opacity: 0 !important; transition: opacity 0.15s !important; z-index: 10 !important; min-width: 140px !important; } .sglv-trend-tooltip-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 3px 0; } .sglv-trend-tooltip-label { display: flex; align-items: center; gap: 6px; color: #94a3b8; } .sglv-trend-tooltip-dot { width: 8px; height: 8px; border-radius: 50%; } .sglv-trend-tooltip-value { font-weight: 700; font-variant-numeric: tabular-nums; } .sglv-trend-legend { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px; margin-top: 12px; padding-top: 10px; border-top: 1px solid rgba(102, 192, 244, 0.12); flex-shrink: 0; } .sglv-trend-legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #94a3b8; } .sglv-trend-legend-dot { width: 10px; height: 10px; border-radius: 2px; } .sglv-trend-legend-line { width: 18px; height: 2px; border-radius: 2px; } .sglv-trend-summary { display: flex; justify-content: space-around; margin-top: 10px; font-size: 11px; color: #94a3b8; flex-shrink: 0; } .sglv-trend-note { font-size: 11px; color: #94a3b8; margin-top: 10px; padding: 8px 10px; background: rgba(255,255,255,0.04); border-radius: 6px; flex-shrink: 0; } @media(max-width:768px) { .sglv-panel { width: 100vw !important; max-width: 100vw !important; height: 100vh !important; max-height: 100vh !important; border-radius: 0 !important; } .sglv-pub-sidebar { width: 200px !important; min-width: 200px !important; } .sglv-card-grid { grid-template-columns: repeat(2, 1fr) !important; } .sglv-trend-section { padding: 12px !important; } } .sglv-delisted-wrap { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; } .sglv-delisted-toolbar { flex-wrap: wrap; gap: 6px; } .sglv-delisted-filter-group { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } .sglv-delisted-sub-group { display: flex; align-items: center; gap: 4px; margin-left: auto; flex-wrap: wrap; } .sglv-delisted-type-btn { font-size: 11px !important; padding: 4px 10px !important; } .sglv-delisted-type-btn.active { background: rgba(244,67,54,0.2) !important; border-color: rgba(244,67,54,0.4) !important; color: #f44336 !important; } .sglv-delisted-sub-btn { font-size: 11px !important; padding: 4px 10px !important; border-radius: 6px !important; } .sglv-delisted-sub-btn.active { background: rgba(34,197,94,0.2) !important; border-color: rgba(34,197,94,0.4) !important; color: #22c55e !important; } .sglv-delisted-card { background: rgba(255,255,255,0.04) !important; border: 1px solid rgba(255,255,255,0.06) !important; border-radius: 8px !important; padding: 10px 14px !important; transition: var(--sglv-transition) !important; cursor: pointer !important; margin-bottom: 6px !important; } .sglv-delisted-card:hover { background: rgba(255,255,255,0.08) !important; border-color: rgba(244,67,54,0.3) !important; transform: translateY(-1px) !important; } .sglv-delisted-badge { display: inline-block !important; font-size: 10px !important; font-weight: 600 !important; padding: 1px 6px !important; border-radius: 3px !important; } .sglv-delisted-badge.type-delisted { background: rgba(244,67,54,0.2) !important; color: #f44336 !important; } .sglv-delisted-badge.type-purchase_disabled { background: rgba(255,152,0,0.2) !important; color: #ff9800 !important; } .sglv-delisted-badge.type-f2p_unavailable { background: rgba(156,39,176,0.2) !important; color: #ce93d8 !important; } .sglv-delisted-badge.type-retail_only { background: rgba(33,150,243,0.2) !important; color: #64b5f6 !important; } .sglv-delisted-badge.type-test_app { background: rgba(158,158,158,0.2) !important; color: #9e9e9e !important; } .sglv-delisted-owners { font-size: 10px !important; color: var(--sglv-text-secondary) !important; } .sglv-delisted-date { font-size: 10px !important; color: var(--sglv-text-secondary) !important; } .sglv-delisted-achievements { font-size: 10px !important; color: #66c0f4 !important; } /* v2.3.22: 绝版页面紧凑分类汇总 + 收藏进度条(置于分页操作栏下方) */ .sglv-delisted-summary-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex-shrink: 0; } .sglv-delisted-summary-badges { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; flex: 1; min-width: 0; } .sglv-delisted-summary-badge { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 10px; cursor: pointer; transition: var(--sglv-transition); white-space: nowrap; } .sglv-delisted-summary-badge:hover { filter: brightness(1.3); transform: translateY(-1px); } .sglv-delisted-summary-badge .sglv-dsb-owned { font-weight: 700; } .sglv-delisted-summary-badge .sglv-dsb-sep { opacity: 0.5; } .sglv-delisted-summary-badge .sglv-dsb-total { opacity: 0.8; } .sglv-delisted-progress-compact { display: flex; align-items: center; gap: 8px; padding: 4px 0 8px; flex-shrink: 0; } .sglv-delisted-progress-compact .sglv-dpc-label { font-size: 11px; color: var(--sglv-text-secondary); white-space: nowrap; flex-shrink: 0; } .sglv-delisted-progress-compact .sglv-dpc-track { flex: 1; height: 8px; background: rgba(255,255,255,0.06); border-radius: 4px; overflow: hidden; position: relative; } .sglv-delisted-progress-compact .sglv-dpc-fill { height: 100%; background: linear-gradient(90deg, #22c55e, #66c0f4); border-radius: 4px; transition: width 0.5s; } .sglv-delisted-progress-compact .sglv-dpc-pct { font-size: 12px; font-weight: 700; color: #66c0f4; white-space: nowrap; flex-shrink: 0; min-width: 36px; text-align: right; } .sglv-playtime-wrap { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; } .sglv-playtime-kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; flex-shrink: 0; } /* v2.3.28: .sglv-playtime-kpi-card 基类/hover/::before 已合并至顶部共享 KPI 卡片样式 */ .sglv-playtime-kpi-header { display: flex; align-items: center; gap: 5px; margin-bottom: 2px; } /* v2.3.28: playtime-kpi-card 颜色变体 border-color/::before 已合并至共享 KPI 颜色修饰符,仅保留数值+图标样式 */ .sglv-playtime-kpi-card.amber .sglv-playtime-kpi-value { color: #fbbf24; } .sglv-playtime-kpi-card.amber .sglv-playtime-kpi-icon { background: rgba(245,158,11,0.15); color: #fbbf24; } .sglv-playtime-kpi-card.green .sglv-playtime-kpi-value { color: #22c55e; } .sglv-playtime-kpi-card.green .sglv-playtime-kpi-icon { background: rgba(34,197,94,0.15); color: #22c55e; } .sglv-playtime-kpi-card.blue .sglv-playtime-kpi-value { color: #66c0f4; } .sglv-playtime-kpi-card.blue .sglv-playtime-kpi-icon { background: rgba(102,192,244,0.15); color: #66c0f4; } .sglv-playtime-kpi-card.purple .sglv-playtime-kpi-value { color: #a78bfa; } .sglv-playtime-kpi-card.purple .sglv-playtime-kpi-icon { background: rgba(139,92,246,0.15); color: #a78bfa; } .sglv-playtime-kpi-value { font-size: 20px; font-weight: 800; color: #fff; line-height: 1.2; } .sglv-playtime-kpi-label { font-size: 11px; color: var(--sglv-text-secondary); margin-top: 0; } .sglv-playtime-kpi-icon { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 5px; margin-bottom: 0; flex-shrink: 0; } .sglv-playtime-kpi-icon svg { width: 11px; height: 11px; } .sglv-playtime-kpi-sub { font-size: 9px; color: var(--sglv-text-secondary); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.8; } /* v2.9.22: KPI 卡片 sparkline 迷你趋势线 + 进度条 */ .sglv-playtime-kpi-body { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 2px; } .sglv-playtime-kpi-body .sglv-playtime-kpi-value { flex: 1; min-width: 0; } .sglv-kpi-spark { flex-shrink: 0; opacity: 0.85; display: block; } .sglv-kpi-progress { height: 5px; background: rgba(255,255,255,0.06); border-radius: 3px; overflow: hidden; margin: 3px 0 2px; } .sglv-kpi-progress-fill { height: 100%; border-radius: 3px; transition: width 0.6s ease; min-width: 2px; } .sglv-kpi-progress-fill.cyan { background: linear-gradient(90deg, #22d3ee, #06b6d4); } .sglv-kpi-progress-fill.rose { background: linear-gradient(90deg, #fb7185, #f43f5e); } .sglv-playtime-section { background: var(--sglv-section-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 12px; margin-bottom: 12px; flex-shrink: 0; } .sglv-playtime-section-title { font-size: 14px; font-weight: 700; color: #fff; margin-bottom: 12px; display: flex; align-items: center; gap: 6px; } .sglv-playtime-section-title svg { width: 16px; height: 16px; color: var(--sglv-accent); flex-shrink: 0; } .sglv-playtime-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; flex: 1 1 0; min-height: 0; align-items: stretch; } .sglv-playtime-col { background: var(--sglv-section-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 14px; min-width: 0; display: flex; flex-direction: column; align-items: stretch; } /* v2.9.22: 分布甜甜圈/趋势图内容区撑满列高 */ .sglv-playtime-col > #sglv-pt-donut { flex: 1; min-height: 0; display: flex; flex-direction: column; } .sglv-playtime-section-flex { flex: 1; display: flex; flex-direction: column; min-height: 0; margin-bottom: 0; } .sglv-playtime-section-flex .sglv-pt-all-list { flex: 1; overflow-y: auto; max-height: none; min-height: 120px; } .sglv-playtime-split-row { display: grid; grid-template-columns: minmax(280px, 2fr) minmax(0, 3fr); gap: 12px; flex: 1 1 0; min-height: 0; margin-bottom: 0; } .sglv-family-compare-section { background: var(--sglv-section-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 14px; margin-bottom: 12px; flex-shrink: 0; } .sglv-family-compare-container { margin-top: 8px; max-height: 152px; overflow-y: auto; } .sglv-family-bar-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.04); } .sglv-family-bar-row:last-child { border-bottom: none; } .sglv-family-bar-row.is-me .sglv-family-bar-name { color: #66c0f4; font-weight: 700; } .sglv-family-bar-row.is-me .sglv-family-bar-fill { background: linear-gradient(90deg, #66c0f4, #4a9eda); } .sglv-family-bar-name { width: 80px; min-width: 80px; font-size: 13px; color: #c0c8d0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-family-bar-track { flex: 1; height: 22px; background: rgba(255,255,255,0.05); border-radius: 4px; overflow: hidden; } .sglv-family-bar-fill { height: 100%; border-radius: 4px; background: linear-gradient(90deg, #4caf50, #2e7d32); transition: width 0.6s ease; min-width: 2px; } .sglv-family-bar-hours { width: 70px; min-width: 70px; text-align: right; font-size: 13px; font-weight: 600; color: #e0e0e0; } .sglv-playtime-split-left { background: var(--sglv-section-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 12px; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } .sglv-playtime-split-left #sglv-pt-top-list { flex: 1; overflow-y: auto; min-height: 0; } .sglv-playtime-split-right { background: var(--sglv-section-bg); border: 1px solid rgba(244,67,54,0.2); border-radius: var(--sglv-radius); padding: 12px; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } .sglv-playtime-split-right .sglv-pt-all-list { flex: 1; overflow-y: auto; max-height: none; min-height: 0; } .sglv-trend-filter { display: flex; gap: 4px; } .sglv-trend-btn { padding: 3px 8px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.05); color: var(--sglv-text-secondary); font-size: 11px; cursor: pointer; transition: var(--sglv-transition); } .sglv-trend-btn:hover { background: rgba(255,255,255,0.1); color: #fff; } .sglv-trend-btn.active { background: rgba(102,192,244,0.2); border-color: rgba(102,192,244,0.4); color: var(--sglv-accent); } .sglv-pt-trend-chart { width: 100%; flex: 1; min-height: 0; display: flex; align-items: center; } .sglv-pt-trend-chart .sglv-empty { width: 100%; } .sglv-trend-svg { width: 100% !important; height: 100% !important; overflow: visible; display: block; } .sglv-donut-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; min-height: 0; padding: 4px 0; } .sglv-donut-chart { display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .sglv-donut-chart svg { display: block; max-width: 100%; height: auto; } .sglv-donut-legend { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 14px; width: 100%; max-width: 360px; padding: 0 4px; } .sglv-donut-legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #c7d5e0; min-width: 0; } .sglv-donut-legend-dot { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; } .sglv-donut-legend-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-donut-legend-value { font-weight: 600; color: #e0e0e0; font-variant-numeric: tabular-nums; white-space: nowrap; } .sglv-donut-legend-pct { color: #8a9ba8; font-weight: 400; } .sglv-pt-top-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; margin-bottom: 5px; transition: var(--sglv-transition); cursor: pointer; } .sglv-pt-top-item:hover { background: rgba(255,255,255,0.07); border-color: rgba(102,192,244,0.2); transform: translateX(2px); } .sglv-pt-top-item.rank-1 { border-left: 3px solid #fbbf24; background: rgba(251,191,36,0.06); } .sglv-pt-top-item.rank-2 { border-left: 3px solid #94a3b8; background: rgba(148,163,184,0.06); } .sglv-pt-top-item.rank-3 { border-left: 3px solid #cd7f32; background: rgba(205,127,50,0.06); } .sglv-pt-rank { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 800; flex-shrink: 0; background: rgba(255,255,255,0.06); color: var(--sglv-text-secondary); } .sglv-pt-rank.r1 { background: rgba(251,191,36,0.2); color: #fbbf24; } .sglv-pt-rank.r2 { background: rgba(148,163,184,0.2); color: #cbd5e1; } .sglv-pt-rank.r3 { background: rgba(205,127,50,0.2); color: #d4986a; } .sglv-pt-icon { width: 40px; height: 40px; border-radius: 4px; flex-shrink: 0; object-fit: cover; background: rgba(255,255,255,0.05); } .sglv-pt-info { flex: 1; min-width: 0; } .sglv-pt-name { font-size: 13px; font-weight: 600; color: #e0e0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-pt-meta { font-size: 11px; color: var(--sglv-text-secondary); margin-top: 2px; } .sglv-pt-bar-wrap { flex: 1; min-width: 60px; height: 6px; background: rgba(255,255,255,0.06); border-radius: 3px; overflow: hidden; } .sglv-pt-bar-fill { height: 100%; background: linear-gradient(90deg, #fbbf24, #f97316); border-radius: 3px; transition: width 0.3s ease; } .sglv-pt-hours { font-size: 13px; font-weight: 700; color: #fbbf24; white-space: nowrap; min-width: 55px; text-align: right; flex-shrink: 0; } .sglv-pt-all-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; } .sglv-pt-all-list { max-height: 500px; overflow-y: auto; } .sglv-pt-all-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); border-radius: 6px; margin-bottom: 3px; transition: var(--sglv-transition); cursor: pointer; } .sglv-pt-all-item:hover { background: rgba(255,255,255,0.06); } .sglv-pt-all-rank { width: 30px; font-size: 11px; font-weight: 600; color: var(--sglv-text-secondary); text-align: center; flex-shrink: 0; } .sglv-pt-all-name { flex: 1; font-size: 12px; color: #c7d5e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sglv-pt-all-hours { font-size: 12px; font-weight: 600; color: #fbbf24; white-space: nowrap; flex-shrink: 0; } .sglv-pt-all-bar { width: 80px; height: 4px; background: rgba(255,255,255,0.06); border-radius: 2px; overflow: hidden; flex-shrink: 0; } .sglv-pt-all-bar-fill { height: 100%; background: linear-gradient(90deg, #66c0f4, #4caf50); border-radius: 2px; } /* v2.3.28: 游玩数据底部——总时长分布水平条形图样式(参考 friend-manager 设计) */ /* v2.3.30: 底部行不再撑高(flex-grow:0),压缩容器高度避免大面积空白,把空间留给上方 Top20/全部游戏列表 */ .sglv-playdata-bottom-row { margin-top: 12px; flex: 0 0 auto; } .sglv-playdata-dist { overflow-y: auto; max-height: 260px; } .sglv-playdata-family { border-color: var(--sglv-border) !important; } .sglv-playdata-family .sglv-family-compare-section { padding: 0; margin-bottom: 0; background: transparent; border: none; box-shadow: none; } .sglv-playdata-family .sglv-family-compare-container { max-height: 152px; overflow-y: auto; } /* v2.3.30: "待加入"占位行——虚线轨道、暗淡文字,与真实成员区分 */ .sglv-family-bar-row.sglv-family-bar-pending { opacity: 0.55; } .sglv-family-bar-row.sglv-family-bar-pending .sglv-family-bar-name { color: #64748b; font-style: italic; } .sglv-family-bar-row.sglv-family-bar-pending .sglv-family-bar-track { border: 1px dashed rgba(255,255,255,0.1); background: transparent; } .sglv-family-bar-row.sglv-family-bar-pending .sglv-family-bar-hours { color: #64748b; } /* v2.3.30: 入库热力图样式(参考 steam-family-analysis 设计) */ .sglv-heatmap-section { background: var(--sglv-section-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 14px; flex-shrink: 0; min-width: 0; } /* v2.6.3: 入库热力图 + 近6月入库增量——左右分栏(热力图占主 2.5,增量占次 1);移动端 < 900px 改上下 */ /* v2.9.22: 热力图行 flex:1 撑满剩余高度,消除底部空余边距 */ .sglv-heatmap-acquire-row { display: flex; gap: 12px; align-items: stretch; width: 100%; box-sizing: border-box; flex: 1 1 0; min-height: 0; } .sglv-heatmap-acquire-row .sglv-heatmap-section { flex: 2.5 1 0; min-width: 0; min-height: 0; display: flex; flex-direction: column; } .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { flex: 1 1 0; min-width: 240px; max-width: 360px; min-height: 0; display: flex; flex-direction: column; } /* v2.9.22: 热力图内容区撑满 section 剩余高度并垂直居中 */ .sglv-heatmap-section > #sglv-pt-heatmap { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: center; } @media (max-width: 900px) { .sglv-heatmap-acquire-row { flex-direction: column; } .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { max-width: none; min-width: 0; } } .sglv-heatmap-block { display: flex; flex-direction: column; gap: 2px; } .sglv-heatmap-scroll { overflow-x: auto; overflow-y: hidden; border-radius: 6px; padding-bottom: 4px; } /* v2.4.2: 热力图年份选择器——复用 sglv-trend-btn 样式,支持换行 */ .sglv-hm-year-filter { flex-wrap: wrap; margin-bottom: 6px; } /* v2.5.0: 热力图主体 + 右侧近6月入库增量——两栏等高对齐,mini 独立卡片 */ /* v2.6.3: 入库热力图与近6月入库增量——左右分栏布局;mini 段作为右栏独立 section */ .sglv-heatmap-mainrow { display: flex; gap: 12px; align-items: stretch; } .sglv-heatmap-mainrow .sglv-heatmap-body { flex: 1; min-width: 0; display: flex; flex-direction: column; } .sglv-heatmap-body { display: flex; flex-direction: column; min-width: 0; } .sglv-hm-mini { width: 100%; display: flex; flex-direction: column; } /* v2.6.3: 独立 mini section——右栏容器,占满列宽,与左侧热力图等高对齐 */ .sglv-heatmap-mini-section { background: var(--sglv-section-bg); border: 1px solid var(--sglv-border); border-radius: var(--sglv-radius); padding: 12px 14px 10px; flex-shrink: 0; min-width: 0; display: flex; flex-direction: column; box-sizing: border-box; } .sglv-heatmap-mini-section .sglv-playtime-section-title { font-size: 13px !important; margin-bottom: 8px !important; } /* v2.6.3: mini 段内层——撑开列宽,柱图按列宽自适应(不再用 max-width 限制,避免右栏过宽时柱图被拉变形) */ .sglv-hm-mini-inner { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 8px; padding: 10px 8px 8px; display: flex; flex-direction: column; width: 100%; flex: 1; min-height: 0; box-sizing: border-box; } .sglv-hm-mini-title { font-size: 12px; font-weight: 700; color: var(--sglv-text); text-align: center; margin-bottom: 6px; flex-shrink: 0; } .sglv-hm-mini-chart { display: flex; align-items: center; justify-content: center; min-height: 0; width: 100%; flex: 1; } .sglv-hm-mini-chart svg { width: 100%; height: auto; max-height: 100%; } @media (max-width: 900px) { .sglv-heatmap-mainrow { flex-direction: column; } .sglv-hm-mini { width: 100%; } } .sglv-heatmap-scroll::-webkit-scrollbar { height: var(--sglv-scrollbar-width); } .sglv-heatmap-scroll::-webkit-scrollbar-thumb { background: var(--sglv-scrollbar-thumb); border-radius: 3px; } /* v2.4.9: 图例内嵌于统计摘要行——不再独立右对齐,避免溢出容器右下角 */ .sglv-heatmap-legend { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--sglv-text-secondary); margin-right: auto; } .sglv-heatmap-legend span { display: inline-block; } .sglv-heatmap-legend span.swatch { width: 11px; height: 11px; border-radius: 2px; } .sglv-heatmap-summary { display: flex; gap: 12px; font-size: 11px; color: var(--sglv-text-secondary); flex-wrap: wrap; margin-top: 4px; align-items: center; } .sglv-heatmap-summary b { color: var(--sglv-text); font-weight: 600; } .sglv-dist-bar-list { padding: 4px 0; } .sglv-dist-bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } .sglv-dist-bar-label { width: 72px; flex-shrink: 0; font-size: 11px; color: var(--sglv-text-secondary); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-dist-bar-track { flex: 1; height: 18px; background: rgba(255,255,255,0.04); border-radius: 4px; overflow: hidden; min-width: 0; } .sglv-dist-bar-fill { height: 100%; border-radius: 4px; transition: width .3s ease; min-width: 2px; } .sglv-dist-bar-value { width: 72px; flex-shrink: 0; font-size: 11px; color: var(--sglv-text); text-align: left; white-space: nowrap; font-weight: 600; font-variant-numeric: tabular-nums; } .sglv-dist-bar-pct { color: var(--sglv-text-secondary); font-weight: 400; } @media (max-width: 768px) { .sglv-playtime-row { grid-template-columns: 1fr; } .sglv-playtime-split-row { grid-template-columns: 1fr; } .sglv-playdata-bottom-row { grid-template-columns: 1fr; } } @media (max-width: 600px) { .sglv-playtime-kpi-row { grid-template-columns: repeat(2, 1fr); } .sglv-pt-bar-wrap { min-width: 40px; } } :root { --sgis-bg: linear-gradient(180deg, rgba(15, 17, 35, 0.98) 0%, rgba(11, 14, 28, 0.99) 100%); --sgis-bg-solid: #0c1024; --sgis-bg-2: rgba(15, 19, 42, 0.85); --sgis-bg-3: rgba(20, 24, 50, 0.7); --sgis-card: rgba(255, 255, 255, 0.025); --sgis-card-hover: rgba(255, 255, 255, 0.045); --sgis-border: rgba(139, 92, 246, 0.18); --sgis-border-soft: rgba(102, 192, 244, 0.1); --sgis-text: #e2e8f0; --sgis-text-2: #94a3b8; --sgis-text-3: #64748b; --sgis-text-dim: #475569; --sgis-blue: #66c0f4; --sgis-blue-2: #3b82f6; --sgis-green: #10b981; --sgis-green-text: #34d399; --sgis-purple: #8b5cf6; --sgis-purple-2: #a78bfa; --sgis-purple-text: #c4b5fd; --sgis-amber: #f59e0b; --sgis-amber-text: #fbbf24; --sgis-rose: #f43f5e; --sgis-rose-text: #f87171; --sgis-cyan: #06b6d4; --sgis-radius: 14px; --sgis-radius-sm: 8px; --sgis-shadow: 0 16px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(139, 92, 246, 0.08); --sgis-glow-purple: 0 0 24px rgba(139, 92, 246, 0.18); --sgis-glow-blue: 0 0 24px rgba(59, 130, 246, 0.18); --sgis-w: 440px; } #sgis-fab { position: fixed !important; top: 50% !important; right: 0 !important; transform: translateY(-50%) !important; width: 22px !important; height: 64px !important; background: linear-gradient(135deg, #8b5cf6 0%, #3b82f6 50%, #06b6d4 100%) !important; border: none !important; border-radius: 10px 0 0 10px !important; cursor: pointer !important; z-index: 1000003 !important; display: flex !important; align-items: center !important; justify-content: center !important; color: #fff !important; box-shadow: -4px 0 18px rgba(102, 51, 238, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.15) !important; transition: right 0.4s cubic-bezier(0.4, 0, 0.2, 1), width 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease !important; overflow: hidden; } #sgis-fab::before { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, 0.18) 0%, transparent 50%); pointer-events: none; } #sgis-fab::after { content: ""; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-right: 7px solid #fff; transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5)); } #sgis-fab:hover { width: 28px !important; box-shadow: -6px 0 24px rgba(102, 51, 238, 0.55), inset 0 0 0 1px rgba(255, 255, 255, 0.2) !important; } #sgis-fab:hover::after { border-right-width: 8px; } #sgis-fab.sgis-open { right: var(--sgis-w, 440px) !important; } #sgis-fab.sgis-open::after { transform: rotate(180deg); } #sgis-panel { position: fixed !important; top: 0 !important; right: 0 !important; width: var(--sgis-w, 440px) !important; min-width: var(--sgis-w, 440px) !important; max-width: var(--sgis-w, 440px) !important; box-sizing: border-box !important; height: 100vh !important; background: var(--sgis-bg) !important; border-left: 1px solid rgba(139, 92, 246, 0.22) !important; box-shadow: -16px 0 60px rgba(0, 0, 0, 0.65), inset 1px 0 0 rgba(255, 255, 255, 0.04) !important; z-index: 1000002 !important; display: flex !important; flex-direction: column !important; transform: translateX(100%) !important; transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; color: #f8fafc !important; backdrop-filter: blur(20px) saturate(160%) !important; -webkit-backdrop-filter: blur(20px) saturate(160%) !important; overflow: hidden; } #sgis-panel::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 1px; background: linear-gradient(90deg, transparent 0%, rgba(139, 92, 246, 0.5) 50%, transparent 100%); pointer-events: none; z-index: 5; } #sgis-panel.sgis-open { transform: translateX(0) !important; } .sgis-header { background: linear-gradient(135deg, rgba(139, 92, 246, 0.22) 0%, rgba(59, 130, 246, 0.12) 50%, var(--sgis-bg-2) 100%); padding: 14px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(139, 92, 246, 0.2); flex-shrink: 0; position: relative; overflow: hidden; } .sgis-header::before { content: ""; position: absolute; top: -40px; left: -30px; width: 140px; height: 140px; background: radial-gradient(circle, rgba(139, 92, 246, 0.22) 0%, transparent 65%); pointer-events: none; } .sgis-header::after { content: ""; position: absolute; top: -30px; right: -30px; width: 130px; height: 130px; background: radial-gradient(circle, rgba(6, 182, 212, 0.18) 0%, transparent 65%); pointer-events: none; } .sgis-header-decoration { position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background: linear-gradient(90deg, transparent 0%, rgba(139, 92, 246, 0.4) 50%, transparent 100%); } .sgis-title { font-size: 15px; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 9px; position: relative; z-index: 2; min-width: 0; flex: 1; overflow: hidden; letter-spacing: 0.2px; } .sgis-title-text { background: linear-gradient(135deg, #fff 0%, #c4b5fd 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .sgis-title-icon { width: 26px; height: 26px; border-radius: 8px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 50%, #3b82f6 100%); display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2); position: relative; z-index: 1; } .sgis-title-icon::after { content: ""; position: absolute; inset: 1px; border-radius: 7px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, transparent 50%); pointer-events: none; } .sgis-title-icon svg { width: 14px; height: 14px; stroke: #fff; stroke-width: 2.4; fill: none; position: relative; z-index: 2; } .sgis-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-title-actions { display: flex; gap: 4px; position: relative; z-index: 1; } .sgis-icon-btn { width: 30px; height: 30px; border-radius: 8px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.05); cursor: pointer; display: flex; align-items: center; justify-content: center; color: #b8c5d6; transition: all 0.2s ease; position: relative; z-index: 2; } .sgis-icon-btn:hover { background: rgba(139, 92, 246, 0.18); border-color: rgba(139, 92, 246, 0.3); color: #fff; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25); } .sgis-icon-btn:active { transform: translateY(0); } .sgis-icon-btn.sgis-spin svg { animation: sgis-spin 0.8s linear infinite; } .sgis-icon-btn svg { width: 14px; height: 14px; } @keyframes sgis-spin { to { transform: rotate(360deg); } } .sgis-tabs { display: flex; background: linear-gradient(180deg, rgba(15, 19, 42, 0.7) 0%, rgba(11, 14, 28, 0.85) 100%); padding: 5px; gap: 4px; border-bottom: 1px solid rgba(139, 92, 246, 0.12); flex-shrink: 0; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); position: relative; z-index: 2; } .sgis-tab { flex: 1; border: none; background: transparent; color: var(--sgis-text-2); padding: 8px 6px; font-size: 12px; font-weight: 600; border-radius: 7px; cursor: pointer; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; justify-content: center; gap: 5px; position: relative; overflow: hidden; } .sgis-tab::before { content: ""; position: absolute; inset: 0; background: linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(59, 130, 246, 0.2)); opacity: 0; transition: opacity 0.25s ease; border-radius: 7px; } .sgis-tab:hover { color: #c4b5fd; } .sgis-tab:hover::before { opacity: 0.4; } .sgis-tab.active { color: #fff; box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25), inset 0 0 0 1px rgba(139, 92, 246, 0.5); } .sgis-tab.active::before { opacity: 1; } .sgis-tab > * { position: relative; z-index: 1; } .sgis-tab svg { width: 13px; height: 13px; } .sgis-body { flex: 1; overflow-y: auto; padding: 14px 12px; scrollbar-width: thin; scrollbar-color: rgba(139, 92, 246, 0.4) transparent; position: relative; z-index: 1; } .sgis-body::-webkit-scrollbar { width: 6px; } .sgis-body::-webkit-scrollbar-track { background: transparent; } .sgis-body::-webkit-scrollbar-thumb { background: linear-gradient(180deg, rgba(139, 92, 246, 0.4), rgba(59, 130, 246, 0.4)); border-radius: 3px; } .sgis-body::-webkit-scrollbar-thumb:hover { background: linear-gradient(180deg, rgba(139, 92, 246, 0.6), rgba(59, 130, 246, 0.6)); } .sgis-state { text-align: center; padding: 40px 20px; color: var(--sgis-text-2); } .sgis-retry-btn { padding: 7px 20px; font-size: 12px; font-weight: 600; border: 1px solid rgba(139, 92, 246, 0.4); border-radius: 7px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.1)); color: #c4b5fd; cursor: pointer; transition: all 0.2s; } .sgis-retry-btn:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(59, 130, 246, 0.2)); color: #fff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(139, 92, 246, 0.25); } .sgis-spinner { width: 36px; height: 36px; border: 3px solid rgba(139, 92, 246, 0.15); border-top-color: #8b5cf6; border-right-color: #3b82f6; border-radius: 50%; animation: sgis-spin 0.8s linear infinite; margin: 0 auto 12px; box-shadow: 0 0 16px rgba(139, 92, 246, 0.3); } .sgis-error { color: var(--sgis-rose-text); } .sgis-hero { position: relative; background: linear-gradient(135deg, rgba(102, 192, 244, 0.15) 0%, rgba(139, 92, 246, 0.1) 50%, var(--sgis-bg-2) 100%); border: 1px solid rgba(102, 192, 244, 0.28); border-radius: var(--sgis-radius); padding: 14px; margin-bottom: 12px; display: flex; gap: 12px; align-items: flex-start; overflow: hidden; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.04); } .sgis-hero::before { content: ""; position: absolute; top: -40px; right: -30px; width: 130px; height: 130px; background: radial-gradient(circle, rgba(102, 192, 244, 0.18) 0%, transparent 60%); pointer-events: none; } .sgis-hero::after { content: ""; position: absolute; bottom: -30px; left: -30px; width: 100px; height: 100px; background: radial-gradient(circle, rgba(139, 92, 246, 0.15) 0%, transparent 60%); pointer-events: none; } .sgis-hero-img { width: 132px; height: 50px; border-radius: 8px; object-fit: cover; flex-shrink: 0; background: rgba(0, 0, 0, 0.4); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); position: relative; z-index: 1; border: 1px solid rgba(255, 255, 255, 0.06); } .sgis-hero-info { flex: 1; min-width: 0; position: relative; z-index: 1; } .sgis-hero-name { font-size: 15px; font-weight: 700; color: #fff; margin-bottom: 5px; line-height: 1.3; word-break: break-word; letter-spacing: 0.1px; } .sgis-hero-meta { font-size: 11px; color: var(--sgis-text-2); display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; } .sgis-hero-meta b { color: var(--sgis-text); font-weight: 600; } .sgis-hero-meta-pill { display: inline-flex; align-items: center; gap: 3px; padding: 2px 7px; background: rgba(102, 192, 244, 0.12); border: 1px solid rgba(102, 192, 244, 0.2); border-radius: 10px; font-size: 10px; color: #c4e7ff; font-weight: 600; } .sgis-section { background: var(--sgis-card); border: 1px solid rgba(255, 255, 255, 0.04); border-radius: var(--sgis-radius-sm); padding: 11px 12px; margin-bottom: 10px; transition: border-color 0.25s ease, background 0.25s ease; } .sgis-section:hover { border-color: rgba(139, 92, 246, 0.12); background: var(--sgis-card-hover); } .sgis-section-title { font-size: 11px; font-weight: 700; color: #a78bfa; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 9px; display: flex; align-items: center; gap: 6px; position: relative; padding-bottom: 6px; border-bottom: 1px solid rgba(139, 92, 246, 0.08); } .sgis-section-title svg { width: 12px; height: 12px; color: #a78bfa; } .sgis-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; font-size: 12px; border-bottom: 1px dashed rgba(139, 92, 246, 0.08); } .sgis-row:last-child { border-bottom: none; } .sgis-row-key { color: var(--sgis-text-2); } .sgis-row-val { color: var(--sgis-text); font-weight: 600; text-align: right; max-width: 60%; word-break: break-word; } .sgis-row-val.discount { color: var(--sgis-green-text); } .sgis-row-val.high { color: var(--sgis-rose-text); } .sgis-tag-list { display: flex; flex-wrap: wrap; gap: 5px; } .sgis-tag { padding: 3px 9px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.12), rgba(59, 130, 246, 0.06)); color: #c4b5fd; border-radius: 12px; font-size: 11px; font-weight: 600; border: 1px solid rgba(139, 92, 246, 0.18); transition: all 0.2s; } .sgis-tag:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.22), rgba(59, 130, 246, 0.12)); border-color: rgba(139, 92, 246, 0.35); transform: translateY(-1px); } .sgis-review-line { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 4px; } .sgis-review-line:last-child { margin-bottom: 0; } .sgis-review-line .sgis-review-label { color: var(--sgis-text-2); flex: 1; } .sgis-review-line .sgis-review-val { font-weight: 600; } .sgis-review-line .sgis-review-val.pos { color: var(--sgis-green-text); } .sgis-review-line .sgis-review-val.neg { color: var(--sgis-rose-text); } .sgis-review-line .sgis-review-val.mix { color: var(--sgis-amber-text); } .sgis-thumb { display: inline-block; width: 14px; height: 14px; background: var(--sgis-rose); -webkit-mask: url("data:image/svg+xml;utf8,") center/contain no-repeat; mask: url("data:image/svg+xml;utf8,") center/contain no-repeat; } .sgis-thumb.up { background: var(--sgis-green); } .sgis-thumb.down { background: var(--sgis-rose); } .sgis-links { display: flex; flex-wrap: wrap; gap: 5px; } .sgis-link { padding: 5px 9px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.1), rgba(59, 130, 246, 0.06)); color: #c4b5fd; border-radius: 7px; font-size: 11px; font-weight: 600; text-decoration: none; border: 1px solid rgba(139, 92, 246, 0.2); transition: all 0.2s; } .sgis-link:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(59, 130, 246, 0.12)); color: #fff; border-color: rgba(139, 92, 246, 0.4); transform: translateY(-1px); } /* v2.9.83: 愿望单促销列表 */ .sgis-wl-sale-list { display: flex; flex-direction: column; gap: 4px; } .sgis-wl-sale-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; background: rgba(244, 63, 94, 0.06); border: 1px solid rgba(244, 63, 94, 0.12); border-radius: 6px; text-decoration: none; transition: all 0.2s; } .sgis-wl-sale-item:hover { background: rgba(244, 63, 94, 0.12); border-color: rgba(244, 63, 94, 0.25); transform: translateX(2px); } .sgis-wl-sale-name { font-size: 11px; color: var(--sgis-text); font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-wl-sale-price { display: flex; align-items: center; gap: 5px; flex-shrink: 0; } .sgis-wl-sale-discount { font-size: 10px; font-weight: 700; color: #4ade80; background: rgba(74, 222, 128, 0.15); padding: 1px 5px; border-radius: 4px; } .sgis-wl-sale-final { font-size: 11px; color: #4ade80; font-weight: 600; } .sgis-wl-sale-orig { font-size: 10px; color: var(--sgis-text-2); text-decoration: line-through; } /* v2.9.83: 游戏口味标签画像 */ .sgis-taste-section { margin-bottom: 12px; } .sgis-taste-cloud { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; } .sgis-taste-tag { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; cursor: default; transition: all 0.2s; border: 1px solid; } .sgis-taste-tag:hover { transform: translateY(-1px); } .sgis-taste-tag.t-xl { font-size: 13px; padding: 5px 13px; } .sgis-taste-tag.t-lg { font-size: 12px; padding: 5px 12px; } .sgis-taste-tag.t-md { font-size: 11px; } .sgis-taste-tag.t-sm { font-size: 10px; opacity: 0.85; } .sgis-taste-tag.genre { background: rgba(59, 130, 246, 0.12); color: #93c5fd; border-color: rgba(59, 130, 246, 0.22); } .sgis-taste-tag.theme { background: rgba(139, 92, 246, 0.12); color: #c4b5fd; border-color: rgba(139, 92, 246, 0.22); } .sgis-taste-tag.feature { background: rgba(16, 185, 129, 0.12); color: #6ee7b7; border-color: rgba(16, 185, 129, 0.22); } .sgis-taste-tag.style { background: rgba(245, 158, 11, 0.12); color: #fbbf24; border-color: rgba(245, 158, 11, 0.22); } .sgis-taste-bars { display: flex; flex-direction: column; gap: 6px; } .sgis-taste-bar-row { display: flex; align-items: center; gap: 8px; font-size: 11px; } .sgis-taste-bar-label { width: 80px; flex-shrink: 0; color: var(--sgis-text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-taste-bar-track { flex: 1; height: 6px; background: rgba(255, 255, 255, 0.06); border-radius: 3px; overflow: hidden; } .sgis-taste-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s ease; } .sgis-taste-bar-val { flex-shrink: 0; text-align: right; color: var(--sgis-text-2); font-size: 10px; white-space: nowrap; } .sgis-owned-hero { position: relative; background: linear-gradient(135deg, rgba(16, 185, 129, 0.18) 0%, rgba(20, 184, 166, 0.08) 50%, var(--sgis-bg-2) 100%); border: 1px solid rgba(16, 185, 129, 0.35); border-radius: var(--sgis-radius); padding: 6px 10px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; overflow: hidden; box-shadow: 0 4px 16px rgba(16, 185, 129, 0.1); } .sgis-owned-hero::after { content: ""; position: absolute; top: -30px; right: -30px; width: 120px; height: 120px; background: radial-gradient(circle, rgba(16, 185, 129, 0.2) 0%, transparent 65%); pointer-events: none; } .sgis-owned-hero > * { position: relative; z-index: 1; } .sgis-owned-hero.shared { background: linear-gradient(135deg, rgba(139, 92, 246, 0.18) 0%, rgba(99, 102, 241, 0.08) 50%, var(--sgis-bg-2) 100%); border-color: rgba(139, 92, 246, 0.35); } .sgis-owned-hero.shared::after { background: radial-gradient(circle, rgba(139, 92, 246, 0.2) 0%, transparent 65%); } .sgis-owned-hero.not-owned { background: linear-gradient(135deg, rgba(244, 63, 94, 0.15) 0%, rgba(244, 63, 94, 0.05) 50%, var(--sgis-bg-2) 100%); border-color: rgba(244, 63, 94, 0.35); } .sgis-owned-hero.not-owned::after { background: radial-gradient(circle, rgba(244, 63, 94, 0.2) 0%, transparent 65%); } .sgis-owned-icon { width: 24px; height: 24px; border-radius: 7px; background: linear-gradient(135deg, #10b981, #059669); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 13px; flex-shrink: 0; box-shadow: 0 2px 6px rgba(16, 185, 129, 0.4); } .sgis-owned-hero.shared .sgis-owned-icon { background: linear-gradient(135deg, #8b5cf6, #6366f1); box-shadow: 0 2px 6px rgba(139, 92, 246, 0.4); } .sgis-owned-hero.not-owned .sgis-owned-icon { background: linear-gradient(135deg, #f43f5e, #e11d48); box-shadow: 0 2px 6px rgba(244, 63, 94, 0.4); } .sgis-owned-icon svg { width: 13px; height: 13px; stroke: #fff; fill: none; stroke-width: 2.4; } /* v2.3.30: 压缩为单行——main+sub 内联,按钮直接挂在 hero 行尾 */ .sgis-owned-text-main { font-size: 12px; font-weight: 700; color: #fff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-owned-text-sub { font-size: 10px; color: var(--sgis-text-2); font-weight: 400; margin-left: 5px; } .sgis-owned-hero .sgis-btn { flex-shrink: 0; } .sgis-btn { padding: 3px 9px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(59, 130, 246, 0.1)); border: 1px solid rgba(139, 92, 246, 0.3); color: #c4b5fd; border-radius: 6px; font-size: 10px; font-weight: 600; cursor: pointer; transition: all 0.2s; text-decoration: none; display: inline-flex; align-items: center; gap: 4px; } .sgis-btn:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(59, 130, 246, 0.18)); border-color: rgba(139, 92, 246, 0.45); color: #fff; transform: translateY(-1px); box-shadow: 0 4px 10px rgba(139, 92, 246, 0.2); } .sgis-btn svg { width: 11px; height: 11px; } .sgis-stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-bottom: 8px; } .sgis-stat-box { background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015)); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 7px; padding: 5px 4px; text-align: center; transition: all 0.2s; } .sgis-stat-box:hover { border-color: rgba(139, 92, 246, 0.3); background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.04)); } .sgis-stat-box-val { font-size: 13px; font-weight: 700; color: #fff; line-height: 1.1; letter-spacing: -0.3px; } .sgis-stat-box-lbl { font-size: 9px; color: var(--sgis-text-2); margin-top: 2px; } .sgis-stat-box.playtime .sgis-stat-box-val { color: var(--sgis-purple-text); } .sgis-stat-box.lastplay .sgis-stat-box-val { color: var(--sgis-blue); font-size: 12px; } .sgis-owner-list { display: flex; flex-direction: column; gap: 4px; } .sgis-owner-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 6px; font-size: 12px; } .sgis-owner-item.me { border-color: rgba(16, 185, 129, 0.35); background: rgba(16, 185, 129, 0.08); } .sgis-owner-item.sharer { border-color: rgba(139, 92, 246, 0.35); background: rgba(139, 92, 246, 0.08); } .sgis-owner-avatar { width: 26px; height: 26px; border-radius: 8px; background: linear-gradient(135deg, #66c0f4, #3b82f6); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; box-shadow: 0 2px 6px rgba(59, 130, 246, 0.3); border: 1px solid rgba(255, 255, 255, 0.1); } .sgis-owner-info { flex: 1; min-width: 0; } .sgis-owner-name { font-weight: 600; color: #fff; } .sgis-owner-tag { font-size: 9px; padding: 1px 4px; border-radius: 3px; margin-left: 4px; font-weight: 600; } .sgis-owner-tag.me { background: rgba(16, 185, 129, 0.25); color: var(--sgis-green-text); } .sgis-owner-tag.sharer { background: rgba(139, 92, 246, 0.25); color: var(--sgis-purple-text); } .sgis-owner-time { font-size: 10px; color: var(--sgis-text-2); } .sgis-owner-playtime { font-size: 10px; color: var(--sgis-green-text, #4caf50); margin-top: 2px; } .sgis-card-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 10px; } .sgis-card-stat { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; padding: 6px; text-align: center; } .sgis-card-stat-val { font-size: 14px; font-weight: 700; color: #fff; line-height: 1.2; } .sgis-card-stat-lbl { font-size: 10px; color: var(--sgis-text-2); margin-top: 2px; } .sgis-card-stat.sell .sgis-card-stat-val { color: var(--sgis-green-text); } .sgis-card-stat.net .sgis-card-stat-val { color: var(--sgis-blue); } .sgis-card-stat.buy .sgis-card-stat-val { color: var(--sgis-amber-text); } .sgis-card-toggle { display: flex; gap: 0; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 6px; overflow: hidden; margin-bottom: 8px; } .sgis-card-toggle button { flex: 1; padding: 5px 8px; background: rgba(0, 0, 0, 0.25); border: none; border-right: 1px solid rgba(255, 255, 255, 0.08); color: var(--sgis-text-2); font-size: 11px; font-weight: 600; cursor: pointer; transition: all 0.2s; } .sgis-card-toggle button:last-child { border-right: none; } .sgis-card-toggle button:hover { background: rgba(59, 130, 246, 0.15); color: var(--sgis-text); } .sgis-card-toggle button.active { background: linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(59, 130, 246, 0.2)); color: #fff; box-shadow: inset 0 0 0 1px rgba(139, 92, 246, 0.4); } .sgis-card-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.04); border-radius: 7px; margin-bottom: 4px; transition: all 0.2s; cursor: pointer; text-decoration: none !important; color: inherit !important; position: relative; overflow: hidden; } .sgis-card-item::before { content: ""; position: absolute; inset: 0; background-image: var(--sgis-card-bg, none); background-size: cover; background-position: center; opacity: 0.18; transition: opacity 0.3s ease; pointer-events: none; z-index: 0; } .sgis-card-item:hover { background: rgba(139, 92, 246, 0.12); border-color: rgba(139, 92, 246, 0.3); transform: translateX(2px); box-shadow: 0 4px 12px rgba(139, 92, 246, 0.15); } .sgis-card-item:hover::before { opacity: 0.28; } .sgis-card-item > * { position: relative; z-index: 1; } .sgis-card-icon { width: 40px; height: 40px; border-radius: 6px; background: rgba(0, 0, 0, 0.4); object-fit: cover; flex-shrink: 0; border: 1px solid rgba(255, 255, 255, 0.08); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); } .sgis-card-icon-fallback { display: flex; align-items: center; justify-content: center; color: var(--sgis-text-2); background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.08)); padding: 4px; } .sgis-card-icon-fallback svg { width: 100%; height: 100%; display: block; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); } .sgis-card-info { flex: 1; min-width: 0; overflow: hidden; } .sgis-card-name { font-size: 12px; font-weight: 600; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; } .sgis-card-prices { font-size: 11px; color: var(--sgis-text-2); display: flex; gap: 8px; margin-top: 2px; } .sgis-card-prices .sell { color: var(--sgis-green-text); font-weight: 600; } .sgis-card-prices .buy { color: var(--sgis-amber-text); font-weight: 600; } .sgis-card-badge { font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(139, 92, 246, 0.2); color: var(--sgis-purple-text); font-weight: 700; flex-shrink: 0; } .sgis-card-badge.foil { background: linear-gradient(135deg, rgba(245, 158, 11, 0.3), rgba(217, 119, 6, 0.2)); color: var(--sgis-amber-text); border: 1px solid rgba(245, 158, 11, 0.3); } .sgis-price-table { display: flex; flex-direction: column; gap: 4px; } .sgis-price-row { display: flex; align-items: center; padding: 7px 8px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 7px; transition: all 0.2s; font-size: 12px; gap: 8px; } .sgis-price-row:hover { background: rgba(59, 130, 246, 0.1); border-color: rgba(59, 130, 246, 0.25); transform: translateX(2px); } .sgis-price-row.lowest { background: linear-gradient(135deg, rgba(164, 208, 7, 0.15), rgba(132, 204, 22, 0.08)); border-color: rgba(164, 208, 7, 0.45); box-shadow: 0 2px 8px rgba(164, 208, 7, 0.1); } .sgis-price-row.user-region { border-left: 3px solid var(--sgis-blue); } .sgis-price-flag { width: 30px; height: 20px; border-radius: 2px; object-fit: cover; flex-shrink: 0; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05); } .sgis-price-row.lowest .sgis-price-flag { box-shadow: 0 0 0 2px #a4d007, 0 0 8px rgba(164, 208, 7, 0.4); } .sgis-price-code { font-size: 10px; font-weight: 700; color: var(--sgis-text-2); letter-spacing: 0.5px; flex-shrink: 0; min-width: 26px; } .sgis-price-row.lowest .sgis-price-code { color: #a4d007; } .sgis-price-featured { background: linear-gradient(135deg, rgba(164, 208, 7, 0.22) 0%, rgba(132, 204, 22, 0.1) 50%, rgba(101, 163, 13, 0.05) 100%); border: 1.5px solid rgba(164, 208, 7, 0.55); border-radius: var(--sgis-radius); padding: 12px; margin-bottom: 10px; display: flex; align-items: center; gap: 10px; position: relative; overflow: hidden; box-shadow: 0 4px 16px rgba(164, 208, 7, 0.15); } .sgis-price-featured::before { content: ""; position: absolute; top: -30px; right: -30px; width: 120px; height: 120px; background: radial-gradient(circle, rgba(164, 208, 7, 0.25) 0%, transparent 65%); pointer-events: none; } .sgis-price-featured > * { position: relative; z-index: 1; } .sgis-price-featured::after { content: "最低价"; position: absolute; top: 6px; right: 8px; font-size: 9px; background: linear-gradient(135deg, #a4d007, #65a30d); color: #fff; padding: 2px 6px; border-radius: 3px; font-weight: 700; letter-spacing: 0.5px; } .sgis-price-featured-img { width: 48px; height: 32px; border-radius: 4px; object-fit: cover; flex-shrink: 0; box-shadow: 0 0 0 1.5px rgba(164, 208, 7, 0.5); } .sgis-price-featured-info { flex: 1; min-width: 0; } .sgis-price-featured-region { font-size: 13px; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 6px; } .sgis-price-featured-region .sgis-price-featured-cny { font-size: 11px; color: #a4d007; font-weight: 600; } .sgis-price-featured-original { font-size: 10px; color: var(--sgis-text-2); text-decoration: line-through; margin-right: 4px; } .sgis-price-featured-discount { background: #a4d007; color: #fff; padding: 1px 4px; border-radius: 3px; font-size: 9px; font-weight: 700; margin-left: 4px; } .sgis-price-region { flex: 1; color: var(--sgis-text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-price-region b { color: #fff; font-weight: 600; } .sgis-price-region .sgis-user-tag { font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(102, 192, 244, 0.2); color: var(--sgis-blue); margin-left: 4px; } .sgis-price-original { color: var(--sgis-text-2); text-decoration: line-through; font-size: 11px; margin-right: 4px; } .sgis-price-value { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 1px; } .sgis-price-local { font-size: 12px; color: var(--sgis-text); font-weight: 600; } .sgis-price-cny { font-size: 11px; color: var(--sgis-green-text); font-weight: 600; } .sgis-price-row.lowest .sgis-price-cny { font-size: 12px; } .sgis-price-discount { background: var(--sgis-green); color: #fff; padding: 1px 5px; border-radius: 3px; font-size: 10px; font-weight: 700; margin-left: 4px; } @media (max-width: 600px) { #sgis-fab.sgis-open { display: none !important; } :root { --sgis-w: 100vw !important; } } /* v2.3.14: 移除 body.sgis-open { overflow: hidden !important; } —— 个人信息面板为 position:fixed 侧边栏, 无需锁定主页面滚动;仅在移动端面板全屏覆盖时锁定 body 滚动,避免背后页面误滚动 */ @media (max-width: 600px) { body.sgis-open { overflow: hidden !important; } } body.sgis-open #sf-wf-overlay { right: var(--sgis-w, 440px) !important; left: 0 !important; width: calc(100vw - var(--sgis-w, 440px)) !important; transition: opacity .35s ease, visibility .35s ease, right 0.4s cubic-bezier(0.4, 0, 0.2, 1), width 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important; } body.sgis-open #sf-wf-overlay .sf-wf-header { left: 0 !important; width: 100% !important; } body.sgis-open #sf-wf-overlay .sf-wf-body { max-width: calc(100vw - var(--sgis-w, 440px) - 48px) !important; margin: 0 !important; padding-left: 24px !important; } body.sgis-open #sf-wf-overlay .sf-wf-grid { justify-content: flex-start !important; } body.sgis-open #sf-waterfall-btn, body.sgis-open #sf-friend-btn, body.sgis-open #sf-library-btn, body.sgis-open #sf-settings-btn, body.sgis-open #sf-top-btn { right: calc(30px + var(--sgis-w, 440px)) !important; } @media (max-width: 600px) { body.sgis-open #sf-wf-overlay { right: 0 !important; width: 100vw !important; } body.sgis-open #sf-wf-overlay .sf-wf-body { max-width: none !important; padding-left: 12px !important; } body.sgis-open #sf-waterfall-btn, body.sgis-open #sf-friend-btn, body.sgis-open #sf-library-btn, body.sgis-open #sf-settings-btn, body.sgis-open #sf-top-btn { right: 16px !important; } } body:has(#sf-wf-overlay.sf-wf-open) #sgis-fab { display: none !important; } /* v2.3.26: 愿望单页面隐藏浮动按钮,让 Wishlist Exporter 侧边栏独占 */ body:has(#swe-panel) #sgis-fab { display: none !important; } .sgis-family-share-row { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 6px; margin: 6px 0; font-size: 12px; font-weight: 600; } .sgis-family-share-row.supported { background: rgba(76, 175, 80, 0.12); border: 1px solid rgba(76, 175, 80, 0.3); color: var(--sgis-green-text, #4caf50); } .sgis-family-share-row.unsupported { background: rgba(244, 67, 54, 0.1); border: 1px solid rgba(244, 67, 54, 0.25); color: #f44336; } .sgis-family-share-icon { width: 18px; height: 18px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; } .sgis-family-share-row.supported .sgis-family-share-icon { background: rgba(76, 175, 80, 0.3); } .sgis-family-share-row.unsupported .sgis-family-share-icon { background: rgba(244, 67, 54, 0.3); } .sgis-family-share-row.sharing { background: rgba(139, 92, 246, 0.12); border: 1px solid rgba(139, 92, 246, 0.3); color: #a78bfa; } .sgis-family-share-row.sharing .sgis-family-share-icon { background: rgba(139, 92, 246, 0.3); } .sgis-family-share-source { margin-left: auto; font-size: 9px; opacity: 0.5; font-weight: 400; } .sgis-workshop-row { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 6px; margin: 6px 0; font-size: 12px; font-weight: 600; } .sgis-workshop-row.supported { background: rgba(102, 192, 244, 0.12); border: 1px solid rgba(102, 192, 244, 0.3); color: #66c0f4; } .sgis-workshop-row.unsupported { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); color: var(--sgis-text-2); } .sgis-workshop-icon { width: 18px; height: 18px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .sgis-workshop-icon svg { width: 12px; height: 12px; } .sgis-workshop-row.supported .sgis-workshop-icon { background: rgba(102, 192, 244, 0.3); } .sgis-workshop-row.unsupported .sgis-workshop-icon { background: rgba(255, 255, 255, 0.08); } .sgis-workshop-link { margin-left: auto; font-size: 10px; font-weight: 600; color: inherit; text-decoration: none; padding: 2px 8px; border-radius: 4px; background: rgba(255, 255, 255, 0.08); transition: all 0.2s; } .sgis-workshop-link:hover { background: rgba(255, 255, 255, 0.16); } .sgis-category-list { display: flex; flex-wrap: wrap; gap: 5px; } .sgis-category-pill { padding: 3px 9px; background: rgba(255, 255, 255, 0.05); color: var(--sgis-text); border-radius: 12px; font-size: 11px; font-weight: 600; border: 1px solid rgba(255, 255, 255, 0.08); transition: all 0.2s; } .sgis-category-pill:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.16); } .sgis-category-pill.highlight { background: linear-gradient(135deg, rgba(102, 192, 244, 0.18), rgba(59, 130, 246, 0.1)); color: #c4e7ff; border-color: rgba(102, 192, 244, 0.32); } .sgis-category-pill.highlight:hover { background: linear-gradient(135deg, rgba(102, 192, 244, 0.28), rgba(59, 130, 246, 0.18)); border-color: rgba(102, 192, 244, 0.48); } .sgis-screenshot-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } .sgis-screenshot-item { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 5px; overflow: hidden; background: rgba(0, 0, 0, 0.3); cursor: pointer; transition: transform 0.2s; } .sgis-screenshot-item:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } .sgis-screenshot-item img { width: 100%; height: 100%; object-fit: cover; display: block; } .sgis-screenshot-item img[data-src] { opacity: 0; transition: opacity 0.3s; } .sgis-screenshot-item img.loaded { opacity: 1; } .sgis-screenshot-more { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.65); color: #fff; font-size: 14px; font-weight: 700; } .sgis-package-list { display: flex; flex-direction: column; gap: 5px; } .sgis-package-item { display: flex; align-items: center; gap: 8px; padding: 7px 9px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 6px; font-size: 11px; transition: all 0.2s; } .sgis-package-item:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(102, 192, 244, 0.18); } .sgis-package-name { flex: 1; min-width: 0; color: var(--sgis-text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-package-price { font-weight: 700; color: var(--sgis-green-text); white-space: nowrap; flex-shrink: 0; } .sgis-package-price.free { color: #66c0f4; } .sgis-package-flag { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: 700; flex-shrink: 0; } .sgis-package-flag.free { background: rgba(102, 192, 244, 0.18); color: #66c0f4; } .sgis-package-flag.discount { background: rgba(76, 175, 80, 0.18); color: #4caf50; } .sgis-meta-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 10px; font-size: 10px; color: var(--sgis-text-2); font-weight: 600; } .sgis-meta-chip svg { width: 10px; height: 10px; } .sgis-meta-chip.pos { color: var(--sgis-green-text); border-color: rgba(76, 175, 80, 0.25); background: rgba(76, 175, 80, 0.08); } .sgis-meta-chip.neg { color: var(--sgis-rose-text); border-color: rgba(244, 63, 94, 0.25); background: rgba(244, 63, 94, 0.06); } .sgis-meta-chip.warn { color: var(--sgis-amber-text); border-color: rgba(255, 152, 0, 0.25); background: rgba(255, 152, 0, 0.08); } /* v2.3.17: 年龄限制标识 */ .sgis-age-badge { display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; white-space: nowrap; } .sgis-age-badge svg { width: 12px; height: 12px; } .sgis-age-badge.unrestricted { background: rgba(76, 175, 80, 0.12); color: var(--sgis-green-text); border: 1px solid rgba(76, 175, 80, 0.25); } .sgis-age-badge.restricted { background: rgba(244, 63, 94, 0.12); color: var(--sgis-rose-text); border: 1px solid rgba(244, 63, 94, 0.3); } .sgis-age-badge.mature { background: rgba(255, 152, 0, 0.12); color: var(--sgis-amber-text); border: 1px solid rgba(255, 152, 0, 0.3); } .sgis-content-note { margin-top: 4px; padding: 5px 8px; background: rgba(244, 63, 94, 0.06); border-left: 2px solid rgba(244, 63, 94, 0.4); border-radius: 0 4px 4px 0; font-size: 10px; color: var(--sgis-rose-text); line-height: 1.4; } /* v2.3.17: DLC 列表 */ .sgis-dlc-list { display: flex; flex-direction: column; gap: 5px; } .sgis-dlc-item { display: flex; align-items: center; gap: 8px; padding: 6px 9px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 6px; font-size: 11px; transition: all 0.2s; text-decoration: none; color: var(--sgis-text); } .sgis-dlc-item:hover { background: rgba(102, 192, 244, 0.08); border-color: rgba(102, 192, 244, 0.22); transform: translateX(2px); } .sgis-dlc-id { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 10px; color: var(--sgis-text-2); background: rgba(255, 255, 255, 0.05); padding: 1px 6px; border-radius: 3px; flex-shrink: 0; } .sgis-dlc-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } .sgis-dlc-name.loading { color: var(--sgis-text-2); font-weight: 400; font-style: italic; } .sgis-dlc-arrow { color: var(--sgis-text-2); font-size: 10px; flex-shrink: 0; } .sgis-dlc-item:hover .sgis-dlc-arrow { color: #66c0f4; } /* v2.3.17: 预告片 */ .sgis-movie-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; } .sgis-movie-item { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 5px; overflow: hidden; background: rgba(0, 0, 0, 0.3); cursor: pointer; transition: transform 0.2s; } .sgis-movie-item:hover { transform: scale(1.02); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } .sgis-movie-item img { width: 100%; height: 100%; object-fit: cover; display: block; } .sgis-movie-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.4); transition: background 0.2s; } .sgis-movie-play svg { width: 28px; height: 28px; color: #fff; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.6)); } .sgis-movie-item:hover .sgis-movie-play { background: rgba(0, 0, 0, 0.25); } .sgis-movie-name { position: absolute; bottom: 0; left: 0; right: 0; padding: 4px 6px; background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); color: #fff; font-size: 10px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* v2.3.17: 语言标签 */ .sgis-lang-list { display: flex; flex-wrap: wrap; gap: 4px; } .sgis-lang-pill { padding: 2px 7px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 10px; font-size: 10px; color: var(--sgis-text-2); font-weight: 600; } .sgis-lang-pill.audio { background: rgba(102, 192, 244, 0.1); border-color: rgba(102, 192, 244, 0.22); color: #c4e7ff; } .sgis-lang-more { padding: 2px 7px; font-size: 10px; color: var(--sgis-text-2); font-weight: 600; align-self: center; } /* v2.3.17: 免费游戏标识 */ .sgis-free-badge { display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; background: rgba(102, 192, 244, 0.15); color: #66c0f4; border: 1px solid rgba(102, 192, 244, 0.3); } .sgis-history-lowest { background: linear-gradient(135deg, rgba(164, 208, 7, 0.18) 0%, rgba(101, 163, 13, 0.06) 50%, var(--sgis-bg-2) 100%); border: 1px solid rgba(164, 208, 7, 0.35); border-radius: var(--sgis-radius-sm); padding: 11px 12px; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; position: relative; overflow: hidden; box-shadow: 0 2px 8px rgba(164, 208, 7, 0.08); } .sgis-history-lowest::after { content: ""; position: absolute; top: -20px; right: -20px; width: 100px; height: 100px; background: radial-gradient(circle, rgba(164, 208, 7, 0.2) 0%, transparent 60%); pointer-events: none; } .sgis-history-lowest > * { position: relative; z-index: 1; } .sgis-history-lowest-label { font-size: 10px; color: var(--sgis-text-2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } .sgis-history-lowest-val { font-size: 18px; font-weight: 700; color: #a4d007; } .sgis-history-lowest-store { font-size: 11px; color: var(--sgis-text-2); } .sgis-history-lowest-date { font-size: 10px; color: var(--sgis-text-2); margin-left: auto; } .sgis-history-list { display: flex; flex-direction: column; gap: 3px; } .sgis-history-row { display: flex; align-items: center; gap: 8px; padding: 5px 8px; background: rgba(255, 255, 255, 0.02); border-radius: 5px; font-size: 11px; } .sgis-history-date { color: var(--sgis-text-2); min-width: 70px; } .sgis-history-price { color: #fff; font-weight: 600; flex: 1; } .sgis-history-store { color: var(--sgis-text-2); font-size: 10px; } .sgis-history-cut { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; color: #b4d007; background: rgba(180, 208, 7, 0.15); min-width: 36px; text-align: center; } .sgis-history-days { font-size: 10px; color: var(--sgis-text-2); white-space: nowrap; } .sgis-discount-summary { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; background: rgba(255, 255, 255, 0.02); border-radius: 5px; margin-bottom: 6px; } .sgis-discount-badge { display: inline-flex; align-items: center; gap: 3px; padding: 2px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; color: #b4d007; background: rgba(180, 208, 7, 0.1); border: 1px solid rgba(180, 208, 7, 0.2); } .sgis-discount-badge-days { font-size: 9px; color: var(--sgis-text-2); font-weight: 400; } .sgis-predict-btn { display: flex; align-items: center; gap: 6px; padding: 9px 16px; border: 1px solid rgba(139, 92, 246, 0.4); border-radius: 8px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(59, 130, 246, 0.1)); color: #c4b5fd; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.2s; width: 100%; justify-content: center; position: relative; overflow: hidden; } .sgis-predict-btn::before { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent); transform: translateX(-100%); transition: transform 0.6s ease; } .sgis-predict-btn:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(59, 130, 246, 0.18)); color: #fff; box-shadow: 0 4px 14px rgba(139, 92, 246, 0.3); transform: translateY(-1px); } .sgis-predict-btn:hover::before { transform: translateX(100%); } .sgis-predict-btn:disabled { opacity: 0.5; cursor: not-allowed; } .sgis-predict-btn svg { width: 14px; height: 14px; position: relative; z-index: 1; } .sgis-predict-btn > * { position: relative; z-index: 1; } .sgis-predict-section { margin-top: 12px; padding: 12px; background: linear-gradient(135deg, rgba(102,192,244,0.06), rgba(76,175,80,0.04)); border: 1px solid rgba(102,192,244,0.15); border-radius: 10px; } .sgis-predict-model { padding: 10px; margin-bottom: 8px; background: rgba(255,255,255,0.03); border-radius: 8px; border-left: 3px solid var(--sglv-accent); } .sgis-predict-model:last-child { margin-bottom: 0; } .sgis-predict-model-title { font-size: 12px; font-weight: 700; color: var(--sglv-accent); margin-bottom: 6px; display: flex; align-items: center; justify-content: space-between; } .sgis-predict-model-conf { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; } .sgis-predict-model-conf.high { background: rgba(76,175,80,0.2); color: #66bb6a; } .sgis-predict-model-conf.mid { background: rgba(255,152,0,0.2); color: #ffa726; } .sgis-predict-model-conf.low { background: rgba(244,67,54,0.2); color: #ef5350; } .sgis-predict-detail { font-size: 11px; color: var(--sgis-text-2); line-height: 1.6; } .sgis-predict-detail b { color: #fff; } .sgis-predict-price { color: #b4d007; font-weight: 700; font-size: 13px; } .sgis-predict-date { color: var(--sglv-accent); font-weight: 600; } .sgis-predict-sale { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 4px; } .sgis-predict-sale.seasonal { background: rgba(244,67,54,0.15); color: #ef5350; } .sgis-predict-sale.festival { background: rgba(76,175,80,0.15); color: #66bb6a; } .sgis-predict-rec { margin-top: 8px; padding: 10px; background: rgba(76,175,80,0.08); border-radius: 8px; border: 1px solid rgba(76,175,80,0.15); font-size: 11px; line-height: 1.6; color: #c8e6c9; } .sgis-predict-rec b { color: #66bb6a; } .sgis-predict-modes-label { font-size: 10px; color: var(--sgis-text-2); margin-top: 6px; text-align: center; } .sglv-predict-mode-item { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; margin-bottom: 6px; background: rgba(255,255,255,0.02); border-radius: 6px; cursor: pointer; transition: background 0.15s; } .sglv-predict-mode-item:hover { background: rgba(255,255,255,0.05); } .sglv-predict-mode-item input[type="checkbox"] { margin-top: 2px; accent-color: var(--sglv-accent); cursor: pointer; flex-shrink: 0; } .sglv-predict-mode-info { flex: 1; min-width: 0; } .sglv-predict-mode-name { font-size: 12px; font-weight: 600; color: var(--sglv-text); } .sglv-predict-mode-desc { font-size: 10px; color: var(--sglv-text-secondary); margin-top: 2px; line-height: 1.4; } .sgis-profile-main-card { position: relative; border-radius: 18px; overflow: hidden; margin-bottom: 10px; background: linear-gradient(160deg, #1a1633 0%, #16142b 40%, #0f0d20 100%); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(139, 92, 246, 0.15); padding: 18px 16px 16px; } .sgis-profile-main-card::before { content: ""; position: absolute; top: -50px; right: -40px; width: 180px; height: 180px; background: radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, transparent 60%); pointer-events: none; z-index: 0; } .sgis-profile-main-card::after { content: ""; position: absolute; bottom: -1px; left: 10%; right: 10%; height: 1.5px; background: linear-gradient(90deg, transparent 0%, rgba(139, 92, 246, 0.6) 50%, transparent 100%); pointer-events: none; z-index: 1; } .sgis-profile-main-card-bg { position: absolute; top: 50%; right: -12px; transform: translateY(-50%); width: 180px; height: 180px; background: url('https://leanisssharedstorage.blob.core.windows.net/copilot/asdm-files/game-library.png') no-repeat center center; background-size: contain; opacity: 0.18; pointer-events: none; z-index: 0; filter: saturate(1.2) drop-shadow(0 0 20px rgba(139, 92, 246, 0.25)); mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,1) 100%); -webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,1) 100%); } .sgis-profile-head { position: relative; z-index: 2; display: flex; align-items: center; gap: 14px; margin-bottom: 16px; } .sgis-profile-avatar-wrap { position: relative; flex-shrink: 0; } .sgis-profile-avatar { width: 80px; height: 80px; border-radius: 22px; border: 2px solid rgba(102, 192, 244, 0.5); background: #1b2838; display: block; object-fit: cover; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 4px rgba(102, 192, 244, 0.08); } .sgis-profile-avatar-badge { position: absolute; right: -8px; bottom: -8px; min-width: 38px; height: 28px; padding: 0 8px; border-radius: 14px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 50%, #3b82f6 100%); color: #fff; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.5), 0 0 0 3px #16142b; letter-spacing: 0.3px; } .sgis-profile-level-badge { display: inline-flex; align-items: center; justify-content: center; height: 22px; padding: 0 8px; border-radius: 11px; background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%); color: #fff; font-size: 11px; font-weight: 800; flex-shrink: 0; box-shadow: 0 2px 6px rgba(249, 115, 22, 0.4); letter-spacing: 0.2px; } .sgis-profile-info { flex: 1; min-width: 0; } .sgis-profile-name-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } .sgis-profile-name { font-size: 19px; font-weight: 700; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: 0.2px; line-height: 1.2; } .sgis-profile-verified { width: 20px; height: 20px; border-radius: 6px; background: linear-gradient(135deg, #8b5cf6, #3b82f6); display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-shadow: 0 2px 6px rgba(139, 92, 246, 0.4); } .sgis-profile-verified svg { width: 12px; height: 12px; color: #fff; } .sgis-profile-steamid-row { display: flex; align-items: center; gap: 2px; min-width: 0; } .sgis-profile-steamid { font-size: 11px; color: rgba(148, 163, 184, 0.9); font-family: "SF Mono", "Monaco", "Consolas", monospace; letter-spacing: 0.3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sgis-copy-btn { background: transparent; border: none; color: rgba(148, 163, 184, 0.7); cursor: pointer; padding: 2px 5px; border-radius: 4px; transition: all 0.15s; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; } .sgis-copy-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; } .sgis-copy-btn svg { width: 12px; height: 12px; } /* v2.3.27: 好友数量进度环(参考 steam-friend-manager 个人卡片仪表) */ .sgis-friend-gauge { display: flex; flex-direction: column; align-items: center; gap: 0; margin-left: auto; flex-shrink: 0; position: relative; cursor: pointer; padding: 3px 4px; border-radius: 10px; transition: background 0.15s; } .sgis-friend-gauge:hover { background: rgba(255, 255, 255, 0.05); } .sgis-friend-gauge svg { display: block; } .sgis-friend-gauge-text { font-size: 12px; color: #e2e8f0; margin-top: -27px; pointer-events: none; display: flex; align-items: baseline; gap: 3px; white-space: nowrap; } .sgis-friend-gauge-text b { font-size: 16px; font-weight: 800; color: #10b981; line-height: 1; } .sgis-friend-gauge-text span { font-size: 10px; color: rgba(148, 163, 184, 0.85); font-weight: 600; } .sgis-friend-gauge-lbl { font-size: 9px; color: rgba(148, 163, 184, 0.85); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; margin-top: 2px; pointer-events: none; } .sgis-profile-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; position: relative; z-index: 2; } .sgis-profile-stats.cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; } .sgis-profile-stats.cols-5 .sgis-profile-stat { padding: 10px 3px 8px; border-radius: 12px; } .sgis-profile-stats.cols-5 .sgis-profile-stat-icon { width: 26px; height: 26px; margin: 0 auto 5px; border-radius: 7px; } .sgis-profile-stats.cols-5 .sgis-profile-stat-icon svg { width: 14px; height: 14px; } .sgis-profile-stats.cols-5 .sgis-profile-stat-val { font-size: 15px; } .sgis-profile-stats.cols-5 .sgis-profile-stat-label { font-size: 8px; margin-top: 3px; } .sgis-profile-stat { position: relative; padding: 12px 6px 10px; background: linear-gradient(160deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.01) 100%); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 14px; text-align: center; transition: all 0.25s; overflow: hidden; } .sgis-profile-stat::after { content: ""; position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 40%; height: 1px; background: linear-gradient(90deg, transparent, currentColor, transparent); opacity: 0.35; } .sgis-profile-stat:hover { border-color: rgba(139, 92, 246, 0.3); background: linear-gradient(160deg, rgba(139, 92, 246, 0.08) 0%, rgba(59, 130, 246, 0.03) 100%); transform: translateY(-1px); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3); } .sgis-profile-stat.clickable { cursor: pointer; } .sgis-profile-stat.clickable:active { transform: translateY(0); } .sgis-profile-stat-icon { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; margin: 0 auto 8px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(59, 130, 246, 0.08)); border-radius: 9px; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); } .sgis-profile-stat-icon svg { width: 16px; height: 16px; color: #c4b5fd; } .sgis-profile-stat-icon.t-blue { background: linear-gradient(135deg, rgba(59, 130, 246, 0.22), rgba(37, 99, 235, 0.08)); } .sgis-profile-stat-icon.t-blue svg { color: #93c5fd; } .sgis-profile-stat-icon.t-purple { background: linear-gradient(135deg, rgba(168, 85, 247, 0.22), rgba(139, 92, 246, 0.08)); } .sgis-profile-stat-icon.t-purple svg { color: #c4b5fd; } .sgis-profile-stat-icon.t-cyan { background: linear-gradient(135deg, rgba(6, 182, 212, 0.22), rgba(8, 145, 178, 0.08)); } .sgis-profile-stat-icon.t-cyan svg { color: #67e8f9; } .sgis-profile-stat-icon.t-green { background: linear-gradient(135deg, rgba(16, 185, 129, 0.22), rgba(5, 150, 105, 0.08)); } .sgis-profile-stat-icon.t-green svg { color: #6ee7b7; } .sgis-profile-stat-icon.t-rose { background: linear-gradient(135deg, rgba(244, 63, 94, 0.22), rgba(190, 18, 60, 0.08)); } .sgis-profile-stat-icon.t-rose svg { color: #fda4af; } .sgis-profile-stat-val { font-size: 18px; font-weight: 700; color: #fff; line-height: 1.15; letter-spacing: -0.3px; } .sgis-profile-stat-val.t-blue { color: #93c5fd; } .sgis-profile-stat-val.t-purple { color: #c4b5fd; } .sgis-profile-stat-val.t-cyan { color: #67e8f9; } .sgis-profile-stat-val.t-green { color: #6ee7b7; } .sgis-profile-stat-val.t-rose { color: #fda4af; } .sgis-profile-stat-label { font-size: 9px; color: var(--sgis-text-2); margin-top: 4px; font-weight: 500; letter-spacing: 0.4px; text-transform: uppercase; } @media (max-width: 600px) { .sgis-profile-stats { grid-template-columns: repeat(2, 1fr); } .sgis-profile-stats.cols-5 { grid-template-columns: repeat(3, minmax(0, 1fr)); } } .sgis-xp-card { position: relative; padding: 14px 16px 12px; background: linear-gradient(160deg, rgba(255, 255, 255, 0.035) 0%, rgba(15, 17, 35, 0.5) 100%); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 14px; margin-bottom: 10px; overflow: hidden; } .sgis-xp-card::before { content: ""; position: absolute; bottom: -1px; left: 10%; right: 10%; height: 1.5px; background: linear-gradient(90deg, transparent 0%, rgba(245, 158, 11, 0.6) 50%, transparent 100%); pointer-events: none; } .sgis-xp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } .sgis-xp-title { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; color: #fff; } .sgis-xp-title-icon { width: 22px; height: 22px; border-radius: 6px; background: linear-gradient(135deg, #fbbf24, #f59e0b); display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4); } .sgis-xp-title-icon svg { width: 13px; height: 13px; color: #fff; } .sgis-xp-levels { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; } .sgis-xp-levels .cur { color: #fbbf24; font-size: 13px; } .sgis-xp-levels .arrow { color: var(--sgis-text-2); font-size: 13px; } .sgis-xp-levels .next { color: #c4b5fd; } .sgis-xp-bar { position: relative; height: 8px; background: rgba(255, 255, 255, 0.06); border-radius: 5px; overflow: hidden; margin-bottom: 7px; } .sgis-xp-bar-fill { position: relative; height: 100%; background: linear-gradient(90deg, #fbbf24 0%, #f97316 50%, #ea580c 100%); border-radius: 5px; box-shadow: 0 0 12px rgba(245, 158, 11, 0.4); transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); } .sgis-xp-bar-fill::after { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 50%; background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), transparent); border-radius: 5px 5px 0 0; } .sgis-xp-meta { display: flex; justify-content: space-between; align-items: center; font-size: 10px; } .sgis-xp-current { color: #fbbf24; font-weight: 600; } .sgis-xp-current b { font-weight: 700; } .sgis-xp-percent { color: var(--sgis-text-2); font-weight: 600; } .sgis-xp-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.04); font-size: 10px; color: var(--sgis-text-2); } .sgis-xp-total b { color: #c4b5fd; font-weight: 700; font-size: 12px; } .sgis-feature-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 10px; } @media (max-width: 600px) { .sgis-feature-grid { grid-template-columns: 1fr; } } .sgis-feature-card { position: relative; padding: 14px 12px 12px; 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%); border: 1px solid rgba(139, 92, 246, 0.2); border-radius: 14px; cursor: pointer; transition: all 0.25s; overflow: hidden; } .sgis-feature-card::before { content: ""; position: absolute; top: -40px; right: -30px; width: 120px; height: 120px; background: radial-gradient(circle, rgba(139, 92, 246, 0.2) 0%, transparent 60%); pointer-events: none; } .sgis-feature-card::after { content: ""; position: absolute; bottom: -1px; left: 10%; right: 10%; height: 1.5px; background: linear-gradient(90deg, transparent 0%, rgba(139, 92, 246, 0.5) 50%, transparent 100%); pointer-events: none; } .sgis-feature-card:hover { border-color: rgba(139, 92, 246, 0.4); transform: translateY(-2px); box-shadow: 0 8px 20px rgba(139, 92, 246, 0.2); } .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%); border-color: rgba(99, 102, 241, 0.2); } .sgis-feature-card.alt::before { background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 60%); } .sgis-feature-card.alt::after { background: linear-gradient(90deg, transparent 0%, rgba(99, 102, 241, 0.5) 50%, transparent 100%); } .sgis-feature-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; position: relative; z-index: 1; } .sgis-feature-icon { width: 36px; height: 36px; border-radius: 10px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.22), rgba(99, 102, 241, 0.12)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 6px rgba(139, 92, 246, 0.15); } .sgis-feature-card.alt .sgis-feature-icon { background: linear-gradient(135deg, rgba(99, 102, 241, 0.22), rgba(59, 130, 246, 0.12)); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 2px 6px rgba(99, 102, 241, 0.15); } .sgis-feature-icon svg { width: 18px; height: 18px; color: #c4b5fd; } .sgis-feature-card.alt .sgis-feature-icon svg { color: #a5b4fc; } .sgis-feature-titles { flex: 1; min-width: 0; } .sgis-feature-title { font-size: 14px; font-weight: 700; color: #fff; line-height: 1.2; } .sgis-feature-sub { font-size: 10px; color: var(--sgis-text-2); margin-top: 2px; font-weight: 500; letter-spacing: 0.3px; text-transform: uppercase; } .sgis-feature-arrow { width: 22px; height: 22px; color: var(--sgis-text-2); flex-shrink: 0; transition: all 0.2s; } .sgis-feature-card:hover .sgis-feature-arrow { color: #c4b5fd; transform: translateX(2px); } .sgis-feature-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; position: relative; z-index: 1; } .sgis-feature-stat { padding: 8px 6px; background: linear-gradient(160deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.01) 100%); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 8px; text-align: center; } .sgis-feature-stat-lbl { font-size: 9px; color: var(--sgis-text-2); font-weight: 500; letter-spacing: 0.3px; margin-bottom: 2px; } .sgis-feature-stat-val { font-size: 14px; font-weight: 700; color: #fff; line-height: 1.2; } .sgis-feature-stat-val.t-blue { color: #93c5fd; } .sgis-feature-stat-val.t-green { color: #6ee7b7; } .sgis-feature-stat-val.t-purple { color: #c4b5fd; } .sgis-feature-stat-val.t-amber { color: #fbbf24; } .sgis-feature-extra { display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 7px 10px; background: rgba(255, 255, 255, 0.03); border-radius: 7px; font-size: 10px; color: var(--sgis-text-2); position: relative; z-index: 1; } .sgis-feature-extra svg { width: 12px; height: 12px; color: #f87171; flex-shrink: 0; } .sgis-activity-item { display: block; padding: 10px; margin-bottom: 8px; background: rgba(255,255,255,0.03); border-radius: 8px; border-left: 3px solid var(--sglv-accent); text-decoration: none; transition: background 0.15s; } .sgis-activity-item:hover { background: rgba(255,255,255,0.06); } .sgis-activity-type { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: 600; margin-bottom: 4px; } .sgis-activity-type.sale { background: rgba(76,175,80,0.2); color: #66bb6a; } .sgis-activity-type.update { background: rgba(102,192,244,0.2); color: #66c0f4; } .sgis-activity-type.event { background: rgba(255,152,0,0.2); color: #ffa726; } .sgis-activity-type.news { background: rgba(179,136,255,0.2); color: #b388ff; } /* v2.3.25: 官方动态结构化分区样式 */ .sgis-official-section { margin-bottom: 14px; } .sgis-official-section-title { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; color: #66c0f4; margin-bottom: 8px; padding: 4px 0; border-bottom: 1px solid rgba(102,192,244,0.15); } .sgis-official-section-title .sgis-section-count { font-size: 10px; font-weight: 400; color: var(--sgis-text-2); margin-left: auto; } .sgis-official-empty { text-align: center; padding: 20px; color: var(--sgis-text-2); font-size: 11px; } /* 促销活动聚光灯卡片 */ .sgis-spotlight-card { display: block; padding: 0; margin-bottom: 8px; background: rgba(255,255,255,0.04); border-radius: 8px; overflow: hidden; text-decoration: none; transition: background 0.15s; border: 1px solid rgba(255,255,255,0.08); } .sgis-spotlight-card:hover { background: rgba(255,255,255,0.07); border-color: rgba(255,152,0,0.3); } .sgis-spotlight-banner { width: 100%; height: 60px; object-fit: cover; background: rgba(255,255,255,0.03); } .sgis-spotlight-body { padding: 8px 10px; } .sgis-spotlight-name { font-size: 12px; font-weight: 600; color: #ffa726; margin-bottom: 2px; } .sgis-spotlight-desc { font-size: 10px; color: var(--sgis-text-2); line-height: 1.4; } /* 特惠游戏卡片 */ .sgis-special-grid { display: grid; grid-template-columns: 1fr; gap: 6px; } .sgis-special-card { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: rgba(255,255,255,0.03); border-radius: 7px; text-decoration: none; transition: background 0.15s; border-left: 3px solid #4caf50; } .sgis-special-card:hover { background: rgba(76,175,80,0.08); } .sgis-special-img { width: 48px; height: 22px; object-fit: cover; border-radius: 3px; flex-shrink: 0; background: rgba(255,255,255,0.05); } .sgis-special-info { flex: 1; min-width: 0; } .sgis-special-name { font-size: 11px; font-weight: 500; color: var(--sgis-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sgis-special-meta { font-size: 9px; color: var(--sgis-text-2); margin-top: 1px; } .sgis-special-price { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; flex-shrink: 0; } .sgis-special-discount { display: inline-block; padding: 2px 6px; background: rgba(76,175,80,0.25); color: #66bb6a; border-radius: 4px; font-size: 11px; font-weight: 700; } .sgis-special-final { font-size: 11px; color: #66bb6a; font-weight: 600; } .sgis-special-original { font-size: 9px; color: var(--sgis-text-2); text-decoration: line-through; } .sgis-sub-tabs { display: flex; gap: 6px; margin: 8px 0 10px; flex-wrap: wrap; } .sgis-sub-tab { flex: 1; min-width: 70px; padding: 5px 8px; font-size: 10px; font-weight: 600; color: var(--sgis-text-2); background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 7px; cursor: pointer; transition: all .2s; display: flex; align-items: center; justify-content: center; gap: 4px; white-space: nowrap; } .sgis-sub-tab:hover { color: #fff; border-color: rgba(102,192,244,0.3); } .sgis-sub-tab.active { background: linear-gradient(135deg,rgba(102,192,244,0.25),rgba(59,130,246,0.15)); color: #66c0f4; border-color: rgba(102,192,244,0.35); } .sgis-sub-tab svg { width: 12px; height: 12px; } .sgis-activity-content { min-height: 120px; } .sgis-timeline-divider { display: flex; align-items: center; gap: 8px; margin: 12px 0 8px; } .sgis-timeline-line { flex: 1; height: 1px; background: linear-gradient(90deg,transparent,rgba(102,192,244,0.25),transparent); } .sgis-timeline-date { font-size: 10px; color: var(--sgis-text-2); white-space: nowrap; letter-spacing: .5px; } .sgis-timeline-header { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; margin-bottom: 6px; border-radius: 7px; border: 1px solid; } .sgis-timeline-label { font-size: 12px; font-weight: 600; } .sgis-timeline-count { font-size: 11px; color: var(--sgis-text-2); } .sgis-timeline-desc { font-size: 11px; color: var(--sgis-text-2); padding: 4px 10px 8px; } .sgis-timeline-game { display: flex; align-items: center; gap: 8px; padding: 6px 8px; margin-bottom: 4px; background: rgba(255,255,255,0.03); border-left: 3px solid #10b981; border-radius: 6px; cursor: pointer; transition: background .2s; } .sgis-timeline-game:hover { background: rgba(255,255,255,0.07); } .sgis-timeline-game-icon { width: 32px; height: 32px; border-radius: 4px; object-fit: cover; flex-shrink: 0; } .sgis-timeline-game-info { min-width: 0; } .sgis-timeline-game-name { font-size: 11px; font-weight: 500; color: #fff; text-decoration: none; } .sgis-timeline-game-name:hover { color: #66c0f4; } .sgis-badge-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } .sgis-badge-card { padding: 10px; background: rgba(255,255,255,0.03); border-radius: 8px; text-align: center; } .sgis-badge-icon { font-size: 28px; margin-bottom: 5px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; margin-left: auto; margin-right: auto; background: linear-gradient(135deg, rgba(139, 92, 246, 0.12), rgba(59, 130, 246, 0.06)); border-radius: 8px; transition: all 0.2s; } .sgis-badge-card:hover .sgis-badge-icon { background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(59, 130, 246, 0.1)); transform: scale(1.05); } .sgis-badge-icon svg { width: 18px; height: 18px; color: #c4b5fd; } .sgis-badge-name { font-size: 11px; font-weight: 600; color: #fff; } .sgis-badge-level { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: 700; background: rgba(102,192,244,0.15); color: #66c0f4; margin-top: 4px; } .sgis-badge-xp { font-size: 9px; color: var(--sgis-text-2); margin-top: 2px; } .sgis-user-ach { display: flex; align-items: center; gap: 10px; padding: 10px; margin-bottom: 8px; background: rgba(255,255,255,0.03); border-radius: 8px; border: 1px solid transparent; transition: all 0.2s; } .sgis-user-ach.unlocked { border-color: rgba(180,208,7,0.3); background: rgba(180,208,7,0.05); } .sgis-user-ach.locked { opacity: 0.5; } .sgis-user-ach-icon { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0; border-radius: 10px; background: rgba(255,255,255,0.05); overflow: hidden; position: relative; } .sgis-user-ach-icon svg { width: 100%; height: 100%; display: block; } .sgis-user-ach.locked .sgis-user-ach-icon { filter: grayscale(85%) brightness(0.6); } .sgis-user-ach.unlocked .sgis-user-ach-icon { background: linear-gradient(135deg, rgba(139,92,246,0.12), rgba(59,130,246,0.08)); box-shadow: 0 0 12px rgba(139,92,246,0.2); } .sgis-user-ach-info { flex: 1; min-width: 0; } .sgis-user-ach-name { font-size: 12px; font-weight: 600; color: #fff; } .sgis-user-ach-desc { font-size: 10px; color: var(--sgis-text-2); margin-top: 2px; line-height: 1.4; } .sgis-user-ach-progress { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin-top: 6px; overflow: hidden; } .sgis-user-ach-progress-fill { height: 100%; background: var(--sglv-accent); border-radius: 2px; transition: width 0.3s; } .sgis-user-ach.unlocked .sgis-user-ach-progress-fill { background: #b4d007; } .sgis-user-ach-val { font-size: 10px; color: var(--sgis-text-2); text-align: right; white-space: nowrap; } .sgis-persona-section { padding: 14px; margin-bottom: 12px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.12) 0%, rgba(59, 130, 246, 0.06) 50%, var(--sgis-bg-2) 100%); border: 1px solid rgba(139, 92, 246, 0.22); border-radius: var(--sgis-radius-sm); position: relative; overflow: hidden; box-shadow: 0 2px 8px rgba(139, 92, 246, 0.08); } .sgis-persona-section::before { content: ""; position: absolute; top: -30px; right: -30px; width: 100px; height: 100px; background: radial-gradient(circle, rgba(139, 92, 246, 0.2) 0%, transparent 60%); pointer-events: none; } .sgis-persona-section > * { position: relative; z-index: 1; } .sgis-persona-title { font-size: 14px; font-weight: 700; color: #c4b5fd; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; } .sgis-persona-title svg { width: 18px; height: 18px; color: #c4b5fd; } .sgis-persona-type { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 700; background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(99, 102, 241, 0.15)); color: #c4b5fd; margin-bottom: 8px; border: 1px solid rgba(139, 92, 246, 0.3); } .sgis-persona-desc { font-size: 11px; color: var(--sgis-text-2); line-height: 1.6; margin-bottom: 8px; } .sgis-persona-traits { display: flex; flex-wrap: wrap; gap: 5px; } .sgis-persona-trait { padding: 3px 9px; border-radius: 10px; font-size: 10px; font-weight: 600; background: linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(99, 102, 241, 0.08)); color: #c4b5fd; border: 1px solid rgba(139, 92, 246, 0.2); } .sgis-insight-kpi-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 12px; } @media (min-width: 480px) { .sgis-insight-kpi-grid { grid-template-columns: repeat(3, 1fr); } } .sgis-insight-kpi { position: relative; padding: 12px 10px 12px 13px; border-radius: 10px; background: linear-gradient(160deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.01) 100%); border: 1px solid rgba(255,255,255,0.06); border-left-width: 3px; overflow: hidden; transition: all 0.2s; } .sgis-insight-kpi::before { content: ''; position: absolute; top: -12px; right: -12px; width: 40px; height: 40px; border-radius: 50%; opacity: 0.12; filter: blur(8px); pointer-events: none; } .sgis-insight-kpi:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); } .sgis-insight-kpi.blue { border-color: rgba(59,130,246,0.3); border-left-color: #3b82f6; } .sgis-insight-kpi.blue::before { background: #3b82f6; } .sgis-insight-kpi.blue:hover { border-color: rgba(59,130,246,0.5); } .sgis-insight-kpi.cyan { border-color: rgba(6,182,212,0.3); border-left-color: #06b6d4; } .sgis-insight-kpi.cyan::before { background: #06b6d4; } .sgis-insight-kpi.cyan:hover { border-color: rgba(6,182,212,0.5); } .sgis-insight-kpi.purple { border-color: rgba(139,92,246,0.3); border-left-color: #8b5cf6; } .sgis-insight-kpi.purple::before { background: #8b5cf6; } .sgis-insight-kpi.purple:hover { border-color: rgba(139,92,246,0.5); } .sgis-insight-kpi.amber { border-color: rgba(245,158,11,0.3); border-left-color: #f59e0b; } .sgis-insight-kpi.amber::before { background: #f59e0b; } .sgis-insight-kpi.amber:hover { border-color: rgba(245,158,11,0.5); } .sgis-insight-kpi.rose { border-color: rgba(244,63,94,0.3); border-left-color: #f43f5e; } .sgis-insight-kpi.rose::before { background: #f43f5e; } .sgis-insight-kpi.rose:hover { border-color: rgba(244,63,94,0.5); } .sgis-insight-kpi.green { border-color: rgba(16,185,129,0.3); border-left-color: #10b981; } .sgis-insight-kpi.green::before { background: #10b981; } .sgis-insight-kpi.green:hover { border-color: rgba(16,185,129,0.5); } .sgis-insight-kpi-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .sgis-insight-kpi-icon { width: 28px; height: 28px; border-radius: 7px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .sgis-insight-kpi-icon svg { width: 14px; height: 14px; color: #fff; } .sgis-insight-kpi-label { font-size: 10px; font-weight: 600; color: var(--sgis-text-2); line-height: 1.3; } .sgis-insight-kpi-val { font-size: 20px; font-weight: 800; color: #fff; line-height: 1; letter-spacing: -0.5px; } .sgis-insight-kpi-sub { font-size: 9px; color: var(--sgis-text-2); margin-top: 3px; } .sgis-insight-persona { border-radius: 12px; overflow: hidden; margin-bottom: 12px; border: 1px solid rgba(139, 92, 246, 0.25); background: var(--sgis-bg-2); } .sgis-insight-persona-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.35), rgba(59, 130, 246, 0.18)); } .sgis-insight-persona-icon { width: 36px; height: 36px; border-radius: 9px; background: rgba(255,255,255,0.18); display: flex; align-items: center; justify-content: center; flex-shrink: 0; backdrop-filter: blur(6px); } .sgis-insight-persona-icon svg { width: 18px; height: 18px; color: #fff; } .sgis-insight-persona-title { font-size: 16px; font-weight: 800; color: #fff; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .sgis-insight-persona-rarity { font-size: 10px; font-weight: 700; padding: 3px 10px; border-radius: 10px; background: linear-gradient(135deg, rgba(139,92,246,0.4), rgba(99,102,241,0.25)); color: #ddd6fe; border: 1px solid rgba(139,92,246,0.4); letter-spacing: 0.3px; } .sgis-insight-persona-tagline { font-size: 11px; color: rgba(255,255,255,0.82); margin-top: 2px; } .sgis-insight-persona-body { padding: 12px 14px; } .sgis-insight-persona-traits { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } .sgis-insight-persona-trait { padding: 4px 12px; border-radius: 12px; font-size: 10px; font-weight: 600; background: linear-gradient(135deg, rgba(139,92,246,0.18), rgba(59,130,246,0.08)); color: #c4b5fd; border: 1px solid rgba(139,92,246,0.22); transition: all 0.2s; } .sgis-insight-persona-trait:hover { background: linear-gradient(135deg, rgba(139,92,246,0.3), rgba(59,130,246,0.15)); border-color: rgba(139,92,246,0.4); transform: translateY(-1px); } .sgis-insight-metrics { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.06); } .sgis-insight-metric { display: flex; align-items: center; gap: 7px; padding: 6px 11px; border-radius: 8px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); transition: all 0.2s; } .sgis-insight-metric:hover { border-color: rgba(139,92,246,0.2); background: rgba(255,255,255,0.06); } .sgis-insight-metric-icon { width: 20px; height: 20px; border-radius: 5px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .sgis-insight-metric-icon svg { width: 11px; height: 11px; color: #fff; } .sgis-insight-metric-label { font-size: 9px; color: var(--sgis-text-2); line-height: 1; } .sgis-insight-metric-val { font-size: 11px; font-weight: 700; color: #fff; line-height: 1.2; } .sgis-insight-dim-grid { display: grid; grid-template-columns: 1fr; gap: 8px; margin-bottom: 12px; } @media (min-width: 520px) { .sgis-insight-dim-grid { grid-template-columns: repeat(2, 1fr); } } .sgis-insight-dim { display: flex; border-radius: 10px; overflow: hidden; background: linear-gradient(160deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%); border: 1px solid rgba(255,255,255,0.06); transition: all 0.2s; } .sgis-insight-dim:hover { border-color: rgba(139, 92, 246, 0.3); } .sgis-insight-dim-bar { width: 3px; min-width: 3px; flex-shrink: 0; } .sgis-insight-dim-body { flex: 1; padding: 10px 12px; min-width: 0; } .sgis-insight-dim-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } .sgis-insight-dim-title-row { display: flex; align-items: center; gap: 6px; min-width: 0; } .sgis-insight-dim-label { font-size: 12px; font-weight: 700; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-insight-dim-tag { font-size: 9px; font-weight: 600; padding: 1px 7px; border-radius: 8px; flex-shrink: 0; } .sgis-insight-dim-tag.t-high { background: rgba(16,185,129,0.18); color: #6ee7b7; border: 1px solid rgba(16,185,129,0.25); } .sgis-insight-dim-tag.t-mid { background: rgba(59,130,246,0.18); color: #93c5fd; border: 1px solid rgba(59,130,246,0.25); } .sgis-insight-dim-tag.t-low { background: rgba(245,158,11,0.18); color: #fcd34d; border: 1px solid rgba(245,158,11,0.25); } .sgis-insight-dim-tag.t-bad { background: rgba(244,63,94,0.18); color: #fda4af; border: 1px solid rgba(244,63,94,0.25); } .sgis-insight-dim-score { font-size: 14px; font-weight: 800; color: #fff; flex-shrink: 0; } .sgis-insight-dim-progress { height: 5px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; margin-bottom: 6px; } .sgis-insight-dim-progress-fill { height: 100%; border-radius: 3px; transition: width 0.6s ease; } .sgis-insight-dim-desc { font-size: 10px; color: var(--sgis-text-2); line-height: 1.5; } .sgis-insight-ai { border-radius: 12px; overflow: hidden; margin-bottom: 12px; border: 1px solid rgba(139, 92, 246, 0.22); background: var(--sgis-bg-2); } .sgis-insight-ai-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(59, 130, 246, 0.08)); border-bottom: 1px solid rgba(139, 92, 246, 0.15); } .sgis-insight-ai-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; } .sgis-insight-ai-icon { width: 30px; height: 30px; border-radius: 8px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.35), rgba(99, 102, 241, 0.2)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .sgis-insight-ai-icon svg { width: 15px; height: 15px; color: #c4b5fd; } .sgis-insight-ai-title { font-size: 13px; font-weight: 700; color: #fff; } .sgis-insight-ai-subtitle { font-size: 10px; color: var(--sgis-text-2); margin-top: 1px; } .sgis-insight-ai-btn { display: flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: 7px; font-size: 11px; font-weight: 700; background: linear-gradient(135deg, rgba(139, 92, 246, 0.45), rgba(59, 130, 246, 0.3)); color: #fff; border: 1px solid rgba(139, 92, 246, 0.4); cursor: pointer; flex-shrink: 0; transition: all 0.2s; } .sgis-insight-ai-btn:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.6), rgba(59, 130, 246, 0.45)); transform: translateY(-1px); box-shadow: 0 3px 10px rgba(139, 92, 246, 0.25); } .sgis-insight-ai-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .sgis-insight-ai-btn svg { width: 12px; height: 12px; } .sgis-insight-ai-body { padding: 12px 14px; } .sgis-insight-ai-oneliner { padding: 10px 12px; border-radius: 9px; 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); margin-bottom: 10px; display: flex; gap: 8px; align-items: flex-start; } .sgis-insight-ai-oneliner svg { width: 16px; height: 16px; color: #c4b5fd; flex-shrink: 0; margin-top: 1px; } .sgis-insight-ai-oneliner-text { font-size: 12px; font-weight: 600; color: #fff; line-height: 1.5; } .sgis-insight-ai-section { margin-bottom: 10px; border-radius: 9px; overflow: hidden; border: 1px solid rgba(255,255,255,0.05); } .sgis-insight-ai-section:last-child { margin-bottom: 0; } .sgis-insight-ai-section-head { display: flex; align-items: center; gap: 6px; padding: 7px 10px; font-size: 11px; font-weight: 700; color: #fff; } .sgis-insight-ai-section-head svg { width: 12px; height: 12px; color: rgba(255,255,255,0.85); flex-shrink: 0; } .sgis-insight-ai-section-body { padding: 8px 10px 10px; font-size: 11px; color: var(--sgis-text); line-height: 1.65; background: rgba(255,255,255,0.015); } .sgis-insight-ai-empty { text-align: center; padding: 20px 12px; color: var(--sgis-text-2); } .sgis-insight-ai-empty-icon { font-size: 28px; margin-bottom: 8px; opacity: 0.5; } .sgis-insight-ai-empty-text { font-size: 12px; font-weight: 600; color: var(--sgis-text); margin-bottom: 4px; } .sgis-insight-ai-empty-sub { font-size: 10px; color: var(--sgis-text-2); line-height: 1.5; } .sgis-insight-ai-empty-warn { margin-top: 8px; padding: 5px 9px; border-radius: 6px; font-size: 10px; background: rgba(245,158,11,0.12); color: #fcd34d; display: inline-block; } @keyframes sgis-insight-spin { to { transform: rotate(360deg); } } .sgis-insight-spin { animation: sgis-insight-spin 0.9s linear infinite; } .sgis-insight-market { border-radius: 12px; overflow: hidden; margin-bottom: 12px; border: 1px solid rgba(16, 185, 129, 0.22); background: var(--sgis-bg-2); } .sgis-insight-market-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; background: linear-gradient(135deg, rgba(16, 185, 129, 0.18), rgba(6, 182, 212, 0.08)); border-bottom: 1px solid rgba(16, 185, 129, 0.15); } .sgis-insight-market-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; } .sgis-insight-market-icon { width: 30px; height: 30px; border-radius: 8px; background: linear-gradient(135deg, rgba(16, 185, 129, 0.35), rgba(6, 182, 212, 0.2)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .sgis-insight-market-icon svg { width: 15px; height: 15px; color: #6ee7b7; } .sgis-insight-market-title { font-size: 13px; font-weight: 700; color: #fff; } .sgis-insight-market-subtitle { font-size: 10px; color: var(--sgis-text-2); margin-top: 1px; } .sgis-insight-market-btn { display: flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: 7px; font-size: 11px; font-weight: 700; background: linear-gradient(135deg, rgba(16, 185, 129, 0.4), rgba(6, 182, 212, 0.28)); color: #fff; border: 1px solid rgba(16, 185, 129, 0.35); cursor: pointer; flex-shrink: 0; transition: all 0.2s; } .sgis-insight-market-btn:hover { background: linear-gradient(135deg, rgba(16, 185, 129, 0.55), rgba(6, 182, 212, 0.42)); transform: translateY(-1px); box-shadow: 0 3px 10px rgba(16, 185, 129, 0.2); } .sgis-insight-market-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .sgis-insight-market-btn svg { width: 12px; height: 12px; } .sgis-insight-market-body { padding: 12px 14px; } .sgis-insight-market-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 10px; } .sgis-insight-market-stat { text-align: center; padding: 8px 4px; border-radius: 7px; background: rgba(16, 185, 129, 0.06); border: 1px solid rgba(16, 185, 129, 0.12); } .sgis-insight-market-stat-val { font-size: 16px; font-weight: 800; color: #6ee7b7; line-height: 1; } .sgis-insight-market-stat-label { font-size: 9px; color: var(--sgis-text-2); margin-top: 3px; } .sgis-insight-market-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 7px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); margin-bottom: 5px; } .sgis-insight-market-rank { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 800; background: linear-gradient(135deg, rgba(251, 191, 36, 0.3), rgba(245, 158, 11, 0.18)); color: #fcd34d; flex-shrink: 0; } .sgis-insight-market-row.top1 .sgis-insight-market-rank { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #1b2838; } .sgis-insight-market-name { flex: 1; min-width: 0; font-size: 11px; font-weight: 600; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-insight-market-val { font-size: 11px; font-weight: 700; color: #6ee7b7; flex-shrink: 0; } .sgis-insight-market-rec { padding: 9px 11px; border-radius: 8px; background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(6, 182, 212, 0.05)); border: 1px solid rgba(16, 185, 129, 0.18); margin-bottom: 6px; } .sgis-insight-market-rec:last-child { margin-bottom: 0; } .sgis-insight-market-rec-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } .sgis-insight-market-rec-name { font-size: 11px; font-weight: 700; color: #fff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-insight-market-rec-action { font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 7px; flex-shrink: 0; } .sgis-insight-market-rec-action.buy { background: rgba(16,185,129,0.2); color: #6ee7b7; border: 1px solid rgba(16,185,129,0.3); } .sgis-insight-market-rec-action.sell { background: rgba(244,63,94,0.2); color: #fda4af; border: 1px solid rgba(244,63,94,0.3); } .sgis-insight-market-rec-action.hold { background: rgba(245,158,11,0.2); color: #fcd34d; border: 1px solid rgba(245,158,11,0.3); } .sgis-insight-market-rec-reason { font-size: 10px; color: var(--sgis-text-2); line-height: 1.5; } .sgis-insight-market-summary { padding: 10px 12px; border-radius: 8px; background: linear-gradient(135deg, rgba(16, 185, 129, 0.12), rgba(6, 182, 212, 0.06)); border: 1px solid rgba(16, 185, 129, 0.18); font-size: 11px; color: var(--sgis-text); line-height: 1.6; margin-bottom: 10px; } .sgis-insight-market-summary:last-child { margin-bottom: 0; } .sgis-insight-ach-collapse { margin-top: 8px; border-top: 1px solid rgba(255,255,255,0.06); padding-top: 10px; } .sgis-insight-ach-toggle { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; color: var(--sgis-text-2); cursor: pointer; padding: 6px 0; user-select: none; } .sgis-insight-ach-toggle:hover { color: #c4b5fd; } .sgis-insight-ach-toggle svg { width: 12px; height: 12px; transition: transform 0.2s; } .sgis-insight-ach-toggle.expanded svg { transform: rotate(90deg); } .sgis-insight-ach-content { display: none; margin-top: 8px; } .sgis-insight-ach-content.show { display: block; } .sgis-section-title.sgis-social-title { justify-content: space-between; } .sgis-social-title-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; } .sgis-social-toolbar { display: flex; gap: 6px; margin-bottom: 8px; align-items: center; } .sgis-social-filter-row { display: flex; flex-wrap: nowrap; gap: 5px; margin-bottom: 10px; align-items: center; overflow-x: auto; padding-bottom: 2px; } .sgis-social-filter-row::-webkit-scrollbar { height: 3px; } .sgis-social-filter-row::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; } .sgis-social-search { flex: 1; min-width: 100px; padding: 6px 10px; font-size: 11px; color: var(--sgis-text); background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 7px; outline: none; transition: border-color 0.2s; } .sgis-social-search:focus { border-color: rgba(102, 192, 244, 0.5); } .sgis-social-search::placeholder { color: var(--sgis-text-2); } .sgis-social-filter { padding: 5px 10px; font-size: 10px; font-weight: 600; color: var(--sgis-text-2); background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 7px; cursor: pointer; transition: all 0.2s; white-space: nowrap; display: flex; align-items: center; gap: 4px; } .sgis-social-filter:hover { color: #fff; border-color: rgba(102,192,244,0.3); } .sgis-social-filter.active { background: linear-gradient(135deg, rgba(102,192,244,0.25), rgba(59,130,246,0.15)); color: #66c0f4; border-color: rgba(102,192,244,0.35); } .sgis-social-filter .count { font-size: 9px; padding: 1px 5px; border-radius: 5px; background: rgba(255,255,255,0.1); } .sgis-social-sort { padding: 5px 10px; font-size: 10px; font-weight: 600; color: var(--sgis-text-2); background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 7px; cursor: pointer; } .sgis-social-sort:hover { color: #fff; } .sgis-social-friend { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 9px; background: linear-gradient(135deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%); border: 1px solid rgba(255,255,255,0.05); margin-bottom: 6px; transition: all 0.2s; } .sgis-social-friend:hover { border-color: rgba(139, 92, 246, 0.25); background: linear-gradient(135deg, rgba(139,92,246,0.06) 0%, rgba(59,130,246,0.03) 100%); transform: translateX(2px); } .sgis-social-avatar-wrap { position: relative; flex-shrink: 0; } .sgis-social-avatar { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; border: 1.5px solid rgba(255,255,255,0.1); } .sgis-social-status-dot { position: absolute; bottom: -2px; right: -2px; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--sgis-bg-2); } .sgis-social-status-dot.online { background: #4caf50; box-shadow: 0 0 6px rgba(76,175,80,0.5); } .sgis-social-status-dot.ingame { background: #66c0f4; box-shadow: 0 0 6px rgba(102,192,244,0.6); } .sgis-social-status-dot.offline { background: #6b7280; } .sgis-social-friend-info { flex: 1; min-width: 0; } .sgis-social-friend-name-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; flex-wrap: wrap; } .sgis-social-friend-name { font-size: 12px; font-weight: 700; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 140px; } .sgis-social-level-badge { font-size: 9px; font-weight: 800; padding: 1px 6px; border-radius: 5px; background: linear-gradient(135deg, rgba(102,192,244,0.25), rgba(59,130,246,0.15)); color: #66c0f4; border: 1px solid rgba(102,192,244,0.3); flex-shrink: 0; } .sgis-social-vac-shield { font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 5px; background: linear-gradient(135deg, rgba(244,63,94,0.3), rgba(220,38,38,0.18)); color: #fda4af; border: 1px solid rgba(244,63,94,0.35); display: flex; align-items: center; gap: 3px; flex-shrink: 0; } .sgis-social-vac-shield svg { width: 10px; height: 10px; } .sgis-social-friend-meta { display: flex; align-items: center; gap: 6px; font-size: 10px; color: var(--sgis-text-2); flex-wrap: wrap; } .sgis-social-friend-status { font-size: 10px; font-weight: 600; } .sgis-social-friend-status.ingame { color: #66c0f4; } .sgis-social-friend-status.online { color: #6ee7b7; } .sgis-social-friend-status.offline { color: var(--sgis-text-2); } .sgis-social-friend-days { font-size: 10px; padding: 1px 6px; border-radius: 5px; background: rgba(139, 92, 246, 0.12); color: #c4b5fd; border: 1px solid rgba(139, 92, 246, 0.18); } .sgis-social-friend-days.d-new { background: rgba(16, 185, 129, 0.15); color: #6ee7b7; border-color: rgba(16, 185, 129, 0.25); } .sgis-social-friend-days.d-mid { background: rgba(245, 158, 11, 0.15); color: #fcd34d; border-color: rgba(245, 158, 11, 0.25); } .sgis-social-friend-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .sgis-social-link-btn { width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); color: var(--sgis-text-2); cursor: pointer; transition: all 0.2s; text-decoration: none !important; } .sgis-social-link-btn:hover { background: rgba(102,192,244,0.15); color: #66c0f4; border-color: rgba(102,192,244,0.3); } .sgis-social-link-btn svg { width: 12px; height: 12px; } .sgis-social-group { margin-bottom: 12px; } .sgis-social-group-head { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 700; color: #a78bfa; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; padding-bottom: 5px; border-bottom: 1px solid rgba(139, 92, 246, 0.1); } .sgis-social-group-head svg { width: 12px; height: 12px; } .sgis-social-group-head .count { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 6px; background: rgba(139, 92, 246, 0.15); color: #c4b5fd; } .sgis-social-country { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; font-weight: 500; color: var(--sgis-text-2); background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); padding: 1px 6px 1px 4px; border-radius: 4px; transition: all 0.15s; } .sgis-social-country:hover { background: rgba(255,255,255,0.08); color: #fff; } .sgis-social-country svg { width: 16px; height: 12px; flex-shrink: 0; } .sgis-social-country-name { line-height: 1; } .sgis-social-load-levels, .sgis-social-ban-sync { display: flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 7px; font-size: 10px; font-weight: 600; cursor: pointer; transition: all 0.2s; white-space: nowrap; } .sgis-social-load-levels { background: linear-gradient(135deg, rgba(102,192,244,0.18), rgba(59,130,246,0.1)); color: #66c0f4; border: 1px solid rgba(102,192,244,0.25); } .sgis-social-load-levels:hover { background: linear-gradient(135deg, rgba(102,192,244,0.3), rgba(59,130,246,0.2)); transform: translateY(-1px); } .sgis-social-ban-sync { background: linear-gradient(135deg, rgba(244,63,94,0.18), rgba(244,63,94,0.1)); color: #f43f5e; border: 1px solid rgba(244,63,94,0.25); } .sgis-social-ban-sync:hover { background: linear-gradient(135deg, rgba(244,63,94,0.3), rgba(244,63,94,0.2)); transform: translateY(-1px); } .sgis-social-load-levels:disabled, .sgis-social-ban-sync:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .sgis-social-load-levels svg, .sgis-social-ban-sync svg { width: 12px; height: 12px; } /* v2.3.24: 好友游戏数量 TOP10 排行(参考 steam-friend-manager 社交仪表盘游戏总数排行) */ .sgis-gc-row { 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); margin-bottom: 5px; cursor: pointer; transition: all 0.15s; } .sgis-gc-row:hover { background: rgba(102,192,244,0.06); border-color: rgba(102,192,244,0.2); } .sgis-gc-no { width: 18px; text-align: center; font-size: 11px; font-weight: 800; color: var(--sgis-text-2); flex-shrink: 0; } .sgis-gc-no.r1 { color: #ffd700; } .sgis-gc-no.r2 { color: #c0c0c0; } .sgis-gc-no.r3 { color: #e09a5f; } .sgis-gc-avatar { width: 28px; height: 28px; border-radius: 6px; object-fit: cover; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.08); } .sgis-gc-name { width: 92px; font-size: 11px; font-weight: 600; color: #e0e0e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; } .sgis-gc-bar { flex: 1; height: 10px; background: rgba(255,255,255,0.05); border-radius: 4px; overflow: hidden; min-width: 0; } .sgis-gc-fill { height: 100%; border-radius: 4px; background: linear-gradient(90deg, #38bdf8, #818cf8); transition: width .3s; } .sgis-gc-val { width: 76px; text-align: right; font-size: 11px; font-weight: 700; color: #38bdf8; flex-shrink: 0; white-space: nowrap; } .sgis-gc-val small { display: block; font-size: 9px; font-weight: 500; color: var(--sgis-text-2); } .sgis-gc-hint { font-size: 10px; color: var(--sgis-text-2); text-align: center; padding: 10px 8px; line-height: 1.6; } .sgis-ach-summary { display: flex; align-items: center; gap: 16px; padding: 12px; background: rgba(255, 255, 255, 0.03); border-radius: 10px; margin-bottom: 10px; } .sgis-ach-donut { flex-shrink: 0; --rainbow-intensity: 0.5; position: relative; } .sgis-ach-donut .sgis-rainbow-ring { filter: url(#sgis-rainbow-glow); transition: stroke-dasharray 0.6s ease, filter 0.4s ease; } .sgis-ach-donut svg { overflow: visible; } .sgis-ach-donut .sgis-donut-pct { fill: #f8fafc; filter: drop-shadow(0 0 calc(2px * var(--rainbow-intensity)) rgba(196, 130, 255, calc(0.4 * var(--rainbow-intensity)))); transition: filter 0.4s ease; } .sgis-ach-donut::before { content: ""; position: absolute; inset: -8px; border-radius: 50%; background: conic-gradient(from 0deg, rgba(255, 0, 128, calc(0.15 * var(--rainbow-intensity))), rgba(255, 140, 0, calc(0.15 * var(--rainbow-intensity))), rgba(255, 215, 0, calc(0.15 * var(--rainbow-intensity))), rgba(0, 255, 127, calc(0.15 * var(--rainbow-intensity))), rgba(0, 191, 255, calc(0.15 * var(--rainbow-intensity))), rgba(123, 44, 255, calc(0.15 * var(--rainbow-intensity))), rgba(255, 0, 128, calc(0.15 * var(--rainbow-intensity))) ); filter: blur(calc(8px * var(--rainbow-intensity))); opacity: var(--rainbow-intensity); z-index: -1; animation: sgis-rainbow-rotate 6s linear infinite; pointer-events: none; } @keyframes sgis-rainbow-rotate { to { transform: rotate(360deg); } } .sgis-ach-stats { display: flex; gap: 12px; flex: 1; } .sgis-ach-stat { display: flex; flex-direction: column; align-items: center; flex: 1; } .sgis-ach-stat-val { font-size: 18px; font-weight: 700; color: #fff; } .sgis-ach-stat-lbl { font-size: 10px; color: var(--sgis-text-2); margin-top: 2px; } .sgis-ach-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 8px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.04); border-radius: 6px; margin-bottom: 4px; } .sgis-ach-check { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; margin-top: 1px; } .sgis-ach-check { background: linear-gradient(135deg, rgba(76, 175, 80, 0.3), rgba(16, 185, 129, 0.18)); color: #34d399; box-shadow: 0 2px 6px rgba(16, 185, 129, 0.18); } .sgis-ach-check.locked { background: rgba(255, 255, 255, 0.06); color: var(--sgis-text-2); } .sgis-ach-info { flex: 1; min-width: 0; } .sgis-ach-name { font-size: 12px; font-weight: 600; color: #fff; margin-bottom: 3px; } .sgis-ach-meta { display: flex; gap: 8px; font-size: 10px; color: var(--sgis-text-2); } .sgis-ach-bar { height: 4px; background: rgba(255, 255, 255, 0.06); border-radius: 2px; overflow: hidden; margin: 3px 0; } .sgis-ach-bar-fill { height: 100%; border-radius: 2px; transition: width .3s ease; } .sgis-ach-global { font-size: 10px; color: var(--sgis-text-2); } .sgis-ach-pct { font-size: 12px; font-weight: 600; color: var(--sgis-text); flex-shrink: 0; } .sgis-news-item { display: block; padding: 9px 10px; background: linear-gradient(135deg, rgba(255, 255, 255, 0.025), rgba(139, 92, 246, 0.015)); border: 1px solid rgba(255, 255, 255, 0.04); border-radius: 8px; margin-bottom: 6px; text-decoration: none !important; color: inherit !important; transition: all 0.2s; } .sgis-news-item:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.04)); border-color: rgba(139, 92, 246, 0.25); transform: translateX(2px); box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1); } .sgis-news-header { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; } .sgis-news-feed { font-size: 9px; padding: 2px 7px; border-radius: 4px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(59, 130, 246, 0.1)); color: #c4b5fd; font-weight: 600; border: 1px solid rgba(139, 92, 246, 0.18); } .sgis-news-date { font-size: 10px; color: var(--sgis-text-2); margin-left: auto; } .sgis-news-title { font-size: 12px; font-weight: 600; color: #fff; line-height: 1.4; margin-bottom: 3px; } .sgis-news-desc { font-size: 11px; color: var(--sgis-text-2); line-height: 1.5; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .sgis-news-author { font-size: 10px; color: var(--sgis-text-2); margin-top: 3px; } .sgis-card-item.sgis-card-with-bg { background-size: cover; background-position: center; background-blend-mode: overlay; } .sgis-card-item.sgis-card-with-bg::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(15, 17, 35, 0.92) 0%, rgba(15, 17, 35, 0.75) 50%, rgba(15, 17, 35, 0.5) 100%); pointer-events: none; z-index: 0; transition: opacity 0.3s ease; } .sgis-card-item.sgis-card-with-bg:hover::after { background: linear-gradient(90deg, rgba(15, 17, 35, 0.85) 0%, rgba(139, 92, 246, 0.3) 50%, rgba(59, 130, 246, 0.2) 100%); } .sgis-card-item.sgis-card-with-bg .sgis-card-name { color: #fff; } .sgis-card-item.sgis-card-with-bg .sgis-card-prices { color: rgba(255, 255, 255, 0.85); } .sgis-card-item.sgis-card-with-bg .sgis-card-icon { border-color: rgba(255, 255, 255, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } .sgis-card-stat { position: relative; overflow: hidden; } .sgis-card-stat::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, transparent, currentColor, transparent); opacity: 0.4; } .sgis-card-stat.sell::before { color: var(--sgis-green); } .sgis-card-stat.net::before { color: var(--sgis-blue); } .sgis-card-stat.buy::before { color: var(--sgis-amber); } .sgis-svg { width: 14px; height: 14px; display: inline-block; vertical-align: middle; stroke-width: 2; flex-shrink: 0; } .sgis-svg-tab { width: 14px; height: 14px; } .sgis-svg-section { width: 12px; height: 12px; } .sgis-svg-title { width: 14px; height: 14px; } .sgis-scroll-area { max-height: 280px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: rgba(139, 92, 246, 0.4) transparent; } .sgis-scroll-area::-webkit-scrollbar { width: 5px; } .sgis-scroll-area::-webkit-scrollbar-track { background: transparent; } .sgis-scroll-area::-webkit-scrollbar-thumb { background: linear-gradient(180deg, rgba(139, 92, 246, 0.4), rgba(59, 130, 246, 0.4)); border-radius: 3px; } .sgis-badge-group-stack { display: flex; flex-direction: column; gap: 6px; } .sgis-badge-group { background: linear-gradient(160deg, rgba(255, 255, 255, 0.025) 0%, rgba(139, 92, 246, 0.02) 100%); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 10px; overflow: hidden; transition: all 0.25s; } .sgis-badge-group:hover { border-color: rgba(139, 92, 246, 0.18); } .sgis-badge-group-head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; cursor: pointer; user-select: none; position: relative; } .sgis-badge-group-head::after { content: ""; position: absolute; bottom: 0; left: 10%; right: 10%; height: 1px; background: linear-gradient(90deg, transparent, rgba(139, 92, 246, 0.15), transparent); } .sgis-badge-group-icon { width: 32px; height: 32px; border-radius: 9px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(59, 130, 246, 0.08)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); } .sgis-badge-group-icon svg { width: 15px; height: 15px; color: #c4b5fd; } .sgis-badge-group-titles { flex: 1; min-width: 0; } .sgis-badge-group-title { font-size: 12px; font-weight: 700; color: #fff; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-badge-group-sub { font-size: 10px; color: var(--sgis-text-2); margin-top: 2px; font-weight: 500; } .sgis-badge-group-stats { display: flex; gap: 5px; flex-shrink: 0; } .sgis-badge-group-stat { font-size: 10px; color: #c4b5fd; padding: 2px 6px; background: rgba(139, 92, 246, 0.12); border-radius: 4px; font-weight: 600; } .sgis-badge-group-arrow { width: 16px; height: 16px; color: var(--sgis-text-2); flex-shrink: 0; transition: transform 0.25s; } .sgis-badge-group-arrow svg { width: 14px; height: 14px; } .sgis-badge-group-expanded .sgis-badge-group-arrow { transform: rotate(90deg); color: #c4b5fd; } .sgis-badge-group-body { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; } .sgis-badge-group-expanded .sgis-badge-group-body { max-height: 1200px; overflow-y: auto; } .sgis-badge-group-rows { padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 4px; } .sgis-badge-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; background: rgba(255, 255, 255, 0.025); border: 1px solid rgba(255, 255, 255, 0.04); border-radius: 7px; transition: all 0.2s; } .sgis-badge-row:hover { background: rgba(139, 92, 246, 0.08); border-color: rgba(139, 92, 246, 0.2); transform: translateX(2px); } .sgis-badge-row-icon { width: 28px; height: 28px; border-radius: 7px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.06)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .sgis-badge-row-icon svg { width: 14px; height: 14px; color: #c4b5fd; } .sgis-badge-row-info { flex: 1; min-width: 0; } .sgis-badge-row-name { font-size: 11px; font-weight: 600; color: #fff; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-badge-row-meta { display: flex; gap: 6px; align-items: center; margin-top: 3px; font-size: 9px; flex-wrap: wrap; } .sgis-badge-row-lv { color: #a78bfa; font-weight: 700; } .sgis-badge-row-xp { color: #fbbf24; font-weight: 600; } .sgis-badge-row-rar { padding: 1px 5px; border-radius: 3px; font-weight: 600; font-size: 9px; } .sgis-badge-row-rar.r1 { background: rgba(148, 163, 184, 0.2); color: #cbd5e1; } .sgis-badge-row-rar.r2 { background: rgba(59, 130, 246, 0.18); color: #93c5fd; } .sgis-badge-row-rar.r3 { background: rgba(139, 92, 246, 0.2); color: #c4b5fd; } .sgis-badge-row-rar.r4 { background: rgba(236, 72, 153, 0.2); color: #f9a8d4; } .sgis-badge-row-rar.r5 { background: linear-gradient(135deg, rgba(251, 191, 36, 0.25), rgba(245, 158, 11, 0.15)); color: #fde047; } .sgis-badge-row-time { color: var(--sgis-text-2); font-weight: 500; } .sgis-badge-row-link { width: 24px; height: 24px; border-radius: 6px; background: rgba(139, 92, 246, 0.12); display: flex; align-items: center; justify-content: center; color: #c4b5fd; flex-shrink: 0; text-decoration: none; transition: all 0.2s; } .sgis-badge-row-link:hover { background: rgba(139, 92, 246, 0.25); color: #fff; transform: translateX(2px); } .sgis-badge-row-link svg { width: 12px; height: 12px; } .sgis-badge-group-foot { padding: 8px 12px 10px; font-size: 10px; color: var(--sgis-text-2); display: flex; gap: 6px; align-items: center; border-top: 1px solid rgba(255, 255, 255, 0.04); } .sgis-badge-group-empty { padding: 16px 12px; text-align: center; color: var(--sgis-text-2); font-size: 11px; background: rgba(255, 255, 255, 0.02); border-radius: 8px; border: 1px dashed rgba(255, 255, 255, 0.06); } .sgis-dist-stack { display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px; } .sgis-dist-row { display: flex; align-items: center; gap: 8px; font-size: 10px; } .sgis-dist-label { width: 32px; color: #c4b5fd; font-weight: 600; flex-shrink: 0; } .sgis-dist-bar { flex: 1; height: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 4px; overflow: hidden; } .sgis-dist-bar-fill { height: 100%; background: linear-gradient(90deg, #8b5cf6, #3b82f6); border-radius: 4px; box-shadow: 0 0 6px rgba(139, 92, 246, 0.3); transition: width 0.6s ease; } .sgis-dist-count { width: 30px; text-align: right; color: var(--sgis-text); font-weight: 600; flex-shrink: 0; } .sgis-value-cards { display: flex; flex-direction: column; gap: 6px; } .sgis-value-card { display: flex; align-items: center; gap: 8px; padding: 7px 8px; background: linear-gradient(160deg, rgba(255, 255, 255, 0.04) 0%, rgba(139, 92, 246, 0.025) 100%); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; transition: all 0.2s; cursor: pointer; } .sgis-value-card:hover { background: linear-gradient(160deg, rgba(139, 92, 246, 0.1) 0%, rgba(59, 130, 246, 0.04) 100%); border-color: rgba(139, 92, 246, 0.3); transform: translateX(2px); } .sgis-value-card-img { width: 56px; height: 22px; border-radius: 4px; overflow: hidden; flex-shrink: 0; background: rgba(0, 0, 0, 0.3); } .sgis-value-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; } .sgis-value-card-info { flex: 1; min-width: 0; } .sgis-value-card-name { font-size: 11px; font-weight: 600; color: #fff; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-value-card-meta { font-size: 9px; color: var(--sgis-text-2); margin-top: 2px; } .sgis-value-card-score { width: 38px; text-align: center; flex-shrink: 0; } .sgis-value-card-score-num { font-size: 16px; font-weight: 700; line-height: 1; } .sgis-value-card-score.high { color: #34d399; } .sgis-value-card-score.mid { color: #fbbf24; } .sgis-value-card-score.low { color: #94a3b8; } .sgis-value-card-score-lbl { font-size: 8px; color: var(--sgis-text-2); margin-top: 1px; letter-spacing: 0.5px; text-transform: uppercase; } .sgis-value-card-market { font-size: 9px; color: #c4b5fd; font-weight: 600; flex-shrink: 0; } .sgis-badge-row-icon-wrap { width: 42px; height: 56px; flex-shrink: 0; position: relative; border-radius: 5px; overflow: hidden; background: radial-gradient(circle at 50% 35%, rgba(139, 92, 246, 0.12), transparent 65%), linear-gradient(135deg, rgba(15, 23, 42, 0.6) 0%, rgba(30, 41, 59, 0.5) 100%); display: flex; align-items: center; justify-content: center; border: 1px solid rgba(255, 255, 255, 0.06); } .sgis-badge-row-icon-wrap::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 40%; background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%); pointer-events: none; z-index: 1; } .sgis-badge-row-img { width: 100%; height: 100%; object-fit: cover; display: block; position: relative; z-index: 0; transition: transform 0.3s ease; } .sgis-badge-row:hover .sgis-badge-row-img { transform: scale(1.06); } .sgis-badge-row-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; position: relative; z-index: 0; } .sgis-badge-row-icon svg { width: 13px; height: 13px; color: #c4b5fd; } .sgis-badge-row { position: relative; } .sgis-badge-row.has-tip { cursor: help; } .sgis-badge-tip { position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%) translateY(-8px); min-width: 220px; max-width: 280px; padding: 10px 12px; background: linear-gradient(160deg, rgba(22, 18, 44, 0.98) 0%, rgba(11, 14, 28, 0.98) 100%); border: 1px solid rgba(139, 92, 246, 0.35); border-radius: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(139, 92, 246, 0.1); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 0.2s, transform 0.2s; z-index: 10; z-index: 100; } .sgis-badge-tip::after { content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: rgba(22, 18, 44, 0.98); } .sgis-badge-row.has-tip:hover .sgis-badge-tip { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(-4px); } .sgis-badge-tip-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid rgba(139, 92, 246, 0.15); } .sgis-badge-tip-name { font-size: 11px; font-weight: 700; color: #fff; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgis-badge-tip-score { font-size: 13px; font-weight: 800; padding: 2px 8px; border-radius: 6px; } .sgis-badge-tip-score.high { background: linear-gradient(135deg, rgba(52, 211, 153, 0.25), rgba(16, 185, 129, 0.15)); color: #34d399; } .sgis-badge-tip-score.mid { background: linear-gradient(135deg, rgba(251, 191, 36, 0.25), rgba(245, 158, 11, 0.15)); color: #fbbf24; } .sgis-badge-tip-score.low { background: rgba(148, 163, 184, 0.2); color: #cbd5e1; } .sgis-badge-tip-row { display: flex; justify-content: space-between; font-size: 10px; color: var(--sgis-text-2); padding: 2px 0; } .sgis-badge-tip-row span:last-child { color: var(--sgis-text); font-weight: 600; } .sgis-badge-tip-bd { margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255, 255, 255, 0.04); } .sgis-badge-tip-bd-row { display: flex; align-items: center; gap: 6px; font-size: 9px; color: var(--sgis-text-2); margin-bottom: 2px; } .sgis-badge-tip-bd-row > span:first-child { width: 30px; } .sgis-badge-tip-bd-bar { flex: 1; height: 4px; background: rgba(255, 255, 255, 0.05); border-radius: 2px; overflow: hidden; } .sgis-badge-tip-bd-bar > div { height: 100%; background: linear-gradient(90deg, #8b5cf6, #3b82f6); border-radius: 2px; } .sgis-badge-row-score { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; } .sgis-badge-row-score.high { background: rgba(52, 211, 153, 0.18); color: #34d399; } .sgis-badge-row-score.mid { background: rgba(251, 191, 36, 0.18); color: #fbbf24; } .sgis-badge-row-score.low { background: rgba(148, 163, 184, 0.15); color: #cbd5e1; } /* v2.3.19: 相似游戏推荐网格 + 库存标记(参考 SteamPeek 1.9 配色) */ .sgis-sim-toolbar { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; align-items: center; } .sgis-sim-tbtn { padding: 4px 10px; font-size: 11px; border-radius: 5px; background: rgba(255,255,255,0.05); color: var(--sgis-text-2); border: 1px solid rgba(255,255,255,0.07); cursor: pointer; transition: all 0.15s; font-weight: 500; } .sgis-sim-tbtn:hover { background: rgba(102,192,244,0.1); color: var(--sgis-text); } .sgis-sim-tbtn.active { background: rgba(102,192,244,0.2); color: #66c0f4; border-color: rgba(102,192,244,0.4); } .sgis-sim-tbtn .cnt { font-size: 10px; opacity: 0.7; margin-left: 3px; } .sgis-sim-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 7px; } .sgis-sim-card { position: relative; display: block; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 7px; overflow: hidden; transition: all 0.2s; text-decoration: none; color: var(--sgis-text); } .sgis-sim-card:hover { border-color: rgba(102,192,244,0.4); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); } .sgis-sim-card img { width: 100%; height: 62px; object-fit: cover; display: block; background: rgba(0,0,0,0.3); } .sgis-sim-card-body { padding: 5px 8px; } .sgis-sim-card-name { font-size: 11px; color: #fff; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sgis-sim-card-price { font-size: 10px; color: var(--sgis-green-text); font-weight: 600; margin-top: 2px; } .sgis-sim-card-price.free { color: #66c0f4; } .sgis-sim-card-price.unknown { color: var(--sgis-text-2); font-weight: 400; } /* 三色库存标记 - 严格复刻 SteamPeek 1.9 / Steam 原生配色 */ .sgis-sim-tag { position: absolute; top: 4px; left: 4px; z-index: 5; padding: 0 5px; font-size: 9px; line-height: 16px; height: 16px; text-transform: uppercase; display: flex; align-items: center; border-radius: 1px; box-shadow: 1px 1px 3px rgba(0,0,0,0.5); pointer-events: none; font-weight: 600; white-space: nowrap; letter-spacing: 0.3px; } .sgis-sim-tag-owned { background: #67c1f5; color: #000; } .sgis-sim-tag-wishlist { background: #e1f7f9; color: #4c6c8c; } .sgis-sim-tag-ignored { background: #000; color: #fff; } .sgis-sim-more { text-align: center; font-size: 11px; color: var(--sgis-text-2); margin-top: 8px; padding: 6px; } .sgis-sim-more a { color: #66c0f4; text-decoration: none; } .sgis-sim-more a:hover { text-decoration: underline; } .sgis-sim-empty { font-size: 12px; color: var(--sgis-text-2); padding: 12px; text-align: center; } .sgis-sim-login-hint { font-size: 10px; color: var(--sgis-text-2); padding: 6px 0; margin-bottom: 4px; line-height: 1.5; } /* v2.3.19: 同类型游戏扩展推荐 */ .sgis-tagrec-tabs { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; } .sgis-tagrec-tab { padding: 4px 10px; font-size: 11px; border-radius: 5px; background: rgba(255,255,255,0.05); color: var(--sgis-text-2); border: 1px solid rgba(255,255,255,0.07); cursor: pointer; transition: all 0.15s; font-weight: 500; } .sgis-tagrec-tab:hover { background: rgba(139,92,246,0.1); color: var(--sgis-text); } .sgis-tagrec-tab.active { background: rgba(139,92,246,0.2); color: #c4b5fd; border-color: rgba(139,92,246,0.4); } .sgis-tagrec-loading { font-size: 12px; color: var(--sgis-text-2); padding: 10px; text-align: center; } /* v1.7.0: 年度大作年度筛选 chip bar(在 toolbar 上方一行) */ .sglv-goty-year-bar { display: flex !important; flex-wrap: wrap !important; gap: 5px !important; padding: 6px 12px !important; background: rgba(15, 23, 42, 0.3) !important; border-bottom: 1px solid var(--sglv-border) !important; flex-shrink: 0 !important; } .sglv-goty-year-chip { padding: 3px 10px !important; font-size: 11px !important; font-weight: 600 !important; border-radius: 12px !important; background: rgba(255, 255, 255, 0.04) !important; color: var(--sglv-text-2) !important; border: 1px solid rgba(255, 255, 255, 0.08) !important; cursor: pointer !important; transition: all 0.18s !important; font-variant-numeric: tabular-nums !important; } .sglv-goty-year-chip:hover { background: rgba(139, 92, 246, 0.12) !important; color: var(--sglv-text) !important; border-color: rgba(139, 92, 246, 0.3) !important; } .sglv-goty-year-chip.active { background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(59, 130, 246, 0.15)) !important; color: #c4b5fd !important; border-color: rgba(139, 92, 246, 0.5) !important; box-shadow: 0 0 8px rgba(139, 92, 246, 0.2) !important; } /* v2.7.1: 二级菜单 — 复刻 Steam 原生 supernav 下拉样式 */ .sglv-menu-wrap { position: relative !important; display: inline-block !important; } .sglv-submenu { display: none !important; position: absolute !important; top: 100% !important; left: 0 !important; min-width: 120px !important; background: #171a21 !important; border: none !important; border-radius: 0 !important; box-shadow: none !important; z-index: 9999 !important; padding: 0 !important; margin-top: 0 !important; } .sglv-menu-wrap:hover .sglv-submenu { display: block !important; } .sglv-submenu-item { display: block !important; padding: 0 12px !important; line-height: 25px !important; height: 25px !important; color: #ffffff !important; text-decoration: none !important; white-space: nowrap !important; cursor: pointer !important; font-size: 11px !important; font-family: "Motiva Sans", Arial, Helvetica, sans-serif !important; background: #171a21 !important; } .sglv-submenu-item:hover { background: #316373 !important; color: #ffffff !important; } /* 收藏浮窗:复用主浮窗样式,仅微调标题色 */ .sgc-panel .sglv-header h2 { color: #c2a050 !important; } .sgc-overlay { background: rgba(11, 17, 32, 0.75) !important; }`; /* ---------- sglv-cloud-save.lib.js ---------- */ // ==UserScript== // @name SGLV 云存档模块 (Library) // @name:en SGLV Cloud Save (Library) // @namespace https://greasyfork.org/scripts/588485 // @version 1.3.0 // @description Steam 游戏库查看器 - 云存档模块库(全局云存档列表抓取 / HTML解析 / 增量diff / 单App存档文件列表)。供主脚本 @require 引用,不可单独使用。 // @description:en Steam Game Library Viewer - Cloud Save module library (global cloud save list scrape / HTML parse / incremental diff / per-app file list). For host script @require only. // @author SmallRob // @license MIT // @noframes // ==/UserScript== /* * SGLV Cloud Save Library v1.0.0 * * 桥接契约(Host / ctx 需提供): * apiVersion: 1 * GM_xmlhttpRequest(宿主已 @grant) * isZh: boolean * showToast(msg) * cacheGet(key), cacheSet(key, val, ttl) * cacheTTL: { cloudSave: number } * storage: { getCloudSaveData(), setCloudSaveData(v) } * * 暴露 API: * init(ctx) -> boolean 初始化,注入宿主上下文 * getApiVersion() -> number * fetchGlobalCloudSaves(force) -> Promise<{games, source, lastUpdate, diff}> * 抓取全局云存档页面,解析游戏列表,与 IDB 缓存做增量 diff * fetchAppCloudSaveFiles(appid) -> Promise<{files, totalFiles}> * 抓取单 App 云存档文件列表(懒加载) * getCachedCloudSaves() -> {games, lastUpdate, source} | null * 从内存缓存读取(init 时已从 IDB 加载) * normalizeSize(text) -> number * 将 "1.2 MB" / "345 KB" 等文本转为字节数 * formatSize(bytes) -> string * 将字节数格式化为人类可读字符串 * * 宿主需 @grant GM_xmlhttpRequest, * 并 @connect store.steampowered.com(云存档页面)。 */ window.SGLVCloudSave = (function () { 'use strict'; // ---- 桥接宿主(由 init(ctx) 注入) ---- let Host = null; const apiVersion = 1; // 内存缓存 let _memCache = null; // {games: [], lastUpdate: 0, source: ''} // ==================== 工具函数 ==================== /** * 将 Steam 页面显示的大小文本转为字节数 * 支持: "345 bytes", "1.2 KB", "10.5 MB", "2 GB" */ function normalizeSize(text) { if (!text) return 0; const t = String(text).trim(); const m = t.match(/([\d.]+)\s*(bytes?|KB|MB|GB|TB)/i); if (!m) { // 纯数字也尝试解析 const n = parseFloat(t); return isNaN(n) ? 0 : n; } const val = parseFloat(m[1]) || 0; const unit = m[2].toLowerCase(); if (unit.startsWith('byte')) return Math.round(val); if (unit === 'kb') return Math.round(val * 1024); if (unit === 'mb') return Math.round(val * 1024 * 1024); if (unit === 'gb') return Math.round(val * 1024 * 1024 * 1024); if (unit === 'tb') return Math.round(val * 1024 * 1024 * 1024 * 1024); return 0; } /** * 将字节数格式化为人类可读字符串 */ function formatSize(bytes) { if (!bytes || bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let val = bytes; while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } /** * 从 HTML 文本中提取数字(如文件数 "42 files" -> 42) */ function parseFileCount(text) { if (!text) return 0; const m = String(text).match(/(\d+)/); return m ? parseInt(m[1], 10) : 0; } /** * 从游戏名称链接中提取 appid */ function extractAppId(href) { if (!href) return 0; const m = String(href).match(/appid=(\d+)/) || String(href).match(/\/app\/(\d+)/); return m ? parseInt(m[1], 10) : 0; } // ==================== 网络请求 ==================== /** * GM_xmlhttpRequest Promise 封装(text 模式) */ function gmFetchText(url, opts = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ url, method: opts.method || 'GET', responseType: 'text', timeout: opts.timeout || 30000, headers: opts.headers || { 'Accept': 'text/html', 'Accept-Language': 'en-US,en;q=0.9' }, onload: (xhr) => { if (xhr.status === 200) { resolve(xhr.responseText); } else { reject(new Error(`HTTP ${xhr.status}: ${url}`)); } }, onerror: () => reject(new Error(`Network error: ${url}`)), ontimeout: () => reject(new Error(`Timeout: ${url}`)), }); }); } /** * 检测响应是否为 Steam 登录页(未登录被重定向) */ function isLoginPage(html) { if (!html) return true; // 登录页特征:有 username 输入框 且 无 #main_content return html.includes('input name="username"') && !html.includes('id="main_content"'); } // ==================== 全局云存档页面解析 ==================== /** * 抓取全局云存档页面,解析所有有云存档的游戏列表 * * 页面结构(store.steampowered.com/account/remotestorage): * 每个游戏一个条目,包含游戏名称、各平台文件数、总大小、查看/下载链接 * * 防御性解析:尝试多种 DOM 选择器,兼容页面结构变化 * * @returns {Promise>} */ async function scrapeGlobalCloudSavePage() { const url = 'https://store.steampowered.com/account/remotestorage'; const html = await gmFetchText(url); if (isLoginPage(html)) { throw new Error('LOGIN_REQUIRED'); } const doc = new DOMParser().parseFromString(html, 'text/html'); const games = []; // 策略1: 查找所有指向 remotestorageapp?appid= 的链接 // Steam 全局页面通常每个游戏一行,链接到单App页面 const appLinks = doc.querySelectorAll('a[href*="remotestorageapp"]'); if (appLinks.length > 0) { appLinks.forEach(link => { const href = link.href; const appid = extractAppId(href); if (!appid) return; const name = (link.textContent || '').trim() || `App ${appid}`; // 向上查找包含此链接的行容器(tr / div / li) let row = link.closest('tr') || link.closest('div') || link.parentElement; let fileCount = 0; let sizeText = ''; let sizeBytes = 0; if (row) { // 尝试从行内单元格/子元素提取文件数和大小 const cells = [...row.querySelectorAll('td, .cloudsave_size, .cloudsave_files, span, div')]; for (const cell of cells) { const txt = (cell.textContent || '').trim(); // 匹配大小文本 if (/[\d.]+\s*(bytes?|KB|MB|GB|TB)/i.test(txt) && !sizeText) { sizeText = txt; sizeBytes = normalizeSize(txt); } // 匹配文件数 if (/\d+\s*(file|文件)/i.test(txt) && fileCount === 0) { fileCount = parseFileCount(txt); } } } games.push({ appid, name, fileCount, totalSize: sizeText || formatSize(sizeBytes), sizeBytes, href }); }); } // 策略2: 如果策略1未找到数据,尝试查找 #main_content 下的所有表格行 if (games.length === 0) { const mainContent = doc.querySelector('#main_content'); if (mainContent) { const rows = mainContent.querySelectorAll('tr'); rows.forEach(row => { const link = row.querySelector('a[href*="remotestorageapp"]') || row.querySelector('a[href*="/app/"]'); if (!link) return; const href = link.href; const appid = extractAppId(href); if (!appid) return; const name = (link.textContent || '').trim() || `App ${appid}`; const cells = [...row.children]; let fileCount = 0; let sizeText = ''; let sizeBytes = 0; // 尝试从各列提取信息 for (const cell of cells) { const txt = (cell.textContent || '').trim(); if (/[\d.]+\s*(bytes?|KB|MB|GB|TB)/i.test(txt) && !sizeText) { sizeText = txt; sizeBytes = normalizeSize(txt); } if (/\d+\s*(file|文件)/i.test(txt) && fileCount === 0) { fileCount = parseFileCount(txt); } } games.push({ appid, name, fileCount, totalSize: sizeText || formatSize(sizeBytes), sizeBytes, href }); }); } } // 策略3: 降级模式 - 如果全局页面无法解析,返回空数组让宿主降级处理 if (games.length === 0) { console.warn('[SGLVCloudSave] 全局云存档页面未能解析出游戏列表,可能页面结构已变更'); } return games; } // ==================== 单 App 云存档文件列表解析 ==================== /** * 抓取指定 App 的云存档文件列表(分页) * * 页面结构(store.steampowered.com/account/remotestorageapp?appid=XXX): * 每页50文件,表格行包含 主目录/子目录/大小/日期/下载链接 * 底部有翻页链接 * * @param {number} appid * @returns {Promise<{files: Array, totalFiles: number}>} */ async function scrapeAppCloudSaveFiles(appid) { const files = []; let index = 0; const pageSize = 50; while (true) { const url = `https://store.steampowered.com/account/remotestorageapp?appid=${appid}&index=${index}`; const html = await gmFetchText(url); if (isLoginPage(html)) { throw new Error('LOGIN_REQUIRED'); } const doc = new DOMParser().parseFromString(html, 'text/html'); const pageFiles = parseAppFileRows(doc, index); files.push(...pageFiles); // 翻页检测(null 安全) if (checkNextPage(doc, index)) { index += pageSize; await sleep(200); // 翻页间隔 } else { break; } } return { files, totalFiles: files.length }; } /** * 解析单App页面表格行(使用原生 children,不依赖 Prototype.js) * v1.2.0: 修复选择器——精确匹配 #main_content 内的 accountTable tbody tr, * 避免页面其他表格(导航/页脚)的 tr 被误选导致文件数计算错误 */ function parseAppFileRows(dom, globalIndex) { // v1.2.0: 精确匹配 accountTable 的 tbody tr(参考 Steam 云.html 页面结构) const rows = [...dom.querySelectorAll('#main_content table.accountTable tbody tr, #main_content .accountTable tbody tr')]; // 兜底:如果精确匹配无结果,回退到全文档 tr(兼容旧页面结构) const finalRows = rows.length > 0 ? rows : [...dom.querySelectorAll('tr')].slice(1); return finalRows.map((node, index) => { const childrens = [...node.children]; if (childrens.length < 4) return null; const mainFolder = (childrens[0].innerText || '').trim(); const subFolder = (childrens[1].innerText || '').trim(); const path = mainFolder ? `${mainFolder}/${subFolder}` : subFolder; const size = (childrens[2].innerText || '').trim(); const date = (childrens[3].innerText || '').trim(); const href = childrens[4]?.querySelector('a')?.href || ''; return { index: globalIndex + index, path, size, date, href, sizeBytes: normalizeSize(size) }; }).filter(Boolean); } /** * 翻页检测(null 安全) */ function checkNextPage(dom, currentIndex) { try { const nextLink = dom.querySelector('#main_content > a:last-child'); if (!nextLink || !nextLink.href) return false; const match = nextLink.href.match(/index=(\d+)/); if (!match) return false; const nextIndex = parseInt(match[1], 10) || 0; return nextIndex > currentIndex; } catch { return false; } } // ==================== 增量 Diff 引擎 ==================== /** * 将新抓取的游戏列表与缓存做增量比对 * * @param {Array} newGames - 新抓取的游戏列表 * @param {Array} oldGames - 缓存中的旧游戏列表 * @returns {{new: Array, changed: Array, removed: Array, merged: Array}} * - new: 新增的游戏(appid 不在旧缓存中) * - changed: 文件数或大小发生变化的游戏 * - removed: 旧缓存中有但新抓取中没有的游戏 * - merged: 合并后的完整列表(新数据 + _status 标记) */ function diffCloudSaves(newGames, oldGames, options = {}) { const { additive = false } = options; // v2.9.39: additive=true 时只增不减,保留旧数据中未被新抓取覆盖的项 const oldMap = new Map(); if (Array.isArray(oldGames)) { oldGames.forEach(g => oldMap.set(g.appid, g)); } const newMap = new Map(); newGames.forEach(g => newMap.set(g.appid, g)); const added = []; const changed = []; const removed = []; const merged = []; // 检查新增和变化 for (const game of newGames) { const old = oldMap.get(game.appid); if (!old) { // 新增 game._status = 'new'; added.push(game); } else { // 比较文件数和大小 const fileChanged = game.fileCount !== old.fileCount; const sizeChanged = game.sizeBytes !== old.sizeBytes; // v1.3.0: 保留旧 fileCount——全局页面不展示文件数,新抓取 fileCount=0 不代表文件数变了 // 只有旧缓存的 fileCount 才是逐 App 页面获取的真实值,避免每次重新抓取后全部丢失 if ((!game.fileCount || game.fileCount === 0) && old.fileCount > 0) { game.fileCount = old.fileCount; } if (fileChanged || sizeChanged) { game._status = 'changed'; changed.push(game); } else { game._status = 'unchanged'; } } merged.push(game); } // 检查移除 for (const [appid, oldGame] of oldMap) { if (!newMap.has(appid)) { if (additive) { // v2.9.39: 只增不减模式——保留旧数据,标记为保留 oldGame._status = 'kept'; merged.push(oldGame); // 不推入 removed(不向用户展示"删除"徽章) } else { removed.push(oldGame); } } } return { new: added, changed, removed, merged }; } // ==================== 对外 API ==================== /** * 抓取指定 App 的云存档文件列表(对外封装,含错误处理) * * @param {number} appid * @returns {Promise<{files: Array, totalFiles: number}>} */ async function fetchAppCloudSaveFiles(appid) { if (!Host) throw new Error('SGLVCloudSave not initialized'); if (!appid) throw new Error('appid is required'); return scrapeAppCloudSaveFiles(appid); } /** * v1.2.0: 轻量级获取单 App 云存档文件数量(不下载完整文件列表) * 仅抓取页面并计数 accountTable tbody tr 行数,支持分页累计 * * @param {number} appid * @returns {Promise} 文件总数 */ async function fetchAppFileCount(appid) { if (!Host) throw new Error('SGLVCloudSave not initialized'); if (!appid) throw new Error('appid is required'); let total = 0; let index = 0; const pageSize = 50; while (true) { const url = `https://store.steampowered.com/account/remotestorageapp?appid=${appid}&index=${index}`; const html = await gmFetchText(url); if (isLoginPage(html)) throw new Error('LOGIN_REQUIRED'); const doc = new DOMParser().parseFromString(html, 'text/html'); // v1.2.0: 精确匹配 accountTable tbody tr(参考 Steam 云.html 页面结构) let rows = [...doc.querySelectorAll('#main_content table.accountTable tbody tr, #main_content .accountTable tbody tr')]; if (rows.length === 0) rows = [...doc.querySelectorAll('tr')].slice(1); // 过滤掉非数据行(children < 4 的行) const dataRows = rows.filter(r => r.children.length >= 4); total += dataRows.length; if (dataRows.length >= pageSize && checkNextPage(doc, index)) { index += pageSize; await sleep(200); } else { break; } } return total; } /** * v1.2.0: 批量获取游戏列表的文件数量(后台渐进式填充) * 对 fileCount=0 的游戏逐一请求单 App 页面解析文件数,通过 onProgress 回调通知宿主更新 UI * * @param {Array} games - 游戏列表 [{appid, fileCount, ...}] * @param {object} [options] * @param {function} [options.onProgress] - 回调 (updatedGame, allGames) => void * @param {number} [options.concurrency=3] - 并发数 * @param {number} [options.delay=500] - 每批之间的延迟(ms) * @param {AbortSignal} [options.signal] - 可选中止信号 * @returns {Promise} 更新后的游戏列表 */ async function enrichFileCounts(games, options = {}) { if (!Host) throw new Error('SGLVCloudSave not initialized'); const { onProgress, concurrency = 3, delay = 500, signal } = options; if (!Array.isArray(games) || games.length === 0) return games; // 筛选需要获取文件数的游戏(fileCount=0 且有 appid) const pending = games.filter(g => g.appid && (!g.fileCount || g.fileCount === 0)); if (pending.length === 0) return games; console.log(`[SGLVCloudSave] enrichFileCounts — 需获取 ${pending.length}/${games.length} 款游戏的文件数`); let processed = 0; // 并发池(限流) const pool = [...pending]; const workers = Array.from({ length: Math.min(concurrency, pool.length) }, async () => { while (pool.length > 0) { if (signal?.aborted) return; const game = pool.shift(); if (!game) return; try { const count = await fetchAppFileCount(game.appid); game.fileCount = count; processed++; console.log(`[SGLVCloudSave] 文件数: App ${game.appid} = ${count} (${processed}/${pending.length})`); if (onProgress) onProgress(game, games); } catch (e) { // 单个失败不影响整体,保留 fileCount=0 console.warn(`[SGLVCloudSave] 获取 App ${game.appid} 文件数失败:`, e.message); processed++; } // 请求间隔 await sleep(delay); } }); await Promise.all(workers); // 持久化到 IDB try { if (_memCache) { _memCache.games = games; // v1.3.0: 记录文件数填充时间戳,供宿主判断 5 天内是否需要重新获取 _memCache.fileCountAt = Date.now(); Host.storage.setCloudSaveData(_memCache); console.log(`[SGLVCloudSave] enrichFileCounts 完成,已持久化 ${games.length} 款游戏数据 (fileCountAt=${new Date(_memCache.fileCountAt).toLocaleString()})`); } } catch (e) { console.warn('[SGLVCloudSave] enrichFileCounts 持久化失败:', e); } return games; } /** * 抓取全局云存档数据,与缓存做增量 diff,持久化到 IDB * * @param {boolean} force - 是否强制刷新(忽略缓存TTL) * @param {object} [options] * @param {boolean} [options.additive=false] v2.9.39: 只增不减模式(保留旧数据中未在新抓取中出现的项) * @param {boolean} [options.silent=false] v2.9.39: 静默模式(不修改 lastUpdate,用于后台增量更新;保留旧时间戳以便节流) * @returns {Promise<{games, source, lastUpdate, diff, fromCache?}>} */ async function fetchGlobalCloudSaves(force = false, options = {}) { if (!Host) throw new Error('SGLVCloudSave not initialized'); const { additive = false, silent = false } = options; // 非强制刷新时检查缓存TTL if (!force && _memCache && _memCache.lastUpdate) { const ttl = Host.cacheTTL?.cloudSave || (24 * 3600 * 1000); if (Date.now() - _memCache.lastUpdate < ttl) { return { games: _memCache.games, source: 'cache', lastUpdate: _memCache.lastUpdate, diff: null, fromCache: true, }; } } // 抓取全局页面 const newGames = await scrapeGlobalCloudSavePage(); // 与旧缓存做 diff const oldGames = _memCache?.games || []; const diff = diffCloudSaves(newGames, oldGames, { additive }); // 更新内存缓存 _memCache = { games: diff.merged, // v2.9.39: 静默模式保留原始 lastUpdate(避免重置后台刷新节流时间) lastUpdate: silent && _memCache?.lastUpdate ? _memCache.lastUpdate : Date.now(), source: 'web', }; // 持久化到 IDB try { Host.storage.setCloudSaveData(_memCache); } catch (e) { console.warn('[SGLVCloudSave] IDB 持久化失败:', e); } return { games: diff.merged, source: 'web', lastUpdate: _memCache.lastUpdate, diff: { new: diff.new, changed: diff.changed, removed: diff.removed }, }; } /** * v2.9.39: 仅从 IDB 加载缓存到内存(不抓取网络),用于立即显示已有数据 * @returns {Promise<{games, source, lastUpdate}|null>} */ async function getCachedCloudSavesAsync() { if (!Host?.storage?.getCloudSaveData) return null; try { const cached = await Host.storage.getCloudSaveData(); if (cached && cached.games && Array.isArray(cached.games) && cached.games.length > 0) { _memCache = cached; // v1.3.0: 返回 fileCountAt 供宿主判断文件数是否需要重新获取(5 天 TTL) return { games: cached.games, source: 'cache', lastUpdate: cached.lastUpdate || 0, fileCountAt: cached.fileCountAt || 0, }; } } catch (e) { console.warn('[SGLVCloudSave] IDB 缓存加载失败:', e); } return null; } /** * 从 IDB 加载缓存到内存(init 时调用) */ async function loadCacheFromIDB() { if (!Host?.storage?.getCloudSaveData) return; try { const cached = await Host.storage.getCloudSaveData(); if (cached && cached.games && Array.isArray(cached.games)) { _memCache = cached; console.log(`[SGLVCloudSave] 从 IDB 加载缓存: ${cached.games.length} 款游戏, 更新于 ${new Date(cached.lastUpdate).toLocaleString()}`); } } catch (e) { console.warn('[SGLVCloudSave] IDB 缓存加载失败:', e); } } /** * 获取内存缓存(同步) */ function getCachedCloudSaves() { return _memCache ? { ..._memCache } : null; } // ==================== 初始化 ==================== function init(ctx) { if (!ctx || typeof ctx !== 'object') { console.error('[SGLVCloudSave] init 失败: ctx 为空'); return false; } if (ctx.apiVersion !== apiVersion) { console.error(`[SGLVCloudSave] API 版本不匹配: 宿主=${ctx.apiVersion}, 库=${apiVersion}`); return false; } Host = ctx; console.log('[SGLVCloudSave] v1.2.0 初始化成功'); // 异步加载 IDB 缓存到内存(非阻塞) loadCacheFromIDB(); return true; } function getApiVersion() { return apiVersion; } // ==================== 工具 ==================== function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // ==================== 导出 ==================== return { init, getApiVersion, fetchGlobalCloudSaves, fetchAppCloudSaveFiles, fetchAppFileCount, // v1.2.0: 轻量级获取单 App 文件数 enrichFileCounts, // v1.2.0: 批量获取文件数(后台渐进式) getCachedCloudSaves, getCachedCloudSavesAsync, // v2.9.39: 异步从 IDB 加载缓存到内存(不抓取网络) normalizeSize, formatSize, // 内部函数导出(供测试/调试) _internal: { scrapeGlobalCloudSavePage, scrapeAppCloudSaveFiles, diffCloudSaves, parseAppFileRows, checkNextPage, isLoginPage, }, }; })(); /* ---------- sglv-cover-fallback.lib.js ---------- */ // ==UserScript== // @name SGLV 封面三级降级 (Library) // @name:en SGLV Cover Fallback (Library) // @namespace https://greasyfork.org/scripts/588485 // @version 1.2.0 // @description Steam 游戏封面多级降级库:内存缓存(跨视图共享)→ steamui.com appinfo 精确 hash URL → 持久化缓存(跨会话,GM_setValue,LRU 500)→ CDN fallback 链(shared.akamai → shared.fastly → cdn.akamai → cloudflare 四 CDN × 语言变体)→ Steam appdetails API 兜底 → SVG placeholder。支持 poster/capsule/header/hero/logo/library_header 六种图片类型与语言变体(schinese 等)。v1.2.0 新增 steamui.com 游戏资产信息渠道(每语言 hash 路径/logo_position/associations)。供 SGLV 系列脚本 @require 引用,复用降级逻辑。不可单独使用。 // @description:en SGLV Cover Fallback Library v1.2.0 — Steam game cover multi-tier fallback chain (in-memory cache → steamui.com appinfo exact hash URL → persistent GM_setValue cache LRU 500 → language-variant CDN chain shared.akamai + shared.fastly + cdn.akamai + cloudflare → appdetails API → SVG placeholder). Supports poster/capsule/header/hero/logo/library_header image kinds and language variants. **v1.2.0** adds steamui.com game asset info channel (per-language hash paths / logo_position / associations). For SGLV host scripts @require only. // @author SmallFork // @license MIT // @match *://*/* // @connect store.steampowered.com // @connect steamui.com // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @noframes // ==/UserScript== /* * SGLV Cover Fallback Library v1.2.0 * * v1.2.0 变更(steamui.com appinfo 渠道增强): * - 新增 kind: hero(library_hero 3840x1240 库背景) / logo(logo.png 库徽标) / * library_header(920x430 库页头图) * - 语言变体链: getCoverUrl/buildFullChain/loadImageWithFallback 新增 lang 参数, * img 标签可通过 data-lang="schinese" 指定语言;链前部插入 `_{lang}` 变体文件名 * (实测 CDN 语言后缀可直接拼接: library_600x900_schinese.jpg / library_hero_japanese.jpg / * logo_japanese.png 等全部 200) * - 新增 steamui.com 信息渠道 getSteamuiAssets(appId): Steam 内部 appinfo 数据源, * 一次请求返回各资产 hash 精确路径 + 每语言可用性 + logo_position + associations * (developer/publisher/franchise) + oslist/releasestate/icon hash; * 站点受 Cloudflare 盾保护,设计为尽力而为:失败静默降级 + 全局会话级负缓存(30min) * + 按 appid 负缓存(404 游戏不存在, 24h),防止 CF 拦截/404 重试风暴 * - 新增 getSteamuiGoodUrl(appId, kind, lang): 同步读 steamui 缓存构建精确 hash URL * (免 CDN 探测,语言选择精确: 请求语言 → english → 第一个可用) * - 新增 resolveCover(appId, kind, lang): Promise 版解析,steamui 优先 → 探测链首 * - loadImageWithFallback 降级顺序增强: CDN 探测链耗尽 → steamui hash URL → * appdetails API → SVG 占位 * - 新增 parseSteamInfo(text) 容错 VDF/JSON 解析器(纯函数,供 sglv-app-detail 等库复用) * * v1.1.0 变更: * - 新增 kind 参数支持(poster/capsule/header),缓存键按 kind 隔离 * - CDN 链扩展:shared.akamai → shared.fastly → cdn.akamai → cdn.cloudflare * - poster 链增加 library_600x900_2x/library_600x900 竖版图 * - API 兜底按 kind 选择最佳图片(capsule 优先 capsule_image,poster/header 优先 header_image) * - img 标签可通过 data-kind="poster|capsule|header" 指定图片类型 * v1.1.1: 修复浏览器扩展 hook XHR 导致 responseText 不可访问、新增 shared.fastly CDN 源 * * 设计目标:从 SGLV 业务脚本中抽出"游戏封面降级 + 游戏资产信息获取"基础设施, * 供所有 SGLV 系列脚本通过 @require 共享,避免每个脚本都重写一遍: * - steamui.com appinfo 渠道(hash 精确 URL / 语言可用性 / logo_position) * - 多 CDN fallback 链 × 语言变体(shared.akamai → shared.fastly → cdn.akamai → cloudflare) * - 跨视图/跨会话的 good URL 缓存(按 kind+lang 隔离) * - appdetails API 兜底(kind 感知) * - SVG placeholder 兜底 * * API 暴露方式:window.SGLVCoverFallback (IIFE 直接挂全局,无依赖注入) * * 使用方式: * 1) 宿主 @require 此 lib * 2) img 标签上添加 data-sglv-cover="1" + data-appid="123" * + data-kind="poster|capsule|header|hero|logo|library_header"(可选,默认 capsule) * + data-lang="schinese"(可选,链前部插入语言变体) * 3) 宿主在根容器上调用 SGLVCoverFallback.bindErrorHandler(rootEl) * 4) 初始 src 调用 SGLVCoverFallback.getCoverUrl(appId, kind, lang) 获取 * (自动: good 缓存 → steamui 精确 URL → 语言变体链首) * 5) 错误时自动走降级链;成功时自动写缓存(同会话 + 跨会话) * * 桥接契约(测试/宿主需要注入): * window.__sglvCoverMockGmXhr = function(method, url, opts, cb) 可选,默认走 GM_xmlhttpRequest * * API: * version: '1.2.0' * apiVersion: 2 * SVG_PLACEHOLDER: data:image/svg+xml 兜底图 * CHAIN: 默认 CDN fallback 链(横图 7:3,向后兼容) * KINDS: 支持的图片类型列表 * LANG_VARIANTS: 各 kind 的语言变体文件名模板 * getCoverUrl(appId, kind?, lang?): 获取初始 URL(good缓存→steamui→链首) * resolveCover(appId, kind?, lang?): Promise 版(steamui 拉取优先,失败给链首) * bindErrorHandler(rootEl, onFailed?): 在 rootEl 上绑定 error/load 委托(全局只调一次) * loadImageWithFallback(img, onFailed?): 手动触发单图降级(自动调用,通常无需手动) * clearPersistedCache(): 清空持久化缓存(含 steamui 资产缓存) * getSteamuiAssets(appId, opts?): Promise — steamui appinfo 规范化数据或 null * getSteamuiGoodUrl(appId, kind?, lang?): string — steamui 缓存命中的精确 hash URL * parseSteamInfo(text): VDF/JSON 容错解析(纯函数) * normalizeSteamInfo(raw, appId): appinfo 原始对象 → 规范化资产结构(纯函数) * * 宿主需 @grant GM_xmlhttpRequest, GM_getValue, GM_setValue。 */ (function (root) { 'use strict'; // ==================== CDN 基础 + 链 ==================== const CDN_SHARED = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/'; const CDN_FASTLY = 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/'; const CDN_AKAMAI = 'https://cdn.akamai.steamstatic.com/steam/apps/'; const CDN_CF = 'https://cdn.cloudflare.steamstatic.com/steam/apps/'; // v1.2.0: steamui hash URL 的 CDN 前缀(store_item_assets 路径,fastly 主 + akamai 备) const STEAMUI_ASSET_CDNS = [ 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/', 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/', ]; // 横图 7:3 链(列表/封面视图,向后兼容 v1.0.0) const CHAIN = ['capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg', 'header.jpg']; // v1.1.0: 各 kind 基础文件(无语言变体) const POSTER_FILES = ['library_600x900_2x.jpg', 'library_600x900.jpg']; const CAPSULE_FILES = ['capsule_467x181.jpg', 'capsule_231x87.jpg']; const HEADER_FILES = ['header.jpg']; // v1.2.0: 新 kind 基础文件 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']; // v1.2.0: 语言变体文件名生成器 — {v} 为语言代码(schinese/tchinese/japanese/...) // 实测 CDN 直接支持语言后缀拼接,无需 hash: // library_600x900_schinese.jpg / library_hero_japanese.jpg / logo_japanese.png 等 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; } // ==================== 持久化缓存(跨会话) ==================== const PERSISTED_KEY = 'sglv_cover_url_v2'; const PERSISTED_MAX = 500; 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 */ } for (const key in _persisted) { const ent = _persisted[key]; if (ent && ent.url) _goodMap.set(key, ent.url); } try { const oldRaw = GM_getValue('sglv_cover_url_v1'); if (oldRaw && typeof oldRaw === 'object') { for (const id in oldRaw) { const ent = oldRaw[id]; if (ent && ent.url && !_persisted['capsule:' + id]) { _persisted['capsule:' + id] = { url: ent.url, ts: ent.ts || Date.now() }; _goodMap.set('capsule:' + id, ent.url); } } } } catch (e) { /* ignore */ } return _persisted; } let _writeTimer = null; let _writeDirty = false; function setPersisted(cacheKey, url) { if (!cacheKey || !url) return; const container = loadPersisted(); container[cacheKey] = { url, ts: Date.now() }; const keys = Object.keys(container); if (keys.length > PERSISTED_MAX) { 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) { /* ignore */ } }, 50); } function flushPersisted() { if (_writeTimer) { clearTimeout(_writeTimer); _writeTimer = null; } if (_writeDirty && _persisted) { _writeDirty = false; try { GM_setValue(PERSISTED_KEY, _persisted); } catch (e) { /* ignore */ } } flushSteamuiPersist(); } function clearPersisted() { _persisted = {}; _goodMap.clear(); try { GM_setValue(PERSISTED_KEY, {}); } catch (e) { /* ignore */ } try { GM_setValue('sglv_cover_url_v1', {}); } catch (e) { /* ignore */ } // v1.2.0: 同时清 steamui 资产缓存 _steamuiMem.clear(); _steamuiAppNeg.clear(); try { GM_setValue(STEAMUI_PERSIST_KEY, {}); } catch (e) { /* ignore */ } } // ==================== URL 拼接 ==================== // v1.2.0: kind + lang 感知的文件列表(语言变体在前,默认文件在后) function buildFiles(kind, lang) { const k = KINDS.indexOf(kind) >= 0 ? kind : (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 = []; const tpl = LANG_VARIANTS[k]; if (lang && tpl) { for (const t of tpl) files.push(t.replace('{v}', lang)); } for (const f of base) files.push(f); return files; } // v1.2.0: kind + lang 感知的全链构建(每 file 先 shared.akamai,再 shared.fastly,再 cdn.akamai,再 cloudflare) function buildFullChain(appId, kind, lang) { const id = String(appId); const files = buildFiles(kind, lang); const full = []; for (const f of files) { full.push(CDN_SHARED + id + '/' + f); full.push(CDN_FASTLY + id + '/' + f); full.push(CDN_AKAMAI + id + '/' + f); full.push(CDN_CF + id + '/' + f); } // poster/capsule/hero/logo 链末追加 header.jpg 四 CDN 兜底(header 本身已是 header) if (kind !== 'header' && kind !== 'library_header') { for (const cdn of [CDN_SHARED, CDN_FASTLY, CDN_AKAMAI, CDN_CF]) { full.push(cdn + id + '/header.jpg'); } } return full; } // ==================== steamui.com appinfo 渠道 (v1.2.0) ==================== // 数据源: https://steamui.com/api/games/{appid}/info — Steam 内部 appinfo 的 JSON 化 // 提供: library_assets_full(每资产 hash 路径×语言) / small_capsule / header_image / // icon / logo_position / associations / oslist / releasestate / store_asset_mtime // 风险: 站点受 Cloudflare managed challenge 保护,脚本环境可能被拦 → 尽力而为: // CF 拦截 → 全局会话级负缓存 30min(拦截是全站的,不按 appid 记) // 404 → 按 appid 负缓存 24h(游戏无 appinfo,如未上市/已移除) const STEAMUI_BASE = 'https://steamui.com/api/games/'; const STEAMUI_PERSIST_KEY = 'sglv_steamui_assets_v1'; const STEAMUI_MAX = 300; // LRU 容量 const STEAMUI_TTL = 7 * 24 * 3600 * 1000; // 数据 7 天 const STEAMUI_NEG_TTL = 24 * 3600 * 1000; // appid 404 负缓存 24h const STEAMUI_CF_TTL = 30 * 60 * 1000; // CF 拦截全局负缓存 30min const _steamuiMem = new Map(); // appid → { data, ts } const _steamuiPending = new Map(); // appid → Promise(并发共享) const _steamuiAppNeg = new Map(); // appid → 过期时间戳(404 负缓存) let _steamuiPersisted = null; // 惰性加载的持久化容器 let _steamuiCfBlockedUntil = 0; // CF 全局拦截截止时间 let _steamuiWriteTimer = null; let _steamuiDirty = false; function _steamuiLoadPersisted() { if (_steamuiPersisted !== null) return _steamuiPersisted; _steamuiPersisted = {}; try { const raw = GM_getValue(STEAMUI_PERSIST_KEY); if (raw && typeof raw === 'object') _steamuiPersisted = raw; } catch (e) { /* ignore */ } // 预热内存 + 顺带按 TTL/容量清理过期条目 const now = Date.now(); const keys = Object.keys(_steamuiPersisted); let dirty = false; for (const k of keys) { const ent = _steamuiPersisted[k]; if (ent && ent.data && (now - (ent.ts || 0)) < STEAMUI_TTL) { _steamuiMem.set(k, ent); } else { delete _steamuiPersisted[k]; dirty = true; } } if (dirty) { try { GM_setValue(STEAMUI_PERSIST_KEY, _steamuiPersisted); } catch (e) { /* ignore */ } } return _steamuiPersisted; } function _steamuiPersist(appid, data) { const container = _steamuiLoadPersisted(); container[String(appid)] = { data, ts: Date.now() }; const keys = Object.keys(container); if (keys.length > STEAMUI_MAX) { keys.sort((a, b) => (container[a].ts || 0) - (container[b].ts || 0)); while (keys.length > STEAMUI_MAX) delete container[keys.shift()]; } _steamuiDirty = true; if (_steamuiWriteTimer) return; _steamuiWriteTimer = setTimeout(() => { _steamuiWriteTimer = null; if (!_steamuiDirty) return; _steamuiDirty = false; try { GM_setValue(STEAMUI_PERSIST_KEY, container); } catch (e) { /* ignore */ } }, 200); } function flushSteamuiPersist() { if (_steamuiWriteTimer) { clearTimeout(_steamuiWriteTimer); _steamuiWriteTimer = null; } if (_steamuiDirty && _steamuiPersisted) { _steamuiDirty = false; try { GM_setValue(STEAMUI_PERSIST_KEY, _steamuiPersisted); } catch (e) { /* ignore */ } } } // --- 容错 VDF/JSON 解析器(纯函数) --- // steamui 响应为 VDF 文本(带引号键值 + {} 块 + [] 对象数组);先试 JSON,失败走 VDF tokenizer function parseSteamInfo(text) { if (!text || typeof text !== 'string') return null; const trimmed = text.replace(/^\uFEFF/, '').trim(); if (!trimmed) return null; // CF 拦截页特征 → 直接判定不可用 if (trimmed.charAt(0) === '<' && (trimmed.indexOf('Just a moment') >= 0 || trimmed.indexOf('_cf_chl_opt') >= 0 || trimmed.indexOf('challenges.cloudflare.com') >= 0)) { const err = new Error('steamui: cloudflare challenge'); err.cfBlocked = true; throw err; } if (trimmed.charAt(0) === '{' || trimmed.charAt(0) === '[') { try { return JSON.parse(trimmed); } catch (e) { /* fallthrough to VDF */ } } // 无任何块符号的纯文本不可能是 VDF(真实响应必含 {}),避免裸 token 被误判成键值对 if (trimmed.indexOf('{') < 0 && trimmed.indexOf('[') < 0) return null; try { return _parseVdfText(trimmed); } catch (e) { return null; } } // VDF tokenizer: 产出字符串/符号数组 function _vdfTokens(text) { const tokens = []; let i = 0, n = text.length; while (i < n) { const c = text.charAt(i); // 空白 if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i++; continue; } // 行注释 if (c === '/' && text.charAt(i + 1) === '/') { while (i < n && text.charAt(i) !== '\n') i++; continue; } // 块注释 if (c === '/' && text.charAt(i + 1) === '*') { i += 2; while (i < n && !(text.charAt(i) === '*' && text.charAt(i + 1) === '/')) i++; i += 2; continue; } // 符号 if (c === '{' || c === '}' || c === '[' || c === ']') { tokens.push(c); i++; continue; } // 带引号字符串(支持 \\ \" 转义) if (c === '"') { i++; let s = ''; while (i < n) { const ch = text.charAt(i); if (ch === '\\' && i + 1 < n) { s += text.charAt(i + 1); i += 2; continue; } if (ch === '"') { i++; break; } s += ch; i++; } tokens.push(s); continue; } // 裸 token(直到空白/符号/引号) let w = ''; while (i < n) { const ch = text.charAt(i); if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n' || ch === '{' || ch === '}' || ch === '[' || ch === ']' || ch === '"') break; w += ch; i++; } if (w) tokens.push(w); else i++; // 防御:非法字符跳过 } return tokens; } // VDF 递归下降解析: {"key" {"k" "v"} "key2" [{"a" "b"}]} function _parseVdfText(text) { const tokens = _vdfTokens(text); let pos = 0; function parseBlock(depth) { const obj = {}; while (pos < tokens.length) { const t = tokens[pos]; if (t === '}') { pos++; return obj; } if (t === '{' || t === '[' || t === ']') { pos++; continue; } // 容错:跳过意外符号 // t 为 key pos++; const next = tokens[pos]; if (next === undefined) { obj[t] = ''; break; } if (next === '{') { pos++; obj[t] = parseBlock(depth + 1); } else if (next === '[') { pos++; obj[t] = parseArray(depth + 1); } else { pos++; obj[t] = next; } } return obj; } function parseArray(depth) { const arr = []; while (pos < tokens.length) { const t = tokens[pos]; if (t === ']') { pos++; return arr; } if (t === '{') { pos++; arr.push(parseBlock(depth + 1)); } else if (t === '}' || t === '[') { pos++; } // 容错 else { pos++; arr.push(t); } // 标量数组元素 } return arr; } const result = parseBlock(0); return result; } // --- appinfo 原始对象 → 规范化资产结构(纯函数) --- // 输入形如 { appinfo: { common: {...}, associations: [...] }, appid: "..." } // 或直接 { common: {...}, associations: [...] } function normalizeSteamInfo(raw, appId) { if (!raw || typeof raw !== 'object') return null; const info = raw.appinfo && typeof raw.appinfo === 'object' ? raw.appinfo : raw; const common = info.common && typeof info.common === 'object' ? info.common : null; if (!common) return null; const full = (common.library_assets_full && typeof common.library_assets_full === 'object') ? common.library_assets_full : {}; // 语言可用性列表(简版 library_assets 的逗号分隔语言串) const la = (common.library_assets && typeof common.library_assets === 'object') ? common.library_assets : {}; // hash 路径值校验: "hash/filename" 形式,防脏数据 const hp = (v) => (typeof v === 'string' && /^[0-9a-f]{8,}\/.+\.(jpe?g|png)$/i.test(v)) ? v : null; // 变体组: library_assets_full.X.image/image2x → { lang: {image, image2x} } function variants(block) { if (!block || typeof block !== 'object') return null; const out = {}; let any = false; for (const lang of ['image', 'image2x']) { const m = block[lang]; if (!m || typeof m !== 'object') continue; for (const l in m) { const p = hp(m[l]); if (!p) continue; if (!out[l]) out[l] = {}; out[l][lang === 'image2x' ? 'image2x' : 'image'] = p; any = true; } } return any ? out : null; } // 语言→hash 路径映射(small_capsule / header_image 区块) function langMap(block) { if (!block || typeof block !== 'object') return null; const out = {}; let any = false; for (const l in block) { const p = hp(block[l]); if (p) { out[l] = p; any = true; } } return any ? out : null; } const assoc = Array.isArray(info.associations) || Array.isArray(raw.associations) ? (Array.isArray(info.associations) ? info.associations : raw.associations) : []; const associations = assoc .filter(a => a && typeof a === 'object') .map(a => ({ type: String(a.type || ''), name: String(a.name || '') })) .filter(a => a.name); const languages = common.languages && typeof common.languages === 'object' ? Object.keys(common.languages) : []; return { appid: Number(appId) || Number(raw.appid) || 0, name: typeof common.name === 'string' ? common.name : '', // v1.2.0: 本地化名称表(common.name_localized → {schinese: '中文名', ...}),供上游中文名补全 nameLocalized: (common.name_localized && typeof common.name_localized === 'object') ? common.name_localized : null, type: typeof common.type === 'string' ? common.type : '', oslist: typeof common.oslist === 'string' ? common.oslist.split(',').map(s => s.trim()).filter(Boolean) : [], releasestate: typeof common.releasestate === 'string' ? common.releasestate : '', assetMtime: String(common.store_asset_mtime || ''), associations, languages, icon: typeof common.icon === 'string' ? common.icon : '', clienticon: typeof common.clienticon === 'string' ? common.clienticon : '', logoPosition: la.logo_position && typeof la.logo_position === 'object' ? la.logo_position : null, assets: { poster: variants(full.library_capsule), hero: variants(full.library_hero), heroBlur: variants(full.library_hero_blur), logo: variants(full.library_logo), libraryHeader: variants(full.library_header), smallCapsule: langMap(common.small_capsule), storeHeader: langMap(common.header_image), }, source: 'steamui', fetchedAt: Date.now(), }; } // kind → steamui 资产组 function _steamuiAssetOf(info, kind) { const a = info && info.assets; if (!a) return null; if (kind === 'poster') return a.poster; if (kind === 'hero') return a.hero; if (kind === 'logo') return a.logo; if (kind === 'library_header') return a.libraryHeader; if (kind === 'capsule') return a.smallCapsule; return a.storeHeader; // header } // 语言优先级: 请求语言 → english → 第一个可用 function _pickLang(variants, lang) { if (!variants) return null; if (lang && variants[lang]) return lang; if (variants.english) return 'english'; const first = Object.keys(variants)[0]; return first || null; } // steamui 规范化数据 → 精确 hash URL(kind + lang 感知) // 返回 '' 表示该 kind 无资产 function buildSteamuiUrl(info, kind, lang) { if (!info) return ''; const group = _steamuiAssetOf(info, kind); if (!group) return ''; const id = String(info.appid || ''); if (!id) return ''; // 形式判定:任一值为 string → langMap(smallCapsule/storeHeader): {lang: 'hash/file'} // 否则 → variants(library_assets_full): {lang: {image, image2x}} const isLangMap = Object.keys(group).some(k => typeof group[k] === 'string'); const l = _pickLang(group, lang); if (!l) return ''; if (isLangMap) { return STEAMUI_ASSET_CDNS[0] + id + '/' + group[l]; } const v = group[l] || {}; const path = v.image2x || v.image; if (!path) return ''; return STEAMUI_ASSET_CDNS[0] + id + '/' + path; } // 安全读 responseText(防扩展 hook XHR,同 v1.1.1) function _safeText(r) { let txt = ''; try { txt = r.responseText; } catch (e) { txt = ''; } if (!txt && r.response instanceof ArrayBuffer) { try { txt = new TextDecoder().decode(new Uint8Array(r.response)); } catch (e) { /* ignore */ } } return txt || ''; } // 拉取 steamui appinfo(带三级负缓存 + 并发共享) // opts.force: 忽略负缓存强制重试;opts.timeout: 默认 8000 function getSteamuiAssets(appId, opts) { const id = String(appId); opts = opts || {}; const now = Date.now(); if (!id || isNaN(Number(id))) return Promise.resolve(null); // 内存命中 const mem = _steamuiMem.get(id); if (mem && (now - mem.ts) < STEAMUI_TTL) return Promise.resolve(mem.data); // 持久化命中 _steamuiLoadPersisted(); const pEnt = _steamuiPersisted[id]; if (pEnt && pEnt.data && (now - (pEnt.ts || 0)) < STEAMUI_TTL) { _steamuiMem.set(id, pEnt); return Promise.resolve(pEnt.data); } // CF 全局拦截期 if (!opts.force && now < _steamuiCfBlockedUntil) return Promise.resolve(null); // appid 404 负缓存 if (!opts.force) { const neg = _steamuiAppNeg.get(id); if (neg && neg > now) return Promise.resolve(null); if (neg) _steamuiAppNeg.delete(id); } // 并发共享 if (_steamuiPending.has(id)) return _steamuiPending.get(id); const p = new Promise((resolve) => { const gmxhr = root.__sglvCoverMockGmXhr || (typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : null); const finish = (data) => { _steamuiPending.delete(id); resolve(data); }; if (!gmxhr && typeof fetch === 'undefined') { finish(null); return; } const handleText = (status, text) => { if (status === 404) { _steamuiAppNeg.set(id, Date.now() + STEAMUI_NEG_TTL); finish(null); return; } if (status < 200 || status >= 300) { finish(null); return; } let raw = null; try { raw = parseSteamInfo(text); } catch (e) { if (e && e.cfBlocked) { _steamuiCfBlockedUntil = Date.now() + STEAMUI_CF_TTL; finish(null); return; } finish(null); return; } const info = raw ? normalizeSteamInfo(raw, id) : null; if (info && (info.assets.poster || info.assets.hero || info.assets.logo || info.assets.smallCapsule || info.assets.storeHeader)) { _steamuiMem.set(id, { data: info, ts: Date.now() }); _steamuiPersist(id, info); finish(info); } else { // 解析成功但无可用资产(纯 DLC/工具类)→ 按 appid 负缓存,避免反复拉 _steamuiAppNeg.set(id, Date.now() + STEAMUI_NEG_TTL); finish(null); } }; if (gmxhr) { gmxhr({ method: 'GET', url: STEAMUI_BASE + id + '/info', headers: { 'Accept': 'application/json, text/plain, */*' }, timeout: opts.timeout || 8000, anonymous: false, onload: (r) => handleText(r.status, _safeText(r)), onerror: () => finish(null), ontimeout: () => finish(null), }); } else { fetch(STEAMUI_BASE + id + '/info') .then(r => r.text().then(t => handleText(r.status, t))) .catch(() => finish(null)); } }); _steamuiPending.set(id, p); return p; } // 同步版:内存/持久化缓存命中的 steamui 精确 URL(不发起网络请求) function getSteamuiGoodUrl(appId, kind, lang) { const id = String(appId); if (!id) return ''; const mem = _steamuiMem.get(id); if (!mem) { _steamuiLoadPersisted(); const ent = _steamuiPersisted[id]; if (!ent || !ent.data) return ''; return buildSteamuiUrl(ent.data, kind, lang); } return buildSteamuiUrl(mem.data, kind, lang); } // ==================== 公共 API ==================== // v1.2.0: kind + lang 感知(good 缓存 → steamui 精确 URL → 语言变体链首) function getCoverUrl(appId, kind, lang) { if (!appId) return SVG_PLACEHOLDER; const k = kind || 'capsule'; loadPersisted(); // 1. good 缓存(带语言键优先,无语言键兜底) const goodLang = _goodMap.get(goodKey(k, appId, lang)); if (goodLang) return goodLang; if (lang) { const goodPlain = _goodMap.get(goodKey(k, appId)); if (goodPlain) return goodPlain; } // 2. steamui 精确 hash URL(缓存命中,免探测) const steamuiUrl = getSteamuiGoodUrl(appId, k, lang); if (steamuiUrl) return steamuiUrl; // 3. 语言变体链首 return buildFullChain(appId, k, lang)[0]; } // v1.2.0: Promise 版封面解析 — steamui 拉取(网络)优先,失败/无资产给探测链首 function resolveCover(appId, kind, lang) { if (!appId) return Promise.resolve(SVG_PLACEHOLDER); const k = kind || 'capsule'; const cached = getCoverUrl(appId, k, lang); // getCoverUrl 已含 steamui 缓存;仅当未命中 good 且未命中 steamui 缓存时才发起网络拉取 loadPersisted(); const hasGood = _goodMap.has(goodKey(k, appId, lang)) || (lang && _goodMap.has(goodKey(k, appId))); const hasSteamuiCache = !!getSteamuiGoodUrl(appId, k, lang); if (hasGood || hasSteamuiCache) return Promise.resolve(cached); return getSteamuiAssets(appId).then((info) => { const url = buildSteamuiUrl(info, k, lang); return url || buildFullChain(appId, k, lang)[0]; }).catch(() => cached); } // kind 感知的 API 兜底(steamui 之后) // capsule 优先 capsule_image,poster/hero/logo/library_header/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); const finish = (url) => resolve(url || ''); 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) { if (k === 'capsule') { finish(d.data.capsule_image || d.data.header_image || ''); } else { finish(d.data.header_image || d.data.capsule_image || ''); } } else { finish(''); } } catch (e) { finish(''); } }, onerror: () => finish(''), ontimeout: () => finish(''), }); } else if (typeof fetch !== 'undefined') { fetch(apiUrl).then(r => r.json()).then(j => { const d = j[appId]; if (d && d.success && d.data) { if (k === 'capsule') { finish(d.data.capsule_image || d.data.header_image || ''); } else { finish(d.data.header_image || d.data.capsule_image || ''); } } else { finish(''); } }).catch(() => finish('')); } else { finish(''); } }); } // 推进降级链:链中下一种 URL → steamui hash → API → SVG // v1.2.0: 从 img.dataset.kind/lang 提取图片类型与语言 function loadImageWithFallback(img, onFailed) { if (!img || !img.dataset) return; const appId = img.dataset.appid; if (!appId) return; const kind = img.dataset.kind || 'capsule'; const lang = img.dataset.lang || ''; const cacheKey = goodKey(kind, appId, lang); loadPersisted(); const good = _goodMap.get(cacheKey) || _goodMap.get(goodKey(kind, appId)); if (good && img.src !== good) { img.src = good; return; } const fullChain = buildFullChain(appId, kind, lang); let idx = parseInt(img.dataset.fbIdx || '0', 10); const fallbackToPlaceholder = () => { img.onerror = null; img.src = SVG_PLACEHOLDER; img.classList.add('sglv-cover-failed'); if (typeof onFailed === 'function') { try { onFailed(img); } catch (e) { /* ignore */ } } }; const tryApiFallback = () => { fetchFromAPI(appId, kind).then(url => { if (url) { _goodMap.set(cacheKey, url); setPersisted(cacheKey, url); img.onerror = () => fallbackToPlaceholder(); img.src = url; } else { fallbackToPlaceholder(); } }); }; // CDN 链全失败 → steamui 精确 hash(网络拉取,一次) → API → SVG const trySteamuiFallback = () => { getSteamuiAssets(appId).then(info => { const url = buildSteamuiUrl(info, kind, lang); if (url) { _goodMap.set(cacheKey, url); setPersisted(cacheKey, url); // steamui URL 也可能挂 → API 兜底 img.onerror = () => tryApiFallback(); img.src = url; } else { tryApiFallback(); } }).catch(() => tryApiFallback()); }; const tryNext = () => { while (idx < fullChain.length && img.src === fullChain[idx]) idx++; if (idx < fullChain.length) { img.dataset.fbIdx = String(idx + 1); img.src = fullChain[idx++]; } else { trySteamuiFallback(); } }; tryNext(); } // 绑定全局 error/load 委托(全局只调一次) // v1.2.0: load 缓存键纳入 data-lang 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' && t.dataset && t.dataset.sglvCover && t.dataset.appid) { loadImageWithFallback(t, onFailed); } }, true); rootEl.addEventListener('load', (e) => { const t = e.target; if (t && t.tagName === 'IMG' && t.dataset && t.dataset.sglvCover && t.dataset.appid) { if (t.src && t.src.indexOf('data:image') !== 0) { const key = goodKey(t.dataset.kind || 'capsule', String(t.dataset.appid), t.dataset.lang || ''); _goodMap.set(key, t.src); setPersisted(key, t.src); } } }, true); } // ==================== 暴露 API ==================== const api = { version: '1.2.0', apiVersion: 2, SVG_PLACEHOLDER, CHAIN, KINDS, LANG_VARIANTS, CDN_SHARED, CDN_FASTLY, CDN_AKAMAI, CDN_CF, getCoverUrl, resolveCover, bindErrorHandler, loadImageWithFallback, clearPersistedCache: clearPersisted, flushPersisted, // v1.2.0: steamui appinfo 渠道 getSteamuiAssets, getSteamuiGoodUrl, buildSteamuiUrl, buildFullChain, // (appId, kind?, lang?) → 完整多CDN回退链(含语言变体前缀),供宿主/兄弟库复用 parseSteamInfo, normalizeSteamInfo, // 测试辅助:重置 steamui 负缓存状态 _resetSteamuiState() { _steamuiCfBlockedUntil = 0; _steamuiAppNeg.clear(); _steamuiPending.clear(); }, }; root.SGLVCoverFallback = api; if (typeof unsafeWindow !== 'undefined' && unsafeWindow !== root) { try { unsafeWindow.SGLVCoverFallback = api; } catch (e) { /* ignore */ } } return api; })(typeof window !== 'undefined' ? window : globalThis); /* ---------- sglv-game-collection.lib.js ---------- */ // ==UserScript== // @name SGLV 游戏收藏模块 (Library) // @name:en SGLV Game Collection (Library) // @namespace https://greasyfork.org/scripts/588484 // @version 2.0.0 // @description Steam 游戏库查看器 - 游戏收藏模块库(供应商分类 / 绝版收藏 / PS会免 / Epic赠送 / 锁区游戏 / 年度大作 / DLC收藏 / 捆绑包 / Humble Choice 月包分析)。更新日志见脚本目录 README。 // @description:en Steam Game Library Viewer - Game Collection module library (Publisher / Delisted / PS Plus / Epic Free / Region Blocked / GotY / DLC Collection / Bundles / Humble Choice). See README for changelog. // @license MIT // @noframes // ==/UserScript== /* * SGLV Game Collection Library v2.0.0 * * ── 版本沿革 ────────────────────────────────────────────── * 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 一致) * ② 接入 8 个封面函数:blockedPosterImg / blockedCapsuleImg / seriesCoverImg / * seriesCapsuleImg / psplusPosterImg / psplusCapsuleImg / epicPosterImg / * epicCapsuleImg——good 缓存查询叠加 steamui 精确 URL(getSteamuiGoodUrl 同步缓存命中), * 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 () { '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; } // v1.9.42: 4 CDN 前缀(顺序与 sglv-cover-fallback.lib.js 一致:shared.akamai → shared.fastly → cdn.akamai → cloudflare) const _COVER_CDNS = [ 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/', 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/', 'https://cdn.akamai.steamstatic.com/steam/apps/', 'https://cdn.cloudflare.steamstatic.com/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; 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 */ } 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.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: 96px !important; height: 74px !important; opacity: 0.9 !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; } /* 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; } /* === 加载中骨架: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; } /* === 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: 捆绑包标签页专用样式 === */ .sglv-bundle-card { cursor: pointer; } .sglv-bundle-dlc-list { margin-top: 6px; display: flex; flex-direction: column; gap: 3px; } .sglv-bundle-dlc-item { display: flex; align-items: center; gap: 6px; padding: 3px 6px; border-radius: 4px; background: rgba(255,255,255,0.04); transition: background 0.15s; cursor: pointer; } .sglv-bundle-dlc-item:hover { background: rgba(102,192,244,0.12); } .sglv-bundle-dlc-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: #94a3b8; } .sglv-bundle-dlc-more { font-size: 10px; color: #64748b; padding: 2px 6px; font-style: italic; } /* === 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-delisted-type-btn:focus-visible, .sgc-panel .sglv-delisted-sub-btn: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; } } `; // 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 锁兜底 // 参照 psplusPosterImg 逻辑:先查 good 缓存 → CDN 回退链 → API → 锁占位 // v1.9.29: ① 传递 kind 给 _sglvBlockedCoverFallback ② onerror 链跳过 chain[0](与初始 src 相同) // v1.9.41: good/链走 coverGoodUrl/coverChainLang(steamui 精确 URL + 语言变体链,见模块头注释) function blockedPosterImg(appid, name) { const good = coverGoodUrl(appid, 'poster'); if (good) { return '' + Host.wlEscape(name) + ''; } // v1.9.44: 竖版确认缺失 → 横版替代直用(交叉缓存) const horizHtml = _sgcvHorizontalFallbackImgHtml(appid, Host.wlEscape(name), 'sglv-card-img', '_sglvBlockedCoverFallback'); if (horizHtml) return horizHtml; const chain = coverChainLang(appid, 'poster'); let handler = '_sglvBlockedCoverFallback(this, \'' + appid + '\', \'poster\');'; for (let i = chain.length - 1; i >= 1; i--) { handler = 'this.src=\'' + chain[i] + '\';this.onerror=function(){' + handler + '}'; } handler = '_sgcvCoverRace(this, \'' + appid + '\', \'poster\');' + handler; return '' + Host.wlEscape(name) + ''; } // v1.9.24: 锁区游戏横版封面(capsule 大横幅)——同上逻辑,kind=capsule // v1.9.29: ① 传递 kind 给 _sglvBlockedCoverFallback ② onerror 链跳过 chain[0] // v1.9.41: good/链走 coverGoodUrl/coverChainLang function blockedCapsuleImg(appid, name) { const good = coverGoodUrl(appid, 'capsule'); if (good) { return '' + Host.wlEscape(name) + ''; } const chain = coverChainLang(appid, 'capsule'); let handler = '_sglvBlockedCoverFallback(this, \'' + appid + '\', \'capsule\');'; for (let i = chain.length - 1; i >= 1; i--) { handler = 'this.src=\'' + chain[i] + '\';this.onerror=function(){' + handler + '}'; } handler = '_sgcvCoverRace(this, \'' + appid + '\', \'capsule\');' + handler; return '' + Host.wlEscape(name) + ''; } // 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: 页码 } 记忆每个系列的浏览页码,切换回已浏览分类时恢复进度 const pubDetailState = { page: 1, pageSize: 48, 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: 48, 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: 48, viewMode: 'card', updatedAt: '', loadError: null, source: '' }; // v2.9.7: 年度大作模块状态(数据含 name/date/appid,名称无需异步加载) // v1.7.0: 加 year 字段——按年拉取数据 (GitHub 2024/2025/2026.json), 默认 null = 自动选最新有数据的年份 const gotyState = { games: [], loaded: false, loading: false, year: null, availableYears: [], search: '', ownFilter: 'all', sort: 'dateDesc', page: 1, pageSize: 48, 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, 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…' }, ]; // ==================== 供应商分类标签页 ==================== // 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; refreshBtn.addEventListener('click', () => loadAllCollectionData(true)); wrap.appendChild(refreshBtn); // v1.9.40: 不再常驻状态文本(更新时间曾压缩切换按钮致变形)——刷新结果由悬浮提示显示 } return wrap; } /** * v1.9.37: 刷新供应商/系列标签页内容(侧边栏+详情区域),不闪骨架屏 * 供 loadAllCollectionData 完成回调和 sglv:series/supplier-data-loaded 事件复用 */ function refreshPublisherTabContent() { if (_collectionActiveTab !== 'publisher' || !collectionPanelEl || !collectionPanelEl.classList.contains('sglv-show')) return; 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(); } } } /** * v2.9.91: 同时加载供应商和系列数据(并行) * v1.9.37: 移除 refreshStage 加载进度文本,仅保留刷新按钮 spinner 动画 * v2.9.92: 收集各自结果状态,刷新完成后显示汇总(已更新/已是最新/失败) * v1.9.40: 结果汇总改为悬浮提示,不参与工具栏 flex 布局 * @param {boolean} force - 强制刷新 */ function loadAllCollectionData(force) { if (pubState.dataLoading) return; pubState.dataLoading = true; pubState.supplierResult = ''; pubState.seriesResult = ''; const refreshBtn = document.getElementById('sglv-pub-refresh'); if (refreshBtn) refreshBtn.classList.add('sglv-spin'); const tasks = []; // 供应商数据(v1.9.37: 不再传 onProgress 回调,避免加载文本导致界面变形) if (typeof Host.loadSupplierData === 'function') { tasks.push(Host.loadSupplierData(force).then(result => { pubState.supplierResult = (result && result.status) || 'error'; }).catch(() => { pubState.supplierResult = 'error'; })); } // 系列数据 if (typeof Host.loadSeriesData === 'function') { tasks.push(Host.loadSeriesData(force).then(result => { pubState.seriesResult = (result && result.status) || 'error'; }).catch(() => { pubState.seriesResult = 'error'; })); } Promise.all(tasks).finally(() => { pubState.dataLoading = false; if (refreshBtn) refreshBtn.classList.remove('sglv-spin'); // v2.9.92: 显示刷新结果摘要(3秒后消失) pubState.showResultUntil = Date.now() + 3000; renderPubRefreshToast(); setTimeout(() => { if (pubState.showResultUntil && Date.now() >= pubState.showResultUntil) { pubState.showResultUntil = 0; renderPubRefreshToast(); } }, 3100); // v1.9.37: 使用统一的刷新函数,不再内联渲染逻辑 refreshPublisherTabContent(); }); } /** v1.9.40: 刷新结果悬浮提示(3秒后自动消失) * 取代 v2.9.92 的常驻状态文本——更新时间文本曾压缩供应商/系列切换按钮导致变形; * 提示为绝对定位悬浮元素,不参与工具栏 flex 布局,任何内容都不会影响按钮宽度 */ function renderPubRefreshToast() { const wrap = document.querySelector('.sgc-panel .sglv-pub-mode-switch'); if (!wrap) return; let el = wrap.querySelector('.sglv-pub-refresh-toast'); // 刷新结果摘要(刷新完成后3秒内显示) if (pubState.showResultUntil && Date.now() < pubState.showResultUntil) { const parts = []; const zh = Host.isZh; // 供应商结果 if (pubState.supplierResult === 'updated') { parts.push(`${zh ? '供应商✓' : 'Pub✓'}`); } else if (pubState.supplierResult === 'nochange') { parts.push(`${zh ? '供应商=' : 'Pub='}`); } else if (pubState.supplierResult === 'error') { parts.push(`${zh ? '供应商✗' : 'Pub✗'}`); } // 系列结果 if (pubState.seriesResult === 'updated') { parts.push(`${zh ? '系列✓' : 'Ser✓'}`); } else if (pubState.seriesResult === 'nochange') { parts.push(`${zh ? '系列=' : 'Ser='}`); } else if (pubState.seriesResult === 'error') { parts.push(`${zh ? '系列✗' : 'Ser✗'}`); } if (!parts.length) return; if (!el) { el = document.createElement('span'); el.className = 'sglv-pub-refresh-toast'; wrap.appendChild(el); } el.innerHTML = parts.join(' '); return; } pubState.showResultUntil = 0; if (el) el.remove(); } /** 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 = ` ${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 = ` ${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); } } 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 += `
${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); }); }); } 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 = `
${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 += `
${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 = `
${pubName} › ${seriesName}
`; renderSeriesDetailCommon(breadcrumbHtml, games, () => renderPubSeriesDetail(pubName, seriesName), 'pub:' + pubName + '\u0000' + seriesName); } // v1.8.2: 系列模式详情——直接展示系列游戏列表(无供应商面包屑) function renderSeriesModeDetail(seriesName) { const games = getSeriesDb()[seriesName] || []; const breadcrumbHtml = `
${Host.isZh ? '系列' : 'Series'} ${seriesName}
`; renderSeriesDetailCommon(breadcrumbHtml, games, () => renderSeriesModeDetail(seriesName), 'series:' + seriesName); } // v1.8.2: 系列数据未加载时的右侧空态 // v1.9.37: 移除"系列数据加载中,请稍候…"提示,清空主区域即可(避免界面变形) 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 */ function seriesCoverImg(g, kind, cls) { var appid = g.id; var name = Host.wlEscape(g.name || ''); // v1.9.43: onload 属性——竖版 kind 追加宽图检测(横图 letterbox,避免 2:3 容器裁切变形) var onloadAttr = 'this.classList.add(\'sglv-img-loaded\');' + (kind === 'poster' ? 'if(this.naturalWidth>this.naturalHeight)this.classList.add(\'sglv-img-wide\');' : '') + '_sglvRecordPosterGood(\'' + appid + '\',this.src,\'' + kind + '\');'; // v1.9.43: poster 字段方向门控——数据源 poster 实为横版 header URL(预验证可用), // 竖版 kind 仅接受竖版资产(library_600x900 族),横版 kind 仅接受横版 URL var posterOk = false; if (g.poster) posterOk = (kind === 'poster') ? _sgcvIsVerticalUrl(g.poster) : !_sgcvIsVerticalUrl(g.poster); // v1.9.43: 首个 onerror 即竞速 API 兜底(CDN 链继续走,API 先返回则劫持 src) var race = '_sgcvCoverRace(this,\'' + appid + '\',\'' + kind + '\');'; // 1. 优先使用 good 缓存(跨会话已验证 URL,最快)v1.9.41: 叠加 steamui 精确 URL // v1.9.44: coverGoodUrl 已方向严格——poster kind 仅返回竖版条目 var good = coverGoodUrl(appid, kind); if (good) { return '' + name +
                    ''; } // v1.9.44: 竖版确认缺失(poster 缓存横版标记)→ 横版替代直用(交叉缓存),免整链重爬 if (kind === 'poster' && coverVerticalMissing(appid)) { var horizHtml = _sgcvHorizontalFallbackImgHtml(appid, name, cls, '_sglvPosterFallback'); if (horizHtml) return horizHtml; } // 2. 有可用 poster 字段(方向门控后):用作初始 src(预验证URL,避免CDN试错), // onerror 走完整 CDN 链(含 chain[0],初始 src 为 g.poster)+ 竞速 API 兜底 if (posterOk) { var chain = coverChainLang(appid, kind); var handler = '_sglvPosterFallback(this,\'' + appid + '\',\'' + kind + '\');'; for (var i = chain.length - 1; i >= 0; i--) { handler = 'this.src=\'' + chain[i] + '\';this.onerror=function(){' + handler + '};'; } return '' + name +
                    ''; } // 3. 无可用 poster(含横版 poster 被 poster kind 门控拒绝):链首起步 + 竞速。 // v1.9.43: 竖版 kind 不再回退 Host.posterImg,统一走本库语言变体链(2x 置顶) var hChain = coverChainLang(appid, kind); var hHandler = "_sglvPosterFallback(this,'" + appid + "','" + kind + "');"; for (var j = hChain.length - 1; j >= 1; j--) { hHandler = "this.src='" + hChain[j] + "';this.onerror=function(){" + hHandler + '};'; } return '' + name + ''; } // v1.9.34: 系列横版封面图(capsule kind)——用于封面视图 // v1.9.41: good/链走 coverGoodUrl/coverChainLang(steamui 精确 URL + 语言变体链) // v1.9.43: ① 横版 poster 字段复用——数据源 poster 实为预验证横版 header URL, // hero 链首未命中(新游戏无 hero 资产)后第 2 跳即命中,替代 ~36 次全 404 链爬取 // (460x215 与封面视图容器 aspect-ratio 完全一致);竖版 poster 仍拒绝 // ② 首个 onerror 即竞速 API 兜底 function seriesCapsuleImg(g) { var appid = g.id; var name = Host.wlEscape(g.name || ''); var cls = 'sglv-wl-cover-img'; // 1. 优先使用 good 缓存 var good = coverGoodUrl(appid, 'capsule'); if (good) { return '' + name +
                    ''; } // 2. hero 链首起步;横版 poster 插入为第 2 跳;其余 CDN 链跟随 + API 兜底 var chain = coverChainLang(appid, 'capsule'); var seq = chain.slice(1); if (g.poster && !_sgcvIsVerticalUrl(g.poster) && seq.indexOf(g.poster) < 0) { seq = [g.poster].concat(seq); } var handler = '_sglvPosterFallback(this,\'' + appid + '\',\'capsule\');'; for (var i = seq.length - 1; i >= 0; i--) { handler = 'this.src=\'' + seq[i] + '\';this.onerror=function(){' + handler + '};'; } return '' + name + ''; } /** * 系列/供应商游戏列表公共渲染(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')}
${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.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); } 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(); if (delistedState.viewMode === 'card') { content.innerHTML = `
${pageApps.map(app => { const appid = appidFix(app.appid); const isOwned = ownedIds.has(Number(appid)) || ownedIds.has(String(appid)); const typeKey = getTypeKey(app.type); const badgeClass = isOwned ? 'owned' : 'missing'; const badgeText = isOwned ? ('✓ ' + Host.T.owned) : Host.T.missing; 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) : ''; return `
${Host.posterImg(appid, app.name)} ${playtimeH ? `${playtimeH}` : ''}
${app.name}${appid}
${getTypeLabel(app.type)} ${badgeText}
${app.owners || '-'} ${app.changed ? `${app.changed}` : ''} ${lastPlayed ? `${lastPlayed}` : ''} ${app.achievements ? `🏆 ${app.achievements}` : ''}
`; }).join('')}
`; } else { content.innerHTML = `
${pageApps.map(app => { const appid = appidFix(app.appid); const isOwned = ownedIds.has(Number(appid)) || ownedIds.has(String(appid)); const typeKey = getTypeKey(app.type); const badgeClass = isOwned ? 'owned' : 'missing'; const badgeText = isOwned ? ('✓ ' + Host.T.owned) : Host.T.missing; const iconUrl = Host.getGameIconUrl(appid, ''); 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) : ''; return `
${app.name}
${app.name}${appid}
${getTypeLabel(app.type)} ${app.owners || '-'} ${app.changed ? `${app.changed}` : ''} ${app.achievements ? `🏆 ${app.achievements}` : ''} ${lastPlayed ? `${lastPlayed}` : ''}
${playtimeH ? `${playtimeH}` : ''} ${badgeText}
`; }).join('')}
`; } // v2.3.33:异步加载游戏中文名 content.querySelectorAll('[data-sglv-appid]').forEach(el => { Host.loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); // 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); }); } } 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; const delistedPct = totalDelisted > 0 ? Math.round(ownedDelisted / totalDelisted * 100) : 0; // KPI cards (v2.3.22: 增强样式——图标+数值+标签+子文本+彩色光晕) const kpiSection = document.createElement('div'); kpiSection.className = 'sglv-trend-kpi-row'; kpiSection.innerHTML = `
${Host.ICONS.shield}${Host.T.delistedTotal}
${totalDelisted.toLocaleString()}
${Host.T.kpiDelisted}
${Host.ICONS.heart}${Host.T.delistedOwned}
${ownedDelisted.toLocaleString()}
${Host.T.kpiCollected}
${Host.ICONS.shield}${Host.T.delistedMissing}
${missingDelisted.toLocaleString()}
${Host.T.kpiToCollect}
${Host.ICONS.barChart}${Host.T.delistedOwnership}
${delistedPct}%
${Host.T.kpiCollectionProgress}
`; wrap.appendChild(kpiSection); // v2.3.22: 紧凑分类汇总(分页操作栏)—— 可点击徽章 + 分页 const typeCounts = {}; apps.forEach(app => { const key = getTypeKey(app.type); typeCounts[key] = (typeCounts[key] || 0) + 1; }); const typeOwned = {}; apps.forEach(app => { if (ownedIds.has(Number(app.appid)) || ownedIds.has(String(app.appid))) { const key = getTypeKey(app.type); typeOwned[key] = (typeOwned[key] || 0) + 1; } }); const typeColors = { delisted: '#f44336', purchase_disabled: '#ff9800', f2p_unavailable: '#ce93d8', retail_only: '#64b5f6', test_app: '#9e9e9e' }; const summaryBar = document.createElement('div'); summaryBar.className = 'sglv-delisted-summary-bar'; const badgesDiv = document.createElement('div'); badgesDiv.className = 'sglv-delisted-summary-badges'; const typeEntries = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]); typeEntries.forEach(([key, count]) => { const o = typeOwned[key] || 0; const color = typeColors[key] || '#8f98a0'; const label = key === 'delisted' ? Host.T.typeDelisted : key === 'purchase_disabled' ? Host.T.typePurchaseDisabled : key === 'f2p_unavailable' ? Host.T.typeF2P : key === 'retail_only' ? Host.T.typeRetailOnly : Host.T.typeTestApp; const badge = document.createElement('span'); badge.className = 'sglv-delisted-summary-badge'; badge.style.cssText = `background:${color}22;color:${color};border:1px solid ${color}44`; badge.dataset.type = key; badge.title = `${label}: ${o}/${count}`; badge.innerHTML = `${label} ${o}/${count}`; badgesDiv.appendChild(badge); }); summaryBar.appendChild(badgesDiv); // 分页放到分类汇总行右侧 const summaryPagination = document.createElement('div'); summaryPagination.className = 'sglv-pagination sglv-delisted-pagination'; summaryPagination.id = 'sglv-delisted-pagination'; summaryBar.appendChild(summaryPagination); wrap.appendChild(summaryBar); // v2.3.22: 收藏进度条(置于分页操作栏下方,紧凑布局) const progressCompact = document.createElement('div'); progressCompact.className = 'sglv-delisted-progress-compact'; progressCompact.innerHTML = ` ${Host.T.kpiCollectionProgress}
${delistedPct}% `; wrap.appendChild(progressCompact); // Toolbar(类型筛选 + 拥有状态筛选合并到同一行) const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar sglv-delisted-toolbar'; const typeButtons = [ { key: 'all', label: Host.T.all }, { key: 'delisted', label: Host.T.typeDelisted }, { key: 'purchase_disabled', label: Host.T.typePurchaseDisabled }, { key: 'f2p_unavailable', label: Host.T.typeF2P }, { key: 'retail_only', label: Host.T.typeRetailOnly }, { key: 'test_app', label: Host.T.typeTestApp }, ]; const subItems = [ { key: 'all', label: Host.T.delistedSubAll }, { key: 'owned', label: Host.T.delistedSubOwned }, { key: 'missing', label: Host.T.delistedSubMissing }, ]; toolbar.innerHTML = `
${typeButtons.map(tb => ``).join('')}
${subItems.map(si => ``).join('')}
`; 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.3.22: 分类汇总徽章点击——快速筛选对应类型,并与工具栏按钮同步 badgesDiv.querySelectorAll('.sglv-delisted-summary-badge').forEach(badge => { badge.addEventListener('click', () => { delistedState.typeFilter = badge.dataset.type; delistedState.page = 1; toolbar.querySelectorAll('.sglv-delisted-type-btn').forEach(b => { b.classList.toggle('active', b.dataset.type === badge.dataset.type); }); badgesDiv.querySelectorAll('.sglv-delisted-summary-badge').forEach(b => b.style.outline = ''); badge.style.outline = `2px solid ${badge.style.color || '#66c0f4'}`; renderDelistedGamesList(); }); }); toolbar.querySelectorAll('.sglv-delisted-sub-btn').forEach(btn => { btn.addEventListener('click', () => { delistedState.subFilter = btn.dataset.sub; delistedState.page = 1; toolbar.querySelectorAll('.sglv-delisted-sub-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); 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.querySelectorAll('.sglv-delisted-type-btn').forEach(btn => { btn.addEventListener('click', () => { delistedState.typeFilter = btn.dataset.type; delistedState.page = 1; toolbar.querySelectorAll('.sglv-delisted-type-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); // v2.3.22: 同步分类汇总徽章高亮 badgesDiv.querySelectorAll('.sglv-delisted-summary-badge').forEach(b => { b.style.outline = b.dataset.type === btn.dataset.type ? `2px solid ${b.style.color || '#66c0f4'}` : ''; }); 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 解析 ) // 输入 psstore URL;输出 Promise<string>,空字符串表示获取失败 const _psStoreNamePending = new Map(); const _PS_STORE_NAME_TTL = 6 * 3600 * 1000; // 6h,与 PS 会免数据 TTL 对齐 const _PS_STORE_NAME_NEG_TTL = 30 * 60 * 1000; // 失败 30 分钟内不再请求 const SGLV_PS_NAME_KEY = 'sglvPsStoreNameCache_v1'; function sglvPsStoreNameCacheLoad() { try { return JSON.parse(GM_getValue(SGLV_PS_NAME_KEY, '{}') || '{}'); } catch (e) { return {}; } } function sglvPsStoreNameCacheSave(map) { try { GM_setValue(SGLV_PS_NAME_KEY, JSON.stringify(map || {})); } catch (e) { /* 忽略失败,不影响主流程 */ } } function fetchPsStoreName(psstoreUrl) { if (!Host.isZh) return Promise.resolve(''); if (!psstoreUrl) return Promise.resolve(''); // 缓存键:URL → 名字 const cache = sglvPsStoreNameCacheLoad(); const cached = cache[psstoreUrl]; if (cached) { if (cached.name && Date.now() - (cached.ts || 0) < _PS_STORE_NAME_TTL) return Promise.resolve(cached.name); if (!cached.name && Date.now() - (cached.ts || 0) < _PS_STORE_NAME_NEG_TTL) return Promise.resolve(''); } if (_psStoreNamePending.has(psstoreUrl)) return _psStoreNamePending.get(psstoreUrl); const p = new Promise(resolve => { const settle = (name) => { cache[psstoreUrl] = { name, ts: Date.now() }; try { sglvPsStoreNameCacheSave(cache); } catch (e) { /* 忽略失败,不影响主流程 */ } _psStoreNamePending.delete(psstoreUrl); resolve(name); }; // 只抓 zh-hans-hk 区域页面(数据源中 psstore 大多是该区域),避免美区不同步 const targetUrl = psstoreUrl.includes('playstation.com') && !psstoreUrl.includes('zh-hans-hk') ? psstoreUrl.replace(/(playstation\.com)\/([a-z\-]+\/)/, '$1/zh-hans-hk/') : psstoreUrl; GM_xmlhttpRequest({ method: 'GET', url: targetUrl, timeout: 10000, onload(resp) { let name = ''; try { const html = _safeRespText(resp); // 1. 尝试 <meta property="og:title" content="..."> let m = html.match(/<meta\s+(?:[^>]*?)property=["']og:title["']\s+(?:[^>]*?)content=["']([^"']+)/i); if (!m) { // 2. 尝试 <title>... m = html.match(/]*>([^<]+)<\/title>/i); } if (!m) { // 3. 尝试 m = html.match(/]*?)name=["']twitter:title["']\s+(?:[^>]*?)content=["']([^"']+)/i); } if (m) { // 清理:去掉 " | PlayStation Store" 之类后缀,HTML 实体反转义 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) { /* 忽略失败,不影响主流程 */ } settle(name); }, onerror() { settle(''); }, ontimeout() { settle(''); } }); }); _psStoreNamePending.set(psstoreUrl, p); return p; } // PS 会免占位封面 DOM(蓝色渐变 + 真实 PS 标识 + 名称),供运行时回退统一调用 function psPlaceholderDom(name) { const holder = document.createElement('div'); holder.className = 'sglv-ps-cover-placeholder'; holder.innerHTML = '
' + Host.wlEscape(name || 'PS Exclusive') + '
'; 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 会免竖版封面:有 steamAppId → Steam 多 CDN 回退链 + PS 蓝色兜底;无 → 直接蓝色占位 // v1.9.29: ① 传递 kind 给 _sglvPsCoverFallback ② onerror 链跳过 chain[0] // v1.9.41: good/链走 coverGoodUrl/coverChainLang function psplusPosterImg(appid, name) { if (!appid) { return '
' + Host.wlEscape(name || 'PS Exclusive') + '
'; } const good = coverGoodUrl(appid, 'poster'); if (good) { return '' + Host.wlEscape(name) + ''; } // v1.9.44: 竖版确认缺失 → 横版替代直用(交叉缓存) const horizHtml = _sgcvHorizontalFallbackImgHtml(appid, Host.wlEscape(name), 'sglv-card-img', '_sglvPsCoverFallback'); if (horizHtml) return horizHtml; const chain = coverChainLang(appid, 'poster'); let handler = '_sglvPsCoverFallback(this, \'' + appid + '\', \'poster\');'; for (let i = chain.length - 1; i >= 1; i--) { handler = 'this.src=\'' + chain[i] + '\';this.onerror=function(){' + handler + '}'; } handler = '_sgcvCoverRace(this, \'' + appid + '\', \'poster\');' + handler; return '' + Host.wlEscape(name) + ''; } // PS 会免横版封面(capsule 大横幅) // v1.9.29: ① 传递 kind 给 _sglvPsCoverFallback ② onerror 链跳过 chain[0] // v1.9.41: good/链走 coverGoodUrl/coverChainLang function psplusCapsuleImg(appid, name) { if (!appid) { return '
' + Host.wlEscape(name || 'PS Exclusive') + '
'; } const good = coverGoodUrl(appid, 'capsule'); if (good) { return '' + Host.wlEscape(name) + ''; } const chain = coverChainLang(appid, 'capsule'); let handler = '_sglvPsCoverFallback(this, \'' + appid + '\', \'capsule\');'; for (let i = chain.length - 1; i >= 1; i--) { handler = 'this.src=\'' + chain[i] + '\';this.onerror=function(){' + handler + '}'; } handler = '_sgcvCoverRace(this, \'' + appid + '\', \'capsule\');' + handler; return '' + Host.wlEscape(name) + ''; } // 档位标签 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 = `
`; 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 = `
${tierBtns.map(b => ``).join('')}
${statusBtns.map(b => ``).join('')}
`; 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 = `
${Host.ICONS.playstation}${Host.T.psKpiTotal}
${total.toLocaleString()}
${updatedStr}
${Host.ICONS.gift}${Host.T.psKpiMonthly}
${essential.toLocaleString()}
${Host.T.psTierEssential}
${Host.ICONS.library}${Host.T.psKpiCatalog}
${catalog.toLocaleString()}
${Host.T.psTierExtra} / ${Host.T.psTierPremium}
${Host.ICONS.heart}${Host.T.psKpiSteamOwned}
${steamOwned.toLocaleString()}
${Host.T.psKpiSteamTotal.replace('{n}', steamLinked)}
`; } 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 = '
' + Host.T.psLoading + '
'; clearPagination(); return; } if (psplusState.loadError) { content.innerHTML = '
' + Host.T.psLoadError + '
'; clearPagination(); return; } if (!psplusState.loaded || psplusState.games.length === 0) { content.innerHTML = '
' + Host.T.psLoading + '
'; clearPagination(); return; } const games = getFilteredPsplus(); if (games.length === 0) { content.innerHTML = '
' + (Host.isZh ? '无匹配结果' : 'No matching results') + '
'; 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 => '' + psTierLabel(g.tier) + ''; const statusBadgeHtml = g => '' + Host.wlEscape(g.statusDisplay || g.statusName) + ''; const versionsHtml = g => (g.versions || []).map(v => '' + v + '').join(' '); const ownershipBadgeHtml = g => isOwned(g) ? '✓ ' + Host.T.steamInLib + '' : ''; // 卡片视图 if (psplusState.viewMode === 'card') { content.innerHTML = '
' + 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 ? '' + Host.wlEscape(name) + '' + appid + '' : '' + Host.wlEscape(name) + ''; return '
' + '
' + tierBadgeHtml(g) + psplusPosterImg(appid, name) + '
' + '
' + '
' + nameSpan + '
' + '
' + statusBadgeHtml(g) + ownershipBadgeHtml(g) + '
' + '
' + Host.ICONS.clock + '' + Host.wlEscape(g.month || '') + '' + versionsHtml(g) + '
' + '
' + '
'; }).join('') + '
'; } else if (psplusState.viewMode === 'cover') { // 封面视图(横板大图) // v2.9.22: 信息行紧凑到 3 行:标题 / [状态+在库] / [月份+版本] content.innerHTML = '
' + 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 ? '' + Host.wlEscape(name) + '' + appid + '' : '' + Host.wlEscape(name) + ''; return '
' + '
' + tierBadgeHtml(g) + psplusCapsuleImg(appid, name) + '
' + '
' + '
' + nameSpan + '
' + '
' + statusBadgeHtml(g) + ownershipBadgeHtml(g) + '
' + '
' + Host.ICONS.clock + '' + Host.wlEscape(g.month || '') + '' + versionsHtml(g) + '
' + '
' + '
'; }).join('') + '
'; } else { // 列表视图 content.innerHTML = '
' + 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 ? '' + Host.wlEscape(name) + '' : '
' + Host.ICONS.playstation + '
'; const nameSpan = appid ? '' + Host.wlEscape(name) + '' + appid + '' : '' + Host.wlEscape(name) + ''; return '
' + iconHtml + '
' + '
' + nameSpan + '
' + '
' + tierBadgeHtml(g) + statusBadgeHtml(g) + ownershipBadgeHtml(g) + '' + Host.wlEscape(g.month || '') + '' + versionsHtml(g) + '
' + '
' + '
'; }).join('') + '
'; } // 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 页面替换为中文名 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; fetchPsStoreName(psUrl).then(zhName => { if (zhName && zhName !== original) { el.textContent = zhName; el.title = `${zhName} (${original})`; } }); }); } // ==================== 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 = 'EPICGAMES'; // 名称缓存(会话级,供搜索/排序使用;中文复用宿主持久化缓存) const _epicNameCache = new Map(); const _epicNamePending = new Map(); // v1.1.1: 通过 appdetails 获取英文名(filters=basic 含 name 字段;参考 Steam_Show_English_Name.js) // 旧版 filters=name 是无效过滤值,appdetails 返回 data:[],英文名始终为空。 function _epicFetchEnName(id) { return new Promise(resolve => { if (typeof GM_xmlhttpRequest !== 'function') { resolve(''); return; } GM_xmlhttpRequest({ method: 'GET', url: 'https://store.steampowered.com/api/appdetails?appids=' + id + '&filters=basic&l=english', timeout: 10000, onload(r) { let name = ''; try { const j = JSON.parse(_safeRespText(r)); const d = j && j[id]; if (d && d.success && d.data && d.data.name) name = d.data.name; } catch (e) { /* 忽略失败,不影响主流程 */ } resolve(name); }, onerror() { resolve(''); }, ontimeout() { resolve(''); } }); }); } 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; } } const en = await _epicFetchEnName(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 '
' + Host.wlEscape(name || 'Epic') + '
'; } // 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 = '
' + Host.wlEscape(name || 'Epic') + '
'; 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) { if (!appid) return epicPlaceholder(name); const good = coverGoodUrl(appid, 'poster'); if (good) { return '' + Host.wlEscape(name) + ''; } // v1.9.44: 竖版确认缺失 → 横版替代直用(交叉缓存) const horizHtml = _sgcvHorizontalFallbackImgHtml(appid, Host.wlEscape(name), 'sglv-card-img', '_sglvEpicCoverFallback'); if (horizHtml) return horizHtml; const chain = coverChainLang(appid, 'poster'); let handler = '_sglvEpicCoverFallback(this, \'' + appid + '\', \'poster\');'; for (let i = chain.length - 1; i >= 1; i--) { handler = 'this.src=\'' + chain[i] + '\';this.onerror=function(){' + handler + '}'; } handler = '_sgcvCoverRace(this, \'' + appid + '\', \'poster\');' + handler; return '' + Host.wlEscape(name) + ''; } // 横版封面(封面视图大横幅) // v1.9.29: ① 传递 kind 给 _sglvEpicCoverFallback ② onerror 链跳过 chain[0] // v1.9.41: good/链走 coverGoodUrl/coverChainLang function epicCapsuleImg(appid, name) { if (!appid) return epicPlaceholder(name); const good = coverGoodUrl(appid, 'capsule'); if (good) { return '' + Host.wlEscape(name) + ''; } const chain = coverChainLang(appid, 'capsule'); let handler = '_sglvEpicCoverFallback(this, \'' + appid + '\', \'capsule\');'; for (let i = chain.length - 1; i >= 1; i--) { handler = 'this.src=\'' + chain[i] + '\';this.onerror=function(){' + handler + '}'; } handler = '_sgcvCoverRace(this, \'' + appid + '\', \'capsule\');' + handler; return '' + Host.wlEscape(name) + ''; } // 徽章 function epicFreeBadgeHtml() { return '' + Host.T.epicBadgeFree + ''; } function epicOwnedBadgeHtml(owned) { return owned ? '✓ ' + Host.T.steamInLib + '' : '' + Host.T.epicBadgeNotOwned + ''; } // 合并多个来源 → 去重数字数组 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 = `
`; 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 = `
${Host.ICONS.epic}${Host.T.epicKpiTotal}
${total.toLocaleString()}
${updatedStr}
${Host.ICONS.heart}${Host.T.epicKpiOwned}
${owned.toLocaleString()}
${Host.T.epicKpiOwnedSub}
${Host.ICONS.tag}${Host.T.epicKpiNotOwned}
${notOwned.toLocaleString()}
${Host.isZh ? '可在 Epic 领取' : 'Claimable on Epic'}
`; } 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 = '
' + Host.T.epicLoading + '
'; clearPagination(); return; } if (epicState.loadError) { content.innerHTML = '
' + Host.T.epicLoadError + '
'; clearPagination(); return; } if (!epicState.loaded || epicState.appids.length === 0) { content.innerHTML = '
' + Host.T.epicNoData + '
'; clearPagination(); return; } const list = getFilteredEpic(); if (list.length === 0) { content.innerHTML = '
' + (Host.isZh ? '无匹配结果' : 'No matching results') + '
'; 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 = '
' + pageItems.map(appid => { const name = epicDisplayName(appid); const clickUrl = 'https://store.steampowered.com/app/' + appid; const nameSpan = '' + Host.wlEscape(name) + '' + appid + ''; return '
' + '
' + freeBadge + epicPosterImg(appid, name) + '
' + '
' + '
' + nameSpan + '
' + '
' + epicOwnedBadgeHtml(isOwned(appid)) + '
' + '
' + '
'; }).join('') + '
'; } else if (epicState.viewMode === 'cover') { // 封面视图(横板大图) // v2.9.22: 标签统一规则——封面上不放任何徽章(freeBadge 在右下角,owned badge 在标题下方) content.innerHTML = '
' + pageItems.map(appid => { const name = epicDisplayName(appid); const clickUrl = 'https://store.steampowered.com/app/' + appid; const nameSpan = '' + Host.wlEscape(name) + '' + appid + ''; return '
' + '
' + freeBadge + epicCapsuleImg(appid, name) + '
' + '
' + '
' + nameSpan + '
' + '
' + epicOwnedBadgeHtml(isOwned(appid)) + '
' + '
' + '
'; }).join('') + '
'; } else { // 列表视图 content.innerHTML = '
' + pageItems.map(appid => { const name = epicDisplayName(appid); const clickUrl = 'https://store.steampowered.com/app/' + appid; const iconHtml = '' + Host.wlEscape(name) + ''; const nameSpan = '' + Host.wlEscape(name) + '' + appid + ''; return '
' + iconHtml + '
' + '
' + nameSpan + '
' + '
' + freeBadge + epicOwnedBadgeHtml(isOwned(appid)) + '
' + '
' + '
'; }).join('') + '
'; } // 分页 // 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.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 '' + Host.T.blockedBadgeBanned + ''; if (catId === 3) return '' + Host.T.blockedBadgePurchase + ''; if (catId === 10) return '' + Host.T.blockedBadgeRegional + ''; if (catId === 0) return '' + Host.T.blockedBadgeManual + ''; // v1.9.15: 好评500 分类(人工整理锁区游戏,categoryId=500) if (catId === 500) return '' + (Host.isZh ? '好评500' : 'Positive 500+') + ''; return ''; } function blockedOwnedBadgeHtml(owned) { if (owned) return '' + Host.T.blockedBadgeOwned + ''; 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} 好评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 = `
`; wrap.appendChild(toolbar); const filterBar = document.createElement('div'); filterBar.className = 'sglv-filter-bar'; const ownBtns = [ { key: 'all', label: Host.T.blockedFilterAll }, { key: 'owned', label: Host.T.blockedFilterOwned }, { key: 'notOwned', label: Host.T.blockedFilterNotOwned }, ]; const catBtns = [ { key: 'all', label: Host.T.blockedFilterAll }, { key: 'banned', label: Host.T.blockedBadgeBanned }, { key: 'purchase', label: Host.T.blockedBadgePurchase }, { key: 'regional', label: Host.T.blockedBadgeRegional }, { key: 'manual', label: Host.T.blockedBadgeManual }, { key: 'topRated', label: Host.isZh ? '好评500' : 'Positive 500+' }, ]; filterBar.innerHTML = `
${ownBtns.map(b => ``).join('')}
${catBtns.map(b => ``).join('')}
`; wrap.appendChild(filterBar); 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', 'cover', 'list'].forEach(m => { toolbar.querySelector('#sglv-blocked-' + m)?.classList.toggle('active', m === mode); }); renderBlockedGamesList(); }; toolbar.querySelector('#sglv-blocked-card').addEventListener('click', () => setBlockedViewMode('card')); toolbar.querySelector('#sglv-blocked-cover').addEventListener('click', () => setBlockedViewMode('cover')); 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)); filterBar.querySelectorAll('.sglv-blocked-own-btn').forEach(btn => { btn.addEventListener('click', () => { blockedState.ownFilter = btn.dataset.own; blockedState.page = 1; filterBar.querySelectorAll('.sglv-blocked-own-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderBlockedKpi(); // v1.6.0: 同步 KPI active 高亮 renderBlockedGamesList(); }); }); filterBar.querySelectorAll('.sglv-blocked-cat-btn').forEach(btn => { btn.addEventListener('click', () => { blockedState.catFilter = btn.dataset.cat; blockedState.page = 1; filterBar.querySelectorAll('.sglv-blocked-cat-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderBlockedGamesList(); }); }); // v1.6.0: 筛选改为点击 KPI 卡片切换(再次点击同一卡片回到「全部」),与年度大作/Epic赠送标签页一致 kpi.addEventListener('click', e => { const card = e.target.closest('[data-blocked-filter]'); if (!card) return; const f = card.dataset.blockedFilter; blockedState.ownFilter = blockedState.ownFilter === f ? 'all' : f; blockedState.page = 1; syncBlockedFilterBtns(filterBar); // 同步筛选栏按钮 active 状态 renderBlockedKpi(); // 刷新 KPI active 高亮 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 = `${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'; } } // v1.6.0: KPI 点击后同步筛选栏 ownFilter 按钮 active 状态 function syncBlockedFilterBtns(filterBar) { filterBar.querySelectorAll('.sglv-blocked-own-btn').forEach(b => { b.classList.toggle('active', b.dataset.own === blockedState.ownFilter); }); } 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; const notOwned = total - owned; const banned = mergedApps.filter(a => a.categoryId === 20).length; const purchase = mergedApps.filter(a => a.categoryId === 3).length; const regional = mergedApps.filter(a => a.categoryId === 10).length; // v1.9.15: 好评500 分类计数(非零时追加到副标题) const topRated = mergedApps.filter(a => a.categoryId === 500).length; const subText = Host.T.blockedKpiSubBanned.replace('N', banned).replace('M', purchase).replace('K', regional) + (topRated > 0 ? ' · ' + (Host.isZh ? '好评500 ' : 'Positive500 ') + topRated : ''); const tipAll = Host.isZh ? '点击筛选:全部' : 'Click to filter: All'; const tipOwned = Host.isZh ? '点击筛选:steam在库' : 'Click to filter: In Library'; const tipNotOwned = Host.isZh ? '点击筛选:未拥有' : 'Click to filter: Not Owned'; el.innerHTML = `
${BLOCKED_LOCK_ICON_SVG}${Host.T.blockedKpiTotal}
${total.toLocaleString()}
${subText}
${Host.ICONS.heart}${Host.T.blockedKpiOwned}
${owned.toLocaleString()}
${Host.isZh ? '已在 Steam 库' : 'In Steam library'}
${Host.ICONS.tag}${Host.T.blockedKpiNotOwned}
${notOwned.toLocaleString()}
${Host.isZh ? '待探索' : 'To explore'}
`; } 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 = '
' + Host.wlEscape(stageText) + '
'; clearPagination(); return; } if (blockedState.loadError) { content.innerHTML = '
' + Host.T.blockedLoadError + '
'; clearPagination(); return; } if (!blockedState.loaded || blockedState.apps.length === 0) { content.innerHTML = '
' + Host.T.blockedNoData + '
'; clearPagination(); return; } const list = getFilteredBlocked(); if (list.length === 0) { content.innerHTML = '
' + (Host.isZh ? '无匹配结果' : 'No matching results') + '
'; 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)); if (blockedState.viewMode === 'card') { content.innerHTML = '
' + pageItems.map(a => { const name = a.name || ('App ' + a.appid); const clickUrl = 'https://store.steampowered.com/app/' + a.appid; const owned = isOwned(a.appid); const nameSpan = '' + Host.wlEscape(name) + '' + a.appid + ''; const catBadge = blockedCatBadgeHtml(a.categoryId); const ownedBadge = blockedOwnedBadgeHtml(owned); const dateStr = a.changedAt ? '' + Host.T.blockedChangedAt + ': ' + Host.wlEscape(a.changedAt) + '' : ''; return '
' + '
' + blockedPosterImg(a.appid, name) + '
' + '
' + '
' + nameSpan + ' ' + dateStr + '
' + '
' + catBadge + ownedBadge + '
' + '
' + '
'; }).join('') + '
'; } else if (blockedState.viewMode === 'cover') { // v2.9.22: 标签统一规则——封面上不放任何徽章(cat/owned 全部移到标题下方) content.innerHTML = '
' + pageItems.map(a => { const name = a.name || ('App ' + a.appid); const clickUrl = 'https://store.steampowered.com/app/' + a.appid; const owned = isOwned(a.appid); const catBadge = blockedCatBadgeHtml(a.categoryId); const ownedBadge = blockedOwnedBadgeHtml(owned); return '
' + '
' + blockedCapsuleImg(a.appid, name) + '
' + '
' + '
' + Host.wlEscape(name) + ' ' + a.appid + '
' + '
' + catBadge + ownedBadge + '
' + '
' + '
'; }).join('') + '
'; } else { content.innerHTML = '
' + pageItems.map(a => { const name = a.name || ('App ' + a.appid); const clickUrl = 'https://store.steampowered.com/app/' + a.appid; const owned = isOwned(a.appid); const iconHtml = '' + Host.wlEscape(name) + ''; const catBadge = blockedCatBadgeHtml(a.categoryId); const ownedBadge = blockedOwnedBadgeHtml(owned); const dateStr = a.changedAt ? '' + Host.T.blockedChangedAt + ': ' + Host.wlEscape(a.changedAt) + '' : ''; return '
' + iconHtml + '
' + '
' + Host.wlEscape(name) + ' ' + a.appid + '
' + '
' + catBadge + ownedBadge + ' ' + dateStr + '
' + '
' + '
'; }).join('') + '
'; } 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 '' + '' + daysLeft + '' + '' + (Host.isZh ? '天后发售' : 'days left') + '' + ''; } if (daysLeft === 0) { return '' + '★' + '' + (Host.isZh ? '今日发售' : 'today') + '' + ''; } // 已发售:仅显示"X 天前发售"作历史时间感(不强调) const ago = -daysLeft; return '' + '' + ago + '' + '' + (Host.isZh ? '天前发售' : 'days ago') + '' + ''; } // ==================== 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 容量上限 const GOTY_COVER_TTL = 30 * 24 * 3600 * 1000; // 30 天 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 = {}; try { const raw = Host.cacheGet(GOTY_COVER_CACHE_KEY); if (raw && typeof raw === 'object') _gotyCoverPersisted = raw; } catch (e) { /* ignore */ } // 同步预热内存缓存 for (const k in _gotyCoverPersisted) { const ent = _gotyCoverPersisted[k]; if (ent && ent.url) _gotyCoverGood.set(k, ent.url); } } function _gotyCoverRecordGood(appid, url, kind) { if (!appid || !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_TTL); } catch (e) { /* ignore */ } }, 80); } function _gotyCoverGetGood(appid, kind) { _gotyCoverLoadPersisted(); return _gotyCoverGood.get(`${kind || 'poster'}:${appid}`) || ''; } // 生成年度大作封面 (无内联 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 `${Host.wlEscape(name)}`; } 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'); } renderGotyGamesList(); } // 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; renderGotyGamesList(); } 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, year: gotyState.year, 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); } function getFilteredGoty() { 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)); } // v1.7.1: 按年度筛选(默认 null = 全部年度);games 已在 _refreshGotyFromRemote 中预解析 year if (gotyState.year) { list = list.filter(g => g.year === gotyState.year); } if (gotyState.ownFilter === 'owned') list = list.filter(g => isOwned(g.appid)); else if (gotyState.ownFilter === 'notOwned') list = list.filter(g => !isOwned(g.appid)); // v1.7.0: 默认 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 da.localeCompare(db) || (a.appid - b.appid); } }); return list; } function gotyBadgeHtml() { return '' + Host.T.gotyBadgeGoty + ''; } function gotyOwnedBadgeHtml(owned) { return owned ? '✓ ' + Host.T.steamInLib + '' : '' + Host.T.gotyBadgeNotOwned + ''; } function renderGotyTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-psplus-wrap'; const kpi = document.createElement('div'); kpi.id = 'sglv-goty-kpi'; wrap.appendChild(kpi); // v1.7.0: 年度筛选 chip bar——在 toolbar 上方一行,默认"全部"active const yearBar = document.createElement('div'); yearBar.className = 'sglv-goty-year-bar'; const renderYearBar = () => { const yearAllLabel = (Host.T && Host.T.gotyFilterAllYears) || (Host.isZh ? '全部年度' : 'All Years'); const yearChips = [``]; (gotyState.availableYears || []).slice().sort((a, b) => b - a).forEach(y => { yearChips.push(``); }); yearBar.innerHTML = yearChips.join(''); }; renderYearBar(); wrap.appendChild(yearBar); const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar'; const sortOptions = [ { key: 'dateDesc', label: Host.T.gotySortDateDesc }, // v1.7.0: 默认时间降序(最新在前) { key: 'dateAsc', label: Host.T.gotySortDateAsc }, { key: 'nameAsc', label: Host.T.gotySortNameAsc }, { key: 'appidAsc', label: Host.T.gotySortAppidAsc }, ]; toolbar.innerHTML = `
`; wrap.appendChild(toolbar); const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-goty-content'; wrap.appendChild(content); parent.appendChild(wrap); toolbar.querySelector('#sglv-goty-search').addEventListener('input', _debounce((e) => { gotyState.search = e.target.value; gotyState.page = 1; renderGotyGamesList(); }, 200)); toolbar.querySelector('#sglv-goty-sort').addEventListener('change', (e) => { gotyState.sort = e.target.value; gotyState.page = 1; renderGotyGamesList(); }); // v1.7.0: 年度筛选事件——切换 / 拉取新数据 yearBar.addEventListener('click', (e) => { const chip = e.target.closest('.sglv-goty-year-chip'); if (!chip) return; const y = chip.dataset.year; const newYear = y ? parseInt(y, 10) : null; if (newYear === gotyState.year) return; gotyState.year = newYear; gotyState.page = 1; // 切换"全部"时不需要重新拉;切换到具体年份时,如已有缓存就无需重拉 renderYearBar(); if (newYear !== null && !gotyState.availableYears.includes(newYear)) { // 该年还没拉过,触发拉取 _refreshGotyFromRemote(false, true); } else { renderGotySections(); } }); const setGotyViewMode = (mode) => { gotyState.viewMode = mode; ['card', 'cover', 'list'].forEach(m => { toolbar.querySelector('#sglv-goty-' + m)?.classList.toggle('active', m === mode); }); renderGotyGamesList(); }; toolbar.querySelector('#sglv-goty-card').addEventListener('click', () => setGotyViewMode('card')); toolbar.querySelector('#sglv-goty-cover').addEventListener('click', () => setGotyViewMode('cover')); toolbar.querySelector('#sglv-goty-list').addEventListener('click', () => setGotyViewMode('list')); toolbar.querySelector('#sglv-goty-refresh').addEventListener('click', () => loadGotyGames(true)); // v1.4.0: 筛选改为点击 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; gotyState.page = 1; renderGotyKpi(); // 刷新 KPI active 高亮 renderGotyGamesList(); }); if (!gotyState.loaded && !gotyState.loading) { loadGotyGames(); } else { renderGotySections(); } } function renderGotySections() { if (_collectionActiveTab !== 'goty' || !document.getElementById('sglv-goty-content')) return; renderGotyKpi(); renderGotyGamesList(); } function renderGotyKpi() { const el = document.getElementById('sglv-goty-kpi'); if (!el) return; if (!gotyState.loaded) { el.innerHTML = ''; return; } const total = gotyState.games.length; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); 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 = `
${Host.ICONS.trophy}${Host.T.gotyKpiTotal}
${total.toLocaleString()}
${updatedStr}
${Host.ICONS.heart}${Host.T.gotyKpiOwned}
${owned.toLocaleString()}
${Host.T.gotyKpiOwnedSub}
${Host.ICONS.tag}${Host.T.gotyKpiNotOwned}
${notOwned.toLocaleString()}
${Host.isZh ? '可在 Steam 购买' : 'Available on Steam'}
`; } function renderGotyGamesList() { const content = document.getElementById('sglv-goty-content'); if (!content) return; _bindGotyCoverDelegation(content); // v1.4.0: 年度大作封面回退委托(捕获阶段,仅绑定一次) const clearPagination = () => { const p = document.getElementById('sglv-goty-pagination'); if (p) p.innerHTML = ''; }; if (gotyState.loading) { content.innerHTML = '
' + Host.T.gotyLoading + '
'; clearPagination(); return; } if (gotyState.loadError) { content.innerHTML = '
' + Host.T.gotyLoadError + '
'; clearPagination(); return; } if (!gotyState.loaded || gotyState.games.length === 0) { content.innerHTML = '
' + Host.T.gotyNoData + '
'; clearPagination(); return; } const list = getFilteredGoty(); if (list.length === 0) { content.innerHTML = '
' + (Host.isZh ? '无匹配结果' : 'No matching results') + '
'; clearPagination(); return; } const { pageItems, totalPages, page: _clampedPage } = Host.paginate(list, gotyState.page, gotyState.pageSize); gotyState.page = _clampedPage; const ownedIds = Host.getActiveOwnedAppIds(); const isOwned = n => ownedIds.has(n) || ownedIds.has(String(n)); const gotyBadge = gotyBadgeHtml(); const dateLabel = Host.T.gotyReleaseDate; // v1.0.4: 发售日期组件——时间图标 + 完整年月日 + 倒计时(X 天后发售/今日/天前) // 倒计时参考 steam-friend-manager sfd-stress-featured-countdown // 注意:g 是 pageItems.map 回调内的形参,外层不能直接引用,所以用工厂函数 const gotyDateHtml = g => g.date ? '' + Host.ICONS.clock + '' + Host.wlEscape(gotyDateFull(g.date)) + gotyCountdownHtml(g.date) + '' : ''; if (gotyState.viewMode === 'card') { content.innerHTML = '
' + pageItems.map(g => { const clickUrl = 'https://store.steampowered.com/app/' + g.appid; return '
' + '
' + gotyBadge + gotyCoverImgTag(g.appid, g.name, 'poster', 'sglv-card-img') + '
' + '
' + '
' + Host.wlEscape(g.name) + '
' + // 行 2: [是否拥有] [🕐 发售年月日] 紧凑同行 '
' + gotyOwnedBadgeHtml(isOwned(g.appid)) + gotyDateHtml(g) + '
' + '
' + '
'; }).join('') + '
'; } else if (gotyState.viewMode === 'cover') { // v2.9.22: 标签统一规则——封面上不放任何徽章(gotyBadge 在右下角,owned badge + 日期在标题下方同行) content.innerHTML = '
' + pageItems.map(g => { const clickUrl = 'https://store.steampowered.com/app/' + g.appid; return '
' + '
' + gotyBadge + gotyCoverImgTag(g.appid, g.name, 'capsule', 'sglv-wl-cover-img') + '
' + '
' + '
' + Host.wlEscape(g.name) + '
' + // 紧凑:状态 + 日期同行 '
' + gotyOwnedBadgeHtml(isOwned(g.appid)) + gotyDateHtml(g) + '
' + '
' + '
'; }).join('') + '
'; } else { content.innerHTML = '
' + pageItems.map(g => { const clickUrl = 'https://store.steampowered.com/app/' + g.appid; const iconHtml = gotyCoverImgTag(g.appid, g.name, 'header', 'sglv-list-icon'); return '
' + iconHtml + '
' + '
' + Host.wlEscape(g.name) + ' ' + g.appid + '
' + '
' + gotyBadge + gotyOwnedBadgeHtml(isOwned(g.appid)) + ' ' + gotyDateHtml(g) + '
' + '
' + '
'; }).join('') + '
'; } const pagination = document.getElementById('sglv-goty-pagination'); if (pagination) { // v1.9.47-fix: 补 onJump——年度大作翻页 5 个按钮全部走 onJump(参见主脚本 renderPagination) Host.renderPagination(pagination, 'sglv-goty', gotyState.page, totalPages, list.length, () => { gotyState.page--; renderGotyGamesList(); }, () => { gotyState.page++; renderGotyGamesList(); }, (p) => { gotyState.page = p; renderGotyGamesList(); }); } } // ==================== 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 '' + Host.T.dlcBadgeMusic + ''; return 'DLC'; } function dlcBundleBadgeHtml(count) { if (!count || count <= 0) return ''; return '📦' + count + ''; } function dlcPlayedBadgeHtml(playtime) { if (!playtime || playtime <= 0) return ''; return '' + Math.round(playtime / 60) + 'h'; } // v1.9.4: 已拥有/未拥有 徽章 function dlcOwnedBadgeHtml(isOwned) { if (isOwned) return '' + (Host.isZh ? '已拥有' : 'Owned') + ''; return '' + (Host.isZh ? '未拥有' : 'Not Owned') + ''; } function dlcPosterImg(appid, name) { return Host.posterImg(appid, name); } function dlcCapsuleImg(appid, name) { if (typeof Host.capsuleImg === 'function') return Host.capsuleImg(appid, name); return Host.posterImg(appid, name); } 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 = `
`; 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 = '
' + (Host.isZh ? 'DLC 渲染异常,数据已加载,请尝试切换排序或刷新页面' : 'DLC render error, data loaded. Try switching sort or refresh') + '
'; } } } // 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 = ''; return `
${spinSvg}
${safeText}
${Host && Host.isZh ? '正在加载所有已知 DLC 数据…' : 'Loading all known DLC data…'}
`; } function renderDlcKpiSkeletonHtml() { const skel = '
'; const cards = Array.from({ length: 5 }, () => `
${skel}
`).join(''); return `
${cards}
`; } 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 ? `
${Host.isZh ? '后台获取 DLC 子类型中…' : 'Fetching DLC subtypes in background…'}
` : ''; 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 = `
${Host.ICONS.puzzle}${Host.T.dlcKpiTotal}
${total.toLocaleString()}
${subText}
${Host.isZh ? '已拥有 ' + owned.toLocaleString() + ' (' + ownPct + '%)' : 'Owned ' + owned.toLocaleString() + ' (' + ownPct + '%)'}
${Host.ICONS.package}${Host.T.dlcKpiRegular}
${regular.toLocaleString()}
type=1
${Host.ICONS.tag}${Host.T.dlcKpiMusic}
${music.toLocaleString()}
type=6
${Host.ICONS.package}${Host.T.dlcKpiBundled}
${bundled.toLocaleString()}
${Host.isZh ? '含 Bundle' : 'In Bundles'}
${Host.ICONS.trend}${Host.T.dlcKpiPlayed}
${played.toLocaleString()}
playtime>0
${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(''); 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); 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 = '
' + Host.T.dlcLoadError + '
'; clearPagination(); return; } if (!dlcState.loaded || dlcState.dlcs.length === 0) { content.innerHTML = '
' + Host.T.dlcNoData + '
'; 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 = '
' + (Host.isZh ? '无匹配结果' : 'No matching results') + '
'; 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 '
' + '
' + dlcPosterImg(d.appid, d.name) + '
' + '
' + '
' + Host.wlEscape(d.name) + '
' + '
AppID: ' + d.appid + '
' + '
' + badges + '
' + (d.parentName ? '
' + Host.wlEscape(d.parentName) + '
' : '') + '
' + '
'; }); } 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 '
' + '
' + dlcCapsuleImg(d.appid, d.name) + '
' + '
' + '
' + Host.wlEscape(d.name) + ' ' + d.appid + '
' + '
' + badges + '
' + '
' + '
'; }); } else { gridClass = 'sglv-list-view'; cardHtmls = pageItems.map(d => { const clickUrl = 'https://store.steampowered.com/app/' + d.appid; const iconHtml = '' + Host.wlEscape(d.name) + ''; const badges = dlcOwnedBadgeHtml(d.isOwned) + dlcTypeBadgeHtml(d.type) + dlcBundleBadgeHtml(d.bundleCount) + dlcPlayedBadgeHtml(d.playtime); const dimCls = d.isOwned ? '' : ' sglv-dlc-not-owned'; return '
' + iconHtml + '
' + '
' + Host.wlEscape(d.name) + ' ' + d.appid + '
' + '
' + badges + (d.parentName ? ' · ' + Host.wlEscape(d.parentName) : '') + '
' + '
' + '
'; }); } content.innerHTML = `
`; 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(); }); } } // 分组视图渲染:父游戏折叠头 + 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 '
' + '
' + '' + arrow + '' + '' + Host.wlEscape(g.parentName || String(g.parentApp)) + '' + '' + g.ownedDlc + '/' + g.totalDlc + ' DLC' + '' + g.pct + '%' + '
' + (expanded ? '
' : '') + '
'; }); 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 '
' + '
' + dlcCapsuleImg(d.appid, d.name) + '
' + '
' + '
' + Host.wlEscape(d.name) + ' ' + d.appid + '
' + '
' + badges + '
' + '
' + '
'; }); 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, 按父游戏分组 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, name: dlcGame ? dlcGame.name : ('DLC ' + dlcId), 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; 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; el.innerHTML = `
${Host.ICONS.package}${Host.isZh ? '含进包游戏' : 'Games w/ Bundles'}
${totalGames.toLocaleString()}
${Host.isZh ? '已拥有 ' + ownedGames.toLocaleString() + ' (' + ownPct + '%)' : 'Owned ' + ownedGames.toLocaleString() + ' (' + ownPct + '%)'}
${Host.ICONS.package}${Host.isZh ? '进包 DLC' : 'Bundled DLCs'}
${totalDlcs.toLocaleString()}
${Host.isZh ? '含 Bundle 标记' : 'In Bundles'}
${Host.ICONS.gift}${Host.isZh ? '累计进包' : 'Total Bundle Entries'}
${totalBundles.toLocaleString()}
${Host.isZh ? '所有 DLC 进包次数之和' : 'Sum of all bundle counts'}
`; } // 渲染游戏列表 (卡片视图) 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 = ''; }; // 检查 DLC 数据库是否可用 if (typeof Host.isDlcDbReady === 'function' && !Host.isDlcDbReady()) { content.innerHTML = '
' + (Host.isZh ? 'DLC 数据库尚未加载,请先打开「DLC」标签页加载数据后返回。' : 'DLC database not loaded. Please open the "DLC" tab first to load data, then come back.') + '
'; clearPagination(); return; } if (!bundleState.loaded) { // v2.11.49: 数据异步构建中——骨架占位(避免闪"暂无进包数据"空态) const skelCards = Array.from({ length: 12 }, () => '
' ).join(''); content.innerHTML = '
' + skelCards + '
'; clearPagination(); return; } if (bundleState.data.length === 0) { content.innerHTML = '
' + (Host.isZh ? '暂无进包数据' : 'No bundle data available') + '
'; clearPagination(); return; } const list = getFilteredBundle(); if (list.length === 0) { content.innerHTML = '
' + (Host.isZh ? '无匹配结果' : 'No matching results') + '
'; 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 = '
' + pageItems.map(item => { const clickUrl = 'https://store.steampowered.com/app/' + item.appid; const bundleBadge = '📦' + item.totalBundle + ''; const dlcBadge = item.dlcCount > 0 ? '' + item.dlcCount + ' DLC' : ''; const posterHtml = Host.posterImg(item.appid, item.name); // DLC 详情列表 (最多预览 5 条) let dlcListHtml = ''; if (item.dlcs.length > 0) { const preview = item.dlcs.slice(0, 5); const moreCount = item.dlcs.length - preview.length; dlcListHtml = '
' + preview.map(d => { const dlcUrl = 'https://store.steampowered.com/app/' + d.appid; return '
' + '' + Host.wlEscape(d.name) + '' + '📦' + d.bundleCount + '' + '
'; }).join('') + (moreCount > 0 ? '
' + (Host.isZh ? '还有 ' + moreCount + ' 个…' : '+' + moreCount + ' more…') + '
' : '') + '
'; } return '
' + '
' + posterHtml + '
' + '
' + '
' + '' + Host.wlEscape(item.name) + '' + '' + item.appid + '' + '
' + '
' + bundleBadge + dlcBadge + '
' + dlcListHtml + '
' + '
'; }).join('') + '
'; // 分页 // 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 = `
`; 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.11.49: 数据异步加载——buildBundleData 遍历全部拥有游戏×DLC 进包映射,是同步长任务, // 直接执行会阻塞收藏浮窗打开/切 tab。先渲染 KPI+列表骨架,setTimeout 让出主线程后构建渲染; // 容器已随 tab 切换被移出文档时跳过本次构建。 renderBundleKpi(); renderBundleList(); setTimeout(() => { if (!document.getElementById('sglv-bundle-kpi')) return; buildBundleData(); renderBundleKpi(); renderBundleList(); }, 0); } // ==================== 收藏浮窗管理(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 = `
Steam
`; 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}]}]})。 // 性能:月份解析一次缓存;游戏列表放
内惰性展开(不渲染海报,纯文本行); // 分页 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 = `
${Host.T.humbleNoData}
`; 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 = `
${['all', 'owned', 'unowned'].map(f => ``).join('')}
`; 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; kpiEl.innerHTML = `
${Host.ICONS.gift}${Host.T.humbleKpiMonths}
${months.length}
Choice #1 ~ #${months[months.length - 1].choice}
${Host.ICONS.package}${Host.T.humbleKpiGames}
${totalGames.toLocaleString()}
✓${Host.T.humbleKpiOwned}
${totalOwned.toLocaleString()}
${ownedPct}%
${Host.ICONS.clock}${Host.T.humbleKpiUnowned}
${(totalGames - totalOwned).toLocaleString()}
`; const monthCardHtml = m => { const games = m._games; const pct = games.length ? Math.round(m._owned / games.length * 100) : 0; const rows = games.map(g => { const owned = isOwned(g.appid); return `
${owned ? '✓' : '·'} ${Host.wlEscape(g.name || '')} ${g.publisher ? `${Host.wlEscape(g.publisher)}` : ''} ${g.appid ? `${g.appid}` : ''}
`; }).join(''); return `
${Host.wlEscape(m._label)} ${games.length} games ${Host.T.humbleOwnedIn} ${m._owned}/${games.length} (${pct}%)
${Host.isZh ? '展开游戏列表' : 'Show games'}
${rows}
`; }; const filteredMonths = () => { const q = (humbleState.search || '').toLowerCase(); return months.filter(m => { if (humbleState.filter === 'owned' && m._owned === 0) return false; if (humbleState.filter === 'unowned' && m._owned > 0) return false; if (!q) return true; if (m._label.toLowerCase().includes(q)) return true; return m._games.some(g => (g.name || '').toLowerCase().includes(q) || (g.publisher || '').toLowerCase().includes(q) || String(g.appid) === q); }); }; 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(); }); }); } 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 = '
'; const kpiCards = Array.from({ length: 5 }, () => `
${skelLines}
`).join(''); const cards = Array.from({ length: 8 }, () => '
' ).join(''); body.innerHTML = `
${kpiCards}
${cards}
`; } requestAnimationFrame(() => requestAnimationFrame(renderCollectionBody)); } function closeCollection() { 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 }; })(); /* ---------- sglv-app-detail.lib.js ---------- */ // ==UserScript== // @name SGLV App Detail Library // @namespace sglv-lib // @version 1.2.0 // @description SGLV 单个 appid 的详情/多地区价格/拥有与共享状态库,统一封装网络抓取、缓存、解析与文案。供 steam-game-library-viewer 等上游调用。 // 数据源:Steam 官方 appdetails API (cc=cn&l=schinese) + steamui appinfo(VDF,封面/系列/本地化名) + store DOM 兜底;多地区价格:同 appdetails 变 cc。 // 汇率:AugmentedSteam(主) + open.er-api.com(降级);缓存:内存 LRU 5min + GM_setValue 持久化 24h。 // 拥有/共享状态:由宿主通过 setHostApi 注入(getOwnedGames/isGameOwnedByMe/getGameOwnerNames/getActiveSteamId), // 本库不直接读 storage,完全解耦。 // 暴露 SGLVAppDetail:loadDetail / loadPrices / getOwnershipBadge / getSteamuiInfo / enrichWithSteamui / buildLibraryAssets / toCNY / sanitizeHtml / detectLang / setHostApi。 // 设计目标:UI 无关、可 Node 端单测、可被多个插件复用。 // 更新日志见脚本目录 README。 // @author SGLV // @noframes // ==/UserScript== /* * SGLV App Detail Library v1.0.0 * * 模块切分: * 1) 缓存层 _memCache / _diskCache (5min LRU + 24h 持久化) * 2) 网络层 _http / _getJson / _mapLimit * 3) 解析层 _parseAppdetails / _parsePriceRegion / _parseRate * 4) 业务层 loadDetail / loadPrices / getOwnershipBadge * 5) 工具层 toCNY / formatPrice / sanitizeHtml / detectLang / i18n * * 状态机:loadDetail() 内部根据 cache 命中状态走"同步返回 → 异步刷新"路径; * onProgress(stage, msg) 提供阶段回调,UI 层可显示加载动画。 * * 设计契约: * - 库不依赖 window.SGLVCore / SGLVPinyin / SGLVPinyin 等其他 SGLV 库 * - 不引入 GM_* 以外的浏览器 API * - 单元测试入口见同级 sglv-app-detail.lib.test.js */ (function (root) { 'use strict'; // 防止重复挂载 if (root.SGLVAppDetail && root.SGLVAppDetail.version === '1.2.0') return; // ==================== 常量 ==================== const VERSION = '1.2.0'; const API_VERSION = 1; // 缓存 TTL(毫秒) const MEM_TTL = 5 * 60 * 1000; // 内存 5 分钟 const DISK_TTL = 24 * 60 * 60 * 1000; // 磁盘 24 小时 const FAIL_TTL = 60 * 1000; // 失败重试节流 1 分钟 // 多地区价格(同 SGIS,可按需扩展) const PRIORITY_REGIONS = ['CN', 'US', 'TR', 'AR', 'RU', 'IN', 'BR', 'UA', 'KZ']; const REGION_CONCURRENCY = 3; const RATE_BASE = 'CNY'; const RATE_TTL = 60 * 60 * 1000; // 汇率缓存 1 小时 // appdetails API const APPDETAILS_BASE = 'https://store.steampowered.com/api/appdetails'; const RATE_PRIMARY_URL = 'https://api.augmentedsteam.com/rates/v1'; const RATE_FALLBACK_URL = 'https://open.er-api.com/v6/latest/CNY'; // v1.2.0: steamui appinfo 渠道 — 详情降级源 + 增强字段(libraryAssets/franchise/本地化名) // 数据由 sglv-cover-fallback.lib.js 统一拉取(VDF 解析 + 三级缓存 + CF/404 负缓存), // 该库未加载时本渠道自动跳过(可选依赖,不破坏独立可用性)。 const STEAMUI_ASSET_CDN = 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/'; // ==================== 国际化文案 ==================== // 默认英文 + 简体中文双语字典,由 detectLang() 决定取哪一个 const I18N = { en: { loadStage0: 'Preparing…', loadStage1: 'Fetching game details…', loadStage2: 'Parsing description & media…', loadStage3: 'Almost done…', priceStage0: 'Loading exchange rates…', priceStage1: 'Querying regional prices…', ownershipMine: 'Owned', ownershipShared: 'Family Shared', ownershipNotOwned: 'Not Owned', ownershipOwnedBy: 'Owned by: {names}', sharedFrom: 'Shared from: {names}', notOwnedHint: 'Not in your library', priceFree: 'Free', priceUserRegion: 'Your region', priceLowest: 'Lowest', priceFailed: 'Failed regions', rateFromAS: 'AugmentedSteam', rateFromOER: 'open.er-api.com', }, zh: { loadStage0: '准备中…', loadStage1: '正在获取游戏详情…', loadStage2: '正在解析简介与媒体…', loadStage3: '即将完成…', priceStage0: '正在加载汇率…', priceStage1: '正在查询多地区价格…', ownershipMine: '已拥有', ownershipShared: '家庭共享', ownershipNotOwned: '未拥有', ownershipOwnedBy: '由 {names} 拥有', sharedFrom: '共享自: {names}', notOwnedHint: '不在你的库中', priceFree: '免费', priceUserRegion: '本地区', priceLowest: '最低价', priceFailed: '失败地区', rateFromAS: 'AugmentedSteam', rateFromOER: 'open.er-api.com', }, }; function detectLang() { try { const lang = (root.document && root.document.documentElement && root.document.documentElement.lang) || (root.navigator && root.navigator.language) || 'en'; return /^zh/i.test(lang) ? 'zh' : 'en'; } catch (e) { return 'en'; } } function t(key) { const lang = detectLang(); return (I18N[lang] && I18N[lang][key]) || I18N.en[key] || key; } // ==================== 宿主 API 注入 ==================== // 上层可选择性注入"拥有/共享"相关 API,未注入时相关接口返回 unknown。 // 这样库在脱离 SGLV 主脚本时仍能加载,不会因为缺少依赖而抛错。 const _host = { getOwnedGames: null, // () => Game[] 其中 g.appid / g.owners / g.name isGameOwnedByMe: null, // (g) => boolean getGameOwnerNames: null, // (g) => string "Alice, Bob" getActiveSteamId: null, // () => string '7656xxxx' isGameFamilyShared: null, // 可选:(g) => boolean }; function setHostApi(api) { if (!api || typeof api !== 'object') return; Object.keys(_host).forEach(k => { if (typeof api[k] === 'function') _host[k] = api[k]; }); } // ==================== 缓存层 ==================== // 内存 LRU(简单 Map 即可,只按时间淘汰,5min 足够) const _memCache = new Map(); const _failBlacklist = new Map(); function _memGet(key) { const e = _memCache.get(key); if (!e) return null; if (e.exp < Date.now()) { _memCache.delete(key); return null; } return e.data; } function _memSet(key, data, ttl) { _memCache.set(key, { data, exp: Date.now() + ttl }); // 限制内存大小(> 200 项时清理最老) if (_memCache.size > 200) { const firstKey = _memCache.keys().next().value; _memCache.delete(firstKey); } } function _memClear() { _memCache.clear(); _failBlacklist.clear(); } // 持久化(GM_setValue)——浏览器环境可选 function _diskGet(key) { try { if (typeof GM_getValue === 'function') { const raw = GM_getValue(key, null); if (!raw) return null; const obj = JSON.parse(raw); if (!obj || obj.exp < Date.now()) return null; return obj.data; } } catch (e) { /* ignore */ } return null; } function _diskSet(key, data, ttl) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, JSON.stringify({ data, exp: Date.now() + ttl })); } } catch (e) { /* ignore */ } } // 失败节流(防止网络雪崩) function _isInFailBlacklist(key) { const exp = _failBlacklist.get(key); if (!exp) return false; if (exp < Date.now()) { _failBlacklist.delete(key); return false; } return true; } function _markFail(key) { _failBlacklist.set(key, Date.now() + FAIL_TTL); } // ==================== 网络层 ==================== // 优先委托 sglv-core.lib.js 的 gmFetchText(自动重试 + 状态码校验) // 降级路径:无 SGLVCore 时(GM 隔离/Node 测试)用本地 GM_xmlhttpRequest + fetch function _http(opts) { // SGLVCore 可用:委托(带重试 + HTTP 状态校验) if (root.SGLVCore && root.SGLVCore.gmFetchText) { return root.SGLVCore.gmFetchText(opts.url, opts) .then(text => ({ status: 200, responseText: text })); } return new Promise((resolve, reject) => { // 浏览器环境优先 GM_xmlhttpRequest(突破 CORS) if (typeof GM_xmlhttpRequest === 'function') { GM_xmlhttpRequest({ method: opts.method || 'GET', url: opts.url, headers: opts.headers || { 'Accept': 'application/json' }, timeout: opts.timeout || 12000, anonymous: false, onload(r) { if (r.status >= 200 && r.status < 300) resolve(r); else reject(new Error('HTTP ' + r.status)); }, onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timeout')), }); return; } // 降级到 fetch(无 GM 环境,例如 Node 测试) const ctl = (typeof AbortController === 'function') ? new AbortController() : null; const timer = ctl ? setTimeout(() => ctl.abort(), opts.timeout || 12000) : null; fetch(opts.url, { signal: ctl ? ctl.signal : undefined }) .then(r => { if (timer) clearTimeout(timer); if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }) .then(text => resolve({ status: 200, responseText: text })) .catch(err => { if (timer) clearTimeout(timer); reject(err); }); }); } async function _getJson(url, opts) { // SGLVCore 可用:委托 gmFetchJson(自动重试 + 状态校验 + JSON 解析) if (root.SGLVCore && root.SGLVCore.gmFetchJson) { return root.SGLVCore.gmFetchJson(url, opts); } const r = await _http(Object.assign({ url }, opts)); try { return JSON.parse(r.responseText); } catch (e) { throw new Error('invalid JSON'); } } async function _getText(url, opts) { // SGLVCore 可用:委托 gmFetchText(自动重试) if (root.SGLVCore && root.SGLVCore.gmFetchText) { return root.SGLVCore.gmFetchText(url, opts); } const r = await _http(Object.assign({ url }, opts)); return r.responseText; } // 并发限流(优先委托 SGLVCore.concurrentPool,带 429 降并发 + 重试) async function _mapLimit(items, limit, iter) { if (root.SGLVCore && root.SGLVCore.concurrentPool) { const tasks = items.map((item, idx) => () => iter(item, idx)); const results = await root.SGLVCore.concurrentPool(tasks, limit); // 还原 _mapLimit 契约:out[i] = value 或 { __error: msg } return results.map(r => { if (r.status === 'fulfilled') return r.value; return { __error: r.reason && r.reason.message || String(r.reason) }; }); } // 降级路径:本地简易限流 const out = new Array(items.length); let i = 0; async function worker() { while (true) { const idx = i++; if (idx >= items.length) return; try { out[idx] = await iter(items[idx], idx); } catch (e) { out[idx] = { __error: e && e.message || String(e) }; } } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return out; } // ==================== 解析层 ==================== function _parseAppdetails(payload, appid) { if (!payload || !payload[appid] || !payload[appid].success) return null; const d = payload[appid].data || {}; const ov = d.price_overview || null; const reviews = d.reviews || ''; // HTML 串 // 解析评测:Steam reviews HTML 形如 "..." 等 const reviewSummary = (reviews.match(/game_review_summary[^>]*>([^<]+) ({ id: c.id, description: c.description || '' })) : []; // v1.1.0: 支持语言详细解析(检测 * 标记=完全音频支持,提取脚注说明) const supportedLanguages = (() => { const raw = d.supported_languages || ''; if (!raw) return { list: [], note: '' }; let note = '', body = raw; const brIdx = raw.indexOf('
'); if (brIdx >= 0) { body = raw.slice(0, brIdx); note = raw.slice(brIdx + 4).replace(/<[^>]*>/g, '').trim(); } const langs = body.split(',') .map(s => s.replace(/<[^>]*>/g, '').trim()) .filter(Boolean) .map(name => { const hasAudio = name.includes('*'); return { name: name.replace(/\*/g, '').trim(), audio: hasAudio }; }) .filter(x => x.name); return { list: langs, note }; })(); // v1.1.0: 捆绑包/购买选项(从 package_groups 展开) const packages = []; if (Array.isArray(d.package_groups)) { d.package_groups.forEach(group => { (group.subs || []).forEach(sub => { const priceCents = sub.price_in_cents_with_discount; const isFree = sub.is_free_license === true || priceCents === 0; const optionText = (sub.option_text || '').trim(); let name = optionText, priceText = ''; const dashIdx = optionText.lastIndexOf(' - '); if (dashIdx > 0) { name = optionText.slice(0, dashIdx).trim(); priceText = optionText.slice(dashIdx + 3).trim(); } const savings = sub.percent_savings || 0; packages.push({ packageId: sub.packageid, name, priceText: isFree ? '免费' : priceText, isFree, discount: savings > 0 ? savings : 0, groupTitle: group.title || '', }); }); }); } return { appid: Number(appid), type: d.type || 'game', name: d.name || '', isFree: !!d.is_free, cover: d.header_image || '', developers: Array.isArray(d.developers) ? d.developers.slice() : [], publishers: Array.isArray(d.publishers) ? d.publishers.slice() : [], releaseDate: (d.release_date && d.release_date.date) || '', comingSoon: !!(d.release_date && d.release_date.coming_soon), platforms: { win: !!(d.platforms && d.platforms.windows), mac: !!(d.platforms && d.platforms.mac), linux: !!(d.platforms && d.platforms.linux), }, genres: Array.isArray(d.genres) ? d.genres.map(g => g.description || g.id || '').filter(Boolean) : [], // v1.1.0: genres 同时提供对象数组(genreObjs,供 SGIS resolveTagName ID 查找)和字符串数组(向后兼容) genreObjs: Array.isArray(d.genres) ? d.genres.map(g => ({ id: g.id, description: g.description || '' })) : [], // v1.1.0: categories 同时提供对象数组(categoryObjs)和字符串数组(向后兼容) categories: categoryObjs.map(c => c.description || String(c.id || '')).filter(Boolean), categoryObjs, shortDesc: d.short_description || '', aboutDesc: d.about_the_game || '', detailedDescHtml: d.detailed_description || '', website: d.website || '', requirements: { minimum: (d.pc_requirements && d.pc_requirements.minimum) || '', recommended: (d.pc_requirements && d.pc_requirements.recommended) || '', }, languages: (d.supported_languages || '').split(/[,;]\s*/).filter(Boolean).slice(0, 20), supportedLanguages, // v1.1.0: 详细解析版 {list: [{name, audio}], note} cnPrice: ov ? { currency: ov.currency, price: ov.final / 100, initial: ov.initial / 100, discount: ov.discount_percent || 0, } : (d.is_free ? { currency: 'CNY', price: 0, initial: 0, discount: 0, free: true } : null), reviews: { summary: reviewSummary, count: reviewCount, pos: reviewPos }, metacritic: d.metacritic ? { score: d.metacritic.score, url: d.metacritic.url } : null, media: { screenshots: Array.isArray(d.screenshots) ? d.screenshots.map(s => ({ url: s.path_thumbnail || s.path_full, full: s.path_full })) : [], movies: Array.isArray(d.movies) ? d.movies.map(m => ({ name: m.name, url: m.webm && m.webm.max, thumb: m.thumbnail })) : [], }, // v1.1.0: SGIS 概览所需附加字段 movies: Array.isArray(d.movies) ? d.movies.slice(0, 4).map(m => ({ id: m.id, name: m.name || '', thumbnail: m.thumbnail || '', dash_av1: m.dash_av1 || '', dash_h264: m.dash_h264 || '', hls_h264: m.hls_h264 || '', postcard: m.postcard || m.thumbnail || '', highlight: !!m.highlight, })) : [], screenshots: (Array.isArray(d.screenshots) ? d.screenshots : []).slice(0, 9).map(s => ({ thumbnail: s.path_thumbnail, full: s.path_full, })), packages, controllerSupport: d.controller_support || null, requiredAge: (() => { if (d.required_age === undefined || d.required_age === null || d.required_age === '') return 0; const parsed = parseInt(String(d.required_age).replace(/[^\d]/g, ''), 10); return isNaN(parsed) ? 0 : parsed; })(), contentDescriptors: d.content_descriptors ? { ids: d.content_descriptors.ids || [], notes: d.content_descriptors.notes || '' } : null, background: d.background || d.background_raw || '', achievementsTotal: (d.achievements && d.achievements.total) ? d.achievements.total : 0, recommendations: (d.recommendations && d.recommendations.total) ? d.recommendations.total : 0, dlc: Array.isArray(d.dlc) ? d.dlc.slice(0, 30) : [], }; } function _parsePriceRegion(payload, appid, region) { if (!payload || !payload[appid] || !payload[appid].success) return null; const d = payload[appid].data || {}; if (d.is_free) return { region, price: 0, currency: 'FREE', initial: 0, discount: 0, free: true }; if (!d.price_overview) return null; const ov = d.price_overview; return { region, price: ov.final / 100, currency: ov.currency, initial: ov.initial / 100, discount: ov.discount_percent || 0, }; } // D1: 币种不匹配检测 — 借鉴 SGIS 主脚本 v2.3.7 经验 // Steam appdetails API 对登录用户可能忽略 cc= 参数,返回用户实际所在地区的价格 // (如请求 CN 但返回 INR), 这种数据是错误的,应标记为失败 // 返回 { ok: true } 或 { ok: false, error: string, expected, actual } function validatePriceRegion(priceObj, regionMeta) { if (!priceObj) return { ok: false, error: 'no price', expected: null, actual: null }; // FREE / 0 元 不参与校验 if (priceObj.free || priceObj.currency === 'FREE') return { ok: true }; if (!regionMeta || !regionMeta.currency) return { ok: true }; // 无 region meta 时不强制 if (priceObj.currency === regionMeta.currency) return { ok: true }; return { ok: false, error: `币种不匹配(期望${regionMeta.currency}, 实际${priceObj.currency}, 疑似Steam返回用户本区数据)`, expected: regionMeta.currency, actual: priceObj.currency, }; } function _parseAugmentedRate(payload) { // AugmentedSteam 形如 { "rates": { "USD": 0.14, "EUR": 0.13, ... } }, 基准为 USD if (!payload || !payload.rates) return null; const rates = payload.rates; if (!rates[RATE_BASE]) return null; // 转换为 "1 CNY = X 外币" const result = {}; Object.keys(rates).forEach(cur => { result[cur] = rates[cur] / rates[RATE_BASE]; }); return result; } function _parseOpenER(payload) { // open.er-api.com 形如 { "result": "success", "base_code": "CNY", "rates": { "USD": 0.14, ... } } if (!payload || payload.result !== 'success' || !payload.rates) return null; return Object.assign({}, payload.rates); } // ==================== steamui 渠道(v1.2.0) ==================== // 委托 sglv-cover-fallback.lib.js 拉取 steamui appinfo(规范化结构): // { appid, name, nameLocalized, type, oslist, releasestate, associations[], languages[], // assets: { poster, hero, heroBlur, logo, libraryHeader, smallCapsule, storeHeader } } // 该库未加载时返回 null(渠道跳过,不影响主流程)。 async function getSteamuiInfo(appid, options) { const fb = root.SGLVCoverFallback; if (!fb || typeof fb.getSteamuiAssets !== 'function') return null; try { return await fb.getSteamuiAssets(appid, options || {}); } catch (e) { return null; } } // 单资产组 → URL 结构。group 兼容两种形态: // variants: { english: {image, image2x}, japanese: {...} } (library_assets_full) // langMap: { english: 'hash/header.jpg', ... } (small_capsule / header_image) function _steamuiAssetUrls(appid, group, lang) { if (!group || typeof group !== 'object') return null; const langs = Object.keys(group); if (!langs.length) return null; const l = (lang && group[lang] !== undefined) ? lang : (group.english !== undefined ? 'english' : langs[0]); const v = group[l]; const base = STEAMUI_ASSET_CDN + appid + '/'; if (typeof v === 'string') { return /^https?:\/\//.test(v) ? { url: v, url2x: '', lang: l, langs } : { url: base + v, url2x: '', lang: l, langs }; } if (v && typeof v === 'object') { const url = v.image ? base + v.image : ''; const url2x = v.image2x ? base + v.image2x : ''; if (!url && !url2x) return null; return { url, url2x, lang: l, langs }; } return null; } // steamui 规范化信息 → libraryAssets 结构(kind → {url, url2x, lang, langs}) // kind 覆盖: poster(竖版 600x900) / hero(库横幅) / heroBlur / logo / libraryHeader / capsule / header function buildLibraryAssets(info, lang) { if (!info || !info.assets || typeof info.assets !== 'object') return null; const id = info.appid || 0; if (!id) return null; const kindMap = [ ['poster', 'poster'], ['hero', 'hero'], ['heroBlur', 'heroBlur'], ['logo', 'logo'], ['libraryHeader', 'libraryHeader'], ['capsule', 'smallCapsule'], ['header', 'storeHeader'], ]; const out = {}; let any = false; kindMap.forEach(([kind, key]) => { const u = _steamuiAssetUrls(id, info.assets[key], lang); if (u) { out[kind] = u; any = true; } }); return any ? out : null; } // associations → { developers, publishers, franchise } function _steamuiAssoc(info) { const out = { developers: [], publishers: [], franchise: '' }; (Array.isArray(info.associations) ? info.associations : []).forEach(a => { if (!a || !a.name) return; if (a.type === 'developer') out.developers.push(a.name); else if (a.type === 'publisher') out.publishers.push(a.name); else if (a.type === 'franchise' && !out.franchise) out.franchise = a.name; }); return out; } // 增强已有 detail(主路径成功后的补充,只填空缺字段,不覆盖已有值) // 新增/补全: franchise / libraryAssets / chineseName / localizedNames / developers / publishers / cover / background function enrichWithSteamui(detail, info, lang) { if (!detail || !info) return detail; const assoc = _steamuiAssoc(info); if (!detail.franchise && assoc.franchise) detail.franchise = assoc.franchise; if (!Array.isArray(detail.developers) || !detail.developers.length) { if (assoc.developers.length) detail.developers = assoc.developers.slice(); } if (!Array.isArray(detail.publishers) || !detail.publishers.length) { if (assoc.publishers.length) detail.publishers = assoc.publishers.slice(); } if (info.nameLocalized && typeof info.nameLocalized === 'object') { detail.localizedNames = info.nameLocalized; const cn = info.nameLocalized.schinese || info.nameLocalized.tchinese || ''; if (cn && !detail.chineseName) detail.chineseName = cn; } if (!detail.libraryAssets) { detail.libraryAssets = buildLibraryAssets(info, lang); } // cover/background 兜底(appdetails 失败或未返回时) if (!detail.cover && detail.libraryAssets && detail.libraryAssets.header) { detail.cover = detail.libraryAssets.header.url2x || detail.libraryAssets.header.url; } if (!detail.background && detail.libraryAssets && detail.libraryAssets.hero) { detail.background = detail.libraryAssets.hero.url2x || detail.libraryAssets.hero.url; } detail.steamuiEnriched = true; return detail; } // steamui 规范化信息 → 完整 detail 对象(降级源:两路 appdetails 均失败时) // 字段远少于 appdetails(无价格/简介/评测),但名称/封面/平台/厂商/系列/多语言资产齐全 function _parseSteamuiDetail(info, appid, lang) { if (!info) return null; const assoc = _steamuiAssoc(info); const os = Array.isArray(info.oslist) ? info.oslist : []; const assets = buildLibraryAssets(info, lang); const langList = Array.isArray(info.languages) ? info.languages.slice(0, 20) : []; return { appid: Number(appid), type: String(info.type || 'game').toLowerCase(), name: info.name || '', isFree: false, cover: assets && assets.header ? (assets.header.url2x || assets.header.url) : '', developers: assoc.developers, publishers: assoc.publishers, franchise: assoc.franchise, releaseDate: '', comingSoon: !!(info.releasestate && info.releasestate !== 'released'), platforms: { win: os.includes('windows'), mac: os.includes('mac'), linux: os.includes('linux') }, genres: [], genreObjs: [], categories: [], categoryObjs: [], shortDesc: '', aboutDesc: '', detailedDescHtml: '', website: '', requirements: { minimum: '', recommended: '' }, languages: langList, supportedLanguages: { list: langList.map(n => ({ name: n, audio: false })), note: '' }, cnPrice: null, reviews: { summary: '', count: '', pos: 0 }, metacritic: null, media: { screenshots: [], movies: [] }, movies: [], screenshots: [], packages: [], controllerSupport: null, requiredAge: 0, contentDescriptors: null, background: assets && assets.hero ? (assets.hero.url2x || assets.hero.url) : '', achievementsTotal: 0, recommendations: 0, dlc: [], // v1.2.0 steamui 专属字段 chineseName: (info.nameLocalized && (info.nameLocalized.schinese || info.nameLocalized.tchinese)) || '', localizedNames: info.nameLocalized || null, libraryAssets: assets, steamuiEnriched: true, source: 'steamui', }; } // ==================== 业务层 ==================== // --- 详情加载(主入口) --- async function loadDetail(appid, options) { options = options || {}; const lang = options.lang || detectLang(); const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const useCache = options.useCache !== false; const force = !!options.force; const cacheKey = `detail:${appid}:${lang}`; if (useCache && !force) { const hit = _memGet(cacheKey) || _diskGet(cacheKey); if (hit) { if (onProgress) onProgress('done', t('loadStage3'), hit); return hit; } } if (_isInFailBlacklist(cacheKey) && !force) { throw new Error('detail fetch in cool-down'); } if (onProgress) onProgress('fetch', t('loadStage1')); // 主路径:appdetails API(cc=cn + l=schinese 一次拿到中文描述) let detail = null; try { const url = `${APPDETAILS_BASE}?appids=${appid}&cc=cn&l=schinese`; const payload = await _getJson(url, { timeout: 12000 }); detail = _parseAppdetails(payload, appid); } catch (e) { /* fallback below */ } // 降级路径 1:英文 appdetails if (!detail) { try { const url = `${APPDETAILS_BASE}?appids=${appid}&cc=us&l=english`; const payload = await _getJson(url, { timeout: 12000 }); detail = _parseAppdetails(payload, appid); } catch (e) { /* fallback below */ } } // 降级路径 2:steamui appinfo(名称/多语言封面/平台/厂商/系列;无价格与简介) if (!detail) { try { const info = await getSteamuiInfo(appid, { timeout: 6000 }); detail = _parseSteamuiDetail(info, appid, lang); } catch (e) { /* fallback below */ } } // 降级路径 3:store 页面 DOM 抽取 if (!detail) { try { detail = await _extractFromStorePage(appid); } catch (e) { _markFail(cacheKey); throw e; } } if (!detail) { _markFail(cacheKey); throw new Error('detail not found'); } // v1.2.0: steamui 增强 — 补 franchise/libraryAssets/本地化名/封面兜底 // (appdetails 成功时也执行;steamui 缓存/负缓存由 cover-fallback 管理,通常零开销) if (options.enrichSteamui !== false && detail.source !== 'steamui') { try { const info = await getSteamuiInfo(appid, { timeout: 6000 }); if (info) enrichWithSteamui(detail, info, lang); } catch (e) { /* 增强失败不影响主数据 */ } } if (onProgress) onProgress('parse', t('loadStage2')); // 解析 HTML 描述(清洗广告/截断过长描述) if (detail.detailedDescHtml) detail.detailedDescHtml = sanitizeHtml(detail.detailedDescHtml, 4000); if (detail.aboutDesc) detail.aboutDesc = sanitizeHtml(detail.aboutDesc, 1200); detail.cachedAt = Date.now(); detail.lang = lang; _memSet(cacheKey, detail, MEM_TTL); _diskSet(cacheKey, detail, DISK_TTL); if (onProgress) onProgress('done', t('loadStage3'), detail); return detail; } // store 页面 DOM 抽取(降级,仅取基础字段) async function _extractFromStorePage(appid) { const url = `https://store.steampowered.com/app/${appid}/?l=schinese`; const html = await _getText(url, { timeout: 12000 }); // pick(re, fallback, group=1) — group 参数支持选择第 N 个捕获组(默认 1) const pick = (re, fallback, group) => { const m = html.match(re); const idx = group || 1; return m && m[idx] ? m[idx].trim() : fallback; }; const name = pick(/
]*>([^<]+)]+src="([^"]+)"/, ''); const shortDesc = pick(/
]*>([\s\S]*?)<\/div>/, '').replace(/<[^>]+>/g, '').trim(); // 取第 2 个捕获组:

About This Game

之后的描述内容 const aboutDesc = pick(/
]*>([\s\S]*?)

About This Game<\/h2>([\s\S]*?)<\/div>/, '', 2); const developer = pick(/Developer:<\/b>[\s\S]*?]+>([^<]+)Publisher:<\/b>[\s\S]*?]+>([^<]+)Release Date:<\/b>([^<]+) { try { const url = `${APPDETAILS_BASE}?appids=${appid}&cc=${region}&l=english`; const payload = await _getJson(url, { timeout: 10000 }); return _parsePriceRegion(payload, appid, region); } catch (e) { return null; } }); const prices = []; const failed = []; results.forEach((r, i) => { if (!r) { failed.push({ region: regions[i] }); return; } // D1: 币种不匹配检测 — Steam API 对登录用户可能忽略 cc= 参数, // 返回用户实际所在地区的价格(如请求 CN 但返回 INR), 这种数据是错误的 // 借鉴 SGIS 主脚本 v2.3.7 经验 const meta = REGION_META[r.region]; const v = validatePriceRegion(r, meta); if (!v.ok) { failed.push({ region: r.region, error: v.error }); return; } prices.push(r); }); // 转换为 CNY(等值,便于排序) prices.forEach(p => { if (p.free || p.currency === 'FREE') p.cny = 0; else if (p.currency === RATE_BASE) p.cny = p.price; else p.cny = rateInfo.ready ? toCNY(p.price, p.currency, rateInfo.rates) : null; }); // 排序 prices.sort((a, b) => { if (a.cny == null && b.cny == null) return 0; if (a.cny == null) return 1; if (b.cny == null) return -1; return a.cny - b.cny; }); const out = { appid: Number(appid), prices, failed, rateReady: rateInfo.ready, rateSource: rateInfo.source, baseCurrency: RATE_BASE, userRegion: _detectUserRegion(), cachedAt: Date.now(), }; _memSet(cacheKey, out, MEM_TTL); _diskSet(cacheKey, out, DISK_TTL); if (onProgress) onProgress('done', '', out); return out; } function _detectUserRegion() { try { const m = (root.document && root.document.cookie || '').match(/steamCountry=(\w{2})/); if (m) return m[1]; } catch (e) { /* ignore */ } return 'CN'; } // 汇率拉取(主:AS / 降级:open.er-api) let _ratesCache = null; // { rates, exp, source } async function _ensureRates() { if (_ratesCache && _ratesCache.exp > Date.now()) { return { ready: true, rates: _ratesCache.rates, source: _ratesCache.source }; } // 主:AugmentedSteam try { const data = await _getJson(RATE_PRIMARY_URL, { timeout: 8000 }); const rates = _parseAugmentedRate(data); if (rates) { _ratesCache = { rates, exp: Date.now() + RATE_TTL, source: 'augmentedsteam' }; return { ready: true, rates, source: 'augmentedsteam' }; } } catch (e) { /* fallback below */ } // 降级:open.er-api try { const data = await _getJson(RATE_FALLBACK_URL, { timeout: 8000 }); const rates = _parseOpenER(data); if (rates) { _ratesCache = { rates, exp: Date.now() + RATE_TTL, source: 'open.er-api' }; return { ready: true, rates, source: 'open.er-api' }; } } catch (e) { /* ignore */ } return { ready: false, rates: null, source: null }; } // 公共:获取当前内存中的汇率(委托 _ensureRates,带 1h 内存缓存 + 自动重试) // 返回 { ready, rates, source, baseCurrency } // rates 语义为 1 CNY = X 外币 (baseCurrency=CNY 时的 AugmentedSteam 原值); // 跨脚本消费端如需 USD 枢纽表(「1 USD = X 币种」,键小写 apiCode), // 请用 SGLVCurrency.cnyBaseRatesToUsdRates(rates) 适配,勿自行取倒数混用 SGIS 语义。 // 调用方拿到 rates 后: // - 想换算到 baseCurrency: toCNY(amount, code, rates) ← 库已暴露 // - 想做币种校验: rates[code] !== null 即汇率可用 // 失败时 ready=false, rates=null (调用方应降级到本地拉取) async function getRates(options) { const force = !!(options && options.force); if (force) _ratesCache = null; // 强制刷新 const info = await _ensureRates(); return { ready: info.ready, rates: info.rates, source: info.source, baseCurrency: RATE_BASE, }; } // 公共:region 期望币种表(用于 D1 币种不匹配检测) // 默认内置 9 个 PRIORITY_REGIONS; 调用方可通过 setRegionMeta 覆盖或扩展 // v2.9.53 之前的 SGIS 实现 (steam-game-library-viewer 主脚本 line 12327) 有 17 地区 // 完整映射,这里只内置库需要的 9 地区,扩展由调用方负责 const REGION_META = { CN: { name: '中国', flag: '🇨🇳', currency: 'CNY' }, US: { name: '美国', flag: '🇺🇸', currency: 'USD' }, TR: { name: '土耳其', flag: '🇹🇷', currency: 'TRY' }, AR: { name: '阿根廷', flag: '🇦🇷', currency: 'ARS' }, RU: { name: '俄罗斯', flag: '🇷🇺', currency: 'RUB' }, IN: { name: '印度', flag: '🇮🇳', currency: 'INR' }, BR: { name: '巴西', flag: '🇧🇷', currency: 'BRL' }, UA: { name: '乌克兰', flag: '🇺🇦', currency: 'UAH' }, KZ: { name: '哈萨克斯坦', flag: '🇰🇿', currency: 'KZT' }, }; function setRegionMeta(code, meta) { if (!code || typeof code !== 'string') return; REGION_META[code.toUpperCase()] = Object.assign({}, REGION_META[code.toUpperCase()] || {}, meta || {}); } function getRegionMeta(code) { return code ? REGION_META[String(code).toUpperCase()] : null; } // ==================== 拥有 / 共享 状态徽章 ==================== /** * 计算一个 appid 的"拥有 / 共享"状态,返回 UI 可直接使用的徽章信息。 * 返回结构: * { * kind: 'mine' | 'shared' | 'not-owned' | 'unknown', * label: string, // "已拥有" / "家庭共享" / "未拥有" / "—" * tip: string, // tooltip 文本(包含共享者/拥有者姓名) * className: string, // 'sglv-detail-badge-mine' / 'shared' / 'not-owned' / 'unknown' * ownerNames: string, // 原始共享者/拥有者姓名(逗号分隔) * gameObj: object|null // 找到的 ownedGames 项 * } * 若宿主未注入 API,kind 始终为 'unknown'。 * * 第二个参数 `gameOrMap` 用于 O(1) 快速查找(推荐): * - 传 { appid, owners, name, ... }:直接当 gameObj,跳过 Array.find * - 传 Map:从 Map 取 * - 传 null/undefined:降级到 _host.getOwnedGames() + Array.find */ function getOwnershipBadge(appid, gameOrMap /* optional fast lookup */) { const empty = { kind: 'unknown', label: '—', tip: '', className: 'unknown', ownerNames: '', gameObj: null, }; if (!_host.getOwnedGames) return empty; let game = null; if (gameOrMap instanceof Map) { game = gameOrMap.get(Number(appid)) || null; } else if (gameOrMap && typeof gameOrMap === 'object' && Number(gameOrMap.appid) === Number(appid)) { game = gameOrMap; } if (!game) { try { const all = _host.getOwnedGames() || []; game = all.find(g => Number(g.appid) === Number(appid)) || null; } catch (e) { return empty; } } if (!game) { return { kind: 'not-owned', label: t('ownershipNotOwned'), tip: t('notOwnedHint'), className: 'sglv-detail-badge-not-owned', ownerNames: '', gameObj: null, }; } let isMine = true; try { if (_host.isGameOwnedByMe) isMine = !!_host.isGameOwnedByMe(game); } catch (e) { isMine = true; } let ownerNames = ''; try { if (_host.getGameOwnerNames) ownerNames = _host.getGameOwnerNames(game) || ''; } catch (e) { /* ignore */ } if (isMine) { return { kind: 'mine', label: t('ownershipMine'), tip: ownerNames ? t('ownershipOwnedBy').replace('{names}', ownerNames) : '', className: 'sglv-detail-badge-mine', ownerNames, gameObj: game, }; } return { kind: 'shared', label: t('ownershipShared'), tip: ownerNames ? t('sharedFrom').replace('{names}', ownerNames) : t('ownershipShared'), className: 'sglv-detail-badge-shared', ownerNames, gameObj: game, }; } // ==================== 工具层 ==================== function toCNY(amount, currency, rates) { if (!Number.isFinite(amount)) return null; if (currency === RATE_BASE) return amount; if (!rates || !rates[currency]) return null; // rates[CNY] = 1, rates[USD] = 0.14 → 1 USD = 1/0.14 CNY return amount / rates[currency]; } function formatPrice(amount, currency) { if (!Number.isFinite(amount)) return '—'; if (currency === 'CNY') return `¥${amount.toFixed(2)}`; return `${amount.toFixed(2)} ${currency}`; } // HTML 清洗(白名单,strip script/style/on*) // 安全策略: // 1) 整标签剥离: