// ==UserScript== // @name Steam 游戏库展示 // @namespace steam-game-library-viewer // @version 2.9.76 // @description 在 Steam 商店/社区页面增加中央游戏库模态面板(橱窗/时长/洞察/家庭组/系列分类/绝版/愿望单/PS会免/Epic赠送/年度大作/锁区/DLC收藏,支持卡片/横版封面/列表三种视图)和右侧个人信息面板侧滑栏(概览/洞察/社交/勋章/设置)。v2.9.64 搜索功能代码审核修复:异步竞态token保护、拼音库实时求值、XSS转义、面板关闭状态清理、拼音匹配高亮。更新日志见脚本目录 README。 // @license MIT // @match https://store.steampowered.com/* // @match https://steamcommunity.com/id/*/home/ // @match https://steamcommunity.com/id/*/home // @match https://steamcommunity.com/profiles/*/home/ // @match https://steamcommunity.com/profiles/*/home // @connect api.steampowered.com // @connect store.steampowered.com // @connect steamcommunity.com // @connect cdn.akamai.steamstatic.com // @connect cdn.cloudflare.steamstatic.com // @connect fastly.jsdelivr.net // @connect jsdelivr.net // @connect leanisssharedstorage.blob.core.windows.net // @connect api.augmentedsteam.com // @connect open.er-api.com // @connect flagcdn.com // @connect api.isthereanydeal.com // @connect api.cheapshark.com // @connect steamcardexchange.net // @connect api.deepseek.com // @connect api.openai.com // @connect playstation.com // @connect store.playstation.com // @connect keylol.com // @connect bartervg.com // @connect raw.githubusercontent.com // @connect gamestatus.info // @connect steam-tracker.com // @resource gameDb https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/game-db-v1.1.json // @resource delistedData https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/steam_delisted_apps.json // @resource psplusData https://fastly.jsdelivr.net/gh/oXnMe/psplus-steam-overlay@main/data/psplus-games.json // @require https://update.greasyfork.org/scripts/589437/1894057/SGLV%20Core%20Library%20%28SGLV-Suite%29.js // @require https://update.greasyfork.org/scripts/588484/1896469/SGLV%20%E6%B8%B8%E6%88%8F%E6%94%B6%E8%97%8F%E6%A8%A1%E5%9D%97%20%28Library%29.js // @require https://update.greasyfork.org/scripts/588483/1896445/SGLV%20%E5%85%B1%E4%BA%AB%E6%A0%B7%E5%BC%8F%20%28Library%29.js // @require https://update.greasyfork.org/scripts/589926/1893674/SGLV%20%E5%B0%81%E9%9D%A2%E4%B8%89%E7%BA%A7%E9%99%8D%E7%BA%A7%20%28Library%29.js // @require https://update.greasyfork.org/scripts/589870/1896461/SGLV%20%E4%BA%91%E5%AD%98%E6%A1%A3%E6%A8%A1%E5%9D%97%20%28Library%29.js // @require https://update.greasyfork.org/scripts/590086/1895071/SGLV%20%E6%8B%BC%E9%9F%B3%E5%AD%97%E5%BA%93%20%28Library%29.js // @require https://update.greasyfork.org/scripts/590084/1894626/SGLV%20App%20Detail%20Library.js // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_getResourceText // @grant GM_listValues // @grant unsafeWindow // @run-at document-idle // @tag Steam // @tag games // @icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%238b5cf6'/%3E%3Cstop offset='0.5' stop-color='%233b82f6'/%3E%3Cstop offset='1' stop-color='%2306b6d4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Cg fill='%23fff'%3E%3Crect x='13' y='13' width='17' height='17' rx='3.5' opacity='.96'/%3E%3Crect x='34' y='13' width='17' height='17' rx='3.5' opacity='.78'/%3E%3Crect x='13' y='34' width='17' height='17' rx='3.5' opacity='.78'/%3E%3Crect x='34' y='34' width='17' height='17' rx='3.5' opacity='.96'/%3E%3C/g%3E%3Cpath d='M38 39 L47 44.5 L38 50 Z' fill='url(%23g)'/%3E%3C/svg%3E // ==/UserScript== (function () { 'use strict'; // v2.9.54: 顶层环境守卫 — keylol 等论坛帖子内的 Steam 商店链接卡片以 iframe 形式嵌入 // store.steampowered.com 页面,@match 会命中 iframe 内脚本上下文,导致侧边栏侧滑按钮(sgis-fab) // 和游戏库浮窗(sglv-panel/sglv-overlay)被错误注入到卡片小窗口中,影响帖子正常阅读。 // 检测 window.self !== window.top 时直接退出,跳过所有 UI 初始化。 if (window.self !== window.top) return; const _v = typeof GM_info !== 'undefined' ? GM_info.script.version : 'unknown'; console.log(`%c[Steam 游戏库展示] v${_v} 已启动 · 标签菜单紧凑化 + 全局游戏搜索(中文/拼音缩写/英文子串匹配)`, 'color:#a78bfa;font-weight:bold;font-size:13px'); // ==================== v2.8.3: Toast 非阻塞通知系统 ==================== const sglvToast = { _el: null, _timer: null, _show(msg, type) { if (!this._el) { this._el = document.createElement('div'); this._el.id = 'sglv-toast'; document.body.appendChild(this._el); } this._el.textContent = msg; this._el.className = 'sglv-toast' + (type ? ' sglv-toast-' + type : ''); requestAnimationFrame(() => this._el.classList.add('sglv-toast-show')); if (this._timer) clearTimeout(this._timer); this._timer = setTimeout(() => this._el.classList.remove('sglv-toast-show'), 2500); }, success(msg) { this._show(msg, 'success'); }, error(msg) { this._show(msg, 'error'); }, warning(msg) { this._show(msg, 'warning'); }, info(msg) { this._show(msg, 'info'); } }; // ==================== v2.9.29: 全局 HTML 转义函数(防 XSS) ==================== // 统一 HTML 转义工具,所有 innerHTML 拼接外部数据时必须调用 function escHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ==================== v2.9.20: SGLV Core 依赖注入 ==================== // 从 window.SGLVCore 解构基础设施(业务代码仍按原 const/function 名字使用,零改动) // 原 2.9.15 抽出的 IDB / 负缓存 / StageProgress / makeThrottledProgress / concurrentPool / // twoPhaseFetch / fillPendingSlots / GM 网络包装 现统一来自 sglv-core.lib.js v1.0.0。 const C = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVCore) || window.SGLVCore; if (!C || C.apiVersion !== 1) { console.error('[SGLV] SGLVCore 未加载或版本不匹配,请检查 @require sglv-core.lib.js 是否安装'); return; } // ----- 命名空间 ----- const NS = C.NS; const nsKey = C.nsKey; // ----- IDB KV 库 ----- const sglvIDB = C.IDB; // ----- 缓存 schema 版本号(业务特定默认值,本脚本独有) ----- const CACHE_VERSION = { bundle_db: 2, // 旧版大体积缓存需要 v2 精简 dlc_db: 1, app_types: 1, cover_good_url: 1, game_name: 1, blocked_apps: 1, }; function ensureCacheVersions() { return C.ensureCacheVersions(CACHE_VERSION); } // ----- 负缓存(直接转发 core 暴露的 API) ----- const negCacheGet = C.negCache.get.bind(C.negCache); const negCacheSet = C.negCache.set.bind(C.negCache); const negCacheClear = C.negCache.clear.bind(C.negCache); // ----- 阶段进度 / 节流 / 并发 / 抓取 / 占位 ----- const StageProgress = C.StageProgress; const makeThrottledProgress = C.makeThrottledProgress; const concurrentPool = C.concurrentPool; const twoPhaseFetch = C.twoPhaseFetch; // ----- GM 网络包装(原 sglvGmFetchRetry / sglvGmFetchTextRetry) ----- const sglvGmFetchRetry = C.gmFetchJson; const sglvGmFetchTextRetry = C.gmFetchText; // ==================== v2.8.3: 通用 UI 组件函数 ==================== function createLoadingHtml(text, size = 34) { return `
` + `` + `${text}
`; } // ==================== 供应商-系列-游戏数据库 ==================== // 从远程 @resource 加载,数据源:Azure Blob Storage const GAME_DB = (function() { try { const raw = JSON.parse(GM_getResourceText('gameDb') || '{}'); // 移除 _meta 元数据,避免被解析为供应商分类 const { _meta, ...db } = raw; return db; } catch(e) { console.warn('[SGLV] GAME_DB 加载失败:', e); return {}; } })(); const DELISTED_DB = (function() { try { return JSON.parse(GM_getResourceText('delistedData') || '{}'); } catch(e) { console.warn('[SGLV] DELISTED_DB 加载失败:', e); return {}; } })(); // ==================== v2.9.27: 游戏系列数据(game_series.json) ==================== // 数据源: https://raw.githubusercontent.com/SmallFork/json/main/game_series.json // 经 jsdelivr CDN 加速: https://fastly.jsdelivr.net/gh/SmallFork/json@main/game_series.json // 兼容两种格式: 对象 { 系列名: [{序号, date, appid, name}] }(当前线上格式) // / 扁平数组 [{name, date, appid, 序号, 系列}](旧格式,按"系列"字段分组) const SERIES_DATA_URL = 'https://fastly.jsdelivr.net/gh/SmallFork/json@main/game_series.json'; // v2.9.27: 独立系列库 { seriesName: [{id, name, date}] },与 GAME_DB(供应商)分开展示 let seriesDb = {}; /** * 将 game_series.json 规范化为独立系列库(不做二级分类,不与供应商合并) * 兼容对象格式 { 系列名: [{序号, date, appid, name}] } 与扁平数组 [{name, date, appid, 序号, 系列}] * 返回: { seriesName: [{id, name, date, order}, ...] }(系列内按序号升序) */ function groupSeriesData(seriesData) { const grouped = {}; const pushGame = (seriesName, item) => { const id = Number(item.appid) || 0; if (!id) return; if (!grouped[seriesName]) grouped[seriesName] = []; // 同系列内按 appid 去重 if (grouped[seriesName].some(g => g.id === id)) return; grouped[seriesName].push({ id, name: item.name || '', date: item.date || '', order: Number(item['序号'] || item.order) || 0, }); }; if (Array.isArray(seriesData)) { // 扁平数组格式:按"系列"字段分组 for (const item of seriesData) { const seriesName = String(item['系列'] || item.series || '').trim() || '未分类'; pushGame(seriesName, item); } } else if (seriesData && typeof seriesData === 'object') { // 对象格式:键即系列名 for (const [seriesName, games] of Object.entries(seriesData)) { const name = String(seriesName).trim(); if (!name || !Array.isArray(games)) continue; for (const item of games) pushGame(name, item); } } // 系列内按序号升序(无序号的排后,保持稳定排序) for (const games of Object.values(grouped)) { games.sort((a, b) => (a.order || Infinity) - (b.order || Infinity)); } return grouped; } /** * 异步加载游戏系列数据(带缓存,先读缓存同步分组,再后台拉取最新) */ async function loadSeriesData() { // 1. 先读缓存,同步分组(确保 UI 首次渲染即有系列数据) const cached = cacheGet('seriesData'); if (cached) { const cachedDb = groupSeriesData(cached); if (Object.keys(cachedDb).length > 0) { seriesDb = cachedDb; document.dispatchEvent(new CustomEvent('sglv:series-data-loaded')); } } // 2. 后台异步拉取最新数据 try { const data = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: SERIES_DATA_URL, headers: { 'Accept': 'application/json' }, timeout: 15000, onload(r) { if (r.status < 200 || r.status >= 300) { reject(new Error(`HTTP ${r.status}`)); return; } try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error('JSON parse fail')); } }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); const freshDb = groupSeriesData(data); if (Object.keys(freshDb).length > 0) { cacheSet('seriesData', data, CACHE_TTL.seriesData); seriesDb = freshDb; document.dispatchEvent(new CustomEvent('sglv:series-data-loaded')); let totalGames = 0; for (const games of Object.values(freshDb)) totalGames += games.length; console.log(`[SGLV] 系列分类数据加载成功: ${totalGames} 条,共 ${Object.keys(freshDb).length} 个系列`); } } catch (e) { console.warn('[SGLV] 系列分类数据加载失败:', e.message); } } // ==================== SVG 图标 ==================== const _S = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; const ICONS = { game: ``, grid: ``, list: ``, close: ``, settings: ``, refresh: ``, package: ``, trend: ``, barChart: ``, shield: ``, clock: ``, share: ``, // v2.9.67: 家庭共享图标(双人群组,用于愿望单家庭共享标签) familyShare: ``, heart: ``, home: ``, dollar: ``, tag: ``, gift: ``, library: ``, calendar: ``, // v2.7.2: 即将发售 KPI 图标(火箭,区别于日历图标的年份卡片) rocket: ``, // v2.4.3: 横板封面视图图标(宽横幅卡片) cover: ``, // v2.6.0: PlayStation 四符号图标(三角/圆/叉/方)用于 PS 会免标签页 // v2.9.22: 真实 PlayStation 标识(官方蓝紫色 P + S 经典组合),替代原 4 符号简笔 playstation: ``, // v2.7.9: Epic 赠送标签页图标("E" 字形) // v2.9.22: Epic Games 盾形标识(深色盾形 + 白色 EPIC/GAMES 文字),替代原"E"字简笔 epic: `EPICGAMES`, // v2.9.0: 集换式卡牌图标 card: ``, // v2.9.7: 年度大作标签页图标(奖杯) trophy: ``, // v2.9.12: 锁区游戏标签页图标(锁) lock: ``, // v2.9.33: 我的成就标签页图标(奖杯 + 星芒) achievement: ``, // v2.9.34: 云存档标签页图标(云+下载箭头) cloud: ``, // v2.9.32: DLC 收藏标签页图标(拼图块) puzzle: ``, // v2.9.51: 全局搜索图标(放大镜) search: ``, // v2.9.51: 搜索结果跳转图标(外链箭头) external: ``, }; // ==================== CSS 样式 ==================== // v2.7.1: CSS 经 @require 外部库 sglv-shared-css 加载,库未加载时为空字符串(UI 无样式但功能可用) const CSS_STYLES = (typeof window.SGLVSharedCSS === 'string' && window.SGLVSharedCSS) || ''; GM_addStyle(CSS_STYLES); // v2.9.36: 我的成就标签页 — 左右布局重构(参考设计图3) // v2.9.59: 参考成就优化版设计,增强玻璃态/渐变/微光动画/稀有度边框 GM_addStyle(` /* ====== 主布局:左右分栏 ====== */ .sglv-ach-layout { display: flex; height: 100%; min-height: 400px; background: linear-gradient(180deg, #0d1117 0%, #161b22 100%); border-radius: 8px; overflow: hidden; } /* ====== 左侧边栏 ====== */ .sglv-ach-sidebar { width: 240px; flex-shrink: 0; display: flex; flex-direction: column; background: linear-gradient(180deg, rgba(22,27,34,0.95) 0%, rgba(13,17,23,0.95) 100%); border-right: 1px solid rgba(48,54,61,0.6); overflow-y: auto; } .sglv-ach-sidebar-header { display: flex; align-items: center; gap: 8px; padding: 16px 16px 12px; border-bottom: 1px solid rgba(48,54,61,0.4); } .sglv-ach-sidebar-title { font-size: 15px; font-weight: 700; color: #e6edf3; display: flex; align-items: center; gap: 6px; } .sglv-ach-sidebar-title svg { width: 20px; height: 20px; } /* v2.9.59: 用户信息区 — 参考设计渐变背景+径向光晕 */ .sglv-ach-user-card { padding: 20px 16px; display: flex; flex-direction: column; align-items: center; gap: 6px; border-bottom: 1px solid rgba(48,54,61,0.4); background: linear-gradient(160deg, rgba(99,102,241,0.08), rgba(168,85,247,0.04)); position: relative; overflow: hidden; } .sglv-ach-user-card::after { content: ''; position: absolute; inset: -50%; background: radial-gradient(circle, rgba(168,85,247,0.12), transparent 60%); pointer-events: none; } .sglv-ach-user-avatar { width: 64px; height: 64px; border-radius: 50%; background: linear-gradient(135deg, #fde047, #d97706); display: flex; align-items: center; justify-content: center; flex-shrink: 0; position: relative; box-shadow: 0 8px 24px rgba(251,191,36,0.35); z-index: 1; } .sglv-ach-user-avatar svg { width: 32px; height: 32px; } .sglv-ach-user-level { position: absolute; bottom: -4px; right: -4px; background: #1c2128; border: 2px solid #fde047; border-radius: 10px; padding: 0 5px; font-size: 10px; font-weight: 700; color: #fde047; line-height: 1.4; } .sglv-ach-user-name { font-size: 14px; font-weight: 700; color: #e6edf3; position: relative; z-index: 1; } .sglv-ach-user-pts { font-size: 11px; color: #8b949e; position: relative; z-index: 1; } .sglv-ach-user-pts strong { color: #fbbf24; font-weight: 700; } /* v2.9.59: 分类导航 — 参考设计图标背景+激活渐变 */ .sglv-ach-nav { padding: 8px 0; flex: 1; } .sglv-ach-nav-label { font-size: 10px; font-weight: 700; color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em; padding: 8px 16px 4px; } .sglv-ach-nav-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; margin: 0 8px; cursor: pointer; transition: all 0.15s; border-radius: 10px; } .sglv-ach-nav-item:hover { background: rgba(99,102,241,0.08); } .sglv-ach-nav-item.active { background: linear-gradient(90deg, rgba(99,102,241,0.2), transparent); color: #fff; } .sglv-ach-nav-item-icon { width: 28px; height: 28px; border-radius: 8px; overflow: hidden; flex-shrink: 0; background: rgba(99,102,241,0.12); display: flex; align-items: center; justify-content: center; } .sglv-ach-nav-item-icon svg { width: 18px; height: 18px; } .sglv-ach-nav-item-name { font-size: 12px; font-weight: 600; color: #c9d1d9; flex: 1; } .sglv-ach-nav-item.active .sglv-ach-nav-item-name { color: #fff; font-weight: 700; } .sglv-ach-nav-item-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; flex-shrink: 0; background: rgba(99,102,241,0.15); color: #c7d2fe; } .sglv-ach-nav-item.active .sglv-ach-nav-item-badge { background: #6366f1; color: #fff; } .sglv-ach-nav-item-badge.complete { background: rgba(63,185,80,0.15); color: #3fb950; } .sglv-ach-nav-item-badge.partial { background: rgba(56,139,253,0.15); color: #58a6ff; } .sglv-ach-nav-item-badge.none { background: rgba(255,255,255,0.06); color: #6e7681; } /* v2.9.59: 侧边栏最近解锁 — 参考设计mini-icon渐变 */ .sglv-ach-sidebar-recent { padding: 8px 0 12px; border-top: 1px solid rgba(48,54,61,0.4); } .sglv-ach-sidebar-recent-label { font-size: 10px; font-weight: 700; color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em; padding: 8px 16px 4px; } .sglv-ach-sidebar-recent-item { display: flex; align-items: center; gap: 10px; padding: 6px 16px; transition: background 0.15s; cursor: pointer; border-radius: 8px; margin: 0 8px; } .sglv-ach-sidebar-recent-item:hover { background: rgba(99,102,241,0.06); } .sglv-ach-sidebar-recent-icon { width: 32px; height: 32px; border-radius: 8px; overflow: hidden; flex-shrink: 0; background: linear-gradient(135deg, #6366f1, #a78bfa); display: flex; align-items: center; justify-content: center; } .sglv-ach-sidebar-recent-icon svg { width: 20px; height: 20px; } .sglv-ach-sidebar-recent-info { flex: 1; min-width: 0; } .sglv-ach-sidebar-recent-name { font-size: 11px; font-weight: 600; color: #d2a8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-ach-sidebar-recent-time { font-size: 9px; color: #6e7681; margin-top: 1px; } .sglv-ach-sidebar-recent-empty { font-size: 11px; color: #6e7681; padding: 8px 16px; opacity: 0.6; } /* ====== 右侧主内容区 ====== */ .sglv-ach-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; } /* v2.9.59: 统计概览区 — 参考设计玻璃态容器 */ .sglv-ach-header { display: flex; align-items: center; gap: 20px; padding: 20px 24px; background: linear-gradient(135deg, rgba(30,41,59,0.6), rgba(15,23,42,0.6)); backdrop-filter: blur(20px); border-bottom: 1px solid rgba(99,102,241,0.12); flex-shrink: 0; } .sglv-ach-ring-wrap { position: relative; width: 88px; height: 88px; flex-shrink: 0; } .sglv-ach-ring-svg { width: 100%; height: 100%; transform: rotate(-90deg); } .sglv-ach-ring-bg { fill: none; stroke: rgba(99,102,241,0.12); stroke-width: 6; } .sglv-ach-ring-fill { fill: none; stroke: url(#ach-ring-grad); stroke-width: 6; stroke-linecap: round; transition: stroke-dashoffset 0.8s cubic-bezier(0.4,0,0.2,1); } .sglv-ach-ring-text { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; } .sglv-ach-ring-val { font-size: 22px; font-weight: 800; color: #c7d2fe; line-height: 1; } .sglv-ach-ring-label { font-size: 9px; color: #64748b; margin-top: 2px; } /* === v2.9.40: 成就 KPI 卡片复用 sglv-playtime-kpi-card 统一风格 === */ .sglv-ach-kpi-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; flex: 1; min-width: 0; } .sglv-ach-reset-btn { padding: 8px 16px; border-radius: 10px; border: 1px solid rgba(239,68,68,0.3); background: rgba(239,68,68,0.08); color: #fca5a5; cursor: pointer; font-size: 12px; transition: all 0.2s; flex-shrink: 0; } .sglv-ach-reset-btn:hover { background: rgba(239,68,68,0.18); border-color: rgba(239,68,68,0.5); } /* v2.9.59: 成就内容区 — 参考设计容器圆角 */ .sglv-ach-content { flex: 1; padding: 16px 24px; overflow-y: auto; } .sglv-ach-content-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .sglv-ach-content-title { font-size: 15px; font-weight: 700; color: #e6edf3; } .sglv-ach-content-count { font-size: 11px; color: #64748b; background: rgba(99,102,241,0.1); padding: 3px 10px; border-radius: 8px; font-weight: 600; } .sglv-ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; } /* v2.9.59: 成就卡片 — 参考设计稀有度左边框+hover提升+图标光泽 */ .sglv-ach-card { display: flex; align-items: flex-start; gap: 14px; padding: 16px 18px; border-radius: 14px; background: linear-gradient(135deg, rgba(15,23,42,0.6), rgba(30,41,59,0.4)); border: 1px solid rgba(99,102,241,0.1); transition: all 0.25s; position: relative; overflow: hidden; } .sglv-ach-card::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--ach-rarity, #6366f1); opacity: 0.8; } .sglv-ach-card:hover { transform: translateY(-2px); border-color: rgba(99,102,241,0.3); box-shadow: 0 8px 24px rgba(0,0,0,0.3); } .sglv-ach-card.unlocked { --ach-rarity: #22c55e; border-color: rgba(34,197,94,0.15); background: linear-gradient(135deg, rgba(34,197,94,0.05), rgba(13,17,23,0.4)); } .sglv-ach-card.unlocked.rare { --ach-rarity: #22d3ee; } .sglv-ach-card.unlocked.epic { --ach-rarity: #a78bfa; } .sglv-ach-card.unlocked.legendary { --ach-rarity: #fbbf24; } .sglv-ach-card.locked { --ach-rarity: #6366f1; opacity: 0.6; } .sglv-ach-card.locked.rare { --ach-rarity: #22d3ee; } .sglv-ach-card.locked.epic { --ach-rarity: #a78bfa; } .sglv-ach-card.locked.legendary { --ach-rarity: #fbbf24; } /* v2.9.59: 图标 — 参考设计渐变背景+内阴影+光泽叠加 */ .sglv-ach-card-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; position: relative; background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08)); box-shadow: inset 0 1px 0 rgba(255,255,255,0.1), inset 0 -1px 0 rgba(0,0,0,0.2); } .sglv-ach-card-icon::after { content: ''; position: absolute; inset: 0; border-radius: 14px; background: linear-gradient(135deg, rgba(255,255,255,0.12) 0%, transparent 40%); pointer-events: none; } .sglv-ach-card-icon svg { width: 100%; height: 100%; display: block; } .sglv-ach-card.unlocked .sglv-ach-card-icon { box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), inset 0 -1px 0 rgba(0,0,0,0.2), 0 4px 12px rgba(99,102,241,0.25); } .sglv-ach-card.locked .sglv-ach-card-icon { filter: grayscale(70%) brightness(0.5); } .sglv-ach-card-rarity { position: absolute; top: -2px; right: -2px; width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid #0d1117; z-index: 2; } .sglv-ach-card-rarity.common { background: #8b949e; } .sglv-ach-card-rarity.rare { background: #22d3ee; } .sglv-ach-card-rarity.epic { background: #a78bfa; } .sglv-ach-card-rarity.legendary { background: #fbbf24; } .sglv-ach-card-body { flex: 1; min-width: 0; } .sglv-ach-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; } .sglv-ach-card-name { font-size: 14px; font-weight: 700; color: #e6edf3; display: flex; align-items: center; gap: 6px; } .sglv-ach-card.unlocked .sglv-ach-card-name { color: #4ade80; } /* v2.9.59: NEW 标签 */ .sglv-ach-card-new-tag { font-size: 9px; background: #22c55e; color: #fff; padding: 1px 6px; border-radius: 4px; font-weight: 700; line-height: 1.4; } /* v2.9.59: 点数徽章 — 参考设计渐变背景 */ .sglv-ach-card-pts { font-size: 11px; font-weight: 700; padding: 4px 10px; border-radius: 8px; flex-shrink: 0; white-space: nowrap; background: linear-gradient(135deg, rgba(251,191,36,0.15), rgba(245,158,11,0.1)); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; } .sglv-ach-card.unlocked .sglv-ach-card-pts { background: rgba(34,197,94,0.1); border-color: rgba(34,197,94,0.3); color: #4ade80; } .sglv-ach-card-desc { font-size: 11px; color: #94a3b8; line-height: 1.4; margin-bottom: 8px; } /* v2.9.59: 进度条 — 参考设计微光动画 */ .sglv-ach-card-progress-wrap { display: flex; align-items: center; gap: 8px; } .sglv-ach-card-progress { flex: 1; height: 6px; background: rgba(99,102,241,0.1); border-radius: 3px; overflow: hidden; position: relative; } .sglv-ach-card-progress-fill { height: 100%; border-radius: 3px; transition: width 0.6s cubic-bezier(0.4,0,0.2,1); position: relative; } .sglv-ach-card.unlocked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #22c55e, #4ade80); box-shadow: 0 0 8px rgba(34,197,94,0.5); } .sglv-ach-card.locked .sglv-ach-card-progress-fill { background: linear-gradient(90deg, #58a6ff, #bc8cff); box-shadow: 0 0 8px rgba(99,102,241,0.3); } .sglv-ach-card-progress-fill::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: sglv-ach-shimmer 2.5s infinite; } @keyframes sglv-ach-shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } .sglv-ach-card-progress-val { font-size: 10px; color: #64748b; white-space: nowrap; font-variant-numeric: tabular-nums; } .sglv-ach-card.unlocked .sglv-ach-card-progress-val { color: #4ade80; font-weight: 600; } .sglv-ach-card-unlock-time { font-size: 9px; color: #6e7681; margin-top: 4px; } .sglv-ach-footer { font-size: 10px; color: #6e7681; text-align: center; padding: 10px 0; margin-top: 8px; border-top: 1px solid rgba(48,54,61,0.3); } `); // v2.9.34: 云存档标签页样式 GM_addStyle(` /* ====== 云存档主布局 ====== */ .sglv-cs-wrap { display: flex; gap: 0; height: 100%; min-height: 400px; } .sglv-cs-side { width: 540px; flex-shrink: 0; border-right: 1px solid rgba(255,255,255,0.06); padding: 14px; overflow-y: auto; } .sglv-cs-list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; } /* ====== 状态视图(加载/错误/空) ====== */ .sglv-cs-state-box { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; padding: 40px; text-align: center; width: 100%; } .sglv-cs-state-icon { font-size: 36px; } .sglv-cs-state-msg { font-size: 14px; color: var(--sglv-text-1, #e2e8f0); max-width: 360px; line-height: 1.5; } .sglv-cs-state-tip { font-size: 11px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-action-btn { padding: 8px 20px; border-radius: 8px; border: 1px solid rgba(139,92,246,0.4); background: rgba(139,92,246,0.12); color: #c4b5fd; cursor: pointer; font-size: 13px; transition: all 0.2s; } .sglv-cs-action-btn:hover { background: rgba(139,92,246,0.25); border-color: rgba(139,92,246,0.6); } /* ====== KPI 卡片(v2.9.36: 炫彩边框 + 色彩变体) ====== */ /* === v2.9.40: 云存档 KPI 卡片复用 sglv-playtime-kpi-card 统一风格(4 列均分) === */ .sglv-cs-kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; } @media (max-width: 1100px) { .sglv-cs-kpi-row { grid-template-columns: repeat(2, 1fr) !important; } } /* ====== 仪表盘区块 ====== */ .sglv-cs-section { margin-bottom: 16px; } .sglv-cs-section-title { font-size: 12px; font-weight: 600; color: var(--sglv-text-1, #e2e8f0); margin-bottom: 8px; } /* ====== TOP 10 ====== */ .sglv-cs-top10 { display: flex; flex-direction: column; gap: 6px; } .sglv-cs-top10-item { display: flex; align-items: center; gap: 6px; font-size: 11px; } .sglv-cs-top10-rank { width: 16px; text-align: center; font-weight: 700; color: var(--sglv-text-2, #94a3b8); flex-shrink: 0; } .sglv-cs-top10-name { flex-shrink: 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--sglv-text-1, #e2e8f0); width: 140px; } .sglv-cs-top10-bar-track { flex: 1; height: 8px; background: rgba(255,255,255,0.05); border-radius: 4px; overflow: hidden; min-width: 30px; } .sglv-cs-top10-bar { height: 100%; background: linear-gradient(90deg, #8b5cf6, #6366f1); border-radius: 4px; transition: width 0.4s ease; } .sglv-cs-top10-size { flex-shrink: 0; color: var(--sglv-text-2, #94a3b8); font-size: 10px; min-width: 44px; text-align: right; } /* ====== 分布图 ====== */ /* v2.9.43: 大小分布 + 文件数分布 并排两栏 */ .sglv-cs-dist-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; } .sglv-cs-dist-card { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.05); border-radius: 8px; padding: 10px 8px 8px; } .sglv-cs-dist-card .sglv-cs-section-title { font-size: 11px; margin-bottom: 6px; text-align: center; } .sglv-cs-dist { display: flex; align-items: flex-end; gap: 8px; height: 130px; } .sglv-cs-dist-col { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; min-width: 0; } .sglv-cs-dist-bar-track { width: 100%; flex: 1; display: flex; align-items: flex-end; justify-content: center; min-height: 2px; } .sglv-cs-dist-bar { width: 50%; max-width: 32px; background: linear-gradient(180deg, #8b5cf6, #6366f1); border-radius: 3px 3px 0 0; min-height: 2px; transition: height 0.4s ease; } .sglv-cs-dist-bar-2 { background: linear-gradient(180deg, #3b82f6, #06b6d4); } .sglv-cs-dist-val { font-size: 10px; font-weight: 600; color: var(--sglv-text-1, #e2e8f0); margin-top: 3px; } .sglv-cs-dist-label { font-size: 9px; color: var(--sglv-text-2, #94a3b8); text-align: center; margin-top: 1px; } /* ====== Diff 徽章 ====== */ .sglv-cs-diff-box { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; } .sglv-cs-diff-badge { font-size: 10px; padding: 3px 8px; border-radius: 6px; font-weight: 600; } .sglv-cs-diff-badge.new { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); } .sglv-cs-diff-badge.changed { background: rgba(251,191,36,0.15); color: #fbbf24; border: 1px solid rgba(251,191,36,0.3); } .sglv-cs-diff-badge.removed { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); } /* ====== 底部 ====== */ .sglv-cs-footer { display: flex; align-items: center; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.04); flex-wrap: wrap; } .sglv-cs-source-badge { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: rgba(139,92,246,0.12); color: #c4b5fd; border: 1px solid rgba(139,92,246,0.2); } .sglv-cs-updated { font-size: 10px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-refresh-btn { margin-left: auto; background: none; border: none; color: var(--sglv-text-2, #94a3b8); cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; transition: all 0.2s; } .sglv-cs-refresh-btn:hover { color: #c4b5fd; background: rgba(139,92,246,0.12); } .sglv-cs-refresh-btn svg { width: 14px; height: 14px; } /* ====== 工具栏 ====== */ .sglv-cs-toolbar { display: flex; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; } .sglv-cs-search { flex: 1; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 6px 12px; color: var(--sglv-text-1, #e2e8f0); font-size: 12px; outline: none; transition: border-color 0.2s; } .sglv-cs-search:focus { border-color: rgba(139,92,246,0.5); } .sglv-cs-search::placeholder { color: var(--sglv-text-2, #94a3b8); } .sglv-cs-sort { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 6px 8px; color: var(--sglv-text-1, #e2e8f0); font-size: 12px; outline: none; cursor: pointer; } /* ====== 列表头 ====== */ .sglv-cs-list-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; font-size: 12px; font-weight: 600; color: var(--sglv-text-1, #e2e8f0); flex-shrink: 0; } .sglv-cs-list-count { font-size: 11px; color: var(--sglv-text-2, #94a3b8); background: rgba(255,255,255,0.05); padding: 2px 8px; border-radius: 6px; } /* ====== 游戏列表 ====== */ .sglv-cs-game-list { flex: 1; overflow-y: auto; padding: 0 12px 12px; } .sglv-cs-game-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; transition: background 0.15s; margin-bottom: 4px; } .sglv-cs-game-row:hover { background: rgba(255,255,255,0.04); } .sglv-cs-game-cover { width: 46px; height: 22px; border-radius: 4px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.04); } /* v2.9.44: 通用 cover failed 样式(sglv-cover-fallback.lib.js 添加) */ .sglv-cs-cover-failed, .sglv-cover-failed { object-fit: contain !important; padding: 4px; opacity: 0.6; } .sglv-cs-game-cover-fallback { width: 46px; height: 22px; border-radius: 4px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(139,92,246,0.08)); font-size: 9px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-game-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } .sglv-cs-game-name { font-size: 12px; color: var(--sglv-text-1, #e2e8f0); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sglv-cs-game-appid { font-size: 10px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-game-stats { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .sglv-cs-game-files { font-size: 11px; color: var(--sglv-text-2, #94a3b8); } .sglv-cs-game-size { font-size: 11px; color: var(--sglv-text-1, #e2e8f0); font-weight: 600; min-width: 60px; text-align: right; } .sglv-cs-status-badge { font-size: 9px; padding: 2px 6px; border-radius: 4px; font-weight: 700; } .sglv-cs-status-badge.new { background: rgba(34,197,94,0.15); color: #4ade80; } .sglv-cs-status-badge.changed { background: rgba(251,191,36,0.15); color: #fbbf24; } .sglv-cs-list-empty { text-align: center; padding: 40px 20px; font-size: 13px; color: var(--sglv-text-2, #94a3b8); } `); // v2.9.15: StageProgress 多阶段进度条样式 GM_addStyle(` #sglv-stage-progress { padding: 8px 16px; background: linear-gradient(180deg, rgba(167,139,250,0.08), transparent); border-bottom: 1px solid rgba(167,139,250,0.15); display: block; } #sglv-stage-progress .sglv-stage-progress-row { display: flex; align-items: center; gap: 12px; } #sglv-stage-progress .sglv-stage-progress-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.06); border-radius: 3px; overflow: hidden; position: relative; } #sglv-stage-progress .sglv-stage-progress-fill { height: 100%; background: linear-gradient(90deg, #a78bfa 0%, #38bdf8 100%); width: 0%; transition: width 0.25s ease-out; border-radius: 3px; } #sglv-stage-progress .sglv-stage-progress-text { flex-shrink: 0; font-size: 12px; color: #94a3b8; min-width: 200px; text-align: right; font-variant-numeric: tabular-nums; } `); // v2.9.22: 紧凑页头 + tabs 移入 header(参考 steam-friend-manager 1.2.5 sfd-tab-bar / sfd-tab-btn 风格) GM_addStyle(` /* === 紧凑页头:删除 h2 标题占用空间,padding 收紧 === */ .sglv-panel > .sglv-header { padding: 8px 14px !important; gap: 12px; cursor: move; /* v2.9.22: 整个 header 作为拖动手柄 */ user-select: none; } .sglv-panel > .sglv-header h2 { display: none !important; } /* === v2.9.22: 保留最左侧 Steam 图标作为品牌标识(去掉"游戏库"文字) === */ .sglv-header-brand { width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(102, 192, 244, 0.18), rgba(139, 92, 246, 0.18)); border: 1px solid rgba(102, 192, 244, 0.25); color: #66c0f4; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); transition: transform 0.2s, box-shadow 0.2s; } .sglv-header-brand:hover { transform: rotate(-8deg) scale(1.05); box-shadow: 0 2px 8px rgba(102, 192, 244, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1); } .sglv-header-brand img { width: 14px; height: 14px; display: block; border-radius: 2px; } .sglv-header-brand svg { width: 14px; height: 14px; } .sglv-panel > .sglv-header > .sglv-tab-bar { display: flex; align-items: center; gap: 2px; flex: 1; min-width: 0; overflow: visible; position: relative; } /* === tab 按钮紧凑化(v2.9.46: 等宽等距布局,flex:1 使各标签宽度一致) === */ /* v2.9.51: 进一步紧凑——padding 5px 6px → 4px 5px,font-size 12.5px → 11.5px,为右侧全局搜索框腾出空间 */ .sglv-panel .sglv-tab { flex: 1 1 0 !important; padding: 4px 5px !important; font-size: 11.5px !important; font-weight: 600 !important; border-radius: 6px !important; border: 1px solid transparent !important; background: transparent !important; color: #94a3b8 !important; white-space: nowrap !important; justify-content: center !important; gap: 4px !important; min-width: 0 !important; overflow: hidden !important; text-overflow: ellipsis !important; transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s !important; height: 26px !important; } .sglv-panel .sglv-tab:hover { color: #e2e8f0 !important; background: rgba(255, 255, 255, 0.06) !important; } .sglv-panel .sglv-tab.active { color: #fff !important; background: rgba(102, 192, 244, 0.22) !important; border-color: rgba(102, 192, 244, 0.4) !important; box-shadow: 0 0 0 1px rgba(102, 192, 244, 0.15) inset, 0 1px 4px rgba(102, 192, 244, 0.18) !important; } .sglv-panel .sglv-tab svg { width: 12px; height: 12px; flex-shrink: 0; } .sglv-panel .sglv-tab span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } /* === 头部 action 按钮 hover 反馈 + 状态点 === */ .sglv-panel > .sglv-header > .sglv-header-actions { gap: 6px; flex-shrink: 0; margin-left: auto; } .sglv-panel .sglv-header-btn { width: 30px !important; height: 30px !important; } .sglv-panel .sglv-header-btn.sglv-export-csv-btn, .sglv-panel .sglv-header-btn.sglv-export-json-btn { height: 30px !important; line-height: 30px !important; } /* === 头部开关紧凑(家庭组) === */ .sglv-panel .sglv-switch-container { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: #94a3b8; user-select: none; cursor: pointer; height: 30px; padding: 0 4px 0 6px; border-radius: 6px; transition: background 0.15s, color 0.15s; } .sglv-panel .sglv-switch-container:hover { background: rgba(255, 255, 255, 0.05); color: #e2e8f0; } /* === 头部渐变微调,让 tab active 高光更明显 === */ .sglv-panel > .sglv-header { background: linear-gradient(135deg, rgba(27, 40, 56, 0.95) 0%, rgba(42, 71, 94, 0.95) 100%) !important; border-bottom: 1px solid rgba(102, 192, 244, 0.18) !important; } /* === v2.9.51: tab 菜单紧凑化(保留),并接入全局搜索框 + 下拉结果浮层 === */ /* 全局搜索框:紧贴 wishlist tab 右侧,flex:0 0 auto 不抢占 tab 等宽空间 */ /* v2.9.51 审查:移除 flex-grow:1(与 flex:0 0 auto 冲突,会抢 tab 空间导致布局错乱) */ .sglv-panel .sglv-global-search { flex: 0 0 200px !important; position: relative !important; display: flex !important; align-items: center !important; gap: 4px !important; height: 26px !important; padding: 0 6px 0 8px !important; margin-left: 6px !important; background: rgba(255, 255, 255, 0.06) !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 6px !important; color: #c7d5e0 !important; transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, width 0.15s !important; min-width: 120px !important; max-width: 240px !important; } .sglv-panel .sglv-global-search:hover { background: rgba(255, 255, 255, 0.09) !important; } .sglv-panel .sglv-global-search:focus-within { border-color: rgba(102, 192, 244, 0.55) !important; background: rgba(255, 255, 255, 0.1) !important; box-shadow: 0 0 0 1px rgba(102, 192, 244, 0.25) !important; } .sglv-panel .sglv-global-search-icon { display: inline-flex !important; align-items: center !important; color: #8a9ba8 !important; flex-shrink: 0 !important; } .sglv-panel .sglv-global-search-icon svg { width: 12px !important; height: 12px !important; } .sglv-panel .sglv-global-search input { flex: 1 1 auto !important; min-width: 0 !important; border: none !important; background: transparent !important; color: #fff !important; font-size: 11.5px !important; line-height: 1 !important; outline: none !important; padding: 0 !important; height: 100% !important; } .sglv-panel .sglv-global-search input::placeholder { color: #6b7c8c !important; font-size: 11px !important; } .sglv-panel .sglv-global-search-clear { flex: 0 0 auto !important; display: none !important; align-items: center !important; justify-content: center !important; width: 16px !important; height: 16px !important; padding: 0 !important; border: none !important; border-radius: 4px !important; background: rgba(255, 255, 255, 0.1) !important; color: #c7d5e0 !important; cursor: pointer !important; transition: background 0.15s, color 0.15s !important; } .sglv-panel .sglv-global-search-clear:hover { background: rgba(255, 255, 255, 0.2) !important; color: #fff !important; } .sglv-panel .sglv-global-search-clear svg { width: 9px !important; height: 9px !important; } .sglv-panel .sglv-global-search.has-text .sglv-global-search-clear { display: inline-flex !important; } /* v2.9.58: 搜索结果浮层 — 右对齐搜索框,动画显隐,动态 max-height 防溢出面板 */ .sglv-panel .sglv-global-search-pop { position: absolute !important; top: calc(100% + 6px) !important; right: 0 !important; left: auto !important; min-width: 300px !important; max-width: 400px !important; max-height: calc(80vh - 80px) !important; overflow-y: auto !important; background: rgba(15, 23, 42, 0.98) !important; border: 1px solid rgba(102, 192, 244, 0.35) !important; border-radius: 10px !important; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(0, 0, 0, 0.3) !important; z-index: 1000001 !important; padding: 4px 0 !important; backdrop-filter: blur(12px) !important; display: block !important; opacity: 0; transform: translateY(-6px) scale(0.98); visibility: hidden; transition: opacity 0.16s ease, transform 0.16s ease, visibility 0s linear 0.16s; transform-origin: top right; } .sglv-panel .sglv-global-search-pop::-webkit-scrollbar { width: 6px; } .sglv-panel .sglv-global-search-pop::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 3px; } .sglv-panel .sglv-global-search-pop.show { opacity: 1; transform: translateY(0) scale(1); visibility: visible; transition: opacity 0.16s ease, transform 0.16s ease, visibility 0s; } .sglv-panel .sglv-gs-header { padding: 8px 12px 5px !important; font-size: 10px !important; font-weight: 700 !important; color: #6e7681 !important; text-transform: uppercase !important; letter-spacing: 0.05em !important; display: flex !important; align-items: center !important; justify-content: space-between !important; border-bottom: 1px solid rgba(255, 255, 255, 0.04) !important; } .sglv-panel .sglv-gs-header-count { background: rgba(102, 192, 244, 0.12) !important; color: #66c0f4 !important; font-weight: 700 !important; text-transform: none !important; letter-spacing: 0 !important; padding: 1px 7px !important; border-radius: 10px !important; font-size: 10px !important; } .sglv-panel .sglv-gs-item { display: flex !important; align-items: center !important; gap: 10px !important; padding: 7px 12px !important; cursor: pointer !important; color: #c7d5e0 !important; font-size: 12px !important; transition: background 0.12s, color 0.12s !important; border-left: 2px solid transparent !important; } .sglv-panel .sglv-gs-item:hover, .sglv-panel .sglv-gs-item.focus { background: rgba(102, 192, 244, 0.12) !important; color: #fff !important; border-left-color: rgba(102, 192, 244, 0.6) !important; } .sglv-panel .sglv-gs-item-thumb { width: 48px !important; height: 18px !important; flex-shrink: 0 !important; background: rgba(255, 255, 255, 0.04) !important; border-radius: 3px !important; overflow: hidden !important; display: flex !important; align-items: center !important; justify-content: center !important; transition: transform 0.15s ease !important; } .sglv-panel .sglv-gs-item:hover .sglv-gs-item-thumb, .sglv-panel .sglv-gs-item.focus .sglv-gs-item-thumb { transform: scale(1.08) !important; } .sglv-panel .sglv-gs-item-thumb img { width: 100% !important; height: 100% !important; object-fit: cover !important; } .sglv-panel .sglv-gs-item-body { flex: 1 1 auto !important; min-width: 0 !important; } .sglv-panel .sglv-gs-item-name { font-size: 12px !important; font-weight: 600 !important; color: #e2e8f0 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } .sglv-panel .sglv-gs-item.focus .sglv-gs-item-name { color: #fff !important; } .sglv-panel .sglv-gs-item-name mark { background: rgba(102, 192, 244, 0.35) !important; color: #fff !important; padding: 0 1px !important; border-radius: 2px !important; } .sglv-panel .sglv-gs-item-sub { font-size: 10px !important; color: #8a9ba8 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; margin-top: 1px !important; } /* v2.9.58: 右侧徽章+图标容器,统一收紧间距 */ .sglv-panel .sglv-gs-item-right { display: flex !important; align-items: center !important; gap: 4px !important; flex-shrink: 0 !important; } .sglv-panel .sglv-gs-item-type { flex-shrink: 0 !important; font-size: 9px !important; font-weight: 700 !important; padding: 1px 5px !important; border-radius: 3px !important; text-transform: uppercase !important; letter-spacing: 0.04em !important; } .sglv-panel .sglv-gs-item-type.owned { background: rgba(63, 185, 80, 0.15) !important; color: #3fb950 !important; } .sglv-panel .sglv-gs-item-type.wishlist { background: rgba(245, 158, 11, 0.15) !important; color: #f59e0b !important; } .sglv-panel .sglv-gs-item-type.store { background: rgba(102, 192, 244, 0.15) !important; color: #66c0f4 !important; } /* v2.9.53: 家庭共享次级徽章(紫色,小尺寸,紧贴主 type 徽章右侧) */ .sglv-panel .sglv-gs-item-shared { display: inline-flex !important; align-items: center !important; padding: 1px 6px !important; margin-left: 0 !important; font-size: 10px !important; font-weight: 600 !important; border-radius: 3px !important; background: rgba(139, 92, 246, 0.18) !important; color: #c4b5fd !important; border: 1px solid rgba(139, 92, 246, 0.35) !important; cursor: help !important; } .sglv-panel .sglv-gs-item-ext { display: inline-flex !important; align-items: center !important; color: #6e7681 !important; } .sglv-panel .sglv-gs-item-ext svg { width: 10px !important; height: 10px !important; } .sglv-panel .sglv-gs-empty { padding: 20px 12px !important; text-align: center !important; color: #6e7681 !important; font-size: 11px !important; display: flex !important; flex-direction: column !important; align-items: center !important; gap: 8px !important; } .sglv-panel .sglv-gs-empty-icon { opacity: 0.4 !important; } .sglv-panel .sglv-gs-empty-icon svg { width: 28px !important; height: 28px !important; } .sglv-panel .sglv-gs-footer { padding: 6px 12px !important; border-top: 1px solid rgba(255, 255, 255, 0.05) !important; font-size: 10px !important; color: #6e7681 !important; display: flex !important; align-items: center !important; gap: 10px !important; background: rgba(15, 23, 42, 0.5) !important; } .sglv-panel .sglv-gs-footer-hint { display: flex !important; align-items: center !important; gap: 3px !important; } .sglv-panel .sglv-gs-footer kbd { background: rgba(255, 255, 255, 0.08) !important; border: 1px solid rgba(255, 255, 255, 0.12) !important; border-radius: 3px !important; padding: 0 4px !important; font-size: 9px !important; font-family: monospace !important; color: #c7d5e0 !important; } /* === v2.9.63: 游戏详情浮窗(点击搜索结果后弹出) === */ .sglv-panel .sglv-detail-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000002; border-radius: inherit; opacity: 0; visibility: hidden; transition: opacity 0.16s ease, visibility 0s linear 0.16s; } .sglv-panel .sglv-detail-overlay.show { opacity: 1; visibility: visible; transition: opacity 0.16s ease, visibility 0s; } .sglv-panel .sglv-detail-pop { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -48%) scale(0.96); width: calc(100% - 48px); max-width: 560px; max-height: calc(85vh - 60px); background: linear-gradient(160deg, rgba(23,33,45,0.99), rgba(15,23,42,0.99)); border: 1px solid rgba(102,192,244,0.25); border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(0,0,0,0.3); z-index: 1000003; overflow: hidden; display: flex; flex-direction: column; opacity: 0; visibility: hidden; transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s linear 0.2s; } .sglv-panel .sglv-detail-pop.show { opacity: 1; visibility: visible; transform: translate(-50%, -50%) scale(1); transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s; } .sglv-detail-close { position: absolute; top: 10px; right: 10px; z-index: 5; width: 30px; height: 30px; border-radius: 50%; border: none; background: rgba(0,0,0,0.4); color: #c7d5e0; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.16s, color 0.16s; } .sglv-detail-close:hover { background: rgba(244,67,54,0.5); color: #fff; } .sglv-detail-close svg { width: 16px; height: 16px; } .sglv-detail-scroll { overflow-y: auto; overflow-x: hidden; } .sglv-detail-scroll::-webkit-scrollbar { width: 6px; } .sglv-detail-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } .sglv-detail-hero { position: relative; width: 100%; aspect-ratio: 231/87; overflow: hidden; background: #1a2332; } .sglv-detail-hero img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-detail-hero::after { content:''; position:absolute; inset:0; background: linear-gradient(180deg, transparent 40%, rgba(15,23,42,0.95)); } .sglv-detail-body { padding: 0 18px 18px; } .sglv-detail-name { font-size: 18px; font-weight: 700; color: #e2e8f0; margin: -20px 0 4px; position: relative; z-index: 1; text-shadow: 0 2px 8px rgba(0,0,0,0.8); } .sglv-detail-desc { font-size: 12px; color: #8b949e; line-height: 1.6; margin: 10px 0; } .sglv-detail-meta { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 11px; margin: 10px 0; } .sglv-detail-meta dt { color: #6e7681; font-weight: 600; } .sglv-detail-meta dd { color: #c7d5e0; margin: 0; } .sglv-detail-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 10px 0; } .sglv-detail-tag { font-size: 10px; padding: 2px 8px; border-radius: 4px; background: rgba(102,192,244,0.1); color: #66c0f4; border: 1px solid rgba(102,192,244,0.15); } .sglv-detail-price { display: flex; align-items: center; gap: 8px; margin: 10px 0; } .sglv-detail-price-val { font-size: 16px; font-weight: 700; color: #4ade80; } .sglv-detail-price-discount { font-size: 12px; color: #fbbf24; } .sglv-detail-price-free { font-size: 16px; font-weight: 700; color: #4ade80; } .sglv-detail-shots { display: flex; gap: 6px; overflow-x: auto; margin: 10px 0; padding-bottom: 4px; } .sglv-detail-shots::-webkit-scrollbar { height: 4px; } .sglv-detail-shots::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; } .sglv-detail-shot { flex-shrink: 0; width: 200px; height: 113px; border-radius: 6px; overflow: hidden; background: #1a2332; } .sglv-detail-shot img { width: 100%; height: 100%; object-fit: cover; display: block; } .sglv-detail-actions { display: flex; gap: 8px; margin-top: 14px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,0.06); } .sglv-detail-btn { display: inline-flex; align-items: center; gap: 5px; padding: 8px 16px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; border: none; transition: all 0.16s; text-decoration: none; } .sglv-detail-btn-primary { background: linear-gradient(135deg, #66c0f4, #1a9fff); color: #fff; } .sglv-detail-btn-primary:hover { filter: brightness(1.1); } .sglv-detail-btn-secondary { background: rgba(255,255,255,0.08); color: #c7d5e0; } .sglv-detail-btn-secondary:hover { background: rgba(255,255,255,0.14); } .sglv-detail-btn svg { width: 14px; height: 14px; } .sglv-detail-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; gap: 12px; color: #6e7681; font-size: 12px; } .sglv-detail-error { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; gap: 8px; color: #f87171; font-size: 12px; } .sglv-detail-metacritic { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 2px 8px; border-radius: 4px; font-weight: 700; } /* === v2.9.22: body 首屏骨架占位(避免空数据时出现完全空白) === */ .sglv-body-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; min-height: 320px; padding: 40px 20px; gap: 16px; color: #94a3b8; font-size: 13px; text-align: center; } .sglv-body-loading .sglv-bl-title { font-size: 15px; font-weight: 600; color: #cbd5e1; } .sglv-body-loading .sglv-bl-tip { font-size: 12px; color: #64748b; line-height: 1.6; max-width: 420px; } .sglv-body-loading .sglv-bl-spinner { width: 36px; height: 36px; border: 3px solid rgba(102, 192, 244, 0.15); border-top-color: #66c0f4; border-radius: 50%; animation: sglv-spin 0.9s linear infinite; } .sglv-body-loading .sglv-bl-actions { display: flex; gap: 8px; margin-top: 4px; } .sglv-body-loading .sglv-bl-btn { padding: 6px 14px; font-size: 12px; font-weight: 600; border-radius: 6px; border: 1px solid rgba(102, 192, 244, 0.3); background: rgba(102, 192, 244, 0.1); color: #66c0f4; cursor: pointer; transition: background 0.15s, border-color 0.15s; } .sglv-body-loading .sglv-bl-btn:hover { background: rgba(102, 192, 244, 0.2); border-color: rgba(102, 192, 244, 0.5); } .sglv-body-loading .sglv-bl-btn.primary { background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(102, 192, 244, 0.2)); border-color: rgba(139, 92, 246, 0.4); color: #c4b5fd; } .sglv-body-loading .sglv-bl-btn.primary:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.35), rgba(102, 192, 244, 0.3)); } /* === v2.9.26: 统计仪表布局——热力图行改自适应内容高度(不再撑满 wrap),6月增量SVG加高减少留白 === */ /* v2.9.27: sglv-body 需 min-height:0 确保 flex 子项正确滚动(移动端关键修复) */ .sglv-body { min-height: 0 !important; } /* wrap: flex:1 填满 sglv-body 可用高度,内部 flex column 纵向排列各区块 */ .sglv-playtime-wrap { display: flex !important; flex-direction: column !important; flex: 1 1 0 !important; min-height: 0 !important; gap: 10px !important; overflow-y: auto !important; padding: 12px !important; } /* v2.9.55: 图表行(甜甜圈+趋势) 使用内容高度,不再 flex-grow 撑满 wrap 剩余空间。 低分屏下 flex-grow:1 会吞掉热力图行的可用高度,导致下方热力图/增量图被挤压变形。 改为 flex:0 0 auto 后,各行按内容自然排列,wrap 的 overflow-y:auto 负责纵向滚动。 */ .sglv-playtime-row { flex: 0 0 auto !important; min-height: 0 !important; } /* v2.9.55: 图表列最小高度,确保甜甜圈/趋势图有足够渲染空间 */ .sglv-playtime-col { min-height: 220px !important; } /* v2.9.26: 热力图行 grid 7:3——改成自适应内容高度 + 最小高度,不再撑满 wrap 剩余高度,避免盖住上方 KPI/图表区 */ /* v2.9.55: overflow 从 hidden 改为 visible,允许内容自然溢出由 wrap 的 overflow-y:auto 滚动,而非裁剪变形 */ .sglv-heatmap-acquire-row { display: grid !important; grid-template-columns: 7fr 3fr !important; align-items: stretch !important; min-height: 320px !important; flex: 0 0 auto !important; gap: 10px !important; overflow: visible !important; } .sglv-heatmap-acquire-row .sglv-heatmap-section, .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { flex: none !important; min-width: 0 !important; max-width: none !important; min-height: 280px !important; display: flex !important; flex-direction: column !important; overflow: visible !important; } /* v2.9.26: 热力图 block 撑满 section 高度 */ .sglv-heatmap-acquire-row .sglv-heatmap-block, .sglv-heatmap-acquire-row .sglv-heatmap-mini-skeleton, .sglv-heatmap-acquire-row .sglv-hm-mini-inner { flex: 1 1 0 !important; min-height: 0 !important; } /* v2.9.55: 增量柱图——SVG 用 auto 高度而非 100%,避免容器被压缩时 SVG 跟着变形 */ .sglv-heatmap-acquire-row .sglv-hm-mini-chart { flex: 1 1 auto !important; min-height: 120px !important; } .sglv-heatmap-acquire-row .sglv-hm-mini-chart svg { height: auto !important; max-height: 100% !important; } /* v2.9.26: 热力图 SVG 保持原始尺寸,由 scroll 容器水平滚动展示完整内容 */ .sglv-heatmap-block svg { max-width: 100% !important; height: auto !important; } .sglv-heatmap-scroll svg { max-width: none !important; height: auto !important; display: block !important; } .sglv-heatmap-acquire-row .sglv-heatmap-scroll { overflow-x: auto !important; } /* v2.9.26: 热力图 section 紧凑 padding + scroll 容器居中(SVG 内容垂直居中) */ .sglv-heatmap-acquire-row .sglv-heatmap-section { padding: 10px 12px 8px !important; } .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { padding: 10px 12px 8px !important; } .sglv-heatmap-section > #sglv-pt-heatmap { flex: 1 1 0 !important; min-height: 0 !important; display: flex !important; flex-direction: column !important; justify-content: flex-start !important; } /* v2.9.26: 热力图 scroll 紧凑 padding-bottom */ .sglv-heatmap-scroll { padding-bottom: 2px !important; } /* v2.9.26: heatmap-body 擑满 block 剩余高度,scroll 区域可滚动 + 居中 SVG */ .sglv-heatmap-acquire-row .sglv-heatmap-body { flex: 1 1 0 !important; min-height: 0 !important; display: flex !important; flex-direction: column !important; } .sglv-heatmap-acquire-row .sglv-heatmap-body .sglv-heatmap-scroll { flex: 1 1 0 !important; min-height: 0 !important; display: flex !important; flex-direction: column !important; justify-content: center !important; } .sglv-heatmap-acquire-row .sglv-heatmap-body .sglv-heatmap-scroll > svg { flex: 0 0 auto !important; align-self: flex-start !important; } /* 趋势图折线等比缩放(meet)后居中 */ .sglv-pt-trend-chart { align-items: center !important; } /* v2.9.23: 增量柱图底部统计栏样式 */ .sglv-hm-mini-summary { display: flex; flex-wrap: wrap; gap: 8px; padding: 6px 4px 0; flex-shrink: 0; } .sglv-hm-mini-stat { font-size: 10px; color: var(--sglv-text-secondary); white-space: nowrap; } .sglv-hm-mini-stat b { font-size: 13px; font-weight: 800; color: #e0e0e0; } /* 移动端热力图行恢复上下排列 */ @media (max-width: 900px) { .sglv-heatmap-acquire-row { grid-template-columns: 1fr !important; } } /* === v2.9.27: 移动端统计仪表适配——KPI 卡片防换行 + 热力图高度收缩 === */ /* KPI 卡片内部文字截断,防止窄屏换行变形 */ .sglv-playtime-kpi-value { white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } .sglv-playtime-kpi-sub { white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } .sglv-playtime-kpi-label { white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } @media (max-width: 768px) { /* KPI 卡片:2 列布局时进一步紧凑化 */ .sglv-playtime-kpi-row { gap: 5px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-card { padding: 5px 8px 4px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-value { font-size: 15px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-label { font-size: 9px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-sub { font-size: 8px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-icon { width: 14px !important; height: 14px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-icon svg { width: 9px !important; height: 9px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-unit { font-size: 10px !important; } /* v2.9.55: 热力图行降低最小高度,但仍保持足够渲染空间,由 wrap 滚动而非压缩 */ .sglv-heatmap-acquire-row { min-height: 260px !important; } .sglv-heatmap-acquire-row .sglv-heatmap-section, .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { padding: 8px 10px 6px !important; min-height: 220px !important; } /* 图表行(甜甜圈+趋势) 单列堆叠时限制高度,避免无限增高 */ .sglv-playtime-row { flex: 0 0 auto !important; } .sglv-playtime-col { min-height: 200px !important; max-height: 280px !important; } .sglv-playtime-col > #sglv-pt-donut { min-height: 160px !important; } .sglv-pt-trend-chart { min-height: 160px !important; } } @media (max-width: 480px) { /* 超窄屏:KPI 保持 2 列但进一步缩小 */ .sglv-playtime-kpi-row { gap: 4px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-card { padding: 4px 6px 3px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-value { font-size: 14px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-label { font-size: 8px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-sub { font-size: 7px !important; } /* v2.9.55: 热力图行降低高度但保持最小渲染空间 */ .sglv-heatmap-acquire-row { min-height: 220px !important; } .sglv-heatmap-acquire-row .sglv-heatmap-section, .sglv-heatmap-acquire-row .sglv-heatmap-mini-section { min-height: 180px !important; } /* 图表列限制高度 */ .sglv-playtime-col { min-height: 180px !important; max-height: 240px !important; } /* wrap 内边距收紧 */ .sglv-playtime-wrap { padding: 8px !important; gap: 8px !important; } } /* === v2.9.25: 分页导航——简化单行布局,首页|上页|下页|末页 + 跳页输入 === */ .sglv-tl-pagination, .sglv-pagination, .sglv-pt-pagination { display: flex !important; align-items: center !important; justify-content: center !important; flex-wrap: nowrap !important; gap: 4px !important; padding: 8px 0 2px !important; overflow: hidden !important; } /* 箭头/文本按钮 */ .sglv-page-arrow { min-width: 24px; height: 24px; padding: 0 2px; border: 1px solid rgba(255,255,255,0.1); border-radius: 5px; background: rgba(255,255,255,0.04); color: #c7d5e0; font-size: 13px; line-height: 1; cursor: pointer; transition: var(--sglv-transition); text-align: center; flex-shrink: 0; } .sglv-page-arrow:hover:not(:disabled) { background: rgba(102,192,244,0.15); border-color: rgba(102,192,244,0.4); color: #fff; } .sglv-page-arrow:disabled { opacity: 0.3; cursor: not-allowed; } /* 中文文本按钮——加宽适配文字 */ .sglv-page-text-btn { min-width: auto !important; padding: 0 8px !important; font-size: 11px !important; white-space: nowrap !important; } /* 页码按钮组(已废弃,隐藏) */ .sglv-page-btns { display: none !important; } .sglv-page-btn { display: none !important; } .sglv-page-ellipsis { display: none !important; } /* 跳页输入框——紧凑,回车跳转 */ .sglv-page-jump-input { width: 38px; height: 24px; padding: 0 2px; text-align: center; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; color: #fff; font-size: 11px; outline: none; flex-shrink: 0; -moz-appearance: textfield; } .sglv-page-jump-input::-webkit-inner-spin-button, .sglv-page-jump-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .sglv-page-jump-input:focus { border-color: rgba(102,192,244,0.5); } /* 页码信息——极简 */ .sglv-page-info { font-size: 10px !important; color: var(--sglv-text-secondary) !important; white-space: nowrap !important; flex-shrink: 0 !important; } /* 工具栏内嵌分页——更紧凑,隐藏箭头和跳页 */ .sglv-pagination-inline .sglv-page-arrow { min-width: 20px; height: 20px; font-size: 11px; } .sglv-pagination-inline .sglv-page-btn { min-width: 20px; height: 20px; font-size: 10px; } .sglv-pagination-inline .sglv-page-jump-input { display: none; } .sglv-pagination-inline .sglv-page-info { display: none; } /* === v2.9.23: 统计仪表 KPI 卡片 6 列 1 行紧凑布局 + 扁平化(参考设计图) === */ .sglv-playtime-kpi-row { grid-template-columns: repeat(6, 1fr) !important; gap: 6px !important; } @media (max-width: 1280px) { .sglv-playtime-kpi-row { grid-template-columns: repeat(3, 1fr) !important; } } @media (max-width: 700px) { .sglv-playtime-kpi-row { grid-template-columns: repeat(2, 1fr) !important; } } /* 6 列 1 行时各元素字号紧凑 */ .sglv-playtime-kpi-row .sglv-playtime-kpi-value { font-size: 17px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-label { font-size: 10px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-sub { font-size: 9px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-icon { width: 16px !important; height: 16px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-icon svg { width: 10px !important; height: 10px !important; } .sglv-playtime-kpi-row .sglv-playtime-kpi-unit { font-size: 11px !important; } /* v2.9.23: KPI 卡片扁平化——弱化边框,靠左侧色条 + 背景色区分,参考设计图 */ .sglv-playtime-kpi-card { border-width: 1px !important; border-left-width: 3px !important; } /* === v2.9.22: 游玩率/完成度 KPI 卡片颜色变体(cyan/rose)+ % 单位字号 === */ .sglv-playtime-kpi-card.cyan { border-color: rgba(34, 211, 238, 0.35); } .sglv-playtime-kpi-card.cyan::before { background: #22d3ee; } .sglv-playtime-kpi-card.cyan .sglv-playtime-kpi-value { color: #22d3ee; } .sglv-playtime-kpi-card.cyan .sglv-playtime-kpi-icon { background: rgba(34, 211, 238, 0.15); color: #22d3ee; } .sglv-playtime-kpi-card.rose { border-color: rgba(244, 63, 94, 0.35); } .sglv-playtime-kpi-card.rose::before { background: #f43f5e; } .sglv-playtime-kpi-card.rose .sglv-playtime-kpi-value { color: #fb7185; } .sglv-playtime-kpi-card.rose .sglv-playtime-kpi-icon { background: rgba(244, 63, 94, 0.15); color: #fb7185; } .sglv-playtime-kpi-unit { font-size: 13px; font-weight: 700; opacity: 0.85; margin-left: 2px; } /* === v2.9.22: 入库趋势页 KPI 紧凑布局(4 卡放左半侧顶部)+ 时间线撑到顶部 === */ .sglv-trend-kpi-row-compact { gap: 6px !important; margin-bottom: 8px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-card { padding: 7px 10px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-value { font-size: 17px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-label { font-size: 10px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-sub { font-size: 9px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-icon { width: 16px !important; height: 16px !important; } .sglv-trend-kpi-row-compact .sglv-trend-kpi-icon svg { width: 10px !important; height: 10px !important; } /* 时间线顶到页面顶部:rightCol 整体高度撑满 */ .sglv-trend-split-right { align-self: stretch; } /* === v2.9.48: KPI 两行布局 + 新色彩变体 === */ .sglv-trend-kpi-wrap { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; flex-shrink: 0; } .sglv-trend-kpi-wrap .sglv-trend-kpi-row { margin-bottom: 0 !important; } .sglv-trend-kpi-card.green { border-color: rgba(52,211,153,0.3); } .sglv-trend-kpi-card.green::before { background: #34d399; } .sglv-trend-kpi-card.green .sglv-trend-kpi-value { color: #34d399; } .sglv-trend-kpi-card.green .sglv-trend-kpi-icon { background: rgba(52,211,153,0.15); color: #34d399; } .sglv-trend-kpi-card.orange { border-color: rgba(245,158,11,0.3); } .sglv-trend-kpi-card.orange::before { background: #f59e0b; } .sglv-trend-kpi-card.orange .sglv-trend-kpi-value { color: #f59e0b; } .sglv-trend-kpi-card.orange .sglv-trend-kpi-icon { background: rgba(245,158,11,0.15); color: #f59e0b; } .sglv-trend-kpi-card.cyan { border-color: rgba(6,182,212,0.3); } .sglv-trend-kpi-card.cyan::before { background: #06b6d4; } .sglv-trend-kpi-card.cyan .sglv-trend-kpi-value { color: #22d3ee; } .sglv-trend-kpi-card.cyan .sglv-trend-kpi-icon { background: rgba(6,182,212,0.15); color: #22d3ee; } .sglv-trend-kpi-card.purple { border-color: rgba(167,139,250,0.3); } .sglv-trend-kpi-card.purple::before { background: #a78bfa; } .sglv-trend-kpi-card.purple .sglv-trend-kpi-value { color: #a78bfa; } .sglv-trend-kpi-card.purple .sglv-trend-kpi-icon { background: rgba(167,139,250,0.15); color: #a78bfa; } .sglv-trend-kpi-unit { font-size: 10px; font-weight: 600; opacity: 0.7; margin-left: 2px; } /* === v2.9.48: 右侧标签切换 === */ .sglv-trend-right-tabs { display: flex; gap: 2px; margin-bottom: 10px; flex-shrink: 0; border-bottom: 1px solid rgba(148,163,184,0.12); } .sglv-trend-right-tab { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; font-size: 12px; font-weight: 600; color: #8a9ba8; background: transparent; border: none; border-bottom: 2px solid transparent; cursor: pointer; transition: color 0.15s, border-color 0.15s; white-space: nowrap; } .sglv-trend-right-tab svg { width: 14px; height: 14px; } .sglv-trend-right-tab:hover { color: #cbd5e1; } .sglv-trend-right-tab.active { color: var(--sglv-accent, #66c0f4); border-bottom-color: var(--sglv-accent, #66c0f4); } .sglv-trend-right-content { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } /* === v2.9.48: 频率分析图表 === */ .sglv-freq-scroll { flex: 1; overflow-y: auto; min-height: 0; padding-right: 2px; } .sglv-freq-scroll::-webkit-scrollbar { width: 5px; } .sglv-freq-scroll::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.2); border-radius: 3px; } .sglv-freq-summary-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 14px; } .sglv-freq-summary-card { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 6px; background: rgba(255,255,255,0.03); border: 1px solid rgba(148,163,184,0.1); border-radius: 8px; text-align: center; } .sglv-freq-summary-label { font-size: 9px; color: #64748b; font-weight: 600; } .sglv-freq-summary-value { font-size: 16px; font-weight: 700; color: #e2e8f0; } .sglv-freq-summary-sub { font-size: 9px; color: #475569; } .sglv-freq-chart-section { margin-bottom: 14px; } .sglv-freq-chart-title { font-size: 11px; font-weight: 700; color: #94a3b8; margin-bottom: 6px; } .sglv-freq-chart svg rect { transition: opacity 0.15s; } .sglv-freq-chart svg rect[data-v] { cursor: pointer; } .sglv-freq-chart-labels { display: flex; font-size: 9px; color: #475569; margin-top: 3px; } .sglv-freq-chart-labels span { flex: 1; text-align: center; } .sglv-freq-chart-labels-hr span { font-size: 8px; } /* === v2.9.22: 游玩数据页——家庭成员对比紧凑到右半侧底部,Top 20 撑到更高视野 === */ .sglv-playtime-split-right { gap: 0; } /* 家庭对比放右半侧后紧凑显示:限制高度 + 内部滚动 */ .sglv-playtime-split-right > .sglv-family-compare-section { flex-shrink: 0; margin: 10px 0 0; padding: 10px 12px; max-height: 200px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; } .sglv-playtime-split-right > .sglv-family-compare-section .sglv-playtime-section-title { margin-bottom: 6px; font-size: 12px; } .sglv-playtime-split-right > .sglv-family-compare-section .sglv-family-compare-container { margin-top: 0; max-height: 140px; overflow-y: auto; } /* v2.9.69: 游戏橱窗 KPI 扁平化——与统计仪表/入库趋势页一致,左侧色条+紧凑 padding */ .sglv-stats-dashboard .sglv-stat-dash-card { padding: 6px 10px 5px !important; border-left-width: 3px !important; } @keyframes sglv-skel-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 0.9; } } @keyframes sglv-skel-shimmer-x { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } .sglv-heatmap-skeleton svg rect { animation: sglv-skel-pulse 1.6s ease-in-out infinite; } .sglv-heatmap-skeleton svg rect:nth-child(odd) { animation-delay: 0.15s; } .sglv-heatmap-skeleton svg rect:nth-child(3n) { animation-delay: 0.3s; } .sglv-heatmap-mini-skeleton rect { animation: sglv-skel-pulse 1.4s ease-in-out infinite; } .sglv-heatmap-mini-skeleton rect:nth-child(odd) { animation-delay: 0.2s; } .sglv-family-bar-skel .sglv-family-bar-track { background: rgba(255, 255, 255, 0.04) !important; } .sglv-family-bar-skel .sglv-family-bar-name, .sglv-family-bar-skel .sglv-family-bar-hours { background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.04) 100%) !important; background-size: 200% 100%; animation: sglv-skel-shimmer-x 1.6s linear infinite; } .sglv-family-bar-skel:nth-child(2) .sglv-family-bar-name, .sglv-family-bar-skel:nth-child(2) .sglv-family-bar-hours { animation-delay: 0.15s; } .sglv-family-bar-skel:nth-child(3) .sglv-family-bar-name, .sglv-family-bar-skel:nth-child(3) .sglv-family-bar-hours { animation-delay: 0.3s; } .sglv-family-bar-skel:nth-child(4) .sglv-family-bar-name, .sglv-family-bar-skel:nth-child(4) .sglv-family-bar-hours { animation-delay: 0.45s; } `); // v2.9.15: SGIS 侧边栏视觉增强(tab 动画 + 标题图标精致化 + 状态点) GM_addStyle(` /* === tab 容器:增加底部高光 + tab 之间分隔感 === */ .sgis-tabs { position: relative; } .sgis-tabs::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: -1px; height: 1px; background: linear-gradient(90deg, transparent, rgba(139, 92, 246, 0.35), transparent); pointer-events: none; } /* === tab 本身:图标上浮 + 文字微微缩放 === */ .sgis-tab .sgis-svg { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } .sgis-tab:hover .sgis-svg { transform: translateY(-1px) scale(1.08); } .sgis-tab.active .sgis-svg { transform: translateY(-0.5px) scale(1.12); filter: drop-shadow(0 1px 4px rgba(139, 92, 246, 0.45)); } .sgis-tab span { transition: font-weight 0.2s, letter-spacing 0.2s; } .sgis-tab.active span { font-weight: 700; letter-spacing: 0.3px; } /* === 标题图标:更精致渐变 + 内阴影 === */ .sgis-title-icon { background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 45%, #3b82f6 100%) !important; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 -1px 0 rgba(0, 0, 0, 0.2) !important; } .sgis-title-icon::after { background: linear-gradient(180deg, rgba(255, 255, 255, 0.18) 0%, transparent 45%) !important; } /* === 右上角 action 按钮:加 active 反馈 === */ .sgis-icon-btn:active { transform: translateY(0) scale(0.94) !important; } .sgis-icon-btn.sgis-spin svg { animation: sgis-spin 0.8s linear infinite, sgis-pulse 1.4s ease-in-out infinite; } @keyframes sgis-pulse { 0%, 100% { box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25); } 50% { box-shadow: 0 2px 14px rgba(139, 92, 246, 0.6); } } /* === SGIS 整体滚动条更精致 === */ .sgis-body::-webkit-scrollbar-thumb { background: linear-gradient(180deg, rgba(139, 92, 246, 0.45), rgba(59, 130, 246, 0.45)) !important; } /* === SGIS 状态指示器(用于加载/错误/空状态) === */ .sgis-state-icon { width: 48px; height: 48px; border-radius: 14px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.08)); border: 1px solid rgba(139, 92, 246, 0.2); display: inline-flex; align-items: center; justify-content: center; margin-bottom: 10px; color: #a78bfa; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); } .sgis-state-icon svg { width: 22px; height: 22px; } /* === 错误状态用红 === */ .sgis-state.error .sgis-state-icon { background: linear-gradient(135deg, rgba(244, 63, 94, 0.15), rgba(244, 63, 94, 0.05)); border-color: rgba(244, 63, 94, 0.3); color: #f87171; } /* === v2.9.15: 徽章标签页英雄区(顶部总览) === */ .sgis-badge-hero { position: relative; background: linear-gradient(135deg, rgba(139, 92, 246, 0.18) 0%, rgba(59, 130, 246, 0.1) 50%, var(--sgis-bg-2) 100%); border: 1px solid rgba(139, 92, 246, 0.3); border-radius: 12px; padding: 14px 14px 12px; margin-bottom: 10px; overflow: hidden; box-shadow: 0 4px 16px rgba(139, 92, 246, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.05); } .sgis-badge-hero::before { content: ""; position: absolute; top: -40px; right: -30px; width: 140px; height: 140px; background: radial-gradient(circle, rgba(139, 92, 246, 0.25) 0%, transparent 65%); pointer-events: none; } .sgis-badge-hero::after { content: ""; position: absolute; bottom: -30px; left: -20px; width: 100px; height: 100px; background: radial-gradient(circle, rgba(6, 182, 212, 0.15) 0%, transparent 60%); pointer-events: none; } .sgis-badge-hero > * { position: relative; z-index: 1; } .sgis-badge-hero-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 10px; } .sgis-badge-hero-stat { text-align: center; padding: 6px 4px; background: rgba(255, 255, 255, 0.04); border-radius: 7px; border: 1px solid rgba(255, 255, 255, 0.05); } .sgis-badge-hero-stat.highlight { background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(20, 184, 166, 0.08)); border-color: rgba(16, 185, 129, 0.3); } .sgis-badge-hero-stat-val { font-size: 18px; font-weight: 800; color: #fff; line-height: 1.1; font-variant-numeric: tabular-nums; letter-spacing: -0.5px; } .sgis-badge-hero-stat.highlight .sgis-badge-hero-stat-val { color: #34d399; } .sgis-badge-hero-stat-lbl { font-size: 9px; color: var(--sgis-text-2); margin-top: 2px; font-weight: 600; } .sgis-badge-hero-stat.highlight .sgis-badge-hero-stat-lbl { color: #6ee7b7; } .sgis-badge-hero-progress { margin-bottom: 12px; } .sgis-badge-hero-progress-label { display: flex; justify-content: space-between; font-size: 10px; color: var(--sgis-text-2); margin-bottom: 4px; font-weight: 600; } .sgis-badge-hero-progress-label span:last-child { color: #c4b5fd; } .sgis-badge-hero-progress-bar { height: 6px; background: rgba(255, 255, 255, 0.06); border-radius: 3px; overflow: hidden; box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); } .sgis-badge-hero-progress-fill { height: 100%; background: linear-gradient(90deg, #10b981 0%, #34d399 100%); border-radius: 3px; transition: width 0.5s; box-shadow: 0 0 6px rgba(16, 185, 129, 0.4); } .sgis-badge-hero-levels { padding-top: 10px; border-top: 1px dashed rgba(255, 255, 255, 0.08); } .sgis-badge-hero-levels-label { font-size: 10px; color: var(--sgis-text-2); margin-bottom: 6px; font-weight: 600; } .sgis-badge-hero-levels-bars { display: flex; align-items: flex-end; justify-content: space-around; gap: 4px; height: 50px; } .sgis-badge-hero-level-cell { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; } .sgis-badge-hero-level-bar { width: 100%; min-height: 3px; max-width: 24px; background: linear-gradient(180deg, #8b5cf6 0%, #6366f1 100%); border-radius: 3px 3px 0 0; flex: 1; align-self: flex-end; display: flex; align-items: flex-start; justify-content: center; padding-top: 2px; box-shadow: 0 0 6px rgba(139, 92, 246, 0.3); transition: height 0.4s; } .sgis-badge-hero-level-count { font-size: 9px; color: #fff; font-weight: 700; font-variant-numeric: tabular-nums; } .sgis-badge-hero-level-num { font-size: 9px; color: var(--sgis-text-2); font-weight: 600; } /* === v2.9.15: 章节标题 meta(右侧次要信息) === */ .sgis-section-title-meta { font-size: 10px; color: var(--sgis-text-2); font-weight: 500; text-transform: none; letter-spacing: 0; margin-left: auto; } /* === v2.9.38: 洞察页成就引导入口(替代旧折叠列表) === */ .sgis-insight-ach-entry { display: flex; align-items: center; gap: 12px; padding: 12px 14px; background: linear-gradient(135deg, rgba(251, 191, 36, 0.08), rgba(245, 158, 11, 0.04)); border: 1px solid rgba(251, 191, 36, 0.18); border-radius: 10px; transition: all 0.25s ease; margin-top: 2px; } .sgis-insight-ach-entry:hover { background: linear-gradient(135deg, rgba(251, 191, 36, 0.14), rgba(245, 158, 11, 0.08)); border-color: rgba(251, 191, 36, 0.35); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(251, 191, 36, 0.12); } .sgis-insight-ach-entry > svg { width: 36px; height: 36px; flex-shrink: 0; filter: drop-shadow(0 2px 4px rgba(251, 191, 36, 0.25)); } .sgis-insight-ach-entry-info { flex: 1; min-width: 0; } .sgis-insight-ach-entry-title { font-size: 12px; font-weight: 700; color: #fef3c7; margin-bottom: 2px; line-height: 1.3; } .sgis-insight-ach-entry-sub { font-size: 10px; color: var(--sgis-text-2); line-height: 1.4; } .sgis-insight-ach-entry-btn { flex-shrink: 0; padding: 5px 10px; background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #1c0a01; border: none; border-radius: 6px; font-size: 10px; font-weight: 700; cursor: pointer; transition: all 0.2s ease; white-space: nowrap; } .sgis-insight-ach-entry-btn:hover { background: linear-gradient(135deg, #fcd34d, #fbbf24); transform: translateY(-1px); box-shadow: 0 2px 6px rgba(251, 191, 36, 0.4); } .sgis-insight-ach-entry-btn:active { transform: translateY(0); } /* === v2.9.15: 徽章 group 头增强(加进度条 + 已合成数) === */ .sgis-badge-group-head { gap: 10px; padding: 9px 12px; } .sgis-badge-group-head:hover { background: linear-gradient(135deg, rgba(139, 92, 246, 0.05), rgba(59, 130, 246, 0.02)); } .sgis-badge-group-progress { width: 100%; margin-top: 6px; height: 3px; background: rgba(255, 255, 255, 0.05); border-radius: 2px; overflow: hidden; } .sgis-badge-group-progress-fill { height: 100%; background: linear-gradient(90deg, #10b981, #34d399); border-radius: 2px; } .sgis-badge-group-stats { flex-direction: column; align-items: flex-end; gap: 3px; } .sgis-badge-group-stat-val { font-size: 11px; font-weight: 700; color: #c4b5fd; } .sgis-badge-group-stat-lbl { font-size: 9px; color: var(--sgis-text-3); margin-left: 4px; } /* === v2.9.15: 徽章 footer 折叠说明 === */ .sgis-badge-footer-details { margin-top: 14px; padding: 8px 10px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 7px; font-size: 10px; color: var(--sgis-text-2); } .sgis-badge-footer-details summary { cursor: pointer; font-weight: 600; user-select: none; list-style: none; display: flex; align-items: center; gap: 5px; } .sgis-badge-footer-details summary::-webkit-details-marker { display: none; } .sgis-badge-footer-details summary::before { content: "▸"; display: inline-block; transition: transform 0.2s; font-size: 9px; color: var(--sgis-text-3); } .sgis-badge-footer-details[open] summary::before { transform: rotate(90deg); } .sgis-badge-footer-content { padding-top: 8px; line-height: 1.6; } .sgis-badge-footer-formula { margin-bottom: 4px; } .sgis-badge-footer-formula b { color: #c4b5fd; } .sgis-badge-footer-breakdown { font-size: 9px; color: var(--sgis-text-3); } .sgis-badge-footer-meta { font-size: 9px; color: var(--sgis-text-3); margin-top: 2px; } /* === v2.9.15 → v2.9.18 → v2.9.21: 价值卡片单列布局(共享库默认 flex 单列,在窄侧边栏里 2 列挤压变形) === v2.9.18 尝试 2 列 grid + minmax(0,1fr) + 三层 min-width:0 链式压制,但实际渲染出单卡仍被竖向挤压 (游戏名截断为 S..、Lv./50/XP 竖排)—— 在 300px 宽的 SGIS 侧边栏里 2 列宽度仍不够放下 56px img + 38px score + 名字 + market。 v2.9.21 退回单列布局,删掉所有过紧的子项压缩样式,卡片按 flex 自然撑开,信息完整可读。 历史: 共享库 sglv-shared-css.lib.js:1467 的 .sgis-value-cards { display:flex; flex-direction:column; gap:6px; } 直接可用。 这里只追加 !important 防止共享库默认被覆盖即可,不再做 2 列。 */ .sgis-value-cards { display: flex !important; flex-direction: column !important; gap: 6px !important; width: 100% !important; max-width: 100% !important; box-sizing: border-box; } /* 单元本身保持原样,不压制 min-width,让 flex 子项(img 56 + score 38 + market 文本)自然撑开 */ /* === v2.9.17: 愿望单首次加载骨架屏(避免页面紧缩) === */ @keyframes sglv-skel-shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } .sglv-skel-bar { background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0%, rgba(139, 92, 246, 0.12) 50%, rgba(255, 255, 255, 0.04) 100%); background-size: 200% 100%; animation: sglv-skel-shimmer 1.6s linear infinite; border-radius: 4px; } .sglv-wl-kpi { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; margin-bottom: 10px; } .sglv-wl-kpi-cell { background: linear-gradient(135deg, rgba(139, 92, 246, 0.06), rgba(59, 130, 246, 0.03)); border: 1px solid rgba(139, 92, 246, 0.15); border-radius: 8px; padding: 10px 8px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 6px; } .sglv-skel-num { width: 50%; height: 18px; } .sglv-skel-lbl { width: 70%; height: 9px; } .sglv-skel-section-title { width: 40%; height: 12px; margin-bottom: 8px; } .sglv-skel-row { width: 100%; height: 10px; margin-bottom: 6px; opacity: 0.85; } .sglv-skel-row-short { width: 60%; opacity: 0.6; } .sglv-skel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; padding: 4px 0; } .sglv-skel-game-card { background: rgba(255, 255, 255, 0.025); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 8px; } .sglv-skel-cover { width: 100%; aspect-ratio: 184 / 69; background: linear-gradient(90deg, rgba(139, 92, 246, 0.08) 0%, rgba(139, 92, 246, 0.18) 50%, rgba(139, 92, 246, 0.08) 100%); background-size: 200% 100%; animation: sglv-skel-shimmer 1.6s linear infinite; border-radius: 4px; } .sglv-skel-card-name { width: 80%; height: 11px; } .sglv-skel-card-meta { width: 50%; height: 9px; } .sglv-wl-skel-tip { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 18px 12px; margin-top: 12px; color: var(--sglv-text-secondary, #94a3b8); font-size: 12px; font-weight: 500; } .sglv-wl-skel-tip .sglv-spinner { width: 16px; height: 16px; border-width: 2px; } /* === v2.9.47: 愿望单加载等待动画(参考 DLC spinner 风格) === */ .sglv-wl-loading { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:48px 20px; gap:14px; color:#94a3b8; min-height:280px; } .sglv-wl-loading .sglv-wl-spin { width:32px; height:32px; animation:sglv-spin 1s linear infinite; color:#a78bfa; } .sglv-wl-loading-text { font-size:13px; font-weight:600; color:#cbd5e1; } .sglv-wl-loading-sub { font-size:11px; color:#64748b; } .sglv-wl-loading-bar { width:220px; height:3px; background:rgba(102,192,244,0.12); border-radius:2px; overflow:hidden; } .sglv-wl-loading-bar-fill { height:100%; background:linear-gradient(90deg,#66c0f4,#a78bfa); border-radius:2px; transition:width 0.4s ease; } `); // v2.8.3: Toast 通知 + spinner 动画样式 GM_addStyle(` @keyframes sglv-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .sglv-toast { position: fixed; bottom: 50px; left: 50%; transform: translateX(-50%) translateY(20px); background: rgba(15,23,42,0.95); color: #fff; border: 1px solid rgba(167,139,250,0.5); padding: 10px 24px; border-radius: 20px; font-size: 13px; font-weight: 500; box-shadow: 0 4px 16px rgba(0,0,0,0.3); z-index: 999999; opacity: 0; pointer-events: none; transition: opacity 0.3s, transform 0.3s; } .sglv-toast.sglv-toast-show { opacity: 1; transform: translateX(-50%) translateY(0); } .sglv-toast.sglv-toast-success { border-color: #2ed573; } .sglv-toast.sglv-toast-error { border-color: #ff6b6b; } .sglv-toast.sglv-toast-warning { border-color: #f59e0b; } .sglv-toast.sglv-toast-info { border-color: #54a0ff; } `); // ==================== v2.9.28: 右下角 API Key 引导提示卡片样式 ==================== // 形态参考 GM_notification 的右下角通知,但 GM_notification 需要系统级通知权限且 // 点击后无法直接操作页面,故用页面内自绘卡片实现,点击"打开设置"可直接跳转设置视图。 GM_addStyle(` @keyframes sglv-guide-in { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: translateX(0); } } .sglv-guide-toast { position: fixed; right: 24px; bottom: 24px; z-index: 999999; width: 320px; box-sizing: border-box; background: linear-gradient(145deg, rgba(30,41,59,0.98), rgba(15,23,42,0.98)); border: 1px solid rgba(167,139,250,0.45); border-radius: 14px; padding: 16px; box-shadow: 0 12px 32px rgba(0,0,0,0.45); color: #e2e8f0; font-size: 13px; animation: sglv-guide-in 0.3s ease-out; } .sglv-guide-toast-head { display: flex; align-items: center; gap: 8px; } .sglv-guide-toast-title { flex: 1; display: inline-flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 700; color: #fff; } .sglv-guide-toast-title svg { width: 15px; height: 15px; color: #a78bfa; } .sglv-guide-toast-close { background: none; border: none; color: #94a3b8; cursor: pointer; padding: 3px; line-height: 0; border-radius: 6px; } .sglv-guide-toast-close:hover { color: #fff; background: rgba(148,163,184,0.15); } .sglv-guide-toast-close svg { width: 13px; height: 13px; } .sglv-guide-toast-desc { margin: 8px 0 12px; color: #94a3b8; font-size: 12px; line-height: 1.7; } .sglv-guide-toast-actions { display: flex; gap: 8px; } .sglv-guide-toast-btn { flex: 1; padding: 8px 0; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; border: 1px solid transparent; transition: filter 0.15s; } .sglv-guide-toast-btn.primary { background: linear-gradient(90deg, #8b5cf6, #3b82f6); color: #fff; } .sglv-guide-toast-btn.ghost { background: transparent; border-color: rgba(148,163,184,0.3); color: #cbd5e1; } .sglv-guide-toast-btn:hover { filter: brightness(1.15); } `); // ==================== v2.9.28: 设置页(集成在游戏库主面板内)样式 ==================== GM_addStyle(` .sglv-panel .sglv-settings-btn.sglv-active { background: rgba(102,192,244,0.2) !important; color: #66c0f4 !important; box-shadow: inset 0 0 0 1px rgba(102,192,244,0.5); border-radius: 6px; } .sglv-set-wrap { display: flex; flex-direction: column; height: 100%; min-height: 0; } .sglv-set-topbar { display: flex; align-items: center; gap: 12px; padding: 14px 18px 10px; flex: none; border-bottom: 1px solid rgba(148,163,184,0.12); } .sglv-set-back { display: inline-flex; align-items: center; gap: 6px; background: rgba(148,163,184,0.1); border: 1px solid rgba(148,163,184,0.2); color: #cbd5e1; font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: 8px; cursor: pointer; transition: background 0.15s, color 0.15s; } .sglv-set-back:hover { background: rgba(148,163,184,0.22); color: #fff; } .sglv-set-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; color: #fff; } .sglv-set-title svg { width: 16px; height: 16px; color: #66c0f4; } .sglv-set-grid { flex: 1; min-height: 0; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; padding: 16px 18px; align-content: start; } .sglv-set-card { background: rgba(15,23,42,0.55); border: 1px solid rgba(148,163,184,0.14); border-radius: 12px; padding: 16px; transition: border-color 0.15s; } .sglv-set-card:hover { border-color: rgba(102,192,244,0.35); } .sglv-set-card.wide { grid-column: 1 / -1; } .sglv-set-card-title { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; color: #e2e8f0; margin-bottom: 8px; } .sglv-set-chip { display: inline-flex; width: 26px; height: 26px; align-items: center; justify-content: center; border-radius: 8px; flex: none; } .sglv-set-chip svg { width: 14px; height: 14px; } .sglv-set-chip.blue { background: rgba(102,192,244,0.15); color: #66c0f4; } .sglv-set-chip.purple { background: rgba(167,139,250,0.15); color: #a78bfa; } .sglv-set-chip.amber { background: rgba(245,158,11,0.15); color: #f59e0b; } .sglv-set-desc { font-size: 12px; color: #94a3b8; line-height: 1.7; margin-bottom: 12px; } .sglv-set-desc a { color: #66c0f4; text-decoration: none; } .sglv-set-desc a:hover { text-decoration: underline; } .sglv-set-field { margin-bottom: 12px; } .sglv-set-field:last-child { margin-bottom: 0; } .sglv-set-field > label { display: block; font-size: 12px; font-weight: 600; color: #cbd5e1; margin-bottom: 6px; } .sglv-set-input { width: 100%; box-sizing: border-box; background: rgba(2,6,23,0.6); border: 1px solid rgba(148,163,184,0.22); border-radius: 8px; padding: 9px 12px; color: #e2e8f0; font-size: 13px; transition: border-color 0.15s, box-shadow 0.15s; } .sglv-set-input::placeholder { color: #475569; } .sglv-set-input:focus { outline: none; border-color: #66c0f4; box-shadow: 0 0 0 3px rgba(102,192,244,0.15); } .sglv-set-hint { font-size: 11px; color: #64748b; margin-top: 4px; line-height: 1.5; } .sglv-set-mode-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 10px; } .sglv-set-mode-item { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid rgba(148,163,184,0.16); border-radius: 10px; cursor: pointer; transition: border-color 0.15s, background 0.15s; } .sglv-set-mode-item:hover { border-color: rgba(167,139,250,0.4); background: rgba(167,139,250,0.06); } .sglv-set-mode-item input { margin-top: 3px; accent-color: #a78bfa; cursor: pointer; } .sglv-set-mode-item input:checked ~ .sglv-set-mode-info .sglv-set-mode-name { color: #a78bfa; } .sglv-set-mode-name { font-size: 12.5px; font-weight: 700; color: #e2e8f0; } .sglv-set-mode-desc { font-size: 11.5px; color: #94a3b8; margin-top: 2px; line-height: 1.5; } /* v2.9.46: 设置页面开关样式 */ .sglv-set-chip.green { background: rgba(75,181,79,0.15); color: #4bb54f; } .sglv-set-toggle-row { display: flex !important; align-items: center; justify-content: space-between; font-size: 12.5px; font-weight: 600; color: #cbd5e1; cursor: pointer; user-select: none; } .sglv-set-switch { position: relative; display: inline-block; width: 36px; height: 20px; flex: none; } .sglv-set-switch input { opacity: 0; width: 0; height: 0; } .sglv-set-slider { position: absolute; cursor: pointer; inset: 0; background-color: rgba(255,255,255,0.1); transition: .2s; border-radius: 20px; border: 1px solid rgba(255,255,255,0.05); } .sglv-set-slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 2px; bottom: 2px; background-color: #8a9ba8; transition: .2s; border-radius: 50%; } .sglv-set-switch input:checked + .sglv-set-slider { background-color: rgba(75,181,79,0.2); border-color: rgba(75,181,79,0.4); } .sglv-set-switch input:checked + .sglv-set-slider:before { transform: translateX(16px); background-color: #4bb54f; } .sglv-set-savebar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex: none; border-top: 1px solid rgba(148,163,184,0.12); background: rgba(2,6,23,0.35); } .sglv-set-save-btn { display: inline-flex; align-items: center; gap: 6px; background: linear-gradient(90deg, #8b5cf6, #3b82f6); color: #fff; border: none; border-radius: 8px; padding: 9px 22px; font-size: 13px; font-weight: 700; cursor: pointer; transition: filter 0.15s, transform 0.1s; } .sglv-set-save-btn:hover { filter: brightness(1.15); } .sglv-set-save-btn:active { transform: scale(0.97); } .sglv-set-status { font-size: 12px; color: #2ed573; font-weight: 600; } `); // v2.9.0: 进包/卡牌 KPI 卡片颜色 + badge 样式 // v2.9.3: 卡片一行不换行 + 可点击筛选 + active 高亮(合并自 2.9.0 思路) GM_addStyle(` .sglv-stat-dash-card.orange { border-color: rgba(245, 158, 11, 0.3); background: rgba(245, 158, 11, 0.08); } .sglv-stat-dash-card.orange .sglv-stat-dash-icon { color: #f59e0b; } .sglv-stat-dash-card.orange .sglv-stat-dash-value { color: #f59e0b; } .sglv-stat-dash-card.teal { border-color: rgba(20, 184, 166, 0.3); background: rgba(20, 184, 166, 0.08); } .sglv-stat-dash-card.teal .sglv-stat-dash-icon { color: #14b8a6; } .sglv-stat-dash-card.teal .sglv-stat-dash-value { color: #14b8a6; } .sglv-stat-dash-card.pink { border-color: rgba(236, 72, 153, 0.3); background: rgba(236, 72, 153, 0.08); } .sglv-stat-dash-card.pink .sglv-stat-dash-icon { color: #ec4899; } .sglv-stat-dash-card.pink .sglv-stat-dash-value { color: #ec4899; } .sglv-bundle-badge { display: inline-block; margin-left: 4px; padding: 1px 5px; font-size: 10px; font-weight: 600; border-radius: 3px; background: #f59e0b; color: #fff; vertical-align: middle; cursor: help; } /* v2.9.69: 游戏橱窗 KPI 改为 grid 7 列扁平布局,与统计仪表/趋势页一致 */ .sglv-stats-dashboard { display: grid !important; grid-template-columns: repeat(7, 1fr) !important; gap: 6px !important; } @media (max-width: 1280px) { .sglv-stats-dashboard { grid-template-columns: repeat(4, 1fr) !important; } } @media (max-width: 768px) { .sglv-stats-dashboard { grid-template-columns: repeat(3, 1fr) !important; } } @media (max-width: 480px) { .sglv-stats-dashboard { grid-template-columns: repeat(2, 1fr) !important; } } .sglv-stats-dashboard .sglv-stat-dash-card { min-width: 0; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; } .sglv-stats-dashboard .sglv-stat-dash-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); } .sglv-stats-dashboard .sglv-stat-dash-card.active { box-shadow: 0 0 0 2px rgba(102,192,244,0.4); border-left-width: 4px !important; } .sglv-stats-dashboard .sglv-stat-dash-card.amber { cursor: default; } .sglv-stats-dashboard .sglv-stat-dash-card.amber:hover { transform: none; box-shadow: none; } `); GM_addStyle(` .sgis-sub-status { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .sgis-sub-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 600; border-radius: 4px; } /* v2.9.13: 年度大作 KPI 卡片可点击筛选样式 */ .sglv-trend-kpi-card.clickable { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; } .sglv-trend-kpi-card.clickable:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); } .sglv-trend-kpi-card.active { border-width: 2px; box-shadow: 0 0 0 2px rgba(102,192,244,0.4); } `); // v2.9.31: 游戏橱窗——横版封面视图 + 卡片增强 + 排版优化 GM_addStyle(` /* ====== 横版封面视图(3列,467/181 胶囊横幅) ====== */ .sglv-cover-grid { display: grid !important; grid-template-columns: repeat(3, 1fr) !important; gap: 12px !important; } @media (max-width: 1100px) { .sglv-cover-grid { grid-template-columns: repeat(2, 1fr) !important; } } @media (max-width: 700px) { .sglv-cover-grid { grid-template-columns: 1fr !important; } } .sglv-cover-card { background: linear-gradient(135deg, rgba(255,255,255,0.06) 0%, rgba(255,255,255,0.02) 100%) !important; border: 1px solid rgba(102,192,244,0.1) !important; border-radius: 10px !important; overflow: hidden !important; transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s !important; cursor: pointer !important; display: flex !important; flex-direction: column !important; } .sglv-cover-card:hover { border-color: rgba(102,192,244,0.35) !important; transform: translateY(-3px) !important; box-shadow: 0 6px 20px rgba(102,192,244,0.12), 0 2px 8px rgba(0,0,0,0.3) !important; } .sglv-cover-img-wrap { position: relative !important; width: 100% !important; aspect-ratio: 467/181 !important; overflow: hidden !important; background: rgba(255,255,255,0.05) !important; flex-shrink: 0 !important; } .sglv-cover-img { width: 100% !important; height: 100% !important; object-fit: cover !important; display: block !important; transition: transform 0.3s !important; } .sglv-cover-card:hover .sglv-cover-img { transform: scale(1.04) !important; } .sglv-cover-gradient { position: absolute !important; bottom: 0 !important; left: 0 !important; right: 0 !important; height: 45% !important; background: linear-gradient(to top, rgba(13,18,35,0.85) 0%, transparent 100%) !important; pointer-events: none !important; z-index: 1 !important; } .sglv-cover-badges { position: absolute !important; top: 6px !important; left: 6px !important; display: flex !important; gap: 4px !important; z-index: 2 !important; } .sglv-cover-badge { display: inline-flex !important; align-items: center !important; gap: 3px !important; font-size: 10px !important; font-weight: 700 !important; padding: 2px 7px !important; border-radius: 4px !important; backdrop-filter: blur(6px) !important; -webkit-backdrop-filter: blur(6px) !important; background: rgba(0,0,0,0.55) !important; color: #c4b5fd !important; line-height: 1.3 !important; } .sglv-cover-badge svg { width: 10px !important; height: 10px !important; opacity: 0.85 !important; } .sglv-cover-badge.shared { background: rgba(139,92,246,0.55) !important; color: #ddd6fe !important; } .sglv-cover-badge.bundle { background: rgba(255,152,0,0.55) !important; color: #ffd591 !important; } .sglv-cover-playtime { position: absolute !important; bottom: 6px !important; right: 6px !important; display: inline-flex !important; align-items: center !important; gap: 3px !important; font-size: 11px !important; font-weight: 700 !important; padding: 2px 8px !important; border-radius: 4px !important; background: rgba(0,0,0,0.65) !important; color: #66c0f4 !important; backdrop-filter: blur(6px) !important; -webkit-backdrop-filter: blur(6px) !important; z-index: 2 !important; line-height: 1.3 !important; } .sglv-cover-playtime svg { width: 10px !important; height: 10px !important; opacity: 0.85 !important; } .sglv-cover-body { padding: 8px 10px 9px !important; display: flex !important; flex-direction: column !important; gap: 3px !important; } .sglv-cover-name { font-size: 13px !important; font-weight: 600 !important; color: #e0e0e0 !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } .sglv-cover-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; } .sglv-cover-id { font-size: 10px !important; color: var(--sglv-text-secondary) !important; font-variant-numeric: tabular-nums !important; } /* ====== 卡片视图增强 ====== */ .sglv-game-card { border-radius: 10px !important; } .sglv-game-card:hover { box-shadow: 0 6px 20px rgba(102,192,244,0.1), 0 2px 8px rgba(0,0,0,0.3) !important; } .sglv-card-img { transition: transform 0.3s !important; } .sglv-game-card:hover .sglv-card-img { transform: scale(1.05) !important; } /* ====== 工具栏排版优化 ====== */ .sglv-toolbar { gap: 8px !important; } `); // ==================== I18N ==================== const isZh = (navigator.language || '').toLowerCase().startsWith('zh'); const T = { title: isZh ? '游戏库' : 'Game Library', tabOwned: isZh ? '游戏橱窗' : 'Game Showcase', tabPublisher: isZh ? '系列分类' : 'By Series', tabTrend: isZh ? '入库趋势' : 'Acquisition Trends', // v2.4.3: 入库趋势动态(趋势图 + 入库时间线) trendChartTitle: isZh ? '入库趋势图' : 'Trend Chart', trendTimelineTitle: isZh ? '入库时间线' : 'Acquisition Timeline', trendTimelineTotal: isZh ? '入库总数' : 'Total', // v2.9.48: 入库频率分析 trendFreqTitle: isZh ? '入库频率分析' : 'Acquisition Frequency', trendTabTimeline: isZh ? '入库时间线' : 'Timeline', trendTabFrequency: isZh ? '频率分析' : 'Frequency', trendKpiThisYear: isZh ? '今年入库' : 'This Year', trendKpiAvgYear: isZh ? '年均入库' : 'Avg / Year', trendKpiPeakYear: isZh ? '峰值年份' : 'Peak Year', trendKpiRecent30: isZh ? '近30天' : 'Last 30 Days', trendKpiRecent90: isZh ? '近90天' : 'Last 90 Days', trendKpiActiveDays: isZh ? '活跃天数' : 'Active Days', trendKpiWeeklyAvg: isZh ? '周均入库' : 'Weekly Avg', trendKpiGames: isZh ? '款' : 'games', trendFreqByMonth: isZh ? '月度入库频率' : 'Monthly Frequency', trendFreqByWeekday: isZh ? '星期分布' : 'Weekday Distribution', trendFreqByHour: isZh ? '时段分布' : 'Hourly Distribution', trendFreqSummary: isZh ? '频率摘要' : 'Frequency Summary', trendFreqPeakMonth: isZh ? '最活跃月份' : 'Peak Month', trendFreqPeakWeekday: isZh ? '最活跃星期' : 'Peak Weekday', trendFreqPeakHour: isZh ? '最活跃时段' : 'Peak Hour', trendFreqStreak: isZh ? '最长连续天数' : 'Longest Streak', trendFreqDays: isZh ? '天' : 'days', trendFreqWeekdayLong: isZh ? '周日,周一,周二,周三,周四,周五,周六' : 'Sun,Mon,Tue,Wed,Thu,Fri,Sat', trendFreqHrLabel: isZh ? '时' : 'h', tabSettings: isZh ? '设置' : 'Settings', searchPlaceholder: isZh ? '搜索游戏名称...' : 'Search game name...', noData: isZh ? '暂无游戏数据,请点击右上角刷新按钮' : 'No game data. Click the refresh button at the top right.', noApiKey: isZh ? '请先登录 Steam 商店,或在设置中配置 API Key' : 'Login to Steam Store or configure API Key in Settings.', apiKeyLabel: 'Steam Web API Key', steamIdLabel: isZh ? 'SteamID64(留空自动检测)' : 'SteamID64 (auto-detect if empty)', save: isZh ? '保存设置' : 'Save', saved: isZh ? '已保存' : 'Saved', totalGames: isZh ? '总游戏数' : 'Total Games', ownedInDb: isZh ? '数据库已拥有' : 'Owned in DB', missingInDb: isZh ? '数据库未拥有' : 'Missing in DB', ownership: isZh ? '拥有率' : 'Ownership', page: isZh ? '页' : 'Page', prev: isZh ? '上一页' : 'Prev', next: isZh ? '下一页' : 'Next', first: isZh ? '首页' : 'First', last: isZh ? '末页' : 'Last', jumpTo: isZh ? '跳至' : 'Go to', pageOf: isZh ? '共' : 'of', owned: isZh ? '已拥有' : 'Owned', missing: isZh ? '未拥有' : 'Missing', all: isZh ? '全部' : 'All', cardView: isZh ? '卡片' : 'Card', coverView: isZh ? '横版封面' : 'Wide Cover', listView: isZh ? '列表' : 'List', fetchSuccess: isZh ? '获取成功,共 {n} 款游戏' : 'Fetched {n} games', fetchFail: isZh ? '获取失败' : 'Fetch failed', apiKeyHelp: isZh ? '脚本会自动从 Steam 商店页面获取登录凭证(access_token),无需配置 API Key 即可使用。如需获取更多数据(如游戏时长),可配置 API Key:打开 steamcommunity.com/dev/apikey → 填写域名 → 注册即可。' : 'The script auto-detects login credentials from the Steam store page. API Key is optional for extra data (playtime). Get a free API Key: Visit steamcommunity.com/dev/apikey → Enter domain → Register.', tabDelisted: isZh ? '绝版游戏' : 'Delisted Games', delistedTotal: isZh ? '绝版总数' : 'Total Delisted', delistedOwned: isZh ? '已拥有绝版' : 'Owned Delisted', delistedMissing: isZh ? '未拥有绝版' : 'Missing Delisted', delistedOwnership: isZh ? '绝版拥有率' : 'Delisted Ownership', delistedSearch: isZh ? '搜索绝版游戏...' : 'Search delisted games...', typeAll: isZh ? '全部类型' : 'All Types', typeDelisted: isZh ? '已下架' : 'Delisted', typePurchaseDisabled: isZh ? '购买已禁用' : 'Purchase Disabled', typeF2P: isZh ? 'F2P不可用' : 'F2P Unavailable', typeRetailOnly: isZh ? '仅零售' : 'Retail Only', typeTestApp: isZh ? '测试应用' : 'Test App', delistedOwners: isZh ? '拥有占比' : 'Owner %', delistedDate: isZh ? '下架时间' : 'Delisted Date', delistedAchievements: isZh ? '成就数' : 'Achievements', delistedKeyshops: isZh ? 'Keyshop价格' : 'Keyshop Price', // 游玩时长标签页 tabPlaytime: isZh ? '统计仪表' : 'Stats', tabPlaydata: isZh ? '游玩数据' : 'Play Data', // v2.9.35: 我的成就标签页 — 重构版 tabAchievements: isZh ? '我的成就' : 'Achievements', achCategoryCollector: isZh ? '收藏成就' : 'Collection', achCategoryPlaytime: isZh ? '时长成就' : 'Playtime', achCategoryMastery: isZh ? '专精成就' : 'Mastery', achCategoryDiversity: isZh ? '多元成就' : 'Diversity', achCategoryLoyalty: isZh ? '忠诚成就' : 'Loyalty', achCategorySpecial: isZh ? '特殊成就' : 'Special', achUnlocked: isZh ? '已解锁' : 'Unlocked', achLocked: isZh ? '未解锁' : 'Locked', achProgress: isZh ? '进度' : 'Progress', achNoData: isZh ? '暂无成就数据,请先获取游戏库' : 'No achievement data. Please fetch your library first.', achSummary: isZh ? '成就总览' : 'Achievement Summary', achSummaryDesc: isZh ? '已解锁' : 'Unlocked', achPoints: isZh ? '成就点数' : 'Points', achRarityCommon: isZh ? '普通' : 'Common', achRarityRare: isZh ? '稀有' : 'Rare', achRarityEpic: isZh ? '史诗' : 'Epic', achRarityLegendary: isZh ? '传奇' : 'Legendary', achRecentUnlocks: isZh ? '最近解锁' : 'Recent Unlocks', achTotalPoints: isZh ? '总成就点数' : 'Total Points', achCompletionRate: isZh ? '完成率' : 'Completion', achUnlockedAt: isZh ? '解锁于' : 'Unlocked at', achNoUnlocks: isZh ? '暂无已解锁成就' : 'No achievements unlocked yet', achResetConfirm: isZh ? '确定要重置所有成就解锁记录吗?此操作不可撤销。' : 'Reset all achievement unlock records? This cannot be undone.', achResetBtn: isZh ? '重置记录' : 'Reset', achAllCats: isZh ? '全部' : 'All', // v2.9.51: 全局游戏搜索(中文/拼音缩写/英文子串) globalSearchPh: isZh ? '搜索游戏(中文/拼音/英文)' : 'Search games (CN/Pinyin/EN)', gsHeader: isZh ? '搜索结果' : 'Search Results', gsEmpty: isZh ? '未匹配到游戏,试试中文/拼音首字母/英文子串' : 'No matches. Try CN/pinyin/English substring', gsMinLen: isZh ? '至少输入 1 个字符' : 'Type at least 1 character', gsTypeOwned: isZh ? '已拥有' : 'Owned', gsTypeWishlist: isZh ? '愿望单' : 'Wishlist', gsTypeStore: isZh ? '商店' : 'Store', gsType: isZh ? '类型' : 'Type', gsFooterHint: isZh ? '↑↓ 选择 · Enter 跳转 · Esc 关闭' : '↑↓ Navigate · Enter Open · Esc Close', gsOpenStore: isZh ? '在 Steam 商店打开' : 'Open in Steam Store', gsJumpToOwned: isZh ? '跳转到游戏库' : 'Jump to Library', gsJumpToWishlist: isZh ? '跳转到愿望单' : 'Jump to Wishlist', // v2.9.63: 游戏详情浮窗 dpLoading: isZh ? '正在获取游戏详情…' : 'Loading game details…', dpError: isZh ? '获取详情失败' : 'Failed to load details', dpRetry: isZh ? '重试' : 'Retry', dpDeveloper: isZh ? '开发商' : 'Developer', dpPublisher: isZh ? '发行商' : 'Publisher', dpRelease: isZh ? '发行日期' : 'Release Date', dpPlatforms: isZh ? '平台' : 'Platforms', dpGenres: isZh ? '类型' : 'Genres', dpCategories: isZh ? '特色' : 'Features', dpPrice: isZh ? '价格' : 'Price', dpFree: isZh ? '免费' : 'Free', dpScreenshots: isZh ? '截图' : 'Screenshots', dpStorePage: isZh ? '商店页' : 'Store Page', dpJumpLib: isZh ? '跳转到游戏库' : 'Jump to Library', dpJumpWl: isZh ? '跳转到愿望单' : 'Jump to Wishlist', ptTotalHours: isZh ? '总游玩时长' : 'Total Hours', ptAvgHours: isZh ? '平均时长' : 'Avg Hours', ptPlayedCount: isZh ? '已游玩' : 'Played', ptUnplayedCount: isZh ? '从未游玩' : 'Unplayed', // v2.9.22: 统计仪表新增 KPI 卡片——游玩率 + 完成度 ptPlayRate: isZh ? '游玩率' : 'Play Rate', ptCompletion: isZh ? '完成度' : 'Completion', ptDistTitle: isZh ? '时长分布' : 'Playtime Distribution', ptTopTitle: isZh ? '游玩时长 Top 20' : 'Top 20 by Playtime', ptAllTitle: isZh ? '全部游戏(按时长排序)' : 'All Games (by Playtime)', ptSearchPlaceholder: isZh ? '搜索游戏名称...' : 'Search game name...', ptRange0: isZh ? '从未游玩' : 'Never Played', ptRange1: isZh ? '0-1 小时' : '0-1 hours', ptRange2: isZh ? '1-10 小时' : '1-10 hours', ptRange3: isZh ? '10-50 小时' : '10-50 hours', ptRange4: isZh ? '50-100 小时' : '50-100 hours', ptRange5: isZh ? '100-500 小时' : '100-500 hours', ptRange6: isZh ? '500+ 小时' : '500+ hours', ptHours: isZh ? '小时' : 'hours', ptLastPlayed: isZh ? '最近游玩' : 'Last Played', ptNever: isZh ? '从未' : 'Never', ptNoData: isZh ? '暂无游玩时长数据,请先获取游戏库' : 'No playtime data. Fetch library first.', ptDays: isZh ? '天前' : 'days ago', ptFamilyCompare: isZh ? '家庭成员游玩时长对比' : 'Family Playtime Comparison', ptFetchingFamily: isZh ? '正在获取家庭成员数据…' : 'Fetching family data…', ptNoFamily: isZh ? '未加入家庭组或未配置 API Key' : 'No family group or API Key', ptNoApiKey: isZh ? '请先在设置中配置 API Key' : 'Configure API Key in settings first', ptNoToken: isZh ? '请先点击面板刷新按钮获取家庭组信息' : 'Click refresh to fetch family info first', ptRetryBtn: isZh ? '重试' : 'Retry', // v2.3.30: 入库热力图与家庭组占位 ptLibHeatmap: isZh ? '入库热力图' : 'Library Heatmap', ptHeatmapNoData: isZh ? '暂无入库时间数据' : 'No acquisition time data', ptHeatmapLoading: isZh ? '加载中…' : 'Loading…', ptPending: isZh ? '待加入' : 'Pending', ptHeatmapTotal: isZh ? '总' : 'Total', ptHeatmapPeak: isZh ? '峰值' : 'Peak', ptHeatmapAvg: isZh ? '日均' : 'Avg/day', ptHeatmapLess: isZh ? '少' : 'Less', ptHeatmapMore: isZh ? '多' : 'More', ptHeatmapAcquired: isZh ? '款入库' : 'acquired', // v2.4.2: 热力图按年分割 ptHeatmapAll: isZh ? '全部' : 'All', // v2.4.4: 热力图右下角近6月入库增量 ptHeatmapMini6m: isZh ? '近6月入库增量' : '6-Month Acquisitions', // 新增:刷新与分类 refreshBtnTitle: isZh ? '通过 API Key 刷新游戏库与时长' : 'Refresh library & playtime via API Key', refreshSuccess: isZh ? '游戏库与时长已刷新' : 'Library & playtime refreshed', categoryAll: isZh ? '全部' : 'All', categoryPlayed: isZh ? '已游玩' : 'Played', categoryUnplayed: isZh ? '未游玩' : 'Unplayed', categoryShared: isZh ? '共享' : 'Shared', categoryOwned: isZh ? '仅自己' : 'Owned Only', // v2.9.0: 进包/卡牌筛选与 KPI categoryBundled: isZh ? '进过包' : 'Bundled', categoryCards: isZh ? '有卡牌' : 'Has Cards', statsBundled: isZh ? '进包游戏' : 'Bundled Games', statsCards: isZh ? '有卡游戏' : 'Card Games', kpiInBundles: isZh ? '曾出现在 bundle 中' : 'appeared in bundles', kpiHasCards: isZh ? '含集换式卡牌' : 'has trading cards', sortBundleDesc: isZh ? '进包次数 多→少' : 'Bundle count ↓', bundleBadgeTip: isZh ? '曾出现在' : 'Appeared in', bundleBadgeUnit: isZh ? '个 bundle 中' : ' bundles', // v2.9.1: DLC 分离统计与仅 DLC 筛选 categoryDlcOnly: isZh ? '仅 DLC' : 'DLC Only', statsDlcTotal: isZh ? 'DLC 总数' : 'DLC Total', kpiDlcMusic: isZh ? '音乐' : 'Music', kpiDlcBundled: isZh ? '进包' : 'Bundled', // v2.4.5: 库存排序 sortLabel: isZh ? '排序方式' : 'Sort by', sortAppidAsc: isZh ? 'AppID 小→大' : 'AppID ↑', sortAppidDesc: isZh ? 'AppID 大→小' : 'AppID ↓', sortNameAsc: isZh ? '名称 A→Z' : 'Name A–Z', sortAcquiredDesc: isZh ? '入库 新→旧' : 'Newest first', sortAcquiredAsc: isZh ? '入库 旧→新' : 'Oldest first', sortPlaytimeDesc: isZh ? '时长 多→少' : 'Most played', sortLastPlayedDesc: isZh ? '最近游玩' : 'Recently played', statsTotalGames: isZh ? '总游戏数' : 'Total Games', statsTotalHours: isZh ? '总时长 (h)' : 'Total Hours', statsPlayed: isZh ? '已游玩' : 'Played', statsUnplayed: isZh ? '未游玩' : 'Unplayed', statsShared: isZh ? '共享游戏' : 'Shared Games', // v2.3.22: KPI 卡片子文本 kpiAvgHours: isZh ? '均' : 'Avg', kpiPerGame: isZh ? '/款' : '/game', kpiRate: isZh ? '占比' : 'Rate', kpiOfTotal: isZh ? '占总数' : 'of total', // v2.9.22: 游玩率/完成度 KPI 副标题 kpiPlayRateSub: isZh ? '已启动 ÷ 全部' : 'started ÷ total', kpiCompletionSub: isZh ? '深度游玩 ≥10h' : 'deep play ≥10h', kpiFromFamily: isZh ? '来自家庭组' : 'from family', kpiHours: isZh ? '小时' : 'hours', kpiMyCollection: isZh ? '我的收藏' : 'My Collection', kpiDedupMerge: isZh ? '合并去重' : 'Deduplicated', kpiFamilyMembers: isZh ? '位家庭成员' : 'family members', kpiYearSpan: isZh ? '年跨度' : 'year span', kpiDelisted: isZh ? '款绝版' : 'delisted', kpiCollected: isZh ? '已收藏' : 'collected', kpiToCollect: isZh ? '待收藏' : 'to collect', kpiCollectionProgress: isZh ? '收藏进度' : 'Collection Progress', delistedSubAll: isZh ? '全部绝版' : 'All Delisted', delistedSubOwned: isZh ? '已拥有' : 'Owned', delistedSubMissing: isZh ? '未拥有' : 'Missing', ptSourceApi: isZh ? 'API' : 'API', ptSourceScrape: isZh ? '页面抓取' : 'Scraped', ptTrendTitle: isZh ? '时长趋势' : 'Playtime Trend', ptTrendMonth: isZh ? '月' : 'Month', ptTrendQuarter: isZh ? '季' : 'Quarter', ptTrendYear: isZh ? '年' : 'Year', // AI 翻译总结 aiSettingsTitle: isZh ? 'AI 模型配置' : 'AI Model Settings', aiApiUrlLabel: isZh ? 'AI API 地址' : 'AI API URL', aiApiKeyLabel: isZh ? 'AI API Key' : 'AI API Key', aiModelLabel: isZh ? '模型名称' : 'Model Name', aiApiUrlHelp: isZh ? '默认为 DeepSeek 官方 API 地址,也可填入其他兼容 OpenAI 格式的 API 地址' : 'Default is DeepSeek official API URL. Also supports OpenAI-compatible API endpoints.', aiModelHelp: isZh ? '默认为 deepseek-v4-pro,可修改为其他模型名称' : 'Default is deepseek-v4-pro. Can be changed to other model names.', aiNotConfigured: isZh ? '未配置 AI API Key,请在设置中配置' : 'AI API Key not configured. Set it in Settings.', aiTranslating: isZh ? 'AI 翻译总结中…' : 'AI translating & summarizing…', aiTranslateFail: isZh ? 'AI 翻译失败' : 'AI translation failed', aiToggleOriginal: isZh ? '原文' : 'Original', aiToggleSummary: isZh ? 'AI翻译总结' : 'AI Summary', // v2.9.60: 游玩时长趋势标签页 tabPlayTrend: isZh ? '趋势' : 'Trend', ptKpiThisWeek: isZh ? '本周时长' : 'This Week', ptKpiAvgWeek: isZh ? '周均时长' : 'Weekly Avg', ptKpiPeakWeek: isZh ? '峰值周' : 'Peak Week', ptKpiSampleWeeks: isZh ? '采样周数' : 'Sampled Weeks', ptSubPersonal: isZh ? '个人周时长' : 'Personal', ptSubFamily: isZh ? '家庭组周时长' : 'Family', ptSubTotal: isZh ? '总时长趋势' : 'Total', ptEmptyNoData: isZh ? '数据积累中 — 多次访问该游戏后可查看游玩时长趋势' : 'Collecting data — visit this game again to see playtime trends', ptEmptyNeedTwo: isZh ? '需要至少 2 次访问才能计算周时长差分' : 'At least 2 visits are needed to calculate weekly playtime diff', ptSparseWarning: isZh ? '数据稀疏,部分周可能未统计' : 'Sparse data, some weeks may not be captured', ptTrendNoFamily: isZh ? '无家庭组成员数据' : 'No family member data', ptFamilyBarsTotal: isZh ? '家庭总时长' : 'Family Total', ptFamilyBarsLoading: isZh ? '家庭成员时长获取中…' : 'Loading family playtime…', ptFamilyBarsNoKey: isZh ? '需配置 API Key 获取家庭成员时长' : 'API Key required for family playtime', ptWeeks: isZh ? '周' : 'weeks', ptNoActivity: isZh ? '近期无游玩活动' : 'No recent activity', globalSettings: isZh ? '全局设置' : 'Global Settings', // v2.9.28: 设置集成进主面板——返回按钮与右下角引导提示文案 settingsBack: isZh ? '返回游戏库' : 'Back to Library', apiKeyGuideTitle: isZh ? '尚未配置 Steam API Key' : 'Steam API Key missing', apiKeyGuideDesc: isZh ? '配置 API Key 可获取游玩时长、家庭组等更完整数据。点击游戏库面板右上角 ⚙ 设置按钮进行配置,也可以直接尝试免 Key 抓取。' : 'An API Key unlocks playtime & family data. Click the ⚙ Settings button in the library panel to configure it, or try fetching without a key.', apiKeyGuideOpen: isZh ? '打开设置' : 'Open Settings', apiKeyGuideLater: isZh ? '稍后再说' : 'Later', steamApiSection: isZh ? 'Steam API 配置' : 'Steam API Settings', // AI 价格预测 (v2.3) aiPredictSection: isZh ? 'AI 价格预测配置' : 'AI Price Prediction Settings', aiPredictModes: isZh ? '预测模式' : 'Prediction Modes', aiPredictModesHelp: isZh ? '选择一种或多种预测模式,AI将基于所选模式进行分析' : 'Select one or more prediction modes for AI analysis', aiPredictModeSeasonal: isZh ? '季节性折扣预测' : 'Seasonal Discount', aiPredictModeSeasonalDesc: isZh ? '基于Steam促销活动节点(夏促/秋促/冬促/春促)预测折扣时机' : 'Predict based on Steam sale events (Summer/Autumn/Winter/Spring)', aiPredictModeTrend: isZh ? '价格趋势预测' : 'Price Trend', aiPredictModeTrendDesc: isZh ? '基于历史折扣间隔和幅度趋势预测下次折扣' : 'Predict next discount based on historical intervals and depth trends', aiPredictModeLowest: isZh ? '史低突破预测' : 'Historical Low', aiPredictModeLowestDesc: isZh ? '预测达到或突破历史最低价的可能性与时机' : 'Predict probability and timing of reaching or breaking historical low', aiPredictModeReview: isZh ? '评价动量预测' : 'Review Momentum', aiPredictModeReviewDesc: isZh ? '结合游戏口碑变化趋势评估折扣力度' : 'Assess discount depth based on review momentum changes', aiPredictModeSimilar: isZh ? '相似历史预测' : 'Similar History', aiPredictModeSimilarDesc: isZh ? '匹配相似价格历史片段预测折扣模式' : 'Match similar price history patterns to predict discounts', aiPredictBtn: isZh ? 'AI预测' : 'AI Predict', aiPredicting: isZh ? 'AI预测分析中…' : 'AI predicting…', aiPredictFail: isZh ? 'AI预测失败' : 'AI prediction failed', aiPredictNoData: isZh ? '需要先获取历史价格数据' : 'Need price history data first', aiPredictNoKey: isZh ? '未配置AI API Key,请在设置中配置' : 'AI API Key not configured. Set it in Settings.', aiPredictResult: isZh ? 'AI价格预测分析' : 'AI Price Prediction', aiPredictConfidence: isZh ? '把握度' : 'Confidence', aiPredictDays: isZh ? '天后' : 'days later', aiPredictBestTime: isZh ? '最佳入手时机' : 'Best Time to Buy', aiPredictSaleEvent: isZh ? '促销节点' : 'Sale Event', aiPredictRecommendation: isZh ? '购买建议' : 'Recommendation', // ====== 愿望单标签页 (v2.3.19) ====== tabWishlist: isZh ? '愿望单' : 'Wishlist', wlTotal: isZh ? '愿望单总数' : 'Wishlist Total', wlTotalValue: isZh ? '愿望单总价值' : 'Total Value', wlDiscounted: isZh ? '打折中' : 'On Sale', wlInLibrary: isZh ? '已在库' : 'In Library', wlAvgDiscount: isZh ? '平均折扣' : 'Avg Discount', wlSearch: isZh ? '搜索愿望单游戏...' : 'Search wishlist...', wlNoData: isZh ? '暂无愿望单数据' : 'No wishlist data', wlFetching: isZh ? '正在获取愿望单数据…' : 'Fetching wishlist…', wlFetchFail: isZh ? '愿望单获取失败,请点击右上角刷新按钮重试' : 'Wishlist fetch failed, click refresh to retry', wlFetchSuccess: isZh ? '愿望单获取成功,共 {n} 款' : 'Fetched {n} wishlist items', wlLoadStage1: isZh ? '正在抓取愿望单页面…' : 'Fetching wishlist page…', wlLoadStage2: isZh ? '正在补全商店数据…' : 'Enriching store data…', wlLoadStage3: isZh ? '正在补全游戏详情…' : 'Enriching game details…', wlLoadSub: isZh ? '正在从 Steam 获取愿望单数据,请稍候' : 'Loading wishlist data from Steam, please wait', wlRefreshTip: isZh ? '重新获取愿望单' : 'Refetch wishlist', wlFilterDiscount: isZh ? '打折中' : 'On Sale', wlFilterInLibrary: isZh ? '已在库' : 'In Library', wlFilterFree: isZh ? '免费' : 'Free', wlFilterComingSoon: isZh ? '即将发售' : 'Coming Soon', wlComingSoon: isZh ? '即将发售' : 'Coming Soon', wlKpiComingSoon: isZh ? '即将发售' : 'Coming Soon', wlTypeDist: isZh ? '类型分布' : 'Type Distribution', wlUpcoming: isZh ? '待上市游戏' : 'Upcoming Games', wlUpcoming30d: isZh ? '30天内' : '<30d', wlUpcoming90d: isZh ? '90天内' : '<90d', wlUpcomingFar: isZh ? '更远' : 'Far', wlUpcomingNoDate: isZh ? '未定日期' : 'No Date', wlDiscountDist: isZh ? '折扣分布' : 'Discount Distribution', wlTopDiscount: isZh ? 'TOP 折扣' : 'TOP Discounts', wlFree: isZh ? '免费' : 'Free', wlNoPrice: isZh ? '暂无价格' : 'N/A', wlTypeGame: isZh ? '游戏' : 'Games', wlTypeDlc: 'DLC', wlTypeSoftware: isZh ? '软件' : 'Software', wlTypeOther: isZh ? '其他' : 'Other', wlDiscOff: isZh ? '无折扣' : 'No Discount', wlDateAdded: isZh ? '添加于' : 'Added', wlTopTags: isZh ? '热门标签 TOP 15' : 'Top 15 Tags', wlTimeline: isZh ? '添加时间线' : 'Added Timeline', // ====== 愿望单仪表盘增强 (v2.3.21) ====== wlPriceDist: isZh ? '价格区间分布' : 'Price Range Distribution', wlAddedByYear: isZh ? '按年添加趋势' : 'Added by Year', wlKpiTotalValue: isZh ? '总价值' : 'Total Value', wlKpiOnSale: isZh ? '打折中' : 'On Sale', wlKpiFree: isZh ? '免费游戏' : 'Free Games', wlKpiInLib: isZh ? '已入库' : 'In Library', wlEarliest: isZh ? '最早于' : 'Earliest', wlAvgPrice: isZh ? '均价' : 'Avg', wlMedian: isZh ? '中位' : 'Median', wlRate: isZh ? '占比' : 'Rate', wlAvgDisc: isZh ? '均折' : 'Avg', wlMaxDisc: isZh ? '最高' : 'Max', wlPurchaseRate: isZh ? '购入率' : 'Purchase Rate', wlPriceFree: isZh ? '免费' : 'Free', wlPriceLt50: '<50', wlPrice50_100: '50-100', wlPrice100_200: '100-200', wlPrice200_500: '200-500', wlPriceGte500: '500+', wlUncatEmpty: isZh ? '无分类 {n} 款' : '{n} uncategorized', wlRankTip: isZh ? '愿望单排序位次(可在 Steam 愿望单页面拖拽调整)' : 'Wishlist rank (drag to reorder on your Steam wishlist page)', // ====== 愿望单我的类别统计 (v2.8.0) ====== wlCatStats: isZh ? '我的类别统计' : 'Category Stats', wlCatStatsCats: isZh ? '共 {n} 个类别' : '{n} categories', wlCatStatsNoCat: isZh ? '未分类' : 'Uncategorized', wlCatStatsClick: isZh ? '点击筛选此类别' : 'Click to filter by category', wlCatStatsActive: isZh ? '类别: ' : 'Category: ', wlFamilyShared: isZh ? '家庭共享' : 'Family Shared', wlDisc0: isZh ? '无折扣' : 'None', wlDisc1_25: '1-25%', wlDisc26_50: '26-50%', wlDisc51_75: '51-75%', wlDisc76plus: '76%+', // ====== PS 会免标签页 (v2.6.0) ====== tabPsplus: isZh ? 'PS 会免' : 'PS Plus', psSearch: isZh ? '搜索游戏名称 / AppID...' : 'Search name / AppID...', psSortDateDesc: isZh ? '日期 ↓(新到旧)' : 'Date ↓ (new to old)', psSortDateAsc: isZh ? '日期 ↑(旧到新)' : 'Date ↑ (old to new)', psSortTierAsc: isZh ? '档位 ↑(必备→高级)' : 'Tier ↑ (Essential→Premium)', psSortTierDesc: isZh ? '档位 ↓(高级→必备)' : 'Tier ↓ (Premium→Essential)', psSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', psRefreshTip: isZh ? '重新获取 PS 会免数据' : 'Reload PS Plus data', psTierEssential: isZh ? '必备' : 'Essential', psTierExtra: isZh ? '升级' : 'Extra', psTierPremium: isZh ? '高级' : 'Premium', psStatusAll: isZh ? '全部状态' : 'All Status', psStatusIn: isZh ? '在库' : 'In Library', psStatusActive: isZh ? '领取中' : 'Claimable', psStatusExpired: isZh ? '已截止/出库' : 'Expired/Out', psStatusLeaving: isZh ? '即将出库' : 'Leaving Soon', psKpiTotal: isZh ? '会免总数' : 'Total Titles', psKpiMonthly: isZh ? '每月可领' : 'Monthly', psKpiCatalog: isZh ? '游戏目录' : 'Catalog', psKpiSteamOwned: isZh ? 'Steam 已拥有' : 'Owned on Steam', psKpiSteamTotal: isZh ? '共 {n} 款有 Steam 版' : '{n} on Steam', psUpdated: isZh ? '更新于' : 'Updated', psLoading: isZh ? '正在获取 PS 会免数据...' : 'Loading PS Plus data...', psLoadError: isZh ? 'PS 会免数据获取失败,请稍后点击刷新重试' : 'Failed to load PS Plus data. Click refresh to retry.', // ====== 通用:Steam 在库标识(v2.7.9,区分 PS 在库状态) ====== steamInLib: isZh ? 'steam在库' : 'Steam Owned', // ====== Epic 赠送标签页 (v2.7.9) ====== tabEpic: isZh ? 'Epic赠送' : 'Epic Free', epicSearch: isZh ? '搜索名称 / AppID...' : 'Search name / AppID...', epicSortAppidDesc: isZh ? 'AppID ↓(大→小)' : 'AppID ↓ (high to low)', epicSortAppidAsc: isZh ? 'AppID ↑(小→大)' : 'AppID ↑ (low to high)', epicSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', epicRefreshTip: isZh ? '从其乐论坛更新 Epic 赠送名单' : 'Refresh Epic free list from keylol', epicImportTip: isZh ? '手动导入 Epic 赠送数据 JSON' : 'Import Epic free games JSON', epicKpiTotal: isZh ? 'Epic赠送总数' : 'Epic Free Titles', epicKpiOwned: isZh ? 'steam在库' : 'Steam Owned', epicKpiNotOwned: isZh ? '未拥有' : 'Not Owned', epicKpiOwnedSub: isZh ? '已在 Steam 家庭共享库' : 'In Steam family library', epicLoading: isZh ? '正在获取 Epic 赠送数据...' : 'Loading Epic free data...', epicLoadError: isZh ? 'Epic 数据获取失败,可点击导入按钮手动导入,或前往其乐论坛用「E宝的爱标识助手」更新' : 'Failed to load Epic data. Try import, or update via keylol.', epicNoData: isZh ? '暂无 Epic 赠送数据,请点击刷新或导入' : 'No Epic data. Click refresh or import.', epicImportPrompt: isZh ? '请粘贴 Epic 赠送数据 JSON:\n支持 [appid,...] / {"appids":[...]} / {"games":[{"appid":...}]}\n(与「E宝的爱标识助手」共享存储,自动合并)' : 'Paste Epic free games JSON:\n[appid,...] / {"appids":[...]} / {"games":[{"appid":...}]}', epicImportOk: isZh ? '导入成功,共 {n} 款' : 'Imported {n} games', epicImportNoData: isZh ? '未识别到有效 appid' : 'No valid appid found', epicImportFail: isZh ? 'JSON 解析失败:' : 'JSON parse failed: ', epicBadgeFree: isZh ? 'Epic免费' : 'Epic Free', epicBadgeNotOwned: isZh ? '未拥有' : 'Not Owned', epicSourceKeylol: isZh ? '其乐论坛' : 'keylol', epicSourceLocal: isZh ? '本地缓存' : 'Local', epicSourceManual: isZh ? '内置名单' : 'Built-in', epicSourceImport: isZh ? '手动导入' : 'Imported', // ====== 年度大作标签页 (v2.9.7) ====== tabGoty: isZh ? '年度大作' : 'GotY', gotySearch: isZh ? '搜索游戏名称 / AppID...' : 'Search name / AppID...', gotySortDateAsc: isZh ? '发售日 ↑(早→晚)' : 'Date ↑ (early to late)', gotySortDateDesc: isZh ? '发售日 ↓(晚→早)' : 'Date ↓ (late to early)', gotySortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', gotySortAppidAsc: isZh ? 'AppID ↑(小→大)' : 'AppID ↑ (low to high)', gotyRefreshTip: isZh ? '重新获取年度大作数据' : 'Reload GotY data', gotyFilterAll: isZh ? '全部' : 'All', gotyFilterOwned: isZh ? 'steam在库' : 'Steam Owned', gotyFilterNotOwned: isZh ? '未拥有' : 'Not Owned', gotyKpiTotal: isZh ? '年度大作总数' : 'GotY Total', gotyKpiOwned: isZh ? 'steam在库' : 'Steam Owned', gotyKpiNotOwned: isZh ? '未拥有' : 'Not Owned', gotyKpiOwnedSub: isZh ? '已在 Steam 家庭共享库' : 'In Steam family library', gotyLoading: isZh ? '正在获取年度大作数据...' : 'Loading GotY data...', gotyLoadError: isZh ? '年度大作数据获取失败,请点击刷新重试' : 'Failed to load GotY data. Click refresh to retry.', gotyNoData: isZh ? '暂无年度大作数据' : 'No GotY data', gotyBadgeGoty: isZh ? '年度大作' : 'GotY', gotyBadgeNotOwned: isZh ? '未拥有' : 'Not Owned', gotyReleaseDate: isZh ? '发售' : 'Release', gotySourceGithub: isZh ? 'GitHub' : 'GitHub', gotySourceCache: isZh ? '本地缓存' : 'Local', gotyFilterAllYears: isZh ? '全部年度' : 'All Years', // ====== 锁区游戏标签页 (v2.9.12) ====== tabBlocked: isZh ? '锁区游戏' : 'Region Blocked', blockedSearch: isZh ? '搜索名称 / AppID...' : 'Search name / AppID...', blockedSortAppidDesc: isZh ? 'AppID ↓(大→小)' : 'AppID ↓ (high to low)', blockedSortAppidAsc: isZh ? 'AppID ↑(小→大)' : 'AppID ↑ (low to high)', blockedSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', blockedSortDateDesc: isZh ? '变更日期 ↓(新→旧)' : 'Changed ↓ (new to old)', blockedSortDateAsc: isZh ? '变更日期 ↑(旧→新)' : 'Changed ↑ (old to new)', blockedRefreshTip: isZh ? '重新获取锁区游戏数据' : 'Reload blocked data', blockedImportTip: isZh ? '手动导入锁区游戏 JSON' : 'Import blocked games JSON', blockedFilterAll: isZh ? '全部' : 'All', blockedFilterOwned: isZh ? 'steam在库' : 'Steam Owned', blockedFilterNotOwned: isZh ? '未拥有' : 'Not Owned', blockedKpiTotal: isZh ? '锁区总数' : 'Blocked Total', blockedKpiOwned: isZh ? 'steam在库' : 'Steam Owned', blockedKpiNotOwned: isZh ? '未拥有' : 'Not Owned', blockedKpiSubBanned: isZh ? 'Banned N · Purchase M · Regional K' : 'Banned N · Purchase M · Regional K', blockedLoading: isZh ? '正在获取锁区游戏数据...' : 'Loading blocked data...', blockedLoadError: isZh ? '锁区数据获取失败,可点击导入按钮手动导入,或稍后点击刷新重试' : 'Failed to load blocked data. Try import or retry later.', blockedNoData: isZh ? '暂无锁区游戏数据,请点击刷新或导入' : 'No blocked data. Click refresh or import.', blockedImportPrompt: isZh ? '请粘贴锁区游戏数据 JSON:\n支持 [{appid,name,...}] 数组格式\n或 srbb_blocked_apps 格式 {"appid":{"name":"...","at":...}}' : 'Paste blocked games JSON:\n[{appid,name,...}] array\nor srbb format {"appid":{"name":"...","at":...}}', blockedImportOk: isZh ? '导入成功,共 {n} 款' : 'Imported {n} games', blockedImportNoData: isZh ? '未识别到有效 appid' : 'No valid appid found', blockedImportFail: isZh ? 'JSON 解析失败:' : 'JSON parse failed: ', blockedBadgeOwned: isZh ? '在库' : 'Owned', blockedBadgeBanned: isZh ? '封禁' : 'Banned', blockedBadgePurchase: isZh ? '购买禁用' : 'Purchase Disabled', blockedBadgeRegional: isZh ? '区域变体' : 'Regional Variant', blockedBadgeManual: isZh ? '手动' : 'Manual', blockedChangedAt: isZh ? '变更' : 'Changed', // ====== 云存档标签页 (v2.9.34) ====== tabCloudSave: isZh ? '云存档' : 'Cloud Saves', csTotalGames: isZh ? '云存档游戏' : 'Cloud Games', csTotalSize: isZh ? '总占用' : 'Total Size', csTotalFiles: isZh ? '文件总数' : 'Total Files', csAvgSize: isZh ? '平均大小' : 'Avg Size', csLargestSave: isZh ? '最大存档' : 'Largest Save', csTopSizes: isZh ? 'TOP 10 存档大小' : 'TOP 10 Save Sizes', csSizeDist: isZh ? '大小分布' : 'Size Distribution', csFileDist: isZh ? '文件数分布' : 'File Count Distribution', csGameList: isZh ? '游戏列表' : 'Game List', csSearch: isZh ? '搜索游戏名称...' : 'Search game name...', csSortSizeDesc: isZh ? '大小 ↓(大→小)' : 'Size ↓ (large to small)', csSortSizeAsc: isZh ? '大小 ↑(小→大)' : 'Size ↑ (small to large)', csSortFilesDesc: isZh ? '文件数 ↓' : 'Files ↓', csSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', csRefreshTip: isZh ? '重新获取云存档数据及文件数' : 'Reload cloud save data & file counts', csFetching: isZh ? '正在获取云存档数据…' : 'Fetching cloud saves…', csFetchFail: isZh ? '云存档数据获取失败,请点击刷新重试' : 'Cloud save fetch failed. Click refresh to retry.', csFetchSuccess: isZh ? '获取成功,共 {n} 款游戏' : 'Fetched {n} games', csNoData: isZh ? '暂无云存档数据,请点击刷新获取' : 'No cloud save data. Click refresh to fetch.', csBadgeNew: isZh ? '新增' : 'NEW', csBadgeChanged: isZh ? '变化' : 'CHANGED', csBadgeRemoved: isZh ? '移除' : 'REMOVED', csUpdated: isZh ? '存档更新' : 'Saves updated', csFileCountUpdated: isZh ? '文件数更新' : 'File count updated', csFiles: isZh ? '文件' : 'files', csSizeLt1: '<1MB', csSize1_10: '1-10MB', csSize10_50: '10-50MB', csSize50_100: '50-100MB', csSize100plus: '100MB+', csFilesLt3: '<3', csFiles3_5: '3-5', csFiles5_10: '5-10', csFiles10plus: '10+', csLoginRequired: isZh ? '需要 Steam 登录态,请在 store.steampowered.com 登录后重试' : 'Steam login required. Please login on store.steampowered.com.', csSourceWeb: isZh ? '网页抓取' : 'Web scrape', csSourceCache: isZh ? '本地缓存' : 'Local cache', // v2.9.39: 后台静默刷新状态 csBgRefreshing: isZh ? '后台更新中…' : 'Background updating…', csBgRefreshDone: isZh ? '后台更新完成,新增 {n} 款' : 'Background update done, +{n}', // ====== DLC 收藏标签页 (v2.9.32) ====== tabDlc: isZh ? 'DLC收藏' : 'DLC Collection', dlcKpiTotal: isZh ? 'DLC 总数' : 'DLC Total', dlcKpiRegular: isZh ? '常规 DLC' : 'Regular DLC', dlcKpiMusic: isZh ? '音乐包' : 'Music Packs', dlcKpiBundled: isZh ? '进包 DLC' : 'Bundled DLC', dlcKpiPlayed: isZh ? '已游玩' : 'Played', dlcKpiSub: isZh ? '覆盖 {n} 款游戏' : 'Across {n} games', dlcKpiNoParent: isZh ? '未关联父游戏' : 'No parent', dlcSearch: isZh ? '搜索 DLC 或父游戏...' : 'Search DLC or parent...', dlcSortParent: isZh ? '按父游戏' : 'By Parent', dlcSortAppidDesc: isZh ? 'AppID ↓(大→小)' : 'AppID ↓ (high to low)', dlcSortAppidAsc: isZh ? 'AppID ↑(小→大)' : 'AppID ↑ (low to high)', dlcSortNameAsc: isZh ? '名称 A→Z' : 'Name A→Z', dlcSortType: isZh ? '按类型' : 'By Type', dlcFilterAll: isZh ? '全部' : 'All', dlcFilterDlc: isZh ? '常规' : 'Regular', dlcFilterMusic: isZh ? '音乐' : 'Music', dlcFilterBundled: isZh ? '进包' : 'Bundled', dlcFilterPlayed: isZh ? '已游玩' : 'Played', dlcGroupByParent: isZh ? '按父游戏' : 'By Parent', dlcFlatList: isZh ? '扁平' : 'Flat', dlcLoading: isZh ? '正在加载 DLC 数据库...' : 'Loading DLC database...', dlcLoadError: isZh ? 'DLC 数据加载失败,请稍后重试' : 'DLC data load failed. Please retry later.', dlcNoData: isZh ? '暂无 DLC,请先加载游戏库' : 'No DLC found. Load your library first.', dlcBadgeMusic: isZh ? '音乐' : 'Music', dlcBadgeBundle: isZh ? '包' : 'Bundle', dlcBadgePlayed: isZh ? '已玩' : 'Played', dlcComplete: isZh ? '收集完成度' : 'Completion', dlcUnknownParent: isZh ? '未知父游戏' : 'Unknown Parent', }; // ==================== 状态管理 ==================== const state = { activeTab: 'owned', viewMode: 'card', ownedFilter: 'all', ownedSort: 'appidAsc', // v2.4.5: 库存排序方式(默认 AppID 升序) ownedGames: [], ownedAppIds: new Set(), isLoading: false, showSettings: false, searchQuery: '', page: 1, pageSize: 48, // ====== 愿望单标签页状态 (v2.3.19) ====== wishlistGames: [], wishlistLoaded: false, wishlistLoading: false, wishlistLoadStage: 0, // v2.9.47: 加载阶段 0=未开始 1=页面抓取 2=API补全 3=资料富集 wishlistLoadProgress: 0, // v2.9.47: 加载进度百分比 wishlistSearch: '', wishlistFilter: 'all', // 'all' | 'discount' | 'inlibrary' | 'coming' | 'category' wishlistPage: 1, wishlistPageSize: 48, wishlistViewMode: 'card', wishlistCategoryNames: {}, // v2.3.23: 用户自定义类别 ID -> 名称映射(我的类别) wishlistCategoryFilter: null, // v2.8.0: 当前选中的类别筛选 ID(null=未筛选,'__uncat__'=未分类) // ====== 我的成就标签页状态 (v2.9.33) ====== achSelectedCat: 'all', // 当前选中的成就分类 ('all'=全部, 'collector'/'playtime'/'mastery'/'special') // ====== 锁区游戏标签页状态 (v2.9.12) ====== blockedApps: [], // 锁区游戏列表 [{appid, name, category, categoryId, type, source, changedAt}] blockedLoaded: false, blockedLoading: false, blockedError: null, blockedSource: null, // 'api' | 'html' | 'manual' | 'mixed' // ====== 云存档标签页状态 (v2.9.34) ====== cloudSaveGames: [], // [{appid, name, fileCount, totalSize, sizeBytes, _status}] cloudSaveLoaded: false, cloudSaveLoading: false, cloudSaveError: null, cloudSaveSource: null, // 'web' | 'cache' cloudSaveLastUpdate: 0, // 上次更新时间戳 cloudSaveSearch: '', cloudSaveSort: 'sizeDesc', // 'sizeDesc' | 'sizeAsc' | 'filesDesc' | 'nameAsc' cloudSaveDiff: null, // {new:[], changed:[], removed:[]} 增量比对结果 cloudSaveBgRefresh: false, // v2.9.39: 后台静默增量刷新中(已有缓存数据时使用) cloudSaveBgRefreshAt: 0, // v2.9.39: 上次后台静默刷新尝试时间戳(节流 2 天) cloudSaveFileCountEnriching: false, // v2.9.67: 文件数后台填充中 cloudSaveFileCountAt: 0, // v2.9.74: 上次文件数填充时间戳(5天 TTL,从 IDB 缓存恢复) }; // ==================== 通用工具模块(v2.4.0 借鉴 steam-friend-manager) ==================== // 轻量级 DOM 构建函数 h():替代 innerHTML 拼接,支持事件绑定/CSS变量/布尔属性,降低 XSS 风险 const BOOL_ATTRS = new Set(['disabled', 'checked', 'selected', 'readonly', 'required', 'hidden', 'multiple', 'autofocus', 'open']); function h(tag, props = {}, children = []) { const el = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (v == null) continue; if (k === 'style' && typeof v === 'object') { for (const [sk, sv] of Object.entries(v)) { if (sk.startsWith('--')) el.style.setProperty(sk, sv); else { el.style[sk] = sv; } } } else if (k === 'dataset') { Object.assign(el.dataset, v); } else if (k.startsWith('on') && typeof v === 'function') { el.addEventListener(k.slice(2).toLowerCase(), v); } else if (k === 'text') { el.textContent = v; } else if (k === 'html') { el.innerHTML = v; } else if (BOOL_ATTRS.has(k)) { v ? el.setAttribute(k, '') : el.removeAttribute(k); } else { el.setAttribute(k, v); } } for (const c of children) { if (c == null || c === false) continue; el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } return el; } // 统一 GM 存储 JSON 读取容错(替代 storage 内多处重复的 try{JSON.parse}catch) function parseStored(key, fallback) { const str = GM_getValue(key, ''); if (!str) return fallback; try { return JSON.parse(str); } catch (e) { return fallback; } } // 选择器快捷函数 function $(selector, parent = document) { return parent.querySelector(selector); } function $$(selector, parent = document) { return Array.from(parent.querySelectorAll(selector)); } // v2.9.49: 统一 debounce 工具函数——避免搜索框每次按键触发完整重渲染 function debounce(fn, delay = 200) { let timer = null; const debounced = function(...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); }; debounced.cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; return debounced; } // v2.9.49: 缓存 Intl.Collator 实例,避免每次 renderGamesList 重新构造 const _nameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); // v2.9.56: 统一 Tab 切换函数——消除 5 处重复的 "设置 activeTab + 更新 DOM active 类 + renderBody" 逻辑 // 调用方:_gsJumpToGame(owned/wishlist 跳转)、tab click handler、_onOpenModal 事件 // v2.9.61: 本函数位于子闭包之外,panelEl/renderBody 处于子闭包内,故经 SGLV_API 桥接访问 function switchToTab(tabName) { if (!tabName) return; state.activeTab = tabName; state.showSettings = false; const _panel = SGLV_API.getPanelEl && SGLV_API.getPanelEl(); if (_panel) { _panel.querySelectorAll('.sglv-tab').forEach(t => t.classList.remove('active')); const tabBtn = _panel.querySelector(`.sglv-tab[data-tab="${tabName}"]`); if (tabBtn) tabBtn.classList.add('active'); } if (SGLV_API.renderBody) SGLV_API.renderBody(); } // ==================== v2.9.51: 全局游戏搜索(中文/拼音缩写/英文子串匹配) ==================== // 参考 https://github.com/sys1em/Steam_Buff 的搜索联想思路,结合中文名 + 拼音首字母 + 英文子串, // 本实现完全本地运行,索引从已加载的 state.ownedGames + state.wishlistGames + gameDb 离线数据库 + 持久化自定义别名构建。 // 设计要点: // 1) 启动时一次性构建 _searchIndex(每条 {appid, name, nameLower, nameNoSpace, pinyinAbbr, pinyinAbbrNoSpace, type, sources}) // 2) 搜索时按子串+拼音子串 双向匹配,排序时优先 substring 完全匹配 > 拼音完全匹配 > 短名称 // 3) 命中渲染在浮层中,每条带"类型徽章"(已拥有/愿望单/商店),点击按来源跳转到对应 tab + 滚动定位 // 4) 中文/英文字符串小写化 + 去空格预存,搜索时同样预处理;空格和特殊字符被忽略 // 常用汉字→拼音首字母查表已抽到独立脚本 sglv-pinyin.lib.js(v1.0.0,@require 引入)。 // 主脚本通过 _getPinyinLib() 委托 searchByPinyin,不再需要本地 toPinyinAbbr 包装。 // - 查表来源:SGLVPinyin._TABLE(~3857 常用汉字,ASCII 数字/英文直通) // - 行为契约:空格/标点保留、英文小写、CJK 查表、未收录字符保留原字符(允许 substring 兜底匹配) // v2.9.64: 从一次性求值的 const 改为实时求值函数 _getPinyinLib(), // 解决 @require 脚本因网络延迟晚于主脚本执行时拼音库永久不可用的问题 function _getPinyinLib() { return (typeof window !== 'undefined' && window.SGLVPinyin) || (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVPinyin) || null; } // v2.9.56: 移除 toPinyinAbbr 包装函数——buildSearchIndex 不再预计算 pinyinAbbr 字段, // searchByPinyin 内部通过 getName(it) 实时调用 _getPinyinLib().toPinyinAbbr,包装层已无调用方 // 搜索索引项: {appid, name, type: 'owned'|'wishlist'|'store', owned, wishlist, store, _scoreBias} const _searchIndex = []; // 所有游戏条目(去重,优先级 owned > wishlist > store) const _searchIndexByAppid = new Map(); // appid -> index entry(用于去重) let _searchIndexBuilt = false; // v2.9.56: 统一标点/空格剥离正则——与 sglv-pinyin.lib.js 的 _PUNCT_STRIP_RE 保持一致 // 消除 buildSearchIndex 和 highlightMatch 中 5 处重复的内联正则字面量 const _PUNCT_STRIP_RE = /[\s\-_::·、,,.\/\\()()\[\]【】''""]/g; // 非 global 版本供 .test() 使用——避免 g 标志在循环中 lastIndex 状态泄漏 const _PUNCT_TEST_RE = /[\s\-_::·、,,.\/\\()()\[\]【】''""]/; // 提取已加载 game-db 数据库的所有游戏项(供"商店"维度匹配) // game-db 格式: { "publisher": { "series": [{id, name}, ...] } } function _extractGameDbEntries() { // 优先从 _gameDbCache 拿(如果已加载),否则解析当前 game-db try { let db = null; if (window.SGLVCore && typeof window.SGLVCore.getResourceText === 'function') { const txt = window.SGLVCore.getResourceText('gameDb'); if (txt) db = JSON.parse(txt); } else if (typeof GM_getResourceText === 'function') { const txt = GM_getResourceText('gameDb'); if (txt) db = JSON.parse(txt); } if (!db) return []; const entries = []; for (const publisher of Object.keys(db)) { if (publisher === '_meta') continue; const seriesMap = db[publisher]; if (!seriesMap || typeof seriesMap !== 'object') continue; for (const series of Object.keys(seriesMap)) { const items = seriesMap[series]; if (!Array.isArray(items)) continue; for (const it of items) { if (it && it.id && it.name) { entries.push({ appid: Number(it.id), name: it.name }); } } } } return entries; } catch (e) { return []; } } // 构建/重建搜索索引 function buildSearchIndex() { _searchIndex.length = 0; _searchIndexByAppid.clear(); const ownedSet = new Set(); // 1) 已拥有(优先级最高) // v2.9.53: 保留 _gameObj 引用,渲染时通过 isGameOwnedByMe(_gameObj) 区分"自己拥有"与"家庭共享" if (Array.isArray(state.ownedGames)) { for (const g of state.ownedGames) { if (!g || !g.appid) continue; const appid = Number(g.appid); if (ownedSet.has(appid)) continue; ownedSet.add(appid); _searchIndexByAppid.set(appid, { appid, name: g.name || '', type: 'owned', owned: true, wishlist: false, store: false, _gameObj: g, // v2.9.53: 供徽章渲染时区分 mine/shared }); } } // 2) 愿望单 if (Array.isArray(state.wishlistGames)) { for (const g of state.wishlistGames) { if (!g || !g.appid) continue; const appid = Number(g.appid); if (ownedSet.has(appid)) { const ex = _searchIndexByAppid.get(appid); if (ex) { ex.wishlist = true; ex.type = 'owned'; /* 优先 owned */ } continue; } if (_searchIndexByAppid.has(appid)) continue; _searchIndexByAppid.set(appid, { appid, name: g.name || '', type: 'wishlist', owned: false, wishlist: true, store: false, }); } } // 3) game-db 数据库(商店维度) const dbEntries = _extractGameDbEntries(); for (const e of dbEntries) { if (_searchIndexByAppid.has(e.appid)) { _searchIndexByAppid.get(e.appid).store = true; continue; } _searchIndexByAppid.set(e.appid, { appid: e.appid, name: e.name, type: 'store', owned: false, wishlist: false, store: true, }); } // 4) 转数组,仅预计算来源权重 _scoreBias // v2.9.56: 移除 nameLower/nameNoSpace/pinyinAbbr/pinyinAbbrNoSpace 预计算—— // searchByPinyin 内部通过 getName(it) 实时重算这些字段,预存值从未被读取(死代码) for (const entry of _searchIndexByAppid.values()) { entry._scoreBias = entry.owned ? 30 : entry.wishlist ? 20 : 0; _searchIndex.push(entry); } _searchIndexBuilt = true; console.log(`[Steam 游戏库展示] 全局搜索索引已构建: ${_searchIndex.length} 条 (owned=${ownedSet.size}, wishlist=${(state.wishlistGames||[]).length}, store=${dbEntries.length})`); } // 搜索:返回匹配项数组(按优先级排序),最多 30 条 // v2.9.53 优化:4 级匹配逻辑(100/95/80/75)+ 来源权重(_scoreBias)+ 名称长度排序 // 全部委托给 sglv-pinyin.lib.js 的 searchByPinyin(),sglv-pinyin.lib.test.js 已覆盖 10 个 case。 // 降级路径:拼音库未加载时(理论不会发生,@require 强制)返回空数组并 console.warn。 function searchGames(query, limit = 30) { if (!_searchIndexBuilt) buildSearchIndex(); injectSGLVAppDetailHost(); // v2.9.64: 惰性注入 host API,确保 getOwnershipBadge 可用 const lib = _getPinyinLib(); if (!lib) { console.warn('[Steam 游戏库展示] 拼音库未加载,搜索不可用'); return []; } return lib.searchByPinyin(query, _searchIndex, { limit, getName: e => e.name, }); } // 高亮匹配片段:用 包裹 // v2.9.64: 增加拼音首字母匹配高亮——用户输入"wzry"匹配"王者荣耀"时高亮对应字符 function highlightMatch(name, query) { if (!name || !query) return escHtml(name || ''); const escaped = escHtml(name); const q = String(query).trim(); if (!q) return escaped; // 优先匹配原文字符串(忽略大小写),再尝试拼音首字母 const lowerName = name.toLowerCase(); const lowerQ = q.toLowerCase(); const idx = lowerName.indexOf(lowerQ); if (idx >= 0) { const before = escHtml(name.slice(0, idx)); const hit = escHtml(name.slice(idx, idx + q.length)); const after = escHtml(name.slice(idx + q.length)); return before + '' + hit + '' + after; } // 去空格匹配 const nameNoSpace = lowerName.replace(_PUNCT_STRIP_RE, ''); const qNoSpace = lowerQ.replace(_PUNCT_STRIP_RE, ''); const idx2 = nameNoSpace.indexOf(qNoSpace); if (idx2 >= 0) { // 反推原始字符串中的大致位置 let count = 0, startOrig = -1, endOrig = -1; for (let i = 0; i < name.length; i++) { const c = name[i].toLowerCase(); if (!_PUNCT_TEST_RE.test(c)) { if (count === idx2) startOrig = i; if (count === idx2 + qNoSpace.length - 1) { endOrig = i + 1; break; } count++; } } if (startOrig >= 0 && endOrig > startOrig) { return escHtml(name.slice(0, startOrig)) + '' + escHtml(name.slice(startOrig, endOrig)) + '' + escHtml(name.slice(endOrig)); } } // v2.9.64: 拼音首字母匹配高亮——toPinyinAbbr 与原名字符 1:1 对应,可直接映射位置 const lib = _getPinyinLib(); if (lib && typeof lib.toPinyinAbbr === 'function') { const pinyinAbbr = lib.toPinyinAbbr(name).toLowerCase(); // 先尝试直接子串匹配(含空格) const idx3 = pinyinAbbr.indexOf(lowerQ); if (idx3 >= 0) { return escHtml(name.slice(0, idx3)) + '' + escHtml(name.slice(idx3, idx3 + q.length)) + '' + escHtml(name.slice(idx3 + q.length)); } // 再尝试去空格后的拼音缩写匹配 const abbrNoSpace = pinyinAbbr.replace(_PUNCT_STRIP_RE, ''); const idx4 = abbrNoSpace.indexOf(qNoSpace); if (idx4 >= 0) { let count2 = 0, startOrig2 = -1, endOrig2 = -1; for (let i = 0; i < pinyinAbbr.length; i++) { const c = pinyinAbbr[i]; if (!_PUNCT_TEST_RE.test(c)) { if (count2 === idx4) startOrig2 = i; if (count2 === idx4 + qNoSpace.length - 1) { endOrig2 = i + 1; break; } count2++; } } if (startOrig2 >= 0 && endOrig2 > startOrig2) { return escHtml(name.slice(0, startOrig2)) + '' + escHtml(name.slice(startOrig2, endOrig2)) + '' + escHtml(name.slice(endOrig2)); } } } return escaped; } // ==================== v2.9.51: 全局游戏搜索 - 事件绑定/下拉渲染/键盘导航/游戏跳转 ==================== // 设计:搜索框绑定防抖输入/键盘/外部点击事件;下拉浮层渲染前 30 条匹配项, // 支持 ↑↓ 选择、Enter 跳转、Esc 关闭;游戏条目带 owned/wishlist/store 徽标。 // 跳转策略:owned → 切到 owned tab 并滚动定位;wishlist → 切到 wishlist tab 并定位; // store-only → 直接打开 Steam 商店页。 let _gsFocusIdx = -1; // 当前键盘聚焦的项索引 let _gsLastResults = []; // 最近一次搜索结果 let _gsLastQuery = ''; // 最近一次搜索关键词 let _gsInputEl = null; // 搜索输入框 let _gsPopEl = null; // 下拉浮层 let _gsSearchWrap = null; // 整个搜索容器 let _gsClearBtn = null; // 清除按钮 // 标记搜索索引需重建(数据变化时调用) function markSearchIndexDirty() { _searchIndexBuilt = false; } // 切换搜索下拉浮层显隐 // v2.9.56: 用 classList.toggle 简化 if/else function _gsShowPop(show) { if (!_gsPopEl) return; _gsPopEl.classList.toggle('show', show); } // 关闭搜索下拉(清空聚焦索引) function closeSearchPop() { _gsShowPop(false); _gsFocusIdx = -1; } // 渲染搜索结果下拉 function renderSearchResults(query) { if (!_gsPopEl) return; const q = String(query || '').trim(); if (!q) { _gsShowPop(false); _gsLastResults = []; return; } const results = searchGames(q, 30); _gsLastResults = results; _gsLastQuery = q; _gsFocusIdx = -1; // 空结果 if (results.length === 0) { _gsPopEl.innerHTML = `
${ICONS.search}
${escHtml(T.gsEmpty)}
`; _gsShowPop(true); return; } // 头部 + 列表 const header = `
${escHtml(T.gsHeader)}${results.length}
`; const items = results.map((entry, idx) => { const owned = !!entry.owned, wishlist = !!entry.wishlist, store = !!entry.store; // 多个来源时主徽标优先: owned > wishlist > store let typeKey, typeText; if (owned) { typeKey = 'owned'; typeText = T.gsTypeOwned; } else if (wishlist) { typeKey = 'wishlist'; typeText = T.gsTypeWishlist; } else { typeKey = 'store'; typeText = T.gsTypeStore; } // 次要徽标 const badges = []; if (owned && wishlist) badges.push(`${escHtml(T.gsTypeWishlist)}`); if (owned && store) badges.push(`${escHtml(T.gsTypeStore)}`); // v2.9.64: 移除死代码——wishlist && store && !owned 时推入 display:none 空徽章,无可见效果 // v2.9.53: 拥有/共享状态徽章(基于 SGLVAppDetail.getOwnershipBadge) // 直接传 entry._gameObj(O(1) 替代库内 Array.find,30 条结果渲染 30x 提速) // _gameObj 仅在 owned 条目上存在,wishlist/store-only 跳过即可 let ownershipBadge = ''; try { if (unsafeWindow.SGLVAppDetail && entry._gameObj) { const b = unsafeWindow.SGLVAppDetail.getOwnershipBadge(entry.appid, entry._gameObj); if (b && b.kind === 'shared') { // 家庭共享:紫色徽章 + tooltip 显示共享者 const tip = b.tip ? escHtml(b.tip) : (isZh ? '家庭库中共享' : 'Family shared'); const lbl = isZh ? '共享' : 'Shared'; ownershipBadge = `${lbl}`; } } } catch (e) { /* ignore */ } const subText = owned ? T.gsJumpToOwned : wishlist ? T.gsJumpToWishlist : T.gsOpenStore; const htmlName = highlightMatch(entry.name, q); const capSrc = (typeof getGameIconUrl === 'function') ? getGameIconUrl(entry.appid, '') : ''; // v2.9.58: 外链图标仅在 store-only 时显示(owned/wishlist 是面板内跳转) const extIcon = (!owned && !wishlist) ? `${ICONS.external}` : ''; return `
${capSrc ? `` : ''}
${htmlName}
${escHtml(subText)} · AppID ${entry.appid}
${escHtml(typeText)} ${ownershipBadge} ${badges.join('')} ${extIcon}
`; }).join(''); // v2.9.58: 页脚键盘提示用 kbd 元素视觉化 const footer = isZh ? `` : ``; _gsPopEl.innerHTML = header + items + footer; _gsShowPop(true); // 绑定每项点击 _gsPopEl.querySelectorAll('.sglv-gs-item').forEach(itemEl => { const idx = Number(itemEl.dataset.idx); itemEl.addEventListener('mouseenter', () => { _gsFocusIdx = idx; _gsUpdateFocusClass(); }); itemEl.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); const target = _gsLastResults[idx]; if (target) _gsJumpToGame(target); }); }); } // 更新键盘聚焦项高亮 function _gsUpdateFocusClass() { if (!_gsPopEl) return; const items = _gsPopEl.querySelectorAll('.sglv-gs-item'); items.forEach((el, i) => { if (i === _gsFocusIdx) el.classList.add('focus'); else el.classList.remove('focus'); }); // 滚动到可视区 if (_gsFocusIdx >= 0 && items[_gsFocusIdx]) { items[_gsFocusIdx].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } // v2.9.63: 游戏详情浮窗 —— 点击搜索结果后弹出详情卡片 // 依赖 SGLVAppDetail.loadDetail() 获取 appdetails 数据(内存5min+磁盘24h缓存) let _detailEscHandler = null; let _detailToken = 0; // v2.9.64: 异步竞态保护——快速连续点击不同搜索结果时丢弃旧请求结果 function closeDetailPopup() { _detailToken++; // 使任何在途的详情请求失效 const overlay = document.getElementById('sglv-detail-overlay'); const pop = document.getElementById('sglv-detail-pop'); if (overlay) overlay.classList.remove('show'); if (pop) { pop.classList.remove('show'); pop.innerHTML = ''; } if (_detailEscHandler) { document.removeEventListener('keydown', _detailEscHandler); _detailEscHandler = null; } } function _bindDetailClose(pop) { const overlay = document.getElementById('sglv-detail-overlay'); const closeBtn = pop.querySelector('#sglv-detail-close-btn'); if (closeBtn) closeBtn.addEventListener('click', closeDetailPopup); if (overlay) overlay.onclick = closeDetailPopup; // 覆盖式绑定,避免重复 if (_detailEscHandler) document.removeEventListener('keydown', _detailEscHandler); _detailEscHandler = (e) => { if (e.key === 'Escape') closeDetailPopup(); }; document.addEventListener('keydown', _detailEscHandler); } function showGameDetailPopup(appid, entry) { const overlay = document.getElementById('sglv-detail-overlay'); const pop = document.getElementById('sglv-detail-pop'); if (!overlay || !pop) { console.warn('[SGLV] 详情浮窗容器未找到'); return; } injectSGLVAppDetailHost(); // v2.9.64: 惰性注入 host API,确保 loadDetail 可用 const token = ++_detailToken; // 每次打开递增,旧请求 resolve 后比对发现不一致则丢弃 // 加载态 pop.innerHTML = `
${escHtml(T.dpLoading)}
`; overlay.classList.add('show'); pop.classList.add('show'); _bindDetailClose(pop); _loadAndRenderDetail(appid, entry, pop, token); } async function _loadAndRenderDetail(appid, entry, pop, token) { const A = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail) || (typeof window !== 'undefined' && window.SGLVAppDetail); if (!A || typeof A.loadDetail !== 'function') { if (token !== _detailToken) return; // 已被新请求取代 _renderDetailError(pop, appid, entry, 'SGLVAppDetail 库未加载'); return; } try { const d = await A.loadDetail(appid, { useCache: true }); if (token !== _detailToken) return; // 已被新请求取代,丢弃旧结果 if (!d) { _renderDetailError(pop, appid, entry, 'API 无数据返回'); return; } _renderDetailContent(pop, appid, entry, d); } catch (e) { if (token !== _detailToken) return; // 已被新请求取代 _renderDetailError(pop, appid, entry, e.message || String(e)); } } function _renderDetailError(pop, appid, entry, msg) { pop.innerHTML = `
${escHtml(T.dpError)} ${escHtml(msg)}
`; _bindDetailClose(pop); const retry = pop.querySelector('#sglv-detail-retry'); if (retry) retry.addEventListener('click', () => showGameDetailPopup(appid, entry)); } function _renderDetailContent(pop, appid, entry, d) { const platforms = []; if (d.platforms) { if (d.platforms.win) platforms.push('Windows'); if (d.platforms.mac) platforms.push('macOS'); if (d.platforms.linux) platforms.push('SteamOS+Linux'); } // 价格 let priceHtml = ''; if (d.isFree) { priceHtml = `${escHtml(T.dpFree)}`; } else if (d.cnPrice) { priceHtml = `¥${escHtml(String(d.cnPrice.price))}`; if (d.cnPrice.discount > 0) priceHtml += `-${escHtml(String(d.cnPrice.discount))}%`; } // Metacritic let metaHtml = ''; if (d.metacritic && d.metacritic.score) { const s = d.metacritic.score; const c = s >= 75 ? '#4ade80' : s >= 50 ? '#fbbf24' : '#f87171'; metaHtml = `Metacritic ${escHtml(String(s))}`; } // 评测摘要 let reviewHtml = ''; if (d.reviews && d.reviews.summary) { reviewHtml = `
${escHtml(d.reviews.summary)}${d.reviews.count ? ` (${escHtml(d.reviews.count)})` : ''}
`; } // 类型标签 const genresHtml = (d.genres && d.genres.length) ? `
${d.genres.map(g => `${escHtml(g)}`).join('')}
` : ''; // 截图 const shotsHtml = (d.screenshots && d.screenshots.length) ? `
${d.screenshots.slice(0, 6).map(s => `
` ).join('')}
` : ''; // 操作按钮 const storeUrl = `https://store.steampowered.com/app/${appid}`; let actionsHtml = `${escHtml(T.dpStorePage)}`; if (entry && entry.owned) { actionsHtml += ``; } else if (entry && entry.wishlist) { actionsHtml += ``; } pop.innerHTML = `
${d.cover ? `
` : ''}
${escHtml(d.name || (entry && entry.name) || '')}
${metaHtml} ${reviewHtml} ${d.shortDesc ? `
${escHtml(d.shortDesc)}
` : ''}
${d.developers && d.developers.length ? `
${escHtml(T.dpDeveloper)}
${escHtml(d.developers.join(', '))}
` : ''} ${d.publishers && d.publishers.length ? `
${escHtml(T.dpPublisher)}
${escHtml(d.publishers.join(', '))}
` : ''} ${d.releaseDate ? `
${escHtml(T.dpRelease)}
${escHtml(d.releaseDate)}
` : ''} ${platforms.length ? `
${escHtml(T.dpPlatforms)}
${escHtml(platforms.join(', '))}
` : ''} ${priceHtml ? `
${escHtml(T.dpPrice)}
${priceHtml}
` : ''}
${genresHtml} ${shotsHtml}
${actionsHtml}
`; _bindDetailClose(pop); // 跳转到游戏库/愿望单按钮 const jumpLib = pop.querySelector('#sglv-detail-jump-lib'); if (jumpLib) jumpLib.addEventListener('click', () => { closeDetailPopup(); if (state.activeTab !== 'owned') switchToTab('owned'); else if (SGLV_API.renderBody) SGLV_API.renderBody(); requestAnimationFrame(() => _gsScrollToAppid(entry.appid, '#sglv-body')); showToast(`🎮 ${entry.name}`); }); const jumpWl = pop.querySelector('#sglv-detail-jump-wl'); if (jumpWl) jumpWl.addEventListener('click', () => { closeDetailPopup(); if (state.activeTab !== 'wishlist') switchToTab('wishlist'); else if (SGLV_API.renderBody) SGLV_API.renderBody(); requestAnimationFrame(() => _gsScrollToAppid(entry.appid, '#sglv-body')); showToast(`💖 ${entry.name}`); }); } // 跳转游戏核心逻辑 // v2.9.63: 统一弹出游戏详情浮窗(owned/wishlist/store-only 均显示详情) // 浮窗内提供"跳转到游戏库/愿望单"按钮实现原有跳转功能 function _gsJumpToGame(entry) { if (!entry) return; closeSearchPop(); // 关闭下拉后清空输入框 if (_gsInputEl) { _gsInputEl.value = ''; _gsInputEl.dispatchEvent(new Event('input')); } showGameDetailPopup(entry.appid, entry); } // 在已渲染的列表中滚动定位 appid function _gsScrollToAppid(appid, containerSel = '#sglv-body') { try { const container = document.querySelector(containerSel); if (!container) return false; // 尝试多种选择器(cover/card/list 三种视图) const selectors = [ `.sglv-cover-name[data-sglv-appid="${appid}"]`, `.sglv-card-name[data-sglv-appid="${appid}"]`, `.sglv-list-name[data-sglv-appid="${appid}"]`, `.sglv-game-card[data-appid="${appid}"]`, `.sglv-wl-cover-card[data-appid="${appid}"]`, `.sglv-list-item[data-appid="${appid}"]`, `[data-sglv-appid="${appid}"]`, `[data-appid="${appid}"]`, ]; for (const sel of selectors) { const el = container.querySelector(sel); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); // 视觉高亮 const card = el.closest('.sglv-game-card, .sglv-wl-cover-card, .sglv-list-item, .sglv-cover-card, .sglv-cover-item, .sglv-card-grid > div, .sglv-list-view > div') || el; if (card && card.style) { const orig = card.style.boxShadow; card.style.boxShadow = '0 0 0 3px rgba(102, 192, 244, 0.8), 0 0 24px rgba(102, 192, 244, 0.6)'; card.style.transition = 'box-shadow 0.3s ease'; setTimeout(() => { card.style.boxShadow = orig; }, 2400); } return true; } } // 列表里没找到——可能受分页/搜索过滤影响,提示用户 showToast(isZh ? '该游戏在当前视图未显示(可能受分页或搜索过滤影响)' : 'Game not visible (pagination/search filter?)'); return false; } catch (e) { console.warn('[SGLV] 搜索跳转滚动失败:', e); return false; } } // 输入防抖 const _gsDebouncedRender = debounce((q) => renderSearchResults(q), 150); // 绑定全局搜索框全部事件 function bindGlobalSearchEvents() { const _panel = SGLV_API.getPanelEl && SGLV_API.getPanelEl(); // v2.9.61: panelEl 在子闭包内,经 SGLV_API 桥接 _gsInputEl = _panel?.querySelector('#sglv-global-search-input'); _gsPopEl = _panel?.querySelector('#sglv-global-search-pop'); _gsSearchWrap = _panel?.querySelector('#sglv-global-search'); _gsClearBtn = _panel?.querySelector('#sglv-global-search-clear'); if (!_gsInputEl || !_gsPopEl) return; // 输入事件 const onInput = () => { const v = _gsInputEl.value; // 切换清除按钮显隐 if (v) _gsSearchWrap.classList.add('has-text'); else _gsSearchWrap.classList.remove('has-text'); if (!v.trim()) { _gsShowPop(false); _gsLastResults = []; _gsFocusIdx = -1; return; } _gsDebouncedRender(v); }; _gsInputEl.addEventListener('input', onInput); addDisposer(() => _gsInputEl.removeEventListener('input', onInput)); // 焦点聚焦 → 如有内容则打开下拉 const onFocus = () => { if (_gsInputEl.value.trim() && _gsLastResults.length) _gsShowPop(true); }; _gsInputEl.addEventListener('focus', onFocus); addDisposer(() => _gsInputEl.removeEventListener('focus', onFocus)); // 键盘事件 const onKeydown = (e) => { const key = e.key; if (key === 'ArrowDown') { e.preventDefault(); if (!_gsLastResults.length) return; _gsShowPop(true); _gsFocusIdx = (_gsFocusIdx + 1) % _gsLastResults.length; _gsUpdateFocusClass(); } else if (key === 'ArrowUp') { e.preventDefault(); if (!_gsLastResults.length) return; _gsShowPop(true); _gsFocusIdx = _gsFocusIdx <= 0 ? _gsLastResults.length - 1 : _gsFocusIdx - 1; _gsUpdateFocusClass(); } else if (key === 'Enter') { e.preventDefault(); if (_gsFocusIdx >= 0 && _gsLastResults[_gsFocusIdx]) { _gsJumpToGame(_gsLastResults[_gsFocusIdx]); } else if (_gsLastResults.length > 0) { _gsJumpToGame(_gsLastResults[0]); } } else if (key === 'Escape') { e.preventDefault(); if (_gsPopEl.classList.contains('show')) { closeSearchPop(); } else { _gsInputEl.value = ''; _gsInputEl.dispatchEvent(new Event('input')); _gsInputEl.blur(); } } }; _gsInputEl.addEventListener('keydown', onKeydown); addDisposer(() => _gsInputEl.removeEventListener('keydown', onKeydown)); // 清除按钮 if (_gsClearBtn) { const onClear = (e) => { e.preventDefault(); e.stopPropagation(); _gsInputEl.value = ''; _gsInputEl.dispatchEvent(new Event('input')); _gsInputEl.focus(); }; _gsClearBtn.addEventListener('click', onClear); addDisposer(() => _gsClearBtn.removeEventListener('click', onClear)); } // 点击外部关闭下拉 const onDocClick = (e) => { if (!_gsSearchWrap) return; if (_gsSearchWrap.contains(e.target)) return; closeSearchPop(); }; // 用 mousedown 比 click 早,避免点击下拉项后被外部关闭抢断 document.addEventListener('mousedown', onDocClick, true); addDisposer(() => document.removeEventListener('mousedown', onDocClick, true)); // 切换 tab 时关闭下拉 const onTabClick = (e) => { if (e.target.closest('.sglv-tab')) closeSearchPop(); }; _panel?.addEventListener('click', onTabClick); addDisposer(() => _panel?.removeEventListener('click', onTabClick)); } // 暴露搜索函数到全局,便于其他模块(如侧边栏)调用 unsafeWindow.SGLVSearch = { search: searchGames, build: buildSearchIndex, dirty: markSearchIndexDirty, jump: (appid) => { // 由 appid 跳转(兼容调用方) if (!_searchIndexBuilt) buildSearchIndex(); const entry = _searchIndexByAppid.get(Number(appid)); if (entry) { _gsJumpToGame(entry); return true; } return false; }, }; // v2.9.53: 把主脚本的拥有/共享判定 API 注入到 sglv-app-detail 库, // 这样搜索结果下拉和其他上层都能用 SGLVAppDetail.getOwnershipBadge(appid) 拿到统一状态 // v2.9.64: 从 IIFE 改为惰性注入——@require 脚本可能因网络延迟晚于主脚本执行, // 启动时注入会静默失败。改为在 searchGames/showGameDetailPopup 首次调用时尝试注入。 let _hostApiInjected = false; function injectSGLVAppDetailHost() { if (_hostApiInjected) return; try { const A = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail) || (typeof window !== 'undefined' && window.SGLVAppDetail); if (!A || typeof A.setHostApi !== 'function') return; A.setHostApi({ getOwnedGames: () => state.ownedGames, isGameOwnedByMe: (g) => isGameOwnedByMe(g), getGameOwnerNames: (g) => getGameOwnerNames(g), getActiveSteamId: () => getActiveSteamId(), }); _hostApiInjected = true; } catch (e) { /* ignore - lib 未加载时静默,下次调用会重试 */ } } // 分页切片纯函数(统一各标签页分页计算) function paginate(items, page, size) { const totalPages = Math.max(1, Math.ceil(items.length / size)); const clampedPage = Math.min(Math.max(1, page), totalPages); const start = (clampedPage - 1) * size; return { pageItems: items.slice(start, start + size), totalPages, page: clampedPage }; } // 资源清理机制:统一收集事件监听器/MutationObserver/定时器,卸载时清理,避免内存泄漏 const disposers = []; function addDisposer(fn) { if (typeof fn === 'function') disposers.push(fn); } function runDisposers() { while (disposers.length) { try { (disposers.pop())(); } catch (e) {} } } // 封面图成功 URL 内存缓存:记录已成功加载的封面地址,下次优先使用,减少回退探测请求 // v2.4.3: 缓存键按图片种类(poster/capsule/header)隔离,避免竖版海报 URL 被横版封面复用导致裁切变形 const _posterGoodUrl = new Map(); function recordPosterGood(appid, url, kind) { if (appid && url) _posterGoodUrl.set(`${kind || 'poster'}:${appid}`, url); } function getPosterGood(appid, kind) { return _posterGoodUrl.get(`${kind || 'poster'}:${appid}`); } // 暴露给内联 onload 使用 unsafeWindow._sglvRecordPosterGood = function(appid, url, kind) { recordPosterGood(appid, url, kind); }; const storage = { // ---- 小对象:保持 GM_setValue(同步、零延迟、几 KB 级别)---- getApiKey: () => GM_getValue(nsKey('api_key'), ''), setApiKey: v => GM_setValue(nsKey('api_key'), v), getSteamId: () => GM_getValue(nsKey('steamid'), ''), setSteamId: v => GM_setValue(nsKey('steamid'), v), getShowFamilyShared: () => GM_getValue(nsKey('show_family_shared'), true), setShowFamilyShared: v => GM_setValue(nsKey('show_family_shared'), v), // ---- v2.4.5: 库存排序偏好 ---- getOwnedSort: () => GM_getValue(nsKey('owned_sort'), 'appidAsc'), setOwnedSort: v => GM_setValue(nsKey('owned_sort'), v), // ---- AI 模型配置 ---- getAiApiUrl: () => GM_getValue(nsKey('ai_api_url'), 'https://api.deepseek.com/v1/chat/completions'), setAiApiUrl: v => GM_setValue(nsKey('ai_api_url'), v), getAiApiKey: () => GM_getValue(nsKey('ai_api_key'), ''), setAiApiKey: v => GM_setValue(nsKey('ai_api_key'), v), getAiModel: () => GM_getValue(nsKey('ai_model'), 'deepseek-v4-pro'), setAiModel: v => GM_setValue(nsKey('ai_model'), v), // ---- AI 价格预测模式配置 (v2.3) ---- getPredictModes: () => { const arr = parseStored(nsKey('predict_modes'), ['seasonal', 'trend', 'lowest']); return Array.isArray(arr) && arr.length ? arr : ['seasonal', 'trend', 'lowest']; }, setPredictModes: v => GM_setValue(nsKey('predict_modes'), JSON.stringify(v)), // ---- v2.9.15: 大对象迁移至 IndexedDB(容量无 5MB 限制)---- // 旧 GM key 兼容:第一次读取时若 IDB 无值,尝试从 GM_setValue 迁移 getCachedGames: () => sglvIDB.get(nsKey('cached_games'), parseStored(nsKey('cached_games') /* 老 key 兜底 */, parseStored('sglv_cached_games', []))), setCachedGames: v => sglvIDB.set(nsKey('cached_games'), v), getFamilyInfo: () => sglvIDB.get(nsKey('family_info'), parseStored(nsKey('family_info'), parseStored('sglv_family_info', {}))), setFamilyInfo: v => sglvIDB.set(nsKey('family_info'), v), // ---- v2.9.34: 云存档数据(大对象,IDB 存储)---- getCloudSaveData: () => sglvIDB.get(nsKey('cloud_save_data'), parseStored(nsKey('cloud_save_data'), null)), setCloudSaveData: v => sglvIDB.set(nsKey('cloud_save_data'), v), }; // 启动时一次性把老 GM 大对象迁到 IDB,避免下次升级重复走 fallback function migrateLegacyGmKeysToIDB() { const legacyKeys = [ ['sglv_cached_games', nsKey('cached_games')], ['sglv_family_info', nsKey('family_info')], ]; for (const [oldKey, newKey] of legacyKeys) { try { if (sglvIDB.has(newKey)) continue; // 已迁过 const raw = GM_getValue(oldKey, null); if (raw == null) continue; const v = typeof raw === 'string' ? JSON.parse(raw) : raw; if (v != null && (Array.isArray(v) ? v.length : Object.keys(v).length)) { sglvIDB.set(newKey, v); console.log(`[SGLV] 迁移 ${oldKey} → IDB:${newKey} (${Array.isArray(v) ? v.length : Object.keys(v).length} 项)`); } } catch (e) { console.warn(`[SGLV] 迁移 ${oldKey} 失败:`, e); } } } // ==================== TTL 缓存层 ==================== // 基于 GM_setValue 的带 TTL 缓存,支持侧边栏各标签页数据缓存 const CACHE_TTL = { overview: 24 * 3600 * 1000, // 概览:24h(DOM 提取,刷新页面即变) prices: 2 * 3600 * 1000, // 多地区价格:2h historyPrices: 6 * 3600 * 1000, // ITAD 历史价格:6h achievements: 1 * 3600 * 1000, // 成就数据:1h globalAchievements: 12 * 3600 * 1000, // 全球成就:12h news: 30 * 60 * 1000, // 新闻动态:30min familyShare: 6 * 3600 * 1000, // 家庭共享支持:6h appDetailsExtra: 24 * 3600 * 1000, // v2.3.16: appdetails 增强信息(工坊/截图/分类/捆绑包等):24h friendsList: 6 * 3600 * 1000, // v2.3.8: 好友完整数据(列表+摘要+VAC):6h friendsVac: 24 * 3600 * 1000, // v2.3.8: VAC 封禁状态:24h(变化慢) friendsLevels: 7 * 24 * 3600 * 1000, // v2.3.8: 好友等级:7天(等级变化慢) psplus: 6 * 3600 * 1000, // v2.6.0: PS 会免数据(远程 json):6h epic: 12 * 3600 * 1000, // v2.7.9: Epic 赠送名单(其乐抓取):12h goty: 6 * 24 * 3600 * 1000, // v2.9.27: 年度大作名单(jsdelivr CDN json):6 天(数据基本不变,偶尔新增待发售游戏,5-7 天缓存足够) reviews: 1 * 3600 * 1000, // v2.8.0: 游戏评测数据:1h giftRec: 2 * 3600 * 1000, // v2.8.0: 跨区送礼推荐:2h(依赖价格数据) gameStatus: 6 * 3600 * 1000, // v2.9.10: gamestatus.info 破解状态:6h blockedApps: 24 * 3600 * 1000, // v2.9.12: steam-tracker.com 锁区数据:24h seriesData: 7 * 24 * 3600 * 1000, // v2.9.27: 游戏系列数据(GitHub json):7 天 wishlist: 3 * 24 * 3600 * 1000, // v2.9.30: 愿望单数据:3 天(价格/折扣等动态字段后台静默刷新,6h 节流) cloudSave: 2 * 24 * 3600 * 1000, // v2.9.39: 云存档数据:2 天(后台静默增量更新,6h 节流;只增不减,保留已有游戏列表) }; // v2.9.39: 云存档后台静默刷新节流(与 wishlist 一致,6h) const CLOUDSAVE_BG_REFRESH_INTERVAL = 6 * 3600 * 1000; // v2.9.67: 云存档文件数填充节流(v2.9.74: 12h → 5天,云存档相对固定,避免频繁请求单App页面) const CLOUDSAVE_FILECOUNT_INTERVAL = 5 * 24 * 3600 * 1000; function cacheGet(key) { try { const raw = GM_getValue('sglv_cache_' + key, ''); if (!raw) return null; const obj = JSON.parse(raw); if (obj.expires && Date.now() > obj.expires) { GM_setValue('sglv_cache_' + key, ''); return null; } return obj.data; } catch { return null; } } function cacheSet(key, data, ttl) { try { GM_setValue('sglv_cache_' + key, JSON.stringify({ data, expires: Date.now() + ttl })); } catch { /* ignore quota errors */ } } function cacheClear(prefix) { try { const keys = GM_listValues ? GM_listValues() : []; for (const k of keys) { if (k.startsWith('sglv_cache_' + (prefix || ''))) { GM_setValue(k, ''); } } } catch { /* ignore */ } } // ==================== AI 翻译总结 (v2.2) ==================== const AI_PROMPT_URL = 'https://leanisssharedstorage.blob.core.windows.net/copilot/asdm-files/ai-prompt-template.json'; // ==================== AI 价格预测 (v2.3) ==================== const AI_PRICE_PREDICT_PROMPT_URL = 'https://leanisssharedstorage.blob.core.windows.net/copilot/asdm-files/ai-price-predict-template.json'; // 内置预测提示词模板 (远程加载失败时的 fallback) const FALLBACK_PREDICT_PROMPT = `# 角色\n你是一位精通Steam平台价格分析和游戏行业趋势的数据科学家。基于游戏的历史折扣数据和Steam促销活动节点,使用多种预测模型对游戏价格走势进行预测分析。\n\n# 任务\n根据输入的游戏价格历史数据、折扣记录和发行信息,使用用户选定的预测模式进行分析,输出结构化的预测结果。\n\n## 预测模式说明\n用户已启用以下预测模式:\n{PREDICT_MODES}\n\n### seasonal(季节性折扣预测)\n基于Steam官方促销活动日历(夏促6-7月/秋促11月底/冬促12月底/春促3月)预测下一次折扣时机。\n\n### trend(价格趋势预测)\n基于历史折扣的间隔周期和幅度趋势,预测下次折扣的可能百分比和价格。\n\n### lowest(史低突破预测)\n预测游戏达到或突破历史最低价的可能性和时机。\n\n### review(评价动量预测)\n结合游戏口碑变化趋势评估未来折扣力度。\n\n### similar(相似历史预测)\n匹配游戏价格历史中的相似片段预测折扣模式。\n\n# 输出格式\n严格按以下JSON格式输出,不要输出任何其他文字。\n\n{"models":[{"mode":"seasonal","mode_name":"模式名称","prediction":"预测摘要","discount_percent":50,"predicted_price":134.00,"currency":"CNY","days_until":46,"target_date":"2026-08-27","confidence":92,"sale_event":"秋季特卖","detail":"详细分析","indicators":{}}],"best_buy":{"recommendation":"购买建议","best_time":"最佳时间","best_price":134.00,"wait_days":46,"urgency":"medium"}}\n\n# 输入数据\n- 游戏名称:{GAME_NAME}\n- 应用ID:{APP_ID}\n- 发行日期:{RELEASE_DATE}\n- 当前日期:{CURRENT_DATE}\n- 当前价格:{CURRENT_PRICE} {CURRENCY}\n- 原价:{ORIGINAL_PRICE} {CURRENCY}\n\n## 历史折扣记录:\n{DISCOUNT_RECORDS}\n\n## 完整价格历史:\n{PRICE_HISTORY}\n\n# 注意事项\n- 基于数据客观分析,不要编造数据。\n- confidence反映数据支撑程度:样本充足且规律明显时高分,不足时低分。\n- best_buy应综合所有模式给出最优建议。\n- 输出必须是合法JSON。`; // 预测模式定义 const PREDICT_MODES = [ { key: 'seasonal', name: T.aiPredictModeSeasonal, desc: T.aiPredictModeSeasonalDesc }, { key: 'trend', name: T.aiPredictModeTrend, desc: T.aiPredictModeTrendDesc }, { key: 'lowest', name: T.aiPredictModeLowest, desc: T.aiPredictModeLowestDesc }, { key: 'review', name: T.aiPredictModeReview, desc: T.aiPredictModeReviewDesc }, { key: 'similar', name: T.aiPredictModeSimilar, desc: T.aiPredictModeSimilarDesc }, ]; // ---- 统一提示词模板加载器(带缓存 + 可选 fallback)---- const _promptCache = new Map(); function loadPromptTemplate(url, fallback = null) { if (_promptCache.has(url)) return Promise.resolve(_promptCache.get(url)); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 15000, onload(r) { try { const data = JSON.parse(r.responseText); if (data.prompt && data.prompt.trim()) { _promptCache.set(url, data.prompt.trim()); resolve(_promptCache.get(url)); } else if (fallback) { _promptCache.set(url, fallback); resolve(fallback); } else reject(new Error(isZh ? '提示词模板内容为空' : 'Prompt template is empty')); } catch (e) { if (fallback) { _promptCache.set(url, fallback); resolve(fallback); } else reject(new Error(isZh ? '提示词模板解析失败' : 'Prompt template parse failed')); } }, onerror: () => { if (fallback) { _promptCache.set(url, fallback); resolve(fallback); } else reject(new Error(isZh ? '提示词模板网络请求失败' : 'Prompt template network error')); }, ontimeout: () => { if (fallback) { _promptCache.set(url, fallback); resolve(fallback); } else reject(new Error(isZh ? '提示词模板请求超时' : 'Prompt template timeout')); }, }); }); } const getAiPromptTemplate = () => loadPromptTemplate(AI_PROMPT_URL); const getPredictPromptTemplate = () => loadPromptTemplate(AI_PRICE_PREDICT_PROMPT_URL, FALLBACK_PREDICT_PROMPT); // ---- 统一 AI API POST 调用 ---- // v2.3.18: 新增 maxTokens (默认 4096, 可按场景提高) 和 returnMeta (返回 {content, finishReason, truncated}) function callAiApi(prompt, { temperature = 0.3, timeout = 60000, maxTokens = 4096, returnMeta = false } = {}) { const apiKey = storage.getAiApiKey(); if (!apiKey) return Promise.reject(new Error(T.aiNotConfigured)); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: storage.getAiApiUrl(), timeout, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, data: JSON.stringify({ model: storage.getAiModel(), messages: [{ role: 'user', content: prompt }], temperature, max_tokens: maxTokens }), onload(resp) { try { const respData = JSON.parse(resp.responseText); const choice = respData?.choices?.[0] || {}; const content = choice.message?.content || ''; const finishReason = choice.finish_reason || ''; if (!content) { reject(new Error('AI 返回为空')); return; } if (returnMeta) { resolve({ content, finishReason, truncated: finishReason === 'length' }); } else { resolve(content); } } catch (e) { reject(e); } }, onerror: () => reject(new Error('AI API 网络错误')), ontimeout: () => reject(new Error('AI API 请求超时')), }); }); } // v2.3.18: 修复被截断的 JSON (AI 响应因 max_tokens 限制被截断, 缺少闭合括号) // 策略: 1. 关闭未闭合的字符串 2. 移除尾部不完整的元素 3. 关闭所有未闭合的括号 function repairTruncatedJson(str) { let s = str; // 1. 正向扫描: 跟踪字符串状态、括号栈, 记录最后一个"安全边界"位置 // 安全边界 = 数组内最后一个完整元素 (} 或 ]) 之后的位置, 可在此处安全截断 let inStr = false, esc = false; const stk = []; let lastSafeBoundary = -1; for (let i = 0; i < s.length; i++) { const c = s[i]; if (esc) { esc = false; continue; } if (c === '\\') { esc = true; continue; } if (c === '"') { inStr = !inStr; continue; } if (inStr) continue; if (c === '{' || c === '[') stk.push(c); else if (c === '}' || c === ']') { if (stk.length) { stk.pop(); // 弹出后若栈顶是 '[', 说明刚闭合的是数组内一个完整元素 if (stk.length > 0 && stk[stk.length - 1] === '[') { lastSafeBoundary = i + 1; } } } } // 2. 关闭未闭合的字符串 if (inStr) s += '"'; // 3. 如果截断在元素中间, 裁剪到最后一个安全边界 (移除不完整的尾部元素) s = s.replace(/[\s]+$/, ''); if (s.length > 0) { const lastCh = s[s.length - 1]; if (lastCh !== '}' && lastCh !== ']' && lastCh !== ',') { if (lastSafeBoundary >= 0) { s = s.slice(0, lastSafeBoundary); } } } // 4. 移除尾部逗号 s = s.replace(/,\s*$/, ''); // 5. 关闭所有未闭合的括号 (重新扫描获取当前栈状态) inStr = false; esc = false; const finalStk = []; for (let i = 0; i < s.length; i++) { const c = s[i]; if (esc) { esc = false; continue; } if (c === '\\') { esc = true; continue; } if (c === '"') { inStr = !inStr; continue; } if (inStr) continue; if (c === '{' || c === '[') finalStk.push(c); else if (c === '}' || c === ']') { if (finalStk.length) finalStk.pop(); } } while (finalStk.length > 0) { const open = finalStk.pop(); s += (open === '{' ? '}' : ']'); } return s; } // v2.3.1.1: 鲁棒 JSON 解析 (处理 AI 返回的 markdown/截断/未转义) // v2.3.18: 增加 max_tokens 截断修复 (第 3 次尝试: repairTruncatedJson) // 流程: 剥离 markdown -> 提取最外层 { ... } -> 清理尾部逗号 -> JSON.parse -> 截断修复 function safeParseAiJson(content, expectKey) { if (!content || typeof content !== 'string') throw new Error('AI 返回内容为空'); // 1. 去除 ... 块 let cleaned = content.replace(/[\s\S]*?<\/think>/gi, '').trim(); // 2. 剥离 markdown 代码块 ```json ... ``` 或 ``` ... ``` cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim(); // 3. 提取最外层 JSON: 根据 expectKey 找匹配的 { 或 [ const openChar = expectKey && expectKey.startsWith('[') ? '[' : '{'; const closeChar = openChar === '{' ? '}' : ']'; const firstIdx = cleaned.indexOf(openChar); if (firstIdx === -1) throw new Error('AI 返回中找不到 JSON 起始符'); // v2.3.18: 保存从起始符到字符串末尾的完整内容 (用于截断修复) const fullContent = cleaned.slice(firstIdx); // 4. 用括号配对法找匹配的闭合位置 (处理字符串/转义) let depth = 0, inString = false, escape = false, endIdx = -1; for (let i = firstIdx; i < cleaned.length; i++) { const ch = cleaned[i]; if (escape) { escape = false; continue; } if (ch === '\\') { escape = true; continue; } if (ch === '"') { inString = !inString; continue; } if (inString) continue; if (ch === openChar) depth++; else if (ch === closeChar) { depth--; if (depth === 0) { endIdx = i + 1; break; } } } if (endIdx === -1) { // v2.3.18: 找不到匹配闭合符 — 极可能是 max_tokens 截断 // 先尝试用 repairTruncatedJson 修复完整内容 try { const repaired = repairTruncatedJson(fullContent); const result = JSON.parse(repaired); // 标记为截断恢复结果 (括号配对失败 = 确定被截断) if (result && typeof result === 'object') { result._truncated = true; } return result; } catch (eRepair) { // 修复仍失败, 回退到贪婪匹配 const m = cleaned.match(/\{[\s\S]*\}/); if (m) endIdx = m.index + m[0].length; else throw new Error('AI 返回中找不到 JSON 闭合符 (疑似响应被截断)'); } } let jsonStr = cleaned.slice(firstIdx, endIdx); // 5. 清理 JSON 中常见错误 // 5.1 移除尾部多余的 }, ] (AI 经常多打) jsonStr = jsonStr.replace(/[\s,]+$/, ''); // 5.2 移除 // 单行注释 jsonStr = jsonStr.replace(/^\s*\/\/.*$/gm, ''); // 5.3 移除 /* */ 注释 jsonStr = jsonStr.replace(/\/\*[\s\S]*?\*\//g, ''); // 5.4 修复未闭合的字符串(去除末尾未闭合的引号) const quoteCount = (jsonStr.match(/"/g) || []).length; if (quoteCount % 2 !== 0) { jsonStr = jsonStr.replace(/"([^"]*)$/, '$1'); } // 5.5 修复 trailing comma jsonStr = jsonStr.replace(/,(\s*[}\]])/g, '$1'); // 5.6 修复单引号为双引号 (字段名必须是双引号) jsonStr = jsonStr.replace(/'([a-zA-Z_]\w*)'(\s*:)/g, '"$1"$2'); // 6. 解析 try { return JSON.parse(jsonStr); } catch (e1) { // 7. 二次尝试: 用更激进的清理 try { // 修复模型: 去掉控制字符 const fixed = jsonStr.replace(/[\u0000-\u001F\u007F]/g, (m) => { if (m === '\n' || m === '\r' || m === '\t') return m; return ''; }); return JSON.parse(fixed); } catch (e2) { // v2.3.18: 三次尝试 — 截断修复 (max_tokens 限制导致响应被截断) // 典型症状: "Expected ',' or ']' after array element" 且错误位置 ≈ 字符串末尾 try { const repaired = repairTruncatedJson(fullContent); const result = JSON.parse(repaired); // 标记为截断恢复结果 (调用方可据此提示用户) if (result && typeof result === 'object') { result._truncated = true; } return result; } catch (e3) { throw new Error('AI 返回 JSON 解析失败: ' + e1.message.slice(0, 80) + ' (位置 ' + jsonStr.length + ', 截断修复亦失败)'); } } } } // 调用 AI API 进行翻译总结 // newsItems: [{ title, contents, ... }], gameName: string, appId: string // 返回: [{ title, summary, is_translated, _truncated? }] // v2.3.18: max_tokens 提升至 8192 防截断; 限制最多 15 条新闻降低 token 压力; // 通过 returnMeta 检测 finish_reason=length; 截断时通过 repairTruncatedJson 恢复部分结果 async function callAiTranslateSummary(newsItems, gameName, appId) { let prompt = await getAiPromptTemplate(); // v2.3.18: 限制最多 15 条新闻 (原 20 条), 减少 token 消耗防止输出截断 const cappedNews = newsItems.slice(0, 15); const newsData = cappedNews.map((n, i) => `[${i + 1}] 标题: ${n.title || '(无标题)'}\n内容: ${n.contents || '(无内容)'}` ).join('\n\n'); prompt = prompt.replace(/\{GAME_NAME\}/g, gameName || '未知游戏') .replace(/\{APP_ID\}/g, appId || '') .replace(/\{NEWS_DATA\}/g, newsData); // v2.3.18: maxTokens 8192 + returnMeta 检测截断 const { content, finishReason, truncated } = await callAiApi(prompt, { temperature: 0.3, timeout: 60000, maxTokens: 8192, returnMeta: true }); const parsed = safeParseAiJson(content, '{'); const items = parsed.items || []; // 截断标记: API 层 (finish_reason=length) 或 JSON 修复层 (_truncated) 任一触发即标记 const wasTruncated = truncated || parsed._truncated === true || items.length < cappedNews.length; if (wasTruncated && items.length > 0) { // 部分结果恢复成功 — 在首条 item 上打标记供 UI 提示 items.forEach(it => { it._truncated = true; }); } return items; } // 调用 AI API 进行价格预测 async function callAiPricePredict(historyData, gameName, appId, currentPrice, originalPrice) { let prompt = await getPredictPromptTemplate(); // 获取用户选择的预测模式 const enabledModes = storage.getPredictModes(); const modesText = enabledModes.map(m => { const mode = PREDICT_MODES.find(p => p.key === m); return mode ? `- ${mode.key}(${mode.name}):${mode.desc}` : ''; }).filter(Boolean).join('\n'); // 构建折扣记录文本 // v2.3.7: 安全数值转换, 修复 price?.toFixed is not a function const _safeNum = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; const discounts = historyData.discounts || []; const discountRecords = discounts.map(d => { const dateStr = d.date ? new Date(d.date).toLocaleDateString('zh-CN') : '—'; const daysStr = d.daysFromRelease != null ? `距发行${d.daysFromRelease}天` : ''; return `- ${dateStr} | -${d.cut}% | ${d.currency}${_safeNum(d.price).toFixed(2)} | 原价${d.currency}${_safeNum(d.regular).toFixed(2)} | ${daysStr}`; }).join('\n') || '无折扣记录'; // 构建价格历史文本 const history = historyData.history || []; const priceHistory = history.map(h => { const dateStr = h.date ? new Date(h.date).toLocaleDateString('zh-CN') : '—'; return `- ${dateStr} | ${h.cut > 0 ? '-' + h.cut + '%' : '原价'} | ${h.currency}${_safeNum(h.price).toFixed(2)} | 原价${h.currency}${_safeNum(h.regular).toFixed(2)}`; }).join('\n') || '无价格历史'; // 填充提示词模板 const currentDate = new Date().toISOString().split('T')[0]; const currency = (history[0] || discounts[0] || {}).currency || 'CNY'; prompt = prompt.replace(/\{PREDICT_MODES\}/g, modesText) .replace(/\{GAME_NAME\}/g, gameName || historyData.gameTitle || '未知游戏') .replace(/\{APP_ID\}/g, appId || '') .replace(/\{RELEASE_DATE\}/g, historyData.releaseDate || '未知') .replace(/\{CURRENT_DATE\}/g, currentDate) .replace(/\{CURRENT_PRICE\}/g, String(currentPrice || '—')) .replace(/\{ORIGINAL_PRICE\}/g, String(originalPrice || '—')) .replace(/\{CURRENCY\}/g, currency) .replace(/\{DISCOUNT_RECORDS\}/g, discountRecords) .replace(/\{PRICE_HISTORY\}/g, priceHistory); const content = await callAiApi(prompt, { temperature: 0.4, timeout: 90000 }); // v2.3.1.1: 使用鲁棒 JSON 解析 (处理 markdown/截断/未转义) return safeParseAiJson(content, '{'); } // ==================== 跨产品共享数据访问层(v2.4.1 从 SGLV 区域上移,供 SGLV/SGIS 共用) ==================== // 事件总线契约:SGLV 通过 document.dispatchEvent(new CustomEvent("sglv:games-updated")) 通知 SGIS 刷新 // --- 中文名缓存 --- // ==================== 游戏中文名获取(参考 steam-friend-manager-1.1.2 fetchGameZhName) ==================== // 通过 store.steampowered.com/api/appdetails?l=schinese 获取简体中文名称, // 带跨会话持久缓存(30天 TTL)和内存级防重复请求(pending Map), // 供游戏橱窗列表、游玩仪表、家庭愿望单、绝版游戏等面板异步替换英文名。 // 仅在中文环境(isZh)下生效,非中文环境直接返回空字符串。 const SGLV_GAME_NAME_KEY = 'sglv_game_name_cache'; const SGLV_NAME_CACHE_TTL = 30 * 864e5; // 30天过期 let _sglvGameNameCache = null; const _sglvGameNamePending = new Map(); function sglvGameNameCacheLoad() { if (_sglvGameNameCache !== null) return _sglvGameNameCache; _sglvGameNameCache = {}; try { const raw = GM_getValue(SGLV_GAME_NAME_KEY); if (raw && typeof raw === 'object') _sglvGameNameCache = raw; } catch (e) { console.warn('[SGLV] 游戏名称缓存读取失败:', e); } return _sglvGameNameCache; } function sglvGameNameCacheSave() { try { GM_setValue(SGLV_GAME_NAME_KEY, _sglvGameNameCache || {}); } catch (e) { console.warn('[SGLV] 游戏名称缓存写入失败:', e); } } // v2.4.3: 中文名获取失败负缓存 TTL(30 分钟)——失败结果短期内直接返回, // 防止增量渲染(每次资料补全批次都会重渲列表)反复重发单 appid 请求触发 Steam 限流, // 这曾是中文名大面积获取失败(停留在英文名)的根因 const SGLV_NAME_NEG_TTL = 30 * 60 * 1000; // 获取游戏中文名(异步,返回 Promise,空字符串表示非中文环境或获取失败) function fetchGameZhName(appid) { if (!isZh) return Promise.resolve(''); const id = String(appid); const cache = sglvGameNameCacheLoad(); const cached = cache[id]; if (cached && cached.name && Date.now() - (cached.ts || 0) < SGLV_NAME_CACHE_TTL) { return Promise.resolve(cached.name); } // 负缓存命中:近期已失败过,不再重发请求 if (cached && !cached.name && Date.now() - (cached.ts || 0) < SGLV_NAME_NEG_TTL) { return Promise.resolve(''); } if (_sglvGameNamePending.has(id)) return _sglvGameNamePending.get(id); // v2.8.1: filters=name 是无效过滤值,appdetails 返回 data:[](空),导致中文名始终获取不到 // (库存页因自带英文名未暴露此问题,Epic赠送页仅有 appid 无名称故大面积显示 "App xxxxxx")。 // 改用 filters=basic(含 name 字段,响应约 3KB,远小于无过滤的 15KB)。 const p = new Promise(resolve => { const settle = (name) => { cache[id] = { name, ts: Date.now() }; // 成功/失败均落缓存(空名=负缓存) try { sglvGameNameCacheSave(); } catch (e) { console.warn('[SGLV] 游戏名称缓存保存失败:', id, e); } _sglvGameNamePending.delete(id); resolve(name); }; // v2.8.3: 使用统一 API 调用层(带重试),失败时走负缓存 sglvGmFetchRetry(`https://store.steampowered.com/api/appdetails?appids=${id}&filters=basic&l=schinese`, { timeout: 10000, retries: 1 }) .then(json => { let name = ''; try { const d = json && json[id]; if (d && d.success && d.data && d.data.name) name = d.data.name; } catch (e) { console.warn('[SGLV] 中文名解析失败:', id, e); } settle(name); }) .catch(() => settle('')); }); _sglvGameNamePending.set(id, p); return p; } // 异步加载中文名并更新 DOM 元素(参考 steam-friend-manager loadGameZhName) // el: 显示游戏名的元素;appid: 游戏ID;originalName: 当前显示的名称 function loadGameZhName(el, appid, originalName) { if (!el || !appid) return; fetchGameZhName(appid).then(zhName => { if (zhName && zhName !== originalName) { el.textContent = zhName; el.title = `${zhName} (${originalName})`; } }); } // v2.8.2: 获取游戏英文名(参考 Steam_Show_English_Name.js,filters=basic 含 name 字段) const _sglvEnNameCache = new Map(); const _sglvEnNamePending = new Map(); function fetchGameEnName(appid) { const id = String(appid); if (_sglvEnNameCache.has(id)) return Promise.resolve(_sglvEnNameCache.get(id)); if (_sglvEnNamePending.has(id)) return _sglvEnNamePending.get(id); const p = new Promise(resolve => { // v2.8.3: 使用统一 API 调用层(带重试) sglvGmFetchRetry(`https://store.steampowered.com/api/appdetails?appids=${id}&filters=basic&l=english`, { timeout: 10000, retries: 1 }) .then(json => { let name = ''; try { const d = json && json[id]; if (d && d.success && d.data && d.data.name) name = d.data.name; } catch (e) { console.warn('[SGLV] 英文名解析失败:', id, e); } if (name) _sglvEnNameCache.set(id, name); _sglvEnNamePending.delete(id); resolve(name); }) .catch(() => { _sglvEnNamePending.delete(id); resolve(''); }); }); _sglvEnNamePending.set(id, p); return p; } // v2.8.2: 异步加载游戏名称——默认显示中文名(如有),原始英文名作为灰色别名追加在后 // 逻辑:① 主名是 "App xxx" 占位符 → 获取中文名替换主名,英文名作为别名(需额外请求) // ② 主名是英文名 → 获取中文名替换主名,原始英文名作为灰色别名 // ③ 中文名与主名相同(主名已是中文):不追加别名 // ④ 无中文名(主名为英文且无中文名):不追加别名,避免英文名重复/雷同 // ⑤ 占位符主名 + 无中文名 → 获取英文名替换主名,不追加别名 // 参考steam-friend-manager-1.1.6.js:静默失败不显示任何别名 const _APP_PLACEHOLDER_RE = /^App\s+\d+$/i; function loadGameNameAlias(el, appid, originalName) { if (!el || !appid || !originalName) return; const isPlaceholder = _APP_PLACEHOLDER_RE.test(originalName.trim()); fetchGameZhName(appid).then(zhName => { if (zhName && zhName !== originalName) { // 获取到中文名 → 主名替换为中文 el.textContent = zhName; el.title = `${zhName} (${originalName})`; // 仅当原始名是真实名称(非 App xxx 占位符)时才作为灰色别名 if (!isPlaceholder) { _appendGrayAlias(el, originalName); } return; } // 中文名为空或与主名相同 if (!isPlaceholder) return; // 主名是 App xxx 占位符且无中文名 → 尝试获取英文名替换占位符 fetchGameEnName(appid).then(enName => { if (enName && enName !== originalName) { el.textContent = enName; el.title = enName; // 不追加别名(已是英文名,无中文名可对照) } }); }); } function _appendGrayAlias(el, alias) { if (el.querySelector('.sglv-name-alias')) return; const span = document.createElement('span'); span.className = 'sglv-name-alias'; span.textContent = alias; el.appendChild(span); } // --- getActiveSteamId --- let _activeSteamId = null; function getActiveSteamId() { if (_activeSteamId) return _activeSteamId; _activeSteamId = storage.getSteamId() || detectSteamId(); return _activeSteamId; } // --- isGameOwnedByMe --- function isGameOwnedByMe(g) { const steamId = getActiveSteamId(); if (!g.owners || g.owners.length === 0) return true; if (!steamId) return true; return g.owners.includes(steamId); } // v2.9.2: 获取排除家庭组共享游戏和 DLC 后的游戏列表(用于个人信息面板和洞察页面统计准确性) // v2.9.3: 修复 isDlc 跨作用域不可见问题——isDlc 定义在 SGLV 子闭包内,通过 SGLV_API 桥接访问 // v2.9.50: 加 session 内缓存 + 持久化缓存(签名:dlcReady + ownedGames count) // - session 内:已加载的 isDlc 结果直接复用 // - 跨 session:游戏数 + dlcReady 未变时直接命中(避免每次开面板都遍历全库存) let _cachedStatFiltered = null; let _cachedStatFilteredSig = ''; function getStatFilteredGames() { const allGames = (state.ownedGames && state.ownedGames.length) ? state.ownedGames : storage.getCachedGames(); const _isDlc = SGLV_API.isDlc; const dlcReady = (SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()) ? '1' : '0'; const sig = `${allGames.length}|${dlcReady}`; if (_cachedStatFiltered && _cachedStatFilteredSig === sig) return _cachedStatFiltered; const result = allGames.filter(g => isGameOwnedByMe(g) && !(_isDlc && _isDlc(g.appid))); _cachedStatFiltered = result; _cachedStatFilteredSig = sig; return result; } // v2.9.5: DLC 数量缓存(避免个人信息面板每次渲染都重新 filter + isDlc 遍历) // 缓存 key 基于 ownedGames 数量 + dlcDbData 是否已加载,任一变化时自动失效 let _cachedDlcCount = null; let _cachedDlcCountKey = ''; function getOwnedDlcCount() { const allGames = (state.ownedGames && state.ownedGames.length) ? state.ownedGames : storage.getCachedGames(); const _isDlc = SGLV_API.isDlc; // 缓存 key 需区分 DLC 数据库加载前后(加载前 isDlc 恒返回 false,DLC 计数为 0) const dlcReady = (SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()) ? '1' : '0'; const key = `${allGames.length}|${dlcReady}`; if (_cachedDlcCount !== null && _cachedDlcCountKey === key) return _cachedDlcCount; if (!_isDlc || dlcReady === '0') { _cachedDlcCount = 0; _cachedDlcCountKey = key; return 0; } _cachedDlcCount = allGames.filter(g => isGameOwnedByMe(g) && _isDlc(g.appid)).length; _cachedDlcCountKey = key; return _cachedDlcCount; } // --- ownedAppIds缓存变量 --- // 缓存 getActiveOwnedAppIds 结果,避免供应商分类标签页重复计算导致卡死 let _cachedActiveOwnedAppIds = null; let _cachedActiveOwnedAppIdsKey = ''; // --- getActiveOwnedAppIds --- function getActiveOwnedAppIds() { const showFamilyShared = storage.getShowFamilyShared(); const cacheKey = `${state.ownedGames.length}|${showFamilyShared}`; if (_cachedActiveOwnedAppIds && _cachedActiveOwnedAppIdsKey === cacheKey) { return _cachedActiveOwnedAppIds; } const appIds = new Set(); state.ownedGames.forEach(g => { if (showFamilyShared || isGameOwnedByMe(g)) { appIds.add(g.appid); } }); _cachedActiveOwnedAppIds = appIds; _cachedActiveOwnedAppIdsKey = cacheKey; return appIds; } // --- invalidateActiveOwnedAppIdsCache --- // 当 ownedGames 或家庭组开关变化时,使缓存失效 function invalidateActiveOwnedAppIdsCache() { _cachedActiveOwnedAppIds = null; _cachedActiveOwnedAppIdsKey = ''; } // --- Token&SteamID检测 --- // ==================== Token & SteamID 自动检测 ==================== let _cachedWebApiToken = null; function getAccessTokenSync() { try { const appConfig = document.getElementById('application_config'); if (appConfig) { const storeConfig = JSON.parse(appConfig.getAttribute('data-store_user_config') || '{}'); if (storeConfig.webapi_token) return storeConfig.webapi_token; } } catch (e) { /* ignore */ } try { const m = document.documentElement.innerHTML.match(/"webapi_token"\s*:\s*"([^"]+)"/); if (m && m[1]) return m[1]; } catch (e) { /* ignore */ } return null; } function fetchWebApiTokenFromStore() { return new Promise((resolve) => { if (_cachedWebApiToken) { resolve(_cachedWebApiToken); return; } GM_xmlhttpRequest({ method: 'GET', url: 'https://store.steampowered.com/', timeout: 10000, onload(resp) { try { const html = resp.responseText; const m1 = html.match(/id="application_config"[^>]*data-store_user_config="([^"]*)"/); if (m1 && m1[1]) { const decoded = m1[1].replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); const config = JSON.parse(decoded); if (config.webapi_token) { _cachedWebApiToken = config.webapi_token; resolve(config.webapi_token); return; } } const m2 = html.match(/"webapi_token"\s*:\s*"([^"]+)"/); if (m2 && m2[1]) { _cachedWebApiToken = m2[1]; resolve(m2[1]); return; } } catch (e) { /* ignore */ } resolve(null); }, onerror() { resolve(null); }, ontimeout() { resolve(null); } }); }); } async function getAccessToken() { const syncToken = getAccessTokenSync(); if (syncToken) return syncToken; const storeToken = await fetchWebApiTokenFromStore(); if (storeToken) return storeToken; return storage.getApiKey() || null; } function detectSteamId() { try { if (unsafeWindow.g_steamID) return unsafeWindow.g_steamID; } catch { /* g_steamID may not exist in all page contexts */ } try { const appConfig = document.getElementById('application_config'); if (appConfig) { const userInfo = JSON.parse(appConfig.getAttribute('data-userinfo') || '{}'); if (userInfo.steamid) return String(userInfo.steamid); } } catch (e) { /* ignore */ } const m = document.documentElement.innerHTML.match(/g_steamID\s*=\s*"(\d{17})"/); return m ? m[1] : ''; } // --- showToast --- // v2.8.3: showToast 升级为委托 sglvToast(保持向后兼容) function showToast(msg) { sglvToast.info(msg); } // --- 剪贴板工具 --- // v2.3.27: 复制文本到剪贴板(Clipboard API + textarea 兜底) function copyTextToClipboard(text) { try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text)); return; } } catch { /* ignore */ } fallbackCopyText(text); } function fallbackCopyText(text) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } catch { /* ignore */ } } // --- fetchDynamicStoreOwnedAppIds --- let _dynamicStoreOwnedAppIds = null; // Set | null(模块级缓存) async function fetchDynamicStoreOwnedAppIds() { if (_dynamicStoreOwnedAppIds) return _dynamicStoreOwnedAppIds; try { const resp = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://store.steampowered.com/dynamicstore/userdata/', timeout: 15000, onload(r) { if (r.status >= 200 && r.status < 300) resolve(r); else reject(new Error('HTTP ' + r.status)); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); const data = JSON.parse(resp.responseText); const ownedApps = data?.rgOwnedApps; if (Array.isArray(ownedApps)) { _dynamicStoreOwnedAppIds = new Set(ownedApps.map(Number).filter(n => Number.isFinite(n) && n > 0)); console.log(`[SGLV] dynamicstore/userdata 获取 ${_dynamicStoreOwnedAppIds.size} 个 owned appids`); } } catch (e) { console.warn('[SGLV] dynamicstore/userdata 获取失败:', e); } return _dynamicStoreOwnedAppIds; } // --- getDynamicStoreAppIds --- // 参考 SteamPeek标记库存状态-1.9.js:从页面 GDynamicStore 读取愿望单/库存 appid 集合(即时、零请求) function getDynamicStoreAppIds(kind) { try { const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; let raw = null; if (kind === 'wishlist') raw = win.GDynamicStore?.s_rgWishlist || win.g_rgWishlist; else if (kind === 'owned') raw = win.GDynamicStore?.s_rgOwnedApps; if (!raw) return new Set(); const arr = Array.isArray(raw) ? raw : Object.keys(raw); return new Set(arr.map(Number).filter(n => Number.isFinite(n) && n > 0)); } catch { return new Set(); } } // ==================== SGLV 子模块(v2.4.1 子闭包封装,仅导出 initUI/autoScan) ==================== const SGLV_API = {}; (function () { // ==================== SGLV 专属工具函数 ==================== // ==================== 工具函数 ==================== function requestSteamAPI(url) { // v2.3.15: 增加 timeout(30s)和 ontimeout 处理,避免请求挂起导致 loading 卡住 // v2.7.9: 检测 HTML 响应(未登录/API Key 失效时 Steam 返回登录页),提前抛出清晰错误 return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 30000, onload(resp) { try { const text = resp.responseText || ''; // 检测 HTML 响应(登录页/错误页),避免 JSON.parse 报模糊错误 if (text.startsWith('<') || text.startsWith(' { GM_xmlhttpRequest({ method: 'GET', url, timeout: 20000, onload(r) { resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, text: r.responseText }); }, onerror: reject, ontimeout: reject }); }); if (!resp.ok) return { games: [], total: 0 }; const doc = new DOMParser().parseFromString(resp.text, 'text/html'); const rows = doc.querySelectorAll('.gameListRow'); const games = []; rows.forEach(row => { const appid = parseInt(row.id?.replace('game_', '') || row.dataset?.appid || '0', 10); if (!appid) return; const name = row.querySelector('.gameListRowItemName, .gameListRowItem h2, .gameListRowItem .gameListRowItemName')?.textContent?.trim() || row.querySelector('a[href*="/app/"]')?.textContent?.trim() || ''; const logoImg = row.querySelector('img.gameListLogo, img[src*="capsule"], img[src*="header"]'); const logoSrc = logoImg?.getAttribute('src') || ''; const playtimeEl = row.querySelector('.gameListRowItem .gameListRowItemHours, .hours_played, .gameListRowItem .gameListRowItemTime, .gameListRowItem .gameListRowItemStat'); const playtimeText = playtimeEl?.textContent?.trim() || ''; const playtime = parsePlaytimeTextToMinutes(playtimeText); const lastPlayedText = row.querySelector('.gameListRowItem .gameListRowItemLastPlayed, .last_played, .gameListRowItem .gameListRowItemTime')?.textContent?.trim() || ''; const lastPlayed = parseLastPlayedToTs(lastPlayedText); games.push({ appid, name: name || `App ${appid}`, playtime_forever: playtime, playtime_2weeks: 0, rtime_last_played: lastPlayed, img_icon_url: '', logoSrc }); }); const totalEl = doc.querySelector('.gameListRowItem .gameListRowItemCount, .gameListRow .gameListRowItem .gameListRowItemCount'); const totalText = totalEl?.textContent || ''; const totalMatch = totalText.match(/(\d+)/); const total = totalMatch ? parseInt(totalMatch[1], 10) : games.length; return { games, total }; } catch (e) { console.warn('[SGLV] games 页抓取失败:', e); return { games: [], total: 0 }; } } function getGameOwnerNames(g) { if (!g.owners || g.owners.length === 0) return ''; const familyInfo = storage.getFamilyInfo(); const nameMap = familyInfo?.steamIdtoName || {}; return g.owners.map(sid => nameMap[sid] || 'ID:' + sid.slice(-4)).join(', '); } // v2.9.50: 通用计算缓存(持久化版)— 内存镜像 + IDB 持久化(通过 SGLVCore.PersistentComputeCache) // 旧实现仅 session 内 Map,关闭浏览器/升级脚本即全部失效。 // 新增 PCC IDB 持久化层,关键计算结果(年度统计 / 家庭组累计 / 成就指标 / wishlist 统计)跨 session 复用。 // // 工作流: // 1) getCached(key, computeFn) — 同步读 _computedCache,未命中则 compute 后写内存 + 异步落 PCC // 2) hydrateComputedCache() — 启动时从 PCC IDB 同步预填充到 _computedCache // 3) clearComputedCache() — 清内存 + 清 PCC const _computedCache = new Map(); const _PCC = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVCore) || window.SGLVCore; const _PCC_CACHE = _PCC && _PCC.PersistentComputeCache ? _PCC.PersistentComputeCache : null; // v2.9.50: 通用计算缓存 schema 版本(对应 _computedCache 这层,business 单独用自己 schema 升级) const _COMPUTE_CACHE_SCHEMA_VER = 1; let _pccHydrated = false; function cacheKey(...parts) { return parts.map(p => JSON.stringify(p)).join('|'); } function getCached(key, computeFn) { if (_computedCache.has(key)) return _computedCache.get(key); const v = computeFn(); _computedCache.set(key, v); // v2.9.50: 同步写内存 + 异步落 PCC(IDB 持久化,跨 session 复用) if (_PCC_CACHE) { try { _PCC_CACHE.set(key, _COMPUTE_CACHE_SCHEMA_VER, key, v); } catch (e) { /* quota */ } } return v; } // v2.9.50: 启动时从 PCC IDB 同步预填充到 _computedCache(必须在 sglvIDB.loadAll() 完成后调用) // 返回:已 hydrate 的 key 数量 function hydrateComputedCache() { if (_pccHydrated) return 0; if (!_PCC_CACHE) { _pccHydrated = true; return 0; } try { const pccMem = _PCC_CACHE._mem || {}; let count = 0; for (const k of Object.keys(pccMem)) { if (!_computedCache.has(k)) { const entry = pccMem[k]; if (entry && entry.value !== undefined && entry._ver === _COMPUTE_CACHE_SCHEMA_VER) { _computedCache.set(k, entry.value); count++; } } } _pccHydrated = true; if (count > 0) console.log(`[SGLV-Compute] PCC IDB 预热 ${count} 条已缓存计算结果到 _computedCache`); return count; } catch (e) { console.warn('[SGLV-Compute] PCC hydrate 失败:', e); _pccHydrated = true; return 0; } } // v2.9.50: 按"游戏库版本"选择性地失效 PCC 缓存(避免整个 clear 导致其他无关数据丢失) // 旧逻辑:_computedCache.clear() 把所有内存缓存清空,下次渲染全部重算 // 新逻辑:仅失效 sig 与 ownedGames 关联的 key,其他不依赖 ownedGames 的(搜索结果等)保留 // 当前简单实现:游戏库变化时整体清空(保持安全),但仍通过 PCC 落盘给下次复用 function clearComputedCache() { console.log('[SGLV-Compute] clearComputedCache 被调用 — 失效所有计算缓存'); _computedCache.clear(); if (_PCC_CACHE) { try { _PCC_CACHE.clear(); } catch (e) { /* ignore */ } } _pccHydrated = true; // 已清,无需再 hydrate _cachedActiveOwnedAppIds = null; _cachedActiveOwnedAppIdsKey = ''; _cachedDlcCount = null; _cachedDlcCountKey = ''; _cachedStatFiltered = null; // v2.9.50: 同步失效 getStatFilteredGames 缓存 _cachedStatFilteredSig = ''; if (SGLV_API.invalidateAppTypeCache) SGLV_API.invalidateAppTypeCache(); const hadDlcTypeMap = !!dlcTypeMap; dlcTypeMap = null; _dlcsByParentCache = null; console.log(`[SGLV-Compute] clearComputedCache 完成 — dlcTypeMap ${hadDlcTypeMap ? '已失效 (之前有数据)' : '本就为空'},appTypeCache 已失效,DLC 数量缓存已失效,statFiltered 已失效,dlcsByParent 已失效`); } // v2.9.50: 业务级持久化计算结果工具(给 computeYearlyStats / computeInsightData 等大数据用) // 与 getCached 不同:不依赖 _computedCache 内存层,而是单独命名空间(biz_xxx)便于跨域管理 // key: 业务名,如 'biz_yearly_stats' // schemaVer: schema 版本号,升级时强制重算 // inputSig: 输入签名(如 "123|456" = 游戏数 + 总acquiredTime) // computeFn: 同步重算函数,返回新值 // 返回:同步值(命中缓存直接返回,未命中同步重算并落盘) function getBizCached(key, schemaVer, inputSig, computeFn) { if (!_PCC_CACHE) return computeFn(); // 1) 同步从 PCC 读 + 元数据 const meta = _PCC_CACHE.getSyncWithMeta(key); if (meta && meta._ver === schemaVer && meta._sig === inputSig) { return meta.value; } // 2) 同步重算 + 落盘 const v = computeFn(); try { _PCC_CACHE.set(key, schemaVer, inputSig, v); } catch (e) { /* ignore */ } return v; } function getGameDBMeta(appid) { for (const [pubName, pubData] of Object.entries(GAME_DB)) { for (const [seriesName, games] of Object.entries(pubData)) { if (games.some(g => g.id === appid)) { return { publisher: pubName, series: seriesName }; } } } return { publisher: '', series: '' }; } function getGameIconUrl(appid, iconHash) { if (iconHash) return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${iconHash}.jpg`; return `https://cdn.akamai.steamstatic.com/steam/apps/${appid}/capsule_231x87.jpg`; } // v2.4.3: 多 CDN 封面回退链(参考 steam-family-game-analysis v1.58 faLoadCover 多 CDN fallback) // 修复 cdn.cloudflare.steamstatic.com 不可达(部分网络环境被阻断/抽风)时封面大面积获取不到的问题: // 依次尝试 cloudflare → cdn.akamai → shared.akamai(store_item_assets),全部失败再走 appdetails API function coverChainUrls(appid, kind) { const cf = `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}`; const ak = `https://cdn.akamai.steamstatic.com/steam/apps/${appid}`; const sh = `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}`; if (kind === 'poster') { return [ `${cf}/library_600x900_2x.jpg`, // HD 竖版 `${cf}/library_600x900.jpg`, // 标准竖版 `${ak}/library_600x900_2x.jpg`, // Akamai HD 竖版 `${ak}/library_600x900.jpg`, // Akamai 标准竖版 `${cf}/header.jpg`, // 宽版(几乎所有游戏都有) `${ak}/header.jpg`, `${sh}/header.jpg`, ]; } if (kind === 'capsule') { return [ `${cf}/capsule_467x181.jpg`, // 大横幅封面(最清晰) `${cf}/capsule_231x87.jpg`, // 中等横幅 `${cf}/header.jpg`, // 头图兜底 `${ak}/capsule_231x87.jpg`, `${ak}/header.jpg`, `${sh}/capsule_184x69.jpg`, // shared akamai store_item_assets `${sh}/header.jpg`, ]; } return [`${cf}/header.jpg`, `${ak}/header.jpg`, `${sh}/header.jpg`]; // header } // API 图片回退:当所有 CDN 路径都失败时,通过 Steam Store API 获取真实图片 URL(含 hash) // v2.4.3 重构:成功/进行中/失败三态分离——旧实现用同一 Map 的 '' 同时表示"请求中"与"失败", // 导致同 appid 并发回退时后者直接占位、且瞬时失败后永不再重试(封面获取不到的另一根因) const _posterApiCache = new Map(); // appid → API 获取成功的图片 URL const _posterApiPending = new Map(); // appid → 进行中的 API 请求 Promise(并发共享) // v2.4.3: 占位替换仅替换 节点本身,不再覆盖父容器 innerHTML—— // 封面视图中父容器还含有徽章浮层(sglv-wl-cover-badges),需保留 // v2.8.2: 继承原 img 的 className(sglv-list-icon / sglv-card-img), // 使占位 div 获得与原图相同的固定尺寸(92x43 / 100%×100%),避免布局塌陷 function _posterImgToPlaceholder(img) { if (!img || !img.parentElement) return; const holder = document.createElement('div'); holder.className = img.className; holder.style.cssText = 'display:flex;align-items:center;justify-content:center;color:#5a6a7a;font-size:11px'; holder.textContent = isZh ? '无图片' : 'No Image'; img.parentElement.replaceChild(holder, img); } function _fetchCoverApiUrl(appid) { const key = String(appid); if (_posterApiCache.has(key)) return Promise.resolve(_posterApiCache.get(key)); if (_posterApiPending.has(key)) return _posterApiPending.get(key); const p = new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', // v2.8.1: filters=capsule_image,header_image 同为无效过滤值(返回 data:[]), // CDN 全部失败时 API 兜底始终拿不到 URL → 直接占位。改用 filters=basic(含 header_image)。 url: `https://store.steampowered.com/api/appdetails?appids=${key}&filters=basic`, timeout: 10000, onload(r) { let url = ''; try { const d = JSON.parse(r.responseText)[key]; if (d?.success && d?.data) url = d.data.capsule_image || d.data.header_image || ''; } catch { /* JSON parse or data shape mismatch; return empty url */ } if (url) _posterApiCache.set(key, url); // 仅缓存成功结果;失败不记录,下次渲染可重试 _posterApiPending.delete(key); resolve(url); }, onerror() { _posterApiPending.delete(key); resolve(''); }, ontimeout() { _posterApiPending.delete(key); resolve(''); }, }); }); _posterApiPending.set(key, p); return p; } unsafeWindow._sglvPosterFallback = function(img, appid) { _fetchCoverApiUrl(appid).then(url => { if (url) { // API URL 加载仍失败 → 占位(先替换 onerror,防止链式 handler 死循环) img.onerror = function() { img.onerror = null; _posterImgToPlaceholder(img); }; img.src = url; } else { _posterImgToPlaceholder(img); } }); }; // 统一封面 标签生成:成功URL缓存 → 多 CDN 直链回退 → API 获取 → 占位 // v2.4.0: 增加成功URL内存缓存;v2.4.3: 多 CDN 回退链 + 按 kind 隔离缓存 function coverImgTag(appid, name, kind, cls) { const good = getPosterGood(appid, kind); if (good) { return `${name}`; } const chain = coverChainUrls(appid, kind); let handler = `_sglvPosterFallback(this, '${appid}');`; for (let i = chain.length - 1; i >= 0; i--) { handler = `this.src='${chain[i]}';this.onerror=function(){${handler}}`; } return `${name}`; } function posterImg(appid, name) { return coverImgTag(appid, name, 'poster', 'sglv-card-img'); } // 横版封面图片(header.jpg),用于游戏橱窗列表 function headerImg(appid, name) { return coverImgTag(appid, name, 'header', 'sglv-card-img'); } // v2.4.3: 横板封面图片(capsule 大横幅),用于愿望单封面视图 function capsuleImg(appid, name) { return coverImgTag(appid, name, 'capsule', 'sglv-wl-cover-img'); } // v2.9.25: 分页导航——仅保留 首页|上页|下页|末页 + 跳页输入,无快捷页码按钮 function renderPagination(container, prefix, page, totalPages, totalItems, onPrev, onNext, onJump) { if (!container) return; container.innerHTML = ` ${page}/${totalPages} (${totalItems}) `; const goPage = (p) => { const target = Math.min(Math.max(1, p), totalPages); if (target === page) return; if (onJump) onJump(target); }; container.querySelector(`#${prefix}-first`)?.addEventListener('click', () => goPage(1)); container.querySelector(`#${prefix}-prev`)?.addEventListener('click', () => goPage(page - 1)); container.querySelector(`#${prefix}-next`)?.addEventListener('click', () => goPage(page + 1)); container.querySelector(`#${prefix}-last`)?.addEventListener('click', () => goPage(totalPages)); const jumpInput = container.querySelector(`#${prefix}-jump`); jumpInput?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); const p = parseInt(jumpInput.value); if (!isNaN(p)) goPage(p); } }); jumpInput?.addEventListener('blur', () => { const p = parseInt(jumpInput.value); if (!isNaN(p) && p >= 1 && p <= totalPages && p !== page) goPage(p); else jumpInput.value = page; }); } // ==================== 数据获取 ==================== // 方式1: 通过 access_token 获取家庭共享库(含自身游戏,include_own=true) // v2.8.1: onProgress(stage, percent, text) 进度回调,UI 可选接入避免"界面不动" async function fetchFamilyGameList(authToken, onProgress) { const report = (stage, percent, text) => { if (typeof onProgress === 'function') onProgress(stage, percent, text); }; try { // 阶段 1:获取家庭组基本信息 report(1, 25, '正在获取家庭组信息…'); const familyData = await requestSteamAPI( `https://api.steampowered.com/IFamilyGroupsService/GetFamilyGroupForUser/v1/?access_token=${authToken}&include_family_group_response=true` ); if (!familyData?.response?.family_groupid) { report(1, 100, '家庭组信息为空'); return null; } const familyGroupId = familyData.response.family_groupid; // 阶段 2:拉取共享库(主耗时 API,amount 不可知,给个递进动画) report(2, 50, '正在获取家庭组共享游戏库…'); const gameData = await requestSteamAPI( `https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/?access_token=${authToken}&family_groupid=${familyGroupId}&include_own=true&include_excluded=false&include_non_games=false` ); report(3, 80, '正在整理共享游戏数据…'); if (gameData?.response?.apps) { const apps = gameData.response.apps; const games = apps .filter(app => app.exclude_reason === 0) .map(app => ({ appid: app.appid, name: app.name || `App ${app.appid}`, playtime: 0, icon: app.img_icon_hash || '', lastPlayed: app.rt_time_acquired || 0, acquiredTime: app.rt_time_acquired || 0, owners: app.owner_steamids || [], })); report(4, 100, `已获取 ${games.length} 款家庭组共享游戏`); return games; } } catch (e) { console.warn('[SGLV] fetchFamilyGameList error:', e); } return null; } // 方式2: 获取拥有游戏(优先 API Key,尝试 access_token,最后 fallback 页面抓取) async function fetchOwnedGames(steamId, authToken) { const apiKey = storage.getApiKey(); // 2.1 优先 API Key if (apiKey) { try { const data = await requestSteamAPI( `https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${apiKey}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json` ); if (data?.response?.games) { console.log(`[SGLV] 通过 API Key 获取 ${data.response.games.length} 款游戏(含时长)`); return { games: data.response.games.map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtime: g.playtime_forever || 0, icon: g.img_icon_url || '', lastPlayed: g.rtime_last_played || 0, _source: 'api', })), source: 'api' }; } } catch (e) { console.warn('[SGLV] API Key 获取失败:', e); } } // 2.2 尝试用 access_token 调用 IPlayerService(部分账号可用) if (authToken && steamId) { try { const data = await requestSteamAPI( `https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?access_token=${authToken}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json` ); if (data?.response?.games) { console.log(`[SGLV] 通过 access_token 获取 ${data.response.games.length} 款游戏(含时长)`); return { games: data.response.games.map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtime: g.playtime_forever || 0, icon: g.img_icon_url || '', lastPlayed: g.rtime_last_played || 0, _source: 'api', })), source: 'api' }; } } catch (e) { console.warn('[SGLV] access_token 获取游戏时长失败:', e); } } // 2.3 fallback:抓取社区游戏库页面(无需 Key,可获取时长) if (steamId) { try { const scraped = await fetchGamesPage(steamId, 'all'); if (scraped.games.length > 0) { console.log(`[SGLV] 通过页面抓取获取 ${scraped.games.length} 款游戏(含时长)`); return { games: scraped.games.map(g => ({ appid: g.appid, name: g.name, playtime: g.playtime_forever, icon: g.img_icon_url || '', lastPlayed: g.rtime_last_played, _source: 'scrape', })), source: 'scrape' }; } } catch (e) { console.warn('[SGLV] 页面抓取 fallback 失败:', e); } } return { games: [], source: null }; } async function fetchFamilyInfo(authToken) { try { const data = await requestSteamAPI( `https://api.steampowered.com/IFamilyGroupsService/GetFamilyGroupForUser/v1/?access_token=${authToken}&include_family_group_response=true` ); if (data?.response?.family_group) { const fg = data.response.family_group; const members = fg.members || []; const nameMap = {}; if (members.length > 0) { const steamids = members.map(m => m.steamid); const batchSize = 100; for (let i = 0; i < steamids.length; i += batchSize) { const batch = steamids.slice(i, i + batchSize); const params = batch.map((sid, idx) => `steamids[${idx}]=${sid}`).join('&'); try { const pData = await requestSteamAPI( `https://api.steampowered.com/IPlayerService/GetPlayerLinkDetails/v1/?access_token=${authToken}&${params}` ); if (pData?.response?.accounts) { pData.response.accounts.forEach(acc => { if (acc.public_data) { const sid = acc.public_data.steamid || acc.steamid; if (sid && acc.public_data.persona_name) { nameMap[sid] = acc.public_data.persona_name; } } }); } } catch (e) { /* ignore */ } } } return { family_groupid: data.response.family_groupid, family_name: fg.name || 'Steam Family', family_member: members.map(m => ({ steamid: m.steamid, userName: nameMap[m.steamid] || '' })), steamIdtoName: nameMap }; } } catch (e) { console.warn('[SGLV] fetchFamilyInfo error:', e); } return null; } // v2.3.29: 获取 dynamicstore/userdata——Steam 商店动态数据,返回完整 owned appids 列表 // 该端点比 GetOwnedGames 更可靠:包含 CD key 激活、促销许可、免费领取等所有类型的已入库游戏 // 参考 Steam-License-Classifier 的思路:license 数据是入库的 ground truth // 合并两种数据源 async function fetchAllGames(onProgress) { const gamesMap = new Map(); // v2.9.15: 进度回调——sub-step 1-3 映射到阶段 1 的 0-100% const report = (sub, pct, text) => { if (typeof onProgress === 'function') onProgress(sub, pct, text); }; report(1, 10, isZh ? '正在准备访问令牌…' : 'Preparing access token…'); const authToken = await getAccessToken(); const steamId = getActiveSteamId(); // 步骤1:优先获取个人拥有游戏(含时长) if (steamId) { report(1, 20, isZh ? '正在拉取个人游戏…' : 'Fetching owned games…'); const owned = await fetchOwnedGames(steamId, authToken); if (owned.games.length > 0) { owned.games.forEach(g => { gamesMap.set(g.appid, { appid: g.appid, name: g.name, playtime: g.playtime || 0, icon: g.icon || '', lastPlayed: g.lastPlayed || 0, owners: [], acquiredTime: 0, _source: g._source || owned.source, }); }); console.log(`[SGLV] 个人游戏来源: ${owned.source || 'none'}, 共 ${owned.games.length} 款`); } } // 步骤2:通过 access_token 获取家庭共享库(补充共享游戏和归属信息) if (authToken) { report(2, 50, isZh ? '正在拉取家庭组…' : 'Fetching family…'); const familyInfo = await fetchFamilyInfo(authToken); if (familyInfo) storage.setFamilyInfo(familyInfo); const familyGames = await fetchFamilyGameList(authToken); if (familyGames && familyGames.length > 0) { familyGames.forEach(g => { const existing = gamesMap.get(g.appid); if (!existing) { gamesMap.set(g.appid, { appid: g.appid, name: g.name, playtime: 0, icon: g.icon || '', lastPlayed: g.lastPlayed || 0, owners: g.owners || [], acquiredTime: g.acquiredTime || 0, _source: 'family', }); } else { // 用家庭组数据补充 owners 和 acquiredTime if (g.owners && g.owners.length) existing.owners = g.owners; if (g.acquiredTime) existing.acquiredTime = g.acquiredTime; } }); console.log(`[SGLV] 通过 access_token 获取家庭共享 ${familyGames.length} 款游戏`); } } // 步骤3:v2.3.29 通过 dynamicstore/userdata 补充 GetOwnedGames API 遗漏的游戏 // 某些通过 CD key 激活、促销许可或免费领取的游戏可能不在 GetOwnedGames 返回结果中 // dynamicstore/userdata 是 Steam 商店客户端使用的真实入库数据,包含所有许可类型 report(3, 80, isZh ? '正在补充遗漏游戏…' : 'Supplementing missing games…'); const dsOwnedAppIds = await fetchDynamicStoreOwnedAppIds(); if (dsOwnedAppIds && dsOwnedAppIds.size > 0) { let supplemented = 0; dsOwnedAppIds.forEach(appid => { if (!gamesMap.has(appid)) { gamesMap.set(appid, { appid, name: `App ${appid}`, playtime: 0, icon: '', lastPlayed: 0, owners: [], acquiredTime: 0, _source: 'dynamicstore', }); supplemented++; } }); if (supplemented > 0) { console.log(`[SGLV] dynamicstore/userdata 补充 ${supplemented} 款 GetOwnedGames 遗漏的游戏(CD key/促销许可等)`); } } report(3, 100, isZh ? '库存拉取完成' : 'Done'); return Array.from(gamesMap.values()); } // v2.3.24: 动态页专用数据源——始终拉取完整家庭组库(含个人 acquiredTime/owners,10 分钟会话级缓存)。 // 原因:acquiredTime 仅家庭组 API 提供;state.ownedGames 可能是缺少入库时间的缓存数据, // 导致个人入库动态为空。此处与"显示家庭共享"开关无关,保证两个入库动态都能正确加载。 let _timelineFamilyGamesCache = null; // { games, ts, appidSet } // v2.3.31: 入库热力图渲染缓存——避免每次切换游玩仪表标签页都重新计算 SVG // v2.4.2: 缓存结构扩展为 { signature, html, ts, views }——views 预存各年份/全部视图 HTML,供年份切换直接复用 let _heatmapRenderCache = null; // v2.8.1: onProgress(stage, percent, text) 进度回调,UI 可选接入避免"界面不动" // v2.9.48: 增量更新——缓存有效时先返回缓存数据,后台静默拉取最新记录合并(不清空已有数据) async function fetchTimelineFamilyGames(onProgress) { const report = (stage, percent, text) => { if (typeof onProgress === 'function') onProgress(stage, percent, text); }; // v2.9.48: 缓存有效期内直接返回,不做增量更新(10 分钟 TTL) if (_timelineFamilyGamesCache && Date.now() - _timelineFamilyGamesCache.ts < 10 * 60 * 1000) { report(4, 100, `已从缓存载入 ${_timelineFamilyGamesCache.games.length} 款家庭组游戏`); return _timelineFamilyGamesCache.games; } // v2.9.48: 缓存已过期但仍有数据——先返回旧数据(秒开),后台增量拉取最新记录 const hasStaleCache = _timelineFamilyGamesCache && _timelineFamilyGamesCache.games.length > 0; if (hasStaleCache) { // 立即返回旧数据,UI 秒开 report(4, 100, `已从缓存载入 ${_timelineFamilyGamesCache.games.length} 款家庭组游戏`); // 后台静默增量更新(不阻塞 UI) _refreshTimelineCacheInBackground(); return _timelineFamilyGamesCache.games; } // 无缓存——首次加载,走完整流程 try { report(1, 8, '正在准备访问令牌…'); const authToken = await getAccessToken(); if (authToken) { const games = await fetchFamilyGameList(authToken, (stage, percent, text) => { const mapped = 10 + percent * 0.9; report(Math.min(4, stage + 1), Math.min(99, mapped), text); }); if (games && games.length > 0) { const appidSet = new Set(games.map(g => g.appid)); _timelineFamilyGamesCache = { games, ts: Date.now(), appidSet }; report(4, 100, `已获取 ${games.length} 款家庭组共享游戏`); return games; } } } catch (e) { console.warn('[SGLV] 动态页家庭组数据获取失败:', e); } const fb = (state.ownedGames && state.ownedGames.length > 0) ? state.ownedGames : null; report(4, 100, fb ? `已使用本地游戏库(${fb.length} 款)作为回退` : '家庭组数据不可用'); return fb; } // v2.9.48: 后台增量刷新——拉取家庭组数据,与缓存合并(只新增不删除),更新时间戳 async function _refreshTimelineCacheInBackground() { try { const authToken = await getAccessToken(); if (!authToken) return; const freshGames = await fetchFamilyGameList(authToken); if (!freshGames || freshGames.length === 0) return; const oldCache = _timelineFamilyGamesCache; if (!oldCache) { const appidSet = new Set(freshGames.map(g => g.appid)); _timelineFamilyGamesCache = { games: freshGames, ts: Date.now(), appidSet }; return; } // 增量合并:新数据中 appid 不在旧缓存中的才新增(保留已有数据不清空) const oldSet = oldCache.appidSet || new Set(oldCache.games.map(g => g.appid)); const newGames = freshGames.filter(g => !oldSet.has(g.appid)); if (newGames.length > 0) { // 更新旧缓存中已有游戏的数据(acquiredTime 可能更新) const freshMap = new Map(freshGames.map(g => [g.appid, g])); const mergedGames = oldCache.games.map(g => { const fresh = freshMap.get(g.appid); return fresh ? { ...g, ...fresh } : g; }); // 追加新游戏 mergedGames.push(...newGames); const mergedSet = new Set(mergedGames.map(g => g.appid)); _timelineFamilyGamesCache = { games: mergedGames, ts: Date.now(), appidSet: mergedSet }; console.log(`[SGLV] 时间线增量更新:新增 ${newGames.length} 款,总计 ${mergedGames.length} 款`); } else { // 无新增,仅更新时间戳 _timelineFamilyGamesCache.ts = Date.now(); } } catch (e) { console.warn('[SGLV] 时间线后台增量刷新失败:', e); // 失败时仍更新时间戳,避免频繁重试 if (_timelineFamilyGamesCache) _timelineFamilyGamesCache.ts = Date.now(); } } // v2.3.15: 个人入库动态(优先使用已缓存的 state.ownedGames,不判断"显示家庭共享"开关,直接显示所有个人入库动态) async function buildPersonalTimeline(steamId) { // v2.3.24: 统一走动态页专用数据源(始终含完整家庭组 acquiredTime),与"显示家庭共享"开关无关 const familyGames = await fetchTimelineFamilyGames(); if (!familyGames || familyGames.length === 0) return null; const mySteamId = String(steamId || getActiveSteamId() || '').trim(); const items = []; for (const g of familyGames) { const acquired = g.acquiredTime || 0; if (!acquired) continue; // v2.3.15: 跳过无入库时间的游戏,避免显示无效条目 // 个人入库:owners 包含 mySteamId,或 owners 为空(视为个人拥有) const owners = g.owners || []; if (mySteamId && owners.length > 0 && !owners.some(sid => String(sid).trim() === mySteamId)) continue; const ts = acquired * 1000; const dateStr = new Date(ts).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); items.push({ appid: g.appid, name: g.name || `App ${g.appid}`, ts, dateStr, icon: g.icon || '', playtime: g.playtime || 0, }); } items.sort((a, b) => (b.ts || 0) - (a.ts || 0)); return { items, total: items.length }; } // v2.3.15: 家庭组入库动态(优先使用已缓存的 state.ownedGames,不判断"显示家庭共享"开关,直接显示所有入库动态) // v2.8.1: onProgress(stage, percent, text) 进度回调,UI 可选接入避免"界面不动" // 同时把循环拆成异步 yield,避免处理 1k+ 款游戏时阻塞主线程 async function buildFamilyTimeline(steamId, onProgress) { const report = (stage, percent, text) => { if (typeof onProgress === 'function') onProgress(stage, percent, text); }; // v2.3.24: 统一走动态页专用数据源(始终含完整家庭组 acquiredTime),与"显示家庭共享"开关无关 // 阶段 1-4:token → 家庭组信息 → 共享库 → 整理(在 fetchTimelineFamilyGames 内部完成) const familyGames = await fetchTimelineFamilyGames((stage, percent, text) => { report(stage, percent, text); }); if (!familyGames || familyGames.length === 0) return null; // 阶段 4:分批整理游戏数据,避免长任务阻塞渲染 const items = []; const total = familyGames.length; const BATCH = 200; // 每批 200 条,配合 requestAnimationFrame 让出主线程 const yieldFrame = () => new Promise(r => requestAnimationFrame(() => r())); for (let i = 0; i < total; i++) { const g = familyGames[i]; const acquired = g.acquiredTime || 0; if (acquired) { const ts = acquired * 1000; const dateStr = new Date(ts).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); items.push({ appid: g.appid, name: g.name || `App ${g.appid}`, ts, dateStr, icon: g.icon || '', playtime: g.playtime || 0, owners: g.owners || [], }); } // 每批结束让出一次主线程(数据量大时保持 UI 响应) if ((i + 1) % BATCH === 0 && i + 1 < total) { const pct = 50 + Math.round((i + 1) / total * 45); report(4, pct, `已整理 ${i + 1} / ${total} 款游戏…`); await yieldFrame(); } } items.sort((a, b) => (b.ts || 0) - (a.ts || 0)); report(4, 100, `整理完成:共 ${items.length} 条入库记录`); return { items, total: items.length }; } // ==================== 游戏收藏模块(v2.7.1:@require 外部库) ==================== // 供应商分类 / 绝版收藏 / PS会免 三个模块已拆分至独立 Greasyfork 库 sglv-game-collection。 // 主脚本经 CollectionHost 桥提供数据/工具/i18n,库经 init(ctx) 握手后独立运行收藏浮窗。 // 库未加载时(网络失败/TM缓存损坏),二级菜单项置灰提示,主浮窗功能不受影响。 // 桥接契约:宿主需 @grant GM_xmlhttpRequest/GM_getValue/GM_setValue, // 并 @connect fastly.jsdelivr.net(PS会免数据)与 store.playstation.com(PS独占中文名)。 const CollectionHost = { apiVersion: 1, // ---- 库存数据访问(只读,尊重家庭组开关) ---- getOwnedGames: () => state.ownedGames, getActiveOwnedAppIds, isGameOwnedByMe, getGameOwnerNames, getShowFamilyShared: () => storage.getShowFamilyShared(), // ---- 收藏数据源(@resource 声明保留在主脚本 metadata) ---- // v2.9.27: 供应商(GAME_DB)与系列(seriesDb)分离提供,不再合并 getGameDb: () => GAME_DB, getSeriesDb: () => seriesDb, getDelistedDb: () => DELISTED_DB, getResourceText: (name) => { try { return GM_getResourceText(name); } catch (e) { return null; } }, // ---- 通用工具 ---- fetchGameZhName, loadGameZhName, coverChainUrls, posterImg, capsuleImg, getGameIconUrl, getPosterGood, fetchCoverApiUrl: _fetchCoverApiUrl, renderPagination, paginate, showToast, wlEscape, escHtml, h, formatLastPlayedShort, cacheKey, getCached, // ---- TTL 缓存 ---- cacheGet, cacheSet, cacheTTL: CACHE_TTL, // ---- i18n / 图标 ---- T, ICONS, isZh, // ---- v2.9.12: 锁区游戏数据访问(供收藏模块调用) ---- loadBlockedApps, isBlockedApp, isBlockedLoaded: () => state.blockedLoaded, getBlockedApps: () => state.blockedApps, getBlockedSource: () => state.blockedSource, parseManualBlockedJson, // ---- v2.9.32: DLC 收集数据访问(供收藏模块调用,闭包直达无需 SGLV_API 中转) ---- // v1.9.2: loadDlcDatabaseFromCacheSync 暴露给收藏模块——冷启动时同步预热 dlcDbData / appTypeMap 闭包, // 避免 collection 库每次 await 异步加载导致"假卡死" isDlc, isDlcDbReady: () => !!(dlcDbData || appTypeMap), loadDlcDatabase, loadDlcDatabaseFromCacheSync, // v1.9.2: 新增,同步读 GM 缓存填充闭包 enrichOwnedAppTypes, enrichDlcTypes, getDlcType, getBundleCount, getDlcParent: (dlcAppId) => { const e = dlcDbData && dlcDbData[String(dlcAppId)]; return e ? e.base_appID : null; }, // v2.9.32: 构建 父游戏 appID → 全部 DLC 数 反向映射(用于 DLC 收集完成度计算) getParentDlcTotalMap: () => { const map = {}; if (dlcDbData) { for (const info of Object.values(dlcDbData)) { if (info && info.base_appID) { map[info.base_appID] = (map[info.base_appID] || 0) + 1; } } } console.log(`[SGLV-DLC] getParentDlcTotalMap — 构建 ${Object.keys(map).length} 个父游戏的 DLC 总数映射 (dlcDbData=${dlcDbData ? Object.keys(dlcDbData).length + '条' : 'null'})`); return map; }, // v2.9.46: 构建 父游戏 appID → [dlcAppId, ...] 反向映射(用于 DLC 收藏全量加载) getDlcsByParentMap: () => { if (_dlcsByParentCache) return _dlcsByParentCache; const map = {}; if (dlcDbData) { for (const [dlcAppId, info] of Object.entries(dlcDbData)) { if (info && info.base_appID) { const key = String(info.base_appID); if (!map[key]) map[key] = []; map[key].push(Number(dlcAppId)); } } } _dlcsByParentCache = map; console.log(`[SGLV-DLC] getDlcsByParentMap — 构建 ${Object.keys(map).length} 个父游戏的 DLC 列表映射`); return map; }, }; // v2.7.1: 收藏模块经 @require 外部库加载,握手初始化 let _collectionReady = false; if (window.SGLVCollection && typeof window.SGLVCollection.init === 'function') { _collectionReady = window.SGLVCollection.init(CollectionHost); if (!_collectionReady) { console.warn('[SGLV] 收藏模块握手失败 (apiVersion 不匹配)'); } } else { console.warn('[SGLV] 收藏模块库未加载 — 收藏功能不可用 (请检查 @require 或 Tampermonkey 外部资源设置)'); } // v2.9.34: 云存档模块经 @require 外部库加载,握手初始化 let _cloudSaveReady = false; if (window.SGLVCloudSave && typeof window.SGLVCloudSave.init === 'function') { const CloudSaveHost = { apiVersion: 1, isZh, showToast, cacheGet, cacheSet, cacheTTL: CACHE_TTL, storage: { getCloudSaveData: storage.getCloudSaveData, setCloudSaveData: storage.setCloudSaveData, }, }; _cloudSaveReady = window.SGLVCloudSave.init(CloudSaveHost); if (!_cloudSaveReady) { console.warn('[SGLV] 云存档模块握手失败 (apiVersion 不匹配)'); } } else { console.warn('[SGLV] 云存档模块库未加载 — 云存档功能不可用 (请检查 @require 或 Tampermonkey 外部资源设置)'); } // ==================== UI 构建 ==================== let overlayEl, panelEl; function initUI() { // 库存展示按钮 — 插入到全局导航栏"客服"后方 // v2.7.0: 包装为二级菜单容器,新增"游戏收藏"子菜单项 const menuWrap = document.createElement('span'); menuWrap.className = 'sglv-menu-wrap'; const fab = document.createElement('a'); fab.className = 'menuitem sglv-fab'; fab.title = T.title; fab.href = 'javascript:void(0)'; fab.textContent = T.title; fab.addEventListener('click', (e) => { e.preventDefault(); togglePanel(); }); menuWrap.appendChild(fab); // 二级菜单:游戏收藏 const submenu = document.createElement('div'); submenu.className = 'sglv-submenu'; const collectionLink = document.createElement('a'); collectionLink.className = 'menuitem sglv-submenu-item'; collectionLink.href = 'javascript:void(0)'; collectionLink.textContent = isZh ? '游戏收藏' : 'Game Collection'; collectionLink.title = isZh ? '供应商分类 / 绝版收藏 / PS会免 / Epic赠送 / 年度大作' : 'Publisher / Delisted / PS Plus / Epic Free / GotY'; collectionLink.addEventListener('click', (e) => { e.preventDefault(); if (window.SGLVCollection) { SGLVCollection.toggleCollection(); } else { sglvToast.warning(isZh ? '收藏模块未加载' : 'Collection module not loaded'); } }); submenu.appendChild(collectionLink); menuWrap.appendChild(submenu); const supernav = document.querySelector('.supernav_container'); if (supernav) { // 定位"客服"链接(help.steampowered.com),在其后方插入 const helpLink = supernav.querySelector('a.menuitem[href*="help.steampowered.com"]') || Array.from(supernav.querySelectorAll('a.menuitem')).pop(); if (helpLink) helpLink.after(menuWrap); else supernav.appendChild(menuWrap); } // 遮罩层 overlayEl = document.createElement('div'); overlayEl.className = 'sglv-overlay'; overlayEl.addEventListener('click', closePanel); document.body.appendChild(overlayEl); // 主面板 panelEl = document.createElement('div'); panelEl.className = 'sglv-panel'; // v2.9.22: 页头紧凑化——移除"游戏库"标题,保留最左侧 Steam 图标作为品牌标识,tabs 移入 header 内部(参考 steam-friend-manager 1.2.5 sfd-tab-bar 风格) // v2.9.51: tab 菜单紧凑化 + 愿望单 tab 右侧加入全局搜索框(中文/拼音缩写/英文子串匹配) panelEl.innerHTML = `
Steam
`; document.body.appendChild(panelEl); // v2.9.22: header 整行作为拖动手柄(参考 steam-friend-manager 1.2.5 makeDraggable) makeDraggable(panelEl, panelEl.querySelector('.sglv-header')); // 事件绑定 panelEl.querySelector('.sglv-close-btn').addEventListener('click', closePanel); panelEl.querySelector('.sglv-refresh-btn').addEventListener('click', () => startFetch(true)); panelEl.querySelector('.sglv-settings-btn').addEventListener('click', () => { // v2.9.28: 设置已集成进主面板——再点一次可退出设置视图 if (state.showSettings) closeGlobalSettings(); else openGlobalSettings(); }); // v2.9.27: 心型按钮 → 打开游戏收藏浮窗 panelEl.querySelector('.sglv-collection-btn').addEventListener('click', () => { if (window.SGLVCollection) SGLVCollection.openCollection(); else showToast(isZh ? '收藏模块未加载' : 'Collection module not loaded'); }); panelEl.querySelector('.sglv-export-csv-btn').addEventListener('click', exportCSV); panelEl.querySelector('.sglv-export-json-btn').addEventListener('click', exportJSON); panelEl.querySelectorAll('.sglv-tab').forEach(tab => { tab.addEventListener('click', () => { // v2.9.56: 统一使用 switchToTab switchToTab(tab.dataset.tab); }); }); // v2.9.51: 绑定全局游戏搜索框事件(输入防抖/键盘导航/外部点击关闭/清除按钮) bindGlobalSearchEvents(); // v2.9.51: 首次绑定时也构建一次搜索索引(已有缓存的情况下,UI 即可用) if (!_searchIndexBuilt) { try { buildSearchIndex(); } catch (e) { console.warn('[SGLV] 搜索索引预构建失败:', e); } } // v2.3.27: 供侧边栏 KPI 卡片跨模块打开游戏库浮窗 // v2.9.49: 注册到 addDisposer 确保卸载时清理,避免内存泄漏 const _onOpenLibrary = () => { openPanel(); }; document.addEventListener('sglv:open-library', _onOpenLibrary); addDisposer(() => document.removeEventListener('sglv:open-library', _onOpenLibrary)); // v2.9.38: 监听 SGIS 侧边栏触发的事件, 支持跳转指定 tab const _onOpenModal = (e) => { openPanel(); const targetTab = e?.detail?.tab; if (targetTab) { // v2.9.56: 统一使用 switchToTab (内含 DOM 存在性检查) const tabBtn = panelEl.querySelector(`.sglv-tab[data-tab="${targetTab}"]`); if (tabBtn) switchToTab(targetTab); } }; document.addEventListener('sglv:open-modal', _onOpenModal); addDisposer(() => document.removeEventListener('sglv:open-modal', _onOpenModal)); // 加载缓存(GM_getValue 同步读取,体积小,不阻塞绘制) const cached = storage.getCachedGames(); if (cached.length) { cached.sort((a, b) => a.name.localeCompare(b.name)); state.ownedGames = cached; state.ownedAppIds = new Set(cached.map(g => g.appid)); markSearchIndexDirty(); // v2.9.51: 缓存恢复,索引需要重建 // v2.9.50: 同步从 PCC IDB 预热已计算的 KPI 结果到 _computedCache // 旧逻辑 clearComputedCache 把所有缓存清空,下次开面板全量重算(每次) // 新逻辑 hydrateComputedCache 把已持久化的计算结果(年度统计/家庭组/insight 等)从 IDB 同步填充 // 启动时 IDB.loadAll 已完成,内存镜像 _mem 可用 hydrateComputedCache(); } else { // 首次使用/无缓存:确保缓存清空 clearComputedCache(); } // v2.9.5: 首次使用检测——无 API Key 且无缓存游戏时给出引导 const _isFirstUse = !storage.getApiKey() && state.ownedGames.length === 0; // v2.9.5: DLC 数据库同步预加载延迟到下一事件循环,确保菜单入口优先渲染 // DLC 缓存体积较大(数万条),同步 JSON.parse 会阻塞浏览器首次绘制 setTimeout(() => { loadDlcDatabaseFromCacheSync(); loadDlcDatabase(); // v2.9.27: 异步加载游戏系列数据(game_series.json),合并到系列分类数据库 loadSeriesData(); // v2.9.28: 首次未配置 API Key 不再自动弹出面板/设置,改为右下角引导提示卡片, // 由用户主动点击"打开设置"进入设置视图,避免打断浏览体验 if (_isFirstUse) { showApiKeyGuideToast(); } }, 0); } function togglePanel() { if (panelEl.classList.contains('sglv-show')) closePanel(); else openPanel(); } function openPanel() { panelEl.classList.add('sglv-show'); overlayEl.classList.add('sglv-show'); renderBody(); } function closePanel() { panelEl.classList.remove('sglv-show'); overlayEl.classList.remove('sglv-show'); // v2.9.64: 清理搜索/详情浮窗状态,防止面板重开后浮窗残留 + 监听器泄漏 + 防抖 timer 泄漏 closeDetailPopup(); // 关闭详情浮窗 + 移除 _detailEscHandler + 递增 _detailToken 使在途请求失效 closeSearchPop(); // 关闭搜索下拉 + 重置聚焦索引 _gsDebouncedRender.cancel(); // 取消 pending 防抖 timer if (_gsInputEl) _gsInputEl.value = ''; // 清空搜索框 _gsLastResults = []; } // v2.9.22: 允许拖动面板(参考 steam-friend-manager 1.2.5 makeDraggable) // 用 !important 覆盖 shared CSS 中 sglv-panel 的 top/left 定位, // 否则拖动后内联 top 会被样式表压制,导致窗口只能左右移动、无法上下移动。 function makeDraggable(el, handle) { if (!el || !handle) return; let isDragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0; const onDown = (e) => { // 忽略按钮 / 链接 / 输入控件 / label 上的 mousedown,避免与 tab/操作按钮冲突 const t = e.target; if (t.closest('button, input, select, textarea, label, a')) return; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = el.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; el.style.cursor = 'grabbing'; e.preventDefault(); }; const onMove = (e) => { if (!isDragging) return; el.style.setProperty('left', (startLeft + e.clientX - startX) + 'px', 'important'); el.style.setProperty('top', (startTop + e.clientY - startY) + 'px', 'important'); el.style.setProperty('right', 'auto', 'important'); el.style.setProperty('bottom', 'auto', 'important'); el.style.setProperty('transform', 'none', 'important'); }; const onUp = () => { if (!isDragging) return; isDragging = false; el.style.cursor = ''; }; handle.addEventListener('mousedown', onDown); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); addDisposer(() => { handle.removeEventListener('mousedown', onDown); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }); } // ==================== v2.9.38: 我的成就 — 全新质感炫彩 SVG 图标库 ==================== // 设计规范:80x80 viewBox · 圆形徽章风格 · 四层结构 (背景环 + 主渐变填充 + 内层高光 + 中央图标) // 共享滤镜 ID 命名约定: ach-{key}-shadow / ach-{key}-main / ach-{key}-shine / ach-{key}-rim const _ACH_ICONS = { // ===== 收藏家 — 紫色宝石盒 (紫蓝渐变) ===== collector: ``, // ===== 游戏大王 — 黄金王冠 (金黄橙红渐变) ===== kingCollector: ``, // ===== 时钟 — 青色钟表 (青蓝渐变) ===== clock: ``, // ===== 沙漏 — 橙红沙漏 (橙红渐变) ===== hourglass: ``, // ===== 完美 — 绿色圆环 (翠绿渐变) ===== perfection: ``, // ===== 奖杯 — 黄金奖杯 (金黄渐变) ===== trophy: ``, // ===== 火焰 — 红橙烈焰 (橙红渐变) ===== fire: ``, // ===== 地球 — 青色地球 (青蓝渐变) ===== globe: ``, // ===== 朝阳 — 金黄旭日 (橙黄渐变) ===== sunrise: ``, // ===== 月亮 — 紫蓝夜空 (靛紫渐变) ===== moon: ``, // ===== 钻石 — 青色钻石 (青蓝渐变) ===== diamond: ``, // ===== 红心 — 粉红心形 (粉红渐变) ===== heart: ``, // ===== 星星 — 金色星芒 (金黄渐变) ===== star: ``, // ===== 盾牌 — 紫蓝盾牌 (靛紫渐变) ===== shield: ``, // ===== 火箭 — 紫粉火箭 (紫粉渐变) ===== rocket: ``, // ===== 消费 — 蓝色礼盒 (蓝紫渐变 · 新增) ===== spending: ``, // ===== 社交 — 紫粉对话 (粉紫渐变 · 新增) ===== social: `+1`, // ===== 探索 — 翠绿罗盘 (青绿渐变 · 新增) ===== explore: `N`, }; // v2.9.35: 成就稀有度元数据 const _ACH_RARITY = { common: { label: T.achRarityCommon, pts: 10, color: '#94a3b8' }, rare: { label: T.achRarityRare, pts: 25, color: '#3b82f6' }, epic: { label: T.achRarityEpic, pts: 50, color: '#a855f7' }, legendary: { label: T.achRarityLegendary, pts: 100, color: '#f59e0b' }, }; // v2.9.35: 成就分类元数据 (6 大分类) const _ACH_CATEGORIES = [ { id: 'collector', icon: _ACH_ICONS.collector, label: T.achCategoryCollector, gradient: 'linear-gradient(135deg, #8b5cf6, #3b82f6)' }, { id: 'playtime', icon: _ACH_ICONS.clock, label: T.achCategoryPlaytime, gradient: 'linear-gradient(135deg, #06b6d4, #0891b2)' }, { id: 'mastery', icon: _ACH_ICONS.trophy, label: T.achCategoryMastery, gradient: 'linear-gradient(135deg, #10b981, #059669)' }, { id: 'diversity', icon: _ACH_ICONS.globe, label: T.achCategoryDiversity, gradient: 'linear-gradient(135deg, #f97316, #ea580c)' }, { id: 'loyalty', icon: _ACH_ICONS.heart, label: T.achCategoryLoyalty, gradient: 'linear-gradient(135deg, #f472b6, #db2777)' }, { id: 'special', icon: _ACH_ICONS.star, label: T.achCategorySpecial, gradient: 'linear-gradient(135deg, #fbbf24, #f59e0b)' }, ]; // v2.9.35: 成就定义 — 6 大分类 30+ 成就,含稀有度与成就点数 // metric 字段统一指向 _computeAchMetrics 返回的 key,简化判定逻辑 const _ACHIEVEMENT_DEFS = [ // ===== 收藏成就 (collector) — 游戏数量 ===== { id: 'collector_10', icon: _ACH_ICONS.collector, name: isZh ? '初出茅庐' : 'First Steps', desc: isZh ? '拥有 10 款游戏' : 'Own 10 games', threshold: 10, metric: 'gameCount', cat: 'collector', rarity: 'common' }, { id: 'collector_50', icon: _ACH_ICONS.collector, name: isZh ? '初级收藏家' : 'Novice Collector', desc: isZh ? '拥有 50 款游戏' : 'Own 50 games', threshold: 50, metric: 'gameCount', cat: 'collector', rarity: 'common' }, { id: 'collector_100', icon: _ACH_ICONS.collector, name: isZh ? '中级收藏家' : 'Avid Collector', desc: isZh ? '拥有 100 款游戏' : 'Own 100 games', threshold: 100, metric: 'gameCount', cat: 'collector', rarity: 'rare' }, { id: 'collector_300', icon: _ACH_ICONS.collector, name: isZh ? '高级收藏家' : 'Pro Collector', desc: isZh ? '拥有 300 款游戏' : 'Own 300 games', threshold: 300, metric: 'gameCount', cat: 'collector', rarity: 'rare' }, { id: 'collector_500', icon: _ACH_ICONS.collector, name: isZh ? '超级收藏家' : 'Mega Collector', desc: isZh ? '拥有 500 款游戏' : 'Own 500 games', threshold: 500, metric: 'gameCount', cat: 'collector', rarity: 'epic' }, { id: 'collector_1000', icon: _ACH_ICONS.kingCollector,name: isZh ? '游戏大王' : 'Game Master', desc: isZh ? '拥有 1000 款游戏' : 'Own 1000 games', threshold: 1000, metric: 'gameCount', cat: 'collector', rarity: 'legendary' }, // ===== 时长成就 (playtime) — 总游戏时长 ===== { id: 'playtime_500', icon: _ACH_ICONS.clock, name: isZh ? '初涉江湖' : 'Getting Started', desc: isZh ? '总时长达到 500 小时' : 'Reach 500 hours', threshold: 500, metric: 'totalHours', cat: 'playtime', rarity: 'common' }, { id: 'playtime_1k', icon: _ACH_ICONS.clock, name: isZh ? '入门玩家' : 'Beginner', desc: isZh ? '总时长达到 1000 小时' : 'Reach 1000 hours', threshold: 1000, metric: 'totalHours', cat: 'playtime', rarity: 'common' }, { id: 'playtime_3k', icon: _ACH_ICONS.hourglass, name: isZh ? '熟练玩家' : 'Skilled', desc: isZh ? '总时长达到 3000 小时' : 'Reach 3000 hours', threshold: 3000, metric: 'totalHours', cat: 'playtime', rarity: 'rare' }, { id: 'playtime_5k', icon: _ACH_ICONS.hourglass, name: isZh ? '资深玩家' : 'Veteran', desc: isZh ? '总时长达到 5000 小时' : 'Reach 5000 hours', threshold: 5000, metric: 'totalHours', cat: 'playtime', rarity: 'rare' }, { id: 'playtime_10k', icon: _ACH_ICONS.hourglass, name: isZh ? '硬核玩家' : 'Hardcore', desc: isZh ? '总时长达到 10000 小时' : 'Reach 10000h', threshold: 10000,metric: 'totalHours', cat: 'playtime', rarity: 'epic' }, { id: 'playtime_20k', icon: _ACH_ICONS.fire, name: isZh ? '传奇玩家' : 'Legend', desc: isZh ? '总时长达到 20000 小时' : 'Reach 20000h', threshold: 20000,metric: 'totalHours', cat: 'playtime', rarity: 'legendary' }, // ===== 专精成就 (mastery) — 单游戏深度游玩 ===== { id: 'mastery_1x100', icon: _ACH_ICONS.trophy, name: isZh ? '专精入门' : 'Focused', desc: isZh ? '1 款游戏游玩超过 100 小时' : '1 game 100h+', threshold: 1, metric: 'games100h', cat: 'mastery', rarity: 'common' }, { id: 'mastery_5x100', icon: _ACH_ICONS.trophy, name: isZh ? '多线专精' : 'Multi-Focus', desc: isZh ? '5 款游戏游玩超过 100 小时' : '5 games 100h+', threshold: 5, metric: 'games100h', cat: 'mastery', rarity: 'common' }, { id: 'mastery_10x100', icon: _ACH_ICONS.trophy, name: isZh ? '深度专精' : 'Dedicated', desc: isZh ? '10 款游戏游玩超过 100 小时' : '10 games 100h+',threshold: 10,metric: 'games100h', cat: 'mastery', rarity: 'rare' }, { id: 'mastery_1x500', icon: _ACH_ICONS.fire, name: isZh ? '挚爱之作' : 'Beloved', desc: isZh ? '1 款游戏游玩超过 500 小时' : '1 game 500h+', threshold: 1, metric: 'games500h', cat: 'mastery', rarity: 'rare' }, { id: 'mastery_1x1k', icon: _ACH_ICONS.fire, name: isZh ? '一生挚爱' : 'Soulmate', desc: isZh ? '1 款游戏游玩超过 1000 小时' : '1 game 1000h+',threshold: 1, metric: 'games1kh', cat: 'mastery', rarity: 'epic' }, { id: 'mastery_20x100', icon: _ACH_ICONS.perfection, name: isZh ? '完美主义' : 'Perfectionist', desc: isZh ? '20 款游戏游玩超过 100 小时' : '20 games 100h+',threshold: 20,metric: 'games100h', cat: 'mastery', rarity: 'legendary' }, // ===== 多元成就 (diversity) — 游戏覆盖面 ===== { id: 'div_played50', icon: isZh ? _ACH_ICONS.globe : _ACH_ICONS.globe, name: isZh ? '广泛涉猎' : 'Explorer', desc: isZh ? '游玩库中 50% 的游戏' : 'Play 50% of library', threshold: 50, metric: 'playRate', cat: 'diversity', rarity: 'common' }, { id: 'div_played80', icon: _ACH_ICONS.globe, name: isZh ? '博览群书' : 'Scholar', desc: isZh ? '游玩库中 80% 的游戏' : 'Play 80% of library', threshold: 80, metric: 'playRate', cat: 'diversity', rarity: 'rare' }, { id: 'div_unplayed100',icon: _ACH_ICONS.shield, name: isZh ? '库存积压' : 'Backlog', desc: isZh ? '拥有 100 款从未游玩的游戏' : '100 unplayed games', threshold: 100, metric: 'unplayedCount', cat: 'diversity', rarity: 'common' }, { id: 'div_recent7d', icon: _ACH_ICONS.sunrise, name: isZh ? '活跃玩家' : 'Active', desc: isZh ? '7 天内游玩过游戏' : 'Played within 7 days', threshold: 1, metric: 'recent7d', cat: 'diversity', rarity: 'common' }, { id: 'div_recent24h', icon: _ACH_ICONS.sunrise, name: isZh ? '今日在线' : 'Today', desc: isZh ? '24 小时内游玩过游戏' : 'Played within 24h', threshold: 1, metric: 'recent24h', cat: 'diversity', rarity: 'rare' }, { id: 'div_played100', icon: _ACH_ICONS.perfection, name: isZh ? '全勤玩家' : 'Completionist', desc: isZh ? '游玩库中 100% 的游戏' : 'Play 100% of library', threshold: 100,metric: 'playRate', cat: 'diversity', rarity: 'legendary' }, // ===== 忠诚成就 (loyalty) — 数据完整度与家庭组 ===== { id: 'loy_data50', icon: _ACH_ICONS.heart, name: isZh ? '数据收集者' : 'Data Keeper', desc: isZh ? '50% 游戏有入库时间' : '50% games have acquire time', threshold: 50, metric: 'dataCompleteness', cat: 'loyalty', rarity: 'common' }, { id: 'loy_data80', icon: _ACH_ICONS.heart, name: isZh ? '数据达人' : 'Data Master', desc: isZh ? '80% 游戏有入库时间' : '80% games have acquire time', threshold: 80, metric: 'dataCompleteness', cat: 'loyalty', rarity: 'rare' }, { id: 'loy_family', icon: _ACH_ICONS.shield, name: isZh ? '家庭共享' : 'Family Sharing', desc: isZh ? '启用家庭组共享' : 'Family sharing enabled', threshold: 1, metric: 'hasFamily', cat: 'loyalty', rarity: 'common' }, { id: 'loy_accurate', icon: _ACH_ICONS.diamond, name: isZh ? '精准记录' : 'Precise', desc: isZh ? '95% 游戏有入库时间' : '95% games have acquire time', threshold: 95, metric: 'dataCompleteness', cat: 'loyalty', rarity: 'epic' }, // ===== 特殊成就 (special) — 复合里程碑 ===== { id: 'sp_night_owl', icon: _ACH_ICONS.moon, name: isZh ? '夜猫子' : 'Night Owl', desc: isZh ? '5000h+ 且拥有 200+ 游戏' : '5000h+ & 200+ games', threshold: 1, metric: 'nightOwl', cat: 'special', rarity: 'epic' }, { id: 'sp_whale', icon: _ACH_ICONS.diamond, name: isZh ? '巨鲸玩家' : 'Whale', desc: isZh ? '10000h+ 且拥有 500+ 游戏' : '10000h+ & 500+ games', threshold: 1, metric: 'whale', cat: 'special', rarity: 'legendary' }, { id: 'sp_pioneer', icon: _ACH_ICONS.rocket, name: isZh ? '先锋玩家' : 'Pioneer', desc: isZh ? '拥有游戏且总时长 1000h+' : 'Has games & 1000h+', threshold: 1, metric: 'pioneer', cat: 'special', rarity: 'common' }, { id: 'sp_liberty', icon: _ACH_ICONS.star, name: isZh ? '自由之魂' : 'Free Spirit', desc: isZh ? '从未游玩的游戏超过 200 款' : '200+ unplayed games', threshold: 200,metric: 'unplayedCount', cat: 'special', rarity: 'rare' }, ]; // v2.9.35: 成就解锁缓存 key (GM_setValue 持久化) const _ACH_CACHE_KEY = 'sglv_ach_unlocks_v2'; // v2.9.35: 读取已解锁成就缓存 { achId: unlockTimestamp } function _getAchUnlockCache() { try { const raw = GM_getValue(_ACH_CACHE_KEY, '{}'); const data = typeof raw === 'string' ? JSON.parse(raw) : raw; return (data && typeof data === 'object') ? data : {}; } catch (e) { return {}; } } // v2.9.35: 写入已解锁成就缓存 (新增解锁时追加时间戳) function _saveAchUnlockCache(cache) { try { GM_setValue(_ACH_CACHE_KEY, JSON.stringify(cache)); } catch (e) { /* 静默失败 */ } } // v2.9.35: 统一计算成就指标 — 一次遍历,所有 metric 集中产出 function _computeAchMetrics() { const allGames = getStatFilteredGames(); const gameCount = allGames.length; const totalPlaytimeMin = allGames.reduce((s, g) => s + (g.playtime || 0), 0); const totalHours = Math.floor(totalPlaytimeMin / 60); const playedGames = allGames.filter(g => (g.playtime || 0) > 0).length; const unplayedCount = gameCount - playedGames; const playRate = gameCount > 0 ? Math.round((playedGames / gameCount) * 100) : 0; const games100h = allGames.filter(g => (g.playtime || 0) >= 6000).length; // 100h = 6000min const games500h = allGames.filter(g => (g.playtime || 0) >= 30000).length; // 500h = 30000min const games1kh = allGames.filter(g => (g.playtime || 0) >= 60000).length; // 1000h = 60000min const now = Date.now(); const recent7d = allGames.filter(g => g.lastPlayed && (now - g.lastPlayed * 1000) <= 7 * 86400000).length > 0 ? 1 : 0; const recent24h = allGames.filter(g => g.lastPlayed && (now - g.lastPlayed * 1000) <= 86400000).length > 0 ? 1 : 0; const withAcquired = allGames.filter(g => g.acquiredTime && g.acquiredTime > 0).length; const dataCompleteness = gameCount > 0 ? Math.round((withAcquired / gameCount) * 100) : 0; const hasFamily = (state.familyInfo && state.familyInfo.members && state.familyInfo.members.length > 0) ? 1 : 0; const nightOwl = (totalHours >= 5000 && gameCount >= 200) ? 1 : 0; const whale = (totalHours >= 10000 && gameCount >= 500) ? 1 : 0; const pioneer = (gameCount > 0 && totalHours >= 1000) ? 1 : 0; return { gameCount, totalHours, playRate, unplayedCount, recent7d, recent24h, games100h, games500h, games1kh, dataCompleteness, hasFamily, nightOwl, whale, pioneer, }; } // v2.9.35: 计算用户成就数据 — 统一 metrics + 缓存解锁时间戳 function computeMyAchievements() { const metrics = _computeAchMetrics(); const unlockCache = _getAchUnlockCache(); const now = Date.now(); let cacheDirty = false; const results = _ACHIEVEMENT_DEFS.map(ach => { const current = metrics[ach.metric] || 0; const unlocked = current >= ach.threshold; const progress = ach.threshold > 0 ? Math.min(100, (current / ach.threshold) * 100) : (unlocked ? 100 : 0); const pts = _ACH_RARITY[ach.rarity]?.pts || 10; // 解锁时间戳:新解锁则记录当前时间 let unlockedAt = null; if (unlocked) { if (unlockCache[ach.id]) { unlockedAt = unlockCache[ach.id]; } else { unlockCache[ach.id] = now; unlockedAt = now; cacheDirty = true; } } return { ...ach, current, unlocked, progress, pts, unlockedAt }; }); // 清理已不再解锁的缓存条目(游戏被移除等边界情况) Object.keys(unlockCache).forEach(id => { const ach = results.find(a => a.id === id); if (ach && !ach.unlocked) { delete unlockCache[id]; cacheDirty = true; } }); if (cacheDirty) _saveAchUnlockCache(unlockCache); return results; } // v2.9.35: 格式化解锁时间 function _formatAchTime(ts) { if (!ts) return ''; const d = new Date(ts); const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } // v2.9.35: 格式化相对时间 (如 "3天前") function _formatRelTime(ts) { if (!ts) return ''; const diff = Date.now() - ts; const days = Math.floor(diff / 86400000); if (days === 0) return isZh ? '今天' : 'Today'; if (days === 1) return isZh ? '昨天' : 'Yesterday'; if (days < 7) return isZh ? `${days}天前` : `${days}d ago`; if (days < 30) return isZh ? `${Math.floor(days / 7)}周前` : `${Math.floor(days / 7)}w ago`; if (days < 365) return isZh ? `${Math.floor(days / 30)}月前` : `${Math.floor(days / 30)}mo ago`; return isZh ? `${Math.floor(days / 365)}年前` : `${Math.floor(days / 365)}y ago`; } // ==================== v2.9.36: 我的成就标签页 — 左右布局重构 ==================== // v2.9.50: computeMyAchievementsCached — 走 PCC 持久化,跨 session 复用 function renderMyAchievementsTab(parent) { const achievements = computeMyAchievementsCached(); const unlocked = achievements.filter(a => a.unlocked); const totalPts = unlocked.reduce((s, a) => s + a.pts, 0); const maxPts = achievements.reduce((s, a) => s + a.pts, 0); const completionRate = achievements.length > 0 ? Math.round((unlocked.length / achievements.length) * 100) : 0; const selectedCat = state.achSelectedCat || 'all'; // v2.9.36: 获取用户名(从页面提取) let userName = isZh ? 'Steam 玩家' : 'Steam Player'; try { const pulldown = document.querySelector('#account_pulldown'); if (pulldown && pulldown.textContent.trim()) userName = pulldown.textContent.trim(); } catch { /* 静默 */ } // v2.9.36: 计算成就等级(基于总点数) const achLevel = Math.floor(totalPts / 100) + 1; // 环形进度 SVG const ringR = 38; const ringCirc = 2 * Math.PI * ringR; const ringOffset = ringCirc * (1 - completionRate / 100); // 最近解锁 (按时间倒序,最多 5 个) const recentUnlocks = unlocked .filter(a => a.unlockedAt) .sort((a, b) => b.unlockedAt - a.unlockedAt) .slice(0, 5); // 侧边栏最近解锁列表 const sidebarRecentHtml = recentUnlocks.length > 0 ? recentUnlocks.map(a => `
${a.icon}
${a.name}
${_formatRelTime(a.unlockedAt)}
`).join('') : `
${T.achNoUnlocks}
`; // 分类导航(垂直列表) const navItems = _ACH_CATEGORIES.map(cat => { const catAchs = achievements.filter(a => a.cat === cat.id); const catUnlocked = catAchs.filter(a => a.unlocked).length; const catTotal = catAchs.length; const badgeClass = catUnlocked === catTotal ? 'complete' : catUnlocked > 0 ? 'partial' : 'none'; return `
${cat.icon}
${cat.label} ${catUnlocked}/${catTotal}
`; }).join(''); // "全部" 导航项 const allNavItem = `
${_ACH_ICONS.trophy}
${T.achAllCats} ${unlocked.length}/${achievements.length}
`; // 成就卡片 const filteredAchs = selectedCat === 'all' ? achievements : achievements.filter(a => a.cat === selectedCat); const catLabel = selectedCat === 'all' ? T.achAllCats : (_ACH_CATEGORIES.find(c => c.id === selectedCat)?.label || ''); const renderAchCard = (a) => { const progressVal = a.unlocked ? '100%' : `${a.progress.toFixed(0)}%`; const currentText = a.unlocked ? T.achUnlocked : `${a.current}/${a.threshold}`; const unlockTimeHtml = a.unlocked && a.unlockedAt ? `
${T.achUnlockedAt} ${_formatAchTime(a.unlockedAt)}
` : ''; // v2.9.59: NEW 标签 — 24小时内解锁的成就显示 NEW const isNew = a.unlocked && a.unlockedAt && (Date.now() - a.unlockedAt < 24 * 3600 * 1000); const newTagHtml = isNew ? `NEW` : ''; // v2.9.59: 稀有度 class 添加到卡片本身(驱动左边框颜色) return `
${a.icon}
${a.name}${newTagHtml}
+${a.pts}pts
${a.desc}
${a.unlocked ? progressVal : currentText}
${unlockTimeHtml}
`; }; const html = `
${_ACH_ICONS.trophy} ${T.tabAchievements}
${_ACH_ICONS.trophy} Lv.${achLevel}
${userName}
${T.achPoints}: ${totalPts} / ${maxPts}
${isZh ? '成就分类' : 'Categories'}
${allNavItem} ${navItems}
${T.achRecentUnlocks}
${sidebarRecentHtml}
${completionRate}%
${T.achCompletionRate}
${ICONS.achievement}${T.achUnlocked}
${unlocked.length}/${achievements.length}
${T.kpiRate} ${completionRate}%
${ICONS.trophy}${T.achPoints}
${totalPts}/${maxPts}pts
${T.kpiRate} ${maxPts > 0 ? Math.round(totalPts / maxPts * 100) : 0}%
${ICONS.barChart}${isZh ? '成就等级' : 'Ach Level'}
Lv.${achLevel}
${totalPts} ${isZh ? '点累计' : 'pts total'}
${catLabel} ${filteredAchs.length}
${filteredAchs.map(renderAchCard).join('')}
`; parent.innerHTML = html; // 分类切换事件 parent.querySelectorAll('.sglv-ach-nav-item').forEach(el => { el.addEventListener('click', () => { state.achSelectedCat = el.dataset.cat; renderMyAchievementsTab(parent); }); }); // 重置按钮事件 const resetBtn = parent.querySelector('#sglv-ach-reset'); if (resetBtn) { resetBtn.addEventListener('click', () => { if (confirm(T.achResetConfirm)) { GM_setValue(_ACH_CACHE_KEY, '{}'); renderMyAchievementsTab(parent); } }); } } // ==================== 渲染主体 ==================== function renderBody() { const body = panelEl.querySelector('#sglv-body'); body.innerHTML = ''; // v2.9.28: 同步设置按钮高亮态(设置视图覆盖展示时点亮 ⚙) const _setBtn = panelEl.querySelector('.sglv-settings-btn'); if (_setBtn) _setBtn.classList.toggle('sglv-active', !!state.showSettings); if (state.showSettings) { // v2.9.28: 设置内容直接覆盖渲染在游戏库浮窗内(不再弹出独立浮层) renderSettingsView(body); return; } // v2.9.22: 依赖 ownedGames 的标签页(owned/playtime/trend/playdata)在无数据时 // 显示友好的占位骨架而非纯空白—— // - 若正在获取:spinner + 阶段进度提示 // - 若无缓存且未在获取:空状态 + "立即获取 / 打开设置" 引导按钮 // wishlist 标签页自带 loading 骨架,不在此拦截 const _ownedDepTabs = ['owned', 'playtime', 'trend', 'playdata', 'achievements']; if (_ownedDepTabs.indexOf(state.activeTab) !== -1 && state.ownedGames.length === 0) { body.appendChild(_buildOwnedEmptyPlaceholder()); return; } if (state.activeTab === 'owned') { renderOwnedTab(body); } else if (state.activeTab === 'playtime') { renderPlaytimeTab(body); } else if (state.activeTab === 'trend') { renderTrendTab(body); } else if (state.activeTab === 'playdata') { renderPlaydataTab(body); } else if (state.activeTab === 'achievements') { renderMyAchievementsTab(body); } else if (state.activeTab === 'cloudsave') { renderCloudSaveTab(body); } else if (state.activeTab === 'wishlist') { renderWishlistTab(body); } } // v2.9.22: ownedGames 空数据占位骨架 function _buildOwnedEmptyPlaceholder() { const wrap = document.createElement('div'); wrap.className = 'sglv-body-loading'; const hasKey = !!storage.getApiKey(); const fetching = !!state.isLoading; if (fetching) { wrap.innerHTML = `
${isZh ? '正在获取你的游戏库…' : 'Fetching your library…'}
${isZh ? '首次访问需要从 Steam 拉取库存与时长,请稍候片刻。' : 'First-time load: fetching library and playtime from Steam. Please wait.'}
`; } else { wrap.innerHTML = `
🎮
${isZh ? '游戏库尚未获取' : 'Library not loaded yet'}
${isZh ? '点击下方按钮从 Steam 拉取你的库存与游戏时长(' + (hasKey ? '已配置 API Key' : '未配置 API Key,将自动尝试无 Key 抓取') + ')。' : 'Click below to fetch your library & playtime from Steam (' + (hasKey ? 'API Key configured' : 'no API Key, fallback scraping') + ').'}
`; wrap.querySelector('[data-act="fetch"]').addEventListener('click', () => { wrap.innerHTML = `
${isZh ? '正在获取你的游戏库…' : 'Fetching your library…'}
${isZh ? '请稍候片刻。' : 'Please wait a moment.'}
`; startFetch(true); }); wrap.querySelector('[data-act="settings"]').addEventListener('click', () => { openGlobalSettings(); }); } return wrap; } // ==================== 趋势标签页 ==================== function renderTrendTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-trend-wrap'; if (state.ownedGames.length === 0) { wrap.innerHTML = `
${T.noData}
`; parent.appendChild(wrap); return; } const hasAcquiredTime = state.ownedGames.some(g => g.acquiredTime > 0); const isEstimated = !hasAcquiredTime; // ====== 基础数据统计(始终计算)====== const allGames = state.ownedGames; const totalGames = allGames.length; // 家庭组合并总数 // 获取当前用户 Steam ID const steamId = getActiveSteamId(); // 筛选仅个人拥有的游戏(基于 owners 字段) let personalGames = []; if (steamId) { personalGames = allGames.filter(g => { const owners = g.owners || []; return owners.length === 0 || owners.includes(steamId); }); } else { personalGames = allGames; // 无 steamId 时视为全部是自己的 } const personalTotal = personalGames.length; // 检测是否有家庭组 owners 数据(用于决定是否显示双曲线) const hasOwnerData = steamId && allGames.some(g => g.owners && g.owners.length > 0); // 家庭组其他成员(排除自己,避免主柱与"我的"成员柱视觉重叠) const otherMembers = []; if (storage.getShowFamilyShared() && hasOwnerData && steamId) { const familyInfo = storage.getFamilyInfo(); const nameMap = familyInfo?.steamIdtoName || {}; otherMembers.push(...Object.entries(nameMap) .filter(([sid]) => sid !== steamId) .map(([sid, name]) => ({ steamid: sid, name }))); } // v2.9.50: 一次性计算所有年度统计并走 PCC 持久化缓存(跨 session 复用,避免每次开面板都重算) // 旧实现:分别调用 computeYearlyStats / computePersonalYearlyStats / computeFamilyMemberCumulativeStats / computeFamilyYearlyStats // 共 4 次遍历 allGames,大库存(数千款)时 ~10-30ms × 4 ≈ 几十 ms,且每次开面板都重算 // 新实现:一次遍历 + 签名匹配直接走缓存 const _yearlyAll = computeYearlyStatsAllCached(allGames, steamId, otherMembers); const yearlyStats = _yearlyAll.yearly; const personalYearlyStats = _yearlyAll.personal; const familyCumStats = _yearlyAll.familyCum; const familyYearlyStats = _yearlyAll.familyYearly; // ====== v2.9.48: 8 个 KPI 卡片分两行显示,增加入库频率维度 ====== const now = new Date(); const thisYear = now.getFullYear(); const yearCount = yearlyStats.years.length; const thisYearIdx = yearlyStats.years.indexOf(thisYear); const thisYearCount = thisYearIdx >= 0 ? yearlyStats.counts[thisYearIdx] : 0; const avgPerYear = yearCount > 0 ? Math.round(yearlyStats.total / yearCount) : 0; const peakYearIdx = yearlyStats.counts.indexOf(Math.max(...yearlyStats.counts)); const peakYear = yearlyStats.years[peakYearIdx] || '-'; const peakYearCount = yearlyStats.counts[peakYearIdx] || 0; // 近30天/近90天入库数 const nowTs = Math.floor(now.getTime() / 1000); const days30 = nowTs - 30 * 86400; const days90 = nowTs - 90 * 86400; let recent30 = 0, recent90 = 0; allGames.forEach(g => { const t = g.acquiredTime || 0; if (t <= 0) return; if (t >= days30) recent30++; if (t >= days90) recent90++; }); // 周均入库 const weeklyAvg = yearCount > 0 ? (yearlyStats.total / (yearCount * 52)).toFixed(1) : '0'; // v2.9.48: 活跃天数(有入库记录的独立天数) const activeDateSet = new Set(); allGames.forEach(g => { const t = g.acquiredTime || 0; if (t <= 0) return; const d = new Date(t * 1000); activeDateSet.add(d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate()); }); const activeDays = activeDateSet.size; const activeDaysPercent = totalGames > 0 ? ((activeDays / totalGames) * 100).toFixed(0) : 0; const kpiSection = document.createElement('div'); kpiSection.className = 'sglv-trend-kpi-wrap'; const firstYear = yearlyStats.years[0] || '-'; const lastYear = yearlyStats.years[yearlyStats.years.length - 1] || '-'; const familyMemberCount = (() => { if (!hasOwnerData || !steamId) return 0; const ownerSet = new Set(); allGames.forEach(g => { (g.owners || []).forEach(sid => { if (sid !== steamId) ownerSet.add(sid); }); }); return ownerSet.size; })(); // v2.9.48: 第一行 4 卡 —— 基础统计 const kpiRow1 = `
${ICONS.library}${isZh ? '我的游戏' : 'My Games'}
${personalTotal.toLocaleString()}
${T.kpiMyCollection}
${ICONS.share}${isZh ? '家庭组合并' : 'Family Total'}
${totalGames.toLocaleString()}
${T.kpiDedupMerge}
${ICONS.calendar}${T.trendKpiThisYear}
${thisYearCount}${T.trendKpiGames}
${thisYear}
${ICONS.calendar}${isZh ? '年份范围' : 'Year Range'}
${firstYear} ~ ${lastYear}
${lastYear - firstYear + 1} ${T.kpiYearSpan}
`; // v2.9.48: 第二行 4 卡 —— 频率维度 const kpiRow2 = `
${ICONS.trend}${T.trendKpiAvgYear}
${avgPerYear}${T.trendKpiGames}
${isZh ? '年均入库量' : 'per year'}
${ICONS.trend}${T.trendKpiPeakYear}
${peakYear}
${peakYearCount} ${T.trendKpiGames}
${ICONS.clock}${T.trendKpiRecent30}
${recent30}${T.trendKpiGames}
${recent90} ${isZh ? '近90天' : '/ 90d'}
${ICONS.clock}${T.trendKpiActiveDays}
${activeDays}${isZh ? '天' : 'days'}
${isZh ? '日均 ' + (activeDays > 0 ? (totalGames / activeDays).toFixed(1) : '0') + ' 款' : (activeDays > 0 ? (totalGames / activeDays).toFixed(1) : '0') + ' / day'}
`; kpiSection.innerHTML = `
${kpiRow1}
${kpiRow2}
`; // ====== v2.4.3: 入库趋势动态——左侧入库趋势图(不变)+ 右侧入库时间线 ====== // v2.9.22: KPI 紧凑放到左半侧顶部,时间线在右半侧从页面顶部开始撑满整个高度 const splitRow = document.createElement('div'); splitRow.className = 'sglv-trend-split'; // 左:KPI(紧凑 4 列)+ 入库趋势图(年度新增柱 + 家庭成员明细柱 + 累计折线) const leftCol = document.createElement('div'); leftCol.className = 'sglv-trend-split-left'; leftCol.appendChild(kpiSection); leftCol.insertAdjacentHTML('beforeend', `
${ICONS.trend} ${T.trendChartTitle}
`); splitRow.appendChild(leftCol); // 右:v2.9.48 标签切换(入库时间线 / 入库频率分析) const rightCol = document.createElement('div'); rightCol.className = 'sglv-trend-split-right'; rightCol.innerHTML = `
-${T.trendTimelineTotal}
${T.ptHeatmapLoading}
`; splitRow.appendChild(rightCol); wrap.appendChild(splitRow); // v2.9.49: 使用 requestAnimationFrame 替代 setTimeout,对齐浏览器渲染周期减少卡顿 requestAnimationFrame(() => renderTrendChart(yearlyStats, leftCol.querySelector('#sglv-trend-chart-main'), { isEstimated, familyCumStats, personalYearlyStats, familyYearlyStats, hasOwnerData })); renderAcquiredTimeline(rightCol, allGames); // v2.9.48: 右侧标签切换 const freqLoaded = { done: false }; rightCol.querySelectorAll('.sglv-trend-right-tab').forEach(tab => { tab.addEventListener('click', () => { rightCol.querySelectorAll('.sglv-trend-right-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); const target = tab.dataset.tab; rightCol.querySelector('#sglv-trend-right-timeline').style.display = target === 'timeline' ? '' : 'none'; rightCol.querySelector('#sglv-trend-right-frequency').style.display = target === 'frequency' ? '' : 'none'; // 懒加载频率分析 if (target === 'frequency' && !freqLoaded.done) { freqLoaded.done = true; renderFrequencyAnalysis(rightCol.querySelector('#sglv-trend-freq-content'), rightCol.querySelector('#sglv-trend-freq-loading'), allGames); } }); }); if (isEstimated) { const note = document.createElement('div'); note.className = 'sglv-trend-note'; note.innerHTML = `${isZh ? '提示:当前数据缺少精确入库时间,趋势图基于“最近游玩时间”估算。登录 Steam 商店或获取家庭组数据后可获得更精确结果。' : 'Note: Precise acquisition time is unavailable; trend is estimated from last-played time.'}`; wrap.appendChild(note); } parent.appendChild(wrap); } // ====== v2.9.48: 入库频率分析——带缓存的统计计算 + 图表渲染 ====== let _freqStatsCache = null; // { sig, stats, ts } function computeFreqStats(games) { const sig = games.length + '|' + (games[0]?.appid || 0); if (_freqStatsCache && _freqStatsCache.sig === sig) return _freqStatsCache.stats; const monthData = new Array(12).fill(0); // 0-11 月度 const weekdayData = new Array(7).fill(0); // 0-6 周日~周六 const hourData = new Array(24).fill(0); // 0-23 时段 const dateMap = {}; // 'YYYY-MM-DD' → count let total = 0; for (const g of games) { const t = g.acquiredTime || 0; if (t <= 0) continue; const d = new Date(t * 1000); monthData[d.getMonth()]++; weekdayData[d.getDay()]++; hourData[d.getHours()]++; const dk = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); dateMap[dk] = (dateMap[dk] || 0) + 1; total++; } // 最长连续入库天数 const sortedDates = Object.keys(dateMap).sort(); let maxStreak = 0, curStreak = 0; let prevDate = null; for (const ds of sortedDates) { if (prevDate) { const diff = (new Date(ds) - new Date(prevDate)) / 86400000; curStreak = diff === 1 ? curStreak + 1 : 1; } else { curStreak = 1; } if (curStreak > maxStreak) maxStreak = curStreak; prevDate = ds; } // 峰值月份/星期/时段 const monthNames = isZh ? ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'] : ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const weekdayNames = T.trendFreqWeekdayLong.split(','); const peakMonth = monthNames[monthData.indexOf(Math.max(...monthData))] || '-'; const peakWeekday = weekdayNames[weekdayData.indexOf(Math.max(...weekdayData))] || '-'; const peakHour = hourData.indexOf(Math.max(...hourData)); const peakHourLabel = peakHour >= 0 ? String(peakHour).padStart(2, '0') + ':00 ~ ' + String((peakHour + 1) % 24).padStart(2, '0') + ':00' : '-'; const stats = { monthData, weekdayData, hourData, total, maxStreak, peakMonth, peakWeekday, peakHourLabel, peakMonthCount: Math.max(...monthData), peakWeekdayCount: Math.max(...weekdayData), peakHourCount: Math.max(...hourData), monthNames, weekdayNames, uniqueDays: sortedDates.length, }; _freqStatsCache = { sig, stats, ts: Date.now() }; return stats; } function renderFrequencyAnalysis(contentEl, loadingEl, games) { // 异步获取入库时间数据(增量更新,复用缓存) (async () => { try { const familyGames = await fetchTimelineFamilyGames(); const acquiredMap = {}; if (familyGames) { for (const g of familyGames) { if (g.acquiredTime && g.acquiredTime > 0) acquiredMap[g.appid] = g.acquiredTime; } } // 合并 acquiredTime 到 games const enriched = games.map(g => ({ ...g, acquiredTime: acquiredMap[g.appid] || g.acquiredTime || 0, })); const stats = computeFreqStats(enriched); const wdNames = stats.weekdayNames; // 月度柱状图 SVG const monthMax = Math.max(...stats.monthData, 1); const monthBars = stats.monthData.map((v, i) => { const h = v > 0 ? Math.max(2, (v / monthMax) * 100) : 0; const x = i * (100 / 12) + 1; const isPeak = v === stats.peakMonthCount && v > 0; return ``; }).join(''); // 星期柱状图 const wdMax = Math.max(...stats.weekdayData, 1); const wdBars = stats.weekdayData.map((v, i) => { const h = v > 0 ? Math.max(2, (v / wdMax) * 100) : 0; const x = i * (100 / 7) + 1; const isPeak = v === stats.peakWeekdayCount && v > 0; return ``; }).join(''); // 时段柱状图 const hrMax = Math.max(...stats.hourData, 1); const hrBars = stats.hourData.map((v, i) => { const h = v > 0 ? Math.max(2, (v / hrMax) * 100) : 0; const x = i * (100 / 24) + 0.3; const isPeak = v === stats.peakHourCount && v > 0; return ``; }).join(''); contentEl.innerHTML = `
${T.trendFreqPeakMonth}${stats.peakMonth}${stats.peakMonthCount} ${T.trendKpiGames}
${T.trendFreqPeakWeekday}${stats.peakWeekday}${stats.peakWeekdayCount} ${T.trendKpiGames}
${T.trendFreqPeakHour}${stats.peakHourLabel}${stats.peakHourCount} ${T.trendKpiGames}
${T.trendFreqStreak}${stats.maxStreak}${T.trendFreqDays}
${T.trendFreqByMonth}
${monthBars}
${stats.monthNames.map(m => `${m}`).join('')}
${T.trendFreqByWeekday}
${wdBars}
${wdNames.map(w => `${w}`).join('')}
${T.trendFreqByHour}
${hrBars}
${[0,3,6,9,12,15,18,21].map(h => `${String(h).padStart(2,'0')}`).join('')}
`; // 显示内容,隐藏 loading loadingEl.style.display = 'none'; contentEl.style.display = ''; // v2.9.49: 优化 bar hover tooltip——复用单一 DOM 元素,避免每次 hover 创建/销毁 const freqTooltip = document.createElement('div'); freqTooltip.className = 'sglv-freq-tooltip'; freqTooltip.style.cssText = 'position:fixed;background:rgba(15,23,42,0.96);border:1px solid rgba(102,192,244,0.3);border-radius:6px;padding:4px 10px;font-size:11px;color:#fff;pointer-events:none;z-index:999;display:none;transform:translateX(-50%);'; document.body.appendChild(freqTooltip); addDisposer(() => freqTooltip.remove()); contentEl.querySelectorAll('.sglv-freq-chart svg rect[data-v]').forEach(rect => { rect.addEventListener('mouseenter', function() { const v = this.getAttribute('data-v'); const l = this.getAttribute('data-l'); this.setAttribute('opacity', '0.7'); freqTooltip.textContent = `${l}: ${v} ${T.trendKpiGames}`; const r = this.getBoundingClientRect(); freqTooltip.style.left = (r.left + r.width / 2) + 'px'; freqTooltip.style.top = (r.top - 28) + 'px'; freqTooltip.style.display = ''; }); rect.addEventListener('mouseleave', function() { this.setAttribute('opacity', '1'); freqTooltip.style.display = 'none'; }); }); } catch (e) { console.warn('[SGLV] 频率分析加载失败:', e); loadingEl.innerHTML = `
${T.ptHeatmapNoData}
`; } })(); } function renderAcquiredTimeline(container, games) { const scrollEl = container.querySelector('#sglv-tl-scroll'); const countEl = container.querySelector('#sglv-tl-count'); const searchEl = container.querySelector('#sglv-tl-search'); const pagiEl = container.querySelector('#sglv-tl-pagination'); if (!scrollEl || !countEl || !searchEl || !pagiEl) return; const PAGE_SIZE = 20; // 两列 × 10 行 let allItems = null; // null = 加载中 let page = 1; let query = ''; const fmtDateKey = (ts) => { const d = new Date(ts * 1000); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }; const timeOfDay = (ts) => { const d = new Date(ts * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); }; function renderList() { if (!allItems) { scrollEl.innerHTML = createLoadingHtml(T.ptHeatmapLoading, 28); return; } const items = query ? allItems.filter(it => it.name.toLowerCase().includes(query)) : allItems; countEl.textContent = items.length; if (items.length === 0) { scrollEl.innerHTML = `
${T.ptHeatmapNoData}
`; pagiEl.innerHTML = ''; return; } const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE)); page = Math.min(Math.max(1, page), totalPages); const pageItems = items.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); let html = '
'; let curDate = null; for (const it of pageItems) { if (it.dateKey !== curDate) { curDate = it.dateKey; html += `
${curDate}
`; } html += `
${headerImg(it.appid, it.name)}
${it.name}
${timeOfDay(it.ts)}
`; } html += '
'; scrollEl.innerHTML = html; // v2.3.33: 异步加载游戏中文名 scrollEl.querySelectorAll('[data-sglv-appid]').forEach(el => { loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); renderPagination(pagiEl, 'sglv-tl', page, totalPages, items.length, () => { page--; renderList(); }, () => { page++; renderList(); }, (p) => { page = p; renderList(); }); } // v2.9.49: debounce 搜索输入,避免时间线列表每次按键触发完整重渲染 searchEl.addEventListener('input', debounce(() => { query = searchEl.value.trim().toLowerCase(); page = 1; renderList(); }, 200)); renderList(); // 加载占位 (async () => { try { const familyGames = await fetchTimelineFamilyGames(); if (!familyGames || familyGames.length === 0) { allItems = []; renderList(); return; } const acquiredMap = {}; for (const g of familyGames) { if (g.acquiredTime && g.acquiredTime > 0) acquiredMap[g.appid] = g.acquiredTime; } allItems = games .filter(g => acquiredMap[g.appid]) .map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, ts: acquiredMap[g.appid], dateKey: fmtDateKey(acquiredMap[g.appid]) })) .sort((a, b) => b.ts - a.ts); } catch (e) { console.warn('[SGLV] 入库时间线加载失败:', e); allItems = []; } renderList(); })(); } function computeYearlyStats(games) { const map = new Map(); let total = 0; let maxCount = 0; games.forEach(g => { const t = g.acquiredTime || g.lastPlayed || 0; if (t <= 0) return; const year = new Date(t * 1000).getFullYear(); const c = (map.get(year) || 0) + 1; map.set(year, c); total++; if (c > maxCount) maxCount = c; }); if (map.size === 0) return { years: [], counts: [], cumulative: [], total: 0, maxCount: 0, maxCumulative: 0 }; const minYear = Math.min(...map.keys()); const maxYear = Math.max(...map.keys()); const years = []; const counts = []; const cumulative = []; let cum = 0; for (let y = minYear; y <= maxYear; y++) { const c = map.get(y) || 0; cum += c; years.push(y); counts.push(c); cumulative.push(cum); } return { years, counts, cumulative, total, maxCount, maxCumulative: cum }; } // 计算仅个人拥有的游戏年度统计(用于区分"我的"和"家庭组合并") // refYears: 参考年份范围,确保输出与此对齐 function computePersonalYearlyStats(games, steamId, refYears) { const map = new Map(); let total = 0; let maxCount = 0; games.forEach(g => { const owners = g.owners || []; if (owners.length > 0 && !owners.includes(steamId)) return; const t = g.acquiredTime || g.lastPlayed || 0; if (t <= 0) return; const year = new Date(t * 1000).getFullYear(); const c = (map.get(year) || 0) + 1; map.set(year, c); total++; if (c > maxCount) maxCount = c; }); if (map.size === 0) return { years: [], counts: [], cumulative: [], total: 0, maxCount: 0, maxCumulative: 0 }; // 使用 refYears 作为基准年份范围(与汇总统计对齐),缺失年份补 0 const years = refYears ? [...refYears] : Array.from(map.keys()).sort((a, b) => a - b); const counts = []; const cumulative = []; let cum = 0; for (const y of years) { const c = map.get(y) || 0; cum += c; counts.push(c); cumulative.push(cum); } return { years, counts, cumulative, total, maxCount, maxCumulative: cum }; } function computeFamilyMemberCumulativeStats(games, members, years) { if (!years || years.length === 0 || members.length === 0) return null; const yearlyMemberCounts = new Map(); const memberIds = new Set(members.map(m => m.steamid)); years.forEach(y => { yearlyMemberCounts.set(y, {}); }); games.forEach(g => { const t = g.acquiredTime || g.lastPlayed || 0; if (t <= 0) return; const owners = g.owners || []; if (owners.length === 0) return; const year = new Date(t * 1000).getFullYear(); const entry = yearlyMemberCounts.get(year); if (!entry) return; owners.forEach(sid => { if (memberIds.has(sid)) { entry[sid] = (entry[sid] || 0) + 1; } }); }); // 为每个成员构建年度累计数组 const resultMembers = members.map(m => { let cum = 0; const cumulative = years.map(y => { const entry = yearlyMemberCounts.get(y); cum += (entry ? (entry[m.steamid] || 0) : 0); return cum; }); return { steamid: m.steamid, name: m.name, cumulative }; }); const maxCumulative = Math.max(...resultMembers.map(m => m.cumulative[m.cumulative.length - 1]), 1); return { years, members: resultMembers, maxCumulative }; } function computeFamilyYearlyStats(games, members) { const yearsSet = new Set(); const memberMap = new Map(); members.forEach(m => memberMap.set(m.steamid, { name: m.name, counts: new Map() })); games.forEach(g => { const t = g.acquiredTime || g.lastPlayed || 0; if (t <= 0) return; const year = new Date(t * 1000).getFullYear(); const owners = g.owners || []; members.forEach(m => { if (owners.includes(m.steamid)) { yearsSet.add(year); const counts = memberMap.get(m.steamid).counts; counts.set(year, (counts.get(year) || 0) + 1); } }); }); const years = Array.from(yearsSet).sort((a, b) => a - b); if (years.length === 0) return { years: [], members: [] }; const colors = ['#06cfbe', '#54a0ff', '#ff9f43', '#2ed573', '#ff6b6b', '#a29bfe', '#ffcd56']; const result = []; members.forEach((m, i) => { const data = memberMap.get(m.steamid); const counts = []; years.forEach(y => counts.push(data.counts.get(y) || 0)); result.push({ steamid: m.steamid, name: m.name, counts, color: colors[i % colors.length] }); }); return { years, members: result }; } // ==================== v2.9.50: 持久化版本(基于 PCC) ==================== // 给大数据计算函数加缓存包装,通过输入签名 + schema 版本自动判断是否重算 // 输入签名:游戏数 + acquiredTime 累计 + lastPlayed 累计(避免简单 length 不够,例如库存没变但补全了 acquiredTime) // schema 版本号:业务逻辑变更时手动 +1 强制全量重算 const _BIZ_SCHEMA_YEARLY = 1; const _BIZ_SCHEMA_INSIGHT = 1; const _BIZ_SCHEMA_ACHIEVEMENTS = 1; // v2.9.50: 生成大数据计算输入签名(基于 ownedGames + familyInfo 关键字段) function _bizInputSig(games, extra) { let accSum = 0, lpSum = 0, ptSum = 0; const n = games ? games.length : 0; for (let i = 0; i < n; i++) { const g = games[i]; accSum += (g && g.acquiredTime) || 0; lpSum += (g && g.lastPlayed) || 0; ptSum += (g && g.playtime) || 0; } return `${n}|${accSum}|${lpSum}|${ptSum}|${extra || ''}`; } // v2.9.50: 持久化年度统计(整体 + 个人 + 家庭组) // 返回 { yearly, personal, familyCum, familyYearly } 一次性计算所有 function computeYearlyStatsAllCached(games, steamId, otherMembers) { const sig = _bizInputSig(games, `${steamId || ''}|${otherMembers ? otherMembers.length : 0}`); return getBizCached('biz_yearly_stats_all', _BIZ_SCHEMA_YEARLY, sig, () => { const yearly = computeYearlyStats(games); const personal = steamId ? computePersonalYearlyStats(games, steamId, yearly.years) : null; let familyCum = null; let familyYearly = null; if (otherMembers && otherMembers.length > 0 && yearly.years.length > 0) { familyCum = computeFamilyMemberCumulativeStats(games, otherMembers, yearly.years); familyYearly = computeFamilyYearlyStats(games, otherMembers); } return { yearly, personal, familyCum, familyYearly }; }); } // v2.9.50: 持久化我的成就标签页(computeMyAchievements) // 输入:state.ownedGames + familyInfo 关键字段 + DLC 状态(影响 gameCount) // 注意:我的成就结果依赖 unlockCache(解锁时间),但缓存内只存当前快照 // 真正的解锁时间仍由 _getAchUnlockCache/_saveAchUnlockCache 管理(IDB 持久化) // 业务结果(每个成就的 current/unlocked/progress/pts)可缓存 function computeMyAchievementsCached() { const allGames = getStatFilteredGames(); const dlcReady = (SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()) ? '1' : '0'; const sig = _bizInputSig(allGames, `myAch|${dlcReady}`); return getBizCached('biz_my_achievements', _BIZ_SCHEMA_ACHIEVEMENTS, sig, () => { return computeMyAchievements(); }); } // v2.9.50: 持久化洞察数据(computeInsightData) — SGIS 子闭包也可通过 getBizCached 直接调用 // 返回完整洞察数据集,签名含 dlcReady + 关键游戏统计字段 function computeInsightDataCached(allGames) { const dlcReady = (SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()) ? '1' : '0'; const sig = _bizInputSig(allGames, `insight|${dlcReady}`); return getBizCached('biz_insight_data', _BIZ_SCHEMA_INSIGHT, sig, () => { // 直接重算 computeInsightData 的逻辑(因为它在 SGIS 子闭包内) return _computeInsightDataCore(allGames); }); } // v2.9.50: 洞察数据核心计算(主闭包可调用,SGIS 子闭包后续接入缓存时复用) // 逻辑:与 SGIS.computeInsightData 同步,确保缓存命中时数据一致 function _computeInsightDataCore(allGames) { const gameCount = allGames.length; const totalPlaytimeMin = allGames.reduce((s, g) => s + (g.playtime || 0), 0); const totalHours = Math.floor(totalPlaytimeMin / 60); const playedGames = allGames.filter(g => (g.playtime || 0) > 0); const playedCount = playedGames.length; const unplayedCount = Math.max(0, gameCount - playedCount); const avgHours = playedCount > 0 ? Math.round(totalHours / playedCount) : 0; const over100hCount = allGames.filter(g => (g.playtime || 0) >= 6000).length; const over500hCount = allGames.filter(g => (g.playtime || 0) >= 30000).length; const dustRate = gameCount > 0 ? unplayedCount / gameCount : 0; const longGamesCount = allGames.filter(g => (g.playtime || 0) >= 600).length; const completionRate = playedCount > 0 ? Math.round((longGamesCount / playedCount) * 100) : 0; const topGames = allGames.filter(g => (g.playtime || 0) > 0) .sort((a, b) => (b.playtime || 0) - (a.playtime || 0)) .slice(0, 10) .map(g => ({ name: g.name, hours: Math.floor((g.playtime || 0) / 60) })); const recentGames = allGames.filter(g => g.lastPlayed && g.lastPlayed > 0) .sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0)) .slice(0, 5) .map(g => ({ name: g.name, hours: Math.floor((g.playtime || 0) / 60), lastPlayed: g.lastPlayed })); const dustCollectors = allGames.filter(g => !g.playtime || g.playtime === 0) .sort((a, b) => (b.appid || 0) - (a.appid || 0)) .slice(0, 10) .map(g => ({ name: g.name, appid: g.appid })); // 5 维度评分(0-100, 越高越好) const totalScore = (() => { const playRate = gameCount > 0 ? playedCount / gameCount : 0; const depthScore = avgHours >= 50 ? 100 : Math.min(100, avgHours * 2); const breadthScore = gameCount >= 100 ? 100 : Math.min(100, gameCount); const longScore = gameCount > 0 ? Math.min(100, (longGamesCount / gameCount) * 200) : 0; const achievementScore = 0; // 需要额外数据 return Math.round((playRate * 30) + (depthScore * 0.25) + (breadthScore * 0.15) + (longScore * 0.15) + (achievementScore * 0.15)); })(); return { gameCount, totalPlaytimeMin, totalHours, playedCount, unplayedCount, avgHours, over100hCount, over500hCount, dustRate, longGamesCount, completionRate, topGames, recentGames, dustCollectors, totalScore }; } // v2.9.50: 完整版洞察数据(从 SGIS 子闭包抽出,主闭包可走 PCC 持久化) // 包含 5 维度评分 + 玩家画像,逻辑与原 SGIS.computeInsightData 同步 function _computeInsightDataFull() { const allGames = getStatFilteredGames(); const gameCount = allGames.length; const totalPlaytimeMin = allGames.reduce((s, g) => s + (g.playtime || 0), 0); const totalHours = Math.floor(totalPlaytimeMin / 60); const playedGames = allGames.filter(g => (g.playtime || 0) > 0); const playedCount = playedGames.length; const unplayedCount = Math.max(0, gameCount - playedCount); const avgHours = playedCount > 0 ? Math.round(totalHours / playedCount) : 0; const over100hCount = allGames.filter(g => (g.playtime || 0) >= 6000).length; const over500hCount = allGames.filter(g => (g.playtime || 0) >= 30000).length; const dustRate = gameCount > 0 ? unplayedCount / gameCount : 0; const longGamesCount = allGames.filter(g => (g.playtime || 0) >= 600).length; const completionRate = playedCount > 0 ? Math.round((longGamesCount / playedCount) * 100) : 0; const topGames = allGames.filter(g => (g.playtime || 0) > 0) .sort((a, b) => (b.playtime || 0) - (a.playtime || 0)) .slice(0, 10) .map(g => ({ name: g.name, hours: Math.floor((g.playtime || 0) / 60) })); const recentGames = allGames.filter(g => g.lastPlayed && g.lastPlayed > 0) .sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0)) .slice(0, 5) .map(g => ({ name: g.name, hours: Math.floor((g.playtime || 0) / 60), lastPlayed: g.lastPlayed })); const dustCollectors = allGames.filter(g => !g.playtime || g.playtime === 0) .slice(0, 5) .map(g => g.name); // ── 五维度评分 (0-100) ── const breadthScore = Math.min(100, Math.round((gameCount / 300) * 100)); const depthScore = Math.min(100, Math.round((totalHours / 5000) * 100)); const completionScore = completionRate; let diversityScore = 0; if (playedCount > 0 && totalPlaytimeMin > 0) { const shares = playedGames.map(g => (g.playtime || 0) / totalPlaytimeMin); const hhi = shares.reduce((s, x) => s + x * x, 0); diversityScore = Math.round((1 - hhi) * 100 * Math.min(1, playedCount / 10)); diversityScore = Math.max(0, Math.min(100, diversityScore)); } const activityScore = Math.round((1 - dustRate) * 100); const dimensions = [ { label: '收藏广度', score: breadthScore, tag: breadthScore >= 80 ? '收藏大家' : breadthScore >= 60 ? '中量收藏' : breadthScore >= 40 ? '稳步积累' : '初入Steam', gradient: 'linear-gradient(135deg, #3b82f6, #06cfbe)', desc: `${gameCount} 款游戏 · ${over100hCount} 款超 100h · ${over500hCount} 款超 500h` }, { label: '沉浸深度', score: depthScore, tag: depthScore >= 80 ? '硬核玩家' : depthScore >= 60 ? '资深玩家' : depthScore >= 40 ? '入门玩家' : '体验为主', gradient: 'linear-gradient(135deg, #06cfbe, #22d3ee)', desc: `总时长 ${totalHours}h · 平均 ${avgHours}h/款 · 最长 ${topGames[0]?.hours || 0}h (${topGames[0]?.name || '—'})` }, { label: '完成度', score: completionScore, tag: completionScore >= 80 ? '深度游玩' : completionScore >= 60 ? '认真体验' : completionScore >= 40 ? '浅尝辄止' : '走马观花', gradient: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', desc: `${playedCount}/${gameCount} 已启动 · ${longGamesCount} 款深度游玩(≥10h) · 完成率 ${completionRate}%` }, { label: '偏好多样性', score: diversityScore, tag: diversityScore >= 80 ? '涉猎广泛' : diversityScore >= 60 ? '多元尝试' : diversityScore >= 40 ? '有所偏好' : '专一玩家', gradient: 'linear-gradient(135deg, #f43f5e, #fb7185)', desc: `时长分布集中度 ${diversityScore}% 分散 · Top1 占比 ${topGames[0] && totalHours > 0 ? Math.round((topGames[0].hours / totalHours) * 100) : 0}%` }, { label: '活跃度', score: activityScore, tag: activityScore >= 80 ? '活跃高玩' : activityScore >= 60 ? '稳定玩家' : activityScore >= 40 ? '间歇游玩' : '吃灰严重', gradient: 'linear-gradient(135deg, #f59e0b, #fbbf24)', desc: `吃灰率 ${(dustRate * 100).toFixed(1)}% · ${unplayedCount} 款未启动 · ${dustCollectors.length ? '如: ' + dustCollectors.slice(0, 2).join('、') : '无'}` }, ]; // ── 玩家画像 (本地规则推断) ── let personaType = '均衡型玩家'; let personaRarity = '常见'; let personaTagline = '游戏风格均衡, 各维度发展稳定'; let personaTraits = []; const maxDim = dimensions.reduce((a, b) => a.score >= b.score ? a : b); if (breadthScore >= 75 && depthScore < 50) { personaType = '收藏型玩家'; personaRarity = '稀有'; personaTagline = '热衷扩充库藏, 拥有海量游戏但未必全部深入'; personaTraits = ['数字收藏家', '促销猎手', 'FOMO 型消费', '广度优先']; } else if (depthScore >= 75 && breadthScore < 60) { personaType = '硬核型玩家'; personaRarity = '史诗'; personaTagline = '少量游戏投入海量时长, 追求深度体验与精通'; personaTraits = ['深度沉浸', '反复游玩', '成就猎人', '精通导向']; } else if (activityScore < 40 && breadthScore >= 60) { personaType = '吃灰型玩家'; personaRarity = '常见'; personaTagline = '库藏丰富但启动率低, 大量游戏长期未触碰'; personaTraits = ['仓鼠症', '冲动消费', '收藏 > 游玩', '潜力待发掘']; } else if (diversityScore >= 70 && completionScore >= 60) { personaType = '探索型玩家'; personaRarity = '稀有'; personaTagline = '涉猎广泛且认真体验, 各类游戏均有深度游玩'; personaTraits = ['多元品味', '开放尝试', '均衡发展', '好奇心强']; } else if (completionScore >= 75) { personaType = '完成型玩家'; personaRarity = '史诗'; personaTagline = '注重游戏完成度, 倾向深度通关而非广度收集'; personaTraits = ['完美主义', '通关导向', '深度体验', '宁缺毋滥']; } else if (breadthScore >= 60 && depthScore >= 60) { personaType = '全能型玩家'; personaRarity = '传说'; personaTagline = '收藏与深度兼备, 游戏库既广且深'; personaTraits = ['收藏 + 深度', '资深 Steam 用户', '多元 + 专注', '高活跃']; } return { kpi: { gameCount, totalHours, playedCount, unplayedCount, avgHours, over100hCount, over500hCount, dustRate, longGamesCount, completionRate }, topGames, recentGames, dustCollectors, dimensions, persona: { type: personaType, rarity: personaRarity, tagline: personaTagline, traits: personaTraits, dominantDim: maxDim.label }, }; } // v2.9.50: SGIS 子闭包洞察数据走 PCC 持久化(跨 session 复用) // SGIS 子闭包内的 computeInsightData 直接调用此函数 function computeInsightDataCachedSgis() { const allGames = getStatFilteredGames(); const dlcReady = (SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()) ? '1' : '0'; const sig = _bizInputSig(allGames, `sgis_insight|${dlcReady}`); return getBizCached('biz_sgis_insight_data', _BIZ_SCHEMA_INSIGHT, sig, () => _computeInsightDataFull()); } function createTrendTooltip(chartEl) { let tooltip = chartEl.querySelector('.sglv-trend-tooltip'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.className = 'sglv-trend-tooltip'; chartEl.appendChild(tooltip); } return tooltip; } // ==================== v2.9.15: SVG 图表基础库(共享命名空间) ==================== // 设计目标:提供最小可用 SVG 工具集,统一风格(深色背景 + 渐变 + 暖色), // 现有图表(renderTrendChart/Daily/Donut 等)继续工作,新图表/外部模块可基于此扩展。 // 借鉴 steam-friend-manager 1.2.2 大量 SVG 图表代码经验 const SGLVCharts = { // 通用颜色板(与现有 SGLV 视觉一致) PALETTE: { purple: '#a78bfa', blue: '#3b82f6', cyan: '#06b6d4', teal: '#14b8a6', green: '#22c55e', amber: '#f59e0b', red: '#ef4444', pink: '#ec4899', gray: '#94a3b8', text: '#cbd5e1', textMuted: '#64748b', bgGrid: 'rgba(255,255,255,0.06)', bgCard: 'rgba(255,255,255,0.03)' }, // 通用 SVG 容器生成 svg(w, h, attrs = '') { return ``; }, // 网格线(横向) gridLinesY(pL, pR, pT, cH, maxVal, gridCount = 5) { const lines = []; for (let i = 0; i <= gridCount; i++) { const v = Math.round(maxVal / gridCount * i); const y = pT + cH - (cH * i / gridCount); lines.push(``); lines.push(`${v}`); } return lines.join(''); }, // 通用转义 esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }, // 16 进制 → rgba hexToRgba(hex, a) { const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); return `rgba(${r},${g},${b},${a})`; }, // 多档热力色阶(0..1 → 5 档) heatColor(r, hex = '#38bdf8') { if (r <= 0) return 'rgba(255,255,255,0.03)'; if (r < 0.2) return SGLVCharts.hexToRgba(hex, 0.18); if (r < 0.4) return SGLVCharts.hexToRgba(hex, 0.35); if (r < 0.6) return SGLVCharts.hexToRgba(hex, 0.55); if (r < 0.8) return SGLVCharts.hexToRgba(hex, 0.78); return hex; }, // 折线图(单/多序列) lineChart(series, options = {}) { const { width = 600, height = 240, pL = 40, pR = 16, pT = 16, pB = 28, smooth = true, colors = ['#a78bfa', '#38bdf8', '#22c55e', '#f59e0b'] } = options; if (!series || !series.length) return ''; const allX = series[0].data.map((_, i) => i); const allY = series.flatMap(s => s.data.map(v => Number(v) || 0)); const maxV = Math.max(1, ...allY); const cW = width - pL - pR, cH = height - pT - pB; const stepX = cW / Math.max(1, allX.length - 1); let svg = SGLVCharts.svg(width, height) + SGLVCharts.gridLinesY(pL, width - pR, pT, cH, maxV); series.forEach((s, idx) => { const col = s.color || colors[idx % colors.length]; const pts = s.data.map((v, i) => { const x = pL + i * stepX; const y = pT + cH - (Math.max(0, Number(v) || 0) / maxV) * cH; return [x, y]; }); const d = pts.map((p, i) => (i === 0 ? 'M' : (smooth ? 'C' : 'L')) + p.join(' ')).join(' '); svg += ``; // 端点圆 pts.forEach(([x, y]) => { svg += ``; }); }); // X 轴标签 const xLabels = options.xLabels || allX.map(i => String(i)); const labelStep = Math.max(1, Math.floor(xLabels.length / 8)); xLabels.forEach((lbl, i) => { if (i % labelStep !== 0) return; const x = pL + i * stepX; svg += `${SGLVCharts.esc(lbl)}`; }); return svg + ''; }, // 堆叠条形图 stackedBar(categories, series, options = {}) { const { width = 600, height = 240, pL = 40, pR = 16, pT = 20, pB = 40, colors = ['#a78bfa', '#38bdf8', '#22c55e', '#f59e0b', '#ec4899'] } = options; if (!categories.length) return ''; const totals = categories.map((_, i) => series.reduce((s, ser) => s + (Number(ser.data[i]) || 0), 0)); const maxV = Math.max(1, ...totals); const cW = width - pL - pR, cH = height - pT - pB; const gap = cW / categories.length; const barW = Math.min(Math.max(gap * 0.6, 16), 40); let svg = SGLVCharts.svg(width, height) + SGLVCharts.gridLinesY(pL, width - pR, pT, cH, maxV); categories.forEach((cat, i) => { const cx = pL + gap * i + gap / 2; let yAcc = pT + cH; series.forEach((ser, sIdx) => { const v = Number(ser.data[i]) || 0; if (v <= 0) return; const h = (v / maxV) * cH; yAcc -= h; const col = ser.color || colors[sIdx % colors.length]; svg += ``; }); svg += `${SGLVCharts.esc(cat)}`; }); return svg + ''; }, // 圆环/饼图 donutChart(slices, options = {}) { const { size = 160, thickness = 22, centerText = '' } = options; const total = slices.reduce((s, sl) => s + (sl.value || 0), 0); if (total <= 0) return ''; const r = size / 2 - thickness / 2; const cx = size / 2, cy = size / 2; const C = 2 * Math.PI * r; let acc = 0; let svg = SGLVCharts.svg(size, size); // 背景环 svg += ``; slices.forEach(sl => { if (!sl.value) return; const len = (sl.value / total) * C; const offset = -acc; svg += ``; acc += len; }); if (centerText) { svg += `${SGLVCharts.esc(centerText)}`; } return svg + ''; }, // 热力图(行 × 列) heatmap(matrix, options = {}) { const { cellSize = 18, gap = 3, xLabels = [], yLabels = [], color = '#38bdf8' } = options; const rows = matrix.length, cols = matrix[0]?.length || 0; if (!rows || !cols) return ''; const w = (xLabels.length ? 30 : 0) + cols * (cellSize + gap) - gap + 6; const h = (yLabels.length ? 16 : 0) + rows * (cellSize + gap) - gap + 4; let maxV = 0; matrix.forEach(r => r.forEach(v => { if (v > maxV) maxV = v; })); let svg = SGLVCharts.svg(w, h); matrix.forEach((row, ri) => { row.forEach((v, ci) => { const x = (xLabels.length ? 30 : 0) + ci * (cellSize + gap); const y = (yLabels.length ? 16 : 0) + ri * (cellSize + gap); const r = maxV > 0 ? v / maxV : 0; svg += `${SGLVCharts.esc(v)}`; }); }); if (xLabels.length) { const step = Math.max(1, Math.floor(xLabels.length / 12)); xLabels.forEach((lbl, i) => { if (i % step !== 0) return; const x = 30 + i * (cellSize + gap) + cellSize / 2; svg += `${SGLVCharts.esc(lbl)}`; }); } if (yLabels.length) { yLabels.forEach((lbl, ri) => { const y = 16 + ri * (cellSize + gap) + cellSize / 2 + 3; svg += `${SGLVCharts.esc(lbl)}`; }); } return svg + ''; } }; // v2.9.15: 暴露给 SGIS / Collection 库复用 unsafeWindow.SGLVCharts = SGLVCharts; function renderTrendChart(stats, container, options = {}) { if (!stats.years.length) { container.innerHTML = `
${isZh ? '无时间数据' : 'No time data'}
`; return; } const { isEstimated = false, familyCumStats = null, personalYearlyStats = null, familyYearlyStats = null, hasOwnerData = false } = options; // 主柱数据:家庭组开启时显示"我的年度新增",否则显示总年度新增 const hasPersonalBar = hasOwnerData && personalYearlyStats && personalYearlyStats.years.length > 0 && personalYearlyStats.cumulative.length === stats.years.length; const mainCounts = hasPersonalBar ? personalYearlyStats.counts : stats.counts; // SVG 布局参数:合并后留出顶部图例空间,整体更紧凑 const svgW = 800, svgH = 440; const pL = 52, pR = 62, pT = 20, pB = 50; const cW = svgW - pL - pR, cH = svgH - pT - pB; const n = stats.years.length; const gap = cW / n; // 成员颜色(参考 steam-family-game-analysis) const memberColors = ['#06cfbe', '#54a0ff', '#ff9f43', '#2ed573', '#ff6b6b', '#a29bfe']; // 判断是否有家庭成员柱状图数据 const hasMemberBars = familyYearlyStats && familyYearlyStats.years.length > 0 && familyYearlyStats.members.length > 0; // 分组柱状图尺寸:每组最多 1 根主柱(年度总新增) + N 根成员细柱 const memberCount = hasMemberBars ? familyYearlyStats.members.length : 0; const groupCount = hasMemberBars ? (1 + memberCount) : 1; const groupGap = Math.max(2, gap * 0.12); // 组内柱间隙 // 组内总宽度占年份间距的 0.78;主柱稍宽,成员细柱窄 const groupTotalW = Math.min(gap * 0.78, groupCount * 14 + (groupCount - 1) * groupGap); const mainBarW = hasMemberBars ? Math.max(10, groupTotalW * 0.32) : Math.min(gap * 0.55, 38); const memberBarW = hasMemberBars ? Math.max(3, (groupTotalW - mainBarW - groupGap) / memberCount) : 0; const allBarsTotalW = hasMemberBars ? (mainBarW + groupGap + memberBarW * memberCount) : mainBarW; // Y 轴范围:需要考虑柱状最大值(主柱 + 成员最大柱)和折线最大值 // 主柱最大值 let mainMax = Math.max(...mainCounts, 1); // 成员柱最大值(取所有成员各年新增的最大值) let memberMax = 0; if (hasMemberBars) { familyYearlyStats.members.forEach(m => { m.counts.forEach(c => { if (c > memberMax) memberMax = c; }); }); } const maxCount = Math.max(mainMax, memberMax, 1); const myMaxCumulative = stats.maxCumulative || 1; const familyMaxCum = familyCumStats?.maxCumulative || 0; const personalMaxCum = (personalYearlyStats && personalYearlyStats.years.length > 0) ? personalYearlyStats.maxCumulative : 0; const maxCumulative = Math.max(myMaxCumulative, familyMaxCum, personalMaxCum) || 1; const hasPersonalLine = personalYearlyStats && personalYearlyStats.years.length > 0 && personalYearlyStats.cumulative.length === n; const hasFamilyLines = familyCumStats && familyCumStats.members.length > 0 && familyCumStats.members.every(m => m.cumulative.length === n); // 网格 + 左右轴刻度 let grid = ''; const gridCount = 5; for (let i = 0; i <= gridCount; i++) { const v = Math.round(maxCount / gridCount * i); const y = pT + cH - (cH * i / gridCount); grid += ``; grid += `${v}`; const v2 = Math.round(maxCumulative / gridCount * i); grid += `${v2}`; } // 柱状图:每年绘制主柱(我的年度新增 或 总年度新增) + 各家庭成员细柱 let bars = ''; stats.years.forEach((year, i) => { const cx = pL + gap * i + gap / 2; const groupStartX = cx - allBarsTotalW / 2; const mainCount = mainCounts[i] || 0; const mainH = maxCount ? (mainCount / maxCount) * cH : 0; const mainY = pT + cH - mainH; // 收集各成员当年累计值、累计、成员柱数据用于 tooltip const memberData = hasFamilyLines ? familyCumStats.members.map(m => `${m.name}:${m.cumulative[i]}`).join('|') : ''; const personalCumVal = hasPersonalLine ? personalYearlyStats.cumulative[i] : ''; const familyTotalCumVal = stats.cumulative[i]; // 收集各成员当年新增(用于 tooltip) const memberBarPairs = hasMemberBars ? familyYearlyStats.members.map(m => `${m.name}:${(familyYearlyStats.years.includes(year) ? (m.counts[familyYearlyStats.years.indexOf(year)] || 0) : 0)}`).join('|') : ''; // 主柱(年度总新增) bars += ``; // 成员细柱(家庭组开启时) if (hasMemberBars) { const memberStartX = groupStartX + mainBarW + groupGap; familyYearlyStats.members.forEach((m, mi) => { if (!familyYearlyStats.years.includes(year)) return; const yearIdx = familyYearlyStats.years.indexOf(year); const c = m.counts[yearIdx] || 0; const h = maxCount ? (c / maxCount) * cH : 0; const x = memberStartX + mi * memberBarW; const y = pT + cH - h; bars += ``; }); } }); let linesHtml = ''; if (hasPersonalLine) { // === 家庭组开启时:绘制双曲线 === // 1. 个人累计曲线(绿色实线 #22c55e) const personalPoints = stats.years.map((_, i) => { const x = pL + gap * i + gap / 2; const y = pT + cH - (maxCumulative ? (personalYearlyStats.cumulative[i] / maxCumulative) * cH : 0); return `${x},${y}`; }).join(' '); linesHtml += ``; stats.years.forEach((_, i) => { const x = pL + gap * i + gap / 2; const y = pT + cH - (maxCumulative ? (personalYearlyStats.cumulative[i] / maxCumulative) * cH : 0); linesHtml += ``; }); // 2. 家庭组合并累计曲线(蓝紫色实线 #818cf8) const totalPoints = stats.years.map((_, i) => { const x = pL + gap * i + gap / 2; const y = pT + cH - (maxCumulative ? (stats.cumulative[i] / maxCumulative) * cH : 0); return `${x},${y}`; }).join(' '); linesHtml += ``; stats.years.forEach((_, i) => { const x = pL + gap * i + gap / 2; const y = pT + cH - (maxCumulative ? (stats.cumulative[i] / maxCumulative) * cH : 0); linesHtml += ``; }); } else { // === 非家庭组模式:保持原有单曲线逻辑 === // 我的累计曲线(绿色实线) const myPoints = stats.years.map((_, i) => { const x = pL + gap * i + gap / 2; const y = pT + cH - (maxCumulative ? (stats.cumulative[i] / maxCumulative) * cH : 0); return `${x},${y}`; }).join(' '); linesHtml += ``; stats.years.forEach((_, i) => { const x = pL + gap * i + gap / 2; const y = pT + cH - (maxCumulative ? (stats.cumulative[i] / maxCumulative) * cH : 0); linesHtml += ``; }); } // 家庭组成员累计曲线(彩色半透明,参考 steam-family-game-analysis) if (hasFamilyLines) { familyCumStats.members.forEach((m, mi) => { const color = memberColors[mi % memberColors.length]; const pts = m.cumulative.map((val, i) => { const x = pL + gap * i + gap / 2; const y = pT + cH - (maxCumulative ? (val / maxCumulative) * cH : 0); return `${x},${y}`; }).join(' '); linesHtml += ``; m.cumulative.forEach((_, i) => { const x = pL + gap * i + gap / 2; const y = pT + cH - (maxCumulative ? (m.cumulative[i] / maxCumulative) * cH : 0); linesHtml += ``; }); }); } // X 轴标签 let xLabels = ''; stats.years.forEach((year, i) => { const x = pL + gap * i + gap / 2; xLabels += `${year}`; }); const baseLine = ``; const svg = `${grid}${bars}${linesHtml}${xLabels}${baseLine}`; // 图例 let legendItems = `
${isZh ? (hasPersonalBar ? '我的年度新增' : '年度新增') : (hasPersonalBar ? 'My new this year' : 'New this year')}
`; if (hasMemberBars) { familyYearlyStats.members.forEach((m, mi) => { // v2.8.3: 图例项变为可交互——hover 高亮对应成员柱,其余变暗 legendItems += `
${m.name}
`; }); } if (hasPersonalLine) { // 家庭组开启时:显示个人累计 + 家庭组合并累计 legendItems += `
${isZh ? '我的累计' : 'My cumulative'}
`; legendItems += `
${isZh ? '家庭组合并' : 'Family total'}
`; } else { // 非家庭组模式 legendItems += `
${isZh ? '我的累计' : 'My cumulative'}
`; } const legend = `
${legendItems}
`; const summary = `
${isZh ? '总计' : 'Total'}: ${stats.total} ${isZh ? '年份' : 'Years'}: ${stats.years.length} ${isZh ? '最早' : 'First'}: ${stats.years[0]} ${isZh ? '最新' : 'Latest'}: ${stats.years[stats.years.length - 1]}
`; // v2.8.3: 成员对比摘要 — 贡献最多 / 最少 / 平均 let compareSummary = ''; if (hasMemberBars && familyYearlyStats.members.length > 0) { const memberTotals = familyYearlyStats.members.map(m => ({ name: m.name, count: m.counts.reduce((s, c) => s + c, 0) })).sort((a, b) => b.count - a.count); const topM = memberTotals[0] || { name: '-', count: 0 }; const lowM = memberTotals[memberTotals.length - 1] || { name: '-', count: 0 }; const avgCnt = memberTotals.length > 0 ? Math.round(memberTotals.reduce((s, x) => s + x.count, 0) / memberTotals.length) : 0; compareSummary = `
` + `${isZh ? '贡献最多' : 'Top'}: ${topM.name} (${topM.count})` + `${isZh ? '平均' : 'Avg'}: ${avgCnt}` + `${isZh ? '贡献最少' : 'Low'}: ${lowM.name} (${lowM.count})` + `
`; } container.innerHTML = svg + legend + compareSummary + summary; // v2.8.3: 图例交互 — hover 高亮对应成员柱,其余变暗 // v2.9.49: 缓存 memberBars 引用,避免每次 hover 都 querySelectorAll 扫描 DOM if (hasMemberBars) { const memberBars = container.querySelectorAll('.sglv-trend-bar-member'); container.querySelectorAll('.sglv-trend-legend-interactive').forEach(el => { el.addEventListener('mouseenter', function() { const mi = this.dataset.mi; memberBars.forEach(bar => { bar.style.opacity = bar.dataset.member === familyYearlyStats.members[mi].name ? '1' : '.1'; }); }); el.addEventListener('mouseleave', function() { memberBars.forEach(bar => { bar.style.opacity = '0.85'; }); }); }); } // Hover tooltip(主柱触发) const tooltip = createTrendTooltip(container); container.querySelectorAll('.sglv-trend-bar').forEach(bar => { bar.addEventListener('mouseenter', () => { const year = bar.dataset.year; const count = bar.dataset.count; const cum = bar.dataset.cum; const membersStr = bar.dataset.members || ''; const memberBarsStr = bar.dataset.memberBars || ''; const rect = bar.getBoundingClientRect(); const chartRect = container.getBoundingClientRect(); // 成员柱明细(当年新增) let memberBarRows = ''; if (memberBarsStr && hasMemberBars) { memberBarsStr.split('|').forEach(pair => { const [name, val] = pair.split(':'); if (!name) return; const m = familyYearlyStats.members.find(mm => mm.name === name); const color = m ? m.color : '#888'; const num = parseInt(val, 10) || 0; memberBarRows += `
${name}${num}
`; }); } // 成员累计行 let memberRows = ''; if (membersStr && hasFamilyLines) { membersStr.split('|').forEach(pair => { const [name, val] = pair.split(':'); if (!name || !val) return; const idx = familyCumStats.members.findIndex(m => m.name === name); const color = memberColors[idx % memberColors.length]; memberRows += `
${name} (${isZh ? '累计' : 'cum.'})${val}
`; }); } // 构建累计行 let cumRows = ''; if (hasPersonalLine) { const personalCumVal = bar.dataset.personalCum || ''; cumRows += `
${isZh ? '我的累计' : 'My cum.'}${personalCumVal}
`; cumRows += `
${isZh ? '家庭组合并' : 'Family total'}${cum}
`; } else { cumRows += `
${isZh ? '累计' : 'Cumulative'}${cum}
`; } tooltip.innerHTML = `
${year}
${isZh ? (hasPersonalBar ? '我的新增' : '年度新增') : (hasPersonalBar ? 'My new' : 'New')}${count}
${memberBarRows ? `
${isZh ? '各成员新增' : 'Per member'}
${memberBarRows}` : ''} ${cumRows} ${memberRows ? `
${isZh ? '各成员累计' : 'Member cumulative'}
${memberRows}` : ''} `; tooltip.style.opacity = '1'; const left = rect.left - chartRect.left + rect.width / 2 - tooltip.offsetWidth / 2; const top = rect.top - chartRect.top - tooltip.offsetHeight - 8; tooltip.style.left = `${Math.max(0, left)}px`; tooltip.style.top = `${Math.max(0, top)}px`; }); bar.addEventListener('mouseleave', () => { tooltip.style.opacity = '0'; }); }); } // ==================== 设置面板 ==================== // ==================== v2.9.28: 设置集成进游戏库主面板(移除独立浮层) ==================== // 点击 header 的 ⚙ 设置按钮 → 设置视图在 #sglv-body 内覆盖展示(完整嵌入浮窗), // 再次点击 ⚙、点击"返回游戏库"或切换任意标签页即可退出设置。 function openGlobalSettings() { if (!panelEl) return; // 保留原函数名兼容既有调用点:先确保主面板可见,再在面板内展示设置 openPanel(); state.showSettings = true; renderBody(); } function closeGlobalSettings() { if (!state.showSettings) return; state.showSettings = false; renderBody(); } // v2.9.28: 未配置 API Key 引导提示——右下角卡片。 // 形态参考 GM_notification 的右下角弹窗通知(见 GM_Docs/油猴脚本常见API权限.md), // 但 GM_notification 需系统通知授权且点击后无法直接操作页面,故用页面内自绘卡片实现, // 点击"打开设置"可直接唤起面板内设置视图;不自动弹出面板/设置,由用户主动进入。 function showApiKeyGuideToast() { if (document.getElementById('sglv-guide-toast')) return; const el = document.createElement('div'); el.id = 'sglv-guide-toast'; el.className = 'sglv-guide-toast'; el.innerHTML = `
${ICONS.shield} ${T.apiKeyGuideTitle}
${T.apiKeyGuideDesc}
`; document.body.appendChild(el); let _timer = setTimeout(dismiss, 12000); function dismiss() { clearTimeout(_timer); el.style.transition = 'opacity 0.25s, transform 0.25s'; el.style.opacity = '0'; el.style.transform = 'translateX(24px)'; setTimeout(() => { try { el.remove(); } catch (e) {} }, 260); } el.querySelector('.sglv-guide-toast-close').addEventListener('click', dismiss); el.querySelector('.sglv-guide-toast-btn.ghost').addEventListener('click', dismiss); el.querySelector('.sglv-guide-toast-btn.primary').addEventListener('click', () => { dismiss(); openGlobalSettings(); }); } // v2.9.28: 设置视图渲染——卡片式分区布局,完整嵌入游戏库浮窗 #sglv-body function renderSettingsView(body) { const wrap = document.createElement('div'); wrap.className = 'sglv-set-wrap'; wrap.innerHTML = `
${ICONS.settings} ${T.globalSettings}
${ICONS.shield}${T.steamApiSection}
${T.apiKeyHelp}
${ICONS.trend}${T.aiSettingsTitle}
${T.aiApiUrlHelp}
${T.aiModelHelp}
${ICONS.dollar}${T.aiPredictSection}
${T.aiPredictModesHelp}
${PREDICT_MODES.map(m => { const checked = storage.getPredictModes().includes(m.key); return ``; }).join('')}
${ICONS.shield}${isZh ? '显示偏好' : 'Display Preferences'}
${isZh ? '控制游戏库浮窗中是否显示家庭组共享的游戏。' : 'Toggle whether family-shared games are shown in the library panel.'}
`; wrap.querySelector('.sglv-set-back').addEventListener('click', closeGlobalSettings); wrap.querySelector('#sglv-gs-save').addEventListener('click', () => { storage.setApiKey(wrap.querySelector('#sglv-gs-apikey').value.trim()); storage.setSteamId(wrap.querySelector('#sglv-gs-steamid').value.trim()); storage.setAiApiUrl(wrap.querySelector('#sglv-gs-ai-url').value.trim() || 'https://api.deepseek.com/v1/chat/completions'); storage.setAiApiKey(wrap.querySelector('#sglv-gs-ai-key').value.trim()); storage.setAiModel(wrap.querySelector('#sglv-gs-ai-model').value.trim() || 'deepseek-v4-pro'); // 保存预测模式 const checkedModes = Array.from(wrap.querySelectorAll('#sglv-gs-predict-modes input[type="checkbox"]:checked')).map(cb => cb.value); storage.setPredictModes(checkedModes.length ? checkedModes : ['seasonal', 'trend', 'lowest']); // v2.9.46: 保存家庭组共享开关 storage.setShowFamilyShared(wrap.querySelector('#sglv-gs-family-toggle').checked); _activeSteamId = null; // Invalidate cache! clearComputedCache(); const status = wrap.querySelector('#sglv-gs-status'); status.textContent = '✅ ' + T.saved; setTimeout(() => { status.textContent = ''; closeGlobalSettings(); }, 800); }); body.appendChild(wrap); } // ==================== v2.9.4: Barter.vg Bundle 数据库 (进包历史·离线程动态加载) ==================== // 数据源: https://bartervg.com/browse/bundles/json/ // 返回格式: {"18500":{"bundles":5,"bundles_packages":3},"232810":{"bundles":1},...} // Key 是 appID 字符串, value.bundles = 进过的包数量 // v2.9.4: 原始 JSON 达数 MB,主线程 JSON.parse / GM_setValue 大对象会卡顿 UI。 // 改为:① 获取原始文本(不解析)→ ② Web Worker 离线程解析并按库存 appId 裁剪 // → ③ 仅缓存精简映射 {appId: 进包次数}(体积小,GM_setValue 毫秒级)。 // Worker 不可用(CSP 等)时回退主线程分片扫描(每片 ≤12ms,让出主线程)。 const BUNDLE_DB_API_URL = 'https://bartervg.com/browse/bundles/json/'; // v2.9.15: 使用缓存版本号系统自动管理 schema 升级 const BUNDLE_DB_CACHE_KEY = 'sglv_bundle_db_cache'; // 旧版大体积缓存(废弃,加载成功后清空) const BUNDLE_DB_CACHE_KEY_V2 = nsKey('bundle_db_cache_v2'); // { data, appIds, all, timestamp } const BUNDLE_DB_NEG_KEY = nsKey('bundle_db_neg'); // 负缓存 key const BUNDLE_DB_NEG_TTL = 30 * 60 * 1000; // 负缓存 30 分钟 const BUNDLE_DB_CACHE_TTL = 48 * 60 * 60 * 1000; // 48小时 let bundleDbData = null; // { "appId": 进包次数 }(仅覆盖库存内 appId) let bundleDbLoading = false; function getBundleCount(appId) { if (!bundleDbData || !appId) return 0; const entry = bundleDbData[String(appId)]; if (!entry) return 0; return typeof entry === 'number' ? entry : (entry.bundles || 0); } // Web Worker 源码(ES5):解析大 JSON 并按 wanted 裁剪,仅回传精简计数映射 const BUNDLE_PARSE_WORKER_SRC = 'self.onmessage = function(e) {\n' + ' var text = e.data.text;\n' + ' var wanted = (e.data.wanted && e.data.wanted.length) ? new Set(e.data.wanted) : null;\n' + ' try {\n' + ' var json = JSON.parse(text);\n' + ' var keys = Object.keys(json);\n' + ' var out = {};\n' + ' for (var i = 0; i < keys.length; i++) {\n' + ' var k = keys[i];\n' + ' if (wanted && !wanted.has(k)) continue;\n' + ' var entry = json[k];\n' + ' var n = (entry && entry.bundles) ? entry.bundles : 0;\n' + ' if (n > 0) out[k] = n;\n' + ' }\n' + ' self.postMessage({ ok: true, total: keys.length, data: out });\n' + ' } catch (err) {\n' + ' self.postMessage({ ok: false, error: String(err) });\n' + ' }\n' + '};'; function parseBundleJsonInWorker(text, wantedAppIds, timeoutMs = 15000) { return new Promise((resolve, reject) => { let worker = null; try { const blob = new Blob([BUNDLE_PARSE_WORKER_SRC], { type: 'application/javascript' }); worker = new Worker(URL.createObjectURL(blob)); } catch (e) { reject(e); return; } const timer = setTimeout(() => { try { worker.terminate(); } catch (e) {} reject(new Error('worker timeout')); }, timeoutMs); worker.onmessage = (e) => { clearTimeout(timer); try { worker.terminate(); } catch (err) {} const msg = e.data || {}; if (msg.ok) resolve(msg); else reject(new Error(msg.error || 'worker parse failed')); }; worker.onerror = () => { clearTimeout(timer); try { worker.terminate(); } catch (err) {} reject(new Error('worker error')); }; worker.postMessage({ text: text, wanted: wantedAppIds }); }); } // 兜底:主线程分片正则扫描(每片 ≤12ms 后让出主线程,UI 保持响应) function parseBundleJsonChunked(text, wantedAppIds) { return new Promise((resolve, reject) => { const wanted = (wantedAppIds && wantedAppIds.length) ? new Set(wantedAppIds) : null; const re = /"(\d+)":\s*\{\s*"bundles":(\d+)/g; const out = {}; let total = 0; function step() { const sliceStart = performance.now(); let m; while ((m = re.exec(text)) !== null) { total++; if (!wanted || wanted.has(m[1])) { const n = parseInt(m[2], 10); if (n > 0) out[m[1]] = n; } if ((total & 1023) === 0 && performance.now() - sliceStart > 12) { setTimeout(step, 0); return; } } // v2.9.5: 0 匹配时警告 JSON 结构可能已变更 if (total === 0) console.warn('[SGLV] Bundle 分片解析 0 匹配——Barter.vg JSON 结构可能已变更'); resolve({ total: total, data: out }); } try { step(); } catch (e) { reject(e); } }); } async function loadBundleDatabase() { if (bundleDbData || bundleDbLoading) return bundleDbData; bundleDbLoading = true; try { // v2.9.15: 负缓存——30 分钟内失败不重试 if (negCacheGet(BUNDLE_DB_NEG_KEY, BUNDLE_DB_NEG_TTL)) { console.log('[SGLV] Bundle 数据库命中负缓存,跳过重试'); return null; } // 库存 appId 集合:按需裁剪,降低内存占用与缓存写入体积 const wantedAppIds = (state.ownedGames || []).map(g => String(g.appid)); // v2.9.4: v2 精简缓存命中条件——未过期且覆盖当前全部库存 appId const cached = GM_getValue(BUNDLE_DB_CACHE_KEY_V2, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < BUNDLE_DB_CACHE_TTL) && cached.data) { const covered = new Set(cached.appIds || []); const fullyCovered = wantedAppIds.length === 0 ? !!cached.all : wantedAppIds.every(id => covered.has(id)); if (fullyCovered) { bundleDbData = cached.data; console.log(`[SGLV] Bundle 数据库缓存命中: ${Object.keys(bundleDbData).length} 条记录(精简版)`); return bundleDbData; } } console.log('[SGLV] 正在从 Barter.vg 获取 Bundle 数据库(离线程解析)...'); const text = await sglvGmFetchTextRetry(BUNDLE_DB_API_URL, { timeout: 30000, retries: 2 }); let parsed = null; try { parsed = await parseBundleJsonInWorker(text, wantedAppIds); } catch (we) { console.warn('[SGLV] Worker 解析不可用,回退主线程分片解析:', we); parsed = await parseBundleJsonChunked(text, wantedAppIds); } if (!parsed || parsed.total < 7000) { throw new Error('Barter.vg bundles data sanity check failed'); } bundleDbData = parsed.data; GM_setValue(BUNDLE_DB_CACHE_KEY_V2, { data: parsed.data, appIds: wantedAppIds, all: wantedAppIds.length === 0, timestamp: Date.now() }); // 清理旧版大体积缓存,释放存储 try { GM_setValue(BUNDLE_DB_CACHE_KEY, null); } catch (e) {} // 成功后清掉负缓存 negCacheClear(BUNDLE_DB_NEG_KEY); console.log(`[SGLV] Bundle 数据库加载完成: 全库 ${parsed.total} 条,库存命中 ${Object.keys(parsed.data).length} 条(未阻塞 UI 主线程)`); return bundleDbData; } catch(e) { console.warn('[SGLV] Bundle 数据库加载失败:', e); // v2.9.15: 失败入负缓存 negCacheSet(BUNDLE_DB_NEG_KEY, e.message || String(e)); return null; } finally { bundleDbLoading = false; } } // ==================== v2.9.1: Barter.vg DLC 数据库 (识别 DLC 及其父游戏) ==================== // 数据源: https://bartervg.com/browse/dlc/json/ // 返回格式: {"239550":{"base_appID":221380,"base_item_id":125},...} // Key 是 DLC 的 appID, base_appID 是父游戏 appID const DLC_DB_API_URL = 'https://bartervg.com/browse/dlc/json/'; const DLC_DB_CACHE_KEY = 'sglv_dlc_db_cache'; // v1.9.2: 48h → 72h(3 天)—DLC 数据只增不减,延长 TTL 减少网络请求 const DLC_DB_CACHE_TTL = 72 * 60 * 60 * 1000; // 72小时(3天) let dlcDbData = null; // { "dlcAppId": { base_appID: 123, base_item_id: 45 } } let dlcDbPromise = null; // v2.9.5: 存储 in-flight Promise,避免并发调用返回 null let appTypeMap = null; // v2.9.6: 全库存应用的 Steam type 映射(补充 Barter.vg 未覆盖的 DLC) let appTypePromise = null; // v2.9.6: in-flight Promise,避免并发调用 let _dlcsByParentCache = null; // v2.9.46: 父游戏→DLC列表 反向映射缓存 function isDlc(appId) { if (!appId) return false; const id = String(appId); // v2.9.6: 双源检测——Barter.vg DLC 数据库 OR Steam Store API type=1(dlc)/type=6(music) if (dlcDbData && dlcDbData[id]) return true; if (appTypeMap) { const t = appTypeMap[id]; if (t === 'dlc' || t === 'music') return true; } return false; } // v2.9.5: 同步从缓存加载 DLC 数据库(GM_getValue 同步读取) // 在 renderGamesList() 首次渲染前调用,确保 isDlc() 可用 // 避免总游戏数在 DLC 数据库异步加载前错误包含 DLC 数量 // v2.9.6: 同时从缓存加载 appTypeMap(全库存应用类型),补充 Barter.vg 未覆盖的 DLC function loadDlcDatabaseFromCacheSync() { if (!dlcDbData) { try { const cached = GM_getValue(DLC_DB_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < DLC_DB_CACHE_TTL)) { dlcDbData = cached.data; console.log(`[SGLV] DLC 数据库缓存命中 (sync): ${Object.keys(dlcDbData).length} 条记录`); } } catch(e) { console.warn('[SGLV] DLC 数据库缓存读取失败:', e); } } // v2.9.6: 同步加载全库存应用类型缓存 if (!appTypeMap) { try { const cached = GM_getValue(APP_TYPES_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < APP_TYPES_CACHE_TTL)) { appTypeMap = cached.data; console.log(`[SGLV] 全库存应用类型缓存命中 (sync): ${Object.keys(appTypeMap).length} 条`); } } catch(e) { console.warn('[SGLV] 全库存应用类型缓存读取失败:', e); } } } async function loadDlcDatabase() { if (dlcDbData) return dlcDbData; if (dlcDbPromise) return dlcDbPromise; // v2.9.5: 返回 in-flight Promise,避免并发调用返回 null dlcDbPromise = (async () => { try { const cached = GM_getValue(DLC_DB_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < DLC_DB_CACHE_TTL)) { dlcDbData = cached.data; console.log(`[SGLV] DLC 数据库缓存命中: ${Object.keys(dlcDbData).length} 条记录`); return dlcDbData; } console.log('[SGLV] 正在从 Barter.vg 获取 DLC 数据库...'); const json = await sglvGmFetchRetry(DLC_DB_API_URL, { timeout: 30000, retries: 2 }); if (!json || Object.keys(json).length < 7000) { throw new Error('Barter.vg DLC data sanity check failed'); } dlcDbData = json; GM_setValue(DLC_DB_CACHE_KEY, { data: json, timestamp: Date.now() }); console.log(`[SGLV] DLC 数据库加载完成: ${Object.keys(json).length} 条记录`); return dlcDbData; } catch(e) { console.warn('[SGLV] DLC 数据库加载失败:', e); return null; } finally { dlcDbPromise = null; } })(); return dlcDbPromise; } // ==================== v2.9.1: DLC 类型子分类 (音乐/视频/常规DLC等) ==================== // 通过 Steam IStoreBrowseService/GetItems 批量获取 type 字段 // Steam GetItems type: 0=game, 1=dlc, 2=software, 3=video, 4=series, 6=music, 7=tool, 8=video_series let dlcTypeMap = null; // { appId: 'music' | 'dlc' | 'video' | ... } let dlcTypeLoading = false; function getDlcType(appId) { if (!dlcTypeMap || !appId) return null; return dlcTypeMap[String(appId)] || null; } // v2.9.68: 增量式 DLC 类型获取 — 只获取 dlcTypeMap 中不存在的类型,合并而非替换 // 支持 onProgress 回调供渐进式渲染,支持 options 参数扩展 async function enrichDlcTypes(dlcAppIds, options = {}) { if (!dlcAppIds || dlcAppIds.length === 0) return; const { onProgress } = options; // v2.9.68: 如果正在加载,等待完成后再检查增量(避免并发请求) if (dlcTypeLoading) { console.log('[SGLV] enrichDlcTypes — 等待已有加载完成...'); let waitCount = 0; while (dlcTypeLoading && waitCount < 60) { await new Promise(r => setTimeout(r, 500)); waitCount++; } } // v2.9.68: 先尝试从 GM 缓存加载(如果 dlcTypeMap 尚未初始化) // DLC 类型不变,缓存永久有效(移除 72h TTL 限制) if (!dlcTypeMap) { const cached = GM_getValue('sglv_dlc_types_cache', null); if (cached && cached.data) { dlcTypeMap = cached.data; console.log(`[SGLV] DLC 类型 GM 缓存命中: ${Object.keys(dlcTypeMap).length} 条`); } } // v2.9.68: 增量模式 — 只获取 dlcTypeMap 中不存在的 DLC 类型 const needEnrich = dlcAppIds.filter(id => !dlcTypeMap || !dlcTypeMap[String(id)]); if (needEnrich.length === 0) { console.log(`[SGLV] enrichDlcTypes — 全部 ${dlcAppIds.length} 个 DLC 类型已存在,跳过`); return; } dlcTypeLoading = true; try { console.log(`[SGLV] 正在获取 ${needEnrich.length}/${dlcAppIds.length} 个 DLC 的类型信息...`); const CHUNK = 100, CONCURRENCY = 3; const chunks = []; for (let i = 0; i < needEnrich.length; i += CHUNK) chunks.push(needEnrich.slice(i, i + CHUNK)); const typeMap = {}; let qIdx = 0; let doneCount = 0; const worker = async () => { while (qIdx < chunks.length) { const chunk = chunks[qIdx++]; try { const input = { ids: chunk.map(id => ({ appid: id })), context: { language: 'schinese', country_code: 'CN', steam_realm: 1 }, data_request: { include_release: false } }; const url = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input)); const resp = await requestSteamAPI(url); const storeItems = resp?.response?.store_items || []; storeItems.forEach(si => { if (si && si.appid) { let typeStr = 'dlc'; if (typeof si.type === 'number') { typeStr = ({ 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' })[si.type] || 'other'; } else if (si.type) { typeStr = String(si.type).toLowerCase(); } typeMap[String(si.appid)] = typeStr; // 同时补充 DLC 名称 if (si.name) { const nc = sglvGameNameCacheLoad(); nc[String(si.appid)] = { name: si.name, ts: Date.now() }; } } }); } catch (e) { console.warn('[SGLV] DLC type batch failed:', e); } doneCount += chunk.length; if (onProgress) try { onProgress(doneCount, needEnrich.length); } catch (e) {} await new Promise(r => setTimeout(r, 200)); } }; const workers = []; for (let i = 0; i < CONCURRENCY; i++) workers.push(worker()); await Promise.all(workers); // v2.9.68: 合并新类型到 dlcTypeMap(而非替换),实现"只增不减"永久缓存 if (!dlcTypeMap) dlcTypeMap = {}; let newCount = 0; for (const [k, v] of Object.entries(typeMap)) { if (!dlcTypeMap[k]) { dlcTypeMap[k] = v; newCount++; } } GM_setValue('sglv_dlc_types_cache', { data: dlcTypeMap, timestamp: Date.now() }); if (isZh) { try { sglvGameNameCacheSave(); } catch (e) {} } console.log(`[SGLV] DLC 类型获取完成: 新增 ${newCount} 条, 累计 ${Object.keys(dlcTypeMap).length} 条`); } catch(e) { console.warn('[SGLV] DLC 类型获取失败:', e); } finally { dlcTypeLoading = false; } } // ==================== v2.9.6: 全库存应用类型获取(补充 Barter.vg DLC 数据库未覆盖的 DLC) ==================== // 通过 Steam IStoreBrowseService/GetItems 批量获取所有库存应用的 type 字段 // Barter.vg DLC 数据库覆盖不全(部分新 DLC 或冷门 DLC 未收录), // 此函数用 Steam 官方 API 补充识别 type=1(dlc) 和 type=6(music) 的应用 const APP_TYPES_CACHE_KEY = 'sglv_app_types_cache'; // v1.9.2: 48h → 72h(3 天)—库存应用类型变化极慢 const APP_TYPES_CACHE_TTL = 72 * 60 * 60 * 1000; // 72小时(3天) async function enrichOwnedAppTypes(onProgress) { if (appTypeMap || appTypePromise) return appTypePromise; const allItems = (storage.getShowFamilyShared() ? state.ownedGames : state.ownedGames.filter(g => isGameOwnedByMe(g))); const appIds = allItems.map(g => g.appid); if (appIds.length === 0) return null; const report = (pct, text) => { if (typeof onProgress === 'function') onProgress(pct, text); }; appTypePromise = (async () => { try { // v2.9.15: 负缓存——全失败时短期不再重试,保护 Steam API if (negCacheGet(nsKey('app_types_neg'), 30 * 60 * 1000)) { console.log('[SGLV] app types 命中负缓存,跳过重试'); return null; } // 检查缓存 const cached = GM_getValue(APP_TYPES_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < APP_TYPES_CACHE_TTL)) { appTypeMap = cached.data; console.log(`[SGLV] 全库存应用类型缓存命中: ${Object.keys(appTypeMap).length} 条`); report(100, isZh ? 'appdetails 缓存命中' : 'appdetails cached'); return appTypeMap; } report(0, isZh ? `正在补全 ${appIds.length} 款游戏详情…` : `Enriching ${appIds.length} games…`); console.log(`[SGLV] 正在获取 ${appIds.length} 个库存应用的类型信息...`); const CHUNK = 200, CONCURRENCY = 3; const chunks = []; for (let i = 0; i < appIds.length; i += CHUNK) chunks.push(appIds.slice(i, i + CHUNK)); const typeMap = {}; let doneCount = 0; let qIdx = 0; const total = chunks.length; const worker = async () => { while (qIdx < chunks.length) { const chunk = chunks[qIdx++]; try { const input = { ids: chunk.map(id => ({ appid: id })), context: { language: 'schinese', country_code: 'CN', steam_realm: 1 }, data_request: { include_release: false } }; const url = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input)); const resp = await requestSteamAPI(url); const storeItems = resp?.response?.store_items || []; storeItems.forEach(si => { if (si && si.appid) { let typeStr = 'game'; if (typeof si.type === 'number') { typeStr = ({ 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' })[si.type] || 'other'; } else if (si.type) { typeStr = String(si.type).toLowerCase(); } typeMap[String(si.appid)] = typeStr; } }); } catch (e) { console.warn('[SGLV] App type batch failed:', e); } doneCount++; // v2.9.15: 进度回调(throttle 用 setTimeout 简单实现,避免每批次都更新) if (total > 0) report(Math.round(doneCount / total * 100), isZh ? `已补全 ${doneCount}/${total} 批…` : `${doneCount}/${total} batches…`); await new Promise(r => setTimeout(r, 200)); } }; const workers = []; for (let i = 0; i < CONCURRENCY; i++) workers.push(worker()); await Promise.all(workers); appTypeMap = typeMap; GM_setValue(APP_TYPES_CACHE_KEY, { data: typeMap, timestamp: Date.now() }); // v2.9.6: appTypeMap 加载后失效 DLC 数量缓存,使下次统计使用双源检测 _cachedDlcCount = null; _cachedDlcCountKey = ''; // 成功清负缓存 negCacheClear(nsKey('app_types_neg')); report(100, isZh ? '详情补全完成' : 'Enrichment done'); console.log(`[SGLV] 全库存应用类型获取完成: ${Object.keys(typeMap).length} 条`); return appTypeMap; } catch(e) { console.warn('[SGLV] 全库存应用类型获取失败:', e); // v2.9.15: 失败入负缓存 negCacheSet(nsKey('app_types_neg'), e.message || String(e)); return null; } finally { appTypePromise = null; } })(); return appTypePromise; } // ==================== 游戏橱窗标签页 ==================== function renderOwnedTab(parent) { // v2.4.5: 恢复持久化的排序偏好 state.ownedSort = storage.getOwnedSort(); // 顶部统计仪表板 const dashboard = document.createElement('div'); dashboard.className = 'sglv-stats-dashboard'; dashboard.id = 'sglv-owned-dashboard'; parent.appendChild(dashboard); // 工具栏(获取按钮已合并到面板头部刷新按钮) // v2.9.3: 紧凑布局——第1行搜索+排序+视图切换+分页;筛选改为点击上方 KPI 卡片(合并自 2.9.0 思路) const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar'; toolbar.innerHTML = `
`; parent.appendChild(toolbar); // v2.9.3: KPI 卡片点击筛选——点击统计卡片切换筛选状态(合并自 2.9.0 思路) dashboard.addEventListener('click', e => { const card = e.target.closest('[data-filter]'); if (!card) return; const filter = card.dataset.filter; state.ownedFilter = state.ownedFilter === filter ? 'all' : filter; state.page = 1; renderGamesList(); }); // v2.9.49: debounce 搜索输入,避免大库场景每次按键触发完整重渲染 toolbar.querySelector('#sglv-search').addEventListener('input', debounce(e => { state.searchQuery = e.target.value; state.page = 1; renderGamesList(); }, 200)); // v2.4.5: 排序下拉——切换后持久化并重置到第一页;按入库时间排序时后台补齐缺失的入库时间 const sortSelect = toolbar.querySelector('#sglv-sort'); sortSelect.value = state.ownedSort; sortSelect.addEventListener('change', () => { state.ownedSort = sortSelect.value; storage.setOwnedSort(sortSelect.value); state.page = 1; renderGamesList(); if (sortSelect.value.startsWith('acquired')) enrichAcquiredTimesForSort(); }); toolbar.querySelectorAll('[data-view]').forEach(btn => { btn.addEventListener('click', () => { state.viewMode = btn.dataset.view; toolbar.querySelectorAll('[data-view]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderGamesList(); }); }); // 游戏列表容器 const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-games-content'; parent.appendChild(content); // v2.9.5: 在首次渲染前同步从缓存加载 DLC 数据库(GM_getValue 同步读取) // 确保 dlcDbData 已从缓存载入,使首次 renderGamesList() 的 isDlc() 即可正确工作 // 避免总游戏数在 DLC 数据库加载前错误包含 DLC 数量 loadDlcDatabaseFromCacheSync(); renderGamesList(); // v2.9.4: 异步加载 Bundle 数据库(Worker 离线程解析,不阻塞 UI)和卡牌数据库,加载完成后刷新仪表板 loadBundleDatabase().then(() => { if (document.getElementById('sglv-owned-dashboard')) { renderGamesList(); } }).catch(e => console.warn('[SGLV] Bundle DB load failed:', e)); if (SGLV_API.loadCardDatabase) { SGLV_API.loadCardDatabase().then(() => { if (document.getElementById('sglv-owned-dashboard')) { renderGamesList(); } }).catch(e => console.warn('[SGLV] Card DB load failed:', e)); } // v2.9.1: DLC 数据库已在前方调用 loadDlcDatabase() 启动加载; // v2.9.6: DLC 数据库加载后,启动全库存应用类型获取(补充 Barter.vg 未覆盖的 DLC) loadDlcDatabase().then(() => { if (document.getElementById('sglv-owned-dashboard')) { renderGamesList(); } // v2.9.6: 用 Steam 官方 API 批量获取所有库存应用的 type,补充识别 Barter.vg 未收录的 DLC enrichOwnedAppTypes().then(() => { if (document.getElementById('sglv-owned-dashboard')) { renderGamesList(); } // 全库存 type 获取完成后,对已识别的 DLC 获取子分类(音乐/视频/常规DLC等) const allItems = (storage.getShowFamilyShared() ? state.ownedGames : state.ownedGames.filter(g => isGameOwnedByMe(g))); const dlcIds = allItems.filter(g => isDlc(g.appid)).map(g => g.appid); if (dlcIds.length > 0) { enrichDlcTypes(dlcIds).then(() => { if (document.getElementById('sglv-owned-dashboard')) renderGamesList(); }); } }).catch(e => console.warn('[SGLV] App types enrichment failed:', e)); }).catch(e => console.warn('[SGLV] DLC DB load failed:', e)); } function renderGamesList() { const content = document.getElementById('sglv-games-content'); const pagination = document.getElementById('sglv-pagination'); const dashboard = document.getElementById('sglv-owned-dashboard'); if (!content) return; let games = [...state.ownedGames]; const showFamilyShared = storage.getShowFamilyShared(); if (!showFamilyShared) { games = games.filter(g => isGameOwnedByMe(g)); } // v2.9.1: 所有筛选排除 DLC(dlconly 除外) if (state.ownedFilter !== 'dlconly') { games = games.filter(g => !isDlc(g.appid)); } // 分类筛选 if (state.ownedFilter === 'played') { games = games.filter(g => (g.playtime || 0) > 0); } else if (state.ownedFilter === 'unplayed') { games = games.filter(g => (g.playtime || 0) === 0); } else if (state.ownedFilter === 'shared') { games = games.filter(g => g.owners && g.owners.length > 0 && !isGameOwnedByMe(g)); } else if (state.ownedFilter === 'owned') { games = games.filter(g => isGameOwnedByMe(g)); } else if (state.ownedFilter === 'bundled') { games = games.filter(g => getBundleCount(g.appid) > 0); } else if (state.ownedFilter === 'cards') { games = games.filter(g => SGLV_API.getCardDbMaxLevel && SGLV_API.getCardDbMaxLevel(g.appid) > 0); } else if (state.ownedFilter === 'dlconly') { games = games.filter(g => isDlc(g.appid)); } // 搜索过滤 if (state.searchQuery) { const q = state.searchQuery.toLowerCase(); games = games.filter(g => g.name.toLowerCase().includes(q)); } // v2.4.5: 排序——默认 AppID 升序,修复原列表顺序不明的问题;次级键 AppID 保证顺序稳定确定 // v2.9.49: 缓存 nameCollator 到模块级,避免每次 renderGamesList 都重新构造 const sortKey = state.ownedSort || 'appidAsc'; const acqTime = g => g.acquiredTime || 0; games.sort((a, b) => { let r = 0; switch (sortKey) { case 'appidDesc': r = b.appid - a.appid; break; case 'nameAsc': r = _nameCollator.compare(a.name || '', b.name || ''); break; case 'acquiredDesc': r = acqTime(b) - acqTime(a); break; case 'acquiredAsc': { // 无入库时间的条目始终沉底,不随方向浮动 const x = acqTime(a), y = acqTime(b); r = (!x && !y) ? 0 : !x ? 1 : !y ? -1 : x - y; break; } case 'playtimeDesc': r = (b.playtime || 0) - (a.playtime || 0); break; case 'lastPlayedDesc': r = (b.lastPlayed || 0) - (a.lastPlayed || 0); break; case 'bundleDesc': r = getBundleCount(b.appid) - getBundleCount(a.appid); break; default: r = a.appid - b.appid; // appidAsc } return r || (a.appid - b.appid); }); // 更新统计仪表板(依据“是否显示家庭共享”当前设置) if (dashboard) { // v2.9.1: 游戏与 DLC 分离统计 const allItems = showFamilyShared ? state.ownedGames : state.ownedGames.filter(g => isGameOwnedByMe(g)); const realGames = allItems.filter(g => !isDlc(g.appid)); const dlcGames = allItems.filter(g => isDlc(g.appid)); const totalGames = realGames.length; // v2.9.22: 修复库存标签页与统计仪表总时长不一致—— // 历史实现仅累加 realGames(不含 DLC),但统计仪表 playtime 标签页累加全量(含 DLC), // 导致两个 KPI 数字差异(如 4021.5h vs 4210.3h)。 // 统一取 max(含DLC, 不含DLC),确保两处显示一致,且以较大值为准。 const realGamesMinutes = realGames.reduce((s, g) => s + (g.playtime || 0), 0); const allItemsMinutes = allItems.reduce((s, g) => s + (g.playtime || 0), 0); const totalMinutes = Math.max(realGamesMinutes, allItemsMinutes); const playedCount = realGames.filter(g => (g.playtime || 0) > 0).length; const unplayedCount = totalGames - playedCount; const sharedCount = state.ownedGames.filter(g => g.owners && g.owners.length > 0 && !isGameOwnedByMe(g) && !isDlc(g.appid)).length; const avgHoursPerGame = totalGames > 0 ? (totalMinutes / 60 / totalGames).toFixed(1) : '0'; const playedPct = totalGames > 0 ? Math.round(playedCount / totalGames * 100) : 0; const unplayedPct = totalGames > 0 ? Math.round(unplayedCount / totalGames * 100) : 0; // v2.9.0: 进包/卡牌游戏统计(仅游戏,不含DLC) const bundledCount = realGames.filter(g => getBundleCount(g.appid) > 0).length; const cardCount = realGames.filter(g => SGLV_API.getCardDbMaxLevel && SGLV_API.getCardDbMaxLevel(g.appid) > 0).length; // v2.9.1: DLC 独立统计 const dlcTotal = dlcGames.length; const dlcBundled = dlcGames.filter(g => getBundleCount(g.appid) > 0).length; const dlcMusic = dlcGames.filter(g => getDlcType(g.appid) === 'music').length; const dlcSubText = dlcTotal > 0 ? `${T.kpiDlcMusic} ${dlcMusic} · ${T.kpiDlcBundled} ${dlcBundled}` : (isZh ? '加载中...' : 'Loading...'); dashboard.innerHTML = `
${ICONS.library}${T.statsTotalGames}
${totalGames.toLocaleString()}
${T.kpiAvgHours} ${avgHoursPerGame}${T.kpiPerGame}
${ICONS.trend}${T.statsPlayed}
${playedCount.toLocaleString()}
${T.kpiRate} ${playedPct}%
${ICONS.clock}${T.statsUnplayed}
${unplayedCount.toLocaleString()}
${T.kpiRate} ${unplayedPct}%
${ICONS.share}${T.statsShared}
${sharedCount.toLocaleString()}
${T.kpiFromFamily}
${ICONS.package}${T.statsBundled}
${bundledCount.toLocaleString()}
${T.kpiInBundles}
${ICONS.card}${T.statsCards}
${cardCount.toLocaleString()}
${T.kpiHasCards}
${ICONS.package}${T.statsDlcTotal}
${dlcTotal.toLocaleString()}
${dlcSubText}
`; } // 分页 // v2.4.0: 复用 paginate 纯函数统一分页计算 // v2.9.31: 封面视图每页 24 项(横版卡片占位更大),卡片/列表保持 48 const _effectivePageSize = state.viewMode === 'cover' ? 24 : state.pageSize; const { pageItems: pageGames, totalPages, page: _clampedPage } = paginate(games, state.page, _effectivePageSize); state.page = _clampedPage; if (games.length === 0) { content.innerHTML = `
${state.ownedGames.length === 0 ? T.noData : (isZh ? '无匹配游戏' : 'No matching games')}
`; if (pagination) pagination.innerHTML = ''; return; } // v2.9.31: 横版封面视图——3 列网格,胶囊横幅 + 徽章浮层 + 时长角标 if (state.viewMode === 'cover') { const grid = document.createElement('div'); grid.className = 'sglv-cover-grid'; pageGames.forEach(g => { const card = document.createElement('div'); card.className = 'sglv-cover-card'; const isOwned = isGameOwnedByMe(g); const ownerNames = getGameOwnerNames(g); const hours = Math.round(g.playtime / 60); card.innerHTML = `
${coverImgTag(g.appid, g.name, 'capsule', 'sglv-cover-img')}
${!isOwned ? `${isZh ? '共享' : 'Shared'}` : ''} ${getBundleCount(g.appid) > 0 ? `${ICONS.package}${getBundleCount(g.appid)}` : ''}
${hours > 0 ? `
${ICONS.clock}${hours}h
` : ''}
${g.name}
AppID: ${g.appid}
`; card.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${g.appid}`, '_blank')); grid.appendChild(card); }); content.innerHTML = ''; content.appendChild(grid); } else if (state.viewMode === 'card') { const grid = document.createElement('div'); grid.className = 'sglv-card-grid'; pageGames.forEach(g => { const card = document.createElement('div'); card.className = 'sglv-game-card'; const isOwned = isGameOwnedByMe(g); const ownerNames = getGameOwnerNames(g); card.innerHTML = `
${posterImg(g.appid, g.name)}
${g.name}
AppID: ${g.appid}
${g.playtime > 0 ? `
${Math.round(g.playtime / 60)}h
` : ''} ${!isOwned ? `
共享
` : ''} ${getBundleCount(g.appid) > 0 ? `${getBundleCount(g.appid)}` : ''}
`; card.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${g.appid}`, '_blank')); grid.appendChild(card); }); content.innerHTML = ''; content.appendChild(grid); } else { const list = document.createElement('div'); list.className = 'sglv-list-view'; pageGames.forEach(g => { const item = document.createElement('div'); item.className = 'sglv-list-item'; const hours = Math.round(g.playtime / 60); const isOwned = isGameOwnedByMe(g); const ownerNames = getGameOwnerNames(g); item.innerHTML = ` ${headerImg(g.appid, g.name).replace('sglv-card-img', 'sglv-list-icon')}
${g.name}
AppID: ${g.appid} ${!isOwned ? `👥 共享自: ${ownerNames}` : ''}
${hours > 0 ? `${hours}h` : ''} ${!isOwned ? `共享` : ''} ${getBundleCount(g.appid) > 0 ? `${getBundleCount(g.appid)}` : ''} `; item.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${g.appid}`, '_blank')); list.appendChild(item); }); content.innerHTML = ''; content.appendChild(list); } // 分页导航 renderPagination(pagination, 'sglv', state.page, totalPages, games.length, () => { state.page--; renderGamesList(); }, () => { state.page++; renderGamesList(); }, (p) => { state.page = p; renderGamesList(); }); // v2.3.33:异步加载游戏中文名(参考 steam-friend-manager loadGameZhName) // v2.8.2:列表视图改用 loadGameNameAlias 追加灰色别名(中英文互补),卡片视图保持原替换逻辑 content.querySelectorAll('[data-sglv-appid]').forEach(el => { if (el.classList.contains('sglv-list-name')) { loadGameNameAlias(el, el.getAttribute('data-sglv-appid'), el.textContent); } else { loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); } }); } // v2.4.5: 按入库时间排序时,若当前库数据缺少 acquiredTime(如缓存/抓取来源), // 后台拉取家庭组数据补齐后重渲染,避免排序看似无效 let _acquiredEnrichPending = false; function enrichAcquiredTimesForSort() { if (_acquiredEnrichPending) return; if (!state.ownedGames.length || state.ownedGames.some(g => g.acquiredTime > 0)) return; _acquiredEnrichPending = true; fetchTimelineFamilyGames().then(fg => { if (fg && fg.length) { const map = {}; fg.forEach(g => { if (g.acquiredTime > 0) map[g.appid] = g.acquiredTime; }); let changed = 0; state.ownedGames.forEach(g => { if (!g.acquiredTime && map[g.appid]) { g.acquiredTime = map[g.appid]; changed++; } }); // 仅在仍按入库时间排序且列表仍展示时重渲染 if (changed > 0 && (state.ownedSort || '').startsWith('acquired') && document.getElementById('sglv-games-content')) { renderGamesList(); } } }).catch(e => console.warn('[SGLV] 入库时间补齐失败:', e)) .finally(() => { _acquiredEnrichPending = false; }); } // ==================== 游玩时长标签页 ==================== // v2.9.24: calcPlaytimeDist 支持 mode 参数——'count'(游戏数,默认) 或 'hours'(总时长) function calcPlaytimeDist(games, mode = 'count') { const cacheK = cacheKey('dist', mode, games.length, games.reduce((s, g) => s + (g.playtime || 0), 0)); return getCached(cacheK, () => { const ranges = [ { label: T.ptRange1, min: 0.01, max: 1, color: '#94a3b8' }, { label: T.ptRange2, min: 1, max: 10, color: '#60a5fa' }, { label: T.ptRange3, min: 10, max: 50, color: '#34d399' }, { label: T.ptRange4, min: 50, max: 100, color: '#fbbf24' }, { label: T.ptRange5, min: 100, max: 500, color: '#f97316' }, { label: T.ptRange6, min: 500, max: Infinity, color: '#ef4444' } ]; if (mode === 'hours') { // 时长维度:统计每个区间的总时长(小时),0 时长不计入 return ranges.map(r => ({ label: r.label, count: Math.round(games.filter(g => { const h = (g.playtime || 0) / 60; return h >= r.min && h < r.max; }).reduce((s, g) => s + (g.playtime || 0) / 60, 0)), color: r.color })); } // 游戏数维度(默认) const zeroCount = games.filter(g => (g.playtime || 0) === 0).length; return [ { label: T.ptRange0, count: zeroCount, color: '#64748b' }, ...ranges.map(r => ({ label: r.label, count: games.filter(g => { const h = (g.playtime || 0) / 60; return h >= r.min && h < r.max; }).length, color: r.color })) ]; }); } function getPeriodKey(date, mode) { const y = date.getFullYear(); const m = date.getMonth(); if (mode === 'year') return `${y}`; if (mode === 'quarter') return `${y}-Q${Math.floor(m / 3) + 1}`; return `${y}-${String(m + 1).padStart(2, '0')}`; } function renderPlaytimeTrend(games, mode) { const cacheK = cacheKey('trend', mode, games.length, games.reduce((s, g) => s + (g.lastPlayed || 0), 0)); return getCached(cacheK, () => { const played = games.filter(g => g.lastPlayed > 0); if (!played.length) return `
${isZh ? '暂无趋势数据' : 'No trend data'}
`; const buckets = {}; played.forEach(g => { const d = new Date(g.lastPlayed * 1000); const key = getPeriodKey(d, mode); buckets[key] = (buckets[key] || 0) + ((g.playtime || 0) / 60); }); const dates = played.map(g => new Date(g.lastPlayed * 1000)); const minDate = new Date(Math.min(...dates)); const maxDate = new Date(Math.max(...dates)); const periods = []; let cursor = new Date(minDate); while (cursor <= maxDate) { periods.push(getPeriodKey(cursor, mode)); if (mode === 'year') cursor.setFullYear(cursor.getFullYear() + 1); else if (mode === 'quarter') cursor.setMonth(cursor.getMonth() + 3); else cursor.setMonth(cursor.getMonth() + 1); } const MAX_POINTS = mode === 'month' ? 12 : mode === 'quarter' ? 12 : 10; const displayPeriods = periods.slice(-MAX_POINTS); const displayValues = displayPeriods.map(p => buckets[p] || 0); if (displayValues.every(v => v === 0)) return `
${isZh ? '暂无趋势数据' : 'No trend data'}
`; const W = 400, H = 200, PAD = 30; const maxV = Math.max(...displayValues) || 1; const n = displayValues.length; const stepX = n > 1 ? (W - PAD * 2) / (n - 1) : 0; const pts = displayValues.map((v, i) => { const x = n > 1 ? PAD + i * stepX : W / 2; const y = H - PAD - (v / maxV) * (H - PAD * 2); return [x, y]; }); const areaPath = `M ${pts[0][0]} ${H - PAD} ` + pts.map(p => `L ${p[0]} ${p[1]}`).join(' ') + ` L ${pts[pts.length - 1][0]} ${H - PAD} Z`; const linePath = `M ${pts.map(p => `${p[0]} ${p[1]}`).join(' L ')}`; const labelStep = Math.max(1, Math.floor(n / 6)); const labels = displayPeriods.map((p, i) => { if (i % labelStep !== 0 && i !== n - 1) return ''; const short = mode === 'year' ? p : p.replace(/^\d{4}-/, ''); return `${short}`; }).join(''); const yLabels = [0, 0.25, 0.5, 0.75, 1].map(r => { const v = maxV * r; const y = H - PAD - r * (H - PAD * 2); return `${v.toFixed(0)}h`; }).join(''); // v2.9.23: 均值参考线(dashed)+ 峰值标注,参考设计图 const avgV = displayValues.reduce((s, v) => s + v, 0) / n; const avgY = H - PAD - (avgV / maxV) * (H - PAD * 2); const avgLine = `${isZh ? '均' : 'avg'} ${avgV.toFixed(0)}h`; // 峰值标注(最高点的数值标签) const maxIdx = displayValues.indexOf(maxV); const peakLabel = `${isZh ? '峰' : 'peak'} ${maxV.toFixed(0)}h`; return ` ${yLabels} ${labels} ${avgLine} ${pts.map((p, i) => `${displayPeriods[i]}: ${displayValues[i].toFixed(1)}h`).join('')} ${peakLabel} `; }); } // v2.9.25: renderPlaytimeDonut 数值千分位格式化 + 中心文字自适应字号 function renderPlaytimeDonut(distData, total, unit = '') { if (total === 0) return `
${T.ptNoData}
`; const sz = 240, sw = 44, r = (sz - sw) / 2; const cx = sz / 2, cy = sz / 2; const C = 2 * Math.PI * r; // v2.9.25: 数值格式化——千分位分隔,避免大数字溢出甜甜圈中心 const fmt = (n) => n.toLocaleString(); let off = 0; let segs = ''; for (const d of distData) { if (d.count === 0) continue; const dash = (d.count / total) * C; segs += `${d.label}: ${fmt(d.count)}${unit} (${(d.count/total*100).toFixed(1)}%)`; off += dash; } const legendHtml = distData.filter(d => d.count > 0).map(d => `
${d.label}${fmt(d.count)}${unit} (${(d.count/total*100).toFixed(1)}%)
` ).join(''); // v2.9.25: 中心文字自适应字号——根据格式化后字符串长度动态缩放,避免溢出 const totalStr = fmt(total) + unit; let centerFontSize; if (totalStr.length <= 5) centerFontSize = 28; else if (totalStr.length <= 7) centerFontSize = 24; else if (totalStr.length <= 9) centerFontSize = 20; else centerFontSize = 16; return `
${segs} ${totalStr} ${isZh ? '总计' : 'Total'}
${legendHtml}
`; } // v2.3.30: 入库热力图——参考 steam-family-game-analysis v1.41 的 buildHeatmapBlock 设计 // 输入: games 数组(需含 acquiredTime 秒级时间戳);输出 GitHub 式贡献热力图 SVG // v2.3.31: 新增缓存层——签名一致且 10 分钟内直接返回缓存的 HTML,跳过 SVG 重新计算 // v2.4.2: 按年分割(参考 v1.58)——新增年份选择器,默认展示最新年份; // "全部"视图保留跨年连续网格,并增加年份分隔线与年份标签 // v2.9.22: 入库热力图骨架占位(GitHub 风格年份框架 + 灰格,无数据时也不会出现大片空白) // 设计:显示当前年份的月份标签 + 7 行 × 53 周的灰色空格,结构与最终渲染保持一致, // 加载完成时直接 innerHTML 替换为真实数据,无需 reflow。 // v2.9.22: 当年(currentYear)数据不够时(如 2026/8 才有少量数据), // 早期月份(1-7月)位置用占位灰格填满 53×7 网格——GitHub 风格, // 让"未到的时间"也以视觉可见的灰色块显示,底部不会出现"空白错觉"。 // 骨架色 #21262d(GitHub theme-1)比真实 0 级 #161b22 略亮,避免与背景融合。 function buildAcquiredHeatmapSkeleton() { const cellSize = 12, cellGap = 3, cellStep = cellSize + cellGap; const labelW = 26, monthH = 16; const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; const dayNames = ['', '一', '', '三', '', '五', '']; const totalWeeks = 53; // 单年最多 53 周 const baseColor = '#21262d'; // 骨架占位色(GitHub theme-1),比 #161b22 略亮以区分背景 // v2.9.25: topH 去掉 yearH,与实际单年渲染一致(showYearRow=false),消除加载完成时 14px 垂直跳动 const topH = monthH; const svgW = labelW + totalWeeks * cellStep + 8; const svgH = topH + 7 * cellStep + 8; // 月份标签:每 4 周一个(避免重叠) const monthParts = monthNames.map((n, i) => { const w = Math.floor(i * totalWeeks / 12); return `${n}`; }).join(''); // 星期标签 const dayParts = dayNames.map((n, i) => n ? `${n}` : '').join(''); // 灰格:7 行 × totalWeeks 列 const cellParts = []; for (let w = 0; w < totalWeeks; w++) { for (let d = 0; d < 7; d++) { const x = labelW + w * cellStep, y = topH + d * cellStep; cellParts.push(``); } } // 顶部骨架图例(与真实渲染图例位置一致) const legendSwatches = ['#161b22', '#0e4429', '#006832', '#26a641', '#39d353'] .map(c => ``).join(''); const skeleton = `${monthParts}${dayParts}${cellParts.join('')}
${T.ptHeatmapLess}${legendSwatches}${T.ptHeatmapMore} ${T.ptHeatmapTotal}: ${T.ptHeatmapPeak}: ${T.ptHeatmapAvg}: ${isZh ? '加载中…' : 'Loading…'}
`; return `
${skeleton}
`; } // v2.9.26: 近6月入库增量骨架——尺寸与结构与实际 renderMonthlyAcquireChart 完全一致,消除加载跳动 function buildMonthlyAcquireSkeleton() { const now = new Date(); const months = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); months.push({ y: d.getFullYear(), m: d.getMonth(), key: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(-2)}` }); } // v2.9.26: 与 renderMonthlyAcquireChart 完全一致的尺寸(加高 SVG 减少留白) const W = 280, H = 200, lw = 32, topPad = 20, botPad = 24; const plotW = W - lw - 2, plotH = H - topPad - botPad; const step = plotW / 6, barW = Math.min(26, step * 0.6); let parts = ''; // 网格线 const gridVals = [0, 1]; for (const v of gridVals) { const y = topPad + plotH - (v / 1) * plotH; parts += ``; parts += `${v}`; } months.forEach((mo, i) => { const cx = lw + step * i + step / 2; const bx = cx - barW / 2, by = topPad + plotH - 2; parts += ``; const lbl = String(mo.y).slice(2) + '.' + String(mo.m + 1).padStart(2, '0'); parts += `${lbl}`; }); const svg = `${parts}`; // v2.9.26: 结构与实际渲染一致——inner + title + chart + summary const summaryHtml = `
${isZh ? '总计' : 'total'} ${isZh ? '加载中…' : 'Loading…'}
`; return `
${T.ptHeatmapMini6m}
${svg}
${summaryHtml}
`; } function renderAcquiredHeatmap(heatGames) { const valid = heatGames.filter(g => g.acquiredTime && g.acquiredTime > 0); if (valid.length === 0) return `
${T.ptHeatmapNoData}
`; // v2.3.31: 构建缓存签名——游戏数量 + 首尾 appid + 首尾入库时间,足够区分不同数据集 const sig = valid.length + '_' + valid[0].appid + '_' + valid[valid.length - 1].appid + '_' + valid[0].acquiredTime + '_' + valid[valid.length - 1].acquiredTime; if (_heatmapRenderCache && _heatmapRenderCache.signature === sig && Date.now() - _heatmapRenderCache.ts < 10 * 60 * 1000) { return _heatmapRenderCache.html; } // 单次遍历构建日入库计数:全局 dayMap + 按年 dayMap,同时收集有效时间戳 const dayMap = {}; const yearDayMaps = {}; const tsList = []; for (const g of valid) { const t = g.acquiredTime; const d = new Date(t * 1000); const y = d.getFullYear(); const key = y + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); dayMap[key] = (dayMap[key] || 0) + 1; const ym = yearDayMaps[y] || (yearDayMaps[y] = {}); ym[key] = (ym[key] || 0) + 1; tsList.push(t); } tsList.sort((a, b) => a - b); const firstTs = tsList[0], lastTs = tsList[tsList.length - 1]; const years = Object.keys(yearDayMaps).map(Number).sort((a, b) => a - b); const cellSize = 12, cellGap = 3, cellStep = cellSize + cellGap; const labelW = 26, yearH = 14, monthH = 16; const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; const dayNames = ['', '一', '', '三', '', '五', '']; // GitHub 风格绿色 5 级色阶(暗→亮) const colors = ['#161b22', '#0e4429', '#006832', '#26a641', '#39d353']; // v2.9.22: 未来月份占位色(today 之后)—— 比 0 级 #161b22 略亮,让"未到时间"格子清晰可见且不与 0 级混淆 const futureColor = '#30363d'; function heatColor(count) { if (count === 0) return colors[0]; if (count <= 1) return colors[1]; if (count <= 2) return colors[2]; if (count <= 4) return colors[3]; return colors[4]; } const fmtD = (d) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); // 构建单个视图(某一年 / 全部)的完整 HTML:SVG + 图例 + 统计 // showYearRow: 是否在顶部渲染年份标签与分隔线(仅"全部"跨年视图需要) function buildView(viewDayMap, rangeStartDate, rangeEndDate, avgSpanDays, showYearRow) { const globalStart = new Date(rangeStartDate); globalStart.setHours(0, 0, 0, 0); const endDate = new Date(rangeEndDate); endDate.setHours(0, 0, 0, 0); const gridStart = new Date(globalStart); gridStart.setDate(gridStart.getDate() - globalStart.getDay()); const totalDays = Math.floor((endDate - gridStart) / 86400000) + 1; const totalWeeks = Math.ceil(totalDays / 7); const topH = (showYearRow ? yearH : 0) + monthH; const svgW = labelW + totalWeeks * cellStep + 8; const svgH = topH + 7 * cellStep + 8; let maxDaily = 0, total = 0; for (const k in viewDayMap) { if (viewDayMap[k] > maxDaily) maxDaily = viewDayMap[k]; total += viewDayMap[k]; } const avgDaily = (total / Math.max(1, avgSpanDays)).toFixed(2); const cellParts = [], monthLabelPositions = [], yearBoundaries = []; let lastMonth = -1, lastYear = -1; const startMs = globalStart.getTime(), endMs = endDate.getTime(); for (let w = 0; w < totalWeeks; w++) { for (let d = 0; d < 7; d++) { const cellTs = gridStart.getTime() + (w * 7 + d) * 86400000; if (cellTs < startMs || cellTs > endMs) continue; const cellDate = new Date(cellTs); const key = cellDate.getFullYear() + '-' + String(cellDate.getMonth() + 1).padStart(2, '0') + '-' + String(cellDate.getDate()).padStart(2, '0'); const cnt = viewDayMap[key] || 0; const x = labelW + w * cellStep, y = topH + d * cellStep; if (cnt > 0) { cellParts.push(`${key} · ${cnt} ${T.ptHeatmapAcquired}`); } else { // v2.9.22: 区分"已过但无数据"(colors[0]=#161b22) 与"未来"(futureColor=#30363d)—— // 当年视图 8-12 月位置用 futureColor 填充,一眼能看出"时间还没到" const isFuture = cellDate.getTime() > today.getTime(); cellParts.push(``); } if (d === 0) { if (cellDate.getMonth() !== lastMonth) { lastMonth = cellDate.getMonth(); monthLabelPositions.push({ w, month: cellDate.getMonth() }); } if (showYearRow && cellDate.getFullYear() !== lastYear) { lastYear = cellDate.getFullYear(); yearBoundaries.push({ w, year: cellDate.getFullYear() }); } } } } // v2.4.2: 年份分隔线 + 年份标签(参考 v1.58),仅跨年视图渲染;首个边界不画线避免与坐标轴重叠 const yearParts = []; for (const yb of yearBoundaries) { if (yb.w > 0) { const lineX = labelW + yb.w * cellStep - cellGap / 2; yearParts.push(``); } yearParts.push(`${yb.year}`); } const monthParts = monthLabelPositions.map(ml => `${monthNames[ml.month]}` ).join(''); const dayParts = []; for (let di = 0; di < 7; di++) { if (dayNames[di]) dayParts.push(`${dayNames[di]}`); } const svgStr = `${yearParts.join('')}${monthParts}${dayParts.join('')}${cellParts.join('')}`; // v2.4.9: 图例与统计摘要合并为同一行——图例在左,统计在右,避免图例溢出容器右下角 const summary = `
${T.ptHeatmapLess}${colors.map(c => ``).join('')}${T.ptHeatmapMore} ${T.ptHeatmapTotal}: ${total} ${T.ptHeatmapPeak}: ${maxDaily} ${T.ptHeatmapAvg}: ${avgDaily} ${fmtD(globalStart)} ~ ${fmtD(endDate)}
`; return `
${svgStr}
${summary}`; } // 预计算各视图:每年一个单年视图 + 跨年"全部"视图 const today = new Date(); today.setHours(0, 0, 0, 0); const views = {}; for (const y of years) { const yStart = new Date(y, 0, 1); // v2.9.22: 当年也画完整 1-12 月(不再截断到 today)—— 8-12 月位置用 futureColor 灰格填满, // 让"未到时间"以视觉可见的占位块显示,与真实数据区分清楚 const yEnd = new Date(y, 11, 31); const avgSpan = Math.floor((yEnd - yStart) / 86400000) + 1; views[String(y)] = buildView(yearDayMaps[y], yStart, yEnd, avgSpan, false); } const allAvgSpan = Math.max(1, Math.ceil((lastTs - firstTs) / 86400) + 1); views.all = buildView(dayMap, new Date(firstTs * 1000), today, allAvgSpan, true); // 年份选择器(最新在前,末尾追加"全部"),默认展示最新年份;仅一年数据时无需选择器 let filterHtml = '', defaultView = 'all'; if (years.length > 1) { const latestYear = String(years[years.length - 1]); const btns = [...years].reverse().map(y => `` ).join(''); filterHtml = `
${btns}
`; defaultView = latestYear; } // v2.6.1: 入库热力图与近6月入库增量分成两个独立容器——主热力图占满容器宽度,增量柱图作为独立 section 在 wrap 下方展示(不在热力图节点内) const html = `
${filterHtml}
${views[defaultView]}
`; // v2.3.31: 写入渲染缓存(v2.4.2 起含各年份视图,供年份切换直接复用) _heatmapRenderCache = { signature: sig, html, ts: Date.now(), views }; return html; } // v2.4.4: 近6月入库增量迷你柱状图——展示于入库热力图右下角 // 输入与热力图相同(含 acquiredTime 的游戏数组);输出带标题的紧凑 SVG(6 根彩色柱 + 数值标签 + 网格线) function renderMonthlyAcquireChart(heatGames) { const now = new Date(); const months = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); months.push({ y: d.getFullYear(), m: d.getMonth(), count: 0 }); } const startTs = new Date(months[0].y, months[0].m, 1).getTime() / 1000; for (const g of heatGames) { const t = g.acquiredTime; if (!t || t < startTs) continue; const d = new Date(t * 1000); const idx = (d.getFullYear() - months[0].y) * 12 + (d.getMonth() - months[0].m); if (idx >= 0 && idx < 6) months[idx].count++; } const max = Math.max(...months.map(o => o.count)); const BAR_COLORS = ['#14b8a6', '#3b82f6', '#f59e0b', '#22c55e', '#ef4444', '#8b5cf6']; // v2.4.7: 侧栏紧凑尺寸(适配 260px 宽右栏,独立布局) // v2.9.26: 加高 SVG (H 128→200) 让柱图更方正,在高瘦右栏里减少上下留白 const W = 280, H = 200, lw = 32, topPad = 20, botPad = 24; const plotW = W - lw - 2, plotH = H - topPad - botPad; const step = plotW / 6, barW = Math.min(26, step * 0.6); const yMax = max > 0 ? max : 1; const yOf = v => topPad + plotH - (v / yMax) * plotH; let parts = ''; // 水平网格线 + Y 轴标签(0 / 半值 / 峰值,去重) const gridVals = max > 0 ? [...new Set([0, Math.ceil(max / 2), max])] : [0, 1]; for (const v of gridVals) { const y = yOf(v); parts += ``; parts += `${v}`; } months.forEach((o, i) => { const cx = lw + step * i + step / 2; const bh = max > 0 ? (o.count / yMax) * plotH : 0; const bx = cx - barW / 2, by = topPad + plotH - bh; const color = BAR_COLORS[i % BAR_COLORS.length]; // v2.9.24: count=0 的柱子显示极小高度 + 半透明,避免"标签无柱"困惑 const isZero = o.count === 0; const renderBh = isZero ? 2 : Math.max(bh, 0); const renderBy = isZero ? topPad + plotH - 2 : by; const fillOpacity = isZero ? '0.25' : '1'; parts += `${o.y}-${String(o.m + 1).padStart(2, '0')} · ${o.count}${isZero ? (isZh ? ' (进行中)' : ' (in progress)') : ''}`; if (o.count > 0) { parts += `${o.count}`; } const lbl = String(o.y).slice(2) + '.' + String(o.m + 1).padStart(2, '0'); parts += `${lbl}`; }); // v2.9.23: 增加标题栏统计(总数 + 峰值/低谷标注),参考设计图 const totalAcq = months.reduce((s, o) => s + o.count, 0); const maxIdx = months.indexOf(months.reduce((mx, o) => o.count > mx.count ? o : mx, months[0])); const minIdx = months.indexOf(months.reduce((mn, o) => o.count < mn.count ? o : mn, months[0])); const maxMo = String(months[maxIdx].y).slice(2) + '.' + String(months[maxIdx].m + 1).padStart(2, '0'); const minMo = String(months[minIdx].y).slice(2) + '.' + String(months[minIdx].m + 1).padStart(2, '0'); const summaryHtml = `
${totalAcq} ${isZh ? '总计' : 'total'} ${isZh ? '峰值' : 'peak'} ${max} · ${maxMo} ${isZh ? '低谷' : 'low'} ${months[minIdx].count} · ${minMo}
`; return `
${T.ptHeatmapMini6m} · ${totalAcq}
${parts}
${summaryHtml}
`; } // v2.9.22: 家庭成员对比骨架——4 行灰色 bar row(与最终行高/列宽一致,避免 layout shift) function buildFamilyCompareSkeleton(rows = 4) { // 不同宽度的 bar 让骨架看起来更接近真实数据分布(最上面"我"略长,其他成员递减) const widths = [72, 55, 38, 22]; const rowHtml = []; for (let i = 0; i < rows; i++) { const w = widths[i] || 18; rowHtml.push( `
` + `` + `
` + `` + `
` ); } return rowHtml.join(''); } // v2.3.28: 提取家庭组对比为独立函数——供游玩仪表和游玩数据标签页共用 // v2.3.30: 新增 maxMembers 参数(默认6),不足时补齐"待加入"占位行 // v2.3.31: visibleRows=4 控制默认可见行数,超出部分纵向滚动;pending 只补齐到 visibleRows // v2.9.22: 默认渲染 4 行灰色骨架行(与最终 bar row 样式一致),避免加载中底部大片空白 function createFamilyCompareSection(totalMinutes, maxMembers = 6, visibleRows = 4) { const familySection = document.createElement('div'); familySection.className = 'sglv-family-compare-section'; familySection.innerHTML = `
${ICONS.share} ${T.ptFamilyCompare}
`; const familyContainer = document.createElement('div'); familyContainer.className = 'sglv-family-compare-container'; familyContainer.innerHTML = buildFamilyCompareSkeleton(visibleRows); familySection.appendChild(familyContainer); const apiKey = storage.getApiKey(); const mySteamId = getActiveSteamId(); async function renderFamilyCompare() { const authToken = await getAccessToken().catch(() => null); const hasApiKey = !!apiKey; const hasToken = !!authToken; let familyInfo = storage.getFamilyInfo() || {}; let nameMap = familyInfo.steamIdtoName || {}; let members = familyInfo.family_member || []; let familyName = familyInfo.family_name || ''; if (Object.keys(nameMap).length === 0 || members.length === 0) { // 保留骨架,仅在 familyContainer 顶部覆盖一行小提示 if (authToken) { try { const freshInfo = await fetchFamilyInfo(authToken); if (freshInfo) { storage.setFamilyInfo(freshInfo); nameMap = freshInfo.steamIdtoName || {}; members = freshInfo.family_member || []; familyName = freshInfo.family_name || ''; } } catch { /* ignore */ } } } const titleEl = familySection.querySelector('.sglv-playtime-section-title'); if (titleEl) { const namePart = familyName ? `${familyName} · ` : ''; titleEl.innerHTML = `${ICONS.share} ${namePart}${T.ptFamilyCompare}`; } if (!mySteamId || Object.keys(nameMap).length === 0) { let reason = ''; if (!hasApiKey && !hasToken) reason = T.ptNoApiKey; else if (hasToken && Object.keys(nameMap).length === 0) reason = T.ptNoFamily; else reason = T.ptNoToken; familyContainer.innerHTML = `
${reason}
`; familyContainer.querySelector('#sglv-family-retry')?.addEventListener('click', () => { storage.setFamilyInfo({}); renderFamilyCompare(); }); return; } const myName = nameMap[mySteamId] || (isZh ? '我' : 'Me'); const list = [ { steamId: String(mySteamId), name: myName, isMe: true }, ...Object.entries(nameMap) .filter(([sid]) => sid !== String(mySteamId)) .map(([sid, name]) => ({ steamId: sid, name: name || ('ID:' + String(sid).slice(-4)), isMe: false })), ]; if (members.length > 0) { list.forEach(m => { const fm = members.find(x => x.steamid === m.steamid); if (fm?.userName) m.name = fm.userName; }); } // 保留骨架直到 API 数据返回(避免家庭信息已就绪但成员时长仍在请求时出现空白) Promise.all(list.map(async m => { if (m.isMe) { return { ...m, totalMinutes: totalMinutes }; } try { let data; if (hasApiKey) { data = await requestSteamAPI( `https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${apiKey}&steamid=${m.steamId}&include_played_free_games=1&format=json` ); } else if (authToken) { data = await requestSteamAPI( `https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?access_token=${authToken}&steamid=${m.steamId}&include_played_free_games=1` ); } else { return { ...m, totalMinutes: -1 }; } const gList = data?.response?.games || []; return { ...m, totalMinutes: gList.reduce((s, g) => s + (g.playtime_forever || 0), 0) }; } catch { return { ...m, totalMinutes: -1 }; } })).then(results => { const valid = results.filter(r => r.totalMinutes >= 0); if (valid.length === 0) { familyContainer.innerHTML = `
${isZh ? '获取失败,成员资料可能为私密' : 'Failed - profiles may be private'}
`; return; } const maxMin = Math.max(1, ...valid.map(r => r.totalMinutes)); // v2.3.30: 最多展示 maxMembers 条,按游玩时长降序取前 N;不足则补齐"待加入"占位行 const sorted = valid.sort((a, b) => b.totalMinutes - a.totalMinutes); const displayList = sorted.slice(0, maxMembers); const pendingCount = Math.max(0, visibleRows - displayList.length); const memberRows = displayList.map(m => { const hours = (m.totalMinutes / 60).toFixed(1); const pct = (m.totalMinutes / maxMin * 100).toFixed(1); const isMeClass = m.isMe ? ' is-me' : ''; return `
${m.name}
${hours}h
`; }).join(''); const pendingRows = pendingCount > 0 ? Array.from({ length: pendingCount }, () => `
${T.ptPending}
` ).join('') : ''; familyContainer.innerHTML = memberRows + pendingRows; }).catch(() => { familyContainer.innerHTML = `
${isZh ? '获取失败' : 'Failed to fetch'}
`; }); } renderFamilyCompare(); return familySection; } function formatLastPlayedShort(ts) { if (!ts || ts <= 0) return T.ptNever; const diff = Date.now() / 1000 - ts; const days = Math.floor(diff / 86400); if (days <= 0) return isZh ? '今天' : 'Today'; if (days === 1) return isZh ? '昨天' : 'Yesterday'; if (days < 30) return `${days} ${T.ptDays}`; if (days < 365) return isZh ? `${Math.floor(days / 30)} 个月前` : `${Math.floor(days / 30)} months ago`; return isZh ? `${Math.floor(days / 365)} 年前` : `${Math.floor(days / 365)} years ago`; } function renderPlaytimeTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-playtime-wrap'; if (state.ownedGames.length === 0) { wrap.innerHTML = `
${T.ptNoData}
`; parent.appendChild(wrap); return; } // 获取游戏列表(考虑家庭组共享开关) const showFamilyShared = storage.getShowFamilyShared(); const cacheK = cacheKey('pt-games', showFamilyShared, state.ownedGames.length, state.ownedGames.reduce((s, g) => s + (g.playtime || 0), 0)); const games = getCached(cacheK, () => showFamilyShared ? [...state.ownedGames] : state.ownedGames.filter(g => isGameOwnedByMe(g))); // 统计数据 const totalGames = games.length; // v2.9.22: 与游戏橱窗标签页口径统一——取 max(含DLC, 不含DLC) 作为总时长, // 避免库存 KPI 4021.5h / 统计仪表 KPI 4210.3h 这种不一致。 const realGamesForTotal = games.filter(g => !isDlc(g.appid)); const realGamesMinutes = realGamesForTotal.reduce((s, g) => s + (g.playtime || 0), 0); const allGamesMinutes = games.reduce((s, g) => s + (g.playtime || 0), 0); const totalMinutes = Math.max(realGamesMinutes, allGamesMinutes); const totalHours = totalMinutes / 60; const playedGames = games.filter(g => (g.playtime || 0) > 0); const playedCount = playedGames.length; const unplayedCount = totalGames - playedCount; const avgHours = playedCount > 0 ? totalHours / playedCount : 0; // v2.9.22: 游玩率(已启动/总数) + 完成度(深度游玩 ≥10h / 已启动) const longGames = playedGames.filter(g => (g.playtime || 0) >= 600).length; // 600 min = 10h const playRate = totalGames > 0 ? Math.round(playedCount / totalGames * 100) : 0; const completionRate = playedCount > 0 ? Math.round(longGames / playedCount * 100) : 0; // ====== KPI 数据卡片 ====== const kpiSection = document.createElement('div'); kpiSection.className = 'sglv-playtime-kpi-row'; const ptPlayedPct = playRate; const ptUnplayedPct = totalGames > 0 ? Math.round(unplayedCount / totalGames * 100) : 0; kpiSection.innerHTML = `
${ICONS.clock}${T.ptTotalHours}
${totalHours.toFixed(1)}
${T.kpiHours} · ${T.kpiAvgHours} ${avgHours.toFixed(1)}${T.kpiPerGame}
${ICONS.trend}${T.ptAvgHours}
${avgHours.toFixed(1)}
${T.kpiHours}${T.kpiPerGame}
${ICONS.game}${T.ptPlayedCount}
${playedCount.toLocaleString()}
${T.kpiRate} ${ptPlayedPct}%
${ICONS.library}${T.ptUnplayedCount}
${unplayedCount.toLocaleString()}
${T.kpiRate} ${ptUnplayedPct}%
${ICONS.game}${T.ptPlayRate}
${playRate}%
${T.kpiPlayRateSub}
${ICONS.trophy}${T.ptCompletion}
${completionRate}%
${longGames} ${T.kpiCompletionSub}
`; wrap.appendChild(kpiSection); // ====== 1. 分布图 + 趋势图 左右两栏 ====== const chartRow = document.createElement('div'); chartRow.className = 'sglv-playtime-row'; // 左侧:时长分布甜甜圈(v2.9.25: 默认总时长维度,toggle 切换游戏数|总时长) const distCol = document.createElement('div'); distCol.className = 'sglv-playtime-col'; distCol.innerHTML = `
${ICONS.barChart} ${T.ptDistTitle}
`; chartRow.appendChild(distCol); // v2.9.25: 默认渲染总时长维度(与"时长分布"标题语义一致) let _distMode = 'hours'; const renderDonutByMode = (mode) => { const dd = calcPlaytimeDist(games, mode); if (mode === 'hours') { const totalH = dd.reduce((s, d) => s + d.count, 0); distCol.querySelector('#sglv-pt-donut').innerHTML = renderPlaytimeDonut(dd, totalH, 'h'); } else { distCol.querySelector('#sglv-pt-donut').innerHTML = renderPlaytimeDonut(dd, totalGames, ''); } }; renderDonutByMode(_distMode); // toggle 绑定 distCol.querySelector('.sglv-trend-filter').addEventListener('click', e => { const btn = e.target.closest('[data-dist]'); if (!btn) return; distCol.querySelectorAll('[data-dist]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _distMode = btn.dataset.dist; renderDonutByMode(_distMode); }); // 右侧:时长趋势折线 const trendCol = document.createElement('div'); trendCol.className = 'sglv-playtime-col'; trendCol.innerHTML = `
${ICONS.trend} ${T.ptTrendTitle}
${renderPlaytimeTrend(games, 'month')}
`; chartRow.appendChild(trendCol); wrap.appendChild(chartRow); // 绑定趋势筛选 trendCol.querySelector('.sglv-trend-filter').addEventListener('click', e => { const btn = e.target.closest('.sglv-trend-btn'); if (!btn) return; trendCol.querySelectorAll('.sglv-trend-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); trendCol.querySelector('#sglv-pt-trend').innerHTML = renderPlaytimeTrend(games, btn.dataset.mode); }); // v2.3.30: 入库热力图(替换原家庭组对比——该对比已在"游玩数据"标签页展示,避免重复) // v2.6.3: 左右分栏——热力图 + 近6月入库增量 并排展示 // v2.9.22: 默认渲染空白骨架占位(年份框架 + 灰格),避免加载中底部大片空白 const heatmapRow = document.createElement('div'); heatmapRow.className = 'sglv-heatmap-acquire-row'; wrap.appendChild(heatmapRow); const heatmapSection = document.createElement('div'); heatmapSection.className = 'sglv-heatmap-section'; heatmapSection.innerHTML = `
${ICONS.grid} ${T.ptLibHeatmap}
${buildAcquiredHeatmapSkeleton()}
`; heatmapRow.appendChild(heatmapSection); // v2.6.3: 近6月入库增量作为右栏容器——与入库热力图左右并排 // v2.9.22: 默认渲染 6 根灰条骨架,异步数据回来后替换 const acquireSection = document.createElement('div'); acquireSection.className = 'sglv-heatmap-mini-section'; acquireSection.id = 'sglv-pt-acquire-mini'; acquireSection.innerHTML = buildMonthlyAcquireSkeleton(); heatmapRow.appendChild(acquireSection); // 异步加载含 acquiredTime 的家庭组库数据,按当前 games 列表(已遵循"显示家庭共享"开关)匹配入库时间 (async () => { const heatmapEl = heatmapSection.querySelector('#sglv-pt-heatmap'); try { const familyGames = await fetchTimelineFamilyGames(); if (!familyGames || familyGames.length === 0) { heatmapEl.innerHTML = `
${T.ptHeatmapNoData}
`; acquireSection.innerHTML = `
${T.ptHeatmapNoData}
`; return; } const acquiredMap = {}; for (const g of familyGames) { if (g.acquiredTime && g.acquiredTime > 0) acquiredMap[g.appid] = g.acquiredTime; } const heatGames = games .filter(g => acquiredMap[g.appid]) .map(g => ({ appid: g.appid, acquiredTime: acquiredMap[g.appid] })); heatmapEl.innerHTML = renderAcquiredHeatmap(heatGames); // v2.6.1: 近6月入库增量——渲染到独立的 acquireSection 容器,与热力图平级,避免布局错位 acquireSection.innerHTML = renderMonthlyAcquireChart(heatGames); // v2.4.2: 渲染后默认滚动到最右侧,直接展示最新数据(参考 v1.58) const scrollHmRight = () => requestAnimationFrame(() => { const s = heatmapEl.querySelector('.sglv-heatmap-scroll'); if (s) s.scrollLeft = s.scrollWidth; }); // v2.4.2: 绑定年份选择器——点击切换单年/全部视图(各视图 HTML 已在渲染缓存中预计算) const hmFilter = heatmapEl.querySelector('.sglv-hm-year-filter'); const hmBody = heatmapEl.querySelector('.sglv-heatmap-body'); if (hmFilter && hmBody) { hmFilter.addEventListener('click', e => { const btn = e.target.closest('.sglv-hm-year-btn'); if (!btn || !_heatmapRenderCache || !_heatmapRenderCache.views) return; const view = _heatmapRenderCache.views[btn.dataset.year]; if (!view) return; hmFilter.querySelectorAll('.sglv-hm-year-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); hmBody.innerHTML = view; scrollHmRight(); }); } scrollHmRight(); } catch (e) { console.warn('[SGLV] 入库热力图加载失败:', e); heatmapEl.innerHTML = `
${T.ptHeatmapNoData}
`; acquireSection.innerHTML = `
${T.ptHeatmapNoData}
`; } })(); parent.appendChild(wrap); } // ====== 游玩数据标签页 (Top 20 + 全部游戏) ====== function renderPlaydataTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-playtime-wrap'; wrap.style.flexDirection = 'column'; if (state.ownedGames.length === 0) { wrap.innerHTML = `
${T.ptNoData}
`; parent.appendChild(wrap); return; } const showFamilyShared = storage.getShowFamilyShared(); const cacheK = cacheKey('pt-games', showFamilyShared, state.ownedGames.length, state.ownedGames.reduce((s, g) => s + (g.playtime || 0), 0)); const games = getCached(cacheK, () => showFamilyShared ? [...state.ownedGames] : state.ownedGames.filter(g => isGameOwnedByMe(g))); // ====== Top 20 + 全部游戏 左右分栏 ====== const sortedGames = [...games].sort((a, b) => (b.playtime || 0) - (a.playtime || 0)); const top20 = sortedGames.slice(0, 20); const maxPlaytime = top20.length > 0 ? (top20[0].playtime || 0) : 1; const splitRow = document.createElement('div'); splitRow.className = 'sglv-playtime-split-row'; wrap.appendChild(splitRow); // ====== 左侧:Top 20 ====== const topSection = document.createElement('div'); topSection.className = 'sglv-playtime-split-left'; topSection.innerHTML = `
${ICONS.trend} ${T.ptTopTitle}
`; splitRow.appendChild(topSection); const topListEl = topSection.querySelector('#sglv-pt-top-list'); if (top20.length === 0 || maxPlaytime === 0) { topListEl.innerHTML = `
${T.ptNoData}
`; } else { const frag = document.createDocumentFragment(); top20.forEach((game, i) => { const playtimeMin = game.playtime || 0; const pct = maxPlaytime > 0 ? (playtimeMin / maxPlaytime * 100).toFixed(1) : '0'; const playtimeH = playtimeMin > 0 ? (playtimeMin / 60).toFixed(1) + 'h' : T.ptRange0; const rankClass = i === 0 ? 'rank-1' : i === 1 ? 'rank-2' : i === 2 ? 'rank-3' : ''; const rankBadgeClass = i === 0 ? 'r1' : i === 1 ? 'r2' : i === 2 ? 'r3' : ''; const iconUrl = getGameIconUrl(game.appid, game.icon); const lastPlayedText = game.lastPlayed > 0 ? formatLastPlayedShort(game.lastPlayed) : T.ptNever; const item = document.createElement('div'); item.className = `sglv-pt-top-item ${rankClass}`; item.innerHTML = `
${i + 1}
${game.name}
${T.ptLastPlayed}: ${lastPlayedText}
${playtimeH} `; item.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${game.appid}`, '_blank')); frag.appendChild(item); }); topListEl.appendChild(frag); // v2.3.33:异步加载游戏中文名 topListEl.querySelectorAll('[data-sglv-appid]').forEach(el => { loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); } // ====== 右侧:全部游戏(分页) ====== const PT_ALL_PAGE_SIZE = 50; let ptAllPage = 1; let ptAllQuery = ''; const allSection = document.createElement('div'); allSection.className = 'sglv-playtime-split-right'; allSection.innerHTML = `
${ICONS.list} ${T.ptAllTitle}
`; splitRow.appendChild(allSection); const allListEl = allSection.querySelector('#sglv-pt-all-list'); const searchInput = allSection.querySelector('#sglv-pt-search'); const countEl = allSection.querySelector('#sglv-pt-count'); const ptPagination = allSection.querySelector('#sglv-pt-pagination'); function renderAllPlaytimeList(filterQuery, page = 1) { ptAllQuery = filterQuery; ptAllPage = page; let displayGames = sortedGames; if (filterQuery) { const q = filterQuery.toLowerCase(); displayGames = sortedGames.filter(g => g.name.toLowerCase().includes(q)); } // v2.4.0: 复用 paginate 纯函数统一分页计算 const { pageItems: pageGames, totalPages, page: _clampedPage } = paginate(displayGames, ptAllPage, PT_ALL_PAGE_SIZE); ptAllPage = _clampedPage; const ptAllStart = (ptAllPage - 1) * PT_ALL_PAGE_SIZE; // v2.4.1: 修复 globalRank 计算缺失 start 变量 countEl.textContent = `${displayGames.length} ${isZh ? '款' : 'items'}`; allListEl.innerHTML = ''; if (displayGames.length === 0) { allListEl.innerHTML = `
${isZh ? '无匹配游戏' : 'No matching games'}
`; ptPagination.innerHTML = ''; return; } const allMaxPlaytime = pageGames.length > 0 ? Math.max(...pageGames.map(g => g.playtime || 0)) : 1; const frag = document.createDocumentFragment(); pageGames.forEach((game, i) => { const playtimeMin = game.playtime || 0; const pct = allMaxPlaytime > 0 ? (playtimeMin / allMaxPlaytime * 100).toFixed(1) : '0'; const playtimeH = playtimeMin > 0 ? (playtimeMin / 60).toFixed(1) + 'h' : T.ptRange0; const globalRank = ptAllStart + i + 1; const item = document.createElement('div'); item.className = 'sglv-pt-all-item'; item.innerHTML = ` ${globalRank} ${game.name}
${playtimeH} `; item.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${game.appid}`, '_blank')); frag.appendChild(item); }); allListEl.appendChild(frag); // v2.3.33:异步加载游戏中文名 allListEl.querySelectorAll('[data-sglv-appid]').forEach(el => { loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); // v2.9.25: 统一使用 renderPagination,支持快捷翻页 const _ptTotal = displayGames.length; renderPagination(ptPagination, 'sglv-pt', ptAllPage, totalPages, _ptTotal, () => renderAllPlaytimeList(ptAllQuery, ptAllPage - 1), () => renderAllPlaytimeList(ptAllQuery, ptAllPage + 1), (p) => renderAllPlaytimeList(ptAllQuery, p)); } renderAllPlaytimeList(''); // v2.9.49: debounce 搜索输入,避免游玩时长列表每次按键触发完整重渲染 searchInput.addEventListener('input', debounce((e) => { renderAllPlaytimeList(e.target.value, 1); }, 200)); // v2.4.4: 移除左下角重复的时长分布图("统计仪表"标签页已有),家庭组时长对比独占整行 // v2.7.8: 恢复整行设计,不再与左侧对齐;Top20 列表撑满 splitRow 高度 // v2.9.22: 家庭成员对比紧凑放到右半侧(allSection 底部),Top 20 撑到页面底部获得更大视野 const totalMinutes = games.reduce((s, g) => s + (g.playtime || 0), 0); allSection.appendChild(createFamilyCompareSection(totalMinutes)); parent.appendChild(wrap); } // ==================== 愿望单标签页 (v2.3.19 新增) ==================== // v2.9.29: wlEscape 委托至全局 escHtml,统一 HTML 转义实现(DRY) function wlEscape(s) { return escHtml(s); } let _wlPriceSym = ''; function detectWlPriceSymbol(sampleText) { if (_wlPriceSym) return _wlPriceSym; const text = String(sampleText || document.querySelector('#header_wallet_balance')?.textContent || ''); const m = text.match(/(HK\$|NT\$|A\$|CDN\$|Mex\$|S\$|R\$|₩|€|£|¥|\$|₽|₹|฿|₫|RM)/); _wlPriceSym = m ? m[1] : '¥'; return _wlPriceSym; } // 已在库判定集合:优先使用面板当前生效的库存集合(尊重"显示家庭共享"开关), // 未加载库存时回退 GDynamicStore.s_rgOwnedApps(SteamPeek 方式) function getWishlistOwnedIds() { try { const ids = getActiveOwnedAppIds(); if (ids && ids.size > 0) return ids; } catch { /* ignore */ } return getDynamicStoreAppIds('owned'); } // v2.7.2: 即将发售多源判定(参考 Steam-Wishlist-Sidebar-1.0.9) // 优先级:is_coming_soon 字段 > release.is_coming_soon > 未来时间戳 > release_string 措辞 // 支持 true→false 回退(游戏已发售时纠正旧缓存) const WL_COMING_SOON_RE = /即将推出|即将宣布|coming soon|to be announced|\btba\b|\btbd\b|\bq[1-4]\b|summer|winter|spring|fall|autumn|^\s*\d{4}\s*$|年第[一二三四1-4]季度|[春夏秋冬]季/i; function wlResolveComingSoon(opts) { const rdTs = Number(opts.releaseTs) || 0; let soon = opts.soonFlag === true || opts.releaseSoon === true; if (!soon && rdTs > 0 && rdTs * 1000 > Date.now()) soon = true; if (!soon && opts.releaseStr && WL_COMING_SOON_RE.test(opts.releaseStr)) soon = true; const known = opts.soonFlag != null || opts.releaseSoon != null || !!opts.hasReleaseObj || rdTs > 0 || soon; return { soon, known, date: rdTs > 0 ? new Date(rdTs * 1000).toISOString().slice(0, 10) : '' }; } // 条目规整:兼容 wishlistdata 新旧两种结构,价格单位统一为元,防止 Infinity/NaN function normalizeWishlistEntry(info) { const appid = Number(info.appid) || 0; let finalPrice = 0, originalPrice = 0, discountPct = 0; let hasPriceInfo = false; const sub = Array.isArray(info.subs) && info.subs.length > 0 ? info.subs[0] : null; if (sub) { hasPriceInfo = true; finalPrice = (Number(sub.price) || 0) / 100; discountPct = Number(sub.discount_pct) || 0; originalPrice = (discountPct > 0 && discountPct < 100) ? finalPrice / (1 - discountPct / 100) : finalPrice; } if (info.best_purchase_option) { hasPriceInfo = true; const bpo = info.best_purchase_option; finalPrice = (Number(bpo.final_price_in_cents) || 0) / 100; originalPrice = (Number(bpo.original_price_in_cents) || 0) / 100; if (originalPrice === 0) originalPrice = finalPrice; discountPct = Number(bpo.discount_pct || 0); if (bpo.formatted_final_price) detectWlPriceSymbol(bpo.formatted_final_price); } if (!Number.isFinite(finalPrice) || finalPrice < 0) finalPrice = 0; if (!Number.isFinite(originalPrice) || originalPrice < 0) originalPrice = finalPrice; if (!Number.isFinite(discountPct) || discountPct < 0 || discountPct > 100) discountPct = 0; const name = (info.name && String(info.name).trim()) || ''; // v2.7.2: 即将发售多源判定(wishlistdata 的 is_coming_soon / release_date 时间戳 / release_string 措辞) const cs = wlResolveComingSoon({ soonFlag: info.is_coming_soon, releaseTs: info.release_date, releaseStr: info.release_string, }); return { appid, name: name || `App ${appid}`, priority: Number(info.priority) || 0, added: Number(info.added || info.date_added) || 0, finalPrice, originalPrice, discountPct, isFree: !!info.is_free || (hasPriceInfo && finalPrice === 0 && discountPct === 0), isComingSoon: cs.soon, releaseDate: cs.date, _releaseKnown: cs.known, type: String(info.type || 'game').toLowerCase(), tags: normalizeWishlistTags(info.tags), // v2.3.23: 用户自定义类别 ID 列表(新版愿望单"我的类别",统一为字符串避免 uint64 精度问题) categoryIds: Array.isArray(info.category_ids) ? info.category_ids.map(id => String(id)).filter(s => s && s !== '0') : [], _priced: hasPriceInfo, }; } // 标签规整:兼容字符串数组与 {name}/{tagid,name} 对象数组 function normalizeWishlistTags(raw) { if (!Array.isArray(raw)) return []; return raw.map(t => typeof t === 'string' ? t : (t && (t.name || t.description)) || '').filter(Boolean).slice(0, 20); } // 来源2:store wishlistdata 分页(含名称/价格/折扣,信息最全) async function fetchWishlistFromStore(steamId) { const out = []; if (!steamId) return out; const isProfileId = /^\d{17}$/.test(String(steamId)); const base = isProfileId ? `https://store.steampowered.com/wishlist/profiles/${steamId}/wishlistdata/` : `https://store.steampowered.com/wishlist/id/${steamId}/wishlistdata/`; for (let p = 0; p < 60; p++) { let data = null; try { data = await requestSteamAPI(`${base}?p=${p}`); } catch { break; } if (!data) break; const entries = Array.isArray(data) ? data : Object.entries(data).map(([k, v]) => ({ appid: Number(k), ...(v || {}) })); if (entries.length === 0) break; entries.forEach(e => { if (e && e.appid) out.push(e); }); await new Promise(r => setTimeout(r, 200)); } return out; } // 来源2:IPlayerService/GetWishlist API(仅 appid/排序/添加时间) async function fetchWishlistFromApi(steamId) { const out = []; const apiKey = storage.getApiKey(); let authToken = null; if (!apiKey) { try { authToken = await getAccessToken(); } catch { /* ignore */ } } let url = null; if (apiKey) url = `https://api.steampowered.com/IPlayerService/GetWishlist/v1/?key=${apiKey}&steamid=${steamId}&format=json`; else if (authToken) url = `https://api.steampowered.com/IPlayerService/GetWishlist/v1/?access_token=${authToken}&steamid=${steamId}&format=json`; if (!url) return out; try { const data = await requestSteamAPI(url); const arr = data?.response?.items || []; arr.forEach(it => { if (it && it.appid) out.push({ appid: Number(it.appid), priority: it.priority, added: it.date_added }); }); } catch (e) { console.warn('[SGLV] GetWishlist API 失败:', e); } return out; } // v2.3.23: 解析 GetWishlistCategories 响应(返回结构未文档化,防御性兼容多种形态) function parseWishlistCategoriesResponse(data) { const out = {}; const resp = (data && typeof data === 'object') ? (data.response || data) : {}; const list = resp.categories || resp.wishlist_categories || (Array.isArray(resp) ? resp : null); if (Array.isArray(list)) { list.forEach(c => { if (!c || typeof c !== 'object') return; const id = c.categoryid ?? c.category_id ?? c.id; const name = c.category_name ?? c.name ?? c.label; if (id != null && name) out[String(id)] = String(name); }); } else if (resp && typeof resp === 'object') { // 兼容 { id: name } 或 { id: {category_name} } 映射 Object.entries(resp).forEach(([k, v]) => { if (typeof v === 'string' && v) out[k] = v; else if (v && typeof v === 'object') { const name = v.category_name ?? v.name; if (name) out[k] = String(name); } }); } return out; } // v2.3.23: 获取用户自定义愿望单类别(我的类别)ID -> 名称映射,需 access_token(API Key 兜底) async function fetchWishlistCategoryNames() { const tryUrls = []; try { const token = await getAccessToken(); if (token) tryUrls.push(`https://api.steampowered.com/IWishlistService/GetWishlistCategories/v1/?access_token=${token}`); } catch { /* ignore */ } const apiKey = storage.getApiKey(); if (apiKey) tryUrls.push(`https://api.steampowered.com/IWishlistService/GetWishlistCategories/v1/?key=${apiKey}`); for (const url of tryUrls) { try { const data = await requestSteamAPI(url); const map = parseWishlistCategoriesResponse(data); if (Object.keys(map).length > 0) return map; } catch (e) { // v2.7.9: 静默忽略 HTML 响应(未登录/API Key 失效时 Steam 返回登录页 HTML),不打印警告避免干扰 if (!String(e.message || e).includes('返回 HTML')) { console.warn('[SGLV] GetWishlistCategories 失败:', e); } } } return {}; } // v2.3.23: 后台静默刷新类别名映射,完成后仅局部刷新左侧仪表盘(不重建封面,避免闪烁) function refreshWishlistCategoryNames() { fetchWishlistCategoryNames().then(map => { if (Object.keys(map).length === 0) return; state.wishlistCategoryNames = map; cacheSet('wishlistCatNames', map, 7 * 24 * 3600 * 1000); // 缓存 7 天 if (state.activeTab === 'wishlist' && state.wishlistLoaded) renderWishlistSide(); }).catch(() => {}); } // v2.7.2: IStoreBrowseService/GetItems 批量补全(参考 Steam-Wishlist-Sidebar-1.0.9) // 实测匿名可用,单批 100 个 appid、3 路并发,几秒完成数百条,替代绝大多数 appdetails 单条请求 async function enrichWishlistFromStoreBrowse(items, cc, lang, onBatchDone) { const CHUNK = 100, CONCURRENCY = 3; const chunks = []; for (let i = 0; i < items.length; i += CHUNK) chunks.push(items.slice(i, i + CHUNK)); let qIdx = 0, processed = 0; const applyStoreBrowse = (item, si) => { if (!si || (si.success !== 1 && si.success !== true)) return; if (si.name && (item.name.startsWith('App ') || isZh)) item.name = String(si.name); if (typeof si.type === 'number') { item.type = ({ 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' })[si.type] || 'other'; } else if (si.type) { item.type = String(si.type).toLowerCase(); } if (si.is_free === true) item.isFree = true; const bpo = si.best_purchase_option; if (bpo) { const final = Number(bpo.final_price_in_cents) || 0; const original = Number(bpo.original_price_in_cents) || 0; item.finalPrice = final / 100; item.originalPrice = (original > 0 ? original : final) / 100; item.discountPct = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0); if (item.originalPrice === item.finalPrice && item.discountPct > 0 && item.discountPct < 100) { item.originalPrice = item.finalPrice / (1 - item.discountPct / 100); } if (bpo.formatted_final_price) detectWlPriceSymbol(bpo.formatted_final_price); item._priced = true; if (final === 0 && item.discountPct === 0) item.isFree = true; } // 即将发售判定:顶层 is_coming_soon / release.is_coming_soon / release.steam_release_date 未来时间戳 const rel = si.release || null; const cs = wlResolveComingSoon({ soonFlag: si.is_coming_soon, releaseSoon: rel && rel.is_coming_soon, releaseTs: rel && rel.steam_release_date, hasReleaseObj: !!rel, }); if (cs.known) { item.isComingSoon = cs.soon; item._releaseKnown = true; if (cs.date) item.releaseDate = cs.date; } item._metaDone = true; if (si.name && isZh) { const nc = sglvGameNameCacheLoad(); nc[String(item.appid)] = { name: si.name, ts: Date.now() }; } }; const worker = async () => { while (qIdx < chunks.length) { const chunk = chunks[qIdx++]; try { const input = { ids: chunk.map(g => ({ appid: g.appid })), context: { language: lang, country_code: cc, steam_realm: 1 }, data_request: { include_release: true, include_all_purchase_options: true, include_platforms: true } }; const url = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input)); const resp = await requestSteamAPI(url); const storeItems = resp?.response?.store_items || []; const map = new Map(); storeItems.forEach(si => { if (si && si.appid) map.set(Number(si.appid), si); }); chunk.forEach(g => { const si = map.get(Number(g.appid)); if (si) applyStoreBrowse(g, si); }); } catch (e) { console.warn('[SGLV] GetItems batch failed:', e); } processed += chunk.length; if (onBatchDone) onBatchDone(processed, items.length); await new Promise(r => setTimeout(r, 200)); } }; const workers = []; for (let i = 0; i < CONCURRENCY; i++) workers.push(worker()); await Promise.all(workers); if (isZh) { try { sglvGameNameCacheSave(); } catch (e2) { console.warn('[SGLV] 中文名缓存写入失败:', e2); } } } // 资料补全:v2.7.2 三段式(参考 Steam-Wishlist-Sidebar-1.0.9) // ① IStoreBrowseService/GetItems 批量补全(100 个/批、3 路并发)—— 名称/类型/价格/折扣/发售状态 // ② appdetails 兜底残余(缺名称/价格/发售状态的条目),filters 增加 release_date 判定 coming_soon async function enrichWishlistMeta(items, onBatchDone) { const need = items.filter(i => !i._metaDone); if (need.length === 0) { if (onBatchDone) onBatchDone(0, 0); return; } let cc = 'CN'; try { const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; cc = win.g_strCountryCode || 'CN'; } catch { /* ignore */ } const lang = isZh ? 'schinese' : 'english'; // ① GetItems 批量补全(核心速度优化,替代绝大多数 appdetails 单条请求) await enrichWishlistFromStoreBrowse(need, cc, lang, onBatchDone); // ② appdetails 兜底残余:仍缺名称/价格/发售状态的条目 const leftovers = items.filter(i => !i._metaDone && (i.name.startsWith('App ') || !i._priced || !i._releaseKnown)); if (leftovers.length > 0) { // v2.4.3: filters 限定返回字段,v2.7.2: 增加 release_date 以判定 coming_soon const filters = 'name,type,is_free,genres,price_overview,release_date'; const enrichBatch = async (batch) => { const data = await requestSteamAPI(`https://store.steampowered.com/api/appdetails?appids=${batch.map(g => g.appid).join(',')}&filters=${filters}&cc=${cc}&l=${lang}`); batch.forEach(g => { const d = data?.[g.appid]; if (!d || !d.success || !d.data) return; const dd = d.data; // v2.3.33: 中文环境下 appdetails 使用 l=schinese 请求,返回的为中文名, // 应始终覆盖 SSR/API 返回的英文名,而非仅覆盖占位符。 if (dd.name && (g.name.startsWith('App ') || isZh)) g.name = dd.name; if (dd.type) g.type = String(dd.type).toLowerCase(); if (dd.is_free === true) g.isFree = true; if (Array.isArray(dd.genres) && dd.genres.length > 0) { g.tags = dd.genres.map(x => x.description).filter(Boolean); } const po = dd.price_overview; if (po && typeof po.final === 'number') { g.finalPrice = po.final / 100; g.originalPrice = (po.initial || po.final) / 100; g.discountPct = po.discount_percent || 0; g.isFree = false; g._priced = true; if (po.final_formatted) detectWlPriceSymbol(po.final_formatted); } // v2.7.2: release_date.coming_soon 为权威判定(可回退 true→false,纠正旧缓存) if (dd.release_date && typeof dd.release_date.coming_soon !== 'undefined') { g.isComingSoon = !!dd.release_date.coming_soon; g._releaseKnown = true; if (dd.release_date.date) g.releaseDate = dd.release_date.date; } g._metaDone = true; // v2.3.33:同步写入中文名缓存,避免 loadGameZhName 对已补全的游戏重复请求 appdetails if (dd.name && isZh) { const nc = sglvGameNameCacheLoad(); nc[String(g.appid)] = { name: dd.name, ts: Date.now() }; } }); // v2.3.33:批量写入后统一落盘一次,避免 forEach 内每条都调 GM_setValue if (isZh) { try { sglvGameNameCacheSave(); } catch (e2) { console.warn('[SGLV] 中文名缓存写入失败:', e2); } } }; let done = 0; for (let i = 0; i < leftovers.length; i += 25) { const batch = leftovers.slice(i, i + 25); try { await enrichBatch(batch); } catch { /* 忽略单批失败 */ } done += batch.length; if (onBatchDone) onBatchDone(done, leftovers.length); await new Promise(r => setTimeout(r, 250)); } // v2.4.3: 对补全失败的条目以更小批次重试一轮——旧实现单批失败后条目永久缺失中文名/价格 const retry = leftovers.filter(i => !i._metaDone); for (let i = 0; i < retry.length; i += 10) { const batch = retry.slice(i, i + 10); try { await enrichBatch(batch); } catch { /* 重试失败保持原样 */ } await new Promise(r => setTimeout(r, 300)); } } if (onBatchDone) onBatchDone(need.length, need.length); } // v2.7.2: 后台静默刷新愿望单动态字段(价格/折扣/即将发售) // 仅用 IStoreBrowseService/GetItems 批量请求(快速),比较新旧值后仅更新有变化的条目并局部刷新 UI // v2.9.30: 增加 6 小时节流,避免每次进入愿望单标签页都触发 API 请求(缓存已延长至 3 天) let _wlDynamicRefreshing = false; let _wlDynamicLastRefresh = 0; const WL_DYNAMIC_REFRESH_INTERVAL = 6 * 3600 * 1000; // 6 小时节流 async function refreshWishlistDynamicFields(items) { if (_wlDynamicRefreshing || !items || items.length === 0) return; // v2.9.30: 6 小时内已刷新过则跳过,避免每次进入标签页都发起 API 请求 if (Date.now() - _wlDynamicLastRefresh < WL_DYNAMIC_REFRESH_INTERVAL) return; _wlDynamicRefreshing = true; try { let cc = 'CN'; try { const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; cc = win.g_strCountryCode || 'CN'; } catch { /* ignore */ } const lang = isZh ? 'schinese' : 'english'; // 标记动态字段待刷新:重置 _priced/_releaseKnown 让 enrichWishlistFromStoreBrowse 重新处理 // 但保留 _metaDone 以跳过 appdetails 兜底(名称/类型/标签等静态数据不变) const refreshItems = items.map(g => { const copy = Object.assign({}, g); copy._priced = false; copy._releaseKnown = false; copy._metaDone = true; // 跳过 appdetails 兜底 return copy; }); // 记录刷新前的快照,用于比较变化 const before = new Map(items.map(g => [g.appid, { finalPrice: g.finalPrice, discountPct: g.discountPct, isComingSoon: g.isComingSoon, isFree: g.isFree, }])); await enrichWishlistFromStoreBrowse(refreshItems, cc, lang); // 将刷新结果合并回原数组,仅更新有变化的动态字段 let changed = false; const origMap = new Map(items.map(g => [g.appid, g])); refreshItems.forEach(refreshed => { const orig = origMap.get(refreshed.appid); if (!orig) return; const oldSnap = before.get(orig.appid); // 仅在字段实际变化时更新并标记 changed if (refreshed._priced && refreshed.finalPrice !== oldSnap.finalPrice) { orig.finalPrice = refreshed.finalPrice; orig.originalPrice = refreshed.originalPrice; orig.discountPct = refreshed.discountPct; orig.isFree = refreshed.isFree; orig._priced = true; changed = true; } if (refreshed._releaseKnown && !!refreshed.isComingSoon !== !!oldSnap.isComingSoon) { orig.isComingSoon = refreshed.isComingSoon; orig._releaseKnown = true; if (refreshed.releaseDate) orig.releaseDate = refreshed.releaseDate; changed = true; } }); if (changed) { // 更新缓存并局部刷新 UI cacheSet('wishlistGames', items, CACHE_TTL.wishlist); renderWishlistSections(); } } catch (e) { console.warn('[SGLV] 后台刷新动态字段失败:', e); } finally { _wlDynamicRefreshing = false; _wlDynamicLastRefresh = Date.now(); // v2.9.30: 记录本次刷新时间用于节流 } } // ==================== 愿望单页面 SSR 数据提取 (v2.3.20 新增) ==================== // 参考 steam-wishlist-exporter-2.1.3:从愿望单页面 HTML 中解析 React Query SSR 数据, // 获取 date_added/priority(全部条目)+ StoreItem 详情(名称/价格/标签,首批条目)+ 标签名映射 // 这解决了 wishlistdata API 不返回 added/tags 导致时间线和标签为空、名称需逐批 appdetails 补全的问题 // 从 HTML 文本中提取 React Query 的 queries 数组(扫描 JSON.parse("...") 包含 queryData 的片段) function wlExtractQueryDataFromText(text) { try { let idx = 0; while ((idx = text.indexOf('JSON.parse(', idx)) !== -1) { const start = text.indexOf('"', idx + 11); if (start === -1) { idx++; continue; } let i = start + 1, inEsc = false; while (i < text.length) { const c = text[i]; if (inEsc) inEsc = false; else if (c === '\\') inEsc = true; else if (c === '"') break; i++; } const escaped = text.substring(start + 1, i).replace(/[\n\r]/g, ''); let decoded = ''; try { decoded = JSON.parse('"' + escaped + '"'); } catch (_) { idx = i + 1; continue; } if (typeof decoded === 'string' && decoded.includes('queryData')) { try { const outer = JSON.parse(decoded); if (outer && typeof outer.queryData === 'string') { const qd = JSON.parse(outer.queryData); if (qd && Array.isArray(qd.queries)) return qd.queries; } } catch (_) { console.warn('[SGLV] SSR queryData 解析失败'); } } idx = i + 1; } } catch (e) { console.warn('[SGLV] wlExtractQueryDataFromText failed:', e); } return null; } // 从 queries 数组提取:愿望单条目 / StoreItem 缓存 / 标签名映射 function wlExtractFromQueries(queries) { const entries = []; const tagNameMap = {}; const storeItemCache = new Map(); for (const query of queries) { if (!query || !query.state || !query.state.data) continue; const data = query.state.data; const qKey = query.queryKey || []; // 愿望单条目(含 appid/priority/date_added/category_ids) if (qKey[0] === 'WishlistSortedFiltered') { const payload = Array.isArray(data) ? { items: data } : data; if (payload && Array.isArray(payload.items)) entries.push(...payload.items); } // 标签名映射(tagid -> name) if (qKey[0] === 'LocalizedTagNames' && typeof data === 'object' && !Array.isArray(data)) { Object.assign(tagNameMap, data); } // StoreItem 详情(按 appid 聚合各子查询:default_info/top_tags/include_platforms 等) if (qKey.length >= 3 && qKey[0] === 'StoreItem') { const appId = parseInt(String(qKey[1]).replace(/^app_/, ''), 10); if (!appId || isNaN(appId)) continue; if (!storeItemCache.has(appId)) storeItemCache.set(appId, { _appId: appId }); storeItemCache.get(appId)[qKey[2]] = data; } } // 去重(按 appid) const seen = new Set(); const unique = entries.filter(e => e && e.appid && !seen.has(e.appid) && seen.add(e.appid)); return { entries: unique, storeItemCache, tagNameMap }; } // 将 StoreItem 缓存数据应用到已规整的愿望单条目(补全名称/价格/标签/类型) function applyStoreItemToWlEntry(item, storeData, tagNameMap) { if (!item || !storeData) return; const di = storeData.default_info; if (di) { // v2.3.33:中文环境下 SSR 名称可能为英文,仍作为兜底应用(appdetails 会后续覆盖为中文名) if (di.name && (item.name.startsWith('App ') || isZh)) item.name = String(di.name); // Steam StoreItem.type: 0=game, 1=DLC, 2=software, 3=video, 4=series, 6=music, 7=tool, 8=video_series if (typeof di.type === 'number') { item.type = ({ 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' })[di.type] || 'other'; } else if (di.type) { item.type = String(di.type).toLowerCase(); } if (di.is_free === true) item.isFree = true; const bpo = di.best_purchase_option; if (bpo) { const final = Number(bpo.final_price_in_cents) || 0; const original = Number(bpo.original_price_in_cents) || 0; item.finalPrice = final / 100; item.originalPrice = (original > 0 ? original : final) / 100; item.discountPct = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0); if (bpo.formatted_final_price) detectWlPriceSymbol(bpo.formatted_final_price); item._priced = true; if (final === 0 && item.discountPct === 0) item.isFree = true; } // v2.7.2: 即将发售判定(SSR default_info.is_coming_soon / release.steam_release_date / release.is_coming_soon) const rel = di.release || di.release_date; const cs = wlResolveComingSoon({ soonFlag: di.is_coming_soon, releaseSoon: rel && rel.is_coming_soon, releaseTs: rel && (rel.steam_release_date || rel.date), hasReleaseObj: !!rel, }); if (cs.known) { item.isComingSoon = cs.soon; item._releaseKnown = true; if (cs.date) item.releaseDate = cs.date; } } // top_tags -> 标签名列表(通过 tagNameMap 映射 tagid) const tt = storeData.top_tags; if (Array.isArray(tt) && tt.length > 0) { item.tags = tt.map(t => { if (!t) return ''; if (t.name) return t.name; if (t.tagid != null) return tagNameMap[t.tagid] || ''; return ''; }).filter(Boolean).slice(0, 20); } // v2.3.33:中文环境下 SSR StoreItem 的名称随用户账号语言(可能为英文), // 不标记 _metaDone,让 enrichWishlistMeta 通过 l=schinese 的 appdetails 覆盖为中文名; // 非中文环境保持原有行为(SSR 数据完整即跳过 appdetails)。 item._metaDone = isZh ? false : true; // StoreItem 数据完整,跳过 appdetails 补全 } // 从愿望单页面 HTML 抓取 SSR 数据(参考 steam-wishlist-exporter-2.1.3 的 SSR 提取) async function fetchWishlistFromPage(steamId) { if (!steamId) return null; const isProfileId = /^\d{17}$/.test(String(steamId)); const url = isProfileId ? `https://store.steampowered.com/wishlist/profiles/${steamId}/` : `https://store.steampowered.com/wishlist/id/${steamId}/`; try { const resp = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 30000, onload(r) { resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, text: r.responseText || '' }); }, onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('wishlist page fetch timeout')) }); }); if (!resp.ok || !resp.text) { console.warn('[SGLV] 愿望单页面抓取失败: HTTP', resp.status); return null; } const queries = wlExtractQueryDataFromText(resp.text); if (!queries) { console.warn('[SGLV] 愿望单页面未找到 SSR queryData'); return null; } const data = wlExtractFromQueries(queries); console.log(`[SGLV] 愿望单页面 SSR: ${data.entries.length} 条目, ${data.storeItemCache.size} 详情, ${Object.keys(data.tagNameMap).length} 标签`); if (data.entries.length === 0) return null; return data; } catch (e) { console.warn('[SGLV] fetchWishlistFromPage 失败:', e); return null; } } // 愿望单类型分布:将 type 字段归一化为 game/dlc/software/other function wlGetTypeKey(type) { const t = String(type || 'game').toLowerCase(); if (t === 'game' || t === '0') return 'game'; if (t === 'dlc' || t === '1') return 'dlc'; if (t === 'software' || t === '2') return 'software'; return 'other'; } // 主入口:四级来源兜底(愿望单页面SSR → wishlistdata → GetWishlist API → GDynamicStore) // v2.7.2: 缓存命中时后台静默刷新动态字段(价格/折扣/即将发售),不阻塞 UI // v2.9.30: 缓存 TTL 延长至 3 天,动态字段刷新增加 6h 节流,避免每次进入标签页都触发 API 请求 async function loadWishlistGames(force = false) { if (state.wishlistLoading) return; if (!force) { const cached = cacheGet('wishlistGames'); if (cached && Array.isArray(cached) && cached.length > 0) { state.wishlistGames = cached; state.wishlistLoaded = true; markSearchIndexDirty(); // v2.9.51: 缓存恢复,索引需要重建 state.wishlistCategoryNames = cacheGet('wishlistCatNames') || {}; // v2.3.23: 恢复类别名缓存 renderWishlistSections(); refreshWishlistCategoryNames(); // v2.3.23: 后台静默更新类别名 // v2.7.2: 后台静默刷新动态字段——价格/折扣/即将发售状态变化频繁,缓存内可能已过时 // v2.9.30: 6h 节流,同一会话内不重复刷新;复用 enrichWishlistFromStoreBrowse 批量补全 refreshWishlistDynamicFields(cached); return; } } state.wishlistLoading = true; state.wishlistLoadStage = 1; // v2.9.47: 阶段1 — 页面抓取 state.wishlistLoadProgress = 0; renderWishlistGamesList(); try { const steamId = getActiveSteamId(); // 来源1(新增):愿望单页面 SSR —— 含 date_added/priority(全部条目)+ StoreItem 详情(首批条目) // 解决 wishlistdata API 不返回 added 字段导致时间线为空、名称需逐批 appdetails 补全的问题 let ssrData = null; try { ssrData = await fetchWishlistFromPage(steamId); } catch (e) { console.warn('[SGLV] 愿望单页面 SSR 抓取失败:', e); } let raw = (ssrData && ssrData.entries.length > 0) ? ssrData.entries : []; const storeItemCache = ssrData ? ssrData.storeItemCache : new Map(); const tagNameMap = ssrData ? ssrData.tagNameMap : {}; // v2.9.47: 阶段2 — API 数据补全 state.wishlistLoadStage = 2; renderWishlistGamesList(); // 来源2:store wishlistdata 分页(补充名称/价格/标签/类型) let apiEntries = []; if (steamId) { try { apiEntries = await fetchWishlistFromStore(steamId); } catch (e) { console.warn('[SGLV] wishlistdata API 失败:', e); } } if (raw.length === 0) { // SSR 失败,直接用 wishlistdata API 数据 raw = apiEntries; } else if (apiEntries.length > 0) { // 合并:SSR 提供 date_added/priority(API 通常缺失),API 提供名称/价格/标签(SSR 仅首批有) const apiMap = new Map(apiEntries.map(e => [Number(e.appid), e])); raw = raw.map(ssrEntry => { const apiEntry = apiMap.get(Number(ssrEntry.appid)); if (!apiEntry) return ssrEntry; // API 字段在前,SSR 的 date_added/priority/category_ids 覆盖(SSR 数据更可靠) return Object.assign({}, apiEntry, { date_added: ssrEntry.date_added, priority: ssrEntry.priority, category_ids: ssrEntry.category_ids, }); }); } // 来源3:IPlayerService/GetWishlist API if (raw.length === 0) raw = await fetchWishlistFromApi(steamId); // 来源4:GDynamicStore 兜底(仅 appid) if (raw.length === 0) { raw = [...getDynamicStoreAppIds('wishlist')].map(appid => ({ appid })); } const items = raw.map(entry => { const item = normalizeWishlistEntry(entry); // 应用 SSR StoreItem 详情(首批约 60 个条目有完整名称/价格/标签/类型) if (storeItemCache.has(item.appid)) { applyStoreItemToWlEntry(item, storeItemCache.get(item.appid), tagNameMap); } return item; }).filter(i => i.appid > 0); items.sort((a, b) => { const pa = a.priority > 0 ? a.priority : 999999; const pb = b.priority > 0 ? b.priority : 999999; if (pa !== pb) return pa - pb; return a.name.localeCompare(b.name, 'zh-CN'); }); state.wishlistGames = items; state.wishlistLoaded = true; state.wishlistLoading = false; state.wishlistLoadStage = 0; // v2.9.47: 重置加载阶段 state.wishlistLoadProgress = 0; markSearchIndexDirty(); // v2.9.51: 搜索索引依赖 wishlistGames,标记为脏 state.wishlistCategoryNames = cacheGet('wishlistCatNames') || {}; // v2.3.23: 先用缓存类别名出图 renderWishlistSections(); // 先出列表,再后台逐批补全名称/标签/价格 refreshWishlistCategoryNames(); // v2.3.23: 后台获取最新类别名并局部刷新 // v2.9.47: 阶段3 — 资料富集(后台补全,不阻塞列表展示) state.wishlistLoadStage = 3; await enrichWishlistMeta(items, (done, total) => { updateWishlistProgress(done, total); renderWishlistSections(); }); cacheSet('wishlistGames', items, CACHE_TTL.wishlist); // v2.9.30: 缓存延长至 3 天(动态字段后台静默刷新,6h 节流) updateWishlistProgress(0, 0); state.wishlistLoadStage = 0; // v2.9.47: 富集完成,重置 state.wishlistLoadProgress = 0; renderWishlistSections(); sglvToast.success(T.wlFetchSuccess.replace('{n}', items.length)); } catch (e) { console.error('[SGLV] 愿望单获取失败:', e); sglvToast.error(T.wlFetchFail); state.wishlistLoading = false; state.wishlistLoadStage = 0; // v2.9.47: 重置加载阶段 state.wishlistLoadProgress = 0; renderWishlistSections(); } } // 后台资料补全进度提示(左侧仪表盘顶部) function updateWishlistProgress(done, total) { const el = document.getElementById('sglv-wl-progress'); if (!el) return; if (!done || !total || done >= total) { el.textContent = ''; return; } el.textContent = (isZh ? '正在补全资料… ' : 'Enriching… ') + done + '/' + total; } // 统计计算:参考 steam-portal analyzeWishlist,计算 KPI/价格区间/折扣区间/按年趋势/类型分布 function computeWishlistStats() { const games = state.wishlistGames; const ownedIds = getWishlistOwnedIds(); const total = games.length; const inLibrary = games.filter(g => ownedIds.has(Number(g.appid)) || ownedIds.has(String(g.appid))).length; const isOwned = g => ownedIds.has(Number(g.appid)) || ownedIds.has(String(g.appid)); // 价格统计(参考 steam-portal:仅付费游戏,价格单位为元) const paidItems = games.filter(g => !g.isFree && g.finalPrice > 0); const prices = paidItems.map(g => g.finalPrice).sort((a, b) => a - b); const totalValue = prices.reduce((s, p) => s + p, 0); const avgPrice = paidItems.length > 0 ? totalValue / paidItems.length : 0; const medianPrice = prices.length > 0 ? (prices.length % 2 === 0 ? (prices[prices.length / 2 - 1] + prices[prices.length / 2]) / 2 : prices[Math.floor(prices.length / 2)]) : 0; // 折扣统计 const discountedItems = games.filter(g => g.discountPct > 0); const discounts = discountedItems.map(g => g.discountPct); const avgDiscount = discounts.length > 0 ? discounts.reduce((s, d) => s + d, 0) / discounts.length : 0; const maxDiscount = discounts.length > 0 ? Math.max(...discounts) : 0; const discountedRate = total > 0 ? discountedItems.length / total : 0; // 免费游戏 const freeCount = games.filter(g => g.isFree).length; const freeRate = total > 0 ? freeCount / total : 0; // v2.7.2: 即将发售 const comingSoonItems = games.filter(g => g.isComingSoon); const comingSoonCount = comingSoonItems.length; const comingSoonRate = total > 0 ? comingSoonCount / total : 0; // 购入率 const purchaseRate = total > 0 ? inLibrary / total : 0; // 最早添加日期 const addedDates = games.filter(g => g.added > 0).map(g => g.added); const earliestAdded = addedDates.length > 0 ? Math.min(...addedDates) : 0; // 热门标签 TOP 15 const tagMap = new Map(); games.forEach(g => (g.tags || []).forEach(t => tagMap.set(t, (tagMap.get(t) || 0) + 1))); const topTags = [...tagMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15); // 添加时间线(按月,取最近 12 个月) const monthMap = new Map(); games.forEach(g => { if (g.added > 0) { const d = new Date(g.added * 1000); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; monthMap.set(key, (monthMap.get(key) || 0) + 1); } }); const timeline = [...monthMap.entries()].sort((a, b) => a[0].localeCompare(b[0])).slice(-12); // 按年添加趋势(参考 steam-portal addedByYear) const yearMap = new Map(); games.forEach(g => { if (g.added > 0) { const year = String(new Date(g.added * 1000).getFullYear()); yearMap.set(year, (yearMap.get(year) || 0) + 1); } }); const addedByYear = [...yearMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); // 价格区间分布(参考 steam-portal priceBuckets,单位为元) const priceBuckets = [ { label: T.wlPriceFree, count: games.filter(g => g.isFree).length, color: '#6366f1' }, { label: T.wlPriceLt50, count: paidItems.filter(g => g.finalPrice < 50).length, color: '#10b981' }, { label: T.wlPrice50_100, count: paidItems.filter(g => g.finalPrice >= 50 && g.finalPrice < 100).length, color: '#22c55e' }, { label: T.wlPrice100_200, count: paidItems.filter(g => g.finalPrice >= 100 && g.finalPrice < 200).length, color: '#84cc16' }, { label: T.wlPrice200_500, count: paidItems.filter(g => g.finalPrice >= 200 && g.finalPrice < 500).length, color: '#f59e0b' }, { label: T.wlPriceGte500, count: paidItems.filter(g => g.finalPrice >= 500).length, color: '#ef4444' }, ]; // 折扣分布(参考 steam-portal discountBuckets) const discountBuckets = [ { label: T.wlDisc0, count: games.filter(g => g.discountPct === 0).length, color: '#64748b' }, { label: T.wlDisc1_25, count: games.filter(g => g.discountPct > 0 && g.discountPct <= 25).length, color: '#84cc16' }, { label: T.wlDisc26_50, count: games.filter(g => g.discountPct > 25 && g.discountPct <= 50).length, color: '#22c55e' }, { label: T.wlDisc51_75, count: games.filter(g => g.discountPct > 50 && g.discountPct <= 75).length, color: '#f59e0b' }, { label: T.wlDisc76plus, count: games.filter(g => g.discountPct > 75).length, color: '#ef4444' }, ]; // 类型分布(愿望单游戏分类汇总:游戏/DLC/软件/其他) const typeMap = new Map(); games.forEach(g => { const key = wlGetTypeKey(g.type); typeMap.set(key, (typeMap.get(key) || 0) + 1); }); const typeDist = [...typeMap.entries()].sort((a, b) => b[1] - a[1]); // v2.8.0: 我的类别详细统计(每类别:数量/总值/均价/打折/待上市/已入库/免费 + 占比) const catNames = state.wishlistCategoryNames || {}; const catStatsMap = new Map(); games.forEach(g => { const ids = Array.isArray(g.categoryIds) ? g.categoryIds : []; if (ids.length === 0) return; ids.forEach(id => { if (!catStatsMap.has(id)) catStatsMap.set(id, { count: 0, totalValue: 0, paidCount: 0, discountCount: 0, comingSoonCount: 0, inLibraryCount: 0, freeCount: 0, }); const cs = catStatsMap.get(id); cs.count++; if (!g.isFree && g.finalPrice > 0) { cs.totalValue += g.finalPrice; cs.paidCount++; } if (g.discountPct > 0) cs.discountCount++; if (g.isComingSoon) cs.comingSoonCount++; if (isOwned(g)) cs.inLibraryCount++; if (g.isFree) cs.freeCount++; }); }); const categoryStats = [...catStatsMap.entries()] .map(([id, cs]) => ({ id, name: catNames[id] || `#${id}`, ...cs, avgPrice: cs.paidCount > 0 ? cs.totalValue / cs.paidCount : 0, pct: total > 0 ? cs.count / total : 0, })) .sort((a, b) => b.count - a.count); // v2.8.0: 未分类游戏统计 const uncatGames = games.filter(g => { const ids = Array.isArray(g.categoryIds) ? g.categoryIds : []; return ids.length === 0; }); const uncategorizedStats = { count: uncatGames.length, totalValue: uncatGames.filter(g => !g.isFree && g.finalPrice > 0).reduce((s, g) => s + g.finalPrice, 0), discountCount: uncatGames.filter(g => g.discountPct > 0).length, comingSoonCount: uncatGames.filter(g => g.isComingSoon).length, inLibraryCount: uncatGames.filter(g => isOwned(g)).length, freeCount: uncatGames.filter(g => g.isFree).length, pct: total > 0 ? uncatGames.length / total : 0, }; return { total, inLibrary, purchaseRate, totalValue, avgPrice, medianPrice, discountedCount: discountedItems.length, discountedRate, avgDiscount, maxDiscount, freeCount, freeRate, comingSoonCount, comingSoonRate, earliestAdded, topTags, timeline, addedByYear, priceBuckets, discountBuckets, typeDist, categoryStats, uncategorizedStats, }; } // v2.9.50: 持久化 wishlist 统计(跨 session 复用) // 输入签名:wishlist 数量 + 价格字段总和 + 类别字段总和 // 动态字段(价格/折扣)会由 refreshWishlistDynamicFields 后台刷新(已有) function computeWishlistStatsCached() { const games = state.wishlistGames; let finalPriceSum = 0, originalPriceSum = 0, discountSum = 0, addedSum = 0; const n = games ? games.length : 0; for (let i = 0; i < n; i++) { const g = games[i]; finalPriceSum += (g && g.finalPrice) || 0; originalPriceSum += (g && g.originalPrice) || 0; discountSum += (g && g.discountPct) || 0; addedSum += (g && g.added) || 0; } const catNames = state.wishlistCategoryNames || {}; const catKey = Object.keys(catNames).sort().map(k => `${k}:${catNames[k]}`).join(','); const sig = `${n}|${finalPriceSum}|${originalPriceSum}|${discountSum}|${addedSum}|${catKey}`; return getBizCached('biz_wishlist_stats', 1, sig, () => computeWishlistStats()); } function getFilteredWishlist() { const q = state.wishlistSearch.toLowerCase().trim(); const ownedIds = getWishlistOwnedIds(); return state.wishlistGames.filter(g => { if (q && !g.name.toLowerCase().includes(q) && !String(g.appid).includes(q)) return false; if (state.wishlistFilter === 'discount' && !(g.discountPct > 0)) return false; if (state.wishlistFilter === 'inlibrary' && !(ownedIds.has(Number(g.appid)) || ownedIds.has(String(g.appid)))) return false; if (state.wishlistFilter === 'coming' && !g.isComingSoon) return false; // v2.8.0: 类别筛选(点击"我的类别统计"卡片触发) if (state.wishlistFilter === 'category' && state.wishlistCategoryFilter) { const ids = Array.isArray(g.categoryIds) ? g.categoryIds : []; if (state.wishlistCategoryFilter === '__uncat__') { if (ids.length > 0) return false; } else { if (!ids.includes(state.wishlistCategoryFilter)) return false; } } return true; }); } // ----- 渲染 ----- // v2.9.15: 愿望单首次加载骨架屏——KPI 数字 + 8 section + 游戏网格,避免页面紧缩 function renderWishlistSkeleton(wrap) { // KPI 区:4 个数字骨架 const kpiEl = wrap.querySelector('#sglv-wishlist-kpi'); if (kpiEl && !kpiEl.innerHTML) { kpiEl.className = 'sglv-wl-kpi'; kpiEl.innerHTML = Array.from({ length: 4 }, () => `
` ).join(''); } // 8 个 section:每 section 一行骨架 const sectionIds = ['sglv-wl-upcoming', 'sglv-wl-catstats', 'sglv-wl-tags', 'sglv-wl-year-trend', 'sglv-wl-price-dist', 'sglv-wl-disc-dist', 'sglv-wl-timeline']; sectionIds.forEach(id => { const el = wrap.querySelector('#' + id); if (!el || el.innerHTML) return; el.innerHTML = `
`; }); // 右侧 content:12 个卡片网格骨架 const content = wrap.querySelector('#sglv-wishlist-content'); if (content && !content.innerHTML) { content.innerHTML = `
${Array.from({ length: 12 }, () => `
` ).join('')}
${renderWishlistLoadingHtml()}`; } } // ==================== 云存档标签页 (v2.9.34) ==================== function renderCloudSaveTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-cs-wrap'; // 模块未加载 if (!window.SGLVCloudSave || !_cloudSaveReady) { wrap.innerHTML = `
⚠️
${isZh ? '云存档模块未加载' : 'Cloud save module not loaded'}
${isZh ? '请检查 @require 外部库配置' : 'Please check @require external library config'}
`; parent.innerHTML = ''; parent.appendChild(wrap); return; } // 加载中 if (state.cloudSaveLoading) { wrap.innerHTML = `
${T.csFetching}
`; parent.innerHTML = ''; parent.appendChild(wrap); return; } // 错误态 if (state.cloudSaveError) { const isLogin = state.cloudSaveError === 'LOGIN_REQUIRED'; wrap.innerHTML = `
${isLogin ? '🔐' : '⚠️'}
${isLogin ? T.csLoginRequired : T.csFetchFail}
`; parent.innerHTML = ''; parent.appendChild(wrap); wrap.querySelector('#sglv-cs-retry').addEventListener('click', () => loadCloudSaveData(true)); return; } // 空数据——v2.9.39: 切换到云存档标签页时,先尝试从 IDB 加载缓存(立即显示) if (!state.cloudSaveLoaded || !state.cloudSaveGames || state.cloudSaveGames.length === 0) { // 渲染空态占位(含获取按钮) wrap.innerHTML = `
☁️
${T.csNoData}
`; parent.innerHTML = ''; parent.appendChild(wrap); // v2.9.39: 启动缓存加载流程(可能瞬间命中 IDB 缓存并替换此占位) _initCloudSaveTab(); wrap.querySelector('#sglv-cs-fetch').addEventListener('click', () => loadCloudSaveData(true)); return; } // 仪表盘 renderCloudSaveDashboard(parent, wrap); // v2.9.39: 渲染完成后,若距上次后台刷新超过 6h 节流窗口,触发静默增量更新 // 仅在切到该标签页时触发(不打扰用户) if (Date.now() - (state.cloudSaveBgRefreshAt || 0) >= CLOUDSAVE_BG_REFRESH_INTERVAL) { _backgroundRefreshCloudSave(); } // v2.9.67: 后台渐进式填充文件数量(12h 节流,逐个请求单App页面解析文件数) _enrichCloudSaveFileCounts(); } async function loadCloudSaveData(force) { state.cloudSaveLoading = true; state.cloudSaveError = null; renderBody(); try { const result = await window.SGLVCloudSave.fetchGlobalCloudSaves(force); state.cloudSaveGames = result.games || []; state.cloudSaveSource = result.source; state.cloudSaveLastUpdate = result.lastUpdate; state.cloudSaveDiff = result.diff; state.cloudSaveLoaded = true; state.cloudSaveError = null; showToast(T.csFetchSuccess.replace('{n}', state.cloudSaveGames.length)); // v2.9.67: 数据加载完成后,后台填充文件数量 _enrichCloudSaveFileCounts(); } catch (err) { state.cloudSaveError = err.message || 'UNKNOWN_ERROR'; console.error('[SGLV] 云存档数据获取失败:', err); } finally { state.cloudSaveLoading = false; renderBody(); } } // v2.9.39: 启动时尝试从 IDB 加载缓存(非阻塞)—— 即使后端 fetchGlobalCloudSaves 因 _memCache 未初始化而抓取网络 // 也能保证先看到本地缓存数据 async function _tryLoadCloudSaveFromIDBCache() { if (state.cloudSaveLoaded && state.cloudSaveGames.length > 0) return; // 已有内存数据 if (!window.SGLVCloudSave?.getCachedCloudSavesAsync) return; try { const cached = await window.SGLVCloudSave.getCachedCloudSavesAsync(); if (cached && cached.games && cached.games.length > 0) { // v2.9.39: 清除旧 _status 标记,避免误显示"新增/变化"徽章 cached.games.forEach(g => { if (g._status) g._status = null; }); state.cloudSaveGames = cached.games; state.cloudSaveSource = 'cache'; state.cloudSaveLastUpdate = cached.lastUpdate || 0; // v2.9.74: 从 IDB 缓存恢复 fileCountAt,避免页面刷新后丢失导致每次都重新获取文件数 state.cloudSaveFileCountAt = cached.fileCountAt || 0; state.cloudSaveDiff = null; state.cloudSaveLoaded = true; state.cloudSaveError = null; console.log(`[SGLV] 云存档 IDB 缓存命中: ${cached.games.length} 款游戏, 更新于 ${new Date(cached.lastUpdate).toLocaleString()}`); renderBody(); return true; } } catch (e) { console.warn('[SGLV] 云存档 IDB 缓存加载失败:', e); } return false; } // v2.9.39: 后台静默增量更新(只增不减 + 6h 节流 + 2天 TTL) // - 已有缓存时立即显示缓存数据,后台增量抓取并合并 // - 节流:6h 内不重复后台刷新(避免频繁抓取被 Steam 限流) // - TTL:2 天未更新才触发后台刷新(避免无意义抓取) // - additive: 旧游戏中未被新抓取覆盖的项保留(不会显示"删除") async function _backgroundRefreshCloudSave() { if (state.cloudSaveBgRefresh) return; // 已有后台刷新进行中 if (!window.SGLVCloudSave?.fetchGlobalCloudSaves) return; if (Date.now() - (state.cloudSaveBgRefreshAt || 0) < CLOUDSAVE_BG_REFRESH_INTERVAL) { return; // 6h 节流 } state.cloudSaveBgRefresh = true; state.cloudSaveBgRefreshAt = Date.now(); const prevCount = state.cloudSaveGames.length; try { // additive=true 保留旧数据;silent=true 不修改 lastUpdate(避免影响节流) // 这里不传 force,因为 lib 内部 TTL 已处理(仅当 lastUpdate 超 2天才抓取) const result = await window.SGLVCloudSave.fetchGlobalCloudSaves(false, { additive: true, silent: true }); if (result && result.games) { state.cloudSaveGames = result.games; state.cloudSaveSource = result.source; state.cloudSaveLastUpdate = result.lastUpdate; state.cloudSaveDiff = result.diff; state.cloudSaveLoaded = true; state.cloudSaveError = null; const newCount = result.games.length - prevCount; if (newCount > 0) { console.log(`[SGLV] 云存档后台增量更新: 新增 ${newCount} 款, 总计 ${result.games.length} 款`); showToast(T.csBgRefreshDone.replace('{n}', newCount)); } renderBody(); } } catch (err) { console.warn('[SGLV] 云存档后台刷新失败:', err); } finally { state.cloudSaveBgRefresh = false; } } // v2.9.67: 后台渐进式填充云存档文件数量 // 全局云存档页面不直接展示文件数,需逐一请求单App页面解析 accountTable tbody tr 行数 // 参考 Steam 云.html 页面结构: appid=7 (Steam Client) 有 2 个文件 = tbody 内 2 个 tr // - v2.9.74: 5天 TTL(云存档相对固定),fileCountAt 从 IDB 缓存恢复,避免每次页面刷新都重新获取 // - 并发 3,每请求间隔 500ms // - 每获取到一个游戏的文件数就刷新 UI(渐进式更新) let _csFileCountAbort = null; async function _enrichCloudSaveFileCounts() { if (state.cloudSaveFileCountEnriching) return; if (!window.SGLVCloudSave?.enrichFileCounts) return; // v2.9.74: 5天 TTL——fileCountAt 从 IDB 缓存恢复,避免每次页面刷新都重新获取 if (Date.now() - (state.cloudSaveFileCountAt || 0) < CLOUDSAVE_FILECOUNT_INTERVAL) return; // 检查是否有需要填充的游戏(fileCount=0) const needEnrich = state.cloudSaveGames.some(g => g.appid && (!g.fileCount || g.fileCount === 0)); if (!needEnrich) return; state.cloudSaveFileCountEnriching = true; state.cloudSaveFileCountAt = Date.now(); _csFileCountAbort = new AbortController(); let renderTimer = null; try { console.log(`[SGLV] 云存档文件数填充开始 (${state.cloudSaveGames.filter(g => !g.fileCount || g.fileCount === 0).length} 款待获取)`); await window.SGLVCloudSave.enrichFileCounts(state.cloudSaveGames, { concurrency: 3, delay: 500, signal: _csFileCountAbort.signal, onProgress: (updatedGame, allGames) => { // 节流渲染:每 800ms 最多刷新一次 UI if (!renderTimer) { renderTimer = setTimeout(() => { renderTimer = null; if (state.activeTab === 'cloudsave') renderBody(); }, 800); } }, }); console.log('[SGLV] 云存档文件数填充完成'); } catch (err) { console.warn('[SGLV] 云存档文件数填充失败:', err); } finally { state.cloudSaveFileCountEnriching = false; _csFileCountAbort = null; // 最终刷新一次 UI if (state.activeTab === 'cloudsave') renderBody(); } } // v2.9.39: 首次进入云存档标签页时调用——先显示缓存,再后台增量更新 async function _initCloudSaveTab() { // 1) 立即从 IDB 加载缓存(同步显示,无需等待) const loaded = await _tryLoadCloudSaveFromIDBCache(); // 2) 缓存为空时走原 fetch 流程(用户首次使用 / 清缓存) if (!loaded) { await loadCloudSaveData(false); return; } // 3) 已有缓存 → 后台静默增量更新(不阻塞 UI) _backgroundRefreshCloudSave(); // v2.9.67: 后台渐进式填充文件数量(v2.9.74: 5天 TTL) _enrichCloudSaveFileCounts(); } function renderCloudSaveDashboard(parent, wrap) { const games = state.cloudSaveGames; const fmt = window.SGLVCloudSave.formatSize; // v2.9.36: 构建 appid → 游戏名称 映射(从已拥有游戏库中查找) const _ownedGameMap = {}; if (state.ownedGames && state.ownedGames.length > 0) { state.ownedGames.forEach(g => { if (g.appid) _ownedGameMap[String(g.appid)] = g.name || ''; }); } // 云存档游戏名称解析:优先用游戏库名称,回退到原始 name 字段 function resolveGameName(csGame) { const appid = String(csGame.appid || ''); if (_ownedGameMap[appid]) return _ownedGameMap[appid]; // 如果原始 name 不是 "显示文件" 等无意义值,直接使用 const raw = csGame.name || ''; if (raw && raw !== '显示文件' && raw !== 'Show Files' && !/^App\s+\d+$/.test(raw)) return raw; return `App ${appid}`; } // v2.9.44: 封面降级逻辑已抽取到 sglv-cover-fallback.lib.js(@require 引用) // 这里只做轻量适配:lib 不可用时安全降级(返回空字符串 + onerror 自然触发 placeholder) function getCsCoverUrl(appId) { if (window.SGLVCoverFallback && window.SGLVCoverFallback.getCoverUrl) { return window.SGLVCoverFallback.getCoverUrl(appId); } // 兜底:直接用 cloudflare capsule(兼容 lib 未加载场景) return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_231x87.jpg`; } // ---- KPI 计算 ---- const totalGames = games.length; const totalSize = games.reduce((s, g) => s + (g.sizeBytes || 0), 0); const totalFiles = games.reduce((s, g) => s + (g.fileCount || 0), 0); const avgSize = totalGames > 0 ? Math.round(totalSize / totalGames) : 0; // ---- 大小分布 ---- const sizeBuckets = [0, 0, 0, 0, 0]; // <1MB, 1-10, 10-50, 50-100, 100+ const sizeLabels = [T.csSizeLt1, T.csSize1_10, T.csSize10_50, T.csSize50_100, T.csSize100plus]; games.forEach(g => { const mb = (g.sizeBytes || 0) / (1024 * 1024); if (mb < 1) sizeBuckets[0]++; else if (mb < 10) sizeBuckets[1]++; else if (mb < 50) sizeBuckets[2]++; else if (mb < 100) sizeBuckets[3]++; else sizeBuckets[4]++; }); // ---- 文件数分布 ---- // v2.9.73: 优化分桶——大多数游戏存档数 <10,旧 <5/5-20/20-50/50+ 颗粒度太粗 const fileBuckets = [0, 0, 0, 0]; // <3, 3-5, 5-10, 10+ const fileLabels = [T.csFilesLt3, T.csFiles3_5, T.csFiles5_10, T.csFiles10plus]; games.forEach(g => { const f = g.fileCount || 0; if (f < 3) fileBuckets[0]++; else if (f <= 5) fileBuckets[1]++; else if (f <= 10) fileBuckets[2]++; else fileBuckets[3]++; }); // ---- TOP 10 按大小 ---- const top10 = [...games].sort((a, b) => (b.sizeBytes || 0) - (a.sizeBytes || 0)).slice(0, 10); const maxTopSize = top10[0]?.sizeBytes || 1; // ---- Diff 汇总 ---- const diff = state.cloudSaveDiff; const hasDiff = diff && (diff.new.length || diff.changed.length || diff.removed.length); // ---- 来源/时间 ---- const sourceLabel = state.cloudSaveSource === 'web' ? T.csSourceWeb : T.csSourceCache; const updatedTime = state.cloudSaveLastUpdate ? new Date(state.cloudSaveLastUpdate).toLocaleString() : ''; // v2.9.74: 文件数更新时间独立显示,避免与存档数据更新时间混淆 const fileCountTime = state.cloudSaveFileCountAt ? new Date(state.cloudSaveFileCountAt).toLocaleString() : ''; // ============ 左侧仪表盘 ============ const side = document.createElement('div'); side.className = 'sglv-cs-side'; side.innerHTML = `
${ICONS.cloud}${T.csTotalGames}
${totalGames.toLocaleString()}
${isZh ? '云存档游戏' : 'cloud games'}
${ICONS.package}${T.csTotalSize}
${fmt(totalSize)}
${isZh ? '全部存档' : 'all saves'}
${ICONS.card}${T.csTotalFiles}
${totalFiles.toLocaleString()}${state.cloudSaveFileCountEnriching ? ' ' : ''}
${state.cloudSaveFileCountEnriching ? (isZh ? '正在获取文件数…' : 'Fetching file counts…') : (isZh ? '存档文件' : 'save files')}
${ICONS.trend}${T.csAvgSize}
${fmt(avgSize)}
${isZh ? '平均每款' : 'avg per game'}
${T.csTopSizes}
${top10.map((g, i) => { const gName = resolveGameName(g); return `
${i + 1} ${gName}
${fmt(g.sizeBytes || 0)}
`; }).join('')}
${T.csSizeDist}
${sizeBuckets.map((v, i) => { const mx = Math.max(...sizeBuckets, 1); return `
${v} ${sizeLabels[i]}
`; }).join('')}
${T.csFileDist}
${fileBuckets.map((v, i) => { const mx = Math.max(...fileBuckets, 1); return `
${v} ${fileLabels[i]}
`; }).join('')}
${hasDiff ? `
${diff.new.length ? `${T.csBadgeNew} ${diff.new.length}` : ''} ${diff.changed.length ? `${T.csBadgeChanged} ${diff.changed.length}` : ''} ${diff.removed.length ? `${T.csBadgeRemoved} ${diff.removed.length}` : ''}
` : ''} `; // ============ 右侧游戏列表 ============ const listWrap = document.createElement('div'); listWrap.className = 'sglv-cs-list-wrap'; const toolbar = document.createElement('div'); toolbar.className = 'sglv-cs-toolbar'; toolbar.innerHTML = ` `; const listHeader = document.createElement('div'); listHeader.className = 'sglv-cs-list-header'; listHeader.innerHTML = `${T.csGameList}${games.length}`; const listEl = document.createElement('div'); listEl.className = 'sglv-cs-game-list'; listWrap.appendChild(toolbar); listWrap.appendChild(listHeader); listWrap.appendChild(listEl); // ---- 渲染游戏列表(带搜索+排序) ---- function renderGameList() { let list = [...state.cloudSaveGames]; if (state.cloudSaveSearch) { const q = state.cloudSaveSearch.toLowerCase(); // v2.9.36: 搜索同时匹配解析后的游戏名称 list = list.filter(g => { const resolved = resolveGameName(g).toLowerCase(); return resolved.includes(q) || String(g.appid).includes(q) || (g.name || '').toLowerCase().includes(q); }); } switch (state.cloudSaveSort) { case 'sizeAsc': list.sort((a, b) => (a.sizeBytes || 0) - (b.sizeBytes || 0)); break; case 'filesDesc': list.sort((a, b) => (b.fileCount || 0) - (a.fileCount || 0)); break; case 'nameAsc': list.sort((a, b) => resolveGameName(a).localeCompare(resolveGameName(b))); break; default: list.sort((a, b) => (b.sizeBytes || 0) - (a.sizeBytes || 0)); } if (list.length === 0) { listEl.innerHTML = `
${isZh ? '无匹配结果' : 'No matching results'}
`; return; } listEl.innerHTML = list.map(g => { const gName = resolveGameName(g); const badge = g._status === 'new' ? `${T.csBadgeNew}` : g._status === 'changed' ? `${T.csBadgeChanged}` : ''; // v2.9.44: 游戏封面图(三级降级,data 属性触发全局事件委托) // marker 从 data-sglv-cs-cover 改为通用的 data-sglv-cover(lib 通用 marker) const coverHtml = g.appid ? `${gName}` : ''; return `
${coverHtml}
${gName} App ${g.appid}
${badge} ${g.fileCount || 0} ${T.csFiles} ${g.totalSize || fmt(g.sizeBytes || 0)}
`; }).join(''); } // 组装 wrap.appendChild(side); wrap.appendChild(listWrap); parent.innerHTML = ''; parent.appendChild(wrap); renderGameList(); // ---- 事件绑定 ---- const searchInput = wrap.querySelector('.sglv-cs-search'); if (searchInput) { let timer; searchInput.addEventListener('input', (e) => { clearTimeout(timer); timer = setTimeout(() => { state.cloudSaveSearch = e.target.value; renderGameList(); }, 200); }); } const sortSelect = wrap.querySelector('.sglv-cs-sort'); if (sortSelect) { sortSelect.addEventListener('change', (e) => { state.cloudSaveSort = e.target.value; renderGameList(); }); } const refreshBtn = wrap.querySelector('#sglv-cs-refresh'); if (refreshBtn) { // v2.9.39: 手动刷新按钮也使用 additive 模式(只增不减),保护已有游戏列表不被误删 // v2.9.74: 手动刷新同时重置文件数 TTL,让新增游戏的文件数能立即获取 refreshBtn.addEventListener('click', () => { state.cloudSaveFileCountAt = 0; loadCloudSaveDataAdditive(true); }); } // v2.9.44: 绑定封面降级事件委托(@require sglv-cover-fallback.lib.js) // onFailed 回调:所有降级链失败后,显示右侧 N/A fallback 文本 if (window.SGLVCoverFallback) { window.SGLVCoverFallback.bindErrorHandler(wrap, (img) => { if (img.nextElementSibling) img.nextElementSibling.style.display = 'flex'; }); } else { console.warn('[SGLV] SGLVCoverFallback 未加载 — 封面降级不可用 (请检查 @require sglv-cover-fallback.lib.js)'); } } // v2.9.39: additive 模式加载(保留旧数据中未在新抓取中出现的项) async function loadCloudSaveDataAdditive(force) { state.cloudSaveLoading = true; state.cloudSaveError = null; renderBody(); try { const result = await window.SGLVCloudSave.fetchGlobalCloudSaves(force, { additive: true, silent: !force }); state.cloudSaveGames = result.games || []; state.cloudSaveSource = result.source; state.cloudSaveLastUpdate = result.lastUpdate; state.cloudSaveDiff = result.diff; state.cloudSaveLoaded = true; state.cloudSaveError = null; showToast(T.csFetchSuccess.replace('{n}', state.cloudSaveGames.length)); // v2.9.67: 数据加载完成后,后台填充文件数量 _enrichCloudSaveFileCounts(); } catch (err) { state.cloudSaveError = err.message || 'UNKNOWN_ERROR'; console.error('[SGLV] 云存档数据获取失败:', err); } finally { state.cloudSaveLoading = false; renderBody(); } } function renderWishlistTab(parent) { const wrap = document.createElement('div'); wrap.className = 'sglv-wishlist-wrap'; // 左侧仪表盘面板顺序(v2.8.0 调整): // KPI → 待上市游戏 → 我的类别统计 → 热门标签TOP15 → 按年趋势 → 价格区间 → 折扣分布 → 添加时间线 const side = document.createElement('div'); side.className = 'sglv-wl-side'; side.innerHTML = `
`; wrap.appendChild(side); // 右侧:游戏视图(工具栏 + 列表) const main = document.createElement('div'); main.className = 'sglv-wl-main'; const filterBtns = [ { key: 'all', label: T.all }, { key: 'discount', label: T.wlFilterDiscount }, { key: 'coming', label: T.wlFilterComingSoon }, { key: 'inlibrary', label: T.wlFilterInLibrary }, ]; const toolbar = document.createElement('div'); toolbar.className = 'sglv-toolbar'; toolbar.innerHTML = `
`; main.appendChild(toolbar); // v2.6.1: 筛选标签栏 + 分页放同一行,紧凑操作栏 // v2.7.3: 更新提示信息移至筛选按钮右侧(红框标注位置) const filterBar = document.createElement('div'); filterBar.className = 'sglv-filter-bar'; filterBar.innerHTML = `
${filterBtns.map(fb => `` ).join('')}
`; main.appendChild(filterBar); // 游戏列表 const content = document.createElement('div'); content.className = 'sglv-content'; content.id = 'sglv-wishlist-content'; main.appendChild(content); wrap.appendChild(main); parent.appendChild(wrap); // v2.9.15: 首次加载时显示完整骨架屏(KPI + 8 section + 游戏网格),避免页面紧缩 // v2.9.47: 修复首次加载骨架屏不显示——改为 !wishlistLoaded 即显示(不要求 wishlistLoading) if (!state.wishlistLoaded) { renderWishlistSkeleton(wrap); } // 事件绑定 // v2.9.49: debounce 搜索输入,避免愿望单列表每次按键触发完整重渲染 toolbar.querySelector('#sglv-wishlist-search').addEventListener('input', debounce((e) => { state.wishlistSearch = e.target.value; state.wishlistPage = 1; renderWishlistGamesList(); }, 200)); // v2.5.0: 筛选按钮事件——从 filterBar 查找 filterBar.querySelectorAll('.sglv-wl-filter-btn').forEach(btn => { btn.addEventListener('click', () => { state.wishlistFilter = btn.dataset.filter; state.wishlistCategoryFilter = null; // v2.8.0: 清除类别筛选 state.wishlistPage = 1; filterBar.querySelectorAll('.sglv-wl-filter-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderWishlistSide(); // v2.8.0: 刷新类别卡片高亮状态 renderWishlistGamesList(); }); }); // v2.4.3: 视图切换统一处理(card/cover/list 三模式) const setWlViewMode = (mode) => { state.wishlistViewMode = mode; ['card', 'cover', 'list'].forEach(m => { toolbar.querySelector(`#sglv-wl-${m}`)?.classList.toggle('active', m === mode); }); renderWishlistGamesList(); }; toolbar.querySelector('#sglv-wl-card').addEventListener('click', () => setWlViewMode('card')); toolbar.querySelector('#sglv-wl-cover').addEventListener('click', () => setWlViewMode('cover')); toolbar.querySelector('#sglv-wl-list').addEventListener('click', () => setWlViewMode('list')); toolbar.querySelector('#sglv-wl-refresh').addEventListener('click', () => { sglvToast.info(isZh ? '正在刷新愿望单…' : 'Refreshing wishlist…'); loadWishlistGames(true); }); if (!state.wishlistLoaded && !state.wishlistLoading) { loadWishlistGames(); } else { renderWishlistSections(); } } function renderWishlistSections() { if (state.activeTab !== 'wishlist' || !document.getElementById('sglv-wishlist-content')) return; renderWishlistKpi(); renderWishlistSide(); renderWishlistGamesList(); } // KPI:6 卡片仪表盘(参考 steam-portal WishlistKpiRow,含图标+数值+标签+子文本) function renderWishlistKpi() { const el = document.getElementById('sglv-wishlist-kpi'); if (!el) return; if (!state.wishlistLoaded) { el.innerHTML = ''; return; } const s = computeWishlistStatsCached(); const sym = detectWlPriceSymbol(); // 价格格式化:大数用 k,小数保留整数 const fmtPrice = (v) => { if (v >= 10000) return `${sym}${(v / 1000).toFixed(1)}k`; if (v >= 1000) return `${sym}${Math.round(v).toLocaleString()}`; return `${sym}${v.toFixed(0)}`; }; // 最早添加日期 const earliestStr = s.earliestAdded > 0 ? new Date(s.earliestAdded * 1000).toLocaleDateString(isZh ? 'zh-CN' : 'en-US') : '-'; el.className = 'sglv-wl-kpi-row'; el.innerHTML = `
${ICONS.heart}
${s.total.toLocaleString()}
${T.wlTotal}
${T.wlEarliest} ${earliestStr}
${ICONS.dollar}
${fmtPrice(s.totalValue)}
${T.wlKpiTotalValue}
${T.wlAvgPrice} ${fmtPrice(s.avgPrice)} · ${T.wlMedian} ${fmtPrice(s.medianPrice)}
${ICONS.tag}
${s.discountedCount.toLocaleString()}
${T.wlKpiOnSale}
${T.wlRate} ${(s.discountedRate * 100).toFixed(1)}% · ${T.wlAvgDisc} ${s.avgDiscount.toFixed(0)}% · ${T.wlMaxDisc} ${s.maxDiscount}%
${ICONS.rocket}
${s.comingSoonCount.toLocaleString()}
${T.wlKpiComingSoon}
${T.wlRate} ${(s.comingSoonRate * 100).toFixed(1)}%
${ICONS.library}
${s.inLibrary.toLocaleString()}
${T.wlKpiInLib}
${T.wlPurchaseRate} ${(s.purchaseRate * 100).toFixed(1)}%
${ICONS.calendar}
${s.addedByYear.length}
${isZh ? '添加年份' : 'Years'}
${s.addedByYear.length > 0 ? `${s.addedByYear[0][0]} - ${s.addedByYear[s.addedByYear.length - 1][0]}` : '-'}
`; } // 左侧仪表盘:热门标签 TOP 15 + 添加时间线(参考愿望单导出器统计面板样式) const WL_TAG_COLORS = ['#66c0f4', '#a78bfa', '#34d399', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#f97316', '#84cc16', '#6366f1', '#14b8a6', '#e11d48', '#8b5cf6', '#0ea5e9', '#10b981']; function renderWishlistSide() { const tagsEl = document.getElementById('sglv-wl-tags'); const tlEl = document.getElementById('sglv-wl-timeline'); const upEl = document.getElementById('sglv-wl-upcoming'); const ytEl = document.getElementById('sglv-wl-year-trend'); const pdEl = document.getElementById('sglv-wl-price-dist'); const ddEl = document.getElementById('sglv-wl-disc-dist'); const csEl = document.getElementById('sglv-wl-catstats'); if (!tagsEl || !tlEl) return; if (!state.wishlistLoaded) { [tagsEl, tlEl, upEl, ytEl, pdEl, ddEl, csEl].forEach(e => { if (e) e.innerHTML = ''; }); return; } const s = computeWishlistStatsCached(); // 按年添加趋势(参考 steam-portal AddedTimelineChart,迷你竖向柱状图) // v2.3.23: 柱子外套轨道容器确保百分比高度生效;峰值年份高亮突出 if (ytEl) { const maxY = s.addedByYear.length > 0 ? Math.max(...s.addedByYear.map(x => x[1])) : 1; ytEl.innerHTML = `
${ICONS.calendar} ${T.wlAddedByYear}
${s.addedByYear.length === 0 ? `
${T.wlNoData}
` : `
${s.addedByYear.map(([year, count]) => `
${count}
${year}
`).join('')}
`} `; } // 价格区间分布(参考 steam-portal PriceDistChart,竖向柱状图) if (pdEl) { const maxP = s.priceBuckets.length > 0 ? Math.max(...s.priceBuckets.map(b => b.count)) : 1; const hasP = s.priceBuckets.some(b => b.count > 0); pdEl.innerHTML = `
${ICONS.dollar} ${T.wlPriceDist}
${!hasP ? `
${T.wlNoData}
` : `
${s.priceBuckets.map(b => `
${b.count}
${b.label}
`).join('')}
`} `; } // 折扣分布(参考 steam-portal DiscountDistChart,竖向柱状图) if (ddEl) { const maxD = s.discountBuckets.length > 0 ? Math.max(...s.discountBuckets.map(b => b.count)) : 1; const hasD = s.discountBuckets.some(b => b.count > 0); ddEl.innerHTML = `
${ICONS.tag} ${T.wlDiscountDist}
${!hasD ? `
${T.wlNoData}
` : `
${s.discountBuckets.map(b => `
${b.count}
${b.label}
`).join('')}
`} `; } // 热门标签 TOP 15 const maxTag = s.topTags.length > 0 ? s.topTags[0][1] : 1; tagsEl.innerHTML = `
${ICONS.barChart} ${T.wlTopTags}
${s.topTags.length === 0 ? `
${T.wlNoData}
` : s.topTags.map(([name, count], i) => `
${wlEscape(name)}
${count}
`).join('')} `; // 添加时间线(按月,取最近 12 个月) const maxTl = s.timeline.length > 0 ? Math.max(...s.timeline.map(x => x[1])) : 1; tlEl.innerHTML = `
${ICONS.clock} ${T.wlTimeline}
${s.timeline.length === 0 ? `
${T.wlNoData}
` : s.timeline.map(([month, count]) => `
${month}
${count}
`).join('')} `; // v2.7.3: 待上市游戏图表(参考愿望单插件,替换原类型分布) // 按发售时间分组:30天内 / 90天内 / 更远 / 未定日期,并高亮最近即将发售的游戏 if (upEl) { const now = Date.now(); const day30 = 30 * 864e5, day90 = 90 * 864e5; const upcoming = state.wishlistGames .filter(g => g.isComingSoon) .map(g => { let ts = 0; if (g.releaseDate) { const d = new Date(g.releaseDate); if (!isNaN(d.getTime())) ts = d.getTime(); } return { appid: g.appid, name: g.name, ts, releaseDate: g.releaseDate || '' }; }); const within30 = upcoming.filter(g => g.ts > 0 && g.ts - now <= day30 && g.ts - now >= 0).length; const within90 = upcoming.filter(g => g.ts > 0 && g.ts - now > day30 && g.ts - now <= day90).length; const far = upcoming.filter(g => g.ts > 0 && g.ts - now > day90).length; const noDate = upcoming.filter(g => g.ts === 0).length; const maxUp = Math.max(within30, within90, far, noDate, 1); // 最近即将发售的游戏(取第一个有日期且未过期的) const nextRelease = upcoming.filter(g => g.ts > 0 && g.ts >= now).sort((a, b) => a.ts - b.ts)[0]; const upBuckets = [ { label: T.wlUpcoming30d, count: within30, color: '#10b981' }, { label: T.wlUpcoming90d, count: within90, color: '#06b6d4' }, { label: T.wlUpcomingFar, count: far, color: '#a78bfa' }, { label: T.wlUpcomingNoDate, count: noDate, color: '#94a3b8' }, ]; upEl.innerHTML = `
${ICONS.rocket} ${T.wlUpcoming} ${upcoming.length}
${upcoming.length === 0 ? `
${T.wlNoData}
` : `
${upBuckets.map(b => `
${b.label}
${b.count}
`).join('')}
`} ${nextRelease ? `
${ICONS.clock} ${wlEscape(nextRelease.name)} ${nextRelease.releaseDate}
` : ''} `; } // v2.8.0: 我的类别统计(每类别详细统计 + 点击筛选游戏列表) if (csEl) { const catStats = s.categoryStats || []; const uncat = s.uncategorizedStats || { count: 0, totalValue: 0, discountCount: 0, comingSoonCount: 0, inLibraryCount: 0, pct: 0 }; const sym = detectWlPriceSymbol(); const activeCat = state.wishlistCategoryFilter; const catCount = catStats.length; const titleHtml = `
${ICONS.barChart} ${T.wlCatStats} ${catCount}
`; if (s.total === 0) { csEl.innerHTML = `${titleHtml}
${T.wlNoData}
`; } else if (catCount === 0) { csEl.innerHTML = `${titleHtml}
${T.wlUncatEmpty.replace('{n}', uncat.count.toLocaleString())}
`; } else { const maxCount = Math.max(...catStats.map(c => c.count), uncat.count, 1); const renderCatCard = (c, color, isUncat) => { const catId = isUncat ? '__uncat__' : c.id; const isActive = activeCat === catId; const nameStyle = isUncat ? 'font-style:italic;color:var(--sglv-text-secondary)' : ''; return `
${wlEscape(c.name)} ${c.count}${(c.pct * 100).toFixed(0)}%
${ICONS.dollar}${sym}${c.totalValue.toFixed(0)} ${ICONS.tag}${c.discountCount} ${ICONS.rocket}${c.comingSoonCount} ${ICONS.library}${c.inLibraryCount}
`; }; csEl.innerHTML = `${titleHtml}
${catStats.map((c, i) => renderCatCard(c, WL_TAG_COLORS[i % WL_TAG_COLORS.length], false)).join('')} ${uncat.count > 0 ? renderCatCard({ ...uncat, name: T.wlCatStatsNoCat }, '#64748b', true) : ''}
`; // v2.8.0: 点击类别卡片筛选/取消筛选游戏列表 csEl.querySelectorAll('.sglv-wl-cat-card').forEach(card => { card.addEventListener('click', () => { const catId = card.dataset.catId; if (state.wishlistCategoryFilter === catId) { state.wishlistCategoryFilter = null; state.wishlistFilter = 'all'; } else { state.wishlistCategoryFilter = catId; state.wishlistFilter = 'category'; } state.wishlistPage = 1; document.querySelectorAll('.sglv-wl-filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter === state.wishlistFilter)); renderWishlistSide(); renderWishlistGamesList(); }); }); } } } // v2.9.47: 愿望单加载等待动画(参考 DLC spinner 风格) function renderWishlistLoadingHtml() { const stages = [ { pct: 15, text: T.wlLoadStage1 }, { pct: 45, text: T.wlLoadStage2 }, { pct: 75, text: T.wlLoadStage3 }, ]; const stage = stages[state.wishlistLoadStage - 1] || { pct: 10, text: T.wlFetching }; const pct = state.wishlistLoadProgress > 0 ? state.wishlistLoadProgress : stage.pct; const spinSvg = ''; return `
${spinSvg}
${stage.text}
${T.wlLoadSub}
`; } function renderWishlistGamesList() { const content = document.getElementById('sglv-wishlist-content'); if (!content) return; // v2.5.0: 辅助函数——清空工具栏分页 const clearPagination = () => { const p = document.getElementById('sglv-wishlist-pagination'); if (p) p.innerHTML = ''; }; if (state.wishlistLoading) { // v2.9.49: 优化加载状态更新——已有 loading 容器时仅更新文本和进度条,避免 spinner 动画重启闪烁 const existing = content.querySelector('.sglv-wl-loading'); if (existing) { const stages = [ { pct: 15, text: T.wlLoadStage1 }, { pct: 45, text: T.wlLoadStage2 }, { pct: 75, text: T.wlLoadStage3 }, ]; const stage = stages[state.wishlistLoadStage - 1] || { pct: 10, text: T.wlFetching }; const pct = state.wishlistLoadProgress > 0 ? state.wishlistLoadProgress : stage.pct; const textEl = existing.querySelector('.sglv-wl-loading-text'); const barEl = existing.querySelector('.sglv-wl-loading-bar-fill'); if (textEl) textEl.textContent = stage.text; if (barEl) barEl.style.width = pct + '%'; } else { content.innerHTML = renderWishlistLoadingHtml(); } clearPagination(); return; } if (!state.wishlistLoaded || state.wishlistGames.length === 0) { content.innerHTML = `
${state.wishlistLoaded ? T.wlNoData : T.wlFetching}
`; clearPagination(); return; } const games = getFilteredWishlist(); if (games.length === 0) { content.innerHTML = `
${T.wlNoData}
`; clearPagination(); return; } // v2.4.0: 复用 paginate 纯函数统一分页计算 const { pageItems: pageGames, totalPages, page: _clampedPage } = paginate(games, state.wishlistPage, state.wishlistPageSize); state.wishlistPage = _clampedPage; const ownedIds = getWishlistOwnedIds(); const sym = detectWlPriceSymbol(); const isOwned = g => ownedIds.has(Number(g.appid)) || ownedIds.has(String(g.appid)); // v2.3.23: 在库状态判定集合——个人在库优先,其次家庭共享(ownedGames 未加载时退化为旧集合) const myOwnedIds = new Set(); const familySharedIds = new Set(); state.ownedGames.forEach(og => { if (isGameOwnedByMe(og)) myOwnedIds.add(Number(og.appid)); else familySharedIds.add(Number(og.appid)); }); const showFamily = storage.getShowFamilyShared(); const ownershipBadgeHtml = g => { const id = Number(g.appid); const ownedByMe = state.ownedGames.length > 0 ? myOwnedIds.has(id) : isOwned(g); if (ownedByMe) return `✓ ${T.wlInLibrary}`; // v2.9.67: 家庭共享标签改为图标+“共享”汉字,固定在价格列最右侧,避免换行 if (showFamily && familySharedIds.has(id)) { const _fsIcon = ICONS.familyShare.replace('${_fsIcon}${isZh ? '共享' : 'Shared'}`; } return ''; }; const priceHtml = g => { if (g.isComingSoon) return `${T.wlComingSoon}`; if (g.isFree) return `${T.wlFree}`; if (!g._priced || g.finalPrice <= 0) return `${T.wlNoPrice}`; const orig = (g.discountPct > 0 && g.originalPrice > g.finalPrice) ? `${sym}${g.originalPrice.toFixed(2)}` : ''; return `${sym}${g.finalPrice.toFixed(2)}${orig}`; }; const addedText = g => g.added > 0 ? new Date(g.added * 1000).toLocaleDateString(isZh ? 'zh-CN' : 'en-US') : '-'; // v2.3.23: 愿望单排序位次徽章(priority = 用户愿望单排名,TOP3 金银铜高亮,tooltip 说明含义) const rankHtml = g => g.priority > 0 ? `#${g.priority}` : ''; // v2.3.23: 卡片正文/列表信息 HTML 抽取,供增量更新复用 // 卡片精简为 3 行:①名称+AppID ②价格+在库状态(同行,移除折扣徽章)③排序位次+时间图标日期 const cardBodyHtml = g => `
${wlEscape(g.name)}${g.appid}
${priceHtml(g)} ${ownershipBadgeHtml(g)}
${rankHtml(g)} ${ICONS.clock}${addedText(g)}
`; const listInfoHtml = g => `
${wlEscape(g.name)}${g.appid}
${rankHtml(g)} ${ownershipBadgeHtml(g)} ${ICONS.clock}${addedText(g)}
`; // v2.4.3: 横板封面视图正文(参考 steam-family-game-analysis v1.58 fa-wl-cover-card 布局精简) // 在库/家庭共享徽章以浮层形式叠在封面上(见渲染分支的 sglv-wl-cover-badges),正文不含 const coverBodyHtml = g => `
${wlEscape(g.name)}${g.appid}
${priceHtml(g)} ${rankHtml(g)}
${ICONS.clock}${addedText(g)}
`; // v2.3.23: 增量更新——视图模式与当前页 appid 集合未变时,仅更新文本节点, // 保留已加载的封面 元素,修复详情补全刷新时封面从空白到出现反复闪烁的问题 const idsKey = state.wishlistViewMode + ':' + pageGames.map(g => g.appid).join(','); const existView = content.querySelector('[data-ids-key]'); if (existView && existView.dataset.idsKey === idsKey) { pageGames.forEach(g => { if (state.wishlistViewMode === 'card') { const body = existView.querySelector(`.sglv-game-card[data-appid="${g.appid}"] .sglv-card-body`); if (body) body.innerHTML = cardBodyHtml(g); } else if (state.wishlistViewMode === 'cover') { // v2.4.3: 封面视图增量更新——正文 + 封面浮层徽章,保留已加载的封面 const body = existView.querySelector(`.sglv-wl-cover-card[data-appid="${g.appid}"] .sglv-wl-cover-body`); if (body) body.innerHTML = coverBodyHtml(g); const badges = existView.querySelector(`.sglv-wl-cover-card[data-appid="${g.appid}"] .sglv-wl-cover-badges`); if (badges) badges.innerHTML = ownershipBadgeHtml(g); } else { const item = existView.querySelector(`.sglv-list-item[data-appid="${g.appid}"]`); if (!item) return; const info = item.querySelector('.sglv-list-info'); if (info) info.innerHTML = listInfoHtml(g); const priceBox = item.querySelector('.sglv-list-price'); if (priceBox) priceBox.innerHTML = priceHtml(g); } }); // v2.3.33:增量更新后也需异步加载中文名 existView.querySelectorAll('[data-sglv-appid]').forEach(el => { loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); } else if (state.wishlistViewMode === 'card') { content.innerHTML = `
${pageGames.map(g => `
${posterImg(g.appid, wlEscape(g.name))}
${cardBodyHtml(g)}
`).join('')}
`; } else if (state.wishlistViewMode === 'cover') { // v2.4.3: 横板封面视图(每行3个,参考 steam-family-game-analysis v1.58 fa-wl-cover-grid) content.innerHTML = `
${pageGames.map(g => `
${capsuleImg(g.appid, wlEscape(g.name))}
${ownershipBadgeHtml(g)}
${coverBodyHtml(g)}
`).join('')}
`; } else { content.innerHTML = `
${pageGames.map(g => `
${wlEscape(g.name)}
${listInfoHtml(g)}
${priceHtml(g)}
`).join('')}
`; } // v2.5.0: 分页——使用工具栏内嵌的分页元素(始终可见,不随内容滚动) const pagination = document.getElementById('sglv-wishlist-pagination'); if (pagination) { renderPagination(pagination, 'sglv-wishlist', state.wishlistPage, totalPages, games.length, () => { state.wishlistPage--; renderWishlistGamesList(); }, () => { state.wishlistPage++; renderWishlistGamesList(); }, (p) => { state.wishlistPage = p; renderWishlistGamesList(); }); } // v2.3.33:异步加载游戏中文名(覆盖 SSR/API 返回的英文名) content.querySelectorAll('[data-sglv-appid]').forEach(el => { loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); } // ==================== 数据导出 ==================== function downloadFile(content, filename, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function getExportData() { let list = [...state.ownedGames]; const showFamilyShared = storage.getShowFamilyShared(); if (!showFamilyShared) { list = list.filter(g => isGameOwnedByMe(g)); } if (state.searchQuery) { const q = state.searchQuery.toLowerCase(); list = list.filter(g => g.name.toLowerCase().includes(q)); } list.sort((a, b) => a.name.localeCompare(b.name)); return list; } function exportCSV() { const data = getExportData(); if (!data.length) { showToast(isZh ? '没有可导出的数据' : 'No data to export'); return; } const headers = ['AppID', 'GameName', 'PlaytimeHours', 'IsOwned', 'Owners', 'OwnerCount', 'AcquiredTime', 'Publisher', 'Series']; const rows = data.map(g => { const isOwned = isGameOwnedByMe(g); const ownerNames = getGameOwnerNames(g); const meta = getGameDBMeta(g.appid); const playtimeHours = (g.playtime / 60).toFixed(1); const acquiredTime = g.lastPlayed ? new Date(g.lastPlayed * 1000).toISOString() : ''; return [ g.appid, `"${(g.name || '').replace(/"/g, '""')}"`, playtimeHours, isOwned ? 'Yes' : 'No', `"${ownerNames.replace(/"/g, '""')}"`, g.owners ? g.owners.length : 1, acquiredTime, `"${(meta.publisher || '').replace(/"/g, '""')}"`, `"${(meta.series || '').replace(/"/g, '""')}"` ]; }); const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); downloadFile(csv, `steam_games_${Date.now()}.csv`, 'text/csv;charset=utf-8'); } function exportJSON() { const data = getExportData(); if (!data.length) { showToast(isZh ? '没有可导出的数据' : 'No data to export'); return; } const json = JSON.stringify(data.map(g => { const isOwned = isGameOwnedByMe(g); const ownerNames = getGameOwnerNames(g); const meta = getGameDBMeta(g.appid); return { appid: g.appid, name: g.name, playtime_hours: Number((g.playtime / 60).toFixed(1)), is_owned: isOwned, owners: g.owners || [], owner_names: ownerNames, owner_count: g.owners ? g.owners.length : 1, acquired_time: g.lastPlayed ? new Date(g.lastPlayed * 1000).toISOString() : null, publisher: meta.publisher || null, series: meta.series || null }; }), null, 2); downloadFile(json, `steam_games_${Date.now()}.json`, 'application/json'); } // ==================== 数据获取流程 ==================== async function startFetch(force = false) { const steamId = getActiveSteamId(); const apiKey = storage.getApiKey(); const authToken = await getAccessToken(); if (!steamId) { showToast(isZh ? '未检测到 SteamID,请先在设置中填写' : 'SteamID not detected. Please configure in settings.'); state.showSettings = true; renderBody(); return; } // API Key 是获取完整游戏库+时长的首选;无 Key 时尝试 scraping / access_token 兜底 if (!apiKey) { showToast(isZh ? '未设置 API Key,将尝试页面抓取(数据可能不完整)' : 'No API Key set, will try page scraping (data may be incomplete).'); } const refreshBtn = document.getElementById('sglv-refresh-btn'); const setLoading = (loading) => { if (refreshBtn) refreshBtn.classList.toggle('sglv-spin', loading); }; if (force) { // 强制刷新:清空缓存中的游戏数据(保留设置) storage.setCachedGames([]); state.ownedGames = []; state.ownedAppIds = new Set(); invalidateActiveOwnedAppIdsCache(); clearComputedCache(); // v2.1: 同时清除侧边栏 TTL 缓存 cacheClear('achievements_'); cacheClear('globalAchievements_'); cacheClear('news_'); cacheClear('historyPrices_'); cacheClear('familyShare_'); } setLoading(true); state.isLoading = true; // v2.9.15: 多阶段状态机进度条 StageProgress.show(); try { // 阶段1/4: 拉取库存(带 3 子步进度回调:token→owned→family→dynamicstore) StageProgress.setStage(1, 4, isZh ? '正在拉取库存…' : 'Fetching owned games…'); const games = await fetchAllGames((sub, pct, text) => { // sub 1-3 都映射到阶段 1 的 0-30% 区间 const mappedPct = Math.min(30, Math.round(pct * 0.3)); StageProgress.setProgress(mappedPct, text); }); if (games.length === 0) { StageProgress.fail(isZh ? '未获取到游戏数据' : 'No games fetched'); showToast(isZh ? '未获取到游戏数据,请检查 SteamID、API Key 或登录状态' : 'No games fetched. Check SteamID, API Key or login status.'); return; } games.sort((a, b) => a.name.localeCompare(b.name)); state.ownedGames = games; state.ownedAppIds = new Set(games.map(g => g.appid)); storage.setCachedGames(games); _activeSteamId = null; // Invalidate cache! invalidateActiveOwnedAppIdsCache(); markSearchIndexDirty(); // v2.9.51: 搜索索引依赖 ownedGames,标记为脏 document.dispatchEvent(new CustomEvent('sglv:games-updated', { detail: { count: games.length } })); StageProgress.setProgress(30, isZh ? `已获取 ${games.length} 款游戏,补全详情…` : `Got ${games.length} games, enriching…`); // 阶段2/4: 补全 appdetails(带进度回调,映射到 30-60% 区间) StageProgress.setStage(2, 4, isZh ? '正在补全游戏详情…' : 'Enriching game details…'); if (typeof enrichOwnedAppTypes === 'function') { try { await enrichOwnedAppTypes((pct, text) => { // 阶段 2 区间 30% - 60% const mapped = 30 + Math.round(pct * 0.3); StageProgress.setProgress(mapped, text); }); } catch (e) { console.warn('[SGLV] 补全 appdetails 失败:', e); } } StageProgress.setProgress(60, isZh ? '正在加载进包 / DLC 数据…' : 'Loading bundles / DLC…'); // 阶段3/4: 加载进包 / DLC / 锁区(各自有负缓存,失败不阻塞) // v2.9.15: 3 个并发任务各自报告进度,合并到 60%-90% 区间 StageProgress.setStage(3, 4, isZh ? '正在加载进包 / DLC / 锁区…' : 'Loading bundles / DLC / region…'); const subProgress = { bundle: 0, dlc: 0, blocked: 0 }; const updateCombined = () => { // 3 任务平均 0-100,合并后映射到 60-90% const avg = (subProgress.bundle + subProgress.dlc + subProgress.blocked) / 3; StageProgress.setProgress(60 + Math.round(avg * 0.3)); }; await Promise.allSettled([ typeof loadBundleDatabase === 'function' ? loadBundleDatabase().catch(() => null) : null, typeof loadDlcDatabase === 'function' ? loadDlcDatabase().catch(() => null) : null, typeof loadBlockedApps === 'function' ? loadBlockedApps(force, (stage, text) => { // 锁区 3 步(API/HTML/完成),每步占 1/3 subProgress.blocked = stage * 33; updateCombined(); }).catch(() => null) : null, ].filter(Boolean)); // bundle/dlc 是单次拉取,标记完成 StageProgress.setProgress(90, isZh ? '正在完成渲染准备…' : 'Finalizing…'); // 阶段4/4: 渲染 StageProgress.setStage(4, 4, isZh ? '完成' : 'Done'); sglvToast.success(force ? T.refreshSuccess : T.fetchSuccess.replace('{n}', games.length)); renderBody(); StageProgress.finish(isZh ? '完成' : 'Done'); // v2.9.50: 数据刷新后渐进式更新(不阻塞当前渲染完成) // 立即 renderBody 走 PCC 缓存(可能命中旧数据,跨 session 复用) // 后台异步:enrichOwnedAppTypes 已完成 + 新增的 patch-on-cache-invalidate // 注意:enrichOwnedAppTypes 在阶段 2 已完成,但此时游戏库 inventory 完整;PCC 缓存输入签名 // 包含 acquiredTime 累计,所以 inventory 更新后签名变化,下次 render 会触发重算 // 这里的"渐进式"指:用户已看到页面,后台异步重算大数据并 patch 对应区域(不全量重建) if (typeof _progressiveUpdateAfterFetch === 'function') { _progressiveUpdateAfterFetch(games); } } catch (e) { console.error('[SGLV] Fetch error:', e); StageProgress.fail(e.message || String(e)); sglvToast.error(T.fetchFail + ': ' + (e.message || e)); } finally { state.isLoading = false; setLoading(false); } } // v2.9.50: 数据刷新后渐进式更新 — 立即用缓存渲染后,后台异步重算大数据并局部 patch // 流程: // 1) startFetch 完成 → 立即 renderBody()(走 PCC,可能命中旧数据) // 2) 下一个事件循环:setTimeout(0) 触发 _progressiveUpdateAfterFetch // 3) 后台异步重算(年度统计 / 家庭组 / insight),不影响主线程 // 4) 每个任务完成后,若面板还开着且数据有变化,仅 patch 对应 DOM 节点 // 与 wishlist 模式(reference: refreshWishlistDynamicFields)类似,但作用域更大 // 收益: // - 首次开面板:0 ms 看到页面(PCC 同步预热) // - 数据刷新后:用户立即看到结果(可能旧数据),后台渐进式 patch // - 多次开/关面板:首次同步重算后落 PCC,后续直接命中 function _progressiveUpdateAfterFetch(games) { setTimeout(() => { // 仅在面板还开着且无新加载中时跑(避免用户主动刷新时重复) if (!panelEl || !panelEl.classList.contains('sglv-show')) return; if (state.isLoading) return; try { // 触发大数据重算(后台) — 不强制刷新,只是让 PCC 失效后再算 // 直接调用同步 getBizCached:未命中则同步重算 + 落盘;命中则立即返回 // 这里不直接调用,而是让用户在切到 trend/playtime/insight 标签时按需触发 // 我们预热(预跑)一次,这样切标签时已命中缓存 if (typeof computeYearlyStatsAllCached === 'function' && state.ownedGames.length > 0) { const steamId = getActiveSteamId(); const allGames = state.ownedGames; const hasOwnerData = steamId && allGames.some(g => g.owners && g.owners.length > 0); const otherMembers = []; if (storage.getShowFamilyShared() && hasOwnerData && steamId) { const familyInfo = storage.getFamilyInfo(); const nameMap = familyInfo?.steamIdtoName || {}; otherMembers.push(...Object.entries(nameMap) .filter(([sid]) => sid !== steamId) .map(([sid, name]) => ({ steamid: sid, name }))); } // 同步预热(单次遍历,无重复)— 完成后已落 PCC,下次切 trend 标签时 0 ms 命中 computeYearlyStatsAllCached(allGames, steamId, otherMembers); } // 预热 insight(仅依赖已加载的 state.ownedGames,无需网络) if (typeof computeInsightDataCachedSgis === 'function' && state.ownedGames.length > 0) { computeInsightDataCachedSgis(); } // 预热我的成就 if (typeof computeMyAchievementsCached === 'function' && state.ownedGames.length > 0) { computeMyAchievementsCached(); } // 仅在 trend/playtime/insight/achievements 当前显示时 patch DOM // patch 模式:不全量重建,只更新关键 KPI 数字 _patchActiveTabKpis(); console.log('[SGLV-Compute] 数据刷新后渐进式更新完成 — PCC 已预热'); } catch (e) { console.warn('[SGLV-Compute] 渐进式更新失败:', e); } }, 0); } // v2.9.50: 局部 patch 当前打开的标签页 KPI/图表数据(不重建 DOM 避免闪烁) // 支持:trend(年度统计/家庭组)/playtime/insight(SGIS 子闭包) function _patchActiveTabKpis() { const tab = state.activeTab; if (!tab) return; try { if (tab === 'trend') { // 强制清空 _computedCache 内的 trend 局部缓存(只重算 trend 用的,不影响其他) // 然后只 patch 关键 KPI 区域而不整体 renderBody const trendKpi = document.getElementById('sglv-trend-kpi') || document.querySelector('.sglv-trend-kpi-wrap'); if (trendKpi) { // 简单策略:替换整个 KPI 区域(只 KPI 部分,不动图表)— 用户视觉感知最小 const parent = trendKpi.parentNode; if (parent) { // 备份原节点 const oldHtml = trendKpi.outerHTML; // 清空 _computedCache 让 compute* 重新跑(走 PCC 命中) // 这里我们直接用 PCC 拿到的数据局部 patch const allGames = state.ownedGames; if (allGames.length === 0) return; const steamId = getActiveSteamId(); const hasOwnerData = steamId && allGames.some(g => g.owners && g.owners.length > 0); const otherMembers = []; if (storage.getShowFamilyShared() && hasOwnerData && steamId) { const familyInfo = storage.getFamilyInfo(); const nameMap = familyInfo?.steamIdtoName || {}; otherMembers.push(...Object.entries(nameMap) .filter(([sid]) => sid !== steamId) .map(([sid, name]) => ({ steamid: sid, name }))); } // PCC 已经在 _progressiveUpdateAfterFetch 里预热 // 这里只 patch KPI 数字 const _ya = computeYearlyStatsAllCached(allGames, steamId, otherMembers); if (_ya && _ya.yearly) { // 更新主 KPI 卡片的数字(只 textContent,不重建 DOM) const yearlyStats = _ya.yearly; const now = new Date(); const thisYear = now.getFullYear(); const thisYearIdx = yearlyStats.years.indexOf(thisYear); const thisYearCount = thisYearIdx >= 0 ? yearlyStats.counts[thisYearIdx] : 0; // 找包含"今年"字样的 KPI 卡,只更新其数字 const cards = trendKpi.querySelectorAll('.sglv-trend-kpi-card'); cards.forEach(card => { const label = card.querySelector('.sglv-trend-kpi-label'); if (!label) return; const ltxt = label.textContent || ''; const valueEl = card.querySelector('.sglv-trend-kpi-value'); if (!valueEl) return; if (ltxt.includes(T.trendKpiThisYear)) { valueEl.firstChild && (valueEl.firstChild.nodeValue = String(thisYearCount)); } else if (ltxt.includes(T.trendKpiAvgYear)) { const yc = yearlyStats.years.length; const avg = yc > 0 ? Math.round(yearlyStats.total / yc) : 0; valueEl.firstChild && (valueEl.firstChild.nodeValue = String(avg)); } }); } } } } } catch (e) { // patch 失败时静默回退到 renderBody 整体重建 console.warn('[SGLV-Compute] 局部 patch 失败,回退到 renderBody:', e); try { renderBody(); } catch (_) { /* ignore */ } } } // ==================== 自动初始化扫描 ==================== async function autoScan() { // v2.9.5: 无 API Key 且无缓存时跳过自动扫描(首次使用由 initUI 引导用户配置) if (!storage.getApiKey() && state.ownedGames.length === 0) return; // 有缓存就不扫 if (state.ownedGames.length > 0) return; const steamId = getActiveSteamId(); const authToken = await getAccessToken(); if (!steamId && !authToken) return; console.log('[SGLV] 自动扫描游戏库...'); try { const games = await fetchAllGames(); if (games.length > 0) { games.sort((a, b) => a.name.localeCompare(b.name)); state.ownedGames = games; state.ownedAppIds = new Set(games.map(g => g.appid)); storage.setCachedGames(games); _activeSteamId = null; // Invalidate cache! clearComputedCache(); markSearchIndexDirty(); // v2.9.51: 搜索索引依赖 ownedGames,标记为脏 // 通知侧边栏数据已更新 document.dispatchEvent(new CustomEvent('sglv:games-updated', { detail: { count: games.length, source: 'auto' } })); console.log(`[SGLV] 自动扫描完成,共 ${games.length} 款游戏`); } } catch (e) { console.warn('[SGLV] 自动扫描失败:', e); } } // 导出 SGLV 公开接口(供 bootstrap + SGIS 调用) SGLV_API.initUI = initUI; SGLV_API.autoScan = autoScan; // v2.4.1: 以下函数被 SGIS 子闭包引用,需导出 SGLV_API.buildPersonalTimeline = buildPersonalTimeline; SGLV_API.buildFamilyTimeline = buildFamilyTimeline; SGLV_API.fetchWishlistFromApi = fetchWishlistFromApi; SGLV_API.openGlobalSettings = openGlobalSettings; // v2.9.3: 桥接 DLC 数据库函数到外层作用域,供 getStatFilteredGames 使用 SGLV_API.isDlc = isDlc; SGLV_API.loadDlcDatabase = loadDlcDatabase; SGLV_API.loadDlcDatabaseFromCacheSync = loadDlcDatabaseFromCacheSync; // v2.9.5 SGLV_API.enrichOwnedAppTypes = enrichOwnedAppTypes; // v2.9.6: 供 SGIS 侧边栏调用 SGLV_API.invalidateAppTypeCache = () => { appTypeMap = null; }; // v2.9.6: 游戏库刷新时失效 // v2.9.6: DLC 数据库就绪判定——Barter.vg 数据库 OR Steam type map 任一加载即可 SGLV_API.isDlcDbReady = () => !!(dlcDbData || appTypeMap); // v2.9.61: 导出 renderBody 与 panelEl 访问器,供外层作用域(switchToTab/_gsJumpToGame/bindGlobalSearchEvents)桥接调用 // 背景:v2.9.56 提取 switchToTab 时误置于子闭包之外,导致 panelEl/renderBody 越界 ReferenceError SGLV_API.renderBody = renderBody; SGLV_API.getPanelEl = () => panelEl; // v2.9.68: 桥接 computeInsightDataCachedSgis 供 SGIS 子闭包调用(修复 ReferenceError) SGLV_API.computeInsightDataCachedSgis = computeInsightDataCachedSgis; // v2.9.73: 桥接 PCC hydrate 供 init() 调用(_PCC_CACHE 定义在本子闭包内,init() 在外层作用域无法直接访问) SGLV_API.hydratePccCache = function () { if (!_PCC_CACHE) return; try { const r = _PCC_CACHE.hydrate(); if (r && r.hydrated > 0) console.log(`[SGLV-Compute] PCC IDB hydrate 完成,${r.hydrated} 项已就绪`); } catch (e) { /* noop */ } }; // ==================== v2.9.12: 锁区游戏数据加载 ==================== // 数据源: steam-tracker.com API (Banned + Purchase disabled) + HTML 抓取 (Regional variant) + 手动导入 // v2.9.27: 新增 GitHub 静态数据源(由 py_scripts/scrape_blocked_games.py 预先生成,存放于 SmallRob/steam-namespace),优先加载 const STEAM_TRACKER_API = 'https://steam-tracker.com/api?action=GetAppListV3'; const STEAM_TRACKER_REGIONAL_URL = 'https://steam-tracker.com/user/76561198027066612/apps/10'; // v2.9.27: 静态数据源(jsdelivr CDN 加速),数据由 scrape_blocked_games.py 生成后存放于 steam-namespace 仓库 const BLOCKED_STATIC_URL = 'https://fastly.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/steam_blocked_apps.json'; // 过滤的 category_id: 3=Purchase disabled, 20=Banned const BLOCKED_CATEGORY_IDS = new Set([3, 20]); /** * v2.9.27: 从 GitHub 静态数据源加载锁区游戏数据(jsdelivr CDN 加速) * 数据格式: { version, source, fetchedAt, totalCount, data: [{appid, name, category, categoryId, type, source, changedAt}] } */ function fetchBlockedFromStatic() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: BLOCKED_STATIC_URL, headers: { 'Accept': 'application/json' }, timeout: 15000, onload(r) { if (r.status < 200 || r.status >= 300) { reject(new Error(`HTTP ${r.status}`)); return; } try { const data = JSON.parse(r.responseText); const apps = Array.isArray(data) ? data : (Array.isArray(data.data) ? data.data : []); if (apps.length === 0) { reject(new Error('Empty static data')); return; } resolve(apps.map(a => ({ appid: Number(a.appid) || 0, name: (a.name || '').trim(), category: a.category || '', categoryId: a.categoryId || a.category_id || 0, type: a.type || 'game', source: 'static', changedAt: a.changedAt || a.changed_at || '', }))); } catch (e) { reject(new Error('JSON parse fail')); } }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); } /** * 从 steam-tracker.com API 获取 Banned + Purchase disabled 应用 */ function fetchBlockedFromApi() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: STEAM_TRACKER_API, headers: { 'Accept': 'application/json' }, timeout: 20000, onload(r) { if (r.status < 200 || r.status >= 300) { reject(new Error(`HTTP ${r.status}`)); return; } try { const data = JSON.parse(r.responseText); if (!data || !data.success || !Array.isArray(data.removed_apps)) { reject(new Error('Invalid API response')); return; } const apps = data.removed_apps .filter(a => BLOCKED_CATEGORY_IDS.has(a.category_id)) .map(a => ({ appid: a.appid, name: (a.name || '').trim(), category: a.category || '', categoryId: a.category_id, type: a.type || 'game', source: 'api', changedAt: a.changed_at || '', })); resolve(apps); } catch (e) { reject(new Error('JSON parse fail')); } }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); } /** * 从 steam-tracker.com HTML 页面抓取 Regional variant (cat_id=10) 应用 */ function fetchRegionalVariants() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: STEAM_TRACKER_REGIONAL_URL, headers: { 'Accept': 'text/html', 'Accept-Language': 'en-US,en;q=0.9' }, timeout: 15000, onload(r) { if (r.status < 200 || r.status >= 300) { reject(new Error(`HTTP ${r.status}`)); return; } try { const html = r.responseText; const doc = new DOMParser().parseFromString(html, 'text/html'); const rows = doc.querySelectorAll('table tbody tr'); const apps = []; rows.forEach(row => { const cells = row.querySelectorAll('td'); if (cells.length < 4) return; // 列结构: Owners% | AppID(link to steamdb) | Name(link) | Type | Changed | ... const appidText = cells[1]?.textContent?.trim() || ''; const appid = parseInt(appidText); if (!appid) return; const name = cells[2]?.textContent?.trim() || ''; const changedAt = cells[4]?.textContent?.trim() || ''; apps.push({ appid, name, category: 'Regional variant', categoryId: 10, type: 'game', source: 'html', changedAt, }); }); resolve(apps); } catch (e) { reject(new Error('HTML parse fail')); } }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); } /** * 解析手动导入的 JSON (兼容 srbb_blocked_apps 格式 {appid: {name, at}}) */ function parseManualBlockedJson(text) { const obj = JSON.parse(text); const apps = []; if (Array.isArray(obj)) { // 数组格式 [{appid, name, ...}] obj.forEach(a => { if (a.appid) apps.push({ appid: Number(a.appid), name: String(a.name || '').trim(), category: a.category || 'User imported', categoryId: a.categoryId || 0, type: a.type || 'game', source: 'manual', changedAt: a.changedAt || '', }); }); } else if (obj && typeof obj === 'object') { // srbb_blocked_apps 格式 {appid: {name, at}} Object.entries(obj).forEach(([id, info]) => { const appid = Number(id); if (appid && info && typeof info === 'object') { apps.push({ appid, name: String(info.name || '').trim(), category: 'User imported', categoryId: 0, type: 'game', source: 'manual', changedAt: info.at ? new Date(info.at).toISOString().slice(0, 10) : '', }); } }); } return apps; } /** * 加载锁区游戏数据(v2.9.27: 四级 fallback: 静态源 → API → HTML → 缓存) * @param {boolean} force - 强制刷新 * @param {function} onProgress - (stage, text) 进度回调 */ async function loadBlockedApps(force = false, onProgress) { const report = (stage, text) => { if (typeof onProgress === 'function') onProgress(stage, text); }; if (!force) { const cached = cacheGet('blockedApps'); if (cached && Array.isArray(cached.apps) && cached.apps.length > 0) { state.blockedApps = cached.apps; state.blockedSource = cached.source || 'api'; state.blockedLoaded = true; report(3, isZh ? '缓存命中' : 'Cached'); return { apps: state.blockedApps, source: state.blockedSource }; } // v2.9.15: 负缓存——失败后短期不再重试,保护 steam-tracker.com if (negCacheGet(nsKey('blocked_neg'), 30 * 60 * 1000)) { console.log('[SGLV] blocked apps 命中负缓存,跳过重试'); report(0, isZh ? '负缓存命中,跳过' : 'Negative cache'); return { apps: [], source: 'none' }; } } state.blockedLoading = true; state.blockedError = null; try { const merged = new Map(); let source = 'static'; // v2.9.27 Level 0: GitHub 静态数据源(jsdelivr CDN,优先加载,避免在线抓取网络问题) report(1, isZh ? '正在加载静态数据源…' : 'Fetching static source…'); try { const staticApps = await fetchBlockedFromStatic(); staticApps.forEach(a => merged.set(a.appid, a)); if (merged.size > 0) source = 'static'; } catch (e) { console.warn('[SGLV] 静态数据源失败:', e.message); } // Level 1: API (Banned + Purchase disabled) — 静态源无数据时回退 if (merged.size === 0) { report(1, isZh ? '正在拉取 steam-tracker API…' : 'Fetching API…'); try { const apiApps = await fetchBlockedFromApi(); apiApps.forEach(a => merged.set(a.appid, a)); if (merged.size > 0) source = 'api'; } catch (e) { console.warn('[SGLV] steam-tracker API 失败:', e.message); } } // Level 2: HTML 抓取 (Regional variant) — 补充静态源/API 缺失的 Regional 数据 report(2, isZh ? '正在抓取 HTML 兜底…' : 'Fetching HTML fallback…'); try { const htmlApps = await fetchRegionalVariants(); htmlApps.forEach(a => { if (!merged.has(a.appid)) merged.set(a.appid, a); }); if (merged.size > 0 && source !== 'static') source = 'mixed'; } catch (e) { console.warn('[SGLV] steam-tracker HTML 抓取失败:', e.message); } if (merged.size === 0) { throw new Error('No blocked apps data found'); } const apps = Array.from(merged.values()); state.blockedApps = apps; state.blockedSource = source; state.blockedLoaded = true; state.blockedLoading = false; cacheSet('blockedApps', { apps, source }, CACHE_TTL.blockedApps); // v2.9.15: 成功后清负缓存 negCacheClear(nsKey('blocked_neg')); report(3, isZh ? `完成 (${apps.length} 款)` : `Done (${apps.length})`); return { apps, source }; } catch (e) { state.blockedLoading = false; state.blockedError = e.message || 'Unknown error'; console.warn('[SGLV] 锁区数据加载失败:', e); // v2.9.15: 失败入负缓存(仅当 force=false 时记录,避免用户主动刷新被吞) if (!force) negCacheSet(nsKey('blocked_neg'), e.message || String(e)); throw e; } } /** 检查指定 appid 是否在锁区列表中 */ function isBlockedApp(appid) { if (!state.blockedApps || !state.blockedApps.length) return false; const id = Number(appid); return state.blockedApps.some(a => a.appid === id); } SGLV_API.loadBlockedApps = loadBlockedApps; SGLV_API.isBlockedApp = isBlockedApp; SGLV_API.isBlockedLoaded = () => state.blockedLoaded; })(); // ==================== 信息侧边栏 v2.0 (sgis) ==================== // 与 steam-game-info-sidebar-1.0 同样的能力, 复用本脚本的 state.ownedGames + storage, // 实现"导航栏按钮 + 右侧三角触发器 + 共享 API Key / 家庭组数据" (function () { // v2.4.1: SGLV 子闭包导出的跨模块函数别名(SGLV_API 在外层作用域,SGLV 闭包完成后已填充) const buildPersonalTimeline = SGLV_API.buildPersonalTimeline; const buildFamilyTimeline = SGLV_API.buildFamilyTimeline; const fetchWishlistFromApi = SGLV_API.fetchWishlistFromApi; const openGlobalSettings = SGLV_API.openGlobalSettings; // v2.3.26: 愿望单页面检测 — 跳过浮动按钮创建,避免与 Wishlist Exporter 侧边栏冲突 const IS_WISHLIST_PAGE = /\/wishlist(new)?\//i.test(location.pathname); // 在所有 Steam 商店页面挂载侧边栏 // v2.3.19: 统一页面上下文检测,区分 morelike/app/home 三种页面 // 原正则未锚定开头,/recommended/morelike/app/3767850/ 会被 app 分支误匹配 const PAGE_CTX = (function () { const path = location.pathname; // 1. 相似推荐页(本次改造重点) const moreLike = path.match(/^\/recommended\/morelike\/app\/(\d+)/); if (moreLike) { return { type: 'morelike', appId: moreLike[1], hasAppId: true, hasSimilarGrid: !!document.querySelector('.similar_grid_ctn') }; } // 2. 标准应用详情页(保持原行为) const app = path.match(/^\/(?:agecheck\/)?app\/(\d+)/); if (app) { return { type: 'app', appId: app[1], hasAppId: true, hasSimilarGrid: !!document.querySelector('#steampeek, .similar_grid_ctn') }; } // 3. 首页/社区(profile 模式) return { type: 'home', appId: '', hasAppId: false, hasSimilarGrid: false }; })(); const APP_ID = PAGE_CTX.appId; const HAS_APP_ID = PAGE_CTX.hasAppId; const IS_MORELIKE = PAGE_CTX.type === 'morelike'; // v2.8.0: 新增 UI 组件样式(价格对比表 / 评测 / 跨区送礼推荐) GM_addStyle(` /* === 9区完整价格对比表 === */ .sgis-price-tbl { display:flex; flex-direction:column; gap:1px; } .sgis-price-tbl-header { display:grid; grid-template-columns: 1.6fr 0.9fr 1fr 0.6fr 0.9fr; gap:2px; padding:4px 6px; font-size:9px; color:var(--sgis-text-2); text-transform:uppercase; letter-spacing:0.3px; border-bottom:1px solid rgba(255,255,255,0.06); } .sgis-price-tbl-row { display:grid; grid-template-columns: 1.6fr 0.9fr 1fr 0.6fr 0.9fr; gap:2px; padding:5px 6px; font-size:11px; align-items:center; border-radius:4px; transition:background 0.15s; } .sgis-price-tbl-row:hover { background:rgba(255,255,255,0.04); } .sgis-price-tbl-row-lowest { background:rgba(75,181,79,0.12) !important; border-left:3px solid #4bb54f; } .sgis-price-tbl-row-highest { background:rgba(230,57,70,0.10) !important; border-left:3px solid #e63946; } .sgis-price-tbl-row.user-region { box-shadow: inset 0 0 0 1px rgba(167,139,250,0.3); } .sgis-price-tbl-cell { display:flex; align-items:center; gap:3px; overflow:hidden; } .sgis-price-tbl-region { gap:4px; } .sgis-price-tbl-initial { color:var(--sgis-text-2); font-size:10px; } .sgis-price-tbl-price { font-weight:600; } .sgis-price-tbl-cny { font-weight:700; color:#66c0f4; } .sgis-price-tbl-badge-low { font-size:8px; background:#4bb54f; color:#fff; padding:1px 4px; border-radius:3px; margin-left:2px; } .sgis-price-tbl-badge-high { font-size:8px; background:#e63946; color:#fff; padding:1px 4px; border-radius:3px; margin-left:2px; } /* === 评测标签页 === */ /* === v2.9.15: 评测摘要(Steam 风格大字 + 渐变进度条) === */ .sgis-review-summary { display:flex; flex-direction:column; gap:10px; padding:10px 12px; background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.05); border-radius:8px; } .sgis-review-summary-head { display:flex; align-items:center; gap:12px; } .sgis-review-score { display:flex; align-items:center; gap:12px; flex:1; min-width:0; } .sgis-review-score-icon { font-size:30px; line-height:1; flex-shrink:0; filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3)); } .sgis-review-score-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex:1; } .sgis-review-score-text { font-size:14px; font-weight:700; line-height:1.2; } .sgis-review-score-desc { font-size:10px; color:var(--sgis-text-2); line-height:1.3; } .sgis-review-pct-big { font-size:24px; font-weight:800; font-variant-numeric:tabular-nums; line-height:1; flex-shrink:0; letter-spacing:-0.5px; } .sgis-review-pct-big.pos { color:#4bb54f; } .sgis-review-pct-big.mix { color:#a78bfa; } .sgis-review-pct-big.neg { color:#e63946; } .sgis-review-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:rgba(230,57,70,0.3); box-shadow:inset 0 1px 2px rgba(0,0,0,0.3); } .sgis-review-bar-positive { background:linear-gradient(90deg,#4bb54f 0%,#66c0f4 100%); height:100%; transition:width 0.5s cubic-bezier(0.4,0,0.2,1); box-shadow:0 0 6px rgba(75,181,79,0.4); } .sgis-review-bar-positive.mix { background:linear-gradient(90deg,#a78bfa 0%,#54a0ff 100%); box-shadow:0 0 6px rgba(167,139,250,0.4); } .sgis-review-bar-positive.neg { background:linear-gradient(90deg,#e63946 0%,#ff6b6b 100%); box-shadow:0 0 6px rgba(230,57,70,0.4); } .sgis-review-counts { display:flex; justify-content:space-between; font-size:10px; font-weight:600; } .sgis-review-counts .pos { color:#4bb54f; } .sgis-review-counts .neg { color:#e63946; } .sgis-review-trend { display:flex; gap:10px; font-size:10px; flex-wrap:wrap; padding:6px 8px; background:rgba(255,255,255,0.02); border-radius:6px; } .sgis-review-trend-item { display:flex; flex-direction:column; gap:2px; flex:1; min-width:80px; } .sgis-review-trend-label { color:var(--sgis-text-2); font-size:9px; } .sgis-review-trend-val { font-weight:600; font-variant-numeric:tabular-nums; } .sgis-review-filters { display:flex; flex-wrap:wrap; gap:4px; padding:6px 0; border-top:1px solid rgba(255,255,255,0.06); border-bottom:1px solid rgba(255,255,255,0.06); } .sgis-review-filter-group { display:flex; align-items:center; gap:2px; } .sgis-review-filter-label { font-size:9px; color:var(--sgis-text-2); margin-right:2px; } .sgis-review-filter-btn { font-size:10px; padding:2px 7px; border-radius:10px; border:1px solid rgba(255,255,255,0.1); background:transparent; color:var(--sgis-text-2); cursor:pointer; transition:all 0.15s; } .sgis-review-filter-btn:hover { background:rgba(255,255,255,0.06); } .sgis-review-filter-btn.active { background:rgba(102,192,244,0.2); color:#66c0f4; border-color:rgba(102,192,244,0.4); } .sgis-review-list { display:flex; flex-direction:column; gap:8px; padding:4px 0; } /* v2.9.15: 评测项卡片化——左右色条标识好评/差评 */ .sgis-review-item { position:relative; padding:10px 10px 8px 12px; border-radius:8px; background:rgba(255,255,255,0.025); border:1px solid rgba(255,255,255,0.05); transition:all 0.2s ease; overflow:hidden; } .sgis-review-item::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:linear-gradient(180deg,transparent,rgba(75,181,79,0.5),transparent); opacity:0; transition:opacity 0.2s; } .sgis-review-item.is-positive::before { background:linear-gradient(180deg,transparent,#4bb54f,transparent); opacity:0.7; } .sgis-review-item.is-negative::before { background:linear-gradient(180deg,transparent,#e63946,transparent); opacity:0.7; } .sgis-review-item:hover { background:rgba(255,255,255,0.045); border-color:rgba(139,92,246,0.15); transform:translateY(-1px); box-shadow:0 2px 8px rgba(0,0,0,0.15); } .sgis-review-item.is-positive:hover { border-color:rgba(75,181,79,0.25); } .sgis-review-item.is-negative:hover { border-color:rgba(230,57,70,0.25); } .sgis-review-item-header { display:flex; align-items:center; gap:8px; margin-bottom:6px; } .sgis-review-avatar { width:32px; height:32px; min-width:32px; border-radius:50%; background:rgba(255,255,255,0.08); display:flex; align-items:center; justify-content:center; object-fit:cover; flex-shrink:0; border:1px solid rgba(255,255,255,0.06); } .sgis-review-avatar-link { display:inline-flex; line-height:0; flex-shrink:0; border-radius:50%; transition:transform 0.2s; } .sgis-review-avatar-link:hover { transform:scale(1.08); } .sgis-review-avatar-fallback { background:linear-gradient(135deg,rgba(139,92,246,0.4),rgba(59,130,246,0.3)); color:#fff; font-weight:700; font-size:13px; letter-spacing:0.5px; text-transform:uppercase; } .sgis-review-author-wrap { display:flex; flex-direction:column; gap:1px; min-width:0; flex:1; } .sgis-review-author { font-size:12px; font-weight:600; color:#66c0f4; text-decoration:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color 0.15s; } a.sgis-review-author:hover { color:#93c5fd; } .sgis-review-date { font-size:10px; color:var(--sgis-text-3); font-weight:400; } .sgis-review-rec { font-size:10px; padding:2px 7px; border-radius:4px; font-weight:700; flex-shrink:0; } .sgis-review-rec-yes { background:rgba(75,181,79,0.18); color:#4bb54f; border:1px solid rgba(75,181,79,0.3); } .sgis-review-rec-no { background:rgba(230,57,70,0.18); color:#f87171; border:1px solid rgba(230,57,70,0.3); } .sgis-review-meta { font-size:10px; color:var(--sgis-text-2); display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 4px; } .sgis-review-meta-item { padding:1px 6px; background:rgba(255,255,255,0.04); border-radius:3px; } /* v2.9.15: 计数摘要条——图标 + 数字 + 已筛选徽章 */ .sgis-review-count-summary { display:flex; align-items:center; gap:6px; margin-top:8px; padding:5px 8px; font-size:10px; color:var(--sgis-text-2); background:rgba(139,92,246,0.06); border:1px solid rgba(139,92,246,0.12); border-radius:5px; } .sgis-review-count-summary b { color:#c4b5fd; font-weight:700; } .sgis-review-count-icon { width:12px; height:12px; color:#a78bfa; flex-shrink:0; } .sgis-review-count-icon svg { width:12px; height:12px; } .sgis-review-count-filtered { margin-left:auto; font-size:9px; font-weight:700; padding:1px 6px; background:rgba(167,139,250,0.2); color:#a78bfa; border:1px solid rgba(167,139,250,0.3); border-radius:8px; } .sgis-review-text { font-size:12px; line-height:1.6; color:var(--sgis-text); margin-top:4px; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; cursor:pointer; transition:all 0.15s; padding:4px 0; word-break:break-word; } .sgis-review-text:hover { color:#f8fafc; } .sgis-review-text.expanded { -webkit-line-clamp:unset; overflow:visible; } /* v2.9.15: 评测项操作行(展开/复制)——紧凑胶囊按钮 */ .sgis-review-actions { display:flex; align-items:center; gap:6px; margin-top:6px; } .sgis-review-action-btn { display:inline-flex; align-items:center; gap:4px; padding:2px 8px; font-size:10px; font-weight:600; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); color:var(--sgis-text-2); border-radius:10px; cursor:pointer; transition:all 0.15s; } .sgis-review-action-btn:hover { background:rgba(139,92,246,0.15); border-color:rgba(139,92,246,0.3); color:#c4b5fd; } .sgis-review-action-btn svg { width:11px; height:11px; transition:transform 0.25s; } .sgis-review-expand-btn.is-expanded svg { transform:rotate(180deg); } .sgis-review-action-btn.copied { background:rgba(75,181,79,0.2); border-color:rgba(75,181,79,0.4); color:#4bb54f; } .sgis-review-votes { font-size:10px; color:var(--sgis-text-3); margin-top:6px; display:flex; gap:10px; padding-top:6px; border-top:1px dashed rgba(255,255,255,0.06); } /* v2.9.15: 评测空状态——精致状态点 + 文字 */ .sgis-review-empty { text-align:center; padding:30px 16px; font-size:12px; color:var(--sgis-text-2); display:flex; flex-direction:column; align-items:center; gap:6px; } .sgis-review-empty-icon { width:42px; height:42px; border-radius:12px; background:linear-gradient(135deg,rgba(139,92,246,0.12),rgba(59,130,246,0.06)); border:1px solid rgba(139,92,246,0.18); display:flex; align-items:center; justify-content:center; color:var(--sgis-text-2); margin-bottom:4px; } .sgis-review-empty-icon svg { width:20px; height:20px; } /* === 跨区送礼推荐 === */ .sgis-gift-card { padding:10px; border-radius:8px; background:linear-gradient(135deg, rgba(167,139,250,0.12), rgba(102,192,244,0.08)); border:1px solid rgba(167,139,250,0.2); margin-top:8px; } .sgis-gift-title { font-size:12px; font-weight:700; margin-bottom:6px; display:flex; align-items:center; gap:4px; } .sgis-gift-scheme { font-size:14px; font-weight:700; color:#66c0f4; margin:4px 0; } .sgis-gift-save { font-size:20px; font-weight:800; color:#4bb54f; } .sgis-gift-pct { font-size:11px; color:#4bb54f; font-weight:600; } .sgis-gift-prices { display:flex; gap:12px; margin:6px 0; font-size:10px; } .sgis-gift-price-item { display:flex; flex-direction:column; } .sgis-gift-price-label { color:var(--sgis-text-2); } .sgis-gift-price-val { font-weight:600; } .sgis-gift-warn { font-size:9px; color:#e6a839; margin-top:6px; padding:4px 6px; background:rgba(230,168,57,0.08); border-radius:4px; } .sgis-gift-rule { font-size:9px; color:var(--sgis-text-2); margin-top:4px; } .sgis-gift-alt { font-size:10px; color:var(--sgis-text-2); margin-top:6px; padding-top:6px; border-top:1px dashed rgba(255,255,255,0.06); } /* === v2.9.9: SVG 价格走势图 === */ .sgis-price-chart-wrap { margin-top:8px; border-radius:8px; background:rgba(255,255,255,0.02); padding:8px 6px 4px; border:1px solid rgba(255,255,255,0.05); } .sgis-price-chart-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:4px; } .sgis-price-chart-title { font-size:10px; color:var(--sgis-text-2); font-weight:500; } .sgis-chart-range-group { display:flex; gap:2px; } .sgis-chart-range-btn { font-size:9px; padding:1px 6px; border-radius:8px; border:1px solid rgba(255,255,255,0.08); background:transparent; color:var(--sgis-text-2); cursor:pointer; transition:all 0.15s; } .sgis-chart-range-btn:hover { background:rgba(255,255,255,0.06); } .sgis-chart-range-btn.active { background:rgba(102,192,244,0.2); color:#66c0f4; border-color:rgba(102,192,244,0.4); } /* === v2.9.9: DRM 警告徽章 === */ .sgis-drm-badge { display:inline-flex; align-items:center; gap:3px; font-size:10px; font-weight:600; padding:2px 8px; border-radius:4px; background:rgba(239,68,68,0.15); color:#f87171; border:1px solid rgba(239,68,68,0.25); } .sgis-drm-badge.warn { background:rgba(245,158,11,0.15); color:#fbbf24; border-color:rgba(245,158,11,0.25); } /* === v2.9.9: 中文语音标记 === */ .sgis-audio-badge { display:inline-flex; align-items:center; gap:3px; font-size:10px; font-weight:600; padding:2px 8px; border-radius:4px; background:rgba(75,181,79,0.15); color:#4bb54f; border:1px solid rgba(75,181,79,0.25); } /* === v2.9.11: gamestatus.info 破解状态徽章 === */ .sgis-crack-badge { display:inline-flex; align-items:center; gap:3px; font-size:10px; font-weight:600; padding:2px 8px; border-radius:4px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.06); color:var(--sgis-text); } .sgis-crack-badge svg { width:12px; height:12px; flex-shrink:0; } .sgis-crack-badge.cracked { background:rgba(34,197,94,0.15); color:#22c55e; border-color:rgba(34,197,94,0.25); } .sgis-crack-badge.not-cracked { background:rgba(239,68,68,0.15); color:#f87171; border-color:rgba(239,68,68,0.25); } .sgis-crack-badge.bypass { background:rgba(245,158,11,0.15); color:#fbbf24; border-color:rgba(245,158,11,0.25); } .sgis-crack-badge.release-today { background:rgba(59,130,246,0.15); color:#60a5fa; border-color:rgba(59,130,246,0.25); } .sgis-crack-badge.aaa { background:rgba(168,85,247,0.15); color:#c084fc; border-color:rgba(168,85,247,0.25); } .sgis-crack-protection { display:inline-flex; align-items:center; font-size:9px; font-weight:500; padding:1px 6px; border-radius:3px; background:rgba(255,255,255,0.05); color:var(--sgis-text-2); border:1px solid rgba(255,255,255,0.08); } .sgis-crack-group { display:inline-flex; align-items:center; font-size:9px; font-weight:500; padding:1px 6px; border-radius:3px; background:rgba(20,184,166,0.12); color:#2dd4bf; border:1px solid rgba(20,184,166,0.2); } .sgis-crack-date { font-size:9px; color:var(--sgis-text-2); } .sgis-crack-score { display:inline-flex; align-items:center; gap:2px; font-size:9px; font-weight:600; padding:1px 6px; border-radius:3px; background:rgba(250,204,21,0.12); color:#facc15; border:1px solid rgba(250,204,21,0.2); } .sgis-crack-score svg { width:10px; height:10px; } .sgis-crack-hardware { margin-top:6px; padding:6px 8px; border-radius:6px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.06); } .sgis-crack-hardware-title { font-size:10px; font-weight:600; color:var(--sgis-text-2); margin-bottom:4px; } .sgis-crack-hw-row { display:flex; gap:6px; font-size:10px; line-height:1.5; } .sgis-crack-hw-key { color:var(--sgis-text-2); min-width:32px; font-weight:500; } .sgis-crack-hw-val { color:var(--sgis-text); flex:1; word-break:break-word; } /* === v2.9.15: 评测搜索(flex 布局,按钮不再溢出) === */ .sgis-review-search-wrap { display:flex; align-items:center; gap:6px; margin-bottom:6px; } .sgis-review-search-input { flex:1; min-width:0; width:auto; box-sizing:border-box; font-size:11px; padding:5px 9px; border-radius:6px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:var(--sgis-text); transition:border-color 0.15s; } .sgis-review-search-input:focus { outline:none; border-color:rgba(102,192,244,0.4); } .sgis-review-search-input::placeholder { color:var(--sgis-text-2); font-size:10px; } .sgis-review-regex-toggle { flex-shrink:0; font-size:10px; padding:4px 8px; border-radius:6px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:var(--sgis-text-2); cursor:pointer; transition:all 0.15s; font-family:monospace; font-weight:600; letter-spacing:0.5px; } .sgis-review-regex-toggle:hover { background:rgba(167,139,250,0.1); border-color:rgba(167,139,250,0.3); } .sgis-review-regex-toggle.active { background:rgba(167,139,250,0.2); color:#a78bfa; border-color:rgba(167,139,250,0.4); box-shadow:0 0 0 1px rgba(167,139,250,0.2); } .sgis-review-search-clear { flex-shrink:0; width:22px; height:22px; padding:0; border-radius:50%; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:var(--sgis-text-2); cursor:pointer; transition:all 0.15s; display:flex; align-items:center; justify-content:center; font-size:13px; line-height:1; } .sgis-review-search-clear:hover { background:rgba(230,57,70,0.15); color:#e63946; border-color:rgba(230,57,70,0.4); } `); // v2.8.1: 家庭组入库加载体验优化 —— 进度条 + 游戏框架骨架屏(参考 steam-friend-manager 加载体验) GM_addStyle(` /* === 加载容器(替代裸 spinner,给用户结构感) === */ .sgis-load-block { display:flex; flex-direction:column; align-items:stretch; justify-content:flex-start; padding:18px 16px 14px; gap:10px; } .sgis-load-head { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--sgis-text); } .sgis-load-head .sgis-spinner { width:18px; height:18px; margin:0; flex-shrink:0; } .sgis-load-stage { flex:1; font-weight:600; letter-spacing:0.2px; } .sgis-load-pct { font-size:11px; color:var(--sgis-text-2); font-variant-numeric:tabular-nums; flex-shrink:0; } /* === 主进度条(带渐变 + 流光) === */ .sgis-load-bar { position:relative; height:6px; background:rgba(255,255,255,0.05); border-radius:3px; overflow:hidden; box-shadow:inset 0 1px 2px rgba(0,0,0,0.3); } .sgis-load-bar-fill { position:absolute; left:0; top:0; bottom:0; width:0%; border-radius:3px; background:linear-gradient(90deg,#06b6d4,#3b82f6,#8b5cf6); transition:width 0.35s cubic-bezier(0.4,0,0.2,1); box-shadow:0 0 10px rgba(59,130,246,0.5); } .sgis-load-bar-fill::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,transparent,rgba(255,255,255,0.45),transparent); animation:sgis-load-shimmer 1.4s linear infinite; } @keyframes sgis-load-shimmer { 0%{transform:translateX(-100%)} 100%{transform:translateX(100%)} } /* === 阶段指示条(4 段小条,阶段切换时高亮) === */ .sgis-load-stages { display:flex; gap:3px; } .sgis-load-stage-bar { flex:1; height:3px; border-radius:2px; background:rgba(255,255,255,0.06); transition:all 0.3s; } .sgis-load-stage-bar.active { background:linear-gradient(90deg,#06b6d4,#3b82f6); box-shadow:0 0 6px rgba(59,130,246,0.6); } .sgis-load-stage-bar.done { background:rgba(16,185,129,0.55); } /* === 骨架屏:与真实游戏行 1:1 布局,避免加载完成后跳变 === */ .sgis-skel-list { display:flex; flex-direction:column; gap:6px; margin-top:4px; } .sgis-skel-row { display:flex; align-items:center; gap:10px; padding:8px 10px; background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.04); border-radius:8px; } .sgis-skel-icon { width:32px; height:32px; border-radius:4px; flex-shrink:0; background:linear-gradient(90deg,rgba(139,92,246,0.08),rgba(139,92,246,0.18),rgba(139,92,246,0.08)); background-size:200% 100%; animation:sgis-skel-shimmer 1.4s linear infinite; } .sgis-skel-lines { flex:1; min-width:0; display:flex; flex-direction:column; gap:6px; } .sgis-skel-line { height:8px; border-radius:3px; background:linear-gradient(90deg,rgba(255,255,255,0.04),rgba(255,255,255,0.10),rgba(255,255,255,0.04)); background-size:200% 100%; animation:sgis-skel-shimmer 1.4s linear infinite; } .sgis-skel-line.l1 { width:55%; } .sgis-skel-line.l2 { width:30%; height:7px; opacity:0.6; } .sgis-skel-row:nth-child(odd) .sgis-skel-icon, .sgis-skel-row:nth-child(odd) .sgis-skel-line { animation-delay:0s; } .sgis-skel-row:nth-child(even) .sgis-skel-icon, .sgis-skel-row:nth-child(even) .sgis-skel-line { animation-delay:0.2s; } .sgis-skel-row:nth-child(3n) .sgis-skel-icon, .sgis-skel-row:nth-child(3n) .sgis-skel-line { animation-delay:0.4s; } @keyframes sgis-skel-shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} } /* === 加载中已完成计数(用户能看到 "已处理 X / 约 Y" 真实进度) === */ .sgis-load-counter { font-size:10px; color:var(--sgis-text-2); text-align:center; font-variant-numeric:tabular-nums; margin-top:2px; } /* === v2.9.60: 游玩时长趋势标签页 === */ .sgis-pt-container { display:flex; flex-direction:column; gap:12px; padding:4px 0; } .sgis-pt-kpi-row { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; } @media (max-width:520px) { .sgis-pt-kpi-row { grid-template-columns:repeat(2,1fr); } } .sgis-pt-kpi-card { background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.06); border-left:3px solid; border-radius:8px; padding:10px 12px; transition:background 0.16s; } .sgis-pt-kpi-card:hover { background:rgba(255,255,255,0.05); } .sgis-pt-kpi-label { font-size:11px; color:var(--sgis-text-2); margin-bottom:4px; } .sgis-pt-kpi-value { font-size:20px; font-weight:700; font-variant-numeric:tabular-nums; line-height:1.2; } .sgis-pt-kpi-unit { font-size:10px; color:var(--sgis-text-2); margin-top:2px; } .sgis-pt-warning { font-size:11px; color:#fbbf24; background:rgba(251,191,36,0.08); border:1px solid rgba(251,191,36,0.2); border-radius:6px; padding:6px 10px; } .sgis-pt-subtabs { display:flex; gap:4px; background:rgba(255,255,255,0.03); border-radius:8px; padding:3px; } .sgis-pt-subtab { display:flex; align-items:center; gap:5px; flex:1; justify-content:center; padding:6px 10px; border:none; background:transparent; color:var(--sgis-text-2); font-size:12px; border-radius:6px; cursor:pointer; transition:all 0.16s; } .sgis-pt-subtab:hover { background:rgba(255,255,255,0.05); color:var(--sgis-text); } .sgis-pt-subtab.active { background:rgba(139,92,246,0.2); color:#c7d2fe; font-weight:600; } .sgis-pt-subtab .sgis-svg { width:14px; height:14px; flex-shrink:0; } .sgis-pt-chart-area { background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.04); border-radius:10px; padding:12px; min-height:220px; } .sgis-pt-chart-area svg { display:block; } /* === v2.9.62: 趋势标签页 家庭成员游玩时长横向对比条 === */ .sgis-pt-fbar-section { margin-top:4px; } .sgis-pt-fbar-title { font-size:12px; color:var(--sgis-text-2); margin-bottom:8px; display:flex; align-items:center; gap:6px; } .sgis-pt-fbar-total { margin-left:auto; font-size:10px; font-weight:400; } .sgis-pt-fbar-total strong { color:var(--sgis-text); font-weight:600; font-variant-numeric:tabular-nums; } .sgis-pt-fbar-row { display:flex; align-items:center; gap:8px; padding:4px 0; } .sgis-pt-fbar-name { width:64px; flex-shrink:0; font-size:11px; color:var(--sgis-text-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .sgis-pt-fbar-row.is-me .sgis-pt-fbar-name { color:#c7d2fe; font-weight:600; } .sgis-pt-fbar-track { flex:1; height:8px; background:rgba(255,255,255,0.05); border-radius:4px; overflow:hidden; } .sgis-pt-fbar-fill { height:100%; border-radius:4px; transition:width 0.4s ease; background:linear-gradient(90deg,#66c0f4,#a78bfa); } .sgis-pt-fbar-row.is-me .sgis-pt-fbar-fill { background:linear-gradient(90deg,#a78bfa,#c4b5fd); } .sgis-pt-fbar-hours { width:52px; flex-shrink:0; text-align:right; font-size:11px; font-variant-numeric:tabular-nums; color:var(--sgis-text); } .sgis-pt-fbar-empty { text-align:center; padding:12px 8px; font-size:11px; color:var(--sgis-text-2); } .sgis-pt-legend { display:flex; flex-wrap:wrap; gap:8px 12px; margin-top:8px; font-size:11px; color:var(--sgis-text-2); } .sgis-pt-legend-item { display:flex; align-items:center; gap:4px; } .sgis-pt-legend-dot { width:10px; height:10px; border-radius:2px; flex-shrink:0; } `); const SGIS = { appId: APP_ID, open: false, tab: HAS_APP_ID ? 'overview' : 'profile', overview: null, cards: null, cardsLoading: false, prices: null, pricesLoading: false, rateReady: false, medalTab: 'regular', achievements: null, achievementsLoading: false, globalAchievements: null, dynamics: null, dynamicsLoading: false, dynamicsMode: 'original', // 'original' | 'ai' aiSummary: null, // AI 翻译总结结果缓存 aiSummaryLoading: false, aiSummaryError: null, // AI 翻译总结错误信息 historyPrices: null, historyPricesLoading: false, prediction: null, // AI 价格预测结果 predictionLoading: false, predictionError: null, familyShareSupported: null, // ---- v2.3.16: appdetails API 增强信息 (工坊/截图/分类/捆绑包等) ---- appDetailsExtra: null, // 结构化附加信息 { workshopSupported, categories, platforms, packages, screenshots, ... } appDetailsExtraLoading: false, // ---- v2.3.17: DLC 名称映射 (appid -> name), 异步获取 ---- dlcNames: null, // { [appid]: { name, isFree } } 或 null dlcNamesLoading: false, ownersPlaytime: {}, ownersPlaytimeFetching: false, // ---- v2.9.60: 游玩时长趋势标签页 ---- playTrendData: null, // 聚合后的周趋势数据 playTrendLoading: false, playTrendSubTab: 'personal', // 'personal' | 'family' | 'total' playTrendMode: 'week', // 'week' | 'month' | 'quarter' (仅 total 子标签使用) playTrendSampling: false, // 后台采样进行中标记 // ---- v2.3.19: 相似游戏推荐 + 库存标记 + 类型扩展推荐 (morelike 页专用) ---- similarGames: null, // 从 DOM 采集的相似游戏数组 [{ appId, tagIds, href, capsule, nameSlug, priceText, isFree, status }] similarFilter: 'all', // 'all' | 'unowned' | 'owned' | 'wishlist' similarSort: 'default', // 'default' | 'priceAsc' | 'priceDesc' | 'name' similarDisplayLimit: 12, // 默认展示前 12 个,点击"查看全部"展开 similarExpanded: false, dynamicStore: null, // GDynamicStore 缓存 { owned:Set, wishlist:Set, ignored:Set } dynamicStoreChecked: false, // 是否已尝试读取 GDynamicStore // v2.3.29: dynamicstore/userdata API 缓存的 owned appids(GDynamicStore 不可用时的异步回退) dynamicStoreOwnedApps: null, // Set | null dynamicStoreFetching: false, // 是否正在获取 dynamicstore/userdata tagRecs: null, // 标签扩展推荐 [{ tagId, tagName, appIds:[] }] tagRecsLoading: false, tagRecsError: null, activeTagId: null, // 当前展开的标签 ID tagRecDisplayLimit: 12, // 每个标签默认展示前 12 个 // ---- v2.8.0: 评测标签页 ---- reviews: null, // 评测数据 { summary, reviews[], query_summary } reviewsLoading: false, reviewsFilter: { rec: 'all', playtime: 'all', language: 'all', purchase: 'all', keyword: '', regexMode: false }, reviewsExpanded: new Set(), // 展开的评测 ID 集合 // ---- v2.9.9: 价格图表/DRM 缓存 ---- priceChartRange: 'all', // 价格走势图时间范围 ('6m'|'1y'|'all') drmInfo: null, // DRM 检测结果缓存 // ---- v2.9.10: gamestatus.info 破解状态 ---- gameStatusInfo: null, // 破解状态数据 { status, protections, groups, crackDate, isAAA, ... } gameStatusLoading: false, // 是否正在加载 // ---- v2.8.0: 跨区送礼推荐 ---- giftRec: null, // 送礼推荐数据 { best, alternatives[], userRegion } giftRecLoading: false, // ---- 用户档案 (v2.3: 首页浮窗) ---- profile: null, profileLoading: false, profileError: null, activity: null, activityLoading: false, // ---- v2.3.13: 动态标签子分类 (官方动态 / 个人入库 / 家庭组入库) ---- activitySubTab: 'personal', // v2.3.24: 默认个人入库动态 'personal' | 'family' | 'official' personalTimeline: null, // 个人入库历史(按家庭组 rt_time_acquired 排序) personalTimelineLoading: false, personalTimelinePage: 1, // v2.3.24: 个人入库分页页码(每页 100 条,滚动到底自动加载下一页) familyTimeline: null, // 家庭组入库历史(排除个人已拥有,按 rt_time_acquired 排序) familyTimelineLoading: false, familyTimelinePage: 1, // v2.3.24: 家庭组入库分页页码(每页 100 条,滚动到底自动加载下一页) familyInfo: null, // 家庭组基本信息缓存 userBadges: null, userBadgesLoading: false, userBadgeMarkets: {}, // v2.3.1.1: 徽章市场数据缓存 (appId -> marketSummary) userAchievements: null, userAchievementsLoading: false, aiPersona: null, aiPersonaLoading: false, aiPersonaError: null, // ---- v2.3.8: 洞察标签页 (重构自原 userAchievements) ---- insightData: null, // 本地计算的洞察数据 (KPI/画像/维度) insightDataLoading: false, aiInsight: null, // AI 深度分析结果 { oneLiner, sections: [{title, content}] } aiInsightLoading: false, aiInsightError: null, aiMarketInsight: null, // AI 市场洞察结果 { summary, recommendations: [{name, action, reason}], topValue: [...] } aiMarketInsightLoading: false, aiMarketInsightError: null, // ---- v2.3.8: 社交标签页 (好友列表) ---- friendsList: null, // 完整好友数据 [{ steamid, personaname, avatar, personastate, gameextrainfo, friend_since, friend_days, vac_banned, ... }] friendsListLoading: false, friendsListError: null, friendsLevels: null, // 好友等级 Map { steamid: level } (懒加载) friendsLevelsLoading: false, friendsLevelsProgress: 0, friendsLevelsTotal: 0, friendsBansLoading: false, // v2.3.13: 封禁状态同步中 friendsBansProgress: 0, friendsBansTotal: 0, friendsGameCounts: null, // v2.3.24: 好友游戏数量 Map { steamid: { gc, tm, ts, priv? } } (懒加载, 12h 缓存) friendsGameCountsLoading: false, friendsGameCountsProgress: 0, friendsGameCountsTotal: 0, friendsFilter: 'all', // 筛选: all / ingame / online / vac / new (v2.3.24 移除离线筛选) friendsSearch: '', // 搜索关键词 friendsSort: 'status', // 排序: status / level / days / name }; // ---- 共享数据访问层 (基于游戏库已有的 state + storage) ---- function getCurrentGameInfo() { // 优先用实时 state; 没有则用持久化缓存 const all = (state.ownedGames && state.ownedGames.length) ? state.ownedGames : storage.getCachedGames(); const g = all.find(x => String(x.appid) === String(APP_ID)); if (!g) { // v2.3.29: GetOwnedGames API 可能遗漏部分通过 CD key 激活的游戏(免费游戏未游玩、促销许可等) // 回退1: 页面 GDynamicStore.s_rgOwnedApps(零请求,Steam 商店客户端实时数据,最可靠) const ds = getDynamicStoreStatus(); if (ds && ds.owned && ds.owned.has(Number(APP_ID))) { const pageName = (document.getElementById('appHubAppName') || {}).textContent?.trim() || ''; return { found: true, name: pageName || `App ${APP_ID}`, playtime: 0, lastPlayed: 0, acquiredTime: 0, icon: '', _source: 'dynamicstore', isOwnedByMe: true, isSharedOnly: false, isFamilyLib: false, owners: [], }; } // 回退2: dynamicstore/userdata API 缓存的 owned appids(异步获取后缓存) if (SGIS.dynamicStoreOwnedApps && SGIS.dynamicStoreOwnedApps.has(Number(APP_ID))) { const pageName = (document.getElementById('appHubAppName') || {}).textContent?.trim() || ''; return { found: true, name: pageName || `App ${APP_ID}`, playtime: 0, lastPlayed: 0, acquiredTime: 0, icon: '', _source: 'dynamicstore_api', isOwnedByMe: true, isSharedOnly: false, isFamilyLib: false, owners: [], }; } return { found: false }; } const mySteamId = getActiveSteamId(); const familyInfo = storage.getFamilyInfo() || {}; const nameMap = familyInfo.steamIdtoName || {}; const owners = g.owners || []; const isOwnedByMe = !owners.length || (mySteamId && owners.includes(mySteamId)); const isSharedOnly = owners.length > 0 && !isOwnedByMe; return { found: true, name: g.name, playtime: g.playtime || 0, lastPlayed: g.lastPlayed || 0, acquiredTime: g.acquiredTime || 0, icon: g.icon || '', _source: g._source || 'cache', isOwnedByMe, isSharedOnly, isFamilyLib: owners.length > 0, owners: owners.map(sid => ({ steamId: sid, name: nameMap[sid] || ('ID:' + String(sid).slice(-4)), isMe: mySteamId && String(sid) === String(mySteamId), playtime: SGIS.ownersPlaytime[sid] != null ? SGIS.ownersPlaytime[sid] : null, })), }; } function formatAcquiredTime(ts) { if (!ts) return '—'; try { const d = new Date(ts * 1000); return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); } catch { return '—'; } } // v2.9.15: 相对时间(用于评测项显示) function formatRelativeDate(ts) { if (!ts) return '—'; const t = ts * 1000; const diff = Date.now() - t; if (diff < 0) return new Date(t).toLocaleDateString('zh-CN'); const min = Math.floor(diff / 60000); if (min < 1) return '刚刚'; if (min < 60) return `${min} 分钟前`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr} 小时前`; const day = Math.floor(hr / 24); if (day < 30) return `${day} 天前`; const mon = Math.floor(day / 30); if (mon < 12) return `${mon} 个月前`; const yr = Math.floor(day / 365); return `${yr} 年前`; } // ---- HTTP 工具(v2.9.54 优化:委托 SGLVCore 统一全脚本网络层)---- // 委托目标(由 sglv-core.lib.js 提供,主脚本顶部 @require 已加载): // sglvGmFetchRetry = SGLVCore.gmFetchJson (自动重试 + HTTP 状态码校验 + JSON 解析) // sglvGmFetchTextRetry = SGLVCore.gmFetchText (自动重试 + 状态码校验,返回 raw text) // C.concurrentPool = SGLVCore.concurrentPool (429 降并发 + 指数退避) // 保留以下函数供 SGIS 既有调用方使用,内部委托 SGLVCore,行为兼容: // - httpGet(url, opts) → Promise<{status, responseText}> // - fetchJson(url, opts) → Promise (直接走 sglvGmFetchRetry,不再二次 parse) // - fetchText(url, opts) → Promise (直接走 sglvGmFetchTextRetry) // - mapLimit(items, limit, mapper) → Promise> // 契约:成功时 out[idx] = mapper 返回值,失败时 out[idx] = reason 对象(不抛,调用方走降级) // 收益:删 ~50 行重复代码;SGLVCore.gmFetchJson/Text 已带 2 次重试 + 状态码校验(v1.0.2 修复过同款 bug) function httpGet(url, opts) { return sglvGmFetchTextRetry(url, opts).then(text => ({ status: 200, responseText: text })); } function fetchJson(url, opts) { return sglvGmFetchRetry(url, opts); } function fetchText(url, opts) { return sglvGmFetchTextRetry(url, opts); } // 委托 SGLVCore.concurrentPool:包装 tasks + 还原"失败塞 reason"契约,调用方不变 function mapLimit(items, limit, mapper) { const tasks = items.map((item, idx) => () => mapper(item, idx)); return C.concurrentPool(tasks, limit).then(results => results.map(r => r.status === 'fulfilled' ? r.value : r.reason) ); } // ---- 汇率 ---- function getRates() { try { return GM_getValue('sgis-rates', null); } catch { return null; } } function getRatesTs() { try { return GM_getValue('sgis-rates-ts', 0); } catch { return 0; } } function setRates(r) { try { GM_setValue('sgis-rates', r); GM_setValue('sgis-rates-ts', Date.now()); } catch { /* ignore */ } } // v2.9.54 优化: refreshRates 优先委托 sglv-app-detail.getRates() (双源降级 + 1h 内存缓存) // 库未加载时降级到本地 AugmentedSteam / open.er-api 双源拉取 // rates 表语义: SGIS 内部保持"1 外币 = X CNY"倒数形式 (与 toCNY 算法匹配), // 库返回的是"1 CNY = X 外币"原值形式, 故此转换 function _convertToSgisRatesFormat(rates) { const out = { CNY: 1 }; for (const [code, val] of Object.entries(rates || {})) { if (typeof val === 'number' && val > 0) out[code] = 1 / val; } return out; } async function refreshRates() { if (Date.now() - getRatesTs() < 3600000 && getRates()) { SGIS.rateReady = true; return; } // 优先委托 sglv-app-detail 拉取 (双源降级 + 自动重试 + 状态码校验) try { const A = unsafeWindow.SGLVAppDetail; if (A && typeof A.getRates === 'function') { const info = await A.getRates(); if (info && info.ready && info.rates) { setRates(_convertToSgisRatesFormat(info.rates)); SGIS.rateReady = true; return; } } } catch (e) { /* ignore - fallback below */ } // 降级路径 1: AugmentedSteam 直拉 try { const r = await httpGet('https://api.augmentedsteam.com/rates/v1', { timeout: 10000 }); const data = JSON.parse(r.responseText); if (data?.data?.CNY) { const cny = data.data.CNY; const rates = {}; for (const [code, val] of Object.entries(data.data)) { if (typeof val === 'number' && val > 0) rates[code] = cny / val; } setRates(rates); SGIS.rateReady = true; return; } } catch (e) { /* ignore */ } // 降级路径 2: open.er-api 兜底 try { const r = await httpGet('https://open.er-api.com/v6/latest/CNY', { timeout: 10000 }); const data = JSON.parse(r.responseText); if (data?.rates) { const rates = { CNY: 1 }; for (const [code, val] of Object.entries(data.rates)) { if (typeof val === 'number' && val > 0) rates[code] = 1 / val; } setRates(rates); SGIS.rateReady = true; } } catch (e) { /* ignore */ } } function toCNY(amount, currency) { if (currency === 'CNY' || currency === 'RMB') return amount; const rates = getRates(); if (!rates || !rates[currency]) return null; return amount * rates[currency]; } // ---- 卡牌价格(整合 card-prices 核心) ---- const MARKET_SEARCH_API = 'https://steamcommunity.com/market/search/render/'; const MARKET_LISTING_PAGE = 'https://steamcommunity.com/market/listings/753/'; const BUY_ORDER_CONCURRENCY = 3; // v2.9.32: 可变发行商费率手续费计算 (与 steam-badges-card-view 保持一致) // 替代 v2.9.31 的固定费率 calcfee, 从市场 API 读取每张卡的实际发行商费率 const STEAM_TX_FEE_PERCENT = 0.05; // Steam 交易手续费 5% const DEFAULT_PUBLISHER_FEE_PERCENT = 0.10; // 默认发行商手续费 10% // 从市场搜索结果读取发行商手续费比例 // Steam 市场 API 的 asset_description.owner_actions 中包含 publisherFeePercentDbl 参数 function _readPublisherFeeFromItem(item) { const desc = item && item.asset_description ? item.asset_description : {}; const ownerActions = desc.owner_actions || []; for (let i = 0; i < ownerActions.length; i++) { const link = ownerActions[i].link || ''; const m = link.match(/publisherFeePercentDbl=([0-9.]+)/); if (m) { const v = Number(m[1]); if (Number.isFinite(v) && v >= 0) return v / 100; } } return DEFAULT_PUBLISHER_FEE_PERCENT; } // 计算单笔费用 (Steam 5% + 发行商 fee, 每项最低 1 分) function _calcFees(receivedAmount, publisherFee, steamFeePercent) { const steamFee = Math.max(Math.floor(receivedAmount * steamFeePercent), 1); const publisherFeeAmount = publisherFee > 0 ? Math.max(Math.floor(receivedAmount * publisherFee), 1) : 0; return { steamFee, publisherFee: publisherFeeAmount, fees: steamFee + publisherFeeAmount, amount: receivedAmount + steamFee + publisherFeeAmount, }; } // 计算卖家到手价 (扣除 Steam 交易手续费 + 发行商手续费) // 迭代逼近法与 Steam 官方算法一致, 支持可变发行商费率 function _calculateSellerReceives(buyerPaysCents, publisherFeePercent) { const amount = Math.round(Number(buyerPaysCents)); if (!Number.isFinite(amount) || amount <= 0) return null; publisherFeePercent = publisherFeePercent == null ? DEFAULT_PUBLISHER_FEE_PERCENT : publisherFeePercent; const steamFeePercent = STEAM_TX_FEE_PERCENT; let estimatedReceived = parseInt(amount / (steamFeePercent + publisherFeePercent + 1), 10); if (!Number.isFinite(estimatedReceived)) estimatedReceived = amount; estimatedReceived = Math.max(1, estimatedReceived); let iterations = 0, everUndershot = false; let fees = _calcFees(estimatedReceived, publisherFeePercent, steamFeePercent); while (fees.amount !== amount && iterations < 100) { if (fees.amount > amount) { if (everUndershot) { fees = _calcFees(estimatedReceived - 1, publisherFeePercent, steamFeePercent); fees.steamFee += amount - fees.amount; fees.fees += amount - fees.amount; fees.amount = amount; break; } estimatedReceived--; } else { everUndershot = true; estimatedReceived++; } estimatedReceived = Math.max(1, estimatedReceived); fees = _calcFees(estimatedReceived, publisherFeePercent, steamFeePercent); iterations++; } return amount > fees.fees ? amount - fees.fees : 1; } async function fetchCardGroup(appId, foil) { const cards = []; let total = Infinity, start = 0, count = 100; while (start < total && cards.length < 200) { const params = new URLSearchParams(); params.set('query', ''); params.set('start', String(start)); params.set('count', String(count)); params.set('search_descriptions', '0'); params.set('sort_column', 'name'); params.set('sort_dir', 'asc'); params.set('appid', '753'); params.set('norender', '1'); params.append('category_753_Game[]', 'tag_app_' + appId); params.append('category_753_item_class[]', 'tag_item_class_2'); params.append('category_753_cardborder[]', foil ? 'tag_cardborder_1' : 'tag_cardborder_0'); const data = await fetchJson(MARKET_SEARCH_API + '?' + params.toString(), { timeout: 12000 }); if (data?.success === false) throw new Error('Steam 市场返回失败'); const results = Array.isArray(data?.results) ? data.results : []; total = Number.isFinite(Number(data?.total_count)) ? Number(data.total_count) : results.length; for (const item of results) { const desc = item.asset_description || {}; const hashName = String(item.hash_name || desc.market_hash_name || '').trim(); if (!hashName) continue; // sell_price 单位是 用户的币种最小单位 (分/cent), 已经是 sell_price_text 的数值形式 // 例如 CNY: sell_price=6 表示 6 分 = ¥0.06, sell_price_text="¥0.06" // 保留原始数值, 由显示层决定如何格式化 const sellPrice = Number.isFinite(Number(item.sell_price)) ? Number(item.sell_price) : null; // v2.9.32: 可变发行商费率 — 从市场 API 读取每张卡的实际发行商费率 // 不同游戏的发行商费率可能不同 (默认 10%, 部分游戏为 5% 或其他) const publisherFeePercent = _readPublisherFeeFromItem(item); // v2.9.32: netPrice 用可变费率精确计算 (_calculateSellerReceives 迭代逼近法) // 相比旧公式 sellPrice * 0.95 * 0.9, 低价卡牌 (如 3 分) 误差可从 1+ 分降至 0 const netPrice = sellPrice != null ? _calculateSellerReceives(sellPrice, publisherFeePercent) : null; // 卡牌图片 URL (与 steam-store-card-prices 保持一致) const iconPath = desc.icon_url_large || desc.icon_url || ''; const iconUrl = iconPath ? `https://community.fastly.steamstatic.com/economy/image/${iconPath}/64fx64f` : ''; cards.push({ foil, name: String(item.name || desc.market_name || hashName).trim(), hashName, classId: String(desc.classid || '').trim(), type: String(desc.type || '').trim(), listings: parseInt(item.sell_listings, 10) || 0, sellPrice, netPrice, publisherFeePercent, sellPriceText: String(item.sell_price_text || '').trim(), salePriceText: String(item.sale_price_text || '').trim(), iconUrl, marketUrl: MARKET_LISTING_PAGE + encodeURIComponent(hashName), }); } if (results.length < count) break; start += count; } return cards; } async function enrichBuyOrder(card) { try { const html = await fetchText(card.marketUrl, { timeout: 8000 }); // 求购价单位也是最小单位 (分) const m1 = html.match(/"amtMaxBuyOrder"\s*:\s*(\d+)/); const m2 = html.match(/"cBuyOrders"\s*:\s*(\d+)/); return Object.assign({}, card, { buyPrice: m1 ? parseInt(m1[1], 10) : null, buyOrderCount: m2 ? parseInt(m2[1], 10) : 0, }); } catch (e) { return Object.assign({}, card, { buyPrice: null, buyOrderCount: 0, buyError: e.message }); } } // 最小单位 -> 主单位 (分 -> 元) function toMajorUnit(minor) { return Number.isFinite(minor) ? minor / 100 : null; } async function fetchCardPrices(appId) { const result = { regular: [], foil: [], regularQueried: false, foilQueried: false }; await Promise.all([ fetchCardGroup(appId, false).then(c => { result.regular = c; result.regularQueried = true; }).catch(e => { result.regularError = e.message; }), fetchCardGroup(appId, true).then(c => { result.foil = c; result.foilQueried = true; }).catch(e => { result.foilError = e.message; }), ]); const tasks = []; if (result.regular.length) tasks.push(mapLimit(result.regular, BUY_ORDER_CONCURRENCY, c => enrichBuyOrder(c))); if (result.foil.length) tasks.push(mapLimit(result.foil, BUY_ORDER_CONCURRENCY, c => enrichBuyOrder(c))); await Promise.all(tasks); return result; } // ---- v2.3.1.1: 徽章市场数据轻量抓取 (用于价值分析仪表板) ---- // 只抓 1 页(最多 100 张), 不查 buy orders, 适合批量调用 async function fetchCardMarketLight(appId) { const cacheKey = 'sgis_badge_market_' + appId; const cached = cacheGet(cacheKey); if (cached) return cached; const params = new URLSearchParams(); params.set('query', ''); params.set('start', '0'); params.set('count', '100'); params.set('search_descriptions', '0'); params.set('sort_column', 'name'); params.set('sort_dir', 'asc'); params.set('appid', '753'); params.set('norender', '1'); params.append('category_753_Game[]', 'tag_app_' + appId); params.append('category_753_item_class[]', 'tag_item_class_2'); params.append('category_753_cardborder[]', 'tag_cardborder_0'); // 只要普通卡 const url = MARKET_SEARCH_API + '?' + params.toString(); const data = await fetchJson(url, { timeout: 10000 }); const results = Array.isArray(data?.results) ? data.results : []; let totalCost = 0, priceCount = 0; const seenHash = new Set(); const cards = []; for (const item of results) { const desc = item.asset_description || {}; const hashName = String(item.hash_name || desc.market_hash_name || '').trim(); if (!hashName || seenHash.has(hashName)) continue; seenHash.add(hashName); const sellPrice = Number.isFinite(Number(item.sell_price)) ? Number(item.sell_price) : null; if (sellPrice != null) { totalCost += sellPrice; priceCount++; } // v2.9.32: 读取可变发行商费率 (与 fetchCardGroup 保持一致) const publisherFeePercent = _readPublisherFeeFromItem(item); const iconPath = desc.icon_url_large || desc.icon_url || ''; const iconUrl = iconPath ? `https://community.fastly.steamstatic.com/economy/image/${iconPath}/64fx64f` : ''; cards.push({ name: String(item.name || desc.market_name || hashName).replace(/\s*\(Foil\)\s*$/i, '').trim(), hashName, iconUrl, sellPrice, publisherFeePercent, sellPriceText: String(item.sell_price_text || '').trim(), listings: parseInt(item.sell_listings, 10) || 0, marketUrl: MARKET_LISTING_PAGE + encodeURIComponent(hashName), }); } const summary = { appId, cards, cardCount: cards.length, totalCost, // 单位: 分 (cent) priceCount, avgPrice: priceCount > 0 ? Math.round(totalCost / priceCount) : 0, // v2.9.31: 增加中位数和忽略最高价统计 (借鉴 Steam Get Trading Card Info 脚本) medianPrice: 0, // 中位数价格 (分), 抗极端高价干扰 avgNoMax: 0, // 忽略最高价后的均价 (分) netIncome: 0, // 预计税后总收入 (分), 使用 _calculateSellerReceives 可变费率计算 updatedAt: Date.now(), }; // v2.9.31: 计算中位数和忽略最高价均价 if (priceCount > 0) { const prices = cards.filter(c => c.sellPrice != null).map(c => c.sellPrice).sort((a, b) => a - b); if (prices.length > 0) { // 中位数: 偶数个取中间两个的平均值 const mid = Math.floor(prices.length / 2); summary.medianPrice = prices.length % 2 !== 0 ? prices[mid] : Math.round((prices[mid - 1] + prices[mid]) / 2); // 忽略最高价均价 (至少 2 张卡才有意义) if (prices.length > 1) { const sumNoMax = prices.slice(0, -1).reduce((s, p) => s + p, 0); summary.avgNoMax = Math.round(sumNoMax / (prices.length - 1)); } else { summary.avgNoMax = summary.avgPrice; } // v2.9.32: 预计税后总收入: 均价 * ceil(卡牌数/2) * 可变费率系数 // Steam 每套卡牌掉落 ceil(卡牌数/2) 张, 合成徽章需集齐全套 // 汇总层面使用默认发行商费率 (与 steam-badges-card-view 保持一致) const dropCount = Math.ceil(cards.length / 2); const grossIncome = summary.avgPrice * dropCount; summary.netIncome = _calculateSellerReceives(grossIncome, DEFAULT_PUBLISHER_FEE_PERCENT) || 0; } } cacheSet(cacheKey, summary, 10 * 60 * 1000); // 10 分钟缓存 return summary; } // 格式化分(cent) -> 本地化货币字符串 function formatMarketPrice(cents) { if (cents == null) return '—'; try { // 尝试从页面抓取钱包货币 const m = document.cookie.match(/steamCountry=(\w{2})/) || []; const cc = (m[1] || 'CN').toLowerCase(); const symbols = { cn: '¥', us: '$', eu: '€', uk: '£', jp: '¥', kr: '₩', ru: '₽' }; const symbol = symbols[cc] || '¥'; return symbol + (cents / 100).toFixed(2); } catch (e) { return '¥' + (cents / 100).toFixed(2); } } // ---- v2.3.1.1: 价值分析引擎 (参考 SBC Pro) ---- const BADGE_VALUE_WEIGHTS = { xp: 0.25, level: 0.20, cardPrice: 0.30, completionBonus: 0.15, dropBonus: 0.10, }; // 计算徽章价值评分 // badges: enriched badges (with _gameName), markets: { appId: marketSummary } function computeBadgeValueScores(badges, markets) { const scores = {}; const gameBadges = badges.filter(b => b.appid > 0); if (gameBadges.length === 0) return scores; const maxXp = Math.max(...gameBadges.map(b => b.xp || 0), 1); gameBadges.forEach(badge => { const appId = String(badge.appid); const xpScore = (badge.xp || 0) / maxXp; // v2.3.6: 用 SteamCardExchange API 的真实 maxLevel 计算等级进度 const realMaxLevel = badge._maxLevel || getCardDbMaxLevel(badge.appid) || 5; const levelScore = realMaxLevel > 1 ? ((badge.level || 1) - 1) / (realMaxLevel - 1) : 0; // 卡价评分: 平均卡价越低, 评分越高 (说明这套卡便宜) const marketData = markets[appId]; let cardPriceScore = 0; if (marketData && marketData.avgPrice > 0) { cardPriceScore = Math.max(0, 1 - (marketData.avgPrice / 50000)); // 5 元 = 50000 分为中等 } // 完成度: 1 表示已合成, 0 表示未开始 const completionBonus = badge.completion_time ? 1 : 0; // v2.3.6: 用真实 maxLevel 替代硬编码 5 const dropBonus = Math.min(1, (badge.level || 1) / realMaxLevel); const w = BADGE_VALUE_WEIGHTS; const score = Math.round( (w.xp * xpScore + w.level * levelScore + w.cardPrice * cardPriceScore + w.completionBonus * completionBonus + w.dropBonus * dropBonus) * 100 ); scores[appId] = { score, appId, gameName: badge._gameName || '', level: badge.level || 1, maxLevel: realMaxLevel, // v2.3.6: 游戏最高可达等级 xp: badge.xp || 0, completed: !!badge.completion_time, marketData, breakdown: { xp: Math.round(xpScore * 100), level: Math.round(levelScore * 100), cardPrice: Math.round(cardPriceScore * 100), completion: Math.round(completionBonus * 100), level_max: Math.round(dropBonus * 100), }, }; }); return scores; } // 获取高价值徽章 (Top N) function getTopValueBadges(scores, n) { n = n || 6; return Object.values(scores).sort((a, b) => b.score - a.score).slice(0, n); } // 等级分布 function getBadgeLevelDistribution(badges) { const dist = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; badges.filter(b => b.appid > 0).forEach(b => { const lv = Math.min(5, Math.max(1, b.level || 1)); dist[lv] = (dist[lv] || 0) + 1; }); return dist; } // 抓取徽章市场数据 (异步, 限制并发) async function fetchBadgeMarkets(appIds, opts) { opts = opts || {}; const concurrency = opts.concurrency || 3; const maxApps = opts.maxApps || 20; const targets = appIds.slice(0, maxApps); const results = {}; for (let i = 0; i < targets.length; i += concurrency) { const batch = targets.slice(i, i + concurrency); await Promise.all(batch.map(async (appId) => { try { results[String(appId)] = await fetchCardMarketLight(appId); } catch (e) { results[String(appId)] = { appId, cardCount: 0, totalCost: 0, avgPrice: 0, cards: [], error: e.message }; } })); if (i + concurrency < targets.length) { await new Promise(r => setTimeout(r, 500)); } } return results; } // ---- 多地区价格(整合 game-prices-tools 核心) ---- // 旗帜图用 flagcdn.com (无 key, PNG 24x18), 欧元区用 eu.png function flagUrl(code) { const cc = String(code || '').toLowerCase(); if (cc === 'eu') return 'https://flagcdn.com/w40/eu.png'; return `https://flagcdn.com/w40/${cc}.png`; } const REGIONS = [ { code: 'CN', name: '中国', currency: 'CNY' }, { code: 'US', name: '美国', currency: 'USD' }, { code: 'AR', name: '阿根廷', currency: 'ARS' }, { code: 'TR', name: '土耳其', currency: 'TRY' }, { code: 'KZ', name: '哈萨克斯坦', currency: 'KZT' }, { code: 'UA', name: '乌克兰', currency: 'UAH' }, { code: 'IN', name: '印度', currency: 'INR' }, { code: 'BR', name: '巴西', currency: 'BRL' }, { code: 'CL', name: '智利', currency: 'CLP' }, { code: 'CO', name: '哥伦比亚', currency: 'COP' }, { code: 'PL', name: '波兰', currency: 'PLN' }, { code: 'MX', name: '墨西哥', currency: 'MXN' }, { code: 'RU', name: '俄罗斯', currency: 'RUB' }, { code: 'JP', name: '日本', currency: 'JPY' }, { code: 'KR', name: '韩国', currency: 'KRW' }, { code: 'GB', name: '英国', currency: 'GBP' }, { code: 'EU', name: '欧元区', currency: 'EUR' }, ]; const PRIORITY_REGIONS = ['CN', 'US', 'TR', 'AR', 'RU', 'IN', 'BR', 'UA', 'KZ']; // Steam 官方 API (在已登录 session 下可能忽略 cc= 参数, 用作兜底) async function fetchSteamRegionPrice(appId, countryCode) { try { // 加 cache-buster 减少 Steam 缓存干扰 const url = `https://store.steampowered.com/api/appdetails?appids=${appId}&cc=${countryCode}&l=english&_=${Date.now()}`; const data = await fetchJson(url, { timeout: 12000 }); if (data[appId] && data[appId].success) { const g = data[appId].data; if (g.is_free) return { success: true, data: { region: countryCode, price: 0, currency: 'FREE', discount: 0, initial: 0, source: 'steam' } }; if (g.price_overview) { const actualCurrency = g.price_overview.currency; // v2.3.7: 检测币种不匹配 — Steam API 对登录用户可能忽略 cc= 参数, // 返回用户实际所在地区的价格(如请求 CN 但返回 INR), 这种数据是错误的, 应标记为失败 const expectedRegion = REGIONS.find(r => r.code === countryCode); if (expectedRegion && expectedRegion.currency && expectedRegion.currency !== actualCurrency) { return { success: false, error: `币种不匹配(期望${expectedRegion.currency}, 实际${actualCurrency}, 疑似Steam返回用户本区数据)` }; } return { success: true, data: { region: countryCode, price: g.price_overview.final / 100, currency: actualCurrency, discount: g.price_overview.discount_percent || 0, initial: g.price_overview.initial / 100, source: 'steam', }}; } } return { success: false, error: '无价格数据' }; } catch (e) { return { success: false, error: e.message }; } } // AugmentedSteam API (按 country 返回该地区价格, 不受用户登录影响, 首选) async function fetchAugRegionPrice(appId, countryCode) { try { const data = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://api.augmentedsteam.com/prices/v2', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ country: countryCode, apps: [parseInt(appId, 10)], subs: [], bundles: [], voucher: true, shops: [], }), timeout: 12000, onload(r) { if (r.status >= 200 && r.status < 300) { try { resolve(JSON.parse(r.responseText)); } catch { reject(new Error('JSON parse fail')); } } else reject(new Error('HTTP ' + r.status)); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); const key = `app/${appId}`; const aug = data?.prices?.[key]; if (!aug?.current?.price) return { success: false, error: 'Aug 无数据' }; const cur = aug.current; const cur2 = cur.price; const regular = cur.regular || cur2; const discount = (regular.amount > cur2.amount) ? Math.round((1 - cur2.amount / regular.amount) * 100) : 0; return { success: true, data: { region: countryCode, price: cur2.amount, currency: cur2.currency, discount, initial: regular.amount, source: 'aug', }}; } catch (e) { return { success: false, error: 'Aug: ' + e.message }; } } // 单个地区价格 (Aug 优先 -> Steam 兜底) async function fetchOneRegionPrice(appId, countryCode) { const aug = await fetchAugRegionPrice(appId, countryCode); if (aug.success) return aug; return await fetchSteamRegionPrice(appId, countryCode); } // v2.8.0: Steam API 限速器("预留位"模式) // 在 sleep 前更新 lastRequestTime,防止并发 worker 竞态导致请求间距不足 let _steamApiLastReq = 0; const STEAM_API_MIN_INTERVAL = 1000; // 请求间隔 1 秒 async function steamApiRateLimit() { const now = Date.now(); const wait = STEAM_API_MIN_INTERVAL - (now - _steamApiLastReq); // 预留位:先更新时间戳再 sleep,后续并发的 worker 看到的是已预留的时间 _steamApiLastReq = now + Math.max(0, wait); if (wait > 0) await new Promise(r => setTimeout(r, wait)); } // v2.8.0: 带限速的单地区价格获取(用于 9 区并发对比,3 worker + 1s 间隔) async function fetchOneRegionPriceRateLimited(appId, countryCode) { await steamApiRateLimit(); return await fetchOneRegionPrice(appId, countryCode); } // 多地区价格: 并发获取, 去重 (Steam 官方 API 对登录用户会返回相同的国家价格) // v2.8.0: 改用 3 个并发 worker + 1s 请求间隔(预留位限速模式) async function fetchMultiRegionPrices(appId) { const results = await mapLimit(PRIORITY_REGIONS, 3, r => fetchOneRegionPriceRateLimited(appId, r)); const prices = []; const failed = []; const seenKey = new Set(); // 第一次扫描: 收集成功的结果, 按 "币种 + 金额" 去重 const successList = []; results.forEach((r, i) => { if (r.success) successList.push({ data: r.data, idx: i }); else failed.push({ region: PRIORITY_REGIONS[i], error: r.error }); }); // 检测是否所有结果都"看起来一样" (币种 + 价格一致), 这种情况说明 API 被缓存, 全部失败 const allSameCurrency = successList.length > 1 && successList.every(s => s.data.currency === successList[0].data.currency) && successList.every(s => Math.abs(s.data.price - successList[0].data.price) < 0.01); if (allSameCurrency && successList.length > 1) { // 全部当失败处理 successList.forEach(s => failed.push({ region: PRIORITY_REGIONS[s.idx], error: '区域数据相同(疑似被缓存)' })); } else { successList.forEach(s => { const k = `${s.data.currency}:${Number(s.data.price).toFixed(2)}`; if (!seenKey.has(k)) { seenKey.add(k); prices.push(s.data); } else { // 重复价格 -> 视为失败 failed.push({ region: PRIORITY_REGIONS[s.idx], error: '与已有地区价格重复' }); } }); } return { prices, failed }; } // ---- 图标 v2.9.15: 重新设计精致 SVG 库 ---- // 设计规范:统一 stroke-width 1.8 / currentColor / 圆角连接 / 0.1-0.18 透明填充做"水彩"层次 // viewBox 固定 0 0 24 24,14px 渲染下细节清晰 const _S = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"'; const SGIS_ICONS = { // ===== 导航 tab 图标(重新设计,6 款核心)===== // 概览 - 4 块仪表板 + 中心装饰点 overview: ` `, // 勋章 - 六角星 + 双飘带 medal: ` `, // 价格 - 价签 + $ 标志 price: ` `, // 评测 - 聊天气泡 + 引号 review: ` `, // 成就 - 奖杯 + 双手 + 底座 trophy: ` `, // 动态 - 喇叭 + 声波 activity: ` `, // v2.9.60: 趋势 - 折线图 + 坐标轴 trend: ` `, // ===== 用户档案 / 社交 tab 图标 ===== // 用户 - 头部 + 肩部(更精致) user: ` `, // 社交 - 双人组合 + 闪光背景 social: ` `, // 洞察 - 雷达扫描(眼+十字线) insight: ` `, // ===== 通用工具图标 ===== close: ``, refresh: ``, settings: ``, check: ``, star: ``, share: ``, news: ``, info: ``, // ====== 媒体/工具 ====== game: ``, card: ``, library: ``, // 通用 chevronRight: ``, sparkle: ``, brain: ``, clock: ``, users: ``, package: ``, gift: ``, link: ``, // 工具/统计 barChart: ``, trend: ``, // 维度评分图标 target: ``, // 市场洞察 market: ``, // 闪电 zap: ``, // 盾牌(VAC / 年龄限制共用,v2.3.8 重复定义已合并) shield: ``, // 外链 external: ``, // 心愿 heart: ``, // 复制 copy: ``, // 创意工坊 workshop: ``, // 截图 image: ``, // 标签 tag: ``, // 视频 video: ``, // 地球 globe: ``, // 手柄 gamepad: ``, // 扩展包 (DLC) puzzle: ``, // 火焰(热卖/趋势) fire: ``, // 火箭(速度/即将推出) rocket: ``, // 旗帜(特性/标记) flag: ``, // ====== 默认勋章 SVG (加载失败兜底)====== medalFallback: ``, }; // ---- UI 构建 ---- function ensureFab() { // v2.3.26: 愿望单页面不创建浮动按钮,让 Wishlist Exporter 侧边栏独占 if (IS_WISHLIST_PAGE) return; if (document.getElementById('sgis-fab')) return; const btn = document.createElement('button'); btn.id = 'sgis-fab'; btn.title = '个人信息面板'; btn.setAttribute('aria-label', '个人信息面板'); btn.addEventListener('click', togglePanel); document.body.appendChild(btn); } function ensurePanel() { if (document.getElementById('sgis-panel')) return; // v2.3.2: 动态标题/图标 - 游戏页(带 appid)显示"游戏信息面板", // 其他页面(商店主页等)显示"个人信息面板" const titleIcon = HAS_APP_ID ? SGIS_ICONS.game : SGIS_ICONS.user; const titleText = HAS_APP_ID ? '游戏信息面板' : '个人信息面板'; const panel = document.createElement('div'); panel.id = 'sgis-panel'; panel.innerHTML = `
${titleIcon} ${titleText}
${HAS_APP_ID ? ` ` : ` `}
`; document.body.appendChild(panel); panel.querySelector('#sgis-close-btn').addEventListener('click', closePanel); panel.querySelector('#sgis-settings-btn').addEventListener('click', () => openGlobalSettings()); panel.querySelector('#sgis-refresh-btn').addEventListener('click', () => { const refreshBtn = panel.querySelector('#sgis-refresh-btn'); refreshBtn.classList.add('sgis-spin'); // v2.9.60: 刷新时也触发采样,确保趋势数据最新 if (HAS_APP_ID) _samplePlayTime(APP_ID); Promise.resolve(renderTab(SGIS.tab, true)).finally(() => refreshBtn.classList.remove('sgis-spin')); }); panel.querySelectorAll('.sgis-tab').forEach(tabBtn => { tabBtn.addEventListener('click', () => { panel.querySelectorAll('.sgis-tab').forEach(b => b.classList.remove('active')); tabBtn.classList.add('active'); SGIS.tab = tabBtn.dataset.tab; // v2.9.15: 切换 tab 时滚动条复位到顶部,避免新内容在屏幕外 const body = document.getElementById('sgis-body'); if (body) body.scrollTop = 0; renderTab(SGIS.tab, false); }); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && SGIS.open) closePanel(); }); } function openPanel() { const overlay = document.getElementById('sf-wf-overlay'); if (overlay && overlay.classList.contains('sf-wf-open')) return; SGIS.open = true; document.body.classList.add('sgis-open'); document.getElementById('sgis-panel').classList.add('sgis-open'); const f = document.getElementById('sgis-fab'); if (f) f.classList.add('sgis-open'); renderTab(SGIS.tab, false); // v2.9.60: 后台静默采样游玩时长(30分钟节流,不阻塞渲染) if (HAS_APP_ID) _samplePlayTime(APP_ID); } function closePanel() { SGIS.open = false; document.body.classList.remove('sgis-open'); const p = document.getElementById('sgis-panel'); if (p) p.classList.remove('sgis-open'); const f = document.getElementById('sgis-fab'); if (f) f.classList.remove('sgis-open'); } function togglePanel() { if (SGIS.open) closePanel(); else openPanel(); } function setBody(html) { const b = document.getElementById('sgis-body'); if (b) b.innerHTML = html; } function renderLoading(text) { // v2.9.15: 状态点 + 文字 + spinner 组合,精致化加载态 setBody(`
${text || '正在加载…'}
`); } // v2.8.1: 增强版加载——带阶段进度条 + 游戏骨架占位(参考 steam-friend-manager 加载体验) // 调用方:renderProgressLoading({ stage, totalStages, text, skeletonCount }) 初始化, // 后续用返回的 update({ percent, stage, text, counter }) 增量更新。 // 目的:消除"卡死"感,让用户在等待中看到结构与进度。 function renderProgressLoading(opts) { const cfg = Object.assign({ stage: 1, totalStages: 4, text: '正在加载…', skeletonCount: 8, }, opts || {}); const stagesHtml = Array.from({ length: cfg.totalStages }, (_, i) => `
` ).join(''); const skelHtml = Array.from({ length: Math.max(0, cfg.skeletonCount) }, () => `
` ).join(''); setBody(`
${cfg.text}
0%
${stagesHtml}
${skelHtml}
`); // 返回更新器 return { update(u) { if (!u) return; const fill = document.getElementById('sgis-load-fill'); const pct = document.getElementById('sgis-load-pct'); const text = document.getElementById('sgis-load-stage-text'); const counter = document.getElementById('sgis-load-counter'); const stagesWrap = document.getElementById('sgis-load-stages'); if (u.percent != null) { const p = Math.max(0, Math.min(100, Math.round(u.percent))); if (fill) fill.style.width = p + '%'; if (pct) pct.textContent = p + '%'; } if (u.text != null && text) text.textContent = u.text; if (u.counter != null) { if (counter) { counter.textContent = u.counter; counter.style.display = ''; } } if (u.stage != null && stagesWrap) { const bars = stagesWrap.querySelectorAll('.sgis-load-stage-bar'); bars.forEach((b, i) => { b.classList.remove('active', 'done'); if (i + 1 < u.stage) b.classList.add('done'); else if (i + 1 === u.stage) b.classList.add('active'); }); } }, remove() { const blk = document.getElementById('sgis-load-block'); if (blk) blk.remove(); }, }; } function renderError(text) { // v2.9.15: 错误状态点 + 大字提示 + 重试按钮 setBody(`
${text || '加载失败'}
请检查网络连接或 Steam 登录状态后重试
`); const retryBtn = document.querySelector('#sgis-body .sgis-retry-btn'); if (retryBtn) { retryBtn.addEventListener('click', () => { // 清除当前标签页缓存后重新渲染 if (SGIS.tab === 'overview') { SGIS.overview = null; SGIS.familyShareSupported = null; SGIS.appDetailsExtra = null; SGIS.dlcNames = null; SGIS.drmInfo = null; SGIS.priceChartRange = 'all'; SGIS.gameStatusInfo = null; } else if (SGIS.tab === 'medals') SGIS.cards = null; else if (SGIS.tab === 'prices') { SGIS.prices = null; SGIS.historyPrices = null; SGIS.prediction = null; SGIS.predictionError = null; SGIS.giftRec = null; SGIS.priceChartRange = 'all'; } else if (SGIS.tab === 'reviews') { SGIS.reviews = null; SGIS.reviewsExpanded = new Set(); SGIS.reviewsFilter = { rec: 'all', playtime: 'all', language: 'all', purchase: 'all', keyword: '', regexMode: false }; } else if (SGIS.tab === 'achievements') { SGIS.achievements = null; SGIS.globalAchievements = null; } else if (SGIS.tab === 'dynamics') { SGIS.dynamics = null; SGIS.aiSummary = null; } else if (SGIS.tab === 'profile') { SGIS.profile = null; SGIS.profileError = null; } else if (SGIS.tab === 'activity') { SGIS.activity = null; SGIS.personalTimeline = null; SGIS.familyTimeline = null; } else if (SGIS.tab === 'userBadges') SGIS.userBadges = null; else if (SGIS.tab === 'social') { SGIS.friendsList = null; SGIS.friendsListError = null; SGIS.friendsLevels = null; } else if (SGIS.tab === 'userAchievements') { SGIS.userAchievements = null; SGIS.aiPersona = null; SGIS.aiPersonaError = null; SGIS.insightData = null; SGIS.aiInsight = null; SGIS.aiInsightError = null; SGIS.aiMarketInsight = null; SGIS.aiMarketInsightError = null; } renderTab(SGIS.tab, true); }); } } // ---- 概览标签 (整合 DOM 信息 + 共享游戏库数据) ---- function extractOverviewFromDOM() { const data = { appId: APP_ID, name: '', cover: '', developer: '', publisher: '', releaseDate: '', platforms: [], tags: [], genres: [], priceCurrent: '', priceOriginal: '', discountPercent: 0, isFree: false, reviews: { recent: { label: '', summary: '', count: '', pos: 0 }, all: { label: '', summary: '', count: '', pos: 0 } }, shortDesc: '', }; const nameEl = document.getElementById('appHubAppName'); if (nameEl) data.name = nameEl.textContent.trim(); if (!data.name) { const og = document.querySelector('meta[property="og:title"]'); if (og) data.name = og.content.replace(/\s+on Steam$/i, '').trim(); } const ogImg = document.querySelector('meta[property="og:image"]'); if (ogImg) data.cover = ogImg.content; const devLink = document.querySelector('#developers_list a, .glance_details a[href*="/developer/"], .game_details a.dev_link'); if (devLink) data.developer = devLink.textContent.trim(); const pubLink = document.querySelector('.glance_details a[href*="/publisher/"]'); if (pubLink) data.publisher = pubLink.textContent.trim(); const releaseEl = document.querySelector('.release_date .date'); if (releaseEl) data.releaseDate = releaseEl.textContent.trim(); // 用 Set 去重平台 (页面中可能有多组 .game_area_purchase_game 对应游戏+DLCs) const platformsSet = new Set(); // 兼容多种 selector: 优先 buy 区, 备用 game details const platformNodes = document.querySelectorAll( '.game_area_purchase_game .platform_img, .game_details .platform_img, .game_purchase_platform .platform_img' ); platformNodes.forEach(img => { const cls = (img.className || '').toLowerCase(); if (cls.includes('win')) platformsSet.add('Windows'); else if (cls.includes('mac')) platformsSet.add('macOS'); else if (cls.includes('linux') || cls.includes('steamos')) platformsSet.add('SteamOS + Linux'); }); data.platforms = Array.from(platformsSet); // 兜底: 如果没找到, 从 game details 文本提取 if (data.platforms.length === 0) { const detailsText = (document.querySelector('#game_details, .game_details')?.textContent || '').toLowerCase(); if (/win/i.test(detailsText)) platformsSet.add('Windows'); if (/mac/i.test(detailsText)) platformsSet.add('macOS'); if (/linux/i.test(detailsText)) platformsSet.add('SteamOS + Linux'); data.platforms = Array.from(platformsSet); } document.querySelectorAll('.glance_tags .app_tag, .popular_tags .app_tag').forEach(t => { const txt = t.textContent.trim(); if (txt) data.tags.push(txt); }); data.tags = [...new Set(data.tags)].slice(0, 12); document.querySelectorAll('.glance_details a[href*="/genre/"]').forEach(a => { const g = a.textContent.trim(); if (g) data.genres.push(g); }); const freeText = Array.from(document.querySelectorAll('.game_purchase_price')).find(p => /free/i.test(p.textContent)); const finalPrice = document.querySelector('.discount_final_price'); const originalPrice = document.querySelector('.discount_original_price'); const discountPct = document.querySelector('.discount_pct'); if (freeText) { data.isFree = true; data.priceCurrent = '免费'; } else if (finalPrice) { data.priceCurrent = finalPrice.textContent.trim(); if (originalPrice) data.priceOriginal = originalPrice.textContent.trim(); if (discountPct) { const m = discountPct.textContent.match(/(\d+)/); if (m) data.discountPercent = parseInt(m[1], 10); } } else { const pn = document.querySelector('.game_purchase_price'); if (pn) data.priceCurrent = pn.textContent.trim(); } document.querySelectorAll('.user_reviews_summary_row').forEach(row => { const subtitle = row.querySelector('.subtitle')?.textContent?.trim() || ''; const summary = row.querySelector('.game_review_summary')?.textContent?.trim() || ''; const countText = row.querySelector('.responsive_reviewdesc_summary, .review_summary_with_link')?.textContent?.trim() || ''; const countMatch = countText.match(/(\d+(?:[,.]\d+)*)\s*user reviews/); const count = countMatch ? countMatch[1] : ''; const pctMatch = countText.match(/\((\d+)%\)/); const pos = pctMatch ? parseInt(pctMatch[1], 10) : 0; const target = subtitle.includes('All') ? 'all' : 'recent'; data.reviews[target] = { label: subtitle, summary, count, pos }; }); const shortDesc = document.querySelector('.game_description_snippet'); if (shortDesc) data.shortDesc = shortDesc.textContent.trim(); return data; } // ==================== v2.3.19: morelike 页面专用模块 ==================== // ---- 概览提取:morelike 页适配 ---- // morelike 页 DOM 与 /app/ 页不同:源游戏信息在 .recommendation_highlight, // 而非 #appHubAppName / .game_area_purchase_game 等。其余字段留给 appdetails API 补全。 function extractMoreLikeOverview() { const headerImg = document.querySelector('.recommendation_highlight .header_image'); const tagIdsAttr = headerImg?.getAttribute('data-ds-tagids'); const priceEl = document.querySelector('.highlight_description .regular_price, .highlight_description .discount_final_price'); const priceText = priceEl?.textContent.trim() || ''; const isFree = /免费|free/i.test(priceText); const data = { appId: APP_ID, name: document.querySelector('h2.pageheader')?.textContent.trim() || '', cover: headerImg?.querySelector('img')?.src || '', developer: '', publisher: '', releaseDate: '', platforms: [], tags: [], genres: [], priceCurrent: priceText, priceOriginal: '', discountPercent: 0, isFree, reviews: { recent: { label: '', summary: '', count: '', pos: 0 }, all: { label: '', summary: '', count: '', pos: 0 } }, shortDesc: '', // morelike 专用:源游戏标签 ID,驱动类型扩展推荐 sourceTagIds: tagIdsAttr ? (() => { try { return JSON.parse(tagIdsAttr); } catch { return []; } })() : [], pageType: 'morelike', }; return data; } // ---- 相似游戏采集:从 DOM 读取 .similar_grid_capsule ---- function extractSimilarGames() { const items = Array.from(document.querySelectorAll('.similar_grid_capsule')).map(a => { const item = a.closest('.similar_grid_item'); const href = a.getAttribute('href') || ''; const nameSlug = (href.match(/\/app\/\d+\/([^/?]+)/) || [])[1] || ''; const priceEl = item?.querySelector('.similar_grid_price .regular_price, .similar_grid_price .discount_final_price, .similar_grid_price .discount_original_price'); const priceText = priceEl?.textContent.trim() || ''; const tagIdsAttr = a.getAttribute('data-ds-tagids'); return { appId: Number(a.getAttribute('data-ds-appid')), tagIds: tagIdsAttr ? (() => { try { return JSON.parse(tagIdsAttr); } catch { return []; } })() : [], href: href.split('?snr=')[0], capsule: a.querySelector('img')?.getAttribute('src') || '', nameSlug: decodeURIComponent(nameSlug).replace(/_/g, ' ').trim() || ('App ' + a.getAttribute('data-ds-appid')), priceText, isFree: /免费|free/i.test(priceText), status: null, // 'owned' | 'wishlist' | 'ignored' | null,由 annotateSimilarStatus 填充 }; }).filter(g => g.appId && g.appId !== Number(APP_ID)); // 排除源游戏自身 return items; } // ---- 库存状态获取:移植自 SteamPeek 1.9 getSteamData() ---- // 读取 unsafeWindow.GDynamicStore 三集合,兼容数组/对象两种格式 function getDynamicStoreStatus() { if (SGIS.dynamicStoreChecked) return SGIS.dynamicStore; SGIS.dynamicStoreChecked = true; const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; const store = win.GDynamicStore; if (!store) { SGIS.dynamicStore = null; return null; } const toSet = (raw) => { if (!raw) return new Set(); const arr = Array.isArray(raw) ? raw : Object.keys(raw); return new Set(arr.map(Number)); }; const result = { owned: toSet(store.s_rgOwnedApps), wishlist: toSet(store.s_rgWishlist || win.g_rgWishlist), ignored: toSet(store.s_rgIgnoredApps || win.g_rgIgnoredApps), }; SGIS.dynamicStore = result; return result; } // v2.3.29: 异步获取 dynamicstore/userdata API——GDynamicStore 不可用时的回退 // 该端点返回完整的 owned apps 列表(含 CD key 激活、促销许可等),比 GetOwnedGames 更可靠 // 参考 Steam-License-Classifier 的 license 数据获取思路,确保 key 激活的游戏能被正确识别 // 委托给外层 fetchDynamicStoreOwnedAppIds(),避免重复请求和代码重复 async function fetchDynamicStoreUserData() { if (SGIS.dynamicStoreOwnedApps || SGIS.dynamicStoreFetching) return SGIS.dynamicStoreOwnedApps; SGIS.dynamicStoreFetching = true; try { const ownedApps = await fetchDynamicStoreOwnedAppIds(); SGIS.dynamicStoreOwnedApps = ownedApps; } catch (e) { console.warn('[SGLV] 侧边栏 dynamicstore/userdata 获取失败:', e); } finally { SGIS.dynamicStoreFetching = false; } return SGIS.dynamicStoreOwnedApps; } // ---- 将库存状态回填到相似游戏数据 ---- // 优先级:owned > wishlist > ignored(与 SteamPeek 1.9 一致) // 未登录兜底:GDynamicStore 三集合为空时,回退 state.ownedAppIds 判定 owned function annotateSimilarStatus() { if (!SGIS.similarGames) return; const ds = getDynamicStoreStatus(); const hasDs = ds && (ds.owned.size > 0 || ds.wishlist.size > 0 || ds.ignored.size > 0); // 未登录兜底:用本地扫描的游戏库 const localOwned = (!hasDs || ds.owned.size === 0) ? state.ownedAppIds : null; for (const g of SGIS.similarGames) { if (ds) { if (ds.owned.has(g.appId)) { g.status = 'owned'; continue; } if (ds.wishlist.has(g.appId)) { g.status = 'wishlist'; continue; } if (ds.ignored.has(g.appId)) { g.status = 'ignored'; continue; } } if (localOwned && localOwned.has(g.appId)) { g.status = 'owned'; continue; } g.status = null; } } // ---- 渲染相似游戏区块 HTML ---- function renderSimilarGamesSection() { if (!IS_MORELIKE) return ''; if (!SGIS.similarGames) return ''; const games = SGIS.similarGames; if (!games.length) return ''; annotateSimilarStatus(); // 筛选 let filtered = games.slice(); if (SGIS.similarFilter === 'owned') filtered = filtered.filter(g => g.status === 'owned'); else if (SGIS.similarFilter === 'unowned') filtered = filtered.filter(g => g.status !== 'owned'); else if (SGIS.similarFilter === 'wishlist') filtered = filtered.filter(g => g.status === 'wishlist'); // 排序 const parsePrice = (text) => { if (!text || /免费|free/i.test(text)) return 0; const m = text.match(/([\d,.]+)/); return m ? parseFloat(m[1].replace(/,/g, '')) : 0; }; if (SGIS.similarSort === 'priceAsc') filtered.sort((a, b) => parsePrice(a.priceText) - parsePrice(b.priceText)); else if (SGIS.similarSort === 'priceDesc') filtered.sort((a, b) => parsePrice(b.priceText) - parsePrice(a.priceText)); else if (SGIS.similarSort === 'name') filtered.sort((a, b) => a.nameSlug.localeCompare(b.nameSlug)); // 展示数量 const limit = SGIS.similarExpanded ? filtered.length : SGIS.similarDisplayLimit; const shown = filtered.slice(0, limit); // 统计 const ownedCount = games.filter(g => g.status === 'owned').length; const wishlistCount = games.filter(g => g.status === 'wishlist').length; const unownedCount = games.length - ownedCount; // 登录提示 const ds = SGIS.dynamicStore; const noDsData = !ds || (ds.owned.size === 0 && ds.wishlist.size === 0 && ds.ignored.size === 0); const loginHint = noDsData ? `` : ''; // 工具栏 const toolbar = `
${wishlistCount ? `` : ''}
`; // 卡片网格 const cards = shown.map(g => { const tagHtml = g.status === 'owned' ? '在库中' : g.status === 'wishlist' ? '愿望单' : g.status === 'ignored' ? '已忽略' : ''; const priceClass = g.isFree ? 'free' : (g.priceText ? '' : 'unknown'); const priceDisplay = g.priceText || '价格未知'; return ` ${tagHtml} ${g.capsule ? `${g.nameSlug}` : '
'}
${g.nameSlug}
${priceDisplay}
`; }).join(''); const moreHtml = filtered.length > limit ? `
显示 ${shown.length}/${filtered.length} · 查看全部 →
` : filtered.length > SGIS.similarDisplayLimit && SGIS.similarExpanded ? `` : ''; return `
${SGIS_ICONS.game} 相似游戏推荐 (来自本页 · ${games.length})
${loginHint} ${toolbar}
${cards}
${moreHtml}
`; } // ---- 绑定相似游戏区块事件 ---- function bindSimilarGamesEvents() { if (!IS_MORELIKE || !SGIS.similarGames) return; const body = document.getElementById('sgis-body'); if (!body) return; // 筛选按钮 body.querySelectorAll('[data-sim-filter]').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); SGIS.similarFilter = btn.getAttribute('data-sim-filter'); renderOverview(); }); }); // 排序按钮(循环切换) const sortBtn = body.querySelector('[data-sim-sort]'); if (sortBtn) { sortBtn.addEventListener('click', (e) => { e.preventDefault(); const order = ['default', 'priceAsc', 'priceDesc', 'name']; const idx = order.indexOf(SGIS.similarSort); SGIS.similarSort = order[(idx + 1) % order.length]; renderOverview(); }); } // 展开/收起 const expandBtn = body.querySelector('[data-sim-expand]'); if (expandBtn) { expandBtn.addEventListener('click', (e) => { e.preventDefault(); SGIS.similarExpanded = true; renderOverview(); }); } const collapseBtn = body.querySelector('[data-sim-collapse]'); if (collapseBtn) { collapseBtn.addEventListener('click', (e) => { e.preventDefault(); SGIS.similarExpanded = false; renderOverview(); }); } } // ---- 标签名反查:tagid → name ---- // 策略:① 从 appdetails 的 categories/tags 匹配 ② 从源游戏相似游戏的 tagids 交叉 ③ 本地高频映射表 const TAG_ID_NAME_MAP = { 19: '动作', 21: '冒险', 122: 'RPG', 128: '单人', 1695: '开放世界', 1754: '大型多人在线', 3859: '免费开玩', 3870: '俯视角', 3964: '像素图形', 4135: '重玩价值', 4168: '视觉小说', 4231: '角色扮演', 4345: '生存', 4747: '多人', 492: '独立', 493: '早期访问', 599: '策略', 597: '休闲', 5350: '类魂', 4625: '派对', 4444: '叙事', 1719: '奇幻', 1720: '科幻', 1742: '射击', 1775: '军事', 1665: '生存', 1646: 'Roguelike', 1684: '合作', 3843: '农场模拟', 4106: '时间管理', 4172: '2D', 4182: '3D', 4663: '卡牌', 4684: '在线对战', 6730: '蒸汽工作室', 723991: '收纳', }; function resolveTagName(tagId) { if (TAG_ID_NAME_MAP[tagId]) return TAG_ID_NAME_MAP[tagId]; // 从 appdetails categories 中查找 const extra = SGIS.appDetailsExtra; if (extra) { const cat = extra.categories.find(c => c.id === tagId); if (cat) return cat.description; // v2.9.57: genres 从 genreObjs 查找(库返回的 genres 是字符串数组,无 id) const genre = (extra.genreObjs || []).find(g => g.id === tagId); if (genre) return genre.description; } return '标签 ' + tagId; } // ---- 类型扩展推荐:调用 Steam 标签 API ---- async function fetchTagRecommendations(tagIds) { if (!tagIds || !tagIds.length) return []; const cacheKey = 'tagRecs_' + tagIds.join(','); const cached = cacheGet(cacheKey); if (cached) return cached; SGIS.tagRecsLoading = true; SGIS.tagRecsError = null; try { const results = await mapLimit(tagIds, 4, async (tagId) => { try { const url = `https://steamcommunity.com/actions/QueryAppsWithTag/${tagId}`; const data = await fetchJson(url, { timeout: 10000 }); const appIds = (data.appids || data || []).slice(0, 50).map(Number); return { tagId, tagName: resolveTagName(tagId), appIds, }; } catch (e) { return { tagId, tagName: resolveTagName(tagId), appIds: [], error: e.message }; } }); const merged = results.filter(r => r.appIds.length); // 排除已在相似游戏列表中的 appid,避免重复 const similarSet = new Set((SGIS.similarGames || []).map(g => g.appId)); for (const r of merged) { r.appIds = r.appIds.filter(id => !similarSet.has(id) && id !== Number(APP_ID)); } // 过滤掉去重后为空的标签 const final = merged.filter(r => r.appIds.length); cacheSet(cacheKey, final, 6 * 3600 * 1000); // 6h 缓存 return final; } catch (e) { SGIS.tagRecsError = e.message || '标签推荐获取失败'; return []; } finally { SGIS.tagRecsLoading = false; } } // ---- 渲染类型扩展推荐区块 HTML ---- function renderTagRecsSection() { if (!IS_MORELIKE) return ''; const o = SGIS.overview; if (!o || !o.sourceTagIds || !o.sourceTagIds.length) return ''; // 加载中 if (SGIS.tagRecsLoading && !SGIS.tagRecs) { return `
${SGIS_ICONS.tag} 同类型游戏 (按标签扩展)
正在从 Steam 标签 API 拉取同类型游戏…
`; } // 加载失败 if (SGIS.tagRecsError && !SGIS.tagRecs) { return `
${SGIS_ICONS.tag} 同类型游戏
⚠ ${SGIS.tagRecsError}
`; } const recs = SGIS.tagRecs; if (!recs || !recs.length) return ''; // 标签切换栏 const activeTag = SGIS.activeTagId || recs[0].tagId; SGIS.activeTagId = activeTag; const tabsHtml = recs.map(r => { const active = r.tagId === activeTag; return ``; }).join(''); // 当前标签下的游戏(需要 appid → 卡片,但只有 appid,无封面/价格) // 展示为文本链接列表,点击跳转商店页 const activeRec = recs.find(r => r.tagId === activeTag); if (!activeRec) return ''; const limit = SGIS.tagRecDisplayLimit; const shownIds = activeRec.appIds.slice(0, limit); const total = activeRec.appIds.length; const ds = SGIS.dynamicStore; const cards = shownIds.map(appId => { const status = ds && ds.owned.has(appId) ? 'owned' : ds && ds.wishlist.has(appId) ? 'wishlist' : ds && ds.ignored.has(appId) ? 'ignored' : null; const tagHtml = status === 'owned' ? '在库中' : status === 'wishlist' ? '愿望单' : status === 'ignored' ? '已忽略' : ''; return ` ${tagHtml}
App ${appId}
AppID ${appId}
点击查看
`; }).join(''); const moreHtml = total > limit ? `
该标签下 ${total} 款 · 仅展示评分较高 ${limit} 款 · 查看全部 →
` : ``; return `
${SGIS_ICONS.tag} 同类型游戏 (按标签扩展)
${tabsHtml}
${cards}
${moreHtml}
`; } // ---- 绑定类型推荐区块事件 ---- function bindTagRecsEvents() { if (!IS_MORELIKE || !SGIS.tagRecs) return; const body = document.getElementById('sgis-body'); if (!body) return; body.querySelectorAll('[data-tagrec-tab]').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); SGIS.activeTagId = Number(btn.getAttribute('data-tagrec-tab')); renderOverview(); }); }); } function reviewBar(posPercent) { const color = posPercent >= 80 ? 'var(--sgis-green)' : posPercent >= 50 ? 'var(--sgis-amber)' : 'var(--sgis-rose)'; return `
`; } // 共享数据 -> 渲染个人游戏状态卡片 function renderMyGameStatus() { const info = getCurrentGameInfo(); if (!info.found) { // v2.3.29: 显示正在检查动态数据的提示 const checkingHint = (!SGIS.dynamicStoreOwnedApps && !SGIS.dynamicStoreFetching) ? '
正在检查 Steam 商店动态数据(CD Key 激活的游戏可能需要此检测)…
' : ''; return `
${SGIS_ICONS.library} 我的游戏库
未在你的游戏库中找到此游戏。
打开导航栏【游戏库】面板,点击「刷新」按钮,即可获取完整的个人游戏库 + 家庭组共享数据。
${checkingHint}
`; } const playtimeHours = info.playtime > 0 ? (info.playtime / 60).toFixed(1) : '0'; const acquiredTime = formatAcquiredTime(info.acquiredTime); // 顶部 hero (状态/分类) let heroClass = '', heroIcon = SGIS_ICONS.check, heroMain = '已拥有', heroSub = ''; if (info.isOwnedByMe) { heroClass = ''; heroMain = '你已拥有'; // v2.3.29: 新增 dynamicstore / dynamicstore_api 数据源说明 const sourceLabels = { 'api': 'Web API', 'family': '家庭组', 'scrape': '页面抓取', 'dynamicstore': '商店动态数据', 'dynamicstore_api': '商店动态API', }; heroSub = `来源: ${sourceLabels[info._source] || '缓存'} · ${playtimeHours}h · 入库 ${acquiredTime}`; } else if (info.isSharedOnly) { heroClass = 'shared'; heroIcon = SGIS_ICONS.share; heroMain = '家庭组共享'; const sharer = info.owners.find(o => !o.isMe); heroSub = sharer ? `${sharer.name} 已拥有 · 共享给你 · 入库 ${acquiredTime}` : '由家庭组成员购买'; } // 所有者列表 (家庭组共享 / 多成员拥有) let ownerListHtml = ''; if (info.owners.length > 0) { ownerListHtml = `
${SGIS_ICONS.share} 家庭组购买情况
${info.owners.map(o => { const tag = o.isMe ? '' : (info.isOwnedByMe ? '' : '共享人'); const cls = o.isMe ? 'me' : (info.isOwnedByMe ? '' : 'sharer'); const avatarText = (o.name || '?').slice(0, 1).toUpperCase(); return `
${avatarText}
${o.name} ${tag}
${info.acquiredTime ? `
入库 ${acquiredTime}
` : ''} ${o.playtime != null ? `
${o.playtime > 0 ? `已游玩 ${(o.playtime / 60).toFixed(1)} 小时` : '未游玩'}
` : ''}
`; }).join('')}
${info.owners.filter(o => !o.isMe).length > 0 ? `
共 ${info.owners.length} 位家庭组成员拥有,${info.owners.filter(o => !o.isMe).length} 位可共享
` : ''}
`; } return `
${heroIcon}
${heroMain}${heroSub}
商店页
${renderFamilyShareIndicator(info)} ${ownerListHtml} `; } // ---- 获取家庭成员游玩时长 ---- async function fetchOwnersPlaytime() { const apiKey = storage.getApiKey(); if (!apiKey) return; if (SGIS.ownersPlaytimeFetching) return; const info = getCurrentGameInfo(); if (!info.found || !info.owners.length) return; const ownersToFetch = info.owners.filter(o => !o.isMe && SGIS.ownersPlaytime[o.steamId] == null); if (!ownersToFetch.length) return; SGIS.ownersPlaytimeFetching = true; try { const results = {}; await Promise.all(ownersToFetch.map(async o => { try { const data = await fetchJson(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${apiKey}&steamid=${o.steamId}&include_played_free_games=1&format=json`, { timeout: 12000 }); const games = data?.response?.games || []; const target = games.find(g => String(g.appid) === String(APP_ID)); results[o.steamId] = target ? (target.playtime_forever || 0) : 0; } catch { results[o.steamId] = 0; } })); Object.assign(SGIS.ownersPlaytime, results); if (SGIS.tab === 'overview') renderOverview(); else if (SGIS.tab === 'playtrend') renderPlayTrend(); } finally { SGIS.ownersPlaytimeFetching = false; } } function renderOverview() { // v2.3.19: morelike 页面使用专用概览提取器 if (!SGIS.overview) SGIS.overview = IS_MORELIKE ? extractMoreLikeOverview() : extractOverviewFromDOM(); // v2.3.19: morelike 页面采集相似游戏 + 库存标记 if (IS_MORELIKE && !SGIS.similarGames) { SGIS.similarGames = extractSimilarGames(); annotateSimilarStatus(); } const o = SGIS.overview; const extra = SGIS.appDetailsExtra; // v2.3.16: appdetails 增强信息 // v2.3.16: 用 API 数据补全 DOM 缺失字段 const devName = o.developer || (extra && extra.developers.length ? extra.developers.join(', ') : ''); const pubName = o.publisher || (extra && extra.publishers.length ? extra.publishers.join(', ') : ''); const releaseDate = o.releaseDate || (extra && extra.releaseDate) || ''; const coverUrl = o.cover || (extra && extra.headerImage) || ''; const genres = o.genres.length ? o.genres : (extra && extra.genres) || []; const platforms = o.platforms.length ? o.platforms : (extra && extra.platforms) || []; const reviewRowHtml = (rev) => { if (!rev.count) return ''; const cls = rev.pos >= 80 ? 'pos' : rev.pos >= 50 ? 'mix' : 'neg'; const thumb = rev.pos >= 70 ? 'up' : 'down'; return `
${rev.label || '评测'} ${rev.summary || ''} ${rev.count}
${reviewBar(rev.pos)}`; }; const tagHtml = o.tags.length ? `
${o.tags.map(t => `${t}`).join('')}
` : '
无标签
'; const priceHtml = o.isFree ? `
价格免费
` : o.discountPercent > 0 ? `
价格${o.priceCurrent} -${o.discountPercent}%
${o.priceOriginal}
` : (o.priceCurrent ? `
价格${o.priceCurrent}
` : ''); const linkHtml = ``; // v2.3.16: 基本信息附加芯片 (Metacritic / 评测数 / 成就数 / DLC 数) const metaChipsHtml = (() => { const chips = []; if (extra) { if (extra.metacritic && extra.metacritic.score) { const cls = extra.metacritic.score >= 75 ? 'pos' : extra.metacritic.score >= 50 ? 'warn' : 'neg'; const url = extra.metacritic.url || ''; const inner = `${SGIS_ICONS.star} Metacritic ${extra.metacritic.score}`; chips.push(url ? `${inner}` : `${inner}`); } if (extra.recommendations > 0) { chips.push(`${SGIS_ICONS.barChart} ${formatBigNumber(extra.recommendations)} 评测`); } if (extra.achievementsTotal > 0) { chips.push(`${SGIS_ICONS.trophy} ${extra.achievementsTotal} 成就`); } if (extra.dlc.length > 0) { const dlcUrl = `https://store.steampowered.com/dlc/${APP_ID}/`; chips.push(`${SGIS_ICONS.puzzle} ${extra.dlc.length} 个 DLC`); } // v2.3.17: 控制器支持芯片 if (extra.controllerSupport === 'full') { chips.push(`${SGIS_ICONS.gamepad} 完全支持控制器`); } else if (extra.controllerSupport === 'partial') { chips.push(`${SGIS_ICONS.gamepad} 部分支持控制器`); } if (extra.website) { chips.push(`${SGIS_ICONS.external} 官网`); } } return chips.length ? `
${chips.join('')}
` : ''; })(); // v2.3.17: 年龄限制徽章 + 内容警告 const ageBadgeHtml = (() => { if (!extra) return ''; const info = ageBadgeInfo(extra.requiredAge); let html = `${info.icon} ${info.text}`; // 免费游戏标识 if (extra.isFree) { html += `免费游戏`; } // 内容描述符警告 if (extra.contentDescriptors && extra.contentDescriptors.notes) { html += `
${SGIS_ICONS.shield} ${extra.contentDescriptors.notes}
`; } return `
${html}
`; })(); // v2.9.8: 跨平台订阅状态展示 const subStatusHtml = (() => { const badges = []; const appIdNum = Number(APP_ID); // PS会免 / Epic 状态从 GM 缓存检查 try { const psplusCache = cacheGet('psplus'); if (psplusCache && Array.isArray(psplusCache) && psplusCache.includes(appIdNum)) { badges.push(`${ICONS.playstation} ${isZh ? 'PS会免' : 'PS Plus'}`); } } catch (e) { /* ignore */ } try { const epicCache = cacheGet('epic'); if (epicCache && Array.isArray(epicCache) && epicCache.includes(appIdNum)) { badges.push(`${ICONS.epic} ${isZh ? 'Epic赠送' : 'Epic Free'}`); } } catch (e) { /* ignore */ } if (badges.length === 0) return ''; return `
${badges.join('')}
`; })(); // v2.3.17: DLC 列表区块 const dlcListHtml = (() => { if (!extra || !extra.dlc.length) return ''; const dlcNames = SGIS.dlcNames || {}; const items = extra.dlc.map(dlcId => { const info = dlcNames[dlcId]; const nameClass = info && info.name ? '' : 'loading'; const nameText = info && info.name ? info.name : '加载中…'; const url = `https://store.steampowered.com/app/${dlcId}/`; return ` ${dlcId} ${nameText} ${info && info.isFree ? '免费' : ''} `; }).join(''); const dlcAllUrl = `https://store.steampowered.com/dlc/${APP_ID}/`; return `
${SGIS_ICONS.puzzle} DLC 列表 (${extra.dlc.length})
${items}
查看全部 DLC →
`; })(); // v2.3.17: 支持语言区块 const languagesHtml = (() => { if (!extra || !extra.supportedLanguages || !extra.supportedLanguages.list.length) return ''; const langs = extra.supportedLanguages.list; // 最多展示 12 个, 其余折叠 const shown = langs.slice(0, 12).map(l => `${l.name}${l.audio ? ' ♪' : ''}` ).join(''); const more = langs.length > 12 ? `+${langs.length - 12}` : ''; const noteHtml = extra.supportedLanguages.note ? `
♪ ${extra.supportedLanguages.note}
` : ''; return `
${SGIS_ICONS.globe} 支持语言 (${langs.length})
${shown}${more}
${noteHtml}
`; })(); // v2.3.17: 预告片区块 const moviesHtml = (() => { if (!extra || !extra.movies.length) return ''; const storeUrl = `https://store.steampowered.com/app/${APP_ID}/`; const items = extra.movies.map(m => { const playIcon = ``; // 流媒体清单 URL 供 title 显示 (高级用户可复制到 VLC 播放) const streamUrl = m.hls_h264 || m.dash_h264 || m.dash_av1 || ''; const titleParts = [m.name]; if (streamUrl) titleParts.push(`\n流媒体: ${streamUrl}\n(需 VLC/mpv 等播放器打开)`); return ` ${m.name}
${playIcon}
${m.name}
`; }).join(''); return `
${SGIS_ICONS.video} 预告片 (${extra.movies.length})
${items}
点击缩略图在 Steam 商店页观看 · 悬停可查看流媒体清单 URL
`; })(); // v2.3.16: 游戏特性 (分类标签, 工坊高亮) const categoriesHtml = (() => { if (!extra || !extra.categories.length) return ''; const pills = extra.categories.map(c => { const isWorkshop = c.id === 30 || /创意工坊|steam\s*workshop/i.test(c.description); return `${c.description}`; }).join(''); return `
${SGIS_ICONS.tag} 游戏特性
${pills}
`; })(); // v2.3.16: 捆绑包/购买选项 const packagesHtml = (() => { if (!extra || !extra.packages.length) return ''; // 单个购买选项且为免费/原价时不展示 (避免冗余) if (extra.packages.length === 1 && !extra.packages[0].discount) return ''; const items = extra.packages.map(p => { const flagHtml = p.isFree ? '免费' : (p.discount > 0 ? `-${p.discount}%` : ''); const priceHtml = p.isFree ? '免费' : (p.priceText ? `${p.priceText}` : ''); return `
${flagHtml} ${p.name} ${priceHtml}
`; }).join(''); return `
${SGIS_ICONS.package} 购买选项
${items}
`; })(); // v2.3.16: 游戏截图 (3 列网格, 最多 9 张) const screenshotsHtml = (() => { if (!extra || !extra.screenshots.length) return ''; const items = extra.screenshots.map(s => ` 截图 `).join(''); return `
${SGIS_ICONS.image} 游戏截图
${items}
`; })(); setBody(`
${coverUrl ? `` : ''}
${o.name || ('游戏 ' + APP_ID)}
${devName ? `${SGIS_ICONS.package} 开发商 ${devName}` : ''} ${pubName && pubName !== devName ? `${SGIS_ICONS.share} 发行 ${pubName}` : ''} ${releaseDate ? `${releaseDate}` : ''}
${renderMyGameStatus()} ${subStatusHtml} ${renderDRMBadge()} ${renderCrackStatusBadge()} ${renderChineseAudioBadge()} ${ageBadgeHtml} ${renderWorkshopIndicator()}
${SGIS_ICONS.info} 基本信息
${priceHtml} ${genres.length ? `
类型${genres.join(' / ')}
` : ''} ${(extra && extra.appType) ? `
应用类型${localizeAppType(extra.appType)}
` : ''} ${platforms.length ? `
平台${platforms.join(' / ')}
` : ''}
AppID${APP_ID}
${metaChipsHtml}
${categoriesHtml} ${languagesHtml} ${o.tags.length ? `
${SGIS_ICONS.info} 热门标签
${tagHtml}
` : ''} ${packagesHtml} ${dlcListHtml} ${moviesHtml} ${screenshotsHtml} ${(o.reviews.recent.count || o.reviews.all.count) ? `
${SGIS_ICONS.star} 评测摘要
${reviewRowHtml(o.reviews.recent)} ${reviewRowHtml(o.reviews.all)}
` : ''} ${renderSimilarGamesSection()} ${renderTagRecsSection()}
${SGIS_ICONS.info} 快捷链接
${linkHtml}
${o.shortDesc ? `
简介
${o.shortDesc}
` : ''} `); // v2.3.16: 截图懒加载 (data-src -> src) document.querySelectorAll('#sgis-body .sgis-screenshot-item img[data-src]').forEach(img => { img.src = img.dataset.src; }); // v2.3.17: 预告片缩略图懒加载 document.querySelectorAll('#sgis-body .sgis-movie-item img[data-src]').forEach(img => { img.src = img.dataset.src; }); // v2.3.19: 绑定相似游戏 + 类型推荐区块事件 bindSimilarGamesEvents(); bindTagRecsEvents(); // v2.3.19: 异步拉取类型扩展推荐(不阻塞首屏渲染) if (IS_MORELIKE && o.sourceTagIds && o.sourceTagIds.length && !SGIS.tagRecs && !SGIS.tagRecsLoading) { fetchTagRecommendations(o.sourceTagIds).then(recs => { SGIS.tagRecs = recs; if (SGIS.tab === 'overview') renderOverview(); }).catch(() => {}); } // 异步获取家庭成员游玩时长,不阻塞渲染 fetchOwnersPlaytime().catch(() => {}); // v2.3.29: 异步回退——游戏未在 GetOwnedGames 中找到时,尝试 dynamicstore/userdata API // 该 API 返回完整的 owned apps 列表(含 CD key 激活的游戏),比 GetOwnedGames 更可靠 if (HAS_APP_ID && !SGIS.dynamicStoreOwnedApps && !SGIS.dynamicStoreFetching) { const info = getCurrentGameInfo(); if (!info.found) { fetchDynamicStoreUserData().then(ownedApps => { if (ownedApps && ownedApps.has(Number(APP_ID)) && SGIS.tab === 'overview') { renderOverview(); } }).catch(() => {}); } } } // ---- 勋章标签 ---- // v2.9.31: 社区登录状态前置检测 (借鉴 Steam Get Trading Card Info 脚本) // 缓存登录状态避免重复请求, 未登录时提前提示用户而非静默失败 let _communityLoginCache = null; // null=未检测, true=已登录, false=未登录 let _communityLoginChecking = false; function checkCommunityLogin() { if (_communityLoginCache !== null) return Promise.resolve(_communityLoginCache); if (_communityLoginChecking) return Promise.resolve(null); // 正在检测中, 返回 null 表示待定 _communityLoginChecking = true; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://steamcommunity.com/market/', timeout: 8000, onload(r) { _communityLoginChecking = false; try { const doc = new DOMParser().parseFromString(r.responseText, 'text/html'); const loggedIn = !!doc.querySelector('#account_pulldown'); _communityLoginCache = loggedIn; resolve(loggedIn); } catch { _communityLoginCache = null; // 解析失败, 不缓存, 下次重试 resolve(null); } }, onerror() { _communityLoginChecking = false; _communityLoginCache = null; resolve(null); }, ontimeout() { _communityLoginChecking = false; _communityLoginCache = null; resolve(null); }, }); }); } function renderMedals() { if (SGIS.cardsLoading) return; if (SGIS.cards) { renderMedalsContent(); return; } SGIS.cardsLoading = true; renderLoading('正在获取集换式卡牌价格…'); // v2.9.31: 前置检测社区登录状态 checkCommunityLogin().then(loggedIn => { if (loggedIn === false) { SGIS.cardsLoading = false; setBody(`
⚠️
Steam 社区未登录
卡牌价格需要登录 Steam 社区才能获取
前往登录
`); return; } fetchCardPrices(APP_ID).then(data => { SGIS.cards = data; renderMedalsContent(); }).catch(e => { renderError('卡牌价格获取失败: ' + e.message); }).finally(() => { SGIS.cardsLoading = false; }); }); } function renderMedalsContent() { const data = SGIS.cards; const allCards = [...(data.regular || []), ...(data.foil || [])]; if (allCards.length === 0) { setBody(`
🎴
该游戏没有可交易的集换式卡牌
可能不支持 Steam 集换式卡牌
`); return; } // 从卡牌价格文本中提取用户钱包货币符号 (如 ¥ ₹ $ 等),避免硬编码 CNY const samplePriceText = allCards.find(c => c.sellPriceText)?.sellPriceText || ''; const curSymbol = (samplePriceText.match(/^([^\d.,\s]+)/) || [])[1] || '¥'; // sellPrice/buyPrice 都是最小单位 (分), 显示时 /100 换算到主单位 const sellTotal = (data.regular || []).reduce((s, c) => s + (c.sellPrice || 0), 0) / 100; const foilSellTotal = (data.foil || []).reduce((s, c) => s + (c.sellPrice || 0), 0) / 100; const buyTotal = (data.regular || []).reduce((s, c) => s + (c.buyPrice || 0), 0) / 100; const netTotal = (data.regular || []).reduce((s, c) => s + (c.netPrice || 0), 0) / 100; // v2.9.31: 计算中位数和忽略最高价均价 (借鉴 Trading Card Info 脚本统计模式) const regPrices = (data.regular || []).filter(c => c.sellPrice != null).map(c => c.sellPrice).sort((a, b) => a - b); let medianPrice = 0, avgNoMax = 0; if (regPrices.length > 0) { const mid = Math.floor(regPrices.length / 2); medianPrice = regPrices.length % 2 !== 0 ? regPrices[mid] : Math.round((regPrices[mid - 1] + regPrices[mid]) / 2); if (regPrices.length > 1) { avgNoMax = Math.round(regPrices.slice(0, -1).reduce((s, p) => s + p, 0) / (regPrices.length - 1)); } else { avgNoMax = regPrices[0]; } } const medianDisplay = medianPrice > 0 ? `${curSymbol}${(medianPrice / 100).toFixed(2)}` : '—'; const avgNoMaxDisplay = avgNoMax > 0 ? `${curSymbol}${(avgNoMax / 100).toFixed(2)}` : '—'; const showing = SGIS.medalTab; const cards = showing === 'foil' ? (data.foil || []) : (data.regular || []); const cardItemHtml = (c) => { const sellMajor = toMajorUnit(c.sellPrice); const buyMajor = toMajorUnit(c.buyPrice); // 显示价格: 优先用 Steam 给的本地化文本, 其次用数值 (转主单位) const sellDisplay = c.sellPriceText || (sellMajor != null ? curSymbol + sellMajor.toFixed(2) : ''); const buyDisplay = c.buyPrice != null ? curSymbol + buyMajor.toFixed(2) : (c.buyError ? '—' : ''); // v2.3.1: 卡牌图作为背景 (勋章页面), 缩略图同时保留; 失败时用精美 SVG 兜底 // 由于 CSS background-image 加载失败无事件, 用一个隐藏的 img 预加载检测, 成功后才启用背景 const iconHtml = c.iconUrl ? `` : `
${SGIS_ICONS.medalFallback}
`; // 卡牌图作为背景: 仅当有 iconUrl 时启用, 加载失败时移除 sgis-card-with-bg const hasBg = !!c.iconUrl; return ` ${iconHtml}
${c.name.replace(/\(Foil\)$/i, '').trim()}${c.foil ? '' : ''}
${sellDisplay ? `出售 ${sellDisplay}` : ''} ${buyDisplay ? `求购 ${buyDisplay}` : ''}
${c.listings ? `${c.listings}件` : ''}
`; }; setBody(`
${curSymbol}${sellTotal.toFixed(2)}
普通出售合计
${curSymbol}${netTotal.toFixed(2)}
到手价合计
${curSymbol}${buyTotal.toFixed(2)}
求购合计
中位价 (抗极端高价)${medianDisplay}
均价 (忽略最高价)${avgNoMaxDisplay}
${SGIS_ICONS.card} ${showing === 'foil' ? '闪卡' : '普通卡'} 列表
${cards.length ? cards.map(cardItemHtml).join('') : '
无此类卡牌
'}
${(data.foil || []).length ? `
${SGIS_ICONS.info} 闪卡市场
闪卡出售合计${curSymbol}${foilSellTotal.toFixed(2)}
闪卡数量${data.foil.length}
` : ''}
出价数据来自 Steam 社区市场 · ${data.regularQueried ? '已查询' : '查询中'} · v2.9.32 到手价采用可变发行商费率精确计算
`); const body = document.getElementById('sgis-body'); if (body) { body.querySelectorAll('.sgis-card-toggle button').forEach(btn => { btn.addEventListener('click', () => { SGIS.medalTab = btn.dataset.group; renderMedalsContent(); }); }); // v2.3.1: 异步预加载卡牌图, 成功则启用背景图, 失败则保持默认(不显示背景, 用 SVG 兜底已生效) body.querySelectorAll('.sgis-card-pending-bg[data-bg-url]').forEach(card => { const url = card.dataset.bgUrl; if (!url) return; const probe = new Image(); probe.onload = () => { // 加载成功, 启用背景图 + 切换 class card.style.setProperty('--sgis-card-bg', `url('${url}')`); card.classList.remove('sgis-card-pending-bg'); card.classList.add('sgis-card-with-bg'); }; probe.onerror = () => { // 加载失败, 移除待定状态, 保持纯缩略图 (fallback SVG) card.classList.remove('sgis-card-pending-bg'); }; probe.src = url; }); } } // ---- 价格标签 (多地区价格 + ITAD 历史价格 + AI预测) ---- function appendHistoryPricesToBody(historyData) { if (!historyData || (!historyData.history?.length && !historyData.lowest && !historyData.discounts?.length)) return; const bodyEl = document.getElementById('sgis-body'); if (!bodyEl || SGIS.tab !== 'prices') return; const temp = document.createElement('div'); temp.innerHTML = renderHistoryPricesSection(historyData); while (temp.firstChild) bodyEl.appendChild(temp.firstChild); // v2.9.9: 绑定价格图表时间范围按钮事件 bindPriceChartRangeEvents(historyData); // 追加 AI 预测按钮 appendPredictButton(bodyEl); // 追加已有预测结果 if (SGIS.prediction) appendPredictionResult(bodyEl); } function appendPredictButton(bodyEl) { const enabledModes = storage.getPredictModes(); const modesLabel = enabledModes.map(m => { const mode = PREDICT_MODES.find(p => p.key === m); return mode ? mode.name : ''; }).filter(Boolean).join(' / '); const btnHtml = `
${SGIS.predictionLoading ? `
${T.aiPredicting}
` : ''} ${SGIS.predictionError ? `
⚠️ ${T.aiPredictFail}: ${SGIS.predictionError}
` : ''}
`; const temp = document.createElement('div'); temp.innerHTML = btnHtml; while (temp.firstChild) bodyEl.appendChild(temp.firstChild); const btn = document.getElementById('sgis-predict-trigger'); if (btn) { btn.addEventListener('click', () => triggerPrediction()); } } function triggerPrediction() { if (SGIS.predictionLoading) return; if (!SGIS.historyPrices || (!SGIS.historyPrices.discounts?.length && !SGIS.historyPrices.history?.length)) { showToast(T.aiPredictNoData); return; } const apiKey = storage.getAiApiKey(); if (!apiKey) { showToast(T.aiPredictNoKey); return; } SGIS.predictionLoading = true; SGIS.predictionError = null; SGIS.prediction = null; // 更新UI显示loading const loadingEl = document.getElementById('sgis-predict-loading'); const btn = document.getElementById('sgis-predict-trigger'); if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; } // 插入loading区域 if (!loadingEl) { const bodyEl = document.getElementById('sgis-body'); if (bodyEl) { const div = document.createElement('div'); div.id = 'sgis-predict-loading'; div.className = 'sgis-state'; div.style.cssText = 'padding:12px'; div.innerHTML = `
${T.aiPredicting}`; bodyEl.appendChild(div); } } // 获取当前价格信息 const overview = SGIS.overview || {}; const currentPrice = overview.priceCurrent ? parseFloat(overview.priceCurrent.replace(/[^0-9.]/g, '')) : null; const originalPrice = overview.priceOriginal ? parseFloat(overview.priceOriginal.replace(/[^0-9.]/g, '')) : null; const gameName = overview.name || ''; callAiPricePredict(SGIS.historyPrices, gameName, APP_ID, currentPrice, originalPrice) .then(result => { SGIS.prediction = result; SGIS.predictionLoading = false; // 移除loading const ld = document.getElementById('sgis-predict-loading'); if (ld) ld.remove(); if (btn) { btn.disabled = false; btn.style.opacity = ''; } // 追加结果 const bodyEl = document.getElementById('sgis-body'); if (bodyEl) appendPredictionResult(bodyEl); }) .catch(e => { SGIS.predictionError = e.message; SGIS.predictionLoading = false; const ld = document.getElementById('sgis-predict-loading'); if (ld) ld.remove(); if (btn) { btn.disabled = false; btn.style.opacity = ''; } const bodyEl = document.getElementById('sgis-body'); if (bodyEl) { const errDiv = document.createElement('div'); errDiv.className = 'sgis-state sgis-error'; errDiv.style.cssText = 'padding:8px'; errDiv.textContent = `⚠️ ${T.aiPredictFail}: ${e.message}`; bodyEl.appendChild(errDiv); } }); } function appendPredictionResult(bodyEl) { if (!SGIS.prediction) return; const temp = document.createElement('div'); temp.innerHTML = renderPredictionSection(SGIS.prediction); while (temp.firstChild) bodyEl.appendChild(temp.firstChild); } function renderPredictionSection(prediction) { const models = prediction.models || []; const bestBuy = prediction.best_buy || {}; const enabledModes = storage.getPredictModes(); const modesLabel = enabledModes.map(m => { const mode = PREDICT_MODES.find(p => p.key === m); return mode ? mode.name : ''; }).filter(Boolean).join(' / '); const modelsHtml = models.map(m => { const confClass = m.confidence >= 70 ? 'high' : (m.confidence >= 45 ? 'mid' : 'low'); const dateStr = m.target_date ? new Date(m.target_date).toLocaleDateString('zh-CN') : '—'; const saleBadge = m.sale_event ? `${m.sale_event}` : ''; const indicatorsHtml = m.indicators ? Object.entries(m.indicators).map(([k, v]) => `
${k}: ${v}
`).join('') : ''; return `
${m.mode_name || m.mode} ${T.aiPredictConfidence} ${m.confidence}%
${m.prediction || ''}
预测折扣: -${m.discount_percent || 0}% · 到手: ${m.currency || ''}${m.predicted_price != null ? Number(m.predicted_price).toFixed(2) : '—'} · 时间: ${m.days_until != null ? m.days_until + T.aiPredictDays : '—'}${dateStr !== '—' ? ` (${dateStr})` : ''} ${saleBadge}
${m.detail ? `
${m.detail}
` : ''} ${indicatorsHtml ? `
${indicatorsHtml}
` : ''}
`; }).join(''); const urgencyLabel = bestBuy.urgency === 'high' ? '🔴 尽快入手' : (bestBuy.urgency === 'medium' ? '🟡 可以等待' : '🟢 不急'); const bestBuyHtml = bestBuy.recommendation ? `
${T.aiPredictRecommendation} · ${urgencyLabel}
${bestBuy.recommendation}
${bestBuy.best_time ? `
${T.aiPredictBestTime}: ${bestBuy.best_time}
` : ''} ${bestBuy.best_price ? `
预测最佳价格: ${Number(bestBuy.best_price).toFixed(2)}
` : ''} ${bestBuy.wait_days != null ? `
建议等待: ${bestBuy.wait_days}天
` : ''}
` : ''; return `
${SGIS_ICONS.trend || '🔮'} ${T.aiPredictResult}
${modelsHtml} ${bestBuyHtml}
分析模式: ${modesLabel}
`; } function renderPrices() { if (SGIS.pricesLoading) return; if (SGIS.prices) { renderPricesContent(); appendHistoryPricesToBody(SGIS.historyPrices); appendGiftRecommendationToBody(); return; } SGIS.pricesLoading = true; renderLoading('正在获取多地区价格…'); // refreshRates 独立执行,不阻塞价格渲染,失败静默 refreshRates().catch(() => {}); fetchMultiRegionPrices(APP_ID) .then(priceData => { SGIS.prices = Object.assign(priceData, { ratesReady: SGIS.rateReady }); renderPricesContent(); // v2.8.0: 计算跨区送礼推荐(基于多区价格数据) SGIS.giftRec = calculateGiftRecommendation(SGIS.prices); appendGiftRecommendationToBody(); // 异步获取历史价格,不阻塞、失败静默 fetchHistoryPrices(APP_ID).then(historyData => { SGIS.historyPrices = historyData; appendHistoryPricesToBody(historyData); }).catch(() => {}); }) .catch(e => renderError('价格获取失败: ' + e.message)) .finally(() => { SGIS.pricesLoading = false; }); } function renderPricesContent() { const data = SGIS.prices; const userRegion = (document.cookie.match(/steamCountry=(\w{2})/) || [])[1] || 'CN'; if (!data.prices || !data.prices.length) { const failText = data.failed && data.failed.length ? data.failed.map(f => `${f.region}: ${f.error}`).slice(0, 5).join(' / ') : '未知原因'; setBody(`
💱
未获取到任何地区的价格数据
失败: ${failText}
可能是 Steam 限流或网络问题,稍后重试
`); return; } // v2.3.7: 安全数值转换 (修复 price?.toFixed is not a function, Aug API 可能返回字符串) const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; const fmt = (v) => num(v).toFixed(2); const enriched = data.prices.map(p => { const safePrice = num(p.price); const cny = p.currency === 'CNY' ? safePrice : (SGIS.rateReady ? toCNY(safePrice, p.currency) : null); const info = REGIONS.find(r => r.code === p.region) || { name: p.region, code: p.region }; return Object.assign({}, p, { price: safePrice, initial: num(p.initial), discount: num(p.discount), cny: cny != null ? num(cny) : null, regionName: info, }); }).filter(p => p.cny != null && p.currency !== 'FREE' && p.price > 0); if (!enriched.length) { setBody(`
💱
未获取到有效的价格数据 (${data.prices.length} 个地区失败)
`); return; } enriched.sort((a, b) => a.cny - b.cny); const lowest = enriched[0]; const highest = enriched[enriched.length - 1]; // 旗帜图: 用 flagcdn.com, 加载失败 fallback 到文本代码 const flagHtml = (p) => { const url = flagUrl(p.region); return `${p.region}`; }; // 顶部 featured card (最低价特别展示) const lowestDiscount = lowest.discount > 0 ? `-${lowest.discount}%` : ''; const lowestOriginal = lowest.discount > 0 ? `${fmt(lowest.initial)}` : ''; const featuredHtml = ` `; // v2.8.0: 完整对比表格(区域名称 | 原价 | 折后价 | 折扣率 | 换算后CNY价格) // 按换算后CNY价格升序,最低价高亮绿色,最高价标记红色 const priceTableRowHtml = (p) => { const isUser = p.region === userRegion; const isLowest = p.region === lowest.region; const isHighest = p.region === highest.region && enriched.length > 1; const rowCls = isLowest ? 'sgis-price-tbl-row-lowest' : (isHighest ? 'sgis-price-tbl-row-highest' : ''); const userTag = isUser ? '本区' : ''; const badge = isLowest ? '最低' : (isHighest ? '最高' : ''); const discountCell = p.discount > 0 ? `-${p.discount}%` : ''; const initialCell = p.discount > 0 ? `${fmt(p.initial)}` : ``; return `
${flagHtml(p)} ${p.regionName.name} ${userTag}${badge}
${initialCell}
${fmt(p.price)} ${p.currency}
${discountCell}
¥${fmt(p.cny)}
`; }; // v2.8.0: 当前区域价格 vs 历史最低价对比 const userPrice = enriched.find(p => p.region === userRegion); let historyCompareHtml = ''; if (SGIS.historyPrices && SGIS.historyPrices.lowest && userPrice) { const histLow = SGIS.historyPrices.lowest; const histCny = histLow.currency === 'CNY' ? num(histLow.price) : (SGIS.rateReady ? toCNY(num(histLow.price), histLow.currency) : null); if (histCny != null) { const diff = userPrice.cny - num(histCny); const diffPct = (diff / userPrice.cny) * 100; const isHigher = diff > 0; historyCompareHtml = `
${SGIS_ICONS.trend} 当前价格 vs 历史最低
本区当前¥${fmt(userPrice.cny)}
历史最低¥${fmt(histCny)}${histLow.cut ? ` (-${histLow.cut}%)` : ''}
价差${isHigher ? '↑' : '↓'} ¥${fmt(Math.abs(diff))} (${Math.abs(diffPct).toFixed(1)}%)
`; } } const sources = [...new Set(enriched.map(p => p.source))]; const sourceText = sources.map(s => s === 'aug' ? 'AugmentedSteam' : s === 'steam' ? 'Steam 官方' : s).join(' + '); setBody(`
${SGIS_ICONS.price} 多地区低价排行
${featuredHtml}
${SGIS_ICONS.barChart} 9区完整价格对比
区域
原价
折后价
折扣
CNY
${enriched.map(priceTableRowHtml).join('')}
${historyCompareHtml}
${SGIS_ICONS.info} 比价概览
最低价¥${fmt(lowest.cny)} (${lowest.regionName.name})
最高价¥${fmt(highest.cny)} (${highest.regionName.name})
价差幅度¥${fmt(highest.cny - lowest.cny)} (${((highest.cny - lowest.cny) / lowest.cny * 100).toFixed(1)}%)
本地区${userRegion}${userPrice ? ` · ¥${fmt(userPrice.cny)}` : ''}
有效价格${enriched.length} / ${PRIORITY_REGIONS.length} 区
失败/重复${(data.failed || []).length}
数据源${sourceText}
旗帜来自 flagcdn.com · 价格仅供参考 · 3并发+1s限速
`); } // ---- v2.9.9: DRM 警告检测 (参考 Steam_Buff drm-warning.js) ---- // 检测 Denuvo 等第三方 DRM, 从 DOM 和 appdetails 描述中扫描 function detectDRM() { if (SGIS.drmInfo) return SGIS.drmInfo; const results = []; // 1. DOM 扫描: 购买区域和游戏描述区域 const purchaseSections = document.querySelectorAll('.game_area_purchase_game, .game_area_description, .game_details'); const drmPatterns = [ { regex: /denuvo/i, label: 'Denuvo Anti-Tamper', severity: 'warn' }, { regex: /securom/i, label: 'SecuROM', severity: 'warn' }, { regex: /games for windows live|GFWL/i, label: 'Games for Windows Live', severity: 'warn' }, { regex: /uplay/i, label: 'Ubisoft Connect (Uplay)', severity: 'info' }, { regex: /rockstar.*launcher|RGL/i, label: 'Rockstar Launcher', severity: 'info' }, { regex: /ea.*app|origin.*client/i, label: 'EA App', severity: 'info' }, { regex: /bethesda.*launcher/i, label: 'Bethesda Launcher', severity: 'info' }, { regex: /3rd.party.drm|third.party.drm/i, label: '第三方 DRM', severity: 'warn' }, ]; const found = new Set(); purchaseSections.forEach(sec => { const text = sec.textContent || ''; drmPatterns.forEach(p => { if (p.regex.test(text) && !found.has(p.label)) { found.add(p.label); results.push({ label: p.label, severity: p.severity }); } }); }); // 2. appdetails 缓存补充检测 const extra = SGIS.appDetailsExtra; if (extra && extra.contentDescriptors && extra.contentDescriptors.notes) { const notes = extra.contentDescriptors.notes; if (/denuvo/i.test(notes) && !found.has('Denuvo Anti-Tamper')) { results.push({ label: 'Denuvo Anti-Tamper', severity: 'warn' }); } } SGIS.drmInfo = { list: results, hasDRM: results.length > 0 }; return SGIS.drmInfo; } function renderDRMBadge() { const drm = detectDRM(); if (!drm.hasDRM) return ''; const badges = drm.list.map(d => { const cls = d.severity === 'warn' ? 'warn' : ''; return `${SGIS_ICONS.shield} ${d.label}`; }).join(''); return `
${badges}
`; } // ---- v2.9.9: 中文语音支持醒目标记 (参考 Steam_Buff audio-check.js) ---- function renderChineseAudioBadge() { const extra = SGIS.appDetailsExtra; if (!extra || !extra.supportedLanguages || !extra.supportedLanguages.list.length) return ''; const langs = extra.supportedLanguages.list; // 匹配简体中文/繁体中文/Simplified Chinese/Traditional Chinese const chineseAudio = langs.find(l => /chinese|中文/i.test(l.name) && l.audio ); if (!chineseAudio) return ''; return `
${SGIS_ICONS.globe} ${chineseAudio.name} 语音支持
`; } // ---- v2.9.10: gamestatus.info 破解状态集成 (参考 steam-game-status.js) ---- // 数据源: https://gamestatus.info/back/api/gameinfo/game/{slug}/ // slug 解析: URL 路径提取 → 游戏标题 slugify → steam_prod_id 验证 const GAMESTATUS_API = 'https://gamestatus.info/back/api/gameinfo/game'; const GAMESTATUS_MAX_SLUGS = 2; function slugifyGameStatus(text) { return String(text || '') .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[™®©'':"]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .replace(/-+/g, '-'); } function buildGameStatusSlugs(appId, gameName) { const candidates = []; const add = (slug) => { if (slug && slug.length > 1 && !candidates.includes(slug)) candidates.push(slug); }; // 1. 从 URL 路径提取 slug (如 /app/1245620/ELDEN_RING/) const pathMatch = location.pathname.match(/\/app\/\d+\/([^/?#]+)/i); if (pathMatch) add(slugifyGameStatus(pathMatch[1].replace(/_/g, '-'))); // 2. 从游戏标题生成 slug const name = String(gameName || '').replace(/\s+/g, ' ').trim(); if (name) { add(slugifyGameStatus(name)); add(slugifyGameStatus(name.replace(/\s*[-–—:|].*$/, ''))); } return candidates.slice(0, GAMESTATUS_MAX_SLUGS); } async function fetchGameStatus(appId, gameName) { // 1. 先看 SGIS 内存缓存 if (SGIS.gameStatusInfo) return SGIS.gameStatusInfo; // 2. 再看 GM 持久化缓存 const cached = cacheGet('gameStatus_' + appId); if (cached) { SGIS.gameStatusInfo = cached; return cached; } // 3. 构建 slug 候选并逐个尝试 API const slugs = buildGameStatusSlugs(appId, gameName); if (!slugs.length) return null; for (const slug of slugs) { try { const url = `${GAMESTATUS_API}/${encodeURIComponent(slug)}/`; const data = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'Accept': 'application/json', 'Accept-Language': 'zh-CN' }, timeout: 10000, onload(r) { if (r.status === 404) { resolve(null); return; } if (r.status < 200 || r.status >= 300) { reject(new Error(`HTTP ${r.status}`)); return; } try { resolve(JSON.parse(r.responseText)); } catch { reject(new Error('JSON parse fail')); } }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); // 验证 steam_prod_id 匹配 (防误匹配) if (data && (!data.steam_prod_id || String(data.steam_prod_id) === String(appId))) { // v2.9.11: 增强数据捕获 — 硬件需求 + Metacritic 评分 + 发售日 const hw = [ data.cpu_info && { k: 'CPU', v: data.cpu_info }, data.ram_info && { k: 'RAM', v: data.ram_info }, data.gpu_info && { k: 'GPU', v: data.gpu_info }, data.os_info && { k: 'OS', v: data.os_info }, ].filter(Boolean); const result = { status: data.readable_status || '', protections: data.protections || '', hackedGroups: data.hacked_groups_en || data.hacked_groups || '', crackDate: data.crack_date || '', isAAA: !!data.is_AAA, userScore: data.user_score || null, metacriticScore: data.mata_score || null, // API 字段名为 mata_score releaseDate: data.release_date || '', hardware: hw.length ? hw : null, slug: data.slug || slug, }; SGIS.gameStatusInfo = result; cacheSet('gameStatus_' + appId, result, CACHE_TTL.gameStatus); return result; } } catch { /* try next slug */ } } // 4. 全部 slug 未命中, 缓存 null 避免反复请求 SGIS.gameStatusInfo = { notFound: true }; cacheSet('gameStatus_' + appId, { notFound: true }, CACHE_TTL.gameStatus); return SGIS.gameStatusInfo; } function renderCrackStatusBadge() { const info = SGIS.gameStatusInfo; if (!info || info.notFound) return ''; // 状态分类 → 颜色/图标 const status = String(info.status || '').toLowerCase(); let cls = '', label = info.status || '未知'; if (/cracked|взлом/.test(status)) { cls = 'cracked'; label = info.status; } else if (/bypass|обход|hypervisor/.test(status) || /bypass|обход/.test(String(info.hackedGroups).toLowerCase())) { cls = 'bypass'; } else if (/not cracked|не взлом|unbroken|unreleased/.test(status)) { cls = 'not-cracked'; } else if (/release today|релиз сегодня|выходит сегодня/.test(status)) { cls = 'release-today'; } // 保护机制 chips const protections = String(info.protections || '').split(/[,;/|]+/).map(s => s.trim()).filter(Boolean); const protChips = protections.map(p => `${p}`).join(''); // 破解组织 const groups = String(info.hackedGroups || '').split(/[,;/|]+/).map(s => s.trim()).filter(Boolean); const groupChips = groups.map(g => `${g}`).join(''); // 破解日期 const crackDateStr = info.crackDate ? new Date(info.crackDate).toLocaleDateString('zh-CN') : ''; // v2.9.11: 用户评分 + Metacritic 评分 chips const scoreChips = [ info.userScore ? `${SGIS_ICONS.star} ${info.userScore}` : '', info.metacriticScore ? `Meta ${info.metacriticScore}` : '', ].filter(Boolean).join(''); const chips = [ `${SGIS_ICONS.shield} ${label}`, info.isAAA ? 'AAA' : '', protChips, groupChips, crackDateStr ? `破解于 ${crackDateStr}` : '', scoreChips, ].filter(Boolean).join(''); if (!chips && !info.hardware) return ''; // v2.9.11: 硬件需求区块 const escHw = (s) => String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); const hwHtml = info.hardware && info.hardware.length ? `
${SGIS_ICONS.info} 硬件需求 (gamestatus.info)
${info.hardware.map(h => `
${h.k}${escHw(h.v)}
`).join('')}
` : ''; return `
${chips ? `
${chips}
` : ''} ${hwHtml}
`; } // ---- 家庭组共享支持检测 ---- function detectFamilyShareSupport() { // 从 DOM 检测 Steam 原生家庭共享提示 // Steam 在 /app/* 页面会显示 .game_area_purchase_game 内的共享信息 const shareEl = document.querySelector('.game_area_purchase_game .game_area_purchase_family_share, .family_sharing_notice'); if (shareEl) return { supported: true, source: 'dom' }; // 检测页面中是否有 "Steam Family Sharing" 相关文本 const purchaseSection = document.querySelector('.game_area_purchase_game, #purchaseOptions'); if (purchaseSection) { const text = purchaseSection.textContent || ''; if (/family sharing|家庭共享|Steam Family/i.test(text)) return { supported: true, source: 'dom-text' }; if (/not eligible for family sharing|不支持家庭共享/i.test(text)) return { supported: false, source: 'dom-text' }; } // 从 appdetails API 缓存检测 const cached = cacheGet('familyShare_' + APP_ID); if (cached !== null) return cached; return null; } async function fetchFamilyShareSupport() { // 先看 DOM const domResult = detectFamilyShareSupport(); if (domResult) return domResult; // 从本地缓存检测 const cached = cacheGet('familyShare_' + APP_ID); if (cached !== null) return cached; // v2.9.57: 委托 SGLVAppDetail.loadDetail 共享缓存(与 fetchAppDetailsExtra 复用同一请求) // category 41 = 家庭共享 try { const A = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail) || (typeof window !== 'undefined' && window.SGLVAppDetail); if (A && typeof A.loadDetail === 'function') { const detail = await A.loadDetail(APP_ID, { useCache: true }); if (detail && detail.categoryObjs) { const hasFamilyShare = detail.categoryObjs.some(c => c.id === 41 || /family sharing/i.test(c.description || '')); const result = { supported: !!hasFamilyShare, source: 'api' }; cacheSet('familyShare_' + APP_ID, result, CACHE_TTL.familyShare); return result; } } } catch { /* ignore */ } const fallback = { supported: null, source: 'unknown' }; return fallback; } function renderFamilyShareIndicator(info) { // v2.9.62: 若该游戏已通过家庭组共享给当前用户(isSharedOnly),实际共享状态优先于 // 能力检测,避免出现"共享给你"与"不支持家庭组共享"自相矛盾的描述 if (info && info.isSharedOnly) { return ``; } if (!SGIS.familyShareSupported) return ''; const s = SGIS.familyShareSupported; if (s.supported === true) { return `
支持家庭组共享 ${s.source}
`; } else if (s.supported === false) { return `
不支持家庭组共享
`; } return ''; } // ---- v2.3.16: appdetails API 增强信息 (创意工坊/截图/分类/捆绑包等) ---- // 数据源:https://store.steampowered.com/api/appdetails?appids={APP_ID}&l=schinese // 工坊识别:categories 中 id=30 或描述包含 "创意工坊"/"Steam Workshop" // 附加信息:screenshots / categories / platforms / packages / genres / metacritic / recommendations 等 // v2.9.57: 委托 SGLVAppDetail.loadDetail() 获取 appdetails 数据, // 消除与 sglv-app-detail.lib.js 的重复 API 调用+解析+缓存逻辑(净减约 120 行) // 库提供 3 级缓存(内存 5min + 磁盘 24h + 失败黑名单)+ 中文→英文→DOM 三级降级 + 429 退避 async function fetchAppDetailsExtra() { const A = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail) || (typeof window !== 'undefined' && window.SGLVAppDetail); if (!A || typeof A.loadDetail !== 'function') { console.warn('[SGIS] SGLVAppDetail 库未加载,概览增强信息不可用'); return null; } try { const detail = await A.loadDetail(APP_ID, { useCache: true }); if (!detail) return null; // 映射库返回结构到 SGIS 概览期望的结构 const platforms = []; if (detail.platforms) { if (detail.platforms.win) platforms.push('Windows'); if (detail.platforms.mac) platforms.push('macOS'); if (detail.platforms.linux) platforms.push('SteamOS + Linux'); } return { workshopSupported: (detail.categoryObjs || []).some(c => c.id === 30 || /steam\s*workshop|steam\s*创意工坊|创意工坊/i.test(c.description || '') ), workshopUrl: `https://steamcommunity.com/workshop/browse/?appid=${APP_ID}`, categories: detail.categoryObjs || [], platforms, packages: detail.packages || [], screenshots: detail.screenshots || [], movies: detail.movies || [], supportedLanguages: detail.supportedLanguages || { list: [], note: '' }, controllerSupport: detail.controllerSupport || null, requiredAge: detail.requiredAge || 0, contentDescriptors: detail.contentDescriptors || null, appType: detail.type || '', background: detail.background || '', genres: detail.genres || [], genreObjs: detail.genreObjs || [], developers: detail.developers || [], publishers: detail.publishers || [], releaseDate: detail.releaseDate || '', recommendations: detail.recommendations || 0, metacritic: detail.metacritic || null, achievementsTotal: detail.achievementsTotal || 0, dlc: detail.dlc || [], isFree: detail.isFree || false, website: detail.website || '', headerImage: detail.cover || '', shortDesc: detail.shortDesc || '', source: detail.source || 'api', fetchedAt: Date.now(), }; } catch (e) { console.warn('[SGIS] appdetails 增强信息获取失败:', e.message); return null; } } // 工坊标记渲染 (类似家庭共享指示器) function renderWorkshopIndicator() { const extra = SGIS.appDetailsExtra; if (!extra || extra.workshopSupported === undefined || extra.workshopSupported === null) return ''; if (extra.workshopSupported) { return `
${SGIS_ICONS.workshop} 支持 Steam 创意工坊 浏览工坊 →
`; } return `
${SGIS_ICONS.workshop} 不支持 Steam 创意工坊
`; } // 工具函数: 大数字简写 (如 5178439 -> 517.8万) function formatBigNumber(n) { if (!n || n <= 0) return ''; if (n < 10000) return String(n); if (n < 100000000) return (n / 10000).toFixed(1) + '万'; return (n / 100000000).toFixed(1) + '亿'; } // v2.3.17: 应用类型本地化映射 function localizeAppType(t) { const map = { game: '游戏', dlc: 'DLC', music: '音乐', series: '系列', mod: '模组', demo: '试玩版', advertising: '宣传', series_episode: '系列剧集', tool: '工具', video: '视频', hardware: '硬件', episode: '剧集', }; return map[t] || t || ''; } // v2.3.17: 年龄限制等级文案 function ageBadgeInfo(age) { if (!age || age <= 0) return { cls: 'unrestricted', text: '全年龄', icon: SGIS_ICONS.shield }; if (age >= 18) return { cls: 'restricted', text: age + '+ 限制级', icon: SGIS_ICONS.shield }; if (age >= 16) return { cls: 'mature', text: age + '+ 成熟内容', icon: SGIS_ICONS.shield }; if (age >= 13) return { cls: 'mature', text: age + '+ 青少年', icon: SGIS_ICONS.shield }; return { cls: 'unrestricted', text: age + '+', icon: SGIS_ICONS.shield }; } // v2.3.17: 异步获取 DLC 名称映射 // 调用 appdetails?appids={dlcId}&filters=basic 获取每个 DLC 的名称 // 使用并发限制 (mapLimit 模式), 缓存 24h async function fetchDlcNames(dlcIds) { if (!dlcIds || !dlcIds.length) return {}; // 先看缓存 const cacheKey = 'dlcNames_' + dlcIds.join(','); const cached = cacheGet(cacheKey); if (cached) return cached; const result = {}; const concurrency = 4; // 并发 4 个 const queue = dlcIds.slice(); const workers = []; const fetchOne = async (dlcId) => { try { const url = `https://store.steampowered.com/api/appdetails?appids=${dlcId}&filters=basic&l=schinese&_=${Date.now()}`; const data = await fetchJson(url, { timeout: 8000 }); const d = data?.[dlcId]?.data; if (d) { result[dlcId] = { name: d.name || ('DLC ' + dlcId), isFree: !!d.is_free, headerImage: d.header_image || '', shortDesc: d.short_description || '', }; } } catch (e) { // 单个 DLC 失败不阻塞其他 } }; // 简单的并发池 while (queue.length > 0) { const batch = queue.splice(0, concurrency); await Promise.all(batch.map(fetchOne)); } cacheSet(cacheKey, result, CACHE_TTL.appDetailsExtra); return result; } // ---- ITAD 历史价格 (v2.3: 改用直接ITAD API获取折扣记录) ---- // ITAD API Key(参考 Python 脚本 config.py) const ITAD_API_KEY = '58eb2271a08c1cf60bd812d701d09d5c694f502e'; const ITAD_BASE_URL = 'https://api.isthereanydeal.com'; const ITAD_SHOP_STEAM = 61; // Steam 商店 ID // Steam AppID → ITAD GameID(参考 Python: lookup_itad_id) async function lookupItadId(appId) { const formattedId = `app/${appId}`; try { const data = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${ITAD_BASE_URL}/lookup/id/shop/${ITAD_SHOP_STEAM}/v1?key=${ITAD_API_KEY}`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify([formattedId]), timeout: 15000, onload(r) { if (r.status >= 200 && r.status < 300) { try { resolve(JSON.parse(r.responseText)); } catch { reject(new Error('JSON parse fail')); } } else reject(new Error('HTTP ' + r.status)); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); if (data && data[formattedId]) return data[formattedId]; return null; } catch (e) { console.warn('[SGIS] ITAD lookup 失败:', e.message); return null; } } // 获取游戏信息(参考 Python: get_game_info) async function fetchItadGameInfo(itadId) { try { const url = `${ITAD_BASE_URL}/games/info/v2?key=${ITAD_API_KEY}&id=${itadId}`; const data = await fetchJson(url, { timeout: 15000 }); return data; } catch (e) { console.warn('[SGIS] ITAD game info 失败:', e.message); return null; } } // 获取价格历史(参考 Python: get_price_history) async function fetchItadPriceHistory(itadId, country = 'CN') { const params = new URLSearchParams({ key: ITAD_API_KEY, id: itadId, country: country, shops: String(ITAD_SHOP_STEAM), }); try { // 先尝试不带 since 参数 const url = `${ITAD_BASE_URL}/games/history/v2?${params}`; let data = await fetchJson(url, { timeout: 20000 }); if (data && data.length > 0) return data; // 尝试带 since 参数获取更早数据(5年前) const sinceDate = new Date(Date.now() - 365 * 5 * 86400000).toISOString().replace(/\.\d+Z$/, '+00:00'); params.set('since', sinceDate); const url2 = `${ITAD_BASE_URL}/games/history/v2?${params}`; data = await fetchJson(url2, { timeout: 20000 }); return data || []; } catch (e) { console.warn('[SGIS] ITAD price history 失败:', e.message); return []; } } async function fetchHistoryPrices(appId) { const cacheKey = 'historyPrices_' + appId; const cached = cacheGet(cacheKey); if (cached) return cached; // 步骤1: Steam AppID → ITAD GameID const itadId = await lookupItadId(appId); if (!itadId) { // v2.8.0: Fallback 链 ITAD → CheapShark → AugmentedSteam return await fetchHistoryPricesCheapShark(appId, 'ITAD lookup 失败'); } // 步骤2: 并行获取游戏信息和价格历史 const [gameInfo, history] = await Promise.all([ fetchItadGameInfo(itadId), fetchItadPriceHistory(itadId), ]); if (!history || !history.length) { // v2.8.0: Fallback 链 ITAD → CheapShark → AugmentedSteam return await fetchHistoryPricesCheapShark(appId, 'ITAD 无价格历史'); } // 步骤3: 解析折扣记录(参考 Python: analyze_game 中的 seen_cuts 逻辑) const releaseDate = gameInfo?.releaseDate || null; const seenCuts = new Set(); const discounts = []; // 每个折扣百分比第一次出现的记录 const allHistory = []; // 完整价格历史 for (const item of history) { const deal = item.deal || {}; const cut = deal.cut || 0; const timestamp = item.timestamp || ''; const dealPrice = deal.price || {}; const regularPrice = deal.regular || {}; const shop = item.shop?.name || item.shop || ''; const historyItem = { price: dealPrice.amount || 0, regular: regularPrice.amount || 0, currency: dealPrice.currency || '', cut: cut, store: shop, date: timestamp, }; allHistory.push(historyItem); // 只保留每个折扣百分比第一次出现(参考 Python seen_cuts 逻辑) if (cut > 0 && !seenCuts.has(cut)) { seenCuts.add(cut); let daysFromRelease = null; if (releaseDate && timestamp) { try { const discountDate = new Date(timestamp); const releaseDt = new Date(releaseDate); daysFromRelease = Math.floor((discountDate - releaseDt) / 86400000); } catch { /* ignore */ } } discounts.push({ cut: cut, price: historyItem.price, regular: historyItem.regular, currency: historyItem.currency, store: shop, date: timestamp, daysFromRelease: daysFromRelease, }); } } // 排序: 折扣记录按折扣百分比降序 discounts.sort((a, b) => b.cut - a.cut); // 价格历史按日期降序 allHistory.sort((a, b) => new Date(b.date) - new Date(a.date)); // 计算史低(最大折扣对应的价格) let lowest = null; if (discounts.length > 0) { const maxCut = discounts[0]; // 已按 cut 降序 lowest = { price: maxCut.price, currency: maxCut.currency, store: maxCut.store, date: maxCut.date, cut: maxCut.cut, }; } const result = { history: allHistory.slice(0, 20), discounts: discounts, lowest: lowest, releaseDate: releaseDate, gameTitle: gameInfo?.title || '', source: 'itad', }; cacheSet(cacheKey, result, CACHE_TTL.historyPrices); return result; } // Fallback: 使用 AugmentedSteam API 获取历史价格(原 v2.2 逻辑) async function fetchHistoryPricesFallback(appId, reason) { try { const data = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://api.augmentedsteam.com/prices/v2', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ country: 'US', apps: [parseInt(appId, 10)], subs: [], bundles: [], voucher: false, shops: [], }), timeout: 12000, onload(r) { if (r.status >= 200 && r.status < 300) { try { resolve(JSON.parse(r.responseText)); } catch { reject(new Error('JSON parse fail')); } } else reject(new Error('HTTP ' + r.status)); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); const key = `app/${appId}`; const aug = data?.prices?.[key]; if (!aug) return { history: [], discounts: [], lowest: null, source: 'aug', error: reason }; const lowest = aug.lowest || (aug.historic?.[0] ? { price: aug.historic[0].price, currency: aug.historic[0].currency, store: aug.historic[0].store, date: aug.historic[0].date } : null); const history = (aug.historic || []).map(h => ({ price: h.price, currency: h.currency, cut: 0, store: h.store || '', date: h.date || '', })).sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 20); const result = { history, discounts: [], lowest, releaseDate: null, source: 'aug', error: reason }; cacheSet('historyPrices_' + appId, result, CACHE_TTL.historyPrices); return result; } catch (e) { return { history: [], discounts: [], lowest: null, source: 'error', error: reason + ' / ' + e.message }; } } // 折扣趋势曲线图: 折扣记录 > 2 个时绘制, 无数据或 <= 2 个时隐藏 function renderDiscountTrendChart(discounts) { const pts = (discounts || []) .filter(d => d && d.date && d.cut > 0) .map(d => ({ t: new Date(d.date).getTime(), cut: Number(d.cut) || 0, date: d.date })) .filter(p => !isNaN(p.t)) .sort((a, b) => a.t - b.t); if (pts.length <= 2) return ''; const W = 340, H = 120, PL = 22, PR = 10, PT = 14, PB = 16; const iw = W - PL - PR, ih = H - PT - PB; const t0 = pts[0].t, t1 = pts[pts.length - 1].t; const span = Math.max(t1 - t0, 86400000); const maxCut = Math.max(...pts.map(p => p.cut), 10); const px = t => PL + ((t - t0) / span) * iw; const py = c => PT + ih - (c / maxCut) * ih; const linePath = pts.map((p, i) => `${i ? 'L' : 'M'}${px(p.t).toFixed(1)},${py(p.cut).toFixed(1)}`).join(' '); const areaPath = `${linePath} L${px(t1).toFixed(1)},${(PT + ih).toFixed(1)} L${px(t0).toFixed(1)},${(PT + ih).toFixed(1)} Z`; const grid = [0.25, 0.5, 0.75, 1].map(f => { const gy = (PT + ih - f * ih).toFixed(1); return `` + `${Math.round(f * maxCut)}%`; }).join(''); const dots = pts.map(p => { const cx = px(p.t).toFixed(1), cy = py(p.cut).toFixed(1); const dateFull = new Date(p.date).toLocaleDateString('zh-CN'); const dateShort = new Date(p.date).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit' }); return `${dateFull} · -${p.cut}%` + `-${p.cut}%` + `${dateShort}`; }).join(''); return `
折扣趋势 (按首次到达日期)
${grid} ${dots}
`; } // v2.9.9: SVG 价格历史走势图 (零依赖, 支持时间范围筛选, 参考 Steam_Buff charts.js) function renderPriceHistoryChart(historyData) { const allPoints = (historyData?.history || []) .filter(h => h && h.date && h.price > 0) .map(h => ({ t: new Date(h.date).getTime(), price: Number(h.price) || 0, cut: Number(h.cut) || 0, currency: h.currency || '', store: h.store || '', date: h.date, })) .filter(p => !isNaN(p.t)) .sort((a, b) => a.t - b.t); if (allPoints.length < 2) return ''; const now = Date.now(); const ranges = [ { key: '6m', label: '6月', ms: 180 * 86400000 }, { key: '1y', label: '12月', ms: 365 * 86400000 }, { key: 'all', label: '全部', ms: Infinity }, ]; const activeRange = SGIS.priceChartRange || 'all'; const filterByRange = (rangeKey) => { if (rangeKey === 'all') return allPoints; const r = ranges.find(x => x.key === rangeKey); if (!r) return allPoints; const filtered = allPoints.filter(p => p.t >= now - r.ms); return filtered.length >= 2 ? filtered : allPoints; }; const points = filterByRange(activeRange); const W = 340, H = 140, PL = 34, PR = 10, PT = 14, PB = 20; const iw = W - PL - PR, ih = H - PT - PB; const t0 = points[0].t, t1 = points[points.length - 1].t; const span = Math.max(t1 - t0, 86400000); const prices = points.map(p => p.price); const minP = Math.min(...prices); const maxP = Math.max(...prices); const padP = (maxP - minP) * 0.15 || maxP * 0.1 || 1; const yMin = Math.max(0, minP - padP); const yMax = maxP + padP; const ySpan = Math.max(yMax - yMin, 0.01); const px = t => PL + ((t - t0) / span) * iw; const py = p => PT + ih - ((p - yMin) / ySpan) * ih; const linePath = points.map((p, i) => `${i ? 'L' : 'M'}${px(p.t).toFixed(1)},${py(p.price).toFixed(1)}` ).join(' '); const areaPath = `${linePath} L${px(t1).toFixed(1)},${(PT + ih).toFixed(1)} L${px(t0).toFixed(1)},${(PT + ih).toFixed(1)} Z`; // Y-axis grid (4 steps) const gridSteps = 4; const grid = Array.from({ length: gridSteps + 1 }, (_, i) => { const f = i / gridSteps; const val = yMin + f * ySpan; const gy = (PT + ih - f * ih).toFixed(1); const label = val >= 100 ? Math.round(val).toString() : val.toFixed(1); return `` + `${label}`; }).join(''); // X-axis date labels (max 5, smart placement) const labelCount = Math.min(5, points.length); const xLabels = labelCount > 1 ? Array.from({ length: labelCount }, (_, i) => { const idx = Math.floor(i * (points.length - 1) / (labelCount - 1)); const p = points[idx]; const dateStr = new Date(p.t).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit' }); return `${dateStr}`; }).join('') : ''; // Data points with tooltips — lowest point highlighted const lowestIdx = prices.indexOf(minP); const dots = points.map((p, i) => { const cx = px(p.t).toFixed(1), cy = py(p.price).toFixed(1); const isLowest = i === lowestIdx; const r = isLowest ? 4 : 2.5; const fill = isLowest ? '#a4d007' : '#66c0f4'; const dateFull = new Date(p.date).toLocaleDateString('zh-CN'); const tip = `${dateFull} · ${p.currency}${p.price.toFixed(2)}${p.cut > 0 ? ` (-${p.cut}%)` : ''}${p.store ? ' @ ' + p.store : ''}`; const lowLabel = isLowest ? `${p.currency}${p.price.toFixed(2)}` : ''; return `${tip}${lowLabel}`; }).join(''); const rangeBtns = ranges.map(r => `` ).join(''); const currency = points[0]?.currency || ''; return `
价格走势 (${currency})
${rangeBtns}
${grid} ${dots} ${xLabels}
`; } // v2.9.9: 绑定价格图表时间范围按钮事件 function bindPriceChartRangeEvents(historyData) { const wrap = document.getElementById('sgis-price-chart-wrap'); if (!wrap) return; wrap.querySelectorAll('.sgis-chart-range-btn').forEach(btn => { btn.addEventListener('click', () => { SGIS.priceChartRange = btn.dataset.range; const newChartHtml = renderPriceHistoryChart(historyData); if (newChartHtml) { const temp = document.createElement('div'); temp.innerHTML = newChartHtml; const newWrap = temp.firstChild; wrap.replaceWith(newWrap); bindPriceChartRangeEvents(historyData); } }); }); } function renderHistoryPricesSection(historyData) { if (!historyData || (!historyData.history?.length && !historyData.lowest && !historyData.discounts?.length)) { return `
${SGIS_ICONS.price} 历史价格
暂无历史价格数据${historyData?.error ? '(' + historyData.error + ')' : ''}
`; } const lowest = historyData.lowest; const discounts = historyData.discounts || []; const releaseDate = historyData.releaseDate; // 史低价格卡片 // v2.3.7: 安全数值转换, 修复 price?.toFixed is not a function const _f = (v) => { const n = Number(v); return isNaN(n) ? '—' : n.toFixed(2); }; const lowestHtml = lowest ? `
史低价格 ${lowest.cut ? `(-${lowest.cut}%)` : ''}
${lowest.currency || '$'}${_f(lowest.price)}
${lowest.store ? `
@ ${lowest.store}
` : ''} ${lowest.date ? `
${new Date(lowest.date).toLocaleDateString('zh-CN')}
` : ''}
` : ''; // 折扣记录摘要(每个折扣百分比第一次到达的天数,参考 Python seen_cuts 逻辑) const discountSummaryHtml = discounts.length ? `
${discounts.map(d => { const daysStr = d.daysFromRelease != null ? `第${d.daysFromRelease}天` : ''; return `-${d.cut}%${daysStr}`; }).join('')}
` : ''; // 完整价格历史列表(显示折扣百分比) const historyRows = (historyData.history || []).slice(0, 10).map(h => { const dateStr = h.date ? new Date(h.date).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '—'; const cutBadge = h.cut > 0 ? `-${h.cut}%` : ''; // 计算距发行天数 let daysStr = ''; if (releaseDate && h.date) { try { const days = Math.floor((new Date(h.date) - new Date(releaseDate)) / 86400000); if (days >= 0) daysStr = `+${days}天`; } catch { /* ignore */ } } return `
${dateStr} ${cutBadge} ${h.cut > 0 && h.regular > 0 ? `${h.currency || '$'}${_f(h.regular)}` : ''}${h.currency || '$'}${_f(h.price)} ${h.store || ''} ${daysStr}
`; }).join(''); const sourceLabel = historyData.source === 'itad' ? 'ITAD' : historyData.source === 'aug' ? 'AugmentedSteam' : historyData.source; return `
${SGIS_ICONS.price} 历史价格走势
${lowestHtml} ${discountSummaryHtml} ${renderPriceHistoryChart(historyData)} ${historyRows ? `
${historyRows}
` : ''} ${renderDiscountTrendChart(discounts)} ${releaseDate ? `
发行日期: ${new Date(releaseDate).toLocaleDateString('zh-CN')}
` : ''}
数据源: ${sourceLabel}${discounts.length ? ` · ${discounts.length} 个折扣记录` : ''}
`; } // ---- 成就标签 ---- async function fetchPlayerAchievements(appId) { const apiKey = storage.getApiKey(); const steamId = getActiveSteamId(); if (!apiKey || !steamId) return { error: 'NO_KEY', achievements: [], total: 0, unlocked: 0 }; const cacheKey = 'achievements_' + appId; const cached = cacheGet(cacheKey); if (cached) return cached; const url = `https://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?appid=${appId}&key=${apiKey}&steamid=${steamId}&l=schinese`; const data = await fetchJson(url, { timeout: 15000 }); if (!data?.playerstats?.success) return { error: 'API_FAIL', achievements: [], total: 0, unlocked: 0 }; const ach = data.playerstats.achievements || []; const result = { achievements: ach.map(a => ({ name: a.name || '', apiname: a.apiname || '', achieved: a.achieved === 1, unlocktime: a.unlocktime || 0, })), total: ach.length, unlocked: ach.filter(a => a.achieved === 1).length, gameName: data.playerstats.gameName || '', }; cacheSet(cacheKey, result, CACHE_TTL.achievements); return result; } async function fetchGlobalAchievements(appId) { const cacheKey = 'globalAchievements_' + appId; const cached = cacheGet(cacheKey); if (cached) return cached; const url = `https://api.steampowered.com/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v0002/?gameid=${appId}&l=schinese`; const data = await fetchJson(url, { timeout: 15000 }); const ach = data?.achievementpercentages?.achievements || []; const result = ach.map(a => ({ name: a.name || '', percent: a.percent || 0, })).sort((a, b) => b.percent - a.percent); cacheSet(cacheKey, result, CACHE_TTL.globalAchievements); return result; } function renderAchievements() { if (SGIS.achievementsLoading) return; if (SGIS.achievements && SGIS.globalAchievements) { renderAchievementsContent(); return; } SGIS.achievementsLoading = true; renderLoading('正在获取成就数据…'); Promise.all([ fetchPlayerAchievements(APP_ID).catch(e => ({ error: e.message, achievements: [], total: 0, unlocked: 0 })), fetchGlobalAchievements(APP_ID).catch(e => []), ]).then(([playerData, globalData]) => { SGIS.achievements = playerData; SGIS.globalAchievements = globalData; renderAchievementsContent(); }).catch(e => { renderError('成就获取失败: ' + e.message); }).finally(() => { SGIS.achievementsLoading = false; }); } function renderAchievementsContent() { const player = SGIS.achievements; const global = SGIS.globalAchievements || []; if (player?.error === 'NO_KEY') { setBody(`
🏆
需要 API Key 才能查看成就
请在设置中配置 Steam Web API Key。
打开 steamcommunity.com/dev/apikey 获取免费 Key。
`); return; } if (player?.error && player.error !== 'API_FAIL') { setBody(`
🏆
成就数据获取失败: ${player.error}
`); return; } if (!player || player.total === 0) { // 仅展示全球成就 if (global.length === 0) { setBody(`
🏆
该游戏暂无成就数据
`); return; } // 仅全球数据 const globalList = global.slice(0, 20).map(a => { const barColor = a.percent >= 50 ? 'var(--sgis-green)' : a.percent >= 20 ? 'var(--sgis-amber)' : 'var(--sgis-rose)'; return `
${a.name}
${a.percent.toFixed(1)}%
`; }).join(''); setBody(`
${SGIS_ICONS.star} 全球成就达成率
该游戏不在你的库中,仅展示全球数据
${globalList}
`); return; } // 玩家成就 + 全球对比 const unlocked = player.unlocked; const total = player.total; const pct = total > 0 ? (unlocked / total * 100) : 0; // 合并玩家和全球数据 const globalMap = {}; global.forEach(g => { globalMap[g.name] = g.percent; }); const merged = player.achievements.map(a => ({ name: a.name, achieved: a.achieved, unlocktime: a.unlocktime, globalPct: globalMap[a.name] || null, })); const unlockedList = merged.filter(a => a.achieved).sort((a, b) => (b.unlocktime || 0) - (a.unlocktime || 0)); const lockedList = merged.filter(a => !a.achieved).sort((a, b) => (b.globalPct || 0) - (a.globalPct || 0)); const formatUnlockTime = (ts) => { if (!ts) return ''; try { return new Date(ts * 1000).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit', day: '2-digit' }); } catch { return ''; } }; const unlockedHtml = unlockedList.slice(0, 15).map(a => `
${a.name || '(隐藏成就)'}
${a.unlocktime ? `${formatUnlockTime(a.unlocktime)}` : ''} ${a.globalPct != null ? `全球 ${a.globalPct.toFixed(1)}%` : ''}
`).join(''); const lockedHtml = lockedList.slice(0, 15).map(a => { const barColor = (a.globalPct || 0) >= 50 ? 'var(--sgis-green)' : (a.globalPct || 0) >= 20 ? 'var(--sgis-amber)' : 'var(--sgis-rose)'; return `
${a.name || '(隐藏成就)'}
${a.globalPct != null ? `
全球 ${a.globalPct.toFixed(1)}%
` : ''}
`; }).join(''); const donutR = 52, donutSw = 10, donutCx = 60, donutCy = 60; const donutC = 2 * Math.PI * donutR; const donutDash = (pct / 100) * donutC; // v2.3.3: 彩虹强度 (0.25 ~ 1.0) - 完成度越高, 彩虹越鲜艳 + glow 越强 // 0-20% → 强度 0.30 (灰淡) // 20-50% → 强度 0.50 (中等) // 50-80% → 强度 0.75 (鲜艳) // 80-100% → 强度 1.00 (炫彩 + 强烈发光) const rainbowIntensity = Math.max(0.3, Math.min(1.0, 0.25 + pct / 100 * 0.85)); const rainbowId = `sgis-rainbow-${APP_ID || 'p'}`; const rainbowGlowId = `${rainbowId}-glow`; // 7 色彩虹渐变 (红橙黄绿青蓝紫) const rainbowStops = [ [0.00, '#ff0080'], // 玫红 [0.17, '#ff4500'], // 橙红 [0.34, '#ffb300'], // 琥珀 [0.50, '#00ff7f'], // 翠绿 [0.67, '#00bfff'], // 天蓝 [0.84, '#7b2cff'], // 紫罗兰 [1.00, '#ff00d4'], // 品红 ].map(([off, c]) => ``).join(''); setBody(`
${rainbowStops} ${pct.toFixed(0)}% ${unlocked}/${total}
${unlocked}已解锁
${total - unlocked}未解锁
${total}总成就
${unlockedHtml ? `
${SGIS_ICONS.check} 已解锁 (${unlockedList.length})
${unlockedHtml}
` : ''} ${lockedHtml ? `
${SGIS_ICONS.star} 未解锁 (${lockedList.length})
${lockedHtml}
` : ''}
玩家成就来自 Steam Web API · 全球数据每12小时更新
`); } // ---- 动态标签 (游戏新闻/更新) ---- async function fetchGameNews(appId) { const cacheKey = 'news_' + appId; const cached = cacheGet(cacheKey); if (cached) return cached; const url = `https://api.steampowered.com/ISteamNews/GetNewsForApp/v0002/?appid=${appId}&count=20&maxlength=500&l=schinese&feeds=steam_community_announcements`; const data = await fetchJson(url, { timeout: 15000 }); const newsItems = data?.appnews?.newsitems || []; const result = newsItems.map(n => ({ title: n.title || '', url: n.url || '', contents: (n.contents || '').replace(/\\[a-zA-Z]/g, '').replace(/\\n/g, '\n').trim().slice(0, 300), date: n.date || 0, feedname: n.feedname || '', author: n.author || '', })); cacheSet(cacheKey, result, CACHE_TTL.news); return result; } // ==================== v2.9.60: 游玩时长趋势标签页 ==================== // ---- ISO 周编号 → "YYYY-Www" key ---- function _ptGetWeekKey(date) { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const weekNum = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`; } // ---- IDB key ---- function _ptHistKey(appId) { return nsKey('pt_hist_' + appId); } // ---- 后台静默采样:记录当前 playtime_forever 到 IDB ---- async function _samplePlayTime(appId) { if (!appId || SGIS.playTrendSampling) return; SGIS.playTrendSampling = true; try { const info = getCurrentGameInfo(); if (!info.found || info.playtime == null) return; const now = Math.floor(Date.now() / 1000); const key = _ptHistKey(appId); let hist = sglvIDB.get(key) || { schema: 1, samples: [] }; if (!hist.samples) hist = { schema: 1, samples: [] }; // 节流:同一游戏 30 分钟内不重复采样 const last = hist.samples[hist.samples.length - 1]; if (last && (now - last.ts) < 1800) return; // playtime 未变化也不记录(避免冗余采样点) if (last && last.totalMin === info.playtime && (!info.owners || Object.keys(info.owners).length === 0)) return; const sample = { ts: now, totalMin: info.playtime }; // 附加家庭成员 playtime(如果已获取) if (info.owners && info.owners.length > 0) { const ownersSnap = {}; info.owners.forEach(o => { if (o.playtime != null) ownersSnap[o.steamId] = o.playtime; }); if (Object.keys(ownersSnap).length > 0) sample.owners = ownersSnap; } hist.samples.push(sample); // 限制最多保留 500 个采样点(约 1 年每周多次采样) if (hist.samples.length > 500) hist.samples = hist.samples.slice(-500); sglvIDB.set(key, hist); } catch (e) { console.warn('[SGIS] playtime sampling failed:', e); } finally { SGIS.playTrendSampling = false; } } // ---- 从 IDB 加载采样历史 ---- function _loadPtHistory(appId) { try { const hist = sglvIDB.get(_ptHistKey(appId)); return (hist && hist.samples) ? hist.samples : []; } catch { return []; } } // ---- 周聚合差分算法 ---- // 输入: samples[] (按 ts 升序) // 输出: { weeks: [{ weekKey, myMin, owners: {sid: min} }], ownerIds: [sid...], weekKeys: [key...] } function _aggregateWeekly(samples) { if (!samples || samples.length < 2) return { weeks: [], ownerIds: [], weekKeys: [] }; const sorted = [...samples].sort((a, b) => a.ts - b.ts); const ownerIds = new Set(); // 1) 计算相邻差分 const diffs = []; for (let i = 1; i < sorted.length; i++) { const prev = sorted[i - 1]; const curr = sorted[i]; const myDiff = Math.max(0, (curr.totalMin || 0) - (prev.totalMin || 0)); const ownerDiffs = {}; if (curr.owners && prev.owners) { Object.keys(curr.owners).forEach(sid => { ownerIds.add(sid); const d = (curr.owners[sid] || 0) - (prev.owners[sid] || 0); if (d > 0) ownerDiffs[sid] = d; }); } else if (curr.owners) { Object.keys(curr.owners).forEach(sid => ownerIds.add(sid)); } // 差分归入后一个采样点所在的自然周 const weekKey = _ptGetWeekKey(new Date(curr.ts * 1000)); diffs.push({ weekKey, myMin: myDiff, owners: ownerDiffs }); } // 2) 按周分组累加 const weekMap = new Map(); diffs.forEach(d => { if (!weekMap.has(d.weekKey)) { weekMap.set(d.weekKey, { weekKey: d.weekKey, myMin: 0, owners: {} }); } const w = weekMap.get(d.weekKey); w.myMin += d.myMin; Object.keys(d.owners).forEach(sid => { w.owners[sid] = (w.owners[sid] || 0) + d.owners[sid]; }); }); // 3) 填充空周(连续周序列中间的空周显示 0) const sortedWeeks = [...weekMap.values()].sort((a, b) => a.weekKey.localeCompare(b.weekKey)); const filledWeeks = []; for (let i = 0; i < sortedWeeks.length; i++) { filledWeeks.push(sortedWeeks[i]); if (i < sortedWeeks.length - 1) { // 检查是否有空周间隔 const curr = sortedWeeks[i].weekKey; const next = sortedWeeks[i + 1].weekKey; const gap = _weekGap(curr, next); for (let g = 1; g < gap; g++) { const fillKey = _addWeek(curr, g); filledWeeks.push({ weekKey: fillKey, myMin: 0, owners: {} }); } } } // 4) 截取最近 12 周 const recent = filledWeeks.slice(-12); const weekKeys = recent.map(w => w.weekKey); return { weeks: recent, ownerIds: [...ownerIds], weekKeys }; } // 计算两个 ISO week key 之间的间隔周数 function _weekGap(currKey, nextKey) { const [cy, cw] = currKey.split('-W').map(Number); const [ny, nw] = nextKey.split('-W').map(Number); const totalCurr = cy * 52 + cw; const totalNext = ny * 52 + nw; return totalNext - totalCurr; } // ISO week key 加 N 周 function _addWeek(weekKey, n) { const [y, w] = weekKey.split('-W').map(Number); let total = y * 52 + w + n; let ny = Math.floor(total / 52); let nw = total % 52; if (nw === 0) { nw = 52; ny--; } return `${ny}-W${String(nw).padStart(2, '0')}`; } // ---- 周标签格式化 ---- function _formatWeekLabel(weekKey) { const parts = weekKey.split('-W'); return `W${parts[1]}`; } // ---- SVG 折线图渲染(个人单线) ---- function _renderPtTrendChart(weekKeys, values, color, unit) { const W = 400, H = 200, PAD = 30; const maxV = Math.max(...values, 1); const n = values.length; if (n === 0) return ''; const stepX = n > 1 ? (W - PAD * 2) / (n - 1) : 0; const pts = values.map((v, i) => { const x = n > 1 ? PAD + i * stepX : W / 2; const y = H - PAD - (v / maxV) * (H - PAD * 2); return [x, y]; }); const areaPath = n > 1 ? `M ${pts[0][0]} ${H - PAD} ` + pts.map(p => `L ${p[0]} ${p[1]}`).join(' ') + ` L ${pts[n-1][0]} ${H - PAD} Z` : ''; const linePath = `M ${pts.map(p => `${p[0]} ${p[1]}`).join(' L ')}`; const labelStep = Math.max(1, Math.floor(n / 6)); const labels = weekKeys.map((p, i) => { if (i % labelStep !== 0 && i !== n - 1) return ''; return `${_formatWeekLabel(p)}`; }).join(''); const yLabels = [0, 0.25, 0.5, 0.75, 1].map(r => { const v = maxV * r; const y = H - PAD - r * (H - PAD * 2); return `${v.toFixed(0)}${unit}`; }).join(''); const avgV = values.reduce((s, v) => s + v, 0) / n; const avgY = H - PAD - (avgV / maxV) * (H - PAD * 2); const avgLine = `${isZh ? '均' : 'avg'} ${avgV.toFixed(1)}`; const maxIdx = values.indexOf(maxV); const peakLabel = maxV > 0 ? `${isZh ? '峰' : 'peak'} ${maxV.toFixed(1)}` : ''; return ` ${yLabels}${labels} ${n > 1 ? `` : ''} ${avgLine} ${pts.map((p, i) => `${weekKeys[i]}: ${values[i].toFixed(1)}${unit}`).join('')} ${peakLabel} `; } // ---- SVG 多线折线图渲染(家庭组) ---- function _renderPtMultiLineChart(weekKeys, series, unit) { const W = 400, H = 220, PAD = 30, LEGEND_H = 20; const allVals = series.flatMap(s => s.values); const maxV = Math.max(...allVals, 1); const n = weekKeys.length; if (n === 0 || series.length === 0) return ''; const colors = ['#66c0f4', '#a78bfa', '#22d3ee', '#fbbf24', '#4ade80', '#f87171']; const stepX = n > 1 ? (W - PAD * 2) / (n - 1) : 0; const labelStep = Math.max(1, Math.floor(n / 6)); const labels = weekKeys.map((p, i) => { if (i % labelStep !== 0 && i !== n - 1) return ''; return `${_formatWeekLabel(p)}`; }).join(''); const yLabels = [0, 0.25, 0.5, 0.75, 1].map(r => { const v = maxV * r; const y = H - PAD - LEGEND_H - r * (H - PAD * 2 - LEGEND_H); return `${v.toFixed(0)}${unit}`; }).join(''); const lines = series.map((s, si) => { const color = colors[si % colors.length]; const pts = s.values.map((v, i) => { const x = n > 1 ? PAD + i * stepX : W / 2; const y = H - PAD - LEGEND_H - (v / maxV) * (H - PAD * 2 - LEGEND_H); return [x, y]; }); const linePath = `M ${pts.map(p => `${p[0]} ${p[1]}`).join(' L ')}`; const dots = pts.map((p, i) => `${s.label} ${weekKeys[i]}: ${s.values[i].toFixed(1)}${unit}`).join(''); return `${dots}`; }).join(''); const legend = series.map((s, si) => { const color = colors[si % colors.length]; return `${s.label.slice(0, 8)}`; }).join(''); return ` ${legend}${yLabels}${labels}${lines} `; } // ---- SVG 堆叠面积图渲染(总时长) ---- function _renderPtStackedChart(weekKeys, layers, unit) { const W = 400, H = 220, PAD = 30, LEGEND_H = 20; const n = weekKeys.length; if (n === 0 || layers.length === 0) return ''; // 计算每周总值 const totals = weekKeys.map((_, i) => layers.reduce((s, l) => s + (l.values[i] || 0), 0)); const maxV = Math.max(...totals, 1); const colors = ['#66c0f4', '#a78bfa', '#22d3ee', '#fbbf24', '#4ade80', '#f87171']; const stepX = n > 1 ? (W - PAD * 2) / (n - 1) : 0; const labelStep = Math.max(1, Math.floor(n / 6)); const labels = weekKeys.map((p, i) => { if (i % labelStep !== 0 && i !== n - 1) return ''; return `${_formatWeekLabel(p)}`; }).join(''); const yLabels = [0, 0.25, 0.5, 0.75, 1].map(r => { const v = maxV * r; const y = H - PAD - LEGEND_H - r * (H - PAD * 2 - LEGEND_H); return `${v.toFixed(0)}${unit}`; }).join(''); // 堆叠计算 let cumulative = new Array(n).fill(0); const areas = layers.map((layer, li) => { const color = colors[li % colors.length]; const topPts = []; const botPts = []; for (let i = 0; i < n; i++) { const x = n > 1 ? PAD + i * stepX : W / 2; const botVal = cumulative[i]; const topVal = botVal + (layer.values[i] || 0); cumulative[i] = topVal; const yBot = H - PAD - LEGEND_H - (botVal / maxV) * (H - PAD * 2 - LEGEND_H); const yTop = H - PAD - LEGEND_H - (topVal / maxV) * (H - PAD * 2 - LEGEND_H); topPts.push([x, yTop]); botPts.push([x, yBot]); } const areaPath = `M ${topPts.map(p => `${p[0]} ${p[1]}`).join(' L ')} L ${botPts.reverse().map(p => `${p[0]} ${p[1]}`).join(' L ')} Z`; return ``; }).join(''); const legend = layers.map((l, li) => { const color = colors[li % colors.length]; return `${l.label.slice(0, 8)}`; }).join(''); return ` ${legend}${yLabels}${labels}${areas} `; } // ---- v2.9.62: 家庭成员该游戏游玩时长横向对比条 ---- // 数据源: getCurrentGameInfo().owners (每位家庭组成员对本游戏的 playtime_forever, 分钟) // 我的时长取 info.playtime; 其他成员取 SGIS.ownersPlaytime[sid] (由 fetchOwnersPlaytime 异步填充) function _renderPtFamilyBars() { const info = getCurrentGameInfo(); if (!info.found) return ''; const mySteamId = getActiveSteamId(); const familyInfo = storage.getFamilyInfo() || {}; const nameMap = familyInfo.steamIdtoName || {}; // 构建成员列表(我 + 家庭组拥有该游戏的成员) const members = [{ name: nameMap[String(mySteamId)] || (isZh ? '我' : 'Me'), isMe: true, playtime: info.playtime || 0, }]; (info.owners || []).filter(o => !o.isMe).forEach(o => { members.push({ name: o.name || nameMap[o.steamId] || ('ID:' + String(o.steamId).slice(-4)), isMe: false, playtime: o.playtime, // null = 尚未获取 }); }); // 只有"我"一人,无需对比 if (members.length < 2) return ''; const hasNull = members.some(m => m.playtime == null); // 触发未获取成员时长的异步拉取(完成后由 fetchOwnersPlaytime 自动重渲染当前标签) if (hasNull && !SGIS.ownersPlaytimeFetching) { fetchOwnersPlaytime().catch(() => {}); } // 加载中: 有成员时长未获取 if (hasNull) { const apiKey = storage.getApiKey(); const loadingText = apiKey ? T.ptFamilyBarsLoading : T.ptFamilyBarsNoKey; return `
${SGIS_ICONS.share} ${T.ptFamilyCompare}${loadingText}
${loadingText}
`; } // 全部就绪: 按时长降序, 横向进度条按最大值等比 const sorted = [...members].sort((a, b) => (b.playtime || 0) - (a.playtime || 0)); const maxMin = Math.max(1, ...sorted.map(m => m.playtime || 0)); const totalMin = sorted.reduce((s, m) => s + (m.playtime || 0), 0); const rows = sorted.map(m => { const hours = ((m.playtime || 0) / 60).toFixed(1); const pct = ((m.playtime || 0) / maxMin * 100).toFixed(1); const meCls = m.isMe ? ' is-me' : ''; return `
${escHtml(m.name)}
${hours}h
`; }).join(''); return `
${SGIS_ICONS.share} ${T.ptFamilyCompare}${T.ptFamilyBarsTotal} ${(totalMin / 60).toFixed(1)}h
${rows}
`; } // ---- 主渲染函数 ---- function renderPlayTrend() { const samples = _loadPtHistory(APP_ID); const agg = _aggregateWeekly(samples); // 空状态 if (samples.length === 0) { setBody(`
${SGIS_ICONS.trend}
${T.ptEmptyNoData}
${_renderPtFamilyBars()}`); return; } if (samples.length < 2) { setBody(`
${SGIS_ICONS.trend}
${T.ptEmptyNeedTwo}
${isZh ? '当前采样点: ' + samples.length : 'Current samples: ' + samples.length}
${_renderPtFamilyBars()}`); return; } const weeks = agg.weeks; const weekKeys = weeks.map(w => w.weekKey); const myValues = weeks.map(w => w.myMin / 60); // 分钟→小时 // KPI 计算 const thisWeekH = myValues.length > 0 ? myValues[myValues.length - 1] : 0; const avgWeekH = myValues.length > 0 ? myValues.reduce((s, v) => s + v, 0) / myValues.length : 0; const peakIdx = myValues.indexOf(Math.max(...myValues)); const peakH = myValues.length > 0 ? myValues[peakIdx] : 0; const peakLabel = weekKeys.length > 0 ? _formatWeekLabel(weekKeys[peakIdx]) : ''; const sampleWeeks = weekKeys.length; // 检查数据稀疏 const hasSparseGap = weeks.some((w, i) => { if (i === 0) return false; return _weekGap(weeks[i - 1].weekKey, w.weekKey) > 2; }); const subTab = SGIS.playTrendSubTab || 'personal'; const subTabs = [ { key: 'personal', icon: SGIS_ICONS.trend, label: T.ptSubPersonal }, { key: 'family', icon: SGIS_ICONS.social, label: T.ptSubFamily }, { key: 'total', icon: SGIS_ICONS.barChart || SGIS_ICONS.trend, label: T.ptSubTotal }, ]; // 图表内容 let chartHtml = ''; if (subTab === 'personal') { chartHtml = _renderPtTrendChart(weekKeys, myValues, '#66c0f4', T.ptHours[0] || 'h'); } else if (subTab === 'family') { const ownerIds = agg.ownerIds; if (ownerIds.length === 0) { chartHtml = `
${SGIS_ICONS.social}
${T.ptTrendNoFamily}
`; } else { const familyInfo = storage.getFamilyInfo() || {}; const nameMap = familyInfo.steamIdtoName || {}; const mySteamId = getActiveSteamId(); // 限制最多 6 条线 const displayIds = ownerIds.slice(0, 6); const series = displayIds.map(sid => { const label = sid === mySteamId ? (isZh ? '我' : 'Me') : (nameMap[sid] || ('ID:' + String(sid).slice(-4))); const values = weeks.map(w => (w.owners[sid] || 0) / 60); return { label, values }; }); chartHtml = _renderPtMultiLineChart(weekKeys, series, T.ptHours[0] || 'h'); } } else if (subTab === 'total') { // 堆叠:个人层 + 家庭组成员层 const ownerIds = agg.ownerIds; const familyInfo = storage.getFamilyInfo() || {}; const nameMap = familyInfo.steamIdtoName || {}; const mySteamId = getActiveSteamId(); const layers = []; // 个人层 layers.push({ label: isZh ? '我' : 'Me', values: myValues }); // 家庭成员层(排除自己) const familyIds = ownerIds.filter(sid => sid !== mySteamId).slice(0, 5); familyIds.forEach(sid => { const label = nameMap[sid] || ('ID:' + String(sid).slice(-4)); const values = weeks.map(w => (w.owners[sid] || 0) / 60); layers.push({ label, values }); }); chartHtml = _renderPtStackedChart(weekKeys, layers, T.ptHours[0] || 'h'); // v2.9.62: 家庭成员该游戏总时长横向对比条 chartHtml += _renderPtFamilyBars(); } const kpiCardHtml = (label, value, unit, color) => `
${label}
${value}
${unit}
`; const subTabHtml = subTabs.map(st => ` `).join(''); setBody(`
${kpiCardHtml(T.ptKpiThisWeek, thisWeekH.toFixed(1), T.ptHours, '#66c0f4')} ${kpiCardHtml(T.ptKpiAvgWeek, avgWeekH.toFixed(1), T.ptHours, '#a78bfa')} ${kpiCardHtml(T.ptKpiPeakWeek, peakH.toFixed(1), `${T.ptHours} · ${peakLabel}`, '#fbbf24')} ${kpiCardHtml(T.ptKpiSampleWeeks, sampleWeeks, T.ptWeeks, '#22d3ee')}
${hasSparseGap ? `
⚠️ ${T.ptSparseWarning}
` : ''}
${subTabHtml}
${chartHtml}
`); // 绑定子标签切换 document.querySelectorAll('.sgis-pt-subtab').forEach(btn => { btn.addEventListener('click', () => { SGIS.playTrendSubTab = btn.dataset.subtab; renderPlayTrend(); }); }); } function renderDynamics() { if (SGIS.dynamicsLoading) return; if (SGIS.dynamics) { renderDynamicsContent(); return; } SGIS.dynamicsLoading = true; renderLoading('正在获取游戏动态…'); fetchGameNews(APP_ID).then(news => { SGIS.dynamics = news; renderDynamicsContent(); }).catch(e => { renderError('动态获取失败: ' + e.message); }).finally(() => { SGIS.dynamicsLoading = false; }); } function renderDynamicsContent() { const news = SGIS.dynamics || []; if (news.length === 0) { setBody(`
📢
暂无游戏动态
该游戏近期没有发布新闻或更新公告
`); return; } const formatDate = (ts) => { if (!ts) return ''; try { const d = new Date(ts * 1000); const now = new Date(); const diff = (now - d) / 1000; if (diff < 3600) return Math.floor(diff / 60) + ' 分钟前'; if (diff < 86400) return Math.floor(diff / 3600) + ' 小时前'; if (diff < 30 * 86400) return Math.floor(diff / 86400) + ' 天前'; return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); } catch { return ''; } }; const feedLabels = { steam_community_announcements: '社区公告', steam_blog: '官方博客', patch_notes: '更新日志', product_updates: '产品更新', }; // v2.2: 原文/AI翻译总结 切换按钮 const isAiMode = SGIS.dynamicsMode === 'ai'; // v2.3.18: 检测截断部分恢复 const hasTruncationFlag = SGIS.aiSummary && SGIS.aiSummary.some(it => it._truncated); const aiStatusText = isAiMode ? (SGIS.aiSummary ? (hasTruncationFlag ? `⚠️ 已恢复 ${SGIS.aiSummary.length} 条 (部分结果因长度限制被截断)` : `✅ ${SGIS.aiSummary.length} 条已总结`) : T.aiTranslating) : ''; const toggleBar = `
${aiStatusText}
`; // 根据 mode 渲染不同内容 let contentHtml = ''; if (isAiMode) { // AI 翻译总结模式 if (SGIS.aiSummaryLoading) { contentHtml = `
${T.aiTranslating}
`; } else if (SGIS.aiSummaryError) { contentHtml = `
⚠️ ${T.aiTranslateFail}: ${SGIS.aiSummaryError}
`; } else if (SGIS.aiSummary) { // v2.3.18: 截断部分恢复提示横幅 const truncationBanner = hasTruncationFlag ? `
⚠️ AI 响应因长度限制被截断, 已通过修复算法恢复 ${SGIS.aiSummary.length} 条结果。切换到"原文"可查看完整新闻。
` : ''; contentHtml = truncationBanner + SGIS.aiSummary.map((item, i) => { const origNews = news[i] || {}; const feedLabel = feedLabels[origNews.feedname] || origNews.feedname || '动态'; const badge = item.is_translated ? `已翻译` : ''; return `
${feedLabel} ${formatDate(origNews.date)}
${item.title || origNews.title || ''}${badge}
${item.summary || ''}
`; }).join(''); } else { // 触发 AI 翻译 contentHtml = `
${T.aiTranslating}
`; // 异步调用 AI 翻译 SGIS.aiSummaryLoading = true; const gameName = (document.getElementById('appHubAppName') || {}).textContent || ''; callAiTranslateSummary(news, gameName, APP_ID).then(result => { SGIS.aiSummary = result; SGIS.aiSummaryLoading = false; if (SGIS.tab === 'dynamics') renderDynamicsContent(); }).catch(e => { SGIS.aiSummaryLoading = false; SGIS.aiSummaryError = e.message; if (SGIS.tab === 'dynamics' && SGIS.dynamicsMode === 'ai') { renderDynamicsContent(); } }); } } else { // 原文模式 contentHtml = news.map(n => { const feedLabel = feedLabels[n.feedname] || n.feedname || '动态'; const desc = n.contents ? n.contents.slice(0, 200) + (n.contents.length > 200 ? '...' : '') : ''; return `
${feedLabel} ${formatDate(n.date)}
${n.title}
${desc ? `
${desc}
` : ''} ${n.author ? `
— ${n.author}
` : ''}
`; }).join(''); } setBody(`
${SGIS_ICONS.news} 游戏动态与新闻
共 ${news.length} 条动态 · 来自 Steam 社区公告
${toggleBar} ${contentHtml}
数据来自 Steam News API · 每30分钟刷新缓存${isAiMode ? ' · AI翻译总结由用户配置的模型提供' : ''}
`); // 绑定切换按钮事件 const body = document.getElementById('sgis-body'); if (body) { body.querySelectorAll('.sgis-ai-toggle-btn').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.mode; if (SGIS.dynamicsMode === mode) return; SGIS.dynamicsMode = mode; // 切换模式时清除错误状态 if (mode === 'original') SGIS.aiSummaryError = null; renderDynamicsContent(); }); }); // AI 翻译重试按钮 const aiRetryBtn = body.querySelector('#sgis-ai-retry'); if (aiRetryBtn) { aiRetryBtn.addEventListener('click', () => { SGIS.aiSummaryError = null; SGIS.aiSummary = null; renderDynamicsContent(); }); } } } // ==================== 用户档案浮窗 (v2.3) ==================== // ---- 获取用户摘要 (GetPlayerSummaries) ---- async function fetchPlayerSummaries(steamId) { const apiKey = storage.getApiKey(); if (!apiKey || !steamId) return null; const cacheKey = 'playerSummary_' + steamId; const cached = cacheGet(cacheKey); if (cached) return cached; try { const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${steamId}`; const data = await fetchJson(url, { timeout: 12000 }); const player = data?.response?.players?.[0]; if (player) { cacheSet(cacheKey, player, 30 * 60 * 1000); return player; } return null; } catch { return null; } } // v2.3.13: 获取最近游玩(参考 steam-friend-manager 个人库浮窗) async function fetchRecentlyPlayedGames(steamId) { const apiKey = storage.getApiKey(); if (!apiKey || !steamId) return []; try { const url = `https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v0001/?key=${apiKey}&steamid=${steamId}&format=json`; const data = await fetchJson(url, { timeout: 12000 }); return (data?.response?.games || []).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtime_2weeks: g.playtime_2weeks || 0, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '', })); } catch { return []; } } // 格式化游玩时长(分钟→小时) function formatPlaytimeShort(minutes) { if (!minutes || minutes === 0) return '0h'; return Math.floor(minutes / 60) + 'h'; } // ---- 获取 Steam 等级 (从社区页面抓取) ---- async function fetchSteamLevel(steamId) { if (!steamId) return null; const cacheKey = 'steamLevel_' + steamId; const cached = cacheGet(cacheKey); if (cached) return cached; try { const html = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `https://steamcommunity.com/profiles/${steamId}/`, timeout: 12000, onload(r) { resolve(r.responseText); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('超时')), }); }); const m = html.match(/"player_level":(\d+)/) || html.match(/class="friendPlayerLevelNum"[^>]*>(\d+)/); const level = m ? parseInt(m[1], 10) : null; if (level != null) cacheSet(cacheKey, level, 60 * 60 * 1000); return level; } catch { return null; } } // ---- 获取好友数量 ---- async function fetchFriendCount(steamId) { if (!steamId) return null; const cacheKey = 'friendCount_' + steamId; const cached = cacheGet(cacheKey); if (cached != null) return cached; try { const auth = await sgisAuthParams(); // v2.3.24: API Key 缺省时回退 access_token if (!auth) return null; const url = `https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?${auth}&steamid=${steamId}&relationship=friend`; const data = await fetchJson(url, { timeout: 12000 }); const count = data?.friendslist?.friends?.length || 0; cacheSet(cacheKey, count, 60 * 60 * 1000); return count; } catch { return null; } } // v2.3.27: 轻量愿望单计数(参考 Steam-Wishlist-Exporter 的获取链路,仅供概览 KPI 卡片展示) // 优先级: 会话已加载 → GDynamicStore(零请求) → 6h缓存 → GetWishlist API(单请求) async function fetchWishlistCount(steamId) { // 1. 中央面板本次会话已加载的愿望单列表 if (Array.isArray(state.wishlistGames) && state.wishlistGames.length > 0) { return state.wishlistGames.length; } // 2. GDynamicStore 即时读取(商店页面,零请求且数据最新) try { const dsIds = getDynamicStoreAppIds('wishlist'); if (dsIds.size > 0) return dsIds.size; } catch { /* ignore */ } // 3. 与愿望单标签页共享的 6h 全量缓存 const cachedList = cacheGet('wishlistGames'); if (Array.isArray(cachedList) && cachedList.length > 0) return cachedList.length; // 4. 计数缓存(避免重复 API 请求) const cacheKey = 'wishlistCount_' + steamId; const cachedCount = cacheGet(cacheKey); if (cachedCount != null) return cachedCount; // 5. GetWishlist API 单请求兜底 if (steamId) { try { const entries = await fetchWishlistFromApi(steamId); if (entries.length > 0) { cacheSet(cacheKey, entries.length, 6 * 3600 * 1000); return entries.length; } } catch { /* ignore */ } } return null; } // ==================== v2.3.8: 社交标签页 - 好友列表完整数据 ==================== // 参考 steam-friend-manager-1.0.16.js 的批量获取+缓存策略 // 流程: GetFriendList → GetPlayerSummaries(批量100) → GetPlayerBans(批量100) // 缓存: 完整数据 6h, VAC 独立 24h, 等级独立 7天 const SUMMARY_BATCH = 100; // GetPlayerSummaries 单次上限 const VAC_BATCH = 100; // GetPlayerBans 单次上限 // ==================== SVG 国旗 (移植自 steam-friend-manager-1.0.16) ==================== // 内联 SVG 国旗, 不依赖外部 CDN (flagcdn.com), 加载更快/不跨域/失败时降级为文本代码徽章 const SGIS_FLAGS = { CN:'', US:'', JP:'', KR:'', TW:'', HK:'', MO:'', GB:'', DE:'', FR:'', RU:'', AU:'', CA:'', BR:'', IN:'', SG:'', MY:'', TH:'', VN:'', ID:'', PH:'', NZ:'', SE:'', NO:'', FI:'', DK:'', NL:'', IT:'', ES:'', PL:'', UA:'', TR:'', SA:'', AE:'', EG:'', ZA:'', IL:'', MX:'', AR:'', CL:'', AT:'', BE:'', CH:'', PT:'', GR:'', CZ:'', HU:'', RO:'', IE:'', EU:'' }; // ==================== 国家/地区名称映射 (zh-CN) ==================== const SGIS_COUNTRY_MAP = { CN:'中国',US:'美国',JP:'日本',KR:'韩国',TW:'中国台湾',HK:'中国香港',MO:'中国澳门', SG:'新加坡',MY:'马来西亚',TH:'泰国',VN:'越南',ID:'印度尼西亚',PH:'菲律宾', IN:'印度',AU:'澳大利亚',NZ:'新西兰',GB:'英国',DE:'德国',FR:'法国',IT:'意大利', ES:'西班牙',RU:'俄罗斯',BR:'巴西',CA:'加拿大',MX:'墨西哥',AR:'阿根廷',CL:'智利', SE:'瑞典',NO:'挪威',FI:'芬兰',DK:'丹麦',NL:'荷兰',BE:'比利时',AT:'奥地利', CH:'瑞士',PL:'波兰',CZ:'捷克',PT:'葡萄牙',GR:'希腊',TR:'土耳其', SA:'沙特阿拉伯',AE:'阿联酋',EG:'埃及',ZA:'南非',IL:'以色列',UA:'乌克兰', RO:'罗马尼亚',HU:'匈牙利',SK:'斯洛伐克',BG:'保加利亚',HR:'克罗地亚', SI:'斯洛文尼亚',LT:'立陶宛',LV:'拉脱维亚',EE:'爱沙尼亚',IE:'爱尔兰', IS:'冰岛',LU:'卢森堡',MT:'马耳他',CY:'塞浦路斯',EU:'欧盟' }; // 生成 inline SVG 国旗 (默认 18x13), 失败回退文本代码徽章 function flagSvg(cc, w, h) { if (!cc || cc.length < 2) return ''; const uc = cc.toUpperCase(); const svg = SGIS_FLAGS[uc]; const ww = w || 18, hh = h || 13; const style = `display:inline-block;width:${ww}px;height:${hh}px;vertical-align:middle;flex-shrink:0;border-radius:1px;box-shadow:0 0 0 1px rgba(255,255,255,0.08)`; if (svg) return svg.replace('${uc}`; } // 获取国家信息 { name, flag } function getCountryInfo(code) { if (!code) return null; const uc = code.toUpperCase(); return { code: uc, name: SGIS_COUNTRY_MAP[uc] || uc, flag: flagSvg(uc) }; } // 计算好友天数 (friend_since 是 unix 秒) function calcFriendDays(friendSince) { if (!friendSince) return { days: 0, text: '未知', cls: 'd-old' }; const days = Math.floor((Date.now() / 1000 - friendSince) / 86400); let text, cls; if (days < 30) { text = `${days}天`; cls = 'd-new'; } else if (days < 365) { text = `${Math.floor(days / 30)}个月`; cls = 'd-mid'; } else { text = `${(days / 365).toFixed(1)}年`; cls = 'd-old'; } return { days, text, cls }; } // 获取在线状态文字 function getPersonaStateText(state) { const map = ['离线', '在线', '忙碌', '离开', '休眠', '交易中', '游戏中']; return map[state] || '离线'; } // v2.3.24: 统一鉴权参数——优先 API Key,缺省时回退页面 access_token(免配置也可获取好友数据) async function sgisAuthParams() { const apiKey = storage.getApiKey(); if (apiKey) return `key=${apiKey}`; try { const token = await getAccessToken(); if (token) return `access_token=${token}`; } catch { /* ignore */ } return ''; } // 获取好友完整数据 (列表+摘要+VAC), 带缓存与增量更新 async function fetchFriendsList(steamId, opts = {}) { // v2.3.24: 鉴权优化——API Key 或 access_token 任一可用即可 const auth = await sgisAuthParams(); if (!auth || !steamId) return { error: 'NO_KEY', friends: [] }; const { force = false } = opts; // 1. 检查完整缓存 (6h TTL) const listCacheKey = 'friendsList_' + steamId; if (!force) { const cached = cacheGet(listCacheKey); if (cached && cached.friends && cached.friends.length > 0) { return cached; } } // 2. 获取好友列表 (含 friend_since) const listUrl = `https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?${auth}&steamid=${steamId}&relationship=friend&format=json`; const listData = await fetchJson(listUrl, { timeout: 15000 }); const rawFriends = listData?.friendslist?.friends || []; if (rawFriends.length === 0) { return { error: 'EMPTY', friends: [], steamId }; } const steamIds = rawFriends.map(f => f.steamid); // 3. 批量获取摘要 (每批 100) const summaries = []; for (let i = 0; i < steamIds.length; i += SUMMARY_BATCH) { const batch = steamIds.slice(i, i + SUMMARY_BATCH); try { const sumUrl = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?${auth}&steamids=${batch.join(',')}`; const sumData = await fetchJson(sumUrl, { timeout: 15000 }); if (sumData?.response?.players) summaries.push(...sumData.response.players); } catch (e) { console.warn('[SGIS] GetPlayerSummaries batch failed:', e); } } const sumMap = new Map(summaries.map(s => [s.steamid, s])); // 4. 批量获取 VAC 封禁 (每批 100), 优先用独立缓存 const vacCacheKey = 'friendsVac_' + steamId; const vacCached = cacheGet(vacCacheKey) || {}; const vacMap = { ...vacCached }; const needVacIds = steamIds.filter(sid => !vacMap[sid]); for (let i = 0; i < needVacIds.length; i += VAC_BATCH) { const batch = needVacIds.slice(i, i + VAC_BATCH); try { const banUrl = `https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?${auth}&steamids=${batch.join(',')}`; const banData = await fetchJson(banUrl, { timeout: 15000 }); if (banData?.players) { for (const p of banData.players) { vacMap[p.SteamId] = { VACBanned: !!p.VACBanned, DaysSinceLastBan: p.DaysSinceLastBan || 0, NumberOfVACBans: p.NumberOfVACBans || 0, NumberOfGameBans: p.NumberOfGameBans || 0, }; } } } catch (e) { console.warn('[SGIS] GetPlayerBans batch failed:', e); } } // VAC 独立缓存 24h cacheSet(vacCacheKey, vacMap, CACHE_TTL.friendsVac); // 5. 合并数据 (参考 friend-manager fetchAllData 的 enriched 逻辑) const friends = rawFriends.map(f => { const s = sumMap.get(f.steamid) || {}; const daysInfo = calcFriendDays(f.friend_since); const vacInfo = vacMap[f.steamid] || {}; const countryInfo = getCountryInfo(s.loccountrycode); return { steamid: f.steamid, friend_since: f.friend_since || 0, friend_days: daysInfo.days, friend_days_text: daysInfo.text, friend_days_class: daysInfo.cls, personaname: s.personaname || '匿名玩家', avatar: s.avatar || '', avatarmedium: s.avatarmedium || s.avatar || '', avatarfull: s.avatarfull || s.avatar || '', personastate: s.personastate !== undefined ? s.personastate : 0, lastlogoff: s.lastlogoff || 0, gameextrainfo: s.gameextrainfo || '', gameid: s.gameid || '', loccountrycode: s.loccountrycode || '', // v2.3.8 修复: 预填国家名+SVG国旗, 渲染时直接用, 不再依赖外部 CDN country_name: countryInfo ? countryInfo.name : '', country_flag: countryInfo ? countryInfo.flag : '', profileurl: s.profileurl || `https://steamcommunity.com/profiles/${f.steamid}/`, // VAC 信息 vac_banned: !!vacInfo.VACBanned, vac_days_since_last_ban: vacInfo.DaysSinceLastBan || 0, vac_ban_count: vacInfo.NumberOfVACBans || 0, game_ban_count: vacInfo.NumberOfGameBans || 0, // 等级 (从独立缓存读取, fetchFriendsLevels 填充) level: null, }; }); // 6. 读取等级缓存 (独立 7天 TTL, 可能不完整) const levelCacheKey = 'friendsLevels_' + steamId; const levelCached = cacheGet(levelCacheKey) || {}; for (const f of friends) { if (levelCached[f.steamid] != null) f.level = levelCached[f.steamid]; } const result = { friends, steamId, total: friends.length, _ts: Date.now() }; // 完整数据缓存 6h cacheSet(listCacheKey, result, CACHE_TTL.friendsList); // 同步更新好友数量缓存 cacheSet('friendCount_' + steamId, friends.length, 60 * 60 * 1000); return result; } // 懒加载好友等级 (GetSteamLevel 单次只接受一个 steamid, 使用 mapLimit 并发) // 参考 friend-manager 的并发池思想:v2.3.12 改为 100 并发,点击一次即可自动同步全部未知等级 async function fetchFriendsLevels(steamId, opts = {}) { const auth = await sgisAuthParams(); // v2.3.24: API Key 缺省时回退 access_token if (!auth || !steamId) return null; const { force = false, maxCount = Infinity } = opts; const levelCacheKey = 'friendsLevels_' + steamId; const levelCached = force ? {} : (cacheGet(levelCacheKey) || {}); let changed = false; const friends = (SGIS.friendsList && SGIS.friendsList.friends) || []; let toFetch = friends.filter(f => f.level == null).map(f => f.steamid); if (maxCount !== Infinity && maxCount > 0) { toFetch = toFetch.slice(0, maxCount); } if (toFetch.length === 0) { SGIS.friendsLevels = levelCached; return levelCached; } const LEVEL_CONCURRENCY = 100; const RENDER_INTERVAL = 800; let lastRenderTime = 0; // 仅在用户主动触发(按钮点击)时显示实时进度;后台预加载不干扰按钮状态 if (SGIS.friendsLevelsLoading) { SGIS.friendsLevelsTotal = toFetch.length; SGIS.friendsLevelsProgress = 0; } async function fetchOne(sid, attempt = 0) { const url = `https://api.steampowered.com/IPlayerService/GetSteamLevel/v1/?${auth}&steamid=${sid}`; try { const data = await fetchJson(url, { timeout: 8000 }); return data?.response?.player_level ?? 0; } catch (e) { if (attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 800)); return fetchOne(sid, attempt + 1); } return undefined; } } await mapLimit(toFetch, LEVEL_CONCURRENCY, async (sid) => { const level = await fetchOne(sid); if (level !== undefined) { levelCached[sid] = level; const f = friends.find(x => x.steamid === sid); if (f) f.level = level; changed = true; } if (SGIS.friendsLevelsLoading) { SGIS.friendsLevelsProgress++; const now = Date.now(); if (now - lastRenderTime >= RENDER_INTERVAL) { lastRenderTime = now; if (SGIS.tab === 'social') renderSocialContent(); } } }); if (changed) { cacheSet(levelCacheKey, levelCached, CACHE_TTL.friendsLevels); for (const f of friends) { if (levelCached[f.steamid] != null) f.level = levelCached[f.steamid]; } } } // v2.3.13: 手动同步全部好友 VAC/游戏封禁状态 (GetPlayerBans 支持批量 100) async function fetchFriendsBans(steamId, opts = {}) { const auth = await sgisAuthParams(); // v2.3.24: API Key 缺省时回退 access_token if (!auth || !steamId) return null; const { force = false } = opts; const friends = (SGIS.friendsList && SGIS.friendsList.friends) || []; const steamIds = friends.map(f => f.steamid); if (steamIds.length === 0) return null; const vacCacheKey = 'friendsVac_' + steamId; const vacCached = cacheGet(vacCacheKey) || {}; const vacMap = { ...vacCached }; let toFetch = steamIds; if (!force) { toFetch = steamIds.filter(sid => !vacMap[sid]); } if (toFetch.length === 0) return vacMap; const BAN_BATCH = 100; const RENDER_INTERVAL = 800; let lastRenderTime = 0; if (SGIS.friendsBansLoading) { SGIS.friendsBansTotal = toFetch.length; SGIS.friendsBansProgress = 0; } for (let i = 0; i < toFetch.length; i += BAN_BATCH) { const batch = toFetch.slice(i, i + BAN_BATCH); let retry = 0; while (retry < 3) { try { const banUrl = `https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?${auth}&steamids=${batch.join(',')}`; const banData = await fetchJson(banUrl, { timeout: 15000 }); if (banData?.players) { for (const p of banData.players) { vacMap[p.SteamId] = { VACBanned: !!p.VACBanned, DaysSinceLastBan: p.DaysSinceLastBan || 0, NumberOfVACBans: p.NumberOfVACBans || 0, NumberOfGameBans: p.NumberOfGameBans || 0, }; } } break; } catch (e) { retry++; console.warn(`[SGIS] GetPlayerBans batch retry ${retry}:`, e); if (retry < 3) await new Promise(r => setTimeout(r, retry * 600)); } } for (const sid of batch) { const info = vacMap[sid]; const f = friends.find(x => x.steamid === sid); if (f && info) { f.vac_banned = info.VACBanned; f.vac_days_since_last_ban = info.DaysSinceLastBan || 0; f.vac_ban_count = info.NumberOfVACBans || 0; f.game_ban_count = info.NumberOfGameBans || 0; } } if (SGIS.friendsBansLoading) { SGIS.friendsBansProgress += batch.length; const now = Date.now(); if (now - lastRenderTime >= RENDER_INTERVAL) { lastRenderTime = now; if (SGIS.tab === 'social') renderSocialContent(); } } } cacheSet(vacCacheKey, vacMap, CACHE_TTL.friendsVac); SGIS.friendsVac = vacMap; if (SGIS.friendsBansLoading) SGIS.friendsBansProgress = toFetch.length; return vacMap; } // v2.3.24: 好友游戏数量懒加载(参考 steam-friend-manager 社交仪表盘"游戏总数排行") // GetOwnedGames include_appinfo=0 轻量请求(不拉游戏名,响应更小);mapLimit 6 并发;12h 缓存;私密资料跳过 async function fetchFriendsGameCounts(steamId, opts = {}) { const auth = await sgisAuthParams(); if (!auth || !steamId) return null; const { force = false } = opts; const GC_TTL = 12 * 3600 * 1000; const cacheKey = 'friendsGameCounts_' + steamId; const gcMap = force ? {} : (cacheGet(cacheKey) || {}); const friends = (SGIS.friendsList && SGIS.friendsList.friends) || []; if (friends.length === 0) return gcMap; const now = Date.now(); const toFetch = friends.map(f => f.steamid).filter(sid => !(gcMap[sid] && now - gcMap[sid].ts < GC_TTL)); if (toFetch.length === 0) { SGIS.friendsGameCounts = gcMap; return gcMap; } if (SGIS.friendsGameCountsLoading) { SGIS.friendsGameCountsTotal = toFetch.length; SGIS.friendsGameCountsProgress = 0; } let done = 0; await mapLimit(toFetch, 6, async (sid) => { try { const data = await fetchJson(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?${auth}&steamid=${sid}&include_appinfo=0&include_played_free_games=1&format=json`, { timeout: 15000 }); const resp = data && data.response; if (resp && typeof resp.game_count === 'number' && resp.game_count > 0) { let tm = 0; (resp.games || []).forEach(g => { tm += g.playtime_forever || 0; }); gcMap[sid] = { gc: resp.game_count, tm, ts: now }; } else { // 私密资料或无游戏 gcMap[sid] = { gc: 0, tm: 0, ts: now, priv: true }; } } catch { /* 单个失败跳过,下轮再补 */ } done++; if (SGIS.friendsGameCountsLoading) { SGIS.friendsGameCountsProgress = done; // 直接更新按钮文案,避免整页重绘导致搜索框失焦 const btn = document.getElementById('sgis-social-load-gc'); if (btn) btn.textContent = `${isZh ? '加载中' : 'Loading'} ${done}/${toFetch.length}`; } }); cacheSet(cacheKey, gcMap, GC_TTL); SGIS.friendsGameCounts = gcMap; return gcMap; } // ---- 获取 Steam 官方新闻 (促销/更新/活动) ---- // v2.3.25: 重写官方动态数据获取——接入 featuredcategories API 获取结构化特惠/促销数据 // 数据源: // 1. store.steampowered.com/api/featuredcategories — 当前特惠/每日优惠/聚光灯(含促销活动页URL) // 2. api.steampowered.com/ISteamNews/GetNewsForApp — Steam 官方新闻公告 // 返回结构: { specials:[], dailyDeals:[], spotlights:[], news:[], totalCount:N, fetchedAt:ts } async function fetchSteamOfficialNews() { const cacheKey = 'steamOfficialNews'; const cached = cacheGet(cacheKey); // v2.3.25: 兼容旧版缓存(旧版返回数组,新版返回对象) if (cached && !Array.isArray(cached) && cached.totalCount !== undefined) return cached; const result = { specials: [], dailyDeals: [], spotlights: [], news: [], totalCount: 0, fetchedAt: Date.now() }; // ---- 1. featuredcategories API:获取当前特惠/每日优惠/聚光灯 ---- try { const fcUrl = 'https://store.steampowered.com/api/featuredcategories?cc=us&l=schinese'; const fcData = await fetchJson(fcUrl, { timeout: 15000 }); // 1a. 特惠游戏 (specials) if (fcData.specials && Array.isArray(fcData.specials.items)) { result.specials = fcData.specials.items .filter(it => it && it.discounted && Number(it.discount_percent) > 0) .slice(0, 20) .map(it => { const discount = Number(it.discount_percent) || 0; const original = Number(it.original_price) || 0; const final = Number(it.final_price) || 0; const exp = Number(it.discount_expiration) || 0; return { appid: it.id, name: it.name || '', discount, originalPrice: original, // 分 finalPrice: final, // 分 currency: it.currency || 'USD', expiration: exp, // Unix 时间戳 headerImage: it.header_image || it.large_capsule_image || '', url: `https://store.steampowered.com/app/${it.id}`, }; }); } // 1b. 每日优惠 (cat_dailydeal) — key 不固定,可能是 cat_dailydeal 或数字索引 const dailyDealKey = Object.keys(fcData).find(k => { const v = fcData[k]; return v && v.id === 'cat_dailydeal' && Array.isArray(v.items); }); if (dailyDealKey) { result.dailyDeals = fcData[dailyDealKey].items .filter(it => it && it.discounted) .slice(0, 5) .map(it => { const discount = Number(it.discount_percent) || 0; const original = Number(it.original_price) || 0; const final = Number(it.final_price) || 0; return { appid: it.id, name: it.name || '', discount, originalPrice: original, finalPrice: final, currency: it.currency || 'USD', headerImage: it.header_image || '', url: `https://store.steampowered.com/app/${it.id}`, }; }); } // 1c. 聚光灯/促销活动 (cat_spotlight) — 含促销活动页 URL const spotlightKeys = Object.keys(fcData).filter(k => { const v = fcData[k]; return v && v.id === 'cat_spotlight' && Array.isArray(v.items); }); for (const sk of spotlightKeys) { for (const sp of fcData[sk].items) { if (sp && sp.url && sp.url.includes('/sale/')) { // 从 URL 提取活动名称,如 /sale/Games-Composed-in-Germany const saleNameMatch = sp.url.match(/\/sale\/([^\/\?]+)/); const saleId = saleNameMatch ? saleNameMatch[1] : ''; // 去重:同一 saleId 只保留一条 if (saleId && result.spotlights.some(s => s.saleId === saleId)) continue; result.spotlights.push({ name: sp.name || '促销活动', url: sp.url, saleId, headerImage: sp.header_image || sp.large_capsule_image || '', body: sp.body || '', // v2.9.70: 捕获 discount_expiration 时间戳(部分 spotlight 有此字段) discountExpiration: Number(sp.discount_expiration) || 0, }); } } } } catch (e) { console.warn('[SGIS] featuredcategories 获取失败:', e); } // ---- 2. ISteamNews API:获取 Steam 官方新闻公告 ---- try { const newsAppIds = [593050]; // Steam Client News const allNews = []; for (const aid of newsAppIds) { try { const newsUrl = 'https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/?appid=' + aid + '&count=20&maxlength=500&format=json'; const data = await fetchJson(newsUrl, { timeout: 12000 }); const items = (data && data.appnews && data.appnews.newsitems) || []; allNews.push(...items.map(n => ({ title: n.title || '', url: n.url || '', date: n.date || 0, contents: (n.contents || '').slice(0, 300), feedname: n.feedname || 'steam', author: n.author || '', }))); } catch { /* ignore individual appid failures */ } } allNews.sort((a, b) => b.date - a.date); result.news = allNews.slice(0, 15); } catch (e) { console.warn('[SGIS] ISteamNews 获取失败:', e); } result.totalCount = result.specials.length + result.dailyDeals.length + result.spotlights.length + result.news.length; cacheSet(cacheKey, result, 30 * 60 * 1000); return result; } // ---- v2.3.6: SteamCardExchange 卡牌数据库 (全部有交易卡牌的游戏) ---- // 数据源: https://www.steamcardexchange.net/api/request.php?GetInventory // 返回格式: { data: [ [[appid, name], ..., [size]], ... ] } // 解析为: { appId: { name, maxLevel } } (maxLevel = size, 即徽章最高可达等级) const CARD_DB_API_URL = 'https://www.steamcardexchange.net/api/request.php?GetInventory'; const CARD_DB_CACHE_KEY = 'sgis_card_db_cache'; const CARD_DB_CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时 let cardDbData = null; // { appId: { name, maxLevel } } let cardDbLoading = false; function getCardDbMaxLevel(appId) { if (!cardDbData || !appId) return 0; const info = cardDbData[String(appId)]; return info ? info.maxLevel : 0; } function getCardDbGameName(appId) { if (!cardDbData || !appId) return ''; const info = cardDbData[String(appId)]; return info ? info.name : ''; } async function loadCardDatabase() { if (cardDbData || cardDbLoading) return cardDbData; cardDbLoading = true; try { // 检查本地缓存 const cached = GM_getValue(CARD_DB_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < CARD_DB_CACHE_TTL)) { cardDbData = cached.data; console.log(`[SGIS] 卡牌数据库缓存命中: ${Object.keys(cardDbData).length} 款游戏`); return cardDbData; } console.log('[SGIS] 正在从 SteamCardExchange 获取卡牌数据库...'); const resp = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: CARD_DB_API_URL, timeout: 30000, onload: (r) => resolve(r), onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); const json = JSON.parse(resp.responseText); if (!json || !json.data || !Array.isArray(json.data)) { throw new Error('API 返回数据格式异常'); } const db = {}; let count = 0; // data[i] = [[appid, name], ..., [size]] json.data.forEach((current) => { if (!current || !current[0] || !current[3]) return; const appId = String(current[0][0]); const name = current[0][1] || ''; const size = current[3][0] || 0; if (appId && size) { db[appId] = { name, maxLevel: size }; count++; } }); cardDbData = db; GM_setValue(CARD_DB_CACHE_KEY, { timestamp: Date.now(), data: db }); console.log(`[SGIS] 卡牌数据库加载完成: ${count} 款游戏`); return cardDbData; } catch (e) { console.warn('[SGIS] 卡牌数据库加载失败(不影响基本功能):', e.message); return null; } finally { cardDbLoading = false; } } // v2.9.0: 桥接卡牌数据库函数到 SGLV_API,供外层 renderGamesList 使用 SGLV_API.getCardDbMaxLevel = getCardDbMaxLevel; SGLV_API.loadCardDatabase = loadCardDatabase; // ---- 获取用户勋章/徽章 ---- // v2.3.7: 增加浏览器持久缓存与数据合理性校验, 避免 API 异常时覆盖已有正确缓存 const PROFILE_CACHE_KEY = 'sgis_profile_cache_v1'; const USER_BADGES_CACHE_KEY = 'sgis_user_badges_cache_v1'; const PROFILE_CACHE_TTL = 7 * 24 * 3600 * 1000; // 7 天 (实际过期由失效条件控制) function readPersistentProfileCache(steamId) { try { const raw = GM_getValue(PROFILE_CACHE_KEY, null); if (!raw) return null; const obj = JSON.parse(raw); if (!obj || obj.steamId !== steamId) return null; if (obj.expires && Date.now() > obj.expires) { GM_setValue(PROFILE_CACHE_KEY, ''); return null; } return obj.data || null; } catch { return null; } } function writePersistentProfileCache(steamId, data) { try { GM_setValue(PROFILE_CACHE_KEY, JSON.stringify({ steamId, data, expires: Date.now() + PROFILE_CACHE_TTL })); } catch { /* ignore quota errors */ } } function readPersistentUserBadgesCache(steamId) { try { const raw = GM_getValue(USER_BADGES_CACHE_KEY, null); if (!raw) return null; const obj = JSON.parse(raw); if (!obj || obj.steamId !== steamId) return null; if (obj.expires && Date.now() > obj.expires) { GM_setValue(USER_BADGES_CACHE_KEY, ''); return null; } return obj.data || null; } catch { return null; } } function writePersistentUserBadgesCache(steamId, data) { try { GM_setValue(USER_BADGES_CACHE_KEY, JSON.stringify({ steamId, data, expires: Date.now() + PROFILE_CACHE_TTL })); } catch { /* ignore quota errors */ } } // v2.3.7: 校验 GetBadges 数据是否比缓存更"合理" // 核心规则: 等级进度 (playerXp - curLevelXp) 不应低于缓存值 (经验只增不减) function isBadgeDataBetterThanCache(newData, cachedData) { if (!cachedData) return true; if (!newData) return false; // 等级不能降低 if ((newData.playerLevel || 0) < (cachedData.playerLevel || 0)) return false; // 总经验不能降低 if ((newData.playerXp || 0) < (cachedData.playerXp || 0)) return false; // 计算当前等级进度 (使用公式得出 curLevelXp, 因为 API 阈值可能异常) function curLevelThreshold(d) { const L = d.playerLevel || 0; if (L <= 0) return 0; let xp = 0; let bracketStart = 1; let perLevel = 100; while (bracketStart < L) { const bracketEnd = Math.min(bracketStart + 10, L); xp += (bracketEnd - bracketStart) * perLevel; bracketStart += 10; perLevel += 100; } return xp; } const newProgress = Math.max(0, (newData.playerXp || 0) - curLevelThreshold(newData)); const cachedProgress = Math.max(0, (cachedData.playerXp || 0) - curLevelThreshold(cachedData)); // 如果新进度明显低于缓存进度, 认为 API 数据异常, 不更新缓存 if (newProgress < cachedProgress - 100) return false; return true; } async function fetchUserBadges(steamId, opts = {}) { const apiKey = storage.getApiKey(); if (!apiKey || !steamId) return null; const { force = false } = opts; const cacheKey = 'userBadges_' + steamId; const ttlCache = cacheGet(cacheKey); const persistentCache = readPersistentUserBadgesCache(steamId); const cached = force ? null : (ttlCache || persistentCache); if (cached && !force) { return enrichBadgesWithGameNames(cached); } try { const url = `https://api.steampowered.com/IPlayerService/GetBadges/v1/?key=${apiKey}&steamid=${steamId}`; const data = await fetchJson(url, { timeout: 12000 }); const badges = data?.response?.badges || []; const playerLevel = data?.response?.player_level || 0; const playerXp = data?.response?.player_xp || 0; // v2.3.7: 获取 API 提供的等级 XP 阈值 (权威值, 避免公式计算误差) const playerXpNeededCurrentLevel = data?.response?.player_xp_needed_current_level || 0; const playerXpNeededToLevelUp = data?.response?.player_xp_needed_to_level_up || 0; const result = { badges, playerLevel, playerXp, playerXpNeededCurrentLevel, playerXpNeededToLevelUp, _ts: Date.now() }; // v2.3.7: 数据合理性校验, 如果新数据比缓存差则保留旧缓存 const keepOld = cached && !isBadgeDataBetterThanCache(result, cached); if (keepOld) { console.log('[SGIS] GetBadges API 数据异常(进度低于缓存), 保留旧缓存'); return enrichBadgesWithGameNames(cached); } cacheSet(cacheKey, result, 6 * 3600 * 1000); writePersistentUserBadgesCache(steamId, result); return enrichBadgesWithGameNames(result); } catch { // 网络失败时回退到持久缓存 if (persistentCache) return enrichBadgesWithGameNames(persistentCache); return null; } } // ---- 用本地游戏库补全 badge.gameName (v2.3.1.1 修复) ---- // GetBadges API 不返回 badge 名称, 用 state.ownedGames 反查 // v2.3.6: 增加用 SteamCardExchange API 数据补全名称 (覆盖未拥有但有徽章的游戏) function enrichBadgesWithGameNames(badgeData) { if (!badgeData || !badgeData.badges) return badgeData; const owned = (state.ownedGames && state.ownedGames.length) ? state.ownedGames : storage.getCachedGames(); const ownedMap = {}; owned.forEach(g => { if (g && g.appid) ownedMap[String(g.appid)] = g.name; }); badgeData.badges.forEach(b => { if (b.appid > 0) { // v2.3.6: 优先用本地游戏库名称, 回退到卡牌数据库API名称 b._gameName = ownedMap[String(b.appid)] || getCardDbGameName(b.appid) || ''; b._hasGameName = !!b._gameName; // v2.3.6: 补充最大等级信息 b._maxLevel = getCardDbMaxLevel(b.appid); } // 节日徽章 / 社区徽章: 用 communityitemid / type / completion_time 推断 if (b.communityitemid) { b._isCommunityBadge = true; } }); return badgeData; } // ---- 节日 / 活动徽章名称映射 (v2.3.5 扩展) ---- // Steam 节日活动 appid 常见列表 (从社区维护) // v2.3.5: 增加 Steam Awards(Steam 大奖)投票活动 appid + 历年活动 const STEAM_EVENT_APPID_MAP = { // 2026 4761370: '2026 年夏日特卖', // 2025 4113600: '2025 年冬日特卖', 4098760: '2025 年夏日特卖', 3902760: '2025 年春季特卖', // 2024 3567630: '2024 年冬日特卖', 3443010: '2024 年夏日特卖', 3220140: '2024 年春季特卖', // 2023 3091960: '2023 年冬日特卖', 2881690: '2023 年夏日特卖', 2700500: '2023 年春季特卖', // 2022 2682730: '2022 年冬日特卖', 2489650: '2022 年夏日特卖', 2320430: '2022 年秋季特卖', // 2021 2285690: '2021 年冬日特卖', 2111170: '2021 年夏日特卖', 1977630: '2021 年秋季特卖', // 2020 1919510: '2020 年冬日特卖', 1708140: '2020 年夏日特卖', 1624910: '2020 年秋季特卖', // 2019 1531430: '2019 年冬日特卖', 1411040: '2019 年夏日特卖', 1283310: '2019 年秋季特卖', // 2018 1254900: '2018 年冬日特卖', 1111370: '2018 年夏日特卖', 977950: '2018 年秋季特卖', // 2017 991980: '2017 年冬日特卖', 866930: '2017 年夏日特卖', 748210: '2017 年冬日特卖', // 2016 630870: '2016 年夏日特卖', 516940: '2016 年冬日特卖', // 2015 408590: '2015 年夏日特卖', 302200: '2015 年冬日特卖', // 2014 224260: '2014 年夏日特卖', 161330: '2014 年冬日特卖', // 2013 104700: '2013 年夏日特卖', 57430: '2013 年冬日特卖', // 2012 207250: '2012 年假日特卖', // ===== Steam Awards (Steam 大奖) 投票活动 ===== // Steam Awards 每年秋季特卖期间举办, 有专门的投票 appid 4770200: '2025 Steam 大奖投票', 4149180: '2024 Steam 大奖投票', 3934100: '2023 Steam 大奖投票', 3711000: '2022 Steam 大奖投票', 3510200: '2021 Steam 大奖投票', 3326500: '2020 Steam 大奖投票', 3125400: '2019 Steam 大奖投票', 2944230: '2018 Steam 大奖投票', // ===== 其他特殊活动 ===== 1510: 'Steam 大奖 (2016)', 660: 'Steam 大奖 (2015)', 531: 'Steam 大奖 (2014)', // ===== Steam 游戏庆祝/周年活动 ===== 2519800: 'Steam 20 周年纪念', 1978740: 'Steam 18 周年纪念', }; // v2.3.7: 活动徽章图标 CDN 基础 URL (借鉴 steam-badges-card-view 脚本) // Steam 节日活动徽章图标存储在 steamcommunity/public/images/items/{appid}/ 路径下 const ACTIVITY_ICON_CDN = 'https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/items/'; // v2.3.7: 构建活动徽章图标 URL (icon_64x64.png) function getActivityIconUrl(appid) { if (!appid) return ''; return ACTIVITY_ICON_CDN + appid + '/icon_64x64.png'; } // v2.3.5: 推断 badge 是否属于 Steam 节日活动 // 1) 优先查已知 appid 表 // 2) 辅助检测: appid > 3000000 且不在用户游戏库中的, 很可能是节日活动 function detectEventBadge(b) { if (!b.appid) return null; const eventName = STEAM_EVENT_APPID_MAP[String(b.appid)]; if (eventName) return { name: eventName, appid: b.appid }; // v2.3.5: 辅助检测 - appid > 3000000 且没有对应游戏名(不在游戏库中) // Steam 节日活动 appid 通常很大(300万+), 且用户不会"拥有"这些"游戏" if (b.appid > 3000000 && !b._hasGameName) { return { name: 'Steam 活动 #' + b.appid, appid: b.appid }; } return null; } // ==================== 概览标签 (用户 Hero) ==================== // v2.3.7: 读取持久化 profile 缓存, 并检查游戏库数量变化标记 function shouldInvalidateProfileCache(steamId) { try { const cached = readPersistentProfileCache(steamId); if (!cached) return true; // v2.9.2: 使用排除家庭组共享和 DLC 后的游戏数量,确保统计一致性 const currentGameCount = getStatFilteredGames().length; // 如果游戏库数量变化, 视为缓存失效 (游戏数量可能影响统计展示) if ((cached.gameCount || 0) !== currentGameCount) return true; return false; } catch { return true; } } function renderProfile(opts = {}) { const { force = false } = opts; const steamId = getActiveSteamId(); // v2.9.3: 异步触发 DLC 数据库加载(首次打开侧栏时),加载完成后刷新统计 // v2.9.6: 同时触发全库存应用类型获取,补充 Barter.vg 未覆盖的 DLC if (SGLV_API.loadDlcDatabase) { SGLV_API.loadDlcDatabase().then(() => { if (SGIS.profile && !SGIS.profileLoading) renderProfileContent(); // v2.9.6: DLC 数据库加载后,启动全库存 type 获取,完成后再次刷新 DLC 数量 if (SGLV_API.enrichOwnedAppTypes) { SGLV_API.enrichOwnedAppTypes().then(() => { if (SGIS.profile && !SGIS.profileLoading) renderProfileContent(); }).catch(() => {}); } }).catch(() => {}); } // v2.3.7: 非强制刷新时, 先尝试使用内存与持久缓存 if (!force && SGIS.profile) { renderProfileContent(); return; } if (SGIS.profileLoading) return; SGIS.profileLoading = true; renderLoading('正在获取用户档案…'); if (!steamId) { SGIS.profileLoading = false; setBody(`
${SGIS_ICONS.user}
未检测到 SteamID
请在设置中配置 SteamID64,或访问你的 Steam 个人主页后重试。
`); return; } // v2.3.7: 检查是否需要强制失效缓存 const cacheInvalid = force || shouldInvalidateProfileCache(steamId); const cachedProfile = !cacheInvalid ? readPersistentProfileCache(steamId) : null; if (cachedProfile && cachedProfile.summary) { SGIS.profile = cachedProfile; SGIS.profileLoading = false; renderProfileContent(); return; } // 同时获取 userBadges 用于 XP 计算 (force 时强制刷新) Promise.all([ fetchPlayerSummaries(steamId), fetchSteamLevel(steamId), fetchFriendCount(steamId), fetchUserBadges(steamId, { force }).catch(() => null), fetchRecentlyPlayedGames(steamId).catch(() => []), fetchWishlistCount(steamId).catch(() => null), // v2.3.27: 愿望单计数(KPI 卡片) ]).then(([summary, level, friendCount, userBadges, recentGames, wishlistCount]) => { // v2.9.2: 使用排除家庭组共享和 DLC 后的游戏数量 const gameCount = getStatFilteredGames().length; const profileData = { summary, level, friendCount, userBadges, steamId, gameCount, recentGames, wishlistCount }; SGIS.profile = profileData; SGIS.profileLoading = false; writePersistentProfileCache(steamId, profileData); renderProfileContent(); }).catch(e => { SGIS.profileLoading = false; renderError('用户档案获取失败: ' + e.message); }); } function renderProfileContent() { const p = SGIS.profile; if (!p || !p.summary) { setBody(`
${SGIS_ICONS.user}
无法获取用户档案
请确保 API Key 和 SteamID 配置正确
`); return; } const s = p.summary; const avatarUrl = s.avatarfull || s.avatarmedium || s.avatar || ''; const personaName = s.personaname || '未知玩家'; const level = p.level || '?'; const realName = s.realname || ''; const country = s.loccountrycode || ''; const profileUrl = s.profileurl || `https://steamcommunity.com/profiles/${p.steamId}/`; const steamIdShort = p.steamId ? String(p.steamId) : ''; // 游戏库统计 (v2.9.2: 排除家庭组共享游戏和 DLC,确保统计准确) // v2.9.5: 确保 DLC 数据库已从缓存同步加载,避免 isDlc() 不可用导致 DLC 被计入总游戏数 if (SGLV_API.loadDlcDatabaseFromCacheSync) SGLV_API.loadDlcDatabaseFromCacheSync(); const allGames = getStatFilteredGames(); const gameCount = allGames.length; // v2.9.5: DLC 数量单独统计并缓存(不含在总游戏数内) const dlcCount = getOwnedDlcCount(); // v2.9.5: DLC 数据库未就绪时显示占位符,避免 0 → 实际值 闪烁 const dlcReady = !!(SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()); const dlcDisplay = dlcReady ? dlcCount.toLocaleString() : '—'; const totalPlaytime = allGames.reduce((sum, g) => sum + (g.playtime || 0), 0); const totalHours = Math.floor(totalPlaytime / 60); const friendCount = p.friendCount != null ? p.friendCount : '?'; const badgeCount = (p.userBadges && p.userBadges.badges) ? p.userBadges.badges.length : 0; const totalXp = (p.userBadges && p.userBadges.playerXp) ? p.userBadges.playerXp : 0; // v2.3.4: playerLevel 优先用 GetBadges API 返回的精确值,降级用 fetchSteamLevel const playerLevel = (p.userBadges && p.userBadges.playerLevel) ? p.userBadges.playerLevel : (typeof level === 'number' ? level : 0); // v2.3.27: 好友数量进度环(参考 steam-friend-manager:上限 = 300 + 等级 * 5) const friendLimit = 300 + playerLevel * 5; const fcNum = (typeof friendCount === 'number' && isFinite(friendCount)) ? friendCount : 0; const friendRatio = Math.min(1, fcNum / Math.max(1, friendLimit)); const ringCirc = (Math.PI * 48).toFixed(1); const ringDash = (friendRatio * Math.PI * 48).toFixed(1); // v2.3.27: 愿望单计数(KPI 卡片,好友位替换) const wishlistCount = p.wishlistCount; const wishlistDisplay = (wishlistCount != null) ? Number(wishlistCount).toLocaleString() : '?'; // v2.3.7: 修正 Steam 官方等级 XP 公式 // Steam 官方升级所需 XP: 每 10 级为一个区间, 每个区间内每级固定 XP, 每升一个区间增加 100 XP // 1-10 级: 100 XP/级 // 11-20 级: 200 XP/级 // 21-30 级: 300 XP/级 // 31-40 级: 400 XP/级 // ... // 91-100 级: 1000 XP/级 // 101-110 级: 1100 XP/级 // 111-120 级: 1200 XP/级 // 121-130 级: 1300 XP/级 ... // xpRequiredToReachLevel(L) = 从 0 升级到 L 级所需累计 XP function xpRequiredToReachLevel(L) { if (L <= 1) return 0; let xp = 0; let bracketStart = 1; let perLevel = 100; while (bracketStart < L) { const bracketEnd = Math.min(bracketStart + 10, L); const levelsInBracket = bracketEnd - bracketStart; xp += levelsInBracket * perLevel; bracketStart += 10; perLevel += 100; } return xp; } // v2.3.7: 优先用 GetBadges API 返回的权威 XP 阈值, 但做合法性校验, 异常时回退公式 // API 字段语义(Steam 官方): // player_xp_needed_current_level: 升到当前等级所需累计 XP // player_xp_needed_to_level_up: 从当前等级升到下一级所需 XP (相对值) const apiCurLevelXp = (p.userBadges && p.userBadges.playerXpNeededCurrentLevel) ? p.userBadges.playerXpNeededCurrentLevel : 0; const apiLevelUpXp = (p.userBadges && p.userBadges.playerXpNeededToLevelUp) ? p.userBadges.playerXpNeededToLevelUp : 0; // 验证 API 值是否合法: 当前等级累计 XP 不应超过玩家总 XP 过多, 升级差值应大于 0 let useApiXp = apiCurLevelXp > 0 && apiLevelUpXp > 0; if (useApiXp && totalXp > 0 && totalXp < apiCurLevelXp - 1000) useApiXp = false; // 累计 XP 偏差过大 const curLevelXp = useApiXp ? apiCurLevelXp : xpRequiredToReachLevel(playerLevel); const xpDelta = useApiXp ? apiLevelUpXp : (xpRequiredToReachLevel(playerLevel + 1) - curLevelXp); // v2.3.4: Fallback - 如果 GetBadges API 失败, totalXp = 0 但 playerLevel > 0, 用升级到当前等级所需 XP 估算 const effectiveXp = totalXp > 0 ? totalXp : (playerLevel > 0 ? curLevelXp : 0); const currentIntoLevel = Math.max(0, effectiveXp - curLevelXp); // v2.3.7: 如果当前已积累经验超过升级所需, 可能是 API/公式异常, 做截断并回退到公式确保进度合理 let safeCurrentIntoLevel = currentIntoLevel; let safeXpDelta = Math.max(1, xpDelta); if (safeCurrentIntoLevel > safeXpDelta) { // 回退到公式计算, 重新得出合理的 currentIntoLevel safeCurrentIntoLevel = Math.max(0, effectiveXp - xpRequiredToReachLevel(playerLevel)); safeXpDelta = Math.max(1, xpRequiredToReachLevel(playerLevel + 1) - xpRequiredToReachLevel(playerLevel)); if (safeCurrentIntoLevel > safeXpDelta) safeCurrentIntoLevel = safeXpDelta; // 最多 100% } const xpPercent = Math.max(0, Math.min(100, (safeCurrentIntoLevel / safeXpDelta) * 100)); const xpToNext = Math.max(0, safeXpDelta - safeCurrentIntoLevel); // v2.3.7: Steam 无等级上限; 仅当 playerLevel 极低或公式无法提供下一级阈值时才显示 MAX const isMaxLevel = playerLevel <= 0; // 用于底部"累计经验"显示 const totalXpDisplay = effectiveXp; // 战绩统计(简化版: 用游戏库数据估算) const playedGames = allGames.filter(g => (g.playtime || 0) > 0).length; const unplayedGames = Math.max(0, gameCount - playedGames); // 平均游戏时长 const avgHours = playedGames > 0 ? Math.round(totalHours / playedGames) : 0; // 完成度评分(简单计算: 时长>10h 的游戏占比) const longGames = allGames.filter(g => (g.playtime || 0) >= 600).length; const completeRate = playedGames > 0 ? Math.round((longGames / playedGames) * 100) : 0; // v2.3.13: 最近游玩 const recentGames = p.recentGames || []; const recentGamesHtml = recentGames.slice(0, 8).map(game => { const recentH = formatPlaytimeShort(game.playtime_2weeks); const totalH = formatPlaytimeShort(game.playtime_forever); const iconUrl = game.img_icon_url ? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/${game.img_icon_url}.jpg` : `https://cdn.cloudflare.steamstatic.com/steam/apps/${game.appid}/capsule_sm_120.jpg`; return `
${game.name}
近2周: ${recentH} · 总计: ${totalH}
`; }).join(''); setBody(`
${personaName}
Lv.${playerLevel}
${steamIdShort || '?'}
${steamIdShort ? `` : ''}
${friendCount}/ ${friendLimit}
好友数
${SGIS_ICONS.barChart}
${gameCount.toLocaleString()}
Games
${SGIS_ICONS.package}
${dlcDisplay}
DLC
${SGIS_ICONS.medal}
${badgeCount.toLocaleString()}
Badges
${SGIS_ICONS.heart}
${wishlistDisplay}
Wishlist
${SGIS_ICONS.clock}
${totalHours >= 1000 ? (totalHours / 1000).toFixed(1) + 'k' : totalHours}h
Playtime
经验值 · 等级进度
Lv.${playerLevel} ${isMaxLevel ? `MAX` : `Lv.${playerLevel + 1}`}
${isMaxLevel ? `
已达成 · 满级
100%
` : `
${currentIntoLevel.toLocaleString()} / ${xpDelta.toLocaleString()} XP · 还差 ${xpToNext.toLocaleString()} XP
${xpPercent.toFixed(0)}%
`}
${SGIS_ICONS.trophy}
战绩总览
Battle Stats
已游玩
${playedGames}
完成度
${completeRate}%
平均时长
${avgHours}h
${SGIS_ICONS.sparkle}
游戏人生
Gamer Profile
总时长
${totalHours >= 1000 ? (totalHours / 1000).toFixed(1) + 'k' : totalHours}h
成就数
${badgeCount}
${SGIS_ICONS.gift} ${unplayedGames > 0 ? `还有 ${unplayedGames} 款游戏未游玩` : '所有游戏都已游玩'}
${SGIS_ICONS.game} 最近游玩
${recentGames.length === 0 ? '
最近 2 周未游玩任何游戏
' : recentGamesHtml}
${SGIS_ICONS.link} 快捷链接
数据来自 Steam Web API · 缓存30分钟
`); // v2.3.33:异步加载最近游玩游戏中文名 document.querySelectorAll('#sgis-body [data-sglv-appid]').forEach(el => { loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); const featureStats = document.getElementById('sgis-feature-stats'); if (featureStats) { featureStats.addEventListener('click', () => { // 跳转到动态标签(展示详细统计) if (typeof SGIS !== 'undefined') { const panel = document.getElementById('sgis-panel'); if (panel) { const tab = panel.querySelector('.sgis-tab[data-tab="userAchievements"]'); if (tab) tab.click(); } } }); } const featureAch = document.getElementById('sgis-feature-achievements'); if (featureAch) { featureAch.addEventListener('click', () => { if (typeof SGIS !== 'undefined') { const panel = document.getElementById('sgis-panel'); if (panel) { const tab = panel.querySelector('.sgis-tab[data-tab="userAchievements"]'); if (tab) tab.click(); } } }); } // ==================== v2.3.27: KPI 卡片 + 进度环点击交互 ==================== // 游戏数量 → 弹出中央游戏库浮窗(先收起侧边栏避免遮挡) const statGames = document.getElementById('sgis-stat-games'); if (statGames) { statGames.addEventListener('click', () => { closePanel(); document.dispatchEvent(new CustomEvent('sglv:open-library')); }); } // v2.9.5: DLC 数量 → 弹出中央游戏库浮窗并切换到仅 DLC 筛选 const statDlc = document.getElementById('sgis-stat-dlc'); if (statDlc) { statDlc.addEventListener('click', () => { closePanel(); document.dispatchEvent(new CustomEvent('sglv:open-library')); // 延迟触发仅 DLC 筛选,等待浮窗渲染完成 setTimeout(() => { const dashCard = document.querySelector('.sglv-stat-dash-card[data-filter="dlconly"]'); if (dashCard) dashCard.click(); }, 300); }); } // 徽章数量 → 跳转 Steam 勋章页面 const statBadges = document.getElementById('sgis-stat-badges'); if (statBadges) { statBadges.addEventListener('click', () => { window.open(`${profileUrl}badges/`, '_blank'); }); } // 愿望单数量 → 跳转 Steam 愿望单页面 const statWishlist = document.getElementById('sgis-stat-wishlist'); if (statWishlist) { statWishlist.addEventListener('click', () => { const sid = p.steamId || getActiveSteamId(); window.open(sid ? `https://store.steampowered.com/wishlist/profiles/${sid}/` : 'https://store.steampowered.com/wishlist/', '_blank'); }); } // 好友进度环 → 切换到社交标签页 const friendGauge = document.getElementById('sgis-friend-gauge'); if (friendGauge) { friendGauge.addEventListener('click', () => { const panel = document.getElementById('sgis-panel'); const tab = panel ? panel.querySelector('.sgis-tab[data-tab="social"]') : null; if (tab) tab.click(); }); } // SteamID 复制按钮 const copySidBtn = document.getElementById('sgis-copy-steamid'); if (copySidBtn) { copySidBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); copyTextToClipboard(steamIdShort); sglvToast.success(isZh ? 'SteamID 已复制' : 'SteamID copied'); }); } // v2.3.27: 旧缓存档案无愿望单计数时,后台补取并局部更新卡片 if (p.wishlistCount == null) { fetchWishlistCount(p.steamId || getActiveSteamId()).then(n => { if (n == null) return; p.wishlistCount = n; const el = document.getElementById('sgis-stat-wishlist-val'); if (el) el.textContent = Number(n).toLocaleString(); }).catch(() => { /* ignore */ }); } } // ==================== 动态标签 (Steam 官方新闻 + 个人/家庭组入库) ==================== function renderActivity() { const sub = SGIS.activitySubTab || 'personal'; // v2.3.24: 默认个人入库动态 if (sub === 'official') { if (SGIS.activity) { renderActivityContent(); return; } if (SGIS.activityLoading) return; SGIS.activityLoading = true; renderLoading('正在获取 Steam 官方动态…'); fetchSteamOfficialNews().then(news => { SGIS.activity = news; SGIS.activityLoading = false; renderActivityContent(); }).catch(e => { SGIS.activityLoading = false; renderError('动态获取失败: ' + e.message); }); } else if (sub === 'personal') { if (SGIS.personalTimeline) { renderActivityContent(); return; } if (SGIS.personalTimelineLoading) return; SGIS.personalTimelineLoading = true; renderLoading('正在加载个人入库历史…'); const steamId = getActiveSteamId(); buildPersonalTimeline(steamId).then(data => { SGIS.personalTimeline = data; SGIS.personalTimelineLoading = false; renderActivityContent(); }).catch(e => { SGIS.personalTimelineLoading = false; console.warn('[SGIS] 个人入库历史加载失败:', e); sglvToast.error(isZh ? '个人入库历史加载失败' : 'Personal timeline load failed'); renderError('个人入库历史加载失败: ' + (e.message || '网络错误')); }); } else if (sub === 'family') { if (SGIS.familyTimeline) { renderActivityContent(); return; } // v2.8.1: 已发起加载但用户切走又切回(如先看 personal 再回 family), // DOM 可能已被其他 tab 覆盖;此时仍应重新渲染进度条,避免用户看到残留旧内容 if (SGIS.familyTimelineLoading) { if (!document.getElementById('sgis-load-block')) { renderProgressLoading({ stage: 3, totalStages: 4, text: '正在加载家庭组入库历史…', skeletonCount: 8 }); } return; } SGIS.familyTimelineLoading = true; // v2.8.1: 进度条 + 游戏骨架屏(参考 steam-friend-manager 加载体验),让用户在等待中看到结构 const prog = renderProgressLoading({ stage: 1, totalStages: 4, text: '正在准备家庭组数据…', skeletonCount: 8 }); const steamId = getActiveSteamId(); buildFamilyTimeline(steamId, (stage, percent, text) => { // 防止切走/刷新后旧回调更新到不存在的 DOM if (!SGIS.familyTimelineLoading) return; prog.update({ stage, percent, text }); }).then(data => { SGIS.familyTimeline = data; SGIS.familyTimelineLoading = false; renderActivityContent(); }).catch(e => { SGIS.familyTimelineLoading = false; console.warn('[SGIS] 家庭组入库历史加载失败:', e); sglvToast.error(isZh ? '家庭组入库历史加载失败' : 'Family timeline load failed'); renderError('家庭组入库历史加载失败: ' + (e.message || '网络错误')); }); } } function renderActivityLibraryHistory(data) { if (!data || !data.items || data.items.length === 0) return '
暂无入库记录
'; const items = data.items; let html = ''; let curDate = ''; items.forEach(it => { const dateKey = it.dateStr || ''; if (dateKey && dateKey !== curDate) { curDate = dateKey; html += `
${dateKey}
`; } const iconUrl = it.icon ? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${it.appid}/${it.icon}.jpg` : `https://cdn.cloudflare.steamstatic.com/steam/apps/${it.appid}/capsule_sm_120.jpg`; const timeText = it.ts ? new Date(it.ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : ''; html += `
${it.name} ${timeText ? `
${timeText}
` : ''}
`; }); return html; } function renderActivityContent() { const sub = SGIS.activitySubTab || 'personal'; // v2.3.24: 默认个人入库动态,官方动态移至最后 const tabs = [ { key: 'personal', icon: SGIS_ICONS.package, label: '个人入库' }, { key: 'family', icon: SGIS_ICONS.users, label: '家庭组入库' }, { key: 'official', icon: SGIS_ICONS.news, label: '官方动态' }, ]; const subTabHtml = `
${tabs.map(t => ``).join('')}
`; let contentHtml = ''; let footerText = ''; if (sub === 'official') { // v2.3.25: 结构化官方动态——促销活动/每日优惠/当前特惠/官方新闻 const data = SGIS.activity || { specials: [], dailyDeals: [], spotlights: [], news: [], totalCount: 0 }; const totalCount = Number(data.totalCount) || 0; if (totalCount === 0) { contentHtml = `
📢
暂无官方动态
`; } else { // 价格格式化(分 → 元/美元,安全数值转换) const formatPrice = (cents, currency) => { const v = Number(cents); if (isNaN(v) || v === 0) return '免费'; const symbol = (currency === 'USD') ? '$' : ''; return symbol + (v / 100).toFixed(2); }; // 折扣到期倒计时 const formatExpiration = (expTs) => { const exp = Number(expTs); if (!exp || isNaN(exp)) return ''; const now = Math.floor(Date.now() / 1000); const diff = exp - now; if (diff <= 0) return '已结束'; const days = Math.floor(diff / 86400); const hours = Math.floor((diff % 86400) / 3600); if (days > 0) return `剩余${days}天${hours}小时`; if (hours > 0) return `剩余${hours}小时`; return `剩余${Math.floor(diff / 60)}分钟`; }; // 新闻日期格式化 const formatDate = (ts) => { if (!ts) return ''; const d = new Date(ts * 1000); return `${d.getMonth() + 1}月${d.getDate()}日`; }; let html = ''; // 分区1: 促销活动 (Spotlights — 含活动页URL) if (Array.isArray(data.spotlights) && data.spotlights.length > 0) { // v2.9.70: 处理 body 中的 %1$s 占位符——Steam API 返回的本地化模板未格式化 const processSpotlightBody = (sp) => { if (!sp.body) return ''; let body = sp.body; // 检测是否包含 %1$s 或类似占位符 if (!body.match(/%\d\$s/)) return body; // 尝试用实际截止时间替换 let timeStr = ''; if (sp.discountExpiration && sp.discountExpiration > 0) { timeStr = formatExpiration(sp.discountExpiration); } if (!timeStr) { // 预估截止时间:周末特惠2天(截止明天),其他促销2-3天 const name = (sp.name || '').toLowerCase(); if (name.includes('周末') || name.includes('weekend')) { timeStr = isZh ? '预计明天截止' : 'Est. ends tomorrow'; } else { timeStr = isZh ? '预计2-3天后截止' : 'Est. ends in 2-3 days'; } } // 替换所有 %N$s 占位符 body = body.replace(/%\d\$s/g, timeStr); return body; }; html += `
`; html += `
🎯 促销活动${data.spotlights.length} 个活动
`; html += data.spotlights.map(sp => { const processedBody = processSpotlightBody(sp); return ` ${sp.headerImage ? `` : ''}
${sp.name}
${processedBody ? `
${processedBody}
` : ''}
`; }).join(''); html += `
`; } // 分区2: 每日优惠 (Daily Deals) if (Array.isArray(data.dailyDeals) && data.dailyDeals.length > 0) { html += `
`; html += `
⚡ 每日优惠${data.dailyDeals.length} 款
`; html += `
`; } // 分区3: 当前特惠 (Current Specials — 含折扣到期倒计时) if (Array.isArray(data.specials) && data.specials.length > 0) { html += ``; } // 分区4: 官方新闻 (ISteamNews) if (Array.isArray(data.news) && data.news.length > 0) { html += `
`; html += `
📰 官方新闻${data.news.length} 条
`; html += data.news.map(n => { const desc = n.contents ? n.contents.slice(0, 150) + (n.contents.length > 150 ? '...' : '') : ''; return ` 新闻
${formatDate(n.date)}
${n.title}
${desc ? `
${desc}
` : ''}
`; }).join(''); html += `
`; } contentHtml = html; } footerText = '数据来自 Steam Storefront API + Steam News API · 缓存30分钟'; } else if (sub === 'personal') { if (SGIS.personalTimelineLoading) { contentHtml = `
正在加载…
`; } else if (!SGIS.personalTimeline) { contentHtml = `
暂无数据
`; } else { // v2.3.24: 分页渲染——每页 100 条,默认仅第一页,滚动到底自动追加下一页,避免大数据量卡死 const allItems = SGIS.personalTimeline.items || []; const PAGE_SIZE = 100; const page = Math.max(1, SGIS.personalTimelinePage || 1); const visibleItems = allItems.slice(0, page * PAGE_SIZE); const hasMore = visibleItems.length < allItems.length; contentHtml = renderActivityLibraryHistory({ items: visibleItems }); if (hasMore) { contentHtml += `
向下滚动加载更多(已显示 ${visibleItems.length}/${allItems.length})
`; } else if (allItems.length > 0) { contentHtml += `
已加载全部 ${allItems.length} 条
`; } } const pTotal = (SGIS.personalTimeline && SGIS.personalTimeline.items) ? SGIS.personalTimeline.items.length : 0; const pShown = Math.min(pTotal, Math.max(1, SGIS.personalTimelinePage || 1) * 100); footerText = `基于家庭组共享库入库时间(rt_time_acquired)· 按入库时间排序${pTotal > 0 ? ` · 已显示 ${pShown}/${pTotal}` : ''}`; } else if (sub === 'family') { if (SGIS.familyTimelineLoading) { contentHtml = `
正在加载…
`; } else if (!SGIS.familyTimeline) { contentHtml = `
暂无数据
`; } else { // v2.3.24: 分页渲染——每页 100 条,默认仅第一页,滚动到底自动追加下一页,避免大数据量卡死 const allItems = SGIS.familyTimeline.items || []; const PAGE_SIZE = 100; const page = Math.max(1, SGIS.familyTimelinePage || 1); const visibleItems = allItems.slice(0, page * PAGE_SIZE); const hasMore = visibleItems.length < allItems.length; contentHtml = renderActivityLibraryHistory({ items: visibleItems }); if (hasMore) { contentHtml += `
向下滚动加载更多(已显示 ${visibleItems.length}/${allItems.length})
`; } else if (allItems.length > 0) { contentHtml += `
已加载全部 ${allItems.length} 条
`; } } const totalItems = (SGIS.familyTimeline && SGIS.familyTimeline.items) ? SGIS.familyTimeline.items.length : 0; const shownItems = Math.min(totalItems, Math.max(1, SGIS.familyTimelinePage || 1) * 100); footerText = `基于家庭组共享库,排除个人已拥有的游戏 · 按入库时间排序${totalItems > 0 ? ` · 已显示 ${shownItems}/${totalItems}` : ''}`; } setBody(`
${SGIS_ICONS.news} Steam 动态
${subTabHtml}
${contentHtml}
${footerText}
`); // v2.3.33:异步加载入库动态游戏中文名 document.querySelectorAll('#sgis-body [data-sglv-appid]').forEach(el => { loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); // 绑定子标签切换 const body = document.getElementById('sgis-body'); if (body) { body.querySelectorAll('.sgis-sub-tab').forEach(btn => { btn.addEventListener('click', () => { const newSub = btn.dataset.sub; if (SGIS.activitySubTab === newSub) return; SGIS.activitySubTab = newSub; // v2.3.24: 切换子标签时重置入库分页 SGIS.familyTimelinePage = 1; SGIS.personalTimelinePage = 1; renderActivity(); }); }); } // v2.3.24: 入库动态分页——哨兵进入视口自动加载下一页(每页 100 条),个人/家庭组通用 const bindTimelineLoadMore = (elId, getPage, setPage, getTotal) => { const el = document.getElementById(elId); if (!el) return; const io = new IntersectionObserver(entries => { if (!entries[0].isIntersecting) return; io.disconnect(); const nextPage = getPage() + 1; if (nextPage * 100 - 100 < getTotal()) { setPage(nextPage); renderActivityContent(); } }, { root: document.getElementById('sgis-body'), rootMargin: '300px' }); io.observe(el); }; if (sub === 'family') { bindTimelineLoadMore('sgis-family-load-more', () => SGIS.familyTimelinePage || 1, p => { SGIS.familyTimelinePage = p; }, () => (SGIS.familyTimeline && SGIS.familyTimeline.items ? SGIS.familyTimeline.items.length : 0)); } if (sub === 'personal') { bindTimelineLoadMore('sgis-personal-load-more', () => SGIS.personalTimelinePage || 1, p => { SGIS.personalTimelinePage = p; }, () => (SGIS.personalTimeline && SGIS.personalTimeline.items ? SGIS.personalTimeline.items.length : 0)); } } // ==================== 勋章标签 (Steam 官方勋章) ==================== function renderUserBadges() { if (SGIS.userBadges) { renderUserBadgesContent(); return; } if (SGIS.userBadgesLoading) return; SGIS.userBadgesLoading = true; renderLoading('正在获取勋章数据…'); const steamId = getActiveSteamId(); if (!steamId) { SGIS.userBadgesLoading = false; setBody(`
🎖️
未检测到 SteamID
`); return; } fetchUserBadges(steamId).then(data => { SGIS.userBadges = data; SGIS.userBadgesLoading = false; renderUserBadgesContent(); // v2.3.6: 异步加载卡牌数据库, 加载完成后重新 enrich + 渲染以显示 maxLevel if (!cardDbData && !cardDbLoading) { loadCardDatabase().then(() => { if (cardDbData && SGIS.userBadges) { SGIS.userBadges = enrichBadgesWithGameNames(SGIS.userBadges); if (SGIS.tab === 'userBadges') renderUserBadgesContent(); } }).catch(() => { /* 静默失败, 不影响基本功能 */ }); } }).catch(e => { SGIS.userBadgesLoading = false; renderError('勋章获取失败: ' + e.message); }); } // v2.3.1.1: 完整重写 - 加入价值分析仪表板 + 智能分析浮窗 + 徽章图标加载 function renderUserBadgesContent() { const data = SGIS.userBadges; if (!data || !data.badges) { setBody(`
${SGIS_ICONS.medal}
无法获取勋章数据
请确保 API Key 和 SteamID 配置正确
`); return; } const badges = data.badges || []; const playerLevel = data.playerLevel || 0; const playerXp = data.playerXp || 0; // ---- 分类 ---- const eventBadges = []; const gameBadges = []; const communityBadges = []; badges.forEach(b => { if (b.appid > 0) { const evt = detectEventBadge(b); if (evt) { b._eventName = evt.name; eventBadges.push(b); } else { gameBadges.push(b); } } else { communityBadges.push(b); } }); // 节日活动分组 const eventGroups = {}; eventBadges.forEach(b => { if (!eventGroups[b._eventName]) eventGroups[b._eventName] = []; eventGroups[b._eventName].push(b); }); // 游戏按游戏名分组 const gameGroups = {}; gameBadges.forEach(b => { const gname = b._gameName || `游戏 #${b.appid}`; if (!gameGroups[gname]) gameGroups[gname] = { name: gname, appid: b.appid, badges: [] }; gameGroups[gname].badges.push(b); }); // 排序 const eventNames = Object.keys(eventGroups).sort((a, b) => b.localeCompare(a, 'zh-CN')); const gameNames = Object.keys(gameGroups).sort((a, b) => gameGroups[b].badges.length - gameGroups[a].badges.length); // 统计 const totalBadges = badges.length; const completedCount = badges.filter(b => (b.completion_time || 0) > 0).length; const gameBadgeCount = gameBadges.length; const eventBadgeCount = eventBadges.length; const totalXpFromBadges = badges.reduce((s, b) => s + (b.xp || 0), 0); // 等级分布 const levelDist = getBadgeLevelDistribution(badges); const maxLvCount = Math.max(...Object.values(levelDist), 1); // ---- 价值分析 (异步) ---- // 优先用缓存的市场数据 const markets = SGIS.userBadgeMarkets || {}; const needFetchAppIds = gameBadges .filter(b => !markets[String(b.appid)]) .map(b => b.appid) .filter((v, i, a) => a.indexOf(v) === i) .slice(0, 15); const valueScores = computeBadgeValueScores(badges, markets); const topValueBadges = getTopValueBadges(valueScores, 6); // ---- 工具函数 ---- const formatTime = (ts) => { if (!ts) return ''; try { return new Date(ts * 1000).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit', day: '2-digit' }); } catch { return ''; } }; const rarityLabel = (s) => { if (!s) return null; if (s >= 5) return { label: '史诗', cls: 'r5' }; if (s >= 4) return { label: '极稀有', cls: 'r4' }; if (s >= 3) return { label: '稀有', cls: 'r3' }; if (s >= 2) return { label: '少见', cls: 'r2' }; return { label: '普通', cls: 'r1' }; }; const scoreColor = (s) => s >= 70 ? 'high' : s >= 40 ? 'mid' : 'low'; // ---- 渲染单条徽章行 (带智能分析 tooltip) ---- // v2.3.7: 修复季节徽章图标 - 活动徽章使用 Steam CDN items 路径, 不再用不存在的商店胶囊图 // v2.3.5: 优先用卡牌封面图 (从市场数据获取第一张卡牌 iconUrl), 回退到游戏胶囊图 const getBadgeIcon = (b) => { // 1) 优先用该游戏第一张卡牌的封面图 (集换式卡牌实际图片) const market = markets[String(b.appid)]; if (market && market.cards && market.cards.length > 0) { const cardWithIcon = market.cards.find(c => c.iconUrl); if (cardWithIcon) return cardWithIcon.iconUrl; } // v2.3.7: 节日活动徽章使用 Steam CDN items 路径 (icon_64x64.png) // 活动徽章的 appid 不是真实游戏, 商店胶囊图不存在, 用 steamcommunity/public/images/items/ 路径 if (b._eventName && b.appid > 0) { return getActivityIconUrl(b.appid); } // 3) 回退到 Steam 游戏胶囊图 (仅对真实游戏 appid) if (b.appid > 0) { return `https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/${b.appid}/capsule_184x69.jpg`; } return ''; }; const renderBadgeRow = (b) => { const gname = b._gameName || (b._eventName ? b._eventName + ' 徽章' : (b.communityitemid ? `社区徽章 #${b.badgeid}` : `游戏 #${b.appid}`)); const eventBadge = b._eventName; const rar = rarityLabel(b.scarcity); const icon = eventBadge ? SGIS_ICONS.gift : (b._gameName ? SGIS_ICONS.game : SGIS_ICONS.sparkle); const valueData = valueScores[String(b.appid)]; const hasValue = !!valueData; const scoreCls = hasValue ? scoreColor(valueData.score) : ''; // v2.3.5: 检查是否有卡牌封面图 const market = markets[String(b.appid)]; const hasCardIcon = !!(market && market.cards && market.cards.find(c => c.iconUrl)); const tooltip = hasValue ? `
${gname}
价值 ${valueData.score}
${valueData.marketData ? `
市场卡价 ${valueData.marketData.cardCount} 张 · 均 ${formatMarketPrice(valueData.marketData.avgPrice)}
中位/税后 ${formatMarketPrice(valueData.marketData.medianPrice)} / ${formatMarketPrice(valueData.marketData.netIncome)}
` : '
市场数据加载中...
'}
Lv.${b.level || 1}${b._maxLevel > 0 ? '/' + b._maxLevel : ''}${b.xp || 0} XP
XP
等级
卡价
完成
` : ''; return `
${b.appid > 0 ? `` : `
${icon}
`}
${gname}
Lv.${b.level || 1}${b._maxLevel > 0 ? '/' + b._maxLevel : ''} ${(b.xp || 0).toLocaleString()} XP ${rar ? `${rar.label}` : ''} ${b.completion_time ? `${formatTime(b.completion_time)}` : ''} ${hasValue ? `${valueData.score}分` : ''}
${b.appid > 0 ? `${SGIS_ICONS.chevronRight}` : ''} ${tooltip}
`; }; // ---- 折叠 group ---- const groupId = (k) => 'sgis-bg-' + String(k).replace(/[^\\w]/g, '_').slice(0, 60); const renderGroup = (id, icon, title, sub, badges, defaultExpanded) => { const expClass = defaultExpanded ? ' sgis-badge-group-expanded' : ''; const totalXp = badges.reduce((s, b) => s + (b.xp || 0), 0); const maxLevel = badges.reduce((m, b) => Math.max(m, b.level || 1), 1); // v2.3.6: 获取游戏最高可达等级 (同一游戏的徽章 _maxLevel 相同) const realMaxLevel = badges.reduce((m, b) => Math.max(m, b._maxLevel || 0), 0); const completedN = badges.filter(b => (b.completion_time || 0) > 0).length; const completedPct = badges.length ? Math.round(completedN / badges.length * 100) : 0; // v2.9.15: group 头信息重新设计——左侧图标 + 标题/副标题,右侧 stats(数量 + 等级),底部进度条 return `
${icon}
${title}
${sub}
${badges.length} Lv.${maxLevel}${realMaxLevel > 0 ? `/ ${realMaxLevel}` : ''}
${SGIS_ICONS.chevronRight}
${badges.map(renderBadgeRow).join('')}
${completedN}/${badges.length} 已合成 · ${totalXp.toLocaleString()} XP
`; }; // 折叠状态 const collapseStateKey = 'sgis_badge_group_collapse'; const getCollapseState = () => { try { return JSON.parse(GM_getValue(collapseStateKey, '{}')); } catch { return {}; } }; const setCollapseState = (state) => { try { GM_setValue(collapseStateKey, JSON.stringify(state)); } catch { /* ignore */ } }; const collapseState = getCollapseState(); // ---- 价值仪表板 (Top 6) ---- // v2.3.5: 价值卡片图标也优先用卡牌封面图, 回退游戏胶囊图 const renderValueBadgeCard = (vd) => { if (!vd) return ''; const market = vd.marketData; const avgPrice = market ? formatMarketPrice(market.avgPrice) : '—'; const medianPrice = market && market.medianPrice ? formatMarketPrice(market.medianPrice) : '—'; const netIncome = market && market.netIncome ? formatMarketPrice(market.netIncome) : '—'; const cardCount = market ? `${market.cardCount} 张` : '加载中'; // v2.3.5: 优先用市场数据中的卡牌封面图 let imgSrc = `https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/${vd.appId}/capsule_184x69.jpg`; if (market && market.cards && market.cards.length > 0) { const cardWithIcon = market.cards.find(c => c.iconUrl); if (cardWithIcon) imgSrc = cardWithIcon.iconUrl; } return `
${vd.gameName || '游戏 #' + vd.appId}
Lv.${vd.level}${vd.maxLevel > 0 ? '/' + vd.maxLevel : ''} · ${vd.xp} XP
${vd.score}
价值
${cardCount} · 均 ${avgPrice} 中位 ${medianPrice} · 税后 ${netIncome}
`; }; const valueCardsHtml = topValueBadges.length ? `
${topValueBadges.map(renderValueBadgeCard).join('')}
` : `
暂无可分析的游戏勋章 (需要先获取游戏库数据)
`; // ---- HTML 渲染 ---- // v2.9.15: 视觉层次重构——1 个顶部英雄区(总览) + Top 6 价值卡片 + 折叠 group 列表 // 重点突出:大数字 KPI + 价值 Top 6 // 次要:游戏/节日/社区徽章 默认折叠,只 Top 5(按勋章数)游戏展开 // 折叠策略:用户展开过(collapseState=true) → 保持展开;否则默认折叠 // 顶部英雄区:4 个 KPI + 等级分布迷你条 const totalValueScore = badges.reduce((s, b) => { const v = valueScores[String(b.appid)]; return s + (v ? v.score : 0); }, 0); const avgValueScore = totalBadges ? Math.round(totalValueScore / totalBadges) : 0; const heroHtml = `
${totalBadges}
勋章总数
${completedCount}
已合成
${avgValueScore}
均价值
${(totalXpFromBadges / 1000).toFixed(1)}k
总 XP
完成进度 ${completedCount}/${totalBadges} (${totalBadges ? Math.round(completedCount / totalBadges * 100) : 0}%)
等级分布
${[1, 2, 3, 4, 5].map(lv => { const count = levelDist[lv] || 0; const pct = Math.max(3, (count / maxLvCount) * 100); return `
${count}
Lv.${lv}
`; }).join('')}
`; // 价值 Top 6 卡片网格(重点) const valueSectionHtml = `
${SGIS_ICONS.star} 价值 Top ${topValueBadges.length} ${topValueBadges.length > 0 ? `综合价值评分` : ''}
${valueCardsHtml}
`; // 节日活动:默认全部折叠(用户主动展开才展开) const eventSectionHtml = `
${SGIS_ICONS.gift} 节日活动 ${eventBadgeCount} 枚 / ${eventNames.length} 个活动
${eventNames.length === 0 ? `
暂无节日活动徽章
` : eventNames.map(name => { const list = eventGroups[name]; const id = groupId('event_' + name); // v2.9.15: 默认折叠(用户展开过则保持) const expanded = collapseState[id] === true; return renderGroup(id, SGIS_ICONS.gift, name, 'Steam 限定徽章', list, expanded); }).join('')}
`; // 游戏勋章:Top 5(按勋章数)默认展开,其余折叠;支持"全部展开"按钮 const TOP_EXPAND = 5; const gameSectionHtml = `
${SGIS_ICONS.game} 游戏勋章 ${gameBadgeCount} 枚 / ${gameNames.length} 个游戏
${gameNames.length === 0 ? `
暂无游戏勋章 (需要先获取游戏库数据)
` : gameNames.slice(0, 30).map((name, idx) => { const g = gameGroups[name]; const id = groupId('game_' + g.appid); // v2.9.15: Top 5 默认展开,其余默认折叠(用户展开过的保持展开) const userSetExpanded = collapseState[id] === true; const userSetCollapsed = collapseState[id] === false; const expanded = userSetExpanded || (!userSetCollapsed && idx < TOP_EXPAND); return renderGroup(id, SGIS_ICONS.game, g.name, `AppID: ${g.appid}`, g.badges, expanded); }).join('')} ${gameNames.length > 30 ? `
还有 ${gameNames.length - 30} 个游戏的勋章未显示
` : ''}
`; // 社区勋章:默认折叠(数量少,优先级低) let communitySectionHtml = ''; if (communityBadges.length > 0) { const showComm = communityBadges.slice(0, 12); const id = 'sgis-bg-community'; const expanded = collapseState[id] === true; communitySectionHtml = `
${SGIS_ICONS.sparkle} 社区勋章 ${communityBadges.length} 枚
${SGIS_ICONS.sparkle}
社区勋章
Steam 社区授予的成就
${communityBadges.length} 枚
${SGIS_ICONS.chevronRight}
${showComm.map(renderBadgeRow).join('')}
`; } // 评分说明:折叠到 footer const footerHtml = ` `; // 组装:英雄区 → 价值 Top → 节日 → 游戏 → 社区 → footer let html = heroHtml + valueSectionHtml + eventSectionHtml + gameSectionHtml + communitySectionHtml + footerHtml; setBody(html); // v2.3.5: 市场数据加载完成后, 把原本用游戏胶囊图的徽章行图标刷新为卡牌封面图 // 只更新 data-has-card-icon="0" 的行 (首次渲染时没有卡牌数据的行) function refreshBadgeIconsToCardCover(newMarkets) { const bodyEl = document.getElementById('sgis-body'); if (!bodyEl) return; bodyEl.querySelectorAll('.sgis-badge-row[data-appid]').forEach(row => { if (row.dataset.hasCardIcon === '1') return; // 已经有卡牌封面, 跳过 const appid = row.dataset.appid; if (!appid) return; const market = newMarkets[appid]; if (!market || !market.cards || !market.cards.length) return; const cardWithIcon = market.cards.find(c => c.iconUrl); if (!cardWithIcon) return; const imgEl = row.querySelector('.sgis-badge-row-img'); if (!imgEl) return; // 没有图片元素(社区徽章等), 跳过 // 标记已更新, 避免重复刷新 row.dataset.hasCardIcon = '1'; // 保存原始 src 供 onerror 回退 imgEl.dataset.originalSrc = imgEl.src; imgEl.src = cardWithIcon.iconUrl; // 如果新图片加载失败, 回退到原始游戏胶囊图 imgEl.addEventListener('error', function onError() { imgEl.removeEventListener('error', onError); if (imgEl.dataset.originalSrc) { imgEl.src = imgEl.dataset.originalSrc; delete imgEl.dataset.originalSrc; } }, { once: true }); }); } // 异步抓取市场数据 (后续刷新徽章时显示价格) if (needFetchAppIds.length > 0) { fetchBadgeMarkets(needFetchAppIds, { concurrency: 3, maxApps: 15 }).then(newMarkets => { SGIS.userBadgeMarkets = Object.assign({}, markets, newMarkets); // v2.3.5: 市场数据加载完成后, 刷新徽章行图标为卡牌封面 // 只更新原本没有卡牌封面(data-has-card-icon="0")的行 refreshBadgeIconsToCardCover(newMarkets); // 刷新价值分析 (不重渲染, 静默更新) // 注意: 智能分析浮窗会通过 hover 重新触发 }).catch(e => console.warn('[SGLV] 徽章市场数据获取失败:', e)); } // 绑定折叠事件 const body = document.getElementById('sgis-body'); if (body) { body.querySelectorAll('[data-toggle]').forEach(head => { head.addEventListener('click', () => { const group = head.closest('.sgis-badge-group'); if (group) { const isExp = group.classList.toggle('sgis-badge-group-expanded'); const gid = head.getAttribute('data-toggle'); collapseState[gid] = isExp; setCollapseState(collapseState); } }); }); } } // ==================== 成就标签 (自制成就系统 + AI 人格分析) ==================== // 炫彩 SVG 成就图标库 (v2.3.1) - 每个成就独立的渐变配色 const ACHIEVEMENT_SVG_ICONS = { // 收藏家 - 礼物盒 (蓝紫渐变) collector: ``, // 收藏家皇冠 (金黄渐变) kingCollector: ``, // 时钟入门 (青蓝渐变) clock: ``, // 时钟沙漏 (橙红渐变) hourglass: ``, // 完美主义 (绿对勾) perfection: ``, // 骰子 - 涉猎广泛 (多彩) dice: ``, // 早鸟 (太阳/日出) sunrise: ``, // 夜猫子 (月亮+星星) moon: ``, // 老用户 (钻石) diamond: ``, }; // 自制成就定义 (v2.3.1: 炫彩 SVG 图标) const USER_ACHIEVEMENTS = [ { id: 'collector_50', icon: ACHIEVEMENT_SVG_ICONS.collector, name: '初级收藏家', desc: '拥有 50 款游戏', threshold: 50, metric: 'gameCount' }, { id: 'collector_100', icon: ACHIEVEMENT_SVG_ICONS.collector, name: '中级收藏家', desc: '拥有 100 款游戏', threshold: 100, metric: 'gameCount' }, { id: 'collector_500', icon: ACHIEVEMENT_SVG_ICONS.collector, name: '高级收藏家', desc: '拥有 500 款游戏', threshold: 500, metric: 'gameCount' }, { id: 'collector_1000', icon: ACHIEVEMENT_SVG_ICONS.kingCollector, name: '游戏大王', desc: '拥有 1000 款游戏', threshold: 1000, metric: 'gameCount' }, { id: 'playtime_1k', icon: ACHIEVEMENT_SVG_ICONS.clock, name: '入门玩家', desc: '总游戏时长达到 1000 小时', threshold: 1000, metric: 'totalHours' }, { id: 'playtime_5k', icon: ACHIEVEMENT_SVG_ICONS.hourglass, name: '资深玩家', desc: '总游戏时长达到 5000 小时', threshold: 5000, metric: 'totalHours' }, { id: 'playtime_10k', icon: ACHIEVEMENT_SVG_ICONS.hourglass, name: '硬核玩家', desc: '总游戏时长达到 10000 小时', threshold: 10000, metric: 'totalHours' }, { id: 'completionist', icon: ACHIEVEMENT_SVG_ICONS.perfection, name: '完美主义者', desc: '有 10 款游戏达成 100% 成就', threshold: 10, metric: 'perfectGames' }, { id: 'diverse_10', icon: ACHIEVEMENT_SVG_ICONS.dice, name: '涉猎广泛', desc: '游玩 10 种不同类型的游戏', threshold: 10, metric: 'genres' }, { id: 'early_bird', icon: ACHIEVEMENT_SVG_ICONS.sunrise, name: '早鸟玩家', desc: '在游戏发行 7 天内入库 5 款游戏', threshold: 5, metric: 'earlyAccess' }, { id: 'night_owl', icon: ACHIEVEMENT_SVG_ICONS.moon, name: '夜猫子', desc: '总游戏时长超过 5000 小时且拥有 200+ 游戏', threshold: 1, metric: 'nightOwl' }, { id: 'patron', icon: ACHIEVEMENT_SVG_ICONS.diamond, name: 'Steam 老用户', desc: 'Steam 账号超过 10 年', threshold: 1, metric: 'veteran' }, ]; function computeUserAchievements() { // v2.9.2: 排除家庭组共享游戏和 DLC,确保成就统计准确 const allGames = getStatFilteredGames(); const gameCount = allGames.length; const totalPlaytimeMin = allGames.reduce((s, g) => s + (g.playtime || 0), 0); const totalHours = Math.floor(totalPlaytimeMin / 60); // 完美游戏 (有成就且全部解锁 - 这里用 playtime > 60h 作为近似) const perfectGames = allGames.filter(g => g.playtime && g.playtime > 3600).length; // 游戏类型多样性 (从游戏名推断不了,用 0 占位) const genres = 0; // 早鸟 (acquiredTime 在发行 7 天内) const earlyAccess = allGames.filter(g => { if (!g.acquiredTime || !g.releaseDate) return false; const diff = g.acquiredTime - g.releaseDate; return diff >= 0 && diff <= 7 * 86400000; }).length; // 夜猫子 (游戏时长 > 5000h 且游戏数 > 200) const nightOwl = (totalHours > 5000 && gameCount > 200) ? 1 : 0; // 老用户 (从 SteamID 创建时间估算 - 用 account creation time) const veteran = 0; // 需要额外 API const metrics = { gameCount, totalHours, perfectGames, genres, earlyAccess, nightOwl, veteran }; return USER_ACHIEVEMENTS.map(ach => { const current = metrics[ach.metric] || 0; const unlocked = current >= ach.threshold; const progress = Math.min(100, (current / ach.threshold) * 100); return { ...ach, current, unlocked, progress }; }); } // ==================== v2.3.8: 社交标签页渲染 (参考 friend-manager) ==================== function renderSocial() { if (SGIS.friendsList) { renderSocialContent(); return; } if (SGIS.friendsListLoading) return; const steamId = getActiveSteamId(); if (!steamId) { setBody(`
${SGIS_ICONS.social}
无法获取好友列表
请先配置 SteamID 或登录 Steam 商店
`); return; } SGIS.friendsListLoading = true; renderLoading('正在获取好友列表…'); fetchFriendsList(steamId).then(result => { SGIS.friendsList = result; SGIS.friendsListLoading = false; SGIS.friendsListError = result.error || null; renderSocialContent(); }).catch(e => { SGIS.friendsListLoading = false; SGIS.friendsListError = e.message || '获取好友列表失败'; renderSocialContent(); }); } function renderSocialContent() { // 错误状态 if (SGIS.friendsListError && (!SGIS.friendsList || !SGIS.friendsList.friends || SGIS.friendsList.friends.length === 0)) { setBody(`
${SGIS_ICONS.social}
获取好友列表失败
${SGIS.friendsListError}
`); const retry = document.getElementById('sgis-social-retry'); if (retry) retry.addEventListener('click', () => { SGIS.friendsList = null; SGIS.friendsListError = null; renderSocial(); }); return; } const data = SGIS.friendsList || { friends: [], total: 0 }; const friends = data.friends || []; // ── KPI 计算 ── const totalCount = friends.length; const ingameFriends = friends.filter(f => f.gameextrainfo || f.personastate === 6); const onlineFriends = friends.filter(f => f.personastate > 0 && !f.gameextrainfo); const vacBanned = friends.filter(f => f.vac_banned); const newFriends = friends.filter(f => f.friend_days != null && f.friend_days < 30); const knownLevels = friends.filter(f => f.level != null); const maxLevel = knownLevels.length > 0 ? Math.max(...knownLevels.map(f => f.level)) : null; const KPI_GRADIENTS = { blue: 'linear-gradient(135deg, #3b82f6, #06b6d4)', cyan: 'linear-gradient(135deg, #06b6d4, #22d3ee)', purple: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', amber: 'linear-gradient(135deg, #f59e0b, #fbbf24)', rose: 'linear-gradient(135deg, #f43f5e, #fb7185)', green: 'linear-gradient(135deg, #10b981, #34d399)', }; // ── KPI 卡片 (6 个: 总数/在线/游戏中/VAC/新好友/最高等级) ── const renderKpi = (icon, label, val, sub, grad, color) => `
${icon}
${label}
${val}
${sub ? `
${sub}
` : ''}
`; const kpiHtml = `
${renderKpi(SGIS_ICONS.social, '好友总数', totalCount, `在线 ${onlineFriends.length + ingameFriends.length}`, KPI_GRADIENTS.blue, 'blue')} ${renderKpi(SGIS_ICONS.sparkle, '游戏中', ingameFriends.length, '正在玩游戏', KPI_GRADIENTS.cyan, 'cyan')} ${renderKpi(SGIS_ICONS.check, '在线', onlineFriends.length, '未在游戏中', KPI_GRADIENTS.green, 'green')} ${renderKpi(SGIS_ICONS.shield, 'VAC 封禁', vacBanned.length, vacBanned.length > 0 ? `最近 ${vacBanned[0].vac_days_since_last_ban}天` : '安全', vacBanned.length > 0 ? KPI_GRADIENTS.rose : KPI_GRADIENTS.green, vacBanned.length > 0 ? 'rose' : 'green')} ${renderKpi(SGIS_ICONS.clock, '新好友(30天)', newFriends.length, '最近添加', KPI_GRADIENTS.amber, 'amber')} ${renderKpi(SGIS_ICONS.trophy, '最高等级', maxLevel != null ? 'Lv.' + maxLevel : '—', knownLevels.length > 0 ? `${knownFriends(friends)} 人已加载` : '点击下方加载', KPI_GRADIENTS.purple, 'purple')}
`; // ── 筛选+排序工具栏 ── const filter = SGIS.friendsFilter || 'all'; const sort = SGIS.friendsSort || 'status'; const search = SGIS.friendsSearch || ''; // v2.3.24: 移除"离线"筛选标签(占比最高且信息价值低),剩余按钮紧凑一行显示 const filterBtns = [ { id: 'all', label: '全部', count: totalCount }, { id: 'ingame', label: '游戏中', count: ingameFriends.length }, { id: 'online', label: '在线', count: onlineFriends.length }, { id: 'vac', label: 'VAC', count: vacBanned.length }, { id: 'new', label: '新好友', count: newFriends.length }, ]; const sortOptions = [ { id: 'status', label: '按状态' }, { id: 'days', label: '按好友天数' }, { id: 'level', label: '按等级' }, { id: 'name', label: '按昵称' }, ]; // v2.3.13: 搜索+排序单独一行,筛选标签放第二行 const toolbarHtml = `
${filterBtns.map(f => ``).join('')}
`; // ── 筛选+排序+搜索 ── let filtered = [...friends]; if (filter === 'ingame') filtered = filtered.filter(f => f.gameextrainfo || f.personastate === 6); else if (filter === 'online') filtered = filtered.filter(f => f.personastate > 0 && !f.gameextrainfo); else if (filter === 'offline') filtered = filtered.filter(f => f.personastate === 0); else if (filter === 'vac') filtered = filtered.filter(f => f.vac_banned); else if (filter === 'new') filtered = filtered.filter(f => f.friend_days != null && f.friend_days < 30); if (search) { const q = search.toLowerCase(); filtered = filtered.filter(f => (f.personaname || '').toLowerCase().includes(q) || String(f.steamid).includes(q) ); } // 排序 if (sort === 'days') filtered.sort((a, b) => (b.friend_days || 0) - (a.friend_days || 0)); else if (sort === 'level') filtered.sort((a, b) => (b.level || 0) - (a.level || 0)); else if (sort === 'name') filtered.sort((a, b) => (a.personaname || '').localeCompare(b.personaname || '')); else { // status: 游戏中 > 在线 > 离线 const rank = f => f.gameextrainfo ? 0 : (f.personastate > 0 ? 1 : 2); filtered.sort((a, b) => rank(a) - rank(b)); } // ── 分组渲染 (status 排序时分组, 其他排序时不分组) ── let listHtml = ''; if (sort === 'status' && filter === 'all') { const groups = [ { id: 'ingame', label: '游戏中', icon: SGIS_ICONS.sparkle, friends: filtered.filter(f => f.gameextrainfo || f.personastate === 6) }, { id: 'online', label: '在线', icon: SGIS_ICONS.check, friends: filtered.filter(f => f.personastate > 0 && !f.gameextrainfo) }, { id: 'offline', label: '离线', icon: SGIS_ICONS.clock, friends: filtered.filter(f => f.personastate === 0) }, ]; listHtml = groups.map(g => g.friends.length > 0 ? `
${g.icon}${g.label}${g.friends.length}
${g.friends.slice(0, 50).map(renderFriendCard).join('')} ${g.friends.length > 50 ? `
还有 ${g.friends.length - 50} 位好友未显示, 请使用筛选或搜索查看
` : ''}
` : '').join(''); } else { listHtml = filtered.slice(0, 100).map(renderFriendCard).join(''); if (filtered.length > 100) { listHtml += `
显示前 100 位, 共 ${filtered.length} 位匹配好友
`; } } // ── v2.3.24: 好友游戏数量 TOP 10(参考 steam-friend-manager 社交仪表盘"游戏总数排行") ── const gcMap = SGIS.friendsGameCounts || cacheGet('friendsGameCounts_' + getActiveSteamId()) || {}; const gcRows = friends .map(f => ({ f, gc: gcMap[f.steamid] })) .filter(x => x.gc && x.gc.gc > 0) .sort((a, b) => b.gc.gc - a.gc.gc) .slice(0, 10); const gcLoadedCount = Object.keys(gcMap).length; const gcBtn = ``; const gcSectionHtml = `
${SGIS_ICONS.library} ${isZh ? '好友游戏数量 TOP 10' : 'Friend Game Count Top 10'}
${gcBtn}
${gcRows.length > 0 ? gcRows.map((x, i) => { const pct = Math.max(3, Math.round(x.gc.gc / gcRows[0].gc.gc * 100)); const hours = Math.round((x.gc.tm || 0) / 60); return `
${i + 1} ${x.f.personaname || ''}
${x.gc.gc}${isZh ? '款' : ''}${hours}h
`; }).join('') : `
${isZh ? (gcLoadedCount > 0 ? '已加载的好友均无公开游戏库(资料私密)' : '暂无数据——点击右上角"加载游戏数"逐好友获取游戏库统计
(私密资料自动跳过,数据缓存 12 小时)') : (gcLoadedCount > 0 ? 'No public libraries among loaded friends' : 'No data — click "Load" to fetch per-friend library stats (cached 12h)')}
`}
`; // ── 同步全部等级按钮 ── const unknownLevelCount = friends.filter(f => f.level == null).length; const knownLevelCount = friends.length - unknownLevelCount; const loadLevelsBtn = ``; // v2.3.13: 同步封禁状态按钮 const banSyncBtn = ``; setBody(`
${SGIS_ICONS.social} 好友概览
${banSyncBtn} ${loadLevelsBtn}
${kpiHtml}
${gcSectionHtml}
${SGIS_ICONS.users} 好友列表 (${filtered.length}/${totalCount})
${toolbarHtml} ${listHtml || '
无匹配好友
'}
好友数据缓存 6h · VAC 状态缓存 24h · 等级缓存 7天 · 数据源 Steam Web API
`); // 绑定事件 // v2.3.8 修复: renderSocialContent 没有 steamId 形参, 这里直接取活动 SteamID _bindSocialEvents(getActiveSteamId()); } function knownFriends(friends) { return friends.filter(f => f.level != null).length; } // 渲染单个好友卡片 function renderFriendCard(f) { const hasGame = !!f.gameextrainfo; const isIngame = hasGame || f.personastate === 6; const isOnline = f.personastate > 0 && !hasGame; const stateCls = isIngame ? 'ingame' : isOnline ? 'online' : 'offline'; const statusText = hasGame ? `🎮 ${f.gameextrainfo}` : getPersonaStateText(f.personastate); const statusCls = isIngame ? 'ingame' : isOnline ? 'online' : 'offline'; const avatarUrl = f.avatarmedium || f.avatar || ''; const avatarFallback = `data:image/svg+xml;utf8,${(f.personaname || '?').slice(0, 1).toUpperCase()}`; const levelBadge = f.level != null ? `Lv.${f.level}` : ''; const vacShield = f.vac_banned ? `${SGIS_ICONS.shield}VAC` : ''; // v2.3.8 修复: 用内联 SVG 国旗 + 国家名, 不再依赖外部 CDN, 加载更稳定 const countryBadge = f.country_flag ? `${f.country_flag}${f.country_name || f.loccountrycode}` : ''; return `
${f.personaname} ${levelBadge} ${vacShield}
${statusText} ${f.friend_days_text ? `🤝 ${f.friend_days_text}` : ''} ${countryBadge}
`; } // 绑定社交页事件 function _bindSocialEvents(steamId) { // 搜索 const searchInput = document.getElementById('sgis-social-search'); if (searchInput) { let timer; searchInput.addEventListener('input', e => { clearTimeout(timer); timer = setTimeout(() => { SGIS.friendsSearch = e.target.value; renderSocialContent(); }, 250); }); } // 筛选 document.querySelectorAll('.sgis-social-filter').forEach(btn => { btn.addEventListener('click', () => { SGIS.friendsFilter = btn.dataset.filter; renderSocialContent(); }); }); // 排序 const sortSelect = document.getElementById('sgis-social-sort'); if (sortSelect) { sortSelect.addEventListener('change', e => { SGIS.friendsSort = e.target.value; renderSocialContent(); }); } // 同步全部等级 const loadLevelsBtn = document.getElementById('sgis-social-load-levels'); if (loadLevelsBtn) { loadLevelsBtn.addEventListener('click', async () => { if (SGIS.friendsLevelsLoading) return; const friends = (SGIS.friendsList && SGIS.friendsList.friends) || []; const unknown = friends.filter(f => f.level == null).length; if (unknown === 0) { showToast(isZh ? '所有好友等级已加载' : 'All friend levels already loaded'); return; } SGIS.friendsLevelsLoading = true; SGIS.friendsLevelsProgress = 0; SGIS.friendsLevelsTotal = unknown; renderSocialContent(); try { const totalUnknown = unknown; // v2.3.12: 100 并发,点击一次自动同步全部未知等级 await fetchFriendsLevels(steamId, { maxCount: Infinity }); const remaining = (SGIS.friendsList.friends || []).filter(f => f.level == null).length; if (remaining > 0) { showToast(isZh ? `已同步 ${totalUnknown - remaining}/${totalUnknown} 个等级,${remaining} 位获取失败` : `Synced ${totalUnknown - remaining}/${totalUnknown}, ${remaining} failed`); } else { showToast(isZh ? '所有好友等级同步完成' : 'All friend levels synced'); } } catch (e) { console.warn('[SGIS] 加载好友等级失败:', e); showToast(isZh ? `同步失败: ${e.message || '网络错误'}` : `Sync failed: ${e.message || 'network error'}`); } finally { SGIS.friendsLevelsLoading = false; renderSocialContent(); } }); } // v2.3.13: 同步封禁状态按钮 const banSyncBtn = document.getElementById('sgis-social-ban-sync'); if (banSyncBtn) { banSyncBtn.addEventListener('click', async () => { if (SGIS.friendsBansLoading) return; const friends = (SGIS.friendsList && SGIS.friendsList.friends) || []; if (friends.length === 0) { showToast(isZh ? '暂无好友数据' : 'No friend data'); return; } SGIS.friendsBansLoading = true; SGIS.friendsBansProgress = 0; SGIS.friendsBansTotal = friends.length; renderSocialContent(); try { await fetchFriendsBans(steamId, { force: true }); const vacCount = (SGIS.friendsList.friends || []).filter(f => f.vac_banned).length; showToast(isZh ? `封禁状态同步完成,发现 ${vacCount} 位封禁好友` : `Ban sync complete, ${vacCount} banned friends found`); } catch (e) { console.warn('[SGIS] 同步封禁状态失败:', e); showToast(isZh ? `同步失败: ${e.message || '网络错误'}` : `Sync failed: ${e.message || 'network error'}`); } finally { SGIS.friendsBansLoading = false; renderSocialContent(); } }); } // v2.3.24: 加载好友游戏数量按钮 const gcBtn = document.getElementById('sgis-social-load-gc'); if (gcBtn) { gcBtn.addEventListener('click', async () => { if (SGIS.friendsGameCountsLoading) return; const friends = (SGIS.friendsList && SGIS.friendsList.friends) || []; if (friends.length === 0) { showToast(isZh ? '暂无好友数据' : 'No friend data'); return; } SGIS.friendsGameCountsLoading = true; SGIS.friendsGameCountsProgress = 0; SGIS.friendsGameCountsTotal = 0; gcBtn.disabled = true; gcBtn.textContent = isZh ? '加载中…' : 'Loading…'; try { await fetchFriendsGameCounts(steamId); const loaded = Object.keys(SGIS.friendsGameCounts || {}).length; showToast(isZh ? `好友游戏数量加载完成(${loaded} 位,私密资料已跳过)` : `Game counts loaded (${loaded} friends)`); } catch (e) { console.warn('[SGIS] 加载好友游戏数量失败:', e); showToast(isZh ? `加载失败: ${e.message || '网络错误'}` : `Load failed: ${e.message || 'network error'}`); } finally { SGIS.friendsGameCountsLoading = false; renderSocialContent(); } }); } // v2.3.24: 游戏数量排行行点击跳转主页 document.querySelectorAll('.sgis-gc-row').forEach(row => { row.addEventListener('click', () => { if (row.dataset.profile) window.open(row.dataset.profile, '_blank'); }); }); } function renderUserAchievements() { if (SGIS.userAchievements && SGIS.insightData) { renderUserAchievementsContent(); return; } if (SGIS.userAchievementsLoading) return; SGIS.userAchievementsLoading = true; renderLoading('正在分析游戏数据…'); setTimeout(() => { SGIS.userAchievements = computeUserAchievements(); SGIS.insightData = computeInsightData(); SGIS.userAchievementsLoading = false; renderUserAchievementsContent(); }, 200); } // ==================== v2.3.8: 洞察数据本地计算 (参考 AIPage analyzeLibrary) ==================== // v2.9.50: 改为调用主闭包缓存版本(PCC 持久化)— 跨 session 复用,避免每次开洞察标签页都全量重算 // v2.9.68: 修复 ReferenceError — computeInsightDataCachedSgis 在 SGLV 子闭包内,通过 SGLV_API 桥接调用 function computeInsightData() { if (typeof SGLV_API.computeInsightDataCachedSgis === 'function') { return SGLV_API.computeInsightDataCachedSgis(); } console.warn('[SGIS] computeInsightData: SGLV_API桥接不可用,返回 null'); return null; } function renderUserAchievementsContent() { const data = SGIS.insightData; // 数据为空时的兜底 if (!data || !data.kpi) { setBody(`
${SGIS_ICONS.insight}
暂无洞察数据
请先获取游戏库数据
`); return; } const k = data.kpi; const p = data.persona; // ── KPI 卡片 (参考 AIPage LocalKpiCard) ── const KPI_GRADIENTS = { blue: 'linear-gradient(135deg, #3b82f6, #06b6d4)', cyan: 'linear-gradient(135deg, #06b6d4, #22d3ee)', purple: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', amber: 'linear-gradient(135deg, #f59e0b, #fbbf24)', rose: 'linear-gradient(135deg, #f43f5e, #fb7185)', green: 'linear-gradient(135deg, #10b981, #34d399)', }; const renderKpi = (icon, label, val, sub, grad, color) => `
${icon}
${label}
${val}
${sub ? `
${sub}
` : ''}
`; const kpiHtml = `
${renderKpi(SGIS_ICONS.barChart, '游戏库', k.gameCount, `${k.over100hCount} 款超 100h`, KPI_GRADIENTS.blue, 'blue')} ${renderKpi(SGIS_ICONS.clock, '总时长', k.totalHours + ' h', `平均 ${k.avgHours}h/款`, KPI_GRADIENTS.cyan, 'cyan')} ${renderKpi(SGIS_ICONS.check, '已启动', k.playedCount, `占比 ${k.gameCount > 0 ? Math.round(k.playedCount / k.gameCount * 100) : 0}%`, KPI_GRADIENTS.purple, 'purple')} ${renderKpi(SGIS_ICONS.sparkle, '未启动', k.unplayedCount, `吃灰率 ${(k.dustRate * 100).toFixed(1)}%`, KPI_GRADIENTS.amber, 'amber')} ${renderKpi(SGIS_ICONS.trophy, '深度游玩', k.longGamesCount, '≥10h 的游戏数', KPI_GRADIENTS.rose, 'rose')} ${renderKpi(SGIS_ICONS.target, '完成度', k.completionRate + '%', '深度游玩/已启动', KPI_GRADIENTS.purple, 'purple')}
`; // ── 玩家画像区 (参考 AIPage persona 卡片) ── const personaHtml = `
${SGIS_ICONS.insight}
${p.type}${p.rarity}
${p.tagline}
${p.traits.map(t => `${t}`).join('')}
${SGIS_ICONS.barChart}
总游戏
${k.gameCount}
${SGIS_ICONS.clock}
总时长
${k.totalHours}h
${SGIS_ICONS.trend}
超100h
${k.over100hCount}
${SGIS_ICONS.sparkle}
吃灰率
${(k.dustRate * 100).toFixed(1)}%
${SGIS_ICONS.target}
主导维度
${p.dominantDim}
`; // ── 维度评分卡片 (参考 AIPage LocalDimensionCard) ── const getDimTagClass = (s) => s >= 80 ? 't-high' : s >= 60 ? 't-mid' : s >= 40 ? 't-low' : 't-bad'; const getDimProgressColor = (s) => s >= 80 ? '#10b981' : s >= 60 ? '#3b82f6' : s >= 40 ? '#f59e0b' : '#f43f5e'; const dimensionsHtml = `
${data.dimensions.map(d => `
${d.label} ${d.tag}
${d.score}
${d.desc}
`).join('')}
`; // ── AI 深度分析区 (参考 AIPage "游戏库深度分析") ── const aiConfigured = !!storage.getAiApiKey(); const aiSectionHtml = SGIS.aiInsight ? ` ${SGIS.aiInsight.oneLiner ? `
${SGIS_ICONS.target}
${SGIS.aiInsight.oneLiner}
` : ''} ${SGIS.aiInsight.sections.map((s, i) => { const grad = [KPI_GRADIENTS.blue, KPI_GRADIENTS.cyan, KPI_GRADIENTS.purple, KPI_GRADIENTS.rose, KPI_GRADIENTS.amber][i % 5]; return `
${SGIS_ICONS.sparkle}${s.title}
${s.content}
`; }).join('')} ` : SGIS.aiInsightLoading ? `
${SGIS_ICONS.refresh}
AI 正在分析你的游戏库…
五维度犀利点评生成中, 预计 15-30 秒
` : SGIS.aiInsightError ? `
⚠️
分析失败: ${SGIS.aiInsightError}
` : `
${SGIS_ICONS.brain}
点击「开始深度分析」
AI 将从库存概况、游玩习惯、偏好画像、亮点槽点、购买建议五个维度犀利点评
${!aiConfigured ? `
⚠️ 未配置 AI API Key, 请先在设置中配置
` : ''}
`; const aiHtml = `
${SGIS_ICONS.brain}
AI 深度分析
五维度犀利点评你的 Steam 库存
${aiSectionHtml}
`; // ── 游戏市场洞察区 (基于卡牌/徽章数据) ── const marketHtml = renderMarketInsightSection(); // ── 底部成就引导 (v2.9.38: 移除重复展示, 改为简洁跳转入口指向中央面板) ── const achievements = SGIS.userAchievements || []; const unlocked = achievements.filter(a => a.unlocked); const achievementsHtml = `
${SGIS_ICONS.trophy}
`; setBody(`
${SGIS_ICONS.barChart} KPI 概览
${kpiHtml}
${personaHtml}
${SGIS_ICONS.target} 五维度评分
${dimensionsHtml}
${aiHtml} ${marketHtml}
${SGIS_ICONS.trophy} 成就系统
${achievementsHtml}
洞察数据基于本地游戏库本地计算 · AI 分析需配置 AI API Key · 参考 steam-game-hub-2.0 AIPage 设计
`); // v2.3.33:异步加载市场洞察游戏中文名 document.querySelectorAll('#sgis-body [data-sglv-appid]').forEach(el => { loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent); }); // 绑定 AI 深度分析按钮 const triggerBtn = document.getElementById('sgis-insight-trigger'); if (triggerBtn) triggerBtn.addEventListener('click', triggerAiInsight); const retryBtn = document.getElementById('sgis-insight-retry'); if (retryBtn) retryBtn.addEventListener('click', () => { SGIS.aiInsightError = null; triggerAiInsight(); }); // 绑定市场洞察按钮 const marketBtn = document.getElementById('sgis-market-trigger'); if (marketBtn) marketBtn.addEventListener('click', triggerAiMarketInsight); const marketRetry = document.getElementById('sgis-market-retry'); if (marketRetry) marketRetry.addEventListener('click', () => { SGIS.aiMarketInsightError = null; triggerAiMarketInsight(); }); // v2.9.38: 绑定成就引导按钮 — 关闭 SGIS 侧边栏并打开中央面板的"游戏成就"页签(展示该游戏的具体成就) const achOpenBtn = document.getElementById('sgis-insight-ach-open'); if (achOpenBtn) achOpenBtn.addEventListener('click', () => { try { if (typeof closePanel === 'function') closePanel(); // 触发中央面板打开 + 切换到游戏成就标签(展示当前游戏的成就数据) document.dispatchEvent(new CustomEvent('sglv:open-modal', { detail: { tab: 'achievements' } })); } catch (e) { /* 静默 */ } }); } // ==================== v2.3.8: 游戏市场洞察渲染 (基于 userBadgeMarkets 数据) ==================== function renderMarketInsightSection() { const markets = SGIS.userBadgeMarkets || {}; const aiConfigured = !!storage.getAiApiKey(); // 统计卡牌市场数据 const gameMarkets = Object.entries(markets) .filter(([appId, m]) => m && m.cards && m.cards.length > 0) .map(([appId, m]) => { const totalValue = (m.cards || []).reduce((s, c) => s + (c.lowestPrice || 0), 0); const avgValue = totalValue / (m.cards || []).length; const gameName = (m.gameName || state.ownedGames?.find(g => String(g.appid) === String(appId))?.name || `App ${appId}`); return { appId, gameName, cardCount: (m.cards || []).length, totalValue, avgValue }; }) .sort((a, b) => b.totalValue - a.totalValue); const totalCardValue = gameMarkets.reduce((s, g) => s + g.totalValue, 0); const totalCards = gameMarkets.reduce((s, g) => s + g.cardCount, 0); const top5 = gameMarkets.slice(0, 5); const aiMarketHtml = SGIS.aiMarketInsight ? ` ${SGIS.aiMarketInsight.summary ? `
${SGIS.aiMarketInsight.summary}
` : ''} ${SGIS.aiMarketInsight.recommendations && SGIS.aiMarketInsight.recommendations.length > 0 ? SGIS.aiMarketInsight.recommendations.map(r => `
${r.name} ${r.action}
${r.reason}
`).join('') : ''} ` : SGIS.aiMarketInsightLoading ? `
${SGIS_ICONS.refresh}
AI 正在分析市场数据…
生成投资建议中
` : SGIS.aiMarketInsightError ? `
⚠️
分析失败: ${SGIS.aiMarketInsightError}
` : `
${SGIS_ICONS.market}
点击「生成投资建议」
AI 将基于你的卡牌库分析哪些游戏卡牌值得合成/出售
${!aiConfigured ? `
⚠️ 未配置 AI API Key
` : ''} ${top5.length === 0 ? `
📡 暂无卡牌市场数据, 请先访问「勋章」标签页加载
` : ''}
`; return `
${SGIS_ICONS.market}
游戏市场洞察
基于你的卡牌库分析市场价值与投资机会
${top5.length > 0 ? `
${gameMarkets.length}
有卡牌游戏
${totalCards}
卡牌总数
¥${totalCardValue.toFixed(0)}
总价值
价值 Top 5
${top5.map((g, i) => `
${i + 1}
${g.gameName}
¥${g.totalValue.toFixed(2)} · ${g.cardCount}卡
`).join('')} ` : `
暂无卡牌市场数据
请先访问「勋章」标签页加载卡牌数据
`}
${aiMarketHtml}
`; } // ==================== v2.3.8: AI 深度分析 (参考 AIPage analyzeLibrary + handleLibraryAnalyze) ==================== async function triggerAiInsight() { if (SGIS.aiInsightLoading) return; const apiKey = storage.getAiApiKey(); if (!apiKey) { showToast('未配置 AI API Key,请在设置中配置'); return; } SGIS.aiInsightLoading = true; SGIS.aiInsightError = null; SGIS.aiInsight = null; renderUserAchievementsContent(); try { const data = SGIS.insightData || computeInsightData(); const k = data.kpi; const p = data.persona; const profile = SGIS.profile?.summary; const personaName = profile?.personaname || '未知'; const level = SGIS.profile?.level || SGIS.userBadges?.playerLevel || '?'; // 构造输入数据摘要 const topGamesStr = data.topGames.map(g => `${g.name}(${g.hours}h)`).join(', '); const recentStr = data.recentGames.map(g => `${g.name}(${g.hours}h)`).join(', '); const dustStr = data.dustCollectors.join(', '); const dimsStr = data.dimensions.map(d => `${d.label}=${d.score}分(${d.tag})`).join(', '); const traitsStr = p.traits.join('、'); const prompt = `# 角色\n你是一位游戏行为分析专家和 Steam 库存评论家, 以犀利幽默的风格点评玩家的游戏库。\n\n# 任务\n基于玩家的 Steam 游戏数据, 从五个维度生成深度分析报告。语言要犀利但不恶意, 数据支撑, 避免空话套话。\n\n# 输入数据\n- 玩家昵称: ${personaName}\n- Steam 等级: ${level}\n- 游戏总数: ${k.gameCount}\n- 总时长: ${k.totalHours} 小时\n- 已启动游戏: ${k.playedCount} (${k.gameCount > 0 ? Math.round(k.playedCount / k.gameCount * 100) : 0}%)\n- 未启动游戏: ${k.unplayedCount} (吃灰率 ${(k.dustRate * 100).toFixed(1)}%)\n- 超 100h 游戏: ${k.over100hCount} 款\n- 超 500h 游戏: ${k.over500hCount} 款\n- 深度游玩(≥10h): ${k.longGamesCount} 款\n- 平均时长: ${k.avgHours}h/款\n- 玩家类型: ${p.type} (${p.rarity})\n- 玩家特征: ${traitsStr}\n- 维度评分: ${dimsStr}\n- 游玩最多 Top10: ${topGamesStr || '无数据'}\n- 最近游玩: ${recentStr || '无数据'}\n- 吃灰游戏示例: ${dustStr || '无'}\n\n# 输出格式\n严格按以下 JSON 格式输出, 不要输出任何其他文字。\n{\n "oneLiner": "一句话犀利总结, 30字以内, 直击要害",\n "sections": [\n {"title": "库存概况", "content": "2-4句, 评价游戏数量、规模、收藏习惯"},\n {"title": "游玩习惯", "content": "2-4句, 分析时长投入、深度游玩、吃灰情况"},\n {"title": "偏好画像", "content": "2-4句, 从 Top10 游戏推断品味偏好、游戏类型倾向"},\n {"title": "亮点槽点", "content": "2-4句, 先肯定亮点再点出槽点, 犀利但建设性"},\n {"title": "购买建议", "content": "2-4句, 基于现状给出未来购买/游玩的具体建议"\n }\n]}`; const content = await callAiApi(prompt, { temperature: 0.6, timeout: 90000 }); const result = safeParseAiJson(content, '{'); // 校验结构 if (!result.oneLiner || !Array.isArray(result.sections)) { throw new Error('AI 返回结构不完整'); } SGIS.aiInsight = result; } catch (e) { SGIS.aiInsightError = e.message || 'AI 分析失败'; } finally { SGIS.aiInsightLoading = false; renderUserAchievementsContent(); } } // ==================== v2.3.8: AI 市场洞察 (基于卡牌市场数据) ==================== async function triggerAiMarketInsight() { if (SGIS.aiMarketInsightLoading) return; const apiKey = storage.getAiApiKey(); if (!apiKey) { showToast('未配置 AI API Key,请在设置中配置'); return; } const markets = SGIS.userBadgeMarkets || {}; const gameMarkets = Object.entries(markets) .filter(([appId, m]) => m && m.cards && m.cards.length > 0) .map(([appId, m]) => { const totalValue = (m.cards || []).reduce((s, c) => s + (c.lowestPrice || 0), 0); const gameName = m.gameName || state.ownedGames?.find(g => String(g.appid) === String(appId))?.name || `App ${appId}`; return { appId, gameName, cardCount: (m.cards || []).length, totalValue, avgValue: totalValue / (m.cards || []).length, cards: m.cards }; }) .sort((a, b) => b.totalValue - a.totalValue) .slice(0, 10); if (gameMarkets.length === 0) { showToast('暂无卡牌市场数据, 请先访问「勋章」标签页加载'); return; } SGIS.aiMarketInsightLoading = true; SGIS.aiMarketInsightError = null; SGIS.aiMarketInsight = null; renderUserAchievementsContent(); try { const totalValue = gameMarkets.reduce((s, g) => s + g.totalValue, 0); const totalCards = gameMarkets.reduce((s, g) => s + g.cardCount, 0); const gamesDataStr = gameMarkets.map(g => { const cardDetails = g.cards.slice(0, 5).map(c => `${c.name || '卡'}(¥${(c.lowestPrice || 0).toFixed(2)})`).join(', '); return `- ${g.gameName} (AppID:${g.appId}): ${g.cardCount}张卡牌, 总价值¥${g.totalValue.toFixed(2)}, 均价¥${g.avgValue.toFixed(2)} | 卡牌示例: ${cardDetails}`; }).join('\n'); const prompt = `# 角色\n你是一位 Steam 卡牌市场投资顾问, 精通卡牌合成、徽章升级、市场套利策略。\n\n# 任务\n基于玩家的 Steam 卡牌库数据, 生成市场洞察报告和投资建议。\n\n# 输入数据\n- 有卡牌游戏数: ${gameMarkets.length}\n- 卡牌总数: ${totalCards}\n- 卡牌总价值: ¥${totalValue.toFixed(2)}\n\n## 游戏卡牌详情 (Top ${gameMarkets.length})\n${gamesDataStr}\n\n# 输出格式\n严格按以下 JSON 格式输出, 不要输出任何其他文字。\n{\n "summary": "2-3句话市场总览, 评价卡牌库整体价值、合成潜力、套利空间",\n "recommendations": [\n {"name": "游戏名称", "action": "买入/卖出/持有/合成", "reason": "1-2句话具体建议理由, 包含数据"}\n ]\n}\n\n# 要求\n- recommendations 数组包含 3-5 条建议, 优先选择价值最高或最有套利空间的游戏\n- action 只能是: 买入(补全卡牌合成徽章)、卖出(出售多余卡牌获利)、持有(暂不操作)、合成(立即合成徽章升级)\n- reason 必须包含具体数据支撑\n- 语言简洁有力, 避免空话`; const content = await callAiApi(prompt, { temperature: 0.4, timeout: 90000 }); const result = safeParseAiJson(content, '{'); if (!result.summary || !Array.isArray(result.recommendations)) { throw new Error('AI 返回结构不完整'); } SGIS.aiMarketInsight = result; } catch (e) { SGIS.aiMarketInsightError = e.message || 'AI 市场分析失败'; } finally { SGIS.aiMarketInsightLoading = false; renderUserAchievementsContent(); } } function renderTab(tab, force) { try { if (tab === 'overview') { if (force) { SGIS.overview = null; SGIS.familyShareSupported = null; SGIS.appDetailsExtra = null; SGIS.dlcNames = null; SGIS.similarGames = null; SGIS.dynamicStoreChecked = false; SGIS.drmInfo = null; SGIS.priceChartRange = 'all'; SGIS.gameStatusInfo = null; } renderOverview(); // 异步检测家庭共享支持 if (!SGIS.familyShareSupported) { fetchFamilyShareSupport().then(r => { SGIS.familyShareSupported = r; if (SGIS.tab === 'overview') renderOverview(); }).catch(() => {}); } // v2.3.16: 异步拉取 appdetails 增强信息 (工坊/截图/分类/捆绑包等) if (!SGIS.appDetailsExtra && !SGIS.appDetailsExtraLoading) { SGIS.appDetailsExtraLoading = true; fetchAppDetailsExtra().then(extra => { SGIS.appDetailsExtra = extra; SGIS.appDetailsExtraLoading = false; if (SGIS.tab === 'overview') renderOverview(); // v2.3.17: appdetails 到手后, 若有 DLC 则异步获取 DLC 名称 if (extra && extra.dlc && extra.dlc.length && !SGIS.dlcNames && !SGIS.dlcNamesLoading) { SGIS.dlcNamesLoading = true; fetchDlcNames(extra.dlc).then(names => { SGIS.dlcNames = names; SGIS.dlcNamesLoading = false; if (SGIS.tab === 'overview') renderOverview(); }).catch(() => { SGIS.dlcNamesLoading = false; }); } }).catch(() => { SGIS.appDetailsExtraLoading = false; }); } else if (SGIS.appDetailsExtra && SGIS.appDetailsExtra.dlc && SGIS.appDetailsExtra.dlc.length && !SGIS.dlcNames && !SGIS.dlcNamesLoading) { // v2.3.17: appdetails 已缓存但 DLC 名称未获取 SGIS.dlcNamesLoading = true; fetchDlcNames(SGIS.appDetailsExtra.dlc).then(names => { SGIS.dlcNames = names; SGIS.dlcNamesLoading = false; if (SGIS.tab === 'overview') renderOverview(); }).catch(() => { SGIS.dlcNamesLoading = false; }); } // v2.9.11: 异步获取 gamestatus.info 破解状态(独立于 appDetailsExtra) if (!SGIS.gameStatusInfo && !SGIS.gameStatusLoading) { SGIS.gameStatusLoading = true; const gsName = (SGIS.overview && SGIS.overview.name) || ''; fetchGameStatus(APP_ID, gsName).then(() => { SGIS.gameStatusLoading = false; if (SGIS.tab === 'overview') renderOverview(); }).catch(() => { SGIS.gameStatusLoading = false; }); } } else if (tab === 'medals') { if (force) SGIS.cards = null; renderMedals(); } else if (tab === 'prices') { if (force) { SGIS.prices = null; SGIS.historyPrices = null; SGIS.prediction = null; SGIS.predictionError = null; SGIS.giftRec = null; SGIS.priceChartRange = 'all'; } renderPrices(); } else if (tab === 'reviews') { if (force) { SGIS.reviews = null; SGIS.reviewsExpanded = new Set(); SGIS.reviewsFilter = { rec: 'all', playtime: 'all', language: 'all', purchase: 'all', keyword: '', regexMode: false }; } renderReviews(); } else if (tab === 'achievements') { if (force) { SGIS.achievements = null; SGIS.globalAchievements = null; } renderAchievements(); } else if (tab === 'dynamics') { if (force) { SGIS.dynamics = null; SGIS.aiSummary = null; SGIS.aiSummaryLoading = false; SGIS.aiSummaryError = null; } renderDynamics(); } else if (tab === 'playtrend') { // v2.9.60: 游玩时长趋势 — force 时不清除历史采样数据,仅重置聚合缓存 if (force) { SGIS.playTrendData = null; } renderPlayTrend(); } else if (tab === 'profile') { if (force) { SGIS.profile = null; SGIS.profileError = null; } renderProfile({ force: !!force }); } else if (tab === 'activity') { if (force) { SGIS.activity = null; SGIS.personalTimeline = null; SGIS.familyTimeline = null; } renderActivity(); } else if (tab === 'userBadges') { if (force) { SGIS.userBadges = null; } renderUserBadges(); } else if (tab === 'social') { if (force) { SGIS.friendsList = null; SGIS.friendsListError = null; } renderSocial(); } else if (tab === 'userAchievements') { if (force) { SGIS.userAchievements = null; SGIS.aiPersona = null; SGIS.aiPersonaError = null; SGIS.insightData = null; SGIS.aiInsight = null; SGIS.aiInsightError = null; SGIS.aiMarketInsight = null; SGIS.aiMarketInsightError = null; } renderUserAchievements(); } } catch (e) { console.error('[SGIS] renderTab error:', e); renderError('渲染失败: ' + e.message); } } // v2.3.11: 监听 Activity 瀑布流浮窗,浮窗打开时关闭并隐藏个人信息面板 function watchActivityOverlay() { const overlay = document.getElementById('sf-wf-overlay'); if (!overlay) { const mo = new MutationObserver((_, obs) => { if (document.getElementById('sf-wf-overlay')) { obs.disconnect(); watchActivityOverlay(); } }); mo.observe(document.body, { childList: true }); addDisposer(() => mo.disconnect()); return; } const handle = () => { if (overlay.classList.contains('sf-wf-open') && SGIS.open) closePanel(); }; handle(); const mo = new MutationObserver(handle); mo.observe(overlay, { attributes: true, attributeFilter: ['class'] }); addDisposer(() => mo.disconnect()); } // ---- 初始化 ---- function initSidebar() { ensureFab(); ensurePanel(); // 后台预加载汇率 refreshRates().catch(() => { /* ignore */ }); // 监听游戏库数据更新事件, 侧边栏打开时自动重新渲染 document.addEventListener('sglv:games-updated', () => { if (SGIS.open) renderTab(SGIS.tab, true); }); watchActivityOverlay(); console.log('%c[Steam 个人信息面板 v2.8.0] UI 初始化完成 · AppID:', 'color:#a78bfa;font-weight:bold', APP_ID || '(首页模式)'); } // ==================== v2.8.0: 评测标签页 ==================== // 评测分数描述映射 (review_score → 中文 + 图标 + 渐变色档) const REVIEW_SCORE_MAP = { 9: { desc: '好评如潮', icon: '🎉', cls: 'overwhelmingly-positive' }, 8: { desc: '特别好评', icon: '👍', cls: 'very-positive' }, 7: { desc: '多半好评', icon: '✅', cls: 'mostly-positive' }, 6: { desc: '好评', icon: '🙂', cls: 'positive' }, 5: { desc: '褒贬不一', icon: '⚖️', cls: 'mixed' }, 4: { desc: '差评', icon: '😕', cls: 'negative' }, 3: { desc: '多半差评', icon: '❌', cls: 'mostly-negative' }, 2: { desc: '特别差评', icon: '👎', cls: 'very-negative' }, 1: { desc: '差评如潮', icon: '💢', cls: 'overwhelmingly-negative' }, }; // v2.9.15: cls → 渐变档(pos/mix/neg),用于大字 + 进度条配色 const SCORE_CLS_TO_TIER = { 'overwhelmingly-positive': 'pos', 'very-positive': 'pos', 'mostly-positive': 'pos', 'positive': 'pos', 'mixed': 'mix', 'overwhelmingly-negative': 'neg', 'very-negative': 'neg', 'mostly-negative': 'neg', 'negative': 'neg', }; // 获取评测数据:摘要 + 详细评测 // API: store.steampowered.com/appreviews/{appid}?json=1&filter=summary&language=schinese&purchase_type=all // API: store.steampowered.com/appreviews/{appid}?json=1&filter=all&language=schinese&num_per_page=20 async function fetchReviews(appId) { const cacheKey = 'reviews_' + appId; const cached = cacheGet(cacheKey); if (cached) return cached; // 并行请求摘要和详细评测 const summaryUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&filter=summary&language=schinese&purchase_type=all&day_range=999999`; const detailUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&filter=all&language=schinese&purchase_type=all&num_per_page=20`; const [summaryRes, detailRes] = await Promise.all([ fetchJson(summaryUrl, { timeout: 12000 }).catch(() => null), fetchJson(detailUrl, { timeout: 12000 }).catch(() => null), ]); // 摘要数据 (全部时间) const allTimeSummary = summaryRes?.query_summary || {}; // 详细评测数据 (含最近30天摘要) const recentSummary = detailRes?.query_summary || {}; // v2.9.15: 修复 authorName bug——原代码三目两边都是 'Steam用户' (typo) // 优先用 author.personaname (Steam 实际昵称),匿名则降级 const reviews = (detailRes?.reviews || []).map(r => { const a = r.author || {}; const realName = (a.personaname || '').trim(); return { id: r.recommendationid || '', steamid: a.steamid || '', authorName: realName || 'Steam 用户', profileUrl: a.profileurl || '', avatar: a.avatar || '', avatarMedium: a.avatar_medium || a.avatar || '', avatarFull: a.avatar_full || a.avatar || '', playtimeForever: a.playtime_forever || 0, // 分钟 playtimeAtReview: a.playtime_at_review || 0, numGamesOwned: a.num_games_owned || 0, numReviews: a.num_reviews || 0, language: r.language || '', review: r.review || '', timestampCreated: r.timestamp_created || 0, timestampUpdated: r.timestamp_updated || 0, votedUp: !!r.voted_up, votesUp: r.votes_up || 0, votesFunny: r.votes_funny || 0, weightedVoteScore: r.weighted_vote_score || '0', commentCount: r.comment_count || 0, steamPurchase: !!r.steam_purchase, receivedForFree: !!r.received_for_free, writtenDuringEarlyAccess: !!r.written_during_early_access, }; }); const result = { // 全部时间摘要 allTime: { totalReviews: allTimeSummary.total_reviews || 0, totalPositive: allTimeSummary.total_positive || 0, totalNegative: allTimeSummary.total_negative || 0, reviewScore: allTimeSummary.review_score || 0, reviewScoreDesc: allTimeSummary.review_score_desc || '无数据', }, // 最近30天摘要 (来自详细评测请求的 query_summary) recent: { numReviews: recentSummary.num_reviews || 0, totalPositive: recentSummary.total_positive || 0, totalNegative: recentSummary.total_negative || 0, totalReviews: recentSummary.total_reviews || 0, reviewScore: recentSummary.review_score || 0, reviewScoreDesc: recentSummary.review_score_desc || '', }, reviews: reviews, fetchedAt: Date.now(), }; // v2.9.15: 单一缓存入口(之前双写 cacheSet + GM_setValue 是冗余) cacheSet(cacheKey, result, CACHE_TTL.reviews); return result; } // 评测标签页渲染入口 function renderReviews() { if (SGIS.reviewsLoading) return; if (SGIS.reviews) { renderReviewsContent(SGIS.reviews); return; } SGIS.reviewsLoading = true; renderLoading('正在获取评测数据…'); fetchReviews(APP_ID) .then(data => { SGIS.reviews = data; renderReviewsContent(data); }) .catch(e => renderError('评测获取失败: ' + e.message)) .finally(() => { SGIS.reviewsLoading = false; }); } // 渲染评测内容(摘要 + 筛选 + 列表) function renderReviewsContent(data) { if (!data || (!data.allTime.totalReviews && !data.reviews.length)) { // v2.9.15: 精致空状态——状态点 + 文字 setBody(`
暂无评测数据
该游戏可能没有中文评测,或 Steam API 暂未返回
`); return; } const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; const fmt = (v) => num(v).toLocaleString('zh-CN'); const pct = (a, b) => b > 0 ? ((a / b) * 100).toFixed(1) : '0.0'; const allTime = data.allTime; const recent = data.recent; const scoreInfo = REVIEW_SCORE_MAP[allTime.reviewScore] || { desc: allTime.reviewScoreDesc, icon: '❓', cls: 'unknown' }; // v2.9.15: 渐变档 (pos/mix/neg) 用于大字 + 进度条配色 const scoreTier = SCORE_CLS_TO_TIER[scoreInfo.cls] || 'mix'; const positiveRate = pct(allTime.totalPositive, allTime.totalReviews); const positiveBarWidth = positiveRate; // 评测趋势对比 const recentPositiveRate = recent.totalReviews > 0 ? pct(recent.totalPositive, recent.totalReviews) : null; let trendHtml = ''; if (recentPositiveRate != null && allTime.totalReviews > 0) { const diff = (Number(recentPositiveRate) - Number(positiveRate)).toFixed(1); const trendIcon = Number(diff) > 0 ? '📈' : (Number(diff) < 0 ? '📉' : '➡️'); const trendColor = Number(diff) > 0 ? '#4bb54f' : (Number(diff) < 0 ? '#e63946' : 'var(--sgis-text-2)'); trendHtml = `
最近30天 ${recentPositiveRate}% (${fmt(recent.totalReviews)}条)
全部时间 ${positiveRate}% (${fmt(allTime.totalReviews)}条)
趋势 ${trendIcon} ${diff > 0 ? '+' : ''}${diff}%
`; } // 筛选面板 const filter = SGIS.reviewsFilter; const filterBtn = (group, value, label) => { const active = filter[group] === value ? 'active' : ''; return ``; }; // 评测列表(应用筛选) const filteredReviews = applyReviewFilters(data.reviews, filter); const reviewsHtml = filteredReviews.length ? filteredReviews.map(r => renderReviewItem(r)).join('') : '
无符合条件的评测
尝试调整筛选条件或更换关键词
'; setBody(`
${SGIS_ICONS.review} 评测摘要
${scoreInfo.icon}
${scoreInfo.desc}
${fmt(allTime.totalReviews)} 条评测 · 来自 Steam 用户
${positiveRate}%
👍 ${fmt(allTime.totalPositive)} 好评 👎 ${fmt(allTime.totalNegative)} 差评
${trendHtml}
${SGIS_ICONS.target} 多维筛选
${filter.keyword ? '' : ''}
推荐: ${filterBtn('rec', 'all', '全部')} ${filterBtn('rec', 'yes', '好评')} ${filterBtn('rec', 'no', '差评')}
时长: ${filterBtn('playtime', 'all', '全部')} ${filterBtn('playtime', 'lt1', '<1h')} ${filterBtn('playtime', '1to10', '1-10h')} ${filterBtn('playtime', '10to50', '10-50h')} ${filterBtn('playtime', 'gt50', '50h+')}
语言: ${filterBtn('language', 'all', '全部')} ${filterBtn('language', 'schinese', '中文')} ${filterBtn('language', 'english', '英文')}
获取: ${filterBtn('purchase', 'all', '全部')} ${filterBtn('purchase', 'steam', '购买')} ${filterBtn('purchase', 'free', '免费')} ${filterBtn('purchase', 'key', 'Key')}
${SGIS_ICONS.review} 显示 ${filteredReviews.length} / ${data.reviews.length} 条评测 ${filteredReviews.length !== data.reviews.length ? '已筛选' : ''}
${SGIS_ICONS.review} 评测列表
${reviewsHtml}
`); // 绑定筛选事件 bindReviewFilterEvents(data); // 绑定评测展开事件 bindReviewExpandEvents(); } // 渲染单条评测 function renderReviewItem(r) { const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; const playtimeHours = r.playtimeForever > 0 ? (r.playtimeForever / 60).toFixed(1) : '0'; const recCls = r.votedUp ? 'sgis-review-rec-yes' : 'sgis-review-rec-no'; const recText = r.votedUp ? '推荐' : '不推荐'; // v2.9.15: 相对时间 + 绝对时间双显示(更直观) const dateStr = r.timestampCreated > 0 ? formatRelativeDate(r.timestampCreated) : '—'; const dateAbs = r.timestampCreated > 0 ? new Date(r.timestampCreated * 1000).toLocaleDateString('zh-CN') : ''; // 获取方式 let purchaseType = ''; if (r.receivedForFree) purchaseType = '免费获取'; else if (r.steamPurchase) purchaseType = 'Steam 购买'; else purchaseType = 'Key 激活'; // 语言 const langMap = { schinese: '中文', tchinese: '繁中', english: '英文', japanese: '日文' }; const langText = langMap[r.language] || r.language || '—'; // v2.9.15: 头像升级——优先 medium 尺寸,加 fallback 链路 + 圆形 + 首字母占位 const avatarSrc = r.avatarMedium || r.avatar || ''; const initial = (r.authorName || '?').trim().charAt(0).toUpperCase(); const avatarHtml = avatarSrc ? `` : `
${initial}
`; // v2.9.15: 作者名支持跳转个人主页,匿名作者则不可点 const authorHtml = r.profileUrl ? `${r.authorName}` : `${r.authorName}`; // 评测正文(截断3行,点击展开) const expanded = SGIS.reviewsExpanded.has(r.id) ? 'expanded' : ''; // v2.9.29: 使用全局 escHtml 替代本地 esc(增加单引号转义,更安全) const esc = escHtml; const escReview = esc(r.review).replace(/\n/g, '
'); // 评测项卡片化(v2.9.15 + 展开/收起 + 复制按钮) const isExpanded = SGIS.reviewsExpanded.has(r.id); const reviewLen = (r.review || '').length; const isLong = reviewLen > 200; // 超过 200 字符才显示展开按钮 return `
${avatarHtml}
${authorHtml} ${esc(dateStr)}
${recText}
⏱ ${playtimeHours}h 📦 ${purchaseType} 🌐 ${esc(langText)} ${r.numGamesOwned ? `🎮 ${r.numGamesOwned} 款` : ''}
${escReview}
${isLong ? `` : ''}
👍 ${num(r.votesUp)} 😄 ${num(r.votesFunny)} ${r.commentCount > 0 ? `💬 ${num(r.commentCount)}` : ''}
`; } // 筛选评测列表 function applyReviewFilters(reviews, filter) { // v2.9.9: 预编译正则表达式 (参考 Steam_Buff review-filter-core.js) let compiledRegex = null; if (filter.keyword && filter.regexMode) { try { compiledRegex = new RegExp(filter.keyword, 'i'); } catch { compiledRegex = null; } } return reviews.filter(r => { // 推荐状态 if (filter.rec === 'yes' && !r.votedUp) return false; if (filter.rec === 'no' && r.votedUp) return false; // 游戏时长(分钟) const minutes = r.playtimeForever; if (filter.playtime === 'lt1' && minutes >= 60) return false; if (filter.playtime === '1to10' && (minutes < 60 || minutes >= 600)) return false; if (filter.playtime === '10to50' && (minutes < 600 || minutes >= 3000)) return false; if (filter.playtime === 'gt50' && minutes < 3000) return false; // 语言 if (filter.language === 'schinese' && !r.language.startsWith('schinese')) return false; if (filter.language === 'english' && !r.language.startsWith('english')) return false; // 获取方式 if (filter.purchase === 'steam' && !r.steamPurchase) return false; if (filter.purchase === 'free' && !r.receivedForFree) return false; if (filter.purchase === 'key' && (r.steamPurchase || r.receivedForFree)) return false; // v2.9.9: 关键词/正则搜索 if (filter.keyword && filter.keyword.trim()) { const text = r.review || ''; if (filter.regexMode) { if (!compiledRegex || !compiledRegex.test(text)) return false; } else { if (!text.toLowerCase().includes(filter.keyword.toLowerCase())) return false; } } return true; }); } // 绑定筛选按钮事件 function bindReviewFilterEvents(data) { // v2.9.9: 抽取重渲染逻辑, 供筛选按钮和搜索共用 const rerenderReviews = () => { const filteredReviews = applyReviewFilters(data.reviews, SGIS.reviewsFilter); const listEl = document.getElementById('sgis-review-list'); if (listEl) { listEl.innerHTML = filteredReviews.length ? filteredReviews.map(r => renderReviewItem(r)).join('') : '
无符合条件的评测
尝试调整筛选条件或更换关键词
'; bindReviewExpandEvents(); } // v2.9.15: 更新显示计数(用 [data-count-summary] 选择器替代脆弱的内联样式匹配) const countSummary = document.querySelector('[data-count-summary]'); if (countSummary) { countSummary.innerHTML = ` ${SGIS_ICONS.review} 显示 ${filteredReviews.length} / ${data.reviews.length} 条评测 ${filteredReviews.length !== data.reviews.length ? '已筛选' : ''}`; } }; document.querySelectorAll('.sgis-review-filter-btn').forEach(btn => { btn.addEventListener('click', () => { const group = btn.dataset.filterGroup; const value = btn.dataset.filterValue; SGIS.reviewsFilter[group] = value; document.querySelectorAll(`.sgis-review-filter-btn[data-filter-group="${group}"]`).forEach(b => b.classList.remove('active')); btn.classList.add('active'); rerenderReviews(); }); }); // v2.9.9: 关键词搜索 (防抖 300ms, 参考 Steam_Buff search-suggestions.js) const searchInput = document.getElementById('sgis-review-search'); if (searchInput) { let searchTimer = null; searchInput.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { SGIS.reviewsFilter.keyword = searchInput.value; rerenderReviews(); }, 300); }); } // v2.9.9: 正则模式切换 const regexToggle = document.getElementById('sgis-review-regex-toggle'); if (regexToggle) { regexToggle.addEventListener('click', () => { SGIS.reviewsFilter.regexMode = !SGIS.reviewsFilter.regexMode; regexToggle.classList.toggle('active', SGIS.reviewsFilter.regexMode); rerenderReviews(); }); } // v2.9.15: 清空搜索按钮(只在该按钮存在时绑定) const clearBtn = document.getElementById('sgis-review-search-clear'); if (clearBtn && searchInput) { clearBtn.addEventListener('click', () => { searchInput.value = ''; SGIS.reviewsFilter.keyword = ''; rerenderReviews(); searchInput.focus(); }); } } // 绑定评测文本展开/收起事件 function bindReviewExpandEvents() { // v2.9.15: 展开/收起改由按钮触发(避免点击全行歧义),文本点击只切换(保留旧行为兼容) document.querySelectorAll('.sgis-review-text').forEach(el => { el.addEventListener('click', (e) => { // 点按钮/链接时不展开(让按钮自身处理) if (e.target.closest('.sgis-review-action-btn, a, button')) return; const id = el.dataset.reviewId; toggleReviewExpand(id); }); }); // 展开/收起按钮 document.querySelectorAll('.sgis-review-expand-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); toggleReviewExpand(btn.dataset.reviewId); const isExpanded = btn.classList.contains('is-expanded'); btn.classList.toggle('is-expanded', !isExpanded); btn.querySelector('span').textContent = !isExpanded ? '收起' : '展开'; }); }); // 复制按钮 document.querySelectorAll('.sgis-review-copy-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.reviewId; const review = (SGIS.reviews?.reviews || []).find(x => String(x.id) === String(id)); if (!review || !review.review) return; const ok = await copyTextToClipboard(review.review); btn.classList.add('copied'); const label = btn.querySelector('span'); const origText = label.textContent; label.textContent = ok ? '已复制' : '失败'; setTimeout(() => { btn.classList.remove('copied'); label.textContent = origText; }, 1500); }); }); } function toggleReviewExpand(id) { const textEl = document.querySelector(`.sgis-review-text[data-review-id="${id}"]`); if (!textEl) return; if (SGIS.reviewsExpanded.has(id)) { SGIS.reviewsExpanded.delete(id); textEl.classList.remove('expanded'); } else { SGIS.reviewsExpanded.add(id); textEl.classList.add('expanded'); } } // ==================== v2.8.0: 跨区送礼推荐 ==================== // Steam 跨区送礼限制区域(不能向其他区域送礼的区域) // 参考 steam-gift-checker: 俄罗斯/CIS 区域有送礼限制 const GIFT_RESTRICTED_REGIONS = new Set(['RU']); // 俄罗斯不能向其他区域送礼 // Steam 跨区送礼价格差异阈值(参考 steam-gift-checker: Math.abs(percentage) <= 15) const GIFT_PRICE_DIFF_THRESHOLD = 15; // 计算跨区送礼推荐方案 // 核心逻辑(参考 steam-gift-checker/content.js 的 calculatePriceDifference): // percentage = (recipientCny - senderCny) / senderCny * 100 // canGift = percentage > 0 && percentage <= 15 (送礼区更便宜且差价在15%以内) function calculateGiftRecommendation(pricesData) { if (!pricesData || !pricesData.prices || !pricesData.prices.length) return null; const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; const userRegion = (document.cookie.match(/steamCountry=(\w{2})/) || [])[1] || 'CN'; // 找到收礼区(用户当前区域)的价格 const recipientData = pricesData.prices.find(p => p.region === userRegion); if (!recipientData) { // 用户区域价格不在数据中,无法计算 return { error: 'no-recipient', userRegion, message: '当前区域价格数据缺失,无法计算送礼推荐' }; } // 计算 CNY 价格 const recipientCny = recipientData.currency === 'CNY' ? num(recipientData.price) : (SGIS.rateReady ? num(toCNY(num(recipientData.price), recipientData.currency)) : null); if (recipientCny == null || recipientCny <= 0) { return { error: 'no-rate', userRegion, message: '汇率未就绪,无法换算送礼推荐' }; } // 对每个可送礼区域计算差价 const candidates = []; for (const p of pricesData.prices) { // 跳过收礼区自身 if (p.region === userRegion) continue; // 跳过受限区域(不能向其他区域送礼) if (GIFT_RESTRICTED_REGIONS.has(p.region)) continue; const senderCny = p.currency === 'CNY' ? num(p.price) : (SGIS.rateReady ? num(toCNY(num(p.price), p.currency)) : null); if (senderCny == null || senderCny <= 0) continue; // 只考虑送礼区价格低于收礼区的情况(礼物只能从低价区送往高价区) if (senderCny >= recipientCny) continue; // 计算差价和百分比(参考 gift-checker: percentage = (recipient - sender) / sender * 100) const savings = recipientCny - senderCny; const savingsPercent = (savings / senderCny) * 100; const withinRule = savingsPercent <= GIFT_PRICE_DIFF_THRESHOLD; const regionInfo = REGIONS.find(r => r.code === p.region) || { name: p.region, code: p.region }; candidates.push({ region: p.region, regionName: regionInfo.name, senderCny: num(senderCny), recipientCny: num(recipientCny), savings: num(savings), savingsPercent: num(savingsPercent), withinRule: withinRule, senderPrice: num(p.price), senderCurrency: p.currency, senderDiscount: num(p.discount), canGift: withinRule, // 在15%规则内可以赠送 }); } if (!candidates.length) { return { error: 'no-candidates', userRegion, message: '没有找到可送礼的区域(所有区域价格均不低于本区或为受限区域)' }; } // 排序:优先可赠送的(withinRule=true),然后按节约金额降序 candidates.sort((a, b) => { if (a.withinRule !== b.withinRule) return a.withinRule ? -1 : 1; return b.savings - a.savings; }); const best = candidates[0]; const alternatives = candidates.slice(1, 4); // 备选方案最多3个 return { best: best, alternatives: alternatives, userRegion: userRegion, userRegionName: (REGIONS.find(r => r.code === userRegion) || { name: userRegion }).name, recipientCny: num(recipientCny), allCandidates: candidates.length, timestamp: Date.now(), }; } // 渲染送礼推荐卡片 HTML function renderGiftRecommendation(giftData) { if (!giftData || giftData.error) { const msg = giftData?.message || '无法计算跨区送礼推荐'; return `
${SGIS_ICONS.gift} 跨区送礼推荐
${msg}
`; } const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; const fmt = (v) => num(v).toFixed(2); const best = giftData.best; const ruleText = best.withinRule ? '✓ 符合Steam送礼规则(价差≤15%)' : `⚠ 价差${best.savingsPercent.toFixed(1)}%超过15%限制,可能无法赠送`; const altText = giftData.alternatives.length ? `
其他备选: ${giftData.alternatives.map(a => `${a.regionName} (省¥${fmt(a.savings)}, ${a.withinRule ? '可送' : '超限'})` ).join(' · ')}
` : ''; return `
${SGIS_ICONS.gift} 跨区送礼推荐
${best.regionName} → ${giftData.userRegionName} 赠送最划算
¥${fmt(best.savings)} 节约 ${best.savingsPercent.toFixed(1)}%
送礼区(${best.regionName}) ¥${fmt(best.senderCny)}${best.senderDiscount > 0 ? ` (-${best.senderDiscount}%)` : ''}
收礼区(${giftData.userRegionName}) ¥${fmt(best.recipientCny)}
${ruleText}
⚠ 注意事项:Steam跨区送礼政策可能随时调整,请确认当前政策。部分区域有送礼限制(如俄罗斯/CIS区域不能向其他区域送礼)。礼物只能从低价区送往高价区,且价格差异需在15%以内。
${altText}
`; } // 将送礼推荐追加到价格标签页 body function appendGiftRecommendationToBody() { if (!SGIS.giftRec && !SGIS.prices) return; const bodyEl = document.getElementById('sgis-body'); if (!bodyEl || SGIS.tab !== 'prices') return; // 如果还没计算过,基于已有价格数据计算 if (!SGIS.giftRec && SGIS.prices) { SGIS.giftRec = calculateGiftRecommendation(SGIS.prices); } if (!SGIS.giftRec) return; // 移除旧的推荐卡片 const oldCard = bodyEl.querySelector('.sgis-gift-card'); if (oldCard) oldCard.remove(); // 追加新卡片 const temp = document.createElement('div'); temp.innerHTML = renderGiftRecommendation(SGIS.giftRec); while (temp.firstChild) bodyEl.appendChild(temp.firstChild); } // ==================== v2.8.0: CheapShark 历史价格回退 ==================== // 价格历史数据回退链:ITAD → CheapShark → AugmentedSteam async function fetchHistoryPricesCheapShark(appId, reason) { try { // CheapShark API: 按 steamAppID 查询 const url = `https://api.cheapshark.com/api/1.0/games?id=${appId}`; const data = await fetchJson(url, { timeout: 10000 }); if (!data || !data.deals || !data.deals.length) { return await fetchHistoryPricesFallback(appId, reason + ' / CheapShark 无数据'); } // 解析 deals 中的历史价格 const history = data.deals.map(d => ({ price: Number(d.price) || 0, regular: Number(d.retailPrice) || 0, currency: 'USD', cut: d.savings ? Math.round(Number(d.savings)) : 0, store: d.storeName || '', date: d.lastChange ? new Date(Number(d.lastChange) * 1000).toISOString() : '', })).filter(h => h.price > 0).sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 20); // 找到史低 let lowest = null; if (history.length) { const minPrice = Math.min(...history.map(h => h.price)); lowest = history.find(h => h.price === minPrice); if (lowest) lowest = { price: lowest.price, currency: 'USD', store: lowest.store, date: lowest.date, cut: lowest.cut }; } const result = { history, discounts: history.filter(h => h.cut > 0).sort((a, b) => b.cut - a.cut), lowest, releaseDate: null, source: 'cheapshark', error: reason, }; cacheSet('historyPrices_' + appId, result, CACHE_TTL.historyPrices); return result; } catch (e) { return await fetchHistoryPricesFallback(appId, reason + ' / CheapShark: ' + e.message); } } initSidebar(); })(); // ==================== 启动 ==================== async function init() { // v2.9.15: 启动时一次性把 IDB 缓存加载到内存(异步,非阻塞) try { await sglvIDB.loadAll(); // 缓存 schema 版本检查:旧版本自动失效 const upgraded = ensureCacheVersions(); if (Object.keys(upgraded).length) { console.log('[SGLV] 缓存 schema 已升级:', upgraded); // 失效对应 cache(举例:bundle_db 升级时清空旧 GM v2 缓存) if (upgraded.bundle_db) { try { GM_setValue(BUNDLE_DB_CACHE_KEY_V2, null); } catch (e) {} } } // 从老 GM 大对象一次性迁移到 IDB migrateLegacyGmKeysToIDB(); // v2.9.50: 同步从 IDB hydrate PCC 持久化计算缓存到 _mem,让 getSync 立即可命中 // v2.9.73: _PCC_CACHE 定义在 SGLV 子闭包内,通过 SGLV_API 桥接调用(修复 ReferenceError) if (SGLV_API.hydratePccCache) SGLV_API.hydratePccCache(); } catch (e) { console.warn('[SGLV] IDB 初始化失败,降级使用 GM_setValue:', e); } SGLV_API.initUI(); SGLV_API.autoScan(); console.log('[Steam 游戏库展示] UI 初始化完成'); // v2.4.0: 注册卸载清理,释放事件监听器/MutationObserver,避免内存泄漏 window.addEventListener('pagehide', runDisposers); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();