/* ============================================================================= * SGLV PTW — 游戏数据浮窗框架 (PlayTimeWindow Framework) * ----------------------------------------------------------------------------- * 职责:浮窗外壳 + Tab 注册表 + 视图状态 + 图表 tooltip + 导出 * 边界:不含任何数据获取逻辑,一切经 host (PTW_HOST) 获取 * * 版本:1.0.0 apiVersion: 1 * 依赖:window.SGLVPTW.init(host) 由主脚本调用 * * 设计原则(勿改): * ① Tab 无状态:mount(container, ctx) 不持有跨调用状态,可变状态存 ctx.view * ② Tab 之间禁止互相调用,只能经 host * ③ 错误隔离:任一 Tab mount 抛错 → 仅该 pane 显示错误卡,其余不受影响 * ④ 优雅降级:框架缺失 → 主脚本显示提示卡;能力缺失 → 单 Tab 降级 * ========================================================================== */ (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; // 默认窗口尺寸(相对视口) 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 || []; tabs.push(cfg); 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 .12s;max-width:280px;` + `background:rgba(22,32,44,.96);border:1px solid rgba(102,192,244,.28);` + `border-radius:10px;padding:8px 10px;font-size:12px;line-height:1.55;` + `color:#c7d5e0;box-shadow:0 8px 28px rgba(0,0,0,.45);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;inset:0;z-index:${Z_INDEX.overlay};display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.45);backdrop-filter:blur(3px);} .${NS}win{position:relative;display:flex;flex-direction:column;background:var(--sglv-panel,#1a2733);border:1px solid var(--sglv-border,rgba(102,192,244,.2));border-radius:16px;box-shadow:0 18px 60px rgba(0,0,0,.55);overflow:hidden;} .${NS}head{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--sglv-border,rgba(102,192,244,.2));user-select:none;flex-shrink:0;cursor:grab;} .${NS}head.dragging{cursor:grabbing;} .${NS}title{display:flex;align-items:center;gap:8px;font-size:15px;font-weight:600;color:var(--sglv-text,#f8fafc);} .${NS}spacer{flex:1;} .${NS}btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--sglv-text-dim,#94a3b8);cursor:pointer;transition:background .15s,color .15s;} .${NS}btn:hover{background:rgba(102,192,244,.12);color:#c7d5e0;} .${NS}btn svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;} .${NS}tabs{display:flex;gap:2px;padding:8px 12px 0;border-bottom:1px solid var(--sglv-border,rgba(102,192,244,.2));overflow-x:auto;flex-shrink:0;} .${NS}tab{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:none;border-radius:8px 8px 0 0;background:transparent;color:var(--sglv-text-dim,#94a3b8);font-size:13px;cursor:pointer;white-space:nowrap;transition:background .15s,color .15s;} .${NS}tab:hover{background:rgba(102,192,244,.08);color:#c7d5e0;} .${NS}tab.active{background:rgba(102,192,244,.16);color:#66c0f4;font-weight:600;} .${NS}tab svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;} .${NS}body{flex:1;overflow:auto;position:relative;} .${NS}pane{display:none;padding:16px;} .${NS}pane.active{display:block;} .${NS}err,.${NS}degraded{padding:36px 20px;text-align:center;color:var(--sglv-text-dim,#94a3b8);} .${NS}err-title,.${NS}degraded-title{font-size:14px;font-weight:600;color:#e8a33d;margin-bottom:8px;} .${NS}err-desc,.${NS}degraded-desc{font-size:12px;line-height:1.7;opacity:.8;} /* T5.1 设置与诊断卡 */ .${NS}settings-mask{position:absolute;inset:0;background:rgba(0,0,0,.45);z-index:1;} .${NS}settings-card{position:absolute;top:52px;right:16px;bottom:16px;width:min(380px,92%);z-index:2;overflow-y:auto;background:var(--sglv-panel,#16222e);border:1px solid var(--sglv-border,rgba(102,192,244,.25));border-radius:12px;padding:14px 16px;box-shadow:0 12px 40px rgba(0,0,0,.5);} .${NS}settings-title{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:700;color:var(--sglv-text,#e8eef4);margin-bottom:12px;} .${NS}settings-x{margin-left:auto;width:26px;height:26px;border:none;background:rgba(255,255,255,.06);border-radius:7px;color:var(--sglv-text-dim,#94a3b8);font-size:16px;cursor:pointer;} .${NS}settings-x:hover{color:var(--sglv-text,#e8eef4);background:rgba(255,255,255,.12);} .${NS}settings-sec{margin-bottom:14px;padding:10px 12px;border:1px solid var(--sglv-border,rgba(102,192,244,.14));border-radius:10px;background:rgba(255,255,255,.02);} .${NS}settings-h{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--sglv-text-dim,#94a3b8);margin-bottom:8px;} .${NS}settings-row{display:flex;justify-content:space-between;gap:10px;font-size:12px;color:var(--sglv-text,#cbd5e1);padding:3px 0;} .${NS}settings-row.warn{color:#e8a33d;} .${NS}settings-btn{width:100%;margin-top:8px;padding:7px 10px;border-radius:8px;border:1px solid var(--sglv-border,rgba(102,192,244,.3));background:rgba(102,192,244,.1);color:#66c0f4;font-size:12px;cursor:pointer;} .${NS}settings-btn:hover:not(:disabled){background:rgba(102,192,244,.2);} .${NS}settings-btn:disabled{opacity:.4;cursor:not-allowed;} .${NS}settings-btn.danger{border-color:rgba(239,68,68,.4);background:rgba(239,68,68,.08);color:#ef4444;} .${NS}settings-btn.danger:hover:not(:disabled){background:rgba(239,68,68,.16);} .${NS}settings-note{margin-top:6px;font-size:11px;color:var(--sglv-text-dim,#94a3b8);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-dim,#94a3b8);white-space:pre-wrap;word-break:break-all;} .${NS}err-stack{margin-top:12px;padding:10px;background:rgba(0,0,0,.3);border-radius:8px;font-family:ui-monospace,monospace;font-size:11px;text-align:left;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-all;} .${NS}retry{margin-top:14px;padding:7px 18px;border-radius:8px;border:1px solid rgba(102,192,244,.4);background:rgba(102,192,244,.14);color:#66c0f4;font-size:13px;cursor:pointer;} .${NS}retry:hover{background:rgba(102,192,244,.24);} .${NS}soft-missing{padding:14px;margin:8px 0;border:1px dashed rgba(232,163,61,.4);border-radius:8px;color:#e8a33d;font-size:12px;text-align:center;} @media (max-width:1280px){.${NS}win{width:96vw!important;height:92vh!important;}} /* T5.2 窄屏:Tab 栏横向滚动不换行;头部按钮压缩;设置卡改全宽贴底 */ @media (max-width:820px){ .${NS}tab{padding:7px 10px;font-size:12px;} .${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;} } `; let cssInjected = false; function injectCSS() { if (cssInjected) return; cssInjected = true; const st = document.createElement('style'); st.id = `${NS}style`; st.textContent = CSS; document.head.appendChild(st); } // ============================================================================= // 窗口外壳 // ========================================================================== function loadWinPos() { try { const raw = store.get(WIN_POS_KEY, ''); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function saveWinPos(pos) { try { store.set(WIN_POS_KEY, JSON.stringify(pos)); } catch (e) { /* ignore */ } } function createWindow(opts) { injectCSS(); const o = opts || {}; const pos = loadWinPos(); // 单实例保护:已打开则 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', '游戏数据分析'); // 尺寸 / 位置还原 const savedW = pos && pos.w, savedH = pos && pos.h; box.style.width = (savedW || `min(${Math.round(window.innerWidth * DEFAULT_SIZE.wRatio)}px, ${DEFAULT_SIZE.maxW}px)`) + 'px'; box.style.height = (savedH || `min(${Math.round(window.innerHeight * DEFAULT_SIZE.hRatio)}px, 900px)`) + 'px'; 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 = `
${esc(d.diagnostics.length ? JSON.stringify(d.diagnostics, null, 1) : '—')}