/* ============================================================================= * 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 = `
${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); win = { overlay, box, head, tabBar, body, maximized: false, 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 === 'max') toggleMax(); 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'); saveWinPos({ x: box.offsetLeft, y: box.offsetTop, w: box.offsetWidth, h: box.offsetHeight }); }); } function toggleMax() { if (!win) return; if (win.maximized) { const pos = loadWinPos(); win.box.style.width = ((pos && pos.w) || `min(${Math.round(window.innerWidth * DEFAULT_SIZE.wRatio)}px, ${DEFAULT_SIZE.maxW}px)`) + 'px'; win.box.style.height = ((pos && pos.h) || `min(${Math.round(window.innerHeight * DEFAULT_SIZE.hRatio)}px, 900px)`) + 'px'; win.box.style.left = ''; win.box.style.top = ''; } else { win.box.style.width = '96vw'; win.box.style.height = '94vh'; } win.maximized = !win.maximized; } // ============================================================================= // Tab 渲染与切换 // ========================================================================== function renderTabs() { if (!win) return; const list = sortedTabs().filter(t => 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; // 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, /** * 把某个 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);