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