// ==UserScript== // @name Steam 好友管理器 // @namespace steam-friend-manager // @version 1.3.3 // @description 在 Steam 好友页面增强展示好友天数、国家/地区旗帜、VAC封禁、最近在线、在线状态。提供控制浮窗、好友总览(个人信息+KPI+筛选表格一体化)、家庭组浮窗、个人游戏库浮窗。支持好友动态监控、入库/游玩动态追踪、绝版收藏检测、共同好友查询。社交仪表盘浮窗:好友游戏时长排行、热门游戏分布图、游戏时长热力图、游戏总数排行。v1.2.5合并好友列表与数据大盘为紧凑单页。 // @author SmallFork & SmallRob & Antigravity // @license MIT // @match https://steamcommunity.com/*/friends* // @connect api.steampowered.com // @connect steampowered.com // @connect store.steampowered.com // @connect steamcommunity.com // @connect raw.githubusercontent.com // @resource delistedData https://raw.githubusercontent.com/SmallFork/json/main/steam_delisted_apps.json // @resource stressData https://raw.githubusercontent.com/SmallFork/json/main/2026.json // @resource seriesData https://raw.githubusercontent.com/SmallFork/json/main/game_series.json // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_getResourceText // @run-at document-idle // @tag Steam // @tag games // @icon data:image/svg+xml, // ==/UserScript== (function () { 'use strict'; const _v = (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) || 'unknown'; console.log(`%c[Steam 好友管理器] v${_v} 已启动`, 'color:#66c0f4;font-weight:bold;font-size:13px'); // ==================== 全局常量配置 ==================== // ===== UI 提示与动画时长(ms)===== const TOAST_DURATION = 3000; // Toast 显示时长 const TOAST_FADE_MS = 300; // Toast 淡出动画时长 const RENDER_THROTTLE_MS = 800; // 批量查询时 DOM 刷新节流间隔(避免频繁重绘) // ===== API 批量请求并发与批次大小 ===== const MUTUAL_CONCURRENCY = 20; // 共同好友查询:智能并发池初始并发数(遇429自动降速) const VAC_BATCH_SIZE = 100; // VAC 封禁查询:每次 API 请求的好友数(GetPlayerBans 上限100) const LEVEL_CONCURRENCY = 30; // 等级查询:智能并发池并发数(GetSteamLevel 不支持批量,单次1个) const SUMMARY_BATCH_SIZE = 100; // 玩家摘要查询:每次 API 请求的好友数(GetPlayerSummaries 上限100) // ==================== 共享常量 ==================== // 时间常量(秒) const SECONDS_PER_MINUTE = 60; const SECONDS_PER_HOUR = 3600; const SECONDS_PER_DAY = 86400; const SECONDS_PER_MONTH = 2592000; // 30天 const SECONDS_PER_YEAR = 31536000; // 365天 const CONTRIB_DAYS = 14; // 近期贡献天数 // 许可页分页加载上限 const MAX_LICENSE_PAGES = 50; // 缓存 TTL(毫秒) const NAME_CACHE_TTL = 15 * 864e5; // 游戏中文名缓存15天 const DASH_FRIENDS_TTL = 12 * 60 * 60 * 1000; // 仪表盘好友数据缓存12小时 const OWNED_GAMES_TTL = 12 * 60 * 60 * 1000; // 拥有游戏列表缓存12小时 const RECENT_TTL = 24 * 60 * 60 * 1000; // 最近游玩记录缓存1天 const SUMMARIES_TTL = 5 * 60 * 1000; // 玩家摘要缓存5分钟 const FAMILY_PLAY_TTL = 24 * 60 * 60 * 1000; // 家庭组游玩动态缓存1天 const WISHLIST_TTL = 7 * 24 * 60 * 60 * 1000; // 家庭愿望单缓存1周 const FAMILY_REFRESH_COOLDOWN = 30 * 60 * 1000; // 家庭组后台刷新冷却时间30分钟(冷却时间内有缓存不触发后台网络刷新) const CHART_COLORS = ['#06cfbe', '#54a0ff', '#ff9f43', '#2ed573', '#ff6b6b', '#a29bfe', '#ffcd56']; const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent(''); // 按拥有者数量分6档配色(1人独占~6人共享) const OWNER_COLORS = [ { color: '#06cfbe', bg: 'rgba(6,207,190,0.06)', border: 'rgba(6,207,190,0.35)' }, // 1人 { color: '#54a0ff', bg: 'rgba(84,160,255,0.06)', border: 'rgba(84,160,255,0.35)' }, // 2人 { color: '#ff9f43', bg: 'rgba(255,159,67,0.06)', border: 'rgba(255,159,67,0.35)' }, // 3人 { color: '#2ed573', bg: 'rgba(46,213,115,0.06)', border: 'rgba(46,213,115,0.35)' }, // 4人 { color: '#ff6b6b', bg: 'rgba(255,107,107,0.06)', border: 'rgba(255,107,107,0.35)' }, // 5人 { color: '#a29bfe', bg: 'rgba(162,155,254,0.06)', border: 'rgba(162,155,254,0.35)' } // 6人 ]; function getOwnerStyle(ownerCount) { return OWNER_COLORS[Math.min(ownerCount - 1, OWNER_COLORS.length - 1)]; } // ===== IndexedDB 持久化层 ===== // 所有缓存数据存入 IndexedDB(容量大),启动时全量加载到内存 const IDB_NAME = 'sfd_storage'; const IDB_STORE = 'kv'; const IDB_VERSION = 1; let _idb = null; const _dbCache = {}; // 内存镜像:key(string) -> value(any) function _openIDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); req.onerror = () => reject(req.error); req.onsuccess = () => { _idb = req.result; resolve(_idb); }; req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE); } }; }); } function _idbPut(key, val) { if (!_idb) return Promise.resolve(); return new Promise((resolve) => { const tx = _idb.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).put(val, key); tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); }); } function _idbDelete(key) { if (!_idb) return Promise.resolve(); return new Promise((resolve) => { const tx = _idb.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).delete(key); tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); }); } // 单事务游标遍历,返回 { key: value } 映射(避免 N+1 查询) function _idbLoadAll() { if (!_idb) return Promise.resolve({}); return new Promise((resolve) => { const tx = _idb.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).openCursor(); const result = {}; req.onsuccess = () => { const cursor = req.result; if (cursor) { result[cursor.key] = cursor.value; cursor.continue(); } else { resolve(result); } }; req.onerror = () => resolve({}); }); } // 启动时从 IndexedDB 加载所有数据到内存镜像 async function _loadIDBCache() { try { await _openIDB(); const entries = await _idbLoadAll(); Object.assign(_dbCache, entries); console.log(`%c[SFD] IndexedDB 已加载 ${Object.keys(entries).length} 条缓存`, 'color:#66c0f4'); } catch (e) { logger.warn('IndexedDB 加载失败,回退到内存模式', e); } } // 同步读:从内存镜像取 function _dbGet(key, fallback) { const v = _dbCache[key]; return v === undefined ? fallback : v; } // 同步写内存 + 异步写 IndexedDB function _dbSet(key, val) { _dbCache[key] = val; _idbPut(key, val).catch(e => logger.warn('IndexedDB 写入失败: ' + key, e)); } function _dbDelete(key) { delete _dbCache[key]; _idbDelete(key).catch(() => {}); } // 存储大小日志 async function logStorageSize() { try { if (navigator.storage && navigator.storage.estimate) { const est = await navigator.storage.estimate(); console.log(`%c[SFD] 存储用量: ${(est.usage / 1024).toFixed(1)} KB / ${(est.quota / 1024 / 1024).toFixed(1)} MB`, 'color:#66c0f4'); } } catch (e) { logger.warn('存储统计失败', e); } } // ==================== SVG 图标 (精致笔画风格) ==================== const _S = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; const ICONS = { // 通用 UI close: ``, search: ``, settings: ``, refresh: ``, spinner: ``, check: ``, clock: ``, calendar: ``, // 人物 user: ``, users: ``, userPlaceholder: '', mutual: ``, lock: ``, // 游戏与数据 game: ``, package: ``, barChart: ``, grid: ``, trending: ``, activity: ``, pie: ``, insights: ``, heart: ``, // 家庭组 family: ``, // 等级与奖杯 level: ``, trophy: ``, // 其他 UI monitor: ``, steam: '', // VAC 盾牌(固定颜色,不继承 currentColor) shieldGreen: '', shieldRed: '', shieldDevBan: '', shieldBothBan: '', shieldDots: '', flame: ``, userPlus: ``, manageList: ``, }; // ==================== SVG 国旗 ==================== const FLAGS = { CN:'', US:'', JP:'', KR:'', TW:'', MO:'', HK:'', GB:'', DE:'', FR:'', RU:'', AU:'', CA:'', BR:'', IN:'', SG:'', MY:'', TH:'', VN:'', ID:'', PH:'', NZ:'', SE:'', NO:'', FI:'', DK:'', NL:'', IT:'', ES:'', PL:'', UA:'', TR:'', SA:'', AE:'', EG:'', ZA:'', IL:'', MX:'', AR:'', CL:'', CH:'', AT:'', BE:'', PT:'', GR:'', CZ:'', HU:'', RO:'', BG:'', SK:'', IE:'', HR:'', SI:'', LT:'', LV:'', EE:'', IS:'', LU:'', MT:'', CY:'', LI:'', NE:'', MC:'', XK:'', VG:'', CW:'' }; function flagSvg(cc) { if (!cc || cc.length !== 2) return ''; const uc = cc.toUpperCase(), svg = FLAGS[uc]; const style = 'display:inline-block;width:18px;height:13px;vertical-align:middle;flex-shrink:0'; if (svg) return svg.replace('${uc}`; } // 浮窗统一尺寸(修改宽高只需改此处) const POPUP_W = 1100, POPUP_H = 700; // ==================== I18N 中文文本 ==================== const I18N = { 'zh-CN': { title: '好友列表', tabSettings: '设置', apiKeyLabel: 'Steam Web API Key', apiKeyPlaceholder: '粘贴您的 API Key', steamIdLabel: '我的 SteamID64 (留空自动检测)', steamIdPlaceholder: '17位数字,如 76561198xxxxxxxxx', btnSave: '保存设置', saveSuccess: '设置已保存', noApiKeyTip: '请先在「设置」中配置 API Key。', apiKeyHelp: 'API Key 免费注册:打开 steamcommunity.com/dev/apikey → 填写域名 → 注册即可。', btnFetch: '获取好友数据', fetching: '正在查询好友列表...', fetchingDetail: '已获取 {current}/{total} 位好友...', fetchSuccess: '共 {total} 位好友', fetchFailed: '获取失败:{msg}', searchPlaceholder: '搜索昵称/国家...', statusOffline: '离线', statusOnline: '在线', statusBusy: '忙碌', statusAway: '离开', statusSnooze: '离开', statusTrade: '交易中', statusPlay: '想要游戏', friendSince: '好友于: {date}', lastOnline: '最近在线: {time}', inGame: '正在玩: {game}', noFriends: '暂无好友数据', footerReady: '就绪', dayUnit: '天', anonymous: 'Steam 用户', btnRefreshVAC: '🛡️ 刷新VAC', btnRefreshLevel: '⭐ 刷新等级', btnRefreshMutual: '🔄 共同好友', levelChecking: '等级查询中...', levelDone: '等级查询完成', vacChecking: 'VAC检查中...', vacNone: '🛡️ 无封禁', vacBanned: 'VAC封禁 {n}人', devBanned: '开发者封禁 {n}人', vacDaysAgo: '{n}天', devBan: '开发者封禁', mutualFriends: '共同好友', mutualPrivate: '私密', compareBtn: '对比库存', compareLoading: '正在加载 {name} 的游戏库...', compareError: '加载失败,请检查隐私设置或API Key', compareNeedKey: '请先在设置中配置 Steam API Key', familyPlayTitle: '游玩动态', familyPlayLoading: '正在加载游玩动态…', familyPlayEmpty: '暂无游玩数据', familyPlayRefresh: '刷新动态', activityLoading: '正在加载好友动态…', activityEmptyPlay: '暂无游玩记录', plTitle: '我的游戏库', plTabOverview: '最近游玩', plTabPlaytime: '游玩时长', plTabActivity: '入库动态', plTabDelisted: '绝版收藏', plLoading: '正在加载游戏库数据…', plNeedKey: '请先在设置中配置 Steam API Key', plFetchError: '加载失败,请检查隐私设置或 API Key', plStatTotal: '总游戏数', plStatPlaytime: '总时长(h)', plStatAvg: '平均时长(h)', plStatUnplayed: '未游玩', plTopGames: '游玩时长 Top 20', plPlaytimeDist: '时长分布', plRange0: '0 小时', plRange1: '0-1 小时', plRange2: '1-10 小时', plRange3: '10-50 小时', plRange4: '50-100 小时', plRange5: '100-500 小时', plRange6: '500+ 小时', plRecentGames: '最近游玩', plNoRecent: '最近 2 周未游玩任何游戏', plNoData: '暂无数据', plDelistedTotal: '绝版总数', plDelistedOwned: '已拥有绝版', plDelistedMissing: '未拥有绝版', plDelistedTypeAll: '全部类型', plDelistedTypeDelisted: '已下架', plDelistedTypePurchaseDisabled: '购买禁用', plDelistedTypeF2p: 'F2P不可用', plDelistedTypeRetail: '仅零售', plDelistedTypeTest: '测试应用', plDelistedEmpty: '您的游戏库中暂无绝版游戏', plDelistedLoading: '正在检测绝版游戏…', plHistoryLoading: '正在获取入库历史…', plHistoryTotal: '入库总数', plHistoryEmpty: '未获取到入库记录', plHistorySolo: '仅个人', plHistoryFilterAll: '全部', plHistoryFilterCoOwned: '仅共享', plHistoryFilterSolo: '仅个人', plHistoryLicenseDate: '个人许可', plHistoryDateMismatch: '日期不一致', plHistoryCoOwnedTip: '该游戏被多名家庭成员拥有,入库时间可能为他人获取时间', } }; // ==================== CSS 样式 ==================== const CSS_STYLES = [ ':root{--sfd-bg-card:rgba(30,41,59,0.95);--sfd-border:rgba(102,192,244,0.2);--sfd-border-focus:rgba(102,192,244,0.6);--sfd-text-primary:#f8fafc;--sfd-text-secondary:#94a3b8;--sfd-accent-blue:#3b82f6;--sfd-accent-green:#10b981;--sfd-accent-purple:#8b5cf6;--sfd-accent-amber:#f59e0b;--sfd-accent-rose:#f43f5e;--sfd-radius-lg:16px;--sfd-radius-md:10px;--sfd-shadow:0 10px 40px rgba(0,0,0,0.5);--sfd-transition:all 0.25s cubic-bezier(0.4,0,0.2,1)}', '.sfd-svg{width:16px;height:16px;display:inline-block;vertical-align:middle;stroke-width:2.2}', '.sfd-trigger-base{position:fixed!important;right:24px!important;width:36px!important;height:36px!important;border:none!important;border-radius:50%!important;cursor:pointer!important;z-index:500!important;display:flex!important;align-items:center;justify-content:center;font-size:18px;color:#fff!important;transition:var(--sfd-transition)!important;user-select:none}', '.sfd-trigger-panel{bottom:114px!important;background:linear-gradient(135deg,#3b82f6 0%,#1d4ed8 100%)!important;box-shadow:0 4px 20px rgba(59,130,246,0.4)!important}', '.sfd-trigger-panel:hover{transform:scale(1.1) rotate(10deg)!important;box-shadow:0 8px 30px rgba(59,130,246,0.6)!important}', '.sfd-trigger-library{bottom:72px!important;background:linear-gradient(135deg,#8b5cf6 0%,#6d28d9 100%)!important;box-shadow:0 4px 20px rgba(139,92,246,0.4)!important}', '.sfd-trigger-library:hover{transform:scale(1.1) rotate(-10deg)!important;box-shadow:0 8px 30px rgba(139,92,246,0.6)!important}', '.sfd-trigger-family{bottom:30px!important;background:linear-gradient(135deg,#f59e0b 0%,#d97706 100%)!important;box-shadow:0 4px 20px rgba(245,158,11,0.4)!important}', '.sfd-trigger-family:hover{transform:scale(1.1) rotate(10deg)!important;box-shadow:0 8px 30px rgba(245,158,11,0.6)!important}', '.sfd-header,.sfd-family-header,.sfd-pl-header{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--sfd-border);flex-shrink:0;user-select:none;padding:10px 16px}', '.sfd-header{background:linear-gradient(135deg,rgba(59,130,246,0.15),rgba(15,23,42,0.8));border-bottom-color:rgba(59,130,246,0.2)}', '.sfd-header h3,.sfd-family-header h3,.sfd-pl-header h3{margin:0;color:#fff;font-weight:700;display:flex;align-items:center;gap:8px}', '.sfd-header h3,.sfd-family-header h3{font-size:14px}', '.sfd-tab-bar{display:flex;gap:2px;margin-left:16px;flex-shrink:0}', '.sfd-tab-btn{padding:5px 14px;border-radius:8px;font-size:13px;font-weight:600;color:#94a3b8;cursor:pointer;transition:var(--sfd-transition);border:1px solid transparent;background:transparent;white-space:nowrap}', '.sfd-tab-btn:hover{color:#e2e8f0;background:rgba(255,255,255,0.06)}', '.sfd-tab-btn.sfd-active{color:#fff;background:rgba(59,130,246,0.2);border-color:rgba(59,130,246,0.3)}', '.sfd-header-actions{display:flex;align-items:center;gap:2px;margin-left:auto}', '.sfd-header-btn{background:rgba(255,255,255,0.06)!important;border:none!important;color:#94a3b8;cursor:pointer;width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;transition:var(--sfd-transition)}', '.sfd-header-btn:hover{background:rgba(255,255,255,0.12)!important;color:#e2e8f0}', '.sfd-header-btn.sfd-active{background:rgba(59,130,246,0.2)!important;color:var(--sfd-accent-blue)}', '.sfd-header-btn.sfd-header-btn-close:hover{background:rgba(244,63,94,0.2)!important;color:var(--sfd-accent-rose)}', '.sfd-header-btn svg{width:14px;height:14px}', '.sfd-header-quick-actions{display:flex;align-items:center;gap:4px;margin-right:6px}', '.sfd-header-action-btn{width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#94a3b8;cursor:pointer;transition:var(--sfd-transition)}', '.sfd-header-action-btn:hover{background:rgba(255,255,255,0.08);transform:translateY(-1px)}', '.sfd-header-action-btn:disabled{opacity:0.4;cursor:not-allowed;transform:none!important}', '.sfd-header-action-btn svg{width:14px;height:14px}', '.sfd-header-action-btn.sfd-action-fetch{color:#34d399;border-color:rgba(16,185,129,0.25)}', '.sfd-header-action-btn.sfd-action-fetch:hover{background:rgba(16,185,129,0.12);border-color:rgba(16,185,129,0.45)}', '.sfd-header-action-btn.sfd-action-vac{color:#fbbf24;border-color:rgba(245,158,11,0.25)}', '.sfd-header-action-btn.sfd-action-vac:hover{background:rgba(245,158,11,0.12);border-color:rgba(245,158,11,0.45)}', '.sfd-header-action-btn.sfd-action-level{color:#60a5fa;border-color:rgba(59,130,246,0.25)}', '.sfd-header-action-btn.sfd-action-level:hover{background:rgba(59,130,246,0.12);border-color:rgba(59,130,246,0.45)}', '.sfd-header-action-btn.sfd-action-mutual{color:#a78bfa;border-color:rgba(139,92,246,0.25)}', '.sfd-header-action-btn.sfd-action-mutual:hover{background:rgba(139,92,246,0.12);border-color:rgba(139,92,246,0.45)}', '.sfd-header-action-btn.sfd-action-dash-refresh{color:#67e8f9;border-color:rgba(6,182,212,0.25)}', '.sfd-header-action-btn.sfd-action-dash-refresh:hover{background:rgba(6,182,212,0.12);border-color:rgba(6,182,212,0.45)}', '.sfd-content{flex:1;overflow-y:auto;padding:10px;box-sizing:border-box;display:flex;flex-direction:column}', // 统一滚动条宽度和轨道(所有可滚动区域 + 弹窗内联容器) '.sfd-content::-webkit-scrollbar,.sfd-family-content::-webkit-scrollbar,.sfd-friends-scroll-container::-webkit-scrollbar,.sfd-family-game-list::-webkit-scrollbar,.sfd-pl-content::-webkit-scrollbar,.sfd-pl-scroll::-webkit-scrollbar,.sfd-dash-content::-webkit-scrollbar,.sfd-pl-popup *::-webkit-scrollbar,.sfd-family-popup *::-webkit-scrollbar,.sfd-modal *::-webkit-scrollbar{width:6px}', '.sfd-content::-webkit-scrollbar-track,.sfd-family-content::-webkit-scrollbar-track,.sfd-friends-scroll-container::-webkit-scrollbar-track,.sfd-family-game-list::-webkit-scrollbar-track,.sfd-pl-content::-webkit-scrollbar-track,.sfd-pl-scroll::-webkit-scrollbar-track,.sfd-dash-content::-webkit-scrollbar-track,.sfd-pl-popup *::-webkit-scrollbar-track,.sfd-family-popup *::-webkit-scrollbar-track,.sfd-modal *::-webkit-scrollbar-track{background:transparent}', // 主内容区滚动条滑块(蓝灰色) '.sfd-content::-webkit-scrollbar-thumb,.sfd-family-content::-webkit-scrollbar-thumb{background:var(--sfd-border);border-radius:3px;transition:background 0.2s}', // 其他滚动区域滑块(白色)+ 弹窗内所有容器 '.sfd-friends-scroll-container::-webkit-scrollbar-thumb,.sfd-family-game-list::-webkit-scrollbar-thumb,.sfd-pl-content::-webkit-scrollbar-thumb,.sfd-pl-scroll::-webkit-scrollbar-thumb,.sfd-dash-content::-webkit-scrollbar-thumb,.sfd-pl-popup *::-webkit-scrollbar-thumb,.sfd-family-popup *::-webkit-scrollbar-thumb,.sfd-modal *::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.08);border-radius:3px;transition:background 0.2s}', '.sfd-friends-scroll-container::-webkit-scrollbar-thumb:hover,.sfd-pl-content::-webkit-scrollbar-thumb:hover,.sfd-pl-scroll::-webkit-scrollbar-thumb:hover,.sfd-dash-content::-webkit-scrollbar-thumb:hover,.sfd-pl-popup *::-webkit-scrollbar-thumb:hover,.sfd-family-popup *::-webkit-scrollbar-thumb:hover,.sfd-modal *::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.25)}', '.sfd-friends-scroll-container:hover::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.15)}', '.sfd-tip{background:rgba(59,130,246,0.08)!important;padding:14px;border-radius:var(--sfd-radius-md);font-size:12px;color:var(--sfd-text-secondary);margin-bottom:16px;line-height:1.6;border:1px solid rgba(59,130,246,0.15)!important}', '.sfd-tip a{color:#60a5fa;text-decoration:none;font-weight:600}', '.sfd-tip a:hover{text-decoration:underline;color:#93c5fd}', '.sfd-settings-guide{padding:2px 0}', '.sfd-settings-guide-title{font-size:14px;font-weight:700;color:#fff;margin-bottom:10px}', '.sfd-settings-guide-item{font-size:12px;color:var(--sfd-text-secondary);line-height:1.8;padding-left:2px}', '.sfd-form-group{margin-bottom:16px}', '.sfd-form-group label{display:block;font-size:12px;color:var(--sfd-text-secondary);margin-bottom:6px;font-weight:600}', '.sfd-input{width:100%;padding:10px 14px;background:rgba(15,23,42,0.6)!important;border:1px solid var(--sfd-border)!important;border-radius:8px;color:#fff;font-size:13px;box-sizing:border-box;transition:var(--sfd-transition)}', '.sfd-input:focus{outline:none;border-color:var(--sfd-border-focus)!important;box-shadow:0 0 0 2px rgba(59,130,246,0.25)!important}', '.sfd-btn-row{display:flex;align-items:center;gap:10px;margin-top:10px}', '.sfd-btn{padding:9px 18px;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;transition:var(--sfd-transition);display:inline-flex;align-items:center;justify-content:center;gap:6px}', '.sfd-btn svg{width:14px;height:14px}', '.sfd-btn-primary{background:var(--sfd-accent-blue);color:#fff}', '.sfd-btn-primary:hover{filter:brightness(1.15);transform:translateY(-1px)}', '.sfd-btn:disabled{opacity:0.5;cursor:not-allowed;transform:none!important}', '.sfd-btn-sm{padding:4px 8px;font-size:11px;border-radius:5px;flex-shrink:0;height:26px;line-height:1}', '.sfd-btn-ghost{background:transparent;border:1px solid rgba(148,163,184,0.2);color:#94a3b8}', '.sfd-btn-ghost:hover{background:rgba(148,163,184,0.1);color:#e2e8f0;border-color:rgba(148,163,184,0.35)}', '.sfd-panel-level-badge{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(251,191,36,0.12);border:1px solid rgba(251,191,36,0.25);padding:1px 6px;border-radius:4px;white-space:nowrap;flex-shrink:0}', '.sfd-friends-scroll-container{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:14px;min-height:120px;padding-right:4px;box-sizing:border-box}', '.sfd-friend-item{display:flex;align-items:center;gap:10px;padding:8px 10px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.04);border-radius:10px;transition:var(--sfd-transition);text-decoration:none!important;color:inherit!important;min-width:0}', '.sfd-friend-item:hover{background:rgba(255,255,255,0.05);border-color:rgba(102,192,244,0.2);transform:translateX(2px)}', '.sfd-avatar-wrap{position:relative;width:36px;height:36px;flex-shrink:0}', '.sfd-friend-avatar{width:100%;height:100%;border-radius:50%;object-fit:cover;border:2px solid rgba(255,255,255,0.06)}', '.sfd-friend-avatar:hover{border-color:rgba(102,192,244,0.3)}', '.sfd-status-dot{position:absolute;bottom:-1px;right:-1px;width:9px;height:9px;border-radius:50%;border:2px solid var(--sfd-bg-card)}', '.sfd-status-dot.online{background-color:var(--sfd-accent-green)}', '.sfd-status-dot.in-game{background-color:var(--sfd-accent-purple)}', '.sfd-status-dot.offline{background-color:var(--sfd-text-secondary)}', '.sfd-friend-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}', '.sfd-friend-name-row{display:flex;align-items:center;gap:4px;justify-content:space-between;min-width:0}', '.sfd-friend-name{font-size:13px;font-weight:700;color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}', '.sfd-friend-meta-row{display:flex;align-items:center;gap:5px;flex-wrap:wrap;min-width:0}', '.sfd-friend-status{font-size:11px;font-weight:600;color:#94a3b8;display:flex;align-items:center;gap:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}', '.sfd-friend-status.online{color:#34d399}', '.sfd-friend-status.in-game{color:#c084fc}', '.sfd-friend-status.offline{color:#64748b}', '.sfd-friend-game-time{font-size:11px;color:#94a3b8;font-weight:600}', '.sfd-friend-game-time.new-friend{color:#ff6b6b;font-weight:700}', '.sfd-vac-shield-panel{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center}', '.sfd-vac-shield-panel svg{width:16px;height:16px}', '.sfd-card-actions{display:flex;align-items:center;gap:5px;margin-left:8px;flex-shrink:0}', '.sfd-compare-btn{border-radius:8px;border:1px solid rgba(102,192,244,0.2);background:rgba(102,192,244,0.05);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.2s;flex-shrink:0;width:28px;height:28px}', '.sfd-compare-btn:hover{background:rgba(102,192,244,0.15);border-color:rgba(102,192,244,0.4)}', '.sfd-compare-btn svg{width:14px;height:14px;color:#66c0f4}', '.sfd-badge{font-size:10px;padding:2px 6px;border-radius:4px;font-weight:600;display:inline-flex;align-items:center;gap:3px}', '.sfd-badge-status{background:rgba(255,255,255,0.08);color:var(--sfd-text-secondary)}', '.sfd-badge-status.online,.sfd-etag-status.online{background:rgba(16,185,129,0.15);color:#34d399}', '.sfd-badge-status.in-game{background:rgba(139,92,246,0.15);color:#c084fc;font-weight:600}', '.sfd-vac-status{font-size:11px;color:var(--sfd-text-secondary);margin-left:auto;white-space:nowrap}', '.sfd-bottom-progress-wrap{height:3px;background:rgba(255,255,255,0.06);border-radius:2px;margin-top:4px;overflow:hidden}', '.sfd-bottom-progress{height:100%;width:0;background:linear-gradient(90deg,#3b82f6,#10b981);border-radius:2px;transition:width .3s ease-out;box-shadow:0 0 4px rgba(59,130,246,0.5)}', '@keyframes sfd-zoom-in{from{transform:scale(0.92);opacity:0}to{transform:scale(1);opacity:1}}', `.sfd-panel,.sfd-modal,.sfd-family-popup{position:fixed!important;top:0!important;bottom:0!important;left:0!important;right:0!important;margin:auto!important;width:${POPUP_W}px!important;max-width:96vw!important;height:${POPUP_H}px!important;max-height:96vh!important;background:var(--sfd-bg-card)!important;border:1px solid var(--sfd-border)!important;border-radius:var(--sfd-radius-lg)!important;box-shadow:var(--sfd-shadow)!important;z-index:502!important;display:none;flex-direction:column;overflow:hidden;backdrop-filter:blur(12px)!important;-webkit-backdrop-filter:blur(12px)!important;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:var(--sfd-text-primary);animation:sfd-zoom-in 0.25s cubic-bezier(0.34,1.56,0.64,1)}`, '.sfd-pl-popup{position:fixed!important;top:0!important;bottom:0!important;left:0!important;right:0!important;margin:auto!important;width:1100px!important;max-width:96vw!important;height:720px!important;max-height:96vh!important;background:var(--sfd-bg-card)!important;border:1px solid var(--sfd-border)!important;border-radius:var(--sfd-radius-lg)!important;box-shadow:var(--sfd-shadow)!important;z-index:502!important;display:none;flex-direction:column;overflow:hidden;backdrop-filter:blur(12px)!important;-webkit-backdrop-filter:blur(12px)!important;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:var(--sfd-text-primary);animation:sfd-zoom-in 0.25s cubic-bezier(0.34,1.56,0.64,1)}', '.sfd-panel.sfd-show,.sfd-modal.sfd-show,.sfd-family-popup.sfd-show,.sfd-pl-popup.sfd-show{display:flex!important}', '.sfd-modal-body{flex:1;overflow:hidden;padding:12px 15px 15px;box-sizing:border-box}', // ===== 统一指标卡片(合并原 .sfd-kpi-card / .sfd-pl-stat-card / .sfd-family-stat-card / .sfd-compare-card / .sfd-dash-kpi)===== '.sfd-metric-card{background:rgba(15,23,42,0.4);border:1px solid var(--sfd-border);border-radius:8px;transition:all 0.2s ease;overflow:hidden}', '.sfd-metric-card:hover{background:rgba(15,23,42,0.6)}', '.sfd-metric-card.active{border-color:#66c0f4;background:rgba(102,192,244,0.1);box-shadow:0 0 8px rgba(102,192,244,0.2)}', // 布局变体:紧凑型(原 kpi-card / pl-stat-card) '.sfd-metric-card.sfd-metric-compact{padding:6px 8px;text-align:center;display:flex;flex-direction:column;justify-content:center;min-height:48px}', '.sfd-metric-card.sfd-metric-compact .sfd-metric-val{font-size:18px;font-weight:800;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.sfd-metric-card.sfd-metric-compact .sfd-metric-lbl{font-size:10px;color:var(--sfd-text-secondary);margin-top:4px;font-weight:600}', // 布局变体:横向图标型(原 compare-card / dash-kpi) '.sfd-metric-card.sfd-metric-row{padding:8px 10px;display:flex;align-items:center;gap:10px;min-width:0}', '.sfd-metric-card.sfd-metric-row .sfd-metric-icon{width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0}', '.sfd-metric-card.sfd-metric-row .sfd-metric-icon svg{width:17px;height:17px}', '.sfd-metric-card.sfd-metric-row .sfd-metric-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}', '.sfd-metric-card.sfd-metric-row .sfd-metric-val{font-size:20px;font-weight:700;color:#fff;line-height:1.2;font-variant-numeric:tabular-nums}', '.sfd-metric-card.sfd-metric-row .sfd-metric-lbl{font-size:10px;color:#94a3b8;font-weight:600;white-space:nowrap}', // 布局变体:带 label 上标(原 family-stat-card) '.sfd-metric-card.sfd-metric-label{padding:8px 10px;text-align:center}', '.sfd-metric-card.sfd-metric-label .sfd-metric-val{font-size:20px;font-weight:700;line-height:1.2}', '.sfd-metric-card.sfd-metric-label .sfd-metric-lbl{font-size:10px;color:#94a3b8;margin-top:2px;font-weight:600}', // 保留原容器类(grid 布局) '.sfd-pl-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:12px}', '.sfd-family-stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;flex:1;min-width:0}', '.sfd-dash-kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;flex-shrink:0;position:sticky;top:0;z-index:5;background:rgba(30,41,59,0.98);margin:0 -14px;padding:14px 14px 8px}', '.sfd-table-wrapper{background:rgba(15,23,42,0.4);border:1px solid var(--sfd-border);border-radius:var(--sfd-radius-md);margin-bottom:10px;overflow-y:auto;max-height:calc(85vh - 250px)}', '.sfd-pagination{display:flex;align-items:center;gap:6px;margin-left:auto;flex-shrink:0}', '.sfd-pagination .sfd-btn{padding:2px 8px;font-size:12px;height:25px;border-radius:6px;line-height:1}', '.sfd-toast{position:fixed;bottom:50px;left:50%;transform:translateX(-50%) translateY(20px);background:rgba(15,23,42,0.95);color:#fff;border:1px solid var(--sfd-accent-blue);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:505;opacity:0;pointer-events:none;transition:opacity 0.3s,transform 0.3s}', '.sfd-toast.sfd-toast-show{opacity:1;transform:translateX(-50%) translateY(0)}', '.sfd-toast.sfd-toast-success{border-color:#10b981}', '.sfd-toast.sfd-toast-error{border-color:#ef4444}', '.sfd-toast.sfd-toast-warning{border-color:#f59e0b}', '.sfd-toast.sfd-toast-info{border-color:#54a0ff}', '.sfd-enhanced-block{display:flex;align-items:center;gap:6px;margin-top:6px;flex-wrap:wrap}', '.sfd-etag{display:inline-flex;align-items:center;gap:4px;font-size:11px!important;padding:2px 8px;border-radius:6px;font-weight:600;line-height:1.3}', '.sfd-etag-status{background:rgba(255,255,255,0.08);color:#c7d5e0}', '.sfd-etag-status.in-game{background:rgba(139,92,246,0.15);color:#c084fc}', '.sfd-vac-shield-page{position:absolute;top:50%;right:8px;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;gap:3px;z-index:2;pointer-events:none;opacity:0.9;filter:drop-shadow(0 1px 4px rgba(0,0,0,0.5))}', '.sfd-vac-shield-page svg{width:22px;height:22px}', '.sfd-vac-shield-page.clean{color:#4caf50}', '.sfd-vac-shield-page.banned{color:#ef5350}', '.sfd-vac-shield-page.banned-dev{color:#ff9800}', '.sfd-vac-shield-page.banned-both{color:#ef5350}', '.sfd-shield-item{display:inline-flex;align-items:center;gap:1px}', '.sfd-dev-ban-count{font-size:9px;font-weight:700;color:#ff9800;margin-left:-2px;vertical-align:super;text-shadow:0 1px 2px rgba(0,0,0,0.6)}', '.sfd-vac-days{font-size:11px;font-weight:600;color:#ef5350;margin-right:3px;text-shadow:0 1px 2px rgba(0,0,0,0.6)}', '.friend_block_content{margin-top:4px!important;position:relative!important;min-height:36px!important}', '.friend_block_v2.sfd-card-clean{background:linear-gradient(135deg,rgba(76,175,80,0.08) 0%,rgba(76,175,80,0.02) 100%)!important;border-radius:6px!important}', '.friend_block_v2.sfd-card-banned{background:linear-gradient(135deg,rgba(239,83,80,0.1) 0%,rgba(239,83,80,0.02) 100%)!important;border-radius:6px!important}', '.friend_block_v2.sfd-card-devban{background:linear-gradient(135deg,rgba(255,152,0,0.1) 0%,rgba(255,152,0,0.02) 100%)!important;border-radius:6px!important}', '.player_avatar{position:relative!important;overflow:visible!important}', '.sfd-name-tags{display:inline-flex;align-items:center;gap:4px;margin-left:6px;vertical-align:middle}', '.sfd-name-tag-days{font-size:12px;font-weight:700;color:#66c0f4;text-shadow:0 0 4px rgba(102,192,244,.25)}', '.sfd-name-tag-days.new-friend{color:#ff6b6b;text-shadow:0 0 6px rgba(255,107,107,.35)}', '.sfd-name-tag-days.normal{color:#94a3b8;font-weight:600}', '.sfd-name-tag-days.old-friend{color:#66c0f4;text-shadow:0 0 4px rgba(102,192,244,.25)}', '.sfd-name-tag-country{display:inline-flex;align-items:center;vertical-align:middle}', '.sfd-name-tag-level{position:absolute;bottom:4px;left:-2px;font-size:11px;font-weight:600;background:rgba(0,0,0,0.45);color:#fbbf24;padding:1px 4px;border-radius:4px;white-space:nowrap;z-index:3;pointer-events:none;line-height:1.2;margin-right:2px}', '.sfd-family-popup{height:740px!important;max-height:96vh!important}', '.sfd-family-header{background:linear-gradient(135deg,rgba(245,158,11,0.15),rgba(15,23,42,0.8));border-bottom-color:rgba(245,158,11,0.2)}', '.sfd-family-header h3 svg,.sfd-pl-header h3 svg{flex-shrink:0}', '.sfd-family-header h3 svg{width:18px;height:18px;color:#06cfbe}', '.sfd-family-header-actions,.sfd-pl-header-actions{display:flex;align-items:center;gap:4px}', '.sfd-family-progress{height:3px;flex-shrink:0;background:transparent;overflow:hidden;position:relative}', '.sfd-family-progress.active{background:rgba(245,158,11,0.08)}', '.sfd-family-progress-bar{height:100%;width:0;background:linear-gradient(90deg,#f59e0b,#fbbf24);border-radius:0 2px 2px 0;transition:width .3s ease;box-shadow:0 0 6px rgba(245,158,11,0.5)}', '.sfd-family-progress-bar.done{background:linear-gradient(90deg,#22c55e,#4ade24);box-shadow:0 0 6px rgba(34,197,94,0.5);transition:width .2s ease}', '.sfd-family-progress-bar.err{background:linear-gradient(90deg,#ef4444,#f87171);box-shadow:0 0 6px rgba(239,68,68,0.5)}', '.sfd-family-content,.sfd-pl-content{flex:1;overflow:hidden;display:flex;flex-direction:column;box-sizing:border-box;min-height:0;padding:8px}', '.sfd-family-scroll,.sfd-pl-scroll{flex:1;overflow-y:auto;min-height:0}', '.sfd-family-stat-card{background:rgba(15,23,42,0.4);border:1px solid var(--sfd-border);border-radius:8px;padding:8px 10px;text-align:center}', '.sfd-family-tabs,.sfd-pl-tabs{display:flex;background:rgba(15,23,42,0.6);padding:3px;border-radius:8px;border:1px solid var(--sfd-border);margin-bottom:10px}', '.sfd-family-tab,.sfd-pl-tab{border:none;background:transparent;color:var(--sfd-text-secondary);padding:7px 14px;font-size:12px;font-weight:600;border-radius:6px;cursor:pointer;transition:var(--sfd-transition);display:flex;align-items:center;gap:5px;flex:1;justify-content:center}', '.sfd-family-tab svg,.sfd-pl-tab svg{width:14px;height:14px}', '.sfd-family-tab:hover,.sfd-pl-tab:hover{color:#fff}', '.sfd-family-tab.active{background:rgba(6,207,190,0.2);color:#06cfbe}', '.sfd-family-tab-panel{display:none;flex:1;min-height:0;overflow-y:auto}', '.sfd-family-tab-panel.active{display:flex;flex-direction:column}', '.sfd-family-tab-panel-noscroll{overflow:hidden}', '.sfd-family-chart-wrap{background:rgba(15,23,42,0.4);border:1px solid var(--sfd-border);border-radius:var(--sfd-radius-md);padding:8px;min-height:200px}', '.sfd-family-game-list{display:flex;flex-direction:column;gap:4px;min-height:0}', '.sfd-heat-pie-wrap{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}', '.sfd-heat-pie-legend{display:flex;flex-wrap:wrap;justify-content:center;gap:14px;padding:6px 0 10px;border-bottom:1px solid rgba(102,192,244,0.12);flex-shrink:0}', '.sfd-heat-pie-scroll{flex:1;overflow-y:auto;min-height:0;padding:4px 2px}', '.sfd-heat-pie-year-group{margin-bottom:14px}', '.sfd-heat-pie-year-title{font-size:13px;font-weight:700;color:#66c0f4;margin-bottom:6px;padding:2px 6px;border-left:3px solid #66c0f4;display:flex;align-items:center;gap:6px}', '.sfd-heat-pie-grid{display:grid;grid-template-columns:repeat(12,1fr);gap:6px}', '.sfd-heat-pie-item{display:flex;flex-direction:column;align-items:center;gap:3px;background:rgba(15,23,42,0.35);border:1px solid rgba(255,255,255,0.04);border-radius:8px;padding:6px 2px 5px;transition:all 0.2s;cursor:default;min-width:0}', '.sfd-heat-pie-item:hover{background:rgba(255,255,255,0.04);border-color:rgba(102,192,244,0.25);transform:translateY(-1px)}', '.sfd-heat-pie-item.empty{background:rgba(15,23,42,0.15);border-color:rgba(255,255,255,0.02);opacity:0.5}', '.sfd-heat-pie-svg-wrap{width:100%;aspect-ratio:1;margin:0 auto;display:flex;align-items:center;justify-content:center}', '.sfd-heat-pie-label{font-size:11px;font-weight:700;color:#e2e8f0;line-height:1.2}', '.sfd-heat-pie-summary{display:flex;justify-content:space-around;font-size:11px;color:#94a3b8;padding:8px 0 0;border-top:1px solid rgba(102,192,244,0.12);flex-shrink:0}', '.sfd-heatmap-card{background:rgba(15,23,42,0.4);border:1px solid var(--sfd-border);border-radius:var(--sfd-radius-md);padding:12px;margin-top:12px}', '.sfd-heatmap-card-title{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:700;color:#66c0f4;margin-bottom:4px}', '.sfd-heatmap-card-sub{font-size:11px;color:#64748b;margin-bottom:10px}', '.sfd-heatmap-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}', '.sfd-heatmap-cell{overflowX:auto;min-width:0}', '.sfd-compare-list{display:flex;flex-direction:column;gap:5px}', '.sfd-activity-loading,.sfd-family-loading,.sfd-pl-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 20px;gap:10px;color:var(--sfd-text-secondary)}', '.sfd-compare-game-rank{width:22px;height:22px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;background:rgba(255,255,255,0.06);color:#94a3b8}', '.sfd-compare-game-rank.top1,.sfd-pl-game-rank.r1{background:rgba(245,158,11,0.2);color:#fbbf24}', '.sfd-compare-game-rank.top2,.sfd-pl-game-rank.r2{background:rgba(148,163,184,0.2);color:#cbd5e1}', '.sfd-compare-game-rank.top3,.sfd-pl-game-rank.r3{background:rgba(180,83,9,0.2);color:#d97706}', '.sfd-family-game-item{display:flex;align-items:center;gap:10px;padding:8px 10px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.04);border-radius:8px;transition:var(--sfd-transition)}', '.sfd-family-game-item:hover{background:rgba(255,255,255,0.05);border-color:rgba(255,255,255,0.1)}', '.sfd-family-game-info{flex:1;min-width:0;overflow:hidden}', '.sfd-family-game-name{font-size:12px;font-weight:600;color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none;transition:var(--sfd-transition)}', '.sfd-family-game-name:hover{color:#06cfbe}', '.sfd-compare-playtime-list{display:flex;flex-direction:column;gap:5px}', '.sfd-compare-playtime-item{display:flex;align-items:center;gap:10px;padding:8px 10px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.04);border-radius:8px;cursor:pointer;transition:var(--sfd-transition)}', '.sfd-compare-playtime-item:hover{background:rgba(255,255,255,0.05);border-color:rgba(255,255,255,0.1)}', '.sfd-compare-playtime-bar-wrap{flex:1;height:6px;background:rgba(255,255,255,0.06);border-radius:3px;overflow:hidden;margin:0 8px;min-width:60px}', '.sfd-compare-playtime-bar-fill{height:100%;background:linear-gradient(90deg,#f59e0b,#f97316);border-radius:3px;transition:width 0.3s ease}', '.sfd-compare-playtime-hours{font-size:11px;font-weight:700;color:#fbbf24;white-space:nowrap;min-width:48px;text-align:right}', '.sfd-family-loading svg{width:28px;height:28px;animation:sfd-spin 1s linear infinite;color:#06cfbe}', '@keyframes sfd-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}', '@keyframes sfd-stress-pulse{0%,100%{border-color:rgba(102,192,244,0.3);box-shadow:0 0 4px rgba(102,192,244,0.15)}50%{border-color:rgba(102,192,244,0.7);box-shadow:0 0 14px rgba(102,192,244,0.45)}}', '.sfd-family-empty,.sfd-pl-empty{text-align:center;padding:30px 20px;color:var(--sfd-text-secondary);font-size:13px}', '.sfd-family-refresh{width:28px;height:28px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;color:#94a3b8;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:var(--sfd-transition)}', '.sfd-family-refresh:hover{background:rgba(6,207,190,0.15);border-color:rgba(6,207,190,0.4);color:#06cfbe}', '.sfd-family-refresh svg{width:14px;height:14px}', '.sfd-family-page-nav{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:8px;flex-shrink:0}', '.sfd-family-page-nav.sfd-pagination{margin-left:0;justify-content:center}', '.sfd-family-play-member{background:rgba(15,23,42,0.4);border:1px solid var(--sfd-border);border-radius:var(--sfd-radius-md);padding:10px;margin-bottom:8px}', '.sfd-family-play-member-header{display:flex;align-items:center;gap:10px;margin-bottom:0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,0.06);cursor:pointer;user-select:none;transition:background 0.15s}', '.sfd-family-play-member-header:hover{background:rgba(255,255,255,0.03)}', '.sfd-family-play-member-games-wrap{overflow:hidden;transition:max-height 0.3s ease,margin-top 0.3s ease,opacity 0.3s ease;max-height:9999px;opacity:1;margin-top:8px}', '.sfd-family-play-member.collapsed .sfd-family-play-member-games-wrap{max-height:0;opacity:0;margin-top:0}', '.sfd-family-play-member.collapsed .sfd-family-play-member-header{border-bottom-color:transparent;padding-bottom:0}', '.sfd-family-play-member-avatar{width:32px;height:32px;border-radius:50%;object-fit:cover;border:2px solid rgba(108,92,231,0.4);flex-shrink:0}', '.sfd-family-play-member-avatar.sfd-avatar-banned{border-color:rgba(239,83,80,0.6)}', '.sfd-family-play-member-info{flex:1;min-width:0}', '.sfd-family-play-member-name-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}', '.sfd-family-play-member-name{font-size:13px;font-weight:700;color:#e2e8f0}', '.sfd-family-play-member-flag img{width:16px;height:11px;vertical-align:middle;border-radius:1px}', '.sfd-family-play-member-days{font-size:11px;color:#64748b;margin-top:2px}', '.sfd-family-play-vac{display:inline-flex;font-size:11px;opacity:0.8}', '.sfd-family-play-vac svg{width:13px;height:13px;stroke:#ef5350;fill:none}', '.sfd-family-play-level{display:inline-flex;align-items:center;gap:2px;font-size:11px;color:#fbbf24;font-weight:600}', '.sfd-family-play-level svg{width:11px;height:11px;stroke:#fbbf24;fill:none}', '.sfd-family-play-member-games{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}', '.sfd-family-play-game-item{display:flex;flex-direction:column;gap:4px;padding:6px;background:rgba(15,23,42,0.5);border:1px solid var(--sfd-border);border-radius:8px;transition:all 0.2s;overflow:hidden;min-width:0}', '.sfd-family-play-game-item:hover{background:rgba(108,92,231,0.08)}', '.sfd-family-play-game-icon{width:100%;aspect-ratio:231/87;border-radius:4px;flex-shrink:0;object-fit:cover;background:#0f172a}', '.sfd-family-play-game-info{padding:0 2px;display:flex;flex-direction:column;gap:1px}', '.sfd-family-play-game-name{font-size:11px;font-weight:600;color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none;transition:var(--sfd-transition);line-height:1.3}', '.sfd-family-play-game-name:hover{color:#a29bfe}', '.sfd-family-play-game-meta{display:flex;align-items:center;gap:8px;margin-top:1px}', '.sfd-family-play-game-playtime{font-size:10px;font-weight:600;color:#a29bfe}', '.sfd-family-play-game-playtime-total{font-size:10px;color:#64748b}', '.sfd-family-play-refresh{width:28px;height:28px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;color:#94a3b8;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:var(--sfd-transition);margin-left:auto;flex-shrink:0}', '.sfd-family-play-refresh:hover{background:rgba(108,92,231,0.15);border-color:rgba(108,92,231,0.4);color:#a29bfe}', '.sfd-family-play-refresh svg{width:14px;height:14px}', // ===== 个人游戏库浮窗 (1.0.16) ===== '.sfd-pl-header{background:linear-gradient(135deg,rgba(139,92,246,0.15),rgba(15,23,42,0.8));border-bottom-color:rgba(139,92,246,0.2)}', '.sfd-pl-header h3{font-size:15px}', '.sfd-pl-header h3 svg{width:20px;height:20px;color:#a78bfa}', '.sfd-pl-content{padding:14px}', '.sfd-pl-tab.active{background:rgba(139,92,246,0.2);color:#a78bfa}', '.sfd-pl-recent-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}', '.sfd-pl-recent-card{background:rgba(15,23,42,0.5);border:1px solid var(--sfd-border);border-radius:8px;overflow:hidden;display:flex;flex-direction:column;gap:4px;padding:6px;transition:var(--sfd-transition)}', '.sfd-pl-recent-card:hover{border-color:rgba(139,92,246,0.35);background:rgba(139,92,246,0.04)}', '.sfd-pl-recent-cap{width:100%;aspect-ratio:231/87;object-fit:cover;cursor:pointer;display:block;background:#0f172a;border-radius:4px;flex-shrink:0}', '.sfd-pl-recent-info{padding:0 2px;display:flex;flex-direction:column;gap:1px}', '.sfd-pl-recent-name{font-size:11px;font-weight:600;color:#e2e8f0;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3}', '.sfd-pl-recent-name:hover{color:#a78bfa}', '.sfd-img-wrapper{position:relative;overflow:hidden;background:rgba(255,255,255,0.04);display:flex;align-items:center;justify-content:center}', '.sfd-img-wrapper::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent 25%,rgba(255,255,255,0.04) 50%,transparent 75%);background-size:200% 100%;animation:sfd-shimmer 1.5s ease-in-out infinite}', '.sfd-img-wrapper.sfd-img-loaded::before,.sfd-img-wrapper.sfd-img-broken::before{display:none}', '.sfd-img-wrapper img{display:block;width:100%;height:auto;object-fit:cover;opacity:0;transition:opacity 0.2s}', '.sfd-img-wrapper.sfd-img-loaded img{opacity:1}', '.sfd-img-wrapper.sfd-img-broken img{display:none}', '.sfd-img-broken-icon{opacity:0.3;font-size:24px;color:var(--sfd-text-secondary)}', '@keyframes sfd-shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}', '.sfd-pl-recent-meta{display:flex;gap:10px;font-size:10px;color:#94a3b8;flex-wrap:wrap}', '.sfd-pl-recent-pct{color:#a78bfa;font-weight:600}', '.sfd-pl-recent-bar{height:5px;border-radius:3px;background:rgba(255,255,255,0.06);overflow:hidden}', '.sfd-pl-recent-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,#8b5cf6,#6366f1);transition:width 0.4s ease}', '.sfd-pl-section{background:rgba(15,23,42,0.4);border:1px solid var(--sfd-border);border-radius:var(--sfd-radius-md);padding:12px;margin-bottom:10px}', '.sfd-pl-section-title{font-size:13px;font-weight:700;color:#c7d5e0;margin-bottom:10px;display:flex;align-items:center;gap:6px;padding-bottom:8px;border-bottom:1px solid rgba(139,92,246,0.12)}', '.sfd-pl-section-title svg{width:15px;height:15px;color:#a78bfa}', '.sfd-pl-game-item{display:flex;align-items:center;gap:10px;padding:8px 10px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.04);border-radius:8px;transition:var(--sfd-transition);margin-bottom:4px}', '.sfd-pl-game-item:hover{background:rgba(255,255,255,0.05);border-color:rgba(139,92,246,0.2)}', '.sfd-pl-game-rank{width:24px;height:24px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0}', '.sfd-pl-game-rank.rN{background:rgba(100,116,139,0.15);color:#64748b}', '.sfd-pl-game-icon{width:36px;height:36px;border-radius:6px;flex-shrink:0;object-fit:cover}', '.sfd-pl-game-info{flex:1;min-width:0}', '.sfd-pl-game-name{font-size:12px;font-weight:600;color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none;transition:var(--sfd-transition)}', '.sfd-pl-game-name:hover{color:#a78bfa}', '.sfd-pl-game-meta{display:flex;align-items:center;gap:8px;margin-top:2px;flex-wrap:wrap}', '.sfd-pl-game-time{font-size:10px;color:#64748b;display:flex;align-items:center;gap:3px}', '.sfd-pl-game-playtime{font-size:11px;font-weight:600;color:#a78bfa}', '.sfd-pl-game-bar-wrap{width:80px;height:6px;background:rgba(255,255,255,0.06);border-radius:3px;overflow:hidden;flex-shrink:0}', '.sfd-pl-game-bar-fill{height:100%;background:linear-gradient(90deg,#8b5cf6,#a78bfa);border-radius:3px;transition:width 0.5s}', '.sfd-pl-delisted-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 6px;border-radius:3px;margin-left:6px}', '.sfd-pl-delisted-badge.type-delisted{background:rgba(244,67,54,0.2);color:#f44336}', '.sfd-pl-delisted-badge.type-purchase_disabled{background:rgba(255,152,0,0.2);color:#ff9800}', '.sfd-pl-delisted-badge.type-f2p_unavailable{background:rgba(156,39,176,0.2);color:#ce93d8}', '.sfd-pl-delisted-badge.type-retail_only{background:rgba(33,150,243,0.2);color:#64b5f6}', '.sfd-pl-delisted-badge.type-test_app{background:rgba(96,125,139,0.2);color:#90a4ae}', '.sfd-pl-loading svg{width:28px;height:28px;animation:sfd-spin 1s linear infinite;color:#a78bfa}', '.sfd-pl-timeline-divider{display:flex;align-items:center;gap:10px;margin:14px 0 10px}', '.sfd-pl-timeline-line{flex:1;height:2px;background:linear-gradient(90deg,transparent,rgba(139,92,246,0.6),transparent);border-radius:1px}', '.sfd-pl-timeline-date{font-size:12px;font-weight:600;color:#a78bfa;white-space:nowrap;letter-spacing:0.5px;text-shadow:0 0 8px rgba(139,92,246,0.3)}', '.sfd-pl-coowned-badge{display:inline-flex;align-items:center;gap:3px;font-size:9px;font-weight:600;padding:1px 5px;border-radius:3px;background:rgba(245,158,11,0.55);color:#fff;border:1px solid rgba(245,158,11,0.7);white-space:nowrap}', '.sfd-pl-solo-badge{display:inline-flex;align-items:center;gap:3px;font-size:9px;font-weight:600;padding:1px 5px;border-radius:3px;background:rgba(46,213,115,0.5);color:#fff;border:1px solid rgba(46,213,115,0.65);white-space:nowrap}', '.sfd-pl-mismatch-badge{display:inline-flex;align-items:center;gap:3px;font-size:9px;font-weight:600;padding:1px 5px;border-radius:3px;background:rgba(239,68,68,0.15);color:#ef4444;border:1px solid rgba(239,68,68,0.25);white-space:nowrap}', '.sfd-pl-filter-bar{display:flex;gap:4px;flex-shrink:0}', '@media(max-width:768px){.sfd-pl-popup{width:100vw!important;max-width:100vw!important;height:100vh!important;max-height:100vh!important;border-radius:0!important}.sfd-pl-stats{grid-template-columns:repeat(2,1fr)!important}}', /* ===== 社交仪表盘 (v1.0.17) ===== */ '.sfd-dash-content{flex:1;overflow-y:auto;overflow-x:hidden;padding:0 14px 14px;display:flex;flex-direction:column;gap:12px;box-sizing:border-box}', '.sfd-dash-grid{display:grid;grid-template-columns:1.28fr 1fr;gap:12px;align-items:start}', '.sfd-dash-col{display:flex;flex-direction:column;gap:12px;min-width:0}', '.sfd-dash-card{background:rgba(15,23,42,0.45);border:1px solid rgba(255,255,255,0.07);border-radius:12px;padding:12px 14px;min-width:0}', '.sfd-dash-card-title{font-size:13px;font-weight:700;color:#e2e8f0;margin:0 0 10px;display:flex;align-items:center;gap:7px}', '.sfd-dash-card-title svg{width:15px;height:15px;color:#67e8f9;flex-shrink:0}', '.sfd-dash-card-sub{font-size:10px;color:#64748b;font-weight:400;margin-left:auto;white-space:nowrap}', '.sfd-dash-rank{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;background:rgba(255,255,255,0.025);border:1px solid rgba(255,255,255,0.05);margin-bottom:8px;transition:var(--sfd-transition);cursor:pointer}', '.sfd-dash-rank:hover{background:rgba(102,192,244,0.07);border-color:rgba(102,192,244,0.3);transform:translateX(2px)}', '.sfd-dash-rank-no{width:26px;height:26px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;color:#94a3b8;background:rgba(255,255,255,0.05);flex-shrink:0}', '.sfd-dash-rank-no.r1{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff;box-shadow:0 2px 8px rgba(245,158,11,0.4)}', '.sfd-dash-rank-no.r2{background:linear-gradient(135deg,#cbd5e1,#94a3b8);color:#1e293b;box-shadow:0 2px 8px rgba(148,163,184,0.35)}', '.sfd-dash-rank-no.r3{background:linear-gradient(135deg,#d97706,#92400e);color:#fff;box-shadow:0 2px 8px rgba(217,119,6,0.35)}', '.sfd-dash-rank-avatar{width:38px;height:38px;border-radius:8px;flex-shrink:0;object-fit:cover;border:1px solid rgba(255,255,255,0.1)}', '.sfd-dash-rank-mid{flex:1;min-width:0}', '.sfd-dash-rank-name{display:flex;justify-content:space-between;align-items:baseline;gap:8px;font-size:12.5px;font-weight:700;color:#f1f5f9}', '.sfd-dash-rank-name span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.sfd-dash-rank-hours{color:#fbbf24;font-weight:800;font-variant-numeric:tabular-nums;white-space:nowrap;font-size:12px}', '.sfd-dash-rank-game{display:flex;justify-content:space-between;gap:8px;font-size:10.5px;color:#94a3b8;line-height:1.55}', '.sfd-dash-rank-game span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.sfd-dash-rank-game b{color:#7dd3fc;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}', '.sfd-dash-rank-bar{height:4px;border-radius:2px;background:rgba(255,255,255,0.07);margin-top:6px;overflow:hidden}', '.sfd-dash-rank-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,#06b6d4,#3b82f6);transition:width 0.6s ease}', '.sfd-dash-rank-pct{font-size:9.5px;color:#64748b;text-align:right;margin-top:2px;font-variant-numeric:tabular-nums}', '.sfd-dash-rank-cap{width:92px;height:35px;border-radius:5px;object-fit:cover;flex-shrink:0;background:#0f172a;border:1px solid rgba(255,255,255,0.08)}', '.sfd-dash-donut-wrap{display:flex;flex-direction:column;align-items:center;gap:14px}', '.sfd-dash-donut{flex-shrink:0;position:relative;width:170px;height:170px}', '.sfd-dash-donut svg{transform:rotate(-90deg)}', '.sfd-dash-donut circle{fill:none;transition:stroke-width 0.2s,opacity 0.2s;cursor:pointer}', '.sfd-dash-donut-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none}', '.sfd-dash-donut-total{font-size:19px;font-weight:800;color:#fff;font-variant-numeric:tabular-nums}', '.sfd-dash-donut-total-lbl{font-size:9.5px;color:#64748b;font-weight:600}', '.sfd-dash-legend{width:100%;display:flex;flex-direction:column;gap:6px}', '.sfd-dash-legend-item{display:flex;align-items:center;gap:8px;font-size:11px;color:#cbd5e1;padding:4px 6px;border-radius:6px;cursor:pointer;transition:var(--sfd-transition)}', '.sfd-dash-legend-item:hover{background:rgba(102,192,244,0.08)}', '.sfd-dash-legend-dot{width:9px;height:9px;border-radius:3px;flex-shrink:0}', '.sfd-dash-legend-cap{width:88px;height:33px;border-radius:4px;object-fit:cover;flex-shrink:0;background:#0f172a;border:1px solid rgba(255,255,255,0.08)}', '.sfd-dash-legend-cap-ph{display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.04)}', '.sfd-dash-legend-mid{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}', '.sfd-dash-legend-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}', '.sfd-dash-legend-val{color:#94a3b8;font-variant-numeric:tabular-nums;white-space:nowrap;font-size:10px}', '.sfd-dash-legend-pct{color:#67e8f9;font-weight:700;width:44px;text-align:right;font-variant-numeric:tabular-nums;flex-shrink:0}', '.sfd-dash-owned-row{display:flex;align-items:center;gap:8px;margin-bottom:7px;font-size:11px;cursor:pointer;padding:3px 4px;border-radius:6px;transition:var(--sfd-transition)}', '.sfd-dash-owned-row:hover{background:rgba(102,192,244,0.06)}', '.sfd-dash-owned-no{width:18px;text-align:center;color:#64748b;font-weight:700;font-size:10px;flex-shrink:0}', '.sfd-dash-owned-avatar{width:22px;height:22px;border-radius:5px;flex-shrink:0;object-fit:cover}', '.sfd-dash-owned-name{width:104px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#cbd5e1;font-weight:600;flex-shrink:0}', '.sfd-dash-owned-bar{flex:1;height:15px;background:rgba(255,255,255,0.05);border-radius:4px;overflow:hidden;position:relative}', '.sfd-dash-owned-fill{height:100%;border-radius:4px;background:linear-gradient(90deg,#8b5cf6,#6366f1);transition:width 0.6s ease}', '.sfd-dash-owned-val{width:96px;text-align:right;color:#a5b4fc;font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap;flex-shrink:0}', '.sfd-dash-owned-hours{color:#64748b;font-weight:400;font-size:9.5px}', '.sfd-dash-fr-fill{height:100%;border-radius:4px;background:linear-gradient(90deg,#06b6d4,#0891b2);transition:width 0.6s ease}', '.sfd-dash-fr-val{color:#67e8f9!important}', '.sfd-dash-heat-wrap{overflow-x:auto;padding-bottom:2px}', '.sfd-dash-heat-wrap::-webkit-scrollbar{height:6px}', '.sfd-dash-heat-wrap::-webkit-scrollbar-thumb{background:var(--sfd-border);border-radius:3px}', '.sfd-dash-heat{display:grid;gap:3px;width:max-content}', '.sfd-dash-heat-name{display:flex;align-items:center;gap:5px;font-size:10px;color:#94a3b8;height:15px;width:118px;overflow:hidden;white-space:nowrap;padding-right:4px}', '.sfd-dash-heat-name img{width:14px;height:14px;border-radius:3px;flex-shrink:0}', '.sfd-dash-heat-cell{width:13px;height:13px;border-radius:3px;background:rgba(255,255,255,0.05);transition:transform 0.1s}', '.sfd-dash-heat-cell:hover{transform:scale(1.25);outline:1px solid rgba(102,192,244,0.5)}', '.sfd-dash-heat-label{font-size:8.5px;color:#475569;height:12px;text-align:center;line-height:12px;font-variant-numeric:tabular-nums}', '.sfd-dash-heat-legend{display:flex;align-items:center;gap:4px;font-size:9.5px;color:#64748b;margin-top:8px;justify-content:flex-end}', '.sfd-dash-heat-legend i{width:11px;height:11px;border-radius:2.5px;display:inline-block}', '.sfd-dash-tip{position:fixed;z-index:99999;pointer-events:none;background:rgba(8,12,22,0.97);border:1px solid rgba(102,192,244,0.3);border-radius:10px;padding:10px 12px;font-size:11px;color:#e2e8f0;box-shadow:0 10px 34px rgba(0,0,0,0.65);display:none;max-width:250px;line-height:1.5}', '.sfd-dash-tip img{width:100%;border-radius:6px;margin-bottom:7px;display:block}', '.sfd-dash-tip b{color:#fff}', '.sfd-dash-progress-text{font-size:12px;color:#94a3b8;font-variant-numeric:tabular-nums}', '.sfd-dash-empty{text-align:center;padding:34px 20px;color:var(--sfd-text-secondary);font-size:12.5px;line-height:1.8}', '.sfd-dash-hint{font-size:10px;color:#475569;margin-top:8px;line-height:1.6}', '.sfd-dash-updated{font-size:10px;color:#64748b;font-weight:400;margin-left:8px;white-space:nowrap}', '@media(max-width:1150px){.sfd-dash-grid{grid-template-columns:1fr}.sfd-dash-kpis{grid-template-columns:repeat(3,1fr)}}', /* ===== 仪表盘加载头像网格 (v1.1.5) ===== */ '.sfd-dash-load-stages{display:flex;gap:4px;margin-bottom:8px;flex-shrink:0}', '.sfd-dash-load-stage{flex:1;height:3px;background:rgba(255,255,255,0.06);border-radius:2px;transition:all 0.3s}', '.sfd-dash-load-stage.active{background:linear-gradient(90deg,#06b6d4,#3b82f6);box-shadow:0 0 8px rgba(6,182,212,0.6)}', '.sfd-dash-load-stage.done{background:rgba(16,185,129,0.5)}', '.sfd-dash-load-mainbar{height:6px;background:rgba(255,255,255,0.04);border-radius:3px;overflow:hidden;position:relative;margin-bottom:6px;box-shadow:inset 0 1px 2px rgba(0,0,0,0.3);flex-shrink:0}', '.sfd-dash-load-mainbar-fill{height:100%;background:linear-gradient(90deg,#06b6d4,#3b82f6,#8b5cf6);border-radius:3px;transition:width 0.4s cubic-bezier(0.4,0,0.2,1);position:relative;overflow:hidden;box-shadow:0 0 10px rgba(6,182,212,0.6)}', '.sfd-dash-load-mainbar-fill::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.45),transparent);animation:sfd-dash-shimmer 1.4s linear infinite}', '.sfd-dash-load-text{font-size:11px;color:#8a9ba8;margin-bottom:8px;font-variant-numeric:tabular-nums;flex-shrink:0;display:flex;align-items:center;gap:6px}', '.sfd-dash-load-text b{color:#67e8f9;font-weight:600}', '.sfd-dash-load-grid{display:flex;flex-wrap:wrap;gap:5px;padding:0;flex:1;overflow-y:auto;overflow-x:hidden;align-content:flex-start}', '.sfd-dash-load-grid::-webkit-scrollbar{width:4px}', '.sfd-dash-load-grid::-webkit-scrollbar-thumb{background:var(--sfd-border);border-radius:2px}', '.sfd-dash-load-cell{width:32px;height:32px;border-radius:7px;position:relative;overflow:hidden;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.05);flex-shrink:0;transition:opacity 0.3s,border-color 0.3s,box-shadow 0.3s}', '.sfd-dash-load-cell::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent 0%,rgba(103,232,249,0.06) 50%,transparent 100%);animation:sfd-dash-shimmer 1.6s linear infinite}', '.sfd-dash-load-cell img{width:100%;height:100%;object-fit:cover;opacity:0;transition:opacity 0.4s ease}', '.sfd-dash-load-cell.loaded{border-color:rgba(255,255,255,0.08)}', '.sfd-dash-load-cell.loaded img{opacity:1}', '.sfd-dash-load-cell.loaded::before{display:none}', '.sfd-dash-load-cell.active{border-color:#67e8f9;box-shadow:0 0 8px rgba(103,232,249,0.4)}', '.sfd-dash-load-cell.active::before{background:linear-gradient(90deg,transparent 0%,rgba(103,232,249,0.2) 50%,transparent 100%);animation:sfd-dash-shimmer 0.7s linear infinite}', '.sfd-dash-load-cell.dimmed{opacity:0.25}', '.sfd-dash-load-cell.dimmed.loaded img{opacity:0.5}', // 2026精品 '.sfd-stress-scroll{flex:1;overflow-y:auto;min-height:0;padding-right:2px}', '.sfd-stress-group{margin-bottom:12px}', '.sfd-stress-group-header{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#1a2233;border-radius:8px;margin-bottom:8px;position:sticky;top:0;z-index:10}', '.sfd-stress-group-title{font-size:14px;font-weight:700;color:#f8fafc;flex-shrink:0}', '.sfd-stress-group-count{font-size:12px;color:#94a3b8;flex-shrink:0}', '.sfd-stress-group-bar{flex:1;height:8px;background:#0f172a;border-radius:4px;overflow:hidden;min-width:60px}', '.sfd-stress-group-bar-fill{height:100%;border-radius:4px;transition:width .3s ease}', '.sfd-stress-group-bar-fill.released{background:linear-gradient(90deg,#06cfbe,#2ed573)}', '.sfd-stress-group-bar-fill.unreleased{background:linear-gradient(90deg,#f59e0b,#ff9f43)}', '.sfd-stress-group-pct{font-size:12px;font-weight:600;color:#94a3b8;flex-shrink:0;min-width:40px;text-align:right}', '.sfd-stress-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}', '.sfd-stress-card{display:flex;flex-direction:column;gap:4px;padding:6px;border-radius:8px;background:rgba(15,23,42,0.5);border:1px solid var(--sfd-border);transition:var(--sfd-transition)}', '.sfd-stress-card.checking{animation:sfd-stress-pulse 1.2s ease-in-out infinite;border-color:rgba(102,192,244,0.5);box-shadow:0 0 8px rgba(102,192,244,0.3)}', '.sfd-stress-card.not-owned{opacity:0.55;border-color:rgba(255,255,255,0.06)}', '.sfd-stress-card.not-owned .sfd-stress-poster{filter:grayscale(100%) brightness(0.6)}', '.sfd-stress-card.not-owned .sfd-img-wrapper img{filter:grayscale(100%) brightness(0.6)}', '.sfd-stress-card.owned{background:rgba(46,213,115,0.07);border-color:rgba(46,213,115,0.3)}', '.sfd-stress-poster{width:100%;aspect-ratio:231/87;border-radius:4px;object-fit:cover;flex-shrink:0;background:rgba(15,23,42,0.6)}', '.sfd-stress-info{min-width:0;display:flex;flex-direction:column;gap:1px;padding:0 2px}', '.sfd-stress-name{font-size:11px;font-weight:600;color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3}', '.sfd-stress-date{font-size:10px;color:#f59e0b;font-weight:600}', '.sfd-stress-date.past{color:#94a3b8;font-weight:400}', '.sfd-stress-owners{display:flex;align-items:center;gap:2px;flex-wrap:wrap;margin-top:2px}', '.sfd-stress-owners img{width:18px;height:18px;border-radius:50%;object-fit:cover;border:1px solid rgba(255,255,255,0.15)}', '.sfd-stress-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:40px;color:#94a3b8}', '.sfd-stress-loading svg{width:32px;height:32px;animation:sfd-spin 1s linear infinite}', '.sfd-stress-empty{text-align:center;padding:40px 20px;color:#94a3b8;font-size:13px}', // 即将发售焦点大卡片(新版:一行展示多个) '.sfd-stress-featured-wrap{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}', '.sfd-stress-featured{display:flex;align-items:center;gap:10px;border-radius:10px;overflow:hidden;background:linear-gradient(135deg,rgba(245,158,11,0.08),rgba(255,159,67,0.04));border:1px solid rgba(245,158,11,0.3);cursor:pointer;transition:var(--sfd-transition);padding:8px 10px}', '.sfd-stress-featured.owned{border-color:rgba(76,175,80,0.4);background:linear-gradient(135deg,rgba(76,175,80,0.08),rgba(102,187,106,0.04))}', '.sfd-stress-featured.owned.owned-self{border-color:rgba(102,187,106,0.5);background:linear-gradient(135deg,rgba(102,187,106,0.1),rgba(129,199,132,0.05))}', '.sfd-stress-featured.owned.owned-family{border-color:rgba(76,175,80,0.4);background:linear-gradient(135deg,rgba(76,175,80,0.08),rgba(102,187,106,0.04))}', '.sfd-stress-featured.owned .sfd-stress-featured-badge.self{color:#66bb6a;background:rgba(102,187,106,0.2);border-color:rgba(102,187,106,0.4)}', '.sfd-stress-featured.owned .sfd-stress-featured-badge.family{color:#4caf50;background:rgba(76,175,80,0.15);border-color:rgba(76,175,80,0.3)}', // 网格卡片预购标签 '.sfd-stress-tag{display:inline-block;align-self:flex-end;font-size:10px;font-weight:700;padding:1px 6px;border-radius:3px;margin-top:2px;line-height:1.4}', '.sfd-stress-tag.self{color:#66bb6a;background:rgba(102,187,106,0.15);border:1px solid rgba(102,187,106,0.3)}', '.sfd-stress-tag.family{color:#4caf50;background:rgba(76,175,80,0.12);border:1px solid rgba(76,175,80,0.25)}', '.sfd-stress-featured-poster{width:160px;height:60px;border-radius:6px;object-fit:cover;flex-shrink:0;background:rgba(15,23,42,0.6)}', '.sfd-stress-featured-info{min-width:0;display:flex;flex-direction:column;gap:4px;padding-right:14px;border-right:1px solid rgba(245,158,11,0.25)}', '.sfd-stress-featured-badge{display:inline-block;align-self:flex-start;font-size:11px;font-weight:700;color:#f59e0b;background:rgba(245,158,11,0.15);border:1px solid rgba(245,158,11,0.3);padding:1px 8px;border-radius:4px}', '.sfd-stress-featured-name{font-size:15px;font-weight:700;color:#f8fafc;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.sfd-stress-featured-date{font-size:12px;color:#94a3b8}', '.sfd-stress-featured-countdown{display:flex;flex-direction:column;align-items:center;justify-content:center;flex-shrink:0}', '.sfd-stress-featured-days{font-size:26px;font-weight:800;color:#ff9f43;line-height:1}', '.sfd-stress-featured-days-lbl{font-size:11px;color:#f59e0b;margin-top:3px;white-space:nowrap}', '.sfd-stress-featured-owners{display:flex;align-items:center;gap:3px;flex-shrink:0}', '.sfd-stress-featured-owners img{width:24px;height:24px;border-radius:50%;object-fit:cover;border:1px solid rgba(255,255,255,0.15)}', // ===== 封面科技感加载骨架屏 (v1.2.4) ===== '.sfd-img-wrapper{position:relative;overflow:hidden;background:linear-gradient(135deg,rgba(15,23,42,0.9),rgba(30,41,59,0.7));border-radius:4px;flex-shrink:0}', '.sfd-img-wrapper::before{content:"";position:absolute;inset:0;z-index:1;background:linear-gradient(90deg,transparent 0%,rgba(102,192,244,0.08) 50%,transparent 100%);animation:sfd-shimmer 1.6s ease-in-out infinite;pointer-events:none}', '.sfd-img-wrapper::after{content:"";position:absolute;top:50%;left:50%;z-index:2;width:28px;height:28px;margin:-14px 0 0 -14px;border:2px solid rgba(102,192,244,0.2);border-top-color:rgba(102,192,244,0.7);border-radius:50%;animation:sfd-spin 0.8s linear infinite;pointer-events:none;opacity:0.6}', '.sfd-img-wrapper img{position:relative;z-index:3;width:100%;height:100%;object-fit:cover;display:block;opacity:0;transition:opacity 0.35s ease}', '.sfd-img-wrapper.sfd-img-loaded::before{animation:none;opacity:0;transition:opacity 0.3s ease}', '.sfd-img-wrapper.sfd-img-loaded::after{display:none}', '.sfd-img-wrapper.sfd-img-loaded img{opacity:1}', '.sfd-img-wrapper.sfd-img-broken{background:linear-gradient(135deg,rgba(15,23,42,0.9),rgba(30,41,59,0.7))}', '.sfd-img-wrapper.sfd-img-broken::before{animation:none;opacity:0}', '.sfd-img-wrapper.sfd-img-broken::after{display:none}', '.sfd-img-wrapper.sfd-img-broken img{display:none}', '.sfd-img-broken-icon{position:absolute;z-index:4;top:50%;left:50%;transform:translate(-50%,-50%);color:rgba(102,192,244,0.3);pointer-events:none;display:none}', '.sfd-img-wrapper.sfd-img-broken .sfd-img-broken-icon{display:block}', '.sfd-img-broken-icon svg{width:24px;height:24px}', // 小型 wrapper(用于游戏图标 36x36 / 32x32 等) '.sfd-img-wrapper.sfd-img-sm::after{width:18px;height:18px;margin:-9px 0 0 -9px;border-width:1.5px}', '.sfd-img-broken-icon.sfd-img-broken-sm svg{width:16px;height:16px}', // capsule 封面 wrapper '.sfd-img-wrapper.sfd-img-cap{width:100%;aspect-ratio:231/87;border-radius:4px;flex-shrink:0}', '.sfd-img-wrapper.sfd-img-cap::after{width:32px;height:32px;margin:-16px 0 0 -16px}', // featured poster 固定尺寸 wrapper '.sfd-stress-featured-wrap .sfd-img-wrapper{width:160px;height:60px;aspect-ratio:auto;border-radius:6px}', // 仪表盘 rank/legend cap 固定尺寸 wrapper '.sfd-dash-rank .sfd-img-wrapper{width:92px;height:35px;aspect-ratio:auto;border-radius:5px}', '.sfd-dash-legend .sfd-img-wrapper{width:88px;height:33px;aspect-ratio:auto;border-radius:4px}', // header 大图 wrapper // 通用 shimmer 动画 '@keyframes sfd-shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}', /* ===== v1.2.5 好友总览合并布局 ===== */ '.sfd-ov-hero{display:flex;align-items:center;gap:14px;margin-bottom:10px;flex-shrink:0}', '.sfd-ov-kpis{display:grid;grid-template-columns:repeat(9,70px);gap:6px;flex:1;min-width:0}', '.sfd-ov-kpis .sfd-metric-card{cursor:pointer;padding:5px 6px;min-height:42px;box-sizing:border-box}', '.sfd-ov-kpis .sfd-metric-val{font-size:16px}', '.sfd-ov-kpis .sfd-metric-lbl{font-size:9px;margin-top:2px}', /* Filter Chips */ '.sfd-ov-sort-btn{font-size:11px;padding:3px 8px;border:1px solid var(--sfd-border);border-radius:6px;background:rgba(255,255,255,0.04);color:#94a3b8;cursor:pointer;transition:var(--sfd-transition);line-height:1.3}', '.sfd-ov-sort-btn:hover{color:#e2e8f0;border-color:rgba(102,192,244,0.4)}', '.sfd-ov-sort-btn.active{color:#66c0f4;border-color:rgba(102,192,244,0.5);background:rgba(102,192,244,0.1)}', '@media(max-width:900px){.sfd-ov-kpis{grid-template-columns:repeat(3,1fr)}.sfd-ov-hero{flex-wrap:wrap}}', ].join(''); GM_addStyle(CSS_STYLES); const locale = 'zh-CN'; // 固定中文显示 const t = k => I18N['zh-CN'][k] || k; // ==================== 国家/地区映射 ==================== const COUNTRY_MAP = { CN:'中国',US:'美国',JP:'日本',KR:'韩国',TW:'中国台湾',HK:'中国香港',MO:'中国澳门', SG:'新加坡',MY:'马来西亚',TH:'泰国',VN:'越南',ID:'印度尼西亚',PH:'菲律宾', IN:'印度',AU:'澳大利亚',NZ:'新西兰',GB:'英国',DE:'德国',FR:'法国',IT:'意大利', ES:'西班牙',RU:'俄罗斯',BR:'巴西',CA:'加拿大',MX:'墨西哥',AR:'阿根廷',CL:'智利', SE:'瑞典',NO:'挪威',FI:'芬兰',DK:'丹麦',NL:'荷兰',BE:'比利时',AT:'奥地利', CH:'瑞士',PL:'波兰',CZ:'捷克',PT:'葡萄牙',GR:'希腊',TR:'土耳其', SA:'沙特阿拉伯',AE:'阿联酋',EG:'埃及',ZA:'南非',IL:'以色列',UA:'乌克兰', RO:'罗马尼亚',HU:'匈牙利',SK:'斯洛伐克',BG:'保加利亚',HR:'克罗地亚', SI:'斯洛文尼亚',LT:'立陶宛',LV:'拉脱维亚',EE:'爱沙尼亚',IE:'爱尔兰', IS:'冰岛',LI:'列支敦士登',NE:'尼日尔',MC:'摩纳哥',LU:'卢森堡',MT:'马耳他',CY:'塞浦路斯',XK:'科索沃',VG:'英属维尔京群岛',CW:'库拉索',SS:'南苏丹' }; function getCountryInfo(code) { if (!code) return null; const uc = code.toUpperCase(); const name = COUNTRY_MAP[uc] || uc; return { name, flag: flagSvg(uc) }; } // ==================== HTML 转义 ==================== const esc = s => String(s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // ==================== DOM 辅助构建函数 `h` ==================== const BOOL_ATTRS = new Set(['disabled', 'checked', 'selected', 'readonly', 'required', 'hidden', 'multiple', 'autofocus', 'open']); function h(tag, props = {}, children = []) { const el = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (v == null) continue; if (k === 'style' && typeof v === 'object') { const entries = Object.entries(v); for (const [sk, sv] of entries) { if (sk.startsWith('--')) el.style.setProperty(sk, sv); else { el.style[sk] = sv; } } } else if (k === 'dataset') { Object.assign(el.dataset, v); } else if (k.startsWith('on') && typeof v === 'function') { el.addEventListener(k.slice(2).toLowerCase(), v); } else if (k === 'text') { el.textContent = v; } else if (k === 'html') { el.innerHTML = v; } else if (BOOL_ATTRS.has(k)) { v ? el.setAttribute(k, '') : el.removeAttribute(k); } else { el.setAttribute(k, v); } } for (const c of children) { if (c == null || c === false) continue; el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } return el; } // ==================== 进度条辅助函数 ==================== function showProgressBar() { const wrap = dom.panelProgressWrap; const bar = dom.panelProgressBar; if (wrap) wrap.style.display = ''; if (bar) bar.style.width = '0%'; return bar; } function hideProgressBar() { const wrap = dom.panelProgressWrap; const bar = dom.panelProgressBar; if (bar) bar.style.width = '100%'; setTimeout(() => { if (wrap) wrap.style.display = 'none'; if (bar) bar.style.width = '0%'; }, 1500); } function setProgressPercent(bar, percent) { if (bar) bar.style.width = Math.min(100, Math.round(percent)) + '%'; } // ==================== 统一指标卡片工厂函数 ==================== // 创建统一的指标卡片,替代原有的 5 套独立卡片样式 function createMetricCard(opts) { // value: 数值文本 | label: 标签文本 | iconSvg: SVG图标(row布局用) // accent: 主题色 | layout: 'compact'|'label'|'row' | active: 激活态 | dataset: data-*属性 | onClick: 点击回调 const { value, label, iconSvg, accent, layout = 'compact', active = false, dataset, onClick, id } = opts; const cls = 'sfd-metric-card sfd-metric-' + layout + (active ? ' active' : ''); const style = accent ? { '--metric-accent': accent } : {}; const props = { class: cls, style, onClick }; if (dataset) props.dataset = dataset; const children = []; if (layout === 'row' && iconSvg) { children.push(h('div', { class: 'sfd-metric-icon', style: accent ? { background: accent + '26', color: accent } : {}, html: iconSvg })); } const valProps = { class: 'sfd-metric-val', style: accent ? { color: accent } : {}, text: value }; if (id) valProps.id = id; const bodyChildren = [ h('div', valProps), h('div', { class: 'sfd-metric-lbl', text: label }) ]; if (layout === 'row') { children.push(h('div', { class: 'sfd-metric-body' }, bodyChildren)); } else { children.push(...bodyChildren); } return h('div', props, children); } // ==================== 通用辅助函数 ==================== function filterFriendsByQuery(list, query, includeSteamId = false) { if (!query) return list; const q = query.toLowerCase(); return list.filter(f => (f.personaname && f.personaname.toLowerCase().includes(q)) || (f.country_name && f.country_name.toLowerCase().includes(q)) || (f.loccountrycode && f.loccountrycode.toLowerCase().includes(q)) || (includeSteamId && f.steamid && f.steamid.includes(q)) ); } function createPopupContainer(width = '650px') { return h('div', { class: 'sfd-family-popup sfd-show', style: { position: 'relative', zIndex: 'auto', display: 'flex', width, maxWidth: '95vw', maxHeight: '85vh' } }); } function createPagination(page, totalPages, onPageChange, small) { const btnCls = 'sfd-btn sfd-btn-sm sfd-btn-ghost'; const btnStyle = small ? { fontSize: '11px', padding: '2px 6px', minWidth: '22px', lineHeight: '1' } : {}; const prevBtn = h('button', { class: btnCls, style: btnStyle, text: '◀', disabled: page <= 1, onClick: () => { onPageChange(page - 1); } }); const inputStyle = small ? { width: '32px', textAlign: 'center', fontSize: '11px', padding: '1px 2px', background: 'rgba(255,255,255,0.06)', border: '1px solid var(--sfd-border)', borderRadius: '4px', color: 'var(--sfd-text-primary)', outline: 'none' } : { width: '42px', textAlign: 'center', fontSize: '12px', padding: '2px 4px', background: 'rgba(255,255,255,0.06)', border: '1px solid var(--sfd-border)', borderRadius: '4px', color: 'var(--sfd-text-primary)', outline: 'none' }; const pageInput = h('input', { type: 'number', class: 'sfd-pg-input', value: String(page), min: '1', max: String(totalPages), style: inputStyle, onChange: (e) => { let p = parseInt(e.target.value, 10); if (isNaN(p) || p < 1) p = 1; if (p > totalPages) p = totalPages; e.target.value = String(p); if (p !== page) onPageChange(p); } }); const slashSpan = h('span', { style: { fontSize: small ? '11px' : '12px', color: 'var(--sfd-text-secondary)' }, text: `/${totalPages}` }); const nextBtn = h('button', { class: btnCls, style: btnStyle, text: '▶', disabled: page >= totalPages, onClick: () => { onPageChange(page + 1); } }); return h('div', { class: 'sfd-pagination', style: { display: 'flex', alignItems: 'center', gap: small ? '2px' : '8px', padding: '0' } }, [prevBtn, pageInput, slashSpan, nextBtn]); } function createCloseBtn(onClick) { return h('button', { class: 'sfd-header-btn sfd-header-btn-close', html: ICONS.close, onClick }); } function createRefreshBtn(onClick, title, cls) { return h('button', { class: cls || 'sfd-family-refresh', html: ICONS.refresh, title: title || (locale === 'zh-CN' ? '刷新数据' : 'Refresh'), onClick }); } // 加载动画构建器(统一 7 处重复的 loading 元素创建) function createLoadingEl(text, color = '#a29bfe', cls = 'sfd-activity-loading', extraStyle) { return h('div', { class: cls, style: extraStyle }, [ h('div', { html: ICONS.refresh, style: { animation: 'sfd-spin 1s linear infinite', color } }), h('div', { text }) ]); } // ==================== 状态管理 ==================== const state = { // ===== 好友列表域 ===== friends: { data: [], filtered: [], modalFiltered: [], ownProfile: null, searchQuery: '', sortBy: 'days-desc', activeTab: 'overview', isLoading: false, }, // ===== 好友总览域 ===== overview: { filter: 'all', search: '', sortBy: 'days-desc', page: 1, pageSize: 20, filtered: [], gameExpanded: true, onlineExpanded: false, offlineExpanded: false, deletedFriends: [], }, // ===== UI 通用域 ===== ui: { disposers: [] }, // ===== VAC 封禁域 ===== vac: { banned: 0, devBanned: 0, finished: false, queue: [] }, // ===== 家庭组域 ===== family: { info: null, gameList: null, popupSteamid: null, activeTab: 'chart', dynamicPage: 1, dynamicPageSize: 5, playActivity: null, playActivityLoading: false, chartShowDelisted: false, }, // ===== 个人游戏库域 ===== personal: { popupEl: null, activeTab: 'overview', gamesCache: null, recentCache: null, delistedCache: null, delistedTypeFilter: 'all', delistedPage: 1, delistedPageSize: 20, licenseHistoryCache: null, activityPage: 1, activitySearch: '', activityFilter: 'all', licenseMap: null, licenseMapLoading: false, coOwnedCount: 0, licenseVerifiedCount: 0, licenseMismatchCount: 0, licenseCorrectedCount: 0, }, // ===== 仪表盘域 ===== dashboard: { loading: false, data: null, error: null, progress: '', progressPct: 0, stage: 0, summaries: null, }, }; // 好友索引:steamid → friendData,O(1) 查找替代 O(n) find() const _friendsMap = new Map(); function syncFriendsMap() { _friendsMap.clear(); for (const f of state.friends.data) _friendsMap.set(String(f.steamid), f); } // 静态 DOM 引用缓存(面板元素,创建后不会销毁) const dom = {}; function cacheDom() { const ids = ['sfd-panel-header-info','sfd-fetch-btn','sfd-vac-btn','sfd-level-btn', 'sfd-mutual-btn','sfd-settings-btn','sfd-dash-refresh-btn','sfd-dash-updated', 'sfd-content','sfd-panel-progress-wrap','sfd-panel-progress-bar']; for (const id of ids) { const key = id.replace(/sfd-/g, '').replace(/-(\w)/g, (_, c) => c.toUpperCase()); dom[key] = document.getElementById(id); } } // 公共:从内存镜像读取(IndexedDB 数据已在启动时加载到 _dbCache) function parseStored(key, fallback) { const v = _dbGet(key, undefined); return v === undefined ? fallback : v; } // 带 TTL 检查的读取 function parseStoredTTL(key, ttl, fallback) { const v = _dbGet(key, undefined); if (v === undefined) return fallback; if (v && Date.now() - (v._ts || v.ts || 0) > ttl) return fallback; return v; } // 通用缓存装饰器:统一 "检查缓存 → 获取 → 存储缓存" 模式 // opts.memKey - state 属性路径(如 'personal.gamesCache') // opts.dbKey - IndexedDB 键(可选,启用持久化) // opts.ttl - TTL 毫秒(>0 时配合 dbKey 生效) // fetcher - 纯获取函数,返回 null/undefined 表示失败(不缓存) function withCache(opts, fetcher) { const { memKey, dbKey, ttl = 0 } = opts; const parts = memKey.split('.'); return async (...args) => { let obj = state; for (let i = 0; i < parts.length - 1; i++) obj = obj[parts[i]]; const prop = parts[parts.length - 1]; if (obj[prop] != null) return obj[prop]; if (dbKey && ttl > 0) { const cached = parseStoredTTL(dbKey, ttl, null); if (cached) { obj[prop] = cached; return cached; } } const result = await fetcher(...args); if (result != null) { obj[prop] = result; if (dbKey) _dbSet(dbKey, { ...result, _ts: Date.now() }); } return result; }; } const storage = { getApiKey: () => GM_getValue('sfd_api_key', ''), setApiKey: (val) => GM_setValue('sfd_api_key', val), getSteamId: () => GM_getValue('sfd_steamid', ''), setSteamId: (val) => GM_setValue('sfd_steamid', val), getCachedData: () => parseStored('sfd_cached_friends_data', null), setCachedData: (val) => _dbSet('sfd_cached_friends_data', val), getCachedOwn: () => parseStored('sfd_cached_own_profile', null), setCachedOwn: (val) => _dbSet('sfd_cached_own_profile', val), // VAC/Level/Mutual 统一走 _dbCache,消除 _memCache 冗余层 getVACCache: () => _dbGet('sfd_vac_cache', {}), setVACCache: (val) => _dbSet('sfd_vac_cache', val), getLevelCache: () => _dbGet('sfd_level_cache', {}), setLevelCache: (val) => _dbSet('sfd_level_cache', val), getFamilyCache: () => parseStored('sfd_family_cache', null), setFamilyCache: (val) => _dbSet('sfd_family_cache', val), getWishlistCache: () => parseStoredTTL('sfd_wishlist_cache', WISHLIST_TTL, null), setWishlistCache: (val) => _dbSet('sfd_wishlist_cache', val), delWishlistCache: () => _dbDelete('sfd_wishlist_cache'), getFamilyPlayActivityCache: () => parseStored('sfd_family_play_activity', null), setFamilyPlayActivityCache: (val) => _dbSet('sfd_family_play_activity', val), getMutualCache: () => _dbGet('sfd_mutual_cache', {}), setMutualCache: (val) => _dbSet('sfd_mutual_cache', val), // 入库动态时间线:累积所有检查记录,永不清除 // sfd_timeline_ -> { latestAppIds:[], gameInfo:{}, records:[{ts, type, newAppIds?, totalGames}] } getTimeline: (sid) => parseStored(`sfd_timeline_${sid}`, null), setTimeline: (sid, val) => _dbSet(`sfd_timeline_${sid}`, val), // 已删除好友:累积记录从好友列表中消失的好友 { [steamid]: { ...lastKnownData, _deletedAt } } getDeletedFriends: () => parseStored('sfd_deleted_friends', {}), setDeletedFriends: (val) => _dbSet('sfd_deleted_friends', val), getDashFriendsCache: () => parseStoredTTL('sfd_dash_friends_cache', DASH_FRIENDS_TTL, null), setDashFriendsCache: (val) => _dbSet('sfd_dash_friends_cache', val), // 按好友单独存储完整游戏库(IndexedDB 无单条大小限制) getOwnedGameCache: (sid) => parseStoredTTL(`sfd_og_${sid}`, OWNED_GAMES_TTL, null), setOwnedGameCache: (sid, val) => _dbSet(`sfd_og_${sid}`, val), delOwnedGameCache: (sid) => _dbDelete(`sfd_og_${sid}`), // 摘要缓存(只存game_count/totalMinutes,体积小,用于仪表盘批量判断哪些好友已缓存) getOwnedGamesSummary: () => parseStoredTTL('sfd_og_summary', OWNED_GAMES_TTL, {}), setOwnedGamesSummary: (val) => _dbSet('sfd_og_summary', val), getRecentCache: () => parseStoredTTL('sfd_recent_cache', RECENT_TTL, null), setRecentCache: (val) => _dbSet('sfd_recent_cache', val), getSummariesCache: () => parseStoredTTL('sfd_summaries_cache', SUMMARIES_TTL, null), setSummariesCache: (val) => _dbSet('sfd_summaries_cache', val), getDashSnaps: () => parseStored('sfd_dash_snaps', {}), setDashSnaps: (val) => _dbSet('sfd_dash_snaps', val), getDashCache: () => parseStored('sfd_dash_cache', null), setDashCache: (val) => _dbSet('sfd_dash_cache', val), // 游戏中文名缓存:{ appid: { name: '中文名', ts: timestamp } } getGameNameCache: () => parseStored('sfd_game_name_cache', {}), setGameNameCache: (val) => _dbSet('sfd_game_name_cache', val) }; // ==================== 工具函数 ==================== function gmFetch(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, onload(resp) { resolve(resp.responseText); }, onerror(err) { reject(err); } }); }); } function requestSteamAPI(url, timeout = 15000) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout, onload(resp) { try { resolve(JSON.parse(resp.responseText)); } catch (e) { reject(new Error('JSON parse failed')); } }, onerror(err) { reject(err); }, ontimeout() { reject(new Error('timeout')); } }); }); } // ==================== 游戏中文名获取 ==================== const _gameNameCache = storage.getGameNameCache(); // 内存缓存 const _gameNamePending = new Map(); // 防止重复请求 // 获取游戏中文名(异步,返回 Promise) function fetchGameZhName(appid) { const id = String(appid); const cached = _gameNameCache[id]; if (cached && cached.name && Date.now() - (cached.ts || 0) < NAME_CACHE_TTL) { return Promise.resolve(cached.name); } if (_gameNamePending.has(id)) return _gameNamePending.get(id); const p = SteamAPI.getAppDetails(id) .then(json => { const d = json && json[id]; let name = ''; if (d && d.success && d.data && d.data.name) name = d.data.name; if (name) { _gameNameCache[id] = { name, ts: Date.now() }; storage.setGameNameCache(_gameNameCache); } _gameNamePending.delete(id); return name; }) .catch(() => { _gameNamePending.delete(id); return ''; }); _gameNamePending.set(id, p); return p; } // 异步加载中文名并更新 DOM 元素 function loadGameZhName(el, appid, originalName) { if (!el || !appid) return; if (locale !== 'zh-CN') return; // 非中文环境不获取 fetchGameZhName(appid).then(zhName => { if (zhName && zhName !== originalName) { el.textContent = zhName; el.title = `${zhName} (${originalName})`; } }); } function detectCurrentSteamId() { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.g_steamID) return unsafeWindow.g_steamID; const match = document.documentElement.innerHTML.match(/g_steamID\s*=\s*"(\d{17})"/); return match ? match[1] : ''; } const DateUtils = { fromUnix(ts) { return ts ? new Date(ts * 1000) : null; }, fromMs(ms) { return ms ? new Date(ms) : null; }, format(ts) { if (!ts) return '-'; return new Date(ts * 1000).toLocaleDateString(locale === 'zh-CN' ? 'zh-CN' : 'en-US'); }, formatTimestamp(ts) { if (!ts) return '-'; const d = new Date(ts * 1000); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; }, formatMs(msTs) { if (!msTs) return locale === 'zh-CN' ? '未知' : 'unknown'; const d = new Date(msTs); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); return `${y}-${m}-${day} ${hh}:${mm}:${ss}`; }, relative(ts) { if (!ts) return '-'; const diff = Math.floor(Date.now() / 1000 - ts); if (diff < SECONDS_PER_MINUTE) return locale === 'zh-CN' ? '刚刚' : 'just now'; if (diff < SECONDS_PER_HOUR) return locale === 'zh-CN' ? `${Math.floor(diff / SECONDS_PER_MINUTE)}分钟前` : `${Math.floor(diff / SECONDS_PER_MINUTE)}m ago`; if (diff < SECONDS_PER_DAY) return locale === 'zh-CN' ? `${Math.floor(diff / SECONDS_PER_HOUR)}小时前` : `${Math.floor(diff / SECONDS_PER_HOUR)}h ago`; if (diff < SECONDS_PER_MONTH) return locale === 'zh-CN' ? `${Math.floor(diff / SECONDS_PER_DAY)}天前` : `${Math.floor(diff / SECONDS_PER_DAY)}d ago`; return DateUtils.format(ts); }, lastPlayed(ts) { if (!ts) return ''; const diff = Date.now() / 1000 - ts; if (diff < SECONDS_PER_DAY) return locale === 'zh-CN' ? '今天' : 'today'; if (diff < SECONDS_PER_MONTH) return locale === 'zh-CN' ? Math.floor(diff / SECONDS_PER_DAY) + '天前' : Math.floor(diff / SECONDS_PER_DAY) + 'd ago'; if (diff < SECONDS_PER_YEAR) return locale === 'zh-CN' ? Math.floor(diff / SECONDS_PER_MONTH) + '月前' : Math.floor(diff / SECONDS_PER_MONTH) + 'mo ago'; return locale === 'zh-CN' ? Math.floor(diff / SECONDS_PER_YEAR) + '年前' : Math.floor(diff / SECONDS_PER_YEAR) + 'y ago'; }, duration(minutes) { if (!minutes || minutes <= 0) return '-'; const zh = locale === 'zh-CN'; const h = Math.floor(minutes / 60); const m = minutes % 60; if (h > 0) return zh ? `${h}小时${m > 0 ? m + '分' : ''}` : `${h}h ${m}m`; return zh ? `${m}分钟` : `${m}m`; }, durationShort(minutes) { if (!minutes || minutes === 0) return locale === 'zh-CN' ? '0小时' : '0h'; const hours = Math.floor(minutes / 60); return locale === 'zh-CN' ? `${hours}小时` : hours + 'h'; }, daysSince(ts) { if (!ts) return { days: 0, text: '-' }; const diff = Math.max(0, Math.floor((Date.now() / 1000 - ts) / SECONDS_PER_DAY)); return { days: diff, text: `${diff}${t('dayUnit')}` }; } }; function getDaysClass(days) { if (days < 30) return 'new-friend'; if (days > 1000) return 'old-friend'; return 'normal'; } function getPersonaStateText(stateNum) { const states = [t('statusOffline'), t('statusOnline'), t('statusBusy'), t('statusAway'), t('statusSnooze'), t('statusTrade'), t('statusPlay')]; return states[stateNum] || t('statusOffline'); } function getPersonaStateClass(pstate, hasGame) { if (hasGame) return 'in-game'; if (pstate > 0) return 'online'; return 'offline'; } const toast = { _el: null, _show(msg, type) { let el = this._el || document.getElementById('sfd-toast-box'); if (!el) { el = h('div', { class: 'sfd-toast', id: 'sfd-toast-box' }); document.body.appendChild(el); this._el = el; } el.textContent = msg; el.className = 'sfd-toast' + (type ? ' sfd-toast-' + type : ''); requestAnimationFrame(() => el.classList.add('sfd-toast-show')); setTimeout(() => { el.classList.remove('sfd-toast-show'); setTimeout(() => { if (el && !el.classList.contains('sfd-toast-show')) el.textContent = ''; }, TOAST_FADE_MS); }, TOAST_DURATION); }, success(msg) { this._show(msg, 'success'); }, error(msg) { this._show(msg, 'error'); }, warning(msg) { this._show(msg, 'warning'); }, info(msg) { this._show(msg, 'info'); } }; // 统一错误处理策略: // - error: 致命错误,console.error + toast 提示用户 // - warn: 可恢复错误,console.warn 记录但不打断流程 // - silent: 预期可能失败的尝试(回退逻辑、可选数据),console.debug 记录 const logger = { error(msg, err) { console.error('[SFD] ' + msg, err || ''); toast.error(msg); }, warn(msg, err) { console.warn('[SFD] ' + msg, err || ''); }, silent(msg, err) { console.debug('[SFD] [silent] ' + msg, err || ''); } }; // ==================== VAC 检查系统 ==================== // VAC 状态徽章(返回 DOM 元素) function getVACShieldEl(f) { const hasVAC = !!f.vac_banned, hasDev = !!(f.vac_game_bans > 0); let icon, title; if (hasVAC && hasDev) { icon = ICONS.shieldBothBan; title = `VAC Banned - ${f.vac_days_since_last_ban}${t('dayUnit')}, ${t('devBan')} x${f.vac_game_bans}`; } else if (hasVAC) { icon = ICONS.shieldRed; title = `VAC Banned - ${f.vac_days_since_last_ban}${t('dayUnit')}`; } else if (hasDev) { icon = ICONS.shieldDevBan; title = `${t('devBan')} x${f.vac_game_bans}`; } else { icon = ICONS.shieldGreen; title = 'Clean'; } return h('span', { class: 'sfd-vac-shield-panel', html: icon, title }); } function getVACShieldPageEl(vacBanned, daysSinceLastBan, gameBans) { const hasVAC = !!vacBanned; const hasDev = !!(gameBans && gameBans > 0); if (hasVAC && hasDev) { const daysText = daysSinceLastBan ? t('vacDaysAgo').replace('{n}', daysSinceLastBan) : ''; const devItem = h('span', { class: 'sfd-shield-item', html: ICONS.shieldDevBan }); devItem.appendChild(h('span', { class: 'sfd-dev-ban-count', text: String(gameBans) })); return h('span', { class: 'sfd-vac-shield-page banned-both' }, [ daysText ? h('span', { class: 'sfd-vac-days', text: daysText }) : null, h('span', { class: 'sfd-shield-item', html: ICONS.shieldRed }), devItem ]); } if (hasDev) { const devItem = h('span', { class: 'sfd-shield-item', html: ICONS.shieldDevBan }); devItem.appendChild(h('span', { class: 'sfd-dev-ban-count', text: String(gameBans) })); return h('span', { class: 'sfd-vac-shield-page banned-dev' }, [devItem]); } if (hasVAC) { const daysText = daysSinceLastBan ? t('vacDaysAgo').replace('{n}', daysSinceLastBan) : ''; const el = h('span', { class: 'sfd-vac-shield-page banned', html: ICONS.shieldRed }); if (daysText) el.insertBefore(h('span', { class: 'sfd-vac-days', text: daysText }), el.firstChild); return el; } return h('span', { class: 'sfd-vac-shield-page clean', html: ICONS.shieldGreen }); } function renderVACShieldOnPage(friendEl, vacBanned, daysSinceLastBan, gameBans) { if (!friendEl) return; friendEl.style.position = 'relative'; const oldShield = friendEl.querySelector('.sfd-vac-shield-page'); if (oldShield) oldShield.remove(); friendEl.appendChild(getVACShieldPageEl(vacBanned, daysSinceLastBan, gameBans)); } function syncVacToFriendData(fd, vacInfo) { if (!fd || !vacInfo) return; fd.vac_banned = vacInfo.VACBanned; fd.vac_days_since_last_ban = vacInfo.DaysSinceLastBan || 0; fd.vac_number_of_bans = vacInfo.NumberOfVACBans || 0; fd.vac_game_bans = vacInfo.NumberOfGameBans || 0; } function vacSetStatus(steamid, data) { if (data.error) return; // 优化:直接通过属性选择器精确查找单个元素,避免遍历所有好友 const c = document.querySelector(`.friend_block_v2[data-steamid="${steamid}"]`); if (c) { const playerData = data.players && data.players[0]; if (playerData) { if (playerData.VACBanned) state.vac.banned++; if (playerData.NumberOfGameBans > 0) state.vac.devBanned++; const vacInfo = { VACBanned: playerData.VACBanned, DaysSinceLastBan: playerData.DaysSinceLastBan || 0, NumberOfVACBans: playerData.NumberOfVACBans || 0, NumberOfGameBans: playerData.NumberOfGameBans || 0, timestamp: Date.now() }; // 只更新内存缓存,批处理结束后统一持久化(避免每个玩家都写一次 IndexedDB) const cache = storage.getVACCache(); cache[steamid] = vacInfo; renderVACShieldOnPage(c, playerData.VACBanned, playerData.DaysSinceLastBan, playerData.NumberOfGameBans); // 同步到 friendsData const fd = _friendsMap.get(String(steamid)); if (fd) syncVacToFriendData(fd, vacInfo); } } updateVACStatus(); } function updateVACStatus() { const el = document.getElementById('sfd-vac-status'); if (!el) return; if (state.vac.finished) { const hasAny = state.vac.banned > 0 || state.vac.devBanned > 0; if (!hasAny) { el.innerHTML = t('vacNone'); } else { const parts = []; if (state.vac.banned > 0) parts.push(t('vacBanned').replace('{n}', state.vac.banned)); if (state.vac.devBanned > 0) parts.push(t('devBanned').replace('{n}', state.vac.devBanned)); el.innerHTML = parts.join(' · '); } } else { const cnt = state.vac.banned + state.vac.devBanned; el.innerHTML = cnt === 0 ? t('vacChecking') : t('vacChecking') + ' (' + cnt + ')'; } } function startVACCheck(forceAll) { const apiKey = storage.getApiKey(); if (!apiKey) return; const friends = document.querySelectorAll('.friend_block_v2[data-steamid]'); state.vac.banned = 0; state.vac.devBanned = 0; state.vac.finished = false; state.vac.queue = []; if (forceAll) { for (const f of friends) state.vac.queue.push(f.dataset.steamid); } else { const cache = storage.getVACCache(); for (const f of friends) { if (!cache[f.dataset.steamid]) state.vac.queue.push(f.dataset.steamid); } } document.querySelectorAll('.sfd-vac-shield-page').forEach(s => s.remove()); if (state.vac.queue.length === 0) { loadVACFromCache(); state.vac.finished = true; updateVACStatus(); if (dom.vacBtn) dom.vacBtn.disabled = false; return; } const bpBar = showProgressBar(); if (dom.vacBtn) dom.vacBtn.disabled = true; updateVACStatus(); // 批量请求,每批 VAC_BATCH_SIZE 个 const BATCH_SIZE = VAC_BATCH_SIZE; const queue = [...state.vac.queue]; state.vac.queue = []; const totalToFetch = queue.length; let processedCount = 0; async function processBatch() { for (let i = 0; i < queue.length; i += BATCH_SIZE) { const batch = queue.slice(i, i + BATCH_SIZE); try { const data = await SteamAPI.getVAC(batch); if (data.players) { for (const p of data.players) { vacSetStatus(p.SteamId, { players: [p] }); processedCount++; } } } catch (e) { batch.forEach(sid => { vacSetStatus(sid, { error: true }); processedCount++; }); } setProgressPercent(bpBar, (processedCount / totalToFetch) * 100); } // 批量完成后一次性持久化缓存(vacSetStatus 内部已更新内存缓存) storage.setVACCache(storage.getVACCache()); // 完成 state.vac.finished = true; updateVACStatus(); if (refreshBtn) refreshBtn.disabled = false; hideProgressBar(); } processBatch(); } function loadVACFromCache() { const cache = storage.getVACCache(); const friends = document.querySelectorAll('.friend_block_v2[data-steamid]'); let cached = 0; state.vac.banned = 0; state.vac.devBanned = 0; for (const f of friends) { const sid = f.dataset.steamid; if (cache[sid]) { cached++; if (cache[sid].VACBanned) state.vac.banned++; if (cache[sid].NumberOfGameBans > 0) state.vac.devBanned++; renderVACShieldOnPage(f, cache[sid].VACBanned, cache[sid].DaysSinceLastBan, cache[sid].NumberOfGameBans); // 同步到 friendsData const fd = _friendsMap.get(String(sid)); if (fd) syncVacToFriendData(fd, cache[sid]); } } state.vac.finished = (cached === friends.length && friends.length > 0); updateVACStatus(); return cached; } // ==================== 等级查询系统 ==================== function startLevelCheck(forceAll) { const apiKey = storage.getApiKey(); if (!apiKey) return; const friends = document.querySelectorAll('.friend_block_v2[data-steamid]'); const mySteamId = storage.getSteamId() || detectCurrentSteamId(); const queue = []; if (forceAll) { for (const f of friends) queue.push(f.dataset.steamid); if (mySteamId) queue.push(mySteamId); } else { const cache = storage.getLevelCache(); for (const f of friends) { if (!cache[f.dataset.steamid]) queue.push(f.dataset.steamid); } if (mySteamId && !cache[mySteamId]) queue.push(mySteamId); } if (queue.length === 0) { loadLevelFromCache(); return; } const bpBar = showProgressBar(); if (dom.levelBtn) dom.levelBtn.disabled = true; setStatusText(t('levelChecking')); const totalToFetch = queue.length; let processedCount = 0; async function processBatch() { try { const cache = storage.getLevelCache(); let lastRenderTime = 0; // 构建任务列表:每个好友一个独立请求 const tasks = queue.map(sid => () => SteamAPI.getLevel(sid).then(d => { const level = (d && d.response) ? (d.response.player_level || 0) : 0; cache[sid] = level; const fd = _friendsMap.get(String(sid)); if (fd) fd.level = level; })); // 使用智能并发池:滑动窗口模式,完成1个立刻填入下1个,保持并发恒定 await _concurrentPool(tasks, LEVEL_CONCURRENCY, (completed, total) => { processedCount = completed; setStatusText(t('levelChecking') + ' ' + completed + '/' + total + ' ...'); setProgressPercent(bpBar, (completed / total) * 100); // 降低DOM刷新频率:节流刷新 const now = Date.now(); if (now - lastRenderTime >= RENDER_THROTTLE_MS) { lastRenderTime = now; loadLevelFromCache(); applyPanelFiltersAndSort(); } }); // 批量完成后一次性写入缓存 storage.setLevelCache(cache); // 最终完整刷新 loadLevelFromCache(); applyPanelFiltersAndSort(); if (levelBtn) levelBtn.disabled = false; setStatusText(t('levelDone')); hideProgressBar(); } catch (e) { logger.error('等级查询失败', e); if (levelBtn) levelBtn.disabled = false; setStatusText(t('levelDone')); hideProgressBar(); } } processBatch(); } function loadLevelFromCache() { const cache = storage.getLevelCache(); const friendBlocks = document.querySelectorAll('.friend_block_v2[data-steamid]'); for (const block of friendBlocks) { const sid = block.dataset.steamid; const level = cache[sid] || 0; if (!level) continue; const avatarEl = block.querySelector('.player_avatar'); if (!avatarEl) continue; const oldLevel = avatarEl.querySelector('.sfd-name-tag-level'); if (oldLevel) oldLevel.remove(); avatarEl.style.position = 'relative'; avatarEl.style.overflow = 'visible'; avatarEl.appendChild(h('span', { class: 'sfd-name-tag-level', title: `Steam Lv.${level}`, text: String(level) })); } // 同步到 state.friends.data state.friends.data.forEach(fd => { fd.level = cache[fd.steamid] || fd.level || 0; }); // 同步到自己的等级 const mySteamId = storage.getSteamId() || detectCurrentSteamId(); if (mySteamId && state.friends.ownProfile) { state.friends.ownProfile.level = cache[mySteamId] || state.friends.ownProfile.level || 0; } } // ==================== 共同好友批量查询(智能并发池) ==================== const _mutualCache = {}; const _mutualFriendLists = {}; // 缓存每个好友的好友列表详情,用于浮窗展示 /** * 智能并发池 — 滑动窗口模式,完成一个立刻填入下一个,保持并发数恒定 * 遇到429或错误自动延迟重试,最多重试2次 */ async function _concurrentPool(tasks, concurrency, onProgress) { const results = new Array(tasks.length); let nextIndex = 0; let completed = 0; let activeConcurrency = concurrency; const MAX_RETRY = 2; async function runTask(index) { const task = tasks[index]; let retryCount = 0; while (retryCount <= MAX_RETRY) { try { const result = await task(); results[index] = { status: 'fulfilled', value: result }; return; } catch (e) { retryCount++; if (retryCount <= MAX_RETRY) { // 429 或网络错误:指数退避重试 const delayMs = retryCount === 1 ? 1000 : 2000; await new Promise(r => setTimeout(r, delayMs)); // 如果是429,降低并发数 if (e.message && (e.message.includes('429') || e.message.includes('rate'))) { activeConcurrency = Math.max(3, Math.floor(activeConcurrency * 0.7)); } } else { results[index] = { status: 'rejected', reason: e }; return; } } } } async function worker() { while (nextIndex < tasks.length) { const idx = nextIndex++; await runTask(idx); completed++; if (onProgress) onProgress(completed, tasks.length); } } // 动态调整 worker 数量(不超过剩余任务数) const workerCount = Math.min(activeConcurrency, tasks.length); await Promise.all(Array.from({ length: workerCount }, () => worker())); return results; } async function batchFetchMutualCounts() { const apiKey = storage.getApiKey(); if (!apiKey || !state.friends.data.length) return; const myFriendIds = new Set(state.friends.data.map(f => f.steamid)); if (dom.mutualBtn) dom.mutualBtn.disabled = true; setStatusText(t('btnRefreshMutual') + ' ...'); const bpBar = showProgressBar(); let lastRenderTime = 0; // 缓存版本检查:旧版本可能把私密好友误存为 0,需要清空重查 const MUTUAL_CACHE_VERSION = 2; const cachedVersion = _dbGet('sfd_mutual_cache_ver', 0); if (cachedVersion < MUTUAL_CACHE_VERSION) { storage.setMutualCache({}); _dbSet('sfd_mutual_cache_ver', MUTUAL_CACHE_VERSION); } // 合并缓存:先从 _mutualCache 加载,再从 storage 加载 const storedCache = storage.getMutualCache(); for (const [sid, val] of Object.entries(storedCache)) { if (_mutualCache[sid] === undefined) _mutualCache[sid] = val; } // 同步到 friendsData state.friends.data.forEach(f => { if (_mutualCache[f.steamid] !== undefined) f.mutualFriendsCount = _mutualCache[f.steamid]; }); // 需要查询的:未缓存(_mutualCache[sid] === undefined) 的好友 // 注意:值为 0 表示真正0个共同好友,值为 -1 表示私密,都已查询过不需要重查 const toFetch = state.friends.data.filter(f => _mutualCache[f.steamid] === undefined); const totalToFetch = toFetch.length; if (totalToFetch === 0) { hideProgressBar(); setStatusText(t('footerReady')); if (mutualBtn) mutualBtn.disabled = false; applyPanelFiltersAndSort(); applyModalFiltersAndSort(); return; } // ===== 两阶段查询:阶段1快速并发(不重试,类1.0.12),阶段2仅对失败项重试精查私密 ===== const CONCURRENCY = MUTUAL_CONCURRENCY; // 阶段1:快速获取,出错不重试(返回 ok:false),收集失败项交由阶段2 const phase1Tasks = toFetch.map(f => async () => { try { const resp = await SteamAPI.getFriendsLegacy(f.steamid); // 私密好友:API 返回空 friendslist 或无 friendslist 字段 if (!resp || !resp.friendslist || !resp.friendslist.friends) { f.mutualFriendsCount = -1; _mutualCache[f.steamid] = -1; return { ok: true }; } const theirFriends = resp.friendslist.friends.map(fr => fr.steamid); // 缓存好友列表,用于浮窗展示共同好友详情 _mutualFriendLists[f.steamid] = theirFriends; const count = theirFriends.filter(id => myFriendIds.has(id)).length; f.mutualFriendsCount = count; _mutualCache[f.steamid] = count; return { ok: true }; } catch (e) { // 429/网络/超时:不在此标记,交由阶段2重试精查 return { ok: false }; } }); const phase1Results = await _concurrentPool(phase1Tasks, CONCURRENCY, (completed, total) => { setProgressPercent(bpBar, (completed / total) * 50); setStatusText(t('btnRefreshMutual') + ' ' + completed + '/' + total + ' ...'); // 降低DOM刷新频率:每800ms最多刷新一次 const now = Date.now(); if (now - lastRenderTime >= RENDER_THROTTLE_MS) { lastRenderTime = now; applyPanelFiltersAndSort(); applyModalFiltersAndSort(); } }); // 阶段2:仅对阶段1失败的好友(多为429限流)用重试精查,判定私密(-1)或真实数量 const recheckList = toFetch.filter((f, i) => phase1Results[i] && phase1Results[i].value && phase1Results[i].value.ok === false); if (recheckList.length) { // 阶段2:throw 触发 _concurrentPool 重试(最多2次,指数退避;429自动降并发) const phase2Tasks = recheckList.map(f => async () => { const resp = await SteamAPI.getFriendsLegacy(f.steamid); if (!resp || !resp.friendslist || !resp.friendslist.friends) { f.mutualFriendsCount = -1; _mutualCache[f.steamid] = -1; return -1; } const theirFriends = resp.friendslist.friends.map(fr => fr.steamid); _mutualFriendLists[f.steamid] = theirFriends; const count = theirFriends.filter(id => myFriendIds.has(id)).length; f.mutualFriendsCount = count; _mutualCache[f.steamid] = count; return count; }); const phase2Results = await _concurrentPool(phase2Tasks, CONCURRENCY, (completed, total) => { setProgressPercent(bpBar, 50 + (completed / total) * 50); setStatusText(t('btnRefreshMutual') + ' ' + completed + '/' + total + ' ...'); const now = Date.now(); if (now - lastRenderTime >= RENDER_THROTTLE_MS) { lastRenderTime = now; applyPanelFiltersAndSort(); applyModalFiltersAndSort(); } }); // 阶段2仍失败的好友标记为 -1(私密或无法访问) phase2Results.forEach((r, i) => { if (r && r.status === 'rejected') { const f = recheckList[i]; f.mutualFriendsCount = -1; _mutualCache[f.steamid] = -1; } }); } else { setProgressPercent(bpBar, 100); } // 最终完整刷新 hideProgressBar(); setStatusText(t('footerReady')); if (mutualBtn) mutualBtn.disabled = false; storage.setMutualCache(_mutualCache); applyPanelFiltersAndSort(); applyModalFiltersAndSort(); } // ==================== 批量数据拉取 ==================== async function fetchAllData(steamId, progressCallback) { const friendListData = await SteamAPI.getFriends(steamId); if (!friendListData.friendslist || !friendListData.friendslist.friends) { throw new Error('GetFriendList response invalid. Is your profile set to public?'); } const rawFriends = friendListData.friendslist.friends; const idsToFetch = [steamId, ...rawFriends.map(f => f.steamid)]; const totalIds = idsToFetch.length; const summaries = []; const size = SUMMARY_BATCH_SIZE; for (let i = 0; i < totalIds; i += size) { const batch = idsToFetch.slice(i, i + size); if (progressCallback) progressCallback(i, totalIds - 1); const sumData = await SteamAPI.getSummary(batch); if (sumData.response && sumData.response.players) summaries.push(...sumData.response.players); } // 写入 summaries 缓存,供仪表盘等共用 const sumCache = { _ts: Date.now() }; summaries.forEach(p => { sumCache[p.steamid] = p; }); storage.setSummariesCache(sumCache); const summariesMap = new Map(summaries.map(p => [p.steamid, p])); const ownSummary = summariesMap.get(steamId); // 获取自家游戏数量(共用 owned games 缓存) let ownGameCount = 0; try { const cached = await fetchOwnedGamesCached(steamId); ownGameCount = cached.game_count || 0; } catch (e) { logger.warn('获取自身游戏数量失败', e); } const ownCountryInfo = ownSummary ? getCountryInfo(ownSummary.loccountrycode) : null; const ownData = ownSummary ? { steamid: steamId, personaname: ownSummary.personaname, avatar: ownSummary.avatarmedium || ownSummary.avatar, avatarfull: ownSummary.avatarfull || ownSummary.avatar || '', personastate: ownSummary.personastate !== undefined ? ownSummary.personastate : 0, gameextrainfo: ownSummary.gameextrainfo || '', loccountrycode: ownSummary.loccountrycode || '', country_name: ownCountryInfo ? ownCountryInfo.name : '', country_flag: ownCountryInfo ? ownCountryInfo.flag : '', level: 0, game_count: ownGameCount, profileurl: ownSummary.profileurl || `https://steamcommunity.com/profiles/${steamId}/`, timecreated: ownSummary.timecreated || 0 } : null; // 从缓存恢复 VAC、等级和共同好友数据 const vacCache = storage.getVACCache(); const levelCache = storage.getLevelCache(); const mutualCache = storage.getMutualCache(); // 补充自己的等级和 VAC 信息(ownSummary 不含等级,从缓存获取) if (ownData) { ownData.level = levelCache[steamId] || 0; const ownVacInfo = vacCache[steamId]; if (ownVacInfo) { ownData.vac_banned = ownVacInfo.VACBanned || false; ownData.vac_game_bans = ownVacInfo.NumberOfGameBans || 0; } } const enriched = rawFriends.map(f => { const s = summariesMap.get(f.steamid) || {}; const daysInfo = DateUtils.daysSince(f.friend_since); const countryInfo = getCountryInfo(s.loccountrycode); const vacInfo = vacCache[f.steamid]; return { steamid: f.steamid, friend_since: f.friend_since, friend_days: daysInfo.days, friend_days_text: daysInfo.text, friend_days_class: getDaysClass(daysInfo.days), personaname: s.personaname || t('anonymous'), avatar: s.avatarmedium || DEFAULT_AVATAR, loccountrycode: s.loccountrycode || '', country_name: countryInfo ? countryInfo.name : '', country_flag: countryInfo ? countryInfo.flag : '', personastate: s.personastate !== undefined ? s.personastate : 0, lastlogoff: s.lastlogoff || 0, gameextrainfo: s.gameextrainfo || '', vac_banned: vacInfo ? vacInfo.VACBanned : false, vac_days_since_last_ban: vacInfo ? (vacInfo.DaysSinceLastBan || 0) : 0, vac_game_bans: vacInfo ? (vacInfo.NumberOfGameBans || 0) : 0, vac_number_of_bans: vacInfo ? (vacInfo.NumberOfVACBans || 0) : 0, level: levelCache[f.steamid] || 0, mutualFriendsCount: mutualCache[f.steamid] !== undefined ? mutualCache[f.steamid] : undefined }; }); // 同步共同好友缓存到内存 Object.assign(_mutualCache, mutualCache); return { friends: enriched, own: ownData }; } // ==================== VAC/Level 重置函数 ==================== function resetAndCheckVAC() { storage.setVACCache({}); state.vac.banned = 0; state.vac.devBanned = 0; state.vac.finished = false; startVACCheck(true); } function resetAndCheckLevel() { storage.setLevelCache({}); startLevelCheck(true); } // ==================== 滚动覆盖辅助函数 ==================== function bindScrollOverride(el, selector) { el.addEventListener('wheel', (e) => { const scrollable = e.target.closest(selector); if (scrollable) { const { scrollTop, scrollHeight, clientHeight } = scrollable; if (!(e.deltaY < 0 && scrollTop <= 0) && !(e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight)) { e.preventDefault(); e.stopPropagation(); scrollable.scrollTop += e.deltaY; } } }, { passive: false }); } // ==================== UI 创建与渲染 ==================== let panelTrigger, familyTrigger, libraryTrigger, panelEl; function initUI() { panelTrigger = h('button', { class: 'sfd-trigger-base sfd-trigger-panel', title: t('title'), html: ICONS.users, onClick: () => { panelEl.classList.toggle('sfd-show'); if (panelEl.classList.contains('sfd-show')) { renderActiveTab(); } } }); document.body.appendChild(panelTrigger); libraryTrigger = h('button', { class: 'sfd-trigger-base sfd-trigger-library', title: t('plTitle'), html: ICONS.package, onClick: () => { if (!state.personal.popupEl) { showPersonalLibrary(); } else { state.personal.popupEl.classList.toggle('sfd-show'); } } }); document.body.appendChild(libraryTrigger); familyTrigger = h('button', { class: 'sfd-trigger-base sfd-trigger-family', title: locale === 'zh-CN' ? '家庭组' : 'Family Group', html: ICONS.family, onClick: () => { if (!familyPopupEl) { if (!state.family.popupSteamid) { const ownBlock = document.querySelector('.friend_block_v2[data-steamid]'); if (ownBlock) state.family.popupSteamid = ownBlock.dataset.steamid; if (!state.family.popupSteamid) state.family.popupSteamid = storage.getSteamId() || detectCurrentSteamId(); } if (state.family.popupSteamid) showFamilyPopup(state.family.popupSteamid); return; } familyPopupEl.classList.toggle('sfd-show'); } }); document.body.appendChild(familyTrigger); // ===== 统一好友管理窗口(Tab: 好友总览 / 深度大盘 / 设置) ===== panelEl = h('div', { class: 'sfd-panel', id: 'sfd-panel' }, [ h('div', { class: 'sfd-header' }, [ (() => { const p = state.friends.ownProfile; const hasGame = p && p.gameextrainfo; const hdrLeft = h('div', { id: 'sfd-panel-header-info', style: { display: 'flex', alignItems: 'center', gap: '10px', flex: '1', minWidth: '0' } }); // 头像 if (p && p.avatar) { hdrLeft.appendChild(h('img', { src: p.avatar, style: { width: '36px', height: '36px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(59,130,246,0.45)', flexShrink: '0', boxShadow: '0 4px 12px rgba(59,130,246,0.2)' }, loading: 'lazy', onerror: "this.onerror=null;this.src='https://avatars.steamstatic.com/fef49e7fa7e1997310dd48961da2e7d95a5c7a56_medium.jpg';" })); } else { hdrLeft.appendChild(h('div', { style: { width: '36px', height: '36px', borderRadius: '50%', background: 'rgba(59,130,246,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0' }, html: ICONS.user })); } // 昵称 + 国家 + 在线状态(column布局,参考对比窗口) const infoWrap = h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '0' } }); infoWrap.appendChild(h('h3', { style: { margin: '0', fontSize: '14px', color: '#fff', fontWeight: '700', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } }, [ h('span', { text: p ? p.personaname : (locale === 'zh-CN' ? '好友管理' : 'Friends') }), p && p.level > 0 ? h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#fbbf24', background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.level} Lv.${p.level}` }) : null ])); const metaWrap = h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' } }); if (p && p.country_name) metaWrap.appendChild(h('span', { style: { fontSize: '11px', color: '#94a3b8' }, text: `${p.country_flag || ''} ${p.country_name}` })); if (p) { metaWrap.appendChild(hasGame ? h('span', { class: 'sfd-badge sfd-badge-status in-game', text: `🎮 ${p.gameextrainfo}` }) : h('span', { class: `sfd-badge sfd-badge-status ${p.personastate > 0 ? 'online' : 'offline'}`, text: getPersonaStateText(p.personastate) }) ); } infoWrap.appendChild(metaWrap); hdrLeft.appendChild(infoWrap); return hdrLeft; })(), h('div', { class: 'sfd-tab-bar' }, [ h('button', { class: 'sfd-tab-btn sfd-active', id: 'sfd-tab-overview', text: '好友总览', onClick: () => switchTab('overview') }), h('button', { class: 'sfd-tab-btn', id: 'sfd-tab-insights', text: '深度大盘', onClick: () => switchTab('insights') }) ]), h('div', { class: 'sfd-header-actions' }, [ h('div', { class: 'sfd-header-quick-actions' }, [ h('button', { class: 'sfd-header-action-btn sfd-action-fetch', title: t('btnFetch'), html: ICONS.search, id: 'sfd-fetch-btn', disabled: state.friends.isLoading, onClick: startDataProcess }), h('button', { class: 'sfd-header-action-btn sfd-action-vac', title: t('btnRefreshVAC'), html: ICONS.shieldDots, id: 'sfd-vac-btn', onClick: resetAndCheckVAC }), h('button', { class: 'sfd-header-action-btn sfd-action-level', title: t('btnRefreshLevel'), html: ICONS.level, id: 'sfd-level-btn', onClick: resetAndCheckLevel }), h('button', { class: 'sfd-header-action-btn sfd-action-mutual', title: t('btnRefreshMutual'), html: ICONS.mutual, id: 'sfd-mutual-btn', onClick: batchFetchMutualCounts }), h('button', { class: 'sfd-header-action-btn', title: 'CSV', html: 'CSV', onClick: exportModalCSV }), h('button', { class: 'sfd-header-action-btn', title: 'JSON', html: 'JSON', onClick: exportModalJSON }), h('span', { class: 'sfd-dash-updated', id: 'sfd-dash-updated', text: '' }), h('button', { class: 'sfd-header-action-btn sfd-action-dash-refresh', title: '强制刷新深度大盘数据', html: ICONS.refresh, id: 'sfd-dash-refresh-btn', onClick: () => loadDashboardData(true) }) ]), h('button', { class: 'sfd-header-btn', html: ICONS.settings, id: 'sfd-settings-btn', title: t('tabSettings'), onClick: toggleSettings }), createCloseBtn(() => panelEl.classList.remove('sfd-show')) ]) ]), h('div', { class: 'sfd-content', id: 'sfd-content' }), h('div', { class: 'sfd-bottom-progress-wrap', id: 'sfd-panel-progress-wrap', style: { display: 'none' } }, [ h('div', { class: 'sfd-bottom-progress', id: 'sfd-panel-progress-bar' }) ]) ]); document.body.appendChild(panelEl); cacheDom(); bindScrollOverride(panelEl, '.sfd-content, .sfd-friends-scroll-container, .sfd-modal-body, .sfd-table-wrapper, .sfd-dash-content'); // 点击外部关闭(排除从面板内打开的弹窗,避免误关面板) const panelOutsideClick = (e) => { if (panelEl.classList.contains('sfd-show') && !panelEl.contains(e.target) && !e.target.closest('.sfd-trigger-panel') && !e.target.closest('#sfd-compare-popup') && !e.target.closest('#sfd-member-games-popup') && !e.target.closest('#sfd-month-games-popup') && !e.target.closest('#sfd-my90-games-popup') && !e.target.closest('#sfd-pl-popup') && !e.target.closest('#sfd-family-popup')) { panelEl.classList.remove('sfd-show'); } }; document.addEventListener('mousedown', panelOutsideClick, true); state.ui.disposers.push(() => document.removeEventListener('mousedown', panelOutsideClick, true)); state.overview.deletedFriends = Object.values(storage.getDeletedFriends() || {}); const cachedFriends = storage.getCachedData(); const cachedOwn = storage.getCachedOwn(); if (cachedFriends && cachedFriends.length) { state.friends.data = cachedFriends; syncFriendsMap(); state.friends.ownProfile = cachedOwn; updatePanelHeaderInfo(); if (cachedOwn && cachedOwn.personaname) document.title = cachedOwn.personaname + (locale === 'zh-CN' ? '的好友' : "'s Friends"); // Restore VAC / level / mutual stats from cache const vacCache = storage.getVACCache(); const levelCache = storage.getLevelCache(); const mutualCache = storage.getMutualCache(); state.friends.data.forEach(f => { if (vacCache[f.steamid] !== undefined) f.vac_banned = vacCache[f.steamid]; if (levelCache[f.steamid] !== undefined) f.level = levelCache[f.steamid]; if (mutualCache[f.steamid] !== undefined) f.mutualFriendsCount = mutualCache[f.steamid]; }); // Update VAC stats state state.vac.banned = state.friends.data.filter(f => f.vac_banned).length; state.vac.devBanned = state.friends.data.filter(f => f.vac_game_bans > 0).length; state.vac.finished = true; // Update _mutualCache Object.assign(_mutualCache, mutualCache); applyPanelFiltersAndSort(); applyModalFiltersAndSort(); } } function switchTab(tab) { state.friends.activeTab = tab; document.querySelectorAll('.sfd-tab-btn').forEach(b => b.classList.remove('sfd-active')); const tabBtn = document.getElementById('sfd-tab-' + tab); if (tabBtn) tabBtn.classList.add('sfd-active'); if (dom.settingsBtn) dom.settingsBtn.classList.remove('sfd-active'); renderActiveTab(); } function toggleSettings() { if (state.friends.activeTab === 'settings') { state.friends.activeTab = 'overview'; switchTab('overview'); } else { state.friends.activeTab = 'settings'; document.querySelectorAll('.sfd-tab-btn').forEach(b => b.classList.remove('sfd-active')); if (dom.settingsBtn) dom.settingsBtn.classList.add('sfd-active'); renderActiveTab(); } } function renderActiveTab() { const container = dom.content; if (!container) return; container.innerHTML = ''; container.style.cssText = ''; if (state.friends.activeTab === 'settings') renderSettingsTab(container); else if (state.friends.activeTab === 'insights') { renderInsightsTab(container); renderDashboard(); loadDashboardData(false); } else renderOverviewTab(container); } function renderSettingsTab(parent) { // 使用说明 const guideHtml = locale === 'zh-CN' ? `
📖 使用说明
🔘 面板4按钮:好友列表 · 数据大盘 · 家庭组 · 设置
👨‍👩‍👧 家庭组:点击家庭组按钮查看成员、游戏库与系列收藏
📊 数据大盘:好友游戏排行、时长热力图等
🔑 API Key:在此获取,填入下方即可
` : `
📖 Guide
🔘 4 Panel Buttons: Friends · Dashboard · Family · Settings
👨‍👩‍👧 Family: Click to view members, game library & series
📊 Dashboard: Friends game stats, heatmaps & more
🔑 API Key: Get it here, paste below
`; parent.appendChild(h('div', { class: 'sfd-tip', html: guideHtml })); // SteamID64(放上面) parent.appendChild(h('div', { class: 'sfd-form-group' }, [ h('label', { text: t('steamIdLabel') }), h('input', { type: 'text', class: 'sfd-input', id: 'sfd-id-input', value: storage.getSteamId() || detectCurrentSteamId(), placeholder: t('steamIdPlaceholder') }) ])); // API Key(放下面) parent.appendChild(h('div', { class: 'sfd-form-group' }, [ h('label', { text: t('apiKeyLabel') }), h('input', { type: 'password', class: 'sfd-input', id: 'sfd-key-input', value: storage.getApiKey(), placeholder: t('apiKeyPlaceholder') }) ])); const saveStatus = h('span', { style: { fontSize: '12px', color: 'var(--sfd-accent-green)' } }); const saveBtn = h('button', { class: 'sfd-btn sfd-btn-primary', text: '💾 ' + t('btnSave'), onClick: () => { const key = document.getElementById('sfd-key-input').value.trim(); const id = document.getElementById('sfd-id-input').value.trim(); const hadApiKey = storage.getApiKey(); storage.setApiKey(key); storage.setSteamId(id); saveStatus.textContent = '✅ ' + t('saveSuccess'); // 新用户首次配置 API Key 后,自动触发获取好友数据 if (!hadApiKey && key && !state.friends.data.length) { setTimeout(() => { saveStatus.textContent = ''; toggleSettings(); startDataProcess(); }, 300); } else { setTimeout(() => { saveStatus.textContent = ''; toggleSettings(); }, 800); } } }); parent.appendChild(h('div', { class: 'sfd-btn-row' }, [saveBtn, saveStatus])); } // ==================== v1.2.5 好友总览(合并好友列表+数据大盘) ==================== function renderOverviewTab(parent) { const apiKey = storage.getApiKey(); if (!apiKey) { parent.appendChild(h('div', { class: 'sfd-tip', text: '⚠️ ' + t('noApiKeyTip') })); return; } parent.style.display = 'flex'; parent.style.flexDirection = 'column'; parent.style.overflow = 'hidden'; // ① KPI + 搜索 + 排序 parent.appendChild(buildOverviewHero()); // ② 卡片列表 parent.appendChild(buildOverviewTable()); // 初始筛选并渲染 applyOverviewFilters(); renderOverviewTable(); updateOverviewKpis(); } function buildOverviewHero() { const hero = h('div', { class: 'sfd-ov-hero', id: 'sfd-ov-hero' }); // KPI 指标卡片(可点击筛选) hero.appendChild(h('div', { class: 'sfd-ov-kpis', id: 'sfd-ov-kpis' }, [ createMetricCard({ id: 'sfd-ov-kpi-total', value: '0', label: '好友总数', accent: '#3b82f6', active: state.overview.filter === 'all', dataset: { filter: 'all' }, onClick: switchOverviewFilter }), createMetricCard({ id: 'sfd-ov-kpi-ingame', value: '0', label: '游戏中', accent: '#8b5cf6', active: state.overview.filter === 'ingame', dataset: { filter: 'ingame' }, onClick: switchOverviewFilter }), createMetricCard({ id: 'sfd-ov-kpi-online', value: '0', label: '在线', accent: '#10b981', active: state.overview.filter === 'online', dataset: { filter: 'online' }, onClick: switchOverviewFilter }), createMetricCard({ id: 'sfd-ov-kpi-offline', value: '0', label: '离线', accent: '#94a3b8', active: state.overview.filter === 'offline', dataset: { filter: 'offline' }, onClick: switchOverviewFilter }), createMetricCard({ id: 'sfd-ov-kpi-new', value: '0', label: '新好友', accent: '#f43f5e', active: state.overview.filter === 'new', dataset: { filter: 'new' }, onClick: switchOverviewFilter }), createMetricCard({ id: 'sfd-ov-kpi-regions', value: '0', label: '国家/地区', accent: '#f59e0b', dataset: { filter: 'all' }, onClick: switchOverviewFilter }), createMetricCard({ id: 'sfd-ov-kpi-vac', value: '0', label: 'VAC封禁', accent: '#ef5350', active: state.overview.filter === 'vac', dataset: { filter: 'vac' }, onClick: switchOverviewFilter }), createMetricCard({ id: 'sfd-ov-kpi-devban', value: '0', label: '开发者封禁', accent: '#ff9800', active: state.overview.filter === 'devban', dataset: { filter: 'devban' }, onClick: switchOverviewFilter }), createMetricCard({ id: 'sfd-ov-kpi-deleted', value: '0', label: '已删除', accent: '#64748b', active: state.overview.filter === 'deleted', dataset: { filter: 'deleted' }, onClick: switchOverviewFilter }), ])); // 搜索框 + 排序按钮放在 KPI 右侧 const searchSortWrap = h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', flexShrink: '0' } }); // 搜索框 searchSortWrap.appendChild(h('input', { type: 'text', class: 'sfd-input', id: 'sfd-ov-search', placeholder: t('searchPlaceholder'), value: state.overview.search, style: { width: '390px', padding: '4px 10px', height: '28px', fontSize: '12px', borderRadius: '6px' }, onInput: (e) => { state.overview.search = e.target.value.trim().toLowerCase(); state.overview.page = 1; applyOverviewFilters(); renderOverviewTable(); } })); // 排序按钮 const zhs = locale === 'zh-CN'; const sortBtns = [ { key: 'days', text: zhs ? '好友天数' : 'Days' }, { key: 'status', text: zhs ? '状态' : 'Status' }, { key: 'name', text: zhs ? '名字' : 'Name' }, { key: 'level', text: zhs ? '等级' : 'Level' }, { key: 'country', text: zhs ? '国家' : 'Country' }, { key: 'mutual', text: zhs ? '共同好友' : 'Mutual' }, ]; const sortWrap = h('div', { style: { display: 'inline-flex', alignItems: 'stretch', borderRadius: '14px', overflow: 'hidden', border: '1px solid rgba(59,130,246,0.15)', flexShrink: '0' } }); sortBtns.forEach((s, si) => { const keyDesc = s.key + '-desc'; const keyAsc = s.key + '-asc'; const curSort = state.overview.sortBy; const isActive = curSort === keyDesc || curSort === keyAsc; const isDesc = curSort === keyDesc; const arrow = isActive ? (isDesc ? ' ▼' : ' ▲') : ' ▼'; const btn = h('button', { class: `sfd-ov-sort-btn${isActive ? ' active' : ''}`, text: s.text + arrow, style: { background: isActive ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.05)', color: isActive ? '#66c0f4' : '#94a3b8', border: 'none', borderRight: si < sortBtns.length - 1 ? '1px solid rgba(59,130,246,0.15)' : 'none', fontSize: '11px', padding: '4px 10px', cursor: 'pointer', whiteSpace: 'nowrap', transition: 'all 0.2s' }, onClick: () => { const cur = state.overview.sortBy; const nowActive = cur === keyDesc || cur === keyAsc; const nowDesc = cur === keyDesc; const nextVal = nowActive ? (nowDesc ? keyAsc : keyDesc) : keyDesc; state.overview.sortBy = nextVal; state.overview.page = 1; sortWrap.querySelectorAll('.sfd-ov-sort-btn').forEach(b => { b.classList.remove('active'); b.style.background = 'rgba(59,130,246,0.05)'; b.style.color = '#94a3b8'; }); btn.classList.add('active'); btn.style.background = 'rgba(59,130,246,0.15)'; btn.style.color = '#66c0f4'; btn.textContent = s.text + (nextVal === keyDesc ? ' ▼' : ' ▲'); applyOverviewFilters(); renderOverviewTable(); } }); sortWrap.appendChild(btn); }); searchSortWrap.appendChild(sortWrap); hero.appendChild(searchSortWrap); return hero; } function buildOverviewTable() { return h('div', { class: 'sfd-friends-scroll-container', id: 'sfd-ov-scroll-container', style: { display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gridAutoRows: 'min-content', gap: '8px' } }); } // ===== 筛选逻辑 ===== function switchOverviewFilter(e) { const card = e.currentTarget; const filter = card.dataset.filter; setOverviewFilter(filter); } function setOverviewFilter(filter) { // 再次点击同一个筛选则回到 ingame 默认 if (state.overview.filter === filter && filter !== 'ingame') { state.overview.filter = 'ingame'; } else { state.overview.filter = filter; } state.overview.page = 1; // 更新 KPI 激活态(排除国家/地区卡片,它不作为筛选激活) document.querySelectorAll('#sfd-ov-kpis .sfd-metric-card').forEach(el => { const id = el.querySelector('.sfd-metric-val')?.id; if (id === 'sfd-ov-kpi-regions') return; // 跳过国家/地区 el.classList.toggle('active', el.dataset.filter === state.overview.filter); }); applyOverviewFilters(); renderOverviewTable(); } function applyOverviewFilters() { const filter = state.overview.filter; // 已删除好友特殊处理:数据源不同 if (filter === 'deleted') { let list = [...(state.overview.deletedFriends || [])]; list = filterFriendsByQuery(list, state.overview.search, true); list.sort((a, b) => (b._deletedAt || 0) - (a._deletedAt || 0)); state.overview.filtered = list; updateChipCounts(); return; } let list = [...state.friends.data]; list = filterFriendsByQuery(list, state.overview.search, true); if (filter === 'online') list = list.filter(f => f.personastate > 0 && !f.gameextrainfo); else if (filter === 'ingame') list = list.filter(f => !!f.gameextrainfo); else if (filter === 'offline') list = list.filter(f => f.personastate === 0); else if (filter === 'new') list = list.filter(f => f.friend_days !== undefined && f.friend_days < 30); else if (filter === 'vac') list = list.filter(f => f.vac_banned); else if (filter === 'devban') list = list.filter(f => f.vac_game_bans > 0); sortHelper(list, state.overview.sortBy); state.overview.filtered = list; updateChipCounts(); } function updateChipCounts() { const data = state.friends.data || []; const counts = { all: data.length, ingame: data.filter(f => !!f.gameextrainfo).length, online: data.filter(f => f.personastate > 0 && !f.gameextrainfo).length, offline: data.filter(f => f.personastate === 0).length, new: data.filter(f => f.friend_days !== undefined && f.friend_days < 30).length, vac: data.filter(f => f.vac_banned).length, devban: data.filter(f => f.vac_game_bans > 0).length, deleted: (state.overview.deletedFriends || []).length }; Object.entries(counts).forEach(([key, val]) => { const el = document.getElementById(`sfd-chip-count-${key}`); if (el) el.textContent = String(val); }); } function updateOverviewKpis() { const data = state.friends.data || []; let onlineCount = 0, ingameCount = 0, offlineCount = 0, vacBannedCount = 0, devBannedCount = 0; const regionsSet = new Set(); const now = Math.floor(Date.now() / 1000); let newCount = 0, deletedCount = 0; data.forEach(f => { if (f.personastate > 0) onlineCount++; else if (f.personastate === 0) offlineCount++; if (f.gameextrainfo) ingameCount++; if (f.vac_banned) vacBannedCount++; if (f.vac_game_bans > 0) devBannedCount++; if (f.loccountrycode) regionsSet.add(f.loccountrycode.toUpperCase()); if (f.friend_since && (now - f.friend_since) < SECONDS_PER_MONTH) newCount++; if (f.personastate === -1) deletedCount++; }); const setVal = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = String(v); }; setVal('sfd-ov-kpi-total', getSteamFriendsCount()); setVal('sfd-ov-kpi-ingame', ingameCount); setVal('sfd-ov-kpi-online', onlineCount); setVal('sfd-ov-kpi-offline', offlineCount); setVal('sfd-ov-kpi-new', newCount); setVal('sfd-ov-kpi-regions', regionsSet.size); setVal('sfd-ov-kpi-vac', vacBannedCount); setVal('sfd-ov-kpi-devban', devBannedCount); setVal('sfd-ov-kpi-deleted', deletedCount); } // ===== 卡片式好友列表渲染(参考 1.2.3) ===== function renderFriendCard(f) { const hasGame = !!f.gameextrainfo; const stateCls = getPersonaStateClass(f.personastate, hasGame); // 卡片颜色优先级:游戏中(紫) > 在线(绿) > 离线(灰) let cardStatusCls = 'offline'; if (hasGame) cardStatusCls = 'ingame'; else if (f.personastate > 0) cardStatusCls = 'online'; const sinceText = t('friendSince').replace('{date}', DateUtils.format(f.friend_since)); let titleDetail = sinceText; if (hasGame) titleDetail += `\n${t('inGame').replace('{game}', f.gameextrainfo)}`; else if (f.personastate === 0 && f.lastlogoff) titleDetail += `\n${t('lastOnline').replace('{time}', DateUtils.format(f.lastlogoff) + ' ' + new Date(f.lastlogoff * 1000).toLocaleTimeString())}`; const vacShieldEl = getVACShieldEl(f); const mutualEl = f.mutualFriendsCount !== undefined ? h('span', { class: 'sfd-panel-mutual', style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: f.mutualFriendsCount >= 0 ? 'var(--sfd-accent-green)' : 'var(--sfd-text-secondary)', marginRight: '4px', whiteSpace: 'nowrap' }, title: f.mutualFriendsCount >= 0 ? `${t('mutualFriends')}: ${f.mutualFriendsCount}` : t('mutualPrivate'), html: f.mutualFriendsCount >= 0 ? `${ICONS.mutual}${f.mutualFriendsCount}` : ICONS.lock }) : null; // 不同状态的边框颜色 const borderColor = { ingame: 'rgba(139,92,246,0.4)', online: 'rgba(16,185,129,0.4)', offline: 'rgba(100,116,139,0.25)' }[cardStatusCls]; const isOffline = cardStatusCls === 'offline'; return h('a', { class: `sfd-friend-item sfd-friend-item-${cardStatusCls}`, href: `https://steamcommunity.com/profiles/${f.steamid}`, target: '_blank', title: titleDetail, style: { borderLeft: `3px solid ${borderColor}`, opacity: isOffline ? '0.5' : '1' } }, [ h('div', { class: 'sfd-avatar-wrap' }, [ h('img', { class: 'sfd-friend-avatar', src: f.avatar, loading: 'lazy', style: { filter: isOffline ? 'grayscale(1)' : 'none' } }), h('span', { class: `sfd-status-dot ${stateCls}` }) ]), h('div', { class: 'sfd-friend-info' }, [ h('div', { class: 'sfd-friend-name-row' }, [ h('span', { class: 'sfd-friend-name', text: f.personaname, style: { color: isOffline ? '#64748b' : '#c7d5e0' } }), f.country_name ? h('span', { style: { fontSize: '11px', color: '#94a3b8', flexShrink: '0' }, html: `${f.country_flag} ${f.country_name}` }) : null, f.level > 0 ? h('span', { class: 'sfd-panel-level-badge', title: `Steam Lv.${f.level}`, text: `Lv.${f.level}` }) : null, h('div', { class: 'sfd-card-actions', style: { marginLeft: 'auto' } }, [ mutualEl, vacShieldEl, h('button', { class: 'sfd-compare-btn', title: t('compareBtn'), html: ICONS.monitor, onClick: (e) => { e.preventDefault(); e.stopPropagation(); showGameCompare(f.steamid, f.personaname); } }) ]) ]), h('div', { class: 'sfd-friend-meta-row' }, [ h('span', { class: `sfd-friend-status ${stateCls}`, text: hasGame ? `🎮 ${f.gameextrainfo}` : getPersonaStateText(f.personastate) }), h('span', { class: `sfd-friend-game-time ${f.friend_days_class || ''}`, text: `· ${f.friend_days_text}` }) ]) ]) ]); } function renderOverviewTable() { const scrollContainer = document.getElementById('sfd-ov-scroll-container'); if (!scrollContainer) return; scrollContainer.innerHTML = ''; const all = state.overview.filtered; if (!all.length) { scrollContainer.appendChild(h('div', { style: { textAlign: 'center', padding: '36px', color: 'var(--sfd-text-secondary)', fontSize: '13px' }, text: t('noFriends') })); return; } // 全部好友直接渲染,无分类折叠 all.forEach(f => scrollContainer.appendChild(renderFriendCard(f))); } function renderInsightsTab(parent) { parent.style.padding = '0'; parent.style.overflow = 'hidden'; parent.style.display = 'flex'; parent.style.flexDirection = 'column'; parent.appendChild(h('div', { class: 'sfd-dash-content', id: 'sfd-dash-content' })); } function switchStateLoading(loading) { state.friends.isLoading = loading; if (dom.fetchBtn) dom.fetchBtn.disabled = loading; if (dom.vacBtn) dom.vacBtn.disabled = loading; if (dom.levelBtn) dom.levelBtn.disabled = loading; if (dom.mutualBtn) dom.mutualBtn.disabled = loading; renderActiveTab(); } // ==================== 已删除好友检测 ==================== // 与上次缓存的好友列表对比,找出"消失"的好友并累积记录;重新加回的好友会从已删除列表中移除。 function detectDeletedFriends(prevFriends, currentFriends) { const newIds = new Set(currentFriends.map(f => f.steamid)); const deletedMap = storage.getDeletedFriends(); const now = Date.now(); if (prevFriends && prevFriends.length) { prevFriends.forEach(f => { if (!newIds.has(f.steamid)) { const prevDeletedAt = deletedMap[f.steamid] && deletedMap[f.steamid]._deletedAt; deletedMap[f.steamid] = { ...f, _deletedAt: prevDeletedAt || now }; } }); } // 已重新加回的好友不再视为已删除 Object.keys(deletedMap).forEach(sid => { if (newIds.has(sid)) delete deletedMap[sid]; }); storage.setDeletedFriends(deletedMap); state.overview.deletedFriends = Object.values(deletedMap); } // ==================== 数据同步并调用主逻辑 ==================== async function startDataProcess() { const apiKey = storage.getApiKey(); const steamId = storage.getSteamId() || detectCurrentSteamId(); if (!apiKey || !steamId) { alert('API Key or SteamID invalid.'); return; } switchStateLoading(true); setStatusText(t('fetching')); const bpBar = showProgressBar(); try { const data = await fetchAllData(steamId, (curr, total) => { setProgressPercent(bpBar, (curr / total) * 100); setStatusText(t('fetchingDetail').replace('{current}', curr).replace('{total}', total)); }); setProgressPercent(bpBar, 100); // 已删除好友检测:用本次新数据与上次缓存做差集(须在 setCachedData 覆盖前读取旧缓存) detectDeletedFriends(storage.getCachedData(), data.friends); state.friends.data = data.friends; syncFriendsMap(); state.friends.ownProfile = data.own; updatePanelHeaderInfo(); storage.setCachedData(data.friends); if (data.own) storage.setCachedOwn(data.own); if (data.own && data.own.personaname) document.title = data.own.personaname + (locale === 'zh-CN' ? '的好友' : "'s Friends"); applyPanelFiltersAndSort(); applyModalFiltersAndSort(); setStatusText(t('fetchSuccess').replace('{total}', data.friends.length)); applyPageEnhancements(data.friends); loadVACFromCache(); loadLevelFromCache(); } catch (e) { logger.error('数据拉取失败', e); setStatusText(t('fetchFailed').replace('{msg}', e.message)); } finally { switchStateLoading(false); hideProgressBar(); renderActiveTab(); } } // ==================== Steam 好友卡片 DOM 注入增强 ==================== function applyPageEnhancements(friendsData) { const dataMap = new Map(friendsData.map(d => [d.steamid, d])); const friendBlocks = document.querySelectorAll('.friend_block_v2[data-steamid]'); let count = 0; friendBlocks.forEach(block => { const old = block.querySelector('.sfd-enhanced-block'); if (old) old.remove(); const sid = block.getAttribute('data-steamid'); const f = dataMap.get(sid); if (!f) return; const hasGame = !!f.gameextrainfo; const stateCls = getPersonaStateClass(f.personastate, hasGame); // 等级 → 头像左下角 const levelBadge = f.level ? h('span', { class: 'sfd-name-tag-level', title: `Steam Lv.${f.level}`, text: String(f.level) }) : null; // 国家 → 昵称右侧 const countryTag = f.country_name ? h('span', { class: 'sfd-name-tag-country', title: f.country_name, html: f.country_flag }) : null; // 好友天数 → 昵称下方 const daysTag = f.friend_days_text ? h('span', { class: `sfd-name-tag-days ${f.friend_days_class || ''}`, title: t('friendSince').replace('{date}', DateUtils.format(f.friend_since)), text: `🤝${f.friend_days_text}` }) : null; // 在线状态 → 下方独立块 const statusBlock = h('div', { class: 'sfd-enhanced-block' }, [ ...(hasGame || f.personastate !== 0 ? [h('span', { class: `sfd-etag sfd-etag-status ${stateCls}`, text: hasGame ? `🎮 ${f.gameextrainfo}` : getPersonaStateText(f.personastate) })] : []) ]); const contentBlock = block.querySelector('.friend_block_content'); if (contentBlock) { // 清理旧元素 contentBlock.querySelectorAll('.sfd-name-tag-country, .sfd-name-tag-days').forEach(el => el.remove()); const oldWrap = contentBlock.querySelector('.sfd-name-tags'); if (oldWrap) oldWrap.remove(); const oldBlock = contentBlock.querySelector('.sfd-enhanced-block'); if (oldBlock) oldBlock.remove(); // 清理盾牌(可能在 contentBlock 或父级 friend_block_v2 上) const oldShield1 = contentBlock.querySelector('.sfd-vac-shield-page'); if (oldShield1) oldShield1.remove(); const oldShield2 = block.querySelector(':scope > .sfd-vac-shield-page'); if (oldShield2) oldShield2.remove(); const avatarEl = block.querySelector('.player_avatar'); if (avatarEl) { const oldLevel = avatarEl.querySelector('.sfd-name-tag-level'); if (oldLevel) oldLevel.remove(); } // VAC 盾牌 → friend_block_v2 上(与 renderVACShieldOnPage 一致) block.style.position = 'relative'; block.classList.remove('sfd-card-clean', 'sfd-card-banned', 'sfd-card-devban'); block.classList.add(f.vac_banned ? 'sfd-card-banned' : f.vac_game_bans > 0 ? 'sfd-card-devban' : 'sfd-card-clean'); block.appendChild(getVACShieldPageEl(f.vac_banned, f.vac_days_since_last_ban, f.vac_game_bans)); // 等级 → 头像左下角 if (avatarEl && levelBadge) { avatarEl.style.position = 'relative'; avatarEl.style.overflow = 'visible'; avatarEl.appendChild(levelBadge); } const nameEl = contentBlock.querySelector('.friend_block_name'); if (nameEl && countryTag) { nameEl.style.display = 'inline-flex'; nameEl.style.alignItems = 'center'; nameEl.style.gap = '4px'; nameEl.appendChild(countryTag); } const brEl = contentBlock.querySelector('br'); if (brEl && daysTag) brEl.parentNode.insertBefore(daysTag, brEl); else if (daysTag) contentBlock.appendChild(daysTag); // 在线状态 → 下方 const smallText = contentBlock.querySelector('.friend_small_text'); if (smallText) smallText.after(statusBlock); else contentBlock.appendChild(statusBlock); count++; } // 好友卡片点击 → 新标签打开 const overlay = block.querySelector('a.selectable_overlay[href]'); if (overlay) { overlay.removeAttribute('onclick'); overlay.setAttribute('target', '_blank'); overlay.setAttribute('rel', 'noopener'); } }); console.log(`[SFD] 已增强 ${count} 个好友块`); } // ==================== 家庭组数据获取与缓存 ==================== let _cachedWebApiToken = null; function getAccessTokenSync() { // 同步方式: 尝试从当前页面直接获取 (store.steampowered.com 有效) try { const appConfig = document.getElementById('application_config'); if (appConfig) { const storeConfig = JSON.parse(appConfig.getAttribute('data-store_user_config') || '{}'); if (storeConfig.webapi_token) return storeConfig.webapi_token; } } catch (e) { logger.silent('webapi_token JSON 解析失败', e); } try { const m = document.documentElement.innerHTML.match(/"webapi_token"\s*:\s*"([^"]+)"/); if (m && m[1]) return m[1]; } catch (e) { logger.silent('webapi_token 正则匹配失败', e); } return null; } function fetchWebApiTokenFromStore() { // 跨域方式: 通过 GM_xmlhttpRequest 请求 store.steampowered.com 提取 webapi_token return new Promise((resolve) => { if (_cachedWebApiToken) { resolve(_cachedWebApiToken); return; } GM_xmlhttpRequest({ method: 'GET', url: 'https://store.steampowered.com/', timeout: 5000, onload(resp) { try { const html = resp.responseText; // 从 application_config 提取 const m1 = html.match(/id="application_config"[^>]*data-store_user_config="([^"]*)"/); if (m1 && m1[1]) { const decoded = m1[1].replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); const config = JSON.parse(decoded); if (config.webapi_token) { _cachedWebApiToken = config.webapi_token; resolve(config.webapi_token); return; } } // 备选: 直接正则匹配 const m2 = html.match(/"webapi_token"\s*:\s*"([^"]+)"/); if (m2 && m2[1]) { _cachedWebApiToken = m2[1]; resolve(m2[1]); return; } } catch (e) { logger.warn('解析商店页面失败', e); } resolve(null); }, onerror() { resolve(null); }, ontimeout() { resolve(null); } }); }); } async function getAccessToken() { // 优先: 当前页面直接获取 (store.steampowered.com) const syncToken = getAccessTokenSync(); if (syncToken) return syncToken; // 其次: 跨域请求 store.steampowered.com 提取 token const storeToken = await fetchWebApiTokenFromStore(); if (storeToken) return storeToken; return null; } // ==================== 统一 Steam API 层 ==================== const SteamAPI = { // 基础请求:使用 API Key async _req(endpoint, query = '', timeout = 15000) { const apiKey = storage.getApiKey(); if (!apiKey) throw new Error('No API key'); const url = `https://api.steampowered.com${endpoint}?key=${apiKey}${query ? '&' + query : ''}`; return requestSteamAPI(url, timeout); }, // 需要 access_token 的请求(家庭组相关) async _reqToken(endpoint, query = '', timeout = 15000) { const token = await getAccessToken(); if (!token) throw new Error('No access token'); const url = `https://api.steampowered.com${endpoint}?access_token=${token}${query ? '&' + query : ''}`; return requestSteamAPI(url, timeout); }, // 优先 token,否则 fallback 到 key async _reqAuto(endpoint, query = '', timeout = 15000) { const token = await getAccessToken(); if (token) { const url = `https://api.steampowered.com${endpoint}?access_token=${token}${query ? '&' + query : ''}`; return requestSteamAPI(url, timeout); } return this._req(endpoint, query, timeout); }, // --- ISteamUser --- getSummary: (steamids) => SteamAPI._req('/ISteamUser/GetPlayerSummaries/v2/', `steamids=${Array.isArray(steamids) ? steamids.join(',') : steamids}`), getVAC: (steamids) => SteamAPI._req('/ISteamUser/GetPlayerBans/v1/', `steamids=${Array.isArray(steamids) ? steamids.join(',') : steamids}`), getFriends: (steamid) => SteamAPI._req('/ISteamUser/GetFriendList/v1/', `steamid=${steamid}&relationship=friend`), getFriendsLegacy: (steamid, timeout = 12000) => SteamAPI._req('/ISteamUser/GetFriendList/v0001/', `steamid=${steamid}&relationship=friend&format=json`, timeout), // --- IPlayerService --- getLevel: (steamid) => SteamAPI._req('/IPlayerService/GetSteamLevel/v1/', `steamid=${steamid}`), getOwnedGamesLegacy: (steamid, timeout = 25000) => SteamAPI._req('/IPlayerService/GetOwnedGames/v0001/', `steamid=${steamid}&include_appinfo=1&include_played_free_games=1&format=json`, timeout), getRecentGames: (steamid, timeout = 12000) => SteamAPI._reqAuto('/IPlayerService/GetRecentlyPlayedGames/v1/', `steamid=${steamid}&count=0`, timeout), getRecentGamesLegacy: (steamid, timeout = 12000) => SteamAPI._req('/IPlayerService/GetRecentlyPlayedGames/v0001/', `steamid=${steamid}&format=json`, timeout), getPlayerLinkDetails: (steamids) => { const arr = Array.isArray(steamids) ? steamids : [steamids]; const params = arr.map((id, i) => `steamids[${i}]=${id}`).join('&'); return SteamAPI._reqToken('/IPlayerService/GetPlayerLinkDetails/v1/', params); }, // --- IFamilyGroupsService --- getFamily: () => SteamAPI._reqToken('/IFamilyGroupsService/GetFamilyGroupForUser/v1/', 'include_family_group_response=true'), getFamilyGroup: (familyGroupId) => SteamAPI._reqAuto('/IFamilyGroupsService/GetFamilyGroup/v1/', `family_groupid=${familyGroupId}`), getSharedLibrary: (familyGroupId) => SteamAPI._reqToken('/IFamilyGroupsService/GetSharedLibraryApps/v1/', `family_groupid=${familyGroupId}&include_own=true&include_excluded=false&include_non_games=false`), // --- Store API --- getAppDetails: (appid) => requestSteamAPI(`https://store.steampowered.com/api/appdetails?appids=${appid}&l=schinese`, 10000), }; async function fetchFamilyInfo() { let data; try { data = await SteamAPI.getFamily(); if (data && data.response && data.response.family_group) { const resp = data.response; const fg = resp.family_group; const members = fg.members || []; // 获取成员用户名和头像(先用公开头像URL兜底,API成功则覆盖) const nameMap = {}; const famAvatarMap = {}; members.forEach(m => { // 默认头像 SVG:蓝色圆形背景 + 白色人物剪影 famAvatarMap[m.steamid] = `data:image/svg+xml,${encodeURIComponent('')}`; }); if (members.length > 0) { const steamids = members.map(m => m.steamid); const batchSize = 100; for (let i = 0; i < steamids.length; i += batchSize) { const batch = steamids.slice(i, i + batchSize); try { const pData = await SteamAPI.getPlayerLinkDetails(batch); if (pData && pData.response && pData.response.accounts) { pData.response.accounts.forEach(acc => { const pd = acc.public_data; if (pd) { const sid = pd.steamid || acc.steamid; if (sid && pd.persona_name) { nameMap[sid] = pd.persona_name; } if (sid && pd.avatar) { famAvatarMap[sid] = pd.avatar; } } }); } } catch (e) { logger.silent('家庭成员 PlayerLinkDetails 获取失败', e); } } } const familyInfo = { family_groupid: resp.family_groupid, family_name: fg.name || 'Steam Family', family_member: members.map(m => ({ steamid: m.steamid, userName: nameMap[m.steamid] || 'ID:' + m.steamid.slice(-4), avatar: famAvatarMap[m.steamid] || '', role: m.role, time_joined: m.time_joined || 0, cooldown_remaining: 0 })), steamIdtoName: nameMap, steamIdtoAvatar: famAvatarMap }; // 额外调用 GetFamilyGroup 获取更详细数据(cooldown 在 family_group 级别) try { const detailData = await SteamAPI.getFamilyGroup(resp.family_groupid); if (detailData && detailData.response) { // 响应结构:response 直接包含 members(非 family_group.members) const detailResp = detailData.response; const detailMembers = detailResp.members || (detailResp.family_group && detailResp.family_group.members) || []; const slotCooldown = detailResp.slot_cooldown_remaining_seconds || 0; const detailMap = {}; detailMembers.forEach(dm => { detailMap[dm.steamid] = dm; }); familyInfo.family_member.forEach(m => { const dm = detailMap[m.steamid]; if (dm) { // GetFamilyGroup 可能返回更准确的 time_joined if (dm.time_joined) m.time_joined = dm.time_joined; } }); // 冷却时间在 family_group 级别,对所有空槽位共享 if (slotCooldown > 0) { familyInfo.family_member.forEach(m => { m.cooldown_remaining = slotCooldown; }); } } } catch (e) { logger.warn('获取家庭组详情失败', e); } return familyInfo; } } catch (e) { logger.error('获取家庭组信息失败', e); } // 区分"API调用成功但无家庭组"和"配置错误" if (data && data.response && !data.response.family_group) { return 'NO_FAMILY'; } return null; } async function fetchFamilyGameList(familyGroupId) { try { const data = await SteamAPI.getSharedLibrary(familyGroupId); if (data && data.response && data.response.apps) { const gameList = []; const gameInfo = {}; data.response.apps.forEach(app => { if (app.exclude_reason === 0) { gameList.push(app.appid); gameInfo[app.appid] = { name: app.name, owners: app.owner_steamids || [], time: app.rt_time_acquired || 0, icon_hash: app.img_icon_hash || '' }; } }); gameList.sort((a, b) => (gameInfo[b].time || 0) - (gameInfo[a].time || 0)); return { GameList: gameList, GameInfo: gameInfo }; } } catch (e) { logger.error('获取家庭组游戏列表失败', e); } return null; } // 带顶部进度条的家庭组刷新 let _familyLastRefresh = 0; async function refreshFamilyWithProgress() { const progEl = document.getElementById('sfd-family-progress'); const barEl = document.getElementById('sfd-family-progress-bar'); if (!progEl || !barEl) { // 降级:无进度条直接刷新 state.family.playActivity = null; state.family.playActivityLoading = false; state.family.dynamicPage = 1; loadFamilyData(state.family.popupSteamid, true).then(ok => { if (ok) renderFamilyPopup(); }); return; } const setBar = (pct, cls) => { barEl.className = 'sfd-family-progress-bar' + (cls ? ' ' + cls : ''); barEl.style.width = pct + '%'; }; // 重置 + 激活 progEl.classList.add('active'); setBar(8, ''); // 重置状态 state.family.playActivity = null; _stressGamesCache = null; _stressLoadFailed = false; _stressStoreOwnedCache.clear(); _stressForceNetwork = true; state.family.playActivityLoading = false; state.family.dynamicPage = 1; // 阶段1:获取鉴权 token setBar(20, ''); const authToken = await getAccessToken(); if (!authToken) { setBar(100, 'err'); setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 1500); throw new Error('No access token'); } // 阶段2:获取家庭信息 setBar(45, ''); let familyInfo; try { familyInfo = await fetchFamilyInfo(); } catch (e) { familyInfo = null; } if (!familyInfo || familyInfo === 'NO_FAMILY') { setBar(100, 'err'); setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 1500); throw new Error('No family info'); } // 阶段3:获取共享游戏库 setBar(75, ''); let gameList; try { gameList = await fetchFamilyGameList(familyInfo.family_groupid); } catch (e) { gameList = null; } if (gameList) { state.family.info = familyInfo; state.family.gameList = gameList; storage.setFamilyCache({ steamid: state.family.popupSteamid, familyInfo, familyGameList: gameList, timestamp: Date.now() }); setBar(100, 'done'); setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 800); _familyLastRefresh = Date.now(); renderFamilyPopup(); } else { setBar(100, 'err'); setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 1500); _familyLastRefresh = Date.now(); throw new Error('No game list'); } } async function loadFamilyData(steamid, forceRefresh = false) { // 检查缓存(有数据就用,不设过期时间,因为每次打开都会后台刷新) if (!forceRefresh) { const cached = storage.getFamilyCache(); if (cached && cached.steamid === steamid && cached.familyInfo && cached.familyGameList) { state.family.info = cached.familyInfo; state.family.gameList = cached.familyGameList; return true; } } // 获取鉴权 token (异步: 优先跨域获取 webapi_token,其次使用 API Key) const authToken = await getAccessToken(); if (!authToken) { logger.warn('无可用鉴权 token,无法获取家庭组数据'); return false; } try { const familyInfo = await fetchFamilyInfo(); if (familyInfo === 'NO_FAMILY') return 'NO_FAMILY'; if (!familyInfo) return false; state.family.info = familyInfo; const gameList = await fetchFamilyGameList(familyInfo.family_groupid); if (gameList) { state.family.gameList = gameList; // 缓存 storage.setFamilyCache({ steamid, familyInfo, familyGameList: gameList, timestamp: Date.now() }); return true; } } catch (e) { logger.error('加载家庭组数据失败', e); } return false; } // FIX-2026-07-07: 1.0.14 新窗口打开 Steam 商店页面 function openStorePage(appid) { if (!appid) return; const url = `https://store.steampowered.com/app/${appid}`; window.open(url, '_blank', 'noopener,noreferrer'); } // ==================== 家庭组游玩动态 ==================== async function fetchRecentlyPlayedGames(steamid) { try { const data = await SteamAPI.getRecentGames(steamid); if (data && data.response) { return { total_count: data.response.total_count || 0, games: (data.response.games || []).map(g => ({ 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 || '' })) }; } } catch (e) { logger.warn('获取最近游玩失败', e); } return null; } async function loadFamilyPlayActivity(steamid, forceRefresh = false) { // 检查缓存 if (!forceRefresh) { const cached = storage.getFamilyPlayActivityCache(); if (cached && cached.steamid === steamid && Date.now() - cached.timestamp < FAMILY_PLAY_TTL) { state.family.playActivity = cached.data; return true; } } state.family.playActivityLoading = true; const fi = state.family.info; if (!fi || !fi.family_member || fi.family_member.length === 0) { state.family.playActivityLoading = false; return false; } const authToken = await getAccessToken(); if (!authToken) { state.family.playActivityLoading = false; return false; } // 确保有 ownProfile:优先内存,其次缓存,最后实时获取(和好友列表面板一样的数据来源) if (!state.friends.ownProfile) { const cachedOwn = storage.getCachedOwn(); if (cachedOwn && cachedOwn.steamid === String(steamid)) { state.friends.ownProfile = cachedOwn; } else { try { const apiKey = storage.getApiKey(); if (apiKey) { const sumData = await SteamAPI.getSummary(steamid); if (sumData.response && sumData.response.players && sumData.response.players.length > 0) { const s = sumData.response.players[0]; const countryInfo = getCountryInfo(s.loccountrycode); const levelCache = storage.getLevelCache(); state.friends.ownProfile = { steamid: steamid, personaname: s.personaname, avatar: s.avatarmedium || s.avatar, avatarfull: s.avatarfull || s.avatar || '', personastate: s.personastate !== undefined ? s.personastate : 0, gameextrainfo: s.gameextrainfo || '', loccountrycode: s.loccountrycode || '', country_name: countryInfo ? countryInfo.name : '', country_flag: countryInfo ? countryInfo.flag : '', level: levelCache[steamid] || 0, profileurl: s.profileurl || `https://steamcommunity.com/profiles/${steamid}/`, timecreated: s.timecreated || 0 }; storage.setCachedOwn(state.friends.ownProfile); } } } catch (e) { logger.warn('获取游玩动态自身资料失败', e); } } } try { const result = {}; const members = fi.family_member; // 批量获取每个成员最近游玩的游戏 const promises = members.map(async (member) => { try { const data = await fetchRecentlyPlayedGames(member.steamid); if (data) { result[member.steamid] = { games: data.games, memberName: member.userName, total_count: data.total_count }; } } catch (e) { logger.warn('获取游玩动态失败: ' + member.steamid, e); } }); await Promise.all(promises); state.family.playActivity = result; state.family.playActivityLoading = false; // 缓存结果 storage.setFamilyPlayActivityCache({ steamid, data: result, timestamp: Date.now() }); return true; } catch (e) { logger.error('加载家庭组游玩动态失败', e); state.family.playActivityLoading = false; return false; } } // ==================== 家庭组价值估算 ==================== // 从 Steam 页面 DOM 读取真实好友总数(.friends_count 的 value 属性) function getSteamFriendsCount() { const el = document.querySelector('.profile_friends .friends_count'); if (el) { const val = parseInt(el.getAttribute('value') || el.textContent || '0', 10); if (val > 0) return val; } // 回退:使用 g_rgCounts try { if (typeof g_rgCounts !== 'undefined' && g_rgCounts.cFriends) return g_rgCounts.cFriends; } catch (e) { logger.silent('g_rgCounts 读取失败', e); } return state.friends.data ? state.friends.data.length : 0; } // 从 Steam 页面 DOM 读取好友上限 function getSteamFriendsLimit() { const el = document.querySelector('.profile_friends .friends_limit'); if (el) { const val = parseInt(el.textContent || '0', 10); if (val > 0) return val; } // 回退:按等级估算 const lvl = (state.friends.ownProfile && state.friends.ownProfile.level) || 0; return 300 + lvl * 5; } // ==================== 家庭组浮窗 ==================== let familyPopupEl = null; function showFamilyPopup(steamid, event) { state.family.popupSteamid = steamid; state.family.activeTab = 'chart'; state.family.dynamicPage = 1; state.family.playActivity = null; state.family.playActivityLoading = false; if (!familyPopupEl) { familyPopupEl = h('div', { class: 'sfd-family-popup', id: 'sfd-family-popup' }, [ h('div', { class: 'sfd-family-header' }, [ h('h3', { html: `${ICONS.family} ${locale === 'zh-CN' ? '家庭组' : 'Family Group'}` }), h('div', { class: 'sfd-family-header-actions' }, [ h('span', { class: 'sfd-dash-updated', id: 'sfd-family-updated', text: '' }), createRefreshBtn(() => { refreshFamilyWithProgress().catch(() => {}); }), h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', text: 'CSV', onClick: exportFamilyCSV }), h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', text: 'JSON', onClick: exportFamilyJSON }), createCloseBtn(() => familyPopupEl.classList.remove('sfd-show')) ]) ]), h('div', { class: 'sfd-family-progress', id: 'sfd-family-progress', html: '
' }), h('div', { class: 'sfd-family-content', id: 'sfd-family-content' }) ]); document.body.appendChild(familyPopupEl); const familyOutsideClick = (e) => { if (familyPopupEl.classList.contains('sfd-show') && !familyPopupEl.contains(e.target) && !e.target.closest('.sfd-trigger-family') && !e.target.closest('.sfd-panel') && !e.target.closest('.sfd-modal') && !e.target.closest('#sfd-member-games-popup') && !e.target.closest('#sfd-month-games-popup') && !e.target.closest('#sfd-my90-games-popup') && !e.target.closest('#sfd-compare-popup') && !e.target.closest('#sfd-pl-popup') && !e.target.closest('#sfd-contrib-overlay')) { familyPopupEl.classList.remove('sfd-show'); } }; document.addEventListener('mousedown', familyOutsideClick, true); state.ui.disposers.push(() => document.removeEventListener('mousedown', familyOutsideClick, true)); bindScrollOverride(familyPopupEl, '.sfd-family-content, .sfd-family-game-list'); } familyPopupEl.classList.add('sfd-show'); const content = familyPopupEl.querySelector('#sfd-family-content'); // 有缓存直接渲染(不显示 loading),冷却时间外后台静默刷新 const cached = storage.getFamilyCache(); if (cached && cached.steamid === steamid && cached.familyInfo && cached.familyGameList) { state.family.info = cached.familyInfo; state.family.gameList = cached.familyGameList; renderFamilyPopup(); // 冷却时间内不重复刷新 if (Date.now() - _familyLastRefresh > FAMILY_REFRESH_COOLDOWN) { refreshFamilyWithProgress().catch(() => {}); } } else { // 无缓存:显示 loading 并走进度条 content.replaceChildren(h('div', { class: 'sfd-family-loading', html: `${ICONS.spinner}${locale === 'zh-CN' ? '正在加载家庭组数据…' : 'Loading family data…'}` })); refreshFamilyWithProgress().catch(() => { content.innerHTML = `
⚠️
${locale === 'zh-CN' ? '无法获取家庭组数据' : 'Failed to load family data'}
${locale === 'zh-CN' ? '请访问 Steam 商店页面并登录' : 'Please visit Steam Store and log in'}
${locale === 'zh-CN' ? '前往 Steam 商店' : 'Go to Steam Store'}
`; }); } } function renderFamilyPopup() { if (!familyPopupEl || !state.family.info || !state.family.gameList) return; // 更新数据时间显示 const updatedEl = document.getElementById('sfd-family-updated'); if (updatedEl) { const cached = storage.getFamilyCache(); if (cached && cached.timestamp) { updatedEl.textContent = (locale === 'zh-CN' ? '更新于 ' : 'Updated ') + new Date(cached.timestamp).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } else { updatedEl.textContent = ''; } } // 保存焦点元素信息(防止 re-render 后搜索框失焦) const activeEl = document.activeElement; const focusInfo = activeEl && activeEl.tagName === 'INPUT' && activeEl.classList.contains('sfd-input') ? { sel: '.sfd-input', value: activeEl.value, start: activeEl.selectionStart, end: activeEl.selectionEnd } : null; const fi = state.family.info; const gl = state.family.gameList; const totalGames = gl.GameList.length; const memberCount = fi.family_member.length; const avgGames = totalGames > 0 && memberCount > 0 ? Math.round(totalGames / memberCount) : 0; let singleOwnerCount = 0; let recent30Count = 0; const nowSec = Date.now() / 1000; const cutoff30 = nowSec - 90 * SECONDS_PER_DAY; for (let key in gl.GameInfo) { const info = gl.GameInfo[key]; if (info.owners.length === 1) singleOwnerCount++; if (info.time && info.time >= cutoff30) recent30Count++; } const content = familyPopupEl.querySelector('#sfd-family-content'); const headerH3 = familyPopupEl.querySelector('.sfd-family-header h3'); if (headerH3) { headerH3.innerHTML = `${ICONS.family} ${fi.family_name} ${memberCount} ${locale === 'zh-CN' ? '名成员' : 'members'} · ${totalGames} ${locale === 'zh-CN' ? '个共享游戏' : 'shared games'}`; } const isZh = locale === 'zh-CN'; // 加入时间卡片(原版样式:横向布局,心形图标 + 日期 + 天数) let joinCardHtml = ''; const mySteamId = (state.friends.ownProfile && state.friends.ownProfile.steamid) || state.family.popupSteamid; const selfMember = fi.family_member.find(m => String(m.steamid) === String(mySteamId)); if (selfMember && selfMember.time_joined > 0) { const jd = new Date(selfMember.time_joined * 1000); const joinDateStr = `${jd.getFullYear()}/${String(jd.getMonth() + 1).padStart(2, '0')}/${String(jd.getDate()).padStart(2, '0')}`; const joinDays = Math.floor((nowSec - selfMember.time_joined) / SECONDS_PER_DAY); joinCardHtml = `
${ICONS.heart}
${isZh ? '我已加入家庭 ' : 'Joined family '}${joinDays}${isZh ? ' 天 ' : ' days '}(${joinDateStr}) ${isZh ? '点击查看贡献 →' : 'Contributions →'}
`; } // joinCard 移到 statsGrid 后面 const statsGrid = h('div', { class: 'sfd-family-stats-grid' }, [ createMetricCard({ value: String(totalGames), label: isZh ? '共享游戏总数' : 'Total Shared', accent: '#06cfbe', layout: 'label' }), createMetricCard({ value: String(singleOwnerCount), label: isZh ? '独占贡献游戏' : 'Exclusive', accent: '#ff9f43', layout: 'label' }), createMetricCard({ value: String(avgGames), label: isZh ? '人均贡献数' : 'Avg/Member', accent: '#54a0ff', layout: 'label' }), createMetricCard({ value: String(recent30Count), label: isZh ? '近90日新增' : 'New (90d)', accent: '#34d399', layout: 'label' }) ]); const tabs = h('div', { class: 'sfd-family-tabs' }, [ h('button', { class: `sfd-family-tab ${state.family.activeTab === 'chart' ? 'active' : ''}`, html: ICONS.barChart, onClick: () => { state.family.activeTab = 'chart'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '贡献分布' : 'Distribution' })]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'growth' ? 'active' : ''}`, html: ICONS.trending, onClick: () => { state.family.activeTab = 'growth'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '增长曲线' : 'Growth' })]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'dynamic' ? 'active' : ''}`, html: ICONS.activity, onClick: () => { state.family.activeTab = 'dynamic'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '入库动态' : 'Activity' })]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'play' ? 'active' : ''}`, html: ICONS.game, onClick: () => { state.family.activeTab = 'play'; renderFamilyPopup(); } }, [h('span', { text: t('familyPlayTitle') })]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'heatmap' ? 'active' : ''}`, html: ICONS.pie, onClick: () => { state.family.activeTab = 'heatmap'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '月度占比' : 'Monthly' })]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'insights' ? 'active' : ''}`, html: ICONS.insights, onClick: () => { state.family.activeTab = 'insights'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '成员洞察' : 'Insights' })]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'wishlist' ? 'active' : ''}`, html: ICONS.heart, onClick: () => { state.family.activeTab = 'wishlist'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '家庭愿望单' : 'Wishlist' })]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'stress' ? 'active' : ''}`, html: ICONS.flame, onClick: () => { state.family.activeTab = 'stress'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '精品游戏' : 'Top Games' })]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'series' ? 'active' : ''}`, html: ICONS.grid, onClick: () => { state.family.activeTab = 'series'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '系列游戏' : 'Series' })]) ]); let tabContent; if (state.family.activeTab === 'chart') { tabContent = renderFamilyChartTab(); } else if (state.family.activeTab === 'dynamic') { tabContent = renderFamilyDynamicTab(); } else if (state.family.activeTab === 'play') { tabContent = renderFamilyPlayActivityTab(); } else if (state.family.activeTab === 'heatmap') { tabContent = renderFamilyHeatmapTab(); } else if (state.family.activeTab === 'insights') { tabContent = renderMemberInsightsTab(); } else if (state.family.activeTab === 'wishlist') { tabContent = renderFamilyWishlistTab(); } else if (state.family.activeTab === 'stress') { tabContent = renderStressFamilyTab(); } else if (state.family.activeTab === 'series') { tabContent = renderFamilySeriesTab(); } else { tabContent = renderFamilyGrowthTab(); } content.innerHTML = ''; content.appendChild(tabs); // 贡献分布标签页的统计卡片(和下方图表等宽:4个卡片 flex:1 + joinCard 310px) const statsWrap = h('div', { class: 'sfd-family-stats-wrap', style: { display: state.family.activeTab === 'chart' ? 'flex' : 'none', gap: '14px', alignItems: 'stretch', marginBottom: '10px' } }); statsGrid.style.flex = '1'; statsGrid.style.minWidth = '0'; statsWrap.appendChild(statsGrid); if (joinCardHtml) { const jc = h('div', { style: { width: '310px', flexShrink: '0' } }); jc.innerHTML = joinCardHtml; statsWrap.appendChild(jc.firstChild || jc); } content.appendChild(statsWrap); // 增长曲线标签页的统计卡片(和贡献分布完全一样的5卡片布局) if (state.family.activeTab === 'growth' && gl && gl.GameInfo) { const growthGames = Object.entries(gl.GameInfo) .map(([appid, info]) => ({ appid: Number(appid), time: info.time })) .filter(g => g.time > 0) .sort((a, b) => a.time - b.time); if (growthGames.length > 0) { const firstDate = new Date(growthGames[0].time * 1000); const lastDate = new Date(growthGames[growthGames.length - 1].time * 1000); const daysSpan = Math.max(1, Math.ceil((lastDate - firstDate) / 86400000)); const monthsSpan = daysSpan / 30; const avgPerDay = (growthGames.length / daysSpan).toFixed(1); const avgPerMonth = (growthGames.length / monthsSpan).toFixed(1); const cutoff30 = Date.now() / 1000 - 30 * SECONDS_PER_DAY; const cutoff90 = Date.now() / 1000 - 90 * SECONDS_PER_DAY; let recent30 = 0, recent90 = 0; for (let key in gl.GameInfo) { const t = gl.GameInfo[key].time; if (t && t >= cutoff30) recent30++; if (t && t >= cutoff90) recent90++; } const growthGrid = h('div', { class: 'sfd-family-stats-grid', style: { flex: '1', minWidth: '0' } }, [ createMetricCard({ value: String(avgPerDay), label: isZh ? '日均增长' : 'Avg/Day', accent: '#f59e0b', layout: 'label' }), createMetricCard({ value: String(avgPerMonth), label: isZh ? '月均增长' : 'Avg/Month', accent: '#8b5cf6', layout: 'label' }), createMetricCard({ value: String(recent30), label: isZh ? '30日新增' : 'New (30d)', accent: '#10b981', layout: 'label' }), createMetricCard({ value: String(recent90), label: isZh ? '90日新增' : 'New (90d)', accent: '#3b82f6', layout: 'label' }), ]); const growthWrap = h('div', { class: 'sfd-family-stats-wrap', style: { display: 'flex', gap: '14px', alignItems: 'stretch', marginBottom: '10px' } }); growthWrap.appendChild(growthGrid); // 第5个卡片:90天我贡献了多少游戏 let my90Contrib = 0; for (let key in gl.GameInfo) { const info = gl.GameInfo[key]; if (info.time && info.time >= cutoff90 && info.owners && info.owners.includes(String(mySteamId))) { my90Contrib++; } } const my90Card = h('div', { style: 'flex-shrink:0;width:285px;background:linear-gradient(135deg,rgba(84,160,255,0.08),rgba(139,92,246,0.06));border:1px solid rgba(84,160,255,0.2);border-radius:8px;padding:6px 12px;display:flex;align-items:center;gap:10px;cursor:pointer', onClick: () => showMy90Games() }, [ h('div', { style: 'width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#54a0ff,#8b5cf6);display:flex;align-items:center;justify-content:center;flex-shrink:0', html: '' + ICONS.trophy + '' }), h('span', { style: 'font-size:12px;color:#94a3b8', html: (isZh ? '90天我贡献了 ' : '90d I added ') + '' + my90Contrib + '' + (isZh ? ' 个游戏' : ' games') }), ]); growthWrap.appendChild(my90Card); content.appendChild(growthWrap); } } const tabPanel = h('div', { class: 'sfd-family-tab-panel active' + (state.family.activeTab === 'dynamic' ? ' sfd-family-tab-panel-noscroll' : ' sfd-family-scroll') }); tabPanel.appendChild(tabContent); content.appendChild(tabPanel); if (state.family.activeTab === 'chart') { setTimeout(() => buildStackedBarSvg(state.family.info, state.family.gameList), 50); // 预加载精品游戏数据(供饼图切换使用) if (!_stressGamesCache && !_stressLoadFailed) { try { if (typeof GM_getResourceText === 'function') { const jsonText = GM_getResourceText('stressData'); if (jsonText) { _stressGamesCache = JSON.parse(jsonText); } } } catch (e) { logger.silent('精品游戏 @resource JSON 解析失败', e); } } } // 游玩动态Tab:无数据时由 renderFamilyPlayActivityTab 自行触发加载 // (已移除冗余的异步加载触发,避免重复请求) // 贡献概览卡片点击 const joinCardEl = content.querySelector('#sfd-join-card'); if (joinCardEl) { joinCardEl.addEventListener('click', (e) => { e.stopPropagation(); showContributionOverview(); }); } // 恢复焦点 if (focusInfo) { const newEl = familyPopupEl.querySelector(focusInfo.sel); if (newEl) { newEl.focus(); try { newEl.setSelectionRange(focusInfo.start, focusInfo.end); } catch (e) {} } } } // ==================== 我的贡献概览弹窗 ==================== function showContributionOverview() { const fi = state.family.info; const gl = state.family.gameList; if (!fi || !gl) return; const isZh = locale === 'zh-CN'; const mySteamId = (state.friends.ownProfile && state.friends.ownProfile.steamid) || state.family.popupSteamid; const mySid = String(mySteamId); const gi = gl.GameInfo; const gameList = gl.GameList; const members = fi.family_member || []; const nowSec = Date.now() / 1000; const D30 = 30 * SECONDS_PER_DAY; const D90 = 90 * SECONDS_PER_DAY; const CONTRIB_SECONDS = CONTRIB_DAYS * SECONDS_PER_DAY; // 计算我的贡献数据 const myGames = []; let myExclusive = 0, myShared = 0, myRecent30 = 0, myRecent90 = 0; for (let i = 0; i < gameList.length; i++) { const aid = gameList[i]; const info = gi[aid]; if (!info || !info.owners || info.owners.indexOf(mySid) === -1) continue; const isExclusive = info.owners.length === 1; const isRecent30 = info.time && (nowSec - info.time) < D30; const isRecent90 = info.time && (nowSec - info.time) < D90; myGames.push({ appid: aid, name: info.name || ('App ' + aid), time: info.time || 0, owners: info.owners, isExclusive }); if (isExclusive) myExclusive++; else myShared++; if (isRecent30) myRecent30++; if (isRecent90) myRecent90++; } myGames.sort((a, b) => b.time - a.time); // 近期贡献(用于下方展示) const myRecentGames = myGames.filter(g => g.time && (nowSec - g.time) < CONTRIB_SECONDS); const myTotal = myGames.length; const totalGames = gameList.length; const myPct = totalGames > 0 ? (myTotal / totalGames * 100).toFixed(1) : '0'; // 成员贡献排名 const memberCounts = {}; members.forEach(m => { memberCounts[m.steamid] = 0; }); for (let key in gi) { if (gi[key].owners) { gi[key].owners.forEach(sid => { if (memberCounts[sid] !== undefined) memberCounts[sid]++; }); } } const ranked = members.map(m => ({ sid: m.steamid, name: m.userName || (fi.steamIdtoName || {})[m.steamid] || '成员', count: memberCounts[m.steamid] || 0 })).sort((a, b) => b.count - a.count); let myRank = 0; for (let i = 0; i < ranked.length; i++) { if (String(ranked[i].sid) === mySid) { myRank = i + 1; break; } } // 移除已有弹窗 const existing = document.getElementById('sfd-contrib-overlay'); if (existing) existing.remove(); // 创建弹窗(覆盖在家庭组窗口之上) const overlay = h('div', { id: 'sfd-contrib-overlay', class: 'sfd-family-popup sfd-show', style: { position: 'fixed', inset: '0', margin: 'auto', width: '900px', maxWidth: '96vw', height: '640px', maxHeight: '92vh', zIndex: '505', flexDirection: 'column', overflow: 'hidden' } }); document.body.appendChild(overlay); // 点击外部关闭(排除家庭组窗口本身) const outsideHandler = (e) => { if (overlay.contains(e.target) || familyPopupEl?.contains(e.target)) return; overlay.remove(); document.removeEventListener('mousedown', outsideHandler, true); }; document.addEventListener('mousedown', outsideHandler, true); // ---- 头部 ---- const header = h('div', { class: 'sfd-family-header' }, [ h('h3', { html: `${ICONS.heart} ${isZh ? '我的贡献概览' : 'My Contributions'}` }), h('div', { class: 'sfd-family-header-actions' }, [ h('button', { class: 'sfd-header-btn sfd-header-btn-close', html: ICONS.close, onClick: () => { overlay.remove(); document.removeEventListener('mousedown', outsideHandler, true); } }) ]) ]); overlay.appendChild(header); // ---- 内容区 ---- const body = h('div', { style: { flex: '1', overflow: 'hidden', padding: '14px', display: 'flex', flexDirection: 'column', gap: '12px', minHeight: '0' } }); overlay.appendChild(body); // ---- KPI 卡片行(9个一排) ---- const exclusivePct = myTotal > 0 ? (myExclusive / myTotal * 100).toFixed(0) : '0'; const sharedPct = myTotal > 0 ? (myShared / myTotal * 100).toFixed(0) : '0'; const growthPct = myTotal > 0 ? (myRecent90 / myTotal * 100).toFixed(0) : '0'; const kpiData = [ { label: isZh ? '贡献游戏总数' : 'Total Contrib', value: myTotal, color: '#06cfbe' }, { label: isZh ? '独占贡献' : 'Exclusive', value: myExclusive, color: '#ff9f43' }, { label: isZh ? '共享贡献' : 'Shared', value: myShared, color: '#a78bfa' }, { label: isZh ? '近90日新增' : 'New (90d)', value: myRecent90, color: '#34d399' }, { label: isZh ? '占家庭库' : 'Of Library', value: `${myPct}%`, color: '#06cfbe' }, { label: isZh ? '贡献排名' : 'Rank', value: isZh ? `第${myRank || '-'}名` : `#${myRank || '-'}`, color: '#66c0f4' }, { label: isZh ? '独占率' : 'Exclusive', value: `${exclusivePct}%`, color: '#ff9f43' }, { label: isZh ? '共享率' : 'Shared', value: `${sharedPct}%`, color: '#a78bfa' }, { label: isZh ? '90日增量' : '90d Growth', value: `${growthPct}%`, color: '#34d399' } ]; const kpiRow = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(9,1fr)', gap: '10px', flexShrink: '0' } }); kpiData.forEach(k => { kpiRow.appendChild(createMetricCard({ value: String(k.value), label: k.label, accent: k.color, layout: 'compact' })); }); body.appendChild(kpiRow); // ---- 近期贡献 + 最近游玩(左右两个卡片) ---- const dualRow = h('div', { style: { display: 'flex', gap: '12px', flex: '1', minHeight: '0' } }); // 左卡片:近期贡献 const leftCard = h('div', { class: 'sfd-family-stat-card', style: { flex: '1 1 0%', minWidth: '0', display: 'flex', flexDirection: 'column', padding: '12px', minHeight: '0', overflow: 'hidden' } }); leftCard.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', fontWeight: '600', color: '#c7d5e0', marginBottom: '8px', flexShrink: '0' }, html: `${ICONS.barChart}${isZh ? '近期贡献' : 'Recent Contributions'} (${myRecentGames.length})` })); const leftScroll = h('div', { style: { flex: '1', minHeight: '0', overflowY: 'auto', paddingRight: '2px' } }); const grid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: '8px', alignContent: 'start' } }); if (myRecentGames.length === 0) { grid.appendChild(h('div', { style: { textAlign: 'center', fontSize: '11px', color: '#64748b', padding: '20px' }, text: isZh ? `近${CONTRIB_DAYS}天暂无贡献` : `No contributions in ${CONTRIB_DAYS} days` })); } else { myRecentGames.forEach(g => { const nameEl = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${g.appid}`, target: '_blank', text: g.name }); loadGameZhName(nameEl, g.appid, g.name); const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(g.appid) }); dashLoadCapsule(capImg, g.appid); grid.appendChild(h('div', { class: 'sfd-pl-recent-card' }, [ capImg, h('div', { class: 'sfd-pl-recent-info' }, [ nameEl, h('div', { class: 'sfd-pl-recent-meta' }, [ h('span', { text: g.time > 0 ? DateUtils.format(g.time) : (isZh ? '未知' : 'Unknown') }), g.isExclusive ? h('span', { style: { color: '#ff9f43', fontWeight: '600' }, text: isZh ? '独占' : 'Exclusive' }) : null ]) ]) ])); }); } leftScroll.appendChild(grid); leftCard.appendChild(leftScroll); dualRow.appendChild(leftCard); // 右卡片:最近游玩 const rightCard = h('div', { class: 'sfd-family-stat-card', style: { flex: '1 1 0%', minWidth: '0', display: 'flex', flexDirection: 'column', padding: '12px', minHeight: '0', overflow: 'hidden' } }); rightCard.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', fontWeight: '600', color: '#c7d5e0', marginBottom: '8px', flexShrink: '0' }, html: `${ICONS.game}${isZh ? '最近游玩' : 'Recently Played'}` })); const rightScroll = h('div', { style: { flex: '1', minHeight: '0', overflowY: 'auto', paddingRight: '2px' } }); const playScroll = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: '6px', alignContent: 'start' } }); let playGames = []; const paData = state.family.playActivity; if (paData && paData[mySid]) { playGames = (paData[mySid].games || []).slice().sort((a, b) => (b.playtime_2weeks || 0) - (a.playtime_2weeks || 0) || (b.playtime_forever || 0) - (a.playtime_forever || 0)); } if (playGames.length > 0) { playGames.forEach(game => { playScroll.appendChild(h('div', { class: 'sfd-family-play-game-item' }, [ (() => { const img = h('img', { class: 'sfd-family-play-game-icon', loading: 'lazy' }); dashLoadCapsule(img, game.appid); return img; })(), h('div', { class: 'sfd-family-play-game-info' }, [ (() => { const el = h('a', { class: 'sfd-family-play-game-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(), h('div', { class: 'sfd-family-play-game-meta' }, [ h('span', { class: 'sfd-family-play-game-playtime', text: `${DateUtils.duration(game.playtime_2weeks)}` }), h('span', { class: 'sfd-family-play-game-playtime-total', text: `/ ${DateUtils.duration(game.playtime_forever)}` }) ]) ]) ])); }); } else { playScroll.appendChild(h('div', { style: { textAlign: 'center', fontSize: '11px', color: '#64748b', padding: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: '8px' } }, [ h('div', { text: isZh ? '游玩数据未加载' : 'Play data not loaded' }), h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', text: isZh ? '点击加载' : 'Load Now', onClick: async () => { const btn = playScroll.querySelector('button'); if (btn) { btn.disabled = true; btn.textContent = isZh ? '加载中…' : 'Loading…'; } const ok = await loadFamilyPlayActivity(state.family.popupSteamid); if (ok) { overlay.remove(); document.removeEventListener('mousedown', outsideHandler, true); showContributionOverview(); } else { if (btn) { btn.disabled = false; btn.textContent = isZh ? '加载失败,重试' : 'Failed, Retry'; } } } }) ])); } rightScroll.appendChild(playScroll); rightCard.appendChild(rightScroll); dualRow.appendChild(rightCard); body.appendChild(dualRow); } function renderFamilyChartTab() { const fi = state.family.info; const gl = state.family.gameList; const isZh = locale === 'zh-CN'; const members = (fi && fi.family_member) ? fi.family_member : []; // 精品游戏贡献占比计算(排除未发售,包含未购买) const now = new Date(); const stressGames = getStressAllGames().filter(g => parseStressDate(g.date) < now); const stressAppIds = stressGames.length > 0 ? new Set(stressGames.map(g => Number(g.appid))) : new Set(); // 全部游戏贡献占比 const allMemberContrib = members.map(m => { let count = 0; for (let key in gl.GameInfo) { if (gl.GameInfo[key].owners && gl.GameInfo[key].owners.includes(m.steamid)) count++; } return { name: m.userName, count, sid: m.steamid }; }).sort((a, b) => b.count - a.count); const allTotal = allMemberContrib.reduce((s, m) => s + m.count, 0) || 1; const memberStressContrib = members.map(m => { let count = 0; if (stressAppIds.size > 0) { for (let key in gl.GameInfo) { if (stressAppIds.has(Number(key)) && gl.GameInfo[key].owners && gl.GameInfo[key].owners.includes(m.steamid)) count++; } } else { // 无精品数据时回退到全部游戏 for (let key in gl.GameInfo) { if (gl.GameInfo[key].owners && gl.GameInfo[key].owners.includes(m.steamid)) count++; } } return { name: m.userName, count, sid: m.steamid }; }).sort((a, b) => b.count - a.count); const totalStressGames = stressGames.length || Object.keys(gl.GameInfo).length; const ownedTotal = memberStressContrib.reduce((s, m) => s + m.count, 0); const unowned = Math.max(0, totalStressGames - ownedTotal); const stressContrib = [...memberStressContrib]; if (unowned > 0) { stressContrib.push({ name: isZh ? '未购买' : 'Unowned', count: unowned, sid: 'unowned' }); } const stressTotal = stressContrib.reduce((s, m) => s + m.count, 0) || 1; // 构建饼图 SVG function buildPieSvg(data, total) { let cx = 100, cy = 100, r = 80, svg = '', cumAngle = -Math.PI / 2; data.forEach((m, i) => { const c = m.sid === 'unowned' ? '#64748b' : CHART_COLORS[i % CHART_COLORS.length]; const angle = (m.count / total) * 2 * Math.PI; const x1 = cx + r * Math.cos(cumAngle), y1 = cy + r * Math.sin(cumAngle); const x2 = cx + r * Math.cos(cumAngle + angle), y2 = cy + r * Math.sin(cumAngle + angle); const largeArc = angle > Math.PI ? 1 : 0; svg += `${m.name}: ${m.count} (${(m.count / total * 100).toFixed(1)}%)`; cumAngle += angle; }); return svg; } function buildPieCenter(totalGames, label) { return `${totalGames}${label}`; } function buildPieLegend(data, total, max) { return data.slice(0, max).map((m, i) => { const c = m.sid === 'unowned' ? '#64748b' : CHART_COLORS[i % CHART_COLORS.length]; return `${m.name} ${(m.count / total * 100).toFixed(0)}%`; }).join(''); } const pieSvgAll = buildPieSvg(allMemberContrib, allTotal) + buildPieCenter(Object.keys(gl.GameInfo).length, isZh ? '总入库' : 'Total'); const pieLegendAll = buildPieLegend(allMemberContrib, allTotal, 6); const pieSvgStress = buildPieSvg(stressContrib, stressTotal) + buildPieCenter(stressGames.length, isZh ? '精品总数' : 'Top Games'); const pieLegendStress = buildPieLegend(stressContrib, stressTotal, 7); // 近6月入库增量计算(按成员堆叠柱状图) const memberOrder = allMemberContrib.map(m => m.sid); const memberNameMap = {}; members.forEach(m => { memberNameMap[m.steamid] = m.userName; }); const monthlyMap2 = new Map(); for (let key in gl.GameInfo) { const info = gl.GameInfo[key]; if (!info.time || info.time <= 0) continue; const d2 = new Date(info.time * 1000); const mk = `${d2.getFullYear()}-${String(d2.getMonth() + 1).padStart(2, '0')}`; if (!monthlyMap2.has(mk)) monthlyMap2.set(mk, {}); const entry = monthlyMap2.get(mk); (info.owners || []).forEach(sid => { const s = String(sid); entry[s] = (entry[s] || 0) + 1; }); } const sorted2 = [...monthlyMap2.entries()].sort((a, b) => a[0].localeCompare(b[0])); const recent6 = sorted2.slice(-6); let barSvgW2 = 300, barSvgH2 = 200; let bL2 = 34, bR2 = 12, bT2 = 14, bB2 = 22; let bW2 = barSvgW2 - bL2 - bR2, bH2 = barSvgH2 - bT2 - bB2; const maxRecent6 = Math.max(1, ...recent6.map(([_, entry]) => Object.values(entry).reduce((s, v) => s + v, 0))); let barGrid2 = ''; for (let i = 0; i <= 2; i++) { const v = Math.round(maxRecent6 / 2 * i), y = bT2 + bH2 - (bH2 * i / 2); barGrid2 += `${v}`; } const barW2 = recent6.length > 0 ? Math.min(32, bW2 / recent6.length * 0.65) : 0; const barGap2 = recent6.length > 1 ? (bW2 - barW2 * recent6.length) / (recent6.length - 1) : 0; let bars2 = ''; const fmtM2 = key => { const [_, m] = key.split('-'); return parseInt(m) + (isZh ? '月' : ''); }; recent6.forEach(([key, entry], i) => { const x = bL2 + i * (barW2 + barGap2); const total = Object.values(entry).reduce((s, v) => s + v, 0); let curY = bT2 + bH2; let monthBars = ''; memberOrder.forEach((sid, ci) => { const count = entry[sid] || 0; if (count === 0) return; const h = (count / maxRecent6) * bH2; const c = CHART_COLORS[ci % CHART_COLORS.length]; curY -= h; monthBars += `${memberNameMap[sid] || sid}: ${count}`; }); monthBars += `${total > 0 ? total : ''}`; monthBars += `${fmtM2(key)}`; bars2 += `${monthBars}`; }); const wrap = h('div', { class: 'sfd-family-chart-wrap', id: 'sfd-family-chart-wrap', style: { flex: '1', minWidth: '0' } }, [ h('div', { id: 'sfd-family-chart-left', style: { flex: '1', display: 'flex', flexDirection: 'column', minWidth: '0' } }, [ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px', paddingBottom: '6px', borderBottom: '1px solid rgba(102,192,244,0.15)' } }, [ h('span', { id: 'sfd-family-chart-title', style: { fontSize: '14px', fontWeight: '700', color: '#c7d5e0', flex: '1', textAlign: 'center' }, text: state.family.chartShowDelisted ? (isZh ? '绝版贡献分布图' : 'Delisted Contribution') : (isZh ? '家庭库贡献分布图' : 'Family Library Contribution') }), (() => { const showDelisted = state.family.chartShowDelisted; const btn = h('button', { id: 'sfd-family-chart-toggle', class: `sfd-btn sfd-btn-sm ${showDelisted ? 'sfd-btn-primary' : 'sfd-btn-ghost'}`, style: { flexShrink: '0', padding: '4px 10px', fontSize: '11px', height: '26px' }, text: showDelisted ? (isZh ? '显示全部' : 'Show All') : (isZh ? '显示绝版' : 'Show Delisted') }); btn.addEventListener('click', async () => { state.family.chartShowDelisted = !state.family.chartShowDelisted; const delisted = state.family.chartShowDelisted; // 更新标题和按钮文字 const titleEl = document.getElementById('sfd-family-chart-title'); if (titleEl) titleEl.textContent = delisted ? (isZh ? '绝版贡献分布图' : 'Delisted Contribution') : (isZh ? '家庭库贡献分布图' : 'Family Library Contribution'); btn.textContent = delisted ? (isZh ? '显示全部' : 'Show All') : (isZh ? '显示绝版' : 'Show Delisted'); btn.className = `sfd-btn sfd-btn-sm ${delisted ? 'sfd-btn-primary' : 'sfd-btn-ghost'}`; // 切换到绝版模式时需要加载绝版数据库 if (delisted) { const chartContainer = document.getElementById('sfd-family-chart-left'); if (chartContainer) { // 清除旧图表,显示加载状态 chartContainer.querySelectorAll('#sfd-family-svg, .sfd-family-legend-wrap, #sfd-family-chart-empty, #sfd-family-chart-loading').forEach(el => el.remove()); chartContainer.insertAdjacentHTML('beforeend', `
${ICONS.spinner}${isZh ? '正在加载绝版数据…' : 'Loading delisted data…'}
`); } await loadDelistedDB(); const loadingEl = document.getElementById('sfd-family-chart-loading'); if (loadingEl) loadingEl.remove(); } else { // 切换回全部模式时清除可能的空状态 const emptyEl = document.getElementById('sfd-family-chart-empty'); if (emptyEl) emptyEl.remove(); } buildStackedBarSvg(state.family.info, state.family.gameList); }); return btn; })() ]), h('svg', { id: 'sfd-family-svg', style: { width: '100%', flex: '1', display: 'block' } }) ]) ]); // rightPanel 独立为两个卡片,和贡献分布图并列 const outerWrap = h('div', { style: { display: 'flex', gap: '14px' } }, [ wrap, h('div', { style: { width: '310px', flexShrink: '0', display: 'flex', flexDirection: 'column', gap: '10px' } }, [ (() => { let pieMode = 'all'; // 'all' | 'stress' const pieCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '10px', flex: '1', display: 'flex', flexDirection: 'column' } }); const pieTitleWrap = h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' } }); const pieTitle = h('div', { style: { fontSize: '13px', fontWeight: '600', color: '#c7d5e0' }, text: isZh ? '成员贡献占比' : 'Contribution Share' }); pieTitleWrap.appendChild(pieTitle); const toggleBtn = h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', style: { fontSize: '10px', padding: '2px 8px', height: '22px', lineHeight: '1' }, text: isZh ? '精品' : 'Top', onClick: () => { pieMode = pieMode === 'all' ? 'stress' : 'all'; const svgContent = pieMode === 'all' ? pieSvgAll : pieSvgStress; pieSvgWrap.innerHTML = `${svgContent}`; pieLegendWrap.innerHTML = `
${pieMode === 'all' ? pieLegendAll : pieLegendStress}
`; toggleBtn.textContent = pieMode === 'all' ? (isZh ? '精品' : 'Top') : (isZh ? '全部' : 'All'); pieTitle.textContent = pieMode === 'all' ? (isZh ? '成员贡献占比' : 'Contribution Share') : (isZh ? '精品游戏贡献占比' : 'Top Games Share'); } }); pieTitleWrap.appendChild(toggleBtn); pieCard.appendChild(pieTitleWrap); const pieSvgWrap = h('div', {}); pieSvgWrap.innerHTML = `${pieSvgAll}`; pieCard.appendChild(pieSvgWrap); const pieLegendWrap = h('div', {}); pieLegendWrap.innerHTML = `
${pieLegendAll}
`; pieCard.appendChild(pieLegendWrap); return pieCard; })(), h('div', { class: 'sfd-family-stat-card', style: { padding: '10px', flex: '1', display: 'flex', flexDirection: 'column' } }, [ h('div', { style: { fontSize: '13px', fontWeight: '600', color: '#c7d5e0', marginBottom: '6px' }, text: isZh ? '近6月入库增量' : 'Recent 6 Months' }), (() => { const div = h('div', {}); div.innerHTML = `${barGrid2}${bars2}`; div.addEventListener('click', (e) => { const item = e.target.closest('[data-month]'); if (item) { const month = item.getAttribute('data-month'); if (month) showMonthGames(month); } }); return div; })() ]) ]) ]); return outerWrap; } function buildStackedBarSvg(fi, gl) { if (!fi || !gl) return ''; const members = fi.family_member; const memberCount = members.length; const MAX_FAMILY = 6; const isZh = locale === 'zh-CN'; // 绝版模式:仅统计绝版游戏 let gameInfo = gl.GameInfo; if (state.family.chartShowDelisted) { const delistedApps = getDelistedApps(); const delistedAppIds = new Set(delistedApps.map(app => Number(app.appid))); const filtered = {}; for (let key in gl.GameInfo) { if (delistedAppIds.has(Number(key))) { filtered[key] = gl.GameInfo[key]; } } gameInfo = filtered; // 家庭库中无绝版游戏时显示空状态 if (Object.keys(filtered).length === 0) { const chartContainer = document.getElementById('sfd-family-chart-left'); if (chartContainer) { chartContainer.querySelectorAll('#sfd-family-svg, .sfd-family-legend-wrap, #sfd-family-chart-empty').forEach(el => el.remove()); chartContainer.insertAdjacentHTML('beforeend', `
${isZh ? '家庭库中暂无绝版游戏' : 'No delisted games in family library'}
`); } return; } } const colors = CHART_COLORS; const legendLabels = []; for (let i = 0; i < memberCount; i++) { const ci = memberCount - i - 1; legendLabels[i] = { label: i === memberCount - 1 ? (isZh ? '单独贡献' : 'Exclusive') : `${memberCount - i}${isZh ? '人共同贡献' : '-person shared'}`, color: colors[ci % colors.length] }; } const layerData = Array.from({ length: memberCount }, () => new Array(memberCount).fill(0)); let maxValue = 0; for (let key in gameInfo) { const game = gameInfo[key]; const ownerCount = game.owners.length; const dsIdx = memberCount - ownerCount; if (dsIdx >= 0 && dsIdx < memberCount) { game.owners.forEach(owner => { const mIdx = members.findIndex(m => m.steamid === owner); if (mIdx !== undefined && mIdx >= 0) { layerData[dsIdx][mIdx]++; maxValue = Math.max(maxValue, layerData[dsIdx][mIdx]); } }); } } const svgW = 680, svgH = 420; const pL = 50, pR = 20, pT = 20, pB = 85; const cW = svgW - pL - pR; const cH = svgH - pT - pB; const gap = cW / MAX_FAMILY; const barW = Math.min(Math.max(gap * 0.55, 24), 44); const gridLines = []; const gridCount = 5; for (let i = 0; i <= gridCount; i++) { const v = Math.round(maxValue / gridCount * i); const y = pT + cH - (cH * i / gridCount); gridLines.push(``); gridLines.push(`${v}`); } // 构建头像映射 const avatarMap = buildAvatarMap(); let bars = ''; let labelEls = ''; let avatarEls = ''; // 收集每个成员的完整数据,用于悬浮提示 const memberToolData = []; for (let slot = 0; slot < MAX_FAMILY; slot++) { const cx = pL + gap * slot + gap / 2; if (slot < memberCount) { const member = members[slot]; const sidStr = String(member.steamid); let sh = 0; const layerVals = []; for (let lIdx = 0; lIdx < memberCount; lIdx++) { const n = layerData[lIdx][slot]; layerVals.push(n); if (n <= 0) continue; const ht = maxValue ? (n / maxValue) * cH : 0; const y = pT + cH - sh - ht; const col = legendLabels[lIdx].color; bars += ``; sh += ht; } const total = layerVals.reduce((s, v) => s + v, 0); memberToolData.push({ slot, name: member.userName, steamid: sidStr, total, layerVals }); labelEls += `${member.userName}`; // 名字下方头像(用 foreignObject 嵌入 HTML img,兼容性好) const av = avatarMap[sidStr] || DEFAULT_AVATAR; avatarEls += ``; } else { bars += ``; labelEls += `${isZh ? '待加入' : 'Empty'}`; } } const baseLine = ``; const legendHtml = '
' + legendLabels.map(l => `${l.label}`).join('') + '
'; const svgStr = `${gridLines.join('')}${bars}${labelEls}${avatarEls}${baseLine}`; const chartContainer = document.getElementById('sfd-family-chart-left') || document.getElementById('sfd-family-chart-wrap'); if (chartContainer) { const oldSvg = chartContainer.querySelector('#sfd-family-svg'); const oldLegend = chartContainer.querySelector('.sfd-family-legend-wrap'); const oldEmpty = chartContainer.querySelector('#sfd-family-chart-empty'); if (oldSvg) oldSvg.remove(); if (oldLegend) oldLegend.remove(); if (oldEmpty) oldEmpty.remove(); // 创建高级悬浮提示框 let tipEl = chartContainer.querySelector('#sfd-family-chart-tip'); if (!tipEl) { tipEl = document.createElement('div'); tipEl.id = 'sfd-family-chart-tip'; tipEl.style.cssText = 'position:fixed;z-index:9999;pointer-events:none;display:none;background:linear-gradient(135deg,rgba(15,23,42,0.97),rgba(30,41,59,0.97));border:1px solid rgba(102,192,244,0.3);border-radius:12px;padding:12px 16px;box-shadow:0 8px 32px rgba(0,0,0,0.6),0 0 0 1px rgba(102,192,244,0.1);backdrop-filter:blur(16px);font-size:12px;max-width:280px;transition:opacity 0.15s;opacity:0'; document.body.appendChild(tipEl); } chartContainer.insertAdjacentHTML('beforeend', svgStr); chartContainer.insertAdjacentHTML('beforeend', legendHtml); // 悬浮提示:同一柱子(成员)的多个色块视为整体 let curSlot = -1; // 当前显示的成员 slot chartContainer.querySelectorAll('rect[data-slot]').forEach(rect => { const slot = parseInt(rect.getAttribute('data-slot')); const td = memberToolData[slot]; if (!td) return; rect.addEventListener('mouseenter', (e) => { if (slot === curSlot) return; // 同柱子色块切换,不更新 curSlot = slot; let html = `
${td.name} ${isZh ? '总贡献' : 'Total'}: ${td.total}
`; html += '
'; for (let lIdx = 0; lIdx < memberCount; lIdx++) { const n = td.layerVals[lIdx]; const col = legendLabels[lIdx].color; html += `
${legendLabels[lIdx].label}${n}
`; } html += '
'; tipEl.innerHTML = html; tipEl.style.display = 'block'; tipEl.style.opacity = '1'; tipEl.style.left = Math.min(e.clientX + 14, window.innerWidth - 300) + 'px'; tipEl.style.top = Math.max(8, e.clientY - 10) + 'px'; }); rect.addEventListener('mousemove', (e) => { tipEl.style.left = Math.min(e.clientX + 14, window.innerWidth - 300) + 'px'; tipEl.style.top = Math.max(8, e.clientY - 10) + 'px'; }); rect.addEventListener('mouseleave', (e) => { // 检查是否移到了同柱子的另一个色块 const rt = e.relatedTarget; if (rt && rt.getAttribute && rt.getAttribute('data-slot') === String(slot)) return; tipEl.style.opacity = '0'; setTimeout(() => { tipEl.style.display = 'none'; curSlot = -1; }, 150); }); rect.addEventListener('click', () => { const sid = rect.getAttribute('data-steamid'); const name = rect.getAttribute('data-membername'); showMemberGames(sid, name); }); }); } } // ==================== 家庭组游戏卡片渲染辅助 ==================== function createFamilyGameCard(game, fi, ocColors) { const isZh = locale === 'zh-CN'; const os = getOwnerStyle(game.owners.length); const avatarMap = buildAvatarMap(); const ownerAvatars = game.owners.slice(0, 5).map(sid => { const sidStr = String(sid); const avatar = avatarMap[sidStr] || `https://avatars.steamstatic.com/${sidStr}.jpg`; const name = (fi.steamIdtoName && (fi.steamIdtoName[sidStr] || fi.steamIdtoName[sid])) || 'ID:' + sidStr.slice(-4); return h('img', { src: avatar, style: { width: '22px', height: '22px', borderRadius: '50%', objectFit: 'cover', flexShrink: '0', border: '2px solid rgba(15,23,42,0.9)' }, loading: 'lazy', title: name, onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" }); }); return h('div', { class: 'sfd-pl-recent-card', style: { background: os.bg, borderColor: os.border } }, [ (() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(), h('div', { class: 'sfd-pl-recent-info' }, [ (() => { const el = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(), h('div', { class: 'sfd-pl-recent-meta', style: { justifyContent: 'space-between' } }, [ h('span', { style: { color: '#94a3b8' }, text: DateUtils.formatTimestamp(game.time) }), h('span', { style: { color: os.color, fontWeight: '700', background: os.color + '20', padding: '1px 8px', borderRadius: '10px', fontSize: '11px' }, text: `${game.owners.length}${isZh ? '人' : ''}` }) ]), h('div', { style: { display: 'flex', marginTop: '3px', gap: '3px' } }, ownerAvatars) ]) ]); } // ==================== 好友库存对比 ==================== async function showGameCompare(steamid, friendName) { const isZh = locale === 'zh-CN'; const friend = state.friends.data ? _friendsMap.get(String(steamid)) : null; const friendAvatar = friend ? friend.avatar : ''; const friendHasGame = friend && friend.gameextrainfo; // Remove old overlay const old = document.getElementById('sfd-compare-popup'); if (old) old.remove(); // Create overlay and popup const popup = createPopupContainer('1100px'); popup.id = 'sfd-compare-popup'; popup.style.position = 'fixed'; popup.style.zIndex = '504'; popup.style.width = POPUP_W + 'px'; popup.style.height = POPUP_H + 'px'; popup.style.maxWidth = '96vw'; popup.style.maxHeight = '96vh'; const overlay = popup; // Header const header = h('div', { class: 'sfd-family-header sfd-compare-header' }, [ h('div', { style: { display: 'flex', alignItems: 'center', gap: '12px', flex: '1', minWidth: '0' } }, [ friendAvatar ? h('img', { src: friendAvatar, style: { width: '40px', height: '40px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(6,207,190,0.45)', flexShrink: '0', boxShadow: '0 4px 12px rgba(6,207,190,0.2)' }, loading: 'lazy', onerror: "this.onerror=null;this.src='https://avatars.steamstatic.com/fef49e7fa7e1997310dd48961da2e7d95a5c7a56_medium.jpg';" }) : h('div', { style: { width: '40px', height: '40px', borderRadius: '50%', background: 'rgba(6,207,190,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0' }, html: ICONS.user }), h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '0' } }, [ h('h3', { style: { margin: '0', fontSize: '14px', color: '#fff', fontWeight: '700', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } }, [ h('span', { text: friendName }), friend && friend.level > 0 ? h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#fbbf24', background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.level} Lv.${friend.level}` }) : null, h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#34d399', background: 'rgba(16,185,129,0.12)', border: '1px solid rgba(16,185,129,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.package} -` }), h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#c4b5fd', background: 'rgba(139,92,246,0.12)', border: '1px solid rgba(139,92,246,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.users} -` }) ]), h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' } }, [ h('span', { style: { fontSize: '11px', color: '#64748b', fontFamily: '"SF Mono","Fira Code",monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, text: steamid }), friend ? h('span', { class: `sfd-badge sfd-badge-status ${friendHasGame ? 'in-game' : (friend.personastate > 0 ? 'online' : 'offline')}`, text: friendHasGame ? `🎮 ${friend.gameextrainfo}` : getPersonaStateText(friend.personastate) }) : null ]) ]) ]), h('div', { class: 'sfd-family-header-actions' }, [ createCloseBtn(() => overlay.remove()) ]) ]); // Content area - show loading first const content = h('div', { class: 'sfd-family-content', style: { overflow: 'hidden', flex: '1', padding: '12px' } }); content.appendChild(h('div', { class: 'sfd-family-empty', text: t('compareLoading').replace('{name}', friendName) })); popup.appendChild(header); popup.appendChild(content); document.body.appendChild(overlay); const onEsc = (e) => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onEsc); } }; document.addEventListener('keydown', onEsc); // Fetch friend's games via GM_xmlhttpRequest + API Key try { let friendGames = new Map(); const apiKey = storage.getApiKey(); if (!apiKey) { content.innerHTML = ''; content.appendChild(h('div', { class: 'sfd-family-empty', style: { textAlign: 'center', padding: '20px' } }, [ h('div', { style: { fontSize: '32px', marginBottom: '8px' }, text: '🔑' }), h('div', { text: t('compareNeedKey') }), h('a', { href: 'https://steamcommunity.com/dev/apikey', target: '_blank', style: { color: '#66c0f4', fontSize: '11px' }, text: 'steamcommunity.com/dev/apikey' }) ])); return; } // 好友游戏库与游玩时长统计(用于 KPI 和 Top15 标签页) let friendPlaytimeStats = { totalMinutes: 0, top15: [] }; // Method 1: 统一游戏库缓存(优先命中缓存,未命中则请求并缓存,供仪表盘/我的游戏库共用) try { const cached = await fetchOwnedGamesCached(steamid); if (cached && !cached.private && !cached.error && cached.games && cached.games.length) { const gamesWithTime = []; cached.games.forEach(g => { if (g.appid && g.name) { friendGames.set(Number(g.appid), g.name); gamesWithTime.push({ appid: g.appid, name: g.name, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '' }); } }); const totalMinutes = gamesWithTime.reduce((s, g) => s + g.playtime_forever, 0); const top15 = gamesWithTime.filter(g => g.playtime_forever > 0).sort((a, b) => b.playtime_forever - a.playtime_forever).slice(0, 15); friendPlaytimeStats = { totalMinutes, top15 }; } } catch (e) { logger.silent('好友游戏库 API 方式失败', e); } // Method 2: Try HTML page scraping if (friendGames.size === 0) { try { const html = await gmFetch(`https://steamcommunity.com/profiles/${steamid}/games/?tab=all`); const match = html.match(/var\s+rgGames\s*=\s*(\[[\s\S]*?\])\s*;/); if (match) { const games = JSON.parse(match[1]); games.forEach(g => { if (g.appid && g.name) friendGames.set(Number(g.appid), g.name); }); } } catch (e) { logger.silent('好友游戏库 HTML 抓取失败', e); } } // Method 3: Try XML API if (friendGames.size === 0) { try { const xmlText = await gmFetch(`https://steamcommunity.com/profiles/${steamid}/games/?tab=all&xml=1`); const doc = new DOMParser().parseFromString(xmlText, 'text/xml'); doc.querySelectorAll('game').forEach(g => { const appid = g.querySelector('appID')?.textContent; const name = g.querySelector('name')?.textContent; if (appid && name) friendGames.set(Number(appid), name); }); } catch (e) { logger.silent('好友游戏库 XML 方式失败', e); } } if (friendGames.size === 0) { content.innerHTML = ''; content.appendChild(h('div', { class: 'sfd-family-empty', style: { textAlign: 'center', padding: '20px' } }, [ h('div', { style: { marginBottom: '8px' }, html: `${ICONS.lock}` }), h('div', { text: t('compareError') }), h('div', { style: { fontSize: '11px', color: '#6b7280', marginTop: '4px' }, text: isZh ? '该好友可能将游戏详情设为私密' : 'This friend may have set game details to private' }) ])); return; } // 更新标题中的游戏数徽章 const gcValEl = header.querySelector('#sfd-compare-gc-val'); if (gcValEl) gcValEl.textContent = String(friendGames.size); // ===== 修改:获取"自己"的游戏库(替代家庭组库) ===== let mySteamId = storage.getSteamId() || detectCurrentSteamId(); let myGames = new Map(); if (mySteamId) { try { const myCached = await fetchOwnedGamesCached(mySteamId); if (myCached && !myCached.private && !myCached.error && myCached.games) { myCached.games.forEach(g => { if (g.appid && g.name) myGames.set(Number(g.appid), g.name); }); } } catch (e) { logger.warn('获取比较用自身游戏库失败', e); } } // Compare: shared = 双方都有的, friendOnly = 好友有但我没有的 const shared = [], friendOnly = []; for (const [appid, name] of friendGames) { if (myGames.has(appid)) shared.push({ appid, name }); else friendOnly.push({ appid, name }); } // 我的独占 = 我有但好友没有的 const myOnly = []; for (const [appid, name] of myGames) { if (!friendGames.has(appid)) myOnly.push({ appid, name }); } console.log(`[SFD] 游戏对比: 好友=${friendGames.size}, 自己=${myGames.size}, 共同=${shared.length}, 好友独有=${friendOnly.length}, 自己独有=${myOnly.length}`); // Fetch mutual friends — 优先使用已缓存数据,避免重复API请求 async function fetchMutualFriends(sid) { if (!apiKey) return []; const myFriendIds = new Set(state.friends.data.map(f => f.steamid)); // 优先使用批量查询时缓存的好友列表 if (_mutualFriendLists[sid]) { const mutual = _mutualFriendLists[sid].filter(id => myFriendIds.has(id)); return state.friends.data.filter(f => mutual.includes(f.steamid)); } // 其次使用 _mutualCache 中的数量信息,如果 >= 0 说明之前查过但没缓存列表(私密等),直接用数量 if (_mutualCache[sid] === -1) return []; // 私密好友,无法获取 // 缓存中没有,需要重新请求 try { const resp = await SteamAPI.getFriendsLegacy(sid); const theirFriends = (resp.friendslist?.friends || []).map(f => f.steamid); // 缓存好友列表供下次使用 _mutualFriendLists[sid] = theirFriends; const mutual = theirFriends.filter(id => myFriendIds.has(id)); return state.friends.data.filter(f => mutual.includes(f.steamid)); } catch (e) { logger.warn('获取共同好友详情失败', e); return []; } } let mutualFriendsCache = null; // Tabs let activeTab = 'play'; let comparePage = 1; const PAGE_SIZE = 25; // 8个标签 const tabData = [ { key: 'play', label: isZh ? '最近游玩' : 'Recent Games' }, { key: 'playtime', label: isZh ? '游玩时长' : 'Playtime' }, { key: 'newgames', label: isZh ? '入库动态' : 'Library' }, { key: 'mutual', label: isZh ? '共同好友' : 'Mutual' }, { key: 'delisted', label: isZh ? '绝版游戏' : 'Delisted' }, { key: 'shared', label: isZh ? `共同游戏 (${shared.length})` : `Shared (${shared.length})` }, { key: 'friend', label: isZh ? `好友独有 (${friendOnly.length})` : `Friend Only (${friendOnly.length})` }, { key: 'myonly', label: isZh ? `我的独有 (${myOnly.length})` : `My Only (${myOnly.length})` } ]; const tabBtns = []; const tabsWrap = h('div', { class: 'sfd-family-tabs', style: { marginBottom: '8px' } }); tabData.forEach((td, i) => { const btn = h('button', { class: `sfd-family-tab${td.key === activeTab ? ' active' : ''}`, onClick: () => { activeTab = td.key; comparePage = 1; delistedPage = 1; tabBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderCompareList(); } }, [h('span', { text: td.label })]); tabBtns.push(btn); tabsWrap.appendChild(btn); }); content.innerHTML = ''; content.appendChild(tabsWrap); // Load mutual friends count fetchMutualFriends(steamid).then(mf => { mutualFriendsCache = mf; // 更新共同好友标签文字 const mutualBtn = tabBtns[3]; // 第4个标签 if (mutualBtn) { const span = mutualBtn.querySelector('span'); if (span) { if (_mutualCache[steamid] === -1) { span.textContent = isZh ? '共同好友 🔒' : 'Mutual 🔒'; } else { span.textContent = isZh ? `共同好友 (${mf.length})` : `Mutual (${mf.length})`; } } } // 更新标题中的好友数徽章 const fcValEl = header.querySelector('#sfd-compare-fc-val'); if (fcValEl) { if (_mutualCache[steamid] === -1) { fcValEl.innerHTML = ICONS.lock; } else { const totalFriends = (_mutualFriendLists[steamid] && _mutualFriendLists[steamid].length) || mf.length; fcValEl.textContent = String(totalFriends); } } }); // Activity tab caches (lazy load) let playActivityCache = null; let ownedGamesCache = null; // 绝版收藏筛选状态与缓存 let delistedDataCache = null; let delistedTypeFilter = 'all'; let delistedPage = 1; const DELISTED_PAGE_SIZE = 20; async function fetchFriendPlayActivity(sid) { if (playActivityCache) return playActivityCache; if (!apiKey) return []; try { // 共用仪表盘的 recent cache,避免重复请求 const r = await fetchRecentCached(sid); playActivityCache = r.games || []; return playActivityCache; } catch (e) { logger.warn('获取好友游玩动态失败', e); return []; } } async function fetchFriendOwnedGames(sid) { if (ownedGamesCache) return ownedGamesCache; if (!apiKey) return { timeline: [], total: 0, isFirst: false }; try { const cached = await fetchOwnedGamesCached(sid); if (cached.private || cached.error) return { timeline: [], total: 0, isFirst: false }; // 复用共享的timeline对比逻辑 const result = recordTimeline(sid, cached); ownedGamesCache = result || { timeline: [], gameInfo: {}, total: 0, isFirst: false, allGames: [] }; return ownedGamesCache; } catch (e) { logger.warn('获取好友游戏库失败', e); return { timeline: [], gameInfo: {}, total: 0, isFirst: false, allGames: [] }; } } const listScroll = h('div', { class: 'sfd-family-scroll', style: { overflow: 'hidden', display: 'flex', flexDirection: 'column' } }); const fixedHeader = h('div', { style: { flexShrink: '0' } }); const gameScroll = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } }); const paginationWrap = h('div', { style: { flexShrink: '0', display: 'flex', justifyContent: 'flex-end' } }); listScroll.appendChild(fixedHeader); listScroll.appendChild(gameScroll); listScroll.appendChild(paginationWrap); function renderCompareList() { fixedHeader.innerHTML = ''; gameScroll.innerHTML = ''; paginationWrap.innerHTML = ''; if (activeTab === 'play') { renderPlayTab(); return; } if (activeTab === 'playtime') { renderPlaytimeTab(); return; } if (activeTab === 'newgames') { renderNewGamesTab(); return; } if (activeTab === 'delisted') { renderDelistedTab(); return; } if (activeTab === 'mutual') { renderMutualTab(); return; } let games; if (activeTab === 'shared') games = shared; else if (activeTab === 'friend') games = friendOnly; else if (activeTab === 'myonly') games = myOnly; else games = shared; const totalPages = Math.ceil(games.length / PAGE_SIZE); if (comparePage > totalPages) comparePage = totalPages || 1; const start = (comparePage - 1) * PAGE_SIZE; const pageItems = games.slice(start, start + PAGE_SIZE); const grid = h('div', { class: 'sfd-pl-recent-grid' }); if (games.length === 0) { grid.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '无游戏' : 'No games' })); } else { pageItems.forEach((game) => { const nameEl = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(nameEl, game.appid, game.name); grid.appendChild(h('div', { class: 'sfd-pl-recent-card' }, [ (() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(), h('div', { class: 'sfd-pl-recent-info' }, [ nameEl ]) ])); }); } gameScroll.appendChild(grid); // Pagination if (totalPages > 1) { const pagi = createPagination(comparePage, totalPages, (newPage) => { comparePage = newPage; renderCompareList(); }); pagi.classList.add('sfd-compare-pagi'); pagi.style.borderTop = '1px solid var(--sfd-border)'; pagi.style.flexShrink = '0'; paginationWrap.appendChild(pagi); } } // 游玩动态渲染 async function renderPlayTab() { let loadingEl = content.querySelector('.sfd-activity-loading'); if (!loadingEl) { loadingEl = createLoadingEl(t('activityLoading') || (isZh ? '正在加载游玩动态…' : 'Loading play activity…'), '#a29bfe'); gameScroll.appendChild(loadingEl); } const games = await fetchFriendPlayActivity(steamid); loadingEl.remove(); const list = h('div', { class: 'sfd-pl-recent-grid' }); if (!games || games.length === 0) { list.appendChild(h('div', { class: 'sfd-family-empty', text: t('activityEmptyPlay') || (isZh ? '暂无游玩记录' : 'No play records') })); } else { const total2w = games.reduce((s, g) => s + (g.playtime_2weeks || 0), 0); games.forEach(game => list.appendChild(buildRecentGameCard(game, total2w, isZh))); } gameScroll.appendChild(list); } // 游玩时长标签页:Top15 游玩最多游戏 function renderPlaytimeTab() { const list = h('div', { class: 'sfd-compare-playtime-list' }); const top15 = friendPlaytimeStats.top15 || []; if (top15.length === 0) { list.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '暂无游玩时长数据' : 'No playtime data' })); } else { const maxPlaytime = top15[0].playtime_forever || 1; top15.forEach((game, i) => { const rank = i + 1; const rankClass = rank === 1 ? 'top1' : rank === 2 ? 'top2' : rank === 3 ? 'top3' : ''; const pct = maxPlaytime > 0 ? (game.playtime_forever / maxPlaytime * 100).toFixed(1) : '0'; const hours = (game.playtime_forever / 60).toFixed(1) + 'h'; const iconFallback = game.img_icon_url ? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/${game.img_icon_url}.jpg` : `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/capsule_sm_120.jpg`; list.appendChild(h('div', { class: 'sfd-compare-playtime-item', onClick: () => openStorePage(game.appid) }, [ h('div', { class: `sfd-compare-game-rank ${rankClass}`, text: String(rank) }), (() => { const { wrapper } = createImgWrapper(game.appid, 'sfd-img-sm'); wrapper.style.width = '32px'; wrapper.style.height = '32px'; wrapper.style.flexShrink = '0'; const img = wrapper.querySelector('img'); img.style.width = '32px'; img.style.height = '32px'; img.style.objectFit = 'cover'; img.style.borderRadius = '4px'; loadGameImage(wrapper, game.appid, `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${game.appid}/header.jpg`, iconFallback, '', true, 'header_schinese.jpg'); return wrapper; })(), h('div', { class: 'sfd-family-game-info', style: { minWidth: '0' } }, [ h('a', { class: 'sfd-family-game-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', rel: 'noopener noreferrer', text: game.name, onClick: (e) => { e.preventDefault(); e.stopPropagation(); openStorePage(game.appid); } }) ]), h('div', { class: 'sfd-compare-playtime-bar-wrap' }, [ h('div', { class: 'sfd-compare-playtime-bar-fill', style: { width: pct + '%' } }) ]), h('div', { class: 'sfd-compare-playtime-hours', text: hours }) ])); }); } gameScroll.appendChild(list); } // 入库动态渲染(喜加一) async function renderNewGamesTab() { let loadingEl = content.querySelector('.sfd-activity-loading'); if (!loadingEl) { loadingEl = createLoadingEl(isZh ? '正在加载库…' : 'Loading library…', '#06cfbe'); gameScroll.appendChild(loadingEl); } // 每次点击都强制刷新:清除内存缓存和持久化缓存中该好友的数据 ownedGamesCache = null; storage.delOwnedGameCache(steamid); const summary = storage.getOwnedGamesSummary(); delete summary[steamid]; storage.setOwnedGamesSummary(summary); const data = await fetchFriendOwnedGames(steamid); loadingEl.remove(); const list = h('div', { class: 'sfd-compare-list' }); const records = data.timeline || []; // Summary cards const summaryEl = h('div', { style: { display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap' } }, [ h('div', { style: { background: 'rgba(6,207,190,0.1)', border: '1px solid rgba(6,207,190,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [ h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#06cfbe' }, text: String(data.total) }), h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: isZh ? '当前游戏总数' : 'Current Total' }) ]), h('div', { style: { background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [ h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#a29bfe' }, text: String(records.length) }), h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: isZh ? '检查次数' : 'Checks' }) ]) ]); list.appendChild(summaryEl); if (records.length === 0) { list.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '暂无数据' : 'No data' })); } else { // Render timeline from newest to oldest const sorted = [...records].reverse(); // 预处理:合并连续的"无变化"记录(首次记录和有新增的不合并) const groups = []; sorted.forEach((record, idx) => { const isFirstRecord = idx === sorted.length - 1; const newCount = record.newAppIds ? record.newAppIds.length : 0; const isNoChange = !isFirstRecord && newCount === 0; const lastGroup = groups[groups.length - 1]; if (isNoChange && lastGroup && lastGroup.type === 'nochange') { // 追加到当前无变化组 lastGroup.count++; lastGroup.records.push(record); lastGroup.endTs = record.ts; } else { // 新建组 groups.push({ type: isFirstRecord ? 'first' : (newCount > 0 ? 'new' : 'nochange'), count: 1, records: [record], startTs: record.ts, endTs: record.ts, isFirstRecord, newCount }); } }); // 渲染分组 groups.forEach(group => { const record = group.records[0]; // 最新一条 const tsText = DateUtils.formatMs(group.startTs); const tsEndText = group.count > 1 ? DateUtils.formatMs(group.endTs) : ''; // 分界线 list.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', margin: '12px 0 8px' } }, [ h('div', { style: { flex: '1', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(102,192,244,0.25), transparent)' } }), h('span', { style: { fontSize: '10px', color: '#475569', whiteSpace: 'nowrap', letterSpacing: '0.5px' }, text: group.count > 1 ? `${tsText} → ${tsEndText}` : tsText }), h('div', { style: { flex: '1', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(102,192,244,0.25), transparent)' } }) ])); // Record header const headerColor = group.isFirstRecord ? '#06cfbe' : (group.newCount > 0 ? '#10b981' : '#8a9ba8'); const headerLabel = group.isFirstRecord ? (isZh ? '📌 首次记录' : '📌 First Record') : (group.newCount > 0 ? (isZh ? `🎉 喜加${group.newCount > 1 ? group.newCount : '一'}` : `🎉 ${group.newCount} New`) : (group.count > 1 ? (isZh ? `📦 无变化 ×${group.count}` : `📦 No Change ×${group.count}`) : (isZh ? '📦 无变化' : '📦 No Change'))); list.appendChild(h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 10px', marginBottom: '6px', background: `${headerColor}10`, borderRadius: '6px', border: `1px solid ${headerColor}30` } }, [ h('span', { style: { fontSize: '13px', fontWeight: '600', color: headerColor }, text: headerLabel }), h('span', { style: { fontSize: '11px', color: '#6b7280' }, text: `${group.isFirstRecord ? tsText + ' · ' : ''}${record.totalGames}${isZh ? '款' : ' games'}` }) ])); // Record content if (group.isFirstRecord) { list.appendChild(h('div', { style: { fontSize: '12px', color: '#8a9ba8', padding: '4px 10px 8px' }, text: isZh ? `已记录 ${record.totalGames} 款游戏,后续检查将对比显示新增` : `${record.totalGames} games recorded. Future checks will show additions.` })); } else if (group.newCount === 0) { list.appendChild(h('div', { style: { fontSize: '12px', color: '#8a9ba8', padding: '4px 10px 8px' }, text: isZh ? `该好友当前共 ${record.totalGames} 款游戏,连续 ${group.count} 次检查无变化` : `Friend has ${record.totalGames} games, no change for ${group.count} checks.` })); } else { // gameInfo 从 timeline 顶层全局缓存取 const gi = data.gameInfo || {}; const gameGrid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px', marginBottom: '8px' } }); record.newAppIds.forEach(appid => { const info = gi[appid] || {}; gameGrid.appendChild(h('div', { class: 'sfd-pl-recent-card' }, [ (() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(appid) }); return dashLoadCapsule(capImg, appid); })(), h('div', { class: 'sfd-pl-recent-info' }, [ h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${appid}`, target: '_blank', rel: 'noopener noreferrer', text: info.name || `App ${appid}`, onClick: (e) => { e.preventDefault(); e.stopPropagation(); openStorePage(appid); } }), h('span', { style: { fontSize: '10px', color: '#34d399', fontWeight: '700' }, text: '🎉 新增' }) ]) ])); }); list.appendChild(gameGrid); } }); } gameScroll.appendChild(list); } async function renderMutualTab() { const oldList = gameScroll.querySelector('.sfd-compare-list'); if (oldList) oldList.remove(); const list = h('div', { class: 'sfd-compare-list' }); if (!mutualFriendsCache) { const loadingEl = createLoadingEl(isZh ? '正在加载共同好友…' : 'Loading mutual friends…', '#a29bfe'); list.appendChild(loadingEl); gameScroll.appendChild(list); mutualFriendsCache = await fetchMutualFriends(steamid); const countEl = summary.children[2].querySelector('div:first-child'); if (countEl) countEl.textContent = String(mutualFriendsCache.length); renderMutualTab(); return; } if (mutualFriendsCache.length === 0) { list.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '暂无共同好友' : 'No mutual friends' })); } else { list.style.display = 'grid'; list.style.gridTemplateColumns = 'repeat(3, 1fr)'; list.style.gap = '8px'; mutualFriendsCache.forEach(f => { list.appendChild(h('a', { class: 'sfd-family-game-item', href: `https://steamcommunity.com/profiles/${f.steamid}`, target: '_blank', style: { borderLeft: '3px solid #a29bfe', background: '#a29bfe0d', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '8px', padding: '8px', borderRadius: '0 6px 6px 0' } }, [ h('img', { src: f.avatar, loading: 'lazy', style: { width: '36px', height: '36px', borderRadius: '50%', flexShrink: '0' } }), h('div', { style: { minWidth: '0', flex: '1' } }, [ h('div', { style: { fontSize: '13px', fontWeight: '600', color: 'var(--sfd-text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, text: f.personaname }), h('div', { style: { fontSize: '11px', color: '#8a9ba8', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '4px' } }, [ f.country_flag ? h('span', { style: { display: 'inline-flex', alignItems: 'center', flexShrink: '0' }, html: f.country_flag }) : null, f.country_name || f.loccountrycode || '', f.level > 0 ? h('span', { style: { color: '#fbbf24', fontWeight: '600', marginLeft: '6px' }, text: `Lv.${f.level}` }) : null ]) ]) ])); }); } gameScroll.appendChild(list); } // 绝版游戏标签页(参考个人游戏库:分类标签筛选 + 拥有率 KPI) async function renderDelistedTab() { fixedHeader.innerHTML = ''; gameScroll.innerHTML = ''; paginationWrap.innerHTML = ''; if (friendGames.size === 0) { gameScroll.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '无法获取好友游戏列表' : 'Cannot fetch friend game list' })); return; } if (!delistedDataCache) { const loadingEl = createLoadingEl(isZh ? '正在检测绝版游戏…' : 'Detecting delisted games…', '#f44336'); gameScroll.appendChild(loadingEl); await loadDelistedDB(); const delistedApps = getDelistedApps(); const delistedMap = new Map(); delistedApps.forEach(app => { if (app.appid) delistedMap.set(Number(app.appid), app); }); const owned = []; for (const [appid, name] of friendGames) { const info = delistedMap.get(Number(appid)); if (info) { owned.push({ appid, name: name || info.name || `App ${appid}`, delistedType: getDelistedTypeKey(info.type), delistedDate: info.changed || '' }); } } owned.sort((a, b) => (a.delistedType || '').localeCompare(b.delistedType || '')); delistedDataCache = { owned, total: delistedApps.length }; loadingEl.remove(); } const data = delistedDataCache; const typeColors = { 'delisted': '#f44336', 'purchase_disabled': '#ff9800', 'f2p_unavailable': '#ce93d8', 'retail_only': '#64b5f6', 'test_app': '#90a4ae' }; const ownRate = data.total > 0 ? (data.owned.length / data.total * 100).toFixed(1) : '0'; fixedHeader.appendChild(h('div', { style: { display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap' } }, [ h('div', { style: { background: 'rgba(244,67,54,0.1)', border: '1px solid rgba(244,67,54,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [ h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#f44336' }, text: String(data.total) }), h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: isZh ? '绝版总数' : 'Total Delisted' }) ]), h('div', { style: { background: 'rgba(16,185,129,0.1)', border: '1px solid rgba(16,185,129,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [ h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#34d399' }, text: String(data.owned.length) }), h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: isZh ? '好友拥有' : 'Friend Owns' }) ]), h('div', { style: { background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [ h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#a78bfa' }, text: ownRate + '%' }), h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: isZh ? '拥有率' : 'Own Rate' }) ]) ])); if (data.owned.length === 0) { gameScroll.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '该好友没有绝版游戏' : 'No delisted games' })); return; } const ownedTypes = [...new Set(data.owned.map(g => g.delistedType))]; const filterBar = h('div', { style: { display: 'flex', gap: '6px', marginBottom: '10px', flexWrap: 'wrap', flexShrink: '0' } }); const allBtn = h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', style: { padding: '4px 10px', fontSize: '11px' }, text: isZh ? '全部 (' + data.owned.length + ')' : 'All (' + data.owned.length + ')', onClick: () => { delistedTypeFilter = 'all'; delistedPage = 1; renderDelistedTab(); } }); if (delistedTypeFilter === 'all') allBtn.style.cssText += ';background:rgba(139,92,246,0.2);border-color:rgba(139,92,246,0.4);color:#a78bfa'; filterBar.appendChild(allBtn); ownedTypes.forEach(typeKey => { const typeLabel = getDelistedTypeLabel(typeKey); const count = data.owned.filter(g => g.delistedType === typeKey).length; if (count === 0) return; const btn = h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', style: { padding: '4px 10px', fontSize: '11px' }, text: typeLabel + ' (' + count + ')', onClick: () => { delistedTypeFilter = typeKey; delistedPage = 1; renderDelistedTab(); } }); if (delistedTypeFilter === typeKey) btn.style.cssText += ';background:rgba(139,92,246,0.2);border-color:rgba(139,92,246,0.4);color:#a78bfa'; filterBar.appendChild(btn); }); fixedHeader.appendChild(filterBar); let filtered = data.owned; if (delistedTypeFilter !== 'all') { filtered = filtered.filter(g => g.delistedType === delistedTypeFilter); } const totalPages = Math.max(1, Math.ceil(filtered.length / DELISTED_PAGE_SIZE)); if (delistedPage > totalPages) delistedPage = totalPages; const start = (delistedPage - 1) * DELISTED_PAGE_SIZE; const pageItems = filtered.slice(start, start + DELISTED_PAGE_SIZE); const gameList = h('div', { class: 'sfd-pl-recent-grid' }); pageItems.forEach(game => { const typeLabel = getDelistedTypeLabel(game.delistedType); const badgeClass = 'type-' + game.delistedType; gameList.appendChild(h('div', { class: 'sfd-pl-recent-card' }, [ (() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(), h('div', { class: 'sfd-pl-recent-info' }, [ (() => { const el = h('a', { class: 'sfd-pl-recent-name', href: 'https://store.steampowered.com/app/' + game.appid, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(), h('div', { class: 'sfd-pl-recent-meta' }, [ h('span', { class: 'sfd-pl-delisted-badge ' + badgeClass, text: typeLabel }), game.delistedDate ? h('span', { text: game.delistedDate }) : null ]) ]) ])); }); gameScroll.appendChild(gameList); if (totalPages > 1) { const pagi = createPagination(delistedPage, totalPages, (newPage) => { delistedPage = newPage; renderDelistedTab(); }); pagi.classList.add('sfd-compare-pagi'); pagi.style.borderTop = '1px solid var(--sfd-border)'; pagi.style.flexShrink = '0'; paginationWrap.appendChild(pagi); } } content.appendChild(listScroll); renderCompareList(); } catch (e) { logger.error('游戏对比失败', e); content.innerHTML = ''; content.appendChild(h('div', { class: 'sfd-family-empty', text: t('compareError') })); } } // ==================== 个人游戏库浮窗 (1.0.16) ==================== // 绝版游戏数据库 — 优先使用 GM_getResourceText,失败时回退到 gmFetch(与 steam-game-library-viewer 共享数据源) let _delistedDB = null; async function loadDelistedDB() { if (_delistedDB) return _delistedDB; try { let jsonText = ''; if (typeof GM_getResourceText === 'function') { try { jsonText = GM_getResourceText('delistedData'); } catch (e) { jsonText = ''; } } if (!jsonText) { jsonText = await gmFetch('https://leanisssharedstorage.blob.core.windows.net/copilot/asdm-files/steam_delisted_apps.json'); } _delistedDB = JSON.parse(jsonText); return _delistedDB; } catch (e) { logger.warn('加载绝版游戏库失败', e); _delistedDB = {}; return _delistedDB; } } // 兼容 library-viewer 新旧格式并标准化字段 function getDelistedApps() { let rawApps = []; if (Array.isArray(_delistedDB.data)) rawApps = _delistedDB.data; else if (_delistedDB.delisted && Array.isArray(_delistedDB.delisted.apps)) rawApps = _delistedDB.delisted.apps; return rawApps.map(app => ({ appid: app.appId || app.appid, name: app.name, type: app.delistType || app.type || 'Delisted', changed: app.delistedAt || app.changed, owners: app.ownerPercentage != null ? app.ownerPercentage + '%' : (app.owners || '-'), achievements: app.achievementCount || app.achievements, keyshopPrice: app.keyshopPrice, source: app.source })); } function getDelistedTypeKey(type) { if (!type) return 'delisted'; if (typeof type === 'string' && type.includes('_')) return type; const map = { 'Delisted': 'delisted', 'Purchase disabled': 'purchase_disabled', 'F2P (unavailable)': 'f2p_unavailable', 'Retail only': 'retail_only', 'Test app': 'test_app', }; return map[type] || 'delisted'; } function getDelistedTypeLabel(type) { if (!type) return t('plDelistedTypeDelisted'); const map = { 'delisted': t('plDelistedTypeDelisted'), 'purchase_disabled': t('plDelistedTypePurchaseDisabled'), 'f2p_unavailable': t('plDelistedTypeF2p'), 'retail_only': t('plDelistedTypeRetail'), 'test_app': t('plDelistedTypeTest'), }; return map[type] || type; } // 获取游戏图标 URL function getGameIconUrl(appid, iconHash) { if (iconHash) return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${iconHash}.jpg`; return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_sm_120.jpg`; } // 获取个人游戏库数据(带缓存) const fetchMyOwnedGames = withCache({ memKey: 'personal.gamesCache' }, async () => { const apiKey = storage.getApiKey(); if (!apiKey) return null; const mySteamId = storage.getSteamId() || detectCurrentSteamId(); if (!mySteamId) return null; try { const r = await fetchOwnedGamesCached(mySteamId); if (r.private || r.error) return null; return { games: r.games, total: r.game_count, totalMinutes: r.totalMinutes }; } catch (e) { logger.warn('获取自身游戏库失败', e); return null; } }); // 获取最近游玩数据(共用 recent cache) const fetchMyRecentGames = withCache({ memKey: 'personal.recentCache' }, async () => { const apiKey = storage.getApiKey(); if (!apiKey) return null; const mySteamId = storage.getSteamId() || detectCurrentSteamId(); if (!mySteamId) return null; try { const r = await fetchRecentCached(mySteamId); return r.games || []; } catch (e) { logger.warn('获取最近游玩失败', e); return null; } }); // ===== v1.2.1: 检测家庭共享对入库动态的影响 ===== // GetSharedLibraryApps 返回的 rt_time_acquired 是每个 app 的单个时间戳(秒), // 当多名家庭成员拥有同一游戏时,该时间可能为最早入库者的时间,而非当前用户个人入库时间。 // 检测逻辑:标记 owners.length > 1 的共拥游戏,并通过个人许可页交叉验证日期。 // 解析许可页日期字符串为时间戳 function parseLicenseDate(dateStr) { if (!dateStr) return 0; // 中文格式: "2024年6月13日" let m = dateStr.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/); if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime(); // 英文格式: "13 Jun 2024" const months = { Jan:0, Feb:1, Mar:2, Apr:3, May:4, Jun:5, Jul:6, Aug:7, Sep:8, Oct:9, Nov:10, Dec:11 }; m = dateStr.match(/(\d{1,2})\s+(\w{3})\s+(\d{4})/); if (m && months[m[2]] != null) return new Date(Number(m[3]), months[m[2]], Number(m[1])).getTime(); // 英文格式: "Jun 13, 2024" m = dateStr.match(/(\w{3})\s+(\d{1,2}),?\s+(\d{4})/); if (m && months[m[1]] != null) return new Date(Number(m[3]), months[m[1]], Number(m[2])).getTime(); return 0; } // 从许可页 HTML 中解析许可行 function parseLicenseRows(html, map) { try { const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('.license_row').forEach(row => { const link = row.querySelector('a[href*="/app/"]'); if (!link) return; const appMatch = (link.getAttribute('href') || '').match(/\/app\/(\d+)/); if (!appMatch) return; const appid = Number(appMatch[1]); const dateEl = row.querySelector('.license_date_col'); if (!dateEl) return; const label = dateEl.querySelector('.license_date_col_label'); if (label) label.remove(); const dateText = (dateEl.textContent || '').trim(); const ts = parseLicenseDate(dateText); if (appid && ts) map[appid] = { dateStr: dateText, ts }; }); } catch (e) { logger.silent('许可页 HTML 解析失败', e); } } // 获取个人许可页入库时间(通过 store.steampowered.com/account/licenses 抓取) async function fetchPersonalLicenses() { if (state.personal.licenseMap) return state.personal.licenseMap; if (state.personal.licenseMapLoading) { // 等待正在进行的请求 while (state.personal.licenseMapLoading) await new Promise(r => setTimeout(r, 200)); return state.personal.licenseMap || {}; } state.personal.licenseMapLoading = true; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://store.steampowered.com/account/licenses/?l=schinese', timeout: 15000, onload(resp) { try { const html = resp.responseText || ''; const map = {}; parseLicenseRows(html, map); // 提取 sessionid 用于 AJAX 分页 const sidMatch = html.match(/g_sessionID\s*=\s*"([^"]+)"/); const sessionid = sidMatch ? sidMatch[1] : null; // 检查是否有更多 const hasMore = html.includes('load_more_licenses') || html.includes('btn_load_more'); if (hasMore && sessionid) { loadRemainingLicenses(sessionid, map, resolve); } else { state.personal.licenseMap = map; state.personal.licenseMapLoading = false; resolve(map); } } catch (e) { logger.warn('解析许可页失败', e); state.personal.licenseMapLoading = false; resolve({}); } }, onerror() { state.personal.licenseMapLoading = false; resolve({}); }, ontimeout() { state.personal.licenseMapLoading = false; resolve({}); } }); }); } // AJAX 分页加载剩余许可 function loadRemainingLicenses(sessionid, map, resolve, cursor) { let pageCount = 0; const loadNext = (cur) => { if (pageCount >= MAX_LICENSE_PAGES) { state.personal.licenseMap = map; state.personal.licenseMapLoading = false; resolve(map); return; } pageCount++; GM_xmlhttpRequest({ method: 'POST', url: 'https://store.steampowered.com/account/AjaxLoadMoreLicenses/', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `sessionid=${encodeURIComponent(sessionid)}&cursor=${encodeURIComponent(cur || '')}`, timeout: 15000, onload(resp) { try { const data = JSON.parse(resp.responseText); if (data.html) parseLicenseRows(data.html, map); if (data.has_more && data.cursor) { loadNext(data.cursor); } else { state.personal.licenseMap = map; state.personal.licenseMapLoading = false; resolve(map); } } catch (e) { state.personal.licenseMap = map; state.personal.licenseMapLoading = false; resolve(map); } }, onerror() { state.personal.licenseMap = map; state.personal.licenseMapLoading = false; resolve(map); }, ontimeout() { state.personal.licenseMap = map; state.personal.licenseMapLoading = false; resolve(map); } }); }; loadNext(cursor); } // 获取自己的完整入库历史:复用家庭组数据获取方法(从商店 webapi_token 鉴权), // 调用 IFamilyGroupsService/GetSharedLibraryApps(include_own=true) 得到每个游戏的 rt_time_acquired 入库时间。 // v1.2.1: 新增家庭共享影响检测——标记共拥游戏(owners.length > 1),并通过个人许可页交叉验证日期。 const fetchMyLibraryHistory = withCache({ memKey: 'personal.licenseHistoryCache' }, async () => { const mySteamId = storage.getSteamId() || detectCurrentSteamId(); if (!mySteamId) return null; try { const ok = await loadFamilyData(mySteamId); const gl = state.family.gameList; if (!ok || !gl || !gl.GameInfo) return null; const items = []; let coOwnedCount = 0; for (const appid in gl.GameInfo) { const info = gl.GameInfo[appid]; if (!info || !info.owners || !info.owners.includes(mySteamId)) continue; const ts = (info.time || 0) * 1000; // rt_time_acquired(秒→毫秒) const ownerCount = (info.owners || []).length; const coOwned = ownerCount > 1; if (coOwned) coOwnedCount++; items.push({ appid: Number(appid), name: info.name || `App ${appid}`, ts, dateStr: info.time ? (() => { const d = new Date(info.time * 1000); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; })() : '', icon_hash: info.icon_hash || '', ownerCount, coOwned, licenseTs: 0, // 个人许可页入库时间(0=未获取) licenseDateStr: '', // 个人许可页日期字符串 dateMismatch: false, // 家庭组日期与个人许可日期不一致 familyTs: 0 // 原始家庭组API时间(被修正时保留) }); } items.sort((a, b) => (b.ts || 0) - (a.ts || 0)); // 记录共拥数量用于检测横幅显示 state.personal.coOwnedCount = coOwnedCount; return items; } catch (e) { logger.warn('获取入库历史失败', e); return null; } }); // v1.2.1: 用个人许可页数据交叉验证共拥游戏的入库日期 // 核心修复:GetSharedLibraryApps 的 rt_time_acquired 是所有拥有者中最新的入库时间, // 当家庭成员新入库一个自己已拥有的游戏时,该时间会被刷新为家人的入库时间。 // 解决方案:用个人许可页(store.steampowered.com/account/licenses)的真实入库时间覆盖。 async function crossVerifyLicenses() { const items = state.personal.licenseHistoryCache; if (!items || items.length === 0) return; const coOwnedItems = items.filter(it => it.coOwned); if (coOwnedItems.length === 0) return; // 无共拥游戏,无需验证 try { const licenseMap = await fetchPersonalLicenses(); if (!licenseMap || Object.keys(licenseMap).length === 0) return; let verifiedCount = 0, mismatchCount = 0, correctedCount = 0; for (const it of coOwnedItems) { const lic = licenseMap[it.appid]; if (!lic) continue; it.licenseTs = lic.ts; it.licenseDateStr = lic.dateStr; verifiedCount++; if (it.ts && lic.ts) { const d1 = new Date(it.ts); d1.setHours(0,0,0,0); const d2 = new Date(lic.ts); d2.setHours(0,0,0,0); if (d1.getTime() !== d2.getTime()) { it.dateMismatch = true; mismatchCount++; } } // 核心修复:个人许可页时间才是自己真实入库时间,覆盖家庭组 API 时间 if (lic.ts && lic.ts !== it.ts) { it.familyTs = it.ts; // 保留原始家庭组时间供参考 it.ts = lic.ts; // 用个人许可时间覆盖 it.dateStr = (() => { const d = new Date(lic.ts); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; })(); correctedCount++; } } // 时间被修正后需要重新排序 if (correctedCount > 0) { items.sort((a, b) => (b.ts || 0) - (a.ts || 0)); } state.personal.licenseVerifiedCount = verifiedCount; state.personal.licenseMismatchCount = mismatchCount; state.personal.licenseCorrectedCount = correctedCount; } catch (e) { logger.warn('交叉验证许可失败', e); } } // 检测个人绝版收藏(与 library-viewer 共享数据库并比对个人游戏库) const fetchMyDelisted = withCache({ memKey: 'personal.delistedCache' }, async () => { const ownedData = await fetchMyOwnedGames(); if (!ownedData || !ownedData.games.length) return null; await loadDelistedDB(); const apps = getDelistedApps(); const myAppIds = new Set(ownedData.games.map(g => g.appid)); const owned = []; for (const app of apps) { if (!app.appid) continue; const aid = Number(app.appid); if (myAppIds.has(aid)) { const gameInfo = ownedData.games.find(g => g.appid === aid) || {}; owned.push({ appid: aid, name: gameInfo.name || app.name || `App ${aid}`, playtime_forever: gameInfo.playtime_forever || 0, img_icon_url: gameInfo.img_icon_url || '', delistedType: getDelistedTypeKey(app.type), delistedDate: app.changed || '', ownersPct: app.owners || '', achievements: app.achievements || null, keyshopPrice: app.keyshopPrice || null, }); } } owned.sort((a, b) => b.playtime_forever - a.playtime_forever); return { owned, total: apps.length }; }); function renderPlaytimeBar(distData, total) { if (total === 0) return h('div', { class: 'sfd-pl-empty', text: t('plNoData') }); const maxCount = Math.max(...distData.map(d => d.count), 1); const container = h('div', { style: { padding: '4px 0' } }); for (const d of distData) { const pct = total > 0 ? (d.count / total * 100).toFixed(1) : '0'; const barPct = maxCount > 0 ? (d.count / maxCount * 100).toFixed(1) : '0'; container.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '5px' } }, [ h('span', { style: { width: '70px', flexShrink: '0', fontSize: '11px', color: '#94a3b8', textAlign: 'right', whiteSpace: 'nowrap' }, text: d.label }), h('div', { style: { flex: '1', height: '18px', background: 'rgba(255,255,255,0.04)', borderRadius: '4px', overflow: 'hidden', minWidth: '0' } }, [ h('div', { style: { height: '100%', width: barPct + '%', background: d.color, borderRadius: '4px', transition: 'width .3s ease' }, title: `${d.label}: ${d.count} (${pct}%)` }) ]), h('span', { style: { width: '64px', flexShrink: '0', fontSize: '11px', color: '#8a9ba8', textAlign: 'left', whiteSpace: 'nowrap' }, text: `${d.count} (${pct}%)` }) ])); } return container; } function calcPlaytimeDist(games) { const ranges = [ { label: t('plRange1'), min: 0.01, max: 1, color: '#94a3b8' }, { label: t('plRange2'), min: 1, max: 10, color: '#60a5fa' }, { label: t('plRange3'), min: 10, max: 50, color: '#34d399' }, { label: t('plRange4'), min: 50, max: 100, color: '#fbbf24' }, { label: t('plRange5'), min: 100, max: 500, color: '#f97316' }, { label: t('plRange6'), min: 500, max: Infinity, color: '#ef4444' } ]; const zeroCount = games.filter(g => g.playtime_forever === 0).length; return [ { label: t('plRange0'), count: zeroCount, color: '#64748b' }, ...ranges.map(r => ({ label: r.label, count: games.filter(g => { const h = g.playtime_forever / 60; return h >= r.min && h < r.max; }).length, color: r.color })) ]; } // ===== 主函数:展示个人游戏库浮窗 ===== function showPersonalLibrary() { // 如果已存在浮窗,直接显示 if (state.personal.popupEl) { state.personal.popupEl.classList.add('sfd-show'); return; } // 创建浮窗 state.personal.popupEl = h('div', { class: 'sfd-pl-popup', id: 'sfd-pl-popup' }, [ h('div', { class: 'sfd-pl-header' }, [ h('h3', { html: `${ICONS.package} ${t('plTitle')}` }), h('div', { class: 'sfd-pl-header-actions' }, [ h('span', { class: 'sfd-dash-updated', id: 'sfd-pl-updated', text: '' }), createRefreshBtn(() => { refreshPersonalLibraryWithProgress(); }, locale === 'zh-CN' ? '刷新数据' : 'Refresh', 'sfd-header-btn'), createCloseBtn(() => state.personal.popupEl.classList.remove('sfd-show')) ]) ]), h('div', { class: 'sfd-family-progress', id: 'sfd-pl-progress', html: '
' }), h('div', { class: 'sfd-pl-content', id: 'sfd-pl-content' }) ]); document.body.appendChild(state.personal.popupEl); state.personal.popupEl.classList.add('sfd-show'); // 点击外部关闭 const plOutsideClick = (e) => { if (state.personal.popupEl && state.personal.popupEl.classList.contains('sfd-show') && !state.personal.popupEl.contains(e.target) && !e.target.closest('.sfd-trigger-library') && !e.target.closest('.sfd-panel') && !e.target.closest('.sfd-modal') && !e.target.closest('#sfd-compare-popup') && !e.target.closest('#sfd-member-games-popup') && !e.target.closest('#sfd-month-games-popup') && !e.target.closest('#sfd-my90-games-popup') && !e.target.closest('#sfd-family-popup')) { state.personal.popupEl.classList.remove('sfd-show'); } }; document.addEventListener('mousedown', plOutsideClick, true); state.ui.disposers.push(() => document.removeEventListener('mousedown', plOutsideClick, true)); bindScrollOverride(state.personal.popupEl, '.sfd-pl-content, .sfd-pl-scroll'); const onPlEsc = (e) => { if (e.key === 'Escape' && state.personal.popupEl && state.personal.popupEl.classList.contains('sfd-show')) state.personal.popupEl.classList.remove('sfd-show'); }; document.addEventListener('keydown', onPlEsc); state.ui.disposers.push(() => document.removeEventListener('keydown', onPlEsc)); // 初始渲染 state.personal.activeTab = 'all'; renderPersonalLibrary(); } // 刷新个人游戏库数据(带进度条) async function refreshPersonalLibraryWithProgress() { const progEl = document.getElementById('sfd-pl-progress'); const barEl = document.getElementById('sfd-pl-progress-bar'); if (!progEl || !barEl) { state.personal.gamesCache = null; state.personal.recentCache = null; state.personal.licenseHistoryCache = null; state.personal.delistedCache = null; state.personal.licenseMap = null; state.personal.coOwnedCount = 0; state.personal.licenseVerifiedCount = 0; state.personal.licenseMismatchCount = 0; state.personal.licenseCorrectedCount = 0; state.personal.activeTab = 'overview'; renderPersonalLibrary(); return; } const setBar = (pct, cls) => { barEl.className = 'sfd-family-progress-bar' + (cls ? ' ' + cls : ''); barEl.style.width = pct + '%'; }; progEl.classList.add('active'); setBar(10, ''); state.personal.gamesCache = null; state.personal.recentCache = null; state.personal.licenseHistoryCache = null; state.personal.delistedCache = null; state.personal.licenseMap = null; state.personal.coOwnedCount = 0; state.personal.licenseVerifiedCount = 0; state.personal.licenseMismatchCount = 0; state.personal.licenseCorrectedCount = 0; state.personal.activeTab = 'overview'; setBar(30, ''); try { await fetchMyOwnedGames(); setBar(60, ''); if (state.personal.gamesCache) { try { await fetchMyRecentGames(); } catch (e) {} } setBar(80, ''); try { await fetchMyLibraryHistory(); } catch (e) {} setBar(95, ''); try { await fetchMyDelisted(); } catch (e) {} setBar(100, 'done'); } catch (e) { setBar(100, 'err'); } setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 800); renderPersonalLibrary(); } // 渲染个人游戏库浮窗主体 function renderPersonalLibrary() { if (!state.personal.popupEl) return; // 更新数据时间显示 const updatedEl = document.getElementById('sfd-pl-updated'); if (updatedEl) { const now = Date.now(); updatedEl.textContent = (locale === 'zh-CN' ? '更新于 ' : 'Updated ') + new Date(now).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } const content = state.personal.popupEl.querySelector('#sfd-pl-content'); content.innerHTML = ''; const apiKey = storage.getApiKey(); if (!apiKey) { content.appendChild(h('div', { class: 'sfd-pl-empty', style: { textAlign: 'center', padding: '40px 20px' } }, [ h('div', { style: { fontSize: '32px', marginBottom: '8px' }, text: '🔑' }), h('div', { text: t('plNeedKey') }), h('a', { href: 'https://steamcommunity.com/dev/apikey', target: '_blank', style: { color: '#a78bfa', fontSize: '11px', marginTop: '6px', display: 'inline-block' }, text: 'steamcommunity.com/dev/apikey' }) ])); return; } content.replaceChildren(h('div', { class: 'sfd-pl-loading', html: `${ICONS.spinner}${t('plLoading')}` })); fetchMyOwnedGames().then(async data => { if (!data) { content.innerHTML = ''; content.appendChild(h('div', { class: 'sfd-pl-empty', style: { textAlign: 'center', padding: '40px 20px' } }, [ h('div', { style: { fontSize: '32px', marginBottom: '8px' }, text: '⚠️' }), h('div', { text: t('plFetchError') }) ])); return; } let recent2w = 0; try { const rg = await fetchMyRecentGames(); recent2w = Array.isArray(rg) ? rg.length : 0; } catch (e) {} const unplayedCount = data.games.filter(g => g.playtime_forever === 0).length; const avgHours = data.total > 0 ? (data.totalMinutes / 60 / data.total).toFixed(1) : '0'; const totalHours = (data.totalMinutes / 60).toFixed(1); const unplayedRate = data.total > 0 ? (unplayedCount / data.total * 100).toFixed(1) : '0'; const playedCount = data.total - unplayedCount; const zh = locale === 'zh-CN'; const allGames = (data.games || []).slice(); allGames.sort((a, b) => b.appid - a.appid); const unplayed = allGames.filter(g => g.playtime_forever === 0); const played = allGames.filter(g => g.playtime_forever > 0).sort((a, b) => b.playtime_forever - a.playtime_forever); state.personal._allGames = allGames; state.personal._played = played; state.personal._unplayed = unplayed; state.personal._totalHours = totalHours; const tabs = h('div', { class: 'sfd-pl-tabs', id: 'sfd-pl-tabs' }, [ h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'all' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'all'; renderPersonalLibraryTab(); } }, [h('span', { text: `${zh ? '全部游戏' : 'All'} (${data.total})` })]), h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'played' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'played'; renderPersonalLibraryTab(); } }, [h('span', { text: `${zh ? '已玩' : 'Played'} (${playedCount})` })]), h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'unplayed' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'unplayed'; renderPersonalLibraryTab(); } }, [h('span', { text: `${zh ? '未玩' : 'Unplayed'} (${unplayedCount})` })]), h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'recent' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'recent'; renderPersonalLibraryTab(); } }, [h('span', { text: t('plTabOverview') })]), h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'playtime' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'playtime'; renderPersonalLibraryTab(); } }, [h('span', { text: t('plTabPlaytime') })]), h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'activity' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'activity'; renderPersonalLibraryTab(); } }, [h('span', { text: t('plTabActivity') })]), h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'delisted' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'delisted'; renderPersonalLibraryTab(); } }, [h('span', { text: t('plTabDelisted') })]), ]); content.innerHTML = ''; content.appendChild(tabs); // 标签页内容容器 const tabContent = h('div', { class: 'sfd-pl-scroll', id: 'sfd-pl-tab-content' }); content.appendChild(tabContent); renderPersonalLibraryTab(); }); } function renderPersonalLibraryTab() { const container = document.getElementById('sfd-pl-tab-content'); if (!container) return; const tabOrder = ['all', 'played', 'unplayed', 'recent', 'playtime', 'activity', 'delisted']; const tabs = document.querySelectorAll('#sfd-pl-tabs .sfd-pl-tab'); tabs.forEach((tab, index) => { const key = tabOrder[index]; if (state.personal.activeTab === key) tab.classList.add('active'); else tab.classList.remove('active'); }); container.innerHTML = ''; container.style.cssText = ''; if (state.personal.activeTab === 'all') { renderPLAllGamesTab(container); } else if (state.personal.activeTab === 'played') { renderPLPlayedTab(container); } else if (state.personal.activeTab === 'unplayed') { renderPLUnplayedTab(container); } else if (state.personal.activeTab === 'recent') { renderPLRecentTab(container); } else if (state.personal.activeTab === 'playtime') { renderPLPlaytimeTab(container); } else if (state.personal.activeTab === 'activity') { renderPLActivityTab(container); } else if (state.personal.activeTab === 'delisted') { renderPLDelistedTab(container); } } // ===== 通用游戏网格渲染(复用好友对比窗口布局) ===== const PL_PAGE_SIZE = 25; const _plPageVars = { all: 1, played: 1, unplayed: 1 }; function buildPLGameGrid(container, games, key) { const pageVar = _plPageVars[key]; const zh = locale === 'zh-CN'; const totalPages = Math.ceil(games.length / PL_PAGE_SIZE); if (pageVar > totalPages) _plPageVars[key] = totalPages || 1; const start = (_plPageVars[key] - 1) * PL_PAGE_SIZE; const pageItems = games.slice(start, start + PL_PAGE_SIZE); const listScroll = h('div', { class: 'sfd-family-scroll', style: { overflow: 'hidden', display: 'flex', flexDirection: 'column' } }); const headerArea = h('div', { style: { flexShrink: '0' } }); const scrollArea = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } }); const pagiArea = h('div', { style: { flexShrink: '0', display: 'flex', justifyContent: 'flex-end' } }); listScroll.appendChild(headerArea); listScroll.appendChild(scrollArea); listScroll.appendChild(pagiArea); // 游戏网格 const grid = h('div', { class: 'sfd-pl-recent-grid' }); if (games.length === 0) { grid.appendChild(h('div', { class: 'sfd-pl-empty', text: zh ? '无游戏' : 'No games' })); } else { pageItems.forEach((game) => { const isPlayed = game.playtime_forever > 0; const grey = isPlayed ? '' : 'opacity:0.5;filter:grayscale(60%);'; const nameEl = h('a', { class: 'sfd-pl-recent-name', href: 'https://store.steampowered.com/app/' + game.appid, target: '_blank', text: game.name, style: grey }); loadGameZhName(nameEl, game.appid, game.name); const cardWrapper = h('div', { class: 'sfd-pl-recent-card', style: 'position:relative' }); const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: function() { openStorePage(game.appid); }, style: grey }); const { wrapper } = wrapImgWithLoader(capImg, game.appid, 'sfd-img-cap'); dashLoadCapsule(capImg, game.appid); cardWrapper.appendChild(wrapper); if (isPlayed && game.playtime_forever > 0) { cardWrapper.appendChild(h('div', { style: { position: 'absolute', top: '4px', left: '4px', background: 'rgba(0,0,0,0.75)', color: '#34d399', fontSize: '10px', fontWeight: '700', padding: '1px 6px', borderRadius: '4px', pointerEvents: 'none' }, text: (game.playtime_forever / 60).toFixed(1) + 'h' })); } cardWrapper.appendChild(h('div', { class: 'sfd-pl-recent-info' }, [nameEl])); grid.appendChild(cardWrapper); }); } scrollArea.appendChild(grid); // 分页 if (totalPages > 1) { const pagi = createPagination(_plPageVars[key], totalPages, function(newPage) { _plPageVars[key] = newPage; container.innerHTML = ''; buildPLGameGrid(container, games, key); }); pagi.classList.add('sfd-compare-pagi'); pagi.style.borderTop = '1px solid var(--sfd-border)'; pagi.style.flexShrink = '0'; pagiArea.appendChild(pagi); } container.appendChild(listScroll); } // ===== 全部游戏标签页 ===== function renderPLAllGamesTab(container) { _plPageVars.all = 1; buildPLGameGrid(container, state.personal._allGames || [], 'all'); } // ===== 已玩标签页 ===== function renderPLPlayedTab(container) { _plPageVars.played = 1; buildPLGameGrid(container, state.personal._played || [], 'played'); } // ===== 未玩标签页 ===== function renderPLUnplayedTab(container) { _plPageVars.unplayed = 1; buildPLGameGrid(container, state.personal._unplayed || [], 'unplayed'); } // ===== 最近游玩标签页 ===== function renderPLRecentTab(container) { const data = state.personal.gamesCache; if (!data) { container.replaceChildren(h('div', { class: 'sfd-pl-empty', text: t('plNoData') })); return; } const recentSection = h('div', { class: 'sfd-pl-section' }, [ h('div', { class: 'sfd-pl-section-title', html: ICONS.game + ' ' + t('plRecentGames') }) ]); container.appendChild(recentSection); const renderRecent = function(recentGames) { recentSection.innerHTML = ''; recentSection.appendChild(h('div', { class: 'sfd-pl-section-title', html: ICONS.game + ' ' + t('plRecentGames') })); if (!recentGames || recentGames.length === 0) { recentSection.appendChild(h('div', { class: 'sfd-pl-empty', text: t('plNoRecent') })); } else { const grid = h('div', { class: 'sfd-pl-recent-grid' }); const zh = locale === 'zh-CN'; const total2w = recentGames.reduce(function(s, g) { return s + (g.playtime_2weeks || 0); }, 0); recentGames.forEach(function(game) { grid.appendChild(buildRecentGameCard(game, total2w, zh)); }); recentSection.appendChild(grid); } }; if (state.personal.recentCache) { renderRecent(state.personal.recentCache); } else { recentSection.appendChild(createLoadingEl(t('plLoading'), '#a78bfa', 'sfd-pl-loading', { padding: '20px' })); fetchMyRecentGames().then(renderRecent); } } // ===== 游玩时长标签页 ===== function renderPLPlaytimeTab(container) { const data = state.personal.gamesCache; if (!data) { container.replaceChildren(h('div', { class: 'sfd-pl-empty', text: t('plNoData') })); return; } const zh = locale === 'zh-CN'; const totalH = state.personal._totalHours || '0'; const games = data.games || []; const maxGame = games.reduce((m, g) => g.playtime_forever > (m.playtime_forever || 0) ? g : m, {}); const maxH = maxGame.playtime_forever ? (maxGame.playtime_forever / 60).toFixed(1) : '0'; const recent2w = state.personal.recentCache ? state.personal.recentCache.reduce((s, g) => s + (g.playtime_2weeks || 0), 0) : 0; const recent2wH = (recent2w / 60).toFixed(1); const playedCount = games.filter(g => g.playtime_forever > 0).length; const medianH = games.length > 0 ? (function() { const p = [...games].sort((a,b) => a.playtime_forever - b.playtime_forever); const mid = Math.floor(p.length / 2); return p.length % 2 ? (p[mid].playtime_forever / 60).toFixed(1) : ((p[mid-1].playtime_forever + p[mid].playtime_forever) / 120).toFixed(1); })() : '0'; const playRate = data.total > 0 ? (playedCount / data.total * 100).toFixed(1) : '0'; const kpiRow = h('div', { style: { display: 'flex', gap: '8px', marginBottom: '12px' } }, [ createMetricCard({ value: totalH, label: zh ? '总时长(h)' : 'Total(h)', accent: '#34d399' }), createMetricCard({ value: recent2wH, label: zh ? '近2周(h)' : '2 Weeks(h)', accent: '#60a5fa' }), createMetricCard({ value: maxH, label: `${zh ? '最长单游' : 'Longest'} ${maxGame.name ? '(' + maxGame.name.slice(0,6) + ')' : ''}`, accent: '#f59e0b' }), createMetricCard({ value: (data.total > 0 ? (data.totalMinutes / 60 / data.total).toFixed(1) : '0'), label: zh ? '平均时长(h)' : 'Avg(h)', accent: '#a78bfa' }), createMetricCard({ value: medianH, label: zh ? '中位数(h)' : 'Median(h)', accent: '#f87171' }), createMetricCard({ value: playRate + '%', label: `${zh ? '游玩率' : 'Play Rate'} (${playedCount}/${data.total})`, accent: '#c084fc' }), ]); container.appendChild(kpiRow); const distData = calcPlaytimeDist(data.games); const distSection = h('div', { class: 'sfd-pl-section' }, [ h('div', { class: 'sfd-pl-section-title', html: `${ICONS.barChart} ${t('plPlaytimeDist')}` }) ]); distSection.appendChild(renderPlaytimeBar(distData, data.total)); container.appendChild(distSection); const sorted = [...data.games].sort((a, b) => b.playtime_forever - a.playtime_forever); const top20 = sorted.slice(0, 20); const maxPlaytime = top20.length > 0 ? top20[0].playtime_forever : 1; const topSection = h('div', { class: 'sfd-pl-section' }, [ h('div', { class: 'sfd-pl-section-title', html: `${ICONS.trending} ${t('plTopGames')}` }) ]); if (top20.length === 0) { topSection.appendChild(h('div', { class: 'sfd-pl-empty', text: t('plNoData') })); } else { const grid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: '5px' } }); top20.forEach((game, i) => { const rankClass = i === 0 ? 'r1' : i === 1 ? 'r2' : i === 2 ? 'r3' : 'rN'; const pct = maxPlaytime > 0 ? (game.playtime_forever / maxPlaytime * 100).toFixed(1) : '0'; const playtimeH = game.playtime_forever > 0 ? (game.playtime_forever / 60).toFixed(1) + 'h' : t('plRange0'); grid.appendChild(h('div', { class: 'sfd-pl-game-item' }, [ h('div', { class: `sfd-pl-game-rank ${rankClass}`, text: String(i + 1) }), (() => { const { wrapper } = createImgWrapper(game.appid, 'sfd-img-sm'); const img = wrapper.querySelector('img'); img.className = 'sfd-pl-game-icon'; const fallback = `https://cdn.cloudflare.steamstatic.com/steam/apps/${game.appid}/capsule_sm_120.jpg`; loadGameImage(wrapper, game.appid, getGameIconUrl(game.appid, game.img_icon_url), fallback, '', true); return wrapper; })(), h('div', { class: 'sfd-pl-game-info' }, [ h('a', { class: 'sfd-pl-game-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }), h('div', { class: 'sfd-pl-game-meta' }, [ h('span', { class: 'sfd-pl-game-playtime', text: playtimeH }), game.rtime_last_played > 0 ? h('span', { class: 'sfd-pl-game-time', text: `· ${DateUtils.lastPlayed(game.rtime_last_played)}` }) : null ]) ]), h('div', { class: 'sfd-pl-game-bar-wrap' }, [ h('div', { class: 'sfd-pl-game-bar-fill', style: { width: pct + '%' } }) ]) ])); }); topSection.appendChild(grid); } container.appendChild(topSection); } // ===== 入库动态标签页 ===== // v1.2.1: 新增家庭共享影响检测——标记共拥游戏,交叉验证个人许可页日期 function renderPLActivityTab(container) { const isZh = locale === 'zh-CN'; const PAGE_SIZE = 30; const timeOfDay = (ms) => { if (!ms) return ''; const d = new Date(ms); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`; }; const fmtLicenseDate = (ts) => { if (!ts) return ''; const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }; const renderContent = (allItems) => { container.innerHTML = ''; container.style.overflow = 'hidden'; container.style.display = 'flex'; container.style.flexDirection = 'column'; const coOwnedCount = allItems.filter(it => it.coOwned).length; // ===== 顶部工具栏:摘要 + 筛选 + 搜索 ===== const topBar = h('div', { style: { display: 'flex', gap: '8px', marginBottom: '10px', flexShrink: '0', alignItems: 'center' } }); container.appendChild(topBar); const summarySlot = h('div', { style: { flexShrink: '0' } }); topBar.appendChild(summarySlot); // 筛选按钮组 if (coOwnedCount > 0) { const soloCount = allItems.length - coOwnedCount; const filterBar = h('div', { class: 'sfd-pl-filter-bar' }); const filters = [ { key: 'all', label: t('plHistoryFilterAll') }, { key: 'coOwned', label: `${t('plHistoryFilterCoOwned')} (${coOwnedCount})` }, { key: 'solo', label: `${t('plHistoryFilterSolo')} (${soloCount})` } ]; filters.forEach(f => { const btn = h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', style: { padding: '3px 8px', fontSize: '10px', whiteSpace: 'nowrap', ...(state.personal.activityFilter === f.key ? { background: 'rgba(245,158,11,0.2)', borderColor: 'rgba(245,158,11,0.4)', color: '#fbbf24' } : {}) }, text: f.label, onClick: () => { state.personal.activityFilter = f.key; state.personal.activityPage = 1; filterBar.querySelectorAll('button').forEach(b => { b.style.background = ''; b.style.borderColor = ''; b.style.color = ''; }); btn.style.background = 'rgba(245,158,11,0.2)'; btn.style.borderColor = 'rgba(245,158,11,0.4)'; btn.style.color = '#fbbf24'; renderList(); } }); filterBar.appendChild(btn); }); topBar.appendChild(filterBar); } const searchInput = h('input', { type: 'text', class: 'sfd-input', placeholder: isZh ? '搜索游戏名称...' : 'Search game name...', value: state.personal.activitySearch, style: { flex: '1', boxSizing: 'border-box', minWidth: '0' }, onInput: (e) => { state.personal.activitySearch = e.target.value; state.personal.activityPage = 1; renderList(); } }); topBar.appendChild(searchInput); const scrollArea = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } }); container.appendChild(scrollArea); const paginationWrap = h('div', { style: { flexShrink: '0', display: 'flex', justifyContent: 'flex-end' } }); container.appendChild(paginationWrap); const renderList = () => { summarySlot.innerHTML = ''; scrollArea.innerHTML = ''; paginationWrap.innerHTML = ''; const q = (state.personal.activitySearch || '').trim().toLowerCase(); let items = (allItems || []).filter(it => !q || (it.name || '').toLowerCase().includes(q)); // 应用筛选 if (state.personal.activityFilter === 'coOwned') items = items.filter(it => it.coOwned); else if (state.personal.activityFilter === 'solo') items = items.filter(it => !it.coOwned); const summaryEl = h('div', { style: { background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.2)', borderRadius: '8px', padding: '6px 12px', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '6px' } }, [ h('span', { style: { fontSize: '18px', fontWeight: '700', color: '#a78bfa' }, text: String(items.length) }), h('span', { style: { fontSize: '10px', color: '#8a9ba8' }, text: q ? `/ ${allItems.length}` : t('plHistoryTotal') }) ]); summarySlot.appendChild(summaryEl); if (items.length === 0) { scrollArea.appendChild(h('div', { class: 'sfd-pl-empty', text: t('plHistoryEmpty') })); return; } const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE)); if (state.personal.activityPage > totalPages) state.personal.activityPage = totalPages; if (state.personal.activityPage < 1) state.personal.activityPage = 1; const start = (state.personal.activityPage - 1) * PAGE_SIZE; const pageItems = items.slice(start, start + PAGE_SIZE); const list = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px' } }); let curDate = null; pageItems.forEach(it => { const dateKey = it.dateStr || (isZh ? '未知日期' : 'Unknown'); if (dateKey !== curDate) { curDate = dateKey; list.appendChild(h('div', { class: 'sfd-pl-timeline-divider', style: { gridColumn: '1 / -1' } }, [ h('div', { class: 'sfd-pl-timeline-line' }), h('span', { class: 'sfd-pl-timeline-date', text: dateKey }), h('div', { class: 'sfd-pl-timeline-line' }) ])); } // 构建角标 const infoBadges = []; let posterBadge = null; const posterBadgeStyle = { position: 'absolute', top: '4px', left: '4px', zIndex: '5', textShadow: '0 1px 3px rgba(0,0,0,0.8)', pointerEvents: 'none' }; if (it.coOwned) { posterBadge = h('span', { class: 'sfd-pl-coowned-badge', title: t('plHistoryCoOwnedTip'), style: posterBadgeStyle, text: `👥 ${it.ownerCount}${isZh ? '人共拥' : ''}` }); if (it.dateMismatch) { infoBadges.push(h('span', { class: 'sfd-pl-mismatch-badge', text: `⚠ ${t('plHistoryDateMismatch')}` })); } } else { posterBadge = h('span', { class: 'sfd-pl-solo-badge', style: posterBadgeStyle, text: `✓ ${t('plHistorySolo')}` }); } // 构建元信息行:显示修正后的入库时间(个人许可页时间) const metaChildren = [ h('span', { style: { color: '#94a3b8' }, text: timeOfDay(it.ts) }) ]; // 共拥游戏:显示家庭组原始时间(被刷新的)作为参考 if (it.coOwned && it.familyTs) { metaChildren.push(h('span', { style: { color: '#ef4444', fontSize: '9px', fontWeight: '600', textDecoration: 'line-through' }, title: isZh ? '家庭组API返回的时间(已被修正)' : 'Family API time (corrected)', text: `⊘ ${fmtLicenseDate(it.familyTs)}` })); metaChildren.push(h('span', { style: { color: '#34d399', fontSize: '9px', fontWeight: '600' }, text: `→ ${t('plHistoryLicenseDate')}: ${fmtLicenseDate(it.ts)}` })); } else if (it.coOwned && it.licenseTs) { metaChildren.push(h('span', { style: { color: it.dateMismatch ? '#ef4444' : '#34d399', fontSize: '9px', fontWeight: '600' }, text: `${t('plHistoryLicenseDate')}: ${fmtLicenseDate(it.licenseTs)}` })); } else { metaChildren.push(h('span', { style: { color: '#34d399', fontWeight: '700' }, text: '✓' })); } list.appendChild(h('div', { class: 'sfd-pl-recent-card', style: { position: 'relative', ...(it.coOwned ? { borderColor: 'rgba(245,158,11,0.2)' } : {}) } }, [ (() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(it.appid) }); return dashLoadCapsule(capImg, it.appid); })(), posterBadge, h('div', { class: 'sfd-pl-recent-info' }, [ ...(infoBadges.length > 0 ? [h('div', { style: { display: 'flex', alignItems: 'center', gap: '4px', flexWrap: 'wrap' } }, infoBadges)] : []), (() => { const el = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${it.appid}`, target: '_blank', rel: 'noopener noreferrer', text: it.name, onClick: (e) => { e.preventDefault(); e.stopPropagation(); openStorePage(it.appid); } }); loadGameZhName(el, it.appid, it.name); return el; })(), h('div', { class: 'sfd-pl-recent-meta', style: { justifyContent: 'space-between' } }, metaChildren) ]) ])); }); scrollArea.appendChild(list); if (totalPages > 1) { const pagi = createPagination(state.personal.activityPage, totalPages, (p) => { state.personal.activityPage = p; renderList(); }); paginationWrap.appendChild(pagi); } }; renderList(); // ===== 异步交叉验证(仅当有共拥游戏且尚未验证时) ===== if (coOwnedCount > 0 && !state.personal.licenseMap && !state.personal.licenseMapLoading) { crossVerifyLicenses().then(() => { renderList(); }); } }; if (state.personal.licenseHistoryCache) { renderContent(state.personal.licenseHistoryCache); } else { container.appendChild(createLoadingEl(t('plHistoryLoading'), '#a78bfa', 'sfd-pl-loading', { padding: '30px' })); fetchMyLibraryHistory().then(renderContent); } } // ===== 绝版收藏标签页 ===== function renderPLDelistedTab(container) { const renderContent = (data) => { container.innerHTML = ''; container.style.overflow = 'hidden'; container.style.display = 'flex'; container.style.flexDirection = 'column'; const summaryEl = h('div', { style: { display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap', flexShrink: '0' } }, [ h('div', { style: { background: 'rgba(244,67,54,0.1)', border: '1px solid rgba(244,67,54,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [ h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#f44336' }, text: String(data.total) }), h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: t('plDelistedTotal') }) ]), h('div', { style: { background: 'rgba(16,185,129,0.1)', border: '1px solid rgba(16,185,129,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [ h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#34d399' }, text: String(data.owned.length) }), h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: t('plDelistedOwned') }) ]), h('div', { style: { background: 'rgba(148,163,184,0.1)', border: '1px solid rgba(148,163,184,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [ h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#94a3b8' }, text: String(data.total - data.owned.length) }), h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: t('plDelistedMissing') }) ]) ]); container.appendChild(summaryEl); if (data.owned.length === 0) { container.appendChild(h('div', { class: 'sfd-pl-empty', text: t('plDelistedEmpty') })); return; } const ownedTypes = new Set(data.owned.map(g => g.delistedType)); const filterBar = h('div', { style: { display: 'flex', gap: '6px', marginBottom: '10px', flexWrap: 'wrap', flexShrink: '0' } }); const allBtn = h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', style: { padding: '4px 10px', fontSize: '11px' }, text: t('plDelistedTypeAll'), onClick: () => { state.personal.delistedTypeFilter = 'all'; state.personal.delistedPage = 1; renderPLDelistedTab(container); } }); if (state.personal.delistedTypeFilter === 'all') allBtn.style.cssText += ';background:rgba(139,92,246,0.2);border-color:rgba(139,92,246,0.4);color:#a78bfa'; filterBar.appendChild(allBtn); ownedTypes.forEach(typeKey => { const typeLabel = getDelistedTypeLabel(typeKey); const count = data.owned.filter(g => g.delistedType === typeKey).length; if (count === 0) return; const btn = h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', style: { padding: '4px 10px', fontSize: '11px' }, text: `${typeLabel} (${count})`, onClick: () => { state.personal.delistedTypeFilter = typeKey; state.personal.delistedPage = 1; renderPLDelistedTab(container); } }); if (state.personal.delistedTypeFilter === typeKey) btn.style.cssText += ';background:rgba(139,92,246,0.2);border-color:rgba(139,92,246,0.4);color:#a78bfa'; filterBar.appendChild(btn); }); container.appendChild(filterBar); let filtered = data.owned; if (state.personal.delistedTypeFilter !== 'all') { filtered = filtered.filter(g => g.delistedType === state.personal.delistedTypeFilter); } const totalPages = Math.max(1, Math.ceil(filtered.length / state.personal.delistedPageSize)); if (state.personal.delistedPage > totalPages) state.personal.delistedPage = totalPages; const start = (state.personal.delistedPage - 1) * state.personal.delistedPageSize; const pageItems = filtered.slice(start, start + state.personal.delistedPageSize); const scrollArea = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } }); const list = h('div', { class: 'sfd-pl-recent-grid' }); pageItems.forEach(game => { const typeLabel = getDelistedTypeLabel(game.delistedType); const playtimeText = game.playtime_forever > 0 ? DateUtils.durationShort(game.playtime_forever) : t('plRange0'); list.appendChild(h('div', { class: 'sfd-pl-recent-card' }, [ (() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(), h('div', { class: 'sfd-pl-recent-info' }, [ (() => { const el = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(), game.inLibrary ? h('span', { style: { fontSize: '12px', flexShrink: '0' }, text: '✅️' }) : null, h('div', { class: 'sfd-pl-recent-meta' }, [ h('span', { class: `sfd-pl-delisted-badge type-${game.delistedType}`, text: typeLabel }), h('span', { text: playtimeText }), game.delistedDate ? h('span', { text: game.delistedDate }) : null ]) ]) ])); }); scrollArea.appendChild(list); container.appendChild(scrollArea); const paginationWrap = h('div', { style: { flexShrink: '0', display: 'flex', justifyContent: 'flex-end' } }); if (totalPages > 1) { const pagi = createPagination(state.personal.delistedPage, totalPages, (newPage) => { state.personal.delistedPage = newPage; renderPLDelistedTab(container); }); pagi.classList.add('sfd-pagination'); pagi.style.justifyContent = 'center'; pagi.style.padding = '8px 12px'; paginationWrap.appendChild(pagi); } container.appendChild(paginationWrap); }; if (state.personal.delistedCache) { renderContent(state.personal.delistedCache); } else { container.appendChild(createLoadingEl(t('plDelistedLoading'), '#a78bfa', 'sfd-pl-loading', { padding: '30px' })); fetchMyDelisted().then(renderContent); } } // ==================== 通用游戏列表弹窗 ==================== function showGameListPopup(id, titleHtml, games, options = {}) { const fi = state.family.info; if (!fi) return; const isZh = locale === 'zh-CN'; const { showOwnerTabs = false, emptyText = '' } = options; const existingOverlay = document.getElementById(id); if (existingOverlay) existingOverlay.remove(); const popup = createPopupContainer('1100px'); popup.id = id; popup.style.position = 'fixed'; popup.style.zIndex = '504'; popup.style.width = POPUP_W + 'px'; popup.style.height = POPUP_H + 'px'; popup.style.maxWidth = '96vw'; popup.style.maxHeight = '96vh'; const overlay = popup; const header = h('div', { class: 'sfd-family-header' }, [ h('h3', { html: titleHtml }), h('div', { class: 'sfd-family-header-actions' }, [ h('button', { class: 'sfd-header-btn sfd-header-btn-close', html: ICONS.close, onClick: () => overlay.remove() }) ]) ]); let activeOwnerFilter = 0; // 0 = 全部 let tabBtns = []; let tabsWrap = null; const headerActions = header.querySelector('.sfd-family-header-actions'); if (showOwnerTabs && games.length > 0) { // 统计各人数档位的游戏数量 const countMap = {}; games.forEach(g => { const n = Math.min(g.owners.length, 6); countMap[n] = (countMap[n] || 0) + 1; }); const maxOwners = Math.max(...games.map(g => g.owners.length), 1); tabsWrap = h('div', { class: 'sfd-family-tabs', style: { margin: '0', gap: '4px', flexWrap: 'nowrap' } }); const btnStyle = { flex: 'none', padding: '6px 14px', minWidth: '44px', textAlign: 'center' }; const allTabBtn = h('button', { class: 'sfd-family-tab active', style: btnStyle, onClick: () => { activeOwnerFilter = 0; tabBtns.forEach(b => b.classList.remove('active')); allTabBtn.classList.add('active'); renderGamesList(); } }, [h('span', { text: isZh ? '全部' : 'All' })]); tabBtns.push(allTabBtn); tabsWrap.appendChild(allTabBtn); for (let i = 1; i <= 6; i++) { const n = i; const hasGames = countMap[n] > 0; const btn = h('button', { class: 'sfd-family-tab', style: { ...btnStyle, opacity: hasGames ? '1' : '0.4', cursor: hasGames ? 'pointer' : 'default' }, disabled: !hasGames, onClick: hasGames ? () => { activeOwnerFilter = n; tabBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderGamesList(); } : null }, [h('span', { text: `${n}${isZh ? '人' : ''}` })]); tabBtns.push(btn); tabsWrap.appendChild(btn); } // 插入到关闭按钮前面 headerActions.insertBefore(tabsWrap, headerActions.firstChild); } const listWrap = h('div', { style: { overflow: 'auto', flex: '1', minHeight: '0', padding: '8px 12px' } }); const pagiEl = h('div', { style: { display: 'flex', justifyContent: 'center', padding: '4px 12px 8px', flexShrink: '0' } }); const GAME_PAGE_SIZE = 20; let gamePage = 1; function renderGamesList() { listWrap.innerHTML = ''; const filtered = activeOwnerFilter === 0 ? games : games.filter(g => g.owners.length === activeOwnerFilter); if (filtered.length === 0) { listWrap.appendChild(h('div', { class: 'sfd-family-empty', text: emptyText || (isZh ? '暂无游戏数据' : 'No game data') })); if (pagiEl) { pagiEl.innerHTML = ''; } return; } const totalPages = Math.ceil(filtered.length / GAME_PAGE_SIZE); if (gamePage > totalPages) gamePage = totalPages; const start = (gamePage - 1) * GAME_PAGE_SIZE; const pageItems = filtered.slice(start, start + GAME_PAGE_SIZE); const grid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px' } }); pageItems.forEach(game => { grid.appendChild(createFamilyGameCard(game, fi, CHART_COLORS)); }); listWrap.appendChild(grid); pagiEl.innerHTML = ''; const pagi = createPagination(gamePage, totalPages, (newPage) => { gamePage = newPage; renderGamesList(); }); pagiEl.appendChild(pagi); } renderGamesList(); popup.appendChild(header); popup.appendChild(listWrap); popup.appendChild(pagiEl); document.body.appendChild(overlay); const onEsc = (e) => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onEsc); } }; document.addEventListener('keydown', onEsc); } // ==================== 成员游戏列表弹窗 ==================== function showMemberGames(steamid, memberName) { const fi = state.family.info; const gl = state.family.gameList; if (!fi || !gl) return; const isZh = locale === 'zh-CN'; // 绝版模式下仅筛选绝版游戏 let delistedAppIds = null; if (state.family.chartShowDelisted) { const delistedApps = getDelistedApps(); delistedAppIds = new Set(delistedApps.map(app => Number(app.appid))); } // 筛选该成员拥有的游戏 const memberGames = []; for (let appid in gl.GameInfo) { if (delistedAppIds && !delistedAppIds.has(Number(appid))) continue; const info = gl.GameInfo[appid]; if (info.owners && info.owners.includes(steamid)) { memberGames.push({ appid: Number(appid), name: info.name, time: info.time, owners: info.owners, icon_hash: info.icon_hash }); } } memberGames.sort((a, b) => (b.time || 0) - (a.time || 0)); const titleHtml = `${ICONS.family} ${memberName}${isZh ? '的' : "'s "}${state.family.chartShowDelisted ? (isZh ? '绝版游戏' : 'Delisted Games') : (isZh ? '游戏' : 'Games')} (${memberGames.length})`; showGameListPopup('sfd-member-games-popup', titleHtml, memberGames, { showOwnerTabs: true }); } // ==================== 月度入库游戏弹窗 ==================== function showMonthGames(month) { const fi = state.family.info; const gl = state.family.gameList; if (!fi || !gl || !gl.GameInfo) return; const isZh = locale === 'zh-CN'; // 筛选该月入库的游戏 const [yearStr, monStr] = month.split('-'); const year = Number(yearStr); const mon = Number(monStr); const monthGames = []; for (let appid in gl.GameInfo) { const info = gl.GameInfo[appid]; if (!info.time || info.time <= 0) continue; const d = new Date(info.time * 1000); if (d.getFullYear() === year && d.getMonth() + 1 === mon) { monthGames.push({ appid: Number(appid), name: info.name, time: info.time, owners: info.owners, icon_hash: info.icon_hash }); } } monthGames.sort((a, b) => (b.time || 0) - (a.time || 0)); const titleMonth = isZh ? `${year}年${mon}月` : `${month}`; const titleHtml = `${ICONS.calendar} ${titleMonth}${isZh ? '入库游戏' : ' Added Games'} (${monthGames.length})`; showGameListPopup('sfd-month-games-popup', titleHtml, monthGames, { emptyText: isZh ? '该月无入库游戏' : 'No games added this month' }); } // ==================== 90天我贡献的游戏弹窗 ==================== function showMy90Games() { const fi = state.family.info; const gl = state.family.gameList; if (!fi || !gl || !gl.GameInfo) return; const isZh = locale === 'zh-CN'; const mySteamId = (state.friends.ownProfile && state.friends.ownProfile.steamid) || state.family.popupSteamid; const cutoff90 = Date.now() / 1000 - 90 * SECONDS_PER_DAY; const myGames = []; for (let appid in gl.GameInfo) { const info = gl.GameInfo[appid]; if (info.time && info.time >= cutoff90 && info.owners && info.owners.includes(String(mySteamId))) { myGames.push({ appid: Number(appid), name: info.name, time: info.time, owners: info.owners, icon_hash: info.icon_hash }); } } myGames.sort((a, b) => (b.time || 0) - (a.time || 0)); const titleHtml = `${ICONS.trophy} ${isZh ? '90天我贡献的游戏' : 'My Games Added (90d)'} (${myGames.length})`; showGameListPopup('sfd-my90-games-popup', titleHtml, myGames, { emptyText: isZh ? '近90天无我贡献的游戏' : 'No games added by me in the last 90 days' }); } function renderFamilyDynamicTab() { const fi = state.family.info; const gl = state.family.gameList; if (!fi || !gl) return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无数据' : 'No data' }); const isZh = locale === 'zh-CN'; // 按日期分组(忽略无入库时间的天数),按时间倒序排列 const dateGroups = new Map(); const allGames = gl.GameList .map(appid => { const info = gl.GameInfo[appid]; if (!info || !info.time) return null; return { appid: Number(appid), name: info.name, time: info.time, owners: info.owners, icon_hash: info.icon_hash }; }) .filter(g => g !== null) .sort((a, b) => (b.time || 0) - (a.time || 0)); allGames.forEach(game => { const dateKey = new Date(game.time * 1000).toLocaleDateString(isZh ? 'zh-CN' : 'en-US', { year: 'numeric', month: '2-digit', day: '2-digit' }); if (!dateGroups.has(dateKey)) dateGroups.set(dateKey, []); dateGroups.get(dateKey).push(game); }); const dates = [...dateGroups.keys()]; // 已按游戏时间倒序排列 const DAYS_PER_PAGE = state.family.dynamicPageSize; // 5天 const totalPages = Math.max(1, Math.ceil(dates.length / DAYS_PER_PAGE)); let page = state.family.dynamicPage; if (page > totalPages) page = totalPages; if (page < 1) page = 1; state.family.dynamicPage = page; const start = (page - 1) * DAYS_PER_PAGE; const pageDates = dates.slice(start, start + DAYS_PER_PAGE); const listWrap = h('div', { class: 'sfd-family-game-list', style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px', padding: '8px' } }); pageDates.forEach(dateKey => { const dayGames = dateGroups.get(dateKey) || []; listWrap.appendChild(h('div', { class: 'sfd-pl-timeline-divider', style: { gridColumn: '1 / -1' } }, [ h('div', { class: 'sfd-pl-timeline-line' }), h('span', { class: 'sfd-pl-timeline-date', text: dateKey }), h('div', { class: 'sfd-pl-timeline-line' }) ])); dayGames.forEach(game => { listWrap.appendChild(createFamilyGameCard(game, fi, CHART_COLORS)); }); }); const nav = createPagination(page, totalPages, (newPage) => { state.family.dynamicPage = newPage; renderFamilyPopup(); }); nav.classList.add('sfd-family-page-nav'); // 滚动容器与 grid 分离:scrollWrap 负责滚动,listWrap 负责网格布局,避免 grid 被 flex 挤压 const scrollWrap = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } }); scrollWrap.appendChild(listWrap); const container = h('div', { style: { display: 'flex', flexDirection: 'column', flex: '1', minHeight: '0', overflow: 'hidden' } }); container.appendChild(scrollWrap); container.appendChild(nav); return container; } // 公共:构建"最近游玩"卡片(含进度条) function buildRecentGameCard(game, total2w, isZh) { const recentH = game.playtime_2weeks > 0 ? DateUtils.durationShort(game.playtime_2weeks) : '-'; const totalH = game.playtime_forever > 0 ? DateUtils.durationShort(game.playtime_forever) : '-'; const pct = total2w > 0 ? Math.round((game.playtime_2weeks || 0) / total2w * 100) : 0; const nameEl = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(nameEl, game.appid, game.name); return h('div', { class: 'sfd-pl-recent-card' }, [ (() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => window.open(`https://store.steampowered.com/app/${game.appid}`, '_blank') }); return dashLoadCapsule(capImg, game.appid); })(), h('div', { class: 'sfd-pl-recent-info' }, [ nameEl, h('div', { class: 'sfd-pl-recent-meta' }, [ h('span', { text: `${isZh ? '近2周' : '2wks'}: ${recentH}` }), h('span', { text: `${isZh ? '总计' : 'Total'}: ${totalH}` }), h('span', { class: 'sfd-pl-recent-pct', text: `${isZh ? '本周占比' : 'Weekly Share'}: ${pct}%` }) ]), h('div', { class: 'sfd-pl-recent-bar' }, [h('div', { class: 'sfd-pl-recent-fill', style: { width: pct + '%' } })]) ]) ]); } // ==================== 家庭组许可增长曲线 ==================== function renderFamilyGrowthTab() { const fi = state.family.info; const gl = state.family.gameList; if (!gl || !gl.GameInfo) return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无数据' : 'No data' }); const isZh = locale === 'zh-CN'; const members = fi ? fi.family_member : []; const colors = CHART_COLORS; const games = Object.entries(gl.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); if (games.length === 0) return h('div', { class: 'sfd-family-empty', text: isZh ? '无时间数据' : 'No time data' }); const nowSec = Date.now() / 1000; // 按月聚合 const monthlyMap = new Map(); games.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 sorted = [...monthlyMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); // 累计数据 const cumAll = []; const cumMembers = {}; members.forEach(m => { cumMembers[m.steamid] = []; }); let totalAll = 0; const totalMembers = {}; members.forEach(m => { totalMembers[m.steamid] = 0; }); sorted.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 defs = members.map((m, mi) => { const c = colors[mi % colors.length]; return ``; }).join('') + ``; // SVG 参数 const svgW = 680, svgH = 420, pL = 48, pR = 16, pT = 8, pB = 32; const cW = svgW - pL - pR, cH = svgH - pT - pB; const step = cW / (sorted.length - 1 || 1); // 网格线(暗科技风) let grid = ''; for (let i = 0; i <= 5; i++) { const y = pT + cH - (cH * i / 5); const v = Math.round(maxVal / 5 * i); grid += `${v}`; } // 总计虚线 const totalPoints = cumAll.map((val, i) => `${pL + i * step},${pT + cH - (val / maxVal) * cH}`).join(' '); // 总计:发光底层 + 虚线顶层 let lines = ``; // 每人曲线 + 渐变填充区域 let areas = ''; members.forEach((m, mi) => { const c = colors[mi % colors.length]; const pts = cumMembers[m.steamid].map((val, i) => `${pL + i * step},${pT + cH - (val / maxVal) * cH}`).join(' '); lines += ``; // 填充区域 const areaBottom = `${pL + (sorted.length - 1) * step},${pT + cH}`; const areaPts = cumMembers[m.steamid].map((val, i) => `${pL + i * step},${pT + cH - (val / maxVal) * cH}`).join(' '); areas += ``; }); // X 轴年份标签 let xLabels = '', lastYear = ''; sorted.forEach(([m], i) => { const year = m.split('-')[0]; if (year !== lastYear) { xLabels += `${year}`; lastYear = year; } }); // 坐标轴线 const axisLines = ``; const svgStr = `${defs}${grid}${areas}${lines}${axisLines}${xLabels}`; // 左侧:曲线卡片 const chartCard = h('div', { class: 'sfd-family-stat-card', style: { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column', padding: '12px' } }); chartCard.insertAdjacentHTML('beforeend', `
${isZh ? '许可累计增长曲线' : 'License Growth Curve'}
`); chartCard.insertAdjacentHTML('beforeend', svgStr); // 右侧:90天贡献成员对比 + 图例 const cutoff90 = nowSec - 90 * SECONDS_PER_DAY; const memberRecent = {}; members.forEach(m => { memberRecent[m.steamid] = 0; }); for (let key in gl.GameInfo) { const game = gl.GameInfo[key]; if (game.time && game.time >= cutoff90) { (game.owners || []).forEach(sid => { if (memberRecent[sid] !== undefined) memberRecent[sid]++; }); } } const maxRecent = Math.max(1, ...Object.values(memberRecent)); const avatarMap = buildAvatarMap(); const allMembers = fillPendingSlots(members); const rightCard = h('div', { class: 'sfd-family-stat-card', style: { display: 'flex', flexDirection: 'column', padding: '12px' } }); rightCard.insertAdjacentHTML('beforeend', `
${isZh ? '90天贡献成员对比' : '90-Day Contribution'}
`); // 横向条形图列表 const rowsWrap = h('div', { style: { flex: '1', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '10px', paddingRight: '4px', minHeight: '0' } }); allMembers.forEach((m, i) => { const count = memberRecent[m.steamid] || 0; const pct = m.pending ? 0 : Math.max(3, Math.round(count / maxRecent * 100)); const color = m.pending ? '#64748b' : colors[i % colors.length]; const sidStr = String(m.steamid); const avatar = !m.pending && avatarMap[sidStr] ? avatarMap[sidStr] : null; rowsWrap.appendChild( h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', opacity: m.pending ? '0.5' : '1' } }, [ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px' } }, [ avatar ? h('img', { src: avatar, style: { width: '24px', height: '24px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${color}`, flexShrink: '0' }, loading: 'lazy' }) : h('div', { style: { width: '24px', height: '24px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: `2px dashed ${color}`, flexShrink: '0' }, html: ICONS.userPlaceholder }), h('span', { style: { color: '#e2e8f0', fontWeight: '600', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: '1', minWidth: '0' }, text: m.pending ? (isZh ? '待加入' : 'Empty') : m.userName }), h('span', { style: { color: color, fontWeight: '700', flexShrink: '0', fontVariantNumeric: 'tabular-nums' }, text: String(count) }) ]), h('div', { style: { height: '6px', borderRadius: '3px', background: 'rgba(255,255,255,0.04)', overflow: 'hidden' } }, [ h('div', { style: { height: '100%', borderRadius: '3px', background: `linear-gradient(90deg,${color},${color}88)`, width: pct + '%', boxShadow: `0 0 6px ${color}44` } }) ]) ]) ); }); rightCard.appendChild(rowsWrap); // 底部图例(和贡献分布图例风格一致,横跨左侧卡片) const legendItems = [`${isZh ? '总计' : 'Total'}`]; members.forEach((m, mi) => { const c = colors[mi % colors.length]; legendItems.push(`${m.userName}`); }); chartCard.insertAdjacentHTML('beforeend', `
${legendItems.join('')}
`); // 右侧列容器:最新入库 + 90天贡献 const rightColumn = h('div', { style: { display: 'flex', flexDirection: 'column', gap: '14px', width: '300px', flexShrink: '0' } }); // 最新入库卡片(复用 createFamilyGameCard 入库动态样式) const latestGame = games.length > 0 ? games[games.length - 1] : null; if (latestGame) { rightColumn.appendChild(createFamilyGameCard(latestGame, fi, CHART_COLORS)); } rightColumn.appendChild(rightCard); const wrap = h('div', { style: { display: 'flex', gap: '14px', flex: '1', minHeight: '0' } }); wrap.appendChild(chartCard); wrap.appendChild(rightColumn); return wrap; } // ==================== 家庭组入库月度饼图 ==================== function renderFamilyHeatmapTab() { const fi = state.family.info; const gl = state.family.gameList; if (!gl || !gl.GameInfo) return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无数据' : 'No data' }); const isZh = locale === 'zh-CN'; const members = fi ? fi.family_member : []; const colors = CHART_COLORS; if (members.length === 0) return h('div', { class: 'sfd-family-empty', text: isZh ? '无成员数据' : 'No member data' }); // 成员颜色映射 const memberColor = {}; members.forEach((m, i) => { memberColor[m.steamid] = colors[i % colors.length]; }); // 按月聚合每个成员的入库数 const monthlyMap = new Map(); Object.entries(gl.GameInfo).forEach(([appid, info]) => { if (!info.time || info.time <= 0) return; const d = new Date(info.time * 1000); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; if (!monthlyMap.has(key)) monthlyMap.set(key, {}); const entry = monthlyMap.get(key); (info.owners || []).forEach(sid => { entry[sid] = (entry[sid] || 0) + 1; }); }); const sortedMonths = [...monthlyMap.keys()].sort((a, b) => b.localeCompare(a)); if (sortedMonths.length === 0) return h('div', { class: 'sfd-family-empty', text: isZh ? '无时间数据' : 'No time data' }); // 计算峰值(单月单成员最高入库数) let maxCount = 1; sortedMonths.forEach(month => { const entry = monthlyMap.get(month); members.forEach(m => { const c = entry[m.steamid] || 0; if (c > maxCount) maxCount = c; }); }); // 图例(成员颜色) const legendItems = members.map((m, i) => { const c = colors[i % colors.length]; return `${esc(m.userName)}`; }).join(''); // 单月饼图 SVG 生成 function buildPieSvg(month, entry) { const slices = members .map(m => ({ name: m.userName, count: entry ? (entry[m.steamid] || 0) : 0, color: memberColor[m.steamid] })) .filter(s => s.count > 0); const total = slices.reduce((s, sl) => s + sl.count, 0); if (total === 0) return null; const r = 38, cx = 50, cy = 50; let paths = ''; if (slices.length === 1) { const sl = slices[0]; paths += `${esc(sl.name)}: ${sl.count} (100%)`; } else { let startAngle = -Math.PI / 2; slices.forEach(sl => { const angle = (sl.count / total) * Math.PI * 2; const endAngle = startAngle + angle; const x1 = cx + r * Math.cos(startAngle); const y1 = cy + r * Math.sin(startAngle); const x2 = cx + r * Math.cos(endAngle); const y2 = cy + r * Math.sin(endAngle); const largeArc = angle > Math.PI ? 1 : 0; const pct = Math.round(sl.count / total * 100); paths += `${esc(sl.name)}: ${sl.count} (${pct}%)`; startAngle = endAngle; }); } paths += `${total}`; return `${paths}`; } // 按年分组(年份倒序) const yearSet = [...new Set(sortedMonths.map(m => m.split('-')[0]))].sort((a, b) => b.localeCompare(a)); const yearGroupsHtml = yearSet.map(year => { let items = ''; for (let mon = 1; mon <= 12; mon++) { const monthStr = String(mon).padStart(2, '0'); const key = `${year}-${monthStr}`; const entry = monthlyMap.get(key); const svg = buildPieSvg(key, entry); const monthLabel = isZh ? `${mon}月` : monthStr; if (svg) { items += `
${svg}
${monthLabel}
`; } else { items += `
${monthLabel}
`; } } const yearTotal = sortedMonths.filter(m => m.startsWith(year + '-')).reduce((s, m) => { const e = monthlyMap.get(m); return s + members.reduce((ss, mm) => ss + (e[mm.steamid] || 0), 0); }, 0); return `
${year}${isZh ? '入库' : 'added'} ${yearTotal}
${items}
`; }).join(''); // 统计摘要 const totalGames = Object.keys(gl.GameInfo).length; const wrap = h('div', { class: 'sfd-heat-pie-wrap' }); wrap.insertAdjacentHTML('beforeend', `
${legendItems}
`); const scroll = h('div', { class: 'sfd-heat-pie-scroll' }); scroll.insertAdjacentHTML('beforeend', yearGroupsHtml); wrap.appendChild(scroll); wrap.insertAdjacentHTML('beforeend', `
${isZh ? '总计' : 'Total'}: ${totalGames}${isZh ? '月份数' : 'Months'}: ${sortedMonths.length}${isZh ? '峰值' : 'Peak'}: ${maxCount} ${isZh ? '款/月' : '/mo'}
`); // 饼图点击:展示该月入库游戏 wrap.addEventListener('click', (e) => { const item = e.target.closest('[data-month]'); if (item) { const month = item.getAttribute('data-month'); if (month) showMonthGames(month); } }); return wrap; } // ==================== 家庭组游玩动态渲染 ==================== function renderFamilyPlayActivityTab() { const fi = state.family.info; if (!fi || !fi.family_member || fi.family_member.length === 0) { return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无成员数据' : 'No member data' }); } const isZh = locale === 'zh-CN'; const container = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } }); if (state.family.playActivityLoading) { container.appendChild(h('div', { class: 'sfd-family-loading', html: `${ICONS.spinner}${t('familyPlayLoading')}` })); return container; } // 无数据时直接触发加载(不再显示按钮) if (!state.family.playActivity || Object.keys(state.family.playActivity).length === 0) { if (!state.family.playActivityLoading) { // 先尝试从缓存加载 const cached = storage.getFamilyPlayActivityCache(); if (cached && cached.steamid === state.family.popupSteamid && Date.now() - cached.timestamp < FAMILY_PLAY_TTL) { state.family.playActivity = cached.data; } else { loadFamilyPlayActivity(state.family.popupSteamid).then(() => renderFamilyPopup()); } } // 显示加载动画(如果仍在加载中或刚触发) if (state.family.playActivityLoading || !state.family.playActivity) { container.appendChild(h('div', { class: 'sfd-family-loading', html: `${ICONS.spinner}${t('familyPlayLoading')}` })); return container; } } // 遍历每个成员 const selfSteamId = String(state.family.popupSteamid); const vacCacheData = storage.getVACCache(); const levelCacheData = storage.getLevelCache(); // 统一头像映射(复用 buildAvatarMap) const memberAvatarMap = buildAvatarMap(); // 预计算各成员时长,用于进度条对比 // playtime_2weeks: 近2周时长(分钟), playtime_forever: 该游戏总时长(分钟) // 总时长 = 最近玩过的所有游戏 playtime_2weeks 之和(避免 forever 之和过大无意义) const playMembers = fillPendingSlots(fi.family_member); let max2weeks = 0, maxGames = 0; playMembers.forEach(member => { if (member.pending) return; const md = state.family.playActivity[member.steamid]; if (md && md.games) { const s2 = md.games.reduce((s, g) => s + (g.playtime_2weeks || 0), 0); if (s2 > max2weeks) max2weeks = s2; if (md.games.length > maxGames) maxGames = md.games.length; } }); playMembers.forEach((member, idx) => { const memberSid = String(member.steamid); const isSelf = memberSid === selfSteamId; const isPending = !!member.pending; const memberData = !isPending ? state.family.playActivity[member.steamid] : null; const hasGames = memberData && memberData.games && memberData.games.length > 0; // 该成员近2周总时长 + 游戏数 const mem2weeks = hasGames ? memberData.games.reduce((s, g) => s + (g.playtime_2weeks || 0), 0) : 0; const memGames = hasGames ? memberData.games.length : 0; const pct2 = max2weeks > 0 ? Math.round(mem2weeks / max2weeks * 100) : 0; const pctG = maxGames > 0 ? Math.round(memGames / maxGames * 100) : 0; // 占位成员:灰色头像 + 待加入文字 + 无进度条 if (isPending) { container.appendChild(h('div', { class: 'sfd-family-play-member collapsed', style: { opacity: '0.5' } }, [ h('div', { class: 'sfd-family-play-member-header', onClick: (e) => { e.currentTarget.parentElement.classList.toggle('collapsed'); } }, [ h('div', { style: { width: '32px', height: '32px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px dashed rgba(100,116,139,0.4)', flexShrink: '0' }, html: ICONS.userPlaceholder }), h('div', { class: 'sfd-family-play-member-info' }, [ h('div', { class: 'sfd-family-play-member-name-row' }, [ h('span', { class: 'sfd-family-play-member-name', style: { color: '#64748b', fontStyle: 'italic' }, text: isZh ? '待加入' : 'Empty' }) ]) ]) ]), h('div', { class: 'sfd-family-play-member-games-wrap' }, [ h('div', { class: 'sfd-family-empty', style: { padding: '8px 12px', fontSize: '12px', color: '#475569' }, text: isZh ? '等待新成员加入家庭组' : 'Waiting for new member to join' }) ]) ])); return; } // 头像与名称:复用 memberAvatarMap(与游戏拥有者头像同方法) let avatarSrc = memberAvatarMap[memberSid] || `https://avatars.steamstatic.com/fef49e7fa7e1997310dd48961da2e7d95a5c7a56_medium.jpg`; let memberName = (fi.steamIdtoName && fi.steamIdtoName[memberSid]) || member.userName || ('ID:' + memberSid.slice(-4)); let level = 0, vacBanned = false, gameBans = 0, friendDays = '', countryFlag = '', countryName = ''; // 补充 ownProfile / friendsData 的额外信息(等级、VAC、国家、好友天数) if (isSelf && state.friends.ownProfile) { const p = state.friends.ownProfile; memberName = p.personaname || memberName; level = p.level || 0; countryFlag = p.country_flag || ''; countryName = p.country_name || ''; } const fd = (!isSelf && state.friends.data) ? _friendsMap.get(memberSid) : null; if (fd) { memberName = fd.personaname || memberName; level = fd.level || 0; vacBanned = fd.vac_banned || false; gameBans = fd.vac_game_bans || 0; friendDays = fd.friend_days_text || ''; countryFlag = fd.country_flag || ''; countryName = fd.country_name || ''; } // 数据源3:VAC/Level 缓存补充(自己和好友都补充) const vacData = vacCacheData[memberSid] || vacCacheData[member.steamid]; if (vacData) { vacBanned = vacBanned || vacData.VACBanned || false; gameBans = gameBans || (vacData.NumberOfGameBans || 0); } // levelCacheData[sid] 存的是数字(等级值),不是对象 const lvlVal = levelCacheData[memberSid] || levelCacheData[member.steamid]; if (lvlVal && !level) level = lvlVal; // 兜底:家庭组基础数据 if (isSelf) friendDays = ''; else friendDays = friendDays || (fd ? fd.friend_days_text : ''); // 家庭组游戏库拥有者映射 const gl = state.family.gameList; const gameInfoMap = gl && gl.GameInfo ? gl.GameInfo : {}; const memberCard = h('div', { class: 'sfd-family-play-member collapsed' }, [ // 成员头部(增强版:头像+名称+标签+刷新)— 点击折叠/展开 h('div', { class: 'sfd-family-play-member-header', onClick: (e) => { e.currentTarget.parentElement.classList.toggle('collapsed'); } }, [ h('img', { class: 'sfd-family-play-member-avatar' + (vacBanned ? ' sfd-avatar-banned' : ''), src: avatarSrc, loading: 'lazy', onerror: `this.onerror=null;this.src='data:image/svg+xml,'` }), h('div', { class: 'sfd-family-play-member-info' }, [ h('div', { class: 'sfd-family-play-member-name-row' }, [ h('span', { class: 'sfd-family-play-member-name', text: memberName }), vacBanned ? h('span', { class: 'sfd-family-play-vac', title: 'VAC Banned', html: ICONS.shieldRed }) : gameBans > 0 ? h('span', { class: 'sfd-family-play-vac', title: `Dev Ban x${gameBans}`, html: ICONS.shieldDevBan }) : null, level > 0 ? h('span', { class: 'sfd-family-play-level', title: `Level ${level}`, html: `${ICONS.level} ${level}` }) : null, countryFlag ? h('span', { class: 'sfd-family-play-member-flag', title: countryName, html: countryFlag }) : null ]), friendDays ? h('div', { class: 'sfd-family-play-member-days', text: '🤝' + friendDays }) : null ]), // 进度条:最近2周 + 总时长对比 h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', flex: '1', maxWidth: '420px', minWidth: '200px' } }, [ h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [ h('span', { style: { fontSize: '9px', color: '#64748b', whiteSpace: 'nowrap', width: '42px', flexShrink: '0' }, text: isZh ? '近2周' : '2w' }), h('div', { style: { flex: '1', height: '6px', background: 'rgba(255,255,255,0.06)', borderRadius: '3px', overflow: 'hidden' } }, [ h('div', { style: { height: '100%', width: pct2 + '%', background: 'linear-gradient(90deg,#10b981,#34d399)', borderRadius: '3px', opacity: '0.7', transition: 'width 0.4s ease' } }) ]), h('span', { style: { fontSize: '10px', fontWeight: '600', color: '#34d399', whiteSpace: 'nowrap', width: '48px', textAlign: 'right', flexShrink: '0' }, text: DateUtils.duration(mem2weeks) }) ]), h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [ h('span', { style: { fontSize: '9px', color: '#64748b', whiteSpace: 'nowrap', width: '42px', flexShrink: '0' }, text: isZh ? '游戏数' : 'Games' }), h('div', { style: { flex: '1', height: '6px', background: 'rgba(255,255,255,0.06)', borderRadius: '3px', overflow: 'hidden' } }, [ h('div', { style: { height: '100%', width: pctG + '%', background: 'linear-gradient(90deg,#f59e0b,#fbbf24)', borderRadius: '3px', opacity: '0.7', transition: 'width 0.4s ease' } }) ]), h('span', { style: { fontSize: '10px', fontWeight: '600', color: '#fbbf24', whiteSpace: 'nowrap', width: '48px', textAlign: 'right', flexShrink: '0' }, text: String(memGames) }) ]) ]), createRefreshBtn((e) => { e.stopPropagation(); loadFamilyPlayActivity(state.family.popupSteamid, true).then(() => renderFamilyPopup()); }, t('familyPlayRefresh'), 'sfd-family-play-refresh') ]), // 游戏列表(可折叠容器,gameInfoMap 在外部定义) h('div', { class: 'sfd-family-play-member-games-wrap' }, [ hasGames ? h('div', { class: 'sfd-family-play-member-games' }, memberData.games.map(game => { // 优先 header.jpg(大图),回退 img_icon_url(社区域名),再回退 capsule const iconUrl = game.img_icon_url ? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/${game.img_icon_url}.jpg` : `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/capsule_sm_120.jpg`; // 从家庭组游戏库查找拥有者 const gi = gameInfoMap[game.appid] || gameInfoMap[String(game.appid)]; const owners = gi && gi.owners ? gi.owners : []; const ownerTitle = owners.map(sid => fi.steamIdtoName[sid] || fi.steamIdtoName[String(sid)] || 'ID:' + String(sid).slice(-4)).join(', '); const ownerAvatars = owners.slice(0, 5).map((sid, idx) => { const sidStr = String(sid); const av = memberAvatarMap[sidStr] || `https://avatars.steamstatic.com/${sidStr}.jpg`; const nm = fi.steamIdtoName[sid] || fi.steamIdtoName[sidStr] || 'ID:' + sidStr.slice(-4); return h('img', { src: av, style: { width: '18px', height: '18px', borderRadius: '50%', objectFit: 'cover', flexShrink: '0', border: '1px solid rgba(15,23,42,0.9)' }, loading: 'lazy', title: nm, onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" }); }); return h('div', { class: 'sfd-family-play-game-item' }, [ (() => { const img = h('img', { class: 'sfd-family-play-game-icon', loading: 'lazy' }); dashLoadCapsule(img, game.appid); return img; })(), h('div', { class: 'sfd-family-play-game-info' }, [ (() => { const el = h('a', { class: 'sfd-family-play-game-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(), h('div', { class: 'sfd-family-play-game-meta' }, [ h('span', { class: 'sfd-family-play-game-playtime', text: `${DateUtils.duration(game.playtime_2weeks)}` }), h('span', { class: 'sfd-family-play-game-playtime-total', text: `/ ${DateUtils.duration(game.playtime_forever)}h` }) ]), ownerAvatars.length > 0 ? h('div', { style: { display: 'flex', gap: '3px', marginTop: '2px' }, title: ownerTitle }, ownerAvatars) : null ]) ]); })) : h('div', { class: 'sfd-family-empty', style: { padding: '8px 12px', fontSize: '12px' }, text: isZh ? '近2周无游玩记录' : 'No play in last 2 weeks' }) ]) ]); container.appendChild(memberCard); }); // 如果所有成员都没有游玩数据 if (container.children.length === 0) { container.appendChild(h('div', { class: 'sfd-family-empty', text: t('familyPlayEmpty') })); } return container; } // ==================== 24小时入库热力图 ==================== function renderFamily24hHeatmap() { const fi = state.family.info; const gl = state.family.gameList; const isZh = locale === 'zh-CN'; if (!gl || !gl.GameInfo || !fi || !fi.family_member) return null; const members = fi.family_member; const idMap = fi.steamIdtoName || {}; const avatarMap = buildAvatarMap(); // 统计全家每小时入库数 const familyCounts = new Array(24).fill(0); // 统计每个成员每小时入库数 { sid: [24] } const memberCounts = {}; members.forEach(m => { memberCounts[m.steamid] = new Array(24).fill(0); }); let totalGames = 0; Object.entries(gl.GameInfo).forEach(([appid, info]) => { if (!info.time || info.time <= 0) return; const hour = new Date(info.time * 1000).getHours(); familyCounts[hour]++; totalGames++; (info.owners || []).forEach(sid => { if (memberCounts[sid]) memberCounts[sid][hour]++; }); }); if (totalGames === 0) return null; const maxCount = Math.max(...familyCounts, 1); const peakHour = familyCounts.indexOf(maxCount); // 全家行统一使用琥珀色(聚合总览,按全家自身峰值缩放) function heatColor(count) { if (count === 0) return 'rgba(255,255,255,0.03)'; const r = count / maxCount; if (r < 0.2) return 'rgba(245,158,11,0.15)'; if (r < 0.4) return 'rgba(245,158,11,0.3)'; if (r < 0.6) return 'rgba(245,158,11,0.5)'; if (r < 0.8) return 'rgba(245,158,11,0.75)'; return '#f59e0b'; } // 每个成员的固定色(使用全局 CHART_COLORS,与贡献分布等保持一致) const memberColorMap = {}; members.forEach((mm, i) => { memberColorMap[mm.steamid] = CHART_COLORS[i % CHART_COLORS.length]; }); // 每个成员的自身峰值(各行独立热力,互不影响) const memberMaxMap = {}; members.forEach(mm => { const arr = memberCounts[mm.steamid] || []; memberMaxMap[mm.steamid] = Math.max(...arr, 1); }); function hexToRgba(hex, a) { const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); return `rgba(${r},${g},${b},${a})`; } // count / rowMax,每行独立缩放 function memberHeatColor(count, hex, rowMax) { if (count === 0) return 'rgba(255,255,255,0.03)'; const r = count / rowMax; if (r < 0.2) return hexToRgba(hex, 0.18); if (r < 0.4) return hexToRgba(hex, 0.35); if (r < 0.6) return hexToRgba(hex, 0.55); if (r < 0.8) return hexToRgba(hex, 0.78); return hex; } // SVG 布局:左侧头像+昵称区 + 24 列方格 const cellSize = 26, cellGap = 4; const cols = 24; const labelW = 120; // 左侧头像+昵称区宽度 const hourLabelH = 18; // 顶部小时标签高度 const rowH = cellSize + cellGap; const gridW = cols * (cellSize + cellGap) - cellGap; const gridX = labelW + 8; // 网格起始 x(与左侧标签区留间隔) const nameMaxW = labelW - (cellSize + 12) - 6; // 昵称可用宽度 const nameFontSize = 11; // 按视觉宽度截断昵称(CJK 全宽,ASCII 半宽) function truncateName(str) { let w = 0; for (let i = 0; i < str.length; i++) { const ch = str.charCodeAt(i); const isCJK = (ch >= 0x4E00 && ch <= 0x9FFF) || (ch >= 0x3000 && ch <= 0x30FF) || (ch >= 0xFF00 && ch <= 0xFFEF); const cw = isCJK ? nameFontSize : nameFontSize * 0.55; if (w + cw > nameMaxW - nameFontSize) return str.slice(0, i) + '…'; w += cw; } return str; } const allMembers = fillPendingSlots(members); const totalRows = 1 + allMembers.length; // 全家 + 各成员 = 7 行 const svgW = gridX + gridW + 8; const svgH = hourLabelH + totalRows * rowH + 4; let svg = ''; // 顶部小时标签 0-23 for (let h = 0; h < 24; h++) { const x = gridX + h * (cellSize + cellGap); svg += `${h}`; } // 第 1 行:全家(不带名字,左侧留空) const familyRowY = hourLabelH; for (let h = 0; h < 24; h++) { const x = gridX + h * (cellSize + cellGap); const count = familyCounts[h]; const fill = heatColor(count); svg += `${isZh ? '全家' : 'All'} ${h}:00 — ${count} ${isZh ? '款' : 'games'}`; if (count > 0) { const tc = count / maxCount >= 0.4 ? '#fff' : '#64748b'; svg += ``; } } // 各成员行(左侧头像 + 昵称) allMembers.forEach((m, mi) => { const rowIdx = mi + 1; const rowY = hourLabelH + rowIdx * rowH; const counts = m.pending ? new Array(24).fill(0) : (memberCounts[m.steamid] || new Array(24).fill(0)); const name = familyNameOf(m, idMap); const av = m.pending ? null : (avatarMap[String(m.steamid)] || DEFAULT_AVATAR); const mHex = m.pending ? '#64748b' : (memberColorMap[m.steamid] || CHART_COLORS[mi % CHART_COLORS.length]); const rowMax = memberMaxMap[m.steamid] || 1; // 该成员自身峰值 // 左侧头像(用 foreignObject + img,兼容跨域头像;待加入用统一人形占位) const avSize = cellSize - 4; // 头像略小于方格 const iconSize = Math.round(avSize * 0.6); if (av) { svg += ``; } else { // 待加入:与加入时间/成员活跃度统一的人形占位头像 svg += `
${ICONS.userPlaceholder}
`; } // 昵称(用成员固定色,强化与方格色的关联;过长截断省略号) const nameFill = m.pending ? '#64748b' : mHex; const displayName = m.pending ? name : truncateName(name); svg += `${esc(displayName)}`; // 24 方格(按该成员自身峰值缩放) for (let h = 0; h < 24; h++) { const x = gridX + h * (cellSize + cellGap); const count = counts[h]; const fill = m.pending ? 'rgba(100,116,139,0.08)' : memberHeatColor(count, mHex, rowMax); svg += `${esc(name)} ${h}:00 — ${count} ${isZh ? '款' : 'games'}`; if (count > 0 && !m.pending) { const tc = count / rowMax >= 0.4 ? '#fff' : '#64748b'; svg += ``; } } }); const card = h('div', { class: 'sfd-heatmap-card' }); card.insertAdjacentHTML('beforeend', `
${ICONS.clock}${isZh ? '24小时入库热力图' : '24h Acquisition Heatmap'}${isZh ? '数字' : 'Nums'}
` + `
${isZh ? `第一行为全家合计(琥珀色),下方各成员使用专属固定色(峰值 ${peakHour}:00,${maxCount} 款)` : `Top row: family total (amber), below: per-member with own color (peak at ${peakHour}:00, ${maxCount} games)`}
` ); const wrap = h('div', { style: { overflowX: 'auto', minWidth: '0' } }); wrap.insertAdjacentHTML('beforeend', `${svg}` ); card.appendChild(wrap); // 数字显示开关(默认打开) const toggle = card.querySelector('.sfd-hm-toggle'); const track = card.querySelector('.sfd-hm-track'); const dot = card.querySelector('.sfd-hm-dot'); if (toggle) { let showNums = true; // 初始化:默认显示数字 card.querySelectorAll('.sfd-hm-num').forEach(el => { el.style.display = ''; }); if (track) { track.style.background = '#3b82f6'; } if (dot) { dot.style.left = '18px'; dot.style.background = '#fff'; } toggle.addEventListener('click', () => { showNums = !showNums; card.querySelectorAll('.sfd-hm-num').forEach(el => { el.style.display = showNums ? '' : 'none'; }); if (track) { track.style.background = showNums ? '#3b82f6' : '#334155'; } if (dot) { dot.style.left = showNums ? '18px' : '2px'; dot.style.background = showNums ? '#fff' : '#94a3b8'; } }); } // 热力图滚动:wrap 只有 overflowX:auto,垂直滚轮自然冒泡到父容器,无需拦截 card.insertAdjacentHTML('beforeend', `
` + `${isZh ? '总计' : 'Total'}: ${totalGames}` + `${isZh ? '峰值时段' : 'Peak'}: ${peakHour}:00` + `
` ); return card; } // ==================== 热力图(成员洞察卡片) ==================== function renderFamilyHeatmapGrid() { const fi = state.family.info; const gl = state.family.gameList; const isZh = locale === 'zh-CN'; const members = fi ? fi.family_member : []; const colors = CHART_COLORS; if (!gl || !gl.GameInfo || members.length === 0) return null; const heatMembers = fillPendingSlots(members); // 按月聚合每个成员的入库数 const monthlyMap = new Map(); Object.entries(gl.GameInfo).forEach(([appid, info]) => { if (!info.time || info.time <= 0) return; const d = new Date(info.time * 1000); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; if (!monthlyMap.has(key)) monthlyMap.set(key, {}); const entry = monthlyMap.get(key); (info.owners || []).forEach(sid => { entry[sid] = (entry[sid] || 0) + 1; }); }); const sortedMonths = [...monthlyMap.keys()].sort((a, b) => a.localeCompare(b)); if (sortedMonths.length === 0) return null; // 全局峰值(用于卡片副标题统计展示) let maxCount = 1; sortedMonths.forEach(month => { const entry = monthlyMap.get(month); members.forEach(m => { const c = entry[m.steamid] || 0; if (c > maxCount) maxCount = c; }); }); // 各成员自身峰值(各行独立热力,互不影响) const memberMaxMap = {}; members.forEach(m => { let mm = 1; sortedMonths.forEach(month => { const entry = monthlyMap.get(month); const c = entry ? (entry[m.steamid] || 0) : 0; if (c > mm) mm = c; }); memberMaxMap[m.steamid] = mm; }); const years = [...new Set(sortedMonths.map(m => m.split('-')[0]))].sort(); const cellSize = 20, cellGap = 3, monthLabelW = 28, yearLabelH = 18, memberLabelH = 22; const blockH = memberLabelH + yearLabelH + 12 * (cellSize + cellGap) + 6; const heatW = monthLabelW + years.length * (cellSize + cellGap) + 10; const memberSvgs = heatMembers.map((m, mi) => { const isPending = !!m.pending; const c = isPending ? '#64748b' : colors[mi % colors.length]; const rowMax = memberMaxMap[m.steamid] || 1; // 该成员自身峰值 let s = `${esc(m.userName)}`; years.forEach((year, yi) => { const x = monthLabelW + yi * (cellSize + cellGap); s += `${year.slice(-2)}`; }); for (let mon = 12; mon >= 1; mon--) { const monthStr = String(mon).padStart(2, '0'); const rowIdx = 12 - mon; const y = memberLabelH + yearLabelH + rowIdx * (cellSize + cellGap); s += `${mon}月`; years.forEach((year, yi) => { const key = `${year}-${monthStr}`; const entry = monthlyMap.get(key); const count = isPending ? 0 : (entry ? (entry[m.steamid] || 0) : 0); const x = monthLabelW + yi * (cellSize + cellGap); if (isPending) { s += ``; } else { const opacity = count > 0 ? Math.max(0.15, count / rowMax) : 0; const fill = count > 0 ? c : 'rgba(255,255,255,0.03)'; s += `${count > 0 ? `${count}` : ''}${esc(m.userName)}: ${count} ${isZh ? '款' : 'games'} (${key})`; } }); } return `${s}`; }); const legendItems = members.map((m, mi) => { const c = colors[mi % colors.length]; return `${esc(m.userName)}`; }).join(''); const totalGames = Object.keys(gl.GameInfo).length; const card = h('div', { class: 'sfd-heatmap-card' }); card.insertAdjacentHTML('beforeend', `
${ICONS.grid}${isZh ? '入库热力图' : 'Acquisition Heatmap'}
` + `
${isZh ? `按成员×月份展示入库数,各成员独立按自身峰值缩放(全局峰值 ${maxCount} 款/月)` : `Member×month grid, each scaled by own peak (global peak ${maxCount}/mo)`}
` ); const grid = h('div', { class: 'sfd-heatmap-grid' }); memberSvgs.forEach(svg => { const cell = h('div', { class: 'sfd-heatmap-cell' }); cell.insertAdjacentHTML('beforeend', svg); grid.appendChild(cell); }); card.appendChild(grid); card.insertAdjacentHTML('beforeend', `
${legendItems}
`); card.insertAdjacentHTML('beforeend', `
${isZh ? '总计' : 'Total'}: ${totalGames}${isZh ? '月份数' : 'Months'}: ${sortedMonths.length}${isZh ? '峰值' : 'Peak'}: ${maxCount} ${isZh ? '款/月' : '/mo'}
`); return card; } // ==================== 成员洞察 ==================== // 公共:成员名称获取(避免重复定义) function familyNameOf(m, idMap) { return m.userName || (idMap && idMap[m.steamid]) || ('ID:' + String(m.steamid).slice(-4)); } // 公共:补齐占位成员到 MAX_FAMILY(6人),不足的用"待加入"占位 const MAX_FAMILY_SLOTS = 6; function fillPendingSlots(members) { const result = [...members]; while (result.length < MAX_FAMILY_SLOTS) { result.push({ steamid: 'pending_' + result.length, userName: locale === 'zh-CN' ? '待加入' : 'Empty', pending: true }); } return result; } // 公共:构建 steamid -> avatar 映射(避免重复定义) function buildAvatarMap() { const map = {}; if (state.friends.ownProfile && state.friends.ownProfile.steamid) map[String(state.friends.ownProfile.steamid)] = state.friends.ownProfile.avatar || state.friends.ownProfile.avatarfull; if (state.friends.data) state.friends.data.forEach(f => { if (f.steamid) map[String(f.steamid)] = f.avatar; }); // 家庭成员头像(可能不在好友列表中) if (state.family.info && state.family.info.steamIdtoAvatar) { for (const sid in state.family.info.steamIdtoAvatar) { if (state.family.info.steamIdtoAvatar[sid] && !map[sid]) map[sid] = state.family.info.steamIdtoAvatar[sid]; } } return map; } // 计算成员活跃度 function computeMemberActivity(familyInfo, gameInfo) { const members = (familyInfo && Array.isArray(familyInfo.family_member)) ? familyInfo.family_member : []; const idMap = (familyInfo && familyInfo.steamIdtoName) ? familyInfo.steamIdtoName : {}; const nowSec = Date.now() / 1000; const isZh = locale === 'zh-CN'; const result = { members: [], activeCount: 0, warmCount: 0, coldCount: 0, dormantCount: 0, healthScore: 0, statusLabels: { active: isZh ? '活跃' : 'Active', warm: isZh ? '温热' : 'Warm', cold: isZh ? '冷淡' : 'Cold', dormant: isZh ? '沉睡' : 'Dormant' } }; members.forEach(m => { let sid = m.steamid, games = 0, times = []; for (const appid in gameInfo) { const g = gameInfo[appid]; if (g.owners && g.owners.indexOf(sid) !== -1) { games++; if (g.time > 0) times.push(g.time); } } const latestTime = times.length > 0 ? Math.max(...times) : -1; const firstTime = times.length > 0 ? Math.min(...times) : -1; const daysSinceLatest = latestTime > 0 ? Math.floor((nowSec - latestTime) / SECONDS_PER_DAY) : -1; let status, statusLabel; if (daysSinceLatest < 0) { status = 'dormant'; statusLabel = isZh ? '从未入库' : 'Never'; result.dormantCount++; } else if (daysSinceLatest < 14) { status = 'active'; statusLabel = result.statusLabels.active; result.activeCount++; } else if (daysSinceLatest < 60) { status = 'warm'; statusLabel = result.statusLabels.warm; result.warmCount++; } else if (daysSinceLatest < 180) { status = 'cold'; statusLabel = result.statusLabels.cold; result.coldCount++; } else { status = 'dormant'; statusLabel = result.statusLabels.dormant; result.dormantCount++; } const libMonths = firstTime > 0 ? Math.max(1, (nowSec - firstTime) / SECONDS_PER_MONTH) : 1; result.members.push({ steamid: sid, name: familyNameOf(m, idMap), total: games, latestTime, daysSinceLatest, status, statusLabel, monthlyAvg: (games / libMonths).toFixed(1) }); }); const scoreMap = { active: 100, warm: 75, cold: 50, dormant: 25 }; let totalScore = 0, cnt = 0; result.members.forEach(m => { totalScore += scoreMap[m.status]; cnt++; }); result.healthScore = cnt > 0 ? Math.round(totalScore / cnt) : 0; const order = { active: 0, warm: 1, cold: 2, dormant: 3 }; result.members.sort((a, b) => order[a.status] - order[b.status] || b.total - a.total); return result; } // 计算成员共同游戏矩阵 // 反向索引法:遍历每个游戏的 owners,对每对共拥成员递增计数 // 复杂度 O(m × avg_owners²),独占游戏自动跳过;原方法 O(n² × k) function computeMemberOverlapMatrix(gameInfo, members, idMap) { const n = members.length; const matrix = []; for (let i = 0; i < n; i++) matrix.push(new Array(n).fill(0)); // steamid → 成员索引 const memberIdx = {}; members.forEach((m, i) => { memberIdx[m.steamid] = i; }); // 遍历游戏,仅处理共拥游戏(owners.length >= 2) for (const appid in gameInfo) { const owners = gameInfo[appid].owners; if (!owners || owners.length < 2) continue; // 映射 owner steamid → 成员索引 const idxs = []; for (const sid of owners) { const idx = memberIdx[sid]; if (idx !== undefined) idxs.push(idx); } // 对每对共拥成员递增矩阵 for (let i = 0; i < idxs.length; i++) { for (let j = i + 1; j < idxs.length; j++) { matrix[idxs[i]][idxs[j]]++; matrix[idxs[j]][idxs[i]]++; } } } const pairs = []; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { pairs.push({ a: i, b: j, aName: familyNameOf(members[i], idMap), bName: familyNameOf(members[j], idMap), sharedGames: matrix[i][j] }); } } pairs.sort((a, b) => b.sharedGames - a.sharedGames); return { matrix, pairs }; } // 计算成员雷达数据(归一化 0-1) function computeMemberRadar(gameInfo, members, idMap) { const nowSec = Date.now() / 1000; const isZh = locale === 'zh-CN'; const raw = members.map(m => { let sid = m.steamid, total = 0, solo = 0, shared = 0, times = []; for (const appid in gameInfo) { const 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); } } const firstTime = times.length > 0 ? Math.min(...times) : nowSec; const libMonths = Math.max(1, (nowSec - firstTime) / SECONDS_PER_MONTH); return { name: familyNameOf(m, idMap), total, solo, shared, monthlyAvg: total / libMonths, shareRate: total > 0 ? shared / total : 0, libAge: (nowSec - firstTime) / SECONDS_PER_MONTH }; }); let maxT = 1, maxS = 1, maxSh = 1, maxM = 0.0001, maxA = 0.0001; raw.forEach(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(r => ({ 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 })); } function renderMemberInsightsTab() { const fi = state.family.info; const gl = state.family.gameList; if (!fi || !fi.family_member || fi.family_member.length === 0) { return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无成员数据' : 'No member data' }); } if (!gl || !gl.GameInfo) { return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无游戏数据' : 'No game data' }); } const isZh = locale === 'zh-CN'; const members = fi.family_member; const gameInfo = gl.GameInfo; const idMap = fi.steamIdtoName || {}; const container = h('div', { style: { overflowY: 'auto', flex: '1', minHeight: '0', paddingRight: '4px' } }); // ===== 成员活跃度 + 共同游戏矩阵(左右结构) ===== const activity = computeMemberActivity(fi, gameInfo); const actColors = { active: '#10b981', warm: '#3b82f6', cold: '#f59e0b', dormant: '#ef4444', never: '#6b7280' }; const healthPct = activity.healthScore; const healthColor = healthPct >= 70 ? '#10b981' : (healthPct >= 40 ? '#f59e0b' : '#ef4444'); const statusCounts = { active: activity.activeCount, warm: activity.warmCount, cold: activity.coldCount, dormant: activity.dormantCount }; const avatarMap = buildAvatarMap(); const allMembers = fillPendingSlots(members); const activityCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', flex: '1', minWidth: '0' } }, [ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' } }, [ h('div', { style: { fontSize: '14px', fontWeight: '600', color: '#c7d5e0' }, text: isZh ? '成员活跃度' : 'Member Activity' }), h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#94a3b8' } }, [ h('span', { text: isZh ? '健康分: ' : 'Health: ' }), h('b', { style: { color: healthColor, fontSize: '16px' }, text: String(healthPct) }) ]) ]), h('div', { style: { height: '8px', background: 'rgba(255,255,255,0.06)', borderRadius: '4px', overflow: 'hidden', marginBottom: '12px' } }, [ h('div', { style: { height: '100%', width: healthPct + '%', background: `linear-gradient(90deg,${healthColor},${healthColor}aa)`, borderRadius: '4px', transition: 'width 0.5s ease' } }) ]), h('div', { style: { display: 'flex', gap: '14px', flexWrap: 'wrap', marginBottom: '10px' } }, ['active', 'warm', 'cold', 'dormant'].map(s => h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '11px', color: '#94a3b8' } }, [ h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: actColors[s], display: 'inline-block' } }), h('span', { text: `${activity.statusLabels[s]} ` }), h('b', { style: { color: '#c6d4df' }, text: String(statusCounts[s]) }) ]) ) ), ...activity.members.map(m => { const daysText = m.daysSinceLatest >= 0 ? (isZh ? `${m.daysSinceLatest} 天前` : `${m.daysSinceLatest}d ago`) : (isZh ? '从未' : 'Never'); const av = avatarMap[String(m.steamid)] || DEFAULT_AVATAR; return h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', padding: '7px 8px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.04)', borderLeft: `3px solid ${actColors[m.status]}`, borderRadius: '8px', marginBottom: '5px' } }, [ h('img', { src: av, style: { width: '28px', height: '28px', borderRadius: '50%', objectFit: 'cover', flexShrink: '0', border: `2px solid ${actColors[m.status]}` }, loading: 'lazy', title: m.name, onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" }), h('span', { style: { flexShrink: '0', width: '78px', fontSize: '12px', color: '#c6d4df', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: m.name, text: m.name }), h('span', { style: { fontSize: '10px', fontWeight: '700', padding: '2px 8px', borderRadius: '4px', background: actColors[m.status] + '22', color: actColors[m.status] }, text: m.statusLabel }), h('div', { style: { flex: '1', display: 'flex', gap: '14px', fontSize: '11px', color: '#8097a8', justifyContent: 'flex-end' } }, [ h('span', { html: `${isZh ? '库内' : 'Lib'}: ${m.total}` }), h('span', { html: `${isZh ? '月均' : 'Avg'}: ${m.monthlyAvg}` }), h('span', { html: `${isZh ? '最近入库' : 'Latest'}: ${daysText}` }) ]) ]); }), // 待加入占位成员 ...allMembers.slice(activity.members.length).map((m, i) => { const ci = activity.members.length + i; const c = CHART_COLORS[ci % CHART_COLORS.length]; return h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', padding: '7px 8px', background: 'rgba(100,116,139,0.04)', border: '1px solid rgba(100,116,139,0.1)', borderLeft: `3px dashed ${c}`, borderRadius: '8px', marginBottom: '5px', opacity: '0.5' } }, [ h('div', { style: { width: '24px', height: '24px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', border: `2px dashed ${c}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0' }, html: ICONS.userPlaceholder }), h('span', { style: { flexShrink: '0', width: '90px', fontSize: '12px', color: '#64748b', fontStyle: 'italic' }, text: isZh ? '待加入' : 'Empty' }), h('div', { style: { flex: '1', display: 'flex', gap: '14px', fontSize: '11px', color: '#475569', justifyContent: 'flex-end' } }, [ h('span', { text: `${isZh ? '库内' : 'Lib'}: 0` }), h('span', { text: `${isZh ? '月均' : 'Avg'}: 0` }), h('span', { text: `${isZh ? '最近入库' : 'Latest'}: -` }) ]) ]); }) ]); // 共同游戏矩阵:6×6 头像网格,不足补"待加入" const overlap = computeMemberOverlapMatrix(gameInfo, members, idMap); let maxShared = 1; overlap.pairs.forEach(p => { if (p.sharedGames > maxShared) maxShared = p.sharedGames; }); const N = allMembers.length; const matrixCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column' } }, [ h('div', { style: { fontSize: '14px', fontWeight: '600', color: '#c7d5e0', marginBottom: '4px' }, text: isZh ? '共同游戏矩阵' : 'Shared Games Matrix' }), h('div', { style: { fontSize: '11px', color: '#64748b', marginBottom: '10px' }, text: isZh ? '成员之间共同拥有的游戏数量(悬停查看详情)' : 'Shared games between members (hover for details)' }), (() => { const gridSize = N + 1; // 首行首列为表头 const grid = h('div', { style: { display: 'grid', gridTemplateColumns: `repeat(${gridSize}, 1fr)`, gap: '4px', flex: '1', alignItems: 'center' } }); // 左上角空 grid.appendChild(h('div')); // 表头:头像 for (let j = 0; j < N; j++) { const m = allMembers[j]; const av = m.pending ? null : (avatarMap[String(m.steamid)] || DEFAULT_AVATAR); if (av) { grid.appendChild(h('img', { src: av, style: { width: '32px', height: '32px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${CHART_COLORS[j % CHART_COLORS.length]}`, margin: '0 auto', display: 'block' }, loading: 'lazy', title: familyNameOf(m, idMap), onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" })); } else { grid.appendChild(h('div', { style: { width: '32px', height: '32px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', border: '2px dashed rgba(100,116,139,0.4)', margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: ICONS.userPlaceholder, title: isZh ? '待加入' : 'Empty' })); } } // 数据行 for (let i = 0; i < N; i++) { const mi = allMembers[i]; const avi = mi.pending ? null : (avatarMap[String(mi.steamid)] || DEFAULT_AVATAR); // 行表头:头像 if (avi) { grid.appendChild(h('img', { src: avi, style: { width: '32px', height: '32px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${CHART_COLORS[i % CHART_COLORS.length]}`, display: 'block' }, loading: 'lazy', title: familyNameOf(mi, idMap), onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" })); } else { grid.appendChild(h('div', { style: { width: '32px', height: '32px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', border: '2px dashed rgba(100,116,139,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: ICONS.userPlaceholder, title: isZh ? '待加入' : 'Empty' })); } // 数据格 for (let j = 0; j < N; j++) { const mj = allMembers[j]; const isPending = mi.pending || mj.pending; if (i === j) { grid.appendChild(h('div', { style: { textAlign: 'center', padding: '6px', background: 'rgba(6,207,190,0.1)', borderRadius: '4px', color: '#06cfbe', fontSize: '12px', fontWeight: '700' }, text: '-' })); } else if (isPending) { grid.appendChild(h('div', { style: { textAlign: 'center', padding: '6px', background: 'rgba(100,116,139,0.06)', borderRadius: '4px', color: '#475569', fontSize: '12px' }, text: '·' })); } else { const val = overlap.matrix[i] ? (overlap.matrix[i][j] || 0) : 0; const intensity = val / maxShared; const bg = val === 0 ? 'rgba(255,255,255,0.02)' : `rgba(84,160,255,${(0.15 + intensity * 0.5).toFixed(2)})`; const nmI = familyNameOf(mi, idMap); const nmJ = familyNameOf(mj, idMap); grid.appendChild(h('div', { style: { textAlign: 'center', padding: '6px', background: bg, borderRadius: '4px', color: val > 0 ? '#c6d4df' : '#475569', fontWeight: val > 0 ? '600' : '400', fontSize: '12px' }, title: `${nmI} ↔ ${nmJ}: ${val} ${isZh ? '个共同游戏' : 'shared'}`, text: String(val) })); } } } return grid; })() ]); const topRow = h('div', { style: { display: 'flex', gap: '12px', alignItems: 'stretch', marginBottom: '12px' } }); topRow.appendChild(activityCard); topRow.appendChild(matrixCard); container.appendChild(topRow); // ===== 成员雷达图 + 加入时间(左右结构) ===== const radar = computeMemberRadar(gameInfo, members, idMap); if (radar.length > 0) { const axes = isZh ? ['总游戏', '独占', '共享', '月均', '共享率', '库龄'] : ['Total', 'Solo', 'Shared', 'Avg', 'Share', 'Age']; const cx = 150, cy = 140, R = 95, levels = 4; let radarSvg = ''; for (let l = 1; l <= levels; l++) { const r = R * l / levels; const gpts = []; for (let a = 0; a < axes.length; a++) { const 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 (let a = 0; a < axes.length; a++) { const aang = -Math.PI / 2 + a * 2 * Math.PI / axes.length; radarSvg += ``; radarSvg += `${axes[a]}`; } radar.forEach((m, mi) => { const c = CHART_COLORS[mi % CHART_COLORS.length]; const vals = [m.totalGames, m.soloCount, m.sharedCount, m.monthlyAvg, m.shareRate, m.libraryAge]; const mpts = []; for (let a = 0; a < axes.length; a++) { const mang = -Math.PI / 2 + a * 2 * Math.PI / axes.length; const mr = R * vals[a]; mpts.push(`${(cx + mr * Math.cos(mang)).toFixed(1)},${(cy + mr * Math.sin(mang)).toFixed(1)}`); } radarSvg += ``; }); const radarLegend = radar.map((m, mi) => { const c = CHART_COLORS[mi % CHART_COLORS.length]; return `${m.name}`; }).join(''); // 加入时间+冷却数据 const fmtJoinDate = (ts) => { if (!ts) return isZh ? '未知' : 'Unknown'; const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; }; const fmtCooldown = (secs) => { if (!secs || secs <= 0) return isZh ? '无冷却' : 'None'; const days = Math.floor(secs / SECONDS_PER_DAY); return isZh ? `${days} 天` : `${days}d`; }; const joinListHtml = allMembers.map((m, i) => { const c = CHART_COLORS[i % CHART_COLORS.length]; if (m.pending) { return `
${ICONS.userPlaceholder}
${isZh ? '待加入' : 'Empty'}
- ${isZh ? '冷却' : 'CD'}: -
`; } const av = avatarMap[String(m.steamid)] || DEFAULT_AVATAR; const joinDate = fmtJoinDate(m.time_joined); const cd = fmtCooldown(m.cooldown_remaining); const cdColor = m.cooldown_remaining > 0 ? '#ef4444' : '#34d399'; const nowSec = Date.now() / 1000; const joinDays = m.time_joined > 0 ? Math.floor((nowSec - m.time_joined) / SECONDS_PER_DAY) : 0; return `
${m.userName || familyNameOf(m, idMap)} ${joinDate} ${joinDays}${isZh ? '天' : 'd'} ${isZh ? '冷却' : 'CD'}: ${cd}
`; }).join(''); const radarWrap = h('div', { style: { display: 'flex', gap: '12px', alignItems: 'stretch' } }); // 左侧卡片:雷达图 const radarCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', flex: '1', minWidth: '0' } }); radarCard.insertAdjacentHTML('beforeend', `
${isZh ? '成员多维度雷达对比' : 'Member Radar'}
` + `
${isZh ? '各维度按最大值归一化(0-1)' : 'Normalized by max (0-1)'}
` + `${radarSvg}` + `
${radarLegend}
` ); radarWrap.appendChild(radarCard); // 右侧卡片:加入时间 const joinCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', flex: '1', minWidth: '0' } }); joinCard.insertAdjacentHTML('beforeend', `
${isZh ? '加入时间' : 'Join Time'}
` + `
${isZh ? '成员加入家庭组的时间与冷却' : 'Member join time & cooldown'}
` + joinListHtml ); radarWrap.appendChild(joinCard); container.appendChild(radarWrap); } // ===== 24小时入库热力图(全宽) ===== const heatmap24hCard = renderFamily24hHeatmap(); if (heatmap24hCard) { container.appendChild(heatmap24hCard); } // 热力图大卡片 const heatmapCard = renderFamilyHeatmapGrid(); if (heatmapCard) container.appendChild(heatmapCard); return container; } // ==================== 家庭愿望单 ==================== // 从愿望单页面 HTML 中提取 SSR queryData(参考 steam-family-game-analysis v1.39) function faWlExtractQueryData(text) { try { let idx = 0; while ((idx = text.indexOf('JSON.parse(', idx)) !== -1) { const start = text.indexOf('"', idx + 11); if (start === -1) { idx++; continue; } let i = start + 1, inEsc = false; while (i < text.length) { const c = text[i]; if (inEsc) inEsc = false; else if (c === '\\') inEsc = true; else if (c === '"') break; i++; } const escaped = text.substring(start + 1, i).replace(/[\n\r]/g, ''); let decoded = ''; try { decoded = JSON.parse('"' + escaped + '"'); } catch (_) { idx = i + 1; continue; } if (typeof decoded === 'string' && decoded.indexOf('queryData') !== -1) { try { const outer = JSON.parse(decoded); if (outer && typeof outer.queryData === 'string') { const qd = JSON.parse(outer.queryData); if (qd && Array.isArray(qd.queries)) return qd.queries; } } catch (_) {} } idx = i + 1; } } catch (e) { logger.warn('[WL] SSR 提取失败', e); } return null; } // 从 SSR queries 数组提取愿望单条目(appid + name) function faWlExtractEntries(queries) { const entries = []; const storeItemNames = {}; for (const query of queries) { if (!query || !query.state || !query.state.data) continue; const data = query.state.data; const qKey = query.queryKey || []; // 愿望单条目(含 appid/priority/date_added) if (qKey[0] === 'WishlistSortedFiltered') { const payload = Array.isArray(data) ? { items: data } : data; if (payload && Array.isArray(payload.items)) { for (const item of payload.items) { if (item && item.appid) entries.push({ appid: Number(item.appid), name: '' }); } } } // StoreItem 详情(含名称) if (qKey.length >= 3 && qKey[0] === 'StoreItem') { const appId = parseInt(String(qKey[1]).replace(/^app_/, ''), 10); if (appId && data && data.default_info && data.default_info.name) { storeItemNames[appId] = String(data.default_info.name); } } } // 去重 + 补名称 const seen = {}; const unique = []; for (const e of entries) { if (e.appid && !seen[e.appid]) { seen[e.appid] = true; e.name = storeItemNames[e.appid] || ('App ' + e.appid); unique.push(e); } } return unique; } // 来源1:从愿望单页面 HTML 抓取 SSR 数据(最可靠) function faWlFetchFromPage(steamid) { return new Promise(resolve => { const isProfileId = /^\d{17}$/.test(String(steamid)); const url = isProfileId ? 'https://store.steampowered.com/wishlist/profiles/' + steamid + '/' : 'https://store.steampowered.com/wishlist/id/' + steamid + '/'; GM_xmlhttpRequest({ method: 'GET', url, timeout: 30000, onload(resp) { if (resp.status < 200 || resp.status >= 300 || !resp.responseText) { resolve([]); return; } const queries = faWlExtractQueryData(resp.responseText); if (!queries) { resolve([]); return; } const entries = faWlExtractEntries(queries); console.log('[SFD-WL] ' + steamid + ' SSR: ' + entries.length + ' 条'); resolve(entries); }, onerror() { resolve([]); }, ontimeout() { resolve([]); } }); }); } // 来源2:wishlistdata JSON API 分页(补充名称) function faWlFetchFromApi(steamid) { return new Promise(resolve => { const entries = []; const isProfileId = /^\d{17}$/.test(String(steamid)); const base = isProfileId ? 'https://store.steampowered.com/wishlist/profiles/' + steamid + '/wishlistdata/' : 'https://store.steampowered.com/wishlist/id/' + steamid + '/wishlistdata/'; function fetchPage(p) { if (p >= 60) { resolve(entries); return; } GM_xmlhttpRequest({ method: 'GET', url: base + '?p=' + p, timeout: 20000, onload(resp) { if (resp.status < 200 || resp.status >= 300) { resolve(entries); return; } try { const data = JSON.parse(resp.responseText); if (!data || (typeof data === 'object' && !Array.isArray(data) && Object.keys(data).length === 0)) { resolve(entries); return; } const items = Array.isArray(data) ? data : Object.entries(data); if (items.length === 0) { resolve(entries); return; } items.forEach(item => { let 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) return; entries.push({ appid, name: (info.name && String(info.name).trim()) || ('App ' + appid) }); }); setTimeout(() => fetchPage(p + 1), 300); } catch (e) { resolve(entries); } }, onerror() { resolve(entries); }, ontimeout() { resolve(entries); } }); } fetchPage(0); }); } // 获取单个成员的愿望单(SSR + API 并行抓取 + 合并) async function fetchMemberWishlist(steamid) { const [ssrEntries, apiEntries] = await Promise.all([faWlFetchFromPage(steamid), faWlFetchFromApi(steamid)]); const apiMap = {}; apiEntries.forEach(e => { if (e && e.appid) apiMap[e.appid] = e; }); const result = []; const seen = {}; // SSR 为基底(appid + 可能的名称) ssrEntries.forEach(e => { const api = apiMap[e.appid]; const name = (api && api.name && api.name !== ('App ' + e.appid)) ? api.name : (e.name || ('App ' + e.appid)); result.push({ appid: e.appid, name }); seen[e.appid] = true; }); // API 有但 SSR 没有的 apiEntries.forEach(e => { if (!seen[e.appid]) result.push({ appid: e.appid, name: e.name || ('App ' + e.appid) }); }); console.log('[SFD-WL] ' + steamid + ' 合并: ' + result.length + ' 条(SSR ' + ssrEntries.length + ' / API ' + apiEntries.length + ')'); return result; } // 获取所有成员的愿望单(合并去重,2并发) async function fetchAllWishlists(members, onProgress) { const merged = {}; // appid -> { appid, name, wishers: [steamid,...] } const CONCURRENCY = 2; let idx = 0, running = 0, completed = 0; // 每个成员的完成状态:steamid -> 'pending' | 'loading' | 'done' const statusMap = {}; members.forEach(m => { statusMap[m.steamid] = 'pending'; }); return new Promise(resolve => { function launchNext() { while (running < CONCURRENCY && idx < members.length) { const m = members[idx++]; running++; statusMap[m.steamid] = 'loading'; if (onProgress) onProgress(statusMap); fetchMemberWishlist(m.steamid).then(entries => { entries.forEach(e => { if (!merged[e.appid]) { merged[e.appid] = { appid: e.appid, name: e.name, wishers: [] }; } else { // 已存在:用有效名称覆盖占位名(App xxx) const isPlaceholder = n => !n || /^App \d+$/.test(n); if (isPlaceholder(merged[e.appid].name) && !isPlaceholder(e.name)) { merged[e.appid].name = e.name; } } merged[e.appid].wishers.push(m.steamid); }); running--; completed++; statusMap[m.steamid] = 'done'; if (onProgress) onProgress(statusMap); if (completed >= members.length) { resolve(); return; } launchNext(); }); } } launchNext(); }).then(() => { // 标记家庭库已有的游戏 const libSet = new Set(); const gl = state.family.gameList; if (gl && gl.GameList) gl.GameList.forEach(a => libSet.add(a)); Object.values(merged).forEach(g => { g.inLibrary = libSet.has(g.appid); }); // 排序:多人想要优先 → 家庭库未有 → 名称 return Object.values(merged).sort((a, b) => { const wDiff = b.wishers.length - a.wishers.length; if (wDiff !== 0) return wDiff; if (a.inLibrary !== b.inLibrary) return a.inLibrary ? 1 : -1; return (a.name || '').localeCompare(b.name || '', 'zh-CN'); }); }); } // 愿望单状态 let wlState = { data: null, loading: false, filter: 'all', kpiFilter: 'all', page: 1, updatedAt: 0 }; function renderFamilyWishlistTab() { const fi = state.family.info; if (!fi || !fi.family_member || fi.family_member.length === 0) { return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无成员数据' : 'No member data' }); } const isZh = locale === 'zh-CN'; const members = fi.family_member; const container = h('div', { style: { flex: '1', overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: '0' } }); // 检查愿望单缓存(TTL 1周,手动刷新已提前清除缓存) if (!wlState.data && !wlState.loading) { const cached = storage.getWishlistCache(); if (cached && cached.steamid === state.family.popupSteamid && cached.data) { // 重新计算 inLibrary 标记(家庭库可能已变化) const libSet = new Set(); const gl = state.family.gameList; if (gl && gl.GameList) gl.GameList.forEach(a => libSet.add(a)); cached.data.forEach(g => { g.inLibrary = libSet.has(g.appid); }); wlState.data = cached.data; wlState.updatedAt = cached._ts || 0; } } // 加载中 / 触发加载:显示科技感进度条 if (wlState.loading || !wlState.data) { const avatarMap = buildAvatarMap(); const allMembers = fillPendingSlots(members); const idMap = fi.steamIdtoName || {}; // 进度状态 if (!wlState.loading) { wlState.loading = true; // 首次打开立即将第一个成员设为 loading,让动效立即显示 const initStatusMap = {}; if (members.length > 0) initStatusMap[members[0].steamid] = 'loading'; wlState.progress = { statusMap: initStatusMap }; } const prog = wlState.progress || { statusMap: {} }; // 构建进度条 UI const loadWrap = h('div', { style: { flex: '1', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '20px' } }); loadWrap.appendChild(h('div', { style: { fontSize: '14px', fontWeight: '600', color: '#c7d5e0' }, text: isZh ? '正在获取家庭成员愿望单' : 'Fetching Family Wishlists' })); // 进度条主体:6个头像节点 + 连线 const nodes = h('div', { style: { display: 'flex', alignItems: 'center', gap: '0', position: 'relative' } }); const lineW = 40; // 节点间连线宽度 allMembers.forEach((m, i) => { const sid = String(m.steamid); const status = m.pending ? 'pending' : (prog.statusMap[sid] || 'pending'); const av = m.pending ? null : (avatarMap[sid] || DEFAULT_AVATAR); const nm = familyNameOf(m, idMap); const color = m.pending ? '#475569' : CHART_COLORS[i % CHART_COLORS.length]; // 连线(第一个之前不画) if (i > 0) { const prevStatus = allMembers[i - 1].pending ? 'pending' : (prog.statusMap[String(allMembers[i - 1].steamid)] || 'pending'); const lineDone = (status === 'done' || status === 'loading') && (prevStatus === 'done' || prevStatus === 'loading'); const lineLoading = status === 'loading' && prevStatus === 'done'; nodes.appendChild(h('div', { style: { width: lineW + 'px', height: '3px', borderRadius: '2px', background: lineDone ? color : 'rgba(255,255,255,0.08)', transition: 'background 0.4s ease', position: 'relative', overflow: 'hidden' } }, lineLoading ? [h('div', { style: { position: 'absolute', top: '0', left: '0', height: '100%', width: '100%', background: `linear-gradient(90deg, transparent, ${color}, transparent)`, animation: 'sfd-wl-slide 1.2s linear infinite' } })] : [])); } // 头像节点 const nodeWrap = h('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '6px' } }); const ringColor = status === 'done' ? '#34d399' : (status === 'loading' ? color : 'rgba(255,255,255,0.12)'); const nodeDiv = h('div', { style: { width: '48px', height: '48px', borderRadius: '50%', position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' } }); // 外环发光 if (status === 'loading') { nodeDiv.appendChild(h('div', { style: { position: 'absolute', inset: '-4px', borderRadius: '50%', border: `2px solid ${color}`, opacity: '0.4', animation: 'sfd-wl-pulse 1.5s ease-in-out infinite' } })); } if (m.pending) { nodeDiv.appendChild(h('div', { style: { width: '40px', height: '40px', borderRadius: '50%', background: 'rgba(100,116,139,0.1)', border: `2px dashed rgba(100,116,139,0.3)`, display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: ICONS.userPlaceholder })); } else { nodeDiv.appendChild(h('img', { src: av, style: { width: '40px', height: '40px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${ringColor}`, opacity: status === 'pending' ? '0.3' : '1', transition: 'all 0.4s ease' }, loading: 'lazy', onerror: function() { this.onerror = null; this.src = DEFAULT_AVATAR; } })); } // 完成勾 if (status === 'done') { nodeDiv.appendChild(h('div', { style: { position: 'absolute', bottom: '-2px', right: '-2px', width: '18px', height: '18px', borderRadius: '50%', background: '#34d399', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px solid #0f172a' }, html: `${ICONS.check}` })); } // loading 旋转环 if (status === 'loading') { nodeDiv.appendChild(h('div', { style: { position: 'absolute', inset: '0', borderRadius: '50%', border: '2px solid transparent', borderTopColor: color, borderRightColor: color, animation: 'sfd-wl-spin 0.8s linear infinite' } })); } nodeWrap.appendChild(nodeDiv); // 名称 nodeWrap.appendChild(h('span', { style: { fontSize: '10px', color: status === 'done' ? '#c6d4df' : (status === 'loading' ? color : '#475569'), maxWidth: '60px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center', transition: 'color 0.4s ease' }, title: nm, text: nm })); nodes.appendChild(nodeWrap); }); loadWrap.appendChild(nodes); // 进度文字 const doneCount = allMembers.filter(m => !m.pending && prog.statusMap[String(m.steamid)] === 'done').length; const realTotal = allMembers.filter(m => !m.pending).length; loadWrap.appendChild(h('div', { style: { fontSize: '12px', color: '#8a9ba8' }, html: `${doneCount} / ${realTotal} ${isZh ? '成员已完成' : 'members done'}` })); // CSS 动画(注入一次) if (!document.getElementById('sfd-wl-progress-css')) { const style = document.createElement('style'); style.id = 'sfd-wl-progress-css'; style.textContent = ` @keyframes sfd-wl-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes sfd-wl-pulse { 0%,100% { transform: scale(1); opacity: 0.4; } 50% { transform: scale(1.15); opacity: 0.1; } } @keyframes sfd-wl-slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } `; document.head.appendChild(style); } container.appendChild(loadWrap); // 触发加载(仅首次) if (!wlState._fetchStarted) { wlState._fetchStarted = true; fetchAllWishlists(members, (statusMap) => { wlState.progress = { statusMap: { ...statusMap } }; renderFamilyPopup(); }).then(data => { wlState.data = data; wlState.loading = false; wlState._fetchStarted = false; wlState.updatedAt = Date.now(); storage.setWishlistCache({ steamid: state.family.popupSteamid, data, _ts: wlState.updatedAt }); renderFamilyPopup(); }); } return container; } const data = wlState.data; const avatarMap = buildAvatarMap(); const idMap = fi.steamIdtoName || {}; // 筛选 let filtered = data; // 主筛选:KPI 卡片 if (wlState.kpiFilter === 'inLib') filtered = filtered.filter(g => g.inLibrary); else if (wlState.kpiFilter === 'notInLib') filtered = filtered.filter(g => !g.inLibrary); else if (wlState.kpiFilter === 'multiWant') filtered = filtered.filter(g => g.wishers.length === members.length); // 次级筛选:1-6人 if (wlState.filter !== 'all') { const wantN = Number(wlState.filter); if (!isNaN(wantN)) filtered = filtered.filter(g => g.wishers.length === wantN); } // 统计 const total = data.length; const inLib = data.filter(g => g.inLibrary).length; const multiWant = data.filter(g => g.wishers.length === members.length).length; const notInLib = total - inLib; // 成员愿望单数量分布(含待加入占位) const allMembers = fillPendingSlots(members); const memberCounts = allMembers.map(m => { const count = m.pending ? 0 : (data.filter(g => g.wishers.includes(m.steamid)).length); return { name: familyNameOf(m, idMap), steamid: m.steamid, count, avatar: m.pending ? null : (avatarMap[String(m.steamid)] || DEFAULT_AVATAR), pending: !!m.pending }; }); const maxMc = Math.max(1, ...memberCounts.map(m => m.count)); // 分页(一行4个 × 3行 = 12个) const PAGE_SIZE = 20; const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); if (wlState.page > totalPages) wlState.page = totalPages; const start = (wlState.page - 1) * PAGE_SIZE; const pageItems = filtered.slice(start, start + PAGE_SIZE); // 主体左右布局 const body = h('div', { style: { display: 'flex', gap: '12px', flex: '1', minHeight: '0' } }); // 左侧:KPI田字格(可点击筛选) + 成员愿望单分布 const leftSide = h('div', { style: { width: '260px', flexShrink: '0', display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', paddingRight: '4px' } }); // KPI 田字格(2×2,可点击筛选) const kpiCards = [ { key: 'all', val: total, color: '#06cfbe', label: isZh ? '愿望单总数' : 'Total' }, { key: 'inLib', val: inLib, color: '#f59e0b', label: isZh ? '家庭已有' : 'In Library' }, { key: 'notInLib', val: notInLib, color: '#34d399', label: isZh ? '全家都无' : 'Missing' }, { key: 'multiWant', val: multiWant, color: '#54a0ff', label: isZh ? '全家想要' : 'Multi Want' } ]; const kpiGrid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: '8px' } }, kpiCards.map(k => { const active = wlState.kpiFilter === k.key; return createMetricCard({ value: String(k.val), label: k.label, accent: k.color, layout: 'label', active, onClick: () => { wlState.kpiFilter = active ? 'all' : k.key; wlState.page = 1; renderFamilyPopup(); } }); })); leftSide.appendChild(kpiGrid); // 成员分布卡片 const distCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', display: 'flex', flexDirection: 'column' } }, [ h('div', { style: { fontSize: '13px', fontWeight: '600', color: '#c7d5e0', marginBottom: '8px' }, text: isZh ? '成员愿望单分布' : 'Member Distribution' }), ...memberCounts.map((m, i) => { const c = CHART_COLORS[i % CHART_COLORS.length]; const pct = Math.max(3, Math.round(m.count / maxMc * 100)); if (m.pending) { return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px', opacity: '0.4' } }, [ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px' } }, [ h('div', { style: { width: '24px', height: '24px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', border: `2px dashed ${c}`, flexShrink: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: ICONS.userPlaceholder }), h('span', { style: { color: '#64748b', fontStyle: 'italic', flex: '1' }, text: isZh ? '待加入' : 'Empty' }), h('span', { style: { color: '#475569', fontWeight: '700', flexShrink: '0' }, text: '0' }) ]), h('div', { style: { height: '6px', borderRadius: '3px', background: 'rgba(255,255,255,0.06)', overflow: 'hidden' } }, [ h('div', { style: { height: '100%', borderRadius: '3px', background: 'rgba(100,116,139,0.2)', width: '3%' } }) ]) ]); } return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px', opacity: m.count === 0 ? '0.5' : '1' } }, [ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px' } }, [ h('img', { src: m.avatar, style: { width: '24px', height: '24px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${c}`, flexShrink: '0' }, loading: 'lazy', onerror: function() { this.onerror = null; this.src = DEFAULT_AVATAR; } }), h('span', { style: { color: '#e2e8f0', fontWeight: '600', flex: '1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, text: m.name }), h('span', { style: { color: c, fontWeight: '700', flexShrink: '0' }, text: String(m.count) }) ]), h('div', { style: { height: '6px', borderRadius: '3px', background: 'rgba(255,255,255,0.06)', overflow: 'hidden' } }, [ h('div', { style: { height: '100%', borderRadius: '3px', background: c, width: pct + '%', transition: 'width 0.4s ease' } }) ]) ]); }) ]); leftSide.appendChild(distCard); leftSide.appendChild(h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', style: { width: '100%', flexShrink: '0' }, text: isZh ? '刷新愿望单' : 'Refresh', onClick: () => { storage.delWishlistCache(); wlState.data = null; wlState.page = 1; wlState.kpiFilter = 'all'; wlState.updatedAt = 0; renderFamilyPopup(); } })); if (wlState.updatedAt > 0) { leftSide.appendChild(h('div', { style: { textAlign: 'center', fontSize: '10px', color: '#64748b', marginTop: '4px', whiteSpace: 'nowrap' }, text: (isZh ? '更新于 ' : 'Updated ') + new Date(wlState.updatedAt).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })); } body.appendChild(leftSide); // 右侧:次级筛选 + 游戏列表 const rightSide = h('div', { style: { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column' } }); // 工具栏 const toolbar = h('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px', flexShrink: '0', flexWrap: 'wrap', alignItems: 'center' } }); // 次级筛选按钮:全部、1人、2人、3人、4人、5人、6人(无数据则灰色禁用) const wisherCounts = {}; for (let n = 1; n <= 6; n++) wisherCounts[n] = data.filter(g => g.wishers.length === n).length; const filterKeys = ['all', 1, 2, 3, 4, 5, 6]; filterKeys.forEach(key => { const isAll = key === 'all'; const label = isAll ? (isZh ? '全部' : 'All') : `${key}${isZh ? '人' : ''}`; const active = wlState.filter === String(key); const noData = !isAll && wisherCounts[key] === 0; const btn = h('button', { class: `sfd-btn sfd-btn-sm ${active ? 'sfd-btn-primary' : 'sfd-btn-ghost'}`, text: label, style: noData ? { opacity: '0.35', cursor: 'not-allowed', filter: 'grayscale(0.8)' } : {}, onClick: noData ? null : () => { wlState.filter = String(key); wlState.page = 1; renderFamilyPopup(); } }); if (noData) btn.disabled = true; toolbar.appendChild(btn); }); rightSide.appendChild(toolbar); // 游戏网格 const grid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px', overflowY: 'auto', flex: '1', minHeight: '0', paddingRight: '4px' } }); if (pageItems.length === 0) { grid.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '无匹配结果' : 'No results' })); } else { pageItems.forEach(game => { const os = getOwnerStyle(game.wishers.length); // 构建 wisher 头像元素(用 h() 创建 DOM,确保可靠渲染) const wisherEls = game.wishers.slice(0, 6).map((sid, wi) => { const c = CHART_COLORS[wi % CHART_COLORS.length]; const av = avatarMap[String(sid)] || DEFAULT_AVATAR; const nm = (idMap && idMap[sid]) || familyNameOf({ steamid: sid }, idMap); return h('img', { src: av, style: { width: '20px', height: '20px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${c}` }, loading: 'lazy', title: nm, onerror: function() { this.onerror = null; this.src = DEFAULT_AVATAR; } }); }); if (game.wishers.length > 6) { wisherEls.push(h('span', { style: { fontSize: '10px', color: '#8a9ba8', alignSelf: 'center' }, text: `+${game.wishers.length - 6}` })); } grid.appendChild(h('div', { class: 'sfd-pl-recent-card', style: { background: os.bg, borderColor: os.border } }, [ (() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(), h('div', { class: 'sfd-pl-recent-info' }, [ h('div', { style: { display: 'flex', alignItems: 'center', gap: '4px' } }, [ (() => { const el = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(), game.inLibrary ? h('span', { style: { fontSize: '12px', flexShrink: '0' }, text: '✅️' }) : null ]), h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '3px', marginTop: '4px' } }, wisherEls) ]) ])); }); } rightSide.appendChild(grid); // 分页 if (totalPages > 1) { rightSide.appendChild(createPagination(wlState.page, totalPages, (p) => { wlState.page = p; renderFamilyPopup(); })); } body.appendChild(rightSide); container.appendChild(body); return container; } // ==================== 精品游戏 Tab ==================== let _stressGamesCache = null; let _stressLoadFailed = false; let _stressForceNetwork = false; // 刷新时强制跳过@resource走网络 const STRESS_JSON_URL = 'https://raw.githubusercontent.com/SmallFork/json/main/2026.json'; // 获取精品游戏列表(从缓存中提取所有年份的游戏) function getStressAllGames() { if (!_stressGamesCache) return []; const raw = _stressGamesCache; if (Array.isArray(raw)) return raw; const all = []; Object.values(raw).forEach(v => { if (Array.isArray(v)) all.push(...v); }); return all; } // 商店页面所有权检测缓存(appid → 'self'|'family'|false/null=未检测) const _stressStoreOwnedCache = new Map(); const _stressStorePending = new Set(); // 检测未发售游戏是否已被预购 function checkStressStoreFamilyOwnership(appid) { if (_stressStoreOwnedCache.has(appid)) return _stressStoreOwnedCache.get(appid); if (_stressStorePending.has(appid)) return null; _stressStorePending.add(appid); const url = `https://store.steampowered.com/app/${appid}?l=schinese`; GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 15000, onload(resp) { let ownType = false; try { const html = resp.responseText || ''; // 1. 自己预购/已拥有:game_area_already_owned(优先检测,因为可能同时出现 family 和自己) if (html.includes('game_area_already_owned') && html.includes('steamdb_already_in_library_link')) { ownType = 'self'; } else if (html.includes('ds_owned_flag') && html.includes('already_in_library')) { ownType = 'self'; } // 2. 家庭组预购:family_info else if (html.includes('family_info') && html.includes('steamdb_already_in_library_link')) { ownType = 'family'; } else if (html.includes('game_purchase_area_owned_by_family')) { ownType = 'family'; } else if (html.includes('IN STEAM FAMILY LIBRARY') || html.includes('已在 Steam 家庭组库中')) { ownType = 'family'; } console.log(`[SFD] 2026精品 预购检测: appid=${appid}, ownType=${ownType || 'none'}, htmlLen=${html.length}`); } catch (e) { logger.warn(`2026精品 预购检测异常: appid=${appid}`, e); } _stressStoreOwnedCache.set(appid, ownType); _stressStorePending.delete(appid); if (ownType) updateStressOwnershipUI(appid, ownType); }, onerror(err) { logger.warn(`2026精品 预购检测网络错误: appid=${appid}`, err); _stressStoreOwnedCache.set(appid, false); _stressStorePending.delete(appid); }, ontimeout() { logger.warn(`2026精品 预购检测超时: appid=${appid}`); _stressStoreOwnedCache.set(appid, false); _stressStorePending.delete(appid); } }); return null; } // 检测到预购拥有后,更新对应卡片 UI function updateStressOwnershipUI(appid, ownType) { const isZh = locale === 'zh-CN'; const dateStr = findGameDateByAppid(appid); const labelText = stressOwnedLabel(ownType, isZh, dateStr); const labelClass = ownType === 'self' ? 'self' : 'family'; const cards = document.querySelectorAll(`[data-stress-appid="${appid}"]`); cards.forEach(card => { card.classList.remove('not-owned'); card.classList.add('owned', `owned-${ownType}`); // 如果已有主人头像区域或标签则跳过 if (card.querySelector('.sfd-stress-owners') || card.querySelector('.sfd-stress-featured-owners') || card.querySelector('.sfd-stress-tag')) return; // 焦点卡片:更新徽章 const badge = card.querySelector('.sfd-stress-featured-badge'); if (badge) { badge.textContent = labelText; badge.className = `sfd-stress-featured-badge ${labelClass}`; return; } // 网格卡片:添加标签 const tag = document.createElement('div'); tag.className = `sfd-stress-tag ${labelClass}`; tag.textContent = labelText; card.appendChild(tag); }); // 更新未发售分组的头部计数 const unreleasedGroup = document.getElementById('sfd-stress-unreleased-group'); if (unreleasedGroup) { const header = unreleasedGroup.querySelector('.sfd-stress-group-header'); const totalCards = unreleasedGroup.querySelectorAll('[data-stress-appid]'); const ownedCards = unreleasedGroup.querySelectorAll('[data-stress-appid].owned'); const ownedCount = ownedCards.length; const total = totalCards.length; const pct = total > 0 ? Math.round(ownedCount / total * 100) : 0; if (header) { const countEl = header.querySelector('.sfd-stress-group-count'); const barFill = header.querySelector('.sfd-stress-group-bar-fill'); const pctEl = header.querySelector('.sfd-stress-group-pct'); if (countEl) countEl.textContent = `${ownedCount}/${total}`; if (barFill) barFill.style.width = pct + '%'; if (pctEl) pctEl.textContent = pct + '%'; } } } // 获取未发售游戏的综合拥有状态(API + 商店页面检测) function getStressOwnedInfo(appid) { const gl = state.family.gameList; // 1. 先从 GetSharedLibraryApps 检查 if (gl && gl.GameInfo && gl.GameInfo[appid]) { return { owned: true, owners: gl.GameInfo[appid].owners || [], ownType: 'family' }; } // 2. 再查商店页面检测缓存 const storeType = _stressStoreOwnedCache.get(appid); if (storeType && storeType !== false) { return { owned: true, owners: [], ownType: storeType }; } // 3. 触发异步检测(仅未发售游戏) const storeResult = checkStressStoreFamilyOwnership(appid); if (storeResult && storeResult !== false) { return { owned: true, owners: [], ownType: storeResult }; } return { owned: false, owners: [], ownType: null }; } /* 2026精品 封面加载:复用入库动态的 dashLoadCapsule 逻辑(已知成功 URL 缓存 + 多 CDN 回退 + API + 中文封面) */ function loadStressCapsule(img, appid) { return dashLoadCapsule(img, appid); } function parseStressDate(dateStr) { const m = dateStr.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日/); if (m) return new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3])); return new Date(0); } // 拥有者头像渲染(网格卡片用) function buildOwnerAvatars(owners, maxShow, avatarMap, idMap) { const wrap = h('div', { class: 'sfd-stress-owners' }); owners.slice(0, maxShow).forEach(sid => { const sidStr = String(sid); const avatar = avatarMap[sidStr] || `https://avatars.steamstatic.com/${sidStr}.jpg`; const name = idMap[sidStr] || idMap[sid] || 'ID:' + sidStr.slice(-4); wrap.appendChild(h('img', { src: avatar, loading: 'lazy', title: name, onerror: function handler() { this.removeEventListener('error', handler); this.src = DEFAULT_AVATAR; } })); }); if (owners.length > maxShow) { wrap.appendChild(h('span', { style: { fontSize: '10px', color: '#94a3b8' }, text: `+${owners.length - maxShow}` })); } return wrap; } // 判断游戏日期是否已过(已上市) function isGameReleased(dateStr) { if (!dateStr) return false; // 提取年份和月份,忽略"日" const m = dateStr.match(/(\d{4})年(\d{1,2})月/); if (m) { const releaseDate = new Date(parseInt(m[1]), parseInt(m[2]) - 1, 1); const now = new Date(); // 将当前日期也取到月初比较,避免同月内显示问题 return releaseDate.getTime() <= now.getTime(); } return false; } // 拥有状态标签文本 function stressOwnedLabel(ownType, isZh, dateStr) { if (ownType === 'self') { return (dateStr && isGameReleased(dateStr)) ? (isZh ? '自己拥有' : 'Owned') : (isZh ? '自己预购' : 'Self'); } return (dateStr && isGameReleased(dateStr)) ? (isZh ? '家庭拥有' : 'Family Owned') : (isZh ? '家庭预购' : 'Family'); } // 按 appid 从精品/系列缓存中查找发售日期 function findGameDateByAppid(appid) { const target = String(appid); const search = (cache) => { if (!cache) return null; if (Array.isArray(cache)) { const g = cache.find(x => String(x.appid) === target); return g ? g.date : null; } for (const games of Object.values(cache)) { if (!Array.isArray(games)) continue; const g = games.find(x => String(x.appid) === target); if (g) return g.date; } return null; }; return search(_stressGamesCache) || search(_seriesCache); } // ==================== 系列游戏 Tab ==================== let _seriesCache = null; let _seriesLoadFailed = false; const SERIES_JSON_URL = 'https://raw.githubusercontent.com/SmallFork/json/main/game_series.json'; function getSeriesAllGames() { if (!_seriesCache) return []; // 新格式为 { "系列名": [...] } 对象,需展开为数组并注入系列名 if (!Array.isArray(_seriesCache)) { const all = []; for (const [seriesName, games] of Object.entries(_seriesCache)) { games.forEach(g => all.push({ ...g, '系列': seriesName })); } return all; } return _seriesCache; } function renderFamilySeriesTab() { const fi = state.family.info; const gl = state.family.gameList; const isZh = locale === 'zh-CN'; const container = h('div', { style: { flex: '1', overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: '0' } }); // 数据加载(@resource 同步优先 → 网络异步) if (!_seriesCache && !_seriesLoadFailed) { try { if (typeof GM_getResourceText === 'function') { try { const jsonText = GM_getResourceText('seriesData'); if (jsonText) { _seriesCache = JSON.parse(jsonText); } } catch (e) { logger.silent('系列游戏 @resource JSON 解析失败', e); } } } catch (e) { logger.warn('系列游戏 @resource 读取失败', e); } } if (!_seriesCache && !_seriesLoadFailed) { const loadingWrap = h('div', { class: 'sfd-stress-loading' }, [ h('div', { html: ICONS.spinner }), h('div', { text: isZh ? '正在获取系列游戏…' : 'Fetching series…' }) ]); container.appendChild(loadingWrap); GM_xmlhttpRequest({ method: 'GET', url: SERIES_JSON_URL, timeout: 20000, onload(resp) { try { const data = JSON.parse(resp.responseText); _seriesCache = data; _seriesLoadFailed = false; } catch (e) { _seriesLoadFailed = true; } renderFamilyPopup(); }, onerror() { _seriesLoadFailed = true; renderFamilyPopup(); }, ontimeout() { _seriesLoadFailed = true; renderFamilyPopup(); } }); return container; } if (_seriesLoadFailed && !_seriesCache) { const errWrap = h('div', { class: 'sfd-stress-empty' }, [ h('div', { text: isZh ? '数据获取失败,请检查网络' : 'Failed to load data', style: { marginBottom: '12px' } }), h('button', { class: 'sfd-btn sfd-btn-primary', text: isZh ? '重试' : 'Retry', onClick: () => { _seriesLoadFailed = false; renderFamilyPopup(); } }) ]); container.appendChild(errWrap); return container; } // 按系列分组 const allGames = getSeriesAllGames(); const seriesMap = {}; allGames.forEach(game => { const s = game['系列'] || (isZh ? '未分类' : 'Other'); if (!seriesMap[s]) seriesMap[s] = []; seriesMap[s].push(game); }); // 每个系列内按序号排序 Object.values(seriesMap).forEach(games => { games.sort((a, b) => (a['序号'] || 0) - (b['序号'] || 0)); }); if (!Object.keys(seriesMap).length) { container.appendChild(h('div', { class: 'sfd-stress-empty', text: isZh ? '暂无数据' : 'No data' })); return container; } const avatarMap = buildAvatarMap(); const idMap = fi && fi.steamIdtoName ? fi.steamIdtoName : {}; // 构建网格卡片(和精品游戏一样的样式,非家庭共享游戏触发商店页面检测+checking动效) function buildSeriesCard(game) { const appid = Number(game.appid) || 0; const ownedInfo = appid > 0 ? getStressOwnedInfo(appid) : { owned: false, owners: [], ownType: null }; const isOwned = ownedInfo.owned; const { owners, ownType } = ownedInfo; const ownedClass = isOwned && ownType ? `owned owned-${ownType}` : 'owned'; // 非家庭共享且商店检测仍在进行中 → 添加 checking 动效(复用精品游戏的 sfd-stress-pulse) const isChecking = appid > 0 && !isOwned && _stressStorePending.has(appid); const card = h('div', { class: `sfd-stress-card ${isOwned ? ownedClass : 'not-owned'}${isChecking ? ' checking' : ''}`, 'data-stress-appid': appid, onClick: () => appid > 0 && window.open(`https://store.steampowered.com/app/${appid}`, '_blank') }); const posterImg = h('img', { class: 'sfd-stress-poster', loading: 'lazy' }); if (appid > 0) { card.appendChild(loadStressCapsule(posterImg, appid)); } else { card.appendChild(posterImg); } const info = h('div', { class: 'sfd-stress-info' }); info.appendChild(h('div', { class: 'sfd-stress-name', text: game.name, title: game.name })); info.appendChild(h('div', { class: 'sfd-stress-date', text: game.date || '' })); card.appendChild(info); if (owners.length > 0) { card.appendChild(buildOwnerAvatars(owners, 5, avatarMap, idMap)); } else if (isOwned && ownType) { card.appendChild(h('div', { class: `sfd-stress-tag ${ownType}`, text: stressOwnedLabel(ownType, isZh, game.date) })); } // 检测完成后移除 checking 动效(与精品游戏一致) if (isChecking) { const iv = setInterval(() => { if (!_stressStorePending.has(appid)) { clearInterval(iv); card.classList.remove('checking'); } }, 200); } return card; } // 渲染系列分组(和精品游戏一样样式,默认展开) const scrollWrap = h('div', { class: 'sfd-stress-scroll' }); const seriesKeys = Object.keys(seriesMap).sort((a, b) => a.localeCompare(b, 'zh')); seriesKeys.forEach(seriesName => { const games = seriesMap[seriesName]; if (!games.length) return; const ownedCount = games.filter(g => { const appid = Number(g.appid) || 0; return appid > 0 && getStressOwnedInfo(appid).owned; }).length; const bodyWrap = h('div', { class: 'sfd-stress-group-body' }); const grid = h('div', { class: 'sfd-stress-grid' }); games.forEach(game => grid.appendChild(buildSeriesCard(game))); bodyWrap.appendChild(grid); let collapsed = true; bodyWrap.style.display = 'none'; const header = (() => { const pct = games.length > 0 ? Math.round(ownedCount / games.length * 100) : 0; const arrow = h('span', { class: 'sfd-stress-group-arrow', html: '▼', style: { fontSize: '10px', cursor: 'pointer', transition: 'transform 0.25s', marginRight: '4px', transform: 'rotate(-90deg)' } }); const hdr = h('div', { class: 'sfd-stress-group-header', style: { cursor: 'pointer' }, onClick: () => { collapsed = !collapsed; const a = hdr.querySelector('.sfd-stress-group-arrow'); if (collapsed) { bodyWrap.style.display = 'none'; a.style.transform = 'rotate(-90deg)'; } else { bodyWrap.style.display = ''; a.style.transform = ''; } } }, [ arrow, h('span', { class: 'sfd-stress-group-title', text: `${seriesName}(${games.length})` }), h('span', { class: 'sfd-stress-group-count', text: `${ownedCount}/${games.length}` }), h('div', { class: 'sfd-stress-group-bar' }, [h('div', { class: 'sfd-stress-group-bar-fill released', style: { width: pct + '%' } })]), h('span', { class: 'sfd-stress-group-pct', text: pct + '%' }) ]); return hdr; })(); scrollWrap.appendChild(h('div', { class: 'sfd-stress-group' }, [header, bodyWrap])); }); container.appendChild(scrollWrap); return container; } function renderStressFamilyTab() { const fi = state.family.info; const gl = state.family.gameList; const isZh = locale === 'zh-CN'; const container = h('div', { style: { flex: '1', overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: '0' } }); // 数据加载(@resource 同步优先 → 网络异步,缓存全部数据) if (!_stressGamesCache && !_stressLoadFailed) { if (!_stressForceNetwork) { try { if (typeof GM_getResourceText === 'function') { try { const jsonText = GM_getResourceText('stressData'); if (jsonText) { const data = JSON.parse(jsonText); _stressGamesCache = data; } } catch (e) { logger.silent('精品游戏 @resource JSON 解析失败', e); } } } catch (e) { logger.warn('精品游戏 @resource 读取失败', e); } } } if (!_stressGamesCache && !_stressLoadFailed) { const loadingWrap = h('div', { class: 'sfd-stress-loading' }, [ h('div', { html: ICONS.spinner }), h('div', { text: isZh ? '正在获取精品游戏…' : 'Fetching games…' }) ]); container.appendChild(loadingWrap); GM_xmlhttpRequest({ method: 'GET', url: STRESS_JSON_URL, timeout: 20000, onload(resp) { try { const data = JSON.parse(resp.responseText); _stressGamesCache = data; _stressLoadFailed = false; _stressForceNetwork = false; } catch (e) { _stressLoadFailed = true; } renderFamilyPopup(); }, onerror() { _stressLoadFailed = true; renderFamilyPopup(); }, ontimeout() { _stressLoadFailed = true; renderFamilyPopup(); } }); return container; } if (_stressLoadFailed && !_stressGamesCache) { const errWrap = h('div', { class: 'sfd-stress-empty' }, [ h('div', { text: isZh ? '数据获取失败,请检查网络' : 'Failed to load data', style: { marginBottom: '12px' } }), h('button', { class: 'sfd-btn sfd-btn-primary', text: isZh ? '重试' : 'Retry', onClick: () => { _stressLoadFailed = false; renderFamilyPopup(); } }) ]); container.appendChild(errWrap); return container; } // 从缓存中提取数据:如果是数组按date字段的年份自动分组,否则直接按key分组 const rawData = _stressGamesCache; let yearMap; if (Array.isArray(rawData)) { yearMap = {}; rawData.forEach(game => { const y = game.date ? String(game.date).match(/(\d{4})/) : null; const key = y ? y[1] : 'unknown'; if (!yearMap[key]) yearMap[key] = []; yearMap[key].push(game); }); } else { yearMap = rawData; } const yearKeys = Object.keys(yearMap).filter(k => Array.isArray(yearMap[k]) && yearMap[k].length).sort((a, b) => b - a); if (!yearKeys.length) { container.appendChild(h('div', { class: 'sfd-stress-empty', text: isZh ? '暂无数据' : 'No data' })); return container; } const avatarMap = buildAvatarMap(); const idMap = fi && fi.steamIdtoName ? fi.steamIdtoName : {}; const now = new Date(); // 统计拥有数量 function countOwned(groupGames) { let n = 0; groupGames.forEach(g => { if (getStressOwnedInfo(Number(g.appid)).owned) n++; }); return n; } // 构建分组头部(含折叠箭头) function buildGroupHeader(title, ownedCount, total, barClass, collapsed, onToggle) { const pct = total > 0 ? Math.round(ownedCount / total * 100) : 0; const arrow = h('span', { class: `sfd-stress-group-arrow ${collapsed ? 'collapsed' : ''}`, html: '▼', style: { fontSize: '10px', cursor: 'pointer', transition: 'transform 0.25s', marginRight: '4px' } }); if (collapsed) arrow.style.transform = 'rotate(-90deg)'; const header = h('div', { class: 'sfd-stress-group-header', style: { cursor: 'pointer' }, onClick: onToggle }, [ arrow, h('span', { class: 'sfd-stress-group-title', text: title }), h('span', { class: 'sfd-stress-group-count', text: `${ownedCount}/${total}` }), h('div', { class: 'sfd-stress-group-bar' }, [h('div', { class: `sfd-stress-group-bar-fill ${barClass}`, style: { width: pct + '%' } })]), h('span', { class: 'sfd-stress-group-pct', text: pct + '%' }) ]); return header; } // 构建网格卡片 function buildStressCard(game, showPast, checking) { const appid = Number(game.appid); const { owned: isOwned, owners, ownType } = getStressOwnedInfo(appid); const ownedClass = isOwned && ownType ? `owned owned-${ownType}` : 'owned'; const checkingClass = checking ? ' checking' : ''; const card = h('div', { class: `sfd-stress-card ${isOwned ? ownedClass : 'not-owned'}${checkingClass}`, 'data-stress-appid': appid, onClick: () => window.open(`https://store.steampowered.com/app/${appid}`, '_blank') }); const posterImg = h('img', { class: 'sfd-stress-poster', loading: 'lazy' }); card.appendChild(loadStressCapsule(posterImg, appid)); const info = h('div', { class: 'sfd-stress-info' }); info.appendChild(h('div', { class: 'sfd-stress-name', text: game.name, title: game.name })); const isPast = showPast && parseStressDate(game.date) < now; info.appendChild(h('div', { class: `sfd-stress-date ${isPast ? 'past' : ''}`, text: game.date })); card.appendChild(info); if (owners.length > 0) { card.appendChild(buildOwnerAvatars(owners, 5, avatarMap, idMap)); } else if (isOwned && ownType) { card.appendChild(h('div', { class: `sfd-stress-tag ${ownType}`, text: stressOwnedLabel(ownType, isZh, game.date) })); } return card; } // 渲染单个分组(标题 + 内容),返回 groupEl 用于跟踪 function renderGroup(title, groupGames, barClass, defaultCollapsed) { if (!groupGames.length) return null; const ownedCount = countOwned(groupGames); const bodyWrap = h('div', { class: 'sfd-stress-group-body' }); const grid = h('div', { class: 'sfd-stress-grid' }); groupGames.forEach(game => grid.appendChild(buildStressCard(game, true))); bodyWrap.appendChild(grid); let collapsed = defaultCollapsed; if (collapsed) bodyWrap.style.display = 'none'; const header = buildGroupHeader(title, ownedCount, groupGames.length, barClass, collapsed, () => { collapsed = !collapsed; const arrow = header.querySelector('.sfd-stress-group-arrow'); if (collapsed) { bodyWrap.style.display = 'none'; arrow.style.transform = 'rotate(-90deg)'; } else { bodyWrap.style.display = ''; arrow.style.transform = ''; } }); const groupEl = h('div', { class: 'sfd-stress-group' }, [header, bodyWrap]); return groupEl; } // 渲染未发售分组(带焦点卡片和检测进度条) function renderUnreleasedGroup(yearLabel, groupGames) { if (!groupGames.length) return null; const ownedCount = countOwned(groupGames); const totalCheck = groupGames.length; const bodyWrap = h('div', { class: 'sfd-stress-group-body' }); const progWrap = h('div', { class: 'sfd-family-progress active', style: { marginBottom: '8px' } }); const progBar = h('div', { class: 'sfd-family-progress-bar', style: { width: '0%' } }); progWrap.appendChild(progBar); const header = buildGroupHeader(yearLabel + (isZh ? ' 未发售' : ' Unreleased'), ownedCount, groupGames.length, 'unreleased', false, () => { let c = bodyWrap.style.display === 'none'; bodyWrap.style.display = c ? '' : 'none'; const arrow = header.querySelector('.sfd-stress-group-arrow'); arrow.style.transform = c ? '' : 'rotate(-90deg)'; }); const groupEl = h('div', { class: 'sfd-stress-group' }, [header, progWrap, bodyWrap]); // 追踪检测进度 let checkedCount = 0; function onCheckDone() { checkedCount++; const pct = Math.round(checkedCount / totalCheck * 100); progBar.style.width = pct + '%'; if (checkedCount >= totalCheck) { progBar.classList.add('done'); setTimeout(() => { progWrap.style.opacity = '0'; progWrap.style.height = '0'; progWrap.style.marginBottom = '0'; progWrap.style.overflow = 'hidden'; progWrap.style.transition = 'opacity 0.5s, height 0.5s, margin-bottom 0.5s'; }, 300); } } groupGames.forEach(g => { const appid = Number(g.appid); checkStressStoreFamilyOwnership(appid); if (_stressStoreOwnedCache.has(appid)) { onCheckDone(); } else { const iv = setInterval(() => { if (!_stressStorePending.has(appid)) { clearInterval(iv); const card = document.querySelector(`[data-stress-appid="${appid}"]`); if (card) card.classList.remove('checking'); onCheckDone(); } }, 200); } }); // 焦点大卡片(前2个) if (groupGames.length >= 2) { const featWrap = h('div', { class: 'sfd-stress-featured-wrap' }); groupGames.slice(0, 2).forEach(game => { const appid = Number(game.appid); const { owned: isOwned, owners, ownType } = getStressOwnedInfo(appid); const releaseDate = parseStressDate(game.date); const daysLeft = Math.max(0, Math.ceil((releaseDate - now) / 86400000)); const featClass = isOwned && ownType ? `sfd-stress-featured owned owned-${ownType}` : (isOwned ? 'sfd-stress-featured owned' : 'sfd-stress-featured'); const featCard = h('div', { class: featClass, 'data-stress-appid': appid, onClick: () => window.open(`https://store.steampowered.com/app/${appid}`, '_blank') }); const posterImg = h('img', { class: 'sfd-stress-featured-poster' }); featCard.appendChild(loadStressCapsule(posterImg, appid)); const badgeClass = isOwned && ownType ? `sfd-stress-featured-badge ${ownType}` : 'sfd-stress-featured-badge'; const badgeText = isOwned && ownType ? stressOwnedLabel(ownType, isZh, game.date) : (isZh ? '即将发售' : 'Coming Soon'); const infoWrap = h('div', { class: 'sfd-stress-featured-info' }); infoWrap.appendChild(h('div', { class: badgeClass, text: badgeText })); infoWrap.appendChild(h('div', { class: 'sfd-stress-featured-name', text: game.name, title: game.name })); infoWrap.appendChild(h('div', { class: 'sfd-stress-featured-date', text: game.date })); featCard.appendChild(infoWrap); const countdownWrap = h('div', { class: 'sfd-stress-featured-countdown' }); countdownWrap.appendChild(h('div', { class: 'sfd-stress-featured-days', text: String(daysLeft) })); countdownWrap.appendChild(h('div', { class: 'sfd-stress-featured-days-lbl', text: isZh ? '天后发售' : 'days left' })); featCard.appendChild(countdownWrap); if (owners.length > 0) { const ownersWrap = h('div', { class: 'sfd-stress-featured-owners' }); owners.slice(0, 6).forEach(sid => { const sidStr = String(sid); const avatar = avatarMap[sidStr] || `https://avatars.steamstatic.com/${sidStr}.jpg`; const name = idMap[sidStr] || idMap[sid] || 'ID:' + sidStr.slice(-4); ownersWrap.appendChild(h('img', { src: avatar, loading: 'lazy', title: name, onerror: function handler() { this.removeEventListener('error', handler); this.src = DEFAULT_AVATAR; } })); }); if (owners.length > 6) { ownersWrap.appendChild(h('span', { style: { fontSize: '11px', color: '#94a3b8' }, text: `+${owners.length - 6}` })); } featCard.appendChild(ownersWrap); } featWrap.appendChild(featCard); }); bodyWrap.appendChild(featWrap); } // 剩余网格 if (groupGames.length > 2) { const grid = h('div', { class: 'sfd-stress-grid' }); groupGames.slice(2).forEach(game => { const appid = Number(game.appid); const checking = _stressStorePending.has(appid) || (!_stressStoreOwnedCache.has(appid)); grid.appendChild(buildStressCard(game, false, checking)); }); bodyWrap.appendChild(grid); } return groupEl; } const scrollWrap = h('div', { class: 'sfd-stress-scroll' }); // 遍历每个年份,展示其已发售和未发售分组 yearKeys.forEach(yk => { const yearGames = yearMap[yk]; if (!yearGames || !yearGames.length) return; yearGames.sort((a, b) => parseStressDate(b.date) - parseStressDate(a.date)); const released = [], unreleased = []; yearGames.forEach(game => { (parseStressDate(game.date) < now ? released : unreleased).push(game); }); released.sort((a, b) => parseStressDate(b.date) - parseStressDate(a.date)); unreleased.sort((a, b) => parseStressDate(a.date) - parseStressDate(b.date)); // 已发售:默认折叠(除了最新年份) const isLatest = yk === yearKeys[0]; const relGroup = renderGroup(yk + (isZh ? ' 已发售' : ' Released'), released, 'released', !isLatest); if (relGroup) scrollWrap.appendChild(relGroup); // 未发售(仅当前及未来年份有) const unrelGroup = renderUnreleasedGroup(yk, unreleased); if (unrelGroup) scrollWrap.appendChild(unrelGroup); }); container.appendChild(scrollWrap); return container; } // ==================== 过滤与排序算法 ==================== function applyPanelFiltersAndSort() { // v1.2.5: 联动好友总览筛选 applyOverviewFilters(); renderOverviewTable(); updateOverviewKpis(); updateChipCounts(); } function applyModalFiltersAndSort() { let list = [...state.friends.data]; list = filterFriendsByQuery(list, state.modalSearch, true); if (state.modalStatusFilter === 'online') list = list.filter(f => f.personastate > 0); else if (state.modalStatusFilter === 'offline') list = list.filter(f => f.personastate === 0); else if (state.modalStatusFilter === 'ingame') list = list.filter(f => !!f.gameextrainfo); else if (state.modalStatusFilter === 'vac') list = list.filter(f => f.vac_banned); else if (state.modalStatusFilter === 'devban') list = list.filter(f => f.vac_game_bans > 0); sortHelper(list, state.modalSortBy); state.friends.modalFiltered = list; } function downloadFile(content, filename, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function exportModalCSV() { const data = state.friends.modalFiltered || state.friends.data; if (!data.length) return; const headers = ['Name', 'SteamID', 'Status', 'Country', 'FriendDays', 'LastOnline', 'VACBanned', 'GameBans', 'Level']; const rows = data.map(f => [ `"${(f.personaname || '').replace(/"/g, '""')}"`, f.steamid, f.gameextrainfo ? `In-Game: ${f.gameextrainfo}` : getPersonaStateText(f.personastate), f.country_name || '', f.friend_days || '', f.lastlogoff ? new Date(f.lastlogoff * 1000).toISOString() : '', f.vac_banned ? 'Yes' : 'No', f.vac_game_bans || 0, f.level || '' ]); const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); downloadFile(csv, `steam_friends_${Date.now()}.csv`, 'text/csv;charset=utf-8'); } function exportModalJSON() { const data = state.friends.modalFiltered || state.friends.data; if (!data.length) return; const json = JSON.stringify(data.map(f => ({ name: f.personaname, steamid: f.steamid, status: f.gameextrainfo ? `In-Game: ${f.gameextrainfo}` : getPersonaStateText(f.personastate), country: f.country_name || '', friend_days: f.friend_days || 0, last_online: f.lastlogoff ? new Date(f.lastlogoff * 1000).toISOString() : null, vac_banned: !!f.vac_banned, game_bans: f.vac_game_bans || 0, level: f.level || 0 })), null, 2); downloadFile(json, `steam_friends_${Date.now()}.json`, 'application/json'); } function exportFamilyCSV() { const gl = state.family.gameList; const fi = state.family.info; if (!gl || !gl.GameInfo) return; const headers = ['AppID', 'GameName', 'Owners', 'OwnerCount', 'Time']; const rows = Object.entries(gl.GameInfo).map(([appid, info]) => { const names = info.owners.map(sid => fi ? (fi.steamIdtoName[sid] || sid) : sid).join(' | '); return [appid, `"${(info.name || '').replace(/"/g, '""')}"`, `"${names}"`, info.owners.length, info.time ? new Date(info.time * 1000).toISOString() : '']; }); const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); downloadFile(csv, `family_games_${Date.now()}.csv`, 'text/csv;charset=utf-8'); } function exportFamilyJSON() { const gl = state.family.gameList; const fi = state.family.info; if (!gl || !gl.GameInfo) return; const json = JSON.stringify(Object.entries(gl.GameInfo).map(([appid, info]) => ({ appid: Number(appid), name: info.name, owners: info.owners.map(sid => fi ? (fi.steamIdtoName[sid] || sid) : sid), owner_count: info.owners.length, time: info.time ? new Date(info.time * 1000).toISOString() : null, icon_hash: info.icon_hash })), null, 2); downloadFile(json, `family_games_${Date.now()}.json`, 'application/json'); } function sortHelper(list, method) { const getWeight = (x) => { if (x.gameextrainfo) return 100; if (x.personastate > 0) return 80; return 0; }; list.sort((a, b) => { switch (method) { case 'days-desc': return b.friend_days - a.friend_days; case 'days-asc': return a.friend_days - b.friend_days; case 'status-desc': { const wA = getWeight(a), wB = getWeight(b); if (wA !== wB) return wB - wA; if (wA === 0) return b.lastlogoff - a.lastlogoff; return b.friend_days - a.friend_days; } case 'name-asc': return a.personaname.localeCompare(b.personaname, locale === 'zh-CN' ? 'zh' : 'en'); case 'name-desc': return b.personaname.localeCompare(a.personaname, locale === 'zh-CN' ? 'zh' : 'en'); case 'country-asc': { const na = a.country_name || a.loccountrycode || '\uFFFF'; const nb = b.country_name || b.loccountrycode || '\uFFFF'; return na.localeCompare(nb, locale === 'zh-CN' ? 'zh' : 'en'); } case 'country-desc': { const na = a.country_name || a.loccountrycode || '\u0000'; const nb = b.country_name || b.loccountrycode || '\u0000'; return nb.localeCompare(na, locale === 'zh-CN' ? 'zh' : 'en'); } case 'level-desc': return (b.level || 0) - (a.level || 0); case 'level-asc': return (a.level || 0) - (b.level || 0); case 'mutual-desc': return (b.mutualFriendsCount || 0) - (a.mutualFriendsCount || 0); case 'mutual-asc': return (a.mutualFriendsCount || 0) - (b.mutualFriendsCount || 0); default: return 0; } }); } // ==================== 更新 Panel Header 头像/信息 ==================== function updatePanelHeaderInfo() { const el = dom.panelHeaderInfo; if (!el) return; const p = state.friends.ownProfile; if (!p) return; const hasGame = p.gameextrainfo; el.innerHTML = ''; // 头像 if (p.avatar) { const img = document.createElement('img'); Object.assign(img.style, { width: '36px', height: '36px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(59,130,246,0.45)', flexShrink: '0', boxShadow: '0 4px 12px rgba(59,130,246,0.2)' }); img.src = p.avatar; img.loading = 'lazy'; img.onerror = () => { img.src = 'https://avatars.steamstatic.com/fef49e7fa7e1997310dd48961da2e7d95a5c7a56_medium.jpg'; }; el.appendChild(img); } // info const infoWrap = document.createElement('div'); infoWrap.style.cssText = 'display:flex;flex-direction:column;gap:2px;min-width:0;'; // 昵称 const nameRow = document.createElement('h3'); nameRow.style.cssText = 'margin:0;font-size:14px;color:#fff;font-weight:700;display:flex;align-items:center;gap:8px;flex-wrap:wrap;'; nameRow.appendChild(h('span', { text: p.personaname })); if (p.level > 0) nameRow.appendChild(h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#fbbf24', background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.level} Lv.${p.level}` })); infoWrap.appendChild(nameRow); // meta const metaWrap = document.createElement('div'); metaWrap.style.cssText = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;'; if (p.country_name) metaWrap.appendChild(h('span', { style: { fontSize: '11px', color: '#94a3b8' }, html: `${p.country_flag || ''} ${p.country_name}` })); metaWrap.appendChild(hasGame ? h('span', { class: 'sfd-badge sfd-badge-status in-game', text: `🎮 ${p.gameextrainfo}` }) : h('span', { class: `sfd-badge sfd-badge-status ${p.personastate > 0 ? 'online' : 'offline'}`, text: getPersonaStateText(p.personastate) }) ); infoWrap.appendChild(metaWrap); el.appendChild(infoWrap); // 好友进度条(长条形,在昵称/状态下方) const cnt = getSteamFriendsCount(); const lim = getSteamFriendsLimit(); const pct = Math.min(100, Math.round(cnt / Math.max(1, lim) * 100)); const gaugeBar = document.createElement('div'); gaugeBar.style.cssText = 'flex:1;min-width:120px;max-width:400px;display:flex;align-items:center;gap:8px;'; gaugeBar.appendChild(h('div', { style: { flex: '1', height: '4px', borderRadius: '2px', background: 'rgba(255,255,255,0.08)', overflow: 'hidden' } }, [ h('div', { style: { height: '100%', width: pct + '%', borderRadius: '2px', background: 'linear-gradient(90deg,#3b82f6,#60a5fa)', transition: 'width 0.4s' } }) ])); gaugeBar.appendChild(h('span', { style: { fontSize: '11px', color: '#94a3b8', whiteSpace: 'nowrap', fontWeight: '600' }, text: `${cnt} / ${lim}` })); gaugeBar.title = locale === 'zh-CN' ? `好友 ${cnt} / ${lim}(上限随等级增长)` : `Friends ${cnt} / ${lim}`; el.appendChild(gaugeBar); } // ==================== 页脚状态管理 ==================== function setStatusText(txt) { const el = document.getElementById('sfd-status-txt'); if (el) el.textContent = txt; } // ==================== 初始化与页面变动监听 ==================== // ==================== Title Bar 美化 ==================== function beautifyTitleBar() { const titleBar = document.querySelector('.profile_friends.title_bar'); if (!titleBar || titleBar.dataset.sfdBeautified) return; titleBar.dataset.sfdBeautified = '1'; // 1. 美化 title_bar 整体样式 titleBar.style.cssText = 'display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:8px 12px;background:linear-gradient(135deg,rgba(59,130,246,0.06),rgba(15,23,42,0.4));border:1px solid rgba(59,130,246,0.15);border-radius:10px;margin-bottom:12px;'; // 2. 好友计数 + 进度条 const titleDiv = titleBar.querySelector('.profile_friends.title'); let countVal = '', limitVal = ''; if (titleDiv) { const countSpan = titleDiv.querySelector('.friends_count'); const limitSpan = titleDiv.querySelector('.friends_limit'); countVal = countSpan ? (countSpan.textContent || '').trim() : ''; limitVal = limitSpan ? (limitSpan.textContent || '').trim() : ''; titleDiv.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:0;'; titleDiv.innerHTML = `
${locale === 'zh-CN' ? '您的好友' : 'Your Friends'} ${esc(countVal)} / ${esc(limitVal)}
`; } // 2b. 好友进度条(放在 title_bar 上部,紧贴顶部) const countNum = parseInt(countVal) || 0; const limitNum = parseInt(limitVal) || 1; const pct = Math.min(100, Math.round(countNum / limitNum * 100)); const barColor = pct >= 90 ? '#ef4444' : (pct >= 70 ? '#f59e0b' : '#3b82f6'); const progBar = document.createElement('div'); progBar.style.cssText = `width:100%;height:4px;margin-bottom:4px;border-radius:2px;background:rgba(255,255,255,0.06);overflow:hidden;flex-basis:100%;order:-1;`; progBar.title = `${countNum} / ${limitNum} (${pct}%)`; progBar.innerHTML = `
`; titleBar.insertBefore(progBar, titleBar.firstChild); // 3. 三个脚本按钮(紧跟在进度条后面,靠左排列) const btnGroup = document.createElement('div'); btnGroup.style.cssText = 'display:flex;align-items:center;gap:4px;flex-shrink:0;'; // 好友管理按钮 const panelBtn = document.createElement('button'); panelBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;background:rgba(59,130,246,0.12);border:1px solid rgba(59,130,246,0.2);border-radius:6px;color:#66c0f4;cursor:pointer;transition:all 0.2s;padding:0;'; panelBtn.innerHTML = ICONS.users; panelBtn.title = t('title'); panelBtn.onmouseenter = () => { panelBtn.style.background = 'rgba(59,130,246,0.25)'; panelBtn.style.borderColor = 'rgba(59,130,246,0.4)'; }; panelBtn.onmouseleave = () => { panelBtn.style.background = 'rgba(59,130,246,0.12)'; panelBtn.style.borderColor = 'rgba(59,130,246,0.2)'; }; panelBtn.onclick = () => { panelEl.classList.toggle('sfd-show'); if (panelEl.classList.contains('sfd-show')) { renderActiveTab(); } }; btnGroup.appendChild(panelBtn); // 个人游戏库按钮 const libBtn = document.createElement('button'); libBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.2);border-radius:6px;color:#8b5cf6;cursor:pointer;transition:all 0.2s;padding:0;'; libBtn.innerHTML = ICONS.package; libBtn.title = t('plTitle'); libBtn.onmouseenter = () => { libBtn.style.background = 'rgba(139,92,246,0.25)'; libBtn.style.borderColor = 'rgba(139,92,246,0.4)'; }; libBtn.onmouseleave = () => { libBtn.style.background = 'rgba(139,92,246,0.12)'; libBtn.style.borderColor = 'rgba(139,92,246,0.2)'; }; libBtn.onclick = () => { if (!state.personal.popupEl) { showPersonalLibrary(); } else { state.personal.popupEl.classList.toggle('sfd-show'); } }; btnGroup.appendChild(libBtn); // 家庭组按钮 const familyBtn = document.createElement('button'); familyBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;background:rgba(245,158,11,0.12);border:1px solid rgba(245,158,11,0.2);border-radius:6px;color:#f59e0b;cursor:pointer;transition:all 0.2s;padding:0;'; familyBtn.innerHTML = ICONS.family; familyBtn.title = locale === 'zh-CN' ? '家庭组' : 'Family Group'; familyBtn.onmouseenter = () => { familyBtn.style.background = 'rgba(245,158,11,0.25)'; familyBtn.style.borderColor = 'rgba(245,158,11,0.4)'; }; familyBtn.onmouseleave = () => { familyBtn.style.background = 'rgba(245,158,11,0.12)'; familyBtn.style.borderColor = 'rgba(245,158,11,0.2)'; }; familyBtn.onclick = () => { if (!familyPopupEl) { if (!state.family.popupSteamid) { const ownBlock = document.querySelector('.friend_block_v2[data-steamid]'); if (ownBlock) state.family.popupSteamid = ownBlock.dataset.steamid; if (!state.family.popupSteamid) state.family.popupSteamid = storage.getSteamId() || detectCurrentSteamId(); } if (state.family.popupSteamid) showFamilyPopup(state.family.popupSteamid); return; } familyPopupEl.classList.toggle('sfd-show'); }; btnGroup.appendChild(familyBtn); // 把按钮组插到进度条后面 progBar.insertAdjacentElement('afterend', btnGroup); // 4. 排序按钮区域美化 const sortBox = titleBar.querySelector('.as-sortbox'); if (sortBox) { sortBox.style.cssText = 'display:flex;align-items:center;gap:4px;'; const sortLabel = sortBox.querySelector('.as-sortbox__label'); if (sortLabel) sortLabel.style.cssText = 'font-size:11px;color:#94a3b8;'; const sortTrigger = sortBox.querySelector('.as-sortbox__trigger'); if (sortTrigger) sortTrigger.style.cssText = 'background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.15);border-radius:5px;color:#94a3b8;font-size:11px;padding:3px 8px;cursor:pointer;transition:all 0.2s;'; const sortReverse = sortBox.querySelector('.as-sortbox__reverse'); if (sortReverse) sortReverse.style.cssText = 'background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.15);border-radius:5px;color:#94a3b8;font-size:11px;padding:3px 6px;cursor:pointer;transition:all 0.2s;'; } // 5. 管理好友列表 + 添加好友(带 SVG 图标 + tooltip) const manageBtn = titleBar.querySelector('#manage_friends_control'); const addBtn = titleBar.querySelector('#add_friends_button'); if (manageBtn && addBtn) { // 创建长胶囊容器 const pillWrap = document.createElement('span'); pillWrap.style.cssText = 'display:inline-flex;align-items:stretch;border-radius:14px;overflow:hidden;border:1px solid rgba(59,130,246,0.2);flex-shrink:0;margin-left:auto;order:98;'; // 管理按钮(左半边) manageBtn.style.cssText = 'background:rgba(59,130,246,0.1)!important;background-image:none!important;color:#66c0f4!important;padding:3px 8px!important;cursor:pointer!important;border:none!important;display:inline-flex!important;align-items:center!important;gap:4px!important;transition:background 0.2s!important;white-space:nowrap!important;border-radius:0!important;box-shadow:none!important;text-shadow:none!important;line-height:normal!important;height:auto!important;font-size:11px!important;'; manageBtn.title = locale === 'zh-CN' ? '管理好友列表' : 'Manage Friends'; const manageBtnSpan = manageBtn.querySelector('span'); if (manageBtnSpan) { manageBtnSpan.style.cssText = 'display:inline-flex!important;align-items:center!important;gap:4px!important;background:none!important;color:inherit!important;font-size:11px!important;'; manageBtnSpan.innerHTML = ICONS.manageList + '' + (locale === 'zh-CN' ? '管理好友' : 'Manage') + ''; } manageBtn.onmouseenter = () => { manageBtn.style.background = 'rgba(59,130,246,0.2)'; }; manageBtn.onmouseleave = () => { manageBtn.style.background = 'rgba(59,130,246,0.1)'; }; // 添加按钮(右半边) addBtn.style.cssText = 'background:rgba(16,185,129,0.1)!important;background-image:none!important;color:#10b981!important;padding:3px 8px!important;cursor:pointer!important;border:none!important;display:inline-flex!important;align-items:center!important;gap:4px!important;transition:background 0.2s!important;white-space:nowrap!important;border-radius:0!important;box-shadow:none!important;text-shadow:none!important;line-height:normal!important;height:auto!important;font-size:11px!important;'; addBtn.title = locale === 'zh-CN' ? '添加好友' : 'Add Friend'; const addBtnSpan = addBtn.querySelector('span'); if (addBtnSpan) { addBtnSpan.style.cssText = 'display:inline-flex!important;align-items:center!important;gap:4px!important;background:none!important;color:inherit!important;font-size:11px!important;'; addBtnSpan.innerHTML = ICONS.userPlus + '' + (locale === 'zh-CN' ? '添加好友' : 'Add') + ''; } addBtn.onmouseenter = () => { addBtn.style.background = 'rgba(16,185,129,0.2)'; }; addBtn.onmouseleave = () => { addBtn.style.background = 'rgba(16,185,129,0.1)'; }; // 包裹两个按钮 manageBtn.parentNode.insertBefore(pillWrap, manageBtn); pillWrap.appendChild(manageBtn); pillWrap.appendChild(addBtn); } else { if (manageBtn) { manageBtn.style.cssText = 'background:rgba(59,130,246,0.12)!important;border:1px solid rgba(59,130,246,0.2)!important;border-radius:20px!important;color:#66c0f4!important;font-size:11px!important;padding:4px 10px!important;cursor:pointer!important;transition:all 0.2s!important;display:inline-flex!important;align-items:center!important;gap:3px!important;'; manageBtn.title = locale === 'zh-CN' ? '管理好友列表' : 'Manage Friends'; } if (addBtn) { addBtn.style.cssText = 'background:rgba(16,185,129,0.12)!important;border:1px solid rgba(16,185,129,0.2)!important;border-radius:20px!important;color:#10b981!important;font-size:11px!important;padding:4px 10px!important;cursor:pointer!important;transition:all 0.2s!important;display:inline-flex!important;align-items:center!important;gap:3px!important;'; addBtn.title = locale === 'zh-CN' ? '添加好友' : 'Add Friend'; } } // 6. 删除原有的右下角浮动触发按钮 if (panelTrigger) panelTrigger.remove(); if (familyTrigger) familyTrigger.remove(); if (libraryTrigger) libraryTrigger.remove(); } function init() { if (!window.location.pathname.includes('/friends')) return; initUI(); // ===== Title Bar 美化 + 将三个触发按钮移入 ===== beautifyTitleBar(); // 首次使用无 API Key 时,自动弹出好友管理窗口并打开设置页 if (!storage.getApiKey()) { panelEl.classList.add('sfd-show'); toggleSettings(); } const onKey = (e) => { if (e.target.matches('input, select, textarea')) { if (e.key === 'Escape') e.target.blur(); return; } if (e.key === 'Escape') { const monthGamesPopup = document.getElementById('sfd-month-games-popup'); if (monthGamesPopup) { monthGamesPopup.remove(); return; } const my90GamesPopup = document.getElementById('sfd-my90-games-popup'); if (my90GamesPopup) { my90GamesPopup.remove(); return; } const memberGamesPopup = document.getElementById('sfd-member-games-popup'); if (memberGamesPopup) { memberGamesPopup.remove(); return; } const contribOverlay = document.getElementById('sfd-contrib-overlay'); if (contribOverlay) { contribOverlay.remove(); return; } if (familyPopupEl && familyPopupEl.classList.contains('sfd-show')) familyPopupEl.classList.remove('sfd-show'); else if (panelEl && panelEl.classList.contains('sfd-show')) panelEl.classList.remove('sfd-show'); } }; document.addEventListener('keydown', onKey); state.ui.disposers.push(() => document.removeEventListener('keydown', onKey)); if (state.friends.data.length) { setTimeout(() => applyPageEnhancements(state.friends.data), 1000); } // VAC 检查:先从缓存加载 const apiKey = storage.getApiKey(); if (apiKey) { loadVACFromCache(); loadLevelFromCache(); } const targetNode = document.querySelector('.profile_friends_list') || document.body; const observer = new MutationObserver((mutationsList) => { let shouldUpdate = false; for (const mutation of mutationsList) { if (mutation.type === 'childList') { const added = Array.from(mutation.addedNodes); if (added.some(node => node.nodeType === 1 && (node.classList.contains('friend_block_v2') || node.querySelector('.friend_block_v2')))) { shouldUpdate = true; break; } } } if (shouldUpdate && state.friends.data.length) applyPageEnhancements(state.friends.data); }); observer.observe(targetNode, { childList: true, subtree: true }); state.ui.disposers.push(() => observer.disconnect()); window.addEventListener('beforeunload', () => { state.ui.disposers.forEach(d => d()); state.ui.disposers = []; }); } // ==================== 社交仪表盘 (v1.0.17) ==================== let dashTipEl = null; const DASH_HEAT_DAYS = 14; const DASH_RECENT_TOP = 10; const DASH_OWNED_TOP = 10; const DASH_FRIENDS_TOP = 10; const DASH_HEAT_ROWS = 12; const DASH_COLORS = ['#38bdf8', '#a3e635', '#fbbf24', '#c084fc', '#f472b6', '#2dd4bf', '#fb7185', '#94a3b8']; const DASH_HEAT_SCALE = ['rgba(255,255,255,0.07)', 'rgba(56,189,248,0.28)', 'rgba(56,189,248,0.5)', 'rgba(56,189,248,0.78)', '#38bdf8']; const DASH_ZH = () => locale === 'zh-CN'; function dashEsc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function dashDateKey(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function dashFmtH(minutes) { if (!minutes || minutes <= 0) return locale === 'zh-CN' ? '0分' : '0m'; const zh = locale === 'zh-CN'; if (minutes < 60) return zh ? `${Math.round(minutes)}分钟` : `${Math.round(minutes)}m`; const hrs = minutes / 60; return zh ? `${hrs >= 100 ? Math.round(hrs) : hrs.toFixed(1)}小时` : `${hrs >= 100 ? Math.round(hrs) : hrs.toFixed(1)}h`; } function dashCapsule(appid) { return `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/capsule_184x69.jpg`; } function dashCapsuleFallback(appid) { return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_184x69.jpg`; } const DASH_CAPSULE_CDN = 'https://cdn.cloudflare.steamstatic.com/steam/apps/'; const DASH_CAPSULE_PATHS = ['capsule_467x181.jpg', 'capsule_231x87.jpg', 'capsule_231x87.png', 'capsule_184x69.jpg', 'capsule_184x69.png', 'header.jpg']; const _dashCapsuleGood = new Map(); const _dashCapsuleApiCache = new Map(); const _dashCapsuleApiPending = new Map(); const DB_CAPSULE_PREFIX = 'sfd_capsule_'; // 启动时从 IndexedDB 恢复封面缓存 function _restoreCapsuleCache() { Object.keys(_dbCache).forEach(key => { if (key.startsWith(DB_CAPSULE_PREFIX)) { const appid = Number(key.slice(DB_CAPSULE_PREFIX.length)); if (appid && _dbCache[key]) _dashCapsuleGood.set(appid, _dbCache[key]); } }); if (_dashCapsuleGood.size) console.log(`%c[SFD] 封面缓存已恢复 ${_dashCapsuleGood.size} 条`, 'color:#66c0f4'); } // 持久化封面 URL 到 IndexedDB function _persistCapsuleUrl(appid, url) { if (url && url.startsWith('http')) { _dashCapsuleGood.set(appid, url); _dbSet(DB_CAPSULE_PREFIX + appid, url); } } function dashFetchCapsuleFromStore(appid) { if (_dashCapsuleApiCache.has(appid)) return Promise.resolve(_dashCapsuleApiCache.get(appid)); if (_dashCapsuleApiPending.has(appid)) return _dashCapsuleApiPending.get(appid); const p = SteamAPI.getAppDetails(appid) .then(json => { let url = ''; const d = json && json[appid]; if (d && d.success && d.data && d.data.capsule_image) url = d.data.capsule_image; _dashCapsuleApiCache.set(appid, url); _dashCapsuleApiPending.delete(appid); if (url) _persistCapsuleUrl(appid, url); return url; }) .catch(() => { _dashCapsuleApiCache.set(appid, ''); _dashCapsuleApiPending.delete(appid); return ''; }); _dashCapsuleApiPending.set(appid, p); return p; } // 创建胶囊图破碎占位图标 function _makeBrokenIcon(small) { const icon = h('div', { class: 'sfd-img-broken-icon' + (small ? ' sfd-img-broken-sm' : ''), html: ICONS.steam }); return icon; } // 标记 wrapper 为加载完成 function _markLoaded(wrapper) { wrapper.classList.add('sfd-img-loaded'); } // 标记 wrapper 为碎图 function _markBroken(wrapper, small) { wrapper.classList.add('sfd-img-broken'); wrapper.appendChild(_makeBrokenIcon(small)); } // 创建带科技感加载骨架的图片 wrapper function createImgWrapper(appid, className, small) { const wrapper = h('div', { class: 'sfd-img-wrapper' + (className ? ' ' + className : '') }); const img = h('img', { alt: '', loading: 'lazy', dataset: { appid: String(appid) } }); wrapper.appendChild(img); return { wrapper, img }; } // 为已有的 img 元素包裹 wrapper(用于 dashLoadCapsule 等场景) function wrapImgWithLoader(img, appid, wrapperClass) { if (img.parentElement && img.parentElement.classList.contains('sfd-img-wrapper')) return { wrapper: img.parentElement, img }; const wrapper = h('div', { class: 'sfd-img-wrapper' + (wrapperClass ? ' ' + wrapperClass : '') }); wrapper.dataset.appid = String(appid); if (img.parentElement) { img.parentElement.replaceChild(wrapper, img); } wrapper.appendChild(img); return { wrapper, img }; } function dashLoadCapsule(img, appid) { // 确保图片在 wrapper 中 const { wrapper } = wrapImgWithLoader(img, appid, 'sfd-img-cap'); const chain = []; const good = _dashCapsuleGood.get(appid); if (good) chain.push(good); DASH_CAPSULE_PATHS.forEach(p => chain.push(DASH_CAPSULE_CDN + appid + '/' + p)); chain.push(`https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/capsule_184x69.jpg`); let idx = 0; const onDone = () => { _markLoaded(wrapper); }; const onBroken = () => { img.onload = null; img.onerror = null; _markBroken(wrapper); }; img.onload = () => { if (img.src && img.src !== _dashCapsuleGood.get(appid)) _persistCapsuleUrl(appid, img.src); onDone(); }; const toPlaceholder = () => { img.onload = null; img.onerror = null; img.style.display = 'none'; _markBroken(wrapper); }; const tryNext = () => { if (idx >= chain.length) { dashFetchCapsuleFromStore(appid).then(url => { if (url) { img.onerror = toPlaceholder; img.src = url; } else toPlaceholder(); }).catch(toPlaceholder); return; } img.onerror = () => { idx++; tryNext(); }; img.src = chain[idx]; }; tryNext(); // 异步尝试中文封面,成功则替换 tryZhImage(img, appid, 'capsule_184x69_schinese.jpg'); return wrapper; } // 通用图片加载:自动包裹 wrapper,带加载态和碎图处理 function loadGameImage(wrapperOrImg, appid, primaryUrl, fallbackUrl, wrapperClass, small, zhFile) { const isImg = wrapperOrImg.tagName === 'IMG'; const img = isImg ? wrapperOrImg : wrapperOrImg.querySelector('img') || h('img', { alt: '', loading: 'lazy' }); let wrapper; if (isImg) { const r = wrapImgWithLoader(wrapperOrImg, appid, wrapperClass); wrapper = r.wrapper; // img already inside wrapper } else { wrapper = wrapperOrImg; if (!wrapper.classList.contains('sfd-img-wrapper')) wrapper.classList.add('sfd-img-wrapper'); if (wrapperClass) wrapper.classList.add(wrapperClass); if (!wrapper.contains(img)) wrapper.appendChild(img); } wrapper.dataset.appid = String(appid); let triedPrimary = false; const onDone = () => { _markLoaded(wrapper); }; const onBroken = () => { img.onload = null; img.onerror = null; _markBroken(wrapper, small); }; const tryPrimary = () => { triedPrimary = true; img.onerror = () => { if (fallbackUrl) { img.onerror = onBroken; img.src = fallbackUrl; } else onBroken(); }; img.onload = onDone; img.src = primaryUrl; }; tryPrimary(); if (zhFile && locale === 'zh-CN') { tryZhImage(img, appid, zhFile); } } // ===== 异步中文封面加载 ===== const _zhImgGood = new Map(); // appid -> 中文封面URL const _zhImgFail = new Set(); // appid -> 无中文封面 function tryZhImage(img, appid, zhFileName) { if (locale !== 'zh-CN' || !appid) return; const cached = _zhImgGood.get(appid); if (cached) { img.src = cached; return; } if (_zhImgFail.has(appid)) return; const zhUrl = `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/${zhFileName}`; const tester = new Image(); tester.onload = () => { _zhImgGood.set(appid, zhUrl); img.src = zhUrl; }; tester.onerror = () => { _zhImgFail.add(appid); }; tester.src = zhUrl; } function dashAvatarFallback(el) { if (el.dataset.fb) { el.style.visibility = 'hidden'; return; } el.dataset.fb = '1'; el.src = DEFAULT_AVATAR; } function dashSvg(tag, attrs = {}) { const el = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); return el; } function dashTip(html, x, y) { if (!dashTipEl) { dashTipEl = h('div', { class: 'sfd-dash-tip' }); document.body.appendChild(dashTipEl); } if (dashTipEl.innerHTML !== html) { dashTipEl.innerHTML = html; dashTipEl.querySelectorAll('img').forEach(im => im.addEventListener('error', () => { const a = im.dataset.appid; if (a && !im.dataset.fb) { im.dataset.fb = '1'; im.src = dashCapsuleFallback(a); } else im.remove(); })); } dashTipEl.style.display = 'block'; const pad = 14; const rect = dashTipEl.getBoundingClientRect(); let left = x + pad, top = y + pad; if (left + rect.width > window.innerWidth - 10) left = x - rect.width - pad; if (left < 4) left = 4; if (top + rect.height > window.innerHeight - 10) top = y - rect.height - pad; if (top < 4) top = 4; dashTipEl.style.left = left + 'px'; dashTipEl.style.top = top + 'px'; } function dashTipHide() { if (dashTipEl) dashTipEl.style.display = 'none'; } function dashBindTip(el, htmlFn) { el.addEventListener('mouseenter', e => dashTip(htmlFn(), e.clientX, e.clientY)); el.addEventListener('mousemove', e => dashTip(htmlFn(), e.clientX, e.clientY)); el.addEventListener('mouseleave', dashTipHide); } // —— 数据获取 —— async function dashFetchRecent(sid) { const data = await SteamAPI.getRecentGamesLegacy(sid); const resp = data && data.response; if (!resp || Object.keys(resp).length === 0) return { games: [], private: true }; return { private: false, games: (resp.games || []).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtime_2weeks: g.playtime_2weeks || 0, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '' })) }; } async function fetchOwnedGamesCached(sid) { const now = Date.now(); // 优先读取按好友存储的缓存 const c = storage.getOwnedGameCache(sid); if (c && now - c.ts < OWNED_GAMES_TTL && !c.error) return c; try { const data = await SteamAPI.getOwnedGamesLegacy(sid); const resp = data && data.response; if (!resp || typeof resp.game_count !== 'number' || resp.game_count === 0) { const r = { sid, games: [], game_count: 0, totalMinutes: 0, private: true, ts: now }; storage.setOwnedGameCache(sid, r); const summary = storage.getOwnedGamesSummary(); summary[sid] = { ts: now, game_count: 0, totalMinutes: 0, private: true }; summary._ts = now; storage.setOwnedGamesSummary(summary); return r; } let total = 0; const games = (resp.games || []).map(g => { total += g.playtime_forever || 0; return { appid: g.appid, name: g.name || `App ${g.appid}`, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '', rtime_last_played: g.rtime_last_played || 0 }; }); const r = { sid, games, game_count: resp.game_count, totalMinutes: total, private: false, ts: now }; storage.setOwnedGameCache(sid, r); const summary = storage.getOwnedGamesSummary(); summary[sid] = { ts: now, game_count: r.game_count, totalMinutes: r.totalMinutes, private: false }; summary._ts = now; storage.setOwnedGamesSummary(summary); return r; } catch (e) { return { sid, games: [], game_count: 0, totalMinutes: 0, private: false, error: true, ts: now }; } } async function dashFetchOwned(sid) { const r = await fetchOwnedGamesCached(sid); // 同时记录timeline,检测新增游戏(与好友入库动态共用逻辑) if (!r.private && !r.error) recordTimeline(sid, r); return { game_count: r.game_count, totalMinutes: r.totalMinutes, private: r.private, error: r.error }; } // 共享:入库动态timeline对比记录(仪表盘和好友窗口共用) function recordTimeline(sid, cached) { try { const allGames = (cached.games || []).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '' })); const currentAppIds = allGames.map(g => g.appid); const checkTs = Date.now(); const totalGames = cached.game_count || allGames.length; let timeline = storage.getTimeline(sid); let isFirst = false; let newAppIds = []; if (!timeline) { isFirst = true; timeline = { latestAppIds: currentAppIds, gameInfo: Object.fromEntries(allGames.map(g => [g.appid, { name: g.name, img_icon_url: g.img_icon_url }])), records: [{ ts: checkTs, type: 'first', totalGames }] }; } else { const oldSet = new Set(timeline.latestAppIds); newAppIds = currentAppIds.filter(id => !oldSet.has(id)); if (!timeline.gameInfo) timeline.gameInfo = {}; allGames.forEach(g => { if (newAppIds.includes(g.appid) && !timeline.gameInfo[g.appid]) timeline.gameInfo[g.appid] = { name: g.name, img_icon_url: g.img_icon_url }; }); timeline.records.push({ ts: checkTs, type: 'check', newAppIds, totalGames }); timeline.latestAppIds = currentAppIds; } storage.setTimeline(sid, timeline); return { timeline: timeline.records, gameInfo: timeline.gameInfo, total: totalGames, isFirst, allGames, newAppIds }; } catch (e) { logger.warn('记录入库动态失败', e); return null; } } async function fetchRecentCached(sid) { const cache = storage.getRecentCache() || {}; const c = cache[sid]; const now = Date.now(); if (c && now - c.ts < RECENT_TTL && !c.error) return c; try { const r = await dashFetchRecent(sid); r.ts = now; cache[sid] = r; cache._ts = now; storage.setRecentCache(cache); return r; } catch (e) { return { games: [], error: true, ts: now }; } } async function dashFetchFriendsCount(sid) { try { const data = await SteamAPI.getFriendsLegacy(sid); const resp = data && data.friendslist; if (!resp || !Array.isArray(resp.friends)) return { friendcount: 0, private: true }; return { friendcount: resp.friends.length, private: false }; } catch (e) { return { friendcount: 0, private: true }; } } function dashSetProgress(text, pct) { state.dashboard.progress = text; state.dashboard.progressPct = Math.max(0, Math.min(99, Math.round(pct))); if (!dom.dashProgressText || !dom.dashProgressText.isConnected) dom.dashProgressText = document.getElementById('sfd-dash-progress-text'); if (!dom.dashProgressFill || !dom.dashProgressFill.isConnected) dom.dashProgressFill = document.getElementById('sfd-dash-progress-fill'); if (dom.dashProgressText) dom.dashProgressText.textContent = text || ''; if (dom.dashProgressFill) dom.dashProgressFill.style.width = state.dashboard.progressPct + '%'; } function dashSetStage(stage, totalStages, text) { state.dashboard.stage = stage; const stages = document.querySelectorAll('.sfd-dash-load-stage'); stages.forEach((el, i) => { el.classList.remove('active', 'done'); if (i < stage - 1) el.classList.add('done'); else if (i === stage - 1) el.classList.add('active'); }); if (text) dashSetProgress(text, state.dashboard.progressPct); } function dashDimAllAvatars() { const grid = document.getElementById('sfd-dash-load-grid'); if (grid) grid.querySelectorAll('.sfd-dash-load-cell.loaded').forEach(c => { c.classList.add('dimmed'); c.classList.remove('active'); }); } function dashLightUpAll(sids, interval = 15) { return new Promise(resolve => { let done = 0; sids.forEach((sid, idx) => { setTimeout(() => { const cell = document.querySelector(`.sfd-dash-load-cell[data-sid="${sid}"]`); if (cell) { cell.classList.remove('dimmed'); cell.classList.add('active'); setTimeout(() => { cell.classList.add('loaded'); cell.classList.remove('active'); }, 150); } done++; if (done === sids.length) resolve(); }, idx * interval); }); }); } function dashSetCurrentFriend(sid) { const cell = document.querySelector(`.sfd-dash-load-cell[data-sid="${sid}"]`); if (cell) { cell.classList.remove('dimmed'); cell.classList.add('active'); setTimeout(() => { cell.classList.add('loaded'); cell.classList.remove('active'); }, 150); } } function dashRecordSnapshot(ownedMap) { try { const snaps = storage.getDashSnaps(); const today = dashDateKey(new Date()); const dayData = {}; for (const sid in ownedMap) { const o = ownedMap[sid]; if (!o.private && !o.error && o.totalMinutes > 0) dayData[sid] = o.totalMinutes; } if (Object.keys(dayData).length) snaps[today] = dayData; const keys = Object.keys(snaps).sort(); while (keys.length > 45) delete snaps[keys.shift()]; storage.setDashSnaps(snaps); } catch (e) { logger.warn('仪表盘快照记录失败', e); } } function dashAggregate(friends, summaries, recentMap, ownedMap, friendMap) { const zh = DASH_ZH(); const fMap = {}; friends.forEach(f => { fMap[f.steamid] = f; }); const prof = (sid) => { const s = summaries[sid] || {}; const f = fMap[sid] || {}; return { steamid: sid, personaname: s.personaname || f.personaname || sid, avatar: s.avatarmedium || s.avatar || f.avatar || f.avatarmedium || '', personastate: s.personastate !== undefined ? s.personastate : (f.personastate || 0), gameextrainfo: s.gameextrainfo || f.gameextrainfo || '', profileurl: s.profileurl || f.profileurl || `https://steamcommunity.com/profiles/${sid}/` }; }; const recentRank = []; const gameAgg = {}; let total2wAll = 0, publicCount = 0, privateCount = 0; for (const sid in recentMap) { const r = recentMap[sid]; if (r.error) continue; if (r.private) { privateCount++; continue; } publicCount++; let sum = 0; r.games.forEach(g => { sum += g.playtime_2weeks; if (g.playtime_2weeks > 0) { if (!gameAgg[g.appid]) gameAgg[g.appid] = { appid: g.appid, name: g.name, minutes: 0, players: 0 }; gameAgg[g.appid].minutes += g.playtime_2weeks; gameAgg[g.appid].players++; } }); total2wAll += sum; if (sum > 0) { const topGames = r.games.filter(g => g.playtime_2weeks > 0).sort((a, b) => b.playtime_2weeks - a.playtime_2weeks).slice(0, 3); recentRank.push({ ...prof(sid), total2w: sum, topGames }); } } recentRank.sort((a, b) => b.total2w - a.total2w); const hotAll = Object.values(gameAgg).sort((a, b) => b.minutes - a.minutes); const hotGames = hotAll.slice(0, 10).map(g => ({ ...g })); const rest = hotAll.slice(10); const restMin = rest.reduce((s, g) => s + g.minutes, 0); if (restMin > 0) hotGames.push({ appid: 0, name: zh ? '其他' : 'Others', minutes: restMin, players: rest.reduce((s, g) => s + g.players, 0) }); const ownedRank = []; for (const sid in ownedMap) { const o = ownedMap[sid]; if (o.private || o.error || !o.game_count) continue; ownedRank.push({ ...prof(sid), game_count: o.game_count, totalMinutes: o.totalMinutes }); } ownedRank.sort((a, b) => b.game_count - a.game_count); const friendsRank = []; for (const sid in friendMap) { const fr = friendMap[sid]; if (fr.private || fr.error || !fr.friendcount) continue; friendsRank.push({ ...prof(sid), friendcount: fr.friendcount }); } friendsRank.sort((a, b) => b.friendcount - a.friendcount); const online = []; if (Object.keys(summaries).length) { for (const sid in summaries) { if (summaries[sid].personastate > 0) online.push(prof(sid)); } } else { friends.forEach(f => { if (f.personastate > 0) online.push(prof(f.steamid)); }); } online.sort((a, b) => ((b.gameextrainfo ? 1 : 0) - (a.gameextrainfo ? 1 : 0)) || String(a.personaname).localeCompare(String(b.personaname))); const heatSids = recentRank.slice(0, DASH_HEAT_ROWS).map(r => r.steamid); if (heatSids.length < 6) { const byTotal = ownedRank.slice().sort((a, b) => b.totalMinutes - a.totalMinutes); for (const o of byTotal) { if (heatSids.length >= 6) break; if (!heatSids.includes(o.steamid)) heatSids.push(o.steamid); } } const heatmap = dashComputeHeatmap(storage.getDashSnaps(), heatSids, prof, recentMap); return { kpis: { total: getSteamFriendsCount(), online: online.length, ingame: online.filter(o => o.gameextrainfo).length, public: publicCount, private: privateCount, total2w: total2wAll }, recentRank: recentRank.slice(0, DASH_RECENT_TOP), hotGames, hotTotal: hotAll.reduce((s, g) => s + g.minutes, 0), hotCount: hotAll.length, online, ownedRank: ownedRank.slice(0, DASH_OWNED_TOP), friendsRank: friendsRank.slice(0, DASH_FRIENDS_TOP), heatmap, generatedAt: 0 }; } function dashComputeHeatmap(snaps, sids, prof, recentMap) { const days = []; for (let i = DASH_HEAT_DAYS - 1; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(dashDateKey(d)); } const snapDays = Object.keys(snaps).sort(); const rows = sids.map(sid => { const p = prof(sid); const cells = days.map(day => { const snap = snaps[day]; if (!snap || snap[sid] == null) return null; let prevDay = null; for (let j = snapDays.length - 1; j >= 0; j--) { if (snapDays[j] < day) { prevDay = snapDays[j]; break; } } if (!prevDay || !snaps[prevDay] || snaps[prevDay][sid] == null) return null; const delta = snap[sid] - snaps[prevDay][sid]; return delta >= 0 ? delta : null; }); const hasInc = cells.some(c => c != null); if (!hasInc && recentMap && recentMap[sid] && !recentMap[sid].error && !recentMap[sid].private) { let sum = 0; (recentMap[sid].games || []).forEach(g => { sum += g.playtime_2weeks || 0; }); if (sum > 0) { const daily = sum / 14; for (let i = Math.max(0, days.length - 14); i < days.length; i++) cells[i] = daily; } } return { steamid: sid, name: p.personaname, avatar: p.avatar, cells }; }); let max = 0; rows.forEach(r => r.cells.forEach(c => { if (c && c > max) max = c; })); return { days, rows, max, snapCount: snapDays.length }; } function dashHeatColor(v, max) { if (v == null) return 'rgba(255,255,255,0.028)'; if (v <= 0 || max <= 0) return DASH_HEAT_SCALE[0]; const r = v / max; if (r < 0.25) return DASH_HEAT_SCALE[1]; if (r < 0.5) return DASH_HEAT_SCALE[2]; if (r < 0.75) return DASH_HEAT_SCALE[3]; return DASH_HEAT_SCALE[4]; } async function loadDashboardData(forceRefresh = false) { if (state.dashboard.loading) return; const apiKey = storage.getApiKey(); const friends = state.friends.data.length ? state.friends.data : (storage.getCachedData() || []); const friendsMap = new Map(friends.map(f => [String(f.steamid), f])); if (!apiKey) { state.dashboard.error = 'noapikey'; state.dashboard.data = null; renderDashboard(); return; } if (!friends.length) { state.dashboard.error = 'nofriends'; state.dashboard.data = null; renderDashboard(); return; } if (!forceRefresh) { const cached = storage.getDashCache(); if (cached && cached.data) { state.dashboard.data = cached.data; state.dashboard.error = null; renderDashboard(); return; } } state.dashboard.loading = true; state.dashboard.error = null; state.dashboard.progressPct = 0; renderDashboard(); const zh = DASH_ZH(); try { const sids = friends.map(f => f.steamid); const delay = ms => new Promise(r => setTimeout(r, ms)); const now = Date.now(); // 阶段1:刷新好友在线状态 dashSetStage(1, 4, zh ? '正在刷新好友在线状态…' : 'Refreshing friend status…'); const summaries = {}; // 优先读取 summaries 缓存(5分钟TTL),避免与好友列表重复请求 const summariesCache = storage.getSummariesCache(); if (summariesCache && !forceRefresh) { Object.keys(summariesCache).forEach(sid => { if (sids.includes(sid)) summaries[sid] = summariesCache[sid]; }); } else { for (let i = 0; i < sids.length; i += SUMMARY_BATCH_SIZE) { const batch = sids.slice(i, i + SUMMARY_BATCH_SIZE); try { const d = await SteamAPI.getSummary(batch); ((d && d.response && d.response.players) || []).forEach(p => { summaries[p.steamid] = p; }); } catch (e) { logger.warn('仪表盘摘要批量获取失败', e); } } // 保存到 summaries 缓存 const sumToCache = { _ts: Date.now() }; Object.assign(sumToCache, summaries); storage.setSummariesCache(sumToCache); } state.dashboard.summaries = summaries; dashSetProgress(zh ? '正在加载头像…' : 'Loading avatars…', 3); for (let i = 0; i < sids.length; i++) { const sid = sids[i]; const cell = document.querySelector(`.sfd-dash-load-cell[data-sid="${sid}"]`); if (cell && !cell.classList.contains('loaded')) { const s = summaries[sid] || {}; const f = friendsMap.get(String(sid)) || {}; const avatar = s.avatarmedium || s.avatar || f.avatarmedium || f.avatar || ''; cell.classList.add('active'); const img = cell.querySelector('img'); if (img && avatar) { img.src = avatar; img.onload = () => { cell.classList.add('loaded'); cell.classList.remove('active'); }; img.onerror = () => { cell.classList.add('loaded'); cell.classList.remove('active'); }; } else { cell.classList.add('loaded'); cell.classList.remove('active'); } } if (i % 5 === 0) { dashSetProgress(state.dashboard.progress, 3 + (i / sids.length) * 2); await delay(0); } } await delay(500); dashDimAllAvatars(); await delay(300); // 阶段2:获取最近游玩记录 dashSetStage(2, 4, zh ? '正在获取最近游玩记录…' : 'Fetching recent games…'); const recentCache = storage.getRecentCache() || {}; const recentMap = {}; const needFetchRecent = []; const cachedRecent = []; sids.forEach(sid => { const c = recentCache[sid]; if (c && now - c.ts < RECENT_TTL && !c.error) { recentMap[sid] = c; cachedRecent.push(sid); } else needFetchRecent.push(sid); }); if (cachedRecent.length) { await dashLightUpAll(cachedRecent, 15); } if (needFetchRecent.length) { const recentTasks = needFetchRecent.map(sid => async () => { dashSetCurrentFriend(sid); try { recentMap[sid] = await fetchRecentCached(sid); } catch (e) { recentMap[sid] = { games: [], error: true }; } }); await _concurrentPool(recentTasks, 8, (done, total) => dashSetProgress(zh ? `正在获取最近游玩记录 (${done}/${total})…` : `Fetching recent games (${done}/${total})…`, 5 + 50 * done / total)); } await delay(500); dashDimAllAvatars(); await delay(300); // 阶段3:获取游戏库数据 dashSetStage(3, 4, zh ? '正在获取游戏库数据…' : 'Fetching game libraries…'); const ownedSummary = storage.getOwnedGamesSummary(); const ownedMap = {}; const needFetch = []; const cachedOwned = []; sids.forEach(sid => { const c = ownedSummary[sid]; if (c && now - c.ts < OWNED_GAMES_TTL && !c.error) { ownedMap[sid] = { game_count: c.game_count, totalMinutes: c.totalMinutes, private: c.private }; cachedOwned.push(sid); } else needFetch.push(sid); }); if (cachedOwned.length) { await dashLightUpAll(cachedOwned, 15); } if (needFetch.length) { const ownedTasks = needFetch.map(sid => async () => { dashSetCurrentFriend(sid); try { ownedMap[sid] = await dashFetchOwned(sid); } catch (e) { ownedMap[sid] = { game_count: 0, totalMinutes: 0, error: true }; } }); await _concurrentPool(ownedTasks, 6, (done, total) => dashSetProgress(zh ? `正在获取游戏库数据 (${done}/${total})…` : `Fetching game libraries (${done}/${total})…`, 55 + 22 * done / total)); } await delay(500); dashDimAllAvatars(); await delay(300); // 阶段4:获取好友列表数据 dashSetStage(4, 4, zh ? '正在获取好友列表数据…' : 'Fetching friend lists…'); const friendsCache = storage.getDashFriendsCache() || {}; const friendMap = {}; const needFetchFr = []; const cachedFriends = []; sids.forEach(sid => { const c = friendsCache[sid]; if (c && now - c.ts < DASH_FRIENDS_TTL) { friendMap[sid] = c; cachedFriends.push(sid); } else needFetchFr.push(sid); }); if (cachedFriends.length) { await dashLightUpAll(cachedFriends, 15); } if (needFetchFr.length) { const frTasks = needFetchFr.map(sid => async () => { dashSetCurrentFriend(sid); try { const r = await dashFetchFriendsCount(sid); r.ts = Date.now(); friendMap[sid] = r; friendsCache[sid] = r; } catch (e) { friendMap[sid] = { friendcount: 0, private: true, error: true }; } }); await _concurrentPool(frTasks, 6, (done, total) => dashSetProgress(zh ? `正在获取好友列表数据 (${done}/${total})…` : `Fetching friend lists (${done}/${total})…`, 77 + 20 * done / total)); friendsCache._ts = now; storage.setDashFriendsCache(friendsCache); } dashRecordSnapshot(ownedMap); dashSetProgress(zh ? '正在聚合统计数据…' : 'Aggregating…', 99); const data = dashAggregate(friends, summaries, recentMap, ownedMap, friendMap); data.generatedAt = Date.now(); state.dashboard.data = data; storage.setDashCache({ ts: data.generatedAt, data }); } catch (e) { logger.warn('仪表盘数据加载失败', e); state.dashboard.error = (e && e.message) || 'error'; } state.dashboard.loading = false; state.dashboard.progress = ''; state.dashboard.stage = 0; state.dashboard.summaries = null; renderDashboard(); } function dashCardTitle(icon, title, sub) { return h('div', { class: 'sfd-dash-card-title' }, [h('span', { html: icon, style: { display: 'inline-flex' } }), h('span', { text: title }), sub ? h('span', { class: 'sfd-dash-card-sub', text: sub }) : null]); } function dashRenderKpis(d) { const zh = DASH_ZH(); const k = d.kpis; const items = [ { iconSvg: ICONS.users, val: `${k.total} / ${getSteamFriendsLimit()}`, lbl: zh ? '好友总数' : 'Friends', accent: '#60a5fa' }, { iconSvg: ICONS.activity, val: k.online, lbl: zh ? '当前在线' : 'Online', accent: '#34d399' }, { iconSvg: ICONS.game, val: k.ingame, lbl: zh ? '游戏中' : 'In-Game', accent: '#a78bfa' }, { iconSvg: ICONS.clock, val: dashFmtH(k.total2w), lbl: zh ? '近2周总时长' : '2-Week Playtime', accent: '#fbbf24' }, { iconSvg: ICONS.trophy, val: k.public, lbl: zh ? '数据公开好友' : 'Public Profiles', accent: '#f472b6' } ]; return h('div', { class: 'sfd-dash-kpis' }, items.map(it => createMetricCard({ value: String(it.val), label: it.lbl, iconSvg: it.iconSvg, accent: it.accent, layout: 'row' }))); } function dashRenderRecentRank(d) { const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.trophy, zh ? '最近好友游戏时长排行' : 'Recent Playtime Ranking', zh ? '近2周 · Top 10' : '2 Weeks · Top 10')); if (!d.recentRank.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '近2周暂无好友游玩记录(或好友游戏详情为私密)' : 'No recent records (friends may be private)' })); return card; } const max = d.recentRank[0].total2w || 1; d.recentRank.forEach((r, i) => { const pct = Math.max(2, Math.round(r.total2w / max * 100)); const mid = h('div', { class: 'sfd-dash-rank-mid' }, [ h('div', { class: 'sfd-dash-rank-name' }, [h('span', { text: r.personaname }), h('span', { class: 'sfd-dash-rank-hours', text: dashFmtH(r.total2w) })]), ...r.topGames.map(g => h('div', { class: 'sfd-dash-rank-game' }, [h('span', { text: g.name }), h('b', { text: dashFmtH(g.playtime_2weeks) })])), h('div', { class: 'sfd-dash-rank-bar' }, [h('div', { class: 'sfd-dash-rank-fill', style: { width: pct + '%' } })]), h('div', { class: 'sfd-dash-rank-pct', text: zh ? `为第 1 名的 ${pct}%` : `${pct}% of #1` }) ]); const children = [h('div', { class: `sfd-dash-rank-no ${i === 0 ? 'r1' : i === 1 ? 'r2' : i === 2 ? 'r3' : ''}`, text: String(i + 1) }), h('img', { class: 'sfd-dash-rank-avatar', src: r.avatar, loading: 'lazy', onError: function () { dashAvatarFallback(this); } }), mid]; if (r.topGames[0] && r.topGames[0].appid) { const capImg = h('img', { class: 'sfd-dash-rank-cap', alt: '' }); children.push(dashLoadCapsule(capImg, r.topGames[0].appid)); } const row = h('div', { class: 'sfd-dash-rank', onClick: () => window.open(r.profileurl, '_blank') }, children); dashBindTip(row, () => { const games = r.topGames.map(g => `${dashEsc(g.name)} · ${dashFmtH(g.playtime_2weeks)}`).join('
'); return `${dashEsc(r.personaname)}
${zh ? '近2周总时长' : '2-week total'}: ${dashFmtH(r.total2w)}
${games}`; }); card.appendChild(row); }); return card; } function dashGameTipHtml(g, total) { const zh = DASH_ZH(); const pct = total > 0 ? (g.minutes / total * 100).toFixed(1) : '0'; const img = g.appid ? `` : ''; return `${img}${dashEsc(g.name)}
${zh ? '近2周' : '2 weeks'}: ${dashFmtH(g.minutes)} · ${pct}%
${g.players} ${zh ? '位好友在玩' : 'friends playing'}`; } function dashRenderHotGames(d) { const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.game, zh ? '热门游戏分布' : 'Popular Games', zh ? `近2周 · 共 ${d.hotCount} 款` : `2 weeks · ${d.hotCount} games`)); if (!d.hotGames.length || d.hotTotal <= 0) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '近2周暂无游戏记录' : 'No games played recently' })); return card; } const R = 70, C = 2 * Math.PI * R; const svg = dashSvg('svg', { width: '170', height: '170', viewBox: '0 0 170 170' }); svg.appendChild(dashSvg('circle', { cx: '85', cy: '85', r: String(R), stroke: 'rgba(255,255,255,0.05)', 'stroke-width': '24' })); let offset = 0; const segEls = []; d.hotGames.forEach((g, i) => { const frac = g.minutes / d.hotTotal; const len = Math.max(0.5, frac * C - 2); const color = DASH_COLORS[i % DASH_COLORS.length]; const c = dashSvg('circle', { cx: '85', cy: '85', r: String(R), stroke: color, 'stroke-width': '24', 'stroke-dasharray': `${len} ${C - len}`, 'stroke-dashoffset': String(-offset) }); offset += frac * C; const tipFn = () => dashGameTipHtml(g, d.hotTotal); c.addEventListener('mouseenter', e => dashTip(tipFn(), e.clientX, e.clientY)); c.addEventListener('mousemove', e => dashTip(tipFn(), e.clientX, e.clientY)); c.addEventListener('mouseleave', dashTipHide); if (g.appid) c.addEventListener('click', () => openStorePage(g.appid)); svg.appendChild(c); segEls.push(c); }); const donutBox = h('div', { class: 'sfd-dash-donut' }); donutBox.appendChild(svg); donutBox.appendChild(h('div', { class: 'sfd-dash-donut-center' }, [h('div', { class: 'sfd-dash-donut-total', text: dashFmtH(d.hotTotal) }), h('div', { class: 'sfd-dash-donut-total-lbl', text: zh ? '近2周总时长' : '2-Week Total' })])); const legend = h('div', { class: 'sfd-dash-legend' }); d.hotGames.forEach((g, i) => { const color = DASH_COLORS[i % DASH_COLORS.length]; const pct = g.minutes / d.hotTotal * 100; let capEl; if (g.appid) { capEl = h('img', { class: 'sfd-dash-legend-cap', alt: '' }); capEl = dashLoadCapsule(capEl, g.appid); } else { capEl = h('div', { class: 'sfd-dash-legend-cap sfd-dash-legend-cap-ph' }); } const item = h('div', { class: 'sfd-dash-legend-item' }, [h('span', { class: 'sfd-dash-legend-dot', style: { background: color } }), capEl, h('div', { class: 'sfd-dash-legend-mid' }, [h('div', { class: 'sfd-dash-legend-name', text: g.name, title: g.name }), h('div', { class: 'sfd-dash-legend-val', text: `${dashFmtH(g.minutes)} · ${g.players}${zh ? '人' : 'p'}` })]), h('span', { class: 'sfd-dash-legend-pct', text: pct.toFixed(1) + '%' })]); item.addEventListener('mouseenter', e => { segEls.forEach((s, j) => { s.style.opacity = j === i ? '1' : '0.22'; }); dashTip(dashGameTipHtml(g, d.hotTotal), e.clientX, e.clientY); }); item.addEventListener('mousemove', e => dashTip(dashGameTipHtml(g, d.hotTotal), e.clientX, e.clientY)); item.addEventListener('mouseleave', () => { segEls.forEach(s => { s.style.opacity = '1'; }); dashTipHide(); }); if (g.appid) item.addEventListener('click', () => openStorePage(g.appid)); legend.appendChild(item); }); card.appendChild(h('div', { class: 'sfd-dash-donut-wrap' }, [donutBox, legend])); return card; } function dashRenderHeatmap(d) { const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.trending, zh ? '好友游戏时长热力图' : 'Playtime Heatmap', zh ? `近 ${DASH_HEAT_DAYS} 天` : `Last ${DASH_HEAT_DAYS} days`)); const hm = d.heatmap; if (!hm || !hm.rows.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '暂无可展示的好友数据' : 'No data available' })); return card; } const grid = h('div', { class: 'sfd-dash-heat', style: { gridTemplateColumns: `118px repeat(${DASH_HEAT_DAYS}, 13px)` } }); grid.appendChild(h('div', {})); hm.days.forEach((day, i) => { const dt = new Date(day + 'T00:00:00'); const label = (i % 7 === 0 || i === DASH_HEAT_DAYS - 1) ? `${dt.getMonth() + 1}/${dt.getDate()}` : ''; grid.appendChild(h('div', { class: 'sfd-dash-heat-label', text: label })); }); hm.rows.forEach(row => { grid.appendChild(h('div', { class: 'sfd-dash-heat-name', title: row.name }, [row.avatar ? h('img', { src: row.avatar, loading: 'lazy', onError: function () { this.style.display = 'none'; } }) : null, h('span', { text: row.name, style: { overflow: 'hidden', textOverflow: 'ellipsis' } })])); row.cells.forEach((v, i) => { const cell = h('div', { class: 'sfd-dash-heat-cell', style: { background: dashHeatColor(v, hm.max) } }); const dt = new Date(hm.days[i] + 'T00:00:00'); const dateStr = zh ? `${dt.getMonth() + 1}月${dt.getDate()}日` : `${dt.getMonth() + 1}/${dt.getDate()}`; const valStr = v == null ? (zh ? '无数据' : 'No data') : v === 0 ? (zh ? '未游玩' : 'No play') : `${dashFmtH(v)}`; dashBindTip(cell, () => `${dashEsc(row.name)}
${dateStr} · ${valStr}`); grid.appendChild(cell); }); }); card.appendChild(h('div', { class: 'sfd-dash-heat-wrap' }, [grid])); const legendRow = h('div', { class: 'sfd-dash-heat-legend' }, [zh ? '少 ' : 'Less ']); DASH_HEAT_SCALE.forEach(c => legendRow.appendChild(h('i', { style: { background: c } }))); legendRow.appendChild(document.createTextNode(zh ? ' 多' : ' More')); card.appendChild(legendRow); if (hm.snapCount < 2) card.appendChild(h('div', { class: 'sfd-dash-hint', text: zh ? '当前显示近2周日均估算活跃度。持续使用后将基于每日快照精确计算每日游玩时长。' : 'Showing estimated daily averages for the last 2 weeks. Precise per-day data accumulates with daily use.' })); return card; } function dashRenderOwnedRank(d) { const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.package, zh ? '好友游戏总数排行' : 'Game Count Ranking', 'Top 10')); if (!d.ownedRank.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '暂无公开游戏库的好友' : 'No public libraries' })); return card; } const max = d.ownedRank[0].game_count || 1; d.ownedRank.forEach((r, i) => { const pct = Math.max(3, Math.round(r.game_count / max * 100)); const row = h('div', { class: 'sfd-dash-owned-row', onClick: () => window.open(r.profileurl, '_blank') }, [h('span', { class: 'sfd-dash-owned-no', text: String(i + 1) }), h('img', { class: 'sfd-dash-owned-avatar', src: r.avatar, loading: 'lazy', onError: function () { this.style.display = 'none'; } }), h('span', { class: 'sfd-dash-owned-name', text: r.personaname, title: r.personaname }), h('div', { class: 'sfd-dash-owned-bar' }, [h('div', { class: 'sfd-dash-owned-fill', style: { width: pct + '%' } })]), h('span', { class: 'sfd-dash-owned-val', html: `${r.game_count}${zh ? '款' : ''} ${dashFmtH(r.totalMinutes)}` })]); dashBindTip(row, () => `${dashEsc(r.personaname)}
${zh ? '游戏总数' : 'Games'}: ${r.game_count}
${zh ? '生涯总时长' : 'Total playtime'}: ${dashFmtH(r.totalMinutes)}`); card.appendChild(row); }); return card; } function dashRenderFriendsRank(d) { const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.users, zh ? '好友数排行' : 'Friends Count Ranking', 'Top 10')); if (!d.friendsRank.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '暂无公开好友列表的好友' : 'No public friend lists' })); return card; } const max = d.friendsRank[0].friendcount || 1; d.friendsRank.forEach((r, i) => { const pct = Math.max(3, Math.round(r.friendcount / max * 100)); const row = h('div', { class: 'sfd-dash-owned-row', onClick: () => window.open(r.profileurl, '_blank') }, [h('span', { class: 'sfd-dash-owned-no', text: String(i + 1) }), h('img', { class: 'sfd-dash-owned-avatar', src: r.avatar, loading: 'lazy', onError: function () { this.style.display = 'none'; } }), h('span', { class: 'sfd-dash-owned-name', text: r.personaname, title: r.personaname }), h('div', { class: 'sfd-dash-owned-bar' }, [h('div', { class: 'sfd-dash-fr-fill', style: { width: pct + '%' } })]), h('span', { class: 'sfd-dash-owned-val sfd-dash-fr-val', html: `${r.friendcount}${zh ? '人' : ''}` })]); dashBindTip(row, () => `${dashEsc(r.personaname)}
${zh ? '好友总数' : 'Friends'}: ${r.friendcount}`); card.appendChild(row); }); return card; } function renderDashboard() { const content = document.getElementById('sfd-dash-content'); const updatedEl = dom.dashUpdated; if (!content) return; const zh = DASH_ZH(); if (state.dashboard.loading) { // 加载中如果头像网格已存在(如关闭后重新打开),不重建,只更新进度 if (document.getElementById('sfd-dash-load-grid')) { if (dom.dashProgressText) dom.dashProgressText.textContent = state.dashboard.progress || (zh ? '正在加载…' : 'Loading…'); if (dom.dashProgressFill) dom.dashProgressFill.style.width = (state.dashboard.progressPct || 0) + '%'; return; } if (updatedEl) updatedEl.textContent = ''; content.innerHTML = ''; const friends = state.friends.data.length ? state.friends.data : (storage.getCachedData() || []); const loadChildren = [ h('div', { class: 'sfd-dash-load-stages' }, [1,2,3,4].map(() => h('div', { class: 'sfd-dash-load-stage' }))), h('div', { class: 'sfd-dash-load-text', id: 'sfd-dash-progress-text', text: state.dashboard.progress || (zh ? '正在加载…' : 'Loading…') }), h('div', { class: 'sfd-dash-load-mainbar' }, [h('div', { class: 'sfd-dash-load-mainbar-fill', id: 'sfd-dash-progress-fill', style: { width: (state.dashboard.progressPct || 0) + '%' } })]), h('div', { style: { display: 'none' } }, [h('img', { id: 'sfd-dash-cur-avatar', src: '' }), h('div', { id: 'sfd-dash-cur-name', text: '' })]), ]; if (friends.length) { const grid = h('div', { class: 'sfd-dash-load-grid', id: 'sfd-dash-load-grid' }); const summaries = state.dashboard.summaries || storage.getSummariesCache() || {}; friends.forEach(f => { const cell = h('div', { class: 'sfd-dash-load-cell', dataset: { sid: f.steamid }, title: f.personaname || f.steamid }); const s = summaries[f.steamid] || {}; const avatar = s.avatarmedium || s.avatar || f.avatar || f.avatarmedium || ''; const img = h('img', { alt: '', loading: 'lazy' }); if (avatar) { img.src = avatar; cell.classList.add('loaded'); } cell.appendChild(img); grid.appendChild(cell); }); loadChildren.push(grid); } content.appendChild(h('div', { style: { display: 'flex', flexDirection: 'column', height: '100%' } }, loadChildren)); // 恢复阶段指示器和已加载头像的 dimmed 状态 if (state.dashboard.stage) { dashSetStage(state.dashboard.stage, 4, state.dashboard.progress); } if (state.dashboard.stage >= 2) { dashDimAllAvatars(); } return; } if (state.dashboard.error) { if (updatedEl) updatedEl.textContent = ''; content.innerHTML = ''; let tipText; if (state.dashboard.error === 'noapikey') tipText = zh ? '请先点击面板右上角设置按钮,配置 Steam Web API Key。' : 'Please configure your Steam Web API Key in the panel settings first.'; else if (state.dashboard.error === 'nofriends') tipText = zh ? '暂无好友数据,请先在「好友列表」Tab 点击「获取好友数据」。' : 'No friend data. Please fetch friends in the panel first.'; else tipText = (zh ? '加载失败:' : 'Failed: ') + state.dashboard.error; content.appendChild(h('div', { class: 'sfd-dash-empty' }, [h('div', { text: tipText }), h('div', { style: { marginTop: '12px' } }, [h('button', { class: 'sfd-btn sfd-btn-primary', text: zh ? '重试' : 'Retry', onClick: () => loadDashboardData(true) })])])); return; } const d = state.dashboard.data; if (!d) { content.innerHTML = ''; content.appendChild(h('div', { class: 'sfd-dash-empty' }, [h('div', { text: zh ? '点击上方刷新按钮,生成你的好友社交数据仪表盘' : 'Click refresh to generate your social dashboard' }), h('div', { style: { marginTop: '12px' } }, [h('button', { class: 'sfd-btn sfd-btn-primary', text: zh ? '加载数据' : 'Load Data', onClick: () => loadDashboardData(true) })])])); return; } if (updatedEl) updatedEl.textContent = (zh ? '更新于 ' : 'Updated ') + new Date(d.generatedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); content.innerHTML = ''; content.appendChild(dashRenderKpis(d)); content.appendChild(h('div', { class: 'sfd-dash-grid' }, [h('div', { class: 'sfd-dash-col' }, [dashRenderRecentRank(d), dashRenderHeatmap(d)]), h('div', { class: 'sfd-dash-col' }, [dashRenderHotGames(d), dashRenderOwnedRank(d), dashRenderFriendsRank(d)])])); } // 先从 IndexedDB 加载缓存到内存镜像,再恢复封面缓存并初始化 UI _loadIDBCache().then(() => { _restoreCapsuleCache(); init(); logStorageSize(); }); })();