/* =====================================================================
* 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 = `
设置与诊断
运行状态
框架v${d.framework.version} · api ${d.framework.apiVersion}
已注册 Tab${d.registeredTabs.length} 个(可用 ${d.registeredTabs.length - d.unavailableTabs.length})
宿主能力${d.host.fnCount} 项
${sampledDays ? `
游玩足迹采样${sampledDays} 天${hist.maxDays ? ` / 保留 ${hist.maxDays}` : ''}
` : ''}
熔断器(连续 3 次 mount 失败自动停用)
${brk.length ? brk.map(b => `
${esc(b.tab)}${b.fails} 次${b.tripped ? ' · 已熔断' : ''}
`).join('')
: '
—无熔断记录
'}
数据
仅清除本地逐日采样,不影响 Steam 云端数据与游戏库存。
诊断事件(最近 ${Math.min(20, d.diagnostics.length)} 条)
${esc(d.diagnostics.length ? JSON.stringify(d.diagnostics, null, 1) : '—')}
`;
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}
`;
}
// ---- 面板②:徽章评分排行 ----
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);