/* ============================================================================= * 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);