/* ===================================================================== * SGLV PTW 游戏数据浮窗层(框架 + 7 Tab 合并)—— 样式层已改造为 token 消费者 * --------------------------------------------------------------------- * v2.0.2 样式改造(由 migrate-ptw-css.js 生成,逻辑代码零改动): * · 36 处未定义的 --sglv-text-dim → --sglv-text-secondary * · 7 处未定义的 --sglv-panel(-2) → --sglv-bg / --sglv-surface-deep * · 200+ 处硬编码色值 → 全局语义 token * · 8 档 rgba(102,192,244,*) → accent-wash / soft / strong 三档 * · 3 种橙 (#e8a33d/#f59e0b/#ff9800) → --sglv-amber / --sglv-orange * · 5 份 injectCSS 样板 → SGLVTheme.ensureStyle * 新增能力(来自 sglv-libs-theme.lib.js):主题切换 / 焦点环 / 统一滚动条 / * 等宽数字 / prefers-reduced-motion。 * ⚠️ 依赖顺序:core → sglv-libs-theme → ptw(本库) * ===================================================================== */ /* ===================================================================== * SGLV PTW 游戏数据浮窗层(框架 + 8 Tab 合并) * 由 script_libs 下 8 个单库文件按原 @require 顺序合并而来。 * 各库均为自包含 IIFE,按序拼接即等价于逐个 @require 加载。 * 单库源码已删除;如需拆回,按各分段头注释切分即可。 * ===================================================================== */ /* ---------- sglv-ptw-framework.lib.js ---------- */ /* ============================================================================= * SGLV PTW — 游戏数据浮窗框架 (PlayTimeWindow Framework) * ----------------------------------------------------------------------------- * 职责:浮窗外壳 + Tab 注册表 + 视图状态 + 图表 tooltip + 导出 * 边界:不含任何数据获取逻辑,一切经 host (PTW_HOST) 获取 * * 版本:1.0.2 apiVersion: 1 * 依赖:window.SGLVPTW.init(host) 由主脚本调用 * * 设计原则(勿改): * ① Tab 无状态:mount(container, ctx) 不持有跨调用状态,可变状态存 ctx.view * ② Tab 之间禁止互相调用,只能经 host * ③ 错误隔离:任一 Tab mount 抛错 → 仅该 pane 显示错误卡,其余不受影响 * ④ 优雅降级:框架缺失 → 主脚本显示提示卡;能力缺失 → 单 Tab 降级 * ========================================================================== */ // v1.0.5: 样式注入幂等缓存 —— 8 个独立 IIFE 闭包(框架 + 7 Tab)共用同一份缓存, // 避免每次切 Tab 都 document.getElementById(id) 走查 + 进入 ensureStyle 二次判断; // SGLVTheme 缺失时回落路径也会复用同一份缓存,确保幂等。 var __sglvPtwInjectedStyle = new Set(); function __sglvPtwInjectStyle(id, css) { if (__sglvPtwInjectedStyle.has(id)) return; __sglvPtwInjectedStyle.add(id); var theme = (typeof window !== 'undefined' && window.SGLVTheme) || (typeof unsafeWindow !== 'undefined' && unsafeWindow && unsafeWindow.SGLVTheme); if (theme && theme.ensureStyle) { theme.ensureStyle(id, css); return; } if (typeof document === 'undefined') return; if (document.getElementById(id)) return; var st = document.createElement('style'); st.id = id; st.textContent = css; (document.head || document.documentElement).appendChild(st); } (function (root) { 'use strict'; const API_VERSION = 1; const NS = 'sglv-ptw-'; // ============================================================================= // 常量 // ========================================================================== const DEFAULT_TABS = ['overview', 'all', 'family', 'calendar']; const VIEW_STATE_KEY = 'ptwViewState'; const WIN_POS_KEY = 'ptwWinPos'; const Z_INDEX = { overlay: 1000004, tooltip: 1000010, toast: 1000020 }; const TOOLTIP_EDGE_PAD = 12; // v1.0.5: 样式注入幂等缓存 —— 8 个 IIFE 闭包共用顶层 __sglvPtwInjectStyle, // 避免每次切 Tab 都 document.getElementById(id) 走查 + 进入 ensureStyle 二次判断; // SGLVTheme 缺失时回落路径也会复用同一份缓存,确保幂等。 // 默认窗口尺寸(相对视口) const DEFAULT_SIZE = { wRatio: 0.94, hRatio: 0.90, maxW: 1560 }; let host = null; // PTW_HOST let hostInspect = {}; // 能力快照 let tabs = []; // 已注册 Tab let win = null; // 当前窗口 DOM 引用 let seq = 0; // id 自增 let openOpts = {}; // 最近一次 open(opts),供 ctx.getGames() 覆盖游戏集 // ============================================================================= // 工具 // ========================================================================== const $ = (sel, root_) => (root_ || document).querySelector(sel); const $$ = (sel, root_) => Array.prototype.slice.call((root_ || document).querySelectorAll(sel)); const uid = (p) => `${NS}${p || 'x'}-${++seq}`; /** 路径解析:'a.b.c' → obj.a.b.c(对应 requires 声明规范) */ function resolve(obj, path) { return String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), obj); } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } /** 能力探测:返回缺失路径数组 */ function probe(requires) { return (requires || []).filter(p => resolve(host, p) === undefined); } /** 存储(GM_setValue 优先,降级 localStorage) */ const store = { get(k, dflt) { try { if (typeof GM_getValue === 'function') return GM_getValue(k, dflt); const v = localStorage.getItem(k); return v === null ? dflt : v; } catch (e) { return dflt; } }, set(k, v) { try { if (typeof GM_setValue === 'function') { GM_setValue(k, v); return; } localStorage.setItem(k, v); } catch (e) { /* quota */ } }, }; // ============================================================================= // 视图状态 // ========================================================================== function defaultView() { return { tab: 'overview', page: 1, query: '', sortKey: 'playtime', sortAsc: false, hideUnplayed: false, hideShared: false, personalOnly: false, layerFilter: null, // 活跃度分层钻取 acquiredMonth: null, // 月历钻取 }; } const view = defaultView(); const subscribers = []; /** 订阅状态变更 */ function subscribe(fn) { subscribers.push(fn); return () => { const i = subscribers.indexOf(fn); if (i >= 0) subscribers.splice(i, 1); }; } /** 更新状态并广播 */ function setView(patch, opts) { Object.assign(view, patch || {}); persistView(); if (!opts || opts.notify !== false) { subscribers.slice().forEach(fn => { try { fn(view, patch); } catch (e) { console.warn('[PTW] 订阅回调异常', e); } }); } } function persistView() { try { store.set(VIEW_STATE_KEY, JSON.stringify(view)); } catch (e) { /* ignore */ } } function restoreView() { try { const raw = store.get(VIEW_STATE_KEY, ''); if (raw) Object.assign(view, defaultView(), JSON.parse(raw)); } catch (e) { /* ignore */ } } // ============================================================================= // Tab 注册表 // ========================================================================== function registerTab(cfg) { if (!cfg || !cfg.key || typeof cfg.mount !== 'function') { console.warn('[PTW] registerTab: 无效配置,已忽略', cfg); return false; } if (tabs.some(t => t.key === cfg.key)) { console.warn(`[PTW] Tab "${cfg.key}" 重复注册,已覆盖`); } tabs = tabs.filter(t => t.key !== cfg.key); cfg.order = typeof cfg.order === 'number' ? cfg.order : 500; cfg.requires = cfg.requires || []; cfg.softRequires = cfg.softRequires || []; cfg.hidden = !!cfg.hidden; // v2.22.2: hidden 仅影响浮窗 Tab 栏,不影响 mountInto(主面板复用) tabs.push(cfg); return true; } /** * v2.22.2: 在浮窗 Tab 栏隐藏/恢复某个 Tab(不卸载库,不影响 mountInto)。 * 用途:overview Tab 与主面板「游玩数据」页内容重复,宿主装配后调用 * hideTab('overview', true) 将其从浮窗移除,同时保留 mountInto 复用能力。 */ function hideTab(key, hidden) { const tab = getTab(key); if (!tab) return false; tab.hidden = hidden !== false; // 当前激活 Tab 被隐藏 → 回落首个可见可用 Tab if (win && view.tab === key && tab.hidden) { const first = sortedTabs().find(t => !t.hidden && tabStatus(t).available); if (first) switchTab(first.key); } if (win) renderTabs(); return true; } /** Tab 可用性 */ function tabStatus(tab) { const missingHard = probe(tab.requires); const missingSoft = probe(tab.softRequires); return { key: tab.key, label: tab.label, order: tab.order, available: missingHard.length === 0, missingHard, missingSoft, }; } function sortedTabs() { return tabs.slice().sort((a, b) => a.order - b.order); } function getTab(key) { return tabs.find(t => t.key === key) || null; } // ============================================================================= // 上下文(注入 Tab) // ========================================================================== function buildContext() { return { view, host, subscribe, setView, tooltip: tooltipApi, exportCSV, uid, esc, /** * 本次渲染生效的游戏集。 * 若open({games}) 传入外部集合(如主脚本用 IndexedDB 缓存兜底), * 优先于 host.getOwnedGames(),避免 Tab 拿到空库渲染出空图。 * 未传时直接回落 host(保持 Tab 库零参数调用)。 */ getGames: () => (Array.isArray(openOpts.games) && openOpts.games.length ? openOpts.games : host.getOwnedGames()) || [], goTo(key, patch) { setView(Object.assign({ tab: key }, patch || {})); if (win) switchTab(key); }, }; } // ============================================================================= // 图表 tooltip(事件委托层) // ========================================================================== const tooltipApi = { el: null, lastHtml: '', ensure() { if (this.el) return this.el; const d = document.createElement('div'); d.className = `${NS}tip`; d.style.cssText = `position:fixed;z-index:${Z_INDEX.tooltip};pointer-events:none;` + `opacity:0;transition:opacity var(--sglv-dur-fast) var(--sglv-ease);max-width:280px;` + `background:var(--sglv-surface-elevated);border:1px solid var(--sglv-accent-soft);` + `border-radius:var(--sglv-r-md);padding:var(--sglv-sp-2) var(--sglv-sp-3);font-size:var(--sglv-fs-md);line-height:1.55;` + `color:var(--sglv-text);box-shadow:var(--sglv-shadow-md),0 0 18px var(--sglv-accent-wash);backdrop-filter:blur(12px)`; document.body.appendChild(d); this.el = d; return d; }, /** * 显示。html 为字符串。 * ⚠️ 内容比对必须用「源串」而非 innerHTML —— 浏览器会把 style="#94a3b8" * 序列化成 rgb(...),用 innerHTML 比较永远不等,会导致每次 mousemove 重建。 * (教训来源:steam-friend-manager 1.5.3.js:5160) */ show(html, x, y) { const el = this.ensure(); if (html !== this.lastHtml) { el.innerHTML = html; this.lastHtml = html; } el.style.opacity = '1'; this.move(x, y); }, /** 四向翻转避让视口 */ move(x, y) { const el = this.ensure(); if (!el) return; const r = el.getBoundingClientRect(); let left = x + TOOLTIP_EDGE_PAD; let top = y + TOOLTIP_EDGE_PAD; if (left + r.width > window.innerWidth - 6) left = x - r.width - TOOLTIP_EDGE_PAD; if (left < 6) left = 6; if (top + r.height > window.innerHeight - 6) top = y - r.height - TOOLTIP_EDGE_PAD; if (top < 6) top = 6; el.style.left = left + 'px'; el.style.top = top + 'px'; }, hide() { if (this.el) this.el.style.opacity = '0'; this.lastHtml = ''; }, }; /** 在容器上启用 tooltip 委托:容器内 [data-tip] 元素 hover 显示 */ function bindTooltip(container, fmt) { if (!container || container._tipBound) return; container._tipBound = true; const render = typeof fmt === 'function' ? fmt : (el) => el.getAttribute('data-tip'); container.addEventListener('mouseover', (e) => { const el = e.target.closest('[data-tip]'); if (!el || !container.contains(el)) return; const html = render(el); if (html) tooltipApi.show(html, e.clientX, e.clientY); }); container.addEventListener('mousemove', (e) => { if (tooltipApi.el && tooltipApi.el.style.opacity === '1') tooltipApi.move(e.clientX, e.clientY); }); container.addEventListener('mouseout', (e) => { if (!e.relatedTarget || !container.contains(e.relatedTarget)) tooltipApi.hide(); }); } // ============================================================================= // CSV 导出 // ========================================================================== /** 带 BOM + 时间戳文件名,对照 friend-manager downloadCSV(1.5.3.js:4393) */ function exportCSV(headers, rows, name) { try { const esc2 = (v) => { const s = String(v == null ? '' : v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const csv = [headers.map(esc2).join(',')] .concat(rows.map(r => r.map(esc2).join(','))) .join('\r\n'); const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${name || 'sglv-ptw-export'}-${ts}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); return true; } catch (e) { console.error('[PTW] 导出失败', e); return false; } } // ============================================================================= // CSS // ========================================================================== const CSS = ` .${NS}overlay{position:fixed!important;inset:0!important;z-index:${Z_INDEX.overlay}!important;display:flex!important;align-items:center!important;justify-content:center!important;background:var(--sglv-scrim);backdrop-filter:blur(3px);animation:sglv-ptw-fade-in .18s var(--sglv-ease);} .${NS}win{position:relative!important;display:flex!important;flex-direction:column;background:var(--sglv-bg);border:1px solid var(--sglv-border);border-radius:var(--sglv-r-xl);box-shadow:var(--sglv-shadow-lg);overflow:hidden;transition:width .22s var(--sglv-ease),height .22s var(--sglv-ease),box-shadow var(--sglv-dur) var(--sglv-ease);animation:sglv-ptw-fade-in .22s var(--sglv-ease);} .${NS}win::after{content:'';position:absolute;inset:0;pointer-events:none;border-radius:var(--sglv-r-xl);box-shadow:inset 0 1px 0 var(--sglv-border-subtle);} .${NS}head{display:flex;align-items:center;gap:var(--sglv-sp-3);padding:var(--sglv-sp-3) var(--sglv-sp-4);border-bottom:1px solid var(--sglv-border);user-select:none;flex-shrink:0;cursor:grab;transition:background var(--sglv-dur) var(--sglv-ease);} .${NS}head:hover{background:var(--sglv-surface-1);} .${NS}head.dragging{cursor:grabbing;background:var(--sglv-surface-2);} .${NS}title{display:flex;align-items:center;gap:var(--sglv-sp-2);font-size:var(--sglv-fs-lg);font-weight:600;color:var(--sglv-text);letter-spacing:.2px;} .${NS}spacer{flex:1;} .${NS}btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:var(--sglv-r-sm);border:1px solid transparent;background:transparent;color:var(--sglv-text-secondary);cursor:pointer;transition:background var(--sglv-dur-fast) var(--sglv-ease),color var(--sglv-dur-fast) var(--sglv-ease),border-color var(--sglv-dur-fast) var(--sglv-ease),transform var(--sglv-dur-fast) var(--sglv-ease);} .${NS}btn:hover{background:var(--sglv-accent-soft);color:var(--sglv-text);border-color:var(--sglv-border-subtle);box-shadow:0 0 12px var(--sglv-accent-wash);} .${NS}btn:active{transform:scale(.92);} .${NS}btn[data-act="close"]:hover{background:var(--sglv-danger-wash);color:var(--sglv-danger);border-color:var(--sglv-danger-soft);box-shadow:0 0 12px var(--sglv-danger-wash);} .${NS}btn svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;transition:transform var(--sglv-dur) var(--sglv-ease);} .${NS}btn:hover svg{transform:rotate(-8deg);} .${NS}btn[data-act="close"]:hover svg{transform:rotate(90deg);} .${NS}tabs{display:flex;gap:2px;padding:var(--sglv-sp-2) var(--sglv-sp-3) 0;border-bottom:1px solid var(--sglv-border);overflow-x:auto;flex-shrink:0;background:linear-gradient(180deg,var(--sglv-surface-2),transparent);position:relative;} .${NS}tabs::after{content:'';position:absolute;left:0;right:0;bottom:0;height:1px;background:linear-gradient(90deg,transparent,var(--sglv-border),transparent);pointer-events:none;} .${NS}tab{display:inline-flex;align-items:center;gap:var(--sglv-sp-2);padding:var(--sglv-sp-2) var(--sglv-sp-4);border:none;border-radius:var(--sglv-r-sm) var(--sglv-r-sm) 0 0;background:transparent;color:var(--sglv-text-secondary);font-size:var(--sglv-fs-base);cursor:pointer;white-space:nowrap;position:relative;transition:background var(--sglv-dur-fast) var(--sglv-ease),color var(--sglv-dur-fast) var(--sglv-ease);} .${NS}tab:hover{background:var(--sglv-accent-wash);color:var(--sglv-text);} .${NS}tab.active{background:var(--sglv-accent-soft);color:var(--sglv-accent);font-weight:600;} .${NS}tab.active::after{content:'';position:absolute;left:12px;right:12px;bottom:-1px;height:2px;background:var(--sglv-accent);border-radius:2px;box-shadow:0 0 8px var(--sglv-accent-glow);} .${NS}tab svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;} /* v1.0.1 修复双滚动条 —— body 改为 flex 列容器 + overflow:hidden, 让内部 pane/grid/list 用 flex:1 + min-height:0 撑满高度并自己管滚动, 整窗不会出现 body 滚动条叠加 list 滚动条 */ .${NS}body{flex:1;min-height:0;overflow:hidden;position:relative;display:flex;flex-direction:column;} .${NS}pane{display:none;padding:var(--sglv-sp-4);min-height:0;flex-direction:column;} .${NS}pane.active{display:flex;flex:1;overflow:hidden;} /* Tab 内容根容器建议基座 —— Tab 内部 grid/list 通常是 pane 的直接子项, 缺少 min-height:0 时 flex 子项会按内容撑开导致内容超界 / 父级滚动条被遮, 这里统一兜底(不强制覆盖,业务 Tab 已声明的不影响) */ .${NS}pane.active > *{min-height:0;} /* v1.0.4: 错误/降级卡走 token + 渐变底 + amber 边框,视觉与 toast 风格统一 */ .${NS}err,.${NS}degraded{padding:var(--sglv-sp-6) var(--sglv-sp-5);text-align:center;color:var(--sglv-text-secondary);background:radial-gradient(circle at top,var(--sglv-amber-wash),transparent 70%);border:1px dashed var(--sglv-amber-soft);border-radius:var(--sglv-r-md);margin:var(--sglv-sp-3) auto;max-width:520px;} .${NS}degraded{background:radial-gradient(circle at top,var(--sglv-accent-wash),transparent 70%);border-color:var(--sglv-accent-soft);} .${NS}err-title,.${NS}degraded-title{font-size:14px;font-weight:600;color:var(--sglv-amber);margin-bottom:var(--sglv-sp-2);display:flex;align-items:center;justify-content:center;gap:6px;} .${NS}degraded-title{color:var(--sglv-accent);} .${NS}err-desc,.${NS}degraded-desc{font-size:var(--sglv-fs-md);line-height:1.7;opacity:.85;margin-top:4px;} /* T5.1 设置与诊断卡 v1.0.4 升级:surface-elevated + shadow-md + inset 高光 + rounded token */ .${NS}settings-mask{position:absolute;inset:0;background:var(--sglv-scrim-strong,var(--sglv-scrim));z-index:1;animation:sglv-ptw-fade-in .18s var(--sglv-ease);} .${NS}settings-card{position:absolute;top:52px;right:var(--sglv-sp-4);bottom:var(--sglv-sp-4);width:min(380px,92%);z-index:2;overflow-y:auto;background:var(--sglv-surface-elevated);border:1px solid var(--sglv-border);border-radius:var(--sglv-r-lg);padding:var(--sglv-sp-3) var(--sglv-sp-4);box-shadow:var(--sglv-shadow-md),inset 0 1px 0 var(--sglv-border-subtle);animation:sglv-ptw-fade-in .22s var(--sglv-ease);} .${NS}settings-title{display:flex;align-items:center;gap:var(--sglv-sp-2);font-size:14px;font-weight:700;color:var(--sglv-text);margin-bottom:var(--sglv-sp-3);padding-bottom:var(--sglv-sp-2);border-bottom:1px solid var(--sglv-border-subtle);} .${NS}settings-x{margin-left:auto;width:26px;height:26px;border:1px solid transparent;background:var(--sglv-surface-2);border-radius:var(--sglv-r-xs);color:var(--sglv-text-secondary);font-size:16px;cursor:pointer;transition:background var(--sglv-dur-fast) var(--sglv-ease),color var(--sglv-dur-fast) var(--sglv-ease),transform var(--sglv-dur-fast) var(--sglv-ease);} .${NS}settings-x:hover{color:var(--sglv-text);background:var(--sglv-surface-3);transform:rotate(90deg);} .${NS}settings-x:active{transform:rotate(90deg) scale(.9);} .${NS}settings-sec{margin-bottom:var(--sglv-sp-3);padding:var(--sglv-sp-2) var(--sglv-sp-3);border:1px solid var(--sglv-border-subtle);border-radius:var(--sglv-r-md);background:var(--sglv-surface-1);box-shadow:inset 0 1px 0 var(--sglv-border-subtle);} .${NS}settings-h{font-size:var(--sglv-fs-sm);font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--sglv-text-secondary);margin-bottom:var(--sglv-sp-2);} .${NS}settings-row{display:flex;justify-content:space-between;gap:var(--sglv-sp-2);font-size:var(--sglv-fs-md);color:var(--sglv-text);padding:3px 0;} .${NS}settings-row.warn{color:var(--sglv-amber);} .${NS}settings-btn{width:100%;margin-top:var(--sglv-sp-2);padding:var(--sglv-sp-2) var(--sglv-sp-3);border-radius:var(--sglv-r-sm);border:1px solid var(--sglv-border);background:var(--sglv-accent-soft);color:var(--sglv-accent);font-size:var(--sglv-fs-md);cursor:pointer;transition:background var(--sglv-dur-fast) var(--sglv-ease),border-color var(--sglv-dur-fast) var(--sglv-ease),box-shadow var(--sglv-dur-fast) var(--sglv-ease);} .${NS}settings-btn:hover:not(:disabled){background:var(--sglv-accent-strong);border-color:var(--sglv-accent-strong);box-shadow:0 0 12px var(--sglv-accent-wash);} .${NS}settings-btn:disabled{opacity:.4;cursor:not-allowed;} .${NS}settings-btn.danger{border-color:var(--sglv-danger-strong);background:var(--sglv-danger-wash);color:var(--sglv-danger);} .${NS}settings-btn.danger:hover:not(:disabled){background:var(--sglv-danger-soft);box-shadow:0 0 12px var(--sglv-danger-wash);} .${NS}settings-note{margin-top:6px;font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);line-height:1.6;} .${NS}settings-pre{max-height:150px;overflow:auto;margin:0;font-size:10.5px;line-height:1.6;color:var(--sglv-text-secondary);white-space:pre-wrap;word-break:break-all;background:var(--sglv-code-bg);border-radius:var(--sglv-r-sm);padding:var(--sglv-sp-2);} .${NS}err-stack{margin-top:var(--sglv-sp-3);padding:var(--sglv-sp-2) var(--sglv-sp-3);background:var(--sglv-code-bg);border-radius:var(--sglv-r-sm);font-family:ui-monospace,monospace;font-size:var(--sglv-fs-sm);text-align:left;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-all;border:1px solid var(--sglv-border-subtle);} .${NS}retry{margin-top:var(--sglv-sp-3);padding:var(--sglv-sp-2) var(--sglv-sp-4);border-radius:var(--sglv-r-sm);border:1px solid var(--sglv-accent-strong);background:var(--sglv-accent-soft);color:var(--sglv-accent);font-size:var(--sglv-fs-base);cursor:pointer;font-weight:600;transition:background var(--sglv-dur-fast) var(--sglv-ease),border-color var(--sglv-dur-fast) var(--sglv-ease),box-shadow var(--sglv-dur-fast) var(--sglv-ease),transform var(--sglv-dur-fast) var(--sglv-ease);} .${NS}retry:hover{background:var(--sglv-accent-strong);border-color:var(--sglv-accent);box-shadow:0 0 14px var(--sglv-accent-wash);} .${NS}retry:active{transform:scale(.96);} .${NS}soft-missing{padding:var(--sglv-sp-3);margin:var(--sglv-sp-2) 0;border:1px dashed var(--sglv-amber-strong);border-radius:var(--sglv-r-md);color:var(--sglv-amber);font-size:var(--sglv-fs-md);text-align:center;background:var(--sglv-amber-wash);} /* v2.22.2: 旧 1280px 断点的 !important 强制 96vw/92vh 已移除——与每 Tab 记忆尺寸冲突; 小屏越界由 clampWinToViewport() 统一钳制 */ /* T5.2 窄屏:Tab 栏横向滚动不换行;头部按钮压缩;设置卡改全宽贴底 */ @media (max-width:820px){ .${NS}tab{padding:7px 10px;font-size:var(--sglv-fs-md);} .${NS}pane{padding:12px;} .${NS}tabs{overflow-x:auto;flex-wrap:nowrap;-webkit-overflow-scrolling:touch;scrollbar-width:thin;} .${NS}tab{flex-shrink:0;} .${NS}head{padding:8px 10px;} .${NS}title span{max-width:38vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .${NS}settings-card{left:12px;right:12px;top:auto;bottom:12px;width:auto;max-height:70vh;} /* v1.0.5: 公共 hover 类 —— 4 处 panel/row 共享 transition + box-shadow + ::before 渐变光晕 + translateY; is-amber 变体覆盖主题色(amber 替代 accent),is-flat 取消 translateY(picker/toolbar 用) 改写后预计 CSS 体积 -6KB(原 9 处重复 transition+box-shadow+::before 折叠为 1 处) */ .${NS}hover{ --_h-border:var(--sglv-accent-soft); --_h-glow:var(--sglv-accent-wash); --_h-y:-1px; position:relative;overflow:hidden; box-shadow:var(--sglv-shadow-sm),inset 0 1px 0 var(--sglv-border-subtle); transition:border-color var(--sglv-dur) var(--sglv-ease),box-shadow var(--sglv-dur) var(--sglv-ease),transform var(--sglv-dur) var(--sglv-ease); } .${NS}hover::before{ content:'';position:absolute;inset:-1px;pointer-events:none; border-radius:inherit; background:linear-gradient(135deg,var(--_h-border),transparent 60%); opacity:0;transition:opacity var(--sglv-dur) var(--sglv-ease); } .${NS}hover:hover{ transform:translateY(var(--_h-y)); border-color:var(--_h-border); box-shadow:var(--sglv-shadow-md),0 0 18px var(--_h-glow),inset 0 1px 0 var(--sglv-border-subtle); } .${NS}hover:hover::before{opacity:1;} .${NS}hover > *{position:relative;z-index:1;} .${NS}hover.is-amber{--_h-border:var(--sglv-amber-soft);--_h-glow:var(--sglv-amber-wash);} .${NS}hover.is-flat{--_h-y:0;} /* v1.0.2 浮窗进入动画 keyframes(动画声明挂在 .win 上) */ @keyframes sglv-ptw-fade-in{from{opacity:0;transform:scale(.96) translateY(8px);}to{opacity:1;transform:scale(1) translateY(0);}} `; /* v1.0.5: 5 份 injectCSS 收敛为单行调用 —— 幂等缓存走顶层 INJECTED_STYLE, 共享 injectStyleOnce() 处理 SGLVTheme.ensureStyle 与本地 fallback 两路 */ function injectCSS() { __sglvPtwInjectStyle('sglv-ptw-style', CSS); } // ============================================================================= // 窗口外壳 // ========================================================================== // v2.22.2: 旧 loadWinPos/saveWinPos 已由 loadWinStore/saveWinStore/posForTab 取代 //(每 Tab 独立尺寸记忆 + 旧扁平值兼容迁移,见 toggleMax 替换区的设计注释) function createWindow(opts) { injectCSS(); const o = opts || {}; const pos = posForTab(view && view.tab); // 单实例保护:已打开则 focus,不重复创建(优于 createModal 的"关闭旧的") if (win && document.body.contains(win.overlay)) { win.overlay.style.display = 'flex'; return win; } const wrapId = uid('wrap'); const tabBarId = uid('tabs'); const bodyId = uid('body'); const overlay = document.createElement('div'); overlay.className = `${NS}overlay`; overlay.id = wrapId; const box = document.createElement('div'); box.className = `${NS}win`; box.setAttribute('role', 'dialog'); box.setAttribute('aria-modal', 'true'); box.setAttribute('aria-label', '游戏数据分析'); // 尺寸 / 位置还原 // v2.22.3: 无手动记忆尺寸时按「当前 Tab 档案」取默认(不再统一近全屏) const savedW = pos && pos.w, savedH = pos && pos.h; const def = profileSize(view && view.tab); box.style.width = savedW ? savedW + 'px' : def.w; box.style.height = savedH ? savedH + 'px' : def.h; if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') { // 限视口内,防止上次分辨率变化后位置不可达 box.style.position = 'absolute'; box.style.left = Math.max(4, Math.min(pos.x, window.innerWidth - 200)) + 'px'; box.style.top = Math.max(4, Math.min(pos.y, window.innerHeight - 100)) + 'px'; } // ---- 头部 ---- const head = document.createElement('div'); head.className = `${NS}head`; head.innerHTML = `
${o.icon || ''}${esc(o.title || '游戏数据分析')}
${(o.actions || []).map((a, i) => ``).join('')} `; // ---- Tab 栏 ---- const tabBar = document.createElement('div'); tabBar.className = `${NS}tabs`; tabBar.id = tabBarId; tabBar.setAttribute('role', 'tablist'); // ---- Body ---- const body = document.createElement('div'); body.className = `${NS}body`; body.id = bodyId; box.appendChild(head); box.appendChild(tabBar); box.appendChild(body); overlay.appendChild(box); document.body.appendChild(overlay); // v2.22.2: 记忆尺寸可能来自更大的旧分辨率/旧 Tab,插入 DOM 后统一钳制回视口 clampWinToViewport(); win = { overlay, box, head, tabBar, body, mounted: {} }; // ---- 事件 ---- head.addEventListener('click', (e) => { const btn = e.target.closest('[data-act]'); if (!btn) return; const act = btn.getAttribute('data-act'); if (act === 'close') close(); else if (act === 'settings') openSettings(); else { const cb = (o.actions || []).find(a => a.key === act); if (cb && cb.onClick) { try { cb.onClick(api); } catch (err) { console.error(`[PTW] 动作 ${act} 异常`, err); } } } }); // 熔断卡「重试」按钮委托(body 级一次性绑定) body.addEventListener('click', (e) => { const retry = e.target.closest('[data-breaker-key]'); if (!retry) return; const key = retry.getAttribute('data-breaker-key'); breakerReset(key); const el = win.mounted[key]; if (el) { delete win.mounted[key]; el.remove(); } switchTab(key); }); enableDrag(head, box); overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); }); renderTabs(); return win; } /** 拖拽移动 + 位置记忆(friend-manager createModal 缺失项,此处补齐) */ function enableDrag(handle, box) { let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0; handle.addEventListener('mousedown', (e) => { if (e.target.closest('button')) return; // 点按钮不拖 if (box.style.position !== 'absolute') { const r = box.getBoundingClientRect(); box.style.position = 'absolute'; box.style.left = r.left + 'px'; box.style.top = r.top + 'px'; box.style.right = 'auto'; box.style.bottom = 'auto'; } dragging = true; sx = e.clientX; sy = e.clientY; ox = box.offsetLeft; oy = box.offsetTop; handle.classList.add('dragging'); e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const nx = Math.max(0, Math.min(window.innerWidth - 120, ox + e.clientX - sx)); const ny = Math.max(0, Math.min(window.innerHeight - 40, oy + e.clientY - sy)); box.style.left = nx + 'px'; box.style.top = ny + 'px'; }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; handle.classList.remove('dragging'); // v2.22.2: 拖拽结束按「当前 Tab」持久化几何——每 Tab 独立尺寸记忆 const s = loadWinStore(); const entry = { x: box.offsetLeft, y: box.offsetTop, w: box.offsetWidth, h: box.offsetHeight }; if (view && view.tab) s.byTab[view.tab] = entry; else s.default = entry; saveWinStore(s); }); } // ============================================================================= // v2.22.2: 每 Tab 独立窗口尺寸(替代已移除的全屏/最大化按钮) // ----------------------------------------------------------------------------- // 设计: // ① 头部不再有「最大化」按钮——全屏布局对 8 个内容形态各异的 Tab 都是破坏性的, // 且还原逻辑脆弱(历史上两次事故)。改为「每个 Tab 记住自己的尺寸」: // 用户把某 Tab 拖到合适大小后,切走再切回尺寸保持。 // ② 存储结构升级:WIN_POS_KEY 值从扁平 {x,y,w,h} 变为 // { byTab: { tabKey: {x,y,w,h} }, default: {x,y,w,h} }; // 读取时兼容旧扁平值(视为 default),老用户无感迁移。 // ③ 切换 Tab 时只在该 Tab 有记忆尺寸时才应用(无记忆保持当前尺寸,不跳动); // 位置全局共享,切换后钳制回视口内。 // ============================================================================= // v2.22.3: 每 Tab 默认宽度档案——内容形态决定合理宽度,不再统一近全屏。 // 入库月历/全部游戏=宽(12 月卡网格/密集列表);成就/价值=中(图表+面板); // 用户拖拽后的尺寸仍按 Tab 记忆,档案只是「未手动调整时」的默认值。 const TAB_SIZE_PROFILES = { overview: { w: 980, h: 0.86 }, all: { w: 1080, h: 0.88 }, calendar: { w: 1240, h: 0.86 }, family: { w: 1080, h: 0.84 }, achievements: { w: 900, h: 0.84 }, value: { w: 940, h: 0.84 }, streak: { w: 960, h: 0.86 }, }; const DEFAULT_PROFILE = { w: 1000, h: 0.85 }; /** Tab 默认尺寸字符串(宽 = 档案值封顶 94vw;高 = 视口比例封顶 900px) */ function profileSize(tabKey) { const p = TAB_SIZE_PROFILES[tabKey] || DEFAULT_PROFILE; return { w: `min(${p.w}px, 94vw)`, h: `min(${Math.round(window.innerHeight * p.h)}px, 900px)`, }; } /** 读取规范化存储:{ byTab: {}, default: null };兼容旧扁平 {x,y,w,h} */ function loadWinStore() { try { const raw = store.get(WIN_POS_KEY, ''); if (!raw) return { byTab: {}, default: null }; const obj = JSON.parse(raw); if (obj && typeof obj === 'object' && obj.byTab) { return { byTab: obj.byTab || {}, default: obj.default || null }; } // 旧版扁平值:视为全局默认 if (obj && typeof obj.w === 'number') return { byTab: {}, default: obj }; } catch (e) { /* 损坏即重置 */ } return { byTab: {}, default: null }; } function saveWinStore(storeObj) { try { store.set(WIN_POS_KEY, JSON.stringify(storeObj)); } catch (e) { /* ignore */ } } /** 当前 Tab 的记忆位置(无则回退全局默认) */ function posForTab(tabKey) { const s = loadWinStore(); return (tabKey && s.byTab[tabKey]) || s.default || null; } /** 把窗口尺寸钳制回视口内(切 Tab 变尺寸 / 分辨率变化后调用) */ function clampWinToViewport() { if (!win) return; const box = win.box; const maxW = Math.max(360, window.innerWidth - 24); const maxH = Math.max(280, window.innerHeight - 24); if (box.offsetWidth > maxW) box.style.width = maxW + 'px'; if (box.offsetHeight > maxH) box.style.height = maxH + 'px'; } // ============================================================================= // Tab 渲染与切换 // ========================================================================== function renderTabs() { if (!win) return; // v2.22.2: hidden Tab 不进浮窗 Tab 栏(view 恢复到被隐藏 Tab 时回落首个可用) const list = sortedTabs().filter(t => !t.hidden && tabStatus(t).available); // 恢复的 tab 不可用时回落到首个可用 if (!list.some(t => t.key === view.tab)) view.tab = (list[0] || {}).key || null; // T5.2 aria:容器 tablist;tab 携带 aria-controls/aria-selected/tabindex(roving) win.tabBar.setAttribute('role', 'tablist'); win.tabBar.innerHTML = list.map(t => { const icon = typeof t.icon === 'function' ? t.icon() : (t.icon || ''); const active = t.key === view.tab; return ``; }).join(''); win.tabBar.onclick = (e) => { const btn = e.target.closest('[data-key]'); if (btn) switchTab(btn.getAttribute('data-key')); }; // T5.2 键盘导航:←/→ 循环移动焦点并切换,Home/End 跳边界 win.tabBar.onkeydown = (e) => { const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End']; if (!keys.includes(e.key)) return; const btns = $$('[data-key]', win.tabBar); if (!btns.length) return; const idx = btns.findIndex(b => b.getAttribute('data-key') === view.tab); let next = idx; if (e.key === 'ArrowRight') next = (idx + 1) % btns.length; else if (e.key === 'ArrowLeft') next = (idx - 1 + btns.length) % btns.length; else if (e.key === 'Home') next = 0; else next = btns.length - 1; const key = btns[next].getAttribute('data-key'); switchTab(key); const target = btns[next]; if (target && typeof target.focus === 'function') target.focus(); e.preventDefault(); }; } /** T5.2:pane id 规则固定(与 tab 的 aria-controls 对应),不再用自增 uid */ function paneIdFor(key) { return `${NS}pane-${key}`; } // ---- T5.3 熔断器:同一 Tab 连续 mount 失败 ≥ N 次即熔断,不再反复尝试 ---- // 为什么需要:mount 抛错的 Tab 每次切换都会重试 —— 若失败原因是确定性的 // (如数据契约变更、依赖缺失的边缘形态),用户每切一次就看到一次报错卡, // 且 mount 内可能有半成品 DOM 副作用。熔断后改为稳定提示 + 手动重试入口。 const BREAKER_THRESHOLD = 3; const breaker = {}; // key → 连续失败次数;mount 成功即清零 function breakerTripped(key) { return (breaker[key] || 0) >= BREAKER_THRESHOLD; } function breakerRecord(key) { breaker[key] = (breaker[key] || 0) + 1; } function breakerReset(key) { delete breaker[key]; } /** 懒挂载:Tab 首次切换才 mount,重复切换复用 */ function ensurePane(tab) { if (win.mounted[tab.key]) return win.mounted[tab.key]; const el = document.createElement('div'); el.className = `${NS}pane`; el.id = paneIdFor(tab.key); el.setAttribute('role', 'tabpanel'); el.setAttribute('aria-labelledby', `${NS}tab-${tab.key}`); win.body.appendChild(el); win.mounted[tab.key] = el; // 熔断中:不再尝试 mount,展示稳定提示(避免重复副作用) if (breakerTripped(tab.key)) { el.innerHTML = renderBreakerCard(tab.key); return el; } try { tab.mount(el, buildContext()); breakerReset(tab.key); // 本次成功 → 清零连续失败计数 } catch (e) { breakerRecord(tab.key); console.error(`[PTW] Tab "${tab.key}" mount 失败(${breaker[tab.key]}/${BREAKER_THRESHOLD})`, e); el.innerHTML = breakerTripped(tab.key) ? renderBreakerCard(tab.key) : renderErrorCard(tab.key, e); diagnostics.push({ tab: tab.key, error: e.message, breaker: breaker[tab.key] }); } return el; } /** 熔断卡:说明 + 手动重试按钮(清零后重挂) */ function renderBreakerCard(key) { const tab = getTab(key); return `
「${esc((tab && tab.label) || key)}」连续加载失败,已暂时停用
连续 ${BREAKER_THRESHOLD} 次 mount 异常,为避免反复报错已熔断。
可点击下方按钮重试;若持续失败请更新或反馈脚本版本。
`; } function switchTab(key) { if (!win) return; const tab = getTab(key); if (!tab) return; const st = tabStatus(tab); if (!st.available) { ensureDegradedPane(tab, st); return; } setView({ tab: key }, { notify: false }); renderTabs(); // ⚠️ P0 修复(v2.21.1 审核后冒烟仍漏网 → 真机灰度发现): // 原写法 $$('.${NS}pane', ...) 是**单引号**字符串,${NS} 不插值, // 真实浏览器 querySelectorAll 收到字面 `.${NS}pane` → SyntaxError → // switchTab 整体中断、所有 Tab 无法切换。冒烟 stub 的 querySelectorAll // 不解析 CSS 语法所以未拦截。教训:**stub 必须尽量贴近真实行为**。 $$(`.${NS}pane`, win.body).forEach(p => p.classList.remove('active')); const pane = ensurePane(tab); pane.classList.add('active'); tooltipApi.hide(); win.body.scrollTop = 0; // v2.22.3: 切 Tab 应用该 Tab 的记忆尺寸;无手动记忆时按该 Tab 档案取默认—— // 不同 Tab 内容形态不同,宽度理应不同(月历/排行宽,成就/价值中) const savedPos = loadWinStore().byTab[key]; const size = (savedPos && savedPos.w && savedPos.h) ? { w: savedPos.w + 'px', h: savedPos.h + 'px' } : profileSize(key); win.box.style.width = size.w; win.box.style.height = size.h; clampWinToViewport(); // T5.2:焦点回归当前 tab(键盘/读屏可达) const curTabBtn = $(`#${NS}tab-${key}`, win.tabBar); if (curTabBtn && typeof curTabBtn.getAttribute === 'function') { curTabBtn.setAttribute('tabindex', '0'); } } function ensureDegradedPane(tab, st) { const el = ensurePane(tab); $$(`.${NS}pane`, win.body).forEach(p => p.classList.remove('active')); el.innerHTML = renderDegradedCard(tab, st); el.classList.add('active'); } function renderDegradedCard(tab, st) { return `
该数据源不可用
缺失能力:${esc(st.missingHard.join(', '))}
请更新脚本版本,或检查 Steam API Key 配置
`; } function renderErrorCard(key, e) { return `
Tab "${esc(key)}" 渲染失败
${esc(e && e.message ? e.message : String(e))}
${esc((e && e.stack ? e.stack : '').slice(0, 1200))}
`; } function close() { if (!win) return; tooltipApi.hide(); win.overlay.remove(); win = null; diagnostics.push({ event: 'close' }); if (diagnostics.length > 50) diagnostics.shift(); } // ============================================================================= // 诊断 // ========================================================================== const diagnostics = []; function diagnose() { return { framework: { loaded: true, version: '1.1.0', apiVersion: API_VERSION }, host: { apiVersion: host ? host.apiVersion : null, fnCount: Object.values(hostInspect).filter(v => v === 'fn').length, missing: host ? probe(FRAME_REQUIRED) : [''], }, registeredTabs: sortedTabs().map(tabStatus), unavailableTabs: sortedTabs().filter(t => !tabStatus(t).available).map(t => t.key), // T5.3:熔断状态随诊断暴露,便于控制台排查 breakers: Object.keys(breaker).map(k => ({ tab: k, fails: breaker[k], tripped: breakerTripped(k) })), currentTab: view.tab, winOpen: !!(win && document.body.contains(win.overlay)), diagnostics: diagnostics.slice(-20), }; } // ============================================================================= // T5.1 设置与诊断卡片 // ============================================================================= /** 设置卡状态存 view 状态表(持久化),供下次打开还原 */ function openSettings() { if (!win) return; const d = diagnose(); const brk = d.breakers || []; const hist = (host && host.playtimeHistory) || null; const sampledDays = hist && typeof hist.days === 'function' ? hist.days().length : 0; const el = document.createElement('div'); el.className = `${NS}settings`; el.innerHTML = `
`; win.box.appendChild(el); const card = el.querySelector(`.${NS}settings-card`); const closeMe = () => el.remove(); const x = el.querySelector(`.${NS}settings-x`); if (x) x.addEventListener('click', closeMe); const mask = el.querySelector(`.${NS}settings-mask`); if (mask) mask.addEventListener('click', closeMe); el.addEventListener('click', (e) => { const btn = e.target.closest('[data-set]'); if (!btn) return; const act = btn.getAttribute('data-set'); if (act === 'breaker') { Object.keys(breaker).forEach(breakerReset); // 已挂载的熔断 pane 重建 Object.keys(win.mounted).forEach(k => { if (breakerTripped(k)) return; }); Object.values(win.mounted).forEach(p => { if (p && p.parentNode) p.remove(); }); win.mounted = {}; closeMe(); switchTab(view.tab); } else if (act === 'hist') { if (hist && typeof hist.clear === 'function') { hist.clear(); closeMe(); Object.values(win.mounted).forEach(p => { if (p && p.parentNode) p.remove(); }); win.mounted = {}; switchTab('streak'); } } }); } // 框架自身最低要求(主 IIFE 直连能力,Phase 0 已验证零缺件) const FRAME_REQUIRED = [ 'getOwnedGames', 'T', 'ICONS', 'escHtml', 'SGLVCharts', 'cacheGet', 'cacheSet', 'renderPagination', 'sglvSmartPool', ]; // ============================================================================= // 对外 API // ========================================================================== const api = { version: '1.0.0', apiVersion: API_VERSION, init(h) { if (!h || typeof h !== 'object') { console.warn('[PTW] init: 无宿主对象'); return false; } if (h.apiVersion !== API_VERSION) { console.warn(`[PTW] apiVersion 不匹配 (宿主 ${h.apiVersion} / 框架 ${API_VERSION})`); return false; } host = h; hostInspect = typeof h._inspect === 'function' ? h._inspect() : {}; restoreView(); console.log(`[PTW] 框架 init 完成 (宿主能力 ${Object.keys(hostInspect).length} 项)`); return true; }, isAvailable() { return !!host; }, registerTab, open(opts) { if (!host) { console.warn('[PTW] 宿主未就绪,无法打开浮窗'); return false; } const missing = probe(FRAME_REQUIRED); if (missing.length) { console.warn('[PTW] 框架最低能力缺失:', missing); return false; } // 记录本次打开参数:opts.games 可覆盖 Tab 的游戏集(缓存兜底场景) openOpts = opts || {}; if (openOpts.tab) setView({ tab: openOpts.tab }, { notify: false }); createWindow(openOpts); renderTabs(); if (view.tab) switchTab(view.tab); return true; }, close, switchTab, hideTab, /** * 外部更新游戏集(如主脚本 state.ownedGames 异步加载完毕)—— * 用新集合重建 openOpts.games,并在浮窗已打开时刷新当前 Tab。 * 不打开新浮窗、不重置 view(保留用户的搜索/排序/分页/隐藏选项)。 * @param {Array} games 新游戏集(不传则仅按 host.getOwnedGames() 重取) * @returns {boolean} 是否成功 */ refreshGames(games) { if (!host) return false; if (Array.isArray(games)) { openOpts.games = games.slice().sort((a, b) => (b.playtime || 0) - (a.playtime || 0)); } else { // 不传新数组 → 清掉缓存透传,让 ctx.getGames() 回退到 host.getOwnedGames() openOpts.games = null; } // 浮窗已打开则重建当前 Tab 的 DOM,新数据即刻生效 if (win && win.body && document.body.contains(win.overlay)) { const cur = view && view.tab; if (cur && win.mounted[cur]) { const paneEl = win.mounted[cur]; delete win.mounted[cur]; if (paneEl.parentNode) paneEl.parentNode.removeChild(paneEl); try { switchTab(cur); } catch (e) { /* 重建失败不影响浮窗 */ } try { host.showToast && host.showToast( (host.isZh ? '游戏数据已更新' : 'Game data refreshed'), 'info' ); } catch (e) { /* toast 失败不影响 */ } } } return true; }, /** * 把某个 Tab 的内容挂到任意容器(不开浮窗)。 * 用途:主面板「游玩数据」Tab 与浮窗 overview Tab 共用同一份渲染逻辑, *避免主脚本与 Tab 库各存一份 renderPlaydata* 实现(历史上已出现过双份漂移)。 * 返回 true/false;失败时容器内插入降级说明,不抛异常。 */ mountInto(key, container, ctxPatch) { if (!container) return false; const tab = getTab(key); if (!tab) { container.innerHTML = `
Tab "${esc(key)}" 未注册
请确认对应 PTW Tab 库已通过 @require 加载
`; return false; } const st = tabStatus(tab); if (!st.available) { container.innerHTML = `
该数据源不可用
缺失能力:${esc(st.missingHard.join(', '))}
`; return false; } try { injectCSS(); tab.mount(container, Object.assign(buildContext(), ctxPatch || {})); return true; } catch (e) { console.error(`[PTW] mountInto("${key}") 失败`, e); container.innerHTML = renderErrorCard(key, e); diagnostics.push({ tab: key, phase: 'mountInto', error: e.message }); return false; } }, getView: () => Object.assign({}, view), setView, subscribe, probe, resolve, bindTooltip, exportCSV, diagnose, retryTab(key) { const tab = getTab(key); if (!tab || !win) return; const st = tabStatus(tab); if (st.available) { switchTab(key); return; } ensureDegradedPane(tab, st); }, /** 供 Tab 注册(暴露为 window.SGLVPTW) */ NS, }; root.SGLVPTW = api; // 同步挂 unsafeWindow(部分 sandbox 环境) if (typeof unsafeWindow !== 'undefined' && unsafeWindow !== root) { try { unsafeWindow.SGLVPTW = api; } catch (e) { /* ignore */ } } })(typeof window !== 'undefined' ? window : globalThis); /* ---------- sglv-ptw-tab-overview.lib.js ---------- */ /* ============================================================================= * SGLV PTW · Tab: 总览 (Overview) * ----------------------------------------------------------------------------- * 迁移自主脚本 4 个 renderPlaydata* 函数(v2.20.0 :15615-15846,199 行) * renderPlaydataHero :15615-15650 HERO 5 KPI * renderPlaydataConcentration :15653-15697 时长集中度 Pareto 折线 * renderPlaydataActivity :15726-15788 活跃度分层(可点击钻取) * renderPlaydataTopSection :15792-15846 Top 20 * + PT_ACTIVITY_LAYERS / ptActivityLayerOf / ptActivityLayerLabel 常量与判定 * * 迁移纪律:纯搬运不改逻辑。依赖经 ctx.host 注入。 * * ⭐ 本 Tab 的钻取联动是 Phase 2 的核心价值: * 原实现点击分层行 → openPlaytimeAllWindow(重开浮窗); * 此处改为 ctx.goTo('all', {layerFilter, sortKey}) —— 浮窗不重开,状态保留,可来回切换。 * ========================================================================== */ (function (root) { 'use strict'; // ---- 活跃度分层定义(原 :15700-15708)---- // v1.0.4: 分层色全部走 token,三套主题(dark/purple/light)自动适配 const PT_ACTIVITY_LAYERS = [ { key: 'd7', maxDays: 7, color: 'var(--sglv-success)' }, { key: 'd30', maxDays: 30, color: 'var(--sglv-lime)' }, { key: 'd90', maxDays: 90, color: 'var(--sglv-accent)' }, { key: 'y1', maxDays: 365, color: 'var(--sglv-amber-light)' }, { key: 'y3', maxDays: 1095, color: 'var(--sglv-orange)' }, { key: 'old', maxDays: Infinity, color: 'var(--sglv-danger)' }, { key: 'never', maxDays: -1, color: '#64748b' }, ]; function ptActivityLayerOf(g, isZh) { const lp = g.lastPlayed || 0; if (!lp) return 'never'; const days = (Date.now() / 1000 - lp) / 86400; for (const L of PT_ACTIVITY_LAYERS) { if (L.maxDays < 0) continue; if (days <= L.maxDays) return L.key; } return 'old'; } function ptActivityLayerLabel(key, isZh) { const zh = { d7: '近 7 天', d30: '近 30 天', d90: '近 90 天', y1: '近 1 年', y3: '1-3 年', old: '3 年以上', never: '从未游玩' }; const en = { d7: 'Last 7 days', d30: 'Last 30 days', d90: 'Last 90 days', y1: 'Last year', y3: '1-3 years', old: '3+ years', never: 'Never played' }; return (isZh ? zh : en)[key] || key; } // ========================================================================== function mount(container, ctx) { const H = ctx.host; const T = H.T; const ICONS = H.ICONS; const isZh = H.isZh; const escHtml = H.escHtml; const Charts = H.SGLVCharts; const games = ctx.getGames() || []; const sortedGames = games.slice().sort((a, b) => (b.playtime || 0) - (a.playtime || 0)); const totalMin = games.reduce((s, g) => s + (g.playtime || 0), 0); // ---- HERO 5 KPI ---- function renderHero(wrap) { const stats = H.computePlaytimeStats(games); const top = sortedGames[0] || null; const topH = top && top.playtime ? (top.playtime / 60).toFixed(1) : '0.0'; // 中位数口径:按「已游玩游戏」取中位,而非全库中位(后者在未游玩过半时恒为 0) const playedGames = games.filter(g => (g.playtime || 0) > 0); const medianH = playedGames.length > 0 ? (H.ptPlaytimeMedian(playedGames) / 60).toFixed(1) : '—'; const medianSub = playedGames.length > 0 ? T.ptHeroMedianSub.replace('{n}', String(playedGames.length)) : T.ptHeroMedianNone; const heroRow = document.createElement('div'); heroRow.className = 'sglv-pt-hero-kpi'; heroRow.innerHTML = H.kpiCards([ { icon: ICONS.clock, label: T.ptTotalHours, value: stats.totalHours.toFixed(1), unit: 'h', sub: `${games.length} ${isZh ? '款游戏' : 'games'}`, color: 'amber' }, { icon: ICONS.trend, label: T.ptHeroRecent, value: '—', unit: 'h', sub: T.ptHeroRecentSub, color: 'blue' }, { icon: ICONS.trophy, label: T.ptHeroLongest, value: topH, unit: 'h', sub: top ? top.name : '—', color: 'green' }, { icon: ICONS.barChart, label: T.ptHeroMedian, value: medianH, unit: 'h', sub: medianSub, color: 'purple' }, { icon: ICONS.game, label: T.ptPlayRate, value: stats.playRate, unit: '%', sub: `${stats.playedCount}/${stats.totalGames}`, color: '' }, ], { tone: 'playtime' }); wrap.appendChild(heroRow); // 近 2 周:复用展柜最近游玩数据源(无 API Key 时 playtime_2weeks 恒 0 → 保留 "—") const recentCard = heroRow.children[1]; Promise.resolve() .then(() => H.fetchRecentPlayedGames()) .then(list => { if (!recentCard || !recentCard.isConnected) return; const mins = (list || []).reduce((s, g) => s + (g.playtime_2weeks || 0), 0); if (mins <= 0) return; const valEl = recentCard.querySelector('.sglv-playtime-kpi-value'); if (valEl) valEl.textContent = (mins / 60).toFixed(1); }) .catch(() => { }); } // ---- 时长集中度 Pareto ---- function renderConcentration(wrap) { const panel = document.createElement('div'); panel.className = 'sglv-pt-panel'; panel.innerHTML = `
${ICONS.barChart} ${T.ptConcTitle}
`; const body = document.createElement('div'); body.className = 'sglv-pt-panel-body'; panel.appendChild(body); wrap.appendChild(panel); const n = Math.min(sortedGames.length, 100); if (!totalMin || n === 0) { body.innerHTML = H.emptyHtml(T.ptNoData); return; } const points = []; let acc = 0; for (let i = 0; i < n; i++) { acc += sortedGames[i].playtime || 0; points.push(acc / totalMin * 100); } const marks = [1, 5, 10, 20, 50, 100].filter(k => k <= n); const pctAt = k => points[k - 1]; const chips = marks.map(k => `Top ${k} ${pctAt(k).toFixed(1)}%`).join(''); const halfIdx = points.findIndex(v => v >= 50); const topMark = marks[marks.length - 1]; const note = T.ptConcTop.replace('{n}', String(topMark)).replace('{p}', pctAt(topMark).toFixed(1)) + (halfIdx >= 0 ? T.ptConcHalf.replace('{n}', String(halfIdx + 1)) : ''); const annotations = []; if (halfIdx >= 0) { annotations.push({ type: 'point', idx: halfIdx, text: `${isZh ? '过半' : '50%'} #${halfIdx + 1}`, color: '#22c55e', fontSize: 9, fontWeight: 700, dy: -7, }); } body.innerHTML = `
${chips}
${escHtml(note)}
${Charts.lineChart([{ data: points }], { width: 460, height: 138, pL: 32, pR: 14, pT: 14, pB: 22, smooth: false, colors: [Charts.accent()], svgAttrs: 'class="sglv-trend-svg"', area: { fill: Charts.accent(), opacity: 0.3 }, gridCount: 4, yFormatter: v => v.toFixed(0) + '%', xLabels: points.map((_, i) => String(i + 1)), xLabelEvery: 20, xLabelLast: true, dots: { r: 2, stroke: '#0f172a', strokeWidth: 1, title: (v, i) => `${isZh ? '第' : '#'}${i + 1}: ${v.toFixed(1)}%`, }, annotations, })}
`; } // ---- 活跃度分层(点击钻取 → 切到全部游戏 Tab,浮窗不重开)---- function renderActivity(wrap) { const panel = document.createElement('div'); panel.className = 'sglv-pt-panel'; panel.innerHTML = `
${ICONS.clock} ${T.ptActTitle}
`; wrap.appendChild(panel); const body = panel.querySelector('#sglv-pt-act-body'); const total = games.length; const counts = {}; games.forEach(g => { const k = ptActivityLayerOf(g); counts[k] = (counts[k] || 0) + 1; }); const maxCount = Math.max(1, ...PT_ACTIVITY_LAYERS.map(L => counts[L.key] || 0)); body.innerHTML = PT_ACTIVITY_LAYERS.map(L => { const c = counts[L.key] || 0; const pct = total > 0 ? (c / total * 100).toFixed(1) : '0.0'; const barPct = (c / maxCount * 100).toFixed(1); return `
${ptActivityLayerLabel(L.key, isZh)}
${c} (${pct}%)
`; }).join('') + `
${T.ptActHint}
`; body.addEventListener('click', e => { const row = e.target.closest('.sglv-pt-layer-row'); if (!row) return; const key = row.dataset.layer; // ⭐ 联动:切 Tab 而非重开浮窗,浮窗内状态保留 ctx.goTo('all', { layerFilter: g => ptActivityLayerOf(g) === key, sortKey: key === 'never' ? 'name' : 'recent', page: 1, }); }); // 吃灰榜:需家庭组入库时间(异步);无数据/失败则整行隐藏 const dustEl = document.createElement('div'); dustEl.className = 'sglv-pt-dust'; dustEl.textContent = T.ptDustScanning; body.appendChild(dustEl); (async () => { try { const map = await H.getAcquiredMap(); if (!dustEl.isConnected) return; const oneYearAgo = Math.floor(Date.now() / 1000) - 365 * 86400; const acquired = map || {}; const isDust = g => (g.playtime || 0) === 0 && !!acquired[g.appid] && acquired[g.appid] < oneYearAgo; const dustCount = games.filter(isDust).length; if (dustCount === 0) { dustEl.remove(); return; } dustEl.innerHTML = `${escHtml(T.ptDustText)}: ${dustCount} `; dustEl.querySelector('.sglv-pt-dust-btn').addEventListener('click', () => { ctx.goTo('all', { layerFilter: isDust, sortKey: 'acquired', acquiredMap: acquired, page: 1, }); }); } catch (e) { if (dustEl.isConnected) dustEl.remove(); } })(); } // ---- Top 20 ---- function renderTop(wrap) { const top20 = sortedGames.slice(0, 20); const maxPlaytime = top20.length > 0 ? (top20[0].playtime || 0) : 1; const section = document.createElement('div'); section.className = 'sglv-pt-panel sglv-pt-top-col'; section.innerHTML = `
${ICONS.trend} ${T.ptTopTitle}
`; wrap.appendChild(section); const topListEl = section.querySelector('#sglv-pt-top-list'); if (top20.length === 0 || maxPlaytime === 0) { topListEl.innerHTML = H.emptyHtml(T.ptNoData); } else { const grid = document.createElement('div'); grid.className = 'sglv-pt-top-grid'; 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 sharePct = totalMin > 0 ? (playtimeMin / totalMin * 100).toFixed(1) : '0.0'; 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 = H.getGameIconUrl(game.appid, game.icon); const lastPlayedText = game.lastPlayed > 0 ? H.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} · ${isZh ? '占比' : 'share'} ${sharePct}%
${H.hbarRow({ cls: 'pt', pct })} ${playtimeH}`; item.addEventListener('click', () => root.open(`https://store.steampowered.com/app/${game.appid}`, '_blank')); frag.appendChild(item); }); grid.appendChild(frag); topListEl.appendChild(grid); H.applyGameAliases(grid); } const openAllBtn = section.querySelector('#sglv-pt-open-all'); if (openAllBtn) { openAllBtn.addEventListener('click', () => { ctx.goTo('all', { layerFilter: null, acquiredMonth: null, sortKey: 'playtime', page: 1 }); }); } } // ---- 组装(保持原双栏栅格:左栏 = 集中度 + 活跃度分层,右栏 = Top20 独占) // 原结构 .sglv-pt-main-row(display:grid) > .sglv-pt-left-col + .sglv-pt-top-col, // 由 CSS_PLAYDATA 的 .sglv-pt-main-row > .sglv-pt-top-col{grid-column:2} 定位。 // 纯搬运纪律要求复刻原 DOM 层级,否则迁移后布局静默退化为单列。 const wrap = document.createElement('div'); wrap.className = 'sglv-pt-overview sglv-playtime-wrap'; wrap.style.flexDirection = 'column'; container.appendChild(wrap); renderHero(wrap); const mainRow = document.createElement('div'); mainRow.className = 'sglv-pt-main-row'; wrap.appendChild(mainRow); const leftCol = document.createElement('div'); leftCol.className = 'sglv-pt-left-col'; mainRow.appendChild(leftCol); renderConcentration(leftCol); renderActivity(leftCol); renderTop(mainRow); } // ---- 注册 ---- if (!root.SGLVPTW) { console.warn('[PTW-TAB-OVERVIEW] 框架未就绪,无法注册'); } else { root.SGLVPTW.registerTab({ key: 'overview', label: '总览', order: 10, requires: [ 'getOwnedGames', 'computePlaytimeStats', 'ptPlaytimeMedian', 'kpiCards', 'SGLVCharts', 'ICONS', 'T', 'isZh', 'escHtml', 'emptyHtml', 'hbarRow', 'getGameIconUrl', 'applyGameAliases', 'formatLastPlayedShort', ], softRequires: ['getAcquiredMap', 'fetchRecentPlayedGames'], mount, }); console.log('[PTW-TAB-OVERVIEW] 已注册'); } })(typeof window !== 'undefined' ? window : globalThis); /* ---------- sglv-ptw-tab-all.lib.js ---------- */ /* ============================================================================= * SGLV PTW · Tab: 全部游戏列表 (All Games) * ----------------------------------------------------------------------------- * 迁移自主脚本 mountPlaytimeAllList(v2.20.0 :15850-16058,209 行) * 迁移纪律:纯搬运不改逻辑。唯一改动 = 依赖改为经 ctx.host 注入。 * * 依赖注入对照表: * state.ownedGames → ctx.host.getOwnedGames() * isGameOwnedByMe(g) → ctx.host.isGameOwnedByMe(g) * ptGetAcquiredMap() → ctx.host.getAcquiredMap() * buildListToolbar / hbarRow / emptyHtml / paginate / renderPagination * → ctx.host.* * escHtml / T / isZh → ctx.host.* * getGameIconUrl → ctx.host.getGameIconUrl * applyGameAliases → ctx.host.applyGameAliases * sglvToast → ctx.host.showToast * * 视图态:原实现用闭包变量(page/query/sortKey/sortAsc/hideUnplayed/hideShared), * 此处改为读写 ctx.view —— 换取跨 Tab 联动 + 刷新不丢。 * ========================================================================== */ (function (root) { 'use strict'; // v2.22.3: 每页 18 条——配合行距紧凑化(行高 ~32px),18 行恰好一屏可视, // 分页钉底不再出现「13 行 + 大段空白」的断裂感 const PT_ALL_PAGE_SIZE = 18; // v1.0.1 改造 —— 框架 pane 改为 flex 列 + overflow:hidden 后, // list 自身需要 overflow-y:auto 才能在 list 内部滚动(而非触发 body 滚动条)。 // 行为: 每页 18 条紧凑行 + 列表内独立滚动条 + 分页钉在 pane 底部; // 整体浮窗只剩 list 内单根滚动条, 完美消除双滚动条。 // 行高: 28-32px, 紧凑不挤; 名称截断省略; 顶部加表头栏(搜索/排序) sticky。 const CSS = ` #sglv-ptw-pane-all.active{display:flex;flex-direction:column;flex:1;overflow:hidden;padding:14px 16px 12px;} #sglv-ptw-pane-all > .sglv-pt-all-toolbar{flex-shrink:0;position:sticky;top:0;z-index:1;background:var(--sglv-bg);padding:2px 0 8px;margin:-2px 0 6px;border-bottom:1px solid var(--sglv-border-subtle);} #sglv-ptw-pane-all > .sglv-pt-all-list{flex:1 1 0;min-height:0;overflow-y:auto;overscroll-behavior:contain;max-height:none;padding-right:2px;} #sglv-ptw-pane-all > .sglv-pt-all-hint{flex-shrink:0;margin:0 0 6px;padding:7px 10px;border-radius:var(--sglv-r-sm);background:var(--sglv-amber-wash);border:1px solid var(--sglv-amber-soft);color:var(--sglv-amber);font-size:11.5px;} #sglv-ptw-pane-all > .sglv-pt-pagination{flex-shrink:0;margin-top:8px;padding-top:8px;border-top:1px solid var(--sglv-border-subtle);} .sglv-pt-all-item{display:grid;grid-template-columns:36px 32px minmax(0,1fr) 120px 56px;align-items:center;gap:var(--sglv-sp-2);padding:6px 8px;margin-bottom:3px;border-radius:var(--sglv-r-sm);background:var(--sglv-surface-1);transition:background var(--sglv-dur) var(--sglv-ease),transform var(--sglv-dur) var(--sglv-ease);cursor:pointer;user-select:none;} .sglv-pt-all-item:hover{background:var(--sglv-surface-3);} .sglv-pt-all-item:active{transform:translateY(1px);} .sglv-pt-all-rank{font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--sglv-text-muted);text-align:center;font-weight:500;} .sglv-pt-all-icon{width:28px;height:28px;border-radius:6px;background:var(--sglv-surface-2);object-fit:cover;display:block;} .sglv-pt-all-name{font-size:var(--sglv-fs-base);color:var(--sglv-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3;} .sglv-pt-all-hours{font-variant-numeric:tabular-nums;font-size:var(--sglv-fs-md);color:var(--sglv-text-secondary);text-align:right;font-weight:500;} `; /* v1.0.5: 同框架 CSS 注入,走共享 injectStyleOnce */ function injectCSS() { __sglvPtwInjectStyle('sglv-ptw-all-style', CSS); } function mount(container, ctx) { injectCSS(); const H = ctx.host; const T = H.T; const isZh = H.isZh; const escHtml = H.escHtml; const sortedGames = ctx.getGames(); const view = ctx.view; const nameCmp = (a, b) => String(a.name || '').localeCompare( String(b.name || ''), isZh ? 'zh-Hans-CN' : 'en', { numeric: true, sensitivity: 'base' }); let acquiredMap = view.acquiredMap || null; function mountList(container2, data) { let sortKey = view.sortKey || 'playtime'; let sortAsc = !!view.sortAsc; let hideUnplayed = !!view.hideUnplayed; let hideShared = !!view.hideShared; let page = view.page || 1; let query = view.query || ''; const hasSharedGames = data.some(g => !H.isGameOwnedByMe(g)); function sortBy(key) { const m = acquiredMap || {}; const hasAcq = Object.keys(m).length > 0; const acqDesc = (a, b) => (hasAcq ? ((m[b.appid] || 0) - (m[a.appid] || 0)) : 0); let cmp; if (key === 'recent') { cmp = (a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0) || (b.playtime || 0) - (a.playtime || 0) || acqDesc(a, b) || nameCmp(a, b); } else if (key === 'name') { cmp = (a, b) => nameCmp(a, b) || (b.playtime || 0) - (a.playtime || 0); } else if (key === 'acquired') { cmp = (a, b) => acqDesc(a, b) || (b.playtime || 0) - (a.playtime || 0) || nameCmp(a, b); } else { cmp = (a, b) => (b.playtime || 0) - (a.playtime || 0) || (b.lastPlayed || 0) - (a.lastPlayed || 0) || acqDesc(a, b) || nameCmp(a, b); } const mul = sortAsc ? -1 : 1; data.sort((a, b) => mul * cmp(a, b)); } const filterChipsHtml = ` ${hasSharedGames ? `` : ''} `; const toolbar = H.buildListToolbar({ cls: 'sglv-pt-all-toolbar', search: { id: 'sglv-pt-search', placeholder: T.ptSearchPlaceholder, style: 'flex:1;min-width:140px' }, sort: { id: 'sglv-pt-sort', value: sortKey, options: [ { value: 'playtime', label: T.ptSortPlaytime }, { value: 'recent', label: T.ptSortRecent }, { value: 'acquired', label: T.ptSortAcquired }, { value: 'name', label: T.ptSortName }, ], }, extraHtml: `${filterChipsHtml} `, onChange: ({ type, value }) => { if (type === 'input') { query = value; ctx.setView({ query }); renderList(1); } else if (type === 'sort') { applySort(value).then(() => renderList(1)); } }, }); container2.appendChild(toolbar); const hintEl = document.createElement('div'); hintEl.className = 'sglv-pt-all-hint'; hintEl.style.display = 'none'; container2.appendChild(hintEl); const listEl = document.createElement('div'); listEl.className = 'sglv-pt-all-list'; listEl.id = 'sglv-pt-all-list'; const pagEl = document.createElement('div'); pagEl.className = 'sglv-pagination'; pagEl.id = 'sglv-pt-pagination'; container2.appendChild(listEl); container2.appendChild(pagEl); // 事件委托:整页 HTML 一次性渲染后仅保留 1 个 click 监听 listEl.addEventListener('click', e => { const row = e.target.closest('.sglv-pt-all-item'); if (row && row.dataset.appid) { root.open(`https://store.steampowered.com/app/${row.dataset.appid}`, '_blank'); } }); const countEl = toolbar.querySelector('#sglv-pt-count'); const sortSel = toolbar.querySelector('#sglv-pt-sort'); const hideUnplayedBtn = toolbar.querySelector('#sglv-pt-hide-unplayed'); if (hideUnplayedBtn) { if (hideUnplayed) hideUnplayedBtn.classList.add('active'); hideUnplayedBtn.addEventListener('click', () => { hideUnplayed = !hideUnplayed; hideUnplayedBtn.classList.toggle('active', hideUnplayed); ctx.setView({ hideUnplayed }); renderList(1); }); } const hideSharedBtn = toolbar.querySelector('#sglv-pt-hide-shared'); if (hideSharedBtn) { if (hideShared) hideSharedBtn.classList.add('active'); hideSharedBtn.addEventListener('click', () => { hideShared = !hideShared; hideSharedBtn.classList.toggle('active', hideShared); ctx.setView({ hideShared }); renderList(1); }); } // v2.20.2 (T3.6): 导出当前视图态(全量筛选结果,非当前页)→ CSV const exportBtn = toolbar.querySelector('#sglv-pt-export'); if (exportBtn) { exportBtn.addEventListener('click', () => { const rows = currentViewGames().map(g => [ g.appid, g.name || '', Math.round((g.playtime || 0) / 60 * 10) / 10, g.lastPlayed ? new Date(g.lastPlayed * 1000).toISOString().slice(0, 10) : '', H.isGameOwnedByMe(g) ? 1 : 0, ]); const headers = ['appid', 'name', 'playtime_hours', 'last_played', 'owned_by_me']; if (typeof ctx.exportCSV === 'function') { ctx.exportCSV(headers, rows, 'sglv-games'); } else if (typeof H.showToast === 'function') { H.showToast(isZh ? '导出功能不可用' : 'Export unavailable', 'warn'); } }); } const sortDirBtn = toolbar.querySelector('#sglv-pt-sort-dir'); if (sortDirBtn) { if (sortAsc) { sortDirBtn.textContent = T.ptSortAsc; sortDirBtn.title = T.ptSortAsc; sortDirBtn.classList.add('active'); } sortDirBtn.addEventListener('click', () => { sortAsc = !sortAsc; sortDirBtn.textContent = sortAsc ? T.ptSortAsc : T.ptSortDesc; sortDirBtn.title = sortAsc ? T.ptSortAsc : T.ptSortDesc; sortDirBtn.classList.toggle('active', sortAsc); sortBy(sortKey); ctx.setView({ sortAsc }); renderList(1); }); } const resetToPlaytime = () => { sortKey = 'playtime'; if (sortSel) sortSel.value = 'playtime'; sortBy('playtime'); ctx.setView({ sortKey }); }; // 入库时间排序依赖家庭组数据(异步);无数据 / 失败回退时长排序 function applySort(key) { if (key === 'acquired' && !acquiredMap) { listEl.innerHTML = `
${escHtml(T.ptSortAcquiredLoading)}
`; pagEl.innerHTML = ''; return H.getAcquiredMap() .then(map => { acquiredMap = map || {}; ctx.setView({ acquiredMap }); if (Object.keys(acquiredMap).length === 0) { resetToPlaytime(); H.showToast.info(T.ptSortAcquiredEmpty); return; } sortKey = 'acquired'; ctx.setView({ sortKey }); sortBy('acquired'); }) .catch(() => { resetToPlaytime(); H.showToast.info(T.ptSortAcquiredEmpty); }); } sortKey = key; ctx.setView({ sortKey }); sortBy(key); return Promise.resolve(); } /** * 当前视图态下的完整游戏集合(已应用过滤 + 搜索,未分页)。 * renderList 与 CSV 导出共用,避免「导出结果 ≠ 屏幕结果」的双份逻辑漂移。 */ function currentViewGames() { let out = data; if (hideUnplayed) out = out.filter(g => (g.playtime || 0) > 0); if (hideShared) out = out.filter(g => H.isGameOwnedByMe(g)); if (query) { // v2.20.2 (T3.5): 三路匹配——中文名子串 / 英文名子串 / 拼音首字母子串。 // 原实现仅 name.toLowerCase().includes(q),中文用户必须切输入法。 // 统一走 host.matchGameName;该能力缺失时(软依赖未满足)就地降级为 // 英文子串匹配,绝不让搜索整体失效。 const matcher = typeof H.matchGameName === 'function' ? H.matchGameName : (name, q) => String(name || '').toLowerCase().includes(String(q).trim().toLowerCase()); out = out.filter(g => matcher(g.name, query)); } return out; } function renderList(p = 1) { page = p; ctx.setView({ page }); const displayGames = currentViewGames(); const totalPt = displayGames.reduce((s, g) => s + (g.playtime || 0), 0); if (displayGames.length > 0 && totalPt === 0) { hintEl.textContent = T.ptZeroPtHint; hintEl.style.display = ''; } else { hintEl.style.display = 'none'; } const { pageItems: pageGames, totalPages, page: _clampedPage } = H.paginate(displayGames, page, PT_ALL_PAGE_SIZE); page = _clampedPage; ctx.setView({ page }); const startIdx = (page - 1) * PT_ALL_PAGE_SIZE; countEl.textContent = `${displayGames.length} ${isZh ? '款' : 'items'}`; listEl.innerHTML = ''; if (displayGames.length === 0) { listEl.innerHTML = H.emptyHtml(isZh ? '无匹配游戏' : 'No matching games'); pagEl.innerHTML = ''; return; } // 一次性拼接整页 HTML;图标 lazy + 固定尺寸避免回流 const allMaxPlaytime = pageGames.length > 0 ? Math.max(1, ...pageGames.map(g => g.playtime || 0)) : 1; listEl.innerHTML = pageGames.map((game, i) => { const playtimeMin = game.playtime || 0; const pct = (playtimeMin / allMaxPlaytime * 100).toFixed(1); const playtimeH = playtimeMin > 0 ? (playtimeMin / 60).toFixed(1) + 'h' : T.ptRange0; const iconUrl = H.getGameIconUrl(game.appid, game.icon); return `
${startIdx + i + 1} ${game.name} ${H.hbarRow({ cls: 'ptAll', pct })} ${playtimeH}
`; }).join(''); H.applyGameAliases(listEl); H.renderPagination(pagEl, 'sglv-pt', page, totalPages, displayGames.length, () => renderList(page - 1), () => renderList(page + 1), (p2) => renderList(p2)); } sortBy(sortKey); renderList(1); } // 过滤条件:来自钻取联动(view.layerFilter / acquiredMonth) let data = sortedGames.slice(); if (view.layerFilter && typeof view.layerFilter === 'function') { data = data.filter(view.layerFilter); } // 层筛选 chip(从总览 Tab 钻取过来时显示) if (view.layerFilter || view.acquiredMonth) { const tag = document.createElement('div'); tag.className = 'sglv-pt-all-hint'; tag.style.display = ''; tag.innerHTML = `${escHtml(isZh ? '已应用钻取过滤' : 'Drill-down filter applied')}` + ` `; container.appendChild(tag); const btn = tag.querySelector('#sglv-pt-clear-drill'); if (btn) btn.addEventListener('click', () => ctx.setView({ layerFilter: null, acquiredMonth: null })); } mountList(container, data); } // ---- 注册 ---- if (!root.SGLVPTW) { console.warn('[PTW-TAB-ALL] 框架未就绪,无法注册'); } else { root.SGLVPTW.registerTab({ key: 'all', label: '全部游戏', order: 20, requires: [ 'getOwnedGames', 'isGameOwnedByMe', 'buildListToolbar', 'hbarRow', 'emptyHtml', 'paginate', 'renderPagination', 'getGameIconUrl', 'applyGameAliases', 'T', 'isZh', 'escHtml', ], softRequires: ['getAcquiredMap', 'showToast', 'matchGameName'], mount, }); console.log('[PTW-TAB-ALL] 已注册'); } })(typeof window !== 'undefined' ? window : globalThis); /* ---------- sglv-ptw-tab-calendar.lib.js ---------- */ /* ============================================================================= * SGLV PTW · Tab: 入库月历 (Acquisition Calendar) * ----------------------------------------------------------------------------- * 迁移自主脚本 mountPlaytimeCalendar(v2.20.0 :16090-16502,414 行) * 迁移方式:脚本化提取 + 依赖替换(唯一改动 = 外部依赖改为 ctx.host 注入) * * 保留的三处关键性能设计(勿简化): * ① gridCache 按年缓存 12 个月卡节点(FIFO 16 年)——切年命中直接复用,img/文本零重建 * ② tlBodyCache 按年月缓存时间线 body(FIFO 48 月)——切月命中直接挂回,游戏图标不重建(消闪烁核心) * ③ syncSelection 纯 className 字符串操作(不用 classList,兼容测试桩),不触碰 innerHTML * * 视图态差异:原 opts.filter → 此处 view.layerFilter(支持从总览 Tab 钻取) * ========================================================================== */ (function (root) { 'use strict'; function mount(container, ctx) { const H = ctx.host; const T = H.T; const ICONS = H.ICONS; const isZh = H.isZh; const view = ctx.view; const pane = document.createElement('div'); pane.className = 'sglv-ptcal-pane'; container.appendChild(pane); // v2.20.2: baseSet 为固定基集合; gameSet 随「仅个人游戏」开关注入 // (排除家庭共享后重建索引, 月历/时间线/chips 全部只统计个人游戏) const sortedGames = ctx.getGames(); const baseSet = view.layerFilter ? sortedGames.filter(view.layerFilter) : sortedGames.slice(); let gameSet = baseSet; let personalOnly = false; // 已解析的入库时间映射(个人过滤空态预检用, 免二次读取) let resolvedMap = null; // v2.20.1: opts.acquiredMap 直通(浮窗吃灰榜/活跃度分层已抓过入库时间)时同步渲染—— // 旧逻辑即使有数也走 Promise 一拍, loading 占位先入 DOM 再被替换, 产生闪烁闪帧。 if (view.acquiredMap) { resolvedMap = view.acquiredMap || {}; try { renderPane(resolvedMap); } catch (e) { console.warn('[SGLV] 入库月历渲染失败:', e); renderEmpty(); } } else { pane.innerHTML = `
${H.escHtml(T.ptCalLoading)}
`; Promise.resolve(H.getAcquiredMap()) .then(map => { resolvedMap = map || {}; renderPane(resolvedMap); }) .catch(() => renderEmpty()); } function renderEmpty() { pane.innerHTML = `
${H.escHtml(T.ptCalNoData)}
${H.escHtml(T.ptCalNoDataHint)}
`; } function renderPane(map) { const now = new Date(); const today = new Date(); today.setHours(0, 0, 0, 0); const realYear = now.getFullYear(); // v2.20.2: 索引构建抽为 buildIndex()——「仅个人游戏」切换时重算索引(而非整页重挂), // curYear/curMonth/selDay 等视图状态得以保留。单次遍历构建 年 → 'YYYY-MM-DD' → // [game, ...] 三级映射(仅统计当前游戏集内条目)。 let byYear = {}; let years = []; let minYear = realYear; let maxYear = realYear; let chipYears = []; let hasYearData = () => false; function buildIndex() { byYear = {}; for (const g of gameSet) { const ts = map[Number(g.appid)]; if (!ts || ts <= 0) continue; const d = new Date(ts * 1000); const y = d.getFullYear(); const key = y + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); const ym = byYear[y] || (byYear[y] = {}); (ym[key] || (ym[key] = [])).push(g); } years = Object.keys(byYear).map(Number).sort((a, b) => a - b); minYear = years[0]; maxYear = Math.max(realYear, years[years.length - 1]); // 年份胶囊 = 数据年份 ∪ 今年(今年无数据也占位;按年升序) chipYears = years.indexOf(realYear) >= 0 ? years : years.concat(realYear).sort((a, b) => a - b); hasYearData = y => years.indexOf(y) >= 0; } buildIndex(); if (years.length === 0) { renderEmpty(); return; } // v2.20.1: 默认恒落在「今天」——年份/月份都取今天所在值。年份域在数据年份之外 // 始终补齐当前自然年:吃灰榜(入库超 1 年)等筛选集内今年必然无数据,旧逻辑退到 // 「最末数据年」导致 2026 年打开月历却停在 2025、chips/导航都无法到达今年(数据错位 // 观感)。今年无数据时 12 月卡全空、统计为 0、时间线走空态提示,一键即可翻回有数据年。 let curYear = realYear >= minYear ? realYear : minYear; let curMonth = now.getMonth(); // 0-11,默认「当月」 let selDay = null; pane.innerHTML = ''; const toolbar = document.createElement('div'); toolbar.className = 'sglv-ptcal-toolbar'; pane.appendChild(toolbar); const grid = document.createElement('div'); grid.className = 'sglv-ptcal-grid'; pane.appendChild(grid); const timeline = document.createElement('div'); timeline.className = 'sglv-ptcal-timeline'; pane.appendChild(timeline); // v2.20.1: 性能重构——旧实现每次交互(点色块/切年/切月)都 renderAll() 全量重建 // toolbar + 12 月卡(~500 span) + 时间线全部 group/img, img 节点反复创建导致 // 滚动闪烁与布局跳动。改为: // ① 月卡按年缓存(gridCache, 12 卡为整体, 上限 16 年 FIFO)——切年命中直接 // appendChild 复用节点, img/文本零重建; // ② 时间线 body 按 ${curYear}-${curMonth} 缓存(tlBodyCache, 上限 48 FIFO)—— // 切月命中直接挂回, 游戏图标 img 不重建(消闪烁核心); // ③ 同月点选/取消只做 syncSelection() 纯 class 切换(className 字符串操作, // 兼容无 classList 的测试桩), 不触碰任何 innerHTML; // ④ 仅跨月/跨年才重建对应区块, 末尾统一 syncSelection() 幂等校正选中态。 const gridCache = new Map(); // year → [12 个月卡元素] const tlBodyCache = new Map(); // 'YYYY-M' → 时间线 body 元素 let selCellEl = null; // 月历中当前选中格(网格侧选中态维护) let tlBodyEl = null; // 当前挂载的时间线 body(时间线侧选中态维护) const hasCls = (el, c) => el && (' ' + el.className + ' ').indexOf(' ' + c + ' ') >= 0; const addCls = (el, c) => { if (el && !hasCls(el, c)) el.className = (el.className + ' ' + c).trim(); }; const delCls = (el, c) => { if (el) el.className = (' ' + el.className + ' ').replace(' ' + c + ' ', ' ').replace(/\s+/g, ' ').trim(); }; // 5 级色阶与 renderAcquiredHeatmap 的 heatColor 阈值完全一致(1/2/3-4/5+) const CAL_COLORS = ['#0e4429', '#006832', '#26a641', '#39d353']; function heatLevel(cnt) { return cnt <= 1 ? 1 : cnt <= 2 ? 2 : cnt <= 4 ? 3 : 4; } const pad2 = (n) => String(n).padStart(2, '0'); const dayKey = (y, m, d) => `${y}-${pad2(m + 1)}-${pad2(d)}`; const MONTH_NAMES = 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 WD_NAMES = isZh ? ['一', '二', '三', '四', '五', '六', '日'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // v2.20.0: 时间线分组头用的完整星期名(周一开头,与 WD_NAMES 索引对齐) const WD_FULL = isZh ? ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; function yearStats(y) { const ym = byYear[y] || {}; let count = 0, peakDay = 0; const monthCount = new Array(12).fill(0); for (const k in ym) { const list = ym[k]; count += list.length; monthCount[Number(k.slice(5, 7)) - 1] += list.length; if (list.length > peakDay) peakDay = list.length; } const peakMonth = monthCount.indexOf(Math.max.apply(null, monthCount)); return { count, peakDay, peakMonth, covered: monthCount.filter(c => c > 0).length, monthCount }; } function renderToolbar() { const st = yearStats(curYear); // 今年无数据(筛选集)时最猛月份不展示「1月(0)」,直接 '—' const peakLabel = st.count > 0 ? `${MONTH_NAMES[st.peakMonth]}(${st.monthCount[st.peakMonth]})` : '—'; const chips = chipYears.length > 1 ? chipYears.map(y => ``).join('') : ''; toolbar.innerHTML = `
${curYear}
${chips ? `
${chips}
` : ''}
${H.escHtml(T.ptCalYearTotal)} ${st.count} ${H.escHtml(T.ptCalUnit)} ${H.escHtml(T.ptCalPeakMonth)} ${peakLabel} ${H.escHtml(T.ptCalPeakDay)} ${st.peakDay} ${H.escHtml(T.ptCalMonthsCovered)} ${st.covered}/12 ${H.escHtml(T.ptHeatmapLess)}${CAL_COLORS.map(c => ``).join('')}${H.escHtml(T.ptHeatmapMore)}
`; toolbar.querySelectorAll('.sglv-ptcal-yearnav-btn').forEach(btn => { btn.addEventListener('click', () => { const next = curYear + Number(btn.dataset.nav); if (next < minYear || next > maxYear) return; switchYear(next); }); }); toolbar.querySelectorAll('.sglv-ptcal-year-chip').forEach(btn => { btn.addEventListener('click', () => { switchYear(Number(btn.dataset.year)); }); }); // v2.20.2: 「仅个人游戏」开关——排除家庭共享后重建索引(视图状态保留); // 个人集内无任何入库记录时提示并不切换(避免 renderEmpty 后开关消失无法恢复)。 // 用 querySelectorAll 绑定(innerHTML 重建后节点替换, 每次重绑到新节点, 不叠加)。 toolbar.querySelectorAll('#sglv-ptcal-personal').forEach(btn => { btn.addEventListener('click', () => { const next = !personalOnly; if (next) { const m = resolvedMap || {}; const hasPersonal = baseSet.some(g => H.isGameOwnedByMe(g) && (m[Number(g.appid)] || 0) > 0); if (!hasPersonal) { H.showToast.info(T.ptCalPersonalEmpty); return; } } setPersonalOnly(next); }); }); } // v2.20.1: 年份切换统一入口——切到「今年」时月份锚定当月(而非沿用旧年所选月份), // 其他年份沿用当前月;该年当月无记录时再兜底跳到最晚有记录的月份。 function switchYear(y) { curYear = y; selDay = null; if (curYear === realYear) curMonth = now.getMonth(); ensureMonthHasData(); renderAll(); } // v2.20.2: 仅个人游戏——gameSet 切换为本人拥有子集后重建索引; 数据域已变, // 月卡/时间线缓存全部失效清空; curYear 钳制到新年份域, 切到今年时月份重锚当月; // 选中态清空(被过滤掉的日期分组不再存在)。 function setPersonalOnly(on) { personalOnly = on; gameSet = on ? baseSet.filter(g => H.isGameOwnedByMe(g)) : baseSet; buildIndex(); gridCache.clear(); tlBodyCache.clear(); selDay = null; selCellEl = null; curYear = Math.min(Math.max(curYear, minYear), maxYear); if (curYear === realYear) curMonth = now.getMonth(); ensureMonthHasData(); renderAll(); } // v2.20.0: 切年份后若「当月」在该年无任何记录(如翻到只有上半年数据的年份), // 兜底跳到该年最晚有记录的月份,避免时间线整块空掉(仅年份导航触发; // 初次进入仍严格按「当月」展示)。点击日历色块同样会切到目标月份。 function ensureMonthHasData() { if (monthHasData(curYear, curMonth)) return; for (let m = 11; m >= 0; m--) { if (monthHasData(curYear, m)) { curMonth = m; return; } } } function monthHasData(y, m) { const ym = byYear[y] || {}; const daysInMonth = new Date(y, m + 1, 0).getDate(); for (let d = 1; d <= daysInMonth; d++) { const list = ym[dayKey(y, m, d)]; if (list && list.length > 0) return true; } return false; } function monthCard(m) { const ym = byYear[curYear] || {}; const first = new Date(curYear, m, 1); const daysInMonth = new Date(curYear, m + 1, 0).getDate(); const offset = (first.getDay() + 6) % 7; // 周一开头(与入库热力图 dayNames 一致) let monthTotal = 0; const cells = []; for (let i = 0; i < offset; i++) cells.push(''); for (let d = 1; d <= daysInMonth; d++) { const key = dayKey(curYear, m, d); const list = ym[key]; const cellDate = new Date(curYear, m, d); if (list && list.length > 0) { monthTotal += list.length; cells.push(`${d}`); } else if (cellDate > today) { cells.push(`${d}`); } else { cells.push(`${d}`); } } const tail = (7 - (cells.length % 7)) % 7; for (let i = 0; i < tail; i++) cells.push(''); const card = document.createElement('div'); card.className = 'sglv-ptcal-month'; card.innerHTML = `
${MONTH_NAMES[m]} ${monthTotal > 0 ? `${monthTotal} ${H.escHtml(T.ptCalUnit)}` : '—'}
${WD_NAMES.map(w => `${w}`).join('')}
${cells.join('')}
`; return card; } // 事件委托:整页网格仅 1 个 click 监听(对齐 v2.11.52c 列表项做法) // v2.20.1: 同月点选/取消只切 class(零重建); 跨月才重建时间线并滚入视野 grid.addEventListener('click', e => { const cell = e.target.closest('.sglv-ptcal-day.has'); if (!cell) return; const key = cell.dataset.day; if (selDay === key) { selDay = null; syncSelection(); return; } // 再次点击取消高亮 const prevMonth = curMonth; selDay = key; // 点到别的月份的色块时,时间线跟着切到那个月(否则高亮目标不在视区内) curMonth = Number(key.slice(5, 7)) - 1; if (curMonth !== prevMonth) renderTimeline(true); syncSelection(); }); // 时间线反向同步:点分组头 = 高亮/取消该日(月历格子上的选中态同步回写) // v2.20.1: 同样只切 class——时间线只展示当月分组, 点选不改变年月, 无需重建 timeline.addEventListener('click', e => { const group = e.target.closest('.sglv-ptcal-group'); if (!group) return; const key = group.dataset.day; selDay = (selDay === key) ? null : key; syncSelection(); }); // v2.20.1: 选中态同步(网格 + 时间线两侧)——纯 className 字符串操作, // 不触碰 innerHTML, 复用缓存节点时也不会丢/残留选中类(幂等)。 function syncGridSel() { if (selCellEl) { delCls(selCellEl, 'sel'); selCellEl = null; } if (!selDay) return; const cell = grid.querySelector('.sglv-ptcal-day.has[data-day="' + selDay + '"]'); if (cell) { addCls(cell, 'sel'); selCellEl = cell; } } function syncTimelineSel() { if (!tlBodyEl) return; for (const grp of tlBodyEl.children) { if (!grp || !grp.dataset || !grp.dataset.day) continue; if (grp.dataset.day === selDay) addCls(grp, 'sel'); else delCls(grp, 'sel'); } } function syncSelection() { syncGridSel(); syncTimelineSel(); } // v2.20.0: 当月入库时间线——常驻展示(不再依赖先点某天才出现), // 按日倒序:最后一天 → 第一天;点击月历色块即高亮对应分组, // 高亮底色 = 该日入库数量的色阶色(lv1~lv4,与日历格子同色)。 // v2.20.1: body 按年月缓存复用(img 不重建→滚动不闪烁); 头部(月份/总数) // 仍每次 innerHTML 重建, 保证与当前月一致。 function renderTimeline(scrollToSel) { const ym = byYear[curYear] || {}; const daysInMonth = new Date(curYear, curMonth + 1, 0).getDate(); // 倒序遍历当月日期,只收集有入库记录的日 const groups = []; let monthTotal = 0; for (let d = daysInMonth; d >= 1; d--) { const list = ym[dayKey(curYear, curMonth, d)]; if (!list || list.length === 0) continue; monthTotal += list.length; groups.push({ key: dayKey(curYear, curMonth, d), day: d, list }); } const ymLabel = `${curYear}-${pad2(curMonth + 1)}`; timeline.innerHTML = `
${ICONS.calendar}${H.escHtml(T.ptCalTimeline)}${ymLabel} ${monthTotal > 0 ? `· ${monthTotal} ${H.escHtml(T.ptCalUnit)}` : ''} ${H.escHtml(T.ptCalTimelineHint)}
`; const cacheKey = curYear + '-' + curMonth; let body = tlBodyCache.get(cacheKey); if (!body) { body = document.createElement('div'); body.className = 'sglv-ptcal-tl-body'; if (groups.length === 0) { // 整年无数据(如今今年的吃灰榜)与「该月无记录、同年其他月有」区分提示 const mainHint = hasYearData(curYear) ? T.ptCalTimelineEmpty : T.ptCalNoDataThisYear; body.innerHTML = `
${H.escHtml(mainHint)}
${H.escHtml(T.ptCalTimelineEmptyHint)}
`; } else { const frag = document.createDocumentFragment(); groups.forEach(grp => { const level = heatLevel(grp.list.length); const selected = selDay === grp.key; const wdIdx = (new Date(curYear, curMonth, grp.day).getDay() + 6) % 7; // 周一开头 const group = document.createElement('div'); group.className = `sglv-ptcal-group lv${level}${selected ? ' sel' : ''}`; group.dataset.day = grp.key; group.innerHTML = `
${pad2(curMonth + 1)}-${pad2(grp.day)} ${WD_FULL[wdIdx]} ${grp.list.length} ${H.escHtml(T.ptCalUnit)}
`; const games = document.createElement('div'); games.className = 'sglv-ptcal-group-games'; const gfrag = document.createDocumentFragment(); grp.list.slice().sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), isZh ? 'zh-Hans-CN' : 'en', { numeric: true, sensitivity: 'base' })).forEach(g => { const item = document.createElement('div'); item.className = 'sglv-ptcal-detail-item'; item.title = T.ptCalOpenStore; const hours = g.playtime > 0 ? (g.playtime / 60).toFixed(1) + 'h' : T.ptRange0; item.innerHTML = `${H.escHtml(g.name || ('App ' + g.appid))}${H.escHtml(hours)}`; // 阻止冒泡:游戏条目点击 = 开商店,分组点击 = 切换高亮 item.addEventListener('click', e => { e.stopPropagation(); root.open(`https://store.steampowered.com/app/${g.appid}`, '_blank'); }); gfrag.appendChild(item); }); games.appendChild(gfrag); group.appendChild(games); frag.appendChild(group); }); body.appendChild(frag); } tlBodyCache.set(cacheKey, body); // FIFO 上限 48 个月(4 年), 防长时间翻页无限增长 if (tlBodyCache.size > 48) tlBodyCache.delete(tlBodyCache.keys().next().value); H.applyGameAliases(body); } timeline.appendChild(body); tlBodyEl = body; syncTimelineSel(); // 选中后把对应分组滚入视野(月历网格常在首屏,选中项可能落在折叠区外)。 // v2.20.1: behavior 改 'auto'——smooth 与缓存挂载后的布局竞争会造成抖动闪烁 if (scrollToSel && selDay) { requestAnimationFrame(() => { const el = body.querySelector('.sglv-ptcal-group.sel'); if (el) el.scrollIntoView({ block: 'nearest', behavior: 'auto' }); }); } } // v2.20.1: 月卡按年缓存渲染——命中直接复用 12 个卡片节点(切年零重建) function renderGrid() { let cards = gridCache.get(curYear); if (!cards) { cards = []; for (let m = 0; m < 12; m++) cards.push(monthCard(m)); gridCache.set(curYear, cards); if (gridCache.size > 16) gridCache.delete(gridCache.keys().next().value); // FIFO 上限 16 年 } grid.innerHTML = ''; const frag = document.createDocumentFragment(); for (const c of cards) frag.appendChild(c); grid.appendChild(frag); syncGridSel(); } function renderAll(scrollToSel) { renderToolbar(); renderGrid(); renderTimeline(scrollToSel); } renderAll(); } } // ---- 注册 ---- if (!root.SGLVPTW) { console.warn('[PTW-TAB-CALENDAR] 框架未就绪,无法注册'); } else { root.SGLVPTW.registerTab({ key: 'calendar', label: '入库月历', order: 60, requires: [ 'getOwnedGames', 'isGameOwnedByMe', 'getGameIconUrl', 'applyGameAliases', 'getAcquiredMap', 'escHtml', 'T', 'ICONS', 'isZh', ], softRequires: ['showToast'], mount, }); console.log('[PTW-TAB-CALENDAR] 已注册'); } })(typeof window !== 'undefined' ? window : globalThis); /* ---------- sglv-ptw-tab-family.lib.js ---------- */ /* ============================================================================= * SGLV PTW · Tab: 家庭对比 (Family Compare) * ----------------------------------------------------------------------------- * 迁移自主脚本 createFamilyCompareSection(v2.20.0 :14923-15088,166 行) * + ptFetchMemberPlaytime(:15573-15590,18 行) * * 迁移纪律:纯搬运不改逻辑。依赖经 ctx.host 注入。 * * 架构说明:家庭成员缓存读写(ptFamLoad/ptFamSave/ptFamIsStale)属**数据层**, * 按三层架构留在主脚本,经 PTW_HOST.familyCache 暴露; * 本库只做渲染与并发调度。 * ========================================================================== */ (function (root) { 'use strict'; function mount(container, ctx) { const H = ctx.host; const T = H.T; const ICONS = H.ICONS; const isZh = H.isZh; const escHtml = H.escHtml; const myGames = ctx.getGames(); const totalMinutes = myGames.reduce((s, g) => s + (g.playtime || 0), 0); const familySection = document.createElement('div'); familySection.className = 'sglv-family-compare-section'; familySection.innerHTML = `
${ICONS.share} ${T.ptFamilyCompare}
`; const listEl = familySection.querySelector('#sglv-fam-list'); const titleEl = familySection.querySelector('.sglv-fam-title'); const mySteamId = String(H.getActiveSteamId() || ''); const FAM_TOP_N = 20; // ===== 我的数据:由本地库存派生(零网络)===== const mySource = (myGames.length ? myGames : myGames.filter(g => H.isGameOwnedByMe(g))) .filter(g => (g.playtime || 0) > 0) .sort((a, b) => (b.playtime || 0) - (a.playtime || 0)); const myItem = { steamId: mySteamId, isMe: true, totalMinutes: totalMinutes, top: mySource.slice(0, FAM_TOP_N).map(g => ({ appid: g.appid, name: g.name, playtime: g.playtime || 0, icon: g.icon })), }; // 展开/收起(事件委托) listEl.addEventListener('click', e => { const head = e.target.closest('.sglv-fam-head'); if (head) head.parentElement.classList.toggle('open'); }); function renderItems(items, familyName) { if (familyName) titleEl.innerHTML = `${ICONS.share} ${escHtml(familyName)} · ${T.ptFamilyCompare}`; const maxMin = Math.max(1, ...items.map(i => i.totalMinutes || 0)); listEl.innerHTML = items.map((m, i) => { const hours = m.totalMinutes >= 0 ? (m.totalMinutes / 60).toFixed(1) + 'h' : '—'; const pct = m.totalMinutes > 0 ? (m.totalMinutes / maxMin * 100).toFixed(1) : '0'; let gamesHtml; if (m.failed || !m.top || m.top.length === 0) { gamesHtml = `
${escHtml(m.failed ? T.ptFamPrivate : T.ptFamNoGames)}
`; } else { const maxG = Math.max(1, m.top[0].playtime || 0); // 行首图标:loading=lazy 在隐藏子树内不请求,展开成员才加载该成员 ≤20 张 gamesHtml = m.top.map((g, gi) => { const gp = ((g.playtime || 0) / maxG * 100).toFixed(1); const iconUrl = H.getGameIconUrl(g.appid, g.icon); return `
${gi + 1} ${g.name} ${g.playtime > 0 ? (g.playtime / 60).toFixed(1) + 'h' : T.ptRange0}
`; }).join(''); } return `
${i + 1} ${escHtml(m.name)} ${hours} ▾
${gamesHtml}
`; }).join(''); H.applyGameAliases(listEl); } // 单成员生涯时长 + Top20(apiKey 优先,access_token 兜底) async function fetchMemberPlaytime(steamId, apiKey, token) { const url = apiKey ? `https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${apiKey}&steamid=${steamId}&include_played_free_games=1&format=json` : (token ? `https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?access_token=${token}&steamid=${steamId}&include_played_free_games=1` : ''); if (!url) return null; const data = await H.requestSteamAPI(url); const gList = (data && data.response && data.response.games) || []; if (!gList.length) return null; const total = gList.reduce((s, g) => s + (g.playtime_forever || 0), 0); // 第 5 位透传 img_icon_url —— 社区小图标比 capsule_sm_120 兜底小 ~60% const top = [...gList] .sort((a, b) => (b.playtime_forever || 0) - (a.playtime_forever || 0)) .slice(0, FAM_TOP_N) .map(g => [g.appid, g.name || `App ${g.appid}`, g.playtime_forever || 0, g.rtime_last_played || 0, g.img_icon_url || '']); return { t: total, c: gList.length, ts: Date.now(), g: top }; } function recordToItem(rec) { return { totalMinutes: rec.t, // a[4] = img_icon_url;旧 4 元组缓存 → '' → capsule 兜底 top: (rec.g || []).map(a => ({ appid: a[0], name: a[1], playtime: a[2], icon: a[4] || '' })), failed: !!rec.fail, }; } async function loadFamily(force = false) { listEl.innerHTML = H.createSkeleton('family', 4); const authToken = await H.getAccessToken().catch(() => null); const apiKey = H.storage.getApiKey(); const cache = H.familyCache; if (force) { cache.load(); cache.data.members = {}; cache.save(); } let familyInfo = H.storage.getFamilyInfo() || {}; let nameMap = familyInfo.steamIdtoName || {}; let familyName = familyInfo.family_name || ''; if (Object.keys(nameMap).length === 0 && authToken) { try { const freshInfo = await H.fetchFamilyInfo(authToken); if (freshInfo) { H.storage.setFamilyInfo(freshInfo); nameMap = freshInfo.steamIdtoName || {}; familyName = freshInfo.family_name || ''; } } catch (e) { /* ignore */ } } if (familyName) titleEl.innerHTML = `${ICONS.share} ${escHtml(familyName)} · ${T.ptFamilyCompare}`; if (!mySteamId || Object.keys(nameMap).length === 0) { const reason = (!apiKey && !authToken) ? T.ptNoApiKey : (authToken ? T.ptNoFamily : T.ptNoToken); listEl.innerHTML = `
${H.emptyHtml(reason, { style: 'margin-bottom:10px' })}
`; const retry = listEl.querySelector('#sglv-family-retry'); if (retry) retry.addEventListener('click', () => { H.storage.setFamilyInfo({}); loadFamily(true); }); return; } const myName = nameMap[mySteamId] || (isZh ? '我' : 'Me'); myItem.name = myName; const memberInfo = (H.storage.getFamilyInfo() || {}).family_member || []; const others = Object.entries(nameMap) .filter(([sid]) => sid !== mySteamId) .map(([sid, name]) => { const fm = memberInfo.find(x => x.steamid === sid); return { steamId: sid, name: (fm && fm.userName) || name || ('ID:' + String(sid).slice(-4)) }; }); cache.load(); const withCache = m => { const rec = cache.data.members[m.steamId]; return rec ? { ...m, ...recordToItem(rec) } : null; }; // ① 先用缓存立刻出图(命中即秒开) const cachedItems = [myItem, ...others.map(withCache).filter(Boolean)] .sort((a, b) => (b.totalMinutes || 0) - (a.totalMinutes || 0)); if (cachedItems.length > 1) renderItems(cachedItems, familyName); else listEl.innerHTML = H.createSkeleton('family', Math.max(2, others.length)); // ② 增量刷新:仅刷新缺失 / 过期(>1 周) 的成员,并发 3 + 429 自动降速 const stale = others.filter(m => force || cache.isStale(cache.data.members[m.steamId])); if (stale.length === 0) { renderItems(cachedItems.length > 1 ? cachedItems : [myItem], familyName); return; } const results = await H.sglvSmartPool( stale.map(m => () => fetchMemberPlaytime(m.steamId, apiKey, authToken).then(rec => ({ steamId: m.steamId, rec }))), 3 ); results.forEach((r, i) => { const sid = stale[i].steamId; if (r && r.rec) cache.data.members[sid] = r.rec; else if (!cache.data.members[sid]) { cache.data.members[sid] = { t: -1, c: 0, ts: Date.now(), g: [], fail: true }; } }); cache.save(); const finalItems = [myItem, ...others.map(m => { const rec = cache.data.members[m.steamId]; return rec ? { ...m, ...recordToItem(rec) } : { ...m, totalMinutes: -1, failed: true }; })].sort((a, b) => (b.totalMinutes || 0) - (a.totalMinutes || 0)); renderItems(finalItems, familyName); } const refreshBtn = familySection.querySelector('#sglv-fam-refresh'); if (refreshBtn) refreshBtn.addEventListener('click', e => { e.stopPropagation(); loadFamily(true); }); container.appendChild(familySection); loadFamily(false); } // ---- 注册 ---- if (!root.SGLVPTW) { console.warn('[PTW-TAB-FAMILY] 框架未就绪,无法注册'); } else { root.SGLVPTW.registerTab({ key: 'family', label: '家庭对比', order: 30, requires: [ 'getOwnedGames', 'getActiveSteamId', 'isGameOwnedByMe', 'getGameIconUrl', 'applyGameAliases', 'createSkeleton', 'emptyHtml', 'sglvSmartPool', 'requestSteamAPI', 'getAccessToken', 'storage', 'familyCache', // v2.21.1 审核修正:fetchFamilyInfo(authToken) 真实使用(L134)却未声明, // host 缺失时会 TypeError 而非优雅降级 → 补声明; // 顶层 'getFamilyInfo' 是旧契约残留(实际用的是 storage.getFamilyInfo)→ 移除。 'fetchFamilyInfo', 'ICONS', 'T', 'isZh', 'escHtml', ], mount, }); console.log('[PTW-TAB-FAMILY] 已注册'); } })(typeof window !== 'undefined' ? window : globalThis); /* ---------- sglv-ptw-tab-achievements.lib.js ---------- */ /* ============================================================================= * SGLV PTW · Tab: 成就分析 (Achievements) * ----------------------------------------------------------------------------- * Phase 3 / T3.2新增能力。借鉴 steam-game-hub-2.0 `achievement-analytics.ts:106-390`。 * * 三个分析维度: * ① 稀有度分档 —— 按全球达成率分4 档(>=50% 常见 / >=20% 少见 / >=5% 稀有 / <5% 传说) * ② 成就时间线 —— 按解锁年份/月份聚合,看清成就节奏 * ③ 获取速度 —— 已解锁成就的解锁耗时分布(相对首个/最末解锁点) * * ⚠️ 口径纪律(重要): * `percent` 是**全球达成率**(越高越常见),不是稀有度。 * 分档必须**反向**解读:percent 越低越稀有。 * 分档阈值与主脚本 SGIS 的成就列表保持一致(50/20/5), * 避免同一游戏在两个入口显示不同稀有度。 * * 数据源(均经 PTW_HOST 桥接,Tab 不直连 SGLV_API): * getPlayerAchievements(appId) → { error?, achievements[], total, unlocked, gameName } * achievements[i] = { name, apiname, achieved, unlocktime } * getGlobalAchievements(appId)→ [{ name, percent }] 已按 percent 降序 * * 限流纪律:两个取数函数均**无内部限流**,必须经 sglvSmartPool 约束并发。 * ========================================================================== */ (function (root) { 'use strict'; // ============================================================================= // 样式(内联,随库走) // ----------------------------------------------------------------------------- // 命名空间 sglv-ptw-ach-*;配色沿用主脚本成就列表口径(50/20/5 分档), // 保证同一游戏在 SGIS 侧栏与 PTW 浮窗中稀有度读数一致。 // ========================================================================== const CSS = ` .sglv-ptw-ach{display:flex;flex-direction:column;gap:14px;min-height:0;flex:1;width:100%;} .sglv-ptw-ach-picker{display:flex;align-items:center;gap:var(--sglv-sp-3);padding:var(--sglv-sp-3) var(--sglv-sp-4);border:1px solid var(--sglv-border);border-radius:var(--sglv-r-md);background:var(--sglv-surface-deep);} .sglv-ptw-ach-picker-label{font-size:var(--sglv-fs-md);color:var(--sglv-text-secondary);flex-shrink:0;} .sglv-ptw-ach-select{flex:1;min-width:0;padding:7px 10px;border-radius:var(--sglv-r-sm);border:1px solid var(--sglv-border);background:var(--sglv-bg);color:var(--sglv-text);font-size:var(--sglv-fs-base);outline:none;} .sglv-ptw-ach-select:focus{border-color:var(--sglv-accent);} /* v1.0.2: 横版网格布局——Hero 通栏;稀有度分布(2fr)+解锁时间线(3fr)双栏;成就明细通栏。 此前 flex 纵向堆叠导致窄列居中、右侧大面积留白 v1.0.2 列表行跟窗高自适应——grid 行模板加 1fr,成就明细行用 min-height:0+display:flex 让内部 .sglv-ptw-ach-list 用 overflow-y:auto 撑满剩余高度(替代硬编码 max-height:440px) */ .sglv-ptw-ach-body{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,3fr);grid-template-rows:auto auto minmax(0,1fr);gap:14px;align-items:start;min-height:0;flex:1;width:100%;} .sglv-ptw-ach-body > div:nth-child(1){grid-column:1/-1;} .sglv-ptw-ach-body > div:nth-child(2){grid-column:1;grid-row:2;min-height:0;} .sglv-ptw-ach-body > div:nth-child(3){grid-column:2;grid-row:2;min-height:0;} .sglv-ptw-ach-body > div:nth-child(4){grid-column:1/-1;grid-row:3;min-height:0;display:flex;flex-direction:column;} /* 状态占位(加载中 / 无 Key / 失败) */ .sglv-ptw-ach-state{padding:36px 20px;text-align:center;color:var(--sglv-text-secondary);} .sglv-ptw-ach-state-icon{font-size:32px;margin-bottom:10px;} .sglv-ptw-ach-state-msg{font-size:14px;font-weight:600;color:var(--sglv-amber);} .sglv-ptw-ach-state-sub{margin-top:8px;font-size:var(--sglv-fs-md);opacity:.8;line-height:1.7;} /* HERO 摘要 v1.0.3: 阴影分级 + 炫彩渐变(背景叠 accent-wash,数字 text-shadow 发光)+ hover 抬升 */ .sglv-ptw-ach-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--sglv-sp-3);} .sglv-ptw-ach-hero-item{ position:relative;padding:14px 16px;border:1px solid var(--sglv-border); border-radius:var(--sglv-r-lg); background: linear-gradient(135deg,var(--sglv-accent-wash) 0%,transparent 50%), var(--sglv-surface-deep); text-align:center;overflow:hidden; box-shadow:var(--sglv-shadow-sm),inset 0 1px 0 var(--sglv-border-subtle); transition:transform var(--sglv-dur) var(--sglv-ease),box-shadow var(--sglv-dur) var(--sglv-ease),border-color var(--sglv-dur) var(--sglv-ease); } .sglv-ptw-ach-hero-item::before{ content:'';position:absolute;inset:-1px;border-radius:var(--sglv-r-lg);pointer-events:none; background:linear-gradient(135deg,var(--sglv-accent-glow),transparent 60%); opacity:0;transition:opacity var(--sglv-dur) var(--sglv-ease); } .sglv-ptw-ach-hero-item:hover{ transform:translateY(-2px); border-color:var(--sglv-accent-soft); box-shadow:var(--sglv-shadow-md),0 0 0 1px var(--sglv-accent-soft),0 0 24px var(--sglv-accent-wash),inset 0 1px 0 var(--sglv-border-subtle); } .sglv-ptw-ach-hero-item:hover::before{opacity:1;} .sglv-ptw-ach-hero-val{ font-size:26px;font-weight:700;line-height:1.2; background:linear-gradient(180deg,var(--sglv-text) 0%,var(--sglv-accent) 100%); -webkit-background-clip:text;background-clip:text; -webkit-text-fill-color:transparent;color:transparent; text-shadow:0 0 16px var(--sglv-accent-wash); font-variant-numeric:tabular-nums; position:relative;z-index:1; } .sglv-ptw-ach-hero-total{font-size:14px;font-weight:400;color:var(--sglv-text-secondary);} .sglv-ptw-ach-hero-lbl{margin-top:4px;font-size:var(--sglv-fs-md);color:var(--sglv-text-secondary);position:relative;z-index:1;} .sglv-ptw-ach-hero-bar{margin:10px 0 4px;height:5px;border-radius:3px;background:var(--sglv-surface-2);overflow:hidden;position:relative;z-index:1;} .sglv-ptw-ach-hero-bar i{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,var(--sglv-accent),var(--sglv-purple));box-shadow:0 0 8px var(--sglv-accent-glow);} .sglv-ptw-ach-hero-sub{font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);position:relative;z-index:1;} /* 面板 */ .sglv-ptw-ach-panel{padding:14px 16px;border:1px solid var(--sglv-border);border-radius:var(--sglv-r-lg);background:var(--sglv-surface-deep);} .sglv-ptw-ach-panel-title{display:flex;align-items:center;gap:7px;margin-bottom:12px;font-size:var(--sglv-fs-base);font-weight:600;color:var(--sglv-text);} /* v2.22.1: 标题/状态图标尺寸约束——ICONS SVG 无 width/height,默认撑满整行导致版面错乱 */ .sglv-ptw-ach-panel-title svg{width:14px;height:14px;flex-shrink:0;} .sglv-ptw-ach-state-icon svg{width:34px;height:34px;flex-shrink:0;} .sglv-ptw-ach-count{margin-left:auto;padding:1px 8px;border-radius:var(--sglv-r-md);background:var(--sglv-accent-soft);color:var(--sglv-accent);font-size:var(--sglv-fs-sm);} .sglv-ptw-ach-note{padding:12px;border:1px dashed var(--sglv-amber-strong);border-radius:var(--sglv-r-sm);color:var(--sglv-amber);font-size:var(--sglv-fs-md);text-align:center;} /* 稀有度分布条 */ .sglv-ptw-ach-tier-body{display:flex;flex-direction:column;gap:9px;} .sglv-ptw-ach-tier{display:grid;grid-template-columns:52px 1fr 58px;align-items:center;gap:10px;} .sglv-ptw-ach-tier-label{font-size:var(--sglv-fs-md);color:var(--sglv-text-secondary);} .sglv-ptw-ach-tier-track{height:7px;border-radius:4px;background:var(--sglv-surface-2);overflow:hidden;} .sglv-ptw-ach-tier-track i{display:block;height:100%;border-radius:4px;background:linear-gradient(90deg,var(--sglv-accent),var(--sglv-purple));box-shadow:0 0 8px var(--sglv-accent-glow);transition:width .35s ease;} .sglv-ptw-ach-tier-val{font-size:var(--sglv-fs-md);text-align:right;color:var(--sglv-text-secondary);font-variant-numeric:tabular-nums;} /* 时间线 */ /* v2.22.3: 图表按设计宽 640 封顶居中(svg width=100% 在宽窗口被等比放大数倍); 明细列表改双列横版,24 条成就两列排布不再细长条 */ .sglv-ptw-ach-chart{margin:2px 0 10px;max-width:640px;width:100%;} .sglv-ptw-ach-chart svg{width:100%;height:auto;} .sglv-ptw-ach-tl-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:6px;} .sglv-ptw-ach-tl-row{display:flex;align-items:center;justify-content:space-between;padding:5px 9px;border-radius:var(--sglv-r-xs);background:var(--sglv-surface-1);font-size:var(--sglv-fs-md);color:var(--sglv-text-secondary);} .sglv-ptw-ach-tl-row b{color:var(--sglv-accent);} /* 明细列表 */ /* v2.22.3: 双列网格——横向利用宽度,滚动区高度减半 */ .sglv-ptw-ach-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2px 12px;overflow-y:auto;align-content:start;min-height:0;max-height:none;padding-right:4px;} .sglv-ptw-ach-item{display:grid;grid-template-columns:8px 1fr auto;align-items:center;gap:10px;padding:8px 8px;border-radius:var(--sglv-r-sm);transition:background var(--sglv-dur) var(--sglv-ease);} .sglv-ptw-ach-item:hover{background:var(--sglv-accent-wash);} .sglv-ptw-ach-item.locked{opacity:.62;} .sglv-ptw-ach-dot{width:8px;height:8px;border-radius:50%;} .sglv-ptw-ach-info{min-width:0;} .sglv-ptw-ach-name{font-size:var(--sglv-fs-base);color:var(--sglv-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .sglv-ptw-ach-meta{margin-top:2px;font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);} .sglv-ptw-ach-tier-tag{padding:1px 7px;border:1px solid;border-radius:9px;font-size:var(--sglv-fs-xs);white-space:nowrap;} @media (max-width:820px){.sglv-ptw-ach-tier{grid-template-columns:48px 1fr 52px;}.sglv-ptw-ach-hero-val{font-size:22px;}} `; /* v1.0.5: 同框架 CSS 注入,走共享 injectStyleOnce */ function injectCSS() { __sglvPtwInjectStyle('sglv-ptw-ach-style', CSS); } // ---- 稀有度分档(percent = 全球达成率,越低越稀有)---- const RARITY_TIERS = [ { key: 'common', min: 50, color: '#22c55e', label: { zh: '常见', en: 'Common' } }, { key: 'uncommon', min: 20, color: '#84cc16', label: { zh: '少见', en: 'Uncommon' } }, { key: 'rare', min: 5, color: '#fbbf24', label: { zh: '稀有', en: 'Rare' } }, { key: 'legendary', min: 0, color: '#ef4444', label: { zh: '传说', en: 'Legendary' } }, ]; function rarityOf(percent) { const p = Number(percent) || 0; return RARITY_TIERS.find(t => p >= t.min) || RARITY_TIERS[RARITY_TIERS.length - 1]; } /** 稀有度积分:越稀有得分越高(0-100),用于「成就分」汇总 */ function rarityScore(percent) { const p = Math.max(0, Math.min(100, Number(percent) || 0)); // percent=0 → 100 分;percent=100 → 0 分 return Math.round((100 - p) * 10) / 10; } function fmtDate(ts) { if (!ts) return ''; try { const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } catch (e) { return ''; } } function fmtDay(ts) { if (!ts) return ''; try { const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } catch (e) { return ''; } } // ========================================================================== function mount(container, ctx) { const H = ctx.host; const T = H.T; const ICONS = H.ICONS; const isZh = H.isZh; const escHtml = H.escHtml; const Charts = H.SGLVCharts; injectCSS(); const root_ = document.createElement('div'); root_.className = 'sglv-ptw-ach'; container.appendChild(root_); // ---- 游戏选择器 ---- const games = (ctx.getGames() || []).slice() .sort((a, b) => (b.playtime || 0) - (a.playtime || 0)); if (!games.length) { root_.innerHTML = H.emptyHtml(T.ptNoData); return; } const picker = document.createElement('div'); picker.className = 'sglv-ptw-ach-picker sglv-ptw-hover is-flat'; picker.innerHTML = `
${escHtml(isZh ? '选择游戏' : 'Select a game')}
`; root_.appendChild(picker); const sel = picker.querySelector('#sglv-ptw-ach-game'); sel.innerHTML = games.map(g => `` ).join(''); const cur = ctx.view && ctx.view.achAppId; if (cur && games.some(g => String(g.appid) === String(cur))) sel.value = String(cur); const body = document.createElement('div'); body.className = 'sglv-ptw-ach-body'; root_.appendChild(body); function setLoading(msg) { body.innerHTML = `
${escHtml(msg || (isZh ? '加载中…' : 'Loading…'))}
`; } function setState(icon, msg, sub) { body.innerHTML = `
${icon || ''}
${escHtml(msg)}
${sub ? `
${escHtml(sub)}
` : ''}
`; } /** 无 API Key 时的引导(与 SGIS 口径一致:引导去 dev/apikey 配置) */ function renderNoKey() { setState(ICONS.trophy || '🏆', isZh ? '需要 Steam Web API Key' : 'Steam Web API Key required', isZh ? '请在设置中配置后重试' : 'Configure it in settings and retry'); } // ---- 主渲染 ---- async function load(appId) { setLoading(); let player, global; try { player = await H.getPlayerAchievements(appId); } catch (e) { setState(ICONS.trophy || '🏆', isZh ? '成就数据获取失败' : 'Failed to load achievements', String(e && e.message || e)); return; } if (player && player.error === 'NO_KEY') { renderNoKey(); return; } if (!player || (player.error && player.error !== 'API_FAIL')) { setState(ICONS.trophy || '🏆', isZh ? '成就数据获取失败' : 'Failed to load achievements', player && player.error ? String(player.error) : ''); return; } if (!player.total) { setState(ICONS.trophy || '🏆', isZh ? '该游戏暂无成就' : 'No achievements for this game'); return; } // 全球达成率:软依赖,失败不阻断(仅缺稀有度维度) try { global = await H.getGlobalAchievements(appId); } catch (e) { global = null; } const gMap = {}; (global || []).forEach(g => { gMap[g.name] = Number(g.percent) || 0; }); // v2.22.1: 空数组也视为「全球达成率不可用」——原判断 global !== null, // 空结果会渲染全 0 的分布条而非诚实提示 render(player, gMap, Array.isArray(global) && global.length > 0); } function render(player, gMap, hasGlobal) { const list = player.achievements || []; const total = player.total || list.length; const unlockedList = list.filter(a => a.achieved && a.unlocktime); const unlocked = player.unlocked || unlockedList.length; // ---- 合并 + 分档 ---- // v2.22.1: 合并键修正——fetchPlayerAchievements 的 a.name 是**本地化显示名**, // 而 GetGlobalAchievementPercentages 返回的 name 是**内部 API 名**(同 fetchPlayerAchievements 的 apiname)。 // 原实现用 a.name 查 gMap → 永远 miss → 稀有度全部分档为 null、分布条全 0/0。 const merged = list.map(a => { const p = gMap[a.apiname || a.name]; return { name: a.name, achieved: !!a.achieved, unlocktime: a.unlocktime || 0, percent: (p === undefined || p === null) ? null : p, tier: (p === undefined || p === null) ? null : rarityOf(p), score: (p === undefined || p === null) ? null : rarityScore(p), }; }); const pct = total > 0 ? (unlocked / total * 100) : 0; const scoreSum = merged.reduce((s, a) => s + (a.score || 0), 0); const maxScore = merged.length * 100; // ---- 分档统计 ---- const tierStats = RARITY_TIERS.map(t => { const inTier = merged.filter(a => a.tier && a.tier.key === t.key); const got = inTier.filter(a => a.achieved).length; return { ...t, total: inTier.length, unlocked: got, rate: inTier.length ? (got / inTier.length * 100) : 0 }; }); // ---- 时间线(按年-月聚合)---- const tlMap = new Map(); unlockedList.forEach(a => { const k = fmtDate(a.unlocktime); if (!k) return; tlMap.set(k, (tlMap.get(k) || 0) + 1); }); const timeline = [...tlMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); body.innerHTML = ''; // ---- HERO 摘要 ---- const hero = document.createElement('div'); hero.className = 'sglv-ptw-ach-hero'; hero.innerHTML = `
${unlocked}/${total}
${escHtml(isZh ? '已解锁' : 'Unlocked')}
${pct.toFixed(1)}%
${hasGlobal ? `
${scoreSum.toFixed(0)}
${escHtml(isZh ? '成就分' : 'Score')}
${maxScore > 0 ? `满分 ${maxScore}` : ''}
` : ''}
${timeline.length}
${escHtml(isZh ? '活跃月份' : 'Active months')}
${escHtml(isZh ? '有解锁记录的月份数' : 'months with unlocks')}
`; body.appendChild(hero); // ---- 稀有度分布 ---- const tierPanel = document.createElement('div'); tierPanel.className = 'sglv-ptw-ach-panel sglv-ptw-hover'; tierPanel.innerHTML = `
${ICONS.trophy || '🏆'} ${escHtml(isZh ? '稀有度分布' : 'Rarity distribution')}
`; const tierBody = document.createElement('div'); tierBody.className = 'sglv-ptw-ach-tier-body'; if (!hasGlobal) { tierBody.innerHTML = `
${escHtml(isZh ? '全球达成率不可用,稀有度维度跳过' : 'Global achievement rates unavailable')}
`; } else { const maxN = Math.max(1, ...tierStats.map(t => t.total)); tierBody.innerHTML = tierStats.map(t => `
${escHtml(isZh ? t.label.zh : t.label.en)}
${t.unlocked}/${t.total}
`).join(''); } tierPanel.appendChild(tierBody); body.appendChild(tierPanel); // ---- 时间线图 ---- if (timeline.length > 0) { const tlPanel = document.createElement('div'); tlPanel.className = 'sglv-ptw-ach-panel sglv-ptw-hover'; tlPanel.innerHTML = `
${ICONS.trend || '📈'} ${escHtml(isZh ? '解锁时间线' : 'Unlock timeline')}
`; tlPanel.innerHTML += `
${Charts.lineChart([{ data: timeline.map(x => x[1]) }], { width: 640, height: 180, pL: 34, pR: 20, pT: 18, pB: 26, smooth: false, colors: [Charts.accent()], area: { fill: Charts.accent(), opacity: 0.28 }, gridCount: 3, yFormatter: v => String(Math.round(v)), xLabels: timeline.map(x => x[0]), xLabelEvery: Math.max(1, Math.ceil(timeline.length / 8)), xLabelLast: true, dots: { r: 3, stroke: '#0f172a', strokeWidth: 1, title: (v, i) => `${timeline[i][0]}: ${v}`, }, })}
`; // 逐月明细 tlPanel.innerHTML += `
${timeline.slice().reverse().slice(0, 12).map(([m, n]) => `
${escHtml(m)}${n}
`).join('')}
`; body.appendChild(tlPanel); } // ---- 成就明细(按稀有度降序:最稀有的排前面)---- const listPanel = document.createElement('div'); listPanel.className = 'sglv-ptw-ach-panel sglv-ptw-hover'; const sorted = merged.slice().sort((a, b) => { // 未解锁优先展示(提示可玩),同状态内按稀有度 if (a.achieved !== b.achieved) return a.achieved ? 1 : -1; return (a.percent == null ? 999 : a.percent) - (b.percent == null ? 999 : b.percent); }); listPanel.innerHTML = `
${ICONS.list || '📋'} ${escHtml(isZh ? '成就明细' : 'All achievements')} ${merged.length}
${sorted.map(a => { const color = a.tier ? a.tier.color : '#64748b'; return `
${escHtml(a.name)}
${a.achieved ? escHtml(fmtDay(a.unlocktime)) : escHtml(isZh ? '未解锁' : 'Locked')}${a.percent != null ? ` · ${isZh ? '全球' : 'global'} ${a.percent.toFixed(1)}%` : ''}
${a.percent != null ? `${escHtml(isZh ? a.tier.label.zh : a.tier.label.en)}` : ''}
`; }).join('')}
`; body.appendChild(listPanel); } sel.addEventListener('change', () => { const v = sel.value; ctx.setView({ achAppId: v }); load(v); }); load(sel.value); } // ---- 注册 ---- if (!root.SGLVPTW) { console.warn('[PTW-TAB-ACH] 框架未就绪,无法注册'); } else { root.SGLVPTW.registerTab({ key: 'achievements', label: '成就', order: 35, requires: [ 'getOwnedGames', 'getPlayerAchievements', 'T', 'ICONS', 'isZh', 'escHtml', 'emptyHtml', 'SGLVCharts', ], softRequires: ['getGlobalAchievements'], mount, }); console.log('[PTW-TAB-ACH] 已注册'); } })(typeof window !== 'undefined' ? window : globalThis); /* ---------- sglv-ptw-tab-value.lib.js ---------- */ /* ============================================================================= * SGLV PTW · Tab: 价值分析 (Value) * ----------------------------------------------------------------------------- * Phase 3 / T3.4 新增能力。三个分析视角: * ① 挂卡进度 —— 卡牌掉落推断(进度条 + 预估剩余时长 + 是否仍可挂卡) * ② 徽章评分 —— 复用主脚本 computeBadgeValueScores(xp/level/卡价/完成度/掉落 五维加权) * ③ 卡牌市场 —— 卡牌数量 / 均价 / 中位价 / 忽略最高价均价 / 税后收入预估 * * 算法移植自 steam-kit `src/utils/card-drop.ts:105-167`(零依赖纯函数): * CARD_DROP_INTERVAL_MINUTES / FAST_DROP_PLAYTIME_THRESHOLD * MAX_BADGE_LEVEL / DEFAULT_TOTAL_CARDS / CARDS_PER_BADGE_LEVEL * isGameBadge / isBadgeFarmable * estimateTotalCards / estimateCardsDropped / calculateEstimatedTime * * ⚠️ 推断性质(非精确值): * Steam Web API 不返回剩余卡牌数(需解析社区页),本 Tab 全部为经验估算。 * 掉落模型:前 2 小时每 30 分钟 1 张,超过后速度减半(每 60 分钟 1 张)。 * 徽章等级兜底:level × ceil(totalCards/2),与时长推断取较大值,上限 totalCards。 * * ⚠️ 相对源实现的一处**增强**(有意偏离,已在下方标注): * isBadgeFarmable 的满级阈值改用 badge._maxLevel(SteamCardExchange 真实最大等级) * 优先,缺失时回落 MAX_BADGE_LEVEL(5)。源实现硬编码 5, * 对 maxLevel≠5 的游戏会误判满级。 * ========================================================================== */ (function (root) { 'use strict'; // ========================================================================== // 经验常量(逐行搬运自 card-drop.ts:31-66) // ========================================================================== /** 平均卡牌掉落间隔(分钟) */ const CARD_DROP_INTERVAL_MINUTES = 30; /** 快速掉卡阶段阈值(分钟):前 2 小时掉卡最快,之后速度减半 */ const FAST_DROP_PLAYTIME_THRESHOLD = 120; /** 游戏徽章最高等级(兜底值,_maxLevel 存在时以其为准) */ const MAX_BADGE_LEVEL = 5; /** 卡牌总数默认估算(Steam 游戏通常 5-15 张,取中位数 8) */ const DEFAULT_TOTAL_CARDS = 8; /** 每合成 1 级徽章所需卡牌套数 */ const CARDS_PER_BADGE_LEVEL = 1; /** 市场数据默认抓取的游戏数(fetchBadgeMarkets 内部每批间隔 500ms,别贪多) */ const DEFAULT_MARKET_APPS = 10; // ========================================================================== // 纯算法:卡牌掉落推断(card-drop.ts:81-167) // ========================================================================== /** 是否为游戏徽章(GetBadges 混编了社区/活动/节日徽章,只有游戏徽章带 appid) */ function isGameBadge(badge) { return !!badge && typeof badge.appid === 'number' && badge.appid > 0; } /** 徽章真实最高等级:优先卡牌库 _maxLevel,缺失回落 5 */ function badgeMaxLevel(badge) { const m = badge && Number(badge._maxLevel); return Number.isFinite(m) && m > 1 ? Math.floor(m) : MAX_BADGE_LEVEL; } /** 是否仍可挂卡(未满级) */ function isBadgeFarmable(badge) { if (!isGameBadge(badge)) return false; return (badge.level || 0) < badgeMaxLevel(badge); } /** * 估算卡牌总数。 * 已合成过徽章(level>0)时,总数不应低于已合成的套数 → max(DEFAULT, level)。 */ function estimateTotalCards(badge) { if (badge && (badge.level || 0) > 0) { return Math.max(DEFAULT_TOTAL_CARDS, badge.level); } return DEFAULT_TOTAL_CARDS; } /** * 估算已掉落卡牌数(取时长推断与徽章推断的较大值,上限 totalCards)。 * @param {number} playtimeForever 总游玩时长(分钟) */ function estimateCardsDropped(playtimeForever, badge, totalCards) { const pt = Math.max(0, Number(playtimeForever) || 0); const total = Math.max(1, Number(totalCards) || DEFAULT_TOTAL_CARDS); // ① 时长推断:快速阶段(前 120 分钟)30 分钟/张,之后 60 分钟/张 const fastPlaytime = Math.min(pt, FAST_DROP_PLAYTIME_THRESHOLD); const fastDrops = Math.floor(fastPlaytime / CARD_DROP_INTERVAL_MINUTES); const remaining = Math.max(0, pt - FAST_DROP_PLAYTIME_THRESHOLD); const steadyDrops = Math.floor(remaining / (CARD_DROP_INTERVAL_MINUTES * 2)); const playtimeBased = Math.min(fastDrops + steadyDrops, total); // ② 徽章等级兜底:每级徽章约消耗 ceil(total/2) 张掉落卡 const lv = badge && (badge.level || 0) > 0 ? badge.level : 0; const badgeBased = lv > 0 ? lv * Math.ceil(total / 2) : 0; return Math.min(Math.max(playtimeBased, badgeBased), total); } /** 预估剩余挂卡时长(分钟)= 剩余卡牌数 × 掉落间隔 */ function calculateEstimatedTime(cardsRemaining) { const left = Number(cardsRemaining) || 0; if (left <= 0) return 0; return Math.ceil(left * CARD_DROP_INTERVAL_MINUTES); } /** * 单游戏挂卡进度推断(Tab 层聚合,算法组合上面的纯函数)。 * @returns {{appId:number,total:number,dropped:number,remaining:number, * percent:number,etaMinutes:number,done:boolean,farmable:boolean, * level:number,maxLevel:number,progress:number}} */ function inferCardDropProgress(playtimeForever, badge) { const b = badge || null; const total = estimateTotalCards(b); const dropped = estimateCardsDropped(playtimeForever, b, total); const remaining = Math.max(0, total - dropped); const maxLv = badgeMaxLevel(b); const level = (b && b.level) || 0; const farmable = isBadgeFarmable(b); const done = remaining === 0 || !farmable; return { appId: b ? b.appid : 0, total, dropped, remaining, // 掉落进度(0~1) percent: total > 0 ? dropped / total : 0, // 徽章等级进度(0~1)。badgeMaxLevel 保证 maxLv ≥ 2,故不必再判 maxLv > 1。 progress: Math.max(0, Math.min(1, level / maxLv)), etaMinutes: done ? 0 : calculateEstimatedTime(remaining), done, farmable, level, maxLevel: maxLv, completed: !!(b && b.completion_time), }; } // ========================================================================== // 纯算法:徽章聚合 // ========================================================================== /** * 汇总徽章集合:等级分布 / 可挂卡数 / 累计 XP / 已合成数。 * @param {Array} badges enriched badges(含 _gameName / _maxLevel) */ function aggregateBadges(badges) { const list = Array.isArray(badges) ? badges : []; const gameBadges = list.filter(isGameBadge); const dist = {}; gameBadges.forEach(b => { const lv = (b.level || 0) > 0 ? (b.level || 0) : 0; dist[lv] = (dist[lv] || 0) + 1; }); const farmable = gameBadges.filter(isBadgeFarmable); return { gameBadges, total: gameBadges.length, // 已获得徽章(level >= 1,即已合成过至少 1 级) owned: gameBadges.filter(b => (b.level || 0) > 0).length, farmable: farmable.length, completed: gameBadges.filter(b => !!b.completion_time).length, totalXp: gameBadges.reduce((s, b) => s + (Number(b.xp) || 0), 0), levelDist: dist, }; } /** 挑出「值得抓市场」的游戏徽章:仍可挂卡 + 有游戏名,按等级降序 */ function pickMarketTargets(badges, limit) { const n = Math.max(1, Number(limit) || DEFAULT_MARKET_APPS); return (Array.isArray(badges) ? badges : []) .filter(b => isGameBadge(b) && isBadgeFarmable(b) && b._hasGameName) .sort((a, b) => (b.level || 0) - (a.level || 0) || (b.xp || 0) - (a.xp || 0)) .slice(0, n); } // ========================================================================== // 纯算法:卡牌市场摘要归一 // ========================================================================== /** * 把 fetchCardMarketLight 的 summary 归一成Tab 需要的形状。 * @param {object} market summary(单位:分) */ function summarizeMarket(market) { const m = market || {}; const cardCount = Math.max(0, Number(m.cardCount) || 0); const avg = Math.max(0, Number(m.avgPrice) || 0); const median = Math.max(0, Number(m.medianPrice) || 0); const avgNoMax = Math.max(0, Number(m.avgNoMax) || 0); const net = Math.max(0, Number(m.netIncome) || 0); return { appId: m.appId, cardCount, avg, median, avgNoMax, net, totalCost: Math.max(0, Number(m.totalCost) || 0), priceCount: Math.max(0, Number(m.priceCount) || 0), // 跌幅越大说明市场参考价越低(对买家越有利) spread: (avg > 0 && median > 0) ? Math.abs(avg - median) / Math.max(avg, median) : 0, hasData: cardCount > 0 && avg > 0, error: m.error || '', }; } /** 分(cent)→ 本地化货币字符串。优先读钱包 cookie,回落 ¥。 */ function formatCents(cents) { const v = Number(cents) || 0; if (v <= 0) return '—'; let symbol = '¥'; try { const m = document.cookie.match(/steamCountry=(\w{2})/) || []; const cc = (m[1] || 'CN').toLowerCase(); const map = { cn: '¥', us: '$', eu: '€', uk: '£', jp: '¥', kr: '₩', ru: '₽' }; symbol = map[cc] || '¥'; } catch (e) { /* cookie不可读 */ } return symbol + (v / 100).toFixed(2); } /** 分钟 → 人类可读时长 */ function fmtMinutes(min) { const m = Math.max(0, Math.round(Number(min) || 0)); if (m <= 0) return '0m'; if (m < 60) return `${m}m`; const h = Math.floor(m / 60); if (h < 24) return `${h}h${m % 60 ? `${m % 60}m` : ''}`; const d = Math.floor(h / 24); if (d < 30) return `${d}d${h % 24 ? `${h % 24}h` : ''}`; return `${Math.floor(d / 30)}mo${d % 30 ? `${d % 30}d` : ''}`; } // ========================================================================== // 渲染 // ========================================================================== function mount(container, ctx) { const H = ctx.host; const ICONS = H.ICONS; const isZh = H.isZh; const escHtml = H.escHtml; const Charts = H.SGLVCharts; injectCSS(); const root_ = document.createElement('div'); root_.className = 'sglv-ptw-value'; container.appendChild(root_); // ---- 状态容器 ---- const stateBox = document.createElement('div'); stateBox.className = 'sglv-ptw-value-state'; root_.appendChild(stateBox); const heroBox = document.createElement('div'); root_.appendChild(heroBox); // 工具栏:游戏选择器 + 重新加载 const toolbar = document.createElement('div'); toolbar.className = 'sglv-ptw-value-toolbar sglv-ptw-hover is-amber is-flat'; toolbar.innerHTML = `
${escHtml(isZh ? '选择游戏' : 'Select a game')}
`; root_.appendChild(toolbar); const sel = toolbar.querySelector('#sglv-ptw-value-game'); const reloadBtn = toolbar.querySelector('#sglv-ptw-value-reload'); const body = document.createElement('div'); body.className = 'sglv-ptw-value-body'; root_.appendChild(body); // ---- 挂卡进度面板(全库概览) ---- const farmBox = document.createElement('div'); body.appendChild(farmBox); // ---- 徽章评分面板 ---- const scoreBox = document.createElement('div'); body.appendChild(scoreBox); // ---- 卡牌市场面板(选中游戏) ---- const marketBox = document.createElement('div'); body.appendChild(marketBox); // ---- 内存态(随 mount 重建,不跨调用持久化) ---- const S = { badges: null, // { badges, playerLevel, playerXp } markets: {}, // { [appId]: summary } scores: {}, // { [appId]: scoreObj } agg: null, // aggregateBadges 结果 targets: [], // 实际抓了市场的 appId 列表 loading: false, err: '', }; /** 主加载流程:徽章 → 目标挑选 → 市场 → 评分 */ async function loadAll(force) { if (S.loading) return; S.loading = true; S.err = ''; reloadBtn.disabled = true; setState(isZh ? '加载徽章数据…' : 'Loading badges…', true); // ① 徽章(需要 API Key) let badgeData = null; try { badgeData = await H.getUserBadges(); } catch (e) { S.err = String((e && e.message) || e); } if (!badgeData || !Array.isArray(badgeData.badges)) { S.loading = false; reloadBtn.disabled = false; renderNoBadge(); return; } S.badges = badgeData; S.agg = aggregateBadges(badgeData.badges); renderHero(); renderFarmPanel(); if (S.agg.total === 0) { S.loading = false; reloadBtn.disabled = false; clearBox(marketBox); clearBox(scoreBox); setState(isZh ? '未找到游戏徽章(GetBadges 只返回了社区/节日徽章)' : 'No game badges found', true); return; } // ② 挑选目标并抓市场 S.targets = pickMarketTargets(S.badges.badges, DEFAULT_MARKET_APPS); S.markets = {}; if (S.targets.length) { setState(isZh ? `抓取 ${S.targets.length} 个游戏的卡牌市场数据…` : `Fetching card market for ${S.targets.length} games…`, true); try { const raw = await H.getBadgeMarkets(S.targets.map(b => b.appid)); S.markets = raw || {}; } catch (e) { S.err = String((e && e.message) || e); } } // ③ 评分(依赖 markets;缺失 market 的游戏卡价维度记 0) const marketMap = {}; Object.keys(S.markets || {}).forEach(k => { marketMap[k] = S.markets[k]; }); try { S.scores = H.getBadgeValueScores ? (H.getBadgeValueScores(S.agg.gameBadges, marketMap) || {}) : {}; } catch (e) { S.scores = {}; S.err = S.err || String((e && e.message) || e); } S.loading = false; reloadBtn.disabled = false; renderScorePanel(); syncSelect(); renderMarketPanel(); } /** 无徽章数据(多为缺 API Key) */ function renderNoBadge() { heroBox.innerHTML = ''; clearBox(farmBox); clearBox(scoreBox); clearBox(marketBox); toolbar.style.display = 'none'; setState( isZh ? '需要 Steam API Key 才能读取徽章数据' : 'Steam API Key required for badges', false, isZh ? '在脚本设置中填入 API Key 后即可查看挂卡进度与徽章评分。' : 'Set your API Key in settings to view badge value.' ); } function setState(msg, busy, sub) { stateBox.style.display = msg ? '' : 'none'; stateBox.innerHTML = msg ? `
${busy ? '' : (ICONS.info || 'ℹ️')} ${escHtml(msg)}
${sub ? `
${escHtml(sub)}
` : ''}` : ''; } function clearBox(box) { box.innerHTML = ''; } // ---- HERO:4 项汇总 ---- function renderHero() { const a = S.agg; if (!a) { heroBox.innerHTML = ''; return; } const lvl = (S.badges && Number(S.badges.playerLevel)) || 0; heroBox.innerHTML = `
${a.total}
${escHtml(isZh ? '游戏徽章' : 'Game badges')}
${escHtml(isZh ? `已获得 ${a.owned}` : `${a.owned} owned`)}
${a.farmable}
${escHtml(isZh ? '仍可挂卡' : 'Farmable')}
${escHtml(isZh ? `未满级${a.completed ? ` · 已合成 ${a.completed}` : ''}` : `not maxed${a.completed ? ` · ${a.completed} crafted` : ''}`)}
${lvl || '—'}
${escHtml(isZh ? 'Steam 等级' : 'Steam level')}
${escHtml(isZh ? `徽章 XP ${a.totalXp.toLocaleString()}` : `${a.totalXp.toLocaleString()} badge XP`)}
${Object.keys(S.markets || {}).length}
${escHtml(isZh ? '已抓市场' : 'Markets')}
${escHtml(isZh ? `目标上限 ${DEFAULT_MARKET_APPS}` : `cap ${DEFAULT_MARKET_APPS}`)}
`; } // ---- 面板①:挂卡进度(全库) ---- function renderFarmPanel() { const a = S.agg; if (!a) return; const games = ctx.getGames() || []; const playtimeMap = {}; games.forEach(g => { playtimeMap[String(g.appid)] = Number(g.playtime) || 0; }); // 取「仍可挂卡」的游戏徽章,按推断剩余卡牌数降序(最急迫的排前面) const rows = a.gameBadges .filter(b => isBadgeFarmable(b) && b._hasGameName) .map(b => { const p = inferCardDropProgress(playtimeMap[String(b.appid)] || 0, b); return { badge: b, p, name: b._gameName || String(b.appid), playtime: playtimeMap[String(b.appid)] || 0 }; }) .filter(r => !r.p.done) .sort((x, y) => y.p.remaining - x.p.remaining || y.p.level - x.p.level) .slice(0, 12); const doneCount = a.gameBadges.filter(b => isBadgeFarmable(b) && b._hasGameName).length - rows.length; farmBox.innerHTML = `
${ICONS.badge || '🏅'} ${escHtml(isZh ? '挂卡进度' : 'Card farming progress')} ${rows.length}
`; if (!rows.length) { farmBox.innerHTML += `
${escHtml( isZh ? `没有可挂卡的游戏(${doneCount} 个已完成或满级)` : `No farmable games (${doneCount} completed or maxed)` )}
`; return; } farmBox.innerHTML += `
${escHtml(isZh ? `按估算剩余卡牌数排序 · 掉落模型:前 2h 每 30 分钟 1 张,之后每 60 分钟 1 张(估算值,非精确)` : 'Sorted by estimated cards remaining · drop model: 1 card / 30 min for first 2h, then 1 / 60 min (estimated)')}
${rows.map(r => farmRow(r, playtimeMap)).join('')}
`; } function farmRow(r, playtimeMap) { const p = r.p; const pct = Math.round(p.percent * 100); return `
${escHtml(r.name)} Lv.${p.level}/${p.maxLevel}
${escHtml(isZh ? '已掉落' : 'Dropped')} ${p.dropped}/${p.total} ${escHtml(isZh ? '预计还需' : 'ETA')} ${fmtMinutes(p.etaMinutes)} ${escHtml(isZh ? '时长' : 'Playtime')} ${fmtMinutes(playtimeMap[String(r.badge.appid)] || 0)}
`; } // ---- 面板②:徽章评分排行 ---- function renderScorePanel() { const list = Object.values(S.scores || {}) .filter(s => s && typeof s.score === 'number') .sort((a, b) => b.score - a.score) .slice(0, 10); scoreBox.innerHTML = `
${ICONS.star || '⭐'} ${escHtml(isZh ? '徽章价值评分' : 'Badge value score')}${list.length}
`; if (!list.length) { scoreBox.innerHTML += `
${escHtml(isZh ? '暂无可用评分数据' : 'No score data')}
`; return; } const hasBreakdown = list.some(s => s.breakdown); scoreBox.innerHTML += `
${escHtml(isZh ? '权重:XP 25% · 等级 20% · 卡价 30% · 完成度 15% · 掉落进度 10%(与主脚本价值分析同口径)' : 'Weights: XP 25% · level 20% · card price 30% · completion 15% · drop 10%')}
${list.map((s, i) => { const pct = Math.max(0, Math.min(100, s.score)); const hasMkt = !!(s.marketData && s.marketData.avgPrice > 0); return `
${i + 1}
${escHtml(s.gameName || s.appId)}
Lv.${s.level || 1}/${s.maxLevel || MAX_BADGE_LEVEL} ${escHtml(isZh ? 'XP' : 'XP')} ${Number(s.xp) || 0} ${escHtml(isZh ? '卡均' : 'Avg')} ${hasMkt ? escHtml(formatCents(s.marketData.avgPrice)) : '—'}
${hasBreakdown ? `
${bdBar('XP', s.breakdown.xp, '#a78bfa')}${bdBar(isZh ? '等级' : 'Lv', s.breakdown.level, '#3b82f6')}${bdBar(isZh ? '卡价' : 'Price', s.breakdown.cardPrice, '#22c55e')}${bdBar(isZh ? '完成' : 'Done', s.breakdown.completion, '#f59e0b')}${bdBar(isZh ? '掉落' : 'Drop', s.breakdown.level_max, '#06b6d4')}
` : ''}
${s.score}
`; }).join('')}
`; } function bdBar(label, val, color) { const v = Math.max(0, Math.min(100, Number(val) || 0)); return ` ${escHtml(label)} `; } // ---- 面板③:卡牌市场(选中游戏) ---- function renderMarketPanel() { const appId = sel.value; const badge = S.agg.gameBadges.find(b => String(b.appid) === String(appId)); if (!badge) { marketBox.innerHTML = `
${ICONS.market || '🛒'} ${escHtml(isZh ? '卡牌市场' : 'Card market')}
${escHtml(isZh ? '请选择一个游戏徽章' : 'Pick a game badge')}
`; return; } const raw = (S.markets || {})[String(appId)]; const mk = summarizeMarket(raw); const p = inferCardDropProgress(0, badge); marketBox.innerHTML = `
${ICONS.market || '🛒'} ${escHtml(isZh ? '卡牌市场 · ' : 'Card market · ')}${escHtml(badge._gameName || appId)}
`; if (!raw) { marketBox.innerHTML += `
${escHtml(isZh ? `该游戏未纳入本次市场抓取(仅抓取仍可挂卡的前 ${DEFAULT_MARKET_APPS} 个徽章)` : `Not fetched in this batch (only top ${DEFAULT_MARKET_APPS} farmable badges)`)}
`; return; } if (!mk.hasData) { marketBox.innerHTML += `
${escHtml(mk.error ? (isZh ? `市场数据获取失败:${mk.error}` : `Market fetch failed: ${mk.error}`) : (isZh ? '市场暂无挂单,无法估值' : 'No listings available'))}
`; return; } marketBox.innerHTML += `
${mStat(isZh ? '卡牌数' : 'Cards', String(mk.cardCount))} ${mStat(isZh ? '均价' : 'Avg', formatCents(mk.avg))} ${mStat(isZh ? '中位价' : 'Median', formatCents(mk.median))} ${mStat(isZh ? '均价(去极值)' : 'Avg ex-max', formatCents(mk.avgNoMax))} ${mStat(isZh ? '全套成本' : 'Set cost', formatCents(mk.totalCost))} ${mStat(isZh ? '预计税后收入' : 'Est. net', formatCents(mk.net), '#22c55e')}
${escHtml(isZh ? `掉落进度估算 ${p.dropped}/${p.total} 张 · 市场价差 ${(mk.spread * 100).toFixed(1)}%(均价与中位价偏离,越小越稳定)` : `Drop estimate ${p.dropped}/${p.total} · spread ${(mk.spread * 100).toFixed(1)}% (avg vs median)`)}
`; // 卡牌价格条形图(均价 vs 中位价对照) const cards = Array.isArray(raw.cards) ? raw.cards.filter(c => c && Number(c.sellPrice) > 0) : []; if (cards.length >= 2 && Charts && typeof Charts.lineChart === 'function') { const sorted = cards.slice().sort((a, b) => a.sellPrice - b.sellPrice); const chart = document.createElement('div'); chart.className = 'sglv-ptw-value-chart'; chart.innerHTML = Charts.lineChart([{ data: sorted.map(c => c.sellPrice / 100) }], { width: 680, height: 180, pL: 44, pR: 20, pT: 16, pB: 26, smooth: false, colors: [Charts.accent()], gridCount: 4, yFormatter: (v) => v.toFixed(2), xLabels: sorted.map((_, i) => `#${i + 1}`), xLabelEvery: Math.max(1, Math.ceil(sorted.length / 8)), xLabelLast: true, dots: { r: 2, stroke: '#0f172a', strokeWidth: 1, title: (v, i) => { const c = sorted[i]; if (!c) return String(v); const nm = String(c.name || c.hashName || '').slice(0, 22); return `${nm}\n${(Number(c.sellPrice) / 100).toFixed(2)}`; }, }, }); marketBox.appendChild(chart); const cap = document.createElement('div'); cap.className = 'sglv-ptw-value-chart-cap'; cap.textContent = isZh ? `${cards.length} 张卡牌卖价(按价格升序,含市场搜索页前 100 条)` : `${cards.length} card sell prices (ascending, first 100 listings)`; marketBox.appendChild(cap); } } function mStat(label, value, color) { return `
${escHtml(value)}
${escHtml(label)}
`; } // ---- 选择器同步 ---- function syncSelect() { const list = (S.agg ? S.agg.gameBadges : []) .filter(b => b._hasGameName) .sort((a, b) => (b.level || 0) - (a.level || 0) || (b.xp || 0) - (a.xp || 0)); sel.innerHTML = list.map(b => `` ).join(''); if (!list.length) { marketBox.innerHTML = ''; return; } // 优先选已抓市场且仍可挂卡的第一个 const cur = ctx.view && ctx.view.valueAppId; let target = String(cur || ''); const isValid = target && list.some(b => String(b.appid) === target); if (!isValid) { const preferred = list.find(b => isBadgeFarmable(b) && (S.markets || {})[String(b.appid)]); target = preferred ? String(preferred.appid) : String(list[0].appid); } sel.value = target; ctx.setView({ valueAppId: target }); renderMarketPanel(); } sel.addEventListener('change', () => { const v = sel.value; ctx.setView({ valueAppId: v }); renderMarketPanel(); }); reloadBtn.addEventListener('click', () => loadAll(true)); // ---- 启动 ---- // 挂卡进度面板依赖徽章数据,市场面板依赖选择器,故整体等一次 loadAll const hasGames = (ctx.getGames() || []).length > 0; if (!hasGames) { heroBox.innerHTML = ''; toolbar.style.display = 'none'; setState(isZh ? '游戏库为空,先同步一次数据' : 'Library is empty, sync first', true); return; } loadAll(false); } // ========================================================================== // 样式 // ========================================================================== const CSS = ` .sglv-ptw-value{display:flex;flex-direction:column;gap:14px;min-height:0;flex:1;width:100%;} /* v1.0.2: family Tab 容器适配 flex 列布局——section 是 pane 的唯一子元素, 需要 flex:1 撑满 pane,内部 list 自适应滚动 */ #sglv-ptw-pane-family > .sglv-family-compare-section{display:flex;flex-direction:column;min-height:0;flex:1;gap:var(--sglv-sp-3);} #sglv-ptw-pane-family > .sglv-family-compare-section > .sglv-playtime-section-title{flex-shrink:0;} #sglv-ptw-pane-family .sglv-fam-list{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding-right:4px;margin-top:0;} .sglv-ptw-value-state{padding:10px 14px;border:1px dashed var(--sglv-amber-strong);border-radius:var(--sglv-r-md);background:var(--sglv-amber-wash);} .sglv-ptw-value-state:empty{display:none;} .sglv-ptw-value-state-msg{display:flex;align-items:center;gap:var(--sglv-sp-2);font-size:var(--sglv-fs-md);color:var(--sglv-amber);line-height:1.6;} .sglv-ptw-value-state-sub{margin-top:4px;font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);line-height:1.6;} .sglv-ptw-value-spin{display:inline-block;width:11px;height:11px;border:2px solid var(--sglv-amber-soft);border-top-color:var(--sglv-amber);border-radius:50%;animation:sglv-ptw-value-spin .7s linear infinite;flex-shrink:0;} @keyframes sglv-ptw-value-spin{to{transform:rotate(360deg);}} .sglv-ptw-value-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--sglv-sp-3);} .sglv-ptw-value-hero-item{ position:relative;padding:14px 16px;border:1px solid var(--sglv-border); border-radius:var(--sglv-r-lg);background: linear-gradient(135deg,var(--sglv-amber-wash) 0%,transparent 55%), var(--sglv-surface-1); text-align:center;display:flex;flex-direction:column;gap:4px;justify-content:center;overflow:hidden; box-shadow:var(--sglv-shadow-sm),inset 0 1px 0 var(--sglv-border-subtle); transition:transform var(--sglv-dur) var(--sglv-ease),box-shadow var(--sglv-dur) var(--sglv-ease),border-color var(--sglv-dur) var(--sglv-ease); } .sglv-ptw-value-hero-item::before{ content:'';position:absolute;inset:-1px;border-radius:var(--sglv-r-lg);pointer-events:none; background:linear-gradient(135deg,var(--sglv-amber-soft),transparent 60%); opacity:0;transition:opacity var(--sglv-dur) var(--sglv-ease); } .sglv-ptw-value-hero-item:hover{ transform:translateY(-2px); border-color:var(--sglv-amber-soft); box-shadow:var(--sglv-shadow-md),0 0 0 1px var(--sglv-amber-soft),0 0 24px var(--sglv-amber-wash),inset 0 1px 0 var(--sglv-border-subtle); } .sglv-ptw-value-hero-item:hover::before{opacity:1;} .sglv-ptw-value-hero-val{ font-size:22px;font-weight:700;font-variant-numeric:tabular-nums; background:linear-gradient(180deg,var(--sglv-text) 0%,var(--sglv-amber) 100%); -webkit-background-clip:text;background-clip:text; -webkit-text-fill-color:transparent;color:transparent; text-shadow:0 0 14px var(--sglv-amber-wash); position:relative;z-index:1; } .sglv-ptw-value-hero-lbl{font-size:var(--sglv-fs-md);color:var(--sglv-text-secondary);position:relative;z-index:1;} .sglv-ptw-value-hero-sub{font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);opacity:.85;position:relative;z-index:1;} .sglv-ptw-value-toolbar{display:flex;align-items:center;gap:var(--sglv-sp-3);padding:var(--sglv-sp-3) var(--sglv-sp-4);border:1px solid var(--sglv-border);border-radius:var(--sglv-r-md);background:var(--sglv-surface-1);flex-shrink:0;} .sglv-ptw-value-toolbar-label{font-size:var(--sglv-fs-md);color:var(--sglv-text-secondary);flex-shrink:0;} .sglv-ptw-value-select{flex:1;min-width:0;padding:7px 10px;border-radius:var(--sglv-r-sm);border:1px solid var(--sglv-border);background:var(--sglv-bg);color:var(--sglv-text);font-size:var(--sglv-fs-base);outline:none;transition:border-color var(--sglv-dur) var(--sglv-ease),box-shadow var(--sglv-dur) var(--sglv-ease);} .sglv-ptw-value-select:focus{border-color:var(--sglv-accent);box-shadow:0 0 0 3px var(--sglv-accent-wash);} .sglv-ptw-value-reload{width:32px;height:32px;flex-shrink:0;border-radius:var(--sglv-r-sm);border:1px solid var(--sglv-border);background:var(--sglv-surface-1);color:var(--sglv-text);font-size:var(--sglv-fs-lg);cursor:pointer;line-height:1;transition:background var(--sglv-dur) var(--sglv-ease),border-color var(--sglv-dur) var(--sglv-ease),transform var(--sglv-dur) var(--sglv-ease),color var(--sglv-dur) var(--sglv-ease);} .sglv-ptw-value-reload:hover:not(:disabled){background:var(--sglv-accent-soft);color:var(--sglv-accent);border-color:var(--sglv-accent-soft);} .sglv-ptw-value-reload:active:not(:disabled){transform:rotate(180deg);} .sglv-ptw-value-reload:disabled{opacity:.4;cursor:not-allowed;} .sglv-ptw-value-body{display:flex;flex-direction:column;gap:14px;min-height:0;flex:1;overflow-y:auto;overscroll-behavior:contain;padding-right:4px;} .sglv-ptw-value-panel-title{display:flex;align-items:center;gap:7px;margin-bottom:10px;font-size:var(--sglv-fs-base);font-weight:600;color:var(--sglv-text);} .sglv-ptw-value-count{margin-left:auto;padding:1px 8px;border-radius:var(--sglv-r-md);background:var(--sglv-accent-soft);color:var(--sglv-accent);font-size:var(--sglv-fs-sm);} .sglv-ptw-value-note{margin-bottom:10px;padding:8px 10px;border:1px dashed var(--sglv-surface-border);border-radius:var(--sglv-r-sm);font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);line-height:1.7;} .sglv-ptw-value-farm-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:10px;} .sglv-ptw-value-farm-row{padding:10px 12px;border:1px solid var(--sglv-border);border-radius:var(--sglv-r-md);background:var(--sglv-surface-1);} .sglv-ptw-value-farm-head{display:flex;align-items:baseline;gap:var(--sglv-sp-2);margin-bottom:7px;} .sglv-ptw-value-farm-name{flex:1;min-width:0;font-size:var(--sglv-fs-base);font-weight:600;color:var(--sglv-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .sglv-ptw-value-farm-lv{font-size:var(--sglv-fs-sm);color:var(--sglv-amber);flex-shrink:0;font-variant-numeric:tabular-nums;} .sglv-ptw-value-bar{height:5px;border-radius:3px;background:var(--sglv-surface-2);overflow:hidden;} .sglv-ptw-value-bar-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--sglv-amber-light),var(--sglv-amber));box-shadow:0 0 6px var(--sglv-amber-soft);transition:width .4s ease;} .sglv-ptw-value-bar-fill.alt{background:linear-gradient(90deg,var(--sglv-accent),var(--sglv-purple));box-shadow:0 0 6px var(--sglv-accent-glow);} .sglv-ptw-value-farm-foot{display:flex;flex-wrap:wrap;gap:10px;margin-top:7px;font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);font-variant-numeric:tabular-nums;} .sglv-ptw-value-score-list{display:flex;flex-direction:column;gap:9px;} .sglv-ptw-value-score-row{display:grid;grid-template-columns:22px 1fr auto;align-items:start;gap:10px;padding:10px 12px;border:1px solid var(--sglv-border);border-radius:var(--sglv-r-md);background:var(--sglv-surface-1);} .sglv-ptw-value-rank{font-size:var(--sglv-fs-md);font-weight:700;color:var(--sglv-text-secondary);text-align:center;padding-top:2px;} .sglv-ptw-value-score-main{min-width:0;} .sglv-ptw-value-score-name{font-size:var(--sglv-fs-base);font-weight:600;color:var(--sglv-text);margin-bottom:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .sglv-ptw-value-score-sub{display:flex;flex-wrap:wrap;gap:10px;margin-top:6px;font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);font-variant-numeric:tabular-nums;} .sglv-ptw-value-score-num{font-size:17px;font-weight:700;color:var(--sglv-accent);font-variant-numeric:tabular-nums;} .sglv-ptw-value-bd{display:grid;grid-template-columns:repeat(auto-fit,minmax(64px,1fr));gap:6px;margin-top:8px;} .sglv-ptw-value-bd-item{display:flex;flex-direction:column;gap:3px;} .sglv-ptw-value-bd-lbl{font-size:var(--sglv-fs-xs);color:var(--sglv-text-secondary);} .sglv-ptw-value-bd-track{height:3px;border-radius:2px;background:var(--sglv-surface-2);overflow:hidden;} .sglv-ptw-value-bd-fill{display:block;height:100%;border-radius:2px;} .sglv-ptw-value-market-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:9px;margin-bottom:10px;} .sglv-ptw-value-mstat{padding:9px 11px;border-radius:9px;background:var(--sglv-surface-1);text-align:center;} .sglv-ptw-value-mstat-val{font-size:var(--sglv-fs-lg);font-weight:700;color:var(--sglv-text);font-variant-numeric:tabular-nums;} .sglv-ptw-value-mstat-lbl{margin-top:2px;font-size:var(--sglv-fs-xs);color:var(--sglv-text-secondary);} .sglv-ptw-value-chart{overflow-x:auto;} .sglv-ptw-value-chart-cap{margin-top:6px;font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);opacity:.8;} @media (max-width:820px){.sglv-ptw-value-farm-list{grid-template-columns:1fr;}.sglv-ptw-value-score-row{grid-template-columns:18px 1fr auto;}} `; /* v1.0.5: 同框架 CSS 注入,走共享 injectStyleOnce */ function injectCSS() { __sglvPtwInjectStyle('sglv-ptw-value-style', CSS); } // ---- 纯算法导出(供单测取用,不影响运行时) ---- root.__SGLV_PTW_VALUE_ALGO__ = { CARD_DROP_INTERVAL_MINUTES, FAST_DROP_PLAYTIME_THRESHOLD, MAX_BADGE_LEVEL, DEFAULT_TOTAL_CARDS, CARDS_PER_BADGE_LEVEL, isGameBadge, badgeMaxLevel, isBadgeFarmable, estimateTotalCards, estimateCardsDropped, calculateEstimatedTime, inferCardDropProgress, aggregateBadges, pickMarketTargets, summarizeMarket, }; // ---- 注册 ---- if (!root.SGLVPTW) { console.warn('[PTW-TAB-VALUE] 框架未就绪,无法注册'); } else { root.SGLVPTW.registerTab({ key: 'value', label: '价值', order: 50, requires: [ // getOwnedGames 经 ctx.getGames() 间接依赖(nameOf 反查游戏名),保留声明 'getOwnedGames', 'getUserBadges', 'getBadgeValueScores', 'getBadgeMarkets', 'ICONS', 'isZh', 'escHtml', ], // v2.21.1 审核修正:Charts 仅用于卡牌价格图且已有 typeof 守卫(缺图表库不影响主功能) // → SGLVCharts 属软依赖;'T'/'emptyHtml' 声明后从未使用 → 移除。 softRequires: ['SGLVCharts'], mount, }); console.log('[PTW-TAB-VALUE] 已注册'); } })(typeof window !== 'undefined' ? window : globalThis); /* ---------- sglv-ptw-tab-streak.lib.js ---------- */ /* ============================================================================= * SGLV PTW · Tab: 游玩足迹 (Streak) * ----------------------------------------------------------------------------- * Phase 4 / T4.2。基于 T4.1 的每日快照历史(host.playtimeHistory)派生四项指标: * ① 连续游玩天数 —— 当前 streak / 历史最长 streak * ② 年度游戏 —— 自然年内按「当日增量」排名的游戏 * ③ 首玩日期 —— 每个游戏在本地采样历史中首次出现的日期 * ④ 采样日历 —— 逐日活跃热力格(区分「活跃 / 有采样但未游玩 / 未采样」) * * ⚠️ 口径纪律(本 Tab 全部指标的共同前提,务必先读): * Steam Web API 只返回**累计**时长 playtime_forever,从不提供逐日历史。 * 本 Tab 的全部逐日指标都来自用户本地逐日采样(host.playtimeHistory), * 因此: * · 未采样的日子**不是**「没游玩」,而是「不知道」→ 日历里显式区分三态 * · streak 只按**实采日**计算,采样间隔超过 1 天即中断(宁可低估不可虚高) * · 首次启用当天只播种不产生增量(否则会误报「一天玩了 500 小时」) * UI 上必须如实呈现覆盖率,不得把「无数据」渲染成「0」。 * * 算法(全部为纯函数,便于单测): * dailyDeltas —— 相邻采样日逐游戏求差,得到 { day: { appid: deltaMinutes } } * activeDays —— 有正增量的采样日集合 * computeStreak —— 当前 / 最长连续活跃天数(含采样间隔判空) * yearRanking —— 按年内累计增量排名 * firstPlays —— 各游戏首次出现日期 * ========================================================================== */ (function (root) { 'use strict'; // ========================================================================== // 常量 // ========================================================================== /** 采样间隔超过此天数即认为中间有空洞,streak 中断 */ const MAX_STREAK_GAP_DAYS = 1; /** 日历热力格「算活跃」的最小增量(分钟)。低于此视为噪声 */ const ACTIVE_MIN_MINUTES = 1; /** 年度游戏取前 N 名 */ const YEAR_TOP_N = 10; /** 首玩日期取前 N 名(按最近优先,避免老游戏刷屏) */ const FIRST_TOP_N = 12; // ========================================================================== // 日期工具(纯函数,不依赖 Date.now 之外的宿主状态) // ========================================================================== /** 'YYYY-MM-DD' → UTC 毫秒(仅用于做日期差,不代表真实时刻) */ function dayKeyToMs(key) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(key || '')); if (!m) return NaN; return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])); } /** 两个日期间隔天数(b - a),非法输入返回 NaN */ function daysBetween(a, b) { const ma = dayKeyToMs(a); const mb = dayKeyToMs(b); if (Number.isNaN(ma) || Number.isNaN(mb)) return NaN; return Math.round((mb - ma) / 86400000); } // ========================================================================== // 纯算法 ①:逐日差分 // ========================================================================== /** * 由采样历史求逐游戏日增量。 * * @param {Object>} days 采样历史 { 'YYYY-MM-DD': { appid: minutes } } * @returns {{ deltas: Object>, sampledDays: string[], firstDay: string|null }} * * 语义:某日增量 = 当日快照值 − **上一个采样日**快照值。 * 中间未采样的日子不分配增量(不按天均摊)——否则 3 天没上号会被伪造成 * 「连续 3 天各玩 1 小时」,那是凭空编造。真实游玩日 = 有增量的采样日。 */ function dailyDeltas(days) { const src = (days && typeof days === 'object') ? days : {}; const keys = Object.keys(src).filter(k => src[k] && typeof src[k] === 'object').sort(); const deltas = {}; for (let i = 0; i < keys.length; i++) { const day = keys[i]; const cur = src[day] || {}; if (i === 0) { // 首个采样日无前值可比 → 只记基线,不产出增量 continue; } const prev = src[keys[i - 1]] || {}; const acc = {}; // 遍历当前日全部条目:新游戏也要计入(旧日无记录时 prev 视为 0 → 全量算新增) for (const appid of Object.keys(cur)) { const nowV = Math.max(0, Number(cur[appid]) || 0); const before = Math.max(0, Number(prev[appid]) || 0); // 时长单调递增,理论上 nowV >= before;若因回滚/接口抖动变小则记 0 const d = nowV > before ? nowV - before : 0; if (d >= ACTIVE_MIN_MINUTES) acc[appid] = d; } deltas[day] = acc; } return { deltas, sampledDays: keys, firstDay: keys.length ? keys[0] : null, baselineDay: keys.length ? keys[0] : null, }; } // ========================================================================== // 纯算法 ②:活跃日与 streak // ========================================================================== /** 有正增量的采样日 */ function activeDays(deltas) { return Object.keys(deltas || {}) .filter(d => Object.keys(deltas[d] || {}).length > 0) .sort(); } /** * 计算当前 / 最长连续活跃天数。 * * 连续定义:相邻两个**活跃日**的间隔必须 ≤ MAX_STREAK_GAP_DAYS。 * 间隔 1 天 → 连续;间隔 ≥2 天 → 中间存在未采样或未游玩的空洞,streak 断开。 * * @param {string[]} days 活跃日列表(升序) * @param {string} todayKey 今天(用于判定「当前 streak」是否仍然存活) * @returns {{ current:number, longest:number, currentEnd:string|null, segments:Array }} */ function computeStreak(days, todayKey) { const list = Array.isArray(days) ? days.slice().sort() : []; if (!list.length) return { current: 0, longest: 0, currentEnd: null, segments: [] }; // 切分为连续段 const segments = []; let cur = { start: list[0], end: list[0], len: 1 }; for (let i = 1; i < list.length; i++) { const gap = daysBetween(cur.end, list[i]); if (gap >= 1 && gap <= MAX_STREAK_GAP_DAYS) { cur.end = list[i]; cur.len++; } else { segments.push(cur); cur = { start: list[i], end: list[i], len: 1 }; } } segments.push(cur); const longest = segments.reduce((m, s) => Math.max(m, s.len), 0); // 当前 streak = 最后一段,前提是它「还活着」: // 末段结束日距今 ≤ MAX_STREAK_GAP_DAYS 才算进行中,否则视为已断 let current = 0; let currentEnd = null; const lastSeg = segments[segments.length - 1]; if (lastSeg) { const today = todayKey || dayKey(new Date()); const gap = daysBetween(lastSeg.end, today); // gap = 0(今天玩过)/ 1(昨天玩过,今天还没玩但未断)→ 存活 if (Number.isFinite(gap) && gap >= 0 && gap <= MAX_STREAK_GAP_DAYS) { current = lastSeg.len; currentEnd = lastSeg.end; } } return { current, longest, currentEnd, segments }; } // ========================================================================== // 纯算法 ③:年度游戏 // ========================================================================== /** * 按自然年汇总各游戏增量并排名。 * @param {Object} deltas dailyDeltas().deltas * @param {number} year 目标年份 * @param {number} limit 取前 N */ function yearRanking(deltas, year, limit) { const y = String(year); const agg = {}; let totalMinutes = 0; let activeDayCount = 0; for (const day of Object.keys(deltas || {})) { if (String(day).slice(0, 4) !== y) continue; const entries = deltas[day] || {}; let dayHasPlay = false; for (const appid of Object.keys(entries)) { const m = Number(entries[appid]) || 0; if (m <= 0) continue; agg[appid] = (agg[appid] || 0) + m; totalMinutes += m; dayHasPlay = true; } if (dayHasPlay) activeDayCount++; } const rows = Object.keys(agg) .map(appid => ({ appid, minutes: agg[appid] })) .sort((a, b) => b.minutes - a.minutes); const n = Math.max(1, Number(limit) || YEAR_TOP_N); return { year: Number(y), totalMinutes, activeDayCount, gameCount: rows.length, top: rows.slice(0, n), all: rows, }; } // ========================================================================== // 纯算法 ④:首玩日期 // ========================================================================== /** * 每个游戏在采样历史中首次出现的日期。 * * ⚠️ 严格说是「首次**被采样到**的日期」,不是真实首玩日: * 用户装脚本之前的历史无从得知。这里如实命名为 firstSeen 并在 UI 上标注口径。 * * @param {Object} days 采样历史 * @param {string} baselineDay 首日(该日只播种、无增量,仍计入「首次见到」) */ function firstPlays(days, baselineDay, limit) { const src = (days && typeof days === 'object') ? days : {}; const keys = Object.keys(src).filter(k => src[k] && typeof src[k] === 'object').sort(); const first = {}; for (const day of keys) { for (const appid of Object.keys(src[day])) { const v = Number(src[day][appid]) || 0; // 只把「已有游玩时长」的游戏算作已上手;时长 0 的不算 if (v <= 0) continue; if (!first[appid]) first[appid] = day; } } const rows = Object.keys(first) .map(appid => ({ appid, day: first[appid] })) .sort((a, b) => (a.day < b.day ? 1 : (a.day > b.day ? -1 : 0))); // 日期倒序 const n = Math.max(1, Number(limit) || FIRST_TOP_N); return { map: first, top: rows.slice(0, n), count: rows.length, baselineDay: baselineDay || (keys[0] || null) }; } // ========================================================================== // 纯算法 ⑤:里程碑徽章(Phase 4 / T4.3) // ========================================================================== /** * 里程碑定义。全部基于 T4.2 已算出的指标,不引入新数据源。 * tier: bronze < silver < gold(纯展示分级,无权限含义) */ const MILESTONES = [ { key: 'streak3', tier: 'bronze', icon: '🔥', target: 3, unit: 'day', metric: 'longestStreak', label: { zh: '三日之约', en: 'Hat-trick' }, desc: { zh: '连续游玩 3 天', en: '3-day streak' } }, { key: 'streak7', tier: 'silver', icon: '🔥', target: 7, unit: 'day', metric: 'longestStreak', label: { zh: '七日连击', en: 'Week streak' }, desc: { zh: '连续游玩 7 天', en: '7-day streak' } }, { key: 'streak30', tier: 'gold', icon: '🔥', target: 30, unit: 'day', metric: 'longestStreak', label: { zh: '月度铁人', en: 'Iron month' }, desc: { zh: '连续游玩 30 天', en: '30-day streak' } }, { key: 'active10', tier: 'bronze', icon: '📅', target: 10, unit: 'day', metric: 'activeDayCount', label: { zh: '初窥门径', en: 'Getting warm' }, desc: { zh: '累计 10 个活跃日', en: '10 active days' } }, { key: 'active50', tier: 'silver', icon: '📅', target: 50, unit: 'day', metric: 'activeDayCount', label: { zh: '持之以恒', en: 'Persistent' }, desc: { zh: '累计 50 个活跃日', en: '50 active days' } }, { key: 'active200', tier: 'gold', icon: '📅', target: 200, unit: 'day', metric: 'activeDayCount', label: { zh: '习惯成自然', en: 'Second nature' }, desc: { zh: '累计 200 个活跃日', en: '200 active days' } }, { key: 'year100h', tier: 'bronze', icon: '⏳', target: 6000, unit: 'min', metric: 'yearMinutes', label: { zh: '百小时俱乐部', en: '100h club' }, desc: { zh: '年内增量 100 小时', en: '100h this year' } }, { key: 'year500h', tier: 'silver', icon: '⏳', target: 30000, unit: 'min', metric: 'yearMinutes', label: { zh: '五百小时远征', en: '500h odyssey' }, desc: { zh: '年内增量 500 小时', en: '500h this year' } }, { key: 'games20', tier: 'bronze', icon: '🎮', target: 20, unit: 'game', metric: 'sampledGameCount', label: { zh: '博爱玩家', en: 'Sampler' }, desc: { zh: '采样期内上手 20 款', en: '20 games sampled' } }, { key: 'cover80', tier: 'silver', icon: '📡', target: 80, unit: 'pct', metric: 'coverageRate', label: { zh: '全勤观察员', en: 'Dedicated logger' }, desc: { zh: '采样覆盖率 ≥80%', en: '≥80% coverage' } }, ]; // v1.0.4: 三档 tier 颜色走 token,主题切换时自动适配 const TIER_COLOR = { bronze: 'var(--sglv-amber)', silver: 'var(--sglv-text-secondary)', gold: 'var(--sglv-amber-light)' }; /** * 计算各里程碑解锁状态与进度。 * 进度一律 clamp 到 [0, target](显示用),不虚标。 * @param {{longestStreak:number, activeDayCount:number, yearMinutes:number, * sampledGameCount:number, coverageRate:number}} metrics */ function computeMilestones(metrics) { const m = metrics || {}; const val = (k) => Math.max(0, Number(m[k]) || 0); return MILESTONES.map(ms => { const cur = val(ms.metric); const unlocked = cur >= ms.target; return { key: ms.key, unlocked, progress: Math.min(cur, ms.target), pct: Math.max(0, Math.min(100, Math.round((cur / ms.target) * 100))), tier: ms.tier, icon: ms.icon, label: ms.label, desc: ms.desc, target: ms.target, unit: ms.unit, }; }); } // ========================================================================== // 渲染 // ========================================================================== function mount(container, ctx) { const H = ctx.host; const ICONS = H.ICONS; const isZh = H.isZh; const escHtml = H.escHtml; // v2.21.1 审核清理:原解构的 H.SGLVCharts 从未使用(本 Tab 无图表),已删除 injectCSS(); const root_ = document.createElement('div'); root_.className = 'sglv-ptw-strk'; container.appendChild(root_); const body = document.createElement('div'); body.className = 'sglv-ptw-strk-body'; root_.appendChild(body); const nameOf = (() => { let m = null; return (appid) => { if (!m) { m = {}; (ctx.getGames() || []).forEach(g => { if (g && g.appid != null) m[String(g.appid)] = g.name || ''; }); } return m[String(appid)] || `App ${appid}`; }; })(); const fmtMin = (m) => { const v = Math.max(0, Math.round(Number(m) || 0)); if (v < 60) return `${v}m`; const h = Math.floor(v / 60); return h < 1000 ? `${h}h${v % 60 ? ` ${v % 60}m` : ''}` : `${(h / 1000).toFixed(1)}k h`; }; async function load() { body.innerHTML = `
${escHtml(isZh ? '采样中…' : 'Sampling…')}
`; // ① 先尝试采今日快照(同日重复进入不会覆盖) let collectInfo = null; const H2 = H.playtimeHistory; if (H2 && typeof H2.collect === 'function') { try { collectInfo = await H2.collect(); } catch (e) { collectInfo = { ok: false, reason: 'error' }; } } if (!H2 || typeof H2.load !== 'function') { body.innerHTML = `
${escHtml(isZh ? '当前宿主不支持每日快照(需更新主脚本)' : 'Host lacks daily snapshot support')}
`; return; } const hist = H2.load() || {}; const days = hist.days || {}; const dd = dailyDeltas(days); const act = activeDays(dd.deltas); const today = (typeof H2.dayKey === 'function' ? H2.dayKey() : '') || dayKey(new Date()); const streak = computeStreak(act, today); const year = new Date().getFullYear(); const yr = yearRanking(dd.deltas, year, YEAR_TOP_N); const fp = firstPlays(days, dd.baselineDay, FIRST_TOP_N); render({ hist, dd, act, streak, yr, fp, today, collectInfo, year, maxDays: H2.maxDays || 90 }); } function render(d) { body.innerHTML = ''; const sampled = d.dd.sampledDays || []; const coverage = coverageInfo(d); // ---- 覆盖率横幅(诚实呈现:无数据 ≠ 0) ---- const banner = document.createElement('div'); banner.className = 'sglv-ptw-strk-banner'; if (sampled.length === 0) { banner.classList.add('empty'); banner.innerHTML = `
${escHtml(isZh ? '尚无历史采样数据' : 'No history samples yet')}
${escHtml(isZh ? 'Steam 只提供累计游玩时长,不含逐日历史。本功能需要你每天打开一次本页来积累采样。' : 'Steam only exposes cumulative playtime. Come back daily to build history.')} ${d.collectInfo && d.collectInfo.written ? ` · ${escHtml(isZh ? '今日已记录' : 'today recorded')}` : ''}
`; } else { banner.innerHTML = `
${escHtml(isZh ? `已积累 ${sampled.length} 天采样(保留最近 ${d.maxDays} 天)` : `${sampled.length} sampled days (keeping last ${d.maxDays})`)}
${escHtml(isZh ? `区间 ${sampled[0]} ~ ${sampled[sampled.length - 1]} · 累计 ${fmtMin(coverage.totalMinutes)} · 活跃 ${d.act.length} 天 · 覆盖率 ${coverage.rate}%(未采样日不计为 0)` : `${sampled[0]} ~ ${sampled[sampled.length - 1]} · ${fmtMin(coverage.totalMinutes)} · ${d.act.length} active days · ${coverage.rate}% coverage`)}
`; } body.appendChild(banner); // ---- HERO ---- const hero = document.createElement('div'); hero.className = 'sglv-ptw-strk-hero'; hero.innerHTML = `
${d.streak.current}
${escHtml(isZh ? '当前连续' : 'Current streak')}
${d.streak.currentEnd ? escHtml(isZh ? `截至 ${d.streak.currentEnd}` : `ends ${d.streak.currentEnd}`) : escHtml(isZh ? '当前无活跃连续' : 'no active streak')}
${d.streak.longest}
${escHtml(isZh ? '最长连续' : 'Longest')}
${escHtml(isZh ? '天' : 'days')}
${d.act.length}
${escHtml(isZh ? '活跃天数' : 'Active days')}
${escHtml(isZh ? `共采样 ${sampled.length} 天` : `of ${sampled.length} sampled`)}
${fmtMin(d.yr.totalMinutes)}
${escHtml(isZh ? `${d.year} 年增量` : `${d.year} total`)}
${escHtml(isZh ? `${d.yr.gameCount} 款 · ${d.yr.activeDayCount} 天` : `${d.yr.gameCount} games · ${d.yr.activeDayCount} days`)}
`; body.appendChild(hero); // ---- 采样日历(三态热力格) ---- body.appendChild(buildCalendar(d, coverage)); // ---- 年度游戏 ---- body.appendChild(buildYear(d)); // ---- 首玩日期 ---- body.appendChild(buildFirstPlays(d)); // ---- 里程碑徽章(T4.3) ---- body.appendChild(buildMilestones(d, coverage)); } /** 里程碑面板:已解锁置顶(按 tier 降序),未解锁带进度条 */ function buildMilestones(d, coverage) { const panel = document.createElement('div'); panel.className = 'sglv-ptw-strk-panel sglv-ptw-hover'; const sampled = d.dd.sampledDays || []; const metrics = { longestStreak: d.streak.longest, activeDayCount: d.act.length, yearMinutes: d.yr.totalMinutes, sampledGameCount: d.fp.count, coverageRate: coverage.rate || 0, }; const list = computeMilestones(metrics) .sort((a, b) => (b.unlocked - a.unlocked) || (a.pct && b.pct ? b.pct - a.pct : 0)); const unlockedN = list.filter(x => x.unlocked).length; panel.innerHTML = `
${ICONS.star || '★'} ${escHtml(isZh ? '里程碑' : 'Milestones')}${unlockedN}/${list.length}
`; if (sampled.length === 0) { panel.innerHTML += `
${escHtml(isZh ? '尚无采样数据 —— 全部里程碑待积累。每天打开一次本页即可开始。' : 'No samples yet. Open this page daily to start earning milestones.')}
`; body.appendChild(panel); return panel; } const fmtTarget = (ms) => ms.unit === 'min' ? fmtMin(ms.target) : `${ms.target}${ms.unit === 'pct' ? '%' : (ms.unit === 'day' ? (isZh ? ' 天' : 'd') : (isZh ? ' 款' : ''))}`; const fmtCur = (ms, cur) => ms.unit === 'min' ? fmtMin(cur) : `${cur}${ms.unit === 'pct' ? '%' : (ms.unit === 'day' ? (isZh ? ' 天' : 'd') : (isZh ? ' 款' : ''))}`; panel.innerHTML += `
${escHtml(isZh ? '基于本地采样指标(连续天数取历史最长;活跃日为采样窗口内)。诚实口径:无数据不点亮。' : 'Based on local sampling metrics. Honest gating: no data, no badge.')}
${list.map(ms => { const color = TIER_COLOR[ms.tier] || 'var(--sglv-text-secondary)'; return `
${ms.icon}
${escHtml(isZh ? ms.label.zh : ms.label.en)} ${escHtml(ms.tier)}
${escHtml(isZh ? ms.desc.zh : ms.desc.en)}
${ms.unlocked ? `
` : `
${escHtml(`${fmtCur(ms, ms.progress)} / ${fmtTarget(ms)}`)}
`}
${ms.unlocked ? `✓` : ''}
`; }).join('')}
`; return panel; } /** 覆盖率:已采样日数 / 区间自然日数 */ function coverageInfo(d) { const sampled = d.dd.sampledDays || []; if (sampled.length < 2) return { totalMinutes: 0, rate: 0 }; const span = daysBetween(sampled[0], sampled[sampled.length - 1]) + 1; const rate = span > 0 ? Math.round((sampled.length / span) * 100) : 100; let totalMinutes = 0; Object.keys(d.dd.deltas).forEach(day => { Object.keys(d.dd.deltas[day] || {}).forEach(a => { totalMinutes += d.dd.deltas[day][a]; }); }); return { totalMinutes, rate, span, sampledCount: sampled.length }; } /** * 采样日历:三态严格区分 * active 有正增量(当天确实玩了) * idle 有采样但零增量(当天确实没玩) * missing 未采样(不知道,不等于没玩) */ function buildCalendar(d, coverage) { const sampled = d.dd.sampledDays || []; const sampledSet = new Set(sampled); const panel = document.createElement('div'); panel.className = 'sglv-ptw-strk-panel sglv-ptw-hover'; if (sampled.length < 2) { panel.innerHTML = `
${ICONS.grid || '▦'} ${escHtml(isZh ? '采样日历' : 'Sampling calendar')}
${escHtml(isZh ? '至少需要 2 天采样才能计算日增量' : 'Need ≥2 sampled days for daily deltas')}
`; return panel; } const start = sampled[0]; const end = sampled[sampled.length - 1]; const span = Math.min(daysBetween(start, end) + 1, d.maxDays); const todayMs = dayKeyToMs(d.today); const cells = []; for (let i = 0; i < span; i++) { const ms = dayKeyToMs(start) + i * 86400000; const key = dayKey(new Date(ms)); const future = dayKeyToMs(key) > todayMs; let cls = 'missing', title = key; if (sampledSet.has(key)) { const delta = d.dd.deltas[key] || {}; const sum = Object.keys(delta).reduce((s, a) => s + (Number(delta[a]) || 0), 0); if (sum > 0) { cls = 'active'; const top = Object.keys(delta).sort((a, b) => delta[b] - delta[a])[0]; title = `${key} · ${fmtMin(sum)}\n${nameOf(top)}`; } else { cls = 'idle'; title = `${key} · ${isZh ? '无游玩' : 'no play'}`; } } else { title = `${key} · ${isZh ? '未采样' : 'not sampled'}`; } if (future) cls += ' future'; cells.push(``); } const legend = `
${escHtml(isZh ? '有游玩' : 'active')} ${escHtml(isZh ? '已采样·未玩' : 'sampled·idle')} ${escHtml(isZh ? '未采样' : 'not sampled')}
`; panel.innerHTML = `
${ICONS.grid || '▦'} ${escHtml(isZh ? '采样日历' : 'Sampling calendar')}${escHtml(`${start} ~ ${end}`)}
${cells.join('')}
${legend}`; return panel; } function buildYear(d) { const panel = document.createElement('div'); panel.className = 'sglv-ptw-strk-panel sglv-ptw-hover'; panel.innerHTML = `
${ICONS.trophy || '🏆'} ${escHtml(isZh ? `${d.year} 年度游戏` : `${d.year} top games`)}${d.yr.top.length}
`; if (!d.yr.top.length) { panel.innerHTML += `
${escHtml(isZh ? `${d.year} 年暂无采样增量(今年可能刚开始积累,或当年无采样记录)` : `No ${d.year} deltas yet`)}
`; return panel; } const max = d.yr.top[0].minutes || 1; panel.innerHTML += `
${escHtml(isZh ? `按采样日增量排名 · 合计 ${fmtMin(d.yr.totalMinutes)} · ${d.yr.activeDayCount} 个活跃日(采样窗口内)` : `By sampled-day delta · ${fmtMin(d.yr.totalMinutes)} · ${d.yr.activeDayCount} active days`)}
${d.yr.top.map((r, i) => `
${i + 1}
${escHtml(nameOf(r.appid))}
${escHtml(fmtMin(r.minutes))}
`).join('')}
`; return panel; } function buildFirstPlays(d) { const panel = document.createElement('div'); panel.className = 'sglv-ptw-strk-panel sglv-ptw-hover'; panel.innerHTML = `
${ICONS.clock || '⏱'} ${escHtml(isZh ? '首次上手' : 'First seen')}${d.fp.count}
`; if (!d.fp.top.length) { panel.innerHTML += `
${escHtml(isZh ? '暂无采样数据' : 'No samples yet')}
`; return panel; } panel.innerHTML += `
${escHtml(isZh ? `⚠️ 这是「首次被采样到」的日期,不等于真实首玩日 —— 装脚本之前的历史无从得知。共 ${d.fp.count} 款进入过采样。` : `⚠️ First *sampled* date, not the true first-play day. ${d.fp.count} games sampled.`)}
${d.fp.top.map(r => `
${escHtml(r.day)} ${escHtml(nameOf(r.appid))}
`).join('')}
`; return panel; } // 首日无前值可比,dailyDeltas 首项被跳过 → 这里需要一个本地 dayKey 兜底 function dayKey(dt) { const d = dt || new Date(); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } load(); } // ========================================================================== // 样式 // ========================================================================== const CSS = ` .sglv-ptw-strk{display:flex;flex-direction:column;gap:14px;min-height:0;flex:1;width:100%;} /* v1.0.2: 横版网格布局——banner/KPI 通栏;采样日历(左)+年度游戏(右)双栏; 首次上手/里程碑通栏。此前 flex 纵向堆叠窄列居中、右侧大面积留白 v1.0.2 高度自适应——grid 行模板加 1fr 让 row 3 通栏区跟窗高, 子项用 min-height:0 让内 panel/list 用 overflow-y:auto 自管滚动 */ .sglv-ptw-strk-body{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:auto auto minmax(0,1fr);gap:14px;align-items:start;min-height:0;flex:1;width:100%;} .sglv-ptw-strk-banner,.sglv-ptw-strk-hero{grid-column:1/-1;} .sglv-ptw-strk-body > div:nth-child(3){grid-column:1;grid-row:3;min-height:0;} .sglv-ptw-strk-body > div:nth-child(4){grid-column:2;grid-row:3;min-height:0;} .sglv-ptw-strk-body > div:nth-child(n+5){grid-column:1/-1;min-height:0;} .sglv-ptw-strk-state{padding:24px;text-align:center;color:var(--sglv-text-secondary);font-size:var(--sglv-fs-base);display:flex;align-items:center;justify-content:center;gap:var(--sglv-sp-2);} .sglv-ptw-strk-state-msg{padding:24px;text-align:center;font-size:var(--sglv-fs-base);color:var(--sglv-amber);} .sglv-ptw-strk-spin{display:inline-block;width:11px;height:11px;border:2px solid var(--sglv-amber-soft);border-top-color:var(--sglv-amber);border-radius:50%;animation:sglv-ptw-strk-spin .7s linear infinite;} @keyframes sglv-ptw-strk-spin{to{transform:rotate(360deg);}} .sglv-ptw-strk-banner{padding:11px 14px;border:1px solid var(--sglv-accent-strong);border-radius:var(--sglv-r-md);background:linear-gradient(135deg,var(--sglv-accent-wash) 0%,var(--sglv-accent-soft) 100%);box-shadow:var(--sglv-shadow-sm),inset 0 1px 0 var(--sglv-border-subtle);position:relative;overflow:hidden;} .sglv-ptw-strk-banner::before{content:'';position:absolute;inset:-1px;pointer-events:none;background:linear-gradient(135deg,var(--sglv-accent-glow),transparent 60%);opacity:.35;} .sglv-ptw-strk-banner.empty{border-color:var(--sglv-amber-strong);background:var(--sglv-amber-wash);} .sglv-ptw-strk-banner-msg{font-size:12.5px;font-weight:600;color:var(--sglv-text);} .sglv-ptw-strk-banner.empty .sglv-ptw-strk-banner-msg{color:var(--sglv-amber);} .sglv-ptw-strk-banner-sub{margin-top:4px;font-size:var(--sglv-fs-sm);line-height:1.7;color:var(--sglv-text-secondary);} .sglv-ptw-strk-banner-sub b{color:var(--sglv-success);} .sglv-ptw-strk-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--sglv-sp-3);} .sglv-ptw-strk-hero-item{ position:relative;padding:14px 16px;border:1px solid var(--sglv-border); border-radius:var(--sglv-r-lg);background: linear-gradient(135deg,var(--sglv-accent-wash) 0%,transparent 55%), var(--sglv-surface-1); text-align:center;display:flex;flex-direction:column;gap:4px;justify-content:center;overflow:hidden; box-shadow:var(--sglv-shadow-sm),inset 0 1px 0 var(--sglv-border-subtle); transition:transform var(--sglv-dur) var(--sglv-ease),box-shadow var(--sglv-dur) var(--sglv-ease),border-color var(--sglv-dur) var(--sglv-ease); } .sglv-ptw-strk-hero-item::before{ content:'';position:absolute;inset:-1px;border-radius:var(--sglv-r-lg);pointer-events:none; background:linear-gradient(135deg,var(--sglv-accent-glow),transparent 60%); opacity:0;transition:opacity var(--sglv-dur) var(--sglv-ease); } .sglv-ptw-strk-hero-item:hover{ transform:translateY(-2px); border-color:var(--sglv-accent-soft); box-shadow:var(--sglv-shadow-md),0 0 0 1px var(--sglv-accent-soft),0 0 24px var(--sglv-accent-wash),inset 0 1px 0 var(--sglv-border-subtle); } .sglv-ptw-strk-hero-item:hover::before{opacity:1;} .sglv-ptw-strk-hero-val{ font-size:22px;font-weight:700;font-variant-numeric:tabular-nums; background:linear-gradient(180deg,var(--sglv-text) 0%,var(--sglv-success) 100%); -webkit-background-clip:text;background-clip:text; -webkit-text-fill-color:transparent;color:transparent; text-shadow:0 0 14px color-mix(in srgb,var(--sglv-success) 30%,transparent); position:relative;z-index:1; } .sglv-ptw-strk-hero-lbl{font-size:var(--sglv-fs-md);color:var(--sglv-text-secondary);position:relative;z-index:1;} .sglv-ptw-strk-hero-sub{font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);opacity:.85;position:relative;z-index:1;} .sglv-ptw-strk-panel{padding:14px 16px;border:1px solid var(--sglv-border);border-radius:var(--sglv-r-lg);background:var(--sglv-surface-1);} .sglv-ptw-strk-panel-title{display:flex;align-items:center;gap:7px;margin-bottom:10px;font-size:var(--sglv-fs-base);font-weight:600;color:var(--sglv-text);} .sglv-ptw-strk-count{margin-left:auto;padding:1px 8px;border-radius:var(--sglv-r-md);background:var(--sglv-accent-soft);color:var(--sglv-accent);font-size:var(--sglv-fs-sm);font-variant-numeric:tabular-nums;} .sglv-ptw-strk-note{margin-bottom:10px;padding:8px 10px;border:1px dashed var(--sglv-surface-border);border-radius:var(--sglv-r-sm);font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);line-height:1.7;} .sglv-ptw-strk-cal{display:grid;grid-template-columns:repeat(auto-fill,minmax(13px,1fr));gap:3px;} .sglv-ptw-strk-cell{aspect-ratio:1;border-radius:3px;background:var(--sglv-surface-2);border:1px solid transparent;cursor:default;} .sglv-ptw-strk-cell.active{background:var(--sglv-success);border-color:var(--sglv-success);} .sglv-ptw-strk-cell.idle{background:var(--sglv-surface-border);border-color:var(--sglv-surface-border);} .sglv-ptw-strk-cell.missing{background:repeating-linear-gradient(45deg,var(--sglv-surface-2) 0 3px,transparent 3px 6px);border-color:var(--sglv-surface-2);} .sglv-ptw-strk-cell.future{opacity:.25;} .sglv-ptw-strk-legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:10px;font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);} .sglv-ptw-strk-legend span{display:flex;align-items:center;gap:5px;} .sglv-ptw-strk-legend i{width:11px;height:11px;border-radius:3px;display:inline-block;} .sglv-ptw-strk-legend i.active{background:var(--sglv-success);} .sglv-ptw-strk-legend i.idle{background:var(--sglv-surface-border);} .sglv-ptw-strk-legend i.missing{background:repeating-linear-gradient(45deg,var(--sglv-surface-3) 0 3px,transparent 3px 6px);} .sglv-ptw-strk-list{display:flex;flex-direction:column;gap:var(--sglv-sp-2);} .sglv-ptw-strk-row{display:grid;grid-template-columns:22px 1fr auto;align-items:center;gap:10px;} .sglv-ptw-strk-rank{font-size:var(--sglv-fs-md);font-weight:700;color:var(--sglv-text-secondary);text-align:center;} .sglv-ptw-strk-main{min-width:0;} .sglv-ptw-strk-name{font-size:12.5px;color:var(--sglv-text);margin-bottom:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .sglv-ptw-strk-bar{height:5px;border-radius:3px;background:var(--sglv-surface-2);overflow:hidden;} .sglv-ptw-strk-bar span{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,var(--sglv-accent),var(--sglv-purple));} .sglv-ptw-strk-val{font-size:var(--sglv-fs-md);font-weight:600;color:var(--sglv-accent);font-variant-numeric:tabular-nums;white-space:nowrap;} .sglv-ptw-strk-first{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:7px;} .sglv-ptw-strk-first-row{display:grid;grid-template-columns:92px 1fr;align-items:center;gap:9px;padding:7px 9px;border-radius:var(--sglv-r-sm);background:var(--sglv-surface-1);} .sglv-ptw-strk-first-day{font-size:var(--sglv-fs-sm);color:var(--sglv-amber);font-variant-numeric:tabular-nums;} .sglv-ptw-strk-first-name{font-size:var(--sglv-fs-md);color:var(--sglv-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .sglv-ptw-strk-ms{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:9px;} .sglv-ptw-strk-ms-item{position:relative;display:flex;gap:10px;padding:10px 12px;border:1px solid var(--sglv-border);border-radius:var(--sglv-r-md);background:var(--sglv-surface-1);opacity:.72;} .sglv-ptw-strk-ms-item.on{opacity:1;border-color:var(--sglv-amber-strong);background:var(--sglv-amber-wash);} .sglv-ptw-strk-ms-icon{display:flex;align-items:center;justify-content:center;width:34px;height:34px;flex-shrink:0;border-radius:9px;border:1px solid var(--sglv-surface-border);font-size:17px;filter:grayscale(1);} .sglv-ptw-strk-ms-item.on .sglv-ptw-strk-ms-icon{filter:none;background:var(--sglv-code-bg);} .sglv-ptw-strk-ms-main{min-width:0;flex:1;} .sglv-ptw-strk-ms-name{font-size:12.5px;font-weight:600;color:var(--sglv-text);} .sglv-ptw-strk-ms-tier{font-size:var(--sglv-fs-xs);font-weight:400;margin-left:6px;text-transform:uppercase;letter-spacing:.5px;} .sglv-ptw-strk-ms-desc{margin:3px 0 6px;font-size:var(--sglv-fs-sm);color:var(--sglv-text-secondary);} .sglv-ptw-strk-ms-bar{height:4px;border-radius:2px;background:var(--sglv-surface-2);overflow:hidden;} .sglv-ptw-strk-ms-bar span{display:block;height:100%;border-radius:2px;background:var(--sglv-accent);} .sglv-ptw-strk-ms-bar.done span{background:var(--sglv-amber);} .sglv-ptw-strk-ms-prog{margin-top:3px;font-size:10.5px;color:var(--sglv-text-secondary);font-variant-numeric:tabular-nums;} .sglv-ptw-strk-ms-check{position:absolute;top:8px;right:10px;font-size:14px;font-weight:700;} @media (max-width:820px){.sglv-ptw-strk-ms{grid-template-columns:1fr;}} @media (max-width:820px){.sglv-ptw-strk-first{grid-template-columns:1fr;}} `; /* v1.0.5: 同框架 CSS 注入,走共享 injectStyleOnce */ function injectCSS() { __sglvPtwInjectStyle('sglv-ptw-strk-style', CSS); } // ---- 纯算法导出(供单测) ---- root.__SGLV_PTW_STRK_ALGO__ = { MAX_STREAK_GAP_DAYS, ACTIVE_MIN_MINUTES, YEAR_TOP_N, FIRST_TOP_N, dayKeyToMs, daysBetween, dailyDeltas, activeDays, computeStreak, yearRanking, firstPlays, MILESTONES, computeMilestones, }; // ---- 注册 ---- if (!root.SGLVPTW) { console.warn('[PTW-TAB-STRK] 框架未就绪,无法注册'); } else { root.SGLVPTW.registerTab({ key: 'streak', label: '游玩足迹', order: 45, // getOwnedGames 经 ctx.getGames() 间接依赖(nameOf 反查游戏名),保留声明 // v2.21.1 审核修正:'emptyHtml' 声明后从未使用(状态展示走自有 sglv-ptw-strk-state)→ 移除 requires: ['getOwnedGames', 'playtimeHistory', 'ICONS', 'isZh', 'escHtml'], mount, }); console.log('[PTW-TAB-STRK] 已注册'); } })(typeof window !== 'undefined' ? window : globalThis);