// ==UserScript== // @name steam-family-game-analysis (Publishable) // @namespace http://tampermonkey.net/ // @version 1.75 // @description 扫描 Steam 家庭库库存,在游戏页面与 Keylol 标记已有游戏(区分本人已购与家庭共享)。提供家庭库数据分析面板:贡献分布、增长趋势(成员首字头像、近90天入库对比饼状图)、价值洞察(共享库总价值/人均/性价比 KPI、入库价值趋势月度增量/累积价值切换含原价实付节省对比、价格-时长散点图对数刻度自适应Y轴踩雷警戒、价格档位热力图、成员价值贡献、24H缓存预热并发加载)、成员洞察(活跃度+健康分能量环、雷达+加入时间分栏)、购买动态(支持列表/封面视图切换、按家庭成员筛选购买记录)、游玩动态、入库热力图、家庭愿望单(列表/封面视图、多人想要二级筛选、名称首字头像、头像堆叠显示、折扣/即将推出精准统计、加载进度展示成员头像昵称与旋转光圈特效)、共享冷却(追踪家庭共享借出/归还与24小时冷却倒计时)、DLC 拥有状态检查(商店页标记已拥有 DLC、默认折叠、显示/筛选切换)、游戏进包标记(在 Keylol 与 Steam 商店页标记曾出现在 bundle 中的游戏,数据来源 Barter.vg)。 // @author SmallRob // @match https://store.steampowered.com/* // @match https://keylol.com/* // @match https://steamdb.keylol.com/tooltip* // @icon https://upload.wikimedia.org/wikipedia/commons/8/83/Steam_icon_logo.svg // @require https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.js // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect store.steampowered.com // @connect api.steampowered.com // @connect bartervg.com // @license GPLv3 // ==/UserScript== var dialog,appid,observer var isNewUser = false var isupdate = false var saves const MAX_FAMILY = 6; // Steam 家庭组最多支持6人 readstorage() //console.log(saves) const url = window.location.host + window.location.pathname; var access_token,steamid if(window.location.host == "store.steampowered.com" && g_AccountID != 0){ access_token = JSON.parse(application_config.getAttribute("data-store_user_config")).webapi_token steamid = JSON.parse(application_config.getAttribute("data-userinfo")).steamid if(saves.steamid ==""){ saves.steamid = steamid } if(saves.steamid != steamid){ console.log(saves.noPrompt) if (saves.noPrompt == null || saves.noPrompt == false){ ShowConfirmDialog('脚本提示','当前页面登录的账号与缓存账号不对应,脚本可能会出现一些未知的错误,是否需要重新扫描?','扫描家庭库','取消').done(()=>{scan(true)}).fail(()=>{ ShowAlertDialog('脚本提示','如果需要手动扫描,可以在Steam主页右上角进入进行扫描','好的') saves.noPrompt = true savestorage() }) } } } // ==================== v1.70: Barter.vg Bundle 数据获取与缓存模块 ==================== // 数据源:https://bartervg.com/browse/bundles/json/ // 返回格式:{"18500":{"bundles":5,"bundles_packages":3},"232810":{"bundles":1},...}(Key 是 appID 字符串) var faBundleData = null; var faBundleLoading = false; const FA_BUNDLE_CACHE_KEY = 'fa_bundle_cache'; const FA_BUNDLE_CACHE_TTL = 48 * 60 * 60 * 1000; // 48h,与 SWI 一致 // 返回该游戏进过的包数量(0 表示未进包) function getBundleCount(appId) { if (!faBundleData || !appId) return 0; var entry = faBundleData[String(appId)]; return entry ? (entry.bundles || 0) : 0; } function faLoadBundleData() { if (faBundleData || faBundleLoading) return; faBundleLoading = true; // 先读缓存 var cached = GM_getValue(FA_BUNDLE_CACHE_KEY, null); var cachedTime = GM_getValue(FA_BUNDLE_CACHE_KEY + '_time', 0); if (cached && (Date.now() - cachedTime < FA_BUNDLE_CACHE_TTL)) { faBundleData = cached; faBundleLoading = false; console.log('[FA] Bundle 缓存命中: ' + Object.keys(cached).length + ' 条记录'); return; } GM_xmlhttpRequest({ method: 'GET', url: 'https://bartervg.com/browse/bundles/json/', timeout: 30000, onload: function(r) { try { var json = JSON.parse(r.responseText); if (Object.keys(json).length < 7000) { console.warn('[FA] Barter.vg bundles 数据 sanity check 失败(记录数: ' + Object.keys(json).length + ')'); return; } faBundleData = json; GM_setValue(FA_BUNDLE_CACHE_KEY, json); GM_setValue(FA_BUNDLE_CACHE_KEY + '_time', Date.now()); console.log('[FA] Bundle 数据加载完成: ' + Object.keys(json).length + ' 条记录'); } catch(e) { console.warn('[FA] Bundle 数据解析失败:', e); } finally { faBundleLoading = false; } }, onerror: function() { console.warn('[FA] Bundle 数据获取失败(网络错误)'); faBundleLoading = false; }, ontimeout: function() { console.warn('[FA] Bundle 数据获取超时'); faBundleLoading = false; } }); } // ==================== v1.75: DLC 识别模块(双源检测,参考 steam-game-library-viewer v2.9.6) ==================== // 数据源1: Barter.vg DLC 数据库 https://bartervg.com/browse/dlc/json/ // 返回格式: {"239550":{"base_appID":221380,"base_item_id":125},...}(Key 是 DLC 的 appID) // 数据源2: Steam IStoreBrowseService/GetItems 的 type 字段(type=1 为 dlc, type=6 为 music) // 对 Barter.vg 未收录的新 DLC/冷门 DLC 进行补充识别 var faDlcDbData = null; // { "dlcAppId": { base_appID: 123, base_item_id: 45 } } var faDlcDbLoading = false; var faDlcDbPromise = null; // v1.75: 存储 in-flight Promise,避免并发调用返回 null const FA_DLC_DB_CACHE_KEY = 'fa_dlc_db_cache'; const FA_DLC_DB_CACHE_TTL = 48 * 60 * 60 * 1000; // 48h,与 bundle 数据一致 // v1.75: 全库存应用类型映射(补充 Barter.vg 未覆盖的 DLC/music) var faAppTypeMap = null; // { "appId": "dlc" | "music" | "game" | ... } var faAppTypePromise = null; const FA_APP_TYPES_CACHE_KEY = 'fa_app_types_cache'; const FA_APP_TYPES_CACHE_TTL = 48 * 60 * 60 * 1000; // 48h // 双源 DLC 检测:Barter.vg DLC 数据库 OR Steam type=dlc/music function faIsDlc(appId) { if (!appId) return false; var id = String(appId); if (faDlcDbData && faDlcDbData[id]) return true; if (faAppTypeMap) { var t = faAppTypeMap[id]; if (t === 'dlc' || t === 'music') return true; } return false; } // v1.75: 同步从缓存加载 DLC 数据库和应用类型映射(GM_getValue 同步读取) // 在面板首次渲染前调用,确保 faIsDlc() 可用,避免 DLC 计数初始为 0 function faLoadDlcDataFromCacheSync() { if (!faDlcDbData) { try { var cached = GM_getValue(FA_DLC_DB_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < FA_DLC_DB_CACHE_TTL)) { faDlcDbData = cached.data; console.log('[FA] DLC 数据库缓存命中(sync): ' + Object.keys(faDlcDbData).length + ' 条记录'); } } catch(e) { console.warn('[FA] DLC 数据库缓存读取失败:', e); } } if (!faAppTypeMap) { try { var cached2 = GM_getValue(FA_APP_TYPES_CACHE_KEY, null); if (cached2 && cached2.timestamp && (Date.now() - cached2.timestamp < FA_APP_TYPES_CACHE_TTL)) { faAppTypeMap = cached2.data; console.log('[FA] 应用类型缓存命中(sync): ' + Object.keys(faAppTypeMap).length + ' 条'); } } catch(e) { console.warn('[FA] 应用类型缓存读取失败:', e); } } } // v1.75: 异步加载 Barter.vg DLC 数据库(带缓存 + in-flight 去重) function faLoadDlcDatabase() { if (faDlcDbData) return Promise.resolve(faDlcDbData); if (faDlcDbPromise) return faDlcDbPromise; faDlcDbPromise = new Promise(function(resolve) { var cached = GM_getValue(FA_DLC_DB_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < FA_DLC_DB_CACHE_TTL)) { faDlcDbData = cached.data; console.log('[FA] DLC 数据库缓存命中: ' + Object.keys(faDlcDbData).length + ' 条记录'); resolve(faDlcDbData); return; } console.log('[FA] 正在从 Barter.vg 获取 DLC 数据库...'); GM_xmlhttpRequest({ method: 'GET', url: 'https://bartervg.com/browse/dlc/json/', timeout: 30000, onload: function(r) { try { var json = JSON.parse(r.responseText); if (!json || Object.keys(json).length < 7000) { console.warn('[FA] Barter.vg DLC 数据 sanity check 失败(记录数: ' + (json ? Object.keys(json).length : 0) + ')'); resolve(null); return; } faDlcDbData = json; GM_setValue(FA_DLC_DB_CACHE_KEY, { data: json, timestamp: Date.now() }); console.log('[FA] DLC 数据库加载完成: ' + Object.keys(json).length + ' 条记录'); resolve(faDlcDbData); } catch(e) { console.warn('[FA] DLC 数据库解析失败:', e); resolve(null); } }, onerror: function() { console.warn('[FA] DLC 数据库获取失败(网络错误)'); resolve(null); }, ontimeout: function() { console.warn('[FA] DLC 数据库获取超时'); resolve(null); } }); }); faDlcDbPromise.then(function() { faDlcDbPromise = null; }); return faDlcDbPromise; } // v1.75: 通过 Steam GetItems 批量获取指定 appIds 的类型,补充 Barter.vg 未覆盖的 DLC // 增量更新:仅获取 faAppTypeMap 中缺失的 appIds,已有数据不重复请求 function faEnrichAppTypes(appIds) { if (!appIds || appIds.length === 0) return Promise.resolve(faAppTypeMap); // 筛选出类型未知的 appIds(增量更新) var needFetch = appIds.filter(function(id) { return !faAppTypeMap || !faAppTypeMap[String(id)]; }); if (needFetch.length === 0) return Promise.resolve(faAppTypeMap); if (faAppTypePromise) return faAppTypePromise; faAppTypePromise = new Promise(function(resolve) { // 检查缓存 if (!faAppTypeMap) { var cached = GM_getValue(FA_APP_TYPES_CACHE_KEY, null); if (cached && cached.timestamp && (Date.now() - cached.timestamp < FA_APP_TYPES_CACHE_TTL)) { faAppTypeMap = cached.data; // 缓存命中后重新筛选增量 needFetch = appIds.filter(function(id) { return !faAppTypeMap[String(id)]; }); if (needFetch.length === 0) { resolve(faAppTypeMap); faAppTypePromise = null; return; } } } console.log('[FA] 正在获取 ' + needFetch.length + ' 个应用的类型信息...'); var CHUNK = 200, CONCURRENCY = 3; var chunks = []; for (var i = 0; i < needFetch.length; i += CHUNK) chunks.push(needFetch.slice(i, i + CHUNK)); var typeMap = faAppTypeMap ? Object.assign({}, faAppTypeMap) : {}; var qIdx = 0; function worker() { if (qIdx >= chunks.length) return Promise.resolve(); var chunk = chunks[qIdx++]; var input = { ids: chunk.map(function(id) { return { appid: Number(id) }; }), context: { language: 'schinese', country_code: faCurrency.cc || 'cn', steam_realm: 1 }, data_request: { include_release: false } }; var reqUrl = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input)); return faGmGetJson(reqUrl, 20000).then(function(resp) { var storeItems = (resp && resp.response && resp.response.store_items) || []; storeItems.forEach(function(si) { if (si && si.appid) { var typeStr = 'game'; if (typeof si.type === 'number') { typeStr = ({ 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' })[si.type] || 'other'; } else if (si.type) { typeStr = String(si.type).toLowerCase(); } typeMap[String(si.appid)] = typeStr; } }); return new Promise(function(r) { setTimeout(r, 150); }).then(worker); }).catch(function() { return worker(); }); } var workers = []; for (var w = 0; w < CONCURRENCY; w++) workers.push(worker()); Promise.all(workers).then(function() { faAppTypeMap = typeMap; GM_setValue(FA_APP_TYPES_CACHE_KEY, { data: typeMap, timestamp: Date.now() }); console.log('[FA] 应用类型获取完成: ' + Object.keys(typeMap).length + ' 条'); resolve(faAppTypeMap); faAppTypePromise = null; }); }); return faAppTypePromise; } (function() { 'use strict'; // v1.70: 异步加载 Barter.vg bundle 数据(不阻塞页面渲染,加载失败静默降级) faLoadBundleData(); // v1.75: 同步加载 DLC 数据库和应用类型缓存(确保面板首次渲染时 faIsDlc 可用) faLoadDlcDataFromCacheSync(); if(window.location.host == "keylol.com") { // v1.58: 注入紫色家庭共享标记样式(参考 Keylol-Steam-Key-Tracker badge 设计) faKeylolInjectStyles(); keyloladHover() observer = new MutationObserver((mutations, obs) => { mutations.forEach(function(mutation) { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(function(node) { // 确保是元素节点 if (node.nodeType === Node.ELEMENT_NODE) { keyloladHover(node) } }) } }) }) observer.observe(document, {childList: true, subtree: true}); } if(url.startsWith("steamdb.keylol.com/tooltip")){ let id = Number(document.baseURI.split('#')[1].split("/")[1]) if (saves.familyGameList.GameList.includes(id) && !saves.familyGameList.GameInfo[id].owners.includes(saves.steamid)) { (function wait_block(){ let block = document.querySelector('div#game_score') if(block){ let plugging = document.createElement('div'); let innerHTML = `由 ${saves.familyGameList.GameInfo[id].owners.length} 位成员共享(` saves.familyGameList.GameInfo[id].owners.forEach((steamid)=>{ innerHTML+= `【${saves.familyInfo.steamIdtoName[steamid]}】` }) innerHTML+= `)。最早由【${saves.familyInfo.steamIdtoName[saves.familyGameList.GameInfo[id].owners[0]]}】于 ${timestampToTime(saves.familyGameList.GameInfo[id].time)} 购入。` plugging.innerHTML = `家庭组: ${innerHTML}` block.parentNode.insertBefore(plugging, block.nextSibling); }else{ setTimeout(wait_block,100) } })(); } } // v1.58: 紫色家庭共享标记样式注入(参考 Keylol-Steam-Key-Tracker 的 GM_addStyle + badge 方案) function faKeylolInjectStyles() { var css = '' + '.fa-keylol-link{padding:0 2px;border-radius:3px;text-decoration:none;}' // 家庭共享:紫色主题 #a855f7 + '.fa-keylol-link-family{box-shadow:inset 0 -2px 0 #a855f7;background-color:rgba(168,85,247,0.85)!important;color:#fff!important;font-weight:700!important;}' // badge 通用样式 + '.fa-keylol-badge{display:inline-block;margin-left:4px;padding:1px 6px;font-size:11px;font-weight:700;line-height:1.4;border-radius:3px;vertical-align:baseline;font-family:-apple-system,system-ui,"Segoe UI",sans-serif;letter-spacing:0.3px;}' + '.fa-keylol-badge-family{background:#a855f7;color:#fff;}' // v1.70: 进包标记 badge(橙色主题 #f59e0b) + '.fa-keylol-badge-bundle{background:#f59e0b;color:#fff;}' // v1.70: Steam 商店页进包标记旗帜 + '.fa-bundle-flag{display:inline-block;margin-top:2px;padding:1px 6px;font-size:11px;font-weight:700;line-height:1.4;border-radius:3px;background:#f59e0b;color:#fff;position:relative;z-index:10;}'; if (typeof GM_addStyle === 'function') { GM_addStyle(css); } else { var style = document.createElement('style'); style.textContent = css; (document.head || document.documentElement).appendChild(style); } } // v1.58: 重构 keyloladHover,使用 CSS 类 + badge 替代内联样式 // 参考 Keylol-Steam-Key-Tracker.js 的 decorateLink 设计: // 1. data 属性去重(避免 MutationObserver 重复处理) // 2. CSS 类控制样式(!important 覆盖 Keylol 页面样式) // 3. 链接后插入 badge 元素,显示"家庭共享"标签 function keyloladHover(mutationnode) { let node = document if(mutationnode) { node = mutationnode} node.querySelectorAll('a').forEach(function (link) { var href = String(link.getAttribute('href') || ''); var match; if (match = href.match(/\/(store\.steampowered|steamcommunity)\.com\/(app|sub)\/(\d+)/)) { var type = match[2]; var id = parseInt(match[3]); if (type === 'app') { // v1.58: 使用 data 属性去重,替代旧的 classList.contains 检查 if (saves.familyGameList.GameList.includes(id) && !link.dataset.faKeylolDone) { link.dataset.faKeylolDone = '1'; var gi = saves.familyGameList.GameInfo[id]; if (gi && gi.owners && !gi.owners.includes(saves.steamid)) { // 家庭组共享游戏 — 紫色标记 link.classList.add('fa-keylol-link', 'fa-keylol-link-family'); // 构建 tooltip:显示家庭库拥有者信息 var idMap = saves.familyInfo.steamIdtoName || {}; var ownerNames = gi.owners.map(function(sid) { return idMap[sid] || sid; }).join('、'); link.title = '家庭组共享 · 由 ' + ownerNames + ' 拥有'; // 插入紫色 badge var badge = document.createElement('span'); badge.className = 'fa-keylol-badge fa-keylol-badge-family'; badge.textContent = '家庭共享'; badge.title = '此游戏在家庭库中,由其他成员共享'; link.insertAdjacentElement('afterend', badge); } } // v1.70: 进包标记 — 独立于家庭库检查,对所有 Steam app 链接检查进包状态 var bundleCount = getBundleCount(id); if (bundleCount > 0 && !link.dataset.faBundleDone) { link.dataset.faBundleDone = '1'; var bundleBadge = document.createElement('span'); bundleBadge.className = 'fa-keylol-badge fa-keylol-badge-bundle'; bundleBadge.textContent = '进过' + bundleCount + '包'; bundleBadge.title = '此游戏曾出现在 ' + bundleCount + ' 个 bundle 中(数据来源: Barter.vg)'; bundleBadge.style.marginLeft = '2px'; // 插入在家庭共享 badge 之后(如果存在),否则插入在 link 之后,确保不重合 if (badge && badge.parentNode) { badge.insertAdjacentElement('afterend', bundleBadge); } else { link.insertAdjacentElement('afterend', bundleBadge); } } } } }); } if(window.location.host != "store.steampowered.com") {return ;} init() if(g_AccountID == 0){return;} if(!isNewUser && saves.settings.isAutoScan && g_ServerTime-saves.lastupDateTime>86400){ scan(false) }else if(!isupdate && g_ServerTime-saves.lastupDateTime>604800){ let innerText if (saves.noPrompt == null || saves.noPrompt == false){ if(saves.familyGameList.GameList.length == 0){ innerText="您似乎没有家庭库的游戏记录,是否现在扫描家庭库游戏并记录呢?" }else{ innerText="您已经超过1周没有更新家庭库的游戏列表了,是否现在去扫描?" } ShowConfirmDialog('脚本提示',innerText,'扫描家庭库','取消').done(()=>{scan(true)}).fail(()=>{ saves.settings.isAutoScan = false saves.noPrompt = true savestorage() ShowAlertDialog('脚本提示','如果需要手动扫描,可以在Steam主页右上角进入进行扫描','好的') }) } } var search_suggestion = document.getElementById('search_suggestion_contents') if(search_suggestion){ var observer_search = new MutationObserver((mutations, obs) => { mutations.forEach(function(mutation) { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(function(node) { // 确保是元素节点 if (node.nodeType === Node.ELEMENT_NODE) { // 检查新节点是否有指定的类 if (node.classList.contains('match_app')) { addflag(node) } } }); } }) }); observer_search.observe(search_suggestion, {childList: true, subtree: true}); } if(url == "store.steampowered.com/" ){ observer_3() observer = new MutationObserver((mutations, obs) => { mutations.forEach(function(mutation) { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(function(node) { // 确保是元素节点 if (node.nodeType === Node.ELEMENT_NODE) { // v1.67:跳过家庭分析浮窗内的节点 — 浮窗自身的游戏卡片不需要 Steam 原生共享标记 // 这是首页 observer 的第一道防线(与 fa_observer 的 panel 过滤保持一致) if(node.closest && node.closest('#familyAnalysisPanel')) return; // v1.68:扩展排除 steam-game-library-viewer 浮窗 // 个人信息面板(#sgis-panel,含最近游玩/个人入库/相似推荐等)以及游戏库侧边栏(.sglv-panel), // 它们的游戏卡片已由各自脚本负责标记,不需要本脚本再追加 Steam 原生"在家庭库中"旗帜 if(node.closest && (node.closest('#sgis-panel') || node.closest('.sglv-panel'))) return; if(node.classList.contains('live_streams_ctn')){return;} node.querySelectorAll("div").forEach((node)=>{ addflag(node) }) node.querySelectorAll("a").forEach((node)=>{ if(node.classList.contains('screenshot')){return;} if(node.querySelector('div.broadcast_live_stream_icon')){return;} // v1.67:再次防御 — 浮窗内 直接跳过(即使父节点未被上述 closest 命中) if(node.closest && node.closest('#familyAnalysisPanel')) return; // v1.68:同上 — 防御 直接处于 steam-game-library-viewer 浮窗内的边缘场景 if(node.closest && (node.closest('#sgis-panel') || node.closest('.sglv-panel'))) return; addflag(node) }) } }) } }) }) observer.observe(document, {childList: true, subtree: true}); } if(url.startsWith('store.steampowered.com/app/')&&g_AccountID != 0){ //addBanner(document.querySelector('div.block.game_media_and_summary_ctn')) observer_2(); // v1.62 新增:DLC 拥有状态检查与标记 faCheckDlcOwnership(); } if(url.startsWith('store.steampowered.com/search/')&&g_AccountID != 0){ observer_4() var search_results = document.getElementById('search_results') observer = new MutationObserver((mutations, obs) => { mutations.forEach(function(mutation) { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(function(node) { // 确保是元素节点 if (node.nodeType === Node.ELEMENT_NODE) { if (node.classList.contains('search_result_row') && node.classList.contains('ds_collapse_flag')) { addflag(node,"clear: left;") }else{ let lists = node.querySelectorAll("a.search_result_row.ds_collapse_flag") lists.forEach(function(bar){ addflag(bar,"clear: left;") }) } } }); } }) }); observer.observe(search_results, {childList: true, subtree: true}); } if(url.startsWith('store.steampowered.com/wishlist/')&&g_AccountID != 0){ observer_6() var wishlist_results = document.getElementById('wishlist_ctn') let observer_wishlist = new MutationObserver((mutations, obs) => { mutations.forEach(function(mutation) { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(function(node) { // 确保是元素节点 if (node.nodeType === Node.ELEMENT_NODE) { if (node.classList.contains('wishlist_row')) { addflag(node) } } }); } }) }); observer_wishlist.observe(wishlist_results, {childList: true, subtree: true}); } if(url.startsWith('store.steampowered.com/category/')&&g_AccountID != 0){ let lists = document.body.querySelectorAll("div.ImpressionTrackedElement") lists.forEach(function(bar){ // v1.59:添加 null 守卫,防止 querySelector('a') 返回 null 传入 addflag let node_a = bar.querySelector('a') if(node_a){ addflag(node_a) } }) let observer_sale_item = new MutationObserver((mutations, obs) => { mutations.forEach(function(mutation) { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(function(node) { // 确保是元素节点 if (node.nodeType === Node.ELEMENT_NODE) { if (node.querySelector('div.ImpressionTrackedElement')) { node.querySelectorAll('div.ImpressionTrackedElement').forEach(node=>{ // v1.59:添加 null 守卫 let node_a = node.querySelector('a') if(node_a){ addflag(node_a) } }) }else if( node.classList.contains('Panel')&&node.classList.contains('Focusable')){ let node_a = node.querySelector('a') if(node_a){ addflag(node_a) } }else if( node.querySelector('div.Panel.Focusable')){ let node_a = node.querySelector('div.Panel.Focusable').querySelector('a') if(node_a){ addflag(node_a) } } } }); } }) }); observer_sale_item.observe(document.body, {childList: true, subtree: true}); } // v1.53:发行商/开发商/系列/相似推荐页适配 // 实测卡片结构(data-ds-appid + position:relative,复用 addflag 标准路径与 ds_flag 原生样式): // publisher/developer/franchise → a.store_capsule[data-ds-appid] // recommended/morelike → a.similar_grid_capsule[data-ds-appid] // 无 data-ds-appid 的锚点(评测链接 recommendation_link / recommendation_btn 等)由 addflag 自动跳过 if((url.startsWith('store.steampowered.com/publisher/') || url.startsWith('store.steampowered.com/developer/') || url.startsWith('store.steampowered.com/franchise/') || url.startsWith('store.steampowered.com/recommended/morelike/')) && g_AccountID != 0){ observer_capsule_pages(); let observer_capsule = new MutationObserver((mutations, obs) => { mutations.forEach(function(mutation) { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(function(node) { // 确保是元素节点 if (node.nodeType === Node.ELEMENT_NODE) { // v1.55:选择器追加 a.tab_item[data-ds-appid] if(node.matches && node.matches('a.store_capsule[data-ds-appid], a.similar_grid_capsule[data-ds-appid], a.tab_item[data-ds-appid]')){ addflag(node); }else if(node.querySelectorAll){ node.querySelectorAll('a.store_capsule[data-ds-appid], a.similar_grid_capsule[data-ds-appid], a.tab_item[data-ds-appid]').forEach(function(bar){ addflag(bar); }); } } }); } }); }); observer_capsule.observe(document.body, {childList: true, subtree: true}); } // v1.59:统一兜底扫描 + 防抖观察器(参考 Steam-PSPlus-mark.js 的 scanCards + scheduleScan + observe 设计) // 使用统一选择器扫描全页游戏卡片,捕获各页面 observer 遗漏的异步加载元素 // (如 home_marketing_message 仅含 href 无 data-ds-appid、React 延迟渲染的 capsule 等) // addflag 内部有防重复检测(ds_owned_flag / ds_family_share_flag),与各页面 observer 共存安全 var FA_CARD_SELECTOR = [ '[data-ds-appid]:not([data-fa-scanned])', // 所有带 data-ds-appid 的游戏卡片 'a.home_marketing_message:not([data-fa-scanned])', // 首页营销消息(仅有 href) 'a.store_capsule:not([data-fa-scanned])', // 商店 capsule 'a.tab_item:not([data-fa-scanned])', // 标签页游戏条目 'a.similar_grid_capsule:not([data-fa-scanned])', // 相似推荐网格 'a.search_result_row:not([data-fa-scanned])', // 搜索结果行 'div.wishlist_row:not([data-fa-scanned])', // 愿望单行 'a.match:not([data-fa-scanned])', // 搜索建议 ].join(', '); function faScanCards() { document.querySelectorAll(FA_CARD_SELECTOR).forEach(function(node) { // v1.65:跳过家庭分析浮窗内的元素,避免浮窗游戏封面被添加 Steam 原生共享标记 if (node.closest && node.closest('#familyAnalysisPanel')) return; // v1.68:扩展排除 steam-game-library-viewer 浮窗 if (node.closest && (node.closest('#sgis-panel') || node.closest('.sglv-panel'))) return; node.setAttribute('data-fa-scanned', '1'); // v1.59:部分卡片类型需要 insertBeforeStyle 参数以保证旗帜插入位置正确 // (搜索结果行 clear:left、首页 tab_item clear:both),与各页面 observer 保持一致 if (node.classList && node.classList.contains('search_result_row')) { addflag(node, 'clear: left;'); } else if (node.classList && node.classList.contains('tab_item')) { addflag(node, 'clear: both;'); } else { addflag(node); } }); } function faScheduleScan() { if (faScheduleScan._timer) clearTimeout(faScheduleScan._timer); faScheduleScan._timer = setTimeout(faScanCards, 250); } // v1.59 修复:初始扫描必须延迟执行(setTimeout 0),不能同步调用! // 原因:faScanCards → addflag 内部访问 const FAMILY_SHARE_FLAG_ICON(定义在文件后方 ~行2972), // 同步调用时该 const 尚处于暂时性死区(TDZ),访问会抛 ReferenceError 导致整个 IIFE 中断, // 后续 init()、所有页面 observer 全部不执行 → 看不到任何标记。 // 原始 observer_3/4/6 等不受影响是因为 DOM 未就绪时走 setTimeout 延迟分支,等 IIFE 执行完才调用 addflag。 setTimeout(faScanCards, 0); setTimeout(faScanCards, 1500); setTimeout(faScanCards, 3000); // 统一 MutationObserver:监听全页 DOM 变化,防抖扫描(回调是异步的,不受 TDZ 影响) // v1.65:忽略家庭分析浮窗内部的 DOM 变化,避免浮窗渲染触发全页重扫(性能优化 + 防止误标记) // v1.68:扩展忽略 steam-game-library-viewer 浮窗(#sgis-panel / .sglv-panel)内部的 DOM 变化 if (window.MutationObserver) { var fa_observer = new MutationObserver(function(mutations) { // 过滤掉源自浮窗的变更,仅当存在非浮窗变更时才触发扫描 for (var i = 0; i < mutations.length; i++) { var target = mutations[i].target; if (!target || !target.closest || !target.closest('#familyAnalysisPanel')) { // v1.68:额外跳过 steam-game-library-viewer 浮窗内部变更 if (target && target.closest && (target.closest('#sgis-panel') || target.closest('.sglv-panel'))) { continue; } faScheduleScan(); return; } } }); fa_observer.observe(document.body, { childList: true, subtree: true }); } function init(){ let setting_btn = document.createElement('a'); setting_btn.className = "Focusable" setting_btn.id = "setting_btn" setting_btn.style = "font-size:13px;font-weight:400;flex-direction:row;display:flex;align-items:center;cursor:pointer;gap:5px;padding: 0 10px;" // v1.39 新增:更新菜单入口游戏数量的函数,扫描完成后动态刷新 function updateMenuBadge() { var count = saves && saves.familyGameList && saves.familyGameList.GameList ? saves.familyGameList.GameList.length : 0; var btn = document.getElementById('setting_btn'); if (btn) { var badge = btn.querySelector('.fa-menu-count'); if (badge) { badge.textContent = count; } } } window.faUpdateMenuBadge = updateMenuBadge; setting_btn.innerHTML = ` 我的家庭库${saves.familyGameList.GameList.length}` setting_btn.onclick = btnonclick plug(); plugDropdown(); function plug(){ let headding = document.querySelector('div[aria-label="商店菜单"], div[aria-label="Store menu"]') if(headding){ headding.insertBefore(setting_btn, headding.lastChild); }else{ setTimeout(plug,200) } } // 降级入口:在账户下拉菜单(#account_dropdown)中添加"我的家庭库" // 当顶部导航栏入口因布局/浏览器差异找不到时,用户仍可通过账户下拉菜单访问 var faDdObserver = null; var faDdRetries = 0; function plugDropdown(){ var dropdown = document.querySelector('#account_dropdown'); if(!dropdown){ // 未登录时 #account_dropdown 不存在,最多轮询 150 次(约 30 秒) if (faDdRetries++ < 150) setTimeout(plugDropdown, 200); return; } injectDropdownItem(dropdown); // 监听 dropdown 内容变化(部分浏览器/React 重渲染时重新注入) if (!faDdObserver) { faDdObserver = new MutationObserver(function(){ var dd = document.querySelector('#account_dropdown'); if (dd) injectDropdownItem(dd); }); faDdObserver.observe(dropdown, { childList: true, subtree: true }); } } function injectDropdownItem(dropdown){ var menuBody = dropdown.querySelector('.popup_body.popup_menu') || dropdown; if (menuBody.querySelector('#fa_dd_item')) return; // 避免重复注入 var ddItem = document.createElement('a'); ddItem.className = 'popup_menu_item'; ddItem.id = 'fa_dd_item'; ddItem.href = 'javascript:void(0)'; ddItem.innerHTML = '' + '' + '' + '' + '我的家庭库'; ddItem.addEventListener('click', function(e){ e.preventDefault(); e.stopPropagation(); var dd = document.getElementById('account_dropdown'); if (dd) dd.style.display = 'none'; // 关闭下拉菜单 btnonclick(); }); // 插入到"退出帐户"之前(多语言匹配:退出/退出帐户/log out/Sign out/Abmelden) var logoutLink = null; menuBody.querySelectorAll('a.popup_menu_item').forEach(function(a){ if (!logoutLink) { var href = a.getAttribute('href') || ''; var text = a.textContent || ''; if (href.indexOf('Logout') >= 0 || text.indexOf('退出') >= 0 || /log\s*out/i.test(text) || /sign\s*out/i.test(text) || text.indexOf('Log off') >= 0) { logoutLink = a; } } }); if (logoutLink) menuBody.insertBefore(ddItem, logoutLink); else menuBody.appendChild(ddItem); } function btnonclick(){ let totalGames = saves.familyGameList.GameList.length; let memberCount = saves.familyInfo.family_member.length; let avgGames = totalGames > 0 && memberCount > 0 ? Math.round(totalGames / memberCount) : 0; let singleOwnerCount = 0; for (let key in saves.familyGameList.GameInfo) { if (saves.familyGameList.GameInfo[key].owners.length === 1) singleOwnerCount++; } // v1.38 新增统计指标 let recent30Count = 0, multiOwnerCount = 0; let nowSec = Date.now() / 1000; for (let key in saves.familyGameList.GameInfo) { let gi = saves.familyGameList.GameInfo[key]; if (gi.owners && gi.owners.length > 1) multiOwnerCount++; if (gi.time && (nowSec - gi.time) < 2592000) recent30Count++; } let familyGroupId = saves.familyInfo.family_groupid || 'unknown'; let familyName = saves.familyInfo.family_name || '未知家庭'; // ===================== 自定义浮窗面板 ===================== if (document.getElementById('familyAnalysisPanel')) { document.getElementById('familyAnalysisPanel').remove(); } const panel = document.createElement('div'); panel.id = 'familyAnalysisPanel'; panel.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;display:flex;align-items:center;justify-content:center;'; const activeTabKey = '__familyActiveTab'; window[activeTabKey] = window[activeTabKey] || 'contribution'; const showTab = (tabId) => { window[activeTabKey] = tabId; panel.querySelectorAll('[data-fa-tab]').forEach(t => { t.style.display = t.getAttribute('data-fa-tab') === tabId ? '' : 'none'; }); panel.querySelectorAll('[data-fa-nav]').forEach(b => { const isActive = b.getAttribute('data-fa-nav') === tabId; b.style.background = isActive ? 'rgba(6,207,190,0.2)' : 'transparent'; b.style.color = isActive ? '#06cfbe' : '#8097a8'; }); if (tabId === 'contribution') { // v1.75:切换到贡献分布标签页时,重置"我的贡献"覆盖层(隐藏覆盖层、恢复默认视图) var mcOverlay = panel.querySelector('[data-my-contrib-overlay]'); var mcDefault = panel.querySelector('[data-contrib-default]'); if (mcOverlay) { mcOverlay.style.display = 'none'; mcOverlay.innerHTML = ''; } if (mcDefault) { mcDefault.style.display = ''; } if (!document.getElementById('Family_countChart')) { const chartWrap = panel.querySelector('[data-chart-bar-card]'); if (chartWrap) { const cv = document.createElement('canvas'); cv.id = 'Family_countChart'; cv.width = 560; cv.height = 460; cv.style.cssText = 'display:block;box-sizing:border-box;height:460px;width:560px;max-width:100%;filter:drop-shadow(0 4px 12px rgba(0,0,0,0.2))'; chartWrap.appendChild(cv); } } observer_5(); renderContributionExtras(); } // v1.71:价值洞察标签页渲染 if (tabId === 'value') { renderValueInsightsTab(); } if (tabId === 'growth') { renderGrowthTab(); } if (tabId === 'playactivity') { renderPlayActivityTab(); } if (tabId === 'heatmap') { renderHeatmapTab(); } if (tabId === 'insights') { renderMemberInsightsTab(); } if (tabId === 'wishlist') { renderWishlistTab(); } // v1.62 新增:共享冷却标签页渲染 if (tabId === 'cooldown') { renderCooldownTab(); } }; // v1.66:集中管理 SVG 图标(参考 steam-friend-manager ICONS 模式) const FA_ICONS = { barChart: '', trending: '', insights: '', activity: '', gamepad: '', grid: '', heart: '', clock: '', spinner: '', refresh: '', // v1.71:价值洞察图标(¥ 符号在圆形底盘中) coin: '' }; const tabDefs = [ { id: 'contribution', icon: FA_ICONS.barChart, label: '贡献分布' }, { id: 'growth', icon: FA_ICONS.trending, label: '增长趋势' }, // v1.71:价值洞察标签页(参考 html-demo/family-price-value 原型设计) // v1.74:移至增长趋势之后、成员洞察之前(数量维度→价值维度→成员维度) { id: 'value', icon: FA_ICONS.coin, label: '价值洞察' }, { id: 'insights', icon: FA_ICONS.insights, label: '成员洞察' }, { id: 'activity', icon: FA_ICONS.activity, label: '购买动态' }, { id: 'playactivity', icon: FA_ICONS.gamepad, label: '游玩动态' }, { id: 'heatmap', icon: FA_ICONS.grid, label: '入库热力图' }, { id: 'wishlist', icon: FA_ICONS.heart, label: '家庭愿望单' }, // v1.62 新增:共享冷却标签页(参考 sffxzzp Family Sharing Cooldown 追踪模式) { id: 'cooldown', icon: FA_ICONS.clock, label: '共享冷却' } ]; // 按月聚合数据(增长趋势用) const growthGames = Object.entries(saves.familyGameList.GameInfo) .map(([appid, info]) => ({ appid: Number(appid), time: info.time, owners: info.owners })) .filter(g => g.time > 0) .sort((a, b) => a.time - b.time); const monthlyMap = new Map(); growthGames.forEach(g => { const d = new Date(g.time * 1000); const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); if (!monthlyMap.has(key)) monthlyMap.set(key, { all: 0, members: {} }); const entry = monthlyMap.get(key); entry.all++; g.owners.forEach(sid => { entry.members[sid] = (entry.members[sid] || 0) + 1; }); }); const growthSorted = [...monthlyMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); // 分页游戏列表 const pageSize = 30; let totalPages = Math.ceil(totalGames / pageSize); let currentPage = 1; // v1.56:购买动态视图模式('table' | 'cover'),持久化到 GM 存储 const FA_ACT_VIEW_KEY = 'faActViewMode'; let actViewMode = GM_getValue(FA_ACT_VIEW_KEY, 'table'); if (actViewMode !== 'cover') actViewMode = 'table'; // 白名单校验 // v1.64:购买动态成员筛选(空=全部) let actFilterSid = ''; // 获取筛选后的游戏列表(按购买者=owners末位筛选) function getActivityFilteredGames() { var all = saves.familyGameList.GameList; if (!actFilterSid) return all; return all.filter(function(appid) { var gi = saves.familyGameList.GameInfo[appid]; return gi && gi.owners && gi.owners.length > 0 && gi.owners[gi.owners.length - 1] == actFilterSid; }); } // v1.64:成员筛选下拉框 HTML function actFilterHtml() { var members = saves.familyInfo.family_member || []; var opts = ''; members.forEach(function(m) { var name = m.userName || (saves.familyInfo.steamIdtoName ? saves.familyInfo.steamIdtoName[m.steamid] : '') || ('ID:' + String(m.steamid).slice(-4)); var sel = (actFilterSid == m.steamid) ? ' selected' : ''; opts += ''; }); return '
购买者
'; } // v1.56:视图切换工具栏 HTML(列表视图 / 封面视图) const actViewToggleHtml = function() { var isTable = actViewMode === 'table'; return '
' + '' + '' + '
'; }; // 分页按钮 HTML(复用全局 fa-wl-pgbtn 类 + fa-btn-md 尺寸变体) const pageNavHtml = (pg, tp) => { const pb = (page, label, disabled) => ''; return '
' + pb(1, '首页', pg === 1) + pb(pg - 1, '上一页', pg <= 1) + pb(pg + 1, '下一页', pg >= tp) + pb(tp, '末页', pg === tp) + '
'; }; // v1.56:封面视图渲染(参考 steam-friend-manager buildRecentGameCard + sfd-pl-recent-grid) // v1.64:精简封面卡片信息,仅保留游戏名称、购买时间和购买人名称 function renderActivityCoverPage(pageGames, start, end, page) { var cards = ''; pageGames.forEach((appid, index) => { var gameInfo = saves.familyGameList.GameInfo[appid]; var isNew = saves.lastupDateTime - gameInfo.time < 2592000 && gameInfo.owners.length == 1; var buyerName = saves.familyInfo.steamIdtoName[gameInfo.owners.at(-1)] || ''; // v1.56:封面视图统一使用 faLoadCover 多 CDN fallback 加载大封面图,保证清晰度 var capImgHtml = ''; cards += '
' + '
' + capImgHtml + (isNew ? 'NEW' : '') + '
' + '
' + '' + faEsc(gameInfo.name) + '' + '
' + '' + timestampToTime(gameInfo.time) + '' + '' + faEsc(buyerName) + '' + '
' + '
' + '
'; }); return '
' + cards + '
'; } // v1.56:列表视图渲染(原 renderActivityPage 表格逻辑) function renderActivityTablePage(pageGames, start) { let rows = ''; pageGames.forEach((appid, index) => { let gameInfo = saves.familyGameList.GameInfo[appid]; let isNew = saves.lastupDateTime - gameInfo.time < 2592000 && gameInfo.owners.length == 1; let actualIndex = start + index; let rowBg = isNew ? 'rgba(6,207,190,0.08)' : (actualIndex % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'); let ownerList = gameInfo.owners.map(sid => saves.familyInfo.steamIdtoName[sid] || sid).join(', '); rows += '' + '' + (gameInfo.icon_hash ? '' : '' ) + '' + '' + faEsc(gameInfo.name) + '' + (isNew ? 'NEW' : '') + '' + '' + timestampToTime(gameInfo.time) + '' + '' + saves.familyInfo.steamIdtoName[gameInfo.owners.at(-1)] + '' + '' + gameInfo.owners.length + '' + ''; }); return '' + '' + '' + '' + '' + '' + '' + '' + rows + '
游戏名称购入时间购买者拥有者
'; } function renderActivityPage(page) { if (page < 1) page = 1; // v1.64:使用筛选后的游戏列表计算分页 var filteredGames = getActivityFilteredGames(); var filteredTotal = filteredGames.length; var filteredPages = Math.max(1, Math.ceil(filteredTotal / pageSize)); if (page > filteredPages) page = filteredPages; currentPage = page; const start = (page - 1) * pageSize; const end = Math.min(start + pageSize, filteredTotal); const pageGames = filteredGames.slice(start, end); // v1.56:根据视图模式选择渲染器 var contentHtml = actViewMode === 'cover' ? renderActivityCoverPage(pageGames, start, end, page) : renderActivityTablePage(pageGames, start); const actContent = panel.querySelector('[data-activity-content]'); if (actContent) { // v1.67:渲染前清理活动内容区内残留的 ds_flag(防御性兜底) // 防止未来 addflag 排除逻辑被绕过时,"在家庭库中"/"家庭共享"标记泄漏到浮窗内 actContent.querySelectorAll('div.ds_flag.ds_owned_flag, div.ds_flag.ds_family_share_flag').forEach(function(el){ el.remove(); }); actContent.innerHTML = '
' + '' + filteredTotal + ' 个游戏 | 第 ' + page + '/' + filteredPages + ' 页 (' + (start + 1) + '-' + end + ')' + '
' + actFilterHtml() + actViewToggleHtml() + pageNavHtml(page, filteredPages) + '
' + '
' + contentHtml + '
' + '绿色高亮 + NEW = 近30日新入库的独占贡献' + pageNavHtml(page, filteredPages) + '
'; // v1.64:成员筛选下拉框事件 actContent.querySelectorAll('select[data-act-filter]').forEach(function(sel) { sel.addEventListener('change', function() { actFilterSid = this.value; renderActivityPage(1); }); }); // 分页按钮事件 actContent.querySelectorAll('button[data-page]').forEach(function(btn) { btn.addEventListener('click', function() { var newPage = parseInt(this.getAttribute('data-page')); if (newPage >= 1 && newPage <= filteredPages) renderActivityPage(newPage); }); }); // v1.56:视图切换按钮事件 actContent.querySelectorAll('button[data-act-view]').forEach(function(btn) { btn.addEventListener('click', function() { var newMode = this.getAttribute('data-act-view'); if (newMode === actViewMode) return; actViewMode = newMode; GM_setValue(FA_ACT_VIEW_KEY, newMode); renderActivityPage(currentPage); }); }); // 异步加载游戏中文名(参考 steam-friend-manager loadGameZhName) actContent.querySelectorAll('a[data-fa-appid]').forEach(function(el) { var aid = el.getAttribute('data-fa-appid'); var gi = saves.familyGameList.GameInfo[aid]; if (gi && gi.name) faLoadGameZhName(el, aid, gi.name); }); // v1.55:无 icon_hash 的游戏使用多 CDN 封面 fallback actContent.querySelectorAll('img[data-fa-cover]').forEach(function(img) { faLoadCover(img, img.getAttribute('data-fa-cover')); }); } } // ===================== 增长趋势渲染 ===================== const growthColors = ['#06cfbe', '#54a0ff', '#ff9f43', '#2ed573', '#ff6b6b', '#a29bfe', '#ffcd56']; function renderGrowthTab() { const gWrap = panel.querySelector('[data-growth-content]'); if (!gWrap || growthGames.length === 0) { if (gWrap) gWrap.innerHTML = '
暂无增长趋势数据
'; return; } const months = growthSorted.map(x => x[0]); const members = saves.familyInfo.family_member; const cumAll = [], cumMembers = {}; members.forEach(m => { cumMembers[m.steamid] = []; }); let totalAll = 0; const totalMembers = {}; members.forEach(m => { totalMembers[m.steamid] = 0; }); growthSorted.forEach(([_, entry]) => { totalAll += entry.all; cumAll.push(totalAll); members.forEach(m => { totalMembers[m.steamid] += (entry.members[m.steamid] || 0); cumMembers[m.steamid].push(totalMembers[m.steamid]); }); }); let maxVal = cumAll[cumAll.length - 1] || 1; members.forEach(m => { const last = cumMembers[m.steamid][cumMembers[m.steamid].length - 1] || 0; if (last > maxVal) maxVal = last; }); const svgW = 600, svgH = 360, pL = 50, pR = 20, pT = 20, pB = 50; const cW = svgW - pL - pR, cH = svgH - pT - pB; const step = cW / (months.length - 1 || 1); let grid = ''; for (let i = 0; i <= 5; i++) { const v = Math.round(maxVal / 5 * i), y = pT + cH - (cH * i / 5); grid += ''; grid += '' + v + ''; } const totalPoints = cumAll.map((val, i) => (pL + i * step) + ',' + (pT + cH - (val / maxVal) * cH)).join(' '); let lines = ''; // v1.66:成员对比线增加数据点标记 + 唯一 class 便于交互高亮 members.forEach((m, mi) => { const c = growthColors[mi % growthColors.length]; const pts = cumMembers[m.steamid].map((val, i) => (pL + i * step) + ',' + (pT + cH - (val / maxVal) * cH)).join(' '); lines += ''; // 数据点圆点 cumMembers[m.steamid].forEach((val, i) => { if (val > 0) { const cx = pL + i * step, cy = pT + cH - (val / maxVal) * cH; lines += ''; } }); }); let xLabels = '', lastYear = ''; months.forEach((m, i) => { const year = m.split('-')[0]; if (year !== lastYear) { xLabels += '' + year + ''; lastYear = year; } }); const svgStr = '' + grid + lines + xLabels + ''; let legendItems = ['总计']; members.forEach((m, mi) => { const c = growthColors[mi % growthColors.length]; legendItems.push('' + m.userName + ''); }); const legendHtml = '
' + legendItems.join('') + '
'; // v1.66:成员对比摘要 — 贡献最多 / 最少 / 平均 var memberTotals = members.map(function(m) { var last = cumMembers[m.steamid][cumMembers[m.steamid].length - 1] || 0; return { name: m.userName, count: last }; }).sort(function(a, b) { return b.count - a.count; }); var topM = memberTotals[0] || { name: '-', count: 0 }; var lowM = memberTotals[memberTotals.length - 1] || { name: '-', count: 0 }; var avgCnt = memberTotals.length > 0 ? Math.round(memberTotals.reduce(function(s, x) { return s + x.count; }, 0) / memberTotals.length) : 0; var compareSummary = '
' + '贡献最多: ' + faEsc(topM.name) + ' (' + topM.count + ')' + '平均: ' + avgCnt + '' + '贡献最少: ' + faEsc(lowM.name) + ' (' + lowM.count + ')' + '
'; const firstDate = new Date(growthGames[0].time * 1000); const lastDate = new Date(growthGames[growthGames.length - 1].time * 1000); const fmtD = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); const summary = '
' + '总计: ' + growthGames.length + '' + '首条: ' + fmtD(firstDate) + '' + '最新: ' + fmtD(lastDate) + '' + '月份数: ' + months.length + '' + '
'; // v1.45:近90天入库对比(右栏)。成员入库数按 owners 归属统计(多人共有的游戏计入各拥有者) const nowSec = Date.now() / 1000; const D90 = 90 * 86400; const cur90Cnt = {}; members.forEach(m => { cur90Cnt[m.steamid] = 0; }); let cur90Total = 0, prev90Total = 0; growthGames.forEach(g => { if (g.time >= nowSec - D90) { cur90Total++; g.owners.forEach(sid => { if (cur90Cnt[sid] !== undefined) cur90Cnt[sid]++; }); } else if (g.time >= nowSec - 2 * D90) { prev90Total++; } }); const max90 = Math.max(1, ...members.map(m => cur90Cnt[m.steamid])); let bars90 = ''; members.forEach((m, mi) => { const c = growthColors[mi % growthColors.length]; const v = cur90Cnt[m.steamid]; const wPct = Math.round(v / max90 * 100); // v1.58:成员名称前增加首字头像(唯一背景色区分用户) var avatarHtml = faNameAvatarHtml(m.userName, 16, 'margin-right:5px;'); // v1.69:数值颜色与饼状图扇区保持一致,零贡献使用灰色 bars90 += '
' + avatarHtml + '' + faEsc(m.userName) + '' + '
' + '
' + '' + v + '' + '
'; }); const diff90 = cur90Total - prev90Total; const diff90Text = diff90 > 0 ? '+' + diff90 : String(diff90); const diff90Color = diff90 > 0 ? '#2ed573' : (diff90 < 0 ? '#ff6b6b' : '#8a9ba8'); // v1.69:近90天成员入库对比饼状图(环形图,中心显示总数,置于 KPI 卡片上方) const d90R = 50, d90SW = 18, d90Circ = 2 * Math.PI * d90R; let d90Acc = 0, d90Arcs = ''; const d90Sum = cur90Total || 1; members.forEach((m, mi) => { const c = growthColors[mi % growthColors.length]; const v = cur90Cnt[m.steamid]; const frac = v / d90Sum; const len = frac * d90Circ; if (len > 0.01) { d90Arcs += '' + '' + faEsc(m.userName) + ': ' + v + ' 个 (' + (frac * 100).toFixed(1) + '%)'; } d90Acc += len; }); const donut90Html = '
' + '
成员入库占比
' + '' + '' + d90Arcs + '' + cur90Total + '' + '总入库' + '
'; const d90Html = '
' + '' + '近90天入库对比
' + donut90Html + '
' + '
' + cur90Total + '
近90天
' + '
' + prev90Total + '
前90天
' + '
' + diff90Text + '
环比
' + '
' + '
各成员近90天入库数
' + bars90; // v1.58:左侧累计增长曲线(加宽),右侧90天入库对比(收窄给左侧更多空间) gWrap.innerHTML = '
' + '
' + '
家庭成员游戏累计增长趋势
' + svgStr + legendHtml + compareSummary + summary + '
' + '
' + d90Html + '
' + '
'; // v1.66:图例交互 — hover 高亮对应成员线,其余变暗 var legendEls = gWrap.querySelectorAll('.fa-growth-legend'); legendEls.forEach(function(el) { el.addEventListener('mouseenter', function() { var mi = this.getAttribute('data-mi'); gWrap.querySelectorAll('.fa-growth-line, .fa-growth-dot').forEach(function(l) { if (l.getAttribute('data-mi') === mi) { l.style.opacity = '1'; if (l.tagName === 'polyline') l.style.strokeWidth = '2.5'; if (l.tagName === 'circle') l.setAttribute('r', '3.5'); } else { l.style.opacity = '.15'; } }); }); el.addEventListener('mouseleave', function() { gWrap.querySelectorAll('.fa-growth-line').forEach(function(l) { l.style.opacity = '.7'; l.style.strokeWidth = '1.5'; }); gWrap.querySelectorAll('.fa-growth-dot').forEach(function(l) { l.style.opacity = '.7'; l.setAttribute('r', '2.5'); }); }); }); } // ===================== 贡献分布右栏渲染(v1.45 新增) ===================== // 成员贡献占比环形图(SVG donut)+ 近半年入库增量(月度柱状图) function renderContributionExtras() { const donutWrap = panel.querySelector('[data-member-donut]'); const hyWrap = panel.querySelector('[data-halfyear-chart]'); if (!donutWrap && !hyWrap) return; const members = saves.familyInfo.family_member || []; if (members.length === 0) { const emptyHtml = '
暂无成员数据
'; if (donutWrap) donutWrap.innerHTML = emptyHtml; if (hyWrap) hyWrap.innerHTML = emptyHtml; return; } // ---- 成员贡献占比环形图 ---- if (donutWrap) { const counts = members.map(m => ({ name: m.userName, count: 0 })); const midx = {}; members.forEach((m, i) => { midx[m.steamid] = i; }); for (let key in saves.familyGameList.GameInfo) { saves.familyGameList.GameInfo[key].owners.forEach(sid => { if (midx[sid] !== undefined) counts[midx[sid]].count++; }); } const sum = counts.reduce((a, c) => a + c.count, 0) || 1; const R = 52, SW = 20, CIRC = 2 * Math.PI * R; let acc = 0, arcs = ''; counts.forEach((c, i) => { const frac = c.count / sum; const len = frac * CIRC; const col = growthColors[i % growthColors.length]; if (len > 0.01) { arcs += '' + '' + faEsc(c.name) + ': ' + c.count + ' 个 (' + (frac * 100).toFixed(1) + '%)'; } acc += len; }); let legendRows = ''; counts.forEach((c, i) => { const col = growthColors[i % growthColors.length]; const pct = (c.count / sum * 100).toFixed(1); legendRows += '
' + '' + '' + faEsc(c.name) + '' + '' + c.count + '' + '' + pct + '%' + '
'; }); // v1.49:环形图左右居中,图例移至图形底部 // v1.58:flex 布局上顶均匀分布,图例区可滚动适配 6+ 成员 donutWrap.innerHTML = '
' + '' + '成员贡献占比
' + '
' + '' + arcs + '' + members.length + '' + '名成员' + '
' + '
' + legendRows + '
'; } // ---- 近半年入库增量(最近 6 个自然月,含当月) ---- if (hyWrap) { const now = new Date(); const halfData = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); const entry = monthlyMap.get(key); halfData.push({ label: String(d.getMonth() + 1) + '月', count: entry ? entry.all : 0, isCur: i === 0 }); } const hyTotal = halfData.reduce((a, b) => a + b.count, 0); const hyMax = Math.max(1, ...halfData.map(h => h.count)); let bars = ''; halfData.forEach(h => { const hPct = Math.round(h.count / hyMax * 100); const col = h.isCur ? '#06cfbe' : '#54a0ff'; bars += '
' + '' + h.count + '' + '
' + '
'; }); let monthLabels = ''; halfData.forEach(h => { monthLabels += '' + h.label + ''; }); // v1.58:flex 布局上顶均匀分布,柱状图区域自适应高度 hyWrap.innerHTML = '
' + '近半年入库增量' + '+' + hyTotal + '' + '
' + '
' + bars + '
' + '
' + monthLabels + '
'; } } // ===================== v1.75:我的贡献覆盖层渲染 ===================== // 点击左侧"查看我的贡献"按钮后,覆盖贡献分布默认视图,展示个人贡献 KPI + 最近贡献 + 最近游玩 function renderMyContributionOverlay() { var overlay = panel.querySelector('[data-my-contrib-overlay]'); if (!overlay) return; var defaultView = panel.querySelector('[data-contrib-default]'); // 显示覆盖层,隐藏默认视图 if (defaultView) defaultView.style.display = 'none'; overlay.style.display = 'flex'; overlay.style.flexDirection = 'column'; overlay.style.gap = '12px'; var mySid = saves.steamid || ''; var gi = saves.familyGameList.GameInfo; var gameList = saves.familyGameList.GameList; var members = saves.familyInfo.family_member || []; var myGames = []; var myExclusive = 0, myShared = 0, myRecent30 = 0; var nowSec = Date.now() / 1000, D30 = 30 * 86400; // v1.75:DLC 检测——双源(Barter.vg DLC 数据库 + Steam GetItems 类型映射) // faIsDlc() 同步检查 faDlcDbData 和 faAppTypeMap,两者均在面板初始化时从缓存同步加载 var dlcSourceReady = !!(faDlcDbData || faAppTypeMap); var myDlcCount = 0; for (var i = 0; i < gameList.length; i++) { var aid = gameList[i]; var info = gi[aid]; if (!info || !info.owners || info.owners.indexOf(mySid) === -1) continue; var isExclusive = info.owners.length === 1; var isRecent = info.time && (nowSec - info.time) < D30; myGames.push({ appid: aid, name: info.name || ('App ' + aid), time: info.time || 0, owners: info.owners, isExclusive: isExclusive }); if (isExclusive) myExclusive++; else myShared++; if (isRecent) myRecent30++; // DLC 检测:双源数据就绪时精确检测,未就绪时回退名称启发式 if (dlcSourceReady) { if (faIsDlc(aid)) myDlcCount++; } else { var lowerName = (info.name || '').toLowerCase(); if (/\b(dlc|soundtrack|season pass|ost|demo|bundle)\b/.test(lowerName)) myDlcCount++; } } myGames.sort(function(a, b) { return b.time - a.time; }); var myTotal = myGames.length; var myPct = totalGames > 0 ? (myTotal / totalGames * 100).toFixed(1) : '0'; // 成员贡献排名 var memberCounts = {}; members.forEach(function(m) { memberCounts[m.steamid] = 0; }); for (var mk in gi) { if (gi[mk].owners) { gi[mk].owners.forEach(function(sid) { if (memberCounts[sid] !== undefined) memberCounts[sid]++; }); } } var ranked = members.map(function(m) { return { sid: m.steamid, name: m.userName || (saves.familyInfo.steamIdtoName || {})[m.steamid] || '成员', count: memberCounts[m.steamid] || 0 }; }).sort(function(a, b) { return b.count - a.count; }); var myRank = 0; for (var ri = 0; ri < ranked.length; ri++) { if (ranked[ri].sid == mySid) { myRank = ri + 1; break; } } // ---- 构建 KPI 卡片(参考截图:彩色圆形图标 + 数值 + 标签 + 副标题) ---- var kpiCards = [ { iconType: 'game', label: '贡献游戏总数', value: myTotal, color: '#06cfbe', sub: '占家庭库 ' + myPct + '% · 排名第' + (myRank || '-') + '名' }, { iconType: 'exclusive', label: '独占贡献', value: myExclusive, color: '#ff9f43', sub: '仅我拥有' }, { iconType: 'shared', label: '共享贡献', value: myShared, color: '#a78bfa', sub: '与他人共有' }, { iconType: 'recent', label: '近30日新增', value: myRecent30, color: '#2ed573', sub: myTotal > 0 ? (myRecent30 / myTotal * 100).toFixed(0) + '% 增量' : '暂无新增' }, { iconType: 'dlc', label: 'DLC 数量', value: myDlcCount, color: '#ff6b6b', sub: dlcSourceReady ? '双源数据精确' : '名称估算·加载中' } ]; function buildContribKpiIcon(type, color) { var s = ' viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="' + color + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; if (type === 'game') return ''; if (type === 'exclusive') return ''; if (type === 'shared') return ''; if (type === 'recent') return ''; if (type === 'dlc') return ''; return ''; } var html = ''; // 顶部栏:返回按钮 + 标题 html += '
' + '' + '
' + '' + '我的贡献概览
' + '
'; // KPI 卡片行(5 列,参考截图:彩色圆形图标 + 大号数值 + 标签 + 副标题) html += '
'; kpiCards.forEach(function(k) { html += '
' + '
' + '
' + buildContribKpiIcon(k.iconType, k.color) + '
' + '
' + k.value + '
' + '
' + k.label + '
' + '
' + k.sub + '
' + '
'; }); html += '
'; // 双列面板:最近贡献 | 最近游玩 html += '
'; // ---- 左:最近贡献 ---- var recentContrib = myGames.slice(0, 10); html += '
'; html += '
' + '
' + '' + '最近贡献
' + '' + myTotal + '' + '
'; if (recentContrib.length > 0) { html += '
'; recentContrib.forEach(function(g) { var dateStr = g.time > 0 ? new Date(g.time * 1000).toLocaleDateString('zh-CN') : '未知'; var tagColor = g.isExclusive ? '#ff9f43' : '#a78bfa'; var tagText = g.isExclusive ? '独占' : g.owners.length + '人共享'; html += '
' + '' + '
' + '' + faEsc(g.name) + '' + '
购入于 ' + dateStr + '
' + '
' + '' + tagText + '' + '
'; }); html += '
'; } else { html += '
暂无贡献记录
'; } html += '
'; // ---- 右:最近游玩 ---- var paData = (paCache.data && paCache.data[mySid]) ? paCache.data[mySid] : null; var playGames = paData && paData.result && paData.result.games ? paData.result.games : []; playGames = playGames.slice().sort(function(a, b) { return (b.playtime_2weeks || 0) - (a.playtime_2weeks || 0) || (b.playtime_forever || 0) - (a.playtime_forever || 0); }).slice(0, 10); var playCount = paData && paData.result ? paData.result.total_count : playGames.length; html += '
'; html += '
' + '
' + '' + '最近游玩
' + '' + playCount + '' + '
'; if (playGames.length > 0) { html += '
'; playGames.forEach(function(g) { var gRecent = faFmtHours(g.playtime_2weeks || 0); var gTotal = faFmtHours(g.playtime_forever || 0); html += '
' + '' + '
' + '' + faEsc(g.name) + '' + '
近2周 ' + gRecent + ' / 总计 ' + gTotal + '
' + '
' + '
'; }); html += '
'; } else { html += '
游玩数据加载中…
若长时间无数据,请先在"游玩动态"标签页加载
'; } html += '
'; html += '
'; // end 双列面板 overlay.innerHTML = html; // 绑定返回按钮 var backBtn = overlay.querySelector('#faContribBack'); if (backBtn) { backBtn.addEventListener('click', function() { overlay.style.display = 'none'; overlay.innerHTML = ''; if (defaultView) defaultView.style.display = ''; // 恢复 Chart.js 图表(canvas 可能被覆盖层移除) if (!document.getElementById('Family_countChart')) { var chartWrap = panel.querySelector('[data-chart-bar-card]'); if (chartWrap) { var cv = document.createElement('canvas'); cv.id = 'Family_countChart'; cv.width = 560; cv.height = 460; cv.style.cssText = 'display:block;box-sizing:border-box;height:460px;width:560px;max-width:100%;filter:drop-shadow(0 4px 12px rgba(0,0,0,0.2))'; chartWrap.appendChild(cv); } } observer_5(); renderContributionExtras(); }); } // 异步加载封面图与中文名 overlay.querySelectorAll('img[data-fa-cover]').forEach(function(img) { faLoadCover(img, img.getAttribute('data-fa-cover')); }); overlay.querySelectorAll('a[data-fa-appid]').forEach(function(el) { var aid = el.getAttribute('data-fa-appid'); var info = saves.familyGameList.GameInfo[aid]; faLoadGameZhName(el, aid, info ? (info.name || el.textContent) : el.textContent); }); // 若游玩数据未缓存,后台拉取并自动刷新覆盖层 if ((!paCache.data || !paCache.data[mySid]) && access_token && members.length > 0 && !playActivityLoading) { refreshPlayActivityData(members); var pollCount = 0; var pollTimer = setInterval(function() { pollCount++; var curPanel = document.getElementById('familyAnalysisPanel'); if (!curPanel || pollCount > 15) { clearInterval(pollTimer); return; } var curOverlay = curPanel.querySelector('[data-my-contrib-overlay]'); if (curOverlay && curOverlay.style.display !== 'none' && paCache.data && paCache.data[mySid]) { clearInterval(pollTimer); renderMyContributionOverlay(); } }, 2000); } // v1.75:DLC 数据未就绪时后台加载并自动刷新覆盖层(双源并发:Barter.vg + Steam GetItems) if (!dlcSourceReady) { var dlcAppIds = (saves && saves.familyGameList && saves.familyGameList.GameList) ? saves.familyGameList.GameList.slice() : []; Promise.all([faLoadDlcDatabase(), faEnrichAppTypes(dlcAppIds)]).then(function() { var curPanel = document.getElementById('familyAnalysisPanel'); if (curPanel) { var curOverlay = curPanel.querySelector('[data-my-contrib-overlay]'); if (curOverlay && curOverlay.style.display !== 'none') { renderMyContributionOverlay(); } } }).catch(function(e) { console.warn('[FA] DLC 数据后台加载失败:', e); }); } } // ===================== 游玩动态渲染 ===================== // v1.40:游玩数据缓存提升到 window 级,面板关闭后重开可直接复用(10 分钟 TTL), // 过期时先渲染旧缓存、再后台静默刷新,避免每次重开面板都全量重新加载 const PA_CACHE_KEY = '__faPlayActivityCache'; const PA_CACHE_TTL = 10 * 60 * 1000; // 10 分钟 if (!window[PA_CACHE_KEY]) window[PA_CACHE_KEY] = { data: null, updatedAt: 0 }; const paCache = window[PA_CACHE_KEY]; let playActivityLoading = false; // v1.41:家庭愿望单缓存(window 级,30 分钟 TTL,与游玩动态同样跨面板复用) const WL_CACHE_KEY = '__faWishlistCache'; const WL_CACHE_TTL = 30 * 60 * 1000; if (!window[WL_CACHE_KEY]) window[WL_CACHE_KEY] = { data: null, updatedAt: 0 }; const wlCache = window[WL_CACHE_KEY]; let wlState = { loading: false, search: '', filter: 'all', page: 1, enriching: false, viewMode: GM_getValue('faWlViewMode', 'list'), subFilter: 0 }; function faFmtHours(minutes) { if (!minutes || minutes <= 0) return '0h'; // v1.57: 整数化,修复浮点误差导致 "215h 29.33333333333394m" 显示问题 minutes = Math.round(minutes); var h = Math.floor(minutes / 60); var m = minutes % 60; if (h >= 1000) return (h / 1000).toFixed(1) + 'kh'; if (h > 0) return h + 'h' + (m > 0 ? ' ' + m + 'm' : ''); return m + 'm'; } function faEsc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); } function faEscAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } // v1.66:统一加载动画 HTML(参考 steam-friend-manager createLoadingEl) function createLoadingHtml(text, size) { size = size || 34; return '
' + '' + '' + text + '
'; } // ===================== 价值洞察(v1.71 新增) ===================== // 参考 html-demo/family-price-value 原型设计,展示家庭库货币价值维度 // 数据来源:IStoreBrowseService/GetItems(价格)+ IPlayerService/GetOwnedGames(per-game 游玩时长) const VI_CACHE_KEY = '__faValueInsightsCache'; const VI_CACHE_TTL = 24 * 60 * 60 * 1000; // v1.74: 30分钟 → 24小时(数据变化频率低,价格/游玩时长无需频繁刷新) if (!window[VI_CACHE_KEY]) window[VI_CACHE_KEY] = { priceData: null, playtimeData: null, updatedAt: 0, preheated: false }; const viCache = window[VI_CACHE_KEY]; let viLoading = false; let viPreheatTimer = null; let viTrendMode = 'monthly'; // 'monthly' | 'cumulative' function fmtViMoney(v) { if (v >= 10000) return (v / 1000).toFixed(1) + 'k'; return Math.round(v).toString(); } function buildViKpiCard(label, value, color, sub) { return '
' + '
' + '
' + value + '
' + '
' + label + '
' + '
' + sub + '
' + '
'; } // v1.74:预热——面板打开后延迟 3s 后台静默拉取价值洞察数据,用户切到标签页时秒开 function preheatValueInsightsData() { if (viPreheatTimer) clearTimeout(viPreheatTimer); viPreheatTimer = setTimeout(function() { viPreheatTimer = null; // 已有新鲜缓存或正在加载则跳过 if (viLoading) return; if (viCache.priceData && Object.keys(viCache.priceData).length > 0 && (Date.now() - viCache.updatedAt < VI_CACHE_TTL)) return; var members = (saves && saves.familyInfo && Array.isArray(saves.familyInfo.family_member)) ? saves.familyInfo.family_member : []; if (members.length === 0 || !access_token) return; viCache.preheated = true; refreshValueInsightsData(members, true); }, 3000); } // v1.75:DLC 数据预热——面板打开后延迟 2s 后台加载 DLC 数据库 + 应用类型映射 // 用户查看"我的贡献"时 faIsDlc() 可直接使用缓存数据,无需等待 var faDlcPreheatTimer = null; function preheatDlcData() { if (faDlcPreheatTimer) clearTimeout(faDlcPreheatTimer); faDlcPreheatTimer = setTimeout(function() { faDlcPreheatTimer = null; // 两源数据均已就绪则跳过 if (faDlcDbData && faAppTypeMap) return; var allAppIds = (saves && saves.familyGameList && saves.familyGameList.GameList) ? saves.familyGameList.GameList.slice() : []; if (allAppIds.length === 0) return; // 双源并发:Barter.vg DLC 数据库 + Steam GetItems 应用类型 Promise.all([faLoadDlcDatabase(), faEnrichAppTypes(allAppIds)]).then(function() { console.log('[FA] DLC 数据预热完成(双源)'); // 若"我的贡献"覆盖层可见则自动刷新以更新 DLC 计数 var curPanel = document.getElementById('familyAnalysisPanel'); if (curPanel) { var curOverlay = curPanel.querySelector('[data-my-contrib-overlay]'); if (curOverlay && curOverlay.style.display !== 'none') { renderMyContributionOverlay(); } } }).catch(function(e) { console.warn('[FA] DLC 数据预热失败:', e); }); }, 2000); } function renderValueInsightsTab(forceRefresh) { var viWrap = panel.querySelector('[data-value-content]'); if (!viWrap) return; var members = (saves && saves.familyInfo && Array.isArray(saves.familyInfo.family_member)) ? saves.familyInfo.family_member : []; if (members.length === 0) { viWrap.innerHTML = '
暂无家庭成员数据,请先扫描家庭库
'; return; } if (!access_token) { viWrap.innerHTML = '
访问令牌不可用,请在 Steam 商店页面打开此面板
'; return; } if (forceRefresh) { viCache.priceData = null; viCache.playtimeData = null; viCache.updatedAt = 0; viCache.preheated = false; faToast.info('正在刷新价值洞察数据…'); } var hasCache = viCache.priceData && Object.keys(viCache.priceData).length > 0; if (hasCache) { renderValueInsightsContent(viWrap, viCache.priceData, viCache.playtimeData, members); if (Date.now() - viCache.updatedAt < VI_CACHE_TTL) return; refreshValueInsightsData(members); return; } // v1.74:预热数据已开始加载则显示轻量加载态,否则全量加载 viWrap.innerHTML = createLoadingHtml(viCache.preheated ? '价值数据预热中,即将完成…' : '正在加载家庭库价值数据(价格 + 游玩时长,可能需要数秒)…'); refreshValueInsightsData(members); } function refreshValueInsightsData(members, isPreheat) { if (viLoading) return; viLoading = true; // v1.74:价格 + 游玩时长并发获取(两者无依赖关系),预热模式下静默 var appids = saves.familyGameList.GameList.slice(); // Step 1: 批量获取价格(GetItems 100个/批 3路并发 → appdetails 兜底残余) var pricePromise = faWlEnrichFromStoreBrowse(appids).then(function(priceData) { // Step 1b: appdetails 兜底——GetItems 未返回价格的游戏(下架/锁区等) var leftovers = appids.filter(function(aid) { var r = priceData[aid]; if (!r) return true; if (!r.isFree && !r.isComingSoon && !(Number(r.finalPrice) > 0)) return true; return false; }); if (leftovers.length === 0) return priceData; console.log('[FA] 价值洞察: ' + leftovers.length + ' 个游戏 GetItems 未覆盖,appdetails 兜底'); return enrichWishlistMeta(leftovers).then(function(detailData) { for (var aid in detailData) { if (!priceData[aid]) priceData[aid] = detailData[aid]; } return priceData; }); }); // Step 2: 并发获取每个成员的 per-game 游玩时长,聚合为 { appid: { total, recent, members } } var playtimePromise = Promise.all(members.map(function(m) { return faFetchMemberPlaytimeGames(access_token, m.steamid); })).then(function(results) { var pm = {}; members.forEach(function(m, i) { (results[i] || []).forEach(function(g) { if (!pm[g.appid]) pm[g.appid] = { total: 0, recent: 0, members: {} }; pm[g.appid].total += g.playtime; pm[g.appid].recent += g.recent; pm[g.appid].members[m.steamid] = g.playtime; }); }); return pm; }); // v1.74:渐进式动态渲染——价格先到先渲染(时长区块显示占位),时长到达后自动刷新为完整视图; // 任一数据流失败不阻塞另一路渲染(降级展示),面板未切到价值洞察/预热模式时仅写缓存 var playtimeReady = false; function maybeRender(playtimePending) { if (isPreheat) return; var cur = document.getElementById('familyAnalysisPanel'); if (!cur) return; var w = cur.querySelector('[data-value-content]'); if (!w) return; renderValueInsightsContent(w, viCache.priceData || {}, viCache.playtimeData || {}, members, { playtimePending: !!playtimePending }); } var priceSafe = pricePromise.then(function(pd) { viCache.priceData = pd || {}; if (Object.keys(viCache.priceData).length > 0) maybeRender(!playtimeReady); return true; }, function(err) { console.warn('[FA] 价值洞察价格数据加载失败:', err); return false; }); var playtimeSafe = playtimePromise.then(function(pm) { playtimeReady = true; viCache.playtimeData = pm || {}; // 时长就绪:价格已渲染则动态刷新为完整视图(不阻塞页面,原地更新) if (viCache.priceData && Object.keys(viCache.priceData).length > 0) { maybeRender(false); if (!isPreheat) faToast.success('游玩时长数据已更新'); } return true; }, function(err) { playtimeReady = true; console.warn('[FA] 价值洞察游玩时长加载失败:', err); // 时长失败:价格视图保留,时长区块显示失败占位而非一直转圈 if (viCache.priceData && Object.keys(viCache.priceData).length > 0) maybeRender(false); return false; }); Promise.all([priceSafe, playtimeSafe]).then(function(results) { viLoading = false; viCache.preheated = false; if (results[0] && results[1]) viCache.updatedAt = Date.now(); if (isPreheat) { console.log('[FA] 价值洞察数据预热完成'); return; } var hasPrice = viCache.priceData && Object.keys(viCache.priceData).length > 0; if (hasPrice) { faToast.success('价值洞察数据已更新'); } else { // 价格完全失败:给出可操作的错误提示(时长数据无价格对照无意义) var cur = document.getElementById('familyAnalysisPanel'); var w = cur && cur.querySelector('[data-value-content]'); if (w) w.innerHTML = '
价格数据加载失败,请检查网络后点击下方"刷新价值数据"重试
' + '
'; var errBtn = w && w.querySelector('#btn_refresh_value_err'); if (errBtn) errBtn.addEventListener('click', function() { renderValueInsightsTab(true); }); faToast.error('价值洞察数据加载失败'); } }); } function renderValueInsightsContent(viWrap, priceData, playtimeData, members, opts) { // v1.74:opts.playtimePending=true 时先渲染价格维度内容,游玩时长相关区块显示加载占位,时长到达后自动重渲染 var playtimePending = !!(opts && opts.playtimePending); var gi = saves.familyGameList.GameInfo; var gameList = saves.familyGameList.GameList; var idMap = saves.familyInfo.steamIdtoName || {}; var curSym = faCurrency.symbol; var nowSec = Date.now() / 1000, D90 = 90 * 86400; var totalOriginal = 0, totalActual = 0, totalPlaytimeMin = 0, new90Original = 0; var memberStats = {}; members.forEach(function(m) { memberStats[m.steamid] = { original: 0, actual: 0, games: 0, playtime: 0, discountSum: 0, discountCount: 0 }; }); var monthlyMap = {}; var priceTiers = [ { name: '免费', min: 0, max: 0.01 }, { name: '<' + curSym + '50', min: 0.01, max: 50 }, { name: curSym + '50-100', min: 50, max: 100 }, { name: curSym + '100-200', min: 100, max: 200 }, { name: curSym + '200-500', min: 200, max: 500 }, { name: '≥' + curSym + '500', min: 500, max: Infinity } ]; var scatterPoints = []; gameList.forEach(function(appid) { var info = gi[appid]; if (!info) return; var pr = priceData[appid], pt = playtimeData[appid]; var op = 0, fp = 0, dp = 0, isFree = false; if (pr) { op = pr.originalPrice || 0; fp = pr.finalPrice || 0; dp = pr.discountPct || 0; isFree = pr.isFree; } var actualPaid = fp > 0 ? fp : op; if (!isFree && op > 0) { totalOriginal += op; totalActual += actualPaid; } var playtimeMin = pt ? pt.total : 0, recentMin = pt ? pt.recent : 0; totalPlaytimeMin += playtimeMin; if (info.time && (nowSec - info.time) < D90 && !isFree && op > 0) new90Original += op; if (info.owners) { info.owners.forEach(function(sid) { if (memberStats[sid]) { if (!isFree && op > 0) { memberStats[sid].original += op; memberStats[sid].actual += actualPaid; } memberStats[sid].games++; memberStats[sid].playtime += playtimeMin; if (dp > 0) { memberStats[sid].discountSum += dp; memberStats[sid].discountCount++; } } }); } if (info.time > 0) { var d = new Date(info.time * 1000); var mk = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); if (!monthlyMap[mk]) monthlyMap[mk] = { original: 0, actual: 0, count: 0, tiers: [0,0,0,0,0,0], members: {} }; monthlyMap[mk].original += op; monthlyMap[mk].actual += actualPaid; monthlyMap[mk].count++; for (var ti = 0; ti < priceTiers.length; ti++) { if (actualPaid >= priceTiers[ti].min && actualPaid < priceTiers[ti].max) { monthlyMap[mk].tiers[ti] += actualPaid; break; } } if (info.owners) { info.owners.forEach(function(sid) { if (!monthlyMap[mk].members[sid]) monthlyMap[mk].members[sid] = 0; if (!isFree && op > 0) monthlyMap[mk].members[sid] += op; }); } } if (!isFree && op > 0 && playtimeMin > 0) { var po = info.owners && info.owners.length > 0 ? info.owners[0] : ''; var oi = -1; for (var mi2 = 0; mi2 < members.length; mi2++) { if (members[mi2].steamid === po) { oi = mi2; break; } } scatterPoints.push({ name: info.name, price: op, playtime: playtimeMin / 60, recent: recentMin / 60, ownerIdx: oi }); } }); var totalSavings = totalOriginal - totalActual; var savingsPct = totalOriginal > 0 ? Math.round(totalSavings / totalOriginal * 100) : 0; var perCapita = members.length > 0 ? totalOriginal / members.length : 0; var totalPlaytimeH = totalPlaytimeMin / 60; var costPerHour = totalPlaytimeH > 0 ? totalActual / totalPlaytimeH : 0; var monthlySorted = Object.entries(monthlyMap).sort(function(a, b) { return a[0].localeCompare(b[0]); }); // KPI 卡片行 var kpiHtml = '
' + buildViKpiCard('共享库总价值', curSym + fmtViMoney(totalOriginal), '#06cfbe', '实际支付 ' + curSym + fmtViMoney(totalActual) + '(省 ' + savingsPct + '%)') + buildViKpiCard('人均贡献价值', curSym + fmtViMoney(perCapita), '#54a0ff', '按原价计算,共 ' + members.length + ' 人') + buildViKpiCard('近90天新增价值', curSym + fmtViMoney(new90Original), '#ff9f43', '最近 90 天入库原价合计') + (playtimePending ? buildViKpiCard('平均性价比', '统计中…', '#a78bfa', '游玩时长加载中,完成后自动更新') : buildViKpiCard('平均性价比', curSym + costPerHour.toFixed(2) + '/h', '#a78bfa', '实际支付 ÷ 总游玩时长')) + '
'; viWrap.innerHTML = kpiHtml + '
' + '
' + buildViTrendChart(monthlySorted, members) + '
' + '
' + buildViScatter(scatterPoints, curSym, playtimePending) + '
' + '
' + '
' + '
' + buildViHeatmap(monthlySorted, priceTiers, curSym) + '
' + '
' + buildViMemberContribution(memberStats, members, totalOriginal, idMap, curSym, playtimePending) + '
' + '
' + '
' + '
' + (playtimePending ? '价格数据已就绪,游玩时长统计中…' : '数据更新于 ' + (viCache.updatedAt ? new Date(viCache.updatedAt).toLocaleString('zh-CN') : '-')) + '
' + '' + '
'; // 刷新按钮 var refreshBtn = viWrap.querySelector('#btn_refresh_value'); if (refreshBtn) refreshBtn.addEventListener('click', function() { renderValueInsightsTab(true); }); // 趋势图月度增量/累积价值切换 var trendToggle = viWrap.querySelector('[data-vi-trend-toggle]'); if (trendToggle) { trendToggle.querySelectorAll('button').forEach(function(btn) { btn.addEventListener('click', function() { viTrendMode = this.getAttribute('data-mode'); trendToggle.querySelectorAll('button').forEach(function(b) { b.classList.remove('active'); b.style.background = 'transparent'; b.style.color = '#8097a8'; }); this.classList.add('active'); this.style.background = 'rgba(6,207,190,.2)'; this.style.color = '#06cfbe'; var chartWrap = viWrap.querySelector('[data-vi-trend-svg]'); if (chartWrap) chartWrap.innerHTML = buildViTrendSvg(monthlySorted, members); }); }); } } // ---- 入库价值趋势(SVG 折线图,支持月度增量/累积价值切换)---- function buildViTrendChart(monthlySorted, members) { var toggleHtml = '
' + '' + '' + '
'; var titleHtml = '
' + '' + '入库价值趋势' + toggleHtml + '
'; return titleHtml + '
' + buildViTrendSvg(monthlySorted, members) + '
'; } function buildViTrendSvg(monthlySorted, members) { if (monthlySorted.length === 0) return '
暂无入库时间数据
'; var months = monthlySorted.map(function(x) { return x[0]; }); var curSym = (typeof faCurrency !== 'undefined' && faCurrency.symbol) || '¥'; var fmtY = function(v) { return v >= 1000 ? (v / 1000).toFixed(1) + 'k' : Math.round(v); }; // 自适应横轴标签稀疏化:年份标记(首行) + 月份间隔标签(次行),避免压缩看不清 function buildXLabels(pL, step, pT, cH) { var cW = (months.length - 1) * step || 1; var maxLabels = Math.floor(cW / 40); // 每个 label 至少约 40px var interval = Math.max(1, Math.ceil(months.length / maxLabels)); var xLabels = '', lastYear = ''; months.forEach(function(m, i) { var parts = m.split('-'), year = parts[0], mon = parts[1]; var cx = pL + i * step; if (year !== lastYear) { xLabels += '' + year + ''; lastYear = year; } if (i % interval === 0) { xLabels += '' + mon + '月'; } }); return xLabels; } if (viTrendMode === 'monthly') { // 月度增量柱状图:每月原价(浅) + 实付(深) 双层柱,展示价值入库节奏 var vals = monthlySorted.map(function(x) { return x[1].original; }); var actVals = monthlySorted.map(function(x) { return x[1].actual; }); var maxV = Math.max.apply(null, vals.concat([1])); var svgW = 540, svgH = 250, pL = 52, pR = 20, pT = 16, pB = 48; var cW = svgW - pL - pR, cH = svgH - pT - pB; var step = cW / (months.length > 1 ? months.length - 1 : 1); var grid = ''; for (var i = 0; i <= 4; i++) { var v = Math.round(maxV / 4 * i), y = pT + cH - (cH * i / 4); grid += ''; grid += '' + fmtY(v) + ''; } var bars = '', barW = Math.min(step * 0.65, 22); vals.forEach(function(val, i) { var cx = pL + i * step, bh = (val / maxV) * cH, y = pT + cH - bh; var av = actVals[i], ah = (av / maxV) * cH, ay = pT + cH - ah; bars += '' + months[i] + ' · 原价 ' + curSym + fmtViMoney(val) + ' / 实付 ' + curSym + fmtViMoney(av) + ''; if (av > 0) bars += ''; }); var xLabels = buildXLabels(pL, step, pT, cH); var legendItems = [ '原价', '实付' ]; var legendHtml = '
' + legendItems.join('') + '
'; return '' + grid + bars + xLabels + '' + legendHtml; } // 累计模式:原价累计(虚线) + 实付累计(实线+面积) + 节省面积(绿) + 成员价值线 var cumOrig = [], cumAct = [], cumMembers = {}; var tO = 0, tA = 0, tM = {}; members.forEach(function(m) { tM[m.steamid] = 0; cumMembers[m.steamid] = []; }); monthlySorted.forEach(function(entry) { var d = entry[1]; tO += d.original; tA += d.actual; members.forEach(function(m) { tM[m.steamid] += (d.members[m.steamid] || 0); }); cumOrig.push(tO); cumAct.push(tA); members.forEach(function(m) { cumMembers[m.steamid].push(tM[m.steamid]); }); }); var maxVal = cumOrig[cumOrig.length - 1] || 1; var svgW = 540, svgH = 250, pL = 52, pR = 20, pT = 16, pB = 48; var cW = svgW - pL - pR, cH = svgH - pT - pB; var step = cW / (months.length > 1 ? months.length - 1 : 1); var grid = ''; for (var i = 0; i <= 5; i++) { var v = Math.round(maxVal / 5 * i), y = pT + cH - (cH * i / 5); grid += ''; grid += '' + fmtY(v) + ''; } var origPts = cumOrig.map(function(val, i) { return (pL + i * step).toFixed(1) + ',' + (pT + cH - (val / maxVal) * cH).toFixed(1); }); var actPts = cumAct.map(function(val, i) { return (pL + i * step).toFixed(1) + ',' + (pT + cH - (val / maxVal) * cH).toFixed(1); }); // 节省面积:原价线正向 + 实付线逆向闭合 var savePoly = origPts.slice(); for (var j = cumAct.length - 1; j >= 0; j--) savePoly.push(actPts[j]); var saveArea = '累计节省 ' + curSym + fmtViMoney(tO - tA) + ''; // 实付面积 var actArea = ''; var actLine = ''; var origLine = ''; var memLines = ''; members.forEach(function(m, mi) { var c = growthColors[mi % growthColors.length]; var pts = cumMembers[m.steamid].map(function(val, i) { return (pL + i * step).toFixed(1) + ',' + (pT + cH - (val / maxVal) * cH).toFixed(1); }).join(' '); memLines += ''; }); var xLabels = buildXLabels(pL, step, pT, cH); var legendItems = [ '原价累计', '实付累计', '节省' ]; members.forEach(function(m, mi) { var c = growthColors[mi % growthColors.length]; legendItems.push('' + faEsc(m.userName || m.steamid) + ''); }); var legendHtml = '
' + legendItems.join('') + '
'; return '' + grid + saveArea + actArea + actLine + origLine + memLines + xLabels + '' + legendHtml; } // ---- 价格-时长散点图(X 对数刻度,Y 游玩时长,气泡=近两周,红框=踩雷区)---- // v1.74:pending=true 时显示时长加载占位(价格先行渲染阶段) function buildViScatter(points, curSym, pending) { var titleHtml = '
' + '' + '价格-时长散点图
'; if (pending) { return titleHtml + '
' + '' + '游玩时长统计中,完成后自动绘制…
'; } if (points.length === 0) return titleHtml + '
暂无价格+游玩时长数据
'; // 自适应 Y 轴上限:取游玩时长 90 分位后上浮 15% 再取整到 50 的倍数,避免上方大面积空白 var sortedPt = points.map(function(d) { return d.playtime; }).sort(function(a, b) { return a - b; }); var p90 = sortedPt.length > 0 ? sortedPt[Math.floor(sortedPt.length * 0.9)] : 50; var yMax = Math.max(50, Math.ceil((p90 * 1.15) / 50) * 50); if (yMax > 400) yMax = 400; var svgW = 380, svgH = 210, pL = 42, pR = 14, pT = 12, pB = 34; var cW = svgW - pL - pR, cH = svgH - pT - pB; var xMin = 0.5, xMax = 400; function p2x(p) { if (p <= 0) p = 0.5; return pL + (Math.log10(p) - Math.log10(xMin)) / (Math.log10(xMax) - Math.log10(xMin)) * cW; } function t2y(t) { return pT + cH - (Math.min(t, yMax) / yMax) * cH; } function r2r(r) { return 2.5 + Math.min(r / 80, 1) * 5; } var grid = ''; var yStep = yMax / 5; for (var yi = 0; yi <= 5; yi++) { var vv = Math.round(yStep * yi), yy = pT + cH - (vv / yMax) * cH; grid += ''; grid += '' + vv + 'h'; } [1, 5, 10, 50, 100, 200, 400].forEach(function(v) { var x = p2x(v); if (x < pL || x > svgW - pR) return; grid += ''; grid += '' + curSym + v + ''; }); // 踩雷警戒区(¥100+ 且 <10h) var rx = p2x(100), ry = t2y(10); var zone = ''; zone += ''; zone += ''; var dots = ''; points.forEach(function(d) { var x = p2x(d.price), y = t2y(d.playtime), r = r2r(d.recent); var c = d.ownerIdx >= 0 ? growthColors[d.ownerIdx % growthColors.length] : '#64748b'; var isRegret = d.price >= 100 && d.playtime < 10; dots += '' + faEsc(d.name) + ' | ' + curSym + d.price + ' | ' + d.playtime.toFixed(1) + 'h | 近两周 ' + d.recent.toFixed(1) + 'h'; }); var svg = '' + grid + zone + dots + ''; var desc = '
X轴:价格(对数刻度) · Y轴:总游玩时长 · 气泡大小:近两周时长 · 红框=踩雷区(' + curSym + '100+ 且 <10h)
'; return titleHtml + svg + desc; } // ---- 价格档位热力图(月份 × 价格档位矩阵,近6个月)---- function buildViHeatmap(monthlySorted, priceTiers, curSym) { var titleHtml = '
' + '' + '价格档位热力图(近 6 个月)
'; if (monthlySorted.length === 0) return titleHtml + '
暂无入库时间数据
'; var recentMonths = monthlySorted.slice(-6); var maxVal = 1; recentMonths.forEach(function(entry) { entry[1].tiers.forEach(function(v) { if (v > maxVal) maxVal = v; }); }); function hColor(v) { if (v === 0) return 'rgba(255,255,255,.02)'; var t = v / maxVal; if (t < 0.25) return 'rgba(84,160,255,' + (0.1 + t * 1.2).toFixed(2) + ')'; if (t < 0.5) return 'rgba(6,207,190,' + (0.15 + t * 0.8).toFixed(2) + ')'; if (t < 0.75) return 'rgba(255,159,67,' + (0.2 + t * 0.7).toFixed(2) + ')'; return 'rgba(255,107,107,' + (0.25 + t * 0.6).toFixed(2) + ')'; } function fmtV(v) { return v > 0 ? (v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v) : '-'; } var html = '
'; priceTiers.forEach(function(t) { html += ''; }); html += ''; recentMonths.forEach(function(entry) { var mk = entry[0], parts = mk.split('-'); html += ''; entry[1].tiers.forEach(function(v) { html += ''; }); html += ''; }); html += '
' + t.name + '
' + parts[0] + '-' + parts[1] + '' + fmtV(v) + '
'; return titleHtml + html; } // ---- 成员价值贡献(头像 + 价值/游戏/均时/折扣 + 贡献占比条)---- // v1.74:pending=true 时"均时"列显示统计占位(时长数据未就绪) function buildViMemberContribution(memberStats, members, totalOriginal, idMap, curSym, pending) { var titleHtml = '
' + '' + '成员价值贡献
'; var html = ''; members.forEach(function(m, mi) { var s = memberStats[m.steamid] || { original: 0, actual: 0, games: 0, playtime: 0, discountSum: 0, discountCount: 0 }; var c = growthColors[mi % growthColors.length]; var pct = totalOriginal > 0 ? (s.original / totalOriginal * 100).toFixed(1) : '0.0'; var avgDisc = s.discountCount > 0 ? Math.round(s.discountSum / s.discountCount) : 0; var cph = s.playtime > 0 ? (s.actual / (s.playtime / 60)).toFixed(2) : '-'; var name = m.userName || idMap[m.steamid] || ('ID:' + String(m.steamid).slice(-4)); var ch = name.charAt(0).toUpperCase(); html += '
' + '' + ch + '' + '
' + '
' + faEsc(name) + '
' + '
' + '游戏 ' + s.games + '' + '价值 ' + curSym + fmtViMoney(s.original) + '' + '均时 ' + (pending ? '…' : curSym + cph + '/h') + '' + '折扣 ' + avgDisc + '%' + '
' + '
' + '' + pct + '%' + '
'; }); return titleHtml + html; } function renderPlayActivityTab(forceRefresh) { var paWrap = panel.querySelector('[data-playactivity-content]'); if (!paWrap) return; var members = (saves && saves.familyInfo && Array.isArray(saves.familyInfo.family_member)) ? saves.familyInfo.family_member : []; if (members.length === 0) { paWrap.innerHTML = '
暂无家庭成员数据,请先扫描家庭库
'; return; } if (!access_token) { paWrap.innerHTML = '
访问令牌不可用,请在 Steam 商店页面打开此面板
'; return; } // 手动点击"刷新动态":清空缓存,走全量加载 if (forceRefresh) { paCache.data = null; paCache.updatedAt = 0; faToast.info('正在刷新游玩动态…'); } var hasCache = paCache.data && Object.keys(paCache.data).length > 0; if (hasCache) { // 先渲染缓存内容,重开面板秒开 renderPlayActivityContent(paWrap, paCache.data, members); // 缓存新鲜则直接返回,不发任何请求 if (Date.now() - paCache.updatedAt < PA_CACHE_TTL) return; // 缓存过期:后台静默刷新,完成后自动更新界面 refreshPlayActivityData(members); return; } // 无缓存:显示加载动画并全量加载 paWrap.innerHTML = createLoadingHtml('正在加载家庭成员游玩动态与总时长…'); refreshPlayActivityData(members); } // 拉取全部成员的游玩数据并写入缓存;完成后仅在面板仍打开时刷新界面 function refreshPlayActivityData(members) { if (playActivityLoading) return; playActivityLoading = true; Promise.all(members.map(function(m) { return fetchMemberRecentlyPlayed(access_token, m.steamid); })).then(function(results) { // 同时获取每个成员的完整总游玩时长(GetOwnedGames) return Promise.all(members.map(function(m) { return fetchMemberOwnedGamesTotal(access_token, m.steamid); })).then(function(totalResults) { playActivityLoading = false; var data = {}; members.forEach(function(m, i) { data[m.steamid] = { steamid: m.steamid, memberName: m.userName || (saves.familyInfo.steamIdtoName ? saves.familyInfo.steamIdtoName[m.steamid] : '') || ('ID:' + String(m.steamid).slice(-4)), result: results[i], ownedTotal: totalResults[i] ? totalResults[i].totalMinutes : -1, ownedGameCount: totalResults[i] ? totalResults[i].gameCount : 0 }; }); paCache.data = data; paCache.updatedAt = Date.now(); // 后台静默刷新场景下面板可能已被关闭,避免写入已移除的 DOM var currentPanel = document.getElementById('familyAnalysisPanel'); if (!currentPanel) return; var currentWrap = currentPanel.querySelector('[data-playactivity-content]'); if (currentWrap) renderPlayActivityContent(currentWrap, paCache.data, members); faToast.success('游玩动态已更新'); }); }).catch(function(err) { playActivityLoading = false; console.warn('[FA] 游玩动态刷新失败:', err); faToast.error('游玩动态刷新失败'); }); } function renderPlayActivityContent(paWrap, data, members) { // v1.57: 统一使用家庭库贡献数(GameInfo.owners 统计),与贡献分布/雷达图等标签页保持一致 // 不再使用 GetOwnedGames 的个人游戏总数,避免不同标签页统计逻辑不一致 var libContribution = {}; members.forEach(function(m) { libContribution[m.steamid] = 0; }); var _gameInfo = saves.familyGameList.GameInfo; for (var _aid in _gameInfo) { var _owners = _gameInfo[_aid].owners; if (_owners) { for (var _oi = 0; _oi < _owners.length; _oi++) { if (libContribution[_owners[_oi]] !== undefined) libContribution[_owners[_oi]]++; } } } var stats = members.map(function(m, i) { var d = data[m.steamid]; var games = (d && d.result && d.result.games) ? d.result.games : []; var recent = 0, recentTotal = 0; games.forEach(function(g) { recent += (g.playtime_2weeks || 0); recentTotal += (g.playtime_forever || 0); }); // 使用 GetOwnedGames 获取的真实总游玩时长(含所有拥有游戏),而非仅最近2周游玩的游戏 var trueTotal = (d && d.ownedTotal !== undefined && d.ownedTotal >= 0) ? d.ownedTotal : recentTotal; return { steamid: m.steamid, name: d ? d.memberName : (m.userName || 'ID:' + String(m.steamid).slice(-4)), recent: recent, total: trueTotal, recentTotal: recentTotal, games: games, libCount: libContribution[m.steamid] || 0, // v1.57: 家庭库贡献数 personalCount: d ? d.ownedGameCount : 0, // v1.57: 个人拥有游戏数(保留用于 tooltip) count: (d && d.result) ? d.result.total_count : games.length, color: growthColors[i % growthColors.length] }; }); var maxRecent = 1, maxTotal = 1, sumRecent = 0, sumTotal = 0; stats.forEach(function(s) { if (s.recent > maxRecent) maxRecent = s.recent; if (s.total > maxTotal) maxTotal = s.total; sumRecent += s.recent; sumTotal += s.total; }); // 找出最活跃成员 var mostActive = stats.slice().sort(function(a, b) { return b.recent - a.recent; })[0]; var avgRecent = stats.length > 0 ? sumRecent / stats.length : 0; // v1.40:展示缓存更新时间,便于感知数据新鲜度 var paUpdatedText = paCache.updatedAt ? new Date(paCache.updatedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '未知'; var html = ''; // ===== 置顶 KPI 仪表盘 ===== html += '
'; // 总游玩时长 html += '
' + '
' + faFmtHours(sumTotal) + '
' + '
家庭总游玩时长
' + '
全体成员累计
' + '
'; // 近2周游玩时长 html += '
' + '
' + faFmtHours(sumRecent) + '
' + '
近2周游玩时长
' + '
全体成员合计
' + '
'; // 人均近2周 html += '
' + '
' + faFmtHours(avgRecent) + '
' + '
人均近2周
' + '
' + members.length + ' 名成员
' + '
'; // 最活跃成员 html += '
' + '
' + (mostActive ? faEsc(mostActive.name) : '-') + '
' + '
近2周最活跃
' + '
' + (mostActive ? faFmtHours(mostActive.recent) : '') + '
' + '
'; html += '
'; // ===== 工具栏 ===== html += '
' + '
数据来源:Steam Web API · 更新于 ' + paUpdatedText + '
' + '' + '
'; // ===== 成员游玩卡片(参考参考图:双进度条 + 游戏封面网格) ===== html += '
成员游玩详情
'; var sorted = stats.slice().sort(function(a, b) { return b.recent - a.recent; }); sorted.forEach(function(s, idx) { var gamesSorted = s.games.slice().sort(function(a, b) { return (b.playtime_2weeks || 0) - (a.playtime_2weeks || 0) || (b.playtime_forever || 0) - (a.playtime_forever || 0); }); var recentPct = maxRecent > 0 ? (s.recent / maxRecent * 100) : 0; var totalPct = maxTotal > 0 ? (s.total / maxTotal * 100) : 0; var recentBarPct = s.recent > 0 ? Math.max(2, recentPct) : 0; var totalBarPct = s.total > 0 ? Math.max(2, totalPct) : 0; var initial = (s.name || '?').charAt(0).toUpperCase() || '?'; // 成员卡片头部 html += '
'; // 头部行:头像 + 名称 + 统计摘要 + 折叠箭头 html += '
' + '' + faEsc(initial) + '' + '
' + '
' + faEsc(s.name) + '
' + '
库内贡献 ' + s.libCount + ' 款 · 近2周游玩 ' + s.count + '
' + '
' + '' + '
'; // 双进度条区域(参考参考图:绿色=近2周,黄色=总计) html += '
'; // 近2周进度条 html += '
' + '近2周' + '
' + '
' + '
' + '' + faFmtHours(s.recent) + '' + '
'; // 总计进度条 html += '
' + '总计' + '
' + '
' + '
' + '' + faFmtHours(s.total) + '' + '
'; html += '
'; // 游戏封面网格(可折叠) html += '
'; if (gamesSorted.length > 0) { html += '
'; gamesSorted.forEach(function(g) { var gRecent = faFmtHours(g.playtime_2weeks || 0); var gTotal = faFmtHours(g.playtime_forever || 0); html += '
' + '' + '
' + '' + faEsc(g.name) + '' + '
' + '' + gRecent + '' + ' / ' + gTotal + '' + '
' + '
'; }); html += '
'; } else { html += '
最近 2 周暂无游玩记录
'; } html += '
'; // fa-member-body html += '
'; // fa-member-card }); paWrap.innerHTML = html; var refreshBtn = paWrap.querySelector('#btn_refresh_playactivity'); if (refreshBtn) refreshBtn.addEventListener('click', function() { renderPlayActivityTab(true); }); // 异步加载游戏中文名(参考 steam-friend-manager loadGameZhName) paWrap.querySelectorAll('a[data-fa-appid]').forEach(function(el) { var aid = el.getAttribute('data-fa-appid'); faLoadGameZhName(el, aid, el.textContent); }); // v1.55:多 CDN 封面图 fallback(参考 steam-friend-manager dashLoadCapsule) paWrap.querySelectorAll('img[data-fa-cover]').forEach(function(img) { faLoadCover(img, img.getAttribute('data-fa-cover')); }); // 按好友折叠/展开:点击成员卡片头部切换游戏列表显隐(默认全部展开) paWrap.querySelectorAll('.fa-member-header').forEach(function(hdr) { hdr.addEventListener('click', function(e) { // 点击成员名链接时不触发折叠 if (e.target.tagName === 'A') return; var card = hdr.closest('.fa-member-card'); if (!card) return; var body = card.querySelector('.fa-member-body'); var chevron = hdr.querySelector('.fa-member-chevron'); if (!body) return; var isHidden = body.style.display === 'none'; body.style.display = isHidden ? 'block' : 'none'; if (chevron) chevron.style.transform = isHidden ? '' : 'rotate(-90deg)'; }); }); } // ===================== 入库热力图渲染(v1.38 新增, v1.41 性能优化) ===================== var hmCache = { gridKeys: null, gridMeta: null }; function renderHeatmapTab() { var hmWrap = panel.querySelector('[data-heatmap-content]'); if (!hmWrap) return; var gameInfo = saves.familyGameList.GameInfo; var members = (saves && saves.familyInfo && Array.isArray(saves.familyInfo.family_member)) ? saves.familyInfo.family_member : []; // v1.41 优化:单次遍历计算所有热力图数据 var allHm = computeAllHeatmaps(gameInfo, members); var hm = allHm.family; if (!hm) { hmWrap.innerHTML = '
暂无入库时间数据,无法生成热力图
'; return; } // v1.41 优化:预计算日期网格(所有热力图共享),避免每个 cell 创建 Date 对象 if (!hmCache.gridKeys || hmCache.gridMeta.firstTs !== hm.firstTs) { var globalStart = new Date(hm.firstTs * 1000); globalStart.setHours(0, 0, 0, 0); var globalStartDay = globalStart.getDay(); var gridStart = new Date(globalStart); gridStart.setDate(gridStart.getDate() - globalStartDay); var endDate = new Date(); endDate.setHours(0, 0, 0, 0); var totalDays = Math.floor((endDate - gridStart) / 86400000) + 1; var totalWeeks = Math.ceil(totalDays / 7); // v1.57: 预计算所有 cell 的 date key、月份/年份标签位置 var gridKeys = []; var monthLabelPositions = []; var yearBoundaries = []; var lastMonthKey = -1, lastYear = -1; var _ms = gridStart.getTime(); for (var w = 0; w < totalWeeks; w++) { for (var d = 0; d < 7; d++) { var cellTs = _ms + (w * 7 + d) * 86400000; var cellDate = new Date(cellTs); var cellYear = cellDate.getFullYear(); var cellMonth = cellDate.getMonth(); var key = cellYear + '-' + String(cellMonth + 1).padStart(2, '0') + '-' + String(cellDate.getDate()).padStart(2, '0'); gridKeys.push({ key: key, w: w, d: d, ts: cellTs, month: cellMonth, year: cellYear, inRange: cellTs >= globalStart.getTime() && cellTs <= endDate.getTime() }); if (d === 0 && cellTs >= globalStart.getTime() && cellTs <= endDate.getTime()) { var mk = cellYear * 12 + cellMonth; if (mk !== lastMonthKey) { lastMonthKey = mk; monthLabelPositions.push({ w: w, month: cellMonth, year: cellYear }); } if (cellYear !== lastYear) { lastYear = cellYear; yearBoundaries.push({ w: w, year: cellYear }); } } } } hmCache.gridKeys = gridKeys; hmCache.gridMeta = { firstTs: hm.firstTs, totalWeeks: totalWeeks, monthLabelPositions: monthLabelPositions, yearBoundaries: yearBoundaries }; } var gridKeys = hmCache.gridKeys; var totalWeeks = hmCache.gridMeta.totalWeeks; var monthLabelPositions = hmCache.gridMeta.monthLabelPositions; var yearBoundaries = hmCache.gridMeta.yearBoundaries; var cellSize = 13, cellGap = 3, cellStep = cellSize + cellGap; var labelW = 28, yearH = 14, monthH = 16; var topH = yearH + monthH; var svgW = labelW + totalWeeks * cellStep + 10; var svgH = topH + 7 * cellStep + 10; var monthNames = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月']; var dayNames = ['', '一', '', '三', '', '五', '']; // v1.57: 动态生成 6 级色阶(从暗到亮),替代固定 5 级方案 function faHexLerp(c1, c2, t) { var r1 = parseInt(c1.slice(1,3),16), g1 = parseInt(c1.slice(3,5),16), b1 = parseInt(c1.slice(5,7),16); var r2 = parseInt(c2.slice(1,3),16), g2 = parseInt(c2.slice(3,5),16), b2 = parseInt(c2.slice(5,7),16); var r = Math.round(r1+(r2-r1)*t), g = Math.round(g1+(g2-g1)*t), b = Math.round(b1+(b2-b1)*t); return '#' + ('0'+r.toString(16)).slice(-2) + ('0'+g.toString(16)).slice(-2) + ('0'+b.toString(16)).slice(-2); } function makeColorScheme(baseColor) { var darkBg = '#1a2a3a'; var levels = [darkBg]; for (var i = 1; i <= 5; i++) { levels.push(faHexLerp(darkBg, baseColor, Math.pow(i/5, 0.7))); } return levels; } // v1.41 优化:使用预计算网格构建 SVG,避免逐 cell 创建 Date 对象 function buildHeatmapBlock(hmData, colors, title, ownerLabel) { if (!hmData) { return '
' + '
' + faEsc(title) + '
' + '
暂无入库时间数据
'; } // v1.57: 动态颜色分级,基于 maxDaily 自适应阈值,5个和500个不再同色 function heatColor(count) { if (count === 0) return colors[0]; var max = hmData.maxDaily; if (max <= 5) { if (count === 1) return colors[1]; if (count === 2) return colors[2]; if (count <= 3) return colors[3]; if (count <= 4) return colors[4]; return colors[5]; } var ratio = count / max; if (ratio <= 0.1) return colors[1]; if (ratio <= 0.25) return colors[2]; if (ratio <= 0.5) return colors[3]; if (ratio <= 0.75) return colors[4]; return colors[5]; } // v1.57: 生成图例阈值标签 function getThresholdLabels(max) { if (max <= 5) return ['1', '2', '3', '4', '5+']; return ['≤' + Math.ceil(max * 0.1), '≤' + Math.ceil(max * 0.25), '≤' + Math.ceil(max * 0.5), '≤' + Math.ceil(max * 0.75), '>' + Math.ceil(max * 0.75)]; } // v1.41 优化:用数组 join 替代字符串拼接,减少 GC 压力 var cellParts = []; var dayMap = hmData.dayMap; for (var i = 0; i < gridKeys.length; i++) { var gk = gridKeys[i]; if (!gk.inRange) continue; var cnt = dayMap[gk.key] || 0; var x = labelW + gk.w * cellStep, y = topH + gk.d * cellStep; // v1.41 优化:仅在有数据时才加 title(大部分 cell 为 0,省掉大量 title 解析) if (cnt > 0) { cellParts.push('' + ownerLabel + ':' + gk.key + ' · ' + cnt + ' 个游戏入库'); } else { cellParts.push(''); } } // v1.57: 年份分隔线 + 年份标签 var yearParts = []; for (var yi = 0; yi < yearBoundaries.length; yi++) { var yb = yearBoundaries[yi]; var lineX = labelW + yb.w * cellStep - cellGap / 2; yearParts.push(''); yearParts.push('' + yb.year + ''); } var monthParts = []; for (var mi = 0; mi < monthLabelPositions.length; mi++) { var ml = monthLabelPositions[mi]; monthParts.push('' + monthNames[ml.month] + ''); } var dayParts = []; for (var di = 0; di < 7; di++) { if (dayNames[di]) dayParts.push('' + dayNames[di] + ''); } var svgStr = '' + yearParts.join('') + monthParts.join('') + dayParts.join('') + cellParts.join('') + ''; // v1.57: 图例显示阈值范围,让用户一眼看出颜色对应的入库数量 var thresholds = getThresholdLabels(hmData.maxDaily); var legendSquares = ''; for (var li = 1; li <= 5; li++) { legendSquares += '' + thresholds[li-1] + ''; } var legend = '
' + '' + legendSquares + '
'; var firstDate = new Date(hmData.firstTs * 1000), lastDate = new Date(hmData.lastTs * 1000); var fmtD = function(d){ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }; var summary = '
' + '总: ' + hmData.total + '' + '峰值: ' + hmData.maxDaily + '' + '日均: ' + hmData.avgDaily.toFixed(2) + '' + '' + fmtD(firstDate) + ' ~ ' + fmtD(lastDate) + '' + '
'; return '
' + '
' + '' + faEsc(title) + '' + '共 ' + hmData.total + ' 款入库
' + '
' + svgStr + '
' + legend + summary + '
'; } // v1.41 优化:先渲染加载占位 + 家庭整体热力图,然后异步渲染各成员 var familyColors = ['#1a2a3a', '#0e4429', '#006832', '#26a641', '#39d353']; var familyBlock = buildHeatmapBlock(hm, familyColors, '家庭库入库动态热力图', '家庭库'); var idMap = (saves.familyInfo && saves.familyInfo.steamIdtoName) ? saves.familyInfo.steamIdtoName : {}; var memberCount = members.length; // 先渲染家庭整体 + 成员占位 var placeholderHtml = familyBlock; var placeholders = []; members.forEach(function(m, mi) { var memberName = m.userName || idMap[m.steamid] || ('ID:' + String(m.steamid).slice(-4)); var phId = 'hm_ph_' + mi; placeholders.push({ id: phId, idx: mi, name: memberName, steamid: m.steamid }); placeholderHtml += '
' + '
' + faEsc(memberName) + '
' + '
渲染中…
'; }); hmWrap.innerHTML = placeholderHtml; // v1.57: 热力图默认滚动到最右侧(最新数据) function scrollHeatmapsToRight() { hmWrap.querySelectorAll('.fa-hm-scroll').forEach(function(s) { s.scrollLeft = s.scrollWidth; }); } requestAnimationFrame(scrollHeatmapsToRight); // v1.41 优化:异步分块渲染各成员热力图,避免阻塞主线程 var phIdx = 0; function renderNextMember() { if (phIdx >= placeholders.length) return; var ph = placeholders[phIdx++]; var baseColor = growthColors[ph.idx % growthColors.length]; var memberColors = makeColorScheme(baseColor); var memberHm = allHm.members[ph.steamid]; var blockHtml = buildHeatmapBlock(memberHm, memberColors, ph.name, ph.name); var el = document.getElementById(ph.id); if (el) { el.outerHTML = blockHtml; // v1.57: 新渲染的成员热力图也滚动到最右侧 requestAnimationFrame(scrollHeatmapsToRight); } // 用 requestAnimationFrame 让浏览器有机会重绘,避免连续 DOM 操作卡顿 if (phIdx < placeholders.length) { requestAnimationFrame(renderNextMember); } } if (placeholders.length > 0) { requestAnimationFrame(renderNextMember); } } // ===================== 成员洞察渲染(v1.38 新增) ===================== function renderMemberInsightsTab() { var insWrap = panel.querySelector('[data-insights-content]'); if (!insWrap) return; var members = (saves && saves.familyInfo && Array.isArray(saves.familyInfo.family_member)) ? saves.familyInfo.family_member : []; if (members.length === 0) { insWrap.innerHTML = '
暂无家庭成员数据,请先扫描家庭库
'; return; } var gameInfo = saves.familyGameList.GameInfo; var idMap = saves.familyInfo.steamIdtoName || {}; var nameOf = function(m){ return m.userName || idMap[m.steamid] || ('ID:' + String(m.steamid).slice(-4)); }; var html = ''; // ===== 成员活跃度 + 家庭组健康分(v1.58:左右分栏,右侧炫彩能量环)===== var activity = computeMemberActivity(saves.familyInfo, gameInfo); var actColors = { active: '#10b981', warm: '#3b82f6', cold: '#f59e0b', dormant: '#ef4444', never: '#6b7280' }; var healthPct = activity.healthScore; var healthColor = healthPct >= 70 ? '#10b981' : (healthPct >= 40 ? '#f59e0b' : '#ef4444'); // v1.58:能量环颜色——淡紫色(#a78bfa) → 亮绿色(#22c55e),分数越高越绿越健康 // 插值:0分=淡紫,100分=亮绿,中间渐变 var ringHue = 250 - (healthPct / 100) * 143; // 250(紫) → 107(绿) var ringSat = 55 + (healthPct / 100) * 30; // 55% → 85% var ringLight = 45 + (healthPct / 100) * 15; // 45% → 60% var ringColor = 'hsl(' + ringHue.toFixed(0) + ',' + ringSat.toFixed(0) + '%,' + ringLight.toFixed(0) + '%)'; // v1.58:能量环 SVG(圆弧按分数填充,发光效果) var ringSize = 130, ringStroke = 10, ringR = (ringSize - ringStroke) / 2; var ringCx = ringSize / 2, ringCy = ringSize / 2; var ringCircum = 2 * Math.PI * ringR; var ringDashOffset = ringCircum * (1 - healthPct / 100); var ringSvg = '' + '' + '' + '' + '' + '' + '' + '' + '' // 底环 + '' // 能量弧(旋转 -90° 从顶部开始) + '' // 中心分数 + '' + healthPct + '' + '健康分' + ''; html += '
' + '
' + '
成员活跃度 & 家庭组健康分
' // 左右两栏:左侧活跃度详情,右侧能量环 + '
' // 左栏:活跃度 + '
'; var statusCounts = { active: activity.activeCount, warm: activity.warmCount, cold: activity.coldCount, dormant: activity.dormantCount }; var statusBadges = ['active','warm','cold','dormant'].map(function(s){ return '' + activity.statusLabels[s] + ' ' + statusCounts[s] + ''; }).join(''); html += '
' + statusBadges + '
'; activity.members.forEach(function(m, i){ var daysText = m.daysSinceLatest >= 0 ? (m.daysSinceLatest + ' 天前') : '从未'; var avatarHtml = faNameAvatarHtml(m.name, 16, 'margin-right:6px;'); html += '
' + avatarHtml + '' + faEsc(m.name) + '' + '' + m.statusLabel + '' + '
' + '库内: ' + m.total + '' + '月均: ' + m.monthlyAvg + '' + '最近: ' + daysText + '
'; }); html += '
' // 右栏:能量环(v1.58:宽度与底部加入时间容器对齐,均 240px) + '
' + ringSvg + '
分数越高越健康
紫→绿渐变
' + '
' + '
' + '
'; // ===== 共同游戏矩阵 ===== var n = members.length; if (n > 1) { var overlap = computeMemberOverlapMatrix(gameInfo, members, idMap); html += '
' + '
共同游戏矩阵
' + '
成员之间共同拥有的游戏数量
'; var maxShared = 1; overlap.pairs.forEach(function(p){ if (p.sharedGames > maxShared) maxShared = p.sharedGames; }); var gridHtml = '
'; gridHtml += '
'; members.forEach(function(m){ var nm = nameOf(m); gridHtml += '
' + faEsc(nm.slice(0, 4)) + '
'; }); for (var i = 0; i < n; i++) { var nmi = nameOf(members[i]); gridHtml += '
' + faEsc(nmi.slice(0, 6)) + '
'; for (var j = 0; j < n; j++) { if (i === j) { gridHtml += '
-
'; } else { var val = overlap.matrix[i][j]; var intensity = val / maxShared; var bg = val === 0 ? 'rgba(255,255,255,0.02)' : 'rgba(84,160,255,' + (0.15 + intensity * 0.5).toFixed(2) + ')'; gridHtml += '
' + val + '
'; } } } gridHtml += '
'; html += gridHtml; if (overlap.pairs.length > 0) { html += '
共同游戏最多的组合:
'; overlap.pairs.slice(0, 3).forEach(function(p){ html += '
' + '' + faEsc(p.aName) + ' ↔ ' + faEsc(p.bName) + '' + '' + p.sharedGames + ' 个共同游戏
'; }); } html += '
'; } // ===== 成员多维度雷达 + 加入时间维度对比(v1.58:左右分栏)===== var radar = computeMemberRadar(gameInfo, members, idMap); // v1.58:左右两栏容器 html += '
'; // 左栏:雷达图 html += '
'; if (radar.length > 0) { var axes = ['总游戏', '独占', '共享', '月均', '共享率', '库龄']; var cx = 150, cy = 140, R = 95, levels = 4; var radarSvg = ''; for (var l = 1; l <= levels; l++) { var r = R * l / levels; var gpts = []; for (var a = 0; a < axes.length; a++) { var gang = -Math.PI / 2 + a * 2 * Math.PI / axes.length; gpts.push((cx + r * Math.cos(gang)).toFixed(1) + ',' + (cy + r * Math.sin(gang)).toFixed(1)); } radarSvg += ''; } for (var a = 0; a < axes.length; a++) { var aang = -Math.PI / 2 + a * 2 * Math.PI / axes.length; radarSvg += ''; radarSvg += '' + axes[a] + ''; } radar.forEach(function(m, mi){ var c = growthColors[mi % growthColors.length]; var vals = [m.totalGames, m.soloCount, m.sharedCount, m.monthlyAvg, m.shareRate, m.libraryAge]; var mpts = []; for (var a = 0; a < axes.length; a++) { var mang = -Math.PI / 2 + a * 2 * Math.PI / axes.length; var mr = R * vals[a]; mpts.push((cx + mr * Math.cos(mang)).toFixed(1) + ',' + (cy + mr * Math.sin(mang)).toFixed(1)); } radarSvg += ''; }); var radarLegend = radar.map(function(m, mi){ var c = growthColors[mi % growthColors.length]; return '' + faEsc(m.name) + ''; }).join(''); html += '
成员多维度雷达对比
' + '
各维度按最大值归一化(0-1)
' + '' + radarSvg + '' + '
' + radarLegend + '
'; } else { html += '
暂无雷达数据
'; } html += '
'; // 右栏:加入时间 + 加入天数对比(v1.58:宽度与顶部能量环对齐,均 240px) html += '
' + '
加入时间
' + '
成员加入家庭组的时间与天数对比
'; // v1.58:按加入天数降序排列(早加入的排前面) var sortedMembers = members.slice().sort(function(a, b) { var ta = a.time_joined || 0, tb = b.time_joined || 0; return ta - tb; }); sortedMembers.forEach(function(m){ var nm = nameOf(m); var origIdx = members.indexOf(m); var c = growthColors[origIdx % growthColors.length]; var joinTime = m.time_joined || 0; // v1.58:修复字段名——API 返回 cooldown_seconds_remaining,非 cooldown_remaining var cooldown = m.cooldown_seconds_remaining || m.cooldown_remaining || 0; var joinText = joinTime > 0 ? new Date(joinTime * 1000).toLocaleDateString('zh-CN') : '未知'; // v1.58:加入天数对比 var joinDays = joinTime > 0 ? Math.floor((Date.now() - joinTime * 1000) / 86400000) : 0; var cooldownText = cooldown > 0 ? '冷却: ' + Math.ceil(cooldown / 86400) + ' 天' : '无冷却'; html += '
' + faNameAvatarHtml(nm, 28, 'border:2px solid ' + c + ';') + '
' + '
' + faEsc(nm) + '
' + '
' + '' + joinText + '' + '' + joinDays + ' 天' + '
' + '
' + cooldownText + '
' + '
'; }); html += '
'; html += '
'; // 关闭左右分栏容器 insWrap.innerHTML = html; } // ===================== 家庭愿望单渲染(v1.41 新增) ===================== function renderWishlistTab() { // v1.43:渲染前确保当前用户主货币已识别(函数幂等,可重复调用) faDetectUserCurrency(); var wlWrap = panel.querySelector('[data-wishlist-content]'); if (!wlWrap) return; var members = (saves && saves.familyInfo && Array.isArray(saves.familyInfo.family_member)) ? saves.familyInfo.family_member : []; if (members.length === 0) { wlWrap.innerHTML = '
暂无家庭成员数据,请先扫描家庭库
'; return; } var hasCache = wlCache.data && wlCache.data.GameList && wlCache.data.GameList.length > 0; if (hasCache) { renderWishlistContent(wlWrap, wlCache.data); if (Date.now() - wlCache.updatedAt < WL_CACHE_TTL) { // v1.42:缓存新鲜但仍可能存在占位名称/缺失价格,继续后台静默补全 backgroundEnrichWishlist(wlCache.data); return; } refreshWishlistData(members); return; } wlWrap.innerHTML = '
' + '
' + '
' + faNameAvatarHtml('?', 54, 'width:54px;height:54px;') + '
' + '
' + '
准备加载…
' + '
正在加载家庭成员愿望单…
' + '
'; refreshWishlistData(members); } function refreshWishlistData(members) { if (wlState.loading) return; wlState.loading = true; var merged = {}; var memberIdx = 0; // v1.44:成员抓取由逐个串行改为 2 并发,多名成员时等待时间约减半 var FETCH_CONCURRENCY = 2; var running = 0; var completed = 0; function updateLoadingText(text) { var currentPanel = document.getElementById('familyAnalysisPanel'); if (!currentPanel) return; var el = currentPanel.querySelector('[data-wishlist-loading]'); if (el) el.textContent = text; } // v1.64:更新加载进度——展示正在加载的成员头像(旋转光圈特效)与昵称 function updateLoadingMember(member, completed, total) { var currentPanel = document.getElementById('familyAnalysisPanel'); if (!currentPanel) return; var ringEl = currentPanel.querySelector('[data-wishlist-loading-ring]'); var nameEl = currentPanel.querySelector('[data-wishlist-loading-name]'); var textEl = currentPanel.querySelector('[data-wishlist-loading]'); var name = member.userName || (saves.familyInfo.steamIdtoName ? saves.familyInfo.steamIdtoName[member.steamid] : '') || ('ID:' + String(member.steamid).slice(-4)); var avatarUrl = member.avatar || (saves.familyInfo.steamIdtoAvatar ? saves.familyInfo.steamIdtoAvatar[member.steamid] : ''); // 更新头像:优先使用真实头像 URL,无则回退首字头像 if (ringEl) { var inner = ringEl.querySelector('.fa-wl-loading-ring-inner'); if (inner) { if (avatarUrl) { inner.innerHTML = ''; } else { inner.innerHTML = faNameAvatarHtml(name, 54, 'width:54px;height:54px;'); } } } if (nameEl) nameEl.textContent = name; if (textEl) { textEl.innerHTML = '正在获取 ' + faEsc(name) + ' 的愿望单 (' + completed + '/' + total + ')' + ''; } } function mergeEntries(m, entries) { entries.forEach(function(e) { if (!merged[e.appid]) { merged[e.appid] = { appid: e.appid, name: e.name, wishers: [], finalPrice: e.finalPrice || 0, originalPrice: e.originalPrice || 0, discountPct: e.discountPct || 0, onSale: !!e.onSale, isFree: e.isFree || false, isComingSoon: !!e.isComingSoon, releaseDate: e.releaseDate || '', _releaseKnown: !!e._releaseKnown, type: e.type || '', tags: e.tags || [], addedDate: e.addedDate || 0, priority: e.priority || 0, inLibrary: false, _metaDone: !!e._metaDone }; } merged[e.appid].wishers.push(m.steamid); }); } function launchNext() { while (running < FETCH_CONCURRENCY && memberIdx < members.length) { (function(m) { running++; // v1.64:展示正在加载的成员头像与昵称(旋转光圈特效) updateLoadingMember(m, completed, members.length); fetchMemberWishlist(m.steamid).then(function(entries) { mergeEntries(m, entries); running--; completed++; // 更新进度计数(未启动新成员时显示已完成数) if (completed < members.length && running > 0 && memberIdx >= members.length) { updateLoadingText('已完成 ' + completed + '/' + members.length + ' 位成员,继续加载中…'); } if (completed >= members.length) { finishLoading(); return; } launchNext(); }); })(members[memberIdx++]); } } function finishLoading() { // v1.64:所有成员愿望单加载完成,显示整理数据提示 var pp = document.getElementById('familyAnalysisPanel'); if (pp) { var nameEl = pp.querySelector('[data-wishlist-loading-name]'); var textEl = pp.querySelector('[data-wishlist-loading]'); if (nameEl) nameEl.textContent = '愿望单加载完成'; if (textEl) textEl.innerHTML = '正在整理 ' + members.length + ' 位成员的愿望单数据…'; } var GameList = Object.keys(merged).map(Number); var libSet = {}; if (saves.familyGameList && saves.familyGameList.GameList) { saves.familyGameList.GameList.forEach(function(a) { libSet[a] = true; }); } GameList.forEach(function(a) { merged[a].inLibrary = !!libSet[a]; }); // v1.44:先用跨会话元数据缓存补齐缺失条目;数据完整的条目回写缓存保温 var metaChanged = false; GameList.forEach(function(a) { var gi = merged[a]; // v1.60 修复(参考 1.0.8 refreshDynamicFields):元数据缓存命中仅证明字段齐备, // 价格/折扣/发售状态可能已过期(TTL 最长24h),标记 _fromMetaCache // 供后台 GetItems 批量刷新校正,修复"缓存期内折扣变化不触发重取"问题 if (!gi._metaDone && faWlMetaApply(a, gi)) { gi._fromMetaCache = true; return; } if (gi._metaDone && gi.name && gi.name !== ('App ' + a) && faWlMetaUpdate(a, gi)) metaChanged = true; }); if (metaChanged) faWlMetaSave(); // 排序:多人想要优先 → 家庭库已有 → 折扣 → 名称 GameList.sort(function(a, b) { var wDiff = merged[b].wishers.length - merged[a].wishers.length; if (wDiff !== 0) return wDiff; if (merged[a].inLibrary !== merged[b].inLibrary) return merged[a].inLibrary ? 1 : -1; var dDiff = (merged[b].discountPct || 0) - (merged[a].discountPct || 0); if (dDiff !== 0) return dDiff; return (merged[a].name || '').localeCompare(merged[b].name || '', 'zh-CN'); }); var wlData = { GameList: GameList, GameInfo: merged }; wlCache.data = wlData; wlCache.updatedAt = Date.now(); wlState.loading = false; var p = document.getElementById('familyAnalysisPanel'); if (!p) return; var w = p.querySelector('[data-wishlist-content]'); if (w) renderWishlistContent(w, wlData); // 后台静默补全缺失的价格/标签 backgroundEnrichWishlist(wlData); } launchNext(); } function backgroundEnrichWishlist(wlData) { if (wlState.enriching) return; // v1.60:补全判定对齐参考脚本 1.0.8/1.0.9——名称占位、发售状态未知(!_releaseKnown)、 // 价格缺失(免费/确认未发售除外)、标签缺失的条目均需补全 var needEnrich = wlData.GameList.filter(function(a) { var gi = wlData.GameInfo[a]; if (!gi || gi._metaDone) return false; // 名称仍为占位符 → 必须补全(免费游戏也需要补全名称) if (!gi.name || gi.name === ('App ' + a)) return true; // 发售状态未知 → 需补全(否则"即将推出"统计不准确) if (gi._releaseKnown !== true) return true; if (gi.isFree) return false; // 已发售非免费但缺价格 → 需补全(未发售无价格为合法状态,不再重复补全) if (!gi.isComingSoon && (!gi.finalPrice || gi.finalPrice === 0)) return true; // 缺少标签 → 需补全 if (!gi.tags || gi.tags.length === 0) return true; return false; }); // v1.60 修复(参考 1.0.8 refreshDynamicFields):元数据缓存命中且字段完备的条目, // 价格/折扣/发售状态可能陈旧(TTL 最长24h),一并纳入 GetItems 批量动态刷新(权威覆盖), // 但不进 appdetails 兜底阶段 var needSet = {}; needEnrich.forEach(function(a) { needSet[a] = true; }); var dynamicOnly = wlData.GameList.filter(function(a) { var gi = wlData.GameInfo[a]; return gi && gi._fromMetaCache && !needSet[a]; }); var browseQueue = needEnrich.concat(dynamicOnly); if (browseQueue.length === 0) return; wlState.enriching = true; // v1.44:批次刷新节流(最多 1 次/秒),避免批次连续全量重绘造成卡顿/闪烁 var lastListRender = 0; var listRenderTimer = null; var metaDirty = false; function scheduleListRender() { if (window[activeTabKey] !== 'wishlist') return; var p = document.getElementById('familyAnalysisPanel'); if (!p || !p.querySelector('#wl_game_list')) return; var now = Date.now(); var doRender = function() { listRenderTimer = null; lastListRender = Date.now(); var pp = document.getElementById('familyAnalysisPanel'); if (pp && pp.querySelector('#wl_game_list') && window[activeTabKey] === 'wishlist') { renderWishlistGamesList(wlData); } }; if (now - lastListRender >= 1000) doRender(); else if (!listRenderTimer) listRenderTimer = setTimeout(doRender, 1000 - (now - lastListRender)); } function updateProgressText(done, total) { var p = document.getElementById('familyAnalysisPanel'); if (!p) return; var progEl = p.querySelector('[data-wl-enrich-progress]'); // v1.52:补全完成后立即清空进度文本(原为残留至面板重建) if (progEl) progEl.textContent = (done >= total) ? '' : '补全名称价格中… ' + done + '/' + total; } // v1.60:GetItems 阶段应用——价格/折扣/发售状态为权威覆盖(可纠正陈旧缓存与已结束的折扣), // 名称在中文界面下始终覆盖(与 v1.54 appdetails 中文名策略一致) function applyBrowseResult(aid, r) { var gi = wlData.GameInfo[aid]; if (!gi) return; if (r.name) gi.name = r.name; if (r.type) gi.type = r.type; if (typeof r.isFree !== 'undefined') gi.isFree = !!r.isFree; if (r._priceKnown) { gi.finalPrice = r.finalPrice; gi.originalPrice = r.originalPrice; gi.discountPct = r.discountPct; gi.onSale = !!r.onSale; } if (r._releaseKnown) { gi.isComingSoon = !!r.isComingSoon; gi._releaseKnown = true; if (r.releaseDate) gi.releaseDate = r.releaseDate; } gi._fromMetaCache = false; faWlMetaJudgeDone(gi); if (r.name && faWlMetaUpdate(aid, gi)) metaDirty = true; } // v1.60:appdetails 阶段应用——沿用原"有值才覆盖"语义,新增发售状态权威写入 function applyAppDetailsResult(aid, r) { var gi = wlData.GameInfo[aid]; if (!gi) return; // v1.54 修复:appdetails 使用 l=schinese 请求,返回的为中文名, // 应始终覆盖 SSR/API 返回的英文名,而非仅覆盖占位符。 if (r.name) gi.name = r.name; if (r.finalPrice) gi.finalPrice = r.finalPrice; if (r.originalPrice) gi.originalPrice = r.originalPrice; if (r.discountPct) gi.discountPct = r.discountPct; if (r.onSale) gi.onSale = true; if (r.isFree) gi.isFree = true; if (r.type) gi.type = r.type; if (r.tags && r.tags.length > 0) gi.tags = r.tags; // v1.60(参考 1.0.8):release_date.coming_soon 为权威判定,可回退 true→false if (r._releaseKnown) { gi.isComingSoon = !!r.isComingSoon; gi._releaseKnown = true; if (r.releaseDate) gi.releaseDate = r.releaseDate; } faWlMetaJudgeDone(gi); // v1.44:补全成功的条目写入跨会话元数据缓存 if (r.name && faWlMetaUpdate(aid, gi)) metaDirty = true; } function finishEnrich() { wlState.enriching = false; // v1.44:补全完成后一次性落盘元数据缓存 if (metaDirty) faWlMetaSave(); updateProgressText(1, 1); // 全部完成:重建整个愿望单面板(刷新KPI和图表) var p = document.getElementById('familyAnalysisPanel'); if (!p) return; var w = p.querySelector('[data-wishlist-content]'); if (w && window[activeTabKey] === 'wishlist') renderWishlistContent(w, wlData); } // v1.60 三段式(参考 1.0.9 enrichItemsFromAPI): // ① GetItems 批量补全(100个/批、3路并发,含元数据缓存条目的动态刷新)→ // ② appdetails 仅兜底 GetItems 后仍不完备的少量残余(下架/锁区/缺标签等) faWlEnrichFromStoreBrowse(browseQueue, function(processed, total) { updateProgressText(processed, total); }, function(batchNew) { for (var aid in batchNew) applyBrowseResult(aid, batchNew[aid]); scheduleListRender(); } ).then(function() { var leftovers = needEnrich.filter(function(a) { var gi = wlData.GameInfo[a]; if (!gi) return false; if (!gi.name || gi.name === ('App ' + a)) return true; if (gi._releaseKnown !== true) return true; if (!gi.isFree && !gi.isComingSoon && !(Number(gi.finalPrice) > 0)) return true; if (!gi.tags || gi.tags.length === 0) return true; return false; }); if (leftovers.length === 0) { finishEnrich(); return; } enrichWishlistMeta(leftovers, function(completed, total) { updateProgressText(completed, total); }, function(batchNew) { for (var aid in batchNew) applyAppDetailsResult(aid, batchNew[aid]); scheduleListRender(); } ).then(finishEnrich); }); } function computeWishlistStats(wlData) { var stats = { total: 0, inLibrary: 0, multiWant: 0, discounted: 0, comingSoon: 0, totalValue: 0, avgPrice: 0, maxDiscount: 0, memberDist: [], tagDist: [], priceBuckets: [], typeDist: [] }; if (!wlData || !wlData.GameList) return stats; var members = saves.familyInfo.family_member || []; var idMap = saves.familyInfo.steamIdtoName || {}; var tagMap = {}; var typeMap = {}; var memberCounts = {}; members.forEach(function(m) { memberCounts[m.steamid] = 0; }); var paidItems = []; stats.total = wlData.GameList.length; wlData.GameList.forEach(function(a) { var gi = wlData.GameInfo[a]; if (!gi) return; if (gi.inLibrary) stats.inLibrary++; if (gi.wishers && gi.wishers.length > 1) stats.multiWant++; // v1.60 修复(参考 1.0.8):onSale 计入打折(active_discounts 无 pct 的促销此前被漏统) if (gi.discountPct > 0 || gi.onSale) { stats.discounted++; if (gi.discountPct > stats.maxDiscount) stats.maxDiscount = gi.discountPct; } // v1.60:即将推出(未发售)统计 if (gi.isComingSoon) stats.comingSoon++; var p = Number(gi.finalPrice) || 0; if (!gi.isFree && p > 0) { stats.totalValue += p; paidItems.push(p); } if (gi.wishers) gi.wishers.forEach(function(sid) { if (memberCounts[sid] !== undefined) memberCounts[sid]++; }); (gi.tags || []).forEach(function(t) { tagMap[t] = (tagMap[t] || 0) + 1; }); var tk = wlGetTypeKey(gi.type); typeMap[tk] = (typeMap[tk] || 0) + 1; }); stats.avgPrice = paidItems.length > 0 ? stats.totalValue / paidItems.length : 0; stats.memberDist = members.map(function(m) { return { name: m.userName || idMap[m.steamid] || ('ID:' + String(m.steamid).slice(-4)), count: memberCounts[m.steamid] || 0 }; }).sort(function(a, b) { return b.count - a.count; }); stats.tagDist = Object.entries(tagMap).map(function(e) { return { name: e[0], count: e[1] }; }).sort(function(a, b) { return b.count - a.count; }).slice(0, 15); stats.typeDist = Object.entries(typeMap).map(function(e) { return { key: e[0], count: e[1] }; }).sort(function(a, b) { return b.count - a.count; }); // 价格区间分布(v1.58 修复:区分"免费"与"价格未知"—— // isFree=真免费;!isFree 且 price=0 是价格未获取到,二者不能混为一谈) // v1.60:再区分"未发售"(确认即将推出的无价格游戏不再计入"未知") var buckets = [ { label: '免费', count: 0, color: '#6366f1' }, { label: '<50', count: 0, color: '#10b981' }, { label: '50-100', count: 0, color: '#22c55e' }, { label: '100-200', count: 0, color: '#84cc16' }, { label: '200-500', count: 0, color: '#f59e0b' }, { label: '≥500', count: 0, color: '#ef4444' }, { label: '未发售', count: 0, color: '#38bdf8' }, { label: '未知', count: 0, color: '#64748b' } ]; wlData.GameList.forEach(function(a) { var gi = wlData.GameInfo[a]; if (!gi) return; if (gi.isFree) { buckets[0].count++; return; } var p = Number(gi.finalPrice) || 0; if (p === 0) { if (gi.isComingSoon) buckets[6].count++; else buckets[7].count++; return; } // 无价格:未发售 vs 未获取到 if (p < 50) buckets[1].count++; else if (p < 100) buckets[2].count++; else if (p < 200) buckets[3].count++; else if (p < 500) buckets[4].count++; else buckets[5].count++; }); stats.priceBuckets = buckets; return stats; } function wlGetTypeKey(type) { if (!type) return 'other'; var t = String(type).toLowerCase(); if (t === 'game' || t === '0') return 'game'; if (t === 'dlc' || t === '1') return 'dlc'; if (t === 'software' || t === '2') return 'software'; return 'other'; } function wlFmtPrice(v) { var n = Number(v) || 0; if (n >= 10000) return (n / 1000).toFixed(1) + 'k'; return n.toFixed(2); } function renderWishlistContent(wlWrap, wlData) { var stats = computeWishlistStats(wlData); var members = saves.familyInfo.family_member || []; var idMap = saves.familyInfo.steamIdtoName || {}; var wlUpdatedText = wlCache.updatedAt ? new Date(wlCache.updatedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '未知'; var html = ''; // KPI 行(v1.60:新增"即将推出"卡片,6 列布局) html += '
'; html += buildKpiCard('愿望单总数', stats.total, '#06cfbe', '去重后游戏数'); html += buildKpiCard('家庭已有', stats.inLibrary, '#f59e0b', stats.total > 0 ? (stats.inLibrary / stats.total * 100).toFixed(0) + '% 命中' : ''); html += buildKpiCard('多人想要', stats.multiWant, '#54a0ff', '≥2人共同想要'); html += buildKpiCard('折扣中', stats.discounted, '#2ed573', '最大折扣 ' + stats.maxDiscount + '%'); html += buildKpiCard('即将推出', stats.comingSoon, '#38bdf8', '未发售游戏'); html += buildKpiCard('总价值', faCurrency.symbol + wlFmtPrice(stats.totalValue), '#ff6b6b', '均价 ' + faCurrency.symbol + wlFmtPrice(stats.avgPrice)); html += '
'; // 工具栏 html += '
'; html += '更新于 ' + wlUpdatedText + ''; html += ''; html += ''; html += '
'; // 筛选按钮(v1.60:新增"即将推出"筛选项) var filters = [['all','全部'],['inLibrary','家庭已有'],['multiWant','多人想要'],['discount','折扣中'],['soon','即将推出']]; filters.forEach(function(f) { var active = wlState.filter === f[0]; html += ''; }); html += '
'; // v1.58:多人想要二级筛选所需——统计各愿望人数(2~6)对应的游戏数 var isMultiWant = wlState.filter === 'multiWant'; var wisherCounts = {}; if (isMultiWant) { wlData.GameList.forEach(function(a) { var gi = wlData.GameInfo[a]; if (!gi || !gi.wishers) return; var n = gi.wishers.length; if (n >= 2 && n <= 6) wisherCounts[n] = (wisherCounts[n] || 0) + 1; }); } // 主体两栏 html += '
'; // 左侧仪表盘 — 宽度对齐第二个KPI卡片右边框(2*cardWidth + 1*gap ≈ 274px) html += '
'; // 成员愿望单分布(v1.58:名称前增加首字头像;v1.66:改用 buildBarChart({avatar:true})) html += buildBarChart('成员愿望单分布', stats.memberDist.map(function(m) { return { name: m.name, value: m.count }; }), '#06cfbe', { avatar: true }); // 热门标签 TOP 10 var tagItems = stats.tagDist.slice(0, 10).map(function(t) { return { name: t.name, value: t.count }; }); if (tagItems.length > 0) html += buildBarChart('热门标签 TOP 10', tagItems, '#54a0ff'); // 价格区间分布(环形图) html += buildDonutChart('价格区间分布', stats.priceBuckets); // v1.58:移除类型分布横向图(基本都是游戏,意义不大) html += '
'; // 右侧游戏列表 html += '
'; // v1.58:搜索框 + 二级筛选标签 + 视图切换同一行 // 多人想要时搜索框缩短给二级标签让位;切换到其他筛选项时二级标签隐藏、搜索框恢复默认长度 var searchFlex = isMultiWant ? '0 0 150px' : '1 1 auto'; html += '
'; html += ''; // 二级筛选标签(仅多人想要时显示) if (isMultiWant) { var subTags = [['0','全部'],['2','2人'],['3','3人'],['4','4人'],['5','5人'],['6','6人']]; var subTagsHtml = subTags.map(function(t) { var val = parseInt(t[0]); var enabled = val === 0 ? stats.multiWant > 0 : (wisherCounts[val] || 0) > 0; var active = wlState.subFilter === val; var cls = 'fa-wl-subtag' + (active ? ' active' : '') + (enabled ? '' : ' disabled'); return ''; }).join(''); html += '
' + subTagsHtml + '
'; } // 视图切换(列表 / 封面) var isList = wlState.viewMode !== 'cover'; html += '
' + '' + '' + '
'; html += '
'; // 游戏列表容器 html += '
'; html += '
'; html += '
'; wlWrap.innerHTML = html; // 绑定事件 var refreshBtn = wlWrap.querySelector('#btn_refresh_wishlist'); if (refreshBtn) refreshBtn.addEventListener('click', function() { wlCache.data = null; wlCache.updatedAt = 0; faToast.info('正在刷新家庭愿望单…'); renderWishlistTab(); }); var searchInput = wlWrap.querySelector('#wl_search'); if (searchInput) { var searchTimer = null; searchInput.addEventListener('input', function(e) { clearTimeout(searchTimer); var val = e.target.value; searchTimer = setTimeout(function() { wlState.search = val; wlState.page = 1; renderWishlistGamesList(wlData); }, 200); }); } wlWrap.querySelectorAll('[data-wl-filter]').forEach(function(btn) { btn.addEventListener('click', function() { wlState.filter = btn.getAttribute('data-wl-filter'); wlState.page = 1; wlState.subFilter = 0; // 切换主筛选项时重置二级筛选 renderWishlistContent(wlWrap, wlData); }); }); // v1.58:二级筛选标签——仅刷新游戏列表(轻量,不重建仪表盘) wlWrap.querySelectorAll('.fa-wl-subtag').forEach(function(btn) { if (btn.disabled) return; btn.addEventListener('click', function() { wlState.subFilter = parseInt(btn.getAttribute('data-wl-sub')) || 0; wlState.page = 1; wlWrap.querySelectorAll('.fa-wl-subtag').forEach(function(t) { t.classList.toggle('active', parseInt(t.getAttribute('data-wl-sub')) === wlState.subFilter); }); renderWishlistGamesList(wlData); }); }); // v1.58:视图切换——仅刷新游戏列表 wlWrap.querySelectorAll('[data-wl-view]').forEach(function(btn) { btn.addEventListener('click', function() { var mode = btn.getAttribute('data-wl-view'); if (mode === wlState.viewMode) return; wlState.viewMode = mode; GM_setValue('faWlViewMode', mode); wlState.page = 1; wlWrap.querySelectorAll('[data-wl-view]').forEach(function(b) { b.classList.toggle('active', b.getAttribute('data-wl-view') === mode); }); renderWishlistGamesList(wlData); }); }); renderWishlistGamesList(wlData); } function buildKpiCard(label, value, color, sub) { return '
' + '
' + value + '
' + '
' + label + '
' + (sub ? '
' + sub + '
' : '') + '
'; } // v1.66:合并 buildBarChart 与 buildMemberBarChart,通过 opts.avatar 控制头像显示 function buildBarChart(title, items, color, opts) { opts = opts || {}; var showAvatar = opts.avatar || false; if (!items || items.length === 0) return ''; var max = 1; items.forEach(function(i) { if (i.value > max) max = i.value; }); var gap = showAvatar ? '3px' : '6px'; var nameW = showAvatar ? '64px' : '76px'; var minW = showAvatar ? '30px' : '40px'; var rows = ''; items.forEach(function(i) { var pct = max > 0 ? (i.value / max * 100) : 0; var barPct = i.value > 0 ? Math.max(3, pct) : 0; var avatarHtml = showAvatar ? faNameAvatarHtml(i.name, 18, 'margin-right:5px;') : ''; rows += '
' + avatarHtml + '' + faEsc(i.name) + '' + '
' + '
' + '' + i.value + '' + '
'; }); return '
' + '
' + title + '
' + rows + '
'; } // v1.58:成员愿望单分布专用横向柱状图——名称前增加首字头像(唯一背景色区分用户) // v1.66:薄封装,委托给 buildBarChart({avatar:true}),保留以兼容旧调用 function buildMemberBarChart(title, items, color) { return buildBarChart(title, items, color, { avatar: true }); } function buildDonutChart(title, buckets) { if (!buckets || buckets.length === 0) return ''; var total = 0; buckets.forEach(function(b) { total += b.count; }); if (total === 0) return ''; var size = 100, cx = size / 2, cy = size / 2; var outerR = 42, innerR = 26; var startAngle = -Math.PI / 2; var activeBuckets = buckets.filter(function(b) { return b.count > 0; }); var isSingle = activeBuckets.length === 1; var arcs = ''; var legend = ''; buckets.forEach(function(b) { if (b.count === 0) { legend += '
' + '' + '' + b.label + '' + '0' + '
'; return; } var pct = b.count / total; if (isSingle) { arcs += '' + ''; } else { var endAngle = startAngle + pct * 2 * Math.PI; var x1 = cx + outerR * Math.cos(startAngle); var y1 = cy + outerR * Math.sin(startAngle); var x2 = cx + outerR * Math.cos(endAngle); var y2 = cy + outerR * Math.sin(endAngle); var x3 = cx + innerR * Math.cos(endAngle); var y3 = cy + innerR * Math.sin(endAngle); var x4 = cx + innerR * Math.cos(startAngle); var y4 = cy + innerR * Math.sin(startAngle); var largeArc = pct > 0.5 ? 1 : 0; arcs += ''; startAngle = endAngle; } legend += '
' + '' + '' + b.label + '' + '' + b.count + '' + '
'; }); return '
' + '
' + title + '
' + '
' + '
' + '' + arcs + '' + total + '' + '总数' + '' + '
' + '
' + legend + '
' + '
'; } function getFilteredWishlist(wlData) { if (!wlData || !wlData.GameList || !wlData.GameInfo) return []; var q = wlState.search.trim().toLowerCase(); var libSet = {}; if (saves.familyGameList && saves.familyGameList.GameList) { saves.familyGameList.GameList.forEach(function(a) { libSet[a] = true; }); } return wlData.GameList.filter(function(a) { var gi = wlData.GameInfo[a]; if (!gi) return false; if (q && !String(gi.name || '').toLowerCase().includes(q) && !String(a).includes(q)) return false; if (wlState.filter === 'inLibrary' && !gi.inLibrary) return false; if (wlState.filter === 'multiWant') { var wc = (gi.wishers && gi.wishers.length) || 0; if (wc < 2) return false; // v1.58:二级筛选——subFilter=0 表示全部(≥2),否则精确匹配人数 if (wlState.subFilter > 0 && wc !== wlState.subFilter) return false; } // v1.60 修复(参考 1.0.8):onSale 计入折扣中筛选(与统计口径一致) if (wlState.filter === 'discount' && !(gi.discountPct > 0 || gi.onSale)) return false; // v1.60:即将推出(未发售)筛选 if (wlState.filter === 'soon' && !gi.isComingSoon) return false; return true; }); } // v1.58:价格 HTML(列表/封面视图共用) function wlPriceHtml(gi) { var curSym = faCurrency.symbol; // v1.60:确认未发售且无价格的游戏显示"即将推出"(原为空白,与价格未获取到无法区分); // 未发售免费游戏同属此类("免费"标签留给已发售免费游戏) if (gi.isComingSoon && !(gi.finalPrice > 0)) return '即将推出'; if (gi.isFree) return '免费'; if (gi.finalPrice > 0) { var fp = Number(gi.finalPrice) || 0; if (gi.discountPct > 0) { // v1.43:有折扣时仅显示折扣价 + 折扣徽章,不再展示划线原价,节省空间 return '' + curSym + (isNaN(fp) ? '0.00' : fp.toFixed(2)) + '' + '-' + gi.discountPct + '%'; } // v1.60:onSale 但无折扣百分比(active_discounts 类促销)显示"促销"标记 var saleTag = (gi.onSale && !(gi.discountPct > 0)) ? '促销' : ''; return '' + curSym + (isNaN(fp) ? '0.00' : fp.toFixed(2)) + '' + saleTag; } return ''; } // v1.58:封面视图成员头像 HTML——名称首字头像 + 堆叠效果 // 最多展示 3 个头像,超出显示 +N;价格/折扣占用空间时自动收窄 function wlWisherAvatarsHtml(gi, idMap) { var wishers = gi.wishers || []; if (wishers.length === 0) return ''; var maxShow = 3; var shown = wishers.slice(0, maxShow); var overflow = wishers.length - maxShow; var html = '
'; shown.forEach(function(sid, idx) { var nm = idMap[sid] || ('ID:' + String(sid).slice(-4)); // 堆叠:后面的头像向左偏移,形成重叠效果 var ml = idx === 0 ? '' : 'margin-left:-6px;border:1.5px solid #0f172a;'; html += faNameAvatarHtml(nm, 16, ml + 'z-index:' + (10 - idx) + ';'); }); if (overflow > 0) { html += '+' + overflow + ''; } html += '
'; return html; } function renderWishlistGamesList(wlData) { var listEl = panel.querySelector('#wl_game_list'); if (!listEl || !wlData) return; var filtered = getFilteredWishlist(wlData); var total = filtered.length; // v1.58:列表视图每页 30,封面视图每页 24(6 行×4 列) var pageSize = wlState.viewMode === 'cover' ? 24 : 30; var totalPages = Math.max(1, Math.ceil(total / pageSize)); if (wlState.page > totalPages) wlState.page = totalPages; if (wlState.page < 1) wlState.page = 1; var start = (wlState.page - 1) * pageSize; var end = Math.min(start + pageSize, total); var pageItems = filtered.slice(start, end); var idMap = saves.familyInfo.steamIdtoName || {}; var growthColors = ['#06cfbe', '#54a0ff', '#ff9f43', '#2ed573', '#ff6b6b', '#a29bfe']; var isCover = wlState.viewMode === 'cover'; // v1.46:分页按钮/徽章/卡片/游戏名链接改用 fa-global-style 中的 CSS 类,仅保留动态颜色内联 var pgBtn = function(page, label, disabled) { return ''; }; // 分页信息 var html = '
' + '' + total + ' 个 · 第 ' + wlState.page + '/' + totalPages + '' + '
' + pgBtn(wlState.page - 1, '上一页', wlState.page <= 1) + pgBtn(wlState.page + 1, '下一页', wlState.page >= totalPages) + '
'; if (pageItems.length === 0) { html += '
没有符合条件的游戏
'; } else if (isCover) { // ---- v1.58 封面视图:4 列精简卡片,含成员头像 ---- html += '
'; pageItems.forEach(function(aid) { var gi = wlData.GameInfo[aid]; if (!gi) return; var wantCount = (gi.wishers && gi.wishers.length) || 0; var nameStyle = (!gi.name || gi.name === ('App ' + aid)) ? ' style="color:#64748b;font-style:italic;font-weight:400;"' : ''; html += '
' + '
' + '' + (wantCount > 0 ? '' + wantCount + '人想要' : '') + (gi.inLibrary ? '已有' : '') + (gi.isComingSoon ? '即将推出' : '') + '
' + '
' + '' + faEsc(gi.name) + '' + '
' + '' + wlPriceHtml(gi) + '' + wlWisherAvatarsHtml(gi, idMap) + '
' + '
' + '
'; }); html += '
'; if (totalPages > 1) { html += '
' + pgBtn(1, '首页', wlState.page <= 1) + pgBtn(wlState.page - 1, '上一页', wlState.page <= 1) + pgBtn(wlState.page + 1, '下一页', wlState.page >= totalPages) + pgBtn(totalPages, '末页', wlState.page >= totalPages) + '
'; } } else { // ---- 列表视图(2 列紧凑卡片)---- html += '
'; pageItems.forEach(function(aid) { var gi = wlData.GameInfo[aid]; if (!gi) return; var wisherBadges = (gi.wishers || []).map(function(sid, idx) { var c = growthColors[idx % growthColors.length]; var nm = idMap[sid] || ('ID:' + String(sid).slice(-4)); return '' + faEsc(nm) + ''; }).join(''); var libBadge = gi.inLibrary ? '已有' : ''; var wantBadge = (gi.wishers && gi.wishers.length > 0) ? '' + gi.wishers.length + '人想要' : ''; // v1.60:预购(未发售但有价格)游戏显示"即将推出"徽章(无价格的已由 wlPriceHtml 展示) var soonBadge = (gi.isComingSoon && gi.finalPrice > 0) ? '即将推出' : ''; var tagsHtml = (gi.tags || []).slice(0, 2).map(function(t) { return '' + faEsc(t) + ''; }).join(''); html += '
' + '' + '
' // 第一行:游戏名称(左)+ 价格/已有/N人想要(右侧对齐) + '
' // v1.52:占位名称(App xxxxxx)以暗色斜体显示,与已获取的真实名称明确区分 + '' + faEsc(gi.name) + '' + '' + wlPriceHtml(gi) + soonBadge + libBadge + wantBadge + '' + '
' // 第二行:成员名 + 标签 + '
' + wisherBadges + tagsHtml + '
' + '
' + '
'; }); html += '
'; // 底部分页 if (totalPages > 1) { html += '
' + pgBtn(1, '首页', wlState.page <= 1) + pgBtn(wlState.page - 1, '上一页', wlState.page <= 1) + pgBtn(wlState.page + 1, '下一页', wlState.page >= totalPages) + pgBtn(totalPages, '末页', wlState.page >= totalPages) + '
'; } } listEl.innerHTML = html; // 绑定分页 listEl.querySelectorAll('[data-wl-page]').forEach(function(btn) { if (btn.disabled) return; btn.addEventListener('click', function() { var np = parseInt(btn.getAttribute('data-wl-page')); if (np >= 1) { wlState.page = np; renderWishlistGamesList(wlData); } }); }); // v1.54:异步加载游戏中文名,覆盖 SSR/API 返回的英文名(列表 + 封面视图名称链接) listEl.querySelectorAll('.fa-wl-name, .fa-wl-cover-name').forEach(function(el) { var href = el.getAttribute('href') || ''; var m = href.match(/\/app\/(\d+)/); if (!m) return; var aid = m[1]; var gi = wlData.GameInfo[aid]; faLoadGameZhName(el, aid, gi ? (gi.name || '') : ''); }); // v1.55:多 CDN 封面图 fallback(参考 steam-friend-manager dashLoadCapsule) listEl.querySelectorAll('img[data-fa-cover]').forEach(function(img) { faLoadCover(img, img.getAttribute('data-fa-cover')); }); // v1.58:封面视图成员头像已改为名称首字头像(纯 CSS 渲染,无需异步加载) } // v1.48:6 张 KPI 卡片紧凑为左侧一列(含原次级 KPI 行的多人共享/独占率), // 子标题收进 title 悬浮提示,腾出纵向空间给柱状图 const kpiDefs = [ { label: '共享游戏总数', value: totalGames, color: '#06cfbe', sub: '全家庭去重后' }, { label: '独占贡献游戏', value: singleOwnerCount, color: '#ff9f43', sub: '仅 1 人拥有' }, { label: '人均贡献约', value: avgGames, color: '#54a0ff', sub: memberCount + ' 名成员均摊' }, { label: '近30日新增', value: recent30Count, color: '#2ed573', sub: totalGames > 0 ? (recent30Count / totalGames * 100).toFixed(1) + '% 增量' : '暂无新增' }, { label: '多人共享游戏', value: multiOwnerCount, color: '#a78bfa', sub: totalGames > 0 ? (multiOwnerCount / totalGames * 100).toFixed(0) + '% 共享率' : '' }, { label: '独占率', value: (totalGames > 0 ? (singleOwnerCount / totalGames * 100).toFixed(0) : 0) + '%', color: '#ffcd56', sub: '独占/总数' } ]; const kpiColHtml = kpiDefs.map(k => '
' + '
' + k.value + '
' + '
' + k.label + '
' + '
' ).join(''); // v1.58:首页增加"我加入家庭组的时间"卡片(参考截图设计:心形图标 + 加入日期 + 已加入天数) // v1.75:调整文案为"我于 XXXX 加入家庭组",底部增加"查看我的贡献"按钮 var myJoinHtml = ''; var mySidForJoin = saves.steamid || ''; try { var myMember = (saves.familyInfo.family_member || []).find(function(m) { return m.steamid == mySidForJoin; }); if (myMember) { var myJoinTs = myMember.time_joined || 0; var myJoinDateStr = myJoinTs > 0 ? new Date(myJoinTs * 1000).toLocaleDateString('zh-CN') : '未知'; var myJoinDays = myJoinTs > 0 ? Math.floor((Date.now() - myJoinTs * 1000) / 86400000) : 0; var myName = myMember.userName || (saves.familyInfo.steamIdtoName || {})[mySidForJoin] || '我'; // v1.75:紧凑垂直卡片,文案精简为"我于 XXXX 加入",底部增加"查看我的贡献"按钮 myJoinHtml = '
' + '
' + '' + '我于 ' + myJoinDateStr + ' 加入' + '
' + '
' + myJoinDays + '
' + '
' + faNameAvatarHtml(myName, 24, 'border:2px solid rgba(168,85,247,0.4);') + '
' // v1.75:查看我的贡献按钮 + '' + '
'; } } catch(e){console.warn('[FA]', e)} // ===================== v1.62 共享冷却标签页渲染(参考 sffxzzp Family Sharing Cooldown 追踪模式) ===================== // v1.63:搜索 + 分页 + 紧凑两列卡片 + 拥有者头像堆叠 + 移除封面图(性能优化) // 冷却记录持久化到 GM_setValue(faCooldownRegistry),过期自动移至"可借用"列表 // 拆分为 renderCooldownTab(渲染骨架+工具栏)与 renderCdContent(渲染列表内容), // 搜索/分页仅刷新内容区,不重建搜索框,保持输入焦点 let cdState = { search: '', page: 1 }; function renderCooldownTab() { var cdWrap = panel.querySelector('[data-cooldown-content]'); if (!cdWrap) return; // 清理旧定时器(避免面板重开后残留) if (window.__faCooldownTimer) { clearInterval(window.__faCooldownTimer); window.__faCooldownTimer = null; } // 渲染骨架:工具栏 + 说明 + 内容容器 var html = ''; html += '
' + '' + '
' + '' + '
'; html += '
' + 'Steam 家庭共享机制:借用人停止游玩借来的游戏后,该游戏会有 24 小时冷却期,期间其他借用人无法游玩。点击"标记借出"记录借出时间并启动冷却倒计时,过期后自动移至"可借用"列表。' + '
'; html += '
'; cdWrap.innerHTML = html; // 绑定搜索框(防抖 200ms,仅刷新内容区,保持输入焦点) var searchInput = cdWrap.querySelector('#fa_cd_search'); if (searchInput) { var searchTimer = null; searchInput.addEventListener('input', function(e) { clearTimeout(searchTimer); var val = e.target.value; searchTimer = setTimeout(function() { cdState.search = val; cdState.page = 1; renderCdContent(); }, 200); }); } // 刷新共享库按钮:重新拉取 GetSharedLibraryApps 并更新本地缓存 var refreshBtn = cdWrap.querySelector('#fa_cd_refresh'); if (refreshBtn) { refreshBtn.addEventListener('click', function() { if (!access_token || !(saves.familyInfo && saves.familyInfo.family_groupid)) { refreshBtn.textContent = '缺少家庭组信息'; return; } refreshBtn.disabled = true; refreshBtn.textContent = '刷新中…'; getfamilyGameList(access_token, saves.familyInfo.family_groupid).then(function(ret) { saves.familyGameList = ret; saves.lastupDateTime = (typeof g_ServerTime !== 'undefined') ? g_ServerTime : Math.floor(Date.now() / 1000); savestorage(); if (window.faUpdateMenuBadge) window.faUpdateMenuBadge(); renderCdContent(); }).catch(function() { refreshBtn.disabled = false; refreshBtn.textContent = '刷新共享库'; }); }); } // 渲染内容区 renderCdContent(); } // v1.63:仅渲染列表内容(冷却中 + 可借用分页),搜索/分页/借出/结束均调用此函数 function renderCdContent() { var cdWrap = panel.querySelector('[data-cooldown-content]'); if (!cdWrap) return; var content = cdWrap.querySelector('#fa_cd_content'); if (!content) return; // 渲染前清理已过期的冷却记录 faCooldownPrune(); var reg = faCooldownLoad(); var now = Math.floor(Date.now() / 1000); var idMap = (saves.familyInfo && saves.familyInfo.steamIdtoName) ? saves.familyInfo.steamIdtoName : {}; var gameInfo = (saves.familyGameList && saves.familyGameList.GameInfo) ? saves.familyGameList.GameInfo : {}; var gameList = (saves.familyGameList && saves.familyGameList.GameList) ? saves.familyGameList.GameList : []; // 当前用户可借用的家庭共享游戏 = 家庭库中 owners 不包含自己的游戏 var sharedGames = gameList.filter(function(id) { var gi = gameInfo[id]; return gi && Array.isArray(gi.owners) && gi.owners.length > 0 && !gi.owners.includes(saves.steamid); }); // v1.63:搜索过滤(游戏名称或 AppID) var q = cdState.search.trim().toLowerCase(); if (q) { sharedGames = sharedGames.filter(function(id) { var gi = gameInfo[id]; var nm = (gi && gi.name) ? String(gi.name).toLowerCase() : ''; return nm.indexOf(q) !== -1 || String(id).indexOf(q) !== -1; }); } var cooling = [], borrowable = []; sharedGames.forEach(function(id) { var r = reg[id]; if (r && r.cooldownEndsAt && r.cooldownEndsAt > now) { cooling.push({ appid: id, info: gameInfo[id], record: r }); } else { borrowable.push({ appid: id, info: gameInfo[id] }); } }); // 冷却中按剩余时间升序 cooling.sort(function(a, b) { return a.record.cooldownEndsAt - b.record.cooldownEndsAt; }); // v1.63:拥有者头像堆叠 HTML(参考 wlWisherAvatarsHtml 实现) function cdOwnerAvatarsHtml(owners) { if (!owners || owners.length === 0) return ''; var maxShow = 3; var shown = owners.slice(0, maxShow); var overflow = owners.length - maxShow; var avHtml = '
'; shown.forEach(function(sid, idx) { var nm = idMap[sid] || ('ID:' + String(sid).slice(-4)); var ml = idx === 0 ? '' : 'margin-left:-6px;border:1.5px solid #1b2838;'; avHtml += faNameAvatarHtml(nm, 18, ml + 'z-index:' + (10 - idx) + ';'); }); if (overflow > 0) { avHtml += '+' + overflow + ''; } avHtml += '
'; return avHtml; } // v1.63:构建封面卡片 HTML(保留封面图,优化下方信息为两列布局) // 封面图 + 下方信息:第一列游戏名称(冷却中附倒计时+借用人),第二列左侧头像堆叠+右侧按钮 function buildCard(appid, gi, opt) { opt = opt || {}; var name = (gi && gi.name) ? faEsc(gi.name) : ('App ' + appid); var nameStyle = (!gi || !gi.name || gi.name === ('App ' + appid)) ? ' style="color:#64748b;font-style:italic;font-weight:400;"' : ''; var capImg = ''; var card = '
' + '
' + capImg + '
' + '
' + '
' + '' + name + ''; if (opt.status === 'cooling') { var borrowerName = idMap[opt.borrower] || ('ID:' + String(opt.borrower).slice(-4)); card += '
剩余 ' + faCooldownFormat(opt.cooldownEndsAt - now) + '
' + '
借用人 ' + faEsc(borrowerName) + '
'; } card += '
' + '
'; if (opt.status === 'cooling') { var borrowerNm = idMap[opt.borrower] || ('ID:' + String(opt.borrower).slice(-4)); card += cdOwnerAvatarsHtml([opt.borrower]) + ''; } else { var owners = (gi && gi.owners) ? gi.owners : []; card += cdOwnerAvatarsHtml(owners) + ''; } card += '
'; return card; } var html = ''; // 更新工具栏统计数字 var statsEl = cdWrap.querySelector('#fa_cd_stats'); if (statsEl) { statsEl.innerHTML = '冷却中 ' + cooling.length + ' · ' + '可借用 ' + borrowable.length + ''; } // 冷却中(通常较少,不分页) html += '
冷却中(' + cooling.length + ')
'; if (cooling.length === 0) { html += '
当前无冷却中的游戏
'; } else { html += '
'; cooling.forEach(function(item) { html += buildCard(item.appid, item.info, { status: 'cooling', borrower: item.record.borrower, borrowTime: item.record.borrowTime, cooldownEndsAt: item.record.cooldownEndsAt }); }); html += '
'; } html += '
'; // 可借用(v1.63:分页,每页 15 个,降低渲染卡顿) html += '
可借用(' + borrowable.length + ')
'; if (borrowable.length === 0) { html += '
' + (q ? '没有匹配的游戏' : '无可借用游戏') + '
'; } else { var cdPageSize = 15; var cdTotalPages = Math.max(1, Math.ceil(borrowable.length / cdPageSize)); if (cdState.page > cdTotalPages) cdState.page = cdTotalPages; if (cdState.page < 1) cdState.page = 1; var cdStart = (cdState.page - 1) * cdPageSize; var cdEnd = Math.min(cdStart + cdPageSize, borrowable.length); var cdPageItems = borrowable.slice(cdStart, cdEnd); var pgBtn = function(page, label, disabled) { return ''; }; // 分页信息 html += '
' + '' + cdState.page + '/' + cdTotalPages + ' 页 · 每页 ' + cdPageSize + ' 个' + '
' + pgBtn(cdState.page - 1, '上一页', cdState.page <= 1) + pgBtn(cdState.page + 1, '下一页', cdState.page >= cdTotalPages) + '
'; html += '
'; cdPageItems.forEach(function(item) { html += buildCard(item.appid, item.info, { status: 'borrowable' }); }); html += '
'; if (cdTotalPages > 1) { html += '
' + pgBtn(1, '首页', cdState.page <= 1) + pgBtn(cdState.page - 1, '上一页', cdState.page <= 1) + pgBtn(cdState.page + 1, '下一页', cdState.page >= cdTotalPages) + pgBtn(cdTotalPages, '末页', cdState.page >= cdTotalPages) + '
'; } } html += '
'; content.innerHTML = html; // 异步加载封面图(多 CDN fallback) content.querySelectorAll('img[data-fa-cover]').forEach(function(img) { faLoadCover(img, img.getAttribute('data-fa-cover')); }); // 标记借出 content.querySelectorAll('[data-fa-cd-borrow]').forEach(function(btn) { btn.addEventListener('click', function() { var aid = Number(btn.getAttribute('data-fa-cd-borrow')); var gi = gameInfo[aid]; faCooldownMarkBorrow(aid, gi ? gi.name : '', saves.steamid, gi ? gi.owners : []); renderCdContent(); }); }); // 结束冷却 content.querySelectorAll('[data-fa-cd-end]').forEach(function(btn) { btn.addEventListener('click', function() { var aid = Number(btn.getAttribute('data-fa-cd-end')); faCooldownRemove(aid); renderCdContent(); }); }); // 分页按钮 content.querySelectorAll('[data-cd-page]').forEach(function(btn) { if (btn.disabled) return; btn.addEventListener('click', function() { var np = parseInt(btn.getAttribute('data-cd-page')); if (np >= 1) { cdState.page = np; renderCdContent(); } }); }); // 倒计时定时器(每秒更新,面板关闭或全部过期时自动停止/重渲染) if (window.__faCooldownTimer) { clearInterval(window.__faCooldownTimer); window.__faCooldownTimer = null; } if (cooling.length > 0) { window.__faCooldownTimer = setInterval(function() { if (!document.getElementById('familyAnalysisPanel')) { clearInterval(window.__faCooldownTimer); window.__faCooldownTimer = null; return; } var stillCooling = false; content.querySelectorAll('[data-fa-cd-remaining]').forEach(function(el) { var endsAt = Number(el.getAttribute('data-fa-cd-remaining')); var rem = endsAt - Math.floor(Date.now() / 1000); if (rem > 0) { stillCooling = true; el.textContent = '剩余 ' + faCooldownFormat(rem); } else { el.textContent = '已结束'; } }); if (!stillCooling) { clearInterval(window.__faCooldownTimer); window.__faCooldownTimer = null; faCooldownPrune(); renderCdContent(); } }, 1000); } } panel.innerHTML = '' + '
' + '
' // 头部 + '
' + '
' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + '
' + '
' + familyName + '
' + '
Steam 家庭组 · ' + memberCount + ' 名成员 · ' + totalGames + ' 个共享游戏
' + '
' + ' ' + ' ' + '
' // 标签导航 + '
' + tabDefs.map(t => '').join('') + '
' // 内容区 + '
' // -- 贡献分布 -- + '
' // v1.75:默认视图容器(可被"我的贡献"覆盖层替换) + '
' // v1.58:三列布局——左侧(加入时间卡片 + KPI 列) | 中间贡献分布柱状图 | 右侧成员占比环形图 + 近半年入库增量(上顶均匀分布) + '
' + '
' + myJoinHtml + kpiColHtml + '
' + '
' + ' ' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
上次扫描:' + timestampToTime(saves.lastupDateTime) + '
' + '
' + ' ' + ' ' + '
' + '
' + '
' // v1.75:我的贡献覆盖层(默认隐藏,点击"查看我的贡献"后显示) + '
' + '
' // -- 增长趋势 -- + '
' + '
' + '
' // -- 价值洞察(v1.71 新增,v1.74 移至增长趋势之后) -- + '
' + '
' + '
' // -- 成员洞察(v1.58:移至购买动态之前) -- + '
' + '
' + '
' // -- 购买动态 -- + '
' + '
' + '
' // -- 游玩动态 -- + '
' + '
' + '
' // -- 入库热力图(v1.38 新增) -- + '
' + '
' + '
' // -- 家庭愿望单(v1.41 新增) -- + '
' + '
' + '
' // -- 共享冷却(v1.62 新增) -- + '
' + '
' + '
' + '
' + '
'; document.body.appendChild(panel); // 注入全局样式(幂等):fa-spin 动画、fa-tab-pane 标签页容器、fa-card 卡片、fa-btn-green 绿按钮、fa-wl-* 愿望单类 faInjectGlobalStyle(); // ===================== 事件绑定 ===================== // v1.40:统一关闭入口,关闭前销毁 Chart 实例,避免重复打开时 Canvas is already in use 及实例泄漏 const destroyFaChart = () => { if (window.__faChartInstance) { try { window.__faChartInstance.destroy(); } catch(e){console.warn('[FA]', e)} window.__faChartInstance = null; } }; const closePanel = () => { destroyFaChart(); panel.remove(); }; // 关闭浮窗 document.getElementById('btnClosePanel').addEventListener('click', closePanel); document.getElementById('familyAnalysisBackdrop').addEventListener('click', (e) => { if (e.target === e.currentTarget) closePanel(); }); // 拖动 const dialogEl = document.getElementById('familyAnalysisDialog'); const handle = document.getElementById('familyAnalysisDragHandle'); let isDragging = false, startX, startY, startLeft, startTop; handle.addEventListener('mousedown', (e) => { if (e.target.closest('button')) return; isDragging = true; const rect = dialogEl.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top; dialogEl.style.position = 'fixed'; dialogEl.style.left = startLeft + 'px'; dialogEl.style.top = startTop + 'px'; dialogEl.style.margin = '0'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; dialogEl.style.left = (startLeft + e.clientX - startX) + 'px'; dialogEl.style.top = (startTop + e.clientY - startY) + 'px'; }); document.addEventListener('mouseup', () => { isDragging = false; }); // 滚动拦截 panel.addEventListener('wheel', (e) => { const scrollable = e.target.closest('[data-fa-tab]'); if (scrollable) { const { scrollTop, scrollHeight, clientHeight } = scrollable; const atTop = e.deltaY < 0 && scrollTop <= 0; const atBottom = e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight; if (!atTop && !atBottom) { e.preventDefault(); e.stopPropagation(); scrollable.scrollTop += e.deltaY; } } }, { passive: false }); // ESC 关闭 const escHandler = (e) => { if (e.key === 'Escape') { closePanel(); document.removeEventListener('keydown', escHandler); } }; document.addEventListener('keydown', escHandler); // 标签切换 panel.querySelectorAll('[data-fa-nav]').forEach(btn => { btn.addEventListener('click', () => { showTab(btn.getAttribute('data-fa-nav')); }); }); // v1.75:查看我的贡献按钮——点击后覆盖贡献分布默认视图,展示个人贡献 KPI + 最近贡献 + 最近游玩 var viewMyContribBtn = document.getElementById('faViewMyContrib'); if (viewMyContribBtn) { viewMyContribBtn.addEventListener('click', function() { renderMyContributionOverlay(); }); } // 初始化当前标签 showTab(window[activeTabKey]); // 初始化购买动态 renderActivityPage(1); // v1.74:面板打开后预热价值洞察数据(延迟3s后台静默拉取,切到标签页时秒开) preheatValueInsightsData(); // v1.75:面板打开后预热 DLC 数据库 + 应用类型映射(延迟2s双源并发加载) preheatDlcData(); // ===================== observer_5: Chart.js 图表 ===================== function observer_5(){ let ctx = document.getElementById('Family_countChart') if(ctx){ // v1.40 修复:重复打开面板或切换标签回到贡献分布时,同一 canvas 上会残留旧 Chart 实例, // 直接 new Chart 会抛 "Canvas is already in use",此处先销毁再重建 destroyFaChart(); if (window.Chart && typeof Chart.getChart === 'function') { var existingChart = Chart.getChart(ctx); if (existingChart) { try { existingChart.destroy(); } catch(e){console.warn('[FA]', e)} } } // v1.40 修复:observer_5 会被 showTab 与面板初始化重复调用,事件监听只允许绑定一次, // 否则"现在扫描"/导出按钮会重复触发 if (!ctx.dataset.faEventsBound) { ctx.dataset.faEventsBound = '1'; btn_scan_now.addEventListener('click', () => { scan(true) closePanel() }) // Export button handlers let btnExportCSV = document.getElementById('btn_export_csv'); let btnExportJSON = document.getElementById('btn_export_json'); if(btnExportCSV) btnExportCSV.addEventListener('click', exportCSV); if(btnExportJSON) btnExportJSON.addEventListener('click', exportJSON); } // Color palette let palette = [ ['rgba(6,207,190,0.95)', 'rgba(6,207,190,0.45)'], ['rgba(84,160,255,0.90)', 'rgba(84,160,255,0.40)'], ['rgba(255,159,67,0.90)', 'rgba(255,159,67,0.40)'], ['rgba(46,213,115,0.90)', 'rgba(46,213,115,0.40)'], ['rgba(255,107,107,0.90)', 'rgba(255,107,107,0.40)'], ['rgba(162,155,254,0.90)', 'rgba(162,155,254,0.40)'], ['rgba(255,205,86,0.90)', 'rgba(255,205,86,0.40)'] ]; let borderColors = [ '#06cfbe', '#54a0ff', '#ff9f43', '#2ed573', '#ff6b6b', '#a29bfe', '#ffcd56' ]; let labels = [] let datasets = [] let membermap = {} let i = 0 let maxOwners = Math.min(saves.familyInfo.family_member.length, MAX_FAMILY); saves.familyInfo.family_member.slice(0, MAX_FAMILY).forEach((member) => { labels.push(member.userName) membermap[member.steamid] = i let ci = maxOwners - i - 1; datasets.push({ // v1.47:图例精简(N人共同贡献 → N人贡献,单独贡献 → 1人贡献) label: `${maxOwners - i}人贡献`, data: [], borderWidth: 1.5, backgroundColor: palette[ci % palette.length][0], borderColor: borderColors[ci % borderColors.length], borderRadius: 5, borderSkipped: false, hoverBorderWidth: 2, hoverBorderColor: '#ffffff', // v1.47:限制最大柱宽,避免柱子过宽显扁平 maxBarThickness: 56, }) // v1.47:数据位固定为 MAX_FAMILY(6),为待加入成员预留位置(补 0) for (let k = 0; k < MAX_FAMILY; k++) datasets[i].data.push(0) if(i == maxOwners - 1){ datasets[i].label = "1人贡献" } i++ }) // v1.47:x 轴预留待加入成员位置(如当前 4 人则补 2 个占位) for (let k = maxOwners; k < MAX_FAMILY; k++) labels.push('待加入') for (let key in saves.familyGameList.GameInfo){ let game = saves.familyGameList.GameInfo[key] game.owners.forEach((owner) => { if(membermap[owner] !== undefined) { // 注意:数据集索引基于 datasets.length(实际成员数档位数),而非 labels.length(已含占位) datasets[datasets.length - game.owners.length].data[membermap[owner]] += 1 } }) } // 创建渐变填充 let gradCtx = ctx.getContext('2d'); datasets.forEach((ds, idx) => { let ci = maxOwners - idx - 1; if (ci < 0) ci = 0; let grad = gradCtx.createLinearGradient(0, 0, 0, ctx.height); grad.addColorStop(0, palette[ci % palette.length][0]); grad.addColorStop(1, palette[ci % palette.length][1]); ds.backgroundColor = grad; }); // 创建图表(隐藏默认图例) let chartInstance = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: datasets }, options: { animation: { duration: 800, easing: 'easeOutQuart' }, layout: { padding: { top: 8 } }, plugins: { legend: { display: false }, title: { display: true, text: '家庭库贡献分布图', color: '#c7d5e0', font: { size: 15, weight: '600', family: "'Motiva Sans',Arial,sans-serif" }, padding: { bottom: 18 } }, tooltip: { backgroundColor: 'rgba(15,23,42,0.96)', titleColor: '#06cfbe', bodyColor: '#c7d5e0', borderColor: 'rgba(6,207,190,0.25)', borderWidth: 1, padding: 12, cornerRadius: 8, titleFont: { weight: 'bold', size: 13 }, bodyFont: { size: 12 }, callbacks: { label: function(ctx) { return ' ' + ctx.dataset.label + ': ' + ctx.raw + ' 个游戏'; } } } }, responsive: true, maintainAspectRatio: true, interaction: { mode: 'index', intersect: false }, scales: { x: { stacked: true, // v1.47:待加入占位成员标签置灰,与真实成员区分 ticks: { color: function(c) { return c.index >= maxOwners ? '#475569' : '#8a9ba8'; }, font: { size: 11, weight: '500' }, padding: 6 }, grid: { color: 'rgba(255,255,255,0.03)', drawBorder: false } }, y: { beginAtZero: true, stacked: true, ticks: { color: '#8a9ba8', font: { size: 11 }, callback: function(v) { return v + ' 个' }, padding: 8, stepSize: Math.max(1, Math.ceil(totalGames / 8)) }, grid: { color: 'rgba(255,255,255,0.05)', drawBorder: false } } } }, plugins: [{ id: 'barShadow', afterDatasetsDraw: function(chart) { let ctx2 = chart.ctx; ctx2.save(); ctx2.globalCompositeOperation = 'destination-over'; chart.data.datasets.forEach((_ds, di) => { let meta = chart.getDatasetMeta(di); if (!meta || !meta.data) return; meta.data.forEach((bar) => { let x = bar.x, y = bar.y, w = bar.width; let base = bar.base; if (base === undefined || bar.hidden) return; ctx2.fillStyle = 'rgba(0,0,0,0.12)'; ctx2.beginPath(); ctx2.moveTo(x - w/2, (y + base) / 2 + 2); ctx2.lineTo(x + w/2, (y + base) / 2 + 2); ctx2.lineTo(x + w/2, base); ctx2.lineTo(x - w/2, base); ctx2.closePath(); ctx2.fill(); }); }); ctx2.restore(); } }] }); // v1.40:保存实例引用,供关闭面板/重建前销毁 window.__faChartInstance = chartInstance; // ===================== 自定义 HTML 图例 ===================== let chartContainer = ctx.parentElement; let oldLegend = chartContainer.querySelector('#customChartLegend'); if (oldLegend) oldLegend.remove(); let legendHtml = '
'; datasets.forEach((ds, idx) => { let ci = maxOwners - idx - 1; if (ci < 0) ci = 0; let solidColor = borderColors[ci % borderColors.length]; legendHtml += '' + '' + '' + ds.label + ': ' + ds.data.reduce((a, b) => a + b, 0) + ''; }); legendHtml += '
'; chartContainer.insertAdjacentHTML('beforeend', legendHtml); }else{ setTimeout(observer_5, 200) } } // ===================== escapeCSV / exportCSV / exportJSON ===================== function escapeCSV(str) { if (str == null) return ''; str = String(str); if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { return '"' + str.replace(/"/g, '""') + '"'; } return str; } function exportCSV() { let now = new Date(); let dateStr = now.getFullYear() + ('0'+(now.getMonth()+1)).slice(-2) + ('0'+now.getDate()).slice(-2); let filename = 'steam-family-' + familyGroupId + '-' + dateStr + '.csv'; let csvRows = []; csvRows.push('# Steam家庭库游戏数据'); csvRows.push('# 家庭组: ' + escapeCSV(familyName) + ' (ID: ' + familyGroupId + ')'); csvRows.push('# 成员数: ' + memberCount + ' | 游戏总数: ' + totalGames + ' | 独占贡献: ' + singleOwnerCount + ' | 人均: ' + avgGames); csvRows.push('# 导出时间: ' + timestampToTime(Math.floor(Date.now()/1000))); csvRows.push('# 上次扫描: ' + timestampToTime(saves.lastupDateTime)); csvRows.push(''); csvRows.push(['游戏名称', 'AppID', '购入时间', '购买者', '拥有者数', '拥有者名单'].map(escapeCSV).join(',')); saves.familyGameList.GameList.forEach(appid => { let g = saves.familyGameList.GameInfo[appid]; let lastOwner = saves.familyInfo.steamIdtoName[g.owners.at(-1)] || ''; let ownerList = g.owners.map(sid => saves.familyInfo.steamIdtoName[sid] || sid).join('; '); csvRows.push([ g.name, appid, timestampToTime(g.time), lastOwner, g.owners.length, ownerList ].map(escapeCSV).join(',')); }); let bom = '\uFEFF'; let blob = new Blob([bom + csvRows.join('\n')], {type: 'text/csv;charset=utf-8;'}); let url = URL.createObjectURL(blob); let a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); faToast.success('CSV 导出成功'); } function exportJSON() { let now = new Date(); let dateStr = now.getFullYear() + ('0'+(now.getMonth()+1)).slice(-2) + ('0'+now.getDate()).slice(-2); let filename = 'steam-family-' + familyGroupId + '-' + dateStr + '.json'; let exportData = { export_time: timestampToTime(Math.floor(Date.now()/1000)), family_groupid: familyGroupId, family_name: familyName, member_count: memberCount, total_games: totalGames, single_owner_count: singleOwnerCount, avg_games_per_member: avgGames, last_scan_time: timestampToTime(saves.lastupDateTime), family_members: saves.familyInfo.family_member.map(m => ({steamid: m.steamid, userName: m.userName})), games: saves.familyGameList.GameList.map(appid => ({ appid: appid, name: saves.familyGameList.GameInfo[appid].name, owners: saves.familyGameList.GameInfo[appid].owners.map(sid => saves.familyInfo.steamIdtoName[sid] || sid), owner_steamids: saves.familyGameList.GameInfo[appid].owners, time_acquired: saves.familyGameList.GameInfo[appid].time, time_readable: timestampToTime(saves.familyGameList.GameInfo[appid].time), icon_hash: saves.familyGameList.GameInfo[appid].icon_hash })) }; let blob = new Blob([JSON.stringify(exportData, null, 2)], {type: 'application/json'}); let url = URL.createObjectURL(blob); let a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); faToast.success('JSON 导出成功'); } // ===================== 动态加载自动更新 ===================== observer_5(); const refreshObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { if (document.getElementById('familyAnalysisPanel')) { const el = mutation.addedNodes[0]; if (el.nodeType === 1 && (el.closest && el.closest('.family_updater'))) { renderActivityPage(currentPage); if (window[activeTabKey] === 'growth') renderGrowthTab(); if (window[activeTabKey] === 'heatmap') renderHeatmapTab(); if (window[activeTabKey] === 'insights') renderMemberInsightsTab(); } } } } }); refreshObserver.observe(document.body, { childList: true, subtree: true }); } } function observer_6(){ let block = document.getElementById('wishlist_ctn') if(block){ let lists = block.querySelectorAll("div.wishlist_row") lists.forEach(function(bar){ addflag(bar) }) }else{ setTimeout(observer_6,200) } } function observer_4(){ let block = document.getElementById('search_result_container') if(block){ let lists = block.querySelectorAll("a.search_result_row.ds_collapse_flag") lists.forEach(function(bar){ addflag(bar,"clear: left;") }) }else{ setTimeout(observer_4,200) } } function observer_3(){ let block = document.querySelector('div.home_tabs_content') if(block){ let lists = block.querySelectorAll("a.tab_item") lists.forEach(function(bar){ addflag(bar,"clear: both;") }) block = document.querySelector('div.carousel_container.maincap') lists = block.querySelectorAll("a.store_main_capsule") lists.forEach(function(bar){ addflag(bar) }) block = document.querySelector('div.carousel_container.spotlight') lists = block.querySelectorAll("div.home_area_spotlight") lists.forEach(function(bar){ addflag(bar) }) lists = block.querySelectorAll("a.store_capsule") lists.forEach(function(bar){ addflag(bar) }) block = document.getElementById('module_deep_dive') lists = block.querySelectorAll("a.store_capsule") lists.forEach(function(bar){ addflag(bar) }) block = document.getElementById('module_recommender') lists = block.querySelectorAll("a.store_capsule") lists.forEach(function(bar){ addflag(bar) }) block = document.getElementById('recommended_creators_carousel') lists = block.querySelectorAll("a.store_capsule") lists.forEach(function(bar){ addflag(bar) }) block = document.querySelector('div.specials_under10_content') lists = block.querySelectorAll("a.store_capsule") lists.forEach(function(bar){ addflag(bar) }) block = document.querySelector('div.marketingmessage_area') lists = block.querySelectorAll("a.home_marketing_message") lists.forEach(function(bar){ addflag(bar) }) }else{ setTimeout(observer_3,200) } } // v1.53:发行商/开发商/系列/相似推荐页初始扫描(React 异步渲染,2s 后兜底重扫一次, // 后续新增/懒加载卡片由 MutationObserver 接管;addflag 内部有防重复检测) // v1.55:增加 1s/3s/5s 多次重扫以覆盖懒加载,选择器追加 a.tab_item[data-ds-appid] function observer_capsule_pages(){ const capsuleSelector = 'a.store_capsule[data-ds-appid], a.similar_grid_capsule[data-ds-appid], a.tab_item[data-ds-appid]'; function scan(){ document.body.querySelectorAll(capsuleSelector).forEach(function(bar){ addflag(bar); }); } scan(); setTimeout(scan, 1000); setTimeout(scan, 3000); setTimeout(scan, 5000); } function observer_2(){ let block = document.querySelector('div.queue_and_playtime') if(block){ appid = Number(url.split('/')[2]) if(saves.familyGameList.GameList.includes(appid)){ addBanner(block,appid) } }else{ setTimeout(observer_2,200) } } // v1.59:统一 appId 提取函数(参考 Steam-PSPlus-mark.js 的 appIdFromNode 设计) // 优先从 data-ds-appid / data-app-id 属性获取,回退到 href URL 解析 /app/{appid} // 解决部分列表标签(如 home_marketing_message、部分 capsule)仅有 URL 无 data-ds-appid 导致匹配不到的问题 function getAppIdFromNode(node) { if (!node || !node.getAttribute) return null; // 1. data-ds-appid(支持逗号分隔的捆绑包,取首个) var ds = node.getAttribute('data-ds-appid'); if (ds) return ds.split(',')[0].trim(); // 2. data-app-id var dai = node.getAttribute('data-app-id'); if (dai) return dai.split(',')[0].trim(); // 3. 从 href 解析 /app/{appid}(参考 PS Plus appIdFromNode 回退逻辑) var href = node.href || node.getAttribute('href') || ''; var m = /\/app\/(\d+)/.exec(href); return m ? m[1] : null; } // v1.50:家庭共享标记图标(紫色三人家庭组 SVG 图标,白色三头一身剪影) const FAMILY_SHARE_FLAG_ICON = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='11' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Ccircle cx='12' cy='6.6' r='3.4'/%3E%3Ccircle cx='4.6' cy='9.2' r='2.6'/%3E%3Ccircle cx='19.4' cy='9.2' r='2.6'/%3E%3Cpath d='M3 22.5c0-5 4-8.6 9-8.6s9 3.6 9 8.6v.5H3z'/%3E%3C/svg%3E"; function addflag(node,insertBeforeStyle){ // v1.59:防止 null 节点崩溃(category 页 querySelector('a') 可能返回 null) if(!node || !node.querySelector) return; // v1.65:排除家庭分析浮窗内的元素 — 浮窗自身的游戏封面不需要 Steam 原生共享标记 // ("家庭共享"/"在家庭库中"旗帜仅用于 Steam 原生页面游戏卡片匹配) if(node.closest && node.closest('#familyAnalysisPanel')) return; // v1.67:额外排除浮窗内活动视图的元素(按 class/属性特征识别,覆盖节点在回调时尚未挂载到 panel 的边缘情况) if(node.closest && ( node.closest('.fa-act-cover-grid') || node.closest('.fa-act-cover-card') || node.closest('.fa-act-cover-name') || node.closest('[data-activity-content]') || node.closest('[data-fa-tab="activity"]') || node.closest('[data-fa-tab="value"]') || node.closest('[data-value-content]') )) return; // v1.68:排除 steam-game-library-viewer 浮窗 // 个人信息面板(#sgis-panel,含最近游玩/个人入库/相似推荐等)以及游戏库侧边栏(.sglv-panel), // 它们的游戏卡片已由各自脚本负责标记,不需要本脚本再追加 Steam 原生"在家庭库中"旗帜 if(node.closest && (node.closest('#sgis-panel') || node.closest('.sglv-panel'))) return; // v1.50:同时检测家庭共享旗帜,防止重复标记 if(node.querySelector("div.ds_owned_flag, div.ds_family_share_flag")) return; // v1.59:category 页特殊处理 — 已有 CapsuleDecorators 装饰器则跳过 if(url.startsWith('store.steampowered.com/category/')){ if(node.querySelector("div.CapsuleDecorators")&&node.querySelector("div.CapsuleDecorators").childElementCount>0) return; } // v1.59:统一使用 getAppIdFromNode 提取 appId(参考 Steam-PSPlus-mark.js appIdFromNode) // 替代旧的 data-ds-appid + category href 解析分支,修复仅有 URL 无属性的游戏卡片匹配不到的问题 let thisappid = getAppIdFromNode(node); if(!thisappid) return; // v1.55:处理逗号分隔的 data-ds-appid(捆绑包含多个 appid),取首个在家庭库中的 appid let appidCandidates = thisappid.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n)); let matchedAppid = appidCandidates.find(id => saves.familyGameList.GameList.includes(id)); if(matchedAppid){ thisappid = matchedAppid; let thisurl = node.getAttribute('href'); if(thisurl && thisurl.startsWith('/app/')){ node.classList.add('ds_owned'); } node.classList.add('ds_flagged'); node.classList.remove('ds_wishlist') // v1.50:购买人不包含自己 → 认定为家庭共享,使用紫色家庭组图标旗帜 const thisGameInfo = saves.familyGameList.GameInfo[Number(thisappid)]; const isFamilyShare = thisGameInfo && Array.isArray(thisGameInfo.owners) && thisGameInfo.owners.length > 0 && !thisGameInfo.owners.includes(saves.steamid); var flag = document.createElement('div'); if(isFamilyShare){ flag.className = "ds_flag ds_family_share_flag" flag.innerHTML = '家庭共享  ' flag.title = '家庭共享:由 ' + thisGameInfo.owners.map(function(sid){ return saves.familyInfo.steamIdtoName[sid] || sid }).join('、') + ' 共享' flag.style = "background:url(\"" + FAMILY_SHARE_FLAG_ICON + "\") no-repeat 4px 3px / 11px 11px #7c3aed;color:#fff;font-weight:bold" }else{ flag.className = "ds_flag ds_owned_flag" flag.innerHTML = '在家庭库中  ' flag.style = "background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAAKCAYAAABi8KSDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OUNDNzBFNTUyMUM0MTFFNDk1REVFODRBNUU5RjA2MUYiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OUNDNzBFNTYyMUM0MTFFNDk1REVFODRBNUU5RjA2MUYiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5Q0M3MEU1MzIxQzQxMUU0OTVERUU4NEE1RTlGMDYxRiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5Q0M3MEU1NDIxQzQxMUU0OTVERUU4NEE1RTlGMDYxRiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pv3vUKAAAAAlSURBVHjaYvz//z8DsYARpFhISAivjnfv3jGSp3jUGeQ4AyDAADZHNe2nyOBrAAAAAElFTkSuQmCC') no-repeat 4px 4px #06cfbe" } // v1.54:Steam 搜索结果行新结构已移除 style="clear: left;" 锚点(旧逻辑 querySelector // 返回 null 导致 .nextSibling 抛 TypeError,旗帜创建失败且中断整页 forEach 标记)。 // 插入点三级降级:旧锚点 → div.ds_options 之前(与原生 ds_owned_flag 位置一致)→ 末尾追加 if(insertBeforeStyle){ var styleAnchor = node.querySelector(`[style*="${insertBeforeStyle}"]`); if(styleAnchor){ node.insertBefore(flag, styleAnchor.nextSibling); }else{ var dsOptions = node.querySelector('div.ds_options'); if(dsOptions){ node.insertBefore(flag, dsOptions); }else{ node.appendChild(flag); } } }else{ node.appendChild(flag); } node.querySelectorAll("div.ds_flag.ds_wishlist_flag").forEach((wishlist_flag)=>{wishlist_flag.remove()}) } // v1.70: 进包标记 — 独立于家庭库检查,对所有游戏卡片检查进包状态 // 位置靠下(display:block + clear:both + position:relative),不与 ds_flag 绝对定位旗帜重合 var bundleCount = getBundleCount(Number(thisappid)); if (bundleCount > 0 && !node.querySelector('div.fa-bundle-flag')) { var bundleFlag = document.createElement('div'); bundleFlag.className = 'fa-bundle-flag'; bundleFlag.innerHTML = '进过' + bundleCount + '包 '; bundleFlag.title = '此游戏曾出现在 ' + bundleCount + ' 个 bundle 中(数据来源: Barter.vg)'; bundleFlag.style.cssText = 'display:block;clear:both;margin-top:2px;padding:1px 6px;font-size:11px;font-weight:700;border-radius:3px;background:#f59e0b;color:#fff;position:relative;z-index:10;'; // 插入到 flag 之后(靠下位置),确保不与 ds_flag 重合;无 flag 时追加到节点末尾 if (flag && flag.parentNode) { flag.insertAdjacentElement('afterend', bundleFlag); } else { node.appendChild(bundleFlag); } } } function addBanner(block,appid){ let appname = appHubAppName.innerText let owned = false let thisgameInfo = saves.familyGameList.GameInfo[appid] if(block.querySelector('div.game_area_already_owned.page_content')|| thisgameInfo.owners.includes(steamid)){ owned = true let endplug = document.createElement('div'); endplug.id = 'see_family_benefactor'; endplug.style.position = 'relative'; endplug.style.display = 'inline-block'; endplug.setAttribute('data-tooltip-text', `您有 ${thisgameInfo.owners.length} 个家庭组成员拥有此游戏`); endplug.innerHTML = `
查看贡献者
${thisgameInfo.owners.length}
` let targetElement = block.querySelector('div.already_owned_actions'); targetElement.appendChild(endplug); } if(owned == false){ let headplug = document.createElement('div'); let targetElement = block.querySelector('div.queue_overflow_ctn'); headplug.style = "background:linear-gradient(to right, rgb(6 207 199 / 60%) 0%, rgb(33 105 106 / 60%) 100%);color:#06cfb5" headplug.className = "game_area_already_owned page_content" headplug.innerHTML =`
在家庭库中  
您的 Steam 家庭库中已有《${appname}》
` targetElement.parentNode.insertBefore(headplug, targetElement.nextSibling); let endplug = document.createElement('div'); targetElement = block.querySelector('div.purchase_options_content'); endplug.className = "game_area_play_stats" endplug.innerHTML = `
` targetElement.parentNode.insertBefore(endplug, targetElement); } (function observer_1(){ let btn = document.getElementById('see_family_benefactor') if(btn){ let innerHTML = `
您有 ${thisgameInfo.owners.length} 个家庭组成员拥有此游戏:
` thisgameInfo.owners.forEach((steamid)=>{ innerHTML+= `
${saves.familyInfo.steamIdtoName[steamid]}
` }) innerHTML+= `
--------------------------------------------
该游戏最早由【${saves.familyInfo.steamIdtoName[thisgameInfo.owners[0]]}】购入。
` btn.onclick = function(){ ShowAlertDialog(`【${saves.familyInfo.family_name}】游戏贡献者`,innerHTML,'好的') } }else{ setTimeout(observer_1,200) } })(); } function getGameAppid(element){ return Number(element.firstChild.firstChild.getAttribute('src').split('/')[5]) } function getGameCounts(containGames_panel){ return Number(containGames_panel.querySelector('div.LP9H7bBiPB8N8jFzCQumL').lastChild.innerText.match(/\d*/)[0]) } })(); function scan(isdialog){ saves.noPrompt = false if(isdialog){ let confirmDlg = ShowConfirmDialog('提示','即将开始扫描,请确认已加入一个有效的家庭组,否则脚本可能会出错,扫描期间不要关闭浏览器,耐心等待!','好的,开始扫描') confirmDlg.done(()=>{ confirmDlg.Dismiss() dialog = ShowBlockingWaitDialog('正在扫描家庭组库存...') start() }) }else{ start() } function start(){ getfamilyInfo(access_token).then((returnjson) => { saves.familyInfo = returnjson savestorage() getfamilyGameList(access_token,saves.familyInfo.family_groupid).then((returnjson) => { saves.familyGameList = returnjson saves.lastupDateTime = g_ServerTime savestorage() // v1.39 修复:扫描完成后刷新菜单入口的游戏数量 if (window.faUpdateMenuBadge) { window.faUpdateMenuBadge(); } if(isdialog){ dialog.Dismiss() ShowAlertDialog('完成',`已将${saves.familyGameList.GameList.length}个家庭库游戏记录到本地缓存。`,'好的') } }).catch((err) => { console.error('[家庭库脚本] 获取家庭库游戏列表失败:', err); if(isdialog && dialog){ dialog.Dismiss(); } if(isdialog){ ShowAlertDialog('错误','获取家庭库游戏列表失败,请稍后重试。','好的') } }) }).catch((err) => { console.error('[家庭库脚本] 获取家庭组信息失败:', err); if(isdialog && dialog){ dialog.Dismiss(); } if(isdialog){ // v1.74:按错误类型给出准确提示——未加入家庭组 vs 网络/接口异常(已自动重试 3 次仍失败) if(err && err.code === 'NO_FAMILY'){ ShowAlertDialog('提示','未检测到您加入的 Steam 家庭组。\n请先在 Steam 客户端中创建或加入一个家庭组,然后再使用本脚本扫描。','好的') }else{ ShowAlertDialog('错误','获取家庭组信息失败(已自动重试 3 次)。\n可能原因:网络连接异常、Steam 接口限流或登录状态失效。\n建议:检查网络后刷新页面重新登录,稍后再试。','好的') } } }) } } function getfamilyGameList(access_token,family_groupid){ return new Promise((resolve, reject) => { var url = `https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/?access_token=${access_token}&family_groupid=${family_groupid}&include_own=true&include_excluded=false&include_non_games=false`; faGmFetchRetry(url, {timeout: 25000}).then(function(resp) { var json = resp && resp.response; if(json){ var returnjson = {"GameList":[],"GameInfo":{}} json.apps.forEach((app)=>{ if(app.exclude_reason == 0){ returnjson.GameList.push(app.appid) returnjson.GameInfo[app.appid] = { "name":app.name, "owners":app.owner_steamids, "time":app.rt_time_acquired, "icon_hash":app.img_icon_hash} } }) returnjson.GameList.sort(function(a, b) { if (returnjson.GameInfo[a].time > returnjson.GameInfo[b].time) { return -1; // a 应在 b 前面 }else{ return 1; // b 应在 a 前面 } }); resolve(returnjson) } else { reject(new Error('家庭库游戏列表响应为空')); } }).catch(function(err) { reject(err); }); }); } // v1.74:单次获取家庭组信息(带响应校验,错误分类:NO_FAMILY=未加入家庭组,其余=可重试的网络/接口异常) function fetchFamilyInfoOnce(access_token){ return new Promise((resolve, reject) => { var url = `https://api.steampowered.com/IFamilyGroupsService/GetFamilyGroupForUser/v1/?access_token=${access_token}&include_family_group_response=true`; faGmFetchRetry(url, {timeout: 25000}).then(function(resp) { var json = resp && resp.response; if(!json || Object.keys(json).length === 0){ // Steam 偶发返回空 response,属于可重试异常而非"未加入家庭组" return reject(new Error('家庭组信息响应为空')); } if(!json.family_groupid){ // 明确无 family_groupid 才判定未加入家庭组 var nf = new Error('当前账号未加入家庭组'); nf.code = 'NO_FAMILY'; return reject(nf); } if(!json.family_group || !Array.isArray(json.family_group.members)){ // 有 groupid 但详情缺失:接口抖动,可重试 return reject(new Error('家庭组详情响应不完整')); } var returnjson = { "family_groupid":json.family_groupid, "family_name":json.family_group.name, "family_member":json.family_group.members, "steamIdtoName":{}, "steamIdtoAvatar":{} } getUserNameBySteamId(access_token,json.family_group.members).then((ret)=>{ returnjson.family_member = ret.family_member returnjson.steamIdtoName = ret.steamIdtoName returnjson.steamIdtoAvatar = ret.steamIdtoAvatar || {} resolve(returnjson) }).catch((err) => { reject(err); }) }).catch(function(err) { reject(err); }); }) } // v1.74:家庭组信息获取入口——网络/接口异常自动重试(最多 3 次,2s/4s 退避), // 仅在明确无家庭组(NO_FAMILY)时立即失败,避免已有家庭组的用户被误提示 function getfamilyInfo(access_token){ var MAX_ATTEMPTS = 3; var attempt = 0; function tryOnce(){ attempt++; return fetchFamilyInfoOnce(access_token).catch(function(err){ var isNoFamily = err && err.code === 'NO_FAMILY'; if(!isNoFamily && attempt < MAX_ATTEMPTS){ var waitMs = attempt * 2000; console.warn(`[家庭库脚本] 获取家庭组信息第 ${attempt} 次失败(${err && err.message}),${waitMs/1000}s 后自动重试…`); return new Promise(function(r){ setTimeout(r, waitMs); }).then(tryOnce); } throw err; }); } return tryOnce(); } //该函数弃用 function getMyGame(access_token){ return new Promise((resolve, reject) => { var xhr = new XMLHttpRequest(); var json xhr.open("GET", `https://store.steampowered.com/dynamicstore/userdata/`, true); xhr.onload = function() { if (xhr.status >= 200 && xhr.status < 300) { json = JSON.parse(xhr.responseText); if(json){ if(Object.keys(json).length == 0){ reject("请求自身拥有游戏返回空白。"); }else{ var returnjson = json.rgOwnedApps resolve(returnjson) } } } else { reject("请求出错:", xhr.statusText); } }; xhr.send(); }) } function getUserNameBySteamId(access_token,family_member) { // v1.74:改用 faGmFetchRetry(带超时/重试/429处理),失败时使用兜底昵称降级返回, // 不再因昵称接口异常导致整个家庭组信息获取失败(原裸 XHR 无 onerror,网络异常时 Promise 永不 settle) var url = `https://api.steampowered.com/IPlayerService/GetPlayerLinkDetails/v1/?access_token=${access_token}` family_member.forEach((member, i)=>{ url+=`&steamids[${i}]=${member.steamid}` }) function fallbackResult(reason){ console.warn('[家庭库脚本] 成员昵称获取失败,使用兜底昵称:', reason); var steamIdtoName = {}, steamIdtoAvatar = {}; family_member.forEach((member)=>{ if(!member.userName) member.userName = '成员' + String(member.steamid).slice(-4); steamIdtoName[member.steamid] = member.userName; }); return { family_member, steamIdtoName, steamIdtoAvatar }; } return faGmFetchRetry(url, { timeout: 20000, retries: 2 }).then(function(resp) { var json = resp && resp.response; if(!json || !Array.isArray(json.accounts)) return fallbackResult('响应格式异常'); var steamIdtoName = {}, steamIdtoAvatar = {}; json.accounts.forEach((user)=>{ var pd = user.public_data || {} family_member.forEach((member)=>{ if(member.steamid == pd.steamid){ member.userName = pd.persona_name if (pd.avatar) member.avatar = pd.avatar } }) if (pd.steamid) { steamIdtoName[pd.steamid]=pd.persona_name if (pd.avatar) steamIdtoAvatar[pd.steamid] = pd.avatar } }) // 个别成员昵称缺失时兜底 family_member.forEach((member)=>{ if(!member.userName){ member.userName = '成员' + String(member.steamid).slice(-4); steamIdtoName[member.steamid] = member.userName; } }); return { family_member, steamIdtoName, steamIdtoAvatar }; }).catch(function(err) { return fallbackResult(err && err.message); }); } // 获取家庭成员最近游玩游戏(用于"游玩动态"标签页) // token: 当前登录用户的 webapi_token;sid: 家庭成员 steamid // 返回 { total_count, games: [{appid,name,playtime_2weeks,playtime_forever,img_icon_url}] } function fetchMemberRecentlyPlayed(token, sid) { return new Promise((resolve) => { var xhr = new XMLHttpRequest(); xhr.open("GET", `https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v1/?access_token=${token}&steamid=${sid}`, true); xhr.onload = function() { if (xhr.status >= 200 && xhr.status < 300) { try { var json = JSON.parse(xhr.responseText).response; resolve({ total_count: json.total_count || 0, games: (json.games || []).map(function(g) { return { appid: g.appid, name: g.name, playtime_2weeks: g.playtime_2weeks || 0, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '' }; }) }); return; } catch(e){console.warn('[FA]', e)} } resolve(null); }; xhr.onerror = function() { resolve(null); }; xhr.send(); }); } // 获取成员所有拥有游戏的总游玩时长(v1.38 修复:GetOwnedGames 含全部游戏,而非仅最近2周游玩的) function fetchMemberOwnedGamesTotal(token, sid) { return new Promise((resolve) => { var xhr = new XMLHttpRequest(); xhr.open("GET", `https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?access_token=${token}&steamid=${sid}&include_played_free_games=1`, true); xhr.onload = function() { if (xhr.status >= 200 && xhr.status < 300) { try { var json = JSON.parse(xhr.responseText).response; var games = json.games || []; var totalMinutes = 0; games.forEach(function(g) { totalMinutes += (g.playtime_forever || 0); }); resolve({ totalMinutes: totalMinutes, gameCount: json.game_count || games.length }); return; } catch(e){console.warn('[FA]', e)} } resolve(null); }; xhr.onerror = function() { resolve(null); }; xhr.send(); }); } // v1.72:获取成员拥有游戏的 per-game 游玩时长(用于价值洞察散点图) // 改用 GM_xmlhttpRequest(via faGmGetJson)替代 XMLHttpRequest,参考 steam-game-library-viewer // 的 requestSteamAPI 模式——GM_xmlhttpRequest 不受 CORS 限制且依赖 @connect api.steampowered.com, // 比 XHR 更可靠。返回 [{appid, playtime, recent}],playtime=总分钟数,recent=近2周分钟数 function faFetchMemberPlaytimeGames(token, sid) { var reqUrl = 'https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?access_token=' + token + '&steamid=' + sid + '&include_appinfo=1&include_played_free_games=1'; return faGmGetJson(reqUrl, 20000).then(function(resp) { if (!resp || !resp.response || !resp.response.games) { console.warn('[FA] GetOwnedGames per-game: 响应为空或无 games 字段, sid=' + sid); return []; } return resp.response.games.map(function(g) { return { appid: g.appid, playtime: g.playtime_forever || 0, recent: g.playtime_2weeks || 0 }; }); }); } // v1.66:Toast 非阻塞通知系统(参考 steam-friend-manager toast 单例模式) var faToast = { _el: null, _timer: null, _show: function(msg, type) { if (!this._el) { this._el = document.createElement('div'); this._el.id = 'fa-toast'; document.body.appendChild(this._el); } this._el.textContent = msg; this._el.className = 'fa-toast' + (type ? ' fa-toast-' + type : ''); requestAnimationFrame(function() { this._el.classList.add('fa-toast-show'); }.bind(this)); if (this._timer) clearTimeout(this._timer); this._timer = setTimeout(function() { this._el.classList.remove('fa-toast-show'); }.bind(this), 2500); }, success: function(msg) { this._show(msg, 'success'); }, error: function(msg) { this._show(msg, 'error'); }, warning: function(msg) { this._show(msg, 'warning'); }, info: function(msg) { this._show(msg, 'info'); } }; // ===================== 全局样式注入(v1.46 合并精简) ===================== // 原先分散在 renderPlayActivityTab / renderWishlistTab 的 fa-spin 与 fa-wl-* 注入块, // 以及面板 HTML 中重复的内联样式(7 处标签页容器 / 4 处绿按钮 / 5 处卡片),统一收敛到此。 function faInjectGlobalStyle() { if (document.getElementById('fa-global-style')) return; var st = document.createElement('style'); st.id = 'fa-global-style'; st.textContent = '@keyframes fa-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}' + '.fa-tab-pane{padding:12px 14px;overflow-y:auto;max-height:calc(85vh - 160px)}' + '.fa-card{background:rgba(15,23,42,.4);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px}' + '.fa-btn-green{background:linear-gradient(180deg,#4c7a34 0%,#3a5f28 100%);border:1px solid #5a8f3a;color:#d2e9c0;padding:4px 14px;border-radius:4px;cursor:pointer;font-size:12px;font-weight:500}' + '.fa-btn-green:hover{background:linear-gradient(180deg,#5a8f3a 0%,#4c7a34 100%)}' + '.fa-btn-green.fa-btn-sm{padding:3px 10px;font-size:11px}' + '.fa-wl-pgbtn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#8097a8;padding:3px 8px;border-radius:3px;cursor:pointer;font-size:11px}' + '.fa-wl-pgbtn:disabled{opacity:.4;cursor:default}' + '.fa-wl-pgbtn:not(:disabled):hover{background:rgba(255,255,255,.12);color:#c6d4df}' + '.fa-wl-pgbtn.fa-btn-md{padding:4px 10px;border-radius:4px;font-size:12px}' + '.fa-wl-badge{font-size:8px;padding:1px 4px;border-radius:2px;white-space:nowrap}' + '.fa-wl-card{display:flex;align-items:center;gap:5px;padding:4px 6px;background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.04);border-radius:5px;overflow:hidden;min-width:0}' + '.fa-wl-name{font-size:11px;font-weight:600;color:#e2e8f0;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1 1 auto}' + '.fa-wl-name:hover{color:#06cfbe}' // v1.58:愿望单视图切换 / 二级筛选 / 封面视图 / 成员头像 + '.fa-wl-view-toggle{display:flex;gap:2px;background:rgba(15,23,42,.6);border:1px solid rgba(255,255,255,.08);border-radius:6px;padding:2px}' + '.fa-wl-view-btn{background:transparent;border:none;color:#8097a8;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:11px;display:flex;align-items:center;gap:3px;transition:all .2s}' + '.fa-wl-view-btn:hover{color:#c6d4df}' + '.fa-wl-view-btn.active{background:rgba(6,207,190,.2);color:#06cfbe}' + '.fa-wl-subtags{display:flex;gap:4px;flex-wrap:wrap;flex:1 1 auto;min-width:0;align-items:center}' + '.fa-wl-subtag{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);color:#8097a8;padding:3px 9px;border-radius:4px;cursor:pointer;font-size:11px;white-space:nowrap}' + '.fa-wl-subtag:hover:not(.disabled){background:rgba(255,255,255,.1);color:#c6d4df}' + '.fa-wl-subtag.active{background:rgba(84,160,255,.2);border-color:rgba(84,160,255,.4);color:#54a0ff}' + '.fa-wl-subtag.disabled{opacity:.35;cursor:not-allowed}' + '.fa-wl-cover-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}' + '.fa-wl-cover-card{background:rgba(15,23,42,.4);border:1px solid rgba(255,255,255,.08);border-radius:8px;overflow:hidden;display:flex;flex-direction:column;transition:border-color .2s,background .2s}' + '.fa-wl-cover-card:hover{border-color:rgba(6,207,190,.35);background:rgba(6,207,190,.04)}' + '.fa-wl-cover-cap-wrap{position:relative;aspect-ratio:184/69;overflow:hidden;background:#0f172a;cursor:pointer}' + '.fa-wl-cover-cap{width:100%;height:100%;object-fit:cover;display:block}' + '.fa-wl-cover-want-badge{position:absolute;top:4px;right:4px;background:rgba(84,160,255,.85);color:#0f172a;font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px}' + '.fa-wl-cover-lib-badge{position:absolute;top:4px;left:4px;background:rgba(6,207,190,.85);color:#0f172a;font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px}' + '.fa-wl-cover-soon-badge{position:absolute;bottom:4px;left:4px;background:rgba(56,189,248,.85);color:#0f172a;font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px}' + '.fa-wl-cover-info{padding:6px 8px;display:flex;flex-direction:column;gap:4px;min-width:0}' + '.fa-wl-cover-name{font-size:12px;font-weight:600;color:#e2e8f0;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '.fa-wl-cover-name:hover{color:#06cfbe}' + '.fa-wl-cover-bottom{display:flex;align-items:center;justify-content:space-between;gap:4px;min-width:0}' + '.fa-wl-cover-avatars{display:flex;align-items:center;flex-shrink:0}' + '@media(max-width:900px){.fa-wl-cover-grid{grid-template-columns:repeat(3,1fr)}}' + '@media(max-width:600px){.fa-wl-cover-grid{grid-template-columns:repeat(2,1fr)}}' // v1.56:购买动态封面视图样式(参考 steam-friend-manager sfd-pl-recent-* 系列) + '.fa-act-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:space-between;margin-bottom:10px}' + '.fa-act-view-toggle{display:flex;gap:2px;background:rgba(15,23,42,0.6);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:2px}' + '.fa-act-view-btn{background:transparent;border:none;color:#8097a8;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:11px;display:flex;align-items:center;gap:3px;transition:all 0.2s}' + '.fa-act-view-btn:hover{color:#c6d4df}' + '.fa-act-view-btn.active{background:rgba(6,207,190,0.2);color:#06cfbe}' + '.fa-act-cover-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}' + '.fa-act-cover-card{background:rgba(15,23,42,0.4);border:1px solid rgba(255,255,255,0.08);border-radius:8px;overflow:hidden;display:flex;flex-direction:column;transition:border-color 0.2s,background 0.2s;position:relative}' + '.fa-act-cover-card:hover{border-color:rgba(6,207,190,0.35);background:rgba(6,207,190,0.04)}' + '.fa-act-cover-card.is-new{border-color:rgba(6,207,190,0.3)}' + '.fa-act-cover-cap-wrap{position:relative;aspect-ratio:184/69;overflow:hidden;background:#0f172a;cursor:pointer}' + '.fa-act-cover-cap{width:100%;height:100%;object-fit:cover;display:block}' + '.fa-act-cover-new-badge{position:absolute;top:4px;right:4px;background:rgba(6,207,190,0.85);color:#0f172a;font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px;letter-spacing:0.5px}' + '.fa-act-cover-info{padding:7px 9px;display:flex;flex-direction:column;gap:3px}' + '.fa-act-cover-name{font-size:12px;font-weight:600;color:#e2e8f0;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '.fa-act-cover-name:hover{color:#06cfbe}' + '.fa-act-cover-meta{display:flex;gap:8px;font-size:10px;color:#8097a8;flex-wrap:wrap;align-items:center}' + '.fa-act-cover-buyer{color:#54a0ff;font-weight:500}' + '@media(max-width:900px){.fa-act-cover-grid{grid-template-columns:repeat(3,1fr)}}' + '@media(max-width:600px){.fa-act-cover-grid{grid-template-columns:repeat(2,1fr)}}' // v1.62:共享冷却标签页样式(v1.63:保留封面图,优化下方信息为两列布局) + '.fa-cd-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:6px}' + '.fa-cd-section{margin-top:10px}' + '.fa-cd-section-title{font-size:13px;font-weight:600;margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid rgba(255,255,255,.08)}' + '.fa-cd-empty{font-size:12px;color:#64748b;padding:10px 4px}' + '.fa-cd-list{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}' + '.fa-cd-card{background:rgba(15,23,42,.4);border:1px solid rgba(255,255,255,.08);border-radius:8px;overflow:hidden;display:flex;flex-direction:column;transition:border-color .2s,background .2s}' + '.fa-cd-card:hover{border-color:rgba(6,207,190,.3);background:rgba(6,207,190,.04)}' + '.fa-cd-cap-wrap{position:relative;aspect-ratio:184/69;overflow:hidden;background:#0f172a;cursor:pointer}' + '.fa-cd-cap{width:100%;height:100%;object-fit:cover;display:block}' + '.fa-cd-info{padding:6px 8px;display:flex;align-items:center;gap:6px;min-width:0}' + '.fa-cd-col-name{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}' + '.fa-cd-name{font-size:12px;font-weight:600;color:#e2e8f0;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '.fa-cd-name:hover{color:#06cfbe}' + '.fa-cd-countdown{font-size:11px;font-weight:700;color:#ff6b6b;font-variant-numeric:tabular-nums}' + '.fa-cd-borrower-name{font-size:9px;color:#54a0ff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '.fa-cd-col-actions{display:flex;align-items:center;gap:5px;flex-shrink:0}' + '.fa-cd-avatars{display:flex;align-items:center;flex-shrink:0}' + '.fa-cd-pgbar{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:6px}' + '@media(max-width:900px){.fa-cd-list{grid-template-columns:repeat(2,1fr)}}' + '@media(max-width:600px){.fa-cd-list{grid-template-columns:1fr}}' // v1.62:DLC 拥有状态标记与按钮激活态 + '.fa-btn-green.active{box-shadow:0 0 0 2px rgba(6,207,190,.4)}' + '.fa-dlc-owned-mark{display:inline-flex;align-items:center;gap:2px}' // v1.64:愿望单加载进度——成员头像旋转光圈特效 + '@keyframes fa-wl-ring-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}' + '@keyframes fa-wl-dot-pulse{0%,80%,100%{opacity:.3;transform:scale(.7)}40%{opacity:1;transform:scale(1.3)}}' + '.fa-wl-loading-wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:50px 20px;gap:16px;color:#8097a8}' + '.fa-wl-loading-ring{position:relative;width:64px;height:64px;border-radius:50%;padding:3px;background:conic-gradient(from 0deg,transparent 0%,#06cfbe 30%,#54a0ff 60%,transparent 90%);animation:fa-wl-ring-spin 1.2s linear infinite;flex-shrink:0}' + '.fa-wl-loading-ring-inner{width:100%;height:100%;border-radius:50%;background:#0e1824;display:flex;align-items:center;justify-content:center;overflow:hidden}' + '.fa-wl-loading-avatar-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block}' + '.fa-wl-loading-name{font-size:15px;font-weight:600;color:#c6d4df;text-align:center;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '.fa-wl-loading-progress{font-size:12px;color:#8097a8;text-align:center;line-height:1.6}' + '.fa-wl-loading-dots{display:inline-flex;gap:3px;margin-left:4px;vertical-align:middle}' + '.fa-wl-loading-dots span{width:4px;height:4px;border-radius:50%;background:#06cfbe;animation:fa-wl-dot-pulse 1.4s ease-in-out infinite}' + '.fa-toast{position:fixed;bottom:50px;left:50%;transform:translateX(-50%) translateY(20px);background:rgba(15,23,42,0.95);color:#fff;border:1px solid rgba(6,207,190,0.5);padding:10px 24px;border-radius:20px;font-size:13px;font-weight:500;box-shadow:0 4px 16px rgba(0,0,0,0.3);z-index:999999;opacity:0;pointer-events:none;transition:opacity 0.3s,transform 0.3s}' + '.fa-toast.fa-toast-show{opacity:1;transform:translateX(-50%) translateY(0)}' + '.fa-toast.fa-toast-success{border-color:#2ed573}' + '.fa-toast.fa-toast-error{border-color:#ff6b6b}' + '.fa-toast.fa-toast-warning{border-color:#f59e0b}' + '.fa-toast.fa-toast-info{border-color:#54a0ff}' + '.fa-wl-loading-dots span:nth-child(2){animation-delay:.2s}' + '.fa-wl-loading-dots span:nth-child(3){animation-delay:.4s}' // v1.64:购买动态成员筛选下拉框样式(v1.75:修复"购买者"文本竖排换行——flex-shrink:0 + white-space:nowrap) + '.fa-act-filter{display:flex;align-items:center;gap:4px;flex-shrink:0;white-space:nowrap}' + '.fa-act-filter select{background:rgba(15,23,42,.6);border:1px solid rgba(255,255,255,.1);color:#c6d4df;padding:4px 8px;border-radius:4px;font-size:11px;cursor:pointer;outline:none;max-width:140px;flex-shrink:0}' + '.fa-act-filter select:hover{border-color:rgba(6,207,190,.4)}' + '.fa-act-filter select:focus{border-color:#06cfbe60}'; document.head.appendChild(st); } // ===================== v1.41 家庭愿望单 API ===================== // 参考 steam-wishlist-exporter-2.1.3 和 steam-game-library-viewer-2.3.25 的多源降级策略: // 来源1(主):愿望单页面 SSR 提取(最可靠,利用登录会话 Cookie 渲染的 React Query 数据) // 来源2(补/降级):wishlistdata JSON API 分页(补充名称/价格/标签) // 合并策略:SSR 提供 date_added/priority + StoreItem 详情(首批),API 补充缺失的名称/价格/标签 // ---- SSR 辅助函数 ---- // 从愿望单页面 HTML 中提取 React Query SSR queries 数组 // 参考 steam-game-library-viewer 的 wlExtractQueryDataFromText function faWlExtractQueryDataFromText(text) { try { var idx = 0; while ((idx = text.indexOf('JSON.parse(', idx)) !== -1) { var start = text.indexOf('"', idx + 11); if (start === -1) { idx++; continue; } var i = start + 1, inEsc = false; while (i < text.length) { var c = text[i]; if (inEsc) inEsc = false; else if (c === '\\') inEsc = true; else if (c === '"') break; i++; } var escaped = text.substring(start + 1, i).replace(/[\n\r]/g, ''); var decoded = ''; try { decoded = JSON.parse('"' + escaped + '"'); } catch (_) { idx = i + 1; continue; } if (typeof decoded === 'string' && decoded.indexOf('queryData') !== -1) { try { var outer = JSON.parse(decoded); if (outer && typeof outer.queryData === 'string') { var qd = JSON.parse(outer.queryData); if (qd && Array.isArray(qd.queries)) return qd.queries; } } catch (_) {} } idx = i + 1; } } catch (e) { console.warn('[FA-WL] SSR extract failed:', e); } return null; } // 从 queries 数组提取愿望单条目 / StoreItem 缓存 / 标签名映射 function faWlExtractFromQueries(queries) { var entries = []; var tagNameMap = {}; var storeItemCache = {}; for (var qi = 0; qi < queries.length; qi++) { var query = queries[qi]; if (!query || !query.state || !query.state.data) continue; var data = query.state.data; var qKey = query.queryKey || []; // 愿望单条目(含 appid/priority/date_added/category_ids) if (qKey[0] === 'WishlistSortedFiltered') { var payload = Array.isArray(data) ? { items: data } : data; if (payload && Array.isArray(payload.items)) { for (var ii = 0; ii < payload.items.length; ii++) entries.push(payload.items[ii]); } } // 标签名映射(tagid -> name) if (qKey[0] === 'LocalizedTagNames' && typeof data === 'object' && !Array.isArray(data)) { for (var tk in data) tagNameMap[tk] = data[tk]; } // StoreItem 详情(按 appid 聚合各子查询) if (qKey.length >= 3 && qKey[0] === 'StoreItem') { var appId = parseInt(String(qKey[1]).replace(/^app_/, ''), 10); if (!appId || isNaN(appId)) continue; if (!storeItemCache[appId]) storeItemCache[appId] = { _appId: appId }; storeItemCache[appId][qKey[2]] = data; } } // 去重(按 appid) var seen = {}; var unique = []; for (var ei = 0; ei < entries.length; ei++) { var e = entries[ei]; if (e && e.appid && !seen[e.appid]) { seen[e.appid] = true; unique.push(e); } } return { entries: unique, storeItemCache: storeItemCache, tagNameMap: tagNameMap }; } // 统一的条目完整度判定:名称非占位 +(免费或有价格)+ 有标签(v1.52 抽取为公共函数) function faWlMetaJudgeDone(entry) { var nameOk = !!(entry.name && entry.name !== ('App ' + entry.appid)); // v1.60:已确认未发售(isComingSoon)的游戏可以合法无价格,视为价格已知, // 否则未发售无价格游戏永远 _metaDone=false,每次打开面板都会重复补全 var priceOk = entry.isFree === true || (Number(entry.finalPrice) > 0) || entry.isComingSoon === true; var tagsOk = Array.isArray(entry.tags) && entry.tags.length > 0; entry._metaDone = nameOk && priceOk && tagsOk; } // 将 StoreItem 缓存数据应用到条目(补全名称/价格/标签/类型) function faWlApplyStoreItem(entry, storeData, tagNameMap) { if (!entry || !storeData) return; var di = storeData.default_info; if (di) { if (di.name && (!entry.name || entry.name === ('App ' + entry.appid))) entry.name = String(di.name); if (typeof di.type === 'number') { var typeMap = { 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' }; entry.type = typeMap[di.type] || 'other'; } else if (di.type) { entry.type = String(di.type).toLowerCase(); } if (di.is_free === true) entry.isFree = true; var bpo = di.best_purchase_option; if (bpo) { var finalCents = Number(bpo.final_price_in_cents) || 0; var originalCents = Number(bpo.original_price_in_cents) || 0; entry.finalPrice = finalCents / 100; entry.originalPrice = (originalCents > 0 ? originalCents : finalCents) / 100; entry.discountPct = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0); // v1.43:有折扣但原价缺失/不合法时,由折扣价反推原价 if (entry.discountPct > 0 && entry.discountPct < 100 && entry.originalPrice <= entry.finalPrice) { entry.originalPrice = entry.finalPrice / (1 - entry.discountPct / 100); } if (finalCents === 0 && entry.discountPct === 0) entry.isFree = true; // v1.60 修复(参考 1.0.8):active_discounts 无 pct 字段但代表折扣事实,纳入 onSale, // 否则仅按 discountPct>0 统计会漏掉此类促销游戏 entry.onSale = entry.discountPct > 0 || !!(bpo.active_discounts && bpo.active_discounts.length); } } // v1.60 修复(参考 1.0.8 buildItemFromStoreCache):发售状态权威判定—— // default_info.is_coming_soon 是权威字段(实测:未发售=true,已发售无此键), // 其次 include_release 的 is_coming_soon / steam_release_date 未来时间戳; // default_info 或 include_release 任一存在即代表发售状态已知(可回退 true→false,纠正旧缓存) var rel = storeData.include_release || null; var rdTs = rel ? (Number(rel.steam_release_date) || 0) : 0; var soon = (di && di.is_coming_soon === true) || (rel && rel.is_coming_soon === true); if (!soon && rdTs * 1000 > Date.now()) soon = true; if (di || rel) { entry.isComingSoon = !!soon; entry._releaseKnown = true; if (rdTs > 0) entry.releaseDate = new Date(rdTs * 1000).toISOString().slice(0, 10); } // top_tags -> 标签名列表 var tt = storeData.top_tags; if (Array.isArray(tt) && tt.length > 0) { var tags = []; for (var ti = 0; ti < tt.length; ti++) { var t = tt[ti]; if (!t) continue; if (t.name) tags.push(t.name); else if (t.tagid != null && tagNameMap[t.tagid]) tags.push(tagNameMap[t.tagid]); } if (tags.length > 0) entry.tags = tags.filter(Boolean).slice(0, 20); } // v1.42 修复:仅当名称/价格/标签均齐备时才标记完成。 // SSR StoreItem 缓存可能只有部分子查询(如仅有 top_tags 而无 default_info), // 之前无条件标记 _metaDone 会导致后台 appdetails 补全被跳过,名称永远停留在占位符。 faWlMetaJudgeDone(entry); } // 来源1:从愿望单页面 HTML 抓取 SSR 数据(主来源,最可靠) function faWlFetchFromPage(steamid) { return new Promise(function(resolve) { var isProfileId = /^\d{17}$/.test(String(steamid)); var url = isProfileId ? 'https://store.steampowered.com/wishlist/profiles/' + steamid + '/' : 'https://store.steampowered.com/wishlist/id/' + steamid + '/'; GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 30000, onload: function(resp) { if (resp.status < 200 || resp.status >= 300 || !resp.responseText) { console.warn('[FA-WL] 页面抓取失败: HTTP', resp.status); resolve(null); return; } var queries = faWlExtractQueryDataFromText(resp.responseText); if (!queries) { console.warn('[FA-WL] 页面未找到 SSR queryData'); resolve(null); return; } var data = faWlExtractFromQueries(queries); console.log('[FA-WL] SSR: ' + data.entries.length + ' 条目, ' + Object.keys(data.storeItemCache).length + ' 详情'); if (data.entries.length === 0) { resolve(null); return; } resolve(data); }, onerror: function() { console.warn('[FA-WL] 页面网络错误'); resolve(null); }, ontimeout: function() { console.warn('[FA-WL] 页面请求超时'); resolve(null); } }); }); } // v1.66:统一 API 调用层 — 封装 GM_xmlhttpRequest 带重试机制(参考 steam-friend-manager requestSteamAPI) function faGmFetchRetry(url, options) { options = options || {}; var timeout = options.timeout || 20000; var maxRetries = options.retries || 2; var method = options.method || 'GET'; return new Promise(function(resolve, reject) { var attempt = 0; function doRequest() { GM_xmlhttpRequest({ method: method, url: url, timeout: timeout, onload: function(resp) { // v1.74:429 限流 / 5xx 服务端错误视为可重试错误(原版本会直接交给业务层, // 空响应被误判为"未加入家庭组") if (resp.status === 429 || resp.status >= 500) { attempt++; if (attempt <= maxRetries) { console.warn('[FA] HTTP ' + resp.status + ',第 ' + attempt + ' 次重试:', url); setTimeout(doRequest, attempt * 1500); return; } reject(new Error('HTTP ' + resp.status)); return; } if (resp.status < 200 || resp.status >= 300) { reject(new Error('HTTP ' + resp.status)); return; } try { resolve(JSON.parse(resp.responseText)); } catch (e) { console.warn('[FA] JSON 解析失败:', url, e); reject(new Error('JSON parse failed')); } }, onerror: function(err) { attempt++; if (attempt <= maxRetries) { console.warn('[FA] 请求失败,第 ' + attempt + ' 次重试:', url); setTimeout(doRequest, attempt * 1000); } else { reject(err); } }, ontimeout: function() { attempt++; if (attempt <= maxRetries) { console.warn('[FA] 请求超时,第 ' + attempt + ' 次重试:', url); setTimeout(doRequest, attempt * 1000); } else { reject(new Error('timeout')); } } }); } doRequest(); }); } // v1.60:GM_xmlhttpRequest 的 Promise 封装(JSON),失败/超时/解析错误一律 resolve(null) function faGmGetJson(reqUrl, timeoutMs) { return new Promise(function(resolve) { GM_xmlhttpRequest({ method: 'GET', url: reqUrl, timeout: timeoutMs || 20000, onload: function(resp) { if (resp.status < 200 || resp.status >= 300) { resolve(null); return; } try { resolve(JSON.parse(resp.responseText)); } catch (e) { resolve(null); } }, onerror: function() { resolve(null); }, ontimeout: function() { resolve(null); } }); }); } // v1.60 提速(参考 Steam-Wishlist-Sidebar 1.0.9 fetchAllWishlistDataPages): // 并发波次拉取 wishlistdata 全部分页(每波 4 页并行),替代串行逐页 + 300ms 等待。 // 单页失败/为空即视为已到末页(与原逻辑一致);返回按 appid 去重的原始 {appid, info} 列表。 function faWlFetchAllApiPages(base, maxPages) { var WAVE = 4; var all = []; var seen = {}; function fetchOne(p) { return faGmGetJson(base + '?p=' + p, 20000).then(function(data) { return { p: p, d: data }; }); } function wave(page) { if (page >= maxPages) return Promise.resolve(all); var batch = []; for (var i = 0; i < WAVE && page + i < maxPages; i++) batch.push(page + i); return Promise.all(batch.map(fetchOne)).then(function(results) { results.sort(function(a, b) { return a.p - b.p; }); var done = false; for (var ri = 0; ri < results.length; ri++) { if (done) break; var d = results[ri].d; if (!d || (typeof d === 'object' && Object.keys(d).length === 0)) { done = true; break; } var items = Array.isArray(d) ? d : Object.entries(d); if (items.length === 0) { done = true; break; } items.forEach(function(item) { var appid, info; if (Array.isArray(item)) { appid = Number(item[0]); info = item[1]; } else { appid = Number(item.appid); info = item; } if (!appid || isNaN(appid) || !info || seen[appid]) return; seen[appid] = true; all.push({ appid: appid, info: info }); }); } if (done) return all; return wave(page + WAVE); }); } return wave(0); } // 来源2:wishlistdata JSON API 分页(补充名称/价格/标签/类型) // v1.52:① 兼容新版 best_purchase_option 价格格式(旧版 subs 仍保留解析,与参考脚本 normalizeWishlistEntry 对齐) // ② profiles/id 路径按 steamid 形态自适应(17位 SteamID64 用 profiles,自定义 ID 用 id) function faWlFetchFromApi(steamid) { var maxPages = 60; var isProfileId = /^\d{17}$/.test(String(steamid)); var base = isProfileId ? 'https://store.steampowered.com/wishlist/profiles/' + steamid + '/wishlistdata/' : 'https://store.steampowered.com/wishlist/id/' + steamid + '/wishlistdata/'; // v1.60:并发波次拉页(原串行 300ms/页是多成员愿望单加载慢的主因之一) return faWlFetchAllApiPages(base, maxPages).then(function(raws) { return raws.map(function(r) { return faWlParseApiEntry(r.appid, r.info); }); }); } // v1.60:从 faWlFetchFromApi 抽出的单条解析(价格/折扣/发售状态权威判定,便于复用与测试) function faWlParseApiEntry(appid, info) { var finalPrice = 0, originalPrice = 0, discountPct = 0, isFree = false, onSale = false; // v1.60 修复(参考 1.0.8):旧格式只读 subs[0] 会漏掉折扣在非首个购买选项的游戏,改取折扣最大的 sub if (info.subs && info.subs.length > 0) { var sub = info.subs[0]; for (var si = 0; si < info.subs.length; si++) { if ((Number(info.subs[si].discount_pct) || 0) > (Number(sub.discount_pct) || 0)) sub = info.subs[si]; } finalPrice = (Number(sub.price) || 0) / 100; discountPct = Number(sub.discount_pct) || 0; originalPrice = (discountPct > 0 && discountPct < 100) ? finalPrice / (1 - discountPct / 100) : finalPrice; isFree = Number(sub.price) === 0; } // 新格式:best_purchase_option(单位:分) var bpo = info.best_purchase_option; if (bpo) { finalPrice = (Number(bpo.final_price_in_cents) || 0) / 100; originalPrice = (Number(bpo.original_price_in_cents) || 0) / 100; if (originalPrice <= 0) originalPrice = finalPrice; discountPct = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0); if (discountPct > 0 && discountPct < 100 && originalPrice <= finalPrice) { originalPrice = finalPrice / (1 - discountPct / 100); } if (finalPrice === 0 && discountPct === 0) isFree = true; // v1.60 修复(参考 1.0.8):active_discounts 无 pct 字段但代表折扣事实 onSale = discountPct > 0 || !!(bpo.active_discounts && bpo.active_discounts.length); } if (!isFinite(finalPrice) || finalPrice < 0) finalPrice = 0; if (!isFinite(originalPrice) || originalPrice < 0) originalPrice = finalPrice; if (!isFinite(discountPct) || discountPct < 0 || discountPct > 100) discountPct = 0; if (discountPct > 0) onSale = true; // v1.60 修复(参考 1.0.8 applyApiInfoToItem):发售状态多源权威判定—— // is_coming_soon 字段 / release_date 未来时间戳 / release_string 未发售措辞 //(即将推出/Coming Soon/TBA/Q3 等),且可回退 true→false(游戏已发售时纠正旧缓存) var rdTs = Number(info.release_date) || 0; var relStr = (typeof info.release_string === 'string') ? info.release_string : ''; var soon = info.is_coming_soon === true; if (!soon && rdTs * 1000 > Date.now()) soon = true; if (!soon && relStr && /即将推出|即将宣布|coming soon|to be announced|\btba\b|\btbd\b|\bq[1-4]\b|summer|winter|spring|fall|autumn|^\s*\d{4}\s*$|年第[一二三四1-4]季度|[春夏秋冬]季/i.test(relStr)) soon = true; var relKnown = (typeof info.is_coming_soon !== 'undefined') || rdTs > 0 || soon; var entry = { appid: appid, name: (info.name && String(info.name).trim()) || ('App ' + appid), priority: Number(info.priority) || 0, addedDate: Number(info.date_added) || 0, finalPrice: finalPrice, originalPrice: originalPrice, discountPct: discountPct, onSale: onSale, isFree: !!info.is_free || isFree, isComingSoon: false, releaseDate: '', _releaseKnown: false, type: String(info.type || '').toLowerCase(), tags: (info.tags || []).map(function(t) { return typeof t === 'string' ? t : (t.name || ''); }).filter(Boolean).slice(0, 20), _metaDone: false }; if (relKnown) { entry.isComingSoon = !!soon; entry._releaseKnown = true; if (rdTs > 0) entry.releaseDate = new Date(rdTs * 1000).toISOString().slice(0, 10); } return entry; } // ===================== 用户主货币检测(v1.43) ===================== // 参考 Steam-Spending-History-Classifier 的主货币思路: // 优先读取 g_rgWalletInfo.wallet_currency(Steam 钱包货币数字代码), // 其次读取 application_config 的 country_code 推断,默认 CNY。 var faCurrency = { id: 'CNY', symbol: '¥', cc: 'cn' }; function faDetectUserCurrency() { // Steam ECurrencyCode → [货币代码, 符号, 国家代码] var codeMap = { 1: ['USD', '$', 'us'], 2: ['GBP', '£', 'gb'], 3: ['EUR', '€', 'eu'], 4: ['CHF', 'CHF ', 'ch'], 5: ['RUB', '₽', 'ru'], 6: ['PLN', 'zł', 'pl'], 7: ['BRL', 'R$', 'br'], 8: ['JPY', '¥', 'jp'], 9: ['NOK', 'kr', 'no'], 10: ['IDR', 'Rp', 'id'], 11: ['MYR', 'RM', 'my'], 12: ['PHP', '₱', 'ph'], 13: ['SGD', 'S$', 'sg'], 14: ['THB', '฿', 'th'], 15: ['VND', '₫', 'vn'], 16: ['KRW', '₩', 'kr'], 17: ['TRY', '₺', 'tr'], 18: ['UAH', '₴', 'ua'], 19: ['MXN', 'Mex$', 'mx'], 20: ['CAD', 'C$', 'ca'], 21: ['AUD', 'A$', 'au'], 22: ['NZD', 'NZ$', 'nz'], 23: ['CNY', '¥', 'cn'], 24: ['INR', '₹', 'in'], 25: ['CLP', 'CLP$', 'cl'], 26: ['PEN', 'S/', 'pe'], 27: ['COP', 'COL$', 'co'], 28: ['ZAR', 'R', 'za'], 29: ['HKD', 'HK$', 'hk'], 30: ['TWD', 'NT$', 'tw'], 31: ['SAR', 'SR', 'sa'], 32: ['AED', 'AED ', 'ae'], 34: ['ARS', 'ARS$', 'ar'], 35: ['ILS', '₪', 'il'], 37: ['KZT', '₸', 'kz'], 38: ['KWD', 'KD', 'kw'], 39: ['QAR', 'QR', 'qa'], 40: ['CRC', '₡', 'cr'], 41: ['UYU', '$U', 'uy'] }; try { if (typeof g_rgWalletInfo !== 'undefined' && g_rgWalletInfo && g_rgWalletInfo.wallet_currency) { var hit = codeMap[Number(g_rgWalletInfo.wallet_currency)]; if (hit) { faCurrency = { id: hit[0], symbol: hit[1], cc: hit[2] }; return; } } } catch (e) {} // 国家代码 → [货币代码, 符号] var ccMap = { cn: ['CNY', '¥'], hk: ['HKD', 'HK$'], tw: ['TWD', 'NT$'], jp: ['JPY', '¥'], kr: ['KRW', '₩'], us: ['USD', '$'], gb: ['GBP', '£'], uk: ['GBP', '£'], eu: ['EUR', '€'], de: ['EUR', '€'], fr: ['EUR', '€'], ru: ['RUB', '₽'], br: ['BRL', 'R$'], in: ['INR', '₹'], ca: ['CAD', 'C$'], au: ['AUD', 'A$'], nz: ['NZD', 'NZ$'], tr: ['TRY', '₺'], mx: ['MXN', 'Mex$'], ar: ['ARS', 'ARS$'], cl: ['CLP', 'CLP$'], th: ['THB', '฿'], sg: ['SGD', 'S$'], my: ['MYR', 'RM'], id: ['IDR', 'Rp'], ph: ['PHP', '₱'], vn: ['VND', '₫'], za: ['ZAR', 'R'], ua: ['UAH', '₴'], pl: ['PLN', 'zł'], no: ['NOK', 'kr'], ch: ['CHF', 'CHF '], sa: ['SAR', 'SR'], ae: ['AED', 'AED '], il: ['ILS', '₪'], co: ['COP', 'COL$'], pe: ['PEN', 'S/'], kz: ['KZT', '₸'] }; try { if (typeof application_config !== 'undefined' && application_config) { var cfg = JSON.parse(application_config.getAttribute('data-store_user_config') || '{}'); var cc = String(cfg.country_code || '').toLowerCase(); if (cc && ccMap[cc]) { faCurrency = { id: ccMap[cc][0], symbol: ccMap[cc][1], cc: cc }; } } } catch (e) {} } // ===================== 游戏中文名获取(参考 steam-friend-manager-1.1.2 fetchGameZhName) ===================== // 通过 store.steampowered.com/api/appdetails?l=schinese 获取简体中文名称, // 带跨会话持久缓存(30天 TTL)和内存级防重复请求(pending Map), // 供家庭库游戏列表、游玩动态、家庭愿望单等面板异步替换英文名。 var FA_GAME_NAME_KEY = 'faGameNameCache'; var FA_NAME_CACHE_TTL = 30 * 864e5; // 30天过期 var _faGameNameCache = null; var _faGameNamePending = new Map(); function faGameNameCacheLoad() { if (_faGameNameCache !== null) return _faGameNameCache; _faGameNameCache = {}; try { var raw = GM_getValue(FA_GAME_NAME_KEY); if (raw && typeof raw === 'object') _faGameNameCache = raw; } catch (e) {} return _faGameNameCache; } function faGameNameCacheSave() { try { GM_setValue(FA_GAME_NAME_KEY, _faGameNameCache || {}); } catch (e) {} } // 获取游戏中文名(异步,返回 Promise,空字符串表示获取失败) function faFetchGameZhName(appid) { var id = String(appid); var cache = faGameNameCacheLoad(); var cached = cache[id]; if (cached && cached.name && Date.now() - (cached.ts || 0) < FA_NAME_CACHE_TTL) { return Promise.resolve(cached.name); } if (_faGameNamePending.has(id)) return _faGameNamePending.get(id); var p = new Promise(function(resolve) { GM_xmlhttpRequest({ method: 'GET', url: 'https://store.steampowered.com/api/appdetails?appids=' + id + '&l=schinese', timeout: 10000, onload: function(resp) { var name = ''; try { var json = JSON.parse(resp.responseText); var d = json && json[id]; if (d && d.success && d.data && d.data.name) name = d.data.name; } catch (e) { console.warn('[FA] 游戏名称解析失败:', id, e); } if (name) { cache[id] = { name: name, ts: Date.now() }; faGameNameCacheSave(); } _faGameNamePending.delete(id); resolve(name); }, onerror: function() { _faGameNamePending.delete(id); resolve(''); }, ontimeout: function() { _faGameNamePending.delete(id); resolve(''); } }); }); _faGameNamePending.set(id, p); return p; } // 异步加载中文名并更新 DOM 元素(参考 steam-friend-manager loadGameZhName) // el: 显示游戏名的元素;appid: 游戏ID;originalName: 当前显示的名称 function faLoadGameZhName(el, appid, originalName) { if (!el || !appid) return; faFetchGameZhName(appid).then(function(zhName) { if (zhName && zhName !== originalName) { el.textContent = zhName; el.title = zhName + ' (' + originalName + ')'; // v1.55 修复:占位名称(App xxxxxx / 空)异步加载到真实中文名后, // 清除占位内联样式(灰色斜体 #64748b),让 .fa-wl-name 等类的正式样式生效。 // 仅对占位名称精确匹配生效,不影响购买动态/游玩动态等已具备真实名称的元素。 if (!originalName || originalName === ('App ' + appid)) { el.style.color = ''; el.style.fontStyle = ''; el.style.fontWeight = ''; } } }); } // ===================== 游戏封面图多 CDN fallback(参考 steam-friend-manager dashLoadCapsule) ===================== // v1.57: 封面图 fallback 链(依次尝试,任一成功即停止): // 1. capsule_467x181.jpg (cloudflare steam/apps) — 大横幅,最清晰 // 2. capsule_231x87.jpg (cloudflare steam/apps) — 中等横幅 // 3. capsule_231x87.png (cloudflare steam/apps) — PNG 版本 // 4. capsule_184x69.jpg (cloudflare steam/apps) — 标准横幅,最通用 // 5. capsule_184x69.png (cloudflare steam/apps) — PNG 版本 // 6. header.jpg (cloudflare steam/apps) — 头图兜底 // 7. capsule_184x69.jpg (akamai store_item_assets) — Akamai 兜底 // 8. header.jpg (akamai store_item_assets) — Akamai 兜底 // 9. Steam API appdetails capsule_image / header_image // 10. SVG placeholder(淡紫色 Steam 图标) var _faCoverGood = new Map(); // appid → 已验证可用的封面 URL(跨标签页共享) var _faCoverApiCache = new Map(); // appid → API 返回的 capsule_image URL var _faCoverApiPending = new Map(); var FA_COVER_SVG = 'data:image/svg+xml,' + encodeURIComponent( '' + '' + '' + '' + '' ); // v1.58:成员头像占位图(圆形剪影,参考 steam-friend-manager 默认头像风格) var FA_AVATAR_SVG = 'data:image/svg+xml,' + encodeURIComponent( '' + '' + '' + '' + '' ); // v1.58:根据名称生成唯一不变的背景色(基于字符串 hash → HSL 色相) // 同名用户始终得到相同颜色;首字相同但全名不同的用户也能通过全名 hash 区分 function faNameAvatarColor(str) { var s = String(str || '?'); var hash = 0; for (var i = 0; i < s.length; i++) { hash = s.charCodeAt(i) + ((hash << 5) - hash); hash = hash & hash; // 转 32 位整数 } var hue = Math.abs(hash) % 360; return 'hsl(' + hue + ',55%,45%)'; } // v1.58:根据名称生成圆形头像 HTML(首字 + 唯一背景色) // name: 成员名称 size: 直径 px extra: 额外 style/属性 // 注意:此函数在 IIFE 外层定义,不能调用内部的 faEsc,内联转义 function faNameAvatarHtml(name, size, extra) { var s = String(name || '?'); var ch = s.charAt(0).toUpperCase().replace(/&/g,'&').replace(//g,'>'); var bg = faNameAvatarColor(s); var fs = Math.max(9, Math.floor(size * 0.5)); var ex = extra || ''; return '' + ch + ''; } function faFetchCoverFromApi(appid) { var id = String(appid); if (_faCoverApiCache.has(id)) return Promise.resolve(_faCoverApiCache.get(id)); if (_faCoverApiPending.has(id)) return _faCoverApiPending.get(id); var p = new Promise(function(resolve) { GM_xmlhttpRequest({ method: 'GET', url: 'https://store.steampowered.com/api/appdetails?appids=' + id, timeout: 10000, onload: function(resp) { var url = ''; try { var json = JSON.parse(resp.responseText); var d = json && json[id]; if (d && d.success && d.data) { if (d.data.capsule_image) url = d.data.capsule_image; else if (d.data.header_image) url = d.data.header_image; } } catch (e) { console.warn('[FA] 封面图解析失败:', id, e); } _faCoverApiCache.set(id, url); _faCoverApiPending.delete(id); resolve(url); }, onerror: function() { _faCoverApiPending.delete(id); resolve(''); }, ontimeout: function() { _faCoverApiPending.delete(id); resolve(''); } }); }); _faCoverApiPending.set(id, p); return p; } // v1.57: 封面图 CDN 路径列表(参考 steam-friend-manager DASH_CAPSULE_PATHS) // 使用更通用的封面尺寸,解决大面积封面无法获取问题 var FA_COVER_CF = 'https://cdn.cloudflare.steamstatic.com/steam/apps/'; var FA_COVER_AKAMAI = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/'; var FA_COVER_PATHS = [ 'capsule_467x181.jpg', // 大横幅封面(最清晰) 'capsule_231x87.jpg', // 中等横幅 'capsule_231x87.png', // PNG 版本 'capsule_184x69.jpg', // 标准横幅(最通用) 'capsule_184x69.png', // PNG 版本 'header.jpg' // 头图兜底 ]; // 为 元素加载封面图,自动遍历多 CDN fallback 链 // img: 元素;appid: 游戏ID function faLoadCover(img, appid) { if (!img || !appid) return; var id = String(appid); var chain = []; // 优先使用已验证可用的 URL if (_faCoverGood.has(id)) chain.push(_faCoverGood.get(id)); // v1.57: 参考 steam-friend-manager,先用 cloudflare CDN 遍历所有封面路径 FA_COVER_PATHS.forEach(function(p) { chain.push(FA_COVER_CF + id + '/' + p); }); // 再用 akamai CDN 兜底(store_item_assets 路径) chain.push(FA_COVER_AKAMAI + id + '/capsule_184x69.jpg'); chain.push(FA_COVER_AKAMAI + id + '/header.jpg'); var idx = 0; img.onload = function() { // 记录成功 URL,下次直接用 if (img.src && img.src.indexOf('data:image') !== 0) _faCoverGood.set(id, img.src); }; function toPlaceholder() { img.onload = null; img.onerror = null; img.src = FA_COVER_SVG; } function tryNext() { if (idx >= chain.length) { // 所有 CDN 路径都失败,尝试 Steam API faFetchCoverFromApi(id).then(function(url) { if (url) { img.onerror = toPlaceholder; img.src = url; } else toPlaceholder(); }).catch(toPlaceholder); return; } img.onerror = function() { idx++; tryNext(); }; img.src = chain[idx]; } tryNext(); } // 缓存 appid → {n,fp,op,dp,f,t,g,ts},TTL 24h; // 避免每次打开页面都重新跑 80+ 批 appdetails 补全,是加载提速的主要来源。 var FA_WL_META_KEY = 'faWlMetaCache'; var FA_WL_META_TTL = 24 * 60 * 60 * 1000; var faWlMetaCache = null; // 懒加载,避免影响脚本启动 function faWlMetaLoad() { if (faWlMetaCache !== null) return faWlMetaCache; faWlMetaCache = {}; try { var raw = GM_getValue(FA_WL_META_KEY); if (raw && typeof raw === 'object') faWlMetaCache = raw; } catch (e) {} return faWlMetaCache; } function faWlMetaSave() { if (!faWlMetaCache) return; try { var now = Date.now(); var pruned = {}; for (var k in faWlMetaCache) { var r = faWlMetaCache[k]; if (r && r.ts && (now - r.ts < FA_WL_META_TTL * 2)) pruned[k] = r; // 顺带清理过期条目 } faWlMetaCache = pruned; GM_setValue(FA_WL_META_KEY, faWlMetaCache); } catch (e) {} } // 将缓存元数据应用到条目;返回 true 表示条目已具备完整名称 function faWlMetaApply(appid, entry) { var mc = faWlMetaLoad(); var r = mc[appid]; if (!r || !r.ts || (Date.now() - r.ts > FA_WL_META_TTL)) return false; if (r.n) entry.name = r.n; entry.finalPrice = r.fp || 0; entry.originalPrice = r.op || 0; entry.discountPct = r.dp || 0; entry.onSale = !!r.os; entry.isFree = !!r.f; if (r.t) entry.type = r.t; if (Array.isArray(r.g)) entry.tags = r.g; // v1.60:发售状态字段(旧版缓存无此字段 → _releaseKnown=false → 触发一次后台补全迁移, // 与 1.0.8 "旧缓存缺 _releaseKnown 强制重建一次" 同理) entry.isComingSoon = !!r.cs; entry.releaseDate = r.rd || ''; entry._releaseKnown = (r.rk === 1 || r.rk === true); faWlMetaJudgeDone(entry); return !!r.n; } // 将最新数据写入缓存;返回 true 表示发生了变更(用于决定是否落盘) function faWlMetaUpdate(appid, src) { var mc = faWlMetaLoad(); var old = mc[appid]; if (old && old.ts && (Date.now() - old.ts < FA_WL_META_TTL) && old.n === (src.name || '') && old.fp === (src.finalPrice || 0) && old.dp === (src.discountPct || 0)) { return false; // 新鲜且关键字段一致,无需写入 } mc[appid] = { n: src.name || '', fp: src.finalPrice || 0, op: src.originalPrice || 0, dp: src.discountPct || 0, os: src.onSale ? 1 : 0, f: !!src.isFree, t: src.type || '', g: Array.isArray(src.tags) ? src.tags.slice(0, 20) : [], cs: src.isComingSoon ? 1 : 0, rd: src.releaseDate || '', rk: src._releaseKnown ? 1 : 0, ts: Date.now() }; return true; } // 主入口:多源合并拉取成员愿望单 // 返回 [{appid, name, priority, addedDate, finalPrice, originalPrice, discountPct, isFree, type, tags, _metaDone}] function fetchMemberWishlist(steamid) { return new Promise(function(resolve) { // v1.52 修复(参考 steam-game-library-viewer 2.3.29 loadWishlistGames): // SSR 与 wishlistdata API 由"二选一"改为"并行抓取 + 字段级合并"。 // 根因:SSR 的 StoreItem 缓存只覆盖首批可见条目(约60个),此前 SSR 成功即丢弃 API, // 其余条目没有任何名称来源,只能排队等后台 appdetails 补全 → 大量名称停留在占位符。 // 合并后:API 全量名称/价格先打底,SSR 提供 priority/date_added,StoreItem 详情最后覆盖(最准)。 Promise.all([faWlFetchFromPage(steamid), faWlFetchFromApi(steamid)]).then(function(results) { var ssrData = results[0]; var apiEntries = results[1] || []; var ssrEntries = (ssrData && ssrData.entries) ? ssrData.entries : []; var storeItemCache = ssrData ? (ssrData.storeItemCache || {}) : {}; var tagNameMap = ssrData ? (ssrData.tagNameMap || {}) : {}; // API 条目索引(名称/价格/标签的全量来源) var apiMap = {}; apiEntries.forEach(function(e) { if (e && e.appid) apiMap[e.appid] = e; }); var result = []; if (ssrEntries.length > 0) { // 以 SSR 条目为基底(priority/date_added 最可靠) ssrEntries.forEach(function(ssrEntry) { var appid = Number(ssrEntry.appid); var entry = { appid: appid, name: ('App ' + appid), priority: Number(ssrEntry.priority) || 0, addedDate: Number(ssrEntry.added || ssrEntry.date_added) || 0, finalPrice: 0, originalPrice: 0, discountPct: 0, onSale: false, isFree: false, isComingSoon: false, releaseDate: '', _releaseKnown: false, type: 'game', tags: [], _metaDone: false }; // ① API 填补名称/价格/标签(StoreItem 未覆盖的条目主要靠这里) var api = apiMap[appid]; if (api) { if (api.name && api.name !== ('App ' + appid)) entry.name = api.name; entry.finalPrice = api.finalPrice || 0; entry.originalPrice = api.originalPrice || 0; entry.discountPct = api.discountPct || 0; entry.onSale = !!api.onSale; entry.isFree = !!api.isFree; // v1.60:发售状态仅在 API 已权威判定时采纳(_releaseKnown 标记) if (api._releaseKnown) { entry.isComingSoon = !!api.isComingSoon; entry._releaseKnown = true; if (api.releaseDate) entry.releaseDate = api.releaseDate; } if (api.type) entry.type = api.type; if (api.tags && api.tags.length > 0) entry.tags = api.tags; delete apiMap[appid]; } // ② SSR StoreItem 详情覆盖(default_info / best_purchase_option / top_tags,最准) var si = storeItemCache[appid]; if (si) faWlApplyStoreItem(entry, si, tagNameMap); faWlMetaJudgeDone(entry); result.push(entry); }); // ③ 防御性并入:SSR 遗漏但 API 存在的条目(罕见) for (var restId in apiMap) { var rest = apiMap[restId]; faWlMetaJudgeDone(rest); result.push(rest); } console.log('[FA-WL] ' + steamid + ' SSR+API 合并: ' + result.length + ' 条目(SSR ' + ssrEntries.length + ' / API ' + apiEntries.length + ' / StoreItem ' + Object.keys(storeItemCache).length + ')'); } else if (apiEntries.length > 0) { apiEntries.forEach(function(e) { faWlMetaJudgeDone(e); }); result = apiEntries; console.log('[FA-WL] ' + steamid + ' SSR 无数据,API 兜底: ' + result.length + ' 条目'); } else { console.warn('[FA-WL] ' + steamid + ' SSR/API 均无数据'); } resolve(result); }); }); } // 批量补全愿望单游戏的详情(名称、价格、标签、类型)via appdetails // 参考 steam-game-library-viewer: 批量逗号分隔 appid 请求(25个/批),250ms间隔 // 批量请求大幅减少请求数(2000游戏:80批次 vs 2000个单独请求) // onBatchDone(batchNew): v1.52 改为仅回传"本批新增"结果(原为累积全量,O(n²) 重复应用) // v1.52:网络级失败(超时/错误/JSON解析失败)的条目在主流程结束后以小批量(5个/批)重试一轮, // 修复整批失败导致 25 个游戏名称全部停留在占位符的问题; // HTTP 成功但 success:false 的条目为下架/锁区等真实不可用,不重试。 function enrichWishlistMeta(appids, onProgress, onBatchDone) { return new Promise(function(resolve) { var results = {}; if (!appids || appids.length === 0) { resolve(results); return; } var BATCH_SIZE = 25; var BATCH_INTERVAL = 250; var completed = 0; var total = appids.length; var netFailed = {}; // aid -> true(网络级失败,值得重试) // 单批请求:解析响应 → 写入 results/batchNew → 回调 function requestBatch(batch, markNetFail, doneCb) { var reqUrl = 'https://store.steampowered.com/api/appdetails?appids=' + batch.join(',') + '&l=schinese&cc=' + (faCurrency.cc || 'cn'); GM_xmlhttpRequest({ method: 'GET', url: reqUrl, timeout: 30000, onload: function(resp) { var batchNew = {}; var parseOk = false; try { var data = JSON.parse(resp.responseText); parseOk = true; batch.forEach(function(aid) { var appData = data[String(aid)] || data[aid]; if (appData && appData.success && appData.data) { var d = appData.data; var po = d.price_overview; var fPrice = po ? (Number(po.final) || 0) / 100 : 0; var oPrice = po ? (Number(po.initial) || 0) / 100 : 0; var discPct = po ? (Number(po.discount_percent) || 0) : 0; // v1.43:有折扣但原价缺失/不合法时,由折扣价反推原价 if (discPct > 0 && discPct < 100 && (!oPrice || oPrice <= fPrice)) { oPrice = fPrice / (1 - discPct / 100); } results[aid] = { name: d.name || '', finalPrice: isNaN(fPrice) ? 0 : fPrice, originalPrice: isNaN(oPrice) ? 0 : oPrice, discountPct: discPct, onSale: discPct > 0, isFree: d.is_free || false, type: d.type || '', tags: (d.genres || []).map(function(g) { return g.description; }).filter(Boolean), // v1.60(参考 1.0.8):release_date.coming_soon 为权威判定(可回退 true→false) isComingSoon: (d.release_date && typeof d.release_date.coming_soon !== 'undefined') ? !!d.release_date.coming_soon : false, releaseDate: (d.release_date && d.release_date.date) || '', _releaseKnown: !!(d.release_date && typeof d.release_date.coming_soon !== 'undefined') }; batchNew[aid] = results[aid]; // v1.54:同步写入中文名缓存,避免 faLoadGameZhName 对已补全的游戏重复请求 appdetails if (d.name) { var nc = faGameNameCacheLoad(); nc[String(aid)] = { name: d.name, ts: Date.now() }; } } }); } catch(e){console.warn('[FA]', e)} // v1.54:批量写入后统一落盘一次,避免 forEach 内每条都调 GM_setValue try { faGameNameCacheSave(); } catch(e2){console.warn('[FA]', e2)} if (!parseOk && markNetFail) batch.forEach(function(aid) { netFailed[aid] = true; }); if (onBatchDone) onBatchDone(batchNew); doneCb(); }, onerror: function() { if (markNetFail) batch.forEach(function(aid) { netFailed[aid] = true; }); if (onBatchDone) onBatchDone({}); doneCb(); }, ontimeout: function() { if (markNetFail) batch.forEach(function(aid) { netFailed[aid] = true; }); if (onBatchDone) onBatchDone({}); doneCb(); } }); } // 主批量流程 var batchIdx = 0; var batches = []; for (var i = 0; i < appids.length; i += BATCH_SIZE) { batches.push(appids.slice(i, i + BATCH_SIZE)); } function processNextBatch() { if (batchIdx >= batches.length) { retryNetFailed(); return; } var batch = batches[batchIdx++]; requestBatch(batch, true, function() { completed += batch.length; if (onProgress) onProgress(completed, total); setTimeout(processNextBatch, BATCH_INTERVAL); }); } // v1.52:网络失败条目重试一轮(5个/批,400ms间隔) function retryNetFailed() { var failed = Object.keys(netFailed).map(Number).filter(function(aid) { return !results[aid]; }); if (failed.length === 0) { resolve(results); return; } console.log('[FA-WL] appdetails 重试 ' + failed.length + ' 个网络失败条目'); var rIdx = 0; var retryBatches = []; for (var j = 0; j < failed.length; j += 5) retryBatches.push(failed.slice(j, j + 5)); function processNextRetry() { if (rIdx >= retryBatches.length) { resolve(results); return; } var batch = retryBatches[rIdx++]; requestBatch(batch, false, function() { setTimeout(processNextRetry, 400); }); } processNextRetry(); } processNextBatch(); }); } // v1.60 新增(参考 Steam-Wishlist-Sidebar 1.0.9 enrichFromStoreBrowse): // IStoreBrowseService/GetItems 批量补全(实测匿名可用,100 个 appid/批、3 路并发), // 一次请求即可拿到名称/价格/折扣/发售状态,替代绝大多数 appdetails 批量请求,大幅缩短补全时间。 // onBatchDone(batchNew) 回传本批增量(与 enrichWishlistMeta 语义一致)。 function faWlEnrichFromStoreBrowse(appids, onProgress, onBatchDone) { return new Promise(function(resolve) { var results = {}; if (!appids || appids.length === 0) { resolve(results); return; } var CHUNK = 100, CONCURRENCY = 3; var chunks = []; for (var i = 0; i < appids.length; i += CHUNK) chunks.push(appids.slice(i, i + CHUNK)); var qIdx = 0, processed = 0, total = appids.length; var cc = faCurrency.cc || 'cn'; function worker() { if (qIdx >= chunks.length) return Promise.resolve(); var chunk = chunks[qIdx++]; var input = { ids: chunk.map(function(a) { return { appid: Number(a) }; }), context: { language: 'schinese', country_code: cc, steam_realm: 1 }, data_request: { include_release: true, include_all_purchase_options: true } }; var reqUrl = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input)); return faGmGetJson(reqUrl, 20000).then(function(resp) { var batchNew = {}; var storeItems = (resp && resp.response && resp.response.store_items) || []; var byId = {}; storeItems.forEach(function(si) { if (si && si.appid) byId[si.appid] = si; }); chunk.forEach(function(aid) { var si = byId[aid]; // 注意:success===1 才应用(参考 1.0.9);下架/锁区条目静默跳过,留给 appdetails 兜底 if (si && (si.success === 1 || si.success === true)) { var r = faWlParseStoreBrowseItem(si); results[aid] = r; batchNew[aid] = r; // 同步写入中文名缓存(与 enrichWishlistMeta 一致),避免重复请求 appdetails if (r.name) { var nc = faGameNameCacheLoad(); nc[String(aid)] = { name: r.name, ts: Date.now() }; } } processed++; }); try { faGameNameCacheSave(); } catch (e2) {} if (onBatchDone) onBatchDone(batchNew); if (onProgress) onProgress(processed, total); // 小间隔保护(GetItems 为正式 WebAPI,容忍度高于 appdetails,150ms 即可) return new Promise(function(r2) { setTimeout(r2, 150); }).then(worker); }); } var workers = []; for (var w = 0; w < CONCURRENCY; w++) workers.push(worker()); Promise.all(workers).then(function() { resolve(results); }); }); } // v1.60:解析 GetItems 单个 StoreItem → 统一结果结构 //(_priceKnown/_releaseKnown 为权威覆盖标记,应用方据此可纠正陈旧缓存) function faWlParseStoreBrowseItem(si) { var r = { name: '', type: '', isFree: undefined, _priceKnown: false, finalPrice: 0, originalPrice: 0, discountPct: 0, onSale: false, _releaseKnown: false, isComingSoon: false, releaseDate: '' }; if (!si) return r; if (si.name) r.name = String(si.name); // 注意:GetItems 的 type 是数值枚举(0=game),需映射为字符串(参考 1.0.9,不可直接覆盖字符串字段) if (typeof si.type === 'number') { var typeMap = { 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' }; r.type = typeMap[si.type] || 'other'; } if (typeof si.is_free !== 'undefined') r.isFree = !!si.is_free; // 价格与折扣(best_purchase_option 结构与页面 SSR default_info 一致) var bpo = si.best_purchase_option; if (bpo) { var fp = (Number(bpo.final_price_in_cents) || 0) / 100; var op = (Number(bpo.original_price_in_cents) || 0) / 100; if (op <= 0) op = fp; var dp = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0); if (!isFinite(fp) || fp < 0) fp = 0; if (!isFinite(op) || op < 0) op = fp; if (!isFinite(dp) || dp < 0 || dp > 100) dp = 0; if (dp > 0 && dp < 100 && op <= fp) op = fp / (1 - dp / 100); r.finalPrice = Math.round(fp * 100) / 100; r.originalPrice = Math.round(op * 100) / 100; r.discountPct = dp; r.onSale = dp > 0 || !!(bpo.active_discounts && bpo.active_discounts.length); if (fp === 0 && dp === 0) r.isFree = true; r._priceKnown = true; } // 发售状态:release 子对象与顶层 is_coming_soon 互为冗余(实测:未发售=true,已发售无此键) var rel = si.release || null; var soon = (si.is_coming_soon === true) || (rel && rel.is_coming_soon === true); var rdTs = rel ? (Number(rel.steam_release_date) || 0) : 0; if (!soon && rdTs * 1000 > Date.now()) soon = true; if (rel || typeof si.is_coming_soon !== 'undefined') { r.isComingSoon = !!soon; r._releaseKnown = true; if (rdTs > 0) r.releaseDate = new Date(rdTs * 1000).toISOString().slice(0, 10); } return r; } // ===================== v1.62 共享冷却追踪(参考 sffxzzp Family Sharing Cooldown 追踪模式) ===================== // 冷却记录持久化到 GM_setValue,记录每次借出/归还时间戳,计算剩余冷却时间并以倒计时展示 var FA_COOLDOWN_KEY = 'faCooldownRegistry'; var FA_COOLDOWN_DURATION = 24 * 60 * 60; // 24 小时冷却期(秒) function faCooldownLoad() { var cache = {}; try { var raw = GM_getValue(FA_COOLDOWN_KEY); if (raw && typeof raw === 'object') cache = raw; } catch (e) {} return cache; } function faCooldownSave(cache) { try { GM_setValue(FA_COOLDOWN_KEY, cache || {}); } catch (e) {} } // 清理已过期的冷却记录(冷却结束时间 <= 当前时间 → 移除,自动移至"可借用"列表) function faCooldownPrune() { var reg = faCooldownLoad(); var now = Math.floor(Date.now() / 1000); var changed = false; for (var k in reg) { var r = reg[k]; if (r && r.cooldownEndsAt && r.cooldownEndsAt <= now) { r.returnTime = r.cooldownEndsAt; // 记录归还时间戳 delete reg[k]; changed = true; } } if (changed) faCooldownSave(reg); return changed; } // 标记某游戏借出(启动 24h 冷却倒计时) function faCooldownMarkBorrow(appid, name, borrower, owners) { var reg = faCooldownLoad(); var now = Math.floor(Date.now() / 1000); reg[appid] = { appid: Number(appid), name: name || ('App ' + appid), borrower: borrower || '', borrowTime: now, // 借出时间戳 returnTime: 0, // 归还时间戳(0 表示尚未归还) cooldownEndsAt: now + FA_COOLDOWN_DURATION, // 冷却结束时间 owners: owners || [], lastUpdated: Date.now() }; faCooldownSave(reg); } // 结束冷却(手动移除记录) function faCooldownRemove(appid) { var reg = faCooldownLoad(); if (reg[appid]) { reg[appid].returnTime = Math.floor(Date.now() / 1000); delete reg[appid]; faCooldownSave(reg); } } // 格式化剩余冷却时间为 HH:MM:SS function faCooldownFormat(sec) { if (sec <= 0) return '已结束'; var h = Math.floor(sec / 3600); var m = Math.floor((sec % 3600) / 60); var s = Math.floor(sec % 60); return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; } // ===================== v1.62 DLC 拥有状态检查(参考 sffxzzp Store DLC Checker 折叠/筛选模式) ===================== // 在 store.steampowered.com/app/* 页面检测 DLC 列表,逐个调用 appuserdetails 检查拥有状态, // 为已拥有 DLC 添加绿色勾选标记,默认折叠隐藏,提供"显示已拥有 DLC"/"仅显示未拥有"切换。 // 缓存到 GM_setValue(key: dlc_owned_{appid},TTL 24 小时) var FA_DLC_OWNED_TTL = 24 * 60 * 60 * 1000; // 24 小时 var _faDlcLastReq = 0; // 上次请求时间戳(预留位限速) var FA_DLC_REQ_INTERVAL = 1000; // 请求间隔 1 秒 var _faDlcFilterSet = new Set(); // 共享默认 filter set,必须 new Set 实例,防止跨组件引用共享 // 读取单个 DLC 拥有状态缓存(key: dlc_owned_{appid}),返回 true/false/null function faDlcOwnedGet(dlcAppid) { try { var rec = GM_getValue('dlc_owned_' + dlcAppid); if (rec && typeof rec === 'object' && rec.ts && (Date.now() - rec.ts < FA_DLC_OWNED_TTL)) { return rec.owned === true; } } catch (e) {} return null; } // 写入单个 DLC 拥有状态缓存 function faDlcOwnedSet(dlcAppid, owned) { try { GM_setValue('dlc_owned_' + dlcAppid, { owned: !!owned, ts: Date.now() }); } catch (e) {} } // 预留位限速:在 sleep 前更新 _faDlcLastReq,防止并发 worker 竞态同时触发请求 function faDlcThrottle() { var now = Date.now(); var earliest = _faDlcLastReq + FA_DLC_REQ_INTERVAL; if (now >= earliest) { _faDlcLastReq = now; // 立即触发,占用当前槽位 return Promise.resolve(); } _faDlcLastReq = earliest; // 预留下一个槽位,再 sleep var wait = earliest - now; return new Promise(function(r) { setTimeout(r, wait); }); } // 通过 appuserdetails 检查单个 DLC 拥有状态(不支持逗号分隔批量,必须逐个请求) // URL 使用字符串拼接(不使用 new URL() 处理相对路径) function faDlcCheckOwned(dlcAppid) { var cached = faDlcOwnedGet(dlcAppid); if (cached !== null) return Promise.resolve(cached); var reqUrl = 'https://store.steampowered.com/api/appuserdetails?appids=' + dlcAppid; return faDlcThrottle().then(function() { return faGmGetJson(reqUrl, 10000); }).then(function(resp) { var owned = false; try { var d = resp && resp[String(dlcAppid)]; if (d && d.success && d.data) { // appuserdetails 返回 ownership/purchased 字段标识拥有状态 owned = !!(d.data.owned === true || d.data.purchased || (d.data.ownership && d.data.ownership.owned)); } } catch (e) { console.warn('[FA] DLC 拥有状态解析失败:', dlcAppid, e); } faDlcOwnedSet(dlcAppid, owned); return owned; }); } // 应用拥有状态到 DLC 行(标记 + 可见性) function faDlcApplyOwned(row, owned) { if (!row) return; row.setAttribute('data-fa-dlc-owned', owned ? '1' : '0'); var mark = row.querySelector('.fa-dlc-owned-mark'); if (mark) mark.style.display = owned ? 'inline' : 'none'; faDlcApplyDlcVisibility(); } // 根据 filter set 应用 DLC 行可见性(默认折叠已拥有) function faDlcApplyDlcVisibility() { var showOwned = _faDlcFilterSet.has('show_owned'); var onlyUnowned = _faDlcFilterSet.has('only_unowned'); document.querySelectorAll('[data-fa-dlc-appid]').forEach(function(row) { var owned = row.getAttribute('data-fa-dlc-owned') === '1'; var hide = false; if (owned && !showOwned) hide = true; // 默认折叠已拥有 DLC if (owned && onlyUnowned) hide = true; // "仅显示未拥有"时隐藏已拥有 row.style.display = hide ? 'none' : ''; }); } // 更新 DLC 拥有状态汇总 function faDlcUpdateSummary() { var rows = document.querySelectorAll('[data-fa-dlc-appid]'); var owned = 0, total = 0; rows.forEach(function(r) { total++; if (r.getAttribute('data-fa-dlc-owned') === '1') owned++; }); var el = document.getElementById('fa_dlc_summary'); if (el) el.textContent = '已拥有 ' + owned + ' / ' + total + ' 个 DLC'; } // 绑定工具栏按钮(幂等) function faDlcBindToolbar() { var toggleBtn = document.getElementById('fa_dlc_toggle_owned'); var filterBtn = document.getElementById('fa_dlc_filter_unowned'); if (toggleBtn && !toggleBtn.getAttribute('data-fa-bound')) { toggleBtn.setAttribute('data-fa-bound', '1'); toggleBtn.addEventListener('click', function() { if (_faDlcFilterSet.has('show_owned')) { _faDlcFilterSet.delete('show_owned'); toggleBtn.classList.remove('active'); } else { _faDlcFilterSet.add('show_owned'); toggleBtn.classList.add('active'); } faDlcApplyDlcVisibility(); }); } if (filterBtn && !filterBtn.getAttribute('data-fa-bound')) { filterBtn.setAttribute('data-fa-bound', '1'); filterBtn.addEventListener('click', function() { if (_faDlcFilterSet.has('only_unowned')) { _faDlcFilterSet.delete('only_unowned'); filterBtn.classList.remove('active'); } else { _faDlcFilterSet.add('only_unowned'); filterBtn.classList.add('active'); } faDlcApplyDlcVisibility(); }); } } // 处理 DLC 行列表:注入工具栏、DOM 预标记、3 并发 worker 逐个 API 检查 function faDlcProcessRows(dlcItems) { var dlcList = document.querySelector('#game_area_dlc_list, .game_area_dlc_list'); if (!dlcList) return; // 注入工具栏(幂等) if (!document.getElementById('fa_dlc_toolbar')) { var toolbar = document.createElement('div'); toolbar.id = 'fa_dlc_toolbar'; toolbar.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:8px 10px;margin-bottom:8px;background:rgba(15,23,42,.6);border:1px solid rgba(255,255,255,.08);border-radius:8px;'; toolbar.innerHTML = 'DLC 拥有状态' + '' + '' + ''; dlcList.parentNode.insertBefore(toolbar, dlcList); } // 初始化每行:添加"✓ 已拥有"标记容器 dlcItems.forEach(function(item) { if (item.row.querySelector('.fa-dlc-owned-mark')) return; var mark = document.createElement('span'); mark.className = 'fa-dlc-owned-mark'; mark.style.cssText = 'display:none;margin-left:6px;font-size:11px;color:#5a8f3a;font-weight:600;vertical-align:middle;'; mark.innerHTML = '✓ 已拥有'; var label = item.row.querySelector('.game_area_dlc_name, .ds_flag, a') || item.row; label.appendChild(mark); item.row.setAttribute('data-fa-dlc-appid', item.appid); item.row.setAttribute('data-fa-dlc-owned', '0'); }); // DOM 快速预标记:Steam 原生 ds_owned 标识已拥有,直接写入缓存并应用 dlcItems.forEach(function(item) { if (item.row.classList.contains('ds_owned') || item.row.querySelector('.ds_owned_flag')) { faDlcOwnedSet(item.appid, true); faDlcApplyOwned(item.row, true); } }); // 3 个并发 worker 逐个请求 appuserdetails(请求间隔 1 秒,预留位限速) var idx = 0; var total = dlcItems.length; function worker() { if (idx >= total) return Promise.resolve(); var item = dlcItems[idx++]; // 已由 DOM 确认拥有的跳过 API 请求 if (item.row.getAttribute('data-fa-dlc-owned') === '1') { return worker(); } return faDlcCheckOwned(item.appid).then(function(owned) { faDlcOwnedSet(item.appid, owned); faDlcApplyOwned(item.row, owned); faDlcUpdateSummary(); return worker(); }); } var workers = []; for (var w = 0; w < 3; w++) workers.push(worker()); Promise.all(workers).then(function() { faDlcUpdateSummary(); faDlcApplyDlcVisibility(); }); faDlcBindToolbar(); faDlcUpdateSummary(); faDlcApplyDlcVisibility(); } // DLC 拥有状态检查入口:在 app 页面检测 DLC 列表并启动检查 function faCheckDlcOwnership() { if (!url || !url.startsWith('store.steampowered.com/app/')) return; if (typeof g_AccountID !== 'undefined' && g_AccountID == 0) return; _faDlcFilterSet = new Set(); // 每次进入页面重置 filter set 为新 Set 实例 var started = false; function run() { var dlcList = document.querySelector('#game_area_dlc_list, .game_area_dlc_list'); if (!dlcList) return; var rows = dlcList.querySelectorAll('.game_area_dlc_row'); if (rows.length === 0) return; var dlcItems = []; rows.forEach(function(row) { var dsAppid = row.getAttribute('data-ds-appid'); if (!dsAppid) return; var aid = Number(dsAppid.split(',')[0].trim()); if (!aid || isNaN(aid)) return; dlcItems.push({ row: row, appid: aid }); }); if (dlcItems.length === 0) return; started = true; faDlcProcessRows(dlcItems); } run(); // 覆盖懒加载:多次重试 if (!started) { setTimeout(run, 1500); setTimeout(run, 3000); setTimeout(run, 5000); } } function readstorage(){ let newsaves = { version : 20240501, familyGameList:{"GameList":[],"GameInfo":{}}, familyInfo:{"family_groupid":null, "family_name":null, "family_member":{}, "steamIdtoName":{}}, familyWishlist:{"GameList":[],"GameInfo":{}, "lastUpdated":0}, lastupDateTime:0, steamid:"", settings:{isAutoScan:true} } try{ var save = GM_getValue('saves') if(save !== undefined){ if(save.version == newsaves.version){ saves = save }else{ // v1.39 修复:版本不匹配时清除旧缓存并写入新空存档,避免下次读取仍报错 isupdate=true GM_deleteValue('saves'); saves = newsaves; GM_setValue('saves', newsaves); if(window.location.host == "store.steampowered.com"){ ShowConfirmDialog('脚本提示','脚本缓存列表结构升级,缓存的家庭库列表需要重新扫描!','扫描家庭库','稍后').done(()=>{scan(true)}).fail(()=>{ ShowAlertDialog('脚本提示','稍后可在Steam主页右上角「我的家庭库」入口手动扫描','好的') }) }else if(window.location.host == "keylol.com"){ if (save?.noPrompt == null || save?.noPrompt == false){ alert("家庭游戏标记脚本提示:脚本缓存列表结构升级,缓存的家庭库列表需要重新扫描,请进入steam商店页面按提示扫描!") saves.noPrompt = true savestorage() } } } }else{ if(window.location.host == "keylol.com"){ alert("家庭游戏标记脚本提示:您好像是第一次启动脚本,请进入steam商店页面按提示扫描家庭库!") } isNewUser = true GM_setValue('saves',newsaves) saves = newsaves } }catch(e){ // v1.39 修复:缓存数据损坏时,清除旧缓存并写入新的空存档,避免反复触发读取错误 console.error('[家庭库脚本] 缓存读取异常,已清除旧缓存:', e); GM_deleteValue('saves'); saves = newsaves; GM_setValue('saves', newsaves); isupdate = true; if(window.location.host == "store.steampowered.com"){ ShowConfirmDialog('脚本提示','缓存数据异常已自动清除,需要重新扫描家庭库!','扫描家庭库','稍后').done(()=>{scan(true)}).fail(()=>{ ShowAlertDialog('脚本提示','稍后可在Steam主页右上角「我的家庭库」入口手动扫描','好的') }) } } } function savestorage(isdelete){ if(isdelete) { GM_deleteValue('saves') saves = null return; } GM_setValue('saves',saves) } function timestampToTime(timestamp) { if(timestamp == 0){return '无记录'} timestamp = timestamp ? timestamp : null; timestamp *= 1000 let date = new Date(timestamp);//时间戳为10位需*1000,时间戳为13位的话不需乘1000 let Y = date.getFullYear() + '-'; let M = (date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1) + '-'; let D = (date.getDate() < 10 ? '0' + date.getDate() : date.getDate()) + ' '; let h = (date.getHours() < 10 ? '0' + date.getHours() : date.getHours()) + ':'; let m = (date.getMinutes() < 10 ? '0' + date.getMinutes() : date.getMinutes()) + ':'; let s = date.getSeconds() < 10 ? '0' + date.getSeconds() : date.getSeconds(); return Y + M + D + h + m + s; } // ===================== v1.38 高级分析函数 ===================== // v1.41 性能优化:单次遍历 gameInfo,同时计算家庭整体 + 各成员热力图数据 // 避免对每个成员都遍历全部 gameInfo(N成员 × M游戏 → 1 × M) function computeAllHeatmaps(gameInfo, members) { var familyDayMap = {}; var memberDayMaps = {}; var familyValid = []; var memberValid = {}; members.forEach(function(m) { memberDayMaps[m.steamid] = {}; memberValid[m.steamid] = []; }); for (var appid in gameInfo) { var gi = gameInfo[appid]; var t = gi.time; if (!t || t <= 0) continue; // 日期 key 只计算一次 var d = new Date(t * 1000); var key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); // 家庭整体 familyDayMap[key] = (familyDayMap[key] || 0) + 1; familyValid.push(t); // 各成员 if (gi.owners) { for (var oi = 0; oi < gi.owners.length; oi++) { var sid = gi.owners[oi]; if (memberDayMaps[sid]) { memberDayMaps[sid][key] = (memberDayMaps[sid][key] || 0) + 1; memberValid[sid].push(t); } } } } function buildResult(dayMap, valid) { if (valid.length === 0) return null; valid.sort(function(a, b) { return a - b; }); var maxDaily = 0, total = 0; for (var k in dayMap) { if (dayMap[k] > maxDaily) maxDaily = dayMap[k]; total += dayMap[k]; } var firstTs = valid[0], lastTs = valid[valid.length - 1]; var spanDays = Math.max(1, Math.ceil((lastTs - firstTs) / 86400) + 1); return { dayMap: dayMap, maxDaily: maxDaily, total: total, avgDaily: total / spanDays, firstTs: firstTs, lastTs: lastTs }; } var familyHm = buildResult(familyDayMap, familyValid); var memberHms = {}; members.forEach(function(m) { memberHms[m.steamid] = buildResult(memberDayMaps[m.steamid], memberValid[m.steamid]); }); return { family: familyHm, members: memberHms }; } // 计算每日入库热力图数据(保留兼容,内部委托给 computeAllHeatmaps) function computeFamilyDailyHeatmap(gameInfo) { var dayMap = {}, valid = []; for (var appid in gameInfo) { var t = gameInfo[appid].time; if (t && t > 0) { valid.push(t); var d = new Date(t * 1000); var key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); dayMap[key] = (dayMap[key] || 0) + 1; } } if (valid.length === 0) return null; valid.sort(function(a, b) { return a - b; }); var maxDaily = 0, total = 0; for (var k in dayMap) { if (dayMap[k] > maxDaily) maxDaily = dayMap[k]; total += dayMap[k]; } var firstTs = valid[0], lastTs = valid[valid.length - 1]; var spanDays = Math.max(1, Math.ceil((lastTs - firstTs) / 86400) + 1); return { dayMap: dayMap, maxDaily: maxDaily, total: total, avgDaily: total / spanDays, firstTs: firstTs, lastTs: lastTs }; } // 计算单个成员的每日入库热力图数据(保留兼容) function computeMemberDailyHeatmap(gameInfo, steamid) { var dayMap = {}, valid = []; for (var appid in gameInfo) { var gi = gameInfo[appid]; if (gi.owners && gi.owners.indexOf(steamid) !== -1 && gi.time && gi.time > 0) { valid.push(gi.time); var d = new Date(gi.time * 1000); var key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); dayMap[key] = (dayMap[key] || 0) + 1; } } if (valid.length === 0) return null; valid.sort(function(a, b) { return a - b; }); var maxDaily = 0, total = 0; for (var k in dayMap) { if (dayMap[k] > maxDaily) maxDaily = dayMap[k]; total += dayMap[k]; } var firstTs = valid[0], lastTs = valid[valid.length - 1]; var spanDays = Math.max(1, Math.ceil((lastTs - firstTs) / 86400) + 1); return { dayMap: dayMap, maxDaily: maxDaily, total: total, avgDaily: total / spanDays, firstTs: firstTs, lastTs: lastTs }; } // 计算成员活跃度与健康分 function computeMemberActivity(familyInfo, gameInfo) { var members = (familyInfo && Array.isArray(familyInfo.family_member)) ? familyInfo.family_member : []; var idMap = (familyInfo && familyInfo.steamIdtoName) ? familyInfo.steamIdtoName : {}; var nowSec = Date.now() / 1000; var result = { members: [], activeCount: 0, warmCount: 0, coldCount: 0, dormantCount: 0, healthScore: 0, statusLabels: { active: '活跃', warm: '温热', cold: '冷淡', dormant: '沉睡' } }; var nameOf = function(m) { return m.userName || idMap[m.steamid] || ('ID:' + String(m.steamid).slice(-4)); }; members.forEach(function(m) { var sid = m.steamid, games = 0, times = []; for (var appid in gameInfo) { if (gameInfo[appid].owners && gameInfo[appid].owners.indexOf(sid) !== -1) { games++; if (gameInfo[appid].time > 0) times.push(gameInfo[appid].time); } } var latestTime = times.length > 0 ? Math.max.apply(null, times) : -1; var firstTime = times.length > 0 ? Math.min.apply(null, times) : -1; var daysSinceLatest = latestTime > 0 ? Math.floor((nowSec - latestTime) / 86400) : -1; var status, statusLabel; if (daysSinceLatest < 0) { status = 'dormant'; statusLabel = '从未入库'; result.dormantCount++; } else if (daysSinceLatest < 14) { status = 'active'; statusLabel = '活跃'; result.activeCount++; } else if (daysSinceLatest < 60) { status = 'warm'; statusLabel = '温热'; result.warmCount++; } else if (daysSinceLatest < 180) { status = 'cold'; statusLabel = '冷淡'; result.coldCount++; } else { status = 'dormant'; statusLabel = '沉睡'; result.dormantCount++; } var libMonths = firstTime > 0 ? Math.max(1, (nowSec - firstTime) / 2592000) : 1; result.members.push({ steamid: sid, name: nameOf(m), total: games, latestTime: latestTime, daysSinceLatest: daysSinceLatest, status: status, statusLabel: statusLabel, monthlyAvg: (games / libMonths).toFixed(1) }); }); var scoreMap = { active: 100, warm: 75, cold: 50, dormant: 25 }; var totalScore = 0, cnt = 0; result.members.forEach(function(m) { totalScore += scoreMap[m.status]; cnt++; }); result.healthScore = cnt > 0 ? Math.round(totalScore / cnt) : 0; var order = { active: 0, warm: 1, cold: 2, dormant: 3 }; result.members.sort(function(a, b) { return order[a.status] - order[b.status] || b.total - a.total; }); return result; } // 计算成员共同游戏矩阵 function computeMemberOverlapMatrix(gameInfo, members, idMap) { var n = members.length; var matrix = []; for (var i = 0; i < n; i++) matrix.push(new Array(n).fill(0)); var nameOf = function(m) { return m.userName || (idMap && idMap[m.steamid]) || ('ID:' + String(m.steamid).slice(-4)); }; var memberSets = members.map(function(m) { var set = {}; for (var appid in gameInfo) { if (gameInfo[appid].owners && gameInfo[appid].owners.indexOf(m.steamid) !== -1) set[appid] = true; } return set; }); for (var i = 0; i < n; i++) { for (var j = i + 1; j < n; j++) { var shared = 0; for (var appid in memberSets[i]) { if (memberSets[j][appid]) shared++; } matrix[i][j] = shared; matrix[j][i] = shared; } } var pairs = []; for (var i = 0; i < n; i++) { for (var j = i + 1; j < n; j++) { pairs.push({ a: i, b: j, aName: nameOf(members[i]), bName: nameOf(members[j]), sharedGames: matrix[i][j] }); } } pairs.sort(function(a, b) { return b.sharedGames - a.sharedGames; }); return { matrix: matrix, pairs: pairs }; } // 计算成员雷达数据(归一化 0-1) function computeMemberRadar(gameInfo, members, idMap) { var nowSec = Date.now() / 1000; var nameOf = function(m) { return m.userName || (idMap && idMap[m.steamid]) || ('ID:' + String(m.steamid).slice(-4)); }; var raw = members.map(function(m) { var sid = m.steamid, total = 0, solo = 0, shared = 0, times = []; for (var appid in gameInfo) { var g = gameInfo[appid]; if (g.owners && g.owners.indexOf(sid) !== -1) { total++; if (g.owners.length === 1) solo++; else shared++; if (g.time > 0) times.push(g.time); } } var firstTime = times.length > 0 ? Math.min.apply(null, times) : nowSec; var libMonths = Math.max(1, (nowSec - firstTime) / 2592000); return { name: nameOf(m), total: total, solo: solo, shared: shared, monthlyAvg: total / libMonths, shareRate: total > 0 ? shared / total : 0, libAge: (nowSec - firstTime) / 2592000 }; }); var maxT = 1, maxS = 1, maxSh = 1, maxM = 0.0001, maxA = 0.0001; raw.forEach(function(r) { if (r.total > maxT) maxT = r.total; if (r.solo > maxS) maxS = r.solo; if (r.shared > maxSh) maxSh = r.shared; if (r.monthlyAvg > maxM) maxM = r.monthlyAvg; if (r.libAge > maxA) maxA = r.libAge; }); return raw.map(function(r) { return { name: r.name, totalGames: r.total / maxT, soloCount: r.solo / maxS, sharedCount: r.shared / maxSh, monthlyAvg: r.monthlyAvg / maxM, shareRate: r.shareRate, libraryAge: r.libAge / maxA }; }); }