// ==UserScript== // @name Steam 好友管理器 // @namespace steam-friend-manager // @version 1.5.3 // @description 在 Steam 好友页面增强展示好友天数、国家/地区旗帜、VAC封禁、最近在线、在线状态。提供控制浮窗、好友总览(个人信息+KPI+筛选表格一体化)、家庭组浮窗、个人游戏库浮窗、游戏库浮窗(精品游戏/系列游戏/Humble Choice 月包库+分析入库)。支持好友动态监控、入库/游玩动态追踪、绝版收藏检测、共同好友查询。社交仪表盘浮窗:好友游戏时长排行、热门游戏分布图、游戏时长热力图、游戏总数排行。 // @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 // @resource humbleChoiceData https://raw.githubusercontent.com/SmallFork/json/main/Humble_Choice.json // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addStyle // @grant GM_getResourceText // @run-at document-idle // @tag Steam // @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'); /* ================================================================ * PART 1 · 常量与静态资源 * ================================================================ */ // ==================== 全局常量配置 ==================== // ===== 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 MY_LIBRARY_TTL = 24 * 60 * 60 * 1000; // 我的游戏库数据缓存24小时(全部/已玩/未玩/游玩时长/最近游玩/入库动态/绝版收藏,与数据时间戳对比) 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 FAMILY_REFRESH_COOLDOWN = 24 * 60 * 60 * 1000; // 家庭组缓存TTL 24小时(TTL内开窗直接渲染缓存不发请求,超时才后台刷新;手动按钮始终强制刷新) const STRESS_DETECT_TTL = 24 * 60 * 60 * 1000; // 预购游戏网页检测结果缓存1天 const CHART_COLORS = ['#06cfbe', '#54a0ff', '#ff9f43', '#a29bfe', '#ff6b6b', '#2ed573']; const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent(''); // ==================== 共享工具(合并重复实现) ==================== const DELISTED_TYPES = [ { key: 'delisted', color: '#f44336' }, { key: 'purchase_disabled', color: '#ff9800' }, { key: 'f2p_unavailable', color: '#ce93d8' }, { key: 'retail_only', color: '#64b5f6' }, { key: 'test_app', color: '#90a4ae' } ]; 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})`; } // 头像加载失败回退默认图(img onerror 共享处理器) function sfAvatarError() { this.removeEventListener('error', sfAvatarError); this.src = DEFAULT_AVATAR; } // 按月聚合每个成员的入库数(仅计入首次入库成员,后续重复购买不计数) function buildMonthlyFirstOwnerMap(gameInfo) { const monthlyMap = new Map(); Object.entries(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); const firstOwner = (info.owners || [])[0]; if (firstOwner) entry[firstOwner] = (entry[firstOwner] || 0) + 1; }); return monthlyMap; } // 按拥有者数量分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)]; } // ==================== SVG 图标 (精致笔画风格) ==================== const _S = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; const VAC_SHIELD_D = 'M12 22s8-3 8-10V7l-8-4-8 4v5c0 7 8 10 8 10z'; const ICONS = { // 通用 UI close: ``, expand: ``, minimize: ``, seriesDetail: ``, 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: ``, copy: ``, database: ``, trash: ``, }; // ==================== 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; // ==================== 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:30px!important;right:156px!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:30px!important;right:112px!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;right:68px!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-trigger-gamelib{bottom:30px!important;right:24px!important;background:linear-gradient(135deg,#06cfbe 0%,#0d9488 100%)!important;box-shadow:0 4px 20px rgba(6,207,190,0.4)!important}', '.sfd-trigger-gamelib:hover{transform:scale(1.1) rotate(-10deg)!important;box-shadow:0 8px 30px rgba(6,207,190,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-tab-boxed{background:rgba(15,23,42,0.6);padding:3px;border-radius:8px;border:1px solid var(--sfd-border)}', '.sfd-tab-boxed .sfd-tab-btn{flex:0 0 auto;padding:6px 12px;border-radius:6px}', '.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,.sfd-header-action-btn svg,.sfd-btn svg,.sfd-family-tab svg,.sfd-pl-tab svg,.sfd-family-refresh svg,.sfd-family-play-refresh 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.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-gamelib-popup *::-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-gamelib-popup *::-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-gamelib-popup *::-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-gamelib-popup *::-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-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-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:4px;background:rgba(255,255,255,0.05);border-radius:2px;margin-top:4px;overflow:hidden;position:relative;box-shadow:inset 0 1px 2px rgba(0,0,0,0.3)}', '.sfd-bottom-progress{height:100%;width:0;background:linear-gradient(90deg,#06b6d4,#3b82f6 55%,#8b5cf6);border-radius:2px;transition:width .3s ease-out;position:relative;overflow:hidden;box-shadow:0 0 8px rgba(59,130,246,0.6)}', '.sfd-bottom-progress::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.35),transparent);animation:sfd-shimmer 1.4s linear infinite}', '@keyframes sfd-zoom-in{from{transform:scale(0.92);opacity:0}to{transform:scale(1);opacity:1}}', `.sfd-panel,.sfd-family-popup,.sfd-gamelib-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:770px!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-family-popup.sfd-show,.sfd-gamelib-popup.sfd-show,.sfd-pl-popup.sfd-show{display:flex!important}', '.sfd-series-body{flex:1;overflow:auto;padding:0 16px 16px;box-sizing:border-box}', '.sfd-series-head{display:flex;align-items:center;min-width:0;flex-wrap:wrap;row-gap:6px}', '.sfd-series-head .sfd-tab-bar{flex-wrap:wrap}', '.sfd-series-body::-webkit-scrollbar{width:6px}', '.sfd-series-body::-webkit-scrollbar-track{background:transparent}', '.sfd-series-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.08);border-radius:3px}', '.sfd-series-body::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.25)}', '.sfd-series-view-scroll{flex:1;min-height:0;overflow:auto;padding:0 4px 16px;box-sizing:border-box}', '.sfd-series-view-scroll::-webkit-scrollbar{width:8px}', '.sfd-series-view-scroll::-webkit-scrollbar-track{background:transparent}', '.sfd-series-view-scroll::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.08);border-radius:4px}', '.sfd-series-view-scroll::-webkit-scrollbar-thumb:hover{background:rgba(102,192,244,0.4)}', // ===== 统一指标卡片样式(compact / label / row 三种布局变体)===== '.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;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.sfd-delisted-cards .sfd-metric-card{flex:1;min-width:0}', // 布局变体:横向图标型(原 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}', '.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-series-twrap{width:max-content;min-width:100%;box-sizing:border-box;border:1px solid var(--sfd-border);border-radius:var(--sfd-radius-md);background:rgba(15,23,42,0.35);margin-top:10px}', '.sfd-series-table{width:100%;border-collapse:collapse;font-size:12px;white-space:nowrap}', '.sfd-series-table th{background:rgba(23,32,48,0.95);padding:7px 10px;text-align:center;color:#8f98a0;font-weight:600;border-bottom:1px solid var(--sfd-border);position:sticky;top:0;z-index:1}', '.sfd-series-table td{padding:5px 10px;text-align:center;color:#c6d4df;border-bottom:1px solid rgba(255,255,255,0.05);font-variant-numeric:tabular-nums}', '.sfd-series-table tbody tr{cursor:pointer}', '.sfd-series-table tbody tr:hover td{background:rgba(102,192,244,0.08)}', '.sfd-series-table tbody tr:last-child td{border-bottom:none}', '.sfd-series-table .sfd-st-name{text-align:left;min-width:140px;max-width:240px}', '.sfd-series-table .sfd-st-name-inner{display:flex;align-items:center;gap:6px;min-width:0}', '.sfd-series-table .sfd-st-nametext{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}', '.sfd-series-table .sfd-st-disc{color:#a4d007!important}', '.sfd-series-table td.sfd-st-hl{color:#beee11!important;font-weight:600}', '.sfd-series-table .sfd-st-dim{color:#8f98a0}', '.sfd-series-table .sfd-st-neg{color:#6b7a8f}', '.sfd-series-chart{border:1px solid var(--sfd-border);border-radius:var(--sfd-radius-md);background:rgba(15,23,42,0.35);margin-top:10px;padding:10px 12px}', '.sfd-sc-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}', '.sfd-sc-head-l{display:flex;align-items:center;gap:10px;min-width:0}', '.sfd-sc-title{font-size:13px;font-weight:600;color:#dfe3e6}', '.sfd-sc-stats{font-size:11px;color:#8f98a0;padding:2px 8px;background:rgba(102,192,244,0.06);border-radius:4px;white-space:nowrap}', '.sfd-sc-legend{display:flex;align-items:center;gap:12px;flex-shrink:0;margin-left:auto;flex-wrap:wrap}', '.sfd-sc-hint{font-size:11px;color:#6b7a8f;white-space:nowrap;cursor:help}', '.sfd-sc-lg{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:#8f98a0}', '.sfd-sc-lgdot{width:3px;height:12px;border-radius:1px;display:inline-block;opacity:0.85}', '.sfd-sc-lgdot.sfd-sc-lglow{width:4px;background:#beee11!important;box-shadow:0 0 6px rgba(190,238,17,0.7);opacity:1}', '.sfd-sc-row{display:flex;align-items:center;gap:8px;margin:4px 0}', '.sfd-sc-name{width:170px;flex:none;font-size:12px;color:#c6d4df;display:flex;align-items:center;gap:6px;cursor:default}', '.sfd-sc-nametext{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}', '.sfd-sc-track{flex:1;position:relative;height:12px;background:rgba(255,255,255,0.04);border-radius:3px}', '.sfd-sc-life{position:absolute;right:0;top:0;height:100%;border-radius:3px;background:linear-gradient(270deg,rgba(102,192,244,0.28),rgba(102,192,244,0.06))}', '.sfd-sc-tick{position:absolute;top:-3px;width:3px;height:18px;border-radius:1px;cursor:pointer;transform:translateX(-50%)}', '.sfd-sc-lowtick{width:4px;box-shadow:0 0 6px rgba(190,238,17,0.7)}', '.sfd-sc-dimrow .sfd-sc-name{color:#6b7a8f}', '.sfd-sc-name.sfd-sc-owned{color:#beee11!important;font-weight:600}', '.sfd-sc-dimtrack{background:repeating-linear-gradient(45deg,rgba(255,255,255,0.035) 0 6px,transparent 6px 12px)!important}', '.sfd-sc-lowv{flex:none;width:52px;font-size:12px;color:#6b7a8f;text-align:right;font-variant-numeric:tabular-nums}', '.sfd-sc-lowv.sfd-sc-has{color:#beee11;font-weight:600}', '.sfd-series-bars{padding:2px 0;margin-top:10px}', '.sfd-sb-head{display:flex;align-items:baseline;gap:10px;margin-bottom:12px;flex-wrap:wrap}', '.sfd-sb-title{font-size:13px;font-weight:600;color:#c6d4df}', '.sfd-sb-sub{font-size:11px;color:#8f98a0}', '.sfd-sb-row{display:flex;align-items:center;gap:10px;margin:7px 0}', '.sfd-sb-label{flex:none;width:46px;font-size:12px;color:#94a3b8;text-align:right;font-variant-numeric:tabular-nums}', '.sfd-sb-track{flex:1;height:14px;background:#0f172a;border-radius:7px;overflow:hidden}', '.sfd-sb-fill{display:block;height:100%;border-radius:7px;opacity:0.78;transition:width .3s ease}', '.sfd-sb-val{flex:none;width:56px;font-size:12px;color:#cbd5e1;text-align:right;font-variant-numeric:tabular-nums}', // ===== 预测打折标签(依据新史低节奏推算下次新史低时间)===== '.sfd-series-predict{padding:2px 0;margin-top:10px}', '.sfd-pd-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:12px}', '.sfd-pd-title{font-size:13px;font-weight:600;color:#c6d4df}', '.sfd-pd-sub{font-size:11px;color:#8f98a0}', '.sfd-pd-list{display:flex;flex-direction:column;gap:8px}', '.sfd-pd-row{display:flex;align-items:center;gap:12px;padding:8px 10px;border:1px solid var(--sfd-border);border-radius:var(--sfd-radius-md);background:rgba(15,23,42,0.35)}', '.sfd-pd-row.sfd-pd-dim{opacity:0.55}', '.sfd-pd-thumb{width:150px;flex:none;border-radius:6px;overflow:hidden;border:1px solid var(--sfd-border);background:#0f172a;cursor:pointer;transition:border-color 0.2s,box-shadow 0.2s}', '.sfd-pd-thumb:hover{border-color:#66c0f4;box-shadow:0 0 0 1px rgba(102,192,244,0.35)}', '.sfd-pd-thumb img{width:100%;height:auto;display:block;aspect-ratio:460/215;object-fit:cover}', '.sfd-pd-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}', '.sfd-pd-name{font-size:13px;font-weight:600;color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.sfd-pd-name.sfd-pd-owned{color:#beee11}', '.sfd-pd-name-row{display:flex;align-items:center;gap:8px;min-width:0}', '.sfd-pd-name-row .sfd-pd-name{flex:0 1 auto;min-width:0}', '.sfd-pd-low{flex:none;display:inline-flex;align-items:center;font-size:14px;font-weight:800;padding:2px 9px;border-radius:6px;border:1px solid;letter-spacing:0.2px;white-space:nowrap;font-variant-numeric:tabular-nums;line-height:1.5}', '.sfd-pd-meta{font-size:11px;color:#94a3b8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.sfd-pd-pred{flex:none;display:flex;flex-direction:column;gap:4px;align-items:flex-end;text-align:right}', '.sfd-pd-date{font-size:13px;font-weight:700;color:#66c0f4;font-variant-numeric:tabular-nums}', '.sfd-pd-date.sfd-pd-due{color:#f59e0b}', '.sfd-pd-days{font-size:11px;color:#94a3b8;font-variant-numeric:tabular-nums}', '.sfd-pd-conf{font-size:10px;font-weight:600;padding:1px 6px;border-radius:4px;border:1px solid var(--sfd-border);color:#94a3b8;white-space:nowrap}', '.sfd-pd-conf.sfd-pd-own{color:#4caf50;border-color:rgba(76,175,80,0.3);background:rgba(76,175,80,0.1)}', '.sfd-pd-conf.sfd-pd-series{color:#66c0f4;border-color:rgba(102,192,244,0.3);background:rgba(102,192,244,0.1)}', // ===== 单个游戏打折曲线弹窗(点击折扣表格行打开)===== '.sfd-gc-body{flex:1;overflow:auto;padding:14px 16px 16px;box-sizing:border-box;display:flex;flex-direction:column}', '.sfd-gc-body::-webkit-scrollbar{width:6px}', '.sfd-gc-body::-webkit-scrollbar-track{background:transparent}', '.sfd-gc-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.08);border-radius:3px}', '.sfd-gc-body::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.25)}', '.sfd-gc-top{display:flex;gap:14px;align-items:flex-start;margin-bottom:12px;flex:none}', '.sfd-gc-poster{width:340px;flex:none;border-radius:8px;overflow:hidden;border:1px solid var(--sfd-border);background:#0f172a}', '.sfd-gc-poster img{width:100%;height:auto;display:block;aspect-ratio:460/215;object-fit:cover}', '.sfd-gc-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}', '.sfd-gc-name-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}', '.sfd-gc-name{font-size:16px;font-weight:700;color:#fff;line-height:1.3}', // 游戏名右侧:家庭入库标记(绿色底+描边)+ 入库人头像;未入库显示灰色标记 '.sfd-gc-own{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}', '.sfd-gc-own-tag{display:inline-flex;align-items:center;font-size:10px;font-weight:700;padding:2px 7px;border-radius:4px;line-height:1.4;white-space:nowrap;flex:none}', '.sfd-gc-own-tag.family{color:#4caf50;background:rgba(76,175,80,0.14);border:1px solid rgba(76,175,80,0.45)}', '.sfd-gc-own-tag.self{color:#66bb6a;background:rgba(102,187,106,0.16);border:1px solid rgba(102,187,106,0.45)}', '.sfd-gc-own-tag.none{color:#8a9ba8;background:rgba(148,163,184,0.10);border:1px solid rgba(148,163,184,0.30)}', '.sfd-gc-own .sfd-stress-owners{margin-top:0;gap:4px}', '.sfd-gc-own .sfd-stress-owners img{width:24px;height:24px}', '.sfd-gc-sub{font-size:12px;color:#94a3b8;line-height:1.5}', '.sfd-gc-hl{color:#beee11;font-weight:700}', '.sfd-gc-chips{display:flex;flex-wrap:wrap;gap:6px}', '.sfd-gc-chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:3px 8px;border-radius:6px;background:rgba(255,255,255,0.05);border:1px solid var(--sfd-border);color:#c6d4df;white-space:nowrap;cursor:default}', '.sfd-gc-chip i{width:8px;height:8px;border-radius:2px;display:inline-block;flex:none}', '.sfd-gc-empty-inline{font-size:12px;color:#6b7a8f}', '.sfd-gc-chart{border:1px solid var(--sfd-border);border-radius:var(--sfd-radius-md);background:rgba(15,23,42,0.35);padding:10px 12px;box-sizing:border-box;flex:1;min-height:260px;display:flex;flex-direction:column}', '.sfd-gc-chart-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:6px;flex:none}', '.sfd-gc-chart-title{font-size:13px;font-weight:600;color:#dfe3e6}', '.sfd-gc-chart-sub{font-size:11px;color:#8f98a0}', '.sfd-gc-chart-svg{flex:1;min-height:0}', '.sfd-gc-chart-svg svg{width:100%;height:100%;display:block;overflow:visible}', '.sfd-gc-empty{padding:28px 0;text-align:center;font-size:12px;color:#6b7a8f}', '.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-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-gamelib-popup{height:740px!important;max-height:96vh!important}', '.sfd-gamelib-header{background:linear-gradient(135deg,rgba(6,207,190,0.15),rgba(15,23,42,0.8))!important;border-bottom-color:rgba(6,207,190,0.2)!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{display:flex;align-items:center;gap:4px}', '.sfd-family-progress{height:4px;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;position:relative;overflow:hidden;box-shadow:0 0 8px rgba(245,158,11,0.55)}', '.sfd-family-progress-bar:not(.done):not(.err)::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.35),transparent);animation:sfd-shimmer 1.4s linear infinite}', '.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: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-cmp-timeline-divider{display:flex;align-items:center;gap:10px;margin:12px 0 8px}', '.sfd-cmp-timeline-line{flex:1;height:2px;background:linear-gradient(90deg,transparent,rgba(139,92,246,0.6),transparent);border-radius:1px}', '.sfd-cmp-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);cursor:pointer}', '.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-pl-game-rank.r1{background:rgba(245,158,11,0.2);color:#fbbf24}', '.sfd-pl-game-rank.r2{background:rgba(148,163,184,0.2);color:#cbd5e1}', '.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-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,.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)}', '.sfd-family-refresh:hover{background:rgba(6,207,190,0.15);border-color:rgba(6,207,190,0.4);color:#06cfbe}', '.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:0.3;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,.sfd-pl-recent-grid,.sfd-stress-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}', '.sfd-family-play-game-item{display:flex;flex-direction:column;gap:0;padding:0;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-info,.sfd-pl-recent-info{padding:0 2px;display:flex;flex-direction:column;gap:1px}', '.sfd-fam-dyn-card .sfd-family-play-game-info{padding:4px 6px 5px}', '.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{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-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-tab.active{background:rgba(139,92,246,0.2);color:#a78bfa}', '.sfd-pl-head-main{display:flex;align-items:center;gap:16px;flex:1;min-width:0}', '.sfd-pl-head-main h3{flex:0 0 auto}', '.sfd-pl-tabs-slot{display:flex;align-items:center;min-width:0;overflow-x:auto;overflow-y:hidden}', '.sfd-pl-tabs-slot::-webkit-scrollbar{height:0}', '.sfd-pl-header .sfd-pl-tabs{margin-bottom:0;flex:0 0 auto}', '.sfd-pl-header .sfd-pl-tab{flex:0 0 auto;padding:6px 12px}', '.sfd-gamelib-header .sfd-pl-tabs{margin-bottom:0;flex:0 0 auto}', '.sfd-gamelib-header .sfd-pl-tab{flex:0 0 auto;padding:6px 12px}', '.sfd-gamelib-header .sfd-pl-tab.active{background:rgba(6,207,190,0.2);color:#06cfbe}', '.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-card.not-owned{opacity:0.55}', '.sfd-pl-recent-card.not-owned .sfd-pl-recent-cap,.sfd-pl-recent-card.not-owned .sfd-img-wrapper img,.sfd-stress-card.not-owned .sfd-stress-poster,.sfd-stress-card.not-owned .sfd-img-wrapper img,.sfd-family-play-game-item.not-owned .sfd-img-wrapper img{filter:grayscale(100%) brightness(0.6)}', '.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-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,.sfd-pl-game-name:hover{color:#a78bfa}', '.sfd-pl-recent-meta{display:flex;gap:10px;font-size:10px;color:#94a3b8;flex-wrap:wrap}', // 家庭组入库动态卡片:宽海报贴边(同精品/时间线卡片结构) '.sfd-fam-dyn-card{padding:0;gap:0}', '.sfd-fam-dyn-thumb{position:relative;background:#0f172a;aspect-ratio:460/215;flex-shrink:0}', '.sfd-fam-dyn-thumb .sfd-img-wrapper{width:100%;height:100%;border-radius:0}', '.sfd-fam-dyn-thumb img{width:100%;height:100%;display:block;object-fit:cover}', '.sfd-fam-dyn-card .sfd-pl-recent-info{padding:5px 8px 6px 4px;gap:3px}', '.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-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;box-shadow:0 0 6px rgba(139,92,246,0.3)}', '.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-fd-day-header{grid-column:1/-1;display:flex;align-items:center;gap:1px;margin:-4px 0 4px;height:32px;border-radius:6px;overflow:hidden;background:rgba(15,23,42,0.6);border:1px solid rgba(59,130,246,0.3);box-shadow:0 0 12px rgba(59,130,246,0.1) inset}', '.sfd-fd-day-bar{display:flex;align-items:stretch;height:100%;flex:1;min-width:0;gap:1px}', '.sfd-fd-day-seg{display:flex;align-items:center;justify-content:center;gap:5px;min-width:0;position:relative;transition:flex .3s ease;padding:0 8px}', '.sfd-fd-day-seg::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.5),transparent);opacity:.6}', '.sfd-fd-day-seg img{width:20px;height:20px;border-radius:50%;object-fit:cover;border:1.5px solid rgba(255,255,255,0.5);flex-shrink:0;filter:drop-shadow(0 0 4px rgba(255,255,255,0.2))}', '.sfd-fd-day-seg .seg-pct{font-size:11px;font-weight:700;letter-spacing:.5px;color:#e2e8f0;white-space:nowrap;min-width:0;overflow:hidden;text-overflow:ellipsis;text-shadow:0 0 6px rgba(0,0,0,0.8)}', '.sfd-hdr-avatars{display:inline-flex;align-items:center;gap:4px;margin-left:10px;vertical-align:middle}', '.sfd-hdr-av{display:inline-flex;width:24px;height:24px;border-radius:50%;overflow:hidden;vertical-align:middle;transition:all .2s}', '.sfd-hdr-av.clickable{cursor:pointer}', '.sfd-hdr-av.clickable:hover{transform:scale(1.1)}', '.sfd-hdr-av img{width:100%;height:100%;object-fit:cover;display:block;border-radius:50%;transition:filter .2s;filter:brightness(0.7)}', '.sfd-hdr-av.dim img{filter:grayscale(70%) brightness(0.4) opacity(0.5)}', '.sfd-hdr-av.sfd-hdr-av-pending{background:rgba(100,116,139,0.1);border:2px dashed #64748b;display:inline-flex;align-items:center;justify-content:center}', '.sfd-hdr-av-ph-icon{display:inline-flex;align-items:center;justify-content:center;width:100%;height:100%}', '.sfd-hdr-av-ph-icon svg{width:12px;height:12px;color:#64748b}', '.sfd-hdr-name-clickable{cursor:pointer}', '.sfd-hdr-name-clickable:hover{opacity:0.8}', '.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}', '@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-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,.sfd-dash-rank-game 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 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;box-shadow:0 0 6px rgba(6,182,212,0.3)}', '.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;box-shadow:0 0 6px rgba(139,92,246,0.3)}', '.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;box-shadow:0 0 6px rgba(6,182,212,0.3)}', '.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-poster-prev{position:fixed;z-index:99999;pointer-events:none;display:none;background:rgba(8,12,22,0.97);border:1px solid rgba(102,192,244,0.3);border-radius:10px;padding:8px;box-shadow:0 14px 40px rgba(0,0,0,0.7)}', '.sfd-poster-prev img{display:block;width:340px;max-width:60vw;border-radius:6px;background:#0f172a}', '.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)}}', /* ===== 仪表盘加载头像网格 ===== */ '.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-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-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-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-timeline{position:relative;display:flex;justify-content:space-between;align-items:flex-start;padding:14px 26px 4px;margin:0 6px 10px;flex:none}', '.sfd-stress-tl-line{position:absolute;top:20px;left:26px;right:26px;height:2px;background:linear-gradient(90deg,transparent,#06cfbe 12%,#66c0f4 50%,#a78bfa 88%,transparent);opacity:0.7;pointer-events:none}', '.sfd-stress-tl-node{position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;background:none;border:none;cursor:pointer;padding:0 4px;font-family:inherit}', '.sfd-stress-tl-dot{width:10px;height:10px;border-radius:50%;background:#0f172a;border:2px solid rgba(102,192,244,0.55);transition:all .2s;box-shadow:0 0 6px rgba(102,192,244,0.35)}', '.sfd-stress-tl-node:hover .sfd-stress-tl-dot{transform:scale(1.4);border-color:#66c0f4;box-shadow:0 0 12px rgba(102,192,244,0.8)}', '.sfd-stress-tl-node.sfd-active .sfd-stress-tl-dot{background:#66c0f4;border-color:#66c0f4;box-shadow:0 0 14px rgba(102,192,244,0.95),0 0 4px rgba(255,255,255,0.6)}', '.sfd-stress-tl-year{font-size:11px;font-weight:700;color:#64748b;letter-spacing:0.5px;transition:color .2s,text-shadow .2s}', '.sfd-stress-tl-node:hover .sfd-stress-tl-year{color:#9fd0f5}', '.sfd-stress-tl-node.sfd-active .sfd-stress-tl-year{color:#66c0f4;text-shadow:0 0 10px rgba(102,192,244,0.7)}', '.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;display:flex;gap:1px}', '.sfd-stress-group-bar-seg{height:100%;min-width:1px;opacity:0.8;transition:flex-grow .3s ease}', '.sfd-stress-group-pct{font-size:12px;font-weight:600;color:#94a3b8;flex-shrink:0;min-width:40px;text-align:right}', '.sfd-series-detail-btn{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:26px;height:22px;color:#66c0f4;background:rgba(102,192,244,0.08);border:1px solid rgba(102,192,244,0.35);border-radius:6px;padding:0;cursor:pointer;transition:all .2s}', '.sfd-series-detail-btn .sfd-svg{width:15px;height:15px}', '.sfd-series-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}', '.sfd-series-card{background:rgba(15,23,42,0.5);border:1px solid var(--sfd-border);border-radius:10px;padding:8px 10px;min-width:0}', '.sfd-series-card-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}', '.sfd-series-card-name{font-size:13px;font-weight:700;color:#f8fafc;flex-shrink:0;max-width:42%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.sfd-series-card-bar{flex:1;min-width:36px;display:flex}', '.sfd-series-card-bar .sfd-stress-group-bar{flex:1;height:5px;min-width:0}', '.sfd-series-card-count{font-size:11px;color:#94a3b8;flex-shrink:0;font-variant-numeric:tabular-nums}', '.sfd-series-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(88px,1fr));gap:6px}', '.sfd-series-hero-empty{border-radius:6px;background:rgba(255,255,255,0.03);border:1px dashed rgba(255,255,255,0.1)}', '.sfd-hero-grid{display:grid;gap:6px;min-width:min-content}', '.sfd-hero-rowlabel{display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#66c0f4;background:rgba(102,192,244,0.08);border:1px solid rgba(102,192,244,0.25);border-radius:6px}', // 英雄传说弹窗列头色块:同子系列同色(空=蓝 零碧=绿 闪=橙 创=紫 黎=红 界=金) '.sfd-hero-coltag{display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#0f172a;border-radius:4px;padding:4px 0;letter-spacing:.5px;white-space:nowrap;overflow:hidden}', '.sfd-hero-tag-kara{background:linear-gradient(135deg,#4a90d9,#66c0f4)}', '.sfd-hero-tag-zero{background:linear-gradient(135deg,#2f9e6e,#5eead4)}', '.sfd-hero-tag-haji{background:linear-gradient(135deg,#d97706,#fbbf24)}', '.sfd-hero-tag-kai{background:linear-gradient(135deg,#7c3aed,#a78bfa);color:#fff}', '.sfd-hero-tag-rei{background:linear-gradient(135deg,#dc2626,#f87171);color:#fff}', '.sfd-hero-tag-sky{background:linear-gradient(135deg,#b45309,#fbbf24)}', // 伊苏弹窗列头色块(全系列统一青色) '.sfd-hero-tag-ys{background:linear-gradient(135deg,#0891b2,#67e8f9)}', '.sfd-hero-tag-none{background:rgba(148,163,184,0.35);color:#cbd5e1}', '.sfd-hero-all-overlay{position:fixed;top:0;left:0;width:100%;height:100%;z-index:520;display:flex;align-items:center;justify-content:center;background:rgba(11,17,32,0.75);opacity:0;visibility:hidden;transition:all .3s ease}', '.sfd-hero-all-overlay.visible{opacity:1;visibility:visible}', '.sfd-hero-all-content{position:relative;display:flex;flex-direction:column;width:min(2140px,97%);max-height:84vh;background:var(--sfd-bg-card);border:1px solid var(--sfd-border);border-radius:14px;box-shadow:0 8px 32px rgba(0,0,0,0.5);padding:20px 22px;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);transform:scale(0.95);transition:transform .3s ease}', '.sfd-hero-all-overlay.visible .sfd-hero-all-content{transform:scale(1)}', '.sfd-hero-all-close{position:absolute;top:12px;right:14px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:20px;line-height:1;color:#94a3b8;background:transparent;border:none;border-radius:6px;cursor:pointer;transition:all .2s}', '.sfd-hero-all-close:hover{color:#f8fafc;background:rgba(255,255,255,0.08)}', '.sfd-hero-all-header{display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:15px;font-weight:700;color:#f8fafc}', '.sfd-hero-all-body{flex:1;min-height:0;overflow:auto}', '.sfd-hero-all-footer{margin-top:12px;font-size:11.5px;color:#94a3b8}', '.sfd-series-poster{position:relative;border-radius:6px;overflow:hidden;background:#0f172a;border:1px solid rgba(255,255,255,0.06);cursor:pointer;transition:border-color .2s,transform .15s,opacity .2s}', '.sfd-series-poster:hover{border-color:rgba(102,192,244,0.55);transform:translateY(-1px)}', '.sfd-series-poster.not-owned{filter:grayscale(100%)}', '.sfd-series-poster img{width:100%;display:block;aspect-ratio:460/215;object-fit:cover}', '.sfd-hero-vertical{display:flex;flex-direction:column;align-items:center;min-height:0}', '.sfd-hero-vertical img{flex:1 1 0;min-height:0;width:auto;height:100%;aspect-ratio:2/3;object-fit:cover;object-position:center}', // 竖版海报 loader wrapper:接管 img 的 2:3 自适应布局,宽度随两行高度推导 '.sfd-hero-vertical .sfd-img-wrapper{flex:1 1 0;min-height:0;width:auto;height:100%;aspect-ratio:2/3;border-radius:0}', '.sfd-hero-vertical .sfd-img-wrapper img{width:100%;height:100%;aspect-ratio:auto;object-fit:cover;object-position:center}', // 弹窗拥有者头像行(欧美版海报上方 / 云豹版海报下方,居中);空 cell 隐藏使行高塌缩,不占布局空间 '.sfd-hero-avatars{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:2px;min-height:0}', '.sfd-hero-avatars:empty{display:none}', '.sfd-hero-avatars img{width:22px;height:22px;border-radius:50%;object-fit:cover;border:1.5px solid rgba(102,192,244,0.55)}', // 拥有者头像开关(默认关闭):最左列「欧美版/云豹」标签之间(row3),点击切换头像行显隐 '.sfd-hero-av-toggle{display:flex;align-items:center;justify-content:center;gap:3px;align-self:center;cursor:pointer;z-index:2}', '.sfd-hero-av-toggle svg{width:12px;height:12px;opacity:0.55;flex-shrink:0;transition:opacity .2s}', '.sfd-hero-av-track{width:30px;height:16px;border-radius:8px;background:rgba(148,163,184,0.25);position:relative;transition:background .2s;flex-shrink:0}', '.sfd-hero-av-track::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:#8a9ba8;transition:left .2s,background .2s}', '.sfd-hero-av-toggle.on svg{opacity:1}', '.sfd-hero-av-toggle.on .sfd-hero-av-track{background:rgba(102,192,244,0.45)}', '.sfd-hero-av-toggle.on .sfd-hero-av-track::after{left:16px;background:#66c0f4}', '.sfd-hero-grid.sfd-hero-avatars-off .sfd-hero-avatars{display:none}', '.sfd-series-head{flex-wrap:wrap;row-gap:6px}', '.sfd-series-detail-btn:hover{color:#fff;background:rgba(102,192,244,0.2);border-color:#66c0f4}', '.sfd-series-detail-btn:active{transform:scale(0.96)}', '.sfd-series-main{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}', '.sfd-series-host{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}', '.sfd-series-nav{display:flex;align-items:center;gap:10px;flex-wrap:wrap;row-gap:6px;padding:2px 4px 6px;flex:none}', '.sfd-viewtabs{display:flex;align-items:center;gap:4px;flex-wrap:wrap}', '.sfd-viewtabs .sfd-vt-btn{border:1px solid rgba(102,192,244,0.32);background:rgba(15,23,42,0.4);color:var(--sfd-text-secondary);padding:6px 12px;font-size:14px;font-weight:700;border-radius:9px;cursor:pointer;transition:all .2s;line-height:1.5;font-family:inherit}', '.sfd-viewtabs .sfd-vt-btn:hover{color:#fff;border-color:rgba(102,192,244,0.55)}', '.sfd-viewtabs .sfd-vt-btn.sfd-active{background:rgba(102,192,244,0.18);border-color:rgba(102,192,244,0.65);color:#e8f4ff}', '.sfd-pubsel{position:relative;flex:none;max-width:min(360px,60vw)}', '.sfd-pubsel-btn{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:9px;background:rgba(102,192,244,0.1);border:1px solid rgba(102,192,244,0.32);color:#e8f4ff;font-size:14px;font-weight:700;cursor:pointer;transition:all .2s;max-width:100%;font-family:inherit}', '.sfd-pubsel-btn:hover{background:rgba(102,192,244,0.18);border-color:rgba(102,192,244,0.55)}', '.sfd-pubsel-btn.on{border-color:#66c0f4;box-shadow:0 0 0 1px rgba(102,192,244,0.25)}', '.sfd-pubsel-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}', '.sfd-pubsel-meta{font-size:10px;color:#94a3b8;font-weight:600;white-space:nowrap;font-variant-numeric:tabular-nums}', '.sfd-pubsel-caret{width:12px;height:12px;flex-shrink:0;opacity:.7;transition:transform .2s;display:flex}', '.sfd-pubsel-caret svg{width:12px;height:12px;display:block}', '.sfd-pubsel.open .sfd-pubsel-caret{transform:rotate(180deg)}', '.sfd-pubsel-panel{position:absolute;left:0;top:calc(100% + 6px);z-index:60;width:340px;max-width:80vw;max-height:min(62vh,540px);display:none;flex-direction:column;background:rgba(17,25,40,0.98);border:1px solid var(--sfd-border);border-radius:12px;box-shadow:0 16px 44px rgba(0,0,0,0.55);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);overflow:hidden}', '.sfd-pubsel.open .sfd-pubsel-panel{display:flex}', '.sfd-pubsel-list{overflow-y:auto;padding:8px 6px 8px;min-height:0}', '.sfd-pubsel-list::-webkit-scrollbar{width:6px}', '.sfd-pubsel-list::-webkit-scrollbar-track{background:transparent}', '.sfd-pubsel-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.12);border-radius:3px}', '.sfd-pubsel-item{display:flex;align-items:center;gap:8px;width:100%;padding:7px 9px;margin-top:3px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;transition:all .15s;text-align:left;font-family:inherit;min-width:0}', '.sfd-pubsel-item:hover{background:rgba(255,255,255,0.06)}', '.sfd-pubsel-item.on{background:rgba(102,192,244,0.14);border-color:rgba(102,192,244,0.4)}', '.sfd-pubsel-item-name{font-size:12.5px;font-weight:700;color:#e8eef7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}', '.sfd-pubsel-item-bar{width:64px;height:5px;border-radius:3px;overflow:hidden;display:flex;gap:1px;flex:none;min-width:0;margin:0}', '.sfd-pubsel-item-num{font-size:10.5px;color:#94a3b8;font-variant-numeric:tabular-nums;white-space:nowrap;flex:none;min-width:42px;text-align:right}', '.sfd-wf-root{flex:1;min-height:0;display:flex;flex-direction:column;min-width:0}', '.sfd-wf-scroll{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:0 14px 18px;box-sizing:border-box;overscroll-behavior:contain}', '.sfd-wf-scroll::-webkit-scrollbar{width:8px}', '.sfd-wf-scroll::-webkit-scrollbar-track{background:transparent}', '.sfd-wf-scroll::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:4px}', '.sfd-wf-scroll::-webkit-scrollbar-thumb:hover{background:rgba(102,192,244,0.4)}', '.sfd-wf-sec{margin-bottom:4px}', '.sfd-wf-sec-head{position:sticky;top:0;z-index:4;display:flex;align-items:center;gap:9px;padding:7px 2px 6px;margin-bottom:8px;background:linear-gradient(180deg,rgba(30,41,59,0.98) 0%,rgba(30,41,59,0.92) 70%,rgba(30,41,59,0) 100%);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}', '.sfd-wf-sec-title{font-size:13px;font-weight:800;color:#66c0f4;letter-spacing:.6px;white-space:nowrap}', '.sfd-wf-sec-line{flex:1;height:1px;min-width:20px;background:linear-gradient(90deg,rgba(102,192,244,0.45),rgba(102,192,244,0.02))}', '.sfd-wf-sec-sub{font-size:10px;color:#64748b;font-weight:600;white-space:nowrap}', '.sfd-wf-masonry{column-count:5;column-gap:10px}', '.sfd-wf-card{break-inside:avoid;-webkit-column-break-inside:avoid;page-break-inside:avoid;display:inline-block;width:100%;margin:0 0 10px;background:rgba(15,23,42,0.5);border:1px solid var(--sfd-border);border-radius:9px;overflow:hidden;cursor:pointer;transition:border-color .2s,transform .15s,opacity .28s,filter .28s,box-shadow .2s}', '.sfd-wf-card:hover{border-color:rgba(102,192,244,0.55);transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.35)}', '.sfd-wf-thumb{position:relative;background:#0f172a;aspect-ratio:460/215}', '.sfd-wf-thumb .sfd-img-wrapper{width:100%;height:100%;border-radius:0}', '.sfd-wf-thumb img{width:100%;height:100%;display:block;object-fit:cover}', '.sfd-wf-tag-unrel{position:absolute;right:4px;top:4px;padding:1px 6px;border-radius:5px;font-size:9.5px;font-weight:700;color:#f59e0b;background:rgba(245,158,11,0.18);border:1px solid rgba(245,158,11,0.45)}', '.sfd-wf-curve{position:absolute;left:4px;bottom:4px;width:21px;height:21px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:rgba(15,23,42,0.82);border:1px solid rgba(102,192,244,0.35);color:#66c0f4;cursor:pointer;opacity:0;transition:opacity .2s,background .2s,color .2s;padding:0}', '.sfd-wf-card:hover .sfd-wf-curve{opacity:1}', '.sfd-wf-curve:hover{background:rgba(102,192,244,0.28);color:#fff;border-color:#66c0f4}', '.sfd-wf-curve svg{width:12px;height:12px;display:block}', '.sfd-wf-info{padding:6px 8px 7px;display:flex;flex-direction:column;gap:3px}', '.sfd-wf-name{font-size:11.5px;font-weight:600;color:#e2e8f0;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.sfd-wf-card.owned .sfd-wf-name{color:#f0fdf4}', '.sfd-wf-meta{display:flex;align-items:center;gap:6px;min-width:0;min-height:17px}', '.sfd-wf-date{font-size:10px;color:#8fa3b8;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.sfd-wf-date.unrel{color:#f59e0b}', '.sfd-wf-owners{display:flex;align-items:center;gap:2px;margin-left:auto;flex:none}', '.sfd-wf-owners img{width:15px;height:15px;border-radius:50%;object-fit:cover;border:1px solid rgba(102,192,244,0.5)}', '.sfd-wf-owners span{font-size:9.5px;color:#64748b;font-weight:600}', '.sfd-wf-root.sfd-wf-hl-owned .sfd-wf-card:not(.owned){opacity:.26;filter:grayscale(1)}', '.sfd-wf-root.sfd-wf-hl-unowned .sfd-wf-card.owned{opacity:.26;filter:grayscale(1)}', '.sfd-wf-hint{padding:30px 16px;text-align:center;font-size:12.5px;color:#6b7a8f;line-height:1.9}', '.sfd-wf-hint b{color:#9fd0f5}', // 精品卡片:结构与时间线卡片统一(海报贴边满宽 + 下方信息区 + 卡片级 hover) '.sfd-stress-card{display:flex;flex-direction:column;border-radius:9px;background:rgba(15,23,42,0.5);border:1px solid var(--sfd-border);overflow:hidden;cursor:pointer;transition:border-color .2s,transform .15s,opacity .2s,box-shadow .2s}', '.sfd-stress-card:hover{border-color:rgba(102,192,244,0.55);transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.35)}', '.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.owned{background:rgba(46,213,115,0.07);border-color:rgba(46,213,115,0.3)}', '.sfd-stress-thumb{position:relative;background:#0f172a;aspect-ratio:460/215;flex-shrink:0}', '.sfd-stress-thumb .sfd-img-wrapper{border-radius:0;width:100%;height:100%}', '.sfd-stress-thumb img{width:100%;height:100%;display:block;object-fit:cover}', '.sfd-stress-info{min-width:0;display:flex;flex-direction:column;gap:3px;padding:6px 8px 7px}', '.sfd-stress-name{font-size:11.5px;font-weight:600;color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.35}', '.sfd-stress-date{font-size:10px;color:#f59e0b;font-weight:600}', '.sfd-stress-date.past{color:#94a3b8;font-weight:400}', '.sfd-stress-meta{display:flex;align-items:center;gap:6px;min-width:0}', '.sfd-stress-owners{display:flex;align-items:center;gap:2px;margin-left:auto;flex:none}', '.sfd-stress-owners img{width:15px;height:15px;border-radius:50%;object-fit:cover;border:1px solid rgba(102,192,244,0.5)}', '.sfd-stress-owners span{font-size:9.5px;color:#64748b;font-weight:600}', '.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:0 10px 0 0}', '.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 .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;margin-left:auto;font-size:10px;font-weight:700;padding:1px 6px;border-radius:3px;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:100%;height:100%;object-fit:cover;display:block}', '.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)}', // ===== 封面科技感加载骨架屏 ===== '.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}', '.sfd-img-wrapper.sfd-series-ico{width:34px;aspect-ratio:231/87;border-radius:3px}', '.sfd-img-wrapper.sfd-series-ico::after{width:12px;height:12px;margin:-6px 0 0 -6px;border-width:1.5px}', '.sfd-series-ico .sfd-img-broken-icon svg{width:14px;height:14px}', // featured poster 按宽比例贴卡片左缘 '.sfd-stress-featured-wrap .sfd-img-wrapper{width:190px;aspect-ratio:460/215;flex-shrink:0;border-radius:0}', // 仪表盘 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%)}}', /* ===== 好友总览合并布局 ===== */ '.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}}', '.sfd-hb-kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:14px}', '.sfd-hb-kpi{background:rgba(15,23,42,0.5);border:1px solid var(--sfd-border);border-radius:10px;padding:10px 12px;min-width:0}', '.sfd-hb-kpi .v{font-size:18px;font-weight:700;color:#e2e8f0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.sfd-hb-kpi .k{font-size:12px;color:#94a3b8;margin-top:2px}', '.sfd-hb-sec{font-size:13px;font-weight:600;color:#94a3b8;margin:4px 0 10px}', '.sfd-hb-row{display:grid;grid-template-columns:120px minmax(0,1fr) 110px;align-items:center;gap:8px;margin-bottom:6px}', '.sfd-hb-row .lb{font-size:12px;color:#c7d5e0;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.sfd-hb-row .bar{height:14px;border-radius:4px;background:rgba(102,192,244,0.08);overflow:hidden}', '.sfd-hb-row .bar>div{height:100%;border-radius:4px;min-width:2px}', '.sfd-hb-row .num{font-size:12px;color:#94a3b8;white-space:nowrap}', '.sfd-hb-trow{display:grid;gap:8px;align-items:center;background:rgba(15,23,42,0.4);border:1px solid var(--sfd-border);border-radius:8px;padding:6px 10px;font-size:12.5px;color:#c7d5e0;margin-bottom:5px}', '.sfd-hb-trow .rk{color:#66c0f4;font-weight:700}', '.sfd-hb-trow .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:8px;min-width:0}', '.sfd-hb-trow .sub{color:#94a3b8}', '.sfd-hb-xrow{display:grid;grid-template-columns:30px 34px minmax(0,1fr) 160px 150px 190px;align-items:center;gap:10px;padding:7px 12px;background:rgba(15,23,42,0.4);border:1px solid var(--sfd-border);border-radius:8px;margin-bottom:6px;font-size:13px;color:#c7d5e0}', '.sfd-hb-xrow .rk{color:#66c0f4;font-weight:700;text-align:center}', '.sfd-hb-xrow .nm{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600;color:#e2e8f0}', '.sfd-hb-xrow .pb{color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.sfd-hb-xrow .bi{color:#94a3b8;white-space:nowrap}', '.sfd-hb-xrow .d{color:#ff9f43;white-space:nowrap;text-align:right;font-weight:600}', '.sfd-hb-note{font-size:12px;color:#64748b;margin-top:10px;line-height:1.6}', ].join(''); GM_addStyle(CSS_STYLES); const locale = 'zh-CN'; // 固定中文显示 // ==================== 国家/地区映射 ==================== 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 = String(code).toUpperCase(); // 只接受恰好的两字母国家码,避免脏数据被当作国家名/码渲染 if (!/^[A-Z]{2}$/.test(uc)) return { name: '', flag: '' }; const name = COUNTRY_MAP[uc] || uc; // 注意:flag 是本脚本 FLAGS 表自产的可信 SVG,输出时必须经 html: 直接注入、严禁 esc()—— // esc 会把 SVG 标记转义成可见文本(1.5.1 曾因此昵称旁国旗全部显示为源码乱码); // country_name 虽也来自静态表,esc() 仅作防御,无副作用 return { name, flag: flagSvg(uc) }; } /* ================================================================ * PART 2 · 存储层(IndexedDB / GM 备份镜像 / state / storage) * ================================================================ */ // ===== 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); // 版本升级被旧标签页的长连接阻塞时必须 reject:否则启动链 await 永远挂起,脚本假死 req.onblocked = () => reject(new Error('IndexedDB open blocked')); 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, reject) => { const tx = _idb.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).put(val, key); tx.oncomplete = () => resolve(); // 写入失败时上抛(触发 _dbSet 的警告日志),不再静默丢数据 tx.onerror = () => reject(tx.error || new Error('IndexedDB 写入失败: ' + key)); tx.onabort = () => reject(tx.error || new Error('IndexedDB 写入中断: ' + key)); }); } 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 = () => { try { const cursor = req.result; if (cursor) { try { result[cursor.key] = cursor.value; } catch (e) { logger.warn('缓存条目读取失败,跳过: ' + cursor.key, e); } cursor.continue(); } else { resolve(result); } } catch (e) { // 游标异常时返回已加载部分,避免启动挂起 resolve(result); } }; req.onerror = () => resolve(result); tx.onabort = () => resolve(result); }); } // 启动时从 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; } // 缓存键大小记忆(key -> 字节数):设置页重渲染时复用,避免大缓存重复 stringify;写入/删除时失效 const _sizeCache = new Map(); // 同步写内存 + 异步写 IndexedDB + 关键数据镜像到 GM 扩展存储 function _dbSet(key, val) { _dbCache[key] = val; _sizeCache.delete(key); _idbPut(key, val).catch(e => logger.warn('IndexedDB 写入失败: ' + key, e)); _mirrorToGM(key, val); } function _dbDelete(key) { delete _dbCache[key]; _sizeCache.delete(key); _idbDelete(key).catch(() => {}); } // ==================== GM 扩展备份镜像 ==================== // IndexedDB 属于站点存储,长期不访问可能被浏览器整体逐出(好友/VAC/已删除好友/封面链接同时丢失); // 关键数据同步镜像到油猴扩展存储(独立于站点存储),启动时发现 IndexedDB 缺失则自动回补 const GM_BK_KEYS = ['sfd_cached_friends_data', 'sfd_cached_own_profile', 'sfd_vac_cache', 'sfd_level_cache', 'sfd_mutual_cache', 'sfd_deleted_friends', 'sfd_dash_snaps', 'sfd_wishlist_cache']; const GM_BK_PREFIXES = ['sfd_timeline_', 'sfd_zh_capsule_cache']; // GM 镜像单条上限 512KB:超过则跳过(IndexedDB 仍保有完整数据,仅放弃该条的扩展备份) const GM_BK_MAX_BYTES = 512 * 1024; function _gmBkWrite(key, val) { try { const s = JSON.stringify(val); // 大小上限:超大值(如含每款游戏图标URL的时间线)会拖垮油猴 service worker,导致脚本无法注入 if (s.length > GM_BK_MAX_BYTES) { logger.silent(`GM备份跳过超大条目 ${key} (${Math.round(s.length / 1024)}KB)`); return; } GM_setValue('sfd_bk_' + key, s); } catch (e) { logger.silent('GM备份写入失败: ' + key, e); } } function _gmBkRead(key) { try { const s = GM_getValue('sfd_bk_' + key, null); return s == null ? null : JSON.parse(s); } catch (e) { return null; } } function _mirrorToGM(key, val) { if (GM_BK_KEYS.includes(key) || GM_BK_PREFIXES.some(p => key.startsWith(p))) _gmBkWrite(key, val); } function _restoreGMBackup() { // 先清理 1.3.11~1.3.13 遗留的超大备份:数 MB 的 GM 值会拖垮油猴 service worker, // 导致脚本完全无法注入(面板不显示、无日志、且降级脚本也无法恢复,只能删油猴数据) try { if (typeof GM_deleteValue === 'function' && typeof GM_listValues === 'function') { let cleaned = 0; GM_listValues().forEach(gk => { if (!gk.startsWith('sfd_bk_')) return; try { const s = GM_getValue(gk, null); if (typeof s === 'string' && s.length > GM_BK_MAX_BYTES) { GM_deleteValue(gk); cleaned++; } } catch (e) { /* 单条失败继续 */ } }); if (cleaned) console.log(`%c[SFD] 已清理 ${cleaned} 条超大GM备份(修复脚本无法加载问题)`, 'color:#f59e0b'); } } catch (e) { logger.silent('GM超大备份清理失败', e); } const restored = []; const tryKey = (key) => { if (_dbCache[key] !== undefined) return; const bk = _gmBkRead(key); if (bk !== null && bk !== undefined) { _dbCache[key] = bk; _idbPut(key, bk).catch(() => {}); restored.push(key); } }; GM_BK_KEYS.forEach(tryKey); try { if (typeof GM_listValues === 'function') { GM_listValues().forEach(gk => { if (!gk.startsWith('sfd_bk_')) return; const key = gk.slice(7); if (GM_BK_PREFIXES.some(p => key.startsWith(p))) tryKey(key); }); } } catch (e) { logger.silent('GM备份动态键恢复失败', e); } if (restored.length) console.log(`%c[SFD] IndexedDB 数据曾被清理,已从扩展备份恢复 ${restored.length} 项(好友/VAC/已删除好友/时间线等)`, 'color:#34d399'); } // 存储大小日志 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); } } // ==================== 状态管理 ==================== const state = { // ===== 好友列表域 ===== friends: { data: [], filtered: [], modalFiltered: [], ownProfile: null, sortBy: 'days-desc', activeTab: 'overview', isLoading: false, fetchedThisPage: false, }, // ===== 好友总览域 ===== overview: { filter: 'all', search: '', sortBy: 'days-desc', page: 1, pageSize: 20, filtered: [], deletedFriends: [], }, // ===== UI 通用域 ===== ui: { disposers: [] }, // ===== VAC 封禁域 ===== vac: { banned: 0, devBanned: 0, finished: false, failed: 0, queue: [], checking: false, autoTries: 0 }, // ===== 家庭组域 ===== family: { info: null, gameList: null, popupSteamid: null, activeTab: 'chart', dynamicPage: 1, dynamicPageSize: 5, dynamicSelectedMembers: null, playActivity: null, playActivityLoading: false, playActivityFailed: false, chartShowDelisted: false, }, // ===== 游戏库域(精品游戏 / 系列游戏,数据源同家庭组共享库) ===== gamelib: { activeTab: 'stress', stressYear: '', // 精品游戏时间轴当前选中年份(空=默认当前年份) humbleYear: '', // Humble Choice 时间轴当前选中年份(空=默认当前年份) // ===== 系列游戏统一界面状态 ===== activePub: '', // 当前发行商(左上角面包菜单) seriesView: 'waterfall', // 当前视图 waterfall/grid/table/chart/bars/predict seriesSingle: null, // 分析视图(折扣表格等)的分析对象,null=全部系列(默认) seriesAllowMap: {}, // 发行商名 → 该发行商下选中的系列名数组(多选过滤) seriesHl: 'owned', // 高亮模式 all=全部高亮 / owned=已拥有高亮 / unowned=未拥有高亮 seriesGroupBy: 'year', // 分组维度 year=按发行年份 / series=按系列 seriesOrder: 'desc', // 时间顺序 desc=最新在前 / asc=最早在前 }, // ===== 个人游戏库域 ===== personal: { popupEl: null, activeTab: 'overview', tabSeq: 0, gamesCache: null, recentCache: null, delistedCache: null, delistedTypeFilter: 'all', delistedScope: '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); } } // 带 TTL 检查的读取(过期条目顺带从镜像/IndexedDB 删除,避免内存镜像只增不减) 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) { _dbDelete(key); return fallback; } return v; } // 通用缓存装饰器:统一 "检查缓存 → 获取 → 存储缓存" 模式 // opts.memKey - state 属性路径(如 'personal.gamesCache') // opts.dbKey - IndexedDB 键(可选,启用持久化) // opts.ttl - TTL 毫秒(>0 时配合 dbKey 生效) // fetcher - 纯获取函数,返回 null/undefined 表示失败(不缓存) // 在途去重:同一 memKey 的并发调用共享同一个 Promise,避免双击/快速切标签重复拉全量库 const _withCachePending = new Map(); 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 (_withCachePending.has(memKey)) return _withCachePending.get(memKey); if (dbKey && ttl > 0) { const cached = parseStoredTTL(dbKey, ttl, null); if (cached) { obj[prop] = cached.data !== undefined ? cached.data : cached; return obj[prop]; } } const p = (async () => { const result = await fetcher(...args); if (result != null) { obj[prop] = result; if (dbKey) _dbSet(dbKey, { data: result, _ts: Date.now() }); } return result; })(); _withCachePending.set(memKey, p); try { return await p; } finally { _withCachePending.delete(memKey); } }; } // 缓存失效:丢弃在途请求句柄,避免刷新后立刻复用旧请求的 Promise // 不传 memKey 时清空全部在途句柄(内存缓存/IndexedDB 由调用方各自清理) function invalidateCache(memKey) { if (memKey === undefined || memKey === null) { _withCachePending.clear(); return; } _withCachePending.delete(memKey); } 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: () => _dbGet('sfd_cached_friends_data', null), setCachedData: (val) => _dbSet('sfd_cached_friends_data', val), getCachedOwn: () => _dbGet('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: () => _dbGet('sfd_family_cache', null), setFamilyCache: (val) => _dbSet('sfd_family_cache', val), getWishlistCache: () => _dbGet('sfd_wishlist_cache', null), setWishlistCache: (val) => _dbSet('sfd_wishlist_cache', val), // 同步删除 GM 备份镜像:sfd_wishlist_cache 在 GM_BK_KEYS 中,只删 IndexedDB 会被 _restoreGMBackup 复活 delWishlistCache: () => { _dbDelete('sfd_wishlist_cache'); try { GM_deleteValue('sfd_bk_sfd_wishlist_cache'); } catch (e) { /* GM_deleteValue 不可用时忽略 */ } }, getFamilyPlayActivityCache: () => _dbGet('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), // 入库动态时间线:累积所有检查记录,写入时裁剪到最新 500 条防止无限增长 // sfd_timeline_ -> { latestAppIds:[], gameInfo:{}, records:[{ts, type, newAppIds?, totalGames}] }(按时间正序,最新在末尾) getTimeline: (sid) => _dbGet(`sfd_timeline_${sid}`, null), setTimeline: (sid, val) => { const MAX_TIMELINE_RECORDS = 500; if (val && Array.isArray(val.records) && val.records.length > MAX_TIMELINE_RECORDS) { val.records = val.records.slice(-MAX_TIMELINE_RECORDS); } _dbSet(`sfd_timeline_${sid}`, val); }, // 已删除好友:累积记录从好友列表中消失的好友 { [steamid]: { ...lastKnownData, _deletedAt } } getDeletedFriends: () => _dbGet('sfd_deleted_friends', {}), setDeletedFriends: (val) => _dbSet('sfd_deleted_friends', val), // 大盘好友数/好友游戏库/游戏库摘要:永久保留(无TTL),仅右上角"强制刷新深度大盘数据"按钮重取 getDashFriendsCache: () => _dbGet('sfd_dash_friends_cache', null), setDashFriendsCache: (val) => _dbSet('sfd_dash_friends_cache', val), // 按好友单独存储完整游戏库(IndexedDB 无单条大小限制),与好友单个窗口共用同一存储 getOwnedGameCache: (sid) => _dbGet(`sfd_og_${sid}`, null), setOwnedGameCache: (sid, val) => _dbSet(`sfd_og_${sid}`, val), // 摘要缓存(只存game_count/totalMinutes,体积小,用于仪表盘批量判断哪些好友已缓存) getOwnedGamesSummary: () => _dbGet('sfd_og_summary', {}), 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: () => _dbGet('sfd_dash_snaps', {}), setDashSnaps: (val) => _dbSet('sfd_dash_snaps', val), getDashCache: () => _dbGet('sfd_dash_cache', null), setDashCache: (val) => _dbSet('sfd_dash_cache', val), // 游戏中文名缓存:{ appid: { name: '中文名', ts: timestamp } } getGameNameCache: () => _dbGet('sfd_game_name_cache', {}), setGameNameCache: (val) => _dbSet('sfd_game_name_cache', val), // 预购游戏网页检测结果缓存:{ appid: { result: 'self'|'family'|false, ts: timestamp } } getStressDetectCache: () => _dbGet('sfd_stress_detect_cache', null), setStressDetectCache: (val) => _dbSet('sfd_stress_detect_cache', val) }; /* ================================================================ * PART 3 · 通用工具与 UI 组件工厂 * ================================================================ */ // ==================== HTML 转义 ==================== const esc = s => String(s == null ? '' : 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; } // 公共内联样式(多模块共享,避免重复字面量;h() 只读取不修改,可安全复用引用) const STYLE_FILL_SCROLL = { flex: '1', overflowY: 'auto', minHeight: '0' }; const STYLE_FOOT_RIGHT = { flexShrink: '0', display: 'flex', justifyContent: 'flex-end' }; // ==================== 进度条辅助函数 ==================== let _progressHideTimer = null; function showProgressBar() { // 取消待执行的隐藏任务,避免上一次请求的延迟隐藏掐掉本次进度 if (_progressHideTimer) { clearTimeout(_progressHideTimer); _progressHideTimer = null; } 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%'; if (_progressHideTimer) clearTimeout(_progressHideTimer); _progressHideTimer = setTimeout(() => { _progressHideTimer = null; if (wrap) wrap.style.display = 'none'; if (bar) bar.style.width = '0%'; }, 400); } 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)) ); } // 在线/游戏中的单一真相源:好友筛选、chip 计数与 KPI 必须共用,避免同一份数据出现两个口径 function isInGame(f) { return !!(f && f.personastate > 0 && f.gameextrainfo); } function isOnline(f) { return !!(f && f.personastate > 0); } // 新好友判定:缺失 friend_since(friend_days 为 null)时不算新好友,筛选与 KPI 共用 function isNewFriend(f) { return !!f && f.friend_days != null && f.friend_days < 30; } 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 || ('刷新数据'), 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 }) ]); } // ==================== 统一弹窗工厂 ==================== // 消除 showFamilyPopup / showPersonalLibrary / showGameCompare / // showContributionOverview / showGameListPopup 中重复的 DOM 创建与事件绑定 // 弹窗注册表:id -> close 函数,供 createModal 重建同 id 弹窗时关闭旧实例 const _modalRegistry = new Map(); function createModal(opts) { const { id, cls = 'sfd-family-popup', headerCls = 'sfd-family-header', titleHtml, headerContent, headerActions = [], width, height, zIndex = 504, persistent = false, showProgress = false, exclusions = [], onClose, scrollSelector, bodyStyle = {}, noOutsideClick = false, maximizable = false, progressId, progressBarId, contentId, bodyCls = 'sfd-family-content' } = opts; // 非持久模式:关闭已有同 id 弹窗(调用 close 而非直接 remove,确保 cleanup 执行) if (!persistent && _modalRegistry.has(id)) { const prevClose = _modalRegistry.get(id); if (prevClose) prevClose(); } // 创建容器 // 持久模式:不加 sfd-show(由调用方控制显隐),不设 inline 样式(由 CSS 类控制) // 非持久模式:加 sfd-show,用 cssText 设 inline 样式覆盖 CSS !important const overlay = h('div', { id, class: persistent ? cls : `${cls} sfd-show` }); if (!persistent) { const styleParts = ['position:fixed', 'inset:0', 'margin:auto', 'flex-direction:column', 'overflow:hidden']; styleParts.push('width:' + (width || (POPUP_W + 'px'))); styleParts.push('height:' + (height || (POPUP_H + 'px'))); styleParts.push('max-width:96vw', 'max-height:92vh'); styleParts.push('z-index:' + zIndex); overlay.style.cssText = styleParts.join(';'); } // 关闭逻辑 const close = () => { if (persistent) { overlay.classList.remove('sfd-show'); } else { overlay.remove(); cleanup(); } if (onClose) onClose(); }; // 注册到弹窗注册表(重建同 id 弹窗时先关旧实例,确保 cleanup/onClose 执行) _modalRegistry.set(id, close); // 标题栏 const headerInner = headerContent || h('h3', { html: titleHtml || '' }); const allActions = [...headerActions]; if (maximizable) { const maxBtn = h('button', { class: 'sfd-header-btn', html: ICONS.expand, title: '展开', onClick: () => { const expanded = overlay.dataset.expanded === '1'; if (!expanded) { // 按内容自然尺寸展开:宽度消除表格横向滚动条,高度消除纵向滚动条(均以视口为上限,不铺满窗口) const bodyEl = overlay.querySelector('.sfd-series-body'); if (bodyEl && bodyEl.scrollWidth > bodyEl.clientWidth) { const targetW = Math.min(bodyEl.scrollWidth + 30, Math.round(window.innerWidth * 0.96)); overlay.style.setProperty('width', targetW + 'px', 'important'); } if (bodyEl && bodyEl.scrollHeight > bodyEl.clientHeight) { const headerEl = overlay.querySelector('.sfd-family-header'); const needH = (headerEl ? headerEl.offsetHeight : 0) + bodyEl.scrollHeight + 6; const targetH = Math.min(needH, Math.round(window.innerHeight * 0.96)); overlay.style.setProperty('height', targetH + 'px', 'important'); } overlay.dataset.expanded = '1'; } else { overlay.style.removeProperty('width'); overlay.style.removeProperty('height'); overlay.dataset.expanded = ''; } const isExp = overlay.dataset.expanded === '1'; maxBtn.innerHTML = isExp ? ICONS.minimize : ICONS.expand; maxBtn.title = isExp ? ('收起') : ('展开'); } }); allActions.push(maxBtn); } allActions.push(createCloseBtn(close)); const header = h('div', { class: headerCls }, [ headerInner, h('div', { class: 'sfd-family-header-actions' }, allActions) ]); overlay.appendChild(header); // 可选进度条 let progressEl = null, progressBarEl = null; if (showProgress) { progressBarEl = h('div', { class: 'sfd-family-progress-bar', id: progressBarId || `${id}-progress-bar` }); progressEl = h('div', { class: 'sfd-family-progress', id: progressId || `${id}-progress` }, [progressBarEl]); overlay.appendChild(progressEl); } // 内容区 const body = h('div', { class: bodyCls, id: contentId || `${id}-content`, style: bodyStyle }); overlay.appendChild(body); document.body.appendChild(overlay); // 外部点击关闭 const outsideClick = noOutsideClick ? null : (e) => { if (!overlay.classList.contains('sfd-show')) return; if (overlay.contains(e.target)) return; for (const sel of exclusions) { if (typeof sel === 'string' && e.target.closest(sel)) return; if (sel instanceof Node && sel.contains(e.target)) return; } close(); }; if (outsideClick) document.addEventListener('mousedown', outsideClick, true); // 清理 const cleanup = () => { if (outsideClick) document.removeEventListener('mousedown', outsideClick, true); _modalRegistry.delete(id); }; if (persistent) state.ui.disposers.push(cleanup); // 滚轮拦截 if (scrollSelector) bindScrollOverride(overlay, scrollSelector); return { overlay, header, body, progressEl, progressBarEl, close, destroy: cleanup }; } // ==================== 工具函数 ==================== function gmFetch(url, timeout = 20000) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout, // 必须校验状态码:429/403/5xx 的错误体不能当成功数据下发 onload(resp) { if (resp.status >= 200 && resp.status < 300) resolve(resp.responseText); else reject(new Error('HTTP ' + resp.status)); }, onerror(err) { reject(err); }, ontimeout() { reject(new Error('timeout')); } }); }); } function requestSteamAPI(url, timeout = 15000) { // 复用 gmFetch 状态码校验;并发池依赖错误 message 中的 "429" 识别限流并降速(HTTP 状态码保留在 message 中) return gmFetch(url, timeout).then(t => JSON.parse(t)); } // gmFetch JSON 版:解析失败按 rejection 处理(远程 JSON 数据源共用) function gmFetchJson(url, timeout = 20000) { return gmFetch(url, timeout).then(t => JSON.parse(t)); } // 饼图扇区弧形 path(家庭组贡献饼图与月度入库饼图共用) function pieSlicePath(cx, cy, r, startAngle, angle) { const x1 = cx + r * Math.cos(startAngle), y1 = cy + r * Math.sin(startAngle); const endAngle = startAngle + angle; const x2 = cx + r * Math.cos(endAngle), y2 = cy + r * Math.sin(endAngle); const largeArc = angle > Math.PI ? 1 : 0; return `M${cx},${cy} L${x1.toFixed(2)},${y1.toFixed(2)} A${r},${r} 0 ${largeArc},1 ${x2.toFixed(2)},${y2.toFixed(2)} Z`; } // SVG 水平网格线 + Y 轴刻度文本(柱状图 / 堆叠柱状图 / 增长曲线共用) function svgGridLines({ left, right, top, bottom, count, maxValue, lineStroke = 'rgba(255,255,255,0.07)', dasharray = '', textDx = -6, textFill = '#8a9ba8', fontSize = 10, fontFamily = '' }) { let out = ''; for (let i = 0; i <= count; i++) { const v = Math.round(maxValue / count * i); const y = bottom - (bottom - top) * i / count; out += `${v}`; } return out; } // ==================== 游戏名称与通用工具(中文名缓存 / 日期 / 状态文本 / toast / logger) ==================== let _gameNameCache = storage.getGameNameCache(); // 内存缓存;IndexedDB/GM 备份载入后在启动链中重新快照,防止空对象覆盖持久化缓存 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; // 名称已是中文(如愿望单 l=schinese 直接返回),无需再请求 if (originalName && /[\u4e00-\u9fff]/.test(originalName)) return; fetchGameZhName(appid).then(zhName => { if (zhName && zhName !== originalName) { el.textContent = zhName; el.title = `${zhName} (${originalName})`; } }); } // 确保本人 steamid 可用:优先已保存/可靠来源识别,DOM 第一个好友块在排序/过滤视图下可能是任意好友,仅作最后回退 function ensureFamilyPopupSteamid() { if (!state.family.popupSteamid) { state.family.popupSteamid = storage.getSteamId() || detectCurrentSteamId(); if (!state.family.popupSteamid) { const ownBlock = document.querySelector('.friend_block_v2[data-steamid]'); if (ownBlock) state.family.popupSteamid = ownBlock.dataset.steamid; } } return state.family.popupSteamid; } 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('zh-CN'); }, 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 '未知'; 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 '刚刚'; if (diff < SECONDS_PER_HOUR) return `${Math.floor(diff / SECONDS_PER_MINUTE)}分钟前`; if (diff < SECONDS_PER_DAY) return `${Math.floor(diff / SECONDS_PER_HOUR)}小时前`; if (diff < SECONDS_PER_MONTH) return `${Math.floor(diff / SECONDS_PER_DAY)}天前`; return DateUtils.format(ts); }, lastPlayed(ts) { if (!ts) return ''; const diff = Date.now() / 1000 - ts; if (diff < SECONDS_PER_DAY) return '今天'; if (diff < SECONDS_PER_MONTH) return Math.floor(diff / SECONDS_PER_DAY) + '天前'; if (diff < SECONDS_PER_YEAR) return Math.floor(diff / SECONDS_PER_MONTH) + '月前'; return Math.floor(diff / SECONDS_PER_YEAR) + '年前'; }, duration(minutes) { if (!minutes || minutes <= 0) return '-'; const h = Math.floor(minutes / 60); const m = minutes % 60; if (h > 0) return `${h}小时${m > 0 ? m + '分' : ''}`; return `${m}分钟`; }, durationShort(minutes) { if (!minutes || minutes === 0) return '0小时'; const hours = Math.floor(minutes / 60); return `${hours}小时`; }, daysSince(ts) { // 无 friend_since 时返回 null(而非 0),避免被当成"今天加入"混入新好友口径 if (!ts) return { days: null, text: '-' }; const diff = Math.max(0, Math.floor((Date.now() / 1000 - ts) / SECONDS_PER_DAY)); return { days: diff, text: `${diff}天` }; } }; function getDaysClass(days) { if (days < 30) return 'new-friend'; if (days > 1000) return 'old-friend'; return 'normal'; } function getPersonaStateText(stateNum) { const states = ['离线', '在线', '忙碌', '离开', '离开', '交易中', '想要游戏']; return states[stateNum] || '离线'; } function getPersonaStateClass(pstate, hasGame) { if (hasGame) return 'in-game'; if (pstate > 0) return 'online'; return 'offline'; } const toast = { _el: null, _hideTimer: null, _clearTimer: 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; } // 复用单例元素时先取消上一条的隐藏/清空定时器,否则连续弹第二条会被第一条的定时器提前隐藏 if (this._hideTimer) { clearTimeout(this._hideTimer); this._hideTimer = null; } if (this._clearTimer) { clearTimeout(this._clearTimer); this._clearTimer = null; } el.textContent = msg; el.className = 'sfd-toast' + (type ? ' sfd-toast-' + type : ''); requestAnimationFrame(() => el.classList.add('sfd-toast-show')); this._hideTimer = setTimeout(() => { el.classList.remove('sfd-toast-show'); this._clearTimer = 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 || ''); } }; /* ================================================================ * PART 4 · 数据服务层(Steam API / 家庭组 / 好友数据 / 个人库数据获取) * ================================================================ */ // ==================== Steam 访问令牌管理(Web API Token) ==================== 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), }; // ==================== 家庭组数据获取与缓存(拉取 / 进度刷新 / 头像兜底) ==================== // 用 GetPlayerSummaries(API Key)补齐家庭成员头像与昵称(PlayerLinkDetails 失败时的兜底) async function backfillFamilyAvatars(famAvatarMap, nameMap, members) { if (!storage.getApiKey()) return false; // 调用方(ensureFamilyAvatars)已把「昵称缺失(占位 ID:xxxx)」纳入待修复集合, // 但此处若只按头像条件二次过滤,「头像正常但昵称缺失」的成员永远进不了更新集合, // 占位名会被 setFamilyCache 持续持久化。故二次过滤必须同时考虑昵称条件。 const missing = members.filter(m => { if (!m.steamid) return false; const av = famAvatarMap[m.steamid]; const nm = nameMap[m.steamid]; const avatarMissing = !av || String(av).startsWith('data:image/svg+xml'); const nameMissing = !nm || String(nm).trim() === '' || String(nm).startsWith('ID:'); return avatarMissing || nameMissing; }); if (!missing.length) return false; try { const data = await SteamAPI.getSummary(missing.map(m => m.steamid)); const players = (data && data.response && data.response.players) || []; players.forEach(p => { if (p.avatarfull) famAvatarMap[p.steamid] = p.avatarfull; if (p.personaname) nameMap[p.steamid] = p.personaname; }); return true; } catch (e) { logger.silent('家庭成员头像补齐失败', e); return false; } } 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); } } } // PlayerLinkDetails 失败时会留下占位头像并被长期缓存;用 GetPlayerSummaries 补齐,避免家庭组头像依赖好友数据 await backfillFamilyAvatars(famAvatarMap, nameMap, members); 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; } // 带顶部进度条的家庭组刷新(家庭组窗口 / 游戏库窗口共用;通过 opts 指定进度条与重渲染目标) async function refreshFamilyWithProgress(opts) { const progressId = (opts && opts.progressId) || 'sfd-family-progress'; const progressBarId = (opts && opts.progressBarId) || 'sfd-family-progress-bar'; const rerender = (opts && opts.rerender) || renderFamilyPopup; const progEl = document.getElementById(progressId); const barEl = document.getElementById(progressBarId); // 重置状态(无进度条降级分支与主路径共用同一段,避免两处不一致导致"刷新"只刷一半) const resetFamilyState = () => { state.family.playActivity = null; _stressGamesCache = null; _stressLoadFailed = false; _stressStoreOwnedCache.clear(); _stressDetectDBLoaded = false; // 重置后下次检测会从 IndexedDB 重新加载缓存 _personalAppIdSet = null; _stressForceNetwork = true; state.family.playActivityLoading = false; state.family.playActivityFailed = false; // 手动刷新后允许游玩动态自动重试 state.family.dynamicPage = 1; }; if (!progEl || !barEl) { // 降级:无进度条直接刷新 resetFamilyState(); loadFamilyData(state.family.popupSteamid, true).then(ok => { if (ok === true) rerender(); }); return; } const setBar = (pct, cls) => { barEl.className = 'sfd-family-progress-bar' + (cls ? ' ' + cls : ''); barEl.style.width = pct + '%'; }; // 重置 + 激活 progEl.classList.add('active'); setBar(8, ''); resetFamilyState(); // 阶段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); rerender(); } else { setBar(100, 'err'); setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 1500); 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') { // 无家庭组:明确清空,避免调用方把上一次缓存当作本次结果 state.family.info = null; state.family.gameList = null; return null; } 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; } // 修复缓存中的占位头像/占位名(PlayerLinkDetails 曾失败时留下的默认剪影),补齐后持久化并重渲染 async function ensureFamilyAvatars() { const fi = state.family.info; if (!fi || !fi.family_member || !fi.family_member.length) return; const need = fi.family_member.filter(m => m.steamid && ( !m.avatar || m.avatar.startsWith('data:image/svg+xml') || !m.userName || m.userName.startsWith('ID:') )); if (!need.length) return; const avatarMap = { ...(fi.steamIdtoAvatar || {}) }; const nameMap = { ...(fi.steamIdtoName || {}) }; const ok = await backfillFamilyAvatars(avatarMap, nameMap, need); if (!ok) return; fi.steamIdtoAvatar = avatarMap; fi.steamIdtoName = nameMap; fi.family_member.forEach(m => { if (avatarMap[m.steamid]) m.avatar = avatarMap[m.steamid]; if (nameMap[m.steamid]) m.userName = nameMap[m.steamid]; }); const cached = storage.getFamilyCache(); if (cached && cached.steamid === state.family.popupSteamid) { storage.setFamilyCache({ ...cached, familyInfo: fi, timestamp: cached.timestamp || Date.now() }); } if (familyPopupEl && familyPopupEl.classList.contains('sfd-show') && document.getElementById('sfd-family-content')) { renderFamilyPopup(); } if (gamelibPopupEl && gamelibPopupEl.classList.contains('sfd-show') && document.getElementById('sfd-gamelib-content')) { renderGameLibraryPopup(); } } // 在新窗口打开 Steam 商店页面 function openStorePage(appid) { if (!appid) return; const url = `https://store.steampowered.com/app/${appid}`; window.open(url, '_blank', 'noopener,noreferrer'); } // ==================== 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}天, 开发者封禁 x${f.vac_game_bans}`; } else if (hasVAC) { icon = ICONS.shieldRed; title = `VAC Banned - ${f.vac_days_since_last_ban}天`; } else if (hasDev) { icon = ICONS.shieldDevBan; title = `开发者封禁 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 ? '{n}天'.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 ? '{n}天'.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; } // VAC 批处理合批:vacSetStatus 由每个玩家的网络回调逐个调用, // 逐个 querySelector('.friend_block_v2[data-steamid=...]') 是全文档扫描、逐个 updateVACStatus 又重建状态栏 → 整批 O(N²)。 // 累积到一次 RENDER_THROTTLE_MS 尾沿节流里合批执行;同一玩家多次写入以最后一次为准(最后一次写入获胜)。 let _vacDomFlushTimer = null; const _vacDomPending = new Map(); // steamid -> { banned, days, gameBans } function _vacQueueDomUpdate(steamid, banned, days, gameBans) { _vacDomPending.set(String(steamid), { banned, days, gameBans }); if (_vacDomFlushTimer) return; _vacDomFlushTimer = setTimeout(() => { _vacDomFlushTimer = null; _vacFlushDomUpdates(); }, RENDER_THROTTLE_MS); } function _vacFlushDomUpdates() { if (_vacDomFlushTimer) { clearTimeout(_vacDomFlushTimer); _vacDomFlushTimer = null; } if (_vacDomPending.size) { const pending = Array.from(_vacDomPending.entries()); _vacDomPending.clear(); for (const [sid, v] of pending) { const c = document.querySelector(`.friend_block_v2[data-steamid="${sid}"]`); if (c) renderVACShieldOnPage(c, v.banned, v.days, v.gameBans); } } updateVACStatus(); } function vacSetStatus(steamid, data) { if (data.error) return; const playerData = data.players && data.players[0]; if (!playerData) return; // 统计与缓存不依赖 DOM:搜索过滤/懒加载下好友块可能不在页面中,结果不能因此丢弃 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; const fd = _friendsMap.get(String(steamid)); if (fd) syncVacToFriendData(fd, vacInfo); // 页面盾牌仅在好友块当前可见时渲染;合批执行,避免每人都做一次全文档查询 + 状态栏重建 _vacQueueDomUpdate(steamid, playerData.VACBanned, playerData.DaysSinceLastBan, playerData.NumberOfGameBans); } function updateVACStatus() { const el = document.getElementById('sfd-vac-status'); if (!el) return; if (state.vac.finished) { const parts = []; if (state.vac.banned > 0) parts.push('VAC封禁 {n}人'.replace('{n}', state.vac.banned)); if (state.vac.devBanned > 0) parts.push('开发者封禁 {n}人'.replace('{n}', state.vac.devBanned)); // 限流/网络导致的失败数需要告知用户,避免把"无封禁"误当成全部结果 if (state.vac.failed > 0) parts.push(('⚠ {n}人查询失败').replace('{n}', state.vac.failed)); el.innerHTML = parts.length ? parts.join(' · ') : '🛡️ 无封禁'; } else { const cnt = state.vac.banned + state.vac.devBanned; el.innerHTML = cnt === 0 ? 'VAC检查中...' : 'VAC检查中...' + ' (' + cnt + ')'; } } function startVACCheck(forceAll) { const apiKey = storage.getApiKey(); if (!apiKey) return; state.vac.checking = true; const friends = document.querySelectorAll('.friend_block_v2[data-steamid]'); // 增量补查沿用缓存已统计的封禁数(loadVACFromCache 先行统计),全量重查才清零重算 if (forceAll) { state.vac.banned = 0; state.vac.devBanned = 0; } state.vac.finished = false; state.vac.failed = 0; 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) { state.vac.checking = false; 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); let data = null; // 失败后退避重试一次(429 多等 2s),仍失败才计入失败数,不再静默丢弃整批 for (let attempt = 0; attempt < 2 && !data; attempt++) { try { data = await SteamAPI.getVAC(batch); } catch (e) { if (attempt === 0) await new Promise(r => setTimeout(r, e && e.message && e.message.includes('429') ? 2000 : 500)); } } if (data && data.players) { for (const p of data.players) { vacSetStatus(p.SteamId, { players: [p] }); processedCount++; } } else { state.vac.failed += batch.length; processedCount += batch.length; } setProgressPercent(bpBar, (processedCount / totalToFetch) * 100); } try { // 批量完成后一次性持久化缓存(vacSetStatus 内部已更新内存缓存) storage.setVACCache(storage.getVACCache()); // 完成 state.vac.finished = true; _vacFlushDomUpdates(); // 冲刷节流中剩余的盾牌更新,保证最终状态一致 updateVACStatus(); } finally { state.vac.checking = false; if (dom.vacBtn) dom.vacBtn.disabled = false; hideProgressBar(); } } processBatch().catch(e => logger.error('VAC批量检查失败', e)); } 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(); // 缓存不全(新增好友/上次查询有失败批次/缓存被清理)时自动增量补查缺失项, // 自动补跑 VAC:避免状态栏永久停留在"VAC检查中"; // 延迟 600ms 错开面板自身的加载进度条,autoTries 限制失败重试次数防循环 if (!state.vac.finished && friends.length > 0 && !state.vac.checking && storage.getApiKey() && state.vac.autoTries < 3) { state.vac.autoTries++; setTimeout(() => { if (!state.vac.checking && !state.vac.finished) startVACCheck(); }, 600); } 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('等级查询中...'); 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('等级查询中...' + ' ' + 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 (dom.levelBtn) dom.levelBtn.disabled = false; setStatusText('等级查询完成'); hideProgressBar(); } catch (e) { logger.error('等级查询失败', e); if (dom.levelBtn) dom.levelBtn.disabled = false; setStatusText('等级查询完成'); 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 = {}; // 缓存每个好友的好友列表详情,用于浮窗展示 // 只增不减的内存缓存:容量上限 + FIFO 淘汰;淘汰只损失一次重新抓取,不改变命中语义 const MUTUAL_FRIEND_LIST_MAX = 200; const _mutualFriendListOrder = []; function _setMutualFriendList(steamid, list) { const key = String(steamid); const oldIdx = _mutualFriendListOrder.indexOf(key); if (oldIdx !== -1) _mutualFriendListOrder.splice(oldIdx, 1); _mutualFriendLists[key] = list; _mutualFriendListOrder.push(key); while (_mutualFriendListOrder.length > MUTUAL_FRIEND_LIST_MAX) { const evicted = _mutualFriendListOrder.shift(); if (evicted !== key) delete _mutualFriendLists[evicted]; } } /** * 智能并发池 — 滑动窗口模式,完成一个立刻填入下一个,保持并发数恒定 * 遇到429或错误自动延迟重试,最多重试2次 */ async function _concurrentPool(tasks, concurrency, onProgress) { const results = new Array(tasks.length); let nextIndex = 0; let completed = 0; let activeConcurrency = concurrency; let inFlight = 0; // 当前在途任务数(与 activeConcurrency 构成可动态降速的并发闸门) 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,降低并发数(e 可能是非 Error 值,空值安全写法与上方同类判断一致) const emsg = (e && e.message) ? String(e.message) : String(e); if (emsg.includes('429') || emsg.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) { // 并发闸门:429 降速后在此等待,在途数低于当前上限才取新任务(worker 数固定但限速生效) while (inFlight >= activeConcurrency) await new Promise(r => setTimeout(r, 100)); // 唤醒后复查:闸门等待期间任务可能已被其他 worker 取完, // 否则会取到越界幻影任务(undefined 走满重试 → 进度虚报 → 调用方回调崩在中途) if (nextIndex >= tasks.length) break; const idx = nextIndex++; inFlight++; try { await runTask(idx); } finally { inFlight--; completed++; // onProgress 内含真实 DOM 重排,回调抛错不得污染池的推进与收尾 if (onProgress) { try { onProgress(completed, tasks.length); } catch (e) { logger.warn('并发池进度回调异常', e); } } } } } // worker 按初始并发启动(不超过任务数),运行期通过闸门响应降速 const workerCount = Math.min(concurrency, 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('🔄 共同好友' + ' ...'); const bpBar = showProgressBar(); let lastRenderTime = 0; // 缓存版本检查:v2 之前可能把私密好友误存为 0;v2(1.4.4) 会把限流失败误存为 -1 且永不重查,升到 v3 清空重查 const MUTUAL_CACHE_VERSION = 3; const cachedVersion = _dbGet('sfd_mutual_cache_ver', 0); if (cachedVersion < MUTUAL_CACHE_VERSION) { storage.setMutualCache({}); _dbSet('sfd_mutual_cache_ver', MUTUAL_CACHE_VERSION); // 必须同步清空内存镜像:启动时 Object.assign 已把旧版本脏数据(误存的 -1)载入内存, // 仅清持久层会导致合并循环空转、脏值留存并被 prunedMutual 重新写回,升级清理形同虚设 for (const k of Object.keys(_mutualCache)) delete _mutualCache[k]; state.friends.data.forEach(f => { f.mutualFriendsCount = undefined; }); } // 合并缓存:先从 _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('就绪'); if (dom.mutualBtn) dom.mutualBtn.disabled = false; applyPanelFiltersAndSort(); applyModalFiltersAndSort(); return; } // ===== 两阶段查询:阶段1快速并发(不重试),阶段2仅对失败项重试精查私密 ===== // try/finally 包裹:池或渲染中途抛错时也必须复位按钮 disabled 状态,否则"共同好友"按钮永久禁用 try { 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); // 缓存好友列表,用于浮窗展示共同好友详情 _setMutualFriendList(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('🔄 共同好友' + ' ' + 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); _setMutualFriendList(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('🔄 共同好友' + ' ' + completed + '/' + total + ' ...'); const now = Date.now(); if (now - lastRenderTime >= RENDER_THROTTLE_MS) { lastRenderTime = now; applyPanelFiltersAndSort(); applyModalFiltersAndSort(); } }); // 阶段2仍失败(网络/限流):本会话按 -1 显示,但不写入持久缓存,下次查询自动重试。 // 真正的"私密"判定只来自成功响应(上方 task 内),二者不能混淆 phase2Results.forEach((r, i) => { if (r && r.status === 'rejected' && recheckList[i]) { recheckList[i].mutualFriendsCount = -1; } }); } else { setProgressPercent(bpBar, 100); } // 最终完整刷新;写回前仅保留当前好友的条目,避免缓存随历史好友无限增长 hideProgressBar(); setStatusText('就绪'); if (dom.mutualBtn) dom.mutualBtn.disabled = false; const prunedMutual = {}; state.friends.data.forEach(f => { if (_mutualCache[f.steamid] !== undefined) prunedMutual[f.steamid] = _mutualCache[f.steamid]; }); storage.setMutualCache(prunedMutual); applyPanelFiltersAndSort(); applyModalFiltersAndSort(); } finally { if (dom.mutualBtn) dom.mutualBtn.disabled = false; } } // ==================== 批量数据拉取 ==================== 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); const sumData = await SteamAPI.getSummary(batch); if (sumData.response && sumData.response.players) summaries.push(...sumData.response.players); // 进度 = 已完成数 / 总任务数(不能用批次起始下标:首尾批次会永远到不了 100%) if (progressCallback) progressCallback(Math.min(i + batch.length, totalIds), totalIds); } // 写入 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: daysInfo.days == null ? '' : getDaysClass(daysInfo.days), personaname: s.personaname || 'Steam 用户', 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; state.vac.autoTries = 0; startVACCheck(true); } function resetAndCheckLevel() { storage.setLevelCache({}); startLevelCheck(true); } // ==================== 个人游戏库数据获取(绝版数据库 / 我的游戏库 / 最近游玩) ==================== // 绝版游戏数据库 — 优先使用 GM_getResourceText,失败时回退到 gmFetch(与 steam-game-library-viewer 共享数据源) let _delistedDB = null; async function loadDelistedDB() { if (_delistedDB) return _delistedDB; try { const fromResource = readResourceJson('delistedData', '绝版游戏库'); if (fromResource) { _delistedDB = fromResource; return _delistedDB; } _delistedDB = JSON.parse(await gmFetch('https://leanisssharedstorage.blob.core.windows.net/copilot/asdm-files/steam_delisted_apps.json')); return _delistedDB; } catch (e) { logger.warn('加载绝版游戏库失败', e); // 失败不缓存空对象:否则本会话永远返回空库,且 fetchMyDelisted 会把空结果持久化 24 小时, // 用户整天看到"绝版总数 0"且手动刷新无效;返回 null 让调用方按失败处理、下次重试 _delistedDB = null; return null; } } // 兼容 library-viewer 新旧格式并标准化字段(_delistedDB 加载失败为 null 时返回空列表,调用方按"暂无数据"降级) function getDelistedApps() { let rawApps = []; if (_delistedDB && Array.isArray(_delistedDB.data)) rawApps = _delistedDB.data; else if (_delistedDB && _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 '已下架'; const map = { 'delisted': '已下架', 'purchase_disabled': '购买禁用', 'f2p_unavailable': 'F2P不可用', 'retail_only': '仅零售', 'test_app': '测试应用', }; 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`; } // 获取个人游戏库数据(24小时TTL独立缓存;缓存过期后强制走网络,不依赖好友游戏库的永久缓存) const fetchMyOwnedGames = withCache({ memKey: 'personal.gamesCache', dbKey: 'sfd_my_games_cache', ttl: MY_LIBRARY_TTL }, async () => { const apiKey = storage.getApiKey(); if (!apiKey) return null; const mySteamId = storage.getSteamId() || detectCurrentSteamId(); if (!mySteamId) return null; try { const r = await fetchOwnedGamesCached(mySteamId, true); 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; } }); // 获取最近游玩数据(24小时TTL独立缓存;缓存过期后强制走网络刷新) const fetchMyRecentGames = withCache({ memKey: 'personal.recentCache', dbKey: 'sfd_my_recent_cache', ttl: MY_LIBRARY_TTL }, async () => { const apiKey = storage.getApiKey(); if (!apiKey) return null; const mySteamId = storage.getSteamId() || detectCurrentSteamId(); if (!mySteamId) return null; try { const r = await fetchRecentCached(mySteamId, true); if (r && r.error) return null; // 网络失败与"最近没玩游戏"区分,避免误报空态 return r.games || []; } catch (e) { logger.warn('获取最近游玩失败', e); return null; } }); // ==================== 入库动态数据获取(许可解析 / 库历史 / 绝版收藏) ==================== // 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 抓取) // 在途共享句柄:同一时刻只允许一个许可页请求,其余调用共享它的 Promise(与 _withCachePending 同模式) let _licenseMapPending = null; async function fetchPersonalLicenses() { if (state.personal.licenseMap) return state.personal.licenseMap; // 共享在途 Promise:等待者挂同一请求,finally 复位,异常也不会永久卡住等待者 if (_licenseMapPending) return _licenseMapPending; state.personal.licenseMapLoading = true; const p = new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://store.steampowered.com/account/licenses/?l=schinese', timeout: 15000, onload(resp) { try { // 非 2xx(登录页/403/限流页)不能解析成空表后固化 licenseMap,否则本会话永久短路不再重试 if (resp.status < 200 || resp.status >= 300) { logger.warn('许可页请求失败: HTTP ' + resp.status); state.personal.licenseMapLoading = false; resolve({}); return; } const html = resp.responseText || ''; const map = {}; parseLicenseRows(html, map); // 解析出 0 条通常是登录页而非许可页,同样按失败处理 if (Object.keys(map).length === 0) { logger.warn('许可页解析为空(可能是登录页),跳过写入'); state.personal.licenseMapLoading = false; resolve({}); return; } // 提取 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({}); } }); }); _licenseMapPending = p; try { return await p; } finally { _licenseMapPending = null; } } // 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 { // 与首页同等严格:非 2xx 或响应缺 html 结构字段时,绝不能把"只加载了前几页"的 map 固化进 state.personal.licenseMap if (resp.status < 200 || resp.status >= 300) { logger.warn('许可分页请求失败: HTTP ' + resp.status + ',跳过写入不完整许可表'); state.personal.licenseMapLoading = false; resolve(map); return; } const data = JSON.parse(resp.responseText); if (!data || typeof data.html !== 'string') { logger.warn('许可分页响应结构异常(缺少 html),跳过写入不完整许可表'); state.personal.licenseMapLoading = false; resolve(map); return; } 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) { // 分页中途出错:数据不完整,不写入 licenseMap(否则交叉验证永不重试), // 已解析的部分仍通过 resolve 返回供本次渲染使用,缺失条目显示"待验证" state.personal.licenseMapLoading = false; resolve(map); } }, onerror() { state.personal.licenseMapLoading = false; resolve(map); }, ontimeout() { state.personal.licenseMapLoading = false; resolve(map); } }); }; loadNext(cursor); } // 获取自己的完整入库历史:复用家庭组数据获取方法(从商店 webapi_token 鉴权), // 调用 IFamilyGroupsService/GetSharedLibraryApps(include_own=true) 得到每个游戏的 rt_time_acquired 入库时间。 // 新增家庭共享影响检测——标记共拥游戏(owners.length > 1),并通过个人许可页交叉验证日期。 const fetchMyLibraryHistory = withCache({ memKey: 'personal.licenseHistoryCache', dbKey: 'sfd_library_history_cache', ttl: MY_LIBRARY_TTL }, async () => { const mySteamId = storage.getSteamId() || detectCurrentSteamId(); if (!mySteamId) return null; try { const ok = await loadFamilyData(mySteamId); const gl = state.family.gameList; if (ok !== true || !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; } }); // 用个人许可页数据交叉验证共拥游戏的入库日期 // 核心修复: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; // 矫正结果必须回写 IndexedDB(只改内存会在刷新后丢失);_ts 沿用原值,避免顺带刷新 withCache 的 TTL if (verifiedCount > 0) { const _prevStored = _dbGet('sfd_library_history_cache', null); _dbSet('sfd_library_history_cache', { data: items, _ts: (_prevStored && _prevStored._ts) || Date.now() }); } } catch (e) { logger.warn('交叉验证许可失败', e); } } // 检测个人绝版收藏(与 library-viewer 共享数据库并比对个人游戏库;24小时TTL独立缓存) const fetchMyDelisted = withCache({ memKey: 'personal.delistedCache', dbKey: 'sfd_my_delisted_cache', ttl: MY_LIBRARY_TTL }, async () => { const ownedData = await fetchMyOwnedGames(); if (!ownedData || !ownedData.games.length) return null; const db = await loadDelistedDB(); if (!db) return null; // 绝版库加载失败:返回 null(withCache 不缓存 null),下次进入自动重试 // 详情源预建成 Map:避免循环内 games.find 的 O(绝版数 × 拥有数) const ownedById = new Map(ownedData.games.map(g => [g.appid, g])); // 数据构建走共享板(对比弹窗同款),个人库特有字段经 enrichOwned 附加 return buildDelistedBoardData(ownedData.games, (aid, app) => { const gameInfo = ownedById.get(aid) || {}; return { name: gameInfo.name || app.name || `App ${aid}`, playtime_forever: gameInfo.playtime_forever || 0, img_icon_url: gameInfo.img_icon_url || '', ownersPct: app.owners || '', achievements: app.achievements || null, keyshopPrice: app.keyshopPrice || null, }; }); }); function renderPlaytimeBar(distData, total) { if (total === 0) return h('div', { class: 'sfd-pl-empty', text: '暂无数据' }); 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: '0-1 小时', min: 0.01, max: 1, color: '#94a3b8' }, { label: '1-10 小时', min: 1, max: 10, color: '#60a5fa' }, { label: '10-50 小时', min: 10, max: 50, color: '#34d399' }, { label: '50-100 小时', min: 50, max: 100, color: '#fbbf24' }, { label: '100-500 小时', min: 100, max: 500, color: '#f97316' }, { label: '500+ 小时', min: 500, max: Infinity, color: '#ef4444' } ]; const zeroCount = games.filter(g => g.playtime_forever === 0).length; return [ { label: '0 小时', 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 })) ]; } /* ================================================================ * PART 5 · 好友管理窗口(面板 UI / 设置页 / 总览 / 筛选 / 页面增强) * ================================================================ */ // ==================== 滚动覆盖辅助函数 ==================== 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, gamelibTrigger, panelEl; function initUI() { panelTrigger = h('button', { class: 'sfd-trigger-base sfd-trigger-panel', title: '好友列表', html: ICONS.users, onClick: () => { panelEl.classList.toggle('sfd-show'); dashTipHide(); // 面板关闭时清掉挂在 body 上的 tooltip,避免残留 if (panelEl.classList.contains('sfd-show')) { renderActiveTab(); autoRefreshFriends(); startCacheCountdown(); // E20: 隐藏时已停表,重新显示时恢复倒计时 } } }); document.body.appendChild(panelTrigger); libraryTrigger = h('button', { class: 'sfd-trigger-base sfd-trigger-library', title: '我的游戏库', 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: '家庭组', html: ICONS.family, onClick: () => { if (!familyPopupEl) { if (!state.family.popupSteamid) { // 与 Title Bar 入口一致:可靠来源优先;DOM 首块在排序/筛选视图下可能是任意好友,仅作最后回退 state.family.popupSteamid = storage.getSteamId() || detectCurrentSteamId(); 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) showFamilyPopup(state.family.popupSteamid); return; } familyPopupEl.classList.toggle('sfd-show'); } }); document.body.appendChild(familyTrigger); gamelibTrigger = h('button', { class: 'sfd-trigger-base sfd-trigger-gamelib', title: '游戏库', html: ICONS.grid, onClick: () => { if (!gamelibPopupEl) { showGameLibraryPopup(); return; } gamelibPopupEl.classList.toggle('sfd-show'); } }); document.body.appendChild(gamelibTrigger); // ===== 统一好友管理窗口(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 : ('好友管理') }), 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' }, html: `${p.country_flag || ''} ${esc(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: '获取好友数据', html: ICONS.search, id: 'sfd-fetch-btn', disabled: state.friends.isLoading, onClick: startDataProcess }), h('button', { class: 'sfd-header-action-btn sfd-action-vac', title: '🛡️ 刷新VAC', html: ICONS.shieldDots, id: 'sfd-vac-btn', onClick: resetAndCheckVAC }), h('button', { class: 'sfd-header-action-btn sfd-action-level', title: '⭐ 刷新等级', html: ICONS.level, id: 'sfd-level-btn', onClick: resetAndCheckLevel }), h('button', { class: 'sfd-header-action-btn sfd-action-mutual', title: '🔄 共同好友', 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: () => { if (state.dashboard.loading) { toast.info('正在加载中,请稍候'); return; } loadDashboardData(true); } }) ]), h('button', { class: 'sfd-header-btn', html: ICONS.settings, id: 'sfd-settings-btn', title: '设置', onClick: toggleSettings }), createCloseBtn(() => { dashTipHide(); panelEl.classList.remove('sfd-show'); }) ]) ]), h('div', { class: 'sfd-content', id: 'sfd-content' }), // 状态条:setStatusText/updateVACStatus 的可见出口(VAC 失败数、检查进度等提示依赖此元素) h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 12px', fontSize: '11px', color: '#8a9ba8' } }, [ h('span', { id: 'sfd-status-txt', text: '就绪' }), h('span', { class: 'sfd-vac-status', id: 'sfd-vac-status' }) ]), 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-dash-content'); // 面板不随点击空白处关闭,仅通过标题栏 × 按钮收起,避免误关 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 + ('的好友'); // Restore VAC / level / mutual stats from cache const vacCache = storage.getVACCache(); const levelCache = storage.getLevelCache(); const mutualCache = storage.getMutualCache(); state.friends.data.forEach(f => { // VAC 缓存值是对象 {VACBanned,...},必须逐字段恢复(历史版本把整对象赋给布尔字段,导致全员被误标封禁) const v = vacCache[f.steamid]; if (v) { f.vac_banned = !!v.VACBanned; f.vac_days_since_last_ban = v.DaysSinceLastBan || 0; f.vac_number_of_bans = v.NumberOfVACBans || 0; f.vac_game_bans = v.NumberOfGameBans || 0; } 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 { stopCacheCountdown(); if (state.friends.activeTab === 'insights') { renderInsightsTab(container); renderDashboard(); loadDashboardData(false).catch(e => logger.warn('仪表盘加载异常', e)); } else renderOverviewTab(container); } } // ==================== 设置页(使用说明 / 账号设置 / 缓存信息) ==================== // 保存设置后的延迟切页定时器(用户可能在延迟期间已切走或关闭面板,不能被强行抢切 Tab) let _settingsSaveTimer = null; function isSettingsTabActive() { if (state.friends.activeTab !== 'settings') return false; const panel = document.getElementById('sfd-panel'); return !!(panel && panel.classList.contains('sfd-show')); } function renderSettingsTab(parent) { stopCacheCountdown(); const wrap = h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', maxWidth: '1080px', margin: '0 auto' } }); // ===== 上方左右结构:使用说明(左)+ 账号设置(右) ===== const topRow = h('div', { style: { display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)', gap: '12px', alignItems: 'stretch' } }); // 左:使用说明 const guideItems = [ ['🔘', '面板4按钮', '好友列表 · 数据大盘 · 家庭组 · 设置'], ['👨‍👩‍👧', '家庭组', '点击家庭组按钮查看成员、游戏库与愿望单'], ['📊', '数据大盘', '好友游戏排行、时长热力图等'], ['🎮', '好友游戏库', '点击好友卡片查看 TA 的游戏库与共同游戏'], ['🔑', 'API Key', `在此获取,填入右侧即可`], ]; topRow.appendChild(h('div', { class: 'sfd-family-stat-card', style: { padding: '14px 16px', textAlign: 'left' } }, [ h('div', { style: { fontSize: '14px', fontWeight: '700', color: '#fff', marginBottom: '10px', display: 'flex', alignItems: 'center', gap: '6px' } }, [ h('span', { html: ICONS.manageList, style: { width: '16px', height: '16px', color: '#66c0f4', display: 'inline-flex' } }), h('span', { text: '使用说明'}) ]), ...guideItems.map(([ic, title, desc]) => h('div', { style: { display: 'flex', alignItems: 'baseline', gap: '8px', fontSize: '12px', lineHeight: '2', color: '#94a3b8' } }, [ h('span', { style: { flexShrink: '0' }, text: ic }), h('span', { html: `${title}:${desc}` }) ])) ])); // 右:账号设置(SteamID64 自动检测只读展示 + API Key) const detectedId = storage.getSteamId() || detectCurrentSteamId(); const accountCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '14px 16px', textAlign: 'left', display: 'flex', flexDirection: 'column' } }); accountCard.appendChild(h('div', { style: { fontSize: '14px', fontWeight: '700', color: '#fff', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' } }, [ h('span', { html: ICONS.settings, style: { width: '16px', height: '16px', color: '#66c0f4', display: 'inline-flex' } }), h('span', { text: '账号设置'}) ])); // SteamID64:自动检测,只读展示 accountCard.appendChild(h('div', { style: { marginBottom: '14px' } }, [ h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' } }, [ h('label', { style: { fontSize: '12px', color: '#94a3b8', fontWeight: '600' }, text: '我的 SteamID64'}), h('span', { style: { fontSize: '10px', fontWeight: '700', padding: '1px 8px', borderRadius: '4px', background: 'rgba(52,211,153,0.12)', color: '#34d399', border: '1px solid rgba(52,211,153,0.25)' }, text: '🤖 自动检测'}) ]), h('div', { style: { display: 'flex', gap: '8px' } }, [ h('div', { style: { flex: '1', display: 'flex', alignItems: 'center', padding: '0 14px', minHeight: '38px', background: 'rgba(15,23,42,0.5)', border: '1px solid var(--sfd-border)', borderRadius: '8px', userSelect: 'text' } }, [ h('span', { style: { fontSize: '13px', color: detectedId ? '#66c0f4' : '#64748b', fontFamily: 'ui-monospace,Consolas,monospace', letterSpacing: '0.5px' }, text: detectedId || ('未检测到,请登录 Steam 页面') }) ]), h('button', { class: 'sfd-btn sfd-btn-ghost', title: '复制 SteamID64', style: { width: '38px', height: '38px', padding: '0', flexShrink: '0' }, html: ICONS.copy, onClick: () => { if (!detectedId) return; navigator.clipboard.writeText(detectedId).then(() => toast.success('SteamID64 已复制')).catch(() => toast.error('复制失败')); } }) ]) ])); // API Key accountCard.appendChild(h('div', { class: 'sfd-form-group', style: { marginBottom: '0' } }, [ h('label', { text: 'Steam Web API Key' }), h('input', { type: 'password', class: 'sfd-input', id: 'sfd-key-input', value: storage.getApiKey(), placeholder: '粘贴您的 API Key' }) ])); const saveStatus = h('span', { style: { fontSize: '12px', color: 'var(--sfd-accent-green)' } }); const saveBtn = h('button', { class: 'sfd-btn sfd-btn-primary', text: '💾 保存设置', onClick: () => { const key = document.getElementById('sfd-key-input').value.trim(); const hadApiKey = storage.getApiKey(); storage.setApiKey(key); if (detectedId) storage.setSteamId(detectedId); saveStatus.textContent = '✅ 设置已保存'; if (_settingsSaveTimer) { clearTimeout(_settingsSaveTimer); _settingsSaveTimer = null; } // 新用户首次配置 API Key 后,自动回到好友总览并触发获取好友数据 if (!hadApiKey && key) { _settingsSaveTimer = setTimeout(() => { _settingsSaveTimer = null; saveStatus.textContent = ''; if (isSettingsTabActive()) toggleSettings(); startDataProcess(); }, 300); } else { _settingsSaveTimer = setTimeout(() => { _settingsSaveTimer = null; saveStatus.textContent = ''; if (isSettingsTabActive()) toggleSettings(); }, 800); } } }); accountCard.appendChild(h('div', { class: 'sfd-btn-row', style: { marginTop: '14px' } }, [saveBtn, saveStatus])); accountCard.appendChild(h('div', { style: { fontSize: '11px', color: '#64748b', lineHeight: '1.7', marginTop: '10px' }, text: 'SteamID64 无需手动填写,登录 Steam 页面后自动检测。API Key 仅保存在本地油猴存储中。'})); topRow.appendChild(accountCard); wrap.appendChild(topRow); // ===== 下方:缓存信息卡片 ===== wrap.appendChild(buildCacheInfoCard()); parent.appendChild(wrap); startCacheCountdown(); } // ---- 缓存信息卡片:大小 / 数据量 / 更新时间 / TTL 倒计时 ---- let _cacheTimerId = null; function stopCacheCountdown() { if (_cacheTimerId) { clearInterval(_cacheTimerId); _cacheTimerId = null; } } function startCacheCountdown() { stopCacheCountdown(); _cacheTimerId = setInterval(() => { const root = document.getElementById('sfd-content'); if (!root || !root.isConnected) { stopCacheCountdown(); return; } // 面板隐藏时停表,显示时由面板打开路径重新启动(避免 interval 空转) const panel = root.closest('.sfd-panel'); if (!panel || !panel.classList.contains('sfd-show')) { stopCacheCountdown(); return; } let alive = 0; root.querySelectorAll('[data-ttl-deadline]').forEach(el => { alive++; const left = Number(el.dataset.ttlDeadline) - Date.now(); const txt = el.querySelector('.sfd-cache-cd-text'); const bar = el.querySelector('.sfd-cache-cd-bar'); if (left <= 0) { if (txt) txt.textContent = '已过期 · 待刷新'; if (bar) bar.style.width = '0%'; el.style.color = '#f59e0b'; } else { if (txt) txt.textContent = fmtCountdown(left); if (bar) bar.style.width = Math.max(2, left / Number(el.dataset.ttlTtl) * 100).toFixed(1) + '%'; } }); if (!alive) stopCacheCountdown(); }, 1000); } function fmtBytes(bytes) { if (!bytes || bytes <= 0) return '0 B'; if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB'; if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'; return bytes + ' B'; } // 单个缓存键的 JSON 字节大小(Blob 按 UTF-8 计量);结果记入 _sizeCache,重渲染时直接复用 function _cacheBytes(key) { if (_sizeCache.has(key)) return _sizeCache.get(key); let bytes = 0; try { const v = _dbCache[key]; if (v !== undefined) { const s = JSON.stringify(v); bytes = s ? new Blob([s]).size : 0; } } catch (e) { bytes = 0; } _sizeCache.set(key, bytes); return bytes; } // 提取缓存时间戳:顶层 _ts/timestamp/ts,或各条目的最大 ts function _cacheTs(v) { if (!v || typeof v !== 'object' || Array.isArray(v)) return 0; let ts = (typeof v._ts === 'number' && v._ts) || (typeof v.timestamp === 'number' && v.timestamp) || (typeof v.ts === 'number' && v.ts) || 0; for (const k in v) { const it = v[k]; if (it && typeof it === 'object' && !Array.isArray(it)) { const tk = it._ts || it.timestamp || it.ts || it._deletedAt || 0; if (typeof tk === 'number' && tk > ts) ts = tk; } } return ts; } function fmtTimeShort(ms) { if (!ms) return '—'; const d = new Date(ms); const p = n => String(n).padStart(2, '0'); return `${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } function fmtCountdown(msLeft) { if (msLeft <= 0) return '00:00:00'; const s = Math.floor(msLeft / 1000); const days = Math.floor(s / 86400); const p = n => String(n).padStart(2, '0'); const hm = `${p(Math.floor((s % 86400) / 3600))}:${p(Math.floor((s % 3600) / 60))}:${p(s % 60)}`; return days > 0 ? days + ('天 ') + hm : hm; } // 缓存清单:按窗口分组(keys 用于合计大小与读取,ttl=0 表示永久缓存) function _cacheGroups() { const ogLibs = Object.keys(_dbCache).filter(k => k.startsWith('sfd_og_') && k !== 'sfd_og_summary'); const timelines = Object.keys(_dbCache).filter(k => k.startsWith('sfd_timeline_')); const friendsTs = _dbGet('sfd_friends_cache_ts', 0); const n = (v, suffix) => (v && Object.keys(v).length) ? Object.keys(v).length + suffix : '—'; const nm1 = (v, suffix) => { // 排除 _ts/ts 元属性后计数 if (!v) return '—'; let c = 0; for (const k in v) if (k !== '_ts' && k !== 'ts') c++; return c ? c + suffix : '—'; }; return [ { name: '好友管理窗口', color: '#66c0f4', onPurge: () => { _dbDelete('sfd_friends_cache_ts'); state.friends.data = []; state.friends.filtered = []; state.friends.modalFiltered = []; state.friends.ownProfile = null; _friendsMap.clear(); Object.keys(_mutualCache).forEach(k => delete _mutualCache[k]); Object.keys(_mutualFriendLists).forEach(k => delete _mutualFriendLists[k]); _mutualFriendListOrder.length = 0; // E18: 同步清空插入序,避免淘汰表残留已失效的键 }, items: [ { name: '好友列表', keys: ['sfd_cached_friends_data'], ttl: 0, tsFallback: friendsTs, info: v => Array.isArray(v) && v.length ? v.length + (' 位好友') : '—' }, { name: '我的资料', keys: ['sfd_cached_own_profile'], ttl: 0, tsFallback: friendsTs, info: v => (v && v.personaname) ? v.personaname : '—' }, { name: 'VAC 封禁数据', keys: ['sfd_vac_cache'], ttl: 0, tsFallback: friendsTs, info: v => n(v, ' 位已扫描') }, { name: '好友等级数据', keys: ['sfd_level_cache'], ttl: 0, tsFallback: friendsTs, info: v => n(v, ' 位') }, { name: '共同好友数据', keys: ['sfd_mutual_cache'], ttl: 0, tsFallback: friendsTs, info: v => n(v, ' 位') }, { name: '已删除好友', keys: ['sfd_deleted_friends'], ttl: 0, tsFallback: friendsTs, info: v => n(v, ' 位') }, { name: '入库时间线', keys: timelines, ttl: 0, tsFallback: friendsTs, info: () => timelines.length ? timelines.length + (' 位好友') : '—' }, ] }, { name: '数据大盘', color: '#54a0ff', onPurge: () => { state.dashboard.data = null; state.dashboard.error = null; state.dashboard.loading = false; state.dashboard.summaries = null; }, items: [ { name: '深度大盘数据', keys: ['sfd_dash_friends_cache', 'sfd_dash_cache'], ttl: 0, info: v => nm1(v, ' 位好友库') }, { name: '历史时长快照', keys: ['sfd_dash_snaps'], ttl: 0, info: v => n(v, ' 天') }, { name: '玩家摘要', keys: ['sfd_summaries_cache'], ttl: SUMMARIES_TTL, info: v => nm1(v, ' 位') }, { name: '最近游玩记录', keys: ['sfd_recent_cache'], ttl: RECENT_TTL, info: v => nm1(v, ' 位') }, ] }, { name: '好友游戏库', color: '#a29bfe', onPurge: () => { // D19a: 本组缺 onPurge 会导致清理后内存态仍留旧数据。该组键(sfd_og_summary / sfd_og_) // 是大盘聚合时 ownedMap 的来源,已聚合进 state.dashboard.data,故必须一并作废。 state.dashboard.data = null; state.dashboard.error = null; }, items: [ { name: '好友游戏库', keys: ['sfd_og_summary', ...ogLibs], ttl: 0, info: () => ogLibs.length ? ogLibs.length + (' 个游戏库') : '—' }, ] }, { name: '家庭组窗口', color: '#f59e0b', onPurge: () => { state.family.info = null; state.family.gameList = null; state.family.playActivity = null; state.family.playActivityLoading = false; state.family.playActivityFailed = false; wlState.data = null; wlState.updatedAt = 0; wlState.loading = false; }, items: [ { name: '家庭组数据', keys: ['sfd_family_cache'], ttl: FAMILY_REFRESH_COOLDOWN, info: v => { const mc = v && v.familyInfo && v.familyInfo.family_member ? v.familyInfo.family_member.length : 0; const gc = v && v.familyGameList && v.familyGameList.GameInfo ? Object.keys(v.familyGameList.GameInfo).length : 0; return (mc || gc) ? `${mc} ${'名成员'} · ${gc} ${'款游戏'}` : '—'; } }, { name: '游玩动态', keys: ['sfd_family_play_activity'], ttl: FAMILY_PLAY_TTL, info: v => (v && v.data) ? Object.keys(v.data).length + (' 位成员') : '—' }, { name: '家庭组愿望单', keys: ['sfd_wishlist_cache'], ttl: 0, info: v => (v && v.data && v.data.length) ? v.data.length + (' 款游戏') : '—' }, ] }, { name: '我的游戏库窗口', color: '#2ed573', onPurge: resetPersonalCacheState, items: [ { name: '我的游戏库', keys: ['sfd_my_games_cache'], ttl: MY_LIBRARY_TTL, info: v => (v && v.data && v.data.total) ? v.data.total + (' 款游戏') : '—' }, { name: '我的最近游玩', keys: ['sfd_my_recent_cache'], ttl: MY_LIBRARY_TTL, info: v => (v && v.data && v.data.length) ? v.data.length + (' 条记录') : '—' }, { name: '入库动态记录', keys: ['sfd_library_history_cache'], ttl: MY_LIBRARY_TTL, info: v => (v && v.data && v.data.length) ? v.data.length + (' 条许可') : '—' }, { name: '绝版收藏', keys: ['sfd_my_delisted_cache'], ttl: MY_LIBRARY_TTL, info: v => (v && v.data && v.data.length) ? v.data.length + (' 款游戏') : '—' }, ] }, { name: '通用缓存', color: '#06cfbe', onPurge: () => { Object.keys(_gameNameCache).forEach(k => delete _gameNameCache[k]); _zhImgGood.clear(); _zhImgFailTs.clear(); _stressStoreOwnedCache.clear(); _stressDetectDBLoaded = false; }, items: [ { name: '游戏中文名', keys: ['sfd_game_name_cache'], ttl: NAME_CACHE_TTL, info: v => n(v, ' 个名称') }, { name: '预购游戏检测', keys: ['sfd_stress_detect_cache'], ttl: STRESS_DETECT_TTL, info: v => n(v, ' 款') }, { name: '中文胶囊链接', keys: ['sfd_zh_capsule_cache'], ttl: 0, info: v => (v && v.good && Object.keys(v.good).length) ? Object.keys(v.good).length + (' 个链接') : '—' }, ] }, ]; } function buildCacheInfoCard() { const groups = _cacheGroups(); // 大小计算开销大(数百个游戏库 JSON.stringify 会阻塞主线程数秒), // 渲染时仅显示占位符,键级任务异步分批填充(见 _fillCacheSizesAsync) let totalKeys = 0, permCount = 0, ttlCount = 0; groups.forEach(g => g.items.forEach(it => { totalKeys += it.keys.length; if (it.ttl > 0) ttlCount++; else permCount++; })); const card = h('div', { class: 'sfd-family-stat-card', style: { padding: '14px 16px', textAlign: 'left' } }); // 头部:标题 + 站点存储占比(异步填充) const quotaEl = h('span', { style: { color: '#94a3b8' }, text: '…' }); card.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' } }, [ h('div', { style: { fontSize: '14px', fontWeight: '700', color: '#fff', display: 'flex', alignItems: 'center', gap: '6px' } }, [ h('span', { html: ICONS.database, style: { width: '16px', height: '16px', color: '#66c0f4', display: 'inline-flex' } }), h('span', { text: '缓存信息'}) ]), h('div', { style: { fontSize: '11px', color: '#64748b', display: 'flex', alignItems: 'center', gap: '8px' } }, [ h('span', { html: `站点存储占用:`}), quotaEl ]) ])); if (navigator.storage && navigator.storage.estimate) { navigator.storage.estimate().then(est => { if (!quotaEl.isConnected) return; quotaEl.textContent = `${fmtBytes(est.usage)} / ${fmtBytes(est.quota)}(${(est.usage / est.quota * 100).toFixed(1)}%)`; }).catch(() => { quotaEl.textContent = '—'; }); } else quotaEl.textContent = '—'; // KPI 行:总大小 / 条目数 / 永久 / TTL(总大小异步累加填充) card.appendChild(h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: '8px', marginBottom: '6px' } }, [ createMetricCard({ value: '…', label: '缓存总大小', accent: '#66c0f4', id: 'sfd-cache-total-size' }), createMetricCard({ value: String(totalKeys), label: '缓存条目', accent: '#54a0ff' }), createMetricCard({ value: String(permCount), label: '永久缓存', accent: '#34d399' }), createMetricCard({ value: String(ttlCount), label: 'TTL 缓存', accent: '#f59e0b' }), ])); // 分组列表(含组级清理按钮) groups.forEach(g => { card.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', margin: '10px 2px 6px' } }, [ h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: g.color, display: 'inline-block' } }), h('span', { style: { fontSize: '12px', fontWeight: '700', color: '#c7d5e0' }, text: g.name }), h('span', { style: { flex: '1' } }), buildPurgeBtn(g) ])); g.items.forEach(it => { // 时间:跨键取最新(浅遍历开销小,可同步);大小异步填充 let ts = 0; it.keys.forEach(k => { const t = _cacheTs(_dbCache[k]); if (t > ts) ts = t; }); if (!ts && it.tsFallback) ts = it.tsFallback; const val = _dbGet(it.keys[0], null); const hasData = it.keys.some(k => _dbCache[k] !== undefined); // TTL 状态 let ttlEl; if (it.ttl <= 0) { ttlEl = h('span', { style: { fontSize: '10px', fontWeight: '700', padding: '2px 8px', borderRadius: '4px', background: 'rgba(52,211,153,0.1)', color: '#34d399', border: '1px solid rgba(52,211,153,0.2)', whiteSpace: 'nowrap' }, text: '∞ 永久'}); } else if (!hasData) { ttlEl = h('span', { style: { fontSize: '10px', fontWeight: '700', padding: '2px 8px', borderRadius: '4px', background: 'rgba(100,116,139,0.1)', color: '#64748b', whiteSpace: 'nowrap' }, text: '未缓存'}); } else if (!ts) { // D19b: 取不到时间戳时不能按 ts=0 计算 deadline(= 0 + ttl,恒为过去 → 永远显示「已过期」)。 // 「无时间戳」与「已过期」必须分开呈现,避免把有数据的缓存误标成已过期。 ttlEl = h('span', { style: { fontSize: '10px', fontWeight: '700', padding: '2px 8px', borderRadius: '4px', background: 'rgba(100,116,139,0.1)', color: '#94a3b8', border: '1px solid rgba(100,116,139,0.2)', whiteSpace: 'nowrap' }, text: '— 未知'}); } else { const deadline = ts + it.ttl; const left = deadline - Date.now(); ttlEl = h('div', { style: { minWidth: '96px' }, dataset: { ttlDeadline: String(deadline), ttlTtl: String(it.ttl) } }, [ h('div', { style: { fontSize: '10px', fontWeight: '700', color: left > 0 ? '#fbbf24' : '#f59e0b', whiteSpace: 'nowrap', textAlign: 'right', fontVariantNumeric: 'tabular-nums' } }, [ h('span', { class: 'sfd-cache-cd-text', text: left > 0 ? fmtCountdown(left) : ('已过期') }) ]), h('div', { style: { height: '3px', background: 'rgba(255,255,255,0.06)', borderRadius: '2px', overflow: 'hidden', marginTop: '3px' } }, [ h('div', { class: 'sfd-cache-cd-bar', style: { height: '100%', width: left > 0 ? Math.max(2, left / it.ttl * 100).toFixed(1) + '%' : '0%', background: left > 0 ? '#fbbf24' : '#f59e0b', borderRadius: '2px', opacity: '0.75', transition: 'width 1s linear' } }) ]) ]); } const sizeCell = h('span', { style: { fontSize: '11px', color: '#94a3b8', textAlign: 'right', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, text: hasData ? '…' : '—' }); if (hasData) sizeCell.dataset.pendingSize = it.keys.join('|'); card.appendChild(h('div', { style: { display: 'grid', gridTemplateColumns: 'minmax(110px,150px) minmax(0,1fr) 72px 92px 104px', gap: '10px', alignItems: 'center', padding: '6px 10px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.04)', borderLeft: `3px solid ${g.color}`, borderRadius: '8px', marginBottom: '5px' } }, [ h('span', { style: { fontSize: '12px', fontWeight: '600', color: '#c6d4df', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, text: it.name }), h('span', { style: { fontSize: '11px', color: '#8097a8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, text: hasData ? (it.info(val) || '—') : '—' }), sizeCell, h('span', { style: { fontSize: '11px', color: '#64748b', textAlign: 'right', whiteSpace: 'nowrap' }, text: hasData ? fmtTimeShort(ts) : '—' }), ttlEl ])); }); }); _fillCacheSizesAsync(card); return card; } // 键级异步大小计算:每帧预算 8ms 逐键 stringify,避免大缓存(数百游戏库)阻塞设置页首帧渲染 function _fillCacheSizesAsync(card) { const cells = Array.from(card.querySelectorAll('[data-pending-size]')); // 必须相对 card 查询:本函数在卡片挂载到 document 前调用, // document.getElementById 此时返回 null,导致 KPI 总大小永远停留在占位符 const totalEl = card.querySelector('#sfd-cache-total-size'); const rowBytes = new Map(cells.map(c => [c, 0])); let total = 0, cellIdx = 0, keyIdx = 0; const step = () => { if (!card.isConnected) return; // 卡片已随 Tab 切换销毁 const t0 = Date.now(); while (cellIdx < cells.length && Date.now() - t0 < 8) { const cell = cells[cellIdx]; const keys = cell.dataset.pendingSize.split('|'); if (keyIdx >= keys.length) { cellIdx++; keyIdx = 0; continue; } const b = _cacheBytes(keys[keyIdx++]); rowBytes.set(cell, rowBytes.get(cell) + b); total += b; cell.textContent = fmtBytes(rowBytes.get(cell)); } if (totalEl && totalEl.isConnected) totalEl.textContent = fmtBytes(total); if (cellIdx < cells.length) setTimeout(step, 0); }; setTimeout(step, 0); } // 组级清理按钮:两段式确认(第一次点击高亮"确认清空",3秒内再点执行,超时还原) const _purgeIcon = () => ICONS.trash.replace(' { if (btn.dataset.armed === '1') { purgeCacheGroup(g); return; } btn.dataset.armed = '1'; btn.style.color = '#ff6b6b'; btn.style.borderColor = 'rgba(255,107,107,0.4)'; btn.innerHTML = `${_purgeIcon()}${'确认清空'}`; setTimeout(() => { if (!btn.isConnected || btn.dataset.armed !== '1') return; btn.dataset.armed = ''; btn.style.color = '#94a3b8'; btn.style.borderColor = ''; btn.innerHTML = `${_purgeIcon()}${'清理'}`; }, 3000); } }); btn.innerHTML = `${_purgeIcon()}${'清理'}`; return btn; } // 清空一个缓存组:删除存储键 + GM 备份镜像 + 重置对应内存态,然后重渲染设置页 function purgeCacheGroup(g) { const keys = new Set(); g.items.forEach(it => it.keys.forEach(k => keys.add(k))); keys.forEach(k => { _dbDelete(k); // GM 扩展备份镜像同步删除,避免下次启动从备份回补已清理的数据 if ((GM_BK_KEYS.includes(k) || GM_BK_PREFIXES.some(p => k.startsWith(p))) && typeof GM_deleteValue === 'function') { try { GM_deleteValue('sfd_bk_' + k); } catch (e) { /* 单条失败继续 */ } } }); if (g.onPurge) { try { g.onPurge(); } catch (e) { logger.warn('缓存清理回调失败: ' + g.name, e); } } toast.success(`已清空「${g.name}」缓存(${keys.size} 项)`); renderActiveTab(); } // ==================== 好友总览(KPI 指标 + 搜索排序) ==================== function renderOverviewTab(parent) { const apiKey = storage.getApiKey(); if (!apiKey) { parent.appendChild(h('div', { class: 'sfd-tip', text: '⚠️ 请先在「设置」中配置 API Key。' })); 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: '搜索昵称/国家...', 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; // 防抖:每敲一个字符都全量重建卡片 DOM,上千好友时明显卡顿 clearTimeout(state.overview._searchTimer); state.overview._searchTimer = setTimeout(() => { applyOverviewFilters(); renderOverviewTable(); }, 200); } })); // 排序按钮 const sortBtns = [ { key: 'days', text: '好友天数'}, { key: 'status', text: '状态'}, { key: 'name', text: '名字'}, { key: 'level', text: '等级'}, { key: 'country', text: '国家'}, { key: 'mutual', text: '共同好友'}, ]; 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, dataset: { key: s.key, baseText: s.text }, 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 => { const bKey = b.dataset.key; const bActive = nextVal === bKey + '-desc' || nextVal === bKey + '-asc'; b.classList.toggle('active', bActive); b.style.background = bActive ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.05)'; b.style.color = bActive ? '#66c0f4' : '#94a3b8'; // 箭头是派生量,基准文本始终取自 dataset.baseText b.textContent = b.dataset.baseText + (bActive ? (nextVal === bKey + '-desc' ? ' ▼' : ' ▲') : ' ▼'); }); 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 === 'all') return; // 再次点击同一个筛选则回到 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 => isOnline(f) && !isInGame(f)); 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(isNewFriend); 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 => isOnline(f) && !isInGame(f)).length, offline: data.filter(f => f.personastate === 0).length, new: data.filter(isNewFriend).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(); let newCount = 0, deletedCount = 0; data.forEach(f => { // 「在线」与筛选/chip 同口径:不含游戏中(游戏中另有独立 KPI 与筛选) if (isOnline(f) && !isInGame(f)) 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 (isNewFriend(f)) newCount++; }); // 已删除好友计数来自独立累积记录(其不在当前好友列表中,personastate 统计不到) deletedCount = (state.overview.deletedFriends || []).length; const setVal = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = String(v); }; // 「好友总数」KPI 与「全部」chip 同源(state.friends.data),消除"页面标称数 vs API 分页实拉数"的同屏口径冲突 setVal('sfd-ov-kpi-total', data.length); 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); // 好友总数 KPI 走 getSteamFriendsCount()(页面标称总数),可能大于已加载数组长度; // 在卡片悬浮提示里标明两个口径的来源差异,避免与「全部」chip 计数互相打脸 const totalKpiVal = document.getElementById('sfd-ov-kpi-total'); const totalKpiCard = totalKpiVal ? totalKpiVal.closest('.sfd-metric-card') : null; if (totalKpiCard) { const pageTotal = getSteamFriendsCount(); const loadedTotal = data.length; totalKpiCard.title = pageTotal !== loadedTotal ? (`页面标称总数 ${pageTotal} / 已加载 ${loadedTotal}`) : (`好友总数(页面标称):${pageTotal}`); } } // ==================== 卡片式好友列表渲染 ==================== 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 = '好友于: {date}'.replace('{date}', DateUtils.format(f.friend_since)); let titleDetail = sinceText; if (hasGame) titleDetail += `\n${'正在玩: {game}'.replace('{game}', f.gameextrainfo)}`; else if (f.personastate === 0 && f.lastlogoff) titleDetail += `\n${'最近在线: {time}'.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 ? `共同好友: ${f.mutualFriendsCount}` : '私密', 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 || ''} ${esc(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: '对比库存', 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; // 重建前记录滚动位置,重建后恢复(原本在顶部则不恢复) const _prevScrollTop = scrollContainer.scrollTop; 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: '暂无好友数据' })); return; } // 全部好友直接渲染,无分类折叠;用 fragment 批量插入,避免逐个 appendChild 触发多次布局 const frag = document.createDocumentFragment(); all.forEach(f => frag.appendChild(renderFriendCard(f))); scrollContainer.appendChild(frag); if (_prevScrollTop > 0) scrollContainer.scrollTop = _prevScrollTop; } 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; // 加载态只影响好友总览 Tab;设置页重渲染会销毁重建缓存卡片, // 导致"入库时间线"等大缓存行的大小计算被迫从头再算一遍 if (state.friends.activeTab !== 'settings') renderActiveTab(); } // ==================== 过滤与排序算法 ==================== function applyPanelFiltersAndSort() { // 联动好友总览筛选 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); // state.modalSortBy 是从未定义/赋值的历史遗留字段(无 UI 写入),每轮都会打出 // "unknown sort key: undefined" 警告且排序静默失效;modal 列表是总览的导出镜像, // 直接跟随总览当前排序,保证 CSV/JSON 导出顺序与用户看到的表格一致 sortHelper(list, state.overview.sortBy || 'days-desc'); 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; // 必须先挂进 DOM 再 click,并延迟 revoke:Firefox/部分 WebKit 会在下载读取 blob 前让 URL 失效 document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000); } // CSV/JSON 下载(带 BOM、时间戳文件名),供好友/家庭组导出共用。 // 统一在此做转义:调用方直接传原始值即可 function csvCell(v) { let s = v == null ? '' : String(v); // 防 Excel/LibreOffice 公式注入:以 = + @ 或 "-非数字" 开头的文本前置单引号(负数不受影响) if (/^[=+@]/.test(s) || /^-(?!\d|\.|$)/.test(s)) s = "'" + s; return `"${s.replace(/"/g, '""')}"`; } function downloadCSV(headers, rows, name) { const csv = '\uFEFF' + [headers.map(csvCell).join(','), ...rows.map(r => r.map(csvCell).join(','))].join('\n'); downloadFile(csv, `${name}_${Date.now()}.csv`, 'text/csv;charset=utf-8'); } function downloadJSON(data, name) { downloadFile(JSON.stringify(data, null, 2), `${name}_${Date.now()}.json`, 'application/json'); } 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 || '', 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 || '' ]); downloadCSV(headers, rows, 'steam_friends'); } function exportModalJSON() { const data = state.friends.modalFiltered || state.friends.data; if (!data.length) return; downloadJSON(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 })), 'steam_friends'); } 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 || '', names, info.owners.length, info.time ? new Date(info.time * 1000).toISOString() : '']; }); downloadCSV(headers, rows, 'family_games'); } function exportFamilyJSON() { const gl = state.family.gameList; const fi = state.family.info; if (!gl || !gl.GameInfo) return; downloadJSON(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 })), 'family_games'); } 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 'status-asc': { // 与 status-desc 完全反号,否则连点两次「状态」得到的 'status-asc' 会落到 default 静默失效 const wA = getWeight(a), wB = getWeight(b); if (wA !== wB) return wA - wB; if (wA === 0) return a.lastlogoff - b.lastlogoff; return a.friend_days - b.friend_days; } case 'name-asc': return a.personaname.localeCompare(b.personaname, 'zh'); case 'name-desc': return b.personaname.localeCompare(a.personaname, 'zh'); case 'country-asc': { const na = a.country_name || a.loccountrycode || '\uFFFF'; const nb = b.country_name || b.loccountrycode || '\uFFFF'; return na.localeCompare(nb, 'zh'); } case 'country-desc': { const na = a.country_name || a.loccountrycode || '\u0000'; const nb = b.country_name || b.loccountrycode || '\u0000'; return nb.localeCompare(na, 'zh'); } 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: // 未知排序键必须自曝(warn),不得静默返回 0 logger.warn('unknown sort key: ' + method); 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 || ''} ${esc(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 = `好友 ${cnt} / ${lim}(上限随等级增长)`; el.appendChild(gaugeBar); } // ==================== 页脚状态管理 ==================== function setStatusText(txt) { const el = document.getElementById('sfd-status-txt'); if (el) el.textContent = txt; } // ==================== 已删除好友检测 ==================== // 与上次缓存的好友列表对比,找出"消失"的好友并累积记录;重新加回的好友会从已删除列表中移除。 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); } // 打开面板时自动刷新好友数据(每页仅一次:刷新页面后才允许再次自动请求,同页多次开窗不重复请求) function autoRefreshFriends() { if (state.friends.isLoading) return; if (!storage.getApiKey()) return; if (state.friends.fetchedThisPage) return; startDataProcess(); } // ==================== 数据同步并调用主逻辑 ==================== async function startDataProcess() { const apiKey = storage.getApiKey(); const steamId = storage.getSteamId() || detectCurrentSteamId(); if (!apiKey || !steamId) { // 区分「未配置 API Key」与「无法识别 SteamID」,不再合并成一条硬编码英文 alert if (!apiKey) { toast.error('请先在「设置」中配置 API Key。'); } else { toast.error('未能识别 SteamID,请确认已登录 Steam 页面。'); } return; } // 上一次请求仍在进行中则直接返回(沿用原置位的防抖语义,改用显式 loading 标志) if (state.friends.isLoading) return; switchStateLoading(true); setStatusText('正在查询好友列表...'); const bpBar = showProgressBar(); try { const data = await fetchAllData(steamId, (curr, total) => { // total 为真实总任务数;为 0 时避免 NaN% 写进 width 被静默丢弃 setProgressPercent(bpBar, total > 0 ? (curr / total) * 100 : 0); setStatusText('已获取 {current}/{total} 位好友...'.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); // 好友数据刷新时间戳(缓存信息卡片展示用,与数据本身分开存储避免改动旧结构) _dbSet('sfd_friends_cache_ts', Date.now()); if (data.own) storage.setCachedOwn(data.own); if (data.own && data.own.personaname) document.title = data.own.personaname + ('的好友'); applyPanelFiltersAndSort(); applyModalFiltersAndSort(); setStatusText('共 {total} 位好友'.replace('{total}', data.friends.length)); applyPageEnhancements(data.friends); loadVACFromCache(); loadLevelFromCache(); // 仅在本次请求成功后才置位;失败时不置位,自动重试才不会被 autoRefreshFriends 的守卫挡住 state.friends.fetchedThisPage = true; } catch (e) { logger.error('数据拉取失败', e); setStatusText('获取失败:{msg}'.replace('{msg}', e.message)); } finally { switchStateLoading(false); hideProgressBar(); // 设置页可能有未保存输入,不整页重渲染(与 switchStateLoading 内的保护一致) if (state.friends.activeTab !== 'settings') 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: '好友于: {date}'.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'); } }); } /* ================================================================ * PART 6 · 深度大盘(社交仪表盘数据聚合与渲染) * ================================================================ */ // ==================== 社交仪表盘 · 深度大盘(数据聚合与渲染) ==================== 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']; 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 '0分'; if (minutes < 60) return `${Math.round(minutes)}分钟`; const hrs = minutes / 60; return `${hrs >= 100 ? Math.round(hrs) : hrs.toFixed(1)}小时`; } 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 _dashCapsuleVertGood = new Map(); // 竖版库海报(600x900)真实链接持久缓存:新游戏直链 404,只有 API 哈希 URL 可用,持久化后免重复请求 API const _dashCapsuleHeaderGood = new Map(); // 大图场景(悬停预览/曲线海报/预测海报)用的 header(460x215) 真实链接 const _dashCapsuleApiCache = new Map(); // 纯内存正/负缓存:容量上限 + 插入序淘汰;失败重试节奏由 sfd_capsule_api_fail 的 TTL 控制,淘汰负缓存只是让它更早重试 // 注意:_dashCapsuleGood / _dashCapsuleHeaderGood / _zhImgGood / _zhImgFailTs 同时是持久化缓存 // 的内存镜像(IDB dsh 键 / sfd_zh_capsule_cache、以及 GM 备份),淘汰内存会影响持久化语义,故不加上限。 const DASH_CAPSULE_API_CACHE_MAX = 800; function _capCapsuleApiCache(pkey, value) { if (_dashCapsuleApiCache.has(pkey)) _dashCapsuleApiCache.delete(pkey); _dashCapsuleApiCache.set(pkey, value); while (_dashCapsuleApiCache.size > DASH_CAPSULE_API_CACHE_MAX) { const oldest = _dashCapsuleApiCache.keys().next().value; _dashCapsuleApiCache.delete(oldest); } } const _dashCapsuleApiPending = new Map(); const DB_CAPSULE_PREFIX = 'sfd_capsule_'; const DB_CAPSULE_HDR_PREFIX = 'sfd_capsule_hdr_'; const DB_CAPSULE_VERT_PREFIX = 'sfd_capsule_vert_'; // 三类封面(横版/大图/竖版)持久化工厂:写内存 Map + IndexedDB + 触发 GM 防抖备份 function _makeCapsulePersister(goodMap, dbPrefix) { return (appid, url) => { if (url && url.startsWith('http')) { goodMap.set(appid, url); _dbSet(dbPrefix + appid, url); _capsuleBkWriter.schedule(); } }; } const _persistCapsuleUrl = _makeCapsulePersister(_dashCapsuleGood, DB_CAPSULE_PREFIX); const _persistCapsuleHeaderUrl = _makeCapsulePersister(_dashCapsuleHeaderGood, DB_CAPSULE_HDR_PREFIX); // 仅存真竖版:header 兜底图不存,Steam 补充官方竖版后可自动替换 const _persistCapsuleVertUrl = _makeCapsulePersister(_dashCapsuleVertGood, DB_CAPSULE_VERT_PREFIX); // 封面真实链接整体镜像到 GM 扩展存储(防抖 2s,避免逐条写;beforeunload 冲刷防丢更新) const _capsuleBkWriter = _debouncedWriter(_writeCapsuleGMBackup, 2000); function _writeCapsuleGMBackup() { // 三类各自镜像:超出配额仅跳过该类,不阻断其他类备份 [[_dashCapsuleGood, 'sfd_bk_capsule_map', '封面'], [_dashCapsuleHeaderGood, 'sfd_bk_capsule_hdr_map', '封面大图'], [_dashCapsuleVertGood, 'sfd_bk_capsule_vert_map', '竖版海报']].forEach(([map, gmKey, label]) => { try { const obj = {}; map.forEach((url, appid) => { obj[appid] = url; }); const s = JSON.stringify(obj); if (s.length > GM_BK_MAX_BYTES) { logger.warn(`${label}GM备份超出 ${Math.round(GM_BK_MAX_BYTES / 1024)}KB 配额,本次跳过备份 (${Math.round(s.length / 1024)}KB)`); } else { GM_setValue(gmKey, s); } } catch (e) { logger.silent(label + 'GM备份写入失败', e); } }); } // 从 GM 备份 JSON 恢复到内存 Map(仅补缺失项;IndexedDB 被站点清理时的回补通道) function _restoreGMMap(gmKey, goodMap, label) { try { const s = GM_getValue(gmKey, null); if (!s) return; const obj = JSON.parse(s); let n = 0; Object.entries(obj).forEach(([appid, url]) => { const id = Number(appid); if (url && id && !goodMap.has(id)) { goodMap.set(id, url); n++; } }); if (n) console.log(`%c[SFD] 已从扩展备份恢复 ${n} 条${label}`, 'color:#34d399'); } catch (e) { logger.silent(label + '备份恢复失败', e); } } // 启动时从 IndexedDB 恢复封面缓存;IndexedDB 被清理时从 GM 扩展备份整体回补 function _restoreCapsuleCache() { Object.keys(_dbCache).forEach(key => { if (key.startsWith(DB_CAPSULE_HDR_PREFIX)) { const appid = Number(key.slice(DB_CAPSULE_HDR_PREFIX.length)); if (appid && _dbCache[key]) _dashCapsuleHeaderGood.set(appid, _dbCache[key]); return; } if (key.startsWith(DB_CAPSULE_VERT_PREFIX)) { const appid = Number(key.slice(DB_CAPSULE_VERT_PREFIX.length)); if (appid && _dbCache[key]) _dashCapsuleVertGood.set(appid, _dbCache[key]); return; } if (key.startsWith(DB_CAPSULE_PREFIX)) { const appid = Number(key.slice(DB_CAPSULE_PREFIX.length)); if (appid && _dbCache[key]) _dashCapsuleGood.set(appid, _dbCache[key]); } }); _restoreGMMap('sfd_bk_capsule_map', _dashCapsuleGood, '封面真实链接'); _restoreGMMap('sfd_bk_capsule_hdr_map', _dashCapsuleHeaderGood, '封面大图'); _restoreGMMap('sfd_bk_capsule_vert_map', _dashCapsuleVertGood, '竖版海报'); if (_dashCapsuleGood.size) console.log(`%c[SFD] 封面缓存已恢复 ${_dashCapsuleGood.size} 条`, 'color:#66c0f4'); } // 防抖写入器工厂:schedule() 防抖触发,flush() 立即冲刷(beforeunload 卸载防丢用) function _debouncedWriter(writeFn, ms) { let timer = null; return { schedule() { clearTimeout(timer); timer = setTimeout(() => { timer = null; writeFn(); }, ms); }, flush() { if (timer) { clearTimeout(timer); timer = null; } writeFn(); } }; } // 封面 API 失败负缓存:区分两类失败——确认无资产(未发售/受限/下架)7 天不重试; // 网络/限频失败仅 10 分钟(避免一次抖动就把封面拉黑一周)。旧格式纯数字时间戳按网络失败短 TTL 处理,尽快自愈 const CAPSULE_FAIL_RETRY_TTL = 7 * 864e5; const CAPSULE_NETFAIL_RETRY_TTL = 10 * 6e4; let _capsuleApiFail = null; function _capsuleApiFailMap() { if (_capsuleApiFail === null) _capsuleApiFail = _dbGet('sfd_capsule_api_fail', {}) || {}; return _capsuleApiFail; } function _capsuleFailHit(rec) { if (!rec) return false; const now = Date.now(); if (typeof rec === 'number') return (now - rec) < CAPSULE_NETFAIL_RETRY_TTL; return (now - rec.t) < (rec.n ? CAPSULE_NETFAIL_RETRY_TTL : CAPSULE_FAIL_RETRY_TTL); } function dashFetchCapsuleFromStore(appid, wantHeader) { const pkey = wantHeader ? appid + ':h' : String(appid); const fails = _capsuleApiFailMap(); // '' 是失败负缓存:仅当失败时间戳仍在 TTL 内才算命中,超期视为未命中并删除,恢复重试(键名/结构不变) if (_dashCapsuleApiCache.has(pkey)) { const cached = _dashCapsuleApiCache.get(pkey); const stillFailing = _capsuleFailHit(fails[appid]); if (cached || stillFailing) return Promise.resolve(cached); _dashCapsuleApiCache.delete(pkey); } if (_dashCapsuleApiPending.has(pkey)) return _dashCapsuleApiPending.get(pkey); // 注意:此处不可用 _dashCapsuleHeaderGood 短路——apiFallback 仅在加载链(含缓存 URL)全部失败后才会进入, // 此时旧缓存已确认失效,必须真正请求 appdetails 拿新地址并覆盖持久化,否则失效 URL 死循环导致碎图+重复无效请求 if (_capsuleFailHit(fails[appid])) return Promise.resolve(''); const p = SteamAPI.getAppDetails(appid) .then(json => { let url = ''; const d = json && json[appid]; if (d && d.success && d.data) { url = wantHeader ? (d.data.header_image || d.data.capsule_image || '') : (d.data.capsule_image || d.data.header_image || ''); } _capCapsuleApiCache(pkey, url); _dashCapsuleApiPending.delete(pkey); if (url) { if (wantHeader) _persistCapsuleHeaderUrl(appid, url); else _persistCapsuleUrl(appid, url); delete fails[appid]; } else { fails[appid] = { t: Date.now(), n: 0 }; _dbSet('sfd_capsule_api_fail', fails); } // 确认无资产:长 TTL return url; }) .catch(() => { _capCapsuleApiCache(pkey, ''); _dashCapsuleApiPending.delete(pkey); fails[appid] = { t: Date.now(), n: 1 }; _dbSet('sfd_capsule_api_fail', fails); return ''; }); // 网络/限频:短 TTL _dashCapsuleApiPending.set(pkey, p); return p; } // ===== 通用链式图片加载驱动器:chain 逐个尝试 → 耗尽后 apiFallback 取真实 URL(可选)→ finalChain 尾链(可选)→ onGiveUp ===== // onLoaded(url) 在每次 load 成功时回调(注意:tryZhImage 晚到的中文封面也会触发);onGiveUp 在全部失败时回调一次 function dashChainLoad(img, chain, opts) { const { apiFallback, finalChain, onGiveUp, onLoaded } = opts || {}; let ci = 1, fi = 0, stage = 0; // ci 从 1 起:chain[0] 已作为初始 src 尝试,首个 error 直接推进到 chain[1] const giveUp = () => { if (onGiveUp) onGiveUp(); }; const nextFinal = () => { if (finalChain && fi < finalChain.length) { stage = 2; img.src = finalChain[fi++]; } else giveUp(); }; img.addEventListener('error', () => { if (stage === 0) { if (ci < chain.length) { img.src = chain[ci++]; return; } if (!apiFallback) { nextFinal(); return; } stage = 1; Promise.resolve(apiFallback()).then(url => { if (url) img.src = url; else nextFinal(); }, nextFinal); return; } nextFinal(); // stage 1/2:API URL 或尾链仍失败 }); if (onLoaded) img.onload = () => onLoaded(img.src); img.src = chain[0]; } // ===== 大图(header 460x215)加载:CDN header → 已缓存真实链接 → CDN capsule → Store API 兜底 ===== // 悬停预览 / 打折曲线海报 / 预测海报 共用;新游戏 CDN 缺失时由 appdetails 提供真实 header 链接 function dashLoadPosterImg(img, appid, onGiveUp, heroFirst) { const chain = []; if (heroFirst) chain.push(`https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/library_hero.jpg`); chain.push(`https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`); const hdr = _dashCapsuleHeaderGood.get(appid); if (hdr) chain.push(hdr); chain.push(`${DASH_CAPSULE_CDN}${appid}/capsule_467x181.jpg`); chain.push(dashCapsuleFallback(appid)); dashChainLoad(img, chain, { apiFallback: () => dashFetchCapsuleFromStore(appid, true), onGiveUp, // 成功 URL 统一持久化(幂等):无论命中直链、CDN 变体还是 API 哈希地址,都写入本地缓存; // 下次刷新/重建时该地址直接进入加载链命中,不再重复请求直链(404 探测请求也一并省掉) onLoaded: url => { if (url && url !== hdr) _persistCapsuleHeaderUrl(appid, url); } }); } // ===== 竖版库海报(library_600x900,600x900 竖向宣传图)加载 ===== // 直链链(4 CDN × 2x/标准,参考库展示脚本 coverChainUrls)→ GetItems API 哈希资产 → header.jpg 兜底 // 新游戏直链已迁移至哈希子目录(不可猜测),仅能通过 IStoreBrowseService/GetItems(include_assets) 获取真实竖版 URL const _verticalPosterApiCache = new Map(); // appid -> 哈希竖版 URL;'' 为会话内负缓存,避免重复请求 const _verticalPosterApiPending = new Map(); function dashFetchVerticalPosterFromApi(appid) { const key = String(appid); if (_verticalPosterApiCache.has(key)) return Promise.resolve(_verticalPosterApiCache.get(key)); if (_verticalPosterApiPending.has(key)) return _verticalPosterApiPending.get(key); const input = { ids: [{ appid: Number(appid) }], context: { language: 'schinese', country_code: 'CN', steam_realm: 1 }, data_request: { include_assets: true } }; const url = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input)); const p = requestSteamAPI(url).then(json => { const si = json && json.response && json.response.store_items && json.response.store_items[0]; const assets = si && si.assets; const path = assets && (assets.library_capsule_2x || assets.library_capsule); // library_capsule_2x 即 600x900 竖版 const fmt = assets && assets.asset_url_format; let u = ''; if (path && fmt && fmt.indexOf('${FILENAME}') >= 0) { u = `https://shared.cloudflare.steamstatic.com/store_item_assets/${fmt.replace('${FILENAME}', path)}`; } _verticalPosterApiCache.set(key, u); _verticalPosterApiPending.delete(key); return u; }).catch(() => { _verticalPosterApiCache.set(key, ''); _verticalPosterApiPending.delete(key); return ''; }); _verticalPosterApiPending.set(key, p); return p; } function dashLoadVerticalPosterImg(img, appid) { const { wrapper } = wrapImgWithLoader(img, appid); // 与普通海报一致:包 loader wrapper 提供加载动画 const cf = `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}`; const ak = `https://cdn.akamai.steamstatic.com/steam/apps/${appid}`; const sha = `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}`; const sfy = `https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/${appid}`; // 持久缓存的真竖版 URL 优先(命中则免走 API);失效会经 onerror 自动回退后续链并覆盖更新 const good = _dashCapsuleVertGood.get(appid); const chain = good ? [good] : []; chain.push( `${cf}/library_600x900_2x.jpg`, `${cf}/library_600x900.jpg`, `${ak}/library_600x900_2x.jpg`, `${ak}/library_600x900.jpg`, `${sha}/library_600x900_2x.jpg`, `${sha}/library_600x900.jpg`, `${sfy}/library_600x900_2x.jpg`, `${sfy}/library_600x900.jpg` ); // header 兜底(API 也拿不到竖版资产时);不持久化,保留 Steam 补官方竖版后的自动替换能力 const headers = [`${cf}/header.jpg`, `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}/header.jpg`]; dashChainLoad(img, chain, { apiFallback: () => dashFetchVerticalPosterFromApi(appid), finalChain: headers, onLoaded: url => { // 验证加载成功后持久化真实竖版 URL(header 兜底不存) if (url && url.indexOf('header') < 0 && url !== _dashCapsuleVertGood.get(appid)) _persistCapsuleVertUrl(appid, url); _markLoaded(wrapper); }, onGiveUp: () => { img.onload = null; _markBroken(wrapper); } }); return wrapper; } // ===== 横版网格海报(header 460x215)加载:与悬停预览同源同比例(2.14:1),替代过窄的 capsule_231x87 ===== // 复用 dashLoadPosterImg 的回退链(header → 缓存真实链接 → capsule_467x181 → Store API),外包 loader wrapper 提供加载动画 function dashLoadWidePosterImg(img, appid) { const { wrapper } = wrapImgWithLoader(img, appid); dashLoadPosterImg(img, appid, () => _markBroken(wrapper)); img.addEventListener('load', () => _markLoaded(wrapper)); return wrapper; } // 创建胶囊图破碎占位图标 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) { 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`); dashChainLoad(img, chain, { apiFallback: () => dashFetchCapsuleFromStore(appid), onLoaded: url => { if (url && url !== _dashCapsuleGood.get(appid)) _persistCapsuleUrl(appid, url); _markLoaded(wrapper); }, onGiveUp: () => { img.onload = null; img.onerror = null; img.style.display = 'none'; _markBroken(wrapper); } }); // 异步尝试中文封面,成功则替换 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) { wrapper = wrapImgWithLoader(wrapperOrImg, appid, wrapperClass).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); dashChainLoad(img, fallbackUrl ? [primaryUrl, fallbackUrl] : [primaryUrl], { onLoaded: () => _markLoaded(wrapper), onGiveUp: () => { img.onload = null; img.onerror = null; _markBroken(wrapper, small); } }); if (zhFile) { tryZhImage(img, appid, zhFile); } } // ===== 异步中文封面加载(结果持久化:成功永久保留,失败 7 天后重试,避免每次页面加载都发探测请求)===== const _zhImgGood = new Map(); // "appid|zhFileName" -> 中文封面URL const _zhImgFailTs = new Map(); // "appid|zhFileName" -> 失败时间戳 const _zhCacheWriter = _debouncedWriter(_writeZhCapsuleCache, 3000); // 防抖写盘;beforeunload 冲刷防丢 function _writeZhCapsuleCache() { const good = {}, fail = {}; _zhImgGood.forEach((url, k) => { good[k] = url; }); _zhImgFailTs.forEach((ts, k) => { fail[k] = ts; }); _dbSet('sfd_zh_capsule_cache', { good, fail }); } function _restoreZhCapsuleCache() { const c = _dbGet('sfd_zh_capsule_cache', null); if (!c) return; const now = Date.now(); let g = 0, f = 0; Object.entries(c.good || {}).forEach(([k, url]) => { if (url) { _zhImgGood.set(k, url); g++; } }); Object.entries(c.fail || {}).forEach(([k, ts]) => { if (ts && now - ts < CAPSULE_FAIL_RETRY_TTL) { _zhImgFailTs.set(k, ts); f++; } }); if (g || f) console.log(`%c[SFD] 中文封面缓存已恢复 ${g} 条成功 / ${f} 条失败记录`, 'color:#66c0f4'); } function tryZhImage(img, appid, zhFileName) { if (!appid) return; const ck = appid + '|' + zhFileName; const cached = _zhImgGood.get(ck); if (cached) { img.src = cached; return; } if (_zhImgFailTs.has(ck)) return; const zhUrl = `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/${zhFileName}`; const tester = new Image(); tester.onload = () => { _zhImgGood.set(ck, zhUrl); img.src = zhUrl; _zhCacheWriter.schedule(); }; tester.onerror = () => { _zhImgFailTs.set(ck, Date.now()); _zhCacheWriter.schedule(); }; 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); } // 不能用 innerHTML 比较:浏览器会把 style="#94a3b8" 序列化成 rgb(...),永远不等 → 每次 mousemove 重建 tooltip;与上次源串比较 if (dashTipEl.__lastHtml !== html) { dashTipEl.__lastHtml = 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'; } // 海报卡片悬停预览通用绑定:tooltip 内容 + 一次性懒加载真实海报 + 跟随/隐藏(系列海报/对比弹窗/时间线卡片共用) function bindPosterHoverTip(el, tip, appid) { el.addEventListener('mouseenter', e => { dashTip(tip, e.clientX, e.clientY); const im = dashTipEl && dashTipEl.querySelector('img[data-appid]'); if (im && !im.dataset.posterLoad) { im.dataset.posterLoad = '1'; dashLoadPosterImg(im, appid, () => im.remove()); } }); el.addEventListener('mousemove', e => dashTip(tip, e.clientX, e.clientY)); el.addEventListener('mouseleave', dashTipHide); } 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, force) { const now = Date.now(); // 永久缓存:请求完毕永久保留(无TTL),仅 force(好友窗口手动刷新/大盘强制刷新)才重新请求 const c = storage.getOwnedGameCache(sid); if (!force && c && !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, force) { const r = await fetchOwnedGamesCached(sid, force); // 同时记录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); // 兜底:IndexedDB 缺失但 GM 扩展备份仍在时先恢复,避免误判"首次记录"而清空全部历史 if (!timeline || !Array.isArray(timeline.records)) { const bk = _gmBkRead('sfd_timeline_' + sid); if (bk && Array.isArray(bk.records)) { timeline = bk; storage.setTimeline(sid, bk); } } 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 { // 可疑部分数据保护:Steam API 偶发返回不完整列表(总数骤降过半且原库不小), // 此时跳过对比与写回,防止污染永久记录(假"新增"泛滥/基准被缩小) const lastTotal = timeline.records.length ? (timeline.records[timeline.records.length - 1].totalGames || 0) : (timeline.latestAppIds || []).length; if (lastTotal >= 50 && totalGames < lastTotal * 0.5) { logger.warn(`入库动态跳过可疑数据:${sid} 上次 ${lastTotal} 款,本次仅 ${totalGames} 款`); return { timeline: timeline.records, gameInfo: timeline.gameInfo, total: lastTotal, isFirst, allGames, newAppIds: [] }; } 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, force) { const cache = storage.getRecentCache() || {}; const c = cache[sid]; const now = Date.now(); if (!force && 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, 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'); }); } // 点亮动画的在途定时器:登记后可被统一取消,避免面板关闭后仍继续跑 const _dashLightTimers = new Set(); function dashClearLightTimers() { _dashLightTimers.forEach(t => clearTimeout(t)); _dashLightTimers.clear(); } state.ui.disposers.push(dashClearLightTimers); function dashLightUpAll(sids, interval = 15) { // 空数组立即完成:否则 done === sids.length 永不成立,返回的 Promise 永久 pending if (!sids || !sids.length) return Promise.resolve(); return new Promise(resolve => { let done = 0; sids.forEach((sid, idx) => { const t = setTimeout(() => { _dashLightTimers.delete(t); const cell = document.querySelector(`.sfd-dash-load-cell[data-sid="${sid}"]`); if (cell) { cell.classList.remove('dimmed'); cell.classList.add('active'); const t2 = setTimeout(() => { _dashLightTimers.delete(t2); cell.classList.add('loaded'); cell.classList.remove('active'); }, 150); _dashLightTimers.add(t2); } done++; if (done === sids.length) resolve(); }, idx * interval); _dashLightTimers.add(t); }); }); } 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) { // 同日第二次采集若覆盖面更小(部分好友私密/超时),直接覆盖会让首次采集到的好友凭空消失 → 并集合并,同 sid 以本次较新数据为准 const prev = snaps[today]; snaps[today] = (prev && typeof prev === 'object' && !Array.isArray(prev)) ? Object.assign({}, prev, dayData) : 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 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, noRecentCount = 0; for (const sid in recentMap) { const r = recentMap[sid]; if (r.error) continue; if (r.private) { // 空 response 同时代表「资料私密(读不到)」与「资料公开但近2周无记录」:用 GetPlayerSummaries 的 communityvisibilitystate(3=公开) 区分,避免把"没玩"误算成"私密" const _vis = summaries[sid] ? summaries[sid].communityvisibilitystate : undefined; if (_vis === 3) noRecentCount++; else 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: '其他', 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 + noRecentCount, private: privateCount, noRecent: noRecentCount, 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); // delta 基准必须是「真正的昨天」:原实现取上一个有快照的日,缺日时会把多日累计增量记成单日 const cells = days.map(day => { const snap = snaps[day]; if (!snap || snap[sid] == null) return null; const prevDay = dashDateKey(new Date(new Date(day + 'T00:00:00').getTime() - 86400000)); if (!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; // 流水线序号:设置页清缓存会把 loading 重置为 false,从而放行第二条流水线; // 旧流水线必须靠序号发现自己已过期,避免覆盖新流水线的 state.dashboard.data 与磁盘缓存 state.dashboard.loadSeq = (state.dashboard.loadSeq || 0) + 1; const mySeq = state.dashboard.loadSeq; const apiKey = storage.getApiKey(); // 缓存数据不保证是数组(storage.getCachedData() 不做类型校验): // 非数组会让 friends.map 在 try 之外抛出未捕获异常,noFriends 兜底与 error 状态全部失效 const cachedFriends = storage.getCachedData(); const friends = (Array.isArray(state.friends.data) && state.friends.data.length) ? state.friends.data : (Array.isArray(cachedFriends) ? cachedFriends : []); 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(); try { const sids = friends.map(f => f.steamid); const delay = ms => new Promise(r => setTimeout(r, ms)); const now = Date.now(); // 阶段1:刷新好友在线状态 dashSetStage(1, '正在刷新好友在线状态…'); const summaries = {}; // 优先读取 summaries 缓存(5分钟TTL),避免与好友列表重复请求 const summariesCache = storage.getSummariesCache(); if (summariesCache && !forceRefresh) { const sidSet = new Set(sids); Object.keys(summariesCache).forEach(sid => { if (sidSet.has(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('正在加载头像…', 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, '正在获取最近游玩记录…'); const recentCache = storage.getRecentCache() || {}; const recentMap = {}; const needFetchRecent = []; const cachedRecent = []; sids.forEach(sid => { const c = recentCache[sid]; if (!forceRefresh && 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, forceRefresh); } catch (e) { recentMap[sid] = { games: [], error: true }; } }); await _concurrentPool(recentTasks, 8, (done, total) => dashSetProgress(`正在获取最近游玩记录 (${done}/${total})…`, 5 + 50 * done / total)); } await delay(500); dashDimAllAvatars(); await delay(300); // 阶段3:获取游戏库数据 dashSetStage(3, '正在获取游戏库数据…'); const ownedSummary = storage.getOwnedGamesSummary(); const ownedMap = {}; const needFetch = []; const cachedOwned = []; // 游戏库数据永久缓存:之前在好友单个窗口点过的好友直接复用,不再请求;仅强制刷新时重取 sids.forEach(sid => { const c = ownedSummary[sid]; if (!forceRefresh && c && !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, forceRefresh); } catch (e) { ownedMap[sid] = { game_count: 0, totalMinutes: 0, error: true }; } }); await _concurrentPool(ownedTasks, 6, (done, total) => dashSetProgress(`正在获取游戏库数据 (${done}/${total})…`, 55 + 22 * done / total)); } await delay(500); dashDimAllAvatars(); await delay(300); // 阶段4:获取好友列表数据 dashSetStage(4, '正在获取好友列表数据…'); const friendsCache = storage.getDashFriendsCache() || {}; const friendMap = {}; const needFetchFr = []; const cachedFriends = []; // 好友列表数据永久缓存:仅强制刷新时重取 sids.forEach(sid => { const c = friendsCache[sid]; if (!forceRefresh && c) { 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(`正在获取好友列表数据 (${done}/${total})…`, 77 + 20 * done / total)); friendsCache._ts = now; storage.setDashFriendsCache(friendsCache); } dashRecordSnapshot(ownedMap); dashSetProgress('正在聚合统计数据…', 99); const data = dashAggregate(friends, summaries, recentMap, ownedMap, friendMap); data.generatedAt = Date.now(); // 写回前校验序号:本流水线已被更新的流水线取代时,本次结果整体作废 if (mySeq !== state.dashboard.loadSeq) return; state.dashboard.data = data; storage.setDashCache({ ts: data.generatedAt, data }); } catch (e) { logger.warn('仪表盘数据加载失败', e); if (mySeq !== state.dashboard.loadSeq) return; state.dashboard.error = (e && e.message) || 'error'; } finally { // 无论成功/失败/异常,只有最新一条流水线可以复位 loading 并渲染, // 否则旧流水线会清掉新流水线的在途标志,让两条流水线继续互相覆盖 if (mySeq === state.dashboard.loadSeq) { 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 k = d.kpis; const items = [ { iconSvg: ICONS.users, val: `${k.total} / ${getSteamFriendsLimit()}`, lbl: '好友总数', accent: '#60a5fa' }, { iconSvg: ICONS.activity, val: k.online, lbl: '当前在线', accent: '#34d399' }, { iconSvg: ICONS.game, val: k.ingame, lbl: '游戏中', accent: '#a78bfa' }, { iconSvg: ICONS.clock, val: dashFmtH(k.total2w), lbl: '近2周总时长', accent: '#fbbf24' }, { iconSvg: ICONS.trophy, val: k.public, lbl: `数据公开好友 · 私密 ${k.private}`, 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 card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.trophy, '最近好友游戏时长排行', '近2周 · Top 10')); if (!d.recentRank.length) { // 空数据有两种成因,文案必须区分:资料私密(读不到) vs 资料公开但近2周确实无记录 const k = d.kpis; let msg; if (k.private > 0 && k.noRecent > 0) msg = `无游玩记录:${k.noRecent} 位好友资料公开但近2周无记录,${k.private} 位好友资料私密(无法读取)`; else if (k.private > 0) msg = `无游玩记录:${k.private} 位好友资料私密(无法读取)`; else if (k.noRecent > 0) msg = `好友资料均公开,但近2周无游玩记录`; else msg = '近2周暂无好友游玩记录(或好友游戏详情为私密)'; card.appendChild(h('div', { class: 'sfd-dash-empty', text: msg })); 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: `为第 1 名的 ${pct}%`}) ]); 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', 'noopener,noreferrer') }, children); dashBindTip(row, () => { const games = r.topGames.map(g => `${esc(g.name)} · ${dashFmtH(g.playtime_2weeks)}`).join('
'); return `${esc(r.personaname)}
${'近2周总时长'}: ${dashFmtH(r.total2w)}
${games}`; }); card.appendChild(row); }); return card; } function dashGameTipHtml(g, total) { const pct = total > 0 ? (g.minutes / total * 100).toFixed(1) : '0'; const img = g.appid ? `` : ''; return `${img}${esc(g.name)}
${'近2周'}: ${dashFmtH(g.minutes)} · ${pct}%
${g.players} ${'位好友在玩'}`; } function dashRenderHotGames(d) { const card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.game, '热门游戏分布', `近2周 · 共 ${d.hotCount} 款`)); if (!d.hotGames.length || d.hotTotal <= 0) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: '近2周暂无游戏记录'})); 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: '近2周总时长'})])); 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}${'人'}` })]), 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 card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.trending, '好友游戏时长热力图', `近 ${DASH_HEAT_DAYS} 天`)); const hm = d.heatmap; if (!hm || !hm.rows.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: '暂无可展示的好友数据'})); 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 = `${dt.getMonth() + 1}月${dt.getDate()}日`; const valStr = v == null ? ('无数据') : v === 0 ? ('未游玩') : `${dashFmtH(v)}`; dashBindTip(cell, () => `${esc(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' }, ['少 ']); DASH_HEAT_SCALE.forEach(c => legendRow.appendChild(h('i', { style: { background: c } }))); legendRow.appendChild(document.createTextNode(' 多')); card.appendChild(legendRow); if (hm.snapCount < 2) card.appendChild(h('div', { class: 'sfd-dash-hint', text: '当前显示近2周日均估算活跃度。持续使用后将基于每日快照精确计算每日游玩时长。'})); return card; } function dashRenderOwnedRank(d) { const card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.package, '好友游戏总数排行', 'Top 10')); if (!d.ownedRank.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: '暂无公开游戏库的好友'})); 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', 'noopener,noreferrer') }, [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}${'款'} ${dashFmtH(r.totalMinutes)}` })]); dashBindTip(row, () => `${esc(r.personaname)}
${'游戏总数'}: ${r.game_count}
${'生涯总时长'}: ${dashFmtH(r.totalMinutes)}`); card.appendChild(row); }); return card; } function dashRenderFriendsRank(d) { const card = h('div', { class: 'sfd-dash-card' }); card.appendChild(dashCardTitle(ICONS.users, '好友数排行', 'Top 10')); if (!d.friendsRank.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: '暂无公开好友列表的好友'})); 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', 'noopener,noreferrer') }, [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}${'人'}` })]); dashBindTip(row, () => `${esc(r.personaname)}
${'好友总数'}: ${r.friendcount}`); card.appendChild(row); }); return card; } function renderDashboard() { const content = document.getElementById('sfd-dash-content'); const updatedEl = dom.dashUpdated; if (!content) return; dashTipHide(); // 重渲染/清空 content 前先隐藏 tooltip(它挂在 body 上,不属于 content) if (state.dashboard.loading) { // 加载中如果头像网格已存在(如关闭后重新打开),不重建,只更新进度 if (document.getElementById('sfd-dash-load-grid')) { if (dom.dashProgressText) dom.dashProgressText.textContent = state.dashboard.progress || ('正在加载…'); 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 || ('正在加载…') }), 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, 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 = '请先点击面板右上角设置按钮,配置 Steam Web API Key。'; else if (state.dashboard.error === 'nofriends') tipText = '暂无好友数据,请先在「好友列表」Tab 点击「获取好友数据」。'; else tipText = ('加载失败:') + 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: '重试', onClick: () => { if (state.dashboard.loading) { toast.info('正在加载中,请稍候'); return; } loadDashboardData(true); } })])])); return; } const d = state.dashboard.data; if (!d) { content.innerHTML = ''; content.appendChild(h('div', { class: 'sfd-dash-empty' }, [h('div', { text: '点击上方刷新按钮,生成你的好友社交数据仪表盘'}), h('div', { style: { marginTop: '12px' } }, [h('button', { class: 'sfd-btn sfd-btn-primary', text: '加载数据', onClick: () => { if (state.dashboard.loading) { toast.info('正在加载中,请稍候'); return; } loadDashboardData(true); } })])])); return; } if (updatedEl) updatedEl.textContent = ('更新于 ') + 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)])])); } /* ================================================================ * PART 7 · 家庭组浮窗(动态获取 / 价值估算 / 各 Tab 图表 / 愿望单 / 精品与系列) * ================================================================ */ // ==================== 家庭组游玩动态 ==================== 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; state.family.playActivityFailed = false; 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; } // 确保有 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 { // getAccessToken() 内部会发跨域请求(fetchWebApiTokenFromStore),必须放在 try 内 await: // 请求被拒(断网/CSP)时异常否则会逃逸出函数,playActivityLoading 将永久卡在 true const authToken = await getAccessToken(); if (!authToken) { state.family.playActivityLoading = false; return false; } 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); // 全部成员都失败(限流/断网)时按失败处理:不写状态、不缓存 24h"全员无游玩"假象, // 让失败标志/错误态接管(成员各自无游戏时 data.games 为空数组,result 仍非空,不受影响) if (Object.keys(result).length === 0) { state.family.playActivityLoading = false; return false; } // 会话校验(D1):await 期间用户可能已切换账号/重开浮窗, // 此时 result 属于旧账号的成员键,写进 state 会让新账号全员显示「近2周无游玩记录」。 // 调用侧的 popupSteamid 比对只能挡住重渲染,挡不住这里的写坏 state 与缓存,故必须在此拦。 const sessionSid = state.family.popupSteamid == null ? '' : String(state.family.popupSteamid); if (sessionSid && sessionSid !== String(steamid)) { logger.silent('游玩动态会话已变化,丢弃过期结果: ' + steamid + ' -> ' + sessionSid); state.family.playActivityLoading = false; return false; } state.family.playActivity = result; state.family.playActivityFailed = false; state.family.playActivityLoading = false; // 缓存结果 storage.setFamilyPlayActivityCache({ steamid, data: result, timestamp: Date.now() }); return true; } catch (e) { logger.error('加载家庭组游玩动态失败', e); state.family.playActivityLoading = false; state.family.playActivityFailed = true; 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 gamelibPopupEl = null; function showGameLibraryPopup() { // 复用家庭组数据源:精品游戏/系列游戏均基于共享游戏库 + 成员数据计算 ensureFamilyPopupSteamid(); if (!gamelibPopupEl) { const modal = createModal({ id: 'sfd-gamelib-popup', cls: 'sfd-gamelib-popup', noOutsideClick: true, headerCls: 'sfd-family-header sfd-gamelib-header', persistent: true, headerContent: h('div', { class: 'sfd-pl-head-main' }, [ h('h3', { html: `${ICONS.grid} ${'游戏库'}` }), h('div', { class: 'sfd-pl-tabs-slot', id: 'sfd-gamelib-tabs-slot' }) ]), headerActions: [ h('span', { class: 'sfd-dash-updated', id: 'sfd-gamelib-updated', text: '' }), h('button', { class: 'sfd-series-detail-btn', html: ICONS.barChart, id: 'sfd-gamelib-humble-btn', style: { display: 'none' }, title: 'Humble Choice 分析入库', onClick: e => openHumbleChoiceAnalysis(e.currentTarget) }), createRefreshBtn(() => { refreshGameLibraryWithProgress().catch(() => {}); }) ], showProgress: true, progressId: 'sfd-gamelib-progress', progressBarId: 'sfd-gamelib-progress-bar', contentId: 'sfd-gamelib-content', exclusions: ['.sfd-trigger-gamelib', '.sfd-panel', '.sfd-modal', '#sfd-family-popup', '#sfd-pl-popup', '#sfd-member-games-popup', '#sfd-month-games-popup', '#sfd-my90-games-popup', '#sfd-member90-games-popup', '#sfd-compare-popup', '#sfd-contrib-overlay', '.sfd-series-detail'], scrollSelector: '.sfd-gamelib-content, .sfd-stress-scroll, .sfd-wf-scroll, .sfd-series-view-scroll' }); gamelibPopupEl = modal.overlay; } gamelibPopupEl.classList.add('sfd-show'); const content = gamelibPopupEl.querySelector('#sfd-gamelib-content'); // 好友数据缺失时后台静默拉取(卡片拥有者头像/名称依赖好友数据) if (!state.friends.data.length) autoRefreshFriends(); // 有共享缓存直接渲染,缓存过期则后台静默刷新 const cached = storage.getFamilyCache(); if (cached && String(cached.steamid) === String(state.family.popupSteamid) && cached.familyInfo && cached.familyGameList) { state.family.info = cached.familyInfo; state.family.gameList = cached.familyGameList; renderGameLibraryPopup(); ensureFamilyAvatars().catch(() => {}); const cacheTime = cached.timestamp || 0; if (Date.now() - cacheTime > FAMILY_REFRESH_COOLDOWN) { refreshGameLibraryWithProgress().catch(() => {}); } } else { content.replaceChildren(createLoadingEl('正在加载游戏库数据…', '#06cfbe', 'sfd-gamelib-loading')); refreshGameLibraryWithProgress().catch(() => { if (!content.isConnected) return; content.innerHTML = ''; content.appendChild(h('div', { class: 'sfd-family-empty', style: { textAlign: 'center', padding: '40px 20px' } }, [ h('div', { style: { fontSize: '15px', fontWeight: '600', color: '#f59e0b', marginBottom: '8px' }, text: '无法获取游戏库数据'}), h('div', { style: { fontSize: '13px', color: '#c7d5e0', marginBottom: '12px' }, text: '请访问 Steam 商店页面并登录'}), h('button', { class: 'sfd-btn sfd-btn-primary', text: '前往 Steam 商店', onClick: () => { window.open('https://store.steampowered.com', '_blank', 'noopener,noreferrer'); content.replaceChildren(createLoadingEl('正在重新加载游戏库数据…', '#06cfbe', 'sfd-gamelib-loading')); refreshGameLibraryWithProgress().catch(() => {}); } }) ])); }); } } // 游戏库窗口的进度条刷新(复用家庭组刷新流程,仅切换进度条与重渲染目标) function refreshGameLibraryWithProgress() { return refreshFamilyWithProgress({ progressId: 'sfd-gamelib-progress', progressBarId: 'sfd-gamelib-progress-bar', rerender: renderGameLibraryPopup }); } // 游戏 Tab 面板 DOM 缓存(Tab 切换复用,避免海报 img 重建重复请求;数据更新时清空) const _gamelibPanelCache = { stress: null, series: null, humble: null }; function renderGameLibraryPopup(preferCache) { if (!gamelibPopupEl || !state.family.info || !state.family.gameList) return; const content = gamelibPopupEl.querySelector('#sfd-gamelib-content'); if (!content) return; const updatedEl = document.getElementById('sfd-gamelib-updated'); if (updatedEl) { const cached = storage.getFamilyCache(); updatedEl.textContent = (cached && cached.timestamp) ? ('更新于 ') + new Date(cached.timestamp).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; } const tabs = h('div', { class: 'sfd-pl-tabs', id: 'sfd-gamelib-tabs' }, [ h('button', { class: `sfd-pl-tab ${state.gamelib.activeTab === 'stress' ? 'active' : ''}`, html: ICONS.flame, onClick: () => { state.gamelib.activeTab = 'stress'; renderGameLibraryPopup(true); } }, [h('span', { text: '精品游戏'})]), h('button', { class: `sfd-pl-tab ${state.gamelib.activeTab === 'series' ? 'active' : ''}`, html: ICONS.grid, onClick: () => { state.gamelib.activeTab = 'series'; renderGameLibraryPopup(true); } }, [h('span', { text: '系列游戏'})]), h('button', { class: `sfd-pl-tab ${state.gamelib.activeTab === 'humble' ? 'active' : ''}`, html: ICONS.package, onClick: () => { state.gamelib.activeTab = 'humble'; renderGameLibraryPopup(true); } }, [h('span', { text: 'Humble Choice' })]) ]); // Tab 面板 DOM 缓存:纯 Tab 切换(preferCache)时复用已构建面板,img 节点保活不再重复请求海报; // 未命中只构建当前 Tab(不清其他 Tab 缓存——数据级变化由各数据回调显式清理自己 Tab 的缓存) const key = state.gamelib.activeTab; let tabPanel = preferCache ? _gamelibPanelCache[key] : null; if (!tabPanel) { const tabContent = state.gamelib.activeTab === 'series' ? renderFamilySeriesTab() : state.gamelib.activeTab === 'humble' ? renderHumbleChoiceTab() : renderStressFamilyTab(); // 系列 Tab 的统一界面自带滚动区(.sfd-wf-scroll / .sfd-series-view-scroll),外层不可再滚 tabPanel = h('div', { class: 'sfd-family-tab-panel active sfd-family-scroll' + (state.gamelib.activeTab === 'series' ? ' sfd-family-tab-panel-noscroll' : '') }); tabPanel.appendChild(tabContent); _gamelibPanelCache[key] = tabPanel; } // 标签栏位于标题栏(headerContent 中的 slot),与「我的游戏库」保持一致 const tabsSlot = document.getElementById('sfd-gamelib-tabs-slot'); if (tabsSlot) { tabsSlot.innerHTML = ''; tabsSlot.appendChild(tabs); } // 分析入库按钮仅 Humble Choice Tab 显示(位于标题栏刷新按钮左侧) const humbleBtn = gamelibPopupEl.querySelector('#sfd-gamelib-humble-btn'); if (humbleBtn) humbleBtn.style.display = state.gamelib.activeTab === 'humble' ? '' : 'none'; content.replaceChildren(tabPanel); } // ==================== 家庭组浮窗 ==================== let familyPopupEl = null; function showFamilyPopup(steamid) { // 换账号/换家庭组时重置愿望单页内状态:wlState 是模块级变量且仅 data 为空时才校验 steamid, // 不重置会把上一个人的数据与筛选条件直接带过来 if (showFamilyPopup._lastSid && showFamilyPopup._lastSid !== String(steamid)) { wlState.data = null; wlState.loading = false; wlState.filter = 'all'; wlState.kpiFilter = 'all'; wlState.memberFilter = null; wlState.page = 1; wlState.updatedAt = 0; // _fetchStarted/progress 不重置会导致新账号因"已在加载"而不发请求、进度停在旧任务上 wlState._fetchStarted = false; wlState.progress = null; } showFamilyPopup._lastSid = String(steamid); state.family.popupSteamid = steamid; state.family.activeTab = 'chart'; state.family.dynamicPage = 1; state.family.dynamicSelectedMembers = null; // 清掉上次会话残留的独占筛选 state.family.playActivity = null; state.family.playActivityLoading = false; state.family.playActivityFailed = false; if (!familyPopupEl) { const modal = createModal({ id: 'sfd-family-popup', cls: 'sfd-family-popup', noOutsideClick: true, persistent: true, titleHtml: `${ICONS.family} ${'家庭组'}`, headerActions: [ 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 }) ], showProgress: true, progressId: 'sfd-family-progress', progressBarId: 'sfd-family-progress-bar', contentId: 'sfd-family-content', exclusions: ['.sfd-trigger-family', '.sfd-panel', '.sfd-modal', '#sfd-member-games-popup', '#sfd-month-games-popup', '#sfd-my90-games-popup', '#sfd-member90-games-popup', '#sfd-compare-popup', '#sfd-pl-popup', '#sfd-contrib-overlay', '#sfd-gamelib-popup', '.sfd-series-detail', '#sfd-hero-all-popup'], scrollSelector: '.sfd-family-content, .sfd-family-game-list' }); familyPopupEl = modal.overlay; } familyPopupEl.classList.add('sfd-show'); const content = familyPopupEl.querySelector('#sfd-family-content'); // 好友数据缺失时后台静默拉取(家庭组头像依赖好友数据;有 API Key 且未加载中才触发,不会重复) if (!state.friends.data.length) autoRefreshFriends(); // 有缓存直接渲染(不显示 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(); // 修复缓存中的占位头像,避免长期显示默认剪影 ensureFamilyAvatars().catch(() => {}); // 按缓存时间计时:距缓存写入超过12小时才后台刷新 const cacheTime = cached.timestamp || 0; if (Date.now() - cacheTime > FAMILY_REFRESH_COOLDOWN) { refreshFamilyWithProgress().catch(() => {}); } } else { // 无缓存:显示 loading 并走进度条 content.replaceChildren(createLoadingEl('正在加载家庭组数据…', '#06cfbe', 'sfd-family-loading')); refreshFamilyWithProgress().catch(() => { content.innerHTML = ''; const errBox = h('div', { class: 'sfd-family-empty', style: { textAlign: 'center', padding: '40px 20px' } }, [ h('div', { style: { fontSize: '48px', marginBottom: '12px', opacity: '0.3' }, text: '⚠️' }), h('div', { style: { fontSize: '15px', fontWeight: '600', color: '#f59e0b', marginBottom: '8px' }, text: '无法获取家庭组数据'}), h('div', { style: { fontSize: '13px', color: '#c7d5e0', marginBottom: '12px' }, text: '请访问 Steam 商店页面并登录'}), h('button', { style: { display: 'inline-block', padding: '8px 20px', background: 'rgba(102,192,244,0.15)', border: '1px solid rgba(102,192,244,0.35)', borderRadius: '8px', color: '#66c0f4', fontSize: '13px', fontWeight: '600', cursor: 'pointer', transition: 'all 0.2s' }, text: '前往 Steam 商店', onClick: (e) => { e.preventDefault(); window.open('https://store.steampowered.com', '_blank', 'noopener,noreferrer'); // 点击后自动刷新数据 content.replaceChildren(createLoadingEl('正在重新加载家庭组数据…', '#06cfbe', 'sfd-family-loading')); refreshFamilyWithProgress().catch(() => { if (!content.isConnected) return; content.replaceChildren(errBox.cloneNode(true)); }); }, onMouseover: function () { this.style.background = 'rgba(102,192,244,0.25)'; }, onMouseout: function () { this.style.background = 'rgba(102,192,244,0.15)'; } }) ]); content.appendChild(errBox); }); } } function renderFamilyPopup() { if (!familyPopupEl || !state.family.info || !state.family.gameList) return; // 重渲染会移除处于 hover 中的色块,mouseleave 不会触发:先统一隐藏全局 tooltip 防止滞留 const staleTip = document.getElementById('sfd-family-chart-tip'); if (staleTip) staleTip.style.display = 'none'; // 更新数据时间显示 const updatedEl = document.getElementById('sfd-family-updated'); if (updatedEl) { const cached = storage.getFamilyCache(); if (cached && cached.timestamp) { updatedEl.textContent = ('更新于 ') + 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 isDynamic = state.family.activeTab === 'dynamic'; const members = fi.family_member || []; const allMembers = fillPendingSlots(members); const avatarMap = buildAvatarMap(); const memberColorMap = {}; members.forEach((m, i) => { memberColorMap[m.steamid] = CHART_COLORS[i % CHART_COLORS.length]; }); // 独占筛选:null=全部(默认),sid字符串=该成员独占 const exclusiveSid = state.family.dynamicSelectedMembers; // null 或 sid 字符串 const isExclusiveMode = isDynamic && exclusiveSid !== null; const headerH3 = familyPopupEl.querySelector('.sfd-family-header h3'); if (headerH3) { // 构建6个头像HTML const avatarsHtml = allMembers.map((m, i) => { const sid = m.steamid; const isPending = m.pending; const color = memberColorMap[sid] || CHART_COLORS[i % CHART_COLORS.length]; const name = isPending ? ('待加入') : (fi.steamIdtoName?.[sid] || ('ID:' + String(sid).slice(-4))); // 是否点亮:其他tab全亮;入库动态:默认全灰,选中的成员亮 let isLit = !isDynamic; if (isDynamic && !isPending && exclusiveSid) { isLit = String(exclusiveSid) === String(sid); } if (isPending) { return `${ICONS.userPlaceholder}`; } const av = avatarMap[String(sid)] || DEFAULT_AVATAR; const clickable = isDynamic; const cls = 'sfd-hdr-av' + (isLit ? '' : ' dim') + (clickable ? ' clickable' : ''); const borderStyle = isLit ? `border:2px solid ${color}` : 'border:2px solid #475569'; const filterStyle = isLit ? '' : 'filter:grayscale(70%) opacity(0.5);'; const onclickAttr = ''; // 事件通过 addEventListener 绑定 return ``; }).join(''); // 家庭组名颜色:独占模式变灰可点击,其他情况白色 const nameColor = isExclusiveMode ? '#64748b' : '#fff'; const nameClickable = isDynamic ? 'sfd-hdr-name-clickable' : ''; headerH3.innerHTML = `${ICONS.family} ${esc(fi.family_name)} ${avatarsHtml} ${memberCount} ${'名成员'} · ${totalGames} ${'个共享游戏'}`; // 绑定点击事件(入库动态时) if (isDynamic) { const toggleMember = (sid) => { if (state.family.dynamicSelectedMembers === String(sid)) { state.family.dynamicSelectedMembers = null; } else { state.family.dynamicSelectedMembers = String(sid); } state.family.dynamicPage = 1; renderFamilyPopup(); }; const resetExclusive = () => { state.family.dynamicSelectedMembers = null; state.family.dynamicPage = 1; renderFamilyPopup(); }; // 头像点击 headerH3.querySelectorAll('.sfd-hdr-av.clickable').forEach(el => { const sid = el.getAttribute('data-sid'); if (sid) el.addEventListener('click', () => toggleMember(sid)); }); // 家庭组名点击 const nameEl = headerH3.querySelector('#sfd-hdr-family-name'); if (nameEl) nameEl.addEventListener('click', resetExclusive); } } // 加入时间卡片(原版样式:横向布局,心形图标 + 日期 + 天数) 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}
${'我已加入家庭 '}${joinDays}${' 天 '}(${joinDateStr}) ${'点击查看贡献 →'}
`; } // joinCard 移到 statsGrid 后面 const statsGrid = h('div', { class: 'sfd-family-stats-grid' }, [ createMetricCard({ value: String(totalGames), label: '共享游戏总数', accent: '#06cfbe', layout: 'label' }), createMetricCard({ value: String(singleOwnerCount), label: '独占贡献游戏', accent: '#ff9f43', layout: 'label' }), createMetricCard({ value: String(avgGames), label: '人均贡献数', accent: '#54a0ff', layout: 'label' }), createMetricCard({ value: String(recent30Count), label: '近90日新增', 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: '贡献分布'})]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'growth' ? 'active' : ''}`, html: ICONS.trending, onClick: () => { state.family.activeTab = 'growth'; renderFamilyPopup(); } }, [h('span', { text: '增长曲线'})]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'dynamic' ? 'active' : ''}`, html: ICONS.activity, onClick: () => { state.family.activeTab = 'dynamic'; renderFamilyPopup(); } }, [h('span', { text: '入库动态'})]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'play' ? 'active' : ''}`, html: ICONS.game, onClick: () => { state.family.activeTab = 'play'; renderFamilyPopup(); } }, [h('span', { text: '游玩动态' })]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'heatmap' ? 'active' : ''}`, html: ICONS.pie, onClick: () => { state.family.activeTab = 'heatmap'; renderFamilyPopup(); } }, [h('span', { text: '月度占比'})]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'insights' ? 'active' : ''}`, html: ICONS.insights, onClick: () => { state.family.activeTab = 'insights'; renderFamilyPopup(); } }, [h('span', { text: '成员洞察'})]), h('button', { class: `sfd-family-tab ${state.family.activeTab === 'wishlist' ? 'active' : ''}`, html: ICONS.heart, onClick: () => { state.family.activeTab = 'wishlist'; renderFamilyPopup(); } }, [h('span', { text: '家庭愿望单'})]) ]); 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 { 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: '日均增长', accent: '#f59e0b', layout: 'label' }), createMetricCard({ value: String(avgPerMonth), label: '月均增长', accent: '#8b5cf6', layout: 'label' }), createMetricCard({ value: String(recent30), label: '30日新增', accent: '#10b981', layout: 'label' }), createMetricCard({ value: String(recent90), label: '90日新增', 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 || [])[0] === 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: ('90天我贡献了 ') + '' + my90Contrib + '' + (' 个游戏') }), ]); 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); } // 游玩动态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 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 || [])[0] !== mySid) 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) { const firstOwner = (gi[key].owners || [])[0]; if (firstOwner && memberCounts[firstOwner] !== undefined) memberCounts[firstOwner]++; } 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 { overlay, body, close: closeContrib } = createModal({ id: 'sfd-contrib-overlay', titleHtml: `${ICONS.heart} ${'我的贡献概览'}`, width: '900px', height: '640px', zIndex: 505, noOutsideClick: true, exclusions: [familyPopupEl], bodyStyle: { flex: '1', overflow: 'hidden', padding: '14px', display: 'flex', flexDirection: 'column', gap: '12px', minHeight: '0' } }); // ---- 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: '贡献游戏总数', value: myTotal, color: '#06cfbe' }, { label: '独占贡献', value: myExclusive, color: '#ff9f43' }, { label: '共享贡献', value: myShared, color: '#a78bfa' }, { label: '近90日新增', value: myRecent90, color: '#34d399' }, { label: '占家庭库', value: `${myPct}%`, color: '#06cfbe' }, { label: '贡献排名', value: `第${myRank || '-'}名`, color: '#66c0f4' }, { label: '独占率', value: `${exclusivePct}%`, color: '#ff9f43' }, { label: '共享率', value: `${sharedPct}%`, color: '#a78bfa' }, { label: '90日增量', 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}${'近期贡献'} (${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: `近${CONTRIB_DAYS}天暂无贡献`})); } else { myRecentGames.forEach(g => { grid.appendChild(makeGameCard({ appid: g.appid, name: g.name, metaChildren: [ h('span', { text: g.time > 0 ? DateUtils.format(g.time) : ('未知') }), g.isExclusive ? h('span', { style: { color: '#ff9f43', fontWeight: '600' }, text: '独占'}) : 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}${'最近游玩'}` })); 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 sfd-fam-dyn-card' }, [ (() => { // 宽比例海报:上/左/右贴边,与游玩动态 Tab 同款(467x181 header 加载链) const t = h('div', { class: 'sfd-fam-dyn-thumb' }); t.appendChild(dashLoadWidePosterImg(h('img', { loading: 'lazy' }), game.appid)); return t; })(), 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 { // 三态区分:未加载 / 已加载但两周内无游玩 / 加载失败;"已加载为空"不得落入"未加载"分支(否则点击后命中缓存立即返回,视觉无变化) const playFailed = !!state.family.playActivityFailed; const playLoaded = !!paData; const playStateText = playFailed ? ('游玩数据加载失败') : (playLoaded ? ('近两周无游玩记录') : ('游玩数据未加载')); const playChildren = [h('div', { text: playStateText })]; if (playFailed || !playLoaded) { playChildren.push(h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', text: playFailed ? ('点击重试') : ('点击加载'), onClick: async () => { const btn = playScroll.querySelector('button'); if (btn) { btn.disabled = true; btn.textContent = '加载中…'; } const ok = await loadFamilyPlayActivity(state.family.popupSteamid); if (ok) { closeContrib(); showContributionOverview(); } else { if (btn) { btn.disabled = false; btn.textContent = '加载失败,重试'; } } } })); } playScroll.appendChild(h('div', { style: { textAlign: 'center', fontSize: '11px', color: '#64748b', padding: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: '8px' } }, playChildren)); } rightScroll.appendChild(playScroll); rightCard.appendChild(rightScroll); dualRow.appendChild(rightCard); body.appendChild(dualRow); } // 统一按 fi.family_member 顺序分配成员配色,保证同一成员在所有图表中同色,便于跨图对读 function familyMemberColorMap(members) { const map = {}; (members || []).forEach((m, i) => { map[m.steamid] = CHART_COLORS[i % CHART_COLORS.length]; }); return map; } function renderFamilyChartTab() { const fi = state.family.info; const gl = state.family.gameList; const members = (fi && fi.family_member) || []; const memberColorMap = familyMemberColorMap(members); // 精品游戏贡献占比计算(排除未发售,包含未购买) const now = new Date(); const stressGames = getStressAllGames().filter(g => { const d = parseStressDate(g.date); return !!d && d < 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 || [])[0] === 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 || [])[0] === m.steamid) count++; } } else { // 无精品数据时回退到全部游戏 for (let key in gl.GameInfo) { if ((gl.GameInfo[key].owners || [])[0] === 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: '未购买', count: unowned, sid: 'unowned' }); } const stressTotal = stressContrib.reduce((s, m) => s + m.count, 0) || 1; // 构建饼图 SVG:过滤零计数切片;单切片(100%)时弧线起终点重合会被 SVG 省略,退化为一条半径线,改画整圆 function buildPieSvg(data, total) { const cx = 100, cy = 100, r = 80; const slices = []; data.forEach((m, i) => { if (m.count > 0) slices.push([m, i]); }); if (!slices.length) return ''; if (slices.length === 1) { const [m, i] = slices[0]; const c = m.sid === 'unowned' ? '#64748b' : (memberColorMap[m.sid] || CHART_COLORS[i % CHART_COLORS.length]); return `${esc(m.name)}: ${m.count} (100%)`; } let svg = '', cumAngle = -Math.PI / 2; slices.forEach(([m, i]) => { const c = m.sid === 'unowned' ? '#64748b' : (memberColorMap[m.sid] || CHART_COLORS[i % CHART_COLORS.length]); const angle = (m.count / total) * 2 * Math.PI; svg += `${esc(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' : (memberColorMap[m.sid] || CHART_COLORS[i % CHART_COLORS.length]); return `${esc(m.name)} ${(m.count / total * 100).toFixed(0)}%`; }).join(''); } const pieSvgAll = buildPieSvg(allMemberContrib, allTotal) + buildPieCenter(Object.keys(gl.GameInfo).length, '总入库'); const pieLegendAll = buildPieLegend(allMemberContrib, allTotal, 6); // 中心数字必须与成员计数、扇区使用同一生效口径:totalStressGames 已包含「无精品数据时回退全库」的情况 const pieSvgStress = buildPieSvg(stressContrib, stressTotal) + buildPieCenter(totalStressGames, stressAppIds.size === 0 ? ('全库总数') : ('精品总数')); 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); const firstOwner = (info.owners || [])[0]; if (firstOwner) { const s = String(firstOwner); 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))); const barGrid2 = svgGridLines({ left: bL2, right: barSvgW2 - bR2, top: bT2, bottom: bT2 + bH2, count: 2, maxValue: maxRecent6, lineStroke: 'rgba(255,255,255,.05)', textDx: -5 }); 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) + ('月'); }; 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; // 与饼图/头部等其它图统一按成员顺序取色(memberOrder 按贡献排名,仅用于叠放次序) const c = memberColorMap[sid] || CHART_COLORS[ci % CHART_COLORS.length]; curY -= h; monthBars += `${esc(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 ? ('绝版贡献分布图') : ('家庭库贡献分布图') }), (() => { 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 ? ('显示全部') : ('显示绝版') }); 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 ? ('绝版贡献分布图') : ('家庭库贡献分布图'); btn.textContent = 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}${'正在加载绝版数据…'}
`); } // loadDelistedDB 是网络请求,reject 时下面的 loading 移除与重建都不会执行, // 造成 loading 永久残留且按钮显示「显示全部」而图为空。用 try/catch/finally 兜住。 try { await loadDelistedDB(); } catch (e) { logger.warn('加载绝版数据库失败', e); // 复位为「非绝版」态,避免按钮文案与实际图表不一致 state.family.chartShowDelisted = false; if (titleEl) titleEl.textContent = '家庭库贡献分布图'; btn.textContent = '显示绝版'; btn.className = 'sfd-btn sfd-btn-sm sfd-btn-ghost'; } finally { 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: '成员贡献占比'}); 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: '精品', 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' ? ('精品') : ('全部'); pieTitle.textContent = pieMode === 'all' ? ('成员贡献占比') : ('精品游戏贡献占比'); } }); 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: '近6月入库增量'}), (() => { 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 ''; // 旧版本持久化缓存可能缺失 family_member,裸访问会在取长度时抛错导致图表白屏 const members = (fi && fi.family_member) || []; const memberCount = members.length; if (memberCount === 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', `
${'暂无家庭成员数据'}
`); } return; } const MAX_FAMILY = 6; // 绝版模式:仅统计绝版游戏 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', `
${'家庭库中暂无绝版游戏'}
`); } 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 ? ('单独贡献') : `${memberCount - i}${'人共同贡献'}`, color: colors[ci % colors.length] }; } const layerData = Array.from({ length: memberCount }, () => new Array(memberCount).fill(0)); // 层号必须按「实际属于本家庭组的许可人数」计算: // 许可里混入非成员时 ownerCount 偏大 → dsIdx = memberCount - ownerCount 偏小甚至 <0, // 整条游戏被静默丢弃(或层号错位)。此处先过滤出有效成员许可再定层。 const memberIdxSet = new Set(members.map(m => m.steamid)); let ownerMismatchGames = 0; for (let key in gameInfo) { const game = gameInfo[key]; const rawOwners = game.owners || []; const validOwners = rawOwners.filter(sid => memberIdxSet.has(sid)); if (validOwners.length !== rawOwners.length) ownerMismatchGames++; const ownerCount = validOwners.length; const dsIdx = memberCount - ownerCount; if (dsIdx >= 0 && dsIdx < memberCount) { validOwners.forEach(owner => { const mIdx = members.findIndex(m => m.steamid === owner); if (mIdx !== undefined && mIdx >= 0) { layerData[dsIdx][mIdx]++; } }); } } if (ownerMismatchGames > 0) { logger.silent('家庭库贡献图:' + ownerMismatchGames + ' 款游戏的许可含非本家庭成员,已按有效成员许可数计算层号'); } // 柱高上界取「每个成员跨层累加后的列和」:各段属于同一成员的堆叠,按单(层×成员)格峰值 // 缩放会让柱总高达到 maxValue × 层数,超出绘图区被裁切;网格刻度与段高必须用同一上界 let maxStack = 1; for (let s = 0; s < memberCount; s++) { let colSum = 0; for (let r = 0; r < layerData.length; r++) colSum += layerData[r][s] || 0; if (colSum > maxStack) maxStack = colSum; } 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 = [svgGridLines({ left: pL, right: pR + cW, top: pT, bottom: pT + cH, count: 5, maxValue: maxStack, textDx: -8, fontSize: 11 })]; // 构建头像映射 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 = maxStack ? (n / maxStack) * 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 += `${esc(member.userName)}`; // 名字下方头像(用 foreignObject 嵌入 HTML img,兼容性好;外框用成员固定色,与其他页面统一) const av = avatarMap[sidStr] || DEFAULT_AVATAR; const avColor = CHART_COLORS[slot % CHART_COLORS.length]; avatarEls += ``; } else { bars += ``; labelEls += `${'待加入'}`; // 待加入槽位也显示占位头像(虚线外框用对应槽位色,与其他页面统一) const pc = CHART_COLORS[slot % CHART_COLORS.length]; avatarEls += `
${ICONS.userPlaceholder}
`; } } 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(); // 创建高级悬浮提示框:挂在 body 上(position:fixed 跟随鼠标),必须用 document 级查找复用,否则每次渲染泄漏一个节点 let tipEl = document.getElementById('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 = `
${esc(td.name)} ${'总贡献'}: ${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); }); }); } } // ==================== 家庭组游戏卡片渲染辅助 ==================== // ==================== 通用游戏卡片模板工厂 ==================== // 好友对比 / 个人游戏库 / 游戏库 / 家庭组四个浮窗共用的卡片与绝版板模板, // 收敛到这里:封面加载、名称链接、分页条、分组头、绝版数据板各只保留一份实现 // 封面:dashLoadCapsule 防抖加载 + 点击进商店(原各模板里重复的 IIFE 写法收敛点) function makeGameCapsule(appid, cls) { const img = h('img', { class: cls || 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(appid) }); return dashLoadCapsule(img, appid); } // 名称链接:商店链接 + 中文名异步替换(noZh=true 跳过中文名,用于数据源本身已是目标语言时) function makeGameNameLink(appid, name, noZh) { const el = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${appid}`, target: '_blank', rel: 'noopener noreferrer', text: name }); if (!noZh) loadGameZhName(el, appid, name); return el; } // sfd-pl-recent-card 统一模板:封面 + 信息区;调用方只传差异部分(自定义 nameEl / meta 项 / 附加项) function makeGameCard(opts) { const { appid, name, notOwned, cardStyle, nameEl, metaChildren, extraInfoChildren, metaStyle } = opts; return h('div', { class: `sfd-pl-recent-card${notOwned ? ' not-owned' : ''}`, style: cardStyle }, [ makeGameCapsule(appid), h('div', { class: 'sfd-pl-recent-info' }, [ nameEl || makeGameNameLink(appid, name), metaChildren && metaChildren.length ? h('div', { class: 'sfd-pl-recent-meta', style: metaStyle }, metaChildren) : null, ...(extraInfoChildren || []) ]) ]); } // 对比/库弹窗网格同款分页条样式 function mountPagi(pagi, paginationWrap) { pagi.classList.add('sfd-compare-pagi'); pagi.style.borderTop = '1px solid var(--sfd-border)'; pagi.style.flexShrink = '0'; paginationWrap.appendChild(pagi); } // @resource 同步读取 + JSON 解析(精品/系列/绝版数据源共用;失败返回 null 由调用方走网络回退) function readResourceJson(resourceKey, tag) { try { if (typeof GM_getResourceText !== 'function') return null; try { const jsonText = GM_getResourceText(resourceKey); return jsonText ? JSON.parse(jsonText) : null; } catch (e) { logger.silent(tag + ' @resource JSON 解析失败', e); return null; } } catch (e) { logger.warn(tag + ' @resource 读取失败', e); return null; } } // 绝版板数据构建:来源游戏 ∩ 绝版数据库(好友对比用好友库 / 个人库用我的库)。 // enrichOwned 可为 owned 条目附加来源特有字段(如个人库的游玩时长/图标) function buildDelistedBoardData(sourceEntries, enrichOwned) { const apps = getDelistedApps(); const map = new Map(); apps.forEach(app => { if (app.appid) map.set(Number(app.appid), app); }); const owned = []; for (const src of sourceEntries) { const info = map.get(Number(src.appid)); if (info) owned.push({ appid: Number(src.appid), name: src.name || info.name || `App ${src.appid}`, delistedType: getDelistedTypeKey(info.type), delistedDate: info.changed || '', ...(enrichOwned ? enrichOwned(Number(src.appid), info) : null) }); } owned.sort((a, b) => (b.delistedDate || '').localeCompare(a.delistedDate || '')); const allApps = apps.filter(app => app.appid).map(app => ({ appid: Number(app.appid), name: app.name || `App ${app.appid}`, delistedType: getDelistedTypeKey(app.type), delistedDate: app.changed || '', })); allApps.sort((a, b) => (b.delistedDate || '').localeCompare(a.delistedDate || '')); return { owned, allApps, total: apps.length }; } // 绝版筛选卡片行(总数/拥有/未拥有 + 5 类型):对比与个人库两块板同构,仅文案与状态归属不同 function makeDelistedCardsRow({ total, ownedCount, missingCount, scopeBase, scope, typeFilter, labels, onScope, onType }) { return h('div', { class: 'sfd-delisted-cards', style: { display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap', flexShrink: '0', alignItems: 'center' } }, [ createMetricCard({ value: String(total), label: labels[0], accent: '#f44336', layout: 'compact', active: scope === 'all', onClick: () => onScope('all') }), createMetricCard({ value: String(ownedCount), label: labels[1], accent: '#34d399', layout: 'compact', active: scope === 'owned', onClick: () => onScope('owned') }), createMetricCard({ value: String(missingCount), label: labels[2], accent: '#94a3b8', layout: 'compact', active: scope === 'missing', onClick: () => onScope('missing') }), h('div', { style: { width: '1px', height: '32px', background: 'rgba(255,255,255,0.15)', flexShrink: '0' } }), ...DELISTED_TYPES.map(({ key, color }) => { const count = scopeBase.filter(g => g.delistedType === key).length; return createMetricCard({ value: String(count), label: getDelistedTypeLabel(key), accent: color, layout: 'compact', active: typeFilter === key, onClick: () => onType(key) }); }) ]); } // 绝版卡片:封面 + 名称 + 类型徽章 + 附加 meta 项 function makeDelistedCard(game, isOwned, extraMeta) { return makeGameCard({ appid: game.appid, name: game.name, notOwned: !isOwned, metaChildren: [ h('span', { class: `sfd-pl-delisted-badge type-${game.delistedType}`, text: getDelistedTypeLabel(game.delistedType) }), ...(extraMeta || []) ] }); } // 精品/系列网格卡片共用核心:海报贴边满宽 + 名称/日期行 + 拥有者头像或拥有标签(与时间线卡片同构) function buildStressCardCore({ game, appid, isOwned, owners, ownType, checking, dateCls, dateText, avatarMap, idMap }) { const ownedClass = isOwned && ownType ? `owned owned-${ownType}` : 'owned'; const card = h('div', { class: `sfd-stress-card ${isOwned ? ownedClass : 'not-owned'}${checking ? ' checking' : ''}`, 'data-stress-appid': appid, onClick: () => appid > 0 && window.open(`https://store.steampowered.com/app/${appid}`, '_blank', 'noopener,noreferrer') }); const thumb = h('div', { class: 'sfd-stress-thumb' }); const posterImg = h('img', { class: 'sfd-stress-poster', loading: 'lazy' }); // 宽比例海报:与时间线卡片同源加载链(467x181 header,避免窄版胶囊比例) thumb.appendChild(appid > 0 ? dashLoadWidePosterImg(posterImg, appid) : posterImg); card.appendChild(thumb); const info = h('div', { class: 'sfd-stress-info' }); info.appendChild(h('div', { class: 'sfd-stress-name', text: game.name, title: game.name })); const meta = h('div', { class: 'sfd-stress-meta' }); meta.appendChild(h('span', { class: `sfd-stress-date ${dateCls || ''}`, text: dateText })); if (owners.length > 0) { meta.appendChild(buildOwnerAvatars(owners, 5, avatarMap, idMap)); } else if (isOwned && ownType) { meta.appendChild(h('div', { class: `sfd-stress-tag ${ownType}`, text: stressOwnedLabel(ownType, game.date) })); } info.appendChild(meta); card.appendChild(info); return card; } // 精品/系列分组头部(箭头 + 标题 + 拥有数 + 分段占比条 + 百分比),两个 Tab 共用 function buildGroupHeaderEx({ title, ownedCount, total, groupGames, members, collapsed, onToggle, extraChildren }) { const pct = total > 0 ? Math.round(ownedCount / total * 100) : 0; // 不传 onToggle 时为纯展示头(无折叠箭头、无点击),供不可折叠分组使用 const arrow = onToggle ? h('span', { class: `sfd-stress-group-arrow ${collapsed ? 'collapsed' : ''}`, html: '▼', style: { fontSize: '10px', cursor: 'pointer', transition: 'transform 0.25s', marginRight: '4px' } }) : null; if (arrow && collapsed) arrow.style.transform = 'rotate(-90deg)'; return h('div', { class: 'sfd-stress-group-header', style: onToggle ? { cursor: 'pointer' } : null, onClick: onToggle }, [ ...(arrow ? [arrow] : []), h('span', { class: 'sfd-stress-group-title', text: title }), h('span', { class: 'sfd-stress-group-count', text: `${ownedCount}/${total}` }), buildSegmentedGroupBar(groupGames, members || []), h('span', { class: 'sfd-stress-group-pct', text: pct + '%' }), ...(extraChildren || []) ]); } // 家庭组入库动态卡片:宽海报贴边 + 名称/时间行 + 小头像并排(时间左侧、头像右侧) function createFamilyGameCard(game, fi) { 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] || DEFAULT_AVATAR; // steamid 不是合法头像 hash,拼 URL 必然 404 const name = (fi.steamIdtoName && (fi.steamIdtoName[sidStr] || fi.steamIdtoName[sid])) || 'ID:' + sidStr.slice(-4); return h('img', { src: avatar, style: { width: '15px', height: '15px', borderRadius: '50%', objectFit: 'cover', flexShrink: '0', border: '1px solid rgba(102,192,244,0.5)' }, loading: 'lazy', title: name, onerror: function () { this.onerror = null; this.src = DEFAULT_AVATAR; } }); }); const card = h('div', { class: 'sfd-pl-recent-card sfd-fam-dyn-card', style: { background: os.bg, borderColor: os.border } }); const thumb = h('div', { class: 'sfd-fam-dyn-thumb' }); // 宽比例海报:与精品/时间线同源加载链(467x181 header,避免窄版胶囊比例) thumb.appendChild(dashLoadWidePosterImg(h('img', { alt: '', loading: 'lazy' }), game.appid)); card.appendChild(thumb); const info = h('div', { class: 'sfd-pl-recent-info' }); info.appendChild(makeGameNameLink(game.appid, game.name)); const meta = h('div', { class: 'sfd-pl-recent-meta', style: { flexWrap: 'nowrap' } }, [ h('span', { style: { color: '#94a3b8' }, text: DateUtils.formatTimestamp(game.time) }) ]); if (ownerAvatars.length > 0) { meta.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '2px', marginLeft: 'auto', flex: 'none' } }, ownerAvatars)); } info.appendChild(meta); card.appendChild(info); return card; } // ==================== 家庭组许可增长曲线 ==================== function renderFamilyGrowthTab() { const fi = state.family.info; const gl = state.family.gameList; if (!gl || !gl.GameInfo) return h('div', { class: 'sfd-family-empty', text: '无数据'}); const members = (fi && fi.family_member) || []; const colors = CHART_COLORS; // 成员数据缺失(旧缓存无 family_member)时按该函数既有空态返回,避免后续裸用抛错 if (members.length === 0) return h('div', { class: 'sfd-family-empty', text: '无成员数据'}); 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: '无时间数据'}); 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++; const firstOwner = (g.owners || [])[0]; if (firstOwner) entry.members[firstOwner] = (entry.members[firstOwner] || 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); // 网格线(暗科技风) const grid = svgGridLines({ left: pL, right: svgW - pR, top: pT, bottom: pT + cH, count: 5, maxValue: maxVal, lineStroke: 'rgba(102,192,244,0.07)', dasharray: '3 6', textFill: '#64748b', fontSize: 9, fontFamily: 'monospace' }); // 总计虚线 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', `
${'许可累计增长曲线'}
`); 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) { const firstOwner = (game.owners || [])[0]; if (firstOwner && memberRecent[firstOwner] !== undefined) memberRecent[firstOwner]++; } } 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', `
${'90天贡献成员对比'}
`); // 横向条形图列表 const rowsWrap = h('div', { style: { flex: '1', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '7px', 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', cursor: m.pending ? 'default' : 'pointer', padding: m.pending ? '0' : '4px 6px', borderRadius: '6px' }, title: m.pending ? null : (`点击查看 ${m.userName} 90天贡献的游戏`), onClick: m.pending ? null : () => showMember90Games(m.steamid, m.userName) }, [ 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 ? ('待加入') : 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 = [`${'总计'}`]; members.forEach((m, mi) => { const c = colors[mi % colors.length]; legendItems.push(`${esc(m.userName)}`); }); chartCard.insertAdjacentHTML('beforeend', `
${legendItems.join('')}
`); // 右侧列容器:最新入库 + 90天贡献 const rightColumn = h('div', { style: { display: 'flex', flexDirection: 'column', gap: '14px', width: '310px', 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: '无数据'}); const members = (fi && fi.family_member) || []; const colors = CHART_COLORS; if (members.length === 0) return h('div', { class: 'sfd-family-empty', text: '无成员数据'}); // 成员颜色映射 const memberColor = {}; members.forEach((m, i) => { memberColor[m.steamid] = colors[i % colors.length]; }); // 按月聚合每个成员的入库数(仅计入首次入库成员,后续重复购买不计数) const monthlyMap = buildMonthlyFirstOwnerMap(gl.GameInfo); const sortedMonths = [...monthlyMap.keys()].sort((a, b) => b.localeCompare(a)); if (sortedMonths.length === 0) return h('div', { class: 'sfd-family-empty', text: '无时间数据'}); // 计算峰值(单月单成员最高入库数) 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 pct = Math.round(sl.count / total * 100); paths += `${esc(sl.name)}: ${sl.count} (${pct}%)`; startAngle += angle; }); } 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 = `${mon}月`; 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}${'入库'} ${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', `
${'总计'}: ${totalGames}${'月份数'}: ${sortedMonths.length}${'峰值'}: ${maxCount} ${'款/月'}
`); // 饼图点击:展示该月入库游戏 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: '无成员数据'}); } const container = h('div', { style: STYLE_FILL_SCROLL }); if (state.family.playActivityLoading) { container.appendChild(createLoadingEl('正在加载游玩动态…', '#06cfbe', 'sfd-family-loading')); 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 if (state.family.playActivityFailed) { // 加载失败后停止自动重试:渲染错误态 + 手动重试按钮,避免"渲染→请求→失败→重渲染"无限循环 const errWrap = h('div', { style: { flex: '1', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px', color: '#94a3b8', fontSize: '13px' } }, [ h('div', { text: '游玩动态加载失败,请检查登录状态后重试'}), h('button', { class: 'sfd-btn sfd-btn-primary', style: { padding: '6px 16px' }, text: '刷新动态', onclick: () => { state.family.playActivityFailed = false; renderFamilyPopup(); } }) ]); container.appendChild(errWrap); return container; } else { // 仅加载成功时重渲染;失败时置失败标志,由上方错误态接管。 // 会话校验:换账号后旧请求不得写入/触发渲染 const playSid = String(state.family.popupSteamid); loadFamilyPlayActivity(playSid).then(ok => { if (String(state.family.popupSteamid) !== playSid) return; if (!ok) state.family.playActivityFailed = true; if (familyPopupEl && familyPopupEl.classList.contains('sfd-show')) renderFamilyPopup(); }).catch(err => { // 兜底:任何未预期的拒绝都必须复位加载态,否则游玩动态 Tab 永久显示加载动画 logger.error('游玩动态加载异常', err); if (String(state.family.popupSteamid) !== playSid) return; state.family.playActivityLoading = false; state.family.playActivityFailed = true; if (familyPopupEl && familyPopupEl.classList.contains('sfd-show')) renderFamilyPopup(); }); } } // 显示加载动画(如果仍在加载中或刚触发) if (state.family.playActivityLoading || !state.family.playActivity) { container.appendChild(createLoadingEl('正在加载游玩动态…', '#06cfbe', 'sfd-family-loading')); 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: '待加入'}) ]) ]) ]), h('div', { class: 'sfd-family-play-member-games-wrap' }, [ h('div', { class: 'sfd-family-empty', style: { padding: '8px 12px', fontSize: '12px', color: '#475569' }, text: '等待新成员加入家庭组'}) ]) ])); 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 : {}; // 旧版本持久化缓存可能缺失 steamIdtoName(该字段由 fetchFamilyInfo 写入), // 裸下标访问会抛错导致整个「游玩动态」Tab 白屏。此处统一取一份带兜底的映射复用。 const nameMap = (fi && fi.steamIdtoName) || {}; // 游戏列表容器先建好但不填充,展开时由 buildMemberGamesOnce 惰性构建 const gamesWrap = h('div', { class: 'sfd-family-play-member-games-wrap' }); const memberCard = h('div', { class: 'sfd-family-play-member collapsed' }, [ // 成员头部(增强版:头像+名称+标签+刷新)— 点击折叠/展开 h('div', { class: 'sfd-family-play-member-header', onClick: (e) => { const _card = e.currentTarget.parentElement; const _expanding = _card.classList.contains('collapsed'); _card.classList.toggle('collapsed'); if (_expanding) buildMemberGamesOnce(); } }, [ h('img', { class: 'sfd-family-play-member-avatar' + (vacBanned ? ' sfd-avatar-banned' : ''), src: avatarSrc, loading: 'lazy', style: vacBanned ? {} : { borderColor: CHART_COLORS[idx % CHART_COLORS.length] }, 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: '1000px', 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: '近2周'}), 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: '游戏数'}), 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(() => { if (familyPopupEl && familyPopupEl.classList.contains('sfd-show')) renderFamilyPopup(); }); }, '刷新动态', 'sfd-family-play-refresh') ]), // 折叠态只渲染头部;游戏卡片首次展开时才构建一次并缓存(单成员可达上百款,避免折叠态连带发出全部封面/图标请求) gamesWrap ]); let memberGamesBuilt = false; const buildMemberGamesOnce = () => { if (memberGamesBuilt) return; memberGamesBuilt = true; gamesWrap.appendChild(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 => nameMap[sid] || nameMap[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] || DEFAULT_AVATAR; // steamid 不是合法头像 hash,拼 URL 必然 404 const nm = nameMap[sid] || nameMap[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: function () { this.onerror = null; this.src = DEFAULT_AVATAR; } }); }); return h('div', { class: 'sfd-family-play-game-item sfd-fam-dyn-card' }, [ (() => { // 宽比例海报:上/左/右贴边,与精品/时间线卡片同源加载链(467x181 header) const t = h('div', { class: 'sfd-fam-dyn-thumb' }); t.appendChild(dashLoadWidePosterImg(h('img', { loading: 'lazy' }), game.appid)); return t; })(), 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)}` }) // duration() 已带单位,勿再拼 "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: '近2周无游玩记录'})); }; container.appendChild(memberCard); }); // 如果所有成员都没有游玩数据 if (container.children.length === 0) { container.appendChild(h('div', { class: 'sfd-family-empty', text: '暂无游玩数据' })); } return container; } // ==================== 热力图数字开关(公共) ==================== // 数字开关 HTML(24小时入库热力图 / 入库热力图 共用) function heatmapNumToggleHtml() { return `${'数字'}`; } // 绑定数字开关(默认打开,切换卡片内 .sfd-hm-num 显隐) function attachHeatmapNumToggle(card) { const toggle = card.querySelector('.sfd-hm-toggle'); if (!toggle) return; const track = card.querySelector('.sfd-hm-track'); const dot = card.querySelector('.sfd-hm-dot'); 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'; } }); } // ==================== 24小时入库热力图 ==================== function renderFamily24hHeatmap() { const fi = state.family.info; const gl = state.family.gameList; 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); // 统计每个成员每小时入库数(仅计入首次入库成员) 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++; const firstOwner = (info.owners || [])[0]; if (firstOwner && memberCounts[firstOwner]) memberCounts[firstOwner][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); }); // 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 = 10; // 按视觉宽度截断昵称(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 += `${'全家'} ${h}:00 — ${count} ${'款'}`; 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 = memberColorMap[m.steamid] || CHART_COLORS[mi % CHART_COLORS.length]; const rowMax = memberMaxMap[m.steamid] || 1; // 该成员自身峰值 // 左侧头像(用 foreignObject + img,兼容跨域头像;待加入用统一人形占位) const avSize = cellSize - 4; // 头像略小于方格 if (av) { svg += ``; } else { // 待加入:与加入时间/成员活跃度统一的人形占位头像 svg += `
${ICONS.userPlaceholder}
`; } // 昵称(统一中性色,不使用成员色;过长截断省略号) const nameFill = '#c6d4df'; 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} ${'款'}`; 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}${'24小时入库热力图'}${heatmapNumToggleHtml()}
` + `
${`第一行为全家合计(琥珀色),下方各成员使用专属固定色(峰值 ${peakHour}:00,${maxCount} 款)`}
` ); const wrap = h('div', { style: { overflowX: 'auto', minWidth: '0' } }); wrap.insertAdjacentHTML('beforeend', `${svg}` ); card.appendChild(wrap); // 数字显示开关(默认打开) attachHeatmapNumToggle(card); // 热力图滚动:wrap 只有 overflowX:auto,垂直滚轮自然冒泡到父容器,无需拦截 card.insertAdjacentHTML('beforeend', `
` + `${'总计'}: ${totalGames}` + `${'峰值时段'}: ${peakHour}:00` + `
` ); return card; } // ==================== 热力图(成员洞察卡片) ==================== function renderFamilyHeatmapGrid() { const fi = state.family.info; const gl = state.family.gameList; 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 = buildMonthlyFirstOwnerMap(gl.GameInfo); 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)'; // 必须是 的兄弟节点:SVG 空内容元素内的文字不会被渲染 s += `${esc(m.userName)}: ${count} ${'款'} (${key})`; if (count > 0) s += ``; } }); } 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}${'入库热力图'}${heatmapNumToggleHtml()}
` + `
${`按成员×月份展示入库数,各成员独立按自身峰值缩放(全局峰值 ${maxCount} 款/月)`}
` ); 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); // 数字显示开关(默认打开,与24小时入库热力图共用逻辑) attachHeatmapNumToggle(card); card.insertAdjacentHTML('beforeend', `
${legendItems}
`); card.insertAdjacentHTML('beforeend', `
${'总计'}: ${totalGames}${'月份数'}: ${sortedMonths.length}${'峰值'}: ${maxCount} ${'款/月'}
`); 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: '待加入', 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; } // steamid → 该成员拥有的游戏对象数组(反向索引),供活跃度 / 共拥矩阵 / 雷达共用, // 避免每个成员都对全表扫描一次、并对 owners 做 indexOf 线性查找 function buildOwnerGameIndex(gameInfo) { const index = new Map(); for (const appid in gameInfo) { const g = gameInfo[appid]; const owners = g && g.owners; if (!owners || owners.length === 0) continue; if (owners.length === 1) { const only = owners[0]; let arr = index.get(only); if (!arr) { arr = []; index.set(only, arr); } arr.push(g); continue; } // 同一游戏 owners 中的重复 steamid 只计一次(与 owners.indexOf 的命中语义一致) const seen = new Set(); for (const sid of owners) { if (seen.has(sid)) continue; seen.add(sid); let arr = index.get(sid); if (!arr) { arr = []; index.set(sid, arr); } arr.push(g); } } return index; } // 计算成员活跃度 function computeMemberActivity(familyInfo, gameInfo, ownerIndex) { const members = (familyInfo && Array.isArray(familyInfo.family_member)) ? familyInfo.family_member : []; const idMap = (familyInfo && familyInfo.steamIdtoName) ? familyInfo.steamIdtoName : {}; const nowSec = Date.now() / 1000; const result = { members: [], activeCount: 0, warmCount: 0, coldCount: 0, dormantCount: 0, healthScore: 0, statusLabels: { active: '活跃', warm: '温热', cold: '冷淡', dormant: '沉睡'} }; const _ownerIndex = ownerIndex || buildOwnerGameIndex(gameInfo); members.forEach(m => { let sid = m.steamid, games = 0, times = []; const myGames = _ownerIndex.get(sid) || []; for (let gi = 0; gi < myGames.length; gi++) { const g = myGames[gi]; games++; if (g.time > 0) times.push(g.time); } // times 可达数万项,用 reduce 求极值避免展开传参爆栈 const latestTime = times.length > 0 ? times.reduce((a, b) => (b > a ? b : a)) : -1; const firstTime = times.length > 0 ? times.reduce((a, b) => (b < a ? b : a)) : -1; const daysSinceLatest = latestTime > 0 ? Math.floor((nowSec - latestTime) / SECONDS_PER_DAY) : -1; let status, statusLabel; if (daysSinceLatest < 0) { // 有游戏但全部缺时间戳 ≠ 从未入库,归入中间档避免"库内 N 款却显示从未入库"的矛盾 if (games > 0) { status = 'cold'; statusLabel = '无时间数据'; result.coldCount++; } else { status = 'dormant'; statusLabel = '从未入库'; 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, ownerIndex) { const nowSec = Date.now() / 1000; const _ownerIndex = ownerIndex || buildOwnerGameIndex(gameInfo); const raw = members.map(m => { let sid = m.steamid, total = 0, solo = 0, shared = 0, times = []; const myGames = _ownerIndex.get(sid) || []; for (let gi = 0; gi < myGames.length; gi++) { const g = myGames[gi]; total++; if (g.owners.length === 1) solo++; else shared++; if (g.time > 0) times.push(g.time); } // times 规模可达数万,避免展开传参爆栈 const firstTime = times.length > 0 ? times.reduce((a, b) => (b < a ? b : a)) : 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: '无成员数据'}); } if (!gl || !gl.GameInfo) { return h('div', { class: 'sfd-family-empty', text: '无游戏数据'}); } 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 ownerGameIndex = buildOwnerGameIndex(gameInfo); // ===== 成员活跃度 + 共同游戏矩阵(左右结构) ===== const activity = computeMemberActivity(fi, gameInfo, ownerGameIndex); 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); // 成员独立配色映射(按 family_member 顺序,与全局一致) const memberColorMap = {}; members.forEach((m, i) => { memberColorMap[m.steamid] = CHART_COLORS[i % CHART_COLORS.length]; }); 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: '成员活跃度'}), h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#94a3b8' } }, [ h('span', { text: '健康分: '}), 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 diffSec = m.latestTime > 0 ? Math.floor(Date.now() / 1000 - m.latestTime) : -1; let daysText; if (diffSec < 0) { daysText = '从未'; } else if (diffSec < SECONDS_PER_DAY) { const h = Math.max(1, Math.floor(diffSec / SECONDS_PER_HOUR)); daysText = `${h} 小时前`; } else { daysText = `${m.daysSinceLatest} 天前`; } const av = avatarMap[String(m.steamid)] || DEFAULT_AVATAR; const mc = memberColorMap[m.steamid] || CHART_COLORS[0]; 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 ${mc}` }, loading: 'lazy', title: m.name, onerror: function () { 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: `${'库内'}: ${m.total}` }), h('span', { html: `${'月均'}: ${m.monthlyAvg}` }), h('span', { html: `${'最近入库'}: ${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: '28px', height: '28px', 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: '待加入'}), h('div', { style: { flex: '1', display: 'flex', gap: '14px', fontSize: '11px', color: '#475569', justifyContent: 'flex-end' } }, [ h('span', { text: `${'库内'}: 0` }), h('span', { text: `${'月均'}: 0` }), h('span', { text: `${'最近入库'}: -` }) ]) ]); }) ]); // 共同游戏矩阵: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: '共同游戏矩阵'}), h('div', { style: { fontSize: '11px', color: '#64748b', marginBottom: '10px' }, text: '成员之间共同拥有的游戏数量(悬停查看详情)'}), (() => { 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: function () { 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 ${CHART_COLORS[j % CHART_COLORS.length]}`, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: '0.5' }, html: ICONS.userPlaceholder, title: '待加入'})); } } // 数据行 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]}`, margin: '0 auto', display: 'block' }, loading: 'lazy', title: familyNameOf(mi, idMap), onerror: function () { 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 ${CHART_COLORS[i % CHART_COLORS.length]}`, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: '0.5' }, html: ICONS.userPlaceholder, title: '待加入'})); } // 数据格 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} ${'个共同游戏'}`, 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, ownerGameIndex); if (radar.length > 0) { const axes = ['总游戏', '独占', '共享', '月均', '共享率', '库龄']; 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 `${esc(m.name)}`; }).join(''); // 加入时间+冷却数据 const fmtJoinDate = (ts) => { if (!ts) return '未知'; 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 '无冷却'; const days = Math.floor(secs / SECONDS_PER_DAY); return `${days} 天`; }; const joinListHtml = allMembers.map((m, i) => { const c = CHART_COLORS[i % CHART_COLORS.length]; if (m.pending) { return `
${ICONS.userPlaceholder}
${'待加入'}
- ${'冷却'}: -
`; } 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 `
${esc(m.userName || familyNameOf(m, idMap))} ${joinDate} ${joinDays}${'天'} ${'冷却'}: ${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', `
${'成员多维度雷达对比'}
` + `
${'各维度按最大值归一化(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', `
${'加入时间'}
` + `
${'成员加入家庭组的时间与冷却'}
` + 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 v2.17/v2.18) // 2026-07 愿望单改版:SSR 数据迁移到