-> { latestAppIds:[], gameInfo:{}, records:[{ts, type, newAppIds?, totalGames}] }
getTimeline: (sid) => parseStored(`sfd_timeline_${sid}`, null),
setTimeline: (sid, val) => _dbSet(`sfd_timeline_${sid}`, val),
// 已删除好友:累积记录从好友列表中消失的好友 { [steamid]: { ...lastKnownData, _deletedAt } }
getDeletedFriends: () => parseStored('sfd_deleted_friends', {}),
setDeletedFriends: (val) => _dbSet('sfd_deleted_friends', val),
getDashFriendsCache: () => parseStoredTTL('sfd_dash_friends_cache', DASH_FRIENDS_TTL, null),
setDashFriendsCache: (val) => _dbSet('sfd_dash_friends_cache', val),
// 按好友单独存储完整游戏库(IndexedDB 无单条大小限制)
getOwnedGameCache: (sid) => parseStoredTTL(`sfd_og_${sid}`, OWNED_GAMES_TTL, null),
setOwnedGameCache: (sid, val) => _dbSet(`sfd_og_${sid}`, val),
delOwnedGameCache: (sid) => _dbDelete(`sfd_og_${sid}`),
// 摘要缓存(只存game_count/totalMinutes,体积小,用于仪表盘批量判断哪些好友已缓存)
getOwnedGamesSummary: () => parseStoredTTL('sfd_og_summary', OWNED_GAMES_TTL, {}),
setOwnedGamesSummary: (val) => _dbSet('sfd_og_summary', val),
getRecentCache: () => parseStoredTTL('sfd_recent_cache', RECENT_TTL, null),
setRecentCache: (val) => _dbSet('sfd_recent_cache', val),
getSummariesCache: () => parseStoredTTL('sfd_summaries_cache', SUMMARIES_TTL, null),
setSummariesCache: (val) => _dbSet('sfd_summaries_cache', val),
getDashSnaps: () => parseStored('sfd_dash_snaps', {}),
setDashSnaps: (val) => _dbSet('sfd_dash_snaps', val),
getDashCache: () => parseStored('sfd_dash_cache', null),
setDashCache: (val) => _dbSet('sfd_dash_cache', val),
// 游戏中文名缓存:{ appid: { name: '中文名', ts: timestamp } }
getGameNameCache: () => parseStored('sfd_game_name_cache', {}),
setGameNameCache: (val) => _dbSet('sfd_game_name_cache', val),
// 预购游戏网页检测结果缓存:{ appid: { result: 'self'|'family'|false, ts: timestamp } }
getStressDetectCache: () => parseStored('sfd_stress_detect_cache', null),
setStressDetectCache: (val) => _dbSet('sfd_stress_detect_cache', val)
};
// ==================== 工具函数 ====================
function gmFetch(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url,
onload(resp) { resolve(resp.responseText); },
onerror(err) { reject(err); }
});
});
}
function requestSteamAPI(url, timeout = 15000) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout,
onload(resp) { try { resolve(JSON.parse(resp.responseText)); } catch (e) { reject(new Error('JSON parse failed')); } },
onerror(err) { reject(err); },
ontimeout() { reject(new Error('timeout')); }
});
});
}
// ==================== 游戏中文名获取 ====================
const _gameNameCache = storage.getGameNameCache(); // 内存缓存
const _gameNamePending = new Map(); // 防止重复请求
// 获取游戏中文名(异步,返回 Promise)
function fetchGameZhName(appid) {
const id = String(appid);
const cached = _gameNameCache[id];
if (cached && cached.name && Date.now() - (cached.ts || 0) < NAME_CACHE_TTL) {
return Promise.resolve(cached.name);
}
if (_gameNamePending.has(id)) return _gameNamePending.get(id);
const p = SteamAPI.getAppDetails(id)
.then(json => {
const d = json && json[id];
let name = '';
if (d && d.success && d.data && d.data.name) name = d.data.name;
if (name) {
_gameNameCache[id] = { name, ts: Date.now() };
storage.setGameNameCache(_gameNameCache);
}
_gameNamePending.delete(id);
return name;
})
.catch(() => { _gameNamePending.delete(id); return ''; });
_gameNamePending.set(id, p);
return p;
}
// 异步加载中文名并更新 DOM 元素
function loadGameZhName(el, appid, originalName) {
if (!el || !appid) return;
if (locale !== 'zh-CN') return; // 非中文环境不获取
fetchGameZhName(appid).then(zhName => {
if (zhName && zhName !== originalName) {
el.textContent = zhName;
el.title = `${zhName} (${originalName})`;
}
});
}
function detectCurrentSteamId() {
if (typeof unsafeWindow !== 'undefined' && unsafeWindow.g_steamID) return unsafeWindow.g_steamID;
const match = document.documentElement.innerHTML.match(/g_steamID\s*=\s*"(\d{17})"/);
return match ? match[1] : '';
}
const DateUtils = {
fromUnix(ts) { return ts ? new Date(ts * 1000) : null; },
fromMs(ms) { return ms ? new Date(ms) : null; },
format(ts) {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleDateString(locale === 'zh-CN' ? 'zh-CN' : 'en-US');
},
formatTimestamp(ts) {
if (!ts) return '-';
const d = new Date(ts * 1000);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
},
formatMs(msTs) {
if (!msTs) return locale === 'zh-CN' ? '未知' : 'unknown';
const d = new Date(msTs);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
},
relative(ts) {
if (!ts) return '-';
const diff = Math.floor(Date.now() / 1000 - ts);
if (diff < SECONDS_PER_MINUTE) return locale === 'zh-CN' ? '刚刚' : 'just now';
if (diff < SECONDS_PER_HOUR) return locale === 'zh-CN' ? `${Math.floor(diff / SECONDS_PER_MINUTE)}分钟前` : `${Math.floor(diff / SECONDS_PER_MINUTE)}m ago`;
if (diff < SECONDS_PER_DAY) return locale === 'zh-CN' ? `${Math.floor(diff / SECONDS_PER_HOUR)}小时前` : `${Math.floor(diff / SECONDS_PER_HOUR)}h ago`;
if (diff < SECONDS_PER_MONTH) return locale === 'zh-CN' ? `${Math.floor(diff / SECONDS_PER_DAY)}天前` : `${Math.floor(diff / SECONDS_PER_DAY)}d ago`;
return DateUtils.format(ts);
},
lastPlayed(ts) {
if (!ts) return '';
const diff = Date.now() / 1000 - ts;
if (diff < SECONDS_PER_DAY) return locale === 'zh-CN' ? '今天' : 'today';
if (diff < SECONDS_PER_MONTH) return locale === 'zh-CN' ? Math.floor(diff / SECONDS_PER_DAY) + '天前' : Math.floor(diff / SECONDS_PER_DAY) + 'd ago';
if (diff < SECONDS_PER_YEAR) return locale === 'zh-CN' ? Math.floor(diff / SECONDS_PER_MONTH) + '月前' : Math.floor(diff / SECONDS_PER_MONTH) + 'mo ago';
return locale === 'zh-CN' ? Math.floor(diff / SECONDS_PER_YEAR) + '年前' : Math.floor(diff / SECONDS_PER_YEAR) + 'y ago';
},
duration(minutes) {
if (!minutes || minutes <= 0) return '-';
const zh = locale === 'zh-CN';
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h > 0) return zh ? `${h}小时${m > 0 ? m + '分' : ''}` : `${h}h ${m}m`;
return zh ? `${m}分钟` : `${m}m`;
},
durationShort(minutes) {
if (!minutes || minutes === 0) return locale === 'zh-CN' ? '0小时' : '0h';
const hours = Math.floor(minutes / 60);
return locale === 'zh-CN' ? `${hours}小时` : hours + 'h';
},
daysSince(ts) {
if (!ts) return { days: 0, text: '-' };
const diff = Math.max(0, Math.floor((Date.now() / 1000 - ts) / SECONDS_PER_DAY));
return { days: diff, text: `${diff}${t('dayUnit')}` };
}
};
function getDaysClass(days) {
if (days < 30) return 'new-friend';
if (days > 1000) return 'old-friend';
return 'normal';
}
function getPersonaStateText(stateNum) {
const states = [t('statusOffline'), t('statusOnline'), t('statusBusy'), t('statusAway'), t('statusSnooze'), t('statusTrade'), t('statusPlay')];
return states[stateNum] || t('statusOffline');
}
function getPersonaStateClass(pstate, hasGame) {
if (hasGame) return 'in-game';
if (pstate > 0) return 'online';
return 'offline';
}
const toast = {
_el: null,
_show(msg, type) {
let el = this._el || document.getElementById('sfd-toast-box');
if (!el) { el = h('div', { class: 'sfd-toast', id: 'sfd-toast-box' }); document.body.appendChild(el); this._el = el; }
el.textContent = msg;
el.className = 'sfd-toast' + (type ? ' sfd-toast-' + type : '');
requestAnimationFrame(() => el.classList.add('sfd-toast-show'));
setTimeout(() => {
el.classList.remove('sfd-toast-show');
setTimeout(() => { if (el && !el.classList.contains('sfd-toast-show')) el.textContent = ''; }, TOAST_FADE_MS);
}, TOAST_DURATION);
},
success(msg) { this._show(msg, 'success'); },
error(msg) { this._show(msg, 'error'); },
warning(msg) { this._show(msg, 'warning'); },
info(msg) { this._show(msg, 'info'); }
};
// 统一错误处理策略:
// - error: 致命错误,console.error + toast 提示用户
// - warn: 可恢复错误,console.warn 记录但不打断流程
// - silent: 预期可能失败的尝试(回退逻辑、可选数据),console.debug 记录
const logger = {
error(msg, err) { console.error('[SFD] ' + msg, err || ''); toast.error(msg); },
warn(msg, err) { console.warn('[SFD] ' + msg, err || ''); },
silent(msg, err) { console.debug('[SFD] [silent] ' + msg, err || ''); }
};
// ==================== VAC 检查系统 ====================
// VAC 状态徽章(返回 DOM 元素)
function getVACShieldEl(f) {
const hasVAC = !!f.vac_banned, hasDev = !!(f.vac_game_bans > 0);
let icon, title;
if (hasVAC && hasDev) { icon = ICONS.shieldBothBan; title = `VAC Banned - ${f.vac_days_since_last_ban}${t('dayUnit')}, ${t('devBan')} x${f.vac_game_bans}`; }
else if (hasVAC) { icon = ICONS.shieldRed; title = `VAC Banned - ${f.vac_days_since_last_ban}${t('dayUnit')}`; }
else if (hasDev) { icon = ICONS.shieldDevBan; title = `${t('devBan')} x${f.vac_game_bans}`; }
else { icon = ICONS.shieldGreen; title = 'Clean'; }
return h('span', { class: 'sfd-vac-shield-panel', html: icon, title });
}
function getVACShieldPageEl(vacBanned, daysSinceLastBan, gameBans) {
const hasVAC = !!vacBanned;
const hasDev = !!(gameBans && gameBans > 0);
if (hasVAC && hasDev) {
const daysText = daysSinceLastBan ? t('vacDaysAgo').replace('{n}', daysSinceLastBan) : '';
const devItem = h('span', { class: 'sfd-shield-item', html: ICONS.shieldDevBan });
devItem.appendChild(h('span', { class: 'sfd-dev-ban-count', text: String(gameBans) }));
return h('span', { class: 'sfd-vac-shield-page banned-both' }, [
daysText ? h('span', { class: 'sfd-vac-days', text: daysText }) : null,
h('span', { class: 'sfd-shield-item', html: ICONS.shieldRed }),
devItem
]);
}
if (hasDev) {
const devItem = h('span', { class: 'sfd-shield-item', html: ICONS.shieldDevBan });
devItem.appendChild(h('span', { class: 'sfd-dev-ban-count', text: String(gameBans) }));
return h('span', { class: 'sfd-vac-shield-page banned-dev' }, [devItem]);
}
if (hasVAC) {
const daysText = daysSinceLastBan ? t('vacDaysAgo').replace('{n}', daysSinceLastBan) : '';
const el = h('span', { class: 'sfd-vac-shield-page banned', html: ICONS.shieldRed });
if (daysText) el.insertBefore(h('span', { class: 'sfd-vac-days', text: daysText }), el.firstChild);
return el;
}
return h('span', { class: 'sfd-vac-shield-page clean', html: ICONS.shieldGreen });
}
function renderVACShieldOnPage(friendEl, vacBanned, daysSinceLastBan, gameBans) {
if (!friendEl) return;
friendEl.style.position = 'relative';
const oldShield = friendEl.querySelector('.sfd-vac-shield-page');
if (oldShield) oldShield.remove();
friendEl.appendChild(getVACShieldPageEl(vacBanned, daysSinceLastBan, gameBans));
}
function syncVacToFriendData(fd, vacInfo) {
if (!fd || !vacInfo) return;
fd.vac_banned = vacInfo.VACBanned;
fd.vac_days_since_last_ban = vacInfo.DaysSinceLastBan || 0;
fd.vac_number_of_bans = vacInfo.NumberOfVACBans || 0;
fd.vac_game_bans = vacInfo.NumberOfGameBans || 0;
}
function vacSetStatus(steamid, data) {
if (data.error) return;
// 优化:直接通过属性选择器精确查找单个元素,避免遍历所有好友
const c = document.querySelector(`.friend_block_v2[data-steamid="${steamid}"]`);
if (c) {
const playerData = data.players && data.players[0];
if (playerData) {
if (playerData.VACBanned) state.vac.banned++;
if (playerData.NumberOfGameBans > 0) state.vac.devBanned++;
const vacInfo = { VACBanned: playerData.VACBanned, DaysSinceLastBan: playerData.DaysSinceLastBan || 0, NumberOfVACBans: playerData.NumberOfVACBans || 0, NumberOfGameBans: playerData.NumberOfGameBans || 0, timestamp: Date.now() };
// 只更新内存缓存,批处理结束后统一持久化(避免每个玩家都写一次 IndexedDB)
const cache = storage.getVACCache();
cache[steamid] = vacInfo;
renderVACShieldOnPage(c, playerData.VACBanned, playerData.DaysSinceLastBan, playerData.NumberOfGameBans);
// 同步到 friendsData
const fd = _friendsMap.get(String(steamid));
if (fd) syncVacToFriendData(fd, vacInfo);
}
}
updateVACStatus();
}
function updateVACStatus() {
const el = document.getElementById('sfd-vac-status');
if (!el) return;
if (state.vac.finished) {
const hasAny = state.vac.banned > 0 || state.vac.devBanned > 0;
if (!hasAny) {
el.innerHTML = t('vacNone');
} else {
const parts = [];
if (state.vac.banned > 0) parts.push(t('vacBanned').replace('{n}', state.vac.banned));
if (state.vac.devBanned > 0) parts.push(t('devBanned').replace('{n}', state.vac.devBanned));
el.innerHTML = parts.join(' · ');
}
} else {
const cnt = state.vac.banned + state.vac.devBanned;
el.innerHTML = cnt === 0 ? t('vacChecking') : t('vacChecking') + ' (' + cnt + ')';
}
}
function startVACCheck(forceAll) {
const apiKey = storage.getApiKey();
if (!apiKey) return;
const friends = document.querySelectorAll('.friend_block_v2[data-steamid]');
state.vac.banned = 0; state.vac.devBanned = 0; state.vac.finished = false; state.vac.queue = [];
if (forceAll) {
for (const f of friends) state.vac.queue.push(f.dataset.steamid);
} else {
const cache = storage.getVACCache();
for (const f of friends) { if (!cache[f.dataset.steamid]) state.vac.queue.push(f.dataset.steamid); }
}
document.querySelectorAll('.sfd-vac-shield-page').forEach(s => s.remove());
if (state.vac.queue.length === 0) {
loadVACFromCache();
state.vac.finished = true;
updateVACStatus();
if (dom.vacBtn) dom.vacBtn.disabled = false;
return;
}
const bpBar = showProgressBar();
if (dom.vacBtn) dom.vacBtn.disabled = true;
updateVACStatus();
// 批量请求,每批 VAC_BATCH_SIZE 个
const BATCH_SIZE = VAC_BATCH_SIZE;
const queue = [...state.vac.queue];
state.vac.queue = [];
const totalToFetch = queue.length;
let processedCount = 0;
async function processBatch() {
for (let i = 0; i < queue.length; i += BATCH_SIZE) {
const batch = queue.slice(i, i + BATCH_SIZE);
try {
const data = await SteamAPI.getVAC(batch);
if (data.players) {
for (const p of data.players) {
vacSetStatus(p.SteamId, { players: [p] });
processedCount++;
}
}
} catch (e) {
batch.forEach(sid => { vacSetStatus(sid, { error: true }); processedCount++; });
}
setProgressPercent(bpBar, (processedCount / totalToFetch) * 100);
}
// 批量完成后一次性持久化缓存(vacSetStatus 内部已更新内存缓存)
storage.setVACCache(storage.getVACCache());
// 完成
state.vac.finished = true;
updateVACStatus();
if (refreshBtn) refreshBtn.disabled = false;
hideProgressBar();
}
processBatch();
}
function loadVACFromCache() {
const cache = storage.getVACCache();
const friends = document.querySelectorAll('.friend_block_v2[data-steamid]');
let cached = 0;
state.vac.banned = 0;
state.vac.devBanned = 0;
for (const f of friends) {
const sid = f.dataset.steamid;
if (cache[sid]) {
cached++;
if (cache[sid].VACBanned) state.vac.banned++;
if (cache[sid].NumberOfGameBans > 0) state.vac.devBanned++;
renderVACShieldOnPage(f, cache[sid].VACBanned, cache[sid].DaysSinceLastBan, cache[sid].NumberOfGameBans);
// 同步到 friendsData
const fd = _friendsMap.get(String(sid));
if (fd) syncVacToFriendData(fd, cache[sid]);
}
}
state.vac.finished = (cached === friends.length && friends.length > 0);
updateVACStatus();
return cached;
}
// ==================== 等级查询系统 ====================
function startLevelCheck(forceAll) {
const apiKey = storage.getApiKey();
if (!apiKey) return;
const friends = document.querySelectorAll('.friend_block_v2[data-steamid]');
const mySteamId = storage.getSteamId() || detectCurrentSteamId();
const queue = [];
if (forceAll) {
for (const f of friends) queue.push(f.dataset.steamid);
if (mySteamId) queue.push(mySteamId);
} else {
const cache = storage.getLevelCache();
for (const f of friends) { if (!cache[f.dataset.steamid]) queue.push(f.dataset.steamid); }
if (mySteamId && !cache[mySteamId]) queue.push(mySteamId);
}
if (queue.length === 0) {
loadLevelFromCache();
return;
}
const bpBar = showProgressBar();
if (dom.levelBtn) dom.levelBtn.disabled = true;
setStatusText(t('levelChecking'));
const totalToFetch = queue.length;
let processedCount = 0;
async function processBatch() {
try {
const cache = storage.getLevelCache();
let lastRenderTime = 0;
// 构建任务列表:每个好友一个独立请求
const tasks = queue.map(sid => () => SteamAPI.getLevel(sid).then(d => {
const level = (d && d.response) ? (d.response.player_level || 0) : 0;
cache[sid] = level;
const fd = _friendsMap.get(String(sid));
if (fd) fd.level = level;
}));
// 使用智能并发池:滑动窗口模式,完成1个立刻填入下1个,保持并发恒定
await _concurrentPool(tasks, LEVEL_CONCURRENCY, (completed, total) => {
processedCount = completed;
setStatusText(t('levelChecking') + ' ' + completed + '/' + total + ' ...');
setProgressPercent(bpBar, (completed / total) * 100);
// 降低DOM刷新频率:节流刷新
const now = Date.now();
if (now - lastRenderTime >= RENDER_THROTTLE_MS) {
lastRenderTime = now;
loadLevelFromCache();
applyPanelFiltersAndSort();
}
});
// 批量完成后一次性写入缓存
storage.setLevelCache(cache);
// 最终完整刷新
loadLevelFromCache();
applyPanelFiltersAndSort();
if (levelBtn) levelBtn.disabled = false;
setStatusText(t('levelDone'));
hideProgressBar();
} catch (e) {
logger.error('等级查询失败', e);
if (levelBtn) levelBtn.disabled = false;
setStatusText(t('levelDone'));
hideProgressBar();
}
}
processBatch();
}
function loadLevelFromCache() {
const cache = storage.getLevelCache();
const friendBlocks = document.querySelectorAll('.friend_block_v2[data-steamid]');
for (const block of friendBlocks) {
const sid = block.dataset.steamid;
const level = cache[sid] || 0;
if (!level) continue;
const avatarEl = block.querySelector('.player_avatar');
if (!avatarEl) continue;
const oldLevel = avatarEl.querySelector('.sfd-name-tag-level');
if (oldLevel) oldLevel.remove();
avatarEl.style.position = 'relative';
avatarEl.style.overflow = 'visible';
avatarEl.appendChild(h('span', { class: 'sfd-name-tag-level', title: `Steam Lv.${level}`, text: String(level) }));
}
// 同步到 state.friends.data
state.friends.data.forEach(fd => { fd.level = cache[fd.steamid] || fd.level || 0; });
// 同步到自己的等级
const mySteamId = storage.getSteamId() || detectCurrentSteamId();
if (mySteamId && state.friends.ownProfile) {
state.friends.ownProfile.level = cache[mySteamId] || state.friends.ownProfile.level || 0;
}
}
// ==================== 共同好友批量查询(智能并发池) ====================
const _mutualCache = {};
const _mutualFriendLists = {}; // 缓存每个好友的好友列表详情,用于浮窗展示
/**
* 智能并发池 — 滑动窗口模式,完成一个立刻填入下一个,保持并发数恒定
* 遇到429或错误自动延迟重试,最多重试2次
*/
async function _concurrentPool(tasks, concurrency, onProgress) {
const results = new Array(tasks.length);
let nextIndex = 0;
let completed = 0;
let activeConcurrency = concurrency;
const MAX_RETRY = 2;
async function runTask(index) {
const task = tasks[index];
let retryCount = 0;
while (retryCount <= MAX_RETRY) {
try {
const result = await task();
results[index] = { status: 'fulfilled', value: result };
return;
} catch (e) {
retryCount++;
if (retryCount <= MAX_RETRY) {
// 429 或网络错误:指数退避重试
const delayMs = retryCount === 1 ? 1000 : 2000;
await new Promise(r => setTimeout(r, delayMs));
// 如果是429,降低并发数
if (e.message && (e.message.includes('429') || e.message.includes('rate'))) {
activeConcurrency = Math.max(3, Math.floor(activeConcurrency * 0.7));
}
} else {
results[index] = { status: 'rejected', reason: e };
return;
}
}
}
}
async function worker() {
while (nextIndex < tasks.length) {
const idx = nextIndex++;
await runTask(idx);
completed++;
if (onProgress) onProgress(completed, tasks.length);
}
}
// 动态调整 worker 数量(不超过剩余任务数)
const workerCount = Math.min(activeConcurrency, tasks.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}
async function batchFetchMutualCounts() {
const apiKey = storage.getApiKey();
if (!apiKey || !state.friends.data.length) return;
const myFriendIds = new Set(state.friends.data.map(f => f.steamid));
if (dom.mutualBtn) dom.mutualBtn.disabled = true;
setStatusText(t('btnRefreshMutual') + ' ...');
const bpBar = showProgressBar();
let lastRenderTime = 0;
// 缓存版本检查:旧版本可能把私密好友误存为 0,需要清空重查
const MUTUAL_CACHE_VERSION = 2;
const cachedVersion = _dbGet('sfd_mutual_cache_ver', 0);
if (cachedVersion < MUTUAL_CACHE_VERSION) {
storage.setMutualCache({});
_dbSet('sfd_mutual_cache_ver', MUTUAL_CACHE_VERSION);
}
// 合并缓存:先从 _mutualCache 加载,再从 storage 加载
const storedCache = storage.getMutualCache();
for (const [sid, val] of Object.entries(storedCache)) {
if (_mutualCache[sid] === undefined) _mutualCache[sid] = val;
}
// 同步到 friendsData
state.friends.data.forEach(f => {
if (_mutualCache[f.steamid] !== undefined) f.mutualFriendsCount = _mutualCache[f.steamid];
});
// 需要查询的:未缓存(_mutualCache[sid] === undefined) 的好友
// 注意:值为 0 表示真正0个共同好友,值为 -1 表示私密,都已查询过不需要重查
const toFetch = state.friends.data.filter(f => _mutualCache[f.steamid] === undefined);
const totalToFetch = toFetch.length;
if (totalToFetch === 0) {
hideProgressBar();
setStatusText(t('footerReady'));
if (mutualBtn) mutualBtn.disabled = false;
applyPanelFiltersAndSort();
applyModalFiltersAndSort();
return;
}
// ===== 两阶段查询:阶段1快速并发(不重试,类1.0.12),阶段2仅对失败项重试精查私密 =====
const CONCURRENCY = MUTUAL_CONCURRENCY;
// 阶段1:快速获取,出错不重试(返回 ok:false),收集失败项交由阶段2
const phase1Tasks = toFetch.map(f => async () => {
try {
const resp = await SteamAPI.getFriendsLegacy(f.steamid);
// 私密好友:API 返回空 friendslist 或无 friendslist 字段
if (!resp || !resp.friendslist || !resp.friendslist.friends) {
f.mutualFriendsCount = -1;
_mutualCache[f.steamid] = -1;
return { ok: true };
}
const theirFriends = resp.friendslist.friends.map(fr => fr.steamid);
// 缓存好友列表,用于浮窗展示共同好友详情
_mutualFriendLists[f.steamid] = theirFriends;
const count = theirFriends.filter(id => myFriendIds.has(id)).length;
f.mutualFriendsCount = count;
_mutualCache[f.steamid] = count;
return { ok: true };
} catch (e) {
// 429/网络/超时:不在此标记,交由阶段2重试精查
return { ok: false };
}
});
const phase1Results = await _concurrentPool(phase1Tasks, CONCURRENCY, (completed, total) => {
setProgressPercent(bpBar, (completed / total) * 50);
setStatusText(t('btnRefreshMutual') + ' ' + completed + '/' + total + ' ...');
// 降低DOM刷新频率:每800ms最多刷新一次
const now = Date.now();
if (now - lastRenderTime >= RENDER_THROTTLE_MS) {
lastRenderTime = now;
applyPanelFiltersAndSort();
applyModalFiltersAndSort();
}
});
// 阶段2:仅对阶段1失败的好友(多为429限流)用重试精查,判定私密(-1)或真实数量
const recheckList = toFetch.filter((f, i) => phase1Results[i] && phase1Results[i].value && phase1Results[i].value.ok === false);
if (recheckList.length) {
// 阶段2:throw 触发 _concurrentPool 重试(最多2次,指数退避;429自动降并发)
const phase2Tasks = recheckList.map(f => async () => {
const resp = await SteamAPI.getFriendsLegacy(f.steamid);
if (!resp || !resp.friendslist || !resp.friendslist.friends) {
f.mutualFriendsCount = -1;
_mutualCache[f.steamid] = -1;
return -1;
}
const theirFriends = resp.friendslist.friends.map(fr => fr.steamid);
_mutualFriendLists[f.steamid] = theirFriends;
const count = theirFriends.filter(id => myFriendIds.has(id)).length;
f.mutualFriendsCount = count;
_mutualCache[f.steamid] = count;
return count;
});
const phase2Results = await _concurrentPool(phase2Tasks, CONCURRENCY, (completed, total) => {
setProgressPercent(bpBar, 50 + (completed / total) * 50);
setStatusText(t('btnRefreshMutual') + ' ' + completed + '/' + total + ' ...');
const now = Date.now();
if (now - lastRenderTime >= RENDER_THROTTLE_MS) {
lastRenderTime = now;
applyPanelFiltersAndSort();
applyModalFiltersAndSort();
}
});
// 阶段2仍失败的好友标记为 -1(私密或无法访问)
phase2Results.forEach((r, i) => {
if (r && r.status === 'rejected') {
const f = recheckList[i];
f.mutualFriendsCount = -1;
_mutualCache[f.steamid] = -1;
}
});
} else {
setProgressPercent(bpBar, 100);
}
// 最终完整刷新
hideProgressBar();
setStatusText(t('footerReady'));
if (mutualBtn) mutualBtn.disabled = false;
storage.setMutualCache(_mutualCache);
applyPanelFiltersAndSort();
applyModalFiltersAndSort();
}
// ==================== 批量数据拉取 ====================
async function fetchAllData(steamId, progressCallback) {
const friendListData = await SteamAPI.getFriends(steamId);
if (!friendListData.friendslist || !friendListData.friendslist.friends) {
throw new Error('GetFriendList response invalid. Is your profile set to public?');
}
const rawFriends = friendListData.friendslist.friends;
const idsToFetch = [steamId, ...rawFriends.map(f => f.steamid)];
const totalIds = idsToFetch.length;
const summaries = [];
const size = SUMMARY_BATCH_SIZE;
for (let i = 0; i < totalIds; i += size) {
const batch = idsToFetch.slice(i, i + size);
if (progressCallback) progressCallback(i, totalIds - 1);
const sumData = await SteamAPI.getSummary(batch);
if (sumData.response && sumData.response.players) summaries.push(...sumData.response.players);
}
// 写入 summaries 缓存,供仪表盘等共用
const sumCache = { _ts: Date.now() }; summaries.forEach(p => { sumCache[p.steamid] = p; }); storage.setSummariesCache(sumCache);
const summariesMap = new Map(summaries.map(p => [p.steamid, p]));
const ownSummary = summariesMap.get(steamId);
// 获取自家游戏数量(共用 owned games 缓存)
let ownGameCount = 0;
try {
const cached = await fetchOwnedGamesCached(steamId);
ownGameCount = cached.game_count || 0;
} catch (e) { logger.warn('获取自身游戏数量失败', e); }
const ownCountryInfo = ownSummary ? getCountryInfo(ownSummary.loccountrycode) : null;
const ownData = ownSummary ? {
steamid: steamId, personaname: ownSummary.personaname,
avatar: ownSummary.avatarmedium || ownSummary.avatar,
avatarfull: ownSummary.avatarfull || ownSummary.avatar || '',
personastate: ownSummary.personastate !== undefined ? ownSummary.personastate : 0,
gameextrainfo: ownSummary.gameextrainfo || '',
loccountrycode: ownSummary.loccountrycode || '',
country_name: ownCountryInfo ? ownCountryInfo.name : '',
country_flag: ownCountryInfo ? ownCountryInfo.flag : '',
level: 0,
game_count: ownGameCount,
profileurl: ownSummary.profileurl || `https://steamcommunity.com/profiles/${steamId}/`,
timecreated: ownSummary.timecreated || 0
} : null;
// 从缓存恢复 VAC、等级和共同好友数据
const vacCache = storage.getVACCache();
const levelCache = storage.getLevelCache();
const mutualCache = storage.getMutualCache();
// 补充自己的等级和 VAC 信息(ownSummary 不含等级,从缓存获取)
if (ownData) {
ownData.level = levelCache[steamId] || 0;
const ownVacInfo = vacCache[steamId];
if (ownVacInfo) {
ownData.vac_banned = ownVacInfo.VACBanned || false;
ownData.vac_game_bans = ownVacInfo.NumberOfGameBans || 0;
}
}
const enriched = rawFriends.map(f => {
const s = summariesMap.get(f.steamid) || {};
const daysInfo = DateUtils.daysSince(f.friend_since);
const countryInfo = getCountryInfo(s.loccountrycode);
const vacInfo = vacCache[f.steamid];
return {
steamid: f.steamid, friend_since: f.friend_since,
friend_days: daysInfo.days, friend_days_text: daysInfo.text,
friend_days_class: getDaysClass(daysInfo.days),
personaname: s.personaname || t('anonymous'),
avatar: s.avatarmedium || DEFAULT_AVATAR,
loccountrycode: s.loccountrycode || '',
country_name: countryInfo ? countryInfo.name : '',
country_flag: countryInfo ? countryInfo.flag : '',
personastate: s.personastate !== undefined ? s.personastate : 0,
lastlogoff: s.lastlogoff || 0,
gameextrainfo: s.gameextrainfo || '',
vac_banned: vacInfo ? vacInfo.VACBanned : false,
vac_days_since_last_ban: vacInfo ? (vacInfo.DaysSinceLastBan || 0) : 0,
vac_game_bans: vacInfo ? (vacInfo.NumberOfGameBans || 0) : 0,
vac_number_of_bans: vacInfo ? (vacInfo.NumberOfVACBans || 0) : 0,
level: levelCache[f.steamid] || 0,
mutualFriendsCount: mutualCache[f.steamid] !== undefined ? mutualCache[f.steamid] : undefined
};
});
// 同步共同好友缓存到内存
Object.assign(_mutualCache, mutualCache);
return { friends: enriched, own: ownData };
}
// ==================== VAC/Level 重置函数 ====================
function resetAndCheckVAC() { storage.setVACCache({}); state.vac.banned = 0; state.vac.devBanned = 0; state.vac.finished = false; startVACCheck(true); }
function resetAndCheckLevel() { storage.setLevelCache({}); startLevelCheck(true); }
// ==================== 滚动覆盖辅助函数 ====================
function bindScrollOverride(el, selector) {
el.addEventListener('wheel', (e) => {
const scrollable = e.target.closest(selector);
if (scrollable) {
const { scrollTop, scrollHeight, clientHeight } = scrollable;
if (!(e.deltaY < 0 && scrollTop <= 0) && !(e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight)) {
e.preventDefault(); e.stopPropagation(); scrollable.scrollTop += e.deltaY;
}
}
}, { passive: false });
}
// ==================== UI 创建与渲染 ====================
let panelTrigger, familyTrigger, libraryTrigger, panelEl;
function initUI() {
panelTrigger = h('button', {
class: 'sfd-trigger-base sfd-trigger-panel', title: t('title'),
html: ICONS.users,
onClick: () => {
panelEl.classList.toggle('sfd-show');
if (panelEl.classList.contains('sfd-show')) {
renderActiveTab();
autoRefreshFriends();
}
}
});
document.body.appendChild(panelTrigger);
libraryTrigger = h('button', {
class: 'sfd-trigger-base sfd-trigger-library', title: t('plTitle'),
html: ICONS.package,
onClick: () => {
if (!state.personal.popupEl) { showPersonalLibrary(); }
else { state.personal.popupEl.classList.toggle('sfd-show'); }
}
});
document.body.appendChild(libraryTrigger);
familyTrigger = h('button', {
class: 'sfd-trigger-base sfd-trigger-family', title: locale === 'zh-CN' ? '家庭组' : 'Family Group',
html: ICONS.family,
onClick: () => {
if (!familyPopupEl) {
if (!state.family.popupSteamid) {
const ownBlock = document.querySelector('.friend_block_v2[data-steamid]');
if (ownBlock) state.family.popupSteamid = ownBlock.dataset.steamid;
if (!state.family.popupSteamid) state.family.popupSteamid = storage.getSteamId() || detectCurrentSteamId();
}
if (state.family.popupSteamid) showFamilyPopup(state.family.popupSteamid);
return;
}
familyPopupEl.classList.toggle('sfd-show');
}
});
document.body.appendChild(familyTrigger);
// ===== 统一好友管理窗口(Tab: 好友总览 / 深度大盘 / 设置) =====
panelEl = h('div', { class: 'sfd-panel', id: 'sfd-panel' }, [
h('div', { class: 'sfd-header' }, [
(() => {
const p = state.friends.ownProfile;
const hasGame = p && p.gameextrainfo;
const hdrLeft = h('div', { id: 'sfd-panel-header-info', style: { display: 'flex', alignItems: 'center', gap: '10px', flex: '1', minWidth: '0' } });
// 头像
if (p && p.avatar) {
hdrLeft.appendChild(h('img', { src: p.avatar, style: { width: '36px', height: '36px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(59,130,246,0.45)', flexShrink: '0', boxShadow: '0 4px 12px rgba(59,130,246,0.2)' }, loading: 'lazy', onerror: "this.onerror=null;this.src='https://avatars.steamstatic.com/fef49e7fa7e1997310dd48961da2e7d95a5c7a56_medium.jpg';" }));
} else {
hdrLeft.appendChild(h('div', { style: { width: '36px', height: '36px', borderRadius: '50%', background: 'rgba(59,130,246,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0' }, html: ICONS.user }));
}
// 昵称 + 国家 + 在线状态(column布局,参考对比窗口)
const infoWrap = h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '0' } });
infoWrap.appendChild(h('h3', { style: { margin: '0', fontSize: '14px', color: '#fff', fontWeight: '700', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } }, [
h('span', { text: p ? p.personaname : (locale === 'zh-CN' ? '好友管理' : 'Friends') }),
p && p.level > 0 ? h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#fbbf24', background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.level} Lv.${p.level}` }) : null
]));
const metaWrap = h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' } });
if (p && p.country_name) metaWrap.appendChild(h('span', { style: { fontSize: '11px', color: '#94a3b8' }, text: `${p.country_flag || ''} ${p.country_name}` }));
if (p) {
metaWrap.appendChild(hasGame
? h('span', { class: 'sfd-badge sfd-badge-status in-game', text: `🎮 ${p.gameextrainfo}` })
: h('span', { class: `sfd-badge sfd-badge-status ${p.personastate > 0 ? 'online' : 'offline'}`, text: getPersonaStateText(p.personastate) })
);
}
infoWrap.appendChild(metaWrap);
hdrLeft.appendChild(infoWrap);
return hdrLeft;
})(),
h('div', { class: 'sfd-tab-bar' }, [
h('button', { class: 'sfd-tab-btn sfd-active', id: 'sfd-tab-overview', text: '好友总览', onClick: () => switchTab('overview') }),
h('button', { class: 'sfd-tab-btn', id: 'sfd-tab-insights', text: '深度大盘', onClick: () => switchTab('insights') })
]),
h('div', { class: 'sfd-header-actions' }, [
h('div', { class: 'sfd-header-quick-actions' }, [
h('button', { class: 'sfd-header-action-btn sfd-action-fetch', title: t('btnFetch'), html: ICONS.search, id: 'sfd-fetch-btn', disabled: state.friends.isLoading, onClick: startDataProcess }),
h('button', { class: 'sfd-header-action-btn sfd-action-vac', title: t('btnRefreshVAC'), html: ICONS.shieldDots, id: 'sfd-vac-btn', onClick: resetAndCheckVAC }),
h('button', { class: 'sfd-header-action-btn sfd-action-level', title: t('btnRefreshLevel'), html: ICONS.level, id: 'sfd-level-btn', onClick: resetAndCheckLevel }),
h('button', { class: 'sfd-header-action-btn sfd-action-mutual', title: t('btnRefreshMutual'), html: ICONS.mutual, id: 'sfd-mutual-btn', onClick: batchFetchMutualCounts }),
h('button', { class: 'sfd-header-action-btn', title: 'CSV', html: 'CSV ', onClick: exportModalCSV }),
h('button', { class: 'sfd-header-action-btn', title: 'JSON', html: 'JSON ', onClick: exportModalJSON }),
h('span', { class: 'sfd-dash-updated', id: 'sfd-dash-updated', text: '' }),
h('button', { class: 'sfd-header-action-btn sfd-action-dash-refresh', title: '强制刷新深度大盘数据', html: ICONS.refresh, id: 'sfd-dash-refresh-btn', onClick: () => loadDashboardData(true) })
]),
h('button', { class: 'sfd-header-btn', html: ICONS.settings, id: 'sfd-settings-btn', title: t('tabSettings'), onClick: toggleSettings }),
createCloseBtn(() => panelEl.classList.remove('sfd-show'))
])
]),
h('div', { class: 'sfd-content', id: 'sfd-content' }),
h('div', { class: 'sfd-bottom-progress-wrap', id: 'sfd-panel-progress-wrap', style: { display: 'none' } }, [
h('div', { class: 'sfd-bottom-progress', id: 'sfd-panel-progress-bar' })
])
]);
document.body.appendChild(panelEl);
cacheDom();
bindScrollOverride(panelEl, '.sfd-content, .sfd-friends-scroll-container, .sfd-modal-body, .sfd-table-wrapper, .sfd-dash-content');
// 点击外部关闭(排除从面板内打开的弹窗,避免误关面板)
const panelOutsideClick = (e) => {
if (panelEl.classList.contains('sfd-show') && !panelEl.contains(e.target)
&& !e.target.closest('.sfd-trigger-panel')
&& !e.target.closest('#sfd-compare-popup')
&& !e.target.closest('#sfd-member-games-popup')
&& !e.target.closest('#sfd-month-games-popup')
&& !e.target.closest('#sfd-my90-games-popup')
&& !e.target.closest('#sfd-pl-popup')
&& !e.target.closest('#sfd-family-popup')) {
panelEl.classList.remove('sfd-show');
}
};
document.addEventListener('mousedown', panelOutsideClick, true);
state.ui.disposers.push(() => document.removeEventListener('mousedown', panelOutsideClick, true));
state.overview.deletedFriends = Object.values(storage.getDeletedFriends() || {});
const cachedFriends = storage.getCachedData();
const cachedOwn = storage.getCachedOwn();
if (cachedFriends && cachedFriends.length) {
state.friends.data = cachedFriends;
syncFriendsMap();
state.friends.ownProfile = cachedOwn;
updatePanelHeaderInfo();
if (cachedOwn && cachedOwn.personaname) document.title = cachedOwn.personaname + (locale === 'zh-CN' ? '的好友' : "'s Friends");
// Restore VAC / level / mutual stats from cache
const vacCache = storage.getVACCache();
const levelCache = storage.getLevelCache();
const mutualCache = storage.getMutualCache();
state.friends.data.forEach(f => {
if (vacCache[f.steamid] !== undefined) f.vac_banned = vacCache[f.steamid];
if (levelCache[f.steamid] !== undefined) f.level = levelCache[f.steamid];
if (mutualCache[f.steamid] !== undefined) f.mutualFriendsCount = mutualCache[f.steamid];
});
// Update VAC stats state
state.vac.banned = state.friends.data.filter(f => f.vac_banned).length;
state.vac.devBanned = state.friends.data.filter(f => f.vac_game_bans > 0).length;
state.vac.finished = true;
// Update _mutualCache
Object.assign(_mutualCache, mutualCache);
applyPanelFiltersAndSort();
applyModalFiltersAndSort();
}
}
function switchTab(tab) {
state.friends.activeTab = tab;
document.querySelectorAll('.sfd-tab-btn').forEach(b => b.classList.remove('sfd-active'));
const tabBtn = document.getElementById('sfd-tab-' + tab);
if (tabBtn) tabBtn.classList.add('sfd-active');
if (dom.settingsBtn) dom.settingsBtn.classList.remove('sfd-active');
renderActiveTab();
}
function toggleSettings() {
if (state.friends.activeTab === 'settings') { state.friends.activeTab = 'overview'; switchTab('overview'); }
else {
state.friends.activeTab = 'settings';
document.querySelectorAll('.sfd-tab-btn').forEach(b => b.classList.remove('sfd-active'));
if (dom.settingsBtn) dom.settingsBtn.classList.add('sfd-active');
renderActiveTab();
}
}
function renderActiveTab() {
const container = dom.content;
if (!container) return;
container.innerHTML = '';
container.style.cssText = '';
if (state.friends.activeTab === 'settings') renderSettingsTab(container);
else if (state.friends.activeTab === 'insights') { renderInsightsTab(container); renderDashboard(); loadDashboardData(false); }
else renderOverviewTab(container);
}
function renderSettingsTab(parent) {
// 使用说明
const guideHtml = locale === 'zh-CN'
? `
📖 使用说明
🔘 面板4按钮: 好友列表 · 数据大盘 · 家庭组 · 设置
👨👩👧 家庭组: 点击家庭组按钮查看成员、游戏库与系列收藏
📊 数据大盘: 好友游戏排行、时长热力图等
`
: `
📖 Guide
🔘 4 Panel Buttons: Friends · Dashboard · Family · Settings
👨👩👧 Family: Click to view members, game library & series
📊 Dashboard: Friends game stats, heatmaps & more
`;
parent.appendChild(h('div', { class: 'sfd-tip', html: guideHtml }));
// SteamID64(放上面)
parent.appendChild(h('div', { class: 'sfd-form-group' }, [
h('label', { text: t('steamIdLabel') }),
h('input', { type: 'text', class: 'sfd-input', id: 'sfd-id-input', value: storage.getSteamId() || detectCurrentSteamId(), placeholder: t('steamIdPlaceholder') })
]));
// API Key(放下面)
parent.appendChild(h('div', { class: 'sfd-form-group' }, [
h('label', { text: t('apiKeyLabel') }),
h('input', { type: 'password', class: 'sfd-input', id: 'sfd-key-input', value: storage.getApiKey(), placeholder: t('apiKeyPlaceholder') })
]));
const saveStatus = h('span', { style: { fontSize: '12px', color: 'var(--sfd-accent-green)' } });
const saveBtn = h('button', {
class: 'sfd-btn sfd-btn-primary', text: '💾 ' + t('btnSave'),
onClick: () => {
const key = document.getElementById('sfd-key-input').value.trim();
const id = document.getElementById('sfd-id-input').value.trim();
const hadApiKey = storage.getApiKey();
storage.setApiKey(key);
storage.setSteamId(id);
saveStatus.textContent = '✅ ' + t('saveSuccess');
// 新用户首次配置 API Key 后,自动触发获取好友数据
if (!hadApiKey && key && !state.friends.data.length) {
setTimeout(() => {
saveStatus.textContent = '';
toggleSettings();
startDataProcess();
}, 300);
} else {
setTimeout(() => { saveStatus.textContent = ''; toggleSettings(); }, 800);
}
}
});
parent.appendChild(h('div', { class: 'sfd-btn-row' }, [saveBtn, saveStatus]));
}
// ==================== v1.2.5 好友总览(合并好友列表+数据大盘) ====================
function renderOverviewTab(parent) {
const apiKey = storage.getApiKey();
if (!apiKey) { parent.appendChild(h('div', { class: 'sfd-tip', text: '⚠️ ' + t('noApiKeyTip') })); return; }
parent.style.display = 'flex';
parent.style.flexDirection = 'column';
parent.style.overflow = 'hidden';
// ① KPI + 搜索 + 排序
parent.appendChild(buildOverviewHero());
// ② 卡片列表
parent.appendChild(buildOverviewTable());
// 初始筛选并渲染
applyOverviewFilters();
renderOverviewTable();
updateOverviewKpis();
}
function buildOverviewHero() {
const hero = h('div', { class: 'sfd-ov-hero', id: 'sfd-ov-hero' });
// KPI 指标卡片(可点击筛选)
hero.appendChild(h('div', { class: 'sfd-ov-kpis', id: 'sfd-ov-kpis' }, [
createMetricCard({ id: 'sfd-ov-kpi-total', value: '0', label: '好友总数', accent: '#3b82f6', active: state.overview.filter === 'all', dataset: { filter: 'all' }, onClick: switchOverviewFilter }),
createMetricCard({ id: 'sfd-ov-kpi-ingame', value: '0', label: '游戏中', accent: '#8b5cf6', active: state.overview.filter === 'ingame', dataset: { filter: 'ingame' }, onClick: switchOverviewFilter }),
createMetricCard({ id: 'sfd-ov-kpi-online', value: '0', label: '在线', accent: '#10b981', active: state.overview.filter === 'online', dataset: { filter: 'online' }, onClick: switchOverviewFilter }),
createMetricCard({ id: 'sfd-ov-kpi-offline', value: '0', label: '离线', accent: '#94a3b8', active: state.overview.filter === 'offline', dataset: { filter: 'offline' }, onClick: switchOverviewFilter }),
createMetricCard({ id: 'sfd-ov-kpi-new', value: '0', label: '新好友', accent: '#f43f5e', active: state.overview.filter === 'new', dataset: { filter: 'new' }, onClick: switchOverviewFilter }),
createMetricCard({ id: 'sfd-ov-kpi-regions', value: '0', label: '国家/地区', accent: '#f59e0b', dataset: { filter: 'all' }, onClick: switchOverviewFilter }),
createMetricCard({ id: 'sfd-ov-kpi-vac', value: '0', label: 'VAC封禁', accent: '#ef5350', active: state.overview.filter === 'vac', dataset: { filter: 'vac' }, onClick: switchOverviewFilter }),
createMetricCard({ id: 'sfd-ov-kpi-devban', value: '0', label: '开发者封禁', accent: '#ff9800', active: state.overview.filter === 'devban', dataset: { filter: 'devban' }, onClick: switchOverviewFilter }),
createMetricCard({ id: 'sfd-ov-kpi-deleted', value: '0', label: '已删除', accent: '#64748b', active: state.overview.filter === 'deleted', dataset: { filter: 'deleted' }, onClick: switchOverviewFilter }),
]));
// 搜索框 + 排序按钮放在 KPI 右侧
const searchSortWrap = h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', flexShrink: '0' } });
// 搜索框
searchSortWrap.appendChild(h('input', {
type: 'text', class: 'sfd-input', id: 'sfd-ov-search', placeholder: t('searchPlaceholder'), value: state.overview.search,
style: { width: '390px', padding: '4px 10px', height: '28px', fontSize: '12px', borderRadius: '6px' },
onInput: (e) => { state.overview.search = e.target.value.trim().toLowerCase(); state.overview.page = 1; applyOverviewFilters(); renderOverviewTable(); }
}));
// 排序按钮
const zhs = locale === 'zh-CN';
const sortBtns = [
{ key: 'days', text: zhs ? '好友天数' : 'Days' },
{ key: 'status', text: zhs ? '状态' : 'Status' },
{ key: 'name', text: zhs ? '名字' : 'Name' },
{ key: 'level', text: zhs ? '等级' : 'Level' },
{ key: 'country', text: zhs ? '国家' : 'Country' },
{ key: 'mutual', text: zhs ? '共同好友' : 'Mutual' },
];
const sortWrap = h('div', { style: { display: 'inline-flex', alignItems: 'stretch', borderRadius: '14px', overflow: 'hidden', border: '1px solid rgba(59,130,246,0.15)', flexShrink: '0' } });
sortBtns.forEach((s, si) => {
const keyDesc = s.key + '-desc';
const keyAsc = s.key + '-asc';
const curSort = state.overview.sortBy;
const isActive = curSort === keyDesc || curSort === keyAsc;
const isDesc = curSort === keyDesc;
const arrow = isActive ? (isDesc ? ' ▼' : ' ▲') : ' ▼';
const btn = h('button', {
class: `sfd-ov-sort-btn${isActive ? ' active' : ''}`,
text: s.text + arrow,
style: { background: isActive ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.05)', color: isActive ? '#66c0f4' : '#94a3b8', border: 'none', borderRight: si < sortBtns.length - 1 ? '1px solid rgba(59,130,246,0.15)' : 'none', fontSize: '11px', padding: '4px 10px', cursor: 'pointer', whiteSpace: 'nowrap', transition: 'all 0.2s' },
onClick: () => {
const cur = state.overview.sortBy;
const nowActive = cur === keyDesc || cur === keyAsc;
const nowDesc = cur === keyDesc;
const nextVal = nowActive ? (nowDesc ? keyAsc : keyDesc) : keyDesc;
state.overview.sortBy = nextVal;
state.overview.page = 1;
sortWrap.querySelectorAll('.sfd-ov-sort-btn').forEach(b => { b.classList.remove('active'); b.style.background = 'rgba(59,130,246,0.05)'; b.style.color = '#94a3b8'; });
btn.classList.add('active');
btn.style.background = 'rgba(59,130,246,0.15)';
btn.style.color = '#66c0f4';
btn.textContent = s.text + (nextVal === keyDesc ? ' ▼' : ' ▲');
applyOverviewFilters();
renderOverviewTable();
}
});
sortWrap.appendChild(btn);
});
searchSortWrap.appendChild(sortWrap);
hero.appendChild(searchSortWrap);
return hero;
}
function buildOverviewTable() {
return h('div', { class: 'sfd-friends-scroll-container', id: 'sfd-ov-scroll-container', style: { display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gridAutoRows: 'min-content', gap: '8px' } });
}
// ===== 筛选逻辑 =====
function switchOverviewFilter(e) {
const card = e.currentTarget;
const filter = card.dataset.filter;
setOverviewFilter(filter);
}
function setOverviewFilter(filter) {
// 再次点击同一个筛选则回到 ingame 默认
if (state.overview.filter === filter && filter !== 'ingame') {
state.overview.filter = 'ingame';
} else {
state.overview.filter = filter;
}
state.overview.page = 1;
// 更新 KPI 激活态(排除国家/地区卡片,它不作为筛选激活)
document.querySelectorAll('#sfd-ov-kpis .sfd-metric-card').forEach(el => {
const id = el.querySelector('.sfd-metric-val')?.id;
if (id === 'sfd-ov-kpi-regions') return; // 跳过国家/地区
el.classList.toggle('active', el.dataset.filter === state.overview.filter);
});
applyOverviewFilters();
renderOverviewTable();
}
function applyOverviewFilters() {
const filter = state.overview.filter;
// 已删除好友特殊处理:数据源不同
if (filter === 'deleted') {
let list = [...(state.overview.deletedFriends || [])];
list = filterFriendsByQuery(list, state.overview.search, true);
list.sort((a, b) => (b._deletedAt || 0) - (a._deletedAt || 0));
state.overview.filtered = list;
updateChipCounts();
return;
}
let list = [...state.friends.data];
list = filterFriendsByQuery(list, state.overview.search, true);
if (filter === 'online') list = list.filter(f => f.personastate > 0 && !f.gameextrainfo);
else if (filter === 'ingame') list = list.filter(f => !!f.gameextrainfo);
else if (filter === 'offline') list = list.filter(f => f.personastate === 0);
else if (filter === 'new') list = list.filter(f => f.friend_days !== undefined && f.friend_days < 30);
else if (filter === 'vac') list = list.filter(f => f.vac_banned);
else if (filter === 'devban') list = list.filter(f => f.vac_game_bans > 0);
sortHelper(list, state.overview.sortBy);
state.overview.filtered = list;
updateChipCounts();
}
function updateChipCounts() {
const data = state.friends.data || [];
const counts = {
all: data.length,
ingame: data.filter(f => !!f.gameextrainfo).length,
online: data.filter(f => f.personastate > 0 && !f.gameextrainfo).length,
offline: data.filter(f => f.personastate === 0).length,
new: data.filter(f => f.friend_days !== undefined && f.friend_days < 30).length,
vac: data.filter(f => f.vac_banned).length,
devban: data.filter(f => f.vac_game_bans > 0).length,
deleted: (state.overview.deletedFriends || []).length
};
Object.entries(counts).forEach(([key, val]) => {
const el = document.getElementById(`sfd-chip-count-${key}`);
if (el) el.textContent = String(val);
});
}
function updateOverviewKpis() {
const data = state.friends.data || [];
let onlineCount = 0, ingameCount = 0, offlineCount = 0, vacBannedCount = 0, devBannedCount = 0;
const regionsSet = new Set();
const now = Math.floor(Date.now() / 1000);
let newCount = 0, deletedCount = 0;
data.forEach(f => {
if (f.personastate > 0) onlineCount++; else if (f.personastate === 0) offlineCount++;
if (f.gameextrainfo) ingameCount++;
if (f.vac_banned) vacBannedCount++;
if (f.vac_game_bans > 0) devBannedCount++;
if (f.loccountrycode) regionsSet.add(f.loccountrycode.toUpperCase());
if (f.friend_since && (now - f.friend_since) < SECONDS_PER_MONTH) newCount++;
if (f.personastate === -1) deletedCount++;
});
const setVal = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = String(v); };
setVal('sfd-ov-kpi-total', getSteamFriendsCount());
setVal('sfd-ov-kpi-ingame', ingameCount);
setVal('sfd-ov-kpi-online', onlineCount);
setVal('sfd-ov-kpi-offline', offlineCount);
setVal('sfd-ov-kpi-new', newCount);
setVal('sfd-ov-kpi-regions', regionsSet.size);
setVal('sfd-ov-kpi-vac', vacBannedCount);
setVal('sfd-ov-kpi-devban', devBannedCount);
setVal('sfd-ov-kpi-deleted', deletedCount);
}
// ===== 卡片式好友列表渲染(参考 1.2.3) =====
function renderFriendCard(f) {
const hasGame = !!f.gameextrainfo;
const stateCls = getPersonaStateClass(f.personastate, hasGame);
// 卡片颜色优先级:游戏中(紫) > 在线(绿) > 离线(灰)
let cardStatusCls = 'offline';
if (hasGame) cardStatusCls = 'ingame';
else if (f.personastate > 0) cardStatusCls = 'online';
const sinceText = t('friendSince').replace('{date}', DateUtils.format(f.friend_since));
let titleDetail = sinceText;
if (hasGame) titleDetail += `\n${t('inGame').replace('{game}', f.gameextrainfo)}`;
else if (f.personastate === 0 && f.lastlogoff) titleDetail += `\n${t('lastOnline').replace('{time}', DateUtils.format(f.lastlogoff) + ' ' + new Date(f.lastlogoff * 1000).toLocaleTimeString())}`;
const vacShieldEl = getVACShieldEl(f);
const mutualEl = f.mutualFriendsCount !== undefined
? h('span', {
class: 'sfd-panel-mutual',
style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: f.mutualFriendsCount >= 0 ? 'var(--sfd-accent-green)' : 'var(--sfd-text-secondary)', marginRight: '4px', whiteSpace: 'nowrap' },
title: f.mutualFriendsCount >= 0 ? `${t('mutualFriends')}: ${f.mutualFriendsCount}` : t('mutualPrivate'),
html: f.mutualFriendsCount >= 0 ? `${ICONS.mutual}${f.mutualFriendsCount} ` : ICONS.lock
})
: null;
// 不同状态的边框颜色
const borderColor = { ingame: 'rgba(139,92,246,0.4)', online: 'rgba(16,185,129,0.4)', offline: 'rgba(100,116,139,0.25)' }[cardStatusCls];
const isOffline = cardStatusCls === 'offline';
return h('a', {
class: `sfd-friend-item sfd-friend-item-${cardStatusCls}`,
href: `https://steamcommunity.com/profiles/${f.steamid}`,
target: '_blank',
title: titleDetail,
style: { borderLeft: `3px solid ${borderColor}`, opacity: isOffline ? '0.5' : '1' }
}, [
h('div', { class: 'sfd-avatar-wrap' }, [
h('img', { class: 'sfd-friend-avatar', src: f.avatar, loading: 'lazy', style: { filter: isOffline ? 'grayscale(1)' : 'none' } }),
h('span', { class: `sfd-status-dot ${stateCls}` })
]),
h('div', { class: 'sfd-friend-info' }, [
h('div', { class: 'sfd-friend-name-row' }, [
h('span', { class: 'sfd-friend-name', text: f.personaname, style: { color: isOffline ? '#64748b' : '#c7d5e0' } }),
f.country_name ? h('span', { style: { fontSize: '11px', color: '#94a3b8', flexShrink: '0' }, html: `${f.country_flag} ${f.country_name}` }) : null,
f.level > 0 ? h('span', { class: 'sfd-panel-level-badge', title: `Steam Lv.${f.level}`, text: `Lv.${f.level}` }) : null,
h('div', { class: 'sfd-card-actions', style: { marginLeft: 'auto' } }, [
mutualEl,
vacShieldEl,
h('button', {
class: 'sfd-compare-btn',
title: t('compareBtn'),
html: ICONS.monitor,
onClick: (e) => { e.preventDefault(); e.stopPropagation(); showGameCompare(f.steamid, f.personaname); }
})
])
]),
h('div', { class: 'sfd-friend-meta-row' }, [
h('span', { class: `sfd-friend-status ${stateCls}`, text: hasGame ? `🎮 ${f.gameextrainfo}` : getPersonaStateText(f.personastate) }),
h('span', { class: `sfd-friend-game-time ${f.friend_days_class || ''}`, text: `· ${f.friend_days_text}` })
])
])
]);
}
function renderOverviewTable() {
const scrollContainer = document.getElementById('sfd-ov-scroll-container');
if (!scrollContainer) return;
scrollContainer.innerHTML = '';
const all = state.overview.filtered;
if (!all.length) {
scrollContainer.appendChild(h('div', { style: { textAlign: 'center', padding: '36px', color: 'var(--sfd-text-secondary)', fontSize: '13px' }, text: t('noFriends') }));
return;
}
// 全部好友直接渲染,无分类折叠
all.forEach(f => scrollContainer.appendChild(renderFriendCard(f)));
}
function renderInsightsTab(parent) {
parent.style.padding = '0';
parent.style.overflow = 'hidden';
parent.style.display = 'flex';
parent.style.flexDirection = 'column';
parent.appendChild(h('div', { class: 'sfd-dash-content', id: 'sfd-dash-content' }));
}
function switchStateLoading(loading) {
state.friends.isLoading = loading;
if (dom.fetchBtn) dom.fetchBtn.disabled = loading;
if (dom.vacBtn) dom.vacBtn.disabled = loading;
if (dom.levelBtn) dom.levelBtn.disabled = loading;
if (dom.mutualBtn) dom.mutualBtn.disabled = loading;
renderActiveTab();
}
// ==================== 已删除好友检测 ====================
// 与上次缓存的好友列表对比,找出"消失"的好友并累积记录;重新加回的好友会从已删除列表中移除。
function detectDeletedFriends(prevFriends, currentFriends) {
const newIds = new Set(currentFriends.map(f => f.steamid));
const deletedMap = storage.getDeletedFriends();
const now = Date.now();
if (prevFriends && prevFriends.length) {
prevFriends.forEach(f => {
if (!newIds.has(f.steamid)) {
const prevDeletedAt = deletedMap[f.steamid] && deletedMap[f.steamid]._deletedAt;
deletedMap[f.steamid] = { ...f, _deletedAt: prevDeletedAt || now };
}
});
}
// 已重新加回的好友不再视为已删除
Object.keys(deletedMap).forEach(sid => { if (newIds.has(sid)) delete deletedMap[sid]; });
storage.setDeletedFriends(deletedMap);
state.overview.deletedFriends = Object.values(deletedMap);
}
// 打开面板时自动刷新好友数据(有 API Key 且未在加载中时触发)
function autoRefreshFriends() {
if (state.friends.isLoading) return;
if (!storage.getApiKey()) return;
startDataProcess();
}
// ==================== 数据同步并调用主逻辑 ====================
async function startDataProcess() {
const apiKey = storage.getApiKey();
const steamId = storage.getSteamId() || detectCurrentSteamId();
if (!apiKey || !steamId) { alert('API Key or SteamID invalid.'); return; }
switchStateLoading(true);
setStatusText(t('fetching'));
const bpBar = showProgressBar();
try {
const data = await fetchAllData(steamId, (curr, total) => {
setProgressPercent(bpBar, (curr / total) * 100);
setStatusText(t('fetchingDetail').replace('{current}', curr).replace('{total}', total));
});
setProgressPercent(bpBar, 100);
// 已删除好友检测:用本次新数据与上次缓存做差集(须在 setCachedData 覆盖前读取旧缓存)
detectDeletedFriends(storage.getCachedData(), data.friends);
state.friends.data = data.friends;
syncFriendsMap();
state.friends.ownProfile = data.own;
updatePanelHeaderInfo();
storage.setCachedData(data.friends);
if (data.own) storage.setCachedOwn(data.own);
if (data.own && data.own.personaname) document.title = data.own.personaname + (locale === 'zh-CN' ? '的好友' : "'s Friends");
applyPanelFiltersAndSort();
applyModalFiltersAndSort();
setStatusText(t('fetchSuccess').replace('{total}', data.friends.length));
applyPageEnhancements(data.friends);
loadVACFromCache();
loadLevelFromCache();
} catch (e) {
logger.error('数据拉取失败', e);
setStatusText(t('fetchFailed').replace('{msg}', e.message));
} finally {
switchStateLoading(false);
hideProgressBar();
renderActiveTab();
}
}
// ==================== Steam 好友卡片 DOM 注入增强 ====================
function applyPageEnhancements(friendsData) {
const dataMap = new Map(friendsData.map(d => [d.steamid, d]));
const friendBlocks = document.querySelectorAll('.friend_block_v2[data-steamid]');
let count = 0;
friendBlocks.forEach(block => {
const old = block.querySelector('.sfd-enhanced-block');
if (old) old.remove();
const sid = block.getAttribute('data-steamid');
const f = dataMap.get(sid);
if (!f) return;
const hasGame = !!f.gameextrainfo;
const stateCls = getPersonaStateClass(f.personastate, hasGame);
// 等级 → 头像左下角
const levelBadge = f.level ? h('span', { class: 'sfd-name-tag-level', title: `Steam Lv.${f.level}`, text: String(f.level) }) : null;
// 国家 → 昵称右侧
const countryTag = f.country_name ? h('span', { class: 'sfd-name-tag-country', title: f.country_name, html: f.country_flag }) : null;
// 好友天数 → 昵称下方
const daysTag = f.friend_days_text ? h('span', { class: `sfd-name-tag-days ${f.friend_days_class || ''}`, title: t('friendSince').replace('{date}', DateUtils.format(f.friend_since)), text: `🤝${f.friend_days_text}` }) : null;
// 在线状态 → 下方独立块
const statusBlock = h('div', { class: 'sfd-enhanced-block' }, [
...(hasGame || f.personastate !== 0 ? [h('span', {
class: `sfd-etag sfd-etag-status ${stateCls}`,
text: hasGame ? `🎮 ${f.gameextrainfo}` : getPersonaStateText(f.personastate)
})] : [])
]);
const contentBlock = block.querySelector('.friend_block_content');
if (contentBlock) {
// 清理旧元素
contentBlock.querySelectorAll('.sfd-name-tag-country, .sfd-name-tag-days').forEach(el => el.remove());
const oldWrap = contentBlock.querySelector('.sfd-name-tags');
if (oldWrap) oldWrap.remove();
const oldBlock = contentBlock.querySelector('.sfd-enhanced-block');
if (oldBlock) oldBlock.remove();
// 清理盾牌(可能在 contentBlock 或父级 friend_block_v2 上)
const oldShield1 = contentBlock.querySelector('.sfd-vac-shield-page');
if (oldShield1) oldShield1.remove();
const oldShield2 = block.querySelector(':scope > .sfd-vac-shield-page');
if (oldShield2) oldShield2.remove();
const avatarEl = block.querySelector('.player_avatar');
if (avatarEl) {
const oldLevel = avatarEl.querySelector('.sfd-name-tag-level');
if (oldLevel) oldLevel.remove();
}
// VAC 盾牌 → friend_block_v2 上(与 renderVACShieldOnPage 一致)
block.style.position = 'relative';
block.classList.remove('sfd-card-clean', 'sfd-card-banned', 'sfd-card-devban');
block.classList.add(f.vac_banned ? 'sfd-card-banned' : f.vac_game_bans > 0 ? 'sfd-card-devban' : 'sfd-card-clean');
block.appendChild(getVACShieldPageEl(f.vac_banned, f.vac_days_since_last_ban, f.vac_game_bans));
// 等级 → 头像左下角
if (avatarEl && levelBadge) {
avatarEl.style.position = 'relative';
avatarEl.style.overflow = 'visible';
avatarEl.appendChild(levelBadge);
}
const nameEl = contentBlock.querySelector('.friend_block_name');
if (nameEl && countryTag) {
nameEl.style.display = 'inline-flex';
nameEl.style.alignItems = 'center';
nameEl.style.gap = '4px';
nameEl.appendChild(countryTag);
}
const brEl = contentBlock.querySelector('br');
if (brEl && daysTag) brEl.parentNode.insertBefore(daysTag, brEl);
else if (daysTag) contentBlock.appendChild(daysTag);
// 在线状态 → 下方
const smallText = contentBlock.querySelector('.friend_small_text');
if (smallText) smallText.after(statusBlock);
else contentBlock.appendChild(statusBlock);
count++;
}
// 好友卡片点击 → 新标签打开
const overlay = block.querySelector('a.selectable_overlay[href]');
if (overlay) {
overlay.removeAttribute('onclick');
overlay.setAttribute('target', '_blank');
overlay.setAttribute('rel', 'noopener');
}
});
console.log(`[SFD] 已增强 ${count} 个好友块`);
}
// ==================== 家庭组数据获取与缓存 ====================
let _cachedWebApiToken = null;
function getAccessTokenSync() {
// 同步方式: 尝试从当前页面直接获取 (store.steampowered.com 有效)
try {
const appConfig = document.getElementById('application_config');
if (appConfig) {
const storeConfig = JSON.parse(appConfig.getAttribute('data-store_user_config') || '{}');
if (storeConfig.webapi_token) return storeConfig.webapi_token;
}
} catch (e) { logger.silent('webapi_token JSON 解析失败', e); }
try {
const m = document.documentElement.innerHTML.match(/"webapi_token"\s*:\s*"([^"]+)"/);
if (m && m[1]) return m[1];
} catch (e) { logger.silent('webapi_token 正则匹配失败', e); }
return null;
}
function fetchWebApiTokenFromStore() {
// 跨域方式: 通过 GM_xmlhttpRequest 请求 store.steampowered.com 提取 webapi_token
return new Promise((resolve) => {
if (_cachedWebApiToken) { resolve(_cachedWebApiToken); return; }
GM_xmlhttpRequest({
method: 'GET',
url: 'https://store.steampowered.com/',
timeout: 5000,
onload(resp) {
try {
const html = resp.responseText;
// 从 application_config 提取
const m1 = html.match(/id="application_config"[^>]*data-store_user_config="([^"]*)"/);
if (m1 && m1[1]) {
const decoded = m1[1].replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
const config = JSON.parse(decoded);
if (config.webapi_token) {
_cachedWebApiToken = config.webapi_token;
resolve(config.webapi_token);
return;
}
}
// 备选: 直接正则匹配
const m2 = html.match(/"webapi_token"\s*:\s*"([^"]+)"/);
if (m2 && m2[1]) {
_cachedWebApiToken = m2[1];
resolve(m2[1]);
return;
}
} catch (e) {
logger.warn('解析商店页面失败', e);
}
resolve(null);
},
onerror() { resolve(null); },
ontimeout() { resolve(null); }
});
});
}
async function getAccessToken() {
// 优先: 当前页面直接获取 (store.steampowered.com)
const syncToken = getAccessTokenSync();
if (syncToken) return syncToken;
// 其次: 跨域请求 store.steampowered.com 提取 token
const storeToken = await fetchWebApiTokenFromStore();
if (storeToken) return storeToken;
return null;
}
// ==================== 统一 Steam API 层 ====================
const SteamAPI = {
// 基础请求:使用 API Key
async _req(endpoint, query = '', timeout = 15000) {
const apiKey = storage.getApiKey();
if (!apiKey) throw new Error('No API key');
const url = `https://api.steampowered.com${endpoint}?key=${apiKey}${query ? '&' + query : ''}`;
return requestSteamAPI(url, timeout);
},
// 需要 access_token 的请求(家庭组相关)
async _reqToken(endpoint, query = '', timeout = 15000) {
const token = await getAccessToken();
if (!token) throw new Error('No access token');
const url = `https://api.steampowered.com${endpoint}?access_token=${token}${query ? '&' + query : ''}`;
return requestSteamAPI(url, timeout);
},
// 优先 token,否则 fallback 到 key
async _reqAuto(endpoint, query = '', timeout = 15000) {
const token = await getAccessToken();
if (token) {
const url = `https://api.steampowered.com${endpoint}?access_token=${token}${query ? '&' + query : ''}`;
return requestSteamAPI(url, timeout);
}
return this._req(endpoint, query, timeout);
},
// --- ISteamUser ---
getSummary: (steamids) => SteamAPI._req('/ISteamUser/GetPlayerSummaries/v2/', `steamids=${Array.isArray(steamids) ? steamids.join(',') : steamids}`),
getVAC: (steamids) => SteamAPI._req('/ISteamUser/GetPlayerBans/v1/', `steamids=${Array.isArray(steamids) ? steamids.join(',') : steamids}`),
getFriends: (steamid) => SteamAPI._req('/ISteamUser/GetFriendList/v1/', `steamid=${steamid}&relationship=friend`),
getFriendsLegacy: (steamid, timeout = 12000) => SteamAPI._req('/ISteamUser/GetFriendList/v0001/', `steamid=${steamid}&relationship=friend&format=json`, timeout),
// --- IPlayerService ---
getLevel: (steamid) => SteamAPI._req('/IPlayerService/GetSteamLevel/v1/', `steamid=${steamid}`),
getOwnedGamesLegacy: (steamid, timeout = 25000) => SteamAPI._req('/IPlayerService/GetOwnedGames/v0001/', `steamid=${steamid}&include_appinfo=1&include_played_free_games=1&format=json`, timeout),
getRecentGames: (steamid, timeout = 12000) => SteamAPI._reqAuto('/IPlayerService/GetRecentlyPlayedGames/v1/', `steamid=${steamid}&count=0`, timeout),
getRecentGamesLegacy: (steamid, timeout = 12000) => SteamAPI._req('/IPlayerService/GetRecentlyPlayedGames/v0001/', `steamid=${steamid}&format=json`, timeout),
getPlayerLinkDetails: (steamids) => {
const arr = Array.isArray(steamids) ? steamids : [steamids];
const params = arr.map((id, i) => `steamids[${i}]=${id}`).join('&');
return SteamAPI._reqToken('/IPlayerService/GetPlayerLinkDetails/v1/', params);
},
// --- IFamilyGroupsService ---
getFamily: () => SteamAPI._reqToken('/IFamilyGroupsService/GetFamilyGroupForUser/v1/', 'include_family_group_response=true'),
getFamilyGroup: (familyGroupId) => SteamAPI._reqAuto('/IFamilyGroupsService/GetFamilyGroup/v1/', `family_groupid=${familyGroupId}`),
getSharedLibrary: (familyGroupId) => SteamAPI._reqToken('/IFamilyGroupsService/GetSharedLibraryApps/v1/', `family_groupid=${familyGroupId}&include_own=true&include_excluded=false&include_non_games=false`),
// --- Store API ---
getAppDetails: (appid) => requestSteamAPI(`https://store.steampowered.com/api/appdetails?appids=${appid}&l=schinese`, 10000),
};
async function fetchFamilyInfo() {
let data;
try {
data = await SteamAPI.getFamily();
if (data && data.response && data.response.family_group) {
const resp = data.response;
const fg = resp.family_group;
const members = fg.members || [];
// 获取成员用户名和头像(先用公开头像URL兜底,API成功则覆盖)
const nameMap = {};
const famAvatarMap = {};
members.forEach(m => {
// 默认头像 SVG:蓝色圆形背景 + 白色人物剪影
famAvatarMap[m.steamid] = `data:image/svg+xml,${encodeURIComponent(' ')}`;
});
if (members.length > 0) {
const steamids = members.map(m => m.steamid);
const batchSize = 100;
for (let i = 0; i < steamids.length; i += batchSize) {
const batch = steamids.slice(i, i + batchSize);
try {
const pData = await SteamAPI.getPlayerLinkDetails(batch);
if (pData && pData.response && pData.response.accounts) {
pData.response.accounts.forEach(acc => {
const pd = acc.public_data;
if (pd) {
const sid = pd.steamid || acc.steamid;
if (sid && pd.persona_name) {
nameMap[sid] = pd.persona_name;
}
if (sid && pd.avatar) {
famAvatarMap[sid] = pd.avatar;
}
}
});
}
} catch (e) { logger.silent('家庭成员 PlayerLinkDetails 获取失败', e); }
}
}
const familyInfo = {
family_groupid: resp.family_groupid,
family_name: fg.name || 'Steam Family',
family_member: members.map(m => ({
steamid: m.steamid,
userName: nameMap[m.steamid] || 'ID:' + m.steamid.slice(-4),
avatar: famAvatarMap[m.steamid] || '',
role: m.role,
time_joined: m.time_joined || 0,
cooldown_remaining: 0
})),
steamIdtoName: nameMap,
steamIdtoAvatar: famAvatarMap
};
// 额外调用 GetFamilyGroup 获取更详细数据(cooldown 在 family_group 级别)
try {
const detailData = await SteamAPI.getFamilyGroup(resp.family_groupid);
if (detailData && detailData.response) {
// 响应结构:response 直接包含 members(非 family_group.members)
const detailResp = detailData.response;
const detailMembers = detailResp.members || (detailResp.family_group && detailResp.family_group.members) || [];
const slotCooldown = detailResp.slot_cooldown_remaining_seconds || 0;
const detailMap = {};
detailMembers.forEach(dm => { detailMap[dm.steamid] = dm; });
familyInfo.family_member.forEach(m => {
const dm = detailMap[m.steamid];
if (dm) {
// GetFamilyGroup 可能返回更准确的 time_joined
if (dm.time_joined) m.time_joined = dm.time_joined;
}
});
// 冷却时间在 family_group 级别,对所有空槽位共享
if (slotCooldown > 0) {
familyInfo.family_member.forEach(m => { m.cooldown_remaining = slotCooldown; });
}
}
} catch (e) { logger.warn('获取家庭组详情失败', e); }
return familyInfo;
}
} catch (e) {
logger.error('获取家庭组信息失败', e);
}
// 区分"API调用成功但无家庭组"和"配置错误"
if (data && data.response && !data.response.family_group) {
return 'NO_FAMILY';
}
return null;
}
async function fetchFamilyGameList(familyGroupId) {
try {
const data = await SteamAPI.getSharedLibrary(familyGroupId);
if (data && data.response && data.response.apps) {
const gameList = [];
const gameInfo = {};
data.response.apps.forEach(app => {
if (app.exclude_reason === 0) {
gameList.push(app.appid);
gameInfo[app.appid] = {
name: app.name,
owners: app.owner_steamids || [],
time: app.rt_time_acquired || 0,
icon_hash: app.img_icon_hash || ''
};
}
});
gameList.sort((a, b) => (gameInfo[b].time || 0) - (gameInfo[a].time || 0));
return { GameList: gameList, GameInfo: gameInfo };
}
} catch (e) {
logger.error('获取家庭组游戏列表失败', e);
}
return null;
}
// 带顶部进度条的家庭组刷新
async function refreshFamilyWithProgress() {
const progEl = document.getElementById('sfd-family-progress');
const barEl = document.getElementById('sfd-family-progress-bar');
if (!progEl || !barEl) {
// 降级:无进度条直接刷新
state.family.playActivity = null;
state.family.playActivityLoading = false;
state.family.dynamicPage = 1;
loadFamilyData(state.family.popupSteamid, true).then(ok => { if (ok && familyPopupEl && familyPopupEl.classList.contains('sfd-show')) renderFamilyPopup(); });
return;
}
const setBar = (pct, cls) => {
barEl.className = 'sfd-family-progress-bar' + (cls ? ' ' + cls : '');
barEl.style.width = pct + '%';
};
// 重置 + 激活
progEl.classList.add('active');
setBar(8, '');
// 重置状态
state.family.playActivity = null;
_stressGamesCache = null;
_stressLoadFailed = false;
_stressStoreOwnedCache.clear();
_stressDetectDBLoaded = false; // 重置后下次检测会从 IndexedDB 重新加载缓存
_personalAppIdSet = null;
_stressForceNetwork = true;
state.family.playActivityLoading = false;
state.family.dynamicPage = 1;
// 阶段1:获取鉴权 token
setBar(20, '');
const authToken = await getAccessToken();
if (!authToken) { setBar(100, 'err'); setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 1500); throw new Error('No access token'); }
// 阶段2:获取家庭信息
setBar(45, '');
let familyInfo;
try {
familyInfo = await fetchFamilyInfo();
} catch (e) { familyInfo = null; }
if (!familyInfo || familyInfo === 'NO_FAMILY') {
setBar(100, 'err'); setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 1500);
throw new Error('No family info');
}
// 阶段3:获取共享游戏库
setBar(75, '');
let gameList;
try {
gameList = await fetchFamilyGameList(familyInfo.family_groupid);
} catch (e) { gameList = null; }
if (gameList) {
state.family.info = familyInfo;
state.family.gameList = gameList;
storage.setFamilyCache({ steamid: state.family.popupSteamid, familyInfo, familyGameList: gameList, timestamp: Date.now() });
setBar(100, 'done');
setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 800);
renderFamilyPopup();
} 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') return 'NO_FAMILY';
if (!familyInfo) return false;
state.family.info = familyInfo;
const gameList = await fetchFamilyGameList(familyInfo.family_groupid);
if (gameList) {
state.family.gameList = gameList;
// 缓存
storage.setFamilyCache({
steamid,
familyInfo,
familyGameList: gameList,
timestamp: Date.now()
});
return true;
}
} catch (e) {
logger.error('加载家庭组数据失败', e);
}
return false;
}
// FIX-2026-07-07: 1.0.14 新窗口打开 Steam 商店页面
function openStorePage(appid) {
if (!appid) return;
const url = `https://store.steampowered.com/app/${appid}`;
window.open(url, '_blank', 'noopener,noreferrer');
}
// ==================== 家庭组游玩动态 ====================
async function fetchRecentlyPlayedGames(steamid) {
try {
const data = await SteamAPI.getRecentGames(steamid);
if (data && data.response) {
return {
total_count: data.response.total_count || 0,
games: (data.response.games || []).map(g => ({
appid: g.appid,
name: g.name,
playtime_2weeks: g.playtime_2weeks || 0,
playtime_forever: g.playtime_forever || 0,
img_icon_url: g.img_icon_url || ''
}))
};
}
} catch (e) {
logger.warn('获取最近游玩失败', e);
}
return null;
}
async function loadFamilyPlayActivity(steamid, forceRefresh = false) {
// 检查缓存
if (!forceRefresh) {
const cached = storage.getFamilyPlayActivityCache();
if (cached && cached.steamid === steamid && Date.now() - cached.timestamp < FAMILY_PLAY_TTL) {
state.family.playActivity = cached.data;
return true;
}
}
state.family.playActivityLoading = true;
const fi = state.family.info;
if (!fi || !fi.family_member || fi.family_member.length === 0) {
state.family.playActivityLoading = false;
return false;
}
const authToken = await getAccessToken();
if (!authToken) {
state.family.playActivityLoading = false;
return false;
}
// 确保有 ownProfile:优先内存,其次缓存,最后实时获取(和好友列表面板一样的数据来源)
if (!state.friends.ownProfile) {
const cachedOwn = storage.getCachedOwn();
if (cachedOwn && cachedOwn.steamid === String(steamid)) {
state.friends.ownProfile = cachedOwn;
} else {
try {
const apiKey = storage.getApiKey();
if (apiKey) {
const sumData = await SteamAPI.getSummary(steamid);
if (sumData.response && sumData.response.players && sumData.response.players.length > 0) {
const s = sumData.response.players[0];
const countryInfo = getCountryInfo(s.loccountrycode);
const levelCache = storage.getLevelCache();
state.friends.ownProfile = {
steamid: steamid, personaname: s.personaname,
avatar: s.avatarmedium || s.avatar,
avatarfull: s.avatarfull || s.avatar || '',
personastate: s.personastate !== undefined ? s.personastate : 0,
gameextrainfo: s.gameextrainfo || '',
loccountrycode: s.loccountrycode || '',
country_name: countryInfo ? countryInfo.name : '',
country_flag: countryInfo ? countryInfo.flag : '',
level: levelCache[steamid] || 0,
profileurl: s.profileurl || `https://steamcommunity.com/profiles/${steamid}/`,
timecreated: s.timecreated || 0
};
storage.setCachedOwn(state.friends.ownProfile);
}
}
} catch (e) { logger.warn('获取游玩动态自身资料失败', e); }
}
}
try {
const result = {};
const members = fi.family_member;
// 批量获取每个成员最近游玩的游戏
const promises = members.map(async (member) => {
try {
const data = await fetchRecentlyPlayedGames(member.steamid);
if (data) {
result[member.steamid] = {
games: data.games,
memberName: member.userName,
total_count: data.total_count
};
}
} catch (e) {
logger.warn('获取游玩动态失败: ' + member.steamid, e);
}
});
await Promise.all(promises);
state.family.playActivity = result;
state.family.playActivityLoading = false;
// 缓存结果
storage.setFamilyPlayActivityCache({
steamid,
data: result,
timestamp: Date.now()
});
return true;
} catch (e) {
logger.error('加载家庭组游玩动态失败', e);
state.family.playActivityLoading = false;
return false;
}
}
// ==================== 家庭组价值估算 ====================
// 从 Steam 页面 DOM 读取真实好友总数(.friends_count 的 value 属性)
function getSteamFriendsCount() {
const el = document.querySelector('.profile_friends .friends_count');
if (el) {
const val = parseInt(el.getAttribute('value') || el.textContent || '0', 10);
if (val > 0) return val;
}
// 回退:使用 g_rgCounts
try {
if (typeof g_rgCounts !== 'undefined' && g_rgCounts.cFriends) return g_rgCounts.cFriends;
} catch (e) { logger.silent('g_rgCounts 读取失败', e); }
return state.friends.data ? state.friends.data.length : 0;
}
// 从 Steam 页面 DOM 读取好友上限
function getSteamFriendsLimit() {
const el = document.querySelector('.profile_friends .friends_limit');
if (el) {
const val = parseInt(el.textContent || '0', 10);
if (val > 0) return val;
}
// 回退:按等级估算
const lvl = (state.friends.ownProfile && state.friends.ownProfile.level) || 0;
return 300 + lvl * 5;
}
// ==================== 家庭组浮窗 ====================
let familyPopupEl = null;
function showFamilyPopup(steamid, event) {
state.family.popupSteamid = steamid;
state.family.activeTab = 'chart';
state.family.dynamicPage = 1;
state.family.playActivity = null;
state.family.playActivityLoading = false;
if (!familyPopupEl) {
const modal = createModal({
id: 'sfd-family-popup',
cls: 'sfd-family-popup',
persistent: true,
titleHtml: `${ICONS.family} ${locale === 'zh-CN' ? '家庭组' : 'Family Group'}`,
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-compare-popup', '#sfd-pl-popup', '#sfd-contrib-overlay'],
scrollSelector: '.sfd-family-content, .sfd-family-game-list'
});
familyPopupEl = modal.overlay;
}
familyPopupEl.classList.add('sfd-show');
const content = familyPopupEl.querySelector('#sfd-family-content');
// 有缓存直接渲染(不显示 loading),冷却时间外后台静默刷新
const cached = storage.getFamilyCache();
if (cached && cached.steamid === steamid && cached.familyInfo && cached.familyGameList) {
state.family.info = cached.familyInfo;
state.family.gameList = cached.familyGameList;
renderFamilyPopup();
// 按缓存时间计时:距缓存写入超过12小时才后台刷新
const cacheTime = cached.timestamp || 0;
if (Date.now() - cacheTime > FAMILY_REFRESH_COOLDOWN) {
refreshFamilyWithProgress().catch(() => {});
}
} else {
// 无缓存:显示 loading 并走进度条
content.replaceChildren(h('div', { class: 'sfd-family-loading', html: `${ICONS.spinner}${locale === 'zh-CN' ? '正在加载家庭组数据…' : 'Loading family data…'} ` }));
refreshFamilyWithProgress().catch(() => {
content.innerHTML = '';
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: locale === 'zh-CN' ? '无法获取家庭组数据' : 'Failed to load family data' }),
h('div', { style: { fontSize: '13px', color: '#c7d5e0', marginBottom: '12px' }, text: locale === 'zh-CN' ? '请访问 Steam 商店页面并登录' : 'Please visit Steam Store and log in' }),
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: locale === 'zh-CN' ? '前往 Steam 商店' : 'Go to Steam Store',
onClick: (e) => {
e.preventDefault();
window.open('https://store.steampowered.com', '_blank');
// 点击后自动刷新数据
content.replaceChildren(h('div', { class: 'sfd-family-loading', html: `${ICONS.spinner}${locale === 'zh-CN' ? '正在重新加载家庭组数据…' : 'Reloading family data…'} ` }));
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;
// 更新数据时间显示
const updatedEl = document.getElementById('sfd-family-updated');
if (updatedEl) {
const cached = storage.getFamilyCache();
if (cached && cached.timestamp) {
updatedEl.textContent = (locale === 'zh-CN' ? '更新于 ' : 'Updated ') + new Date(cached.timestamp).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} else {
updatedEl.textContent = '';
}
}
// 保存焦点元素信息(防止 re-render 后搜索框失焦)
const activeEl = document.activeElement;
const focusInfo = activeEl && activeEl.tagName === 'INPUT' && activeEl.classList.contains('sfd-input')
? { sel: '.sfd-input', value: activeEl.value, start: activeEl.selectionStart, end: activeEl.selectionEnd }
: null;
const fi = state.family.info;
const gl = state.family.gameList;
const totalGames = gl.GameList.length;
const memberCount = fi.family_member.length;
const avgGames = totalGames > 0 && memberCount > 0 ? Math.round(totalGames / memberCount) : 0;
let singleOwnerCount = 0;
let recent30Count = 0;
const nowSec = Date.now() / 1000;
const cutoff30 = nowSec - 90 * SECONDS_PER_DAY;
for (let key in gl.GameInfo) {
const info = gl.GameInfo[key];
if (info.owners.length === 1) singleOwnerCount++;
if (info.time && info.time >= cutoff30) recent30Count++;
}
const content = familyPopupEl.querySelector('#sfd-family-content');
const headerH3 = familyPopupEl.querySelector('.sfd-family-header h3');
if (headerH3) {
headerH3.innerHTML = `${ICONS.family} ${fi.family_name} ${memberCount} ${locale === 'zh-CN' ? '名成员' : 'members'} · ${totalGames} ${locale === 'zh-CN' ? '个共享游戏' : 'shared games'} `;
}
const isZh = locale === 'zh-CN';
// 加入时间卡片(原版样式:横向布局,心形图标 + 日期 + 天数)
let joinCardHtml = '';
const mySteamId = (state.friends.ownProfile && state.friends.ownProfile.steamid) || state.family.popupSteamid;
const selfMember = fi.family_member.find(m => String(m.steamid) === String(mySteamId));
if (selfMember && selfMember.time_joined > 0) {
const jd = new Date(selfMember.time_joined * 1000);
const joinDateStr = `${jd.getFullYear()}/${String(jd.getMonth() + 1).padStart(2, '0')}/${String(jd.getDate()).padStart(2, '0')}`;
const joinDays = Math.floor((nowSec - selfMember.time_joined) / SECONDS_PER_DAY);
joinCardHtml = `
${ICONS.heart}
${isZh ? '我已加入家庭 ' : 'Joined family '}${joinDays} ${isZh ? ' 天 ' : ' days '}(${joinDateStr})
${isZh ? '点击查看贡献 →' : 'Contributions →'}
`;
}
// joinCard 移到 statsGrid 后面
const statsGrid = h('div', { class: 'sfd-family-stats-grid' }, [
createMetricCard({ value: String(totalGames), label: isZh ? '共享游戏总数' : 'Total Shared', accent: '#06cfbe', layout: 'label' }),
createMetricCard({ value: String(singleOwnerCount), label: isZh ? '独占贡献游戏' : 'Exclusive', accent: '#ff9f43', layout: 'label' }),
createMetricCard({ value: String(avgGames), label: isZh ? '人均贡献数' : 'Avg/Member', accent: '#54a0ff', layout: 'label' }),
createMetricCard({ value: String(recent30Count), label: isZh ? '近90日新增' : 'New (90d)', accent: '#34d399', layout: 'label' })
]);
const tabs = h('div', { class: 'sfd-family-tabs' }, [
h('button', { class: `sfd-family-tab ${state.family.activeTab === 'chart' ? 'active' : ''}`, html: ICONS.barChart, onClick: () => { state.family.activeTab = 'chart'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '贡献分布' : 'Distribution' })]),
h('button', { class: `sfd-family-tab ${state.family.activeTab === 'growth' ? 'active' : ''}`, html: ICONS.trending, onClick: () => { state.family.activeTab = 'growth'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '增长曲线' : 'Growth' })]),
h('button', { class: `sfd-family-tab ${state.family.activeTab === 'dynamic' ? 'active' : ''}`, html: ICONS.activity, onClick: () => { state.family.activeTab = 'dynamic'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '入库动态' : 'Activity' })]),
h('button', { class: `sfd-family-tab ${state.family.activeTab === 'play' ? 'active' : ''}`, html: ICONS.game, onClick: () => { state.family.activeTab = 'play'; renderFamilyPopup(); } }, [h('span', { text: t('familyPlayTitle') })]),
h('button', { class: `sfd-family-tab ${state.family.activeTab === 'heatmap' ? 'active' : ''}`, html: ICONS.pie, onClick: () => { state.family.activeTab = 'heatmap'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '月度占比' : 'Monthly' })]),
h('button', { class: `sfd-family-tab ${state.family.activeTab === 'insights' ? 'active' : ''}`, html: ICONS.insights, onClick: () => { state.family.activeTab = 'insights'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '成员洞察' : 'Insights' })]),
h('button', { class: `sfd-family-tab ${state.family.activeTab === 'wishlist' ? 'active' : ''}`, html: ICONS.heart, onClick: () => { state.family.activeTab = 'wishlist'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '家庭愿望单' : 'Wishlist' })]),
h('button', { class: `sfd-family-tab ${state.family.activeTab === 'stress' ? 'active' : ''}`, html: ICONS.flame, onClick: () => { state.family.activeTab = 'stress'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '精品游戏' : 'Top Games' })]),
h('button', { class: `sfd-family-tab ${state.family.activeTab === 'series' ? 'active' : ''}`, html: ICONS.grid, onClick: () => { state.family.activeTab = 'series'; renderFamilyPopup(); } }, [h('span', { text: locale === 'zh-CN' ? '系列游戏' : 'Series' })])
]);
let tabContent;
if (state.family.activeTab === 'chart') {
tabContent = renderFamilyChartTab();
} else if (state.family.activeTab === 'dynamic') {
tabContent = renderFamilyDynamicTab();
} else if (state.family.activeTab === 'play') {
tabContent = renderFamilyPlayActivityTab();
} else if (state.family.activeTab === 'heatmap') {
tabContent = renderFamilyHeatmapTab();
} else if (state.family.activeTab === 'insights') {
tabContent = renderMemberInsightsTab();
} else if (state.family.activeTab === 'wishlist') {
tabContent = renderFamilyWishlistTab();
} else if (state.family.activeTab === 'stress') {
tabContent = renderStressFamilyTab();
} else if (state.family.activeTab === 'series') {
tabContent = renderFamilySeriesTab();
} else {
tabContent = renderFamilyGrowthTab();
}
content.innerHTML = '';
content.appendChild(tabs);
// 贡献分布标签页的统计卡片(和下方图表等宽:4个卡片 flex:1 + joinCard 310px)
const statsWrap = h('div', { class: 'sfd-family-stats-wrap', style: { display: state.family.activeTab === 'chart' ? 'flex' : 'none', gap: '14px', alignItems: 'stretch', marginBottom: '10px' } });
statsGrid.style.flex = '1';
statsGrid.style.minWidth = '0';
statsWrap.appendChild(statsGrid);
if (joinCardHtml) {
const jc = h('div', { style: { width: '310px', flexShrink: '0' } });
jc.innerHTML = joinCardHtml;
statsWrap.appendChild(jc.firstChild || jc);
}
content.appendChild(statsWrap);
// 增长曲线标签页的统计卡片(和贡献分布完全一样的5卡片布局)
if (state.family.activeTab === 'growth' && gl && gl.GameInfo) {
const growthGames = Object.entries(gl.GameInfo)
.map(([appid, info]) => ({ appid: Number(appid), time: info.time }))
.filter(g => g.time > 0)
.sort((a, b) => a.time - b.time);
if (growthGames.length > 0) {
const firstDate = new Date(growthGames[0].time * 1000);
const lastDate = new Date(growthGames[growthGames.length - 1].time * 1000);
const daysSpan = Math.max(1, Math.ceil((lastDate - firstDate) / 86400000));
const monthsSpan = daysSpan / 30;
const avgPerDay = (growthGames.length / daysSpan).toFixed(1);
const avgPerMonth = (growthGames.length / monthsSpan).toFixed(1);
const cutoff30 = Date.now() / 1000 - 30 * SECONDS_PER_DAY;
const cutoff90 = Date.now() / 1000 - 90 * SECONDS_PER_DAY;
let recent30 = 0, recent90 = 0;
for (let key in gl.GameInfo) {
const t = gl.GameInfo[key].time;
if (t && t >= cutoff30) recent30++;
if (t && t >= cutoff90) recent90++;
}
const growthGrid = h('div', { class: 'sfd-family-stats-grid', style: { flex: '1', minWidth: '0' } }, [
createMetricCard({ value: String(avgPerDay), label: isZh ? '日均增长' : 'Avg/Day', accent: '#f59e0b', layout: 'label' }),
createMetricCard({ value: String(avgPerMonth), label: isZh ? '月均增长' : 'Avg/Month', accent: '#8b5cf6', layout: 'label' }),
createMetricCard({ value: String(recent30), label: isZh ? '30日新增' : 'New (30d)', accent: '#10b981', layout: 'label' }),
createMetricCard({ value: String(recent90), label: isZh ? '90日新增' : 'New (90d)', accent: '#3b82f6', layout: 'label' }),
]);
const growthWrap = h('div', { class: 'sfd-family-stats-wrap', style: { display: 'flex', gap: '14px', alignItems: 'stretch', marginBottom: '10px' } });
growthWrap.appendChild(growthGrid);
// 第5个卡片:90天我贡献了多少游戏(仅计入首次入库成员)
let my90Contrib = 0;
for (let key in gl.GameInfo) {
const info = gl.GameInfo[key];
if (info.time && info.time >= cutoff90 && (info.owners || [])[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: (isZh ? '90天我贡献了 ' : '90d I added ') + '' + my90Contrib + ' ' + (isZh ? ' 个游戏' : ' games') }),
]);
growthWrap.appendChild(my90Card);
content.appendChild(growthWrap);
}
}
const tabPanel = h('div', { class: 'sfd-family-tab-panel active' + (state.family.activeTab === 'dynamic' ? ' sfd-family-tab-panel-noscroll' : ' sfd-family-scroll') });
tabPanel.appendChild(tabContent);
content.appendChild(tabPanel);
if (state.family.activeTab === 'chart') {
setTimeout(() => buildStackedBarSvg(state.family.info, state.family.gameList), 50);
// 预加载精品游戏数据(供饼图切换使用)
if (!_stressGamesCache && !_stressLoadFailed) {
try {
if (typeof GM_getResourceText === 'function') {
const jsonText = GM_getResourceText('stressData');
if (jsonText) { _stressGamesCache = JSON.parse(jsonText); }
}
} catch (e) { logger.silent('精品游戏 @resource JSON 解析失败', e); }
}
}
// 游玩动态Tab:无数据时由 renderFamilyPlayActivityTab 自行触发加载
// (已移除冗余的异步加载触发,避免重复请求)
// 贡献概览卡片点击
const joinCardEl = content.querySelector('#sfd-join-card');
if (joinCardEl) {
joinCardEl.addEventListener('click', (e) => {
e.stopPropagation();
showContributionOverview();
});
}
// 恢复焦点
if (focusInfo) {
const newEl = familyPopupEl.querySelector(focusInfo.sel);
if (newEl) {
newEl.focus();
try { newEl.setSelectionRange(focusInfo.start, focusInfo.end); } catch (e) {}
}
}
}
// ==================== 我的贡献概览弹窗 ====================
function showContributionOverview() {
const fi = state.family.info;
const gl = state.family.gameList;
if (!fi || !gl) return;
const isZh = locale === 'zh-CN';
const mySteamId = (state.friends.ownProfile && state.friends.ownProfile.steamid) || state.family.popupSteamid;
const mySid = String(mySteamId);
const gi = gl.GameInfo;
const gameList = gl.GameList;
const members = fi.family_member || [];
const nowSec = Date.now() / 1000;
const D30 = 30 * SECONDS_PER_DAY;
const D90 = 90 * SECONDS_PER_DAY;
const CONTRIB_SECONDS = CONTRIB_DAYS * SECONDS_PER_DAY;
// 计算我的贡献数据(仅计入首次入库成员,后续重复购买不计数)
const myGames = [];
let myExclusive = 0, myShared = 0, myRecent30 = 0, myRecent90 = 0;
for (let i = 0; i < gameList.length; i++) {
const aid = gameList[i];
const info = gi[aid];
if (!info || !info.owners || (info.owners || [])[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} ${isZh ? '我的贡献概览' : 'My Contributions'}`,
width: '900px', height: '640px', zIndex: 505,
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: isZh ? '贡献游戏总数' : 'Total Contrib', value: myTotal, color: '#06cfbe' },
{ label: isZh ? '独占贡献' : 'Exclusive', value: myExclusive, color: '#ff9f43' },
{ label: isZh ? '共享贡献' : 'Shared', value: myShared, color: '#a78bfa' },
{ label: isZh ? '近90日新增' : 'New (90d)', value: myRecent90, color: '#34d399' },
{ label: isZh ? '占家庭库' : 'Of Library', value: `${myPct}%`, color: '#06cfbe' },
{ label: isZh ? '贡献排名' : 'Rank', value: isZh ? `第${myRank || '-'}名` : `#${myRank || '-'}`, color: '#66c0f4' },
{ label: isZh ? '独占率' : 'Exclusive', value: `${exclusivePct}%`, color: '#ff9f43' },
{ label: isZh ? '共享率' : 'Shared', value: `${sharedPct}%`, color: '#a78bfa' },
{ label: isZh ? '90日增量' : '90d Growth', value: `${growthPct}%`, color: '#34d399' }
];
const kpiRow = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(9,1fr)', gap: '10px', flexShrink: '0' } });
kpiData.forEach(k => {
kpiRow.appendChild(createMetricCard({ value: String(k.value), label: k.label, accent: k.color, layout: 'compact' }));
});
body.appendChild(kpiRow);
// ---- 近期贡献 + 最近游玩(左右两个卡片) ----
const dualRow = h('div', { style: { display: 'flex', gap: '12px', flex: '1', minHeight: '0' } });
// 左卡片:近期贡献
const leftCard = h('div', { class: 'sfd-family-stat-card', style: { flex: '1 1 0%', minWidth: '0', display: 'flex', flexDirection: 'column', padding: '12px', minHeight: '0', overflow: 'hidden' } });
leftCard.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', fontWeight: '600', color: '#c7d5e0', marginBottom: '8px', flexShrink: '0' }, html: `${ICONS.barChart}${isZh ? '近期贡献' : 'Recent Contributions'} (${myRecentGames.length}) ` }));
const leftScroll = h('div', { style: { flex: '1', minHeight: '0', overflowY: 'auto', paddingRight: '2px' } });
const grid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: '8px', alignContent: 'start' } });
if (myRecentGames.length === 0) {
grid.appendChild(h('div', { style: { textAlign: 'center', fontSize: '11px', color: '#64748b', padding: '20px' }, text: isZh ? `近${CONTRIB_DAYS}天暂无贡献` : `No contributions in ${CONTRIB_DAYS} days` }));
} else {
myRecentGames.forEach(g => {
const nameEl = h('a', {
class: 'sfd-pl-recent-name',
href: `https://store.steampowered.com/app/${g.appid}`,
target: '_blank',
text: g.name
});
loadGameZhName(nameEl, g.appid, g.name);
const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(g.appid) });
dashLoadCapsule(capImg, g.appid);
grid.appendChild(h('div', { class: 'sfd-pl-recent-card' }, [
capImg,
h('div', { class: 'sfd-pl-recent-info' }, [
nameEl,
h('div', { class: 'sfd-pl-recent-meta' }, [
h('span', { text: g.time > 0 ? DateUtils.format(g.time) : (isZh ? '未知' : 'Unknown') }),
g.isExclusive ? h('span', { style: { color: '#ff9f43', fontWeight: '600' }, text: isZh ? '独占' : 'Exclusive' }) : null
])
])
]));
});
}
leftScroll.appendChild(grid);
leftCard.appendChild(leftScroll);
dualRow.appendChild(leftCard);
// 右卡片:最近游玩
const rightCard = h('div', { class: 'sfd-family-stat-card', style: { flex: '1 1 0%', minWidth: '0', display: 'flex', flexDirection: 'column', padding: '12px', minHeight: '0', overflow: 'hidden' } });
rightCard.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', fontWeight: '600', color: '#c7d5e0', marginBottom: '8px', flexShrink: '0' }, html: `${ICONS.game}${isZh ? '最近游玩' : 'Recently Played'} ` }));
const rightScroll = h('div', { style: { flex: '1', minHeight: '0', overflowY: 'auto', paddingRight: '2px' } });
const playScroll = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: '6px', alignContent: 'start' } });
let playGames = [];
const paData = state.family.playActivity;
if (paData && paData[mySid]) {
playGames = (paData[mySid].games || []).slice().sort((a, b) => (b.playtime_2weeks || 0) - (a.playtime_2weeks || 0) || (b.playtime_forever || 0) - (a.playtime_forever || 0));
}
if (playGames.length > 0) {
playGames.forEach(game => {
playScroll.appendChild(h('div', { class: 'sfd-family-play-game-item' }, [
(() => { const img = h('img', { class: 'sfd-family-play-game-icon', loading: 'lazy' }); dashLoadCapsule(img, game.appid); return img; })(),
h('div', { class: 'sfd-family-play-game-info' }, [
(() => { const el = h('a', { class: 'sfd-family-play-game-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(),
h('div', { class: 'sfd-family-play-game-meta' }, [
h('span', { class: 'sfd-family-play-game-playtime', text: `${DateUtils.duration(game.playtime_2weeks)}` }),
h('span', { class: 'sfd-family-play-game-playtime-total', text: `/ ${DateUtils.duration(game.playtime_forever)}` })
])
])
]));
});
} else {
playScroll.appendChild(h('div', { style: { textAlign: 'center', fontSize: '11px', color: '#64748b', padding: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: '8px' } }, [
h('div', { text: isZh ? '游玩数据未加载' : 'Play data not loaded' }),
h('button', {
class: 'sfd-btn sfd-btn-sm sfd-btn-ghost',
text: isZh ? '点击加载' : 'Load Now',
onClick: async () => {
const btn = playScroll.querySelector('button');
if (btn) { btn.disabled = true; btn.textContent = isZh ? '加载中…' : 'Loading…'; }
const ok = await loadFamilyPlayActivity(state.family.popupSteamid);
if (ok) {
closeContrib();
showContributionOverview();
} else {
if (btn) { btn.disabled = false; btn.textContent = isZh ? '加载失败,重试' : 'Failed, Retry'; }
}
}
})
]));
}
rightScroll.appendChild(playScroll);
rightCard.appendChild(rightScroll);
dualRow.appendChild(rightCard);
body.appendChild(dualRow);
}
function renderFamilyChartTab() {
const fi = state.family.info;
const gl = state.family.gameList;
const isZh = locale === 'zh-CN';
const members = (fi && fi.family_member) ? fi.family_member : [];
// 精品游戏贡献占比计算(排除未发售,包含未购买)
const now = new Date();
const stressGames = getStressAllGames().filter(g => parseStressDate(g.date) < now);
const stressAppIds = stressGames.length > 0 ? new Set(stressGames.map(g => Number(g.appid))) : new Set();
// 全部游戏贡献占比(仅计入首次入库成员,后续重复购买不计数)
const allMemberContrib = members.map(m => {
let count = 0;
for (let key in gl.GameInfo) { if ((gl.GameInfo[key].owners || [])[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: isZh ? '未购买' : 'Unowned', count: unowned, sid: 'unowned' });
}
const stressTotal = stressContrib.reduce((s, m) => s + m.count, 0) || 1;
// 构建饼图 SVG
function buildPieSvg(data, total) {
let cx = 100, cy = 100, r = 80, svg = '', cumAngle = -Math.PI / 2;
data.forEach((m, i) => {
const c = m.sid === 'unowned' ? '#64748b' : CHART_COLORS[i % CHART_COLORS.length];
const angle = (m.count / total) * 2 * Math.PI;
const x1 = cx + r * Math.cos(cumAngle), y1 = cy + r * Math.sin(cumAngle);
const x2 = cx + r * Math.cos(cumAngle + angle), y2 = cy + r * Math.sin(cumAngle + angle);
const largeArc = angle > Math.PI ? 1 : 0;
svg += `${m.name}: ${m.count} (${(m.count / total * 100).toFixed(1)}%) `;
cumAngle += angle;
});
return svg;
}
function buildPieCenter(totalGames, label) {
return `${totalGames} ${label} `;
}
function buildPieLegend(data, total, max) {
return data.slice(0, max).map((m, i) => {
const c = m.sid === 'unowned' ? '#64748b' : CHART_COLORS[i % CHART_COLORS.length];
return ` ${m.name} ${(m.count / total * 100).toFixed(0)}% `;
}).join('');
}
const pieSvgAll = buildPieSvg(allMemberContrib, allTotal) + buildPieCenter(Object.keys(gl.GameInfo).length, isZh ? '总入库' : 'Total');
const pieLegendAll = buildPieLegend(allMemberContrib, allTotal, 6);
const pieSvgStress = buildPieSvg(stressContrib, stressTotal) + buildPieCenter(stressGames.length, isZh ? '精品总数' : 'Top Games');
const pieLegendStress = buildPieLegend(stressContrib, stressTotal, 7);
// 近6月入库增量计算(按成员堆叠柱状图,仅计入首次入库成员)
const memberOrder = allMemberContrib.map(m => m.sid);
const memberNameMap = {};
members.forEach(m => { memberNameMap[m.steamid] = m.userName; });
const monthlyMap2 = new Map();
for (let key in gl.GameInfo) {
const info = gl.GameInfo[key];
if (!info.time || info.time <= 0) continue;
const d2 = new Date(info.time * 1000);
const mk = `${d2.getFullYear()}-${String(d2.getMonth() + 1).padStart(2, '0')}`;
if (!monthlyMap2.has(mk)) monthlyMap2.set(mk, {});
const entry = monthlyMap2.get(mk);
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)));
let barGrid2 = '';
for (let i = 0; i <= 2; i++) {
const v = Math.round(maxRecent6 / 2 * i), y = bT2 + bH2 - (bH2 * i / 2);
barGrid2 += `${v} `;
}
const barW2 = recent6.length > 0 ? Math.min(32, bW2 / recent6.length * 0.65) : 0;
const barGap2 = recent6.length > 1 ? (bW2 - barW2 * recent6.length) / (recent6.length - 1) : 0;
let bars2 = '';
const fmtM2 = key => { const [_, m] = key.split('-'); return parseInt(m) + (isZh ? '月' : ''); };
recent6.forEach(([key, entry], i) => {
const x = bL2 + i * (barW2 + barGap2);
const total = Object.values(entry).reduce((s, v) => s + v, 0);
let curY = bT2 + bH2;
let monthBars = '';
memberOrder.forEach((sid, ci) => {
const count = entry[sid] || 0;
if (count === 0) return;
const h = (count / maxRecent6) * bH2;
const c = CHART_COLORS[ci % CHART_COLORS.length];
curY -= h;
monthBars += `${memberNameMap[sid] || sid}: ${count} `;
});
monthBars += `${total > 0 ? total : ''} `;
monthBars += `${fmtM2(key)} `;
bars2 += ` ${monthBars} `;
});
const wrap = h('div', { class: 'sfd-family-chart-wrap', id: 'sfd-family-chart-wrap', style: { flex: '1', minWidth: '0' } }, [
h('div', { id: 'sfd-family-chart-left', style: { flex: '1', display: 'flex', flexDirection: 'column', minWidth: '0' } }, [
h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px', paddingBottom: '6px', borderBottom: '1px solid rgba(102,192,244,0.15)' } }, [
h('span', { id: 'sfd-family-chart-title', style: { fontSize: '14px', fontWeight: '700', color: '#c7d5e0', flex: '1', textAlign: 'center' }, text: state.family.chartShowDelisted ? (isZh ? '绝版贡献分布图' : 'Delisted Contribution') : (isZh ? '家庭库贡献分布图' : 'Family Library Contribution') }),
(() => {
const showDelisted = state.family.chartShowDelisted;
const btn = h('button', {
id: 'sfd-family-chart-toggle',
class: `sfd-btn sfd-btn-sm ${showDelisted ? 'sfd-btn-primary' : 'sfd-btn-ghost'}`,
style: { flexShrink: '0', padding: '4px 10px', fontSize: '11px', height: '26px' },
text: showDelisted ? (isZh ? '显示全部' : 'Show All') : (isZh ? '显示绝版' : 'Show Delisted')
});
btn.addEventListener('click', async () => {
state.family.chartShowDelisted = !state.family.chartShowDelisted;
const delisted = state.family.chartShowDelisted;
// 更新标题和按钮文字
const titleEl = document.getElementById('sfd-family-chart-title');
if (titleEl) titleEl.textContent = delisted ? (isZh ? '绝版贡献分布图' : 'Delisted Contribution') : (isZh ? '家庭库贡献分布图' : 'Family Library Contribution');
btn.textContent = delisted ? (isZh ? '显示全部' : 'Show All') : (isZh ? '显示绝版' : 'Show Delisted');
btn.className = `sfd-btn sfd-btn-sm ${delisted ? 'sfd-btn-primary' : 'sfd-btn-ghost'}`;
// 切换到绝版模式时需要加载绝版数据库
if (delisted) {
const chartContainer = document.getElementById('sfd-family-chart-left');
if (chartContainer) {
// 清除旧图表,显示加载状态
chartContainer.querySelectorAll('#sfd-family-svg, .sfd-family-legend-wrap, #sfd-family-chart-empty, #sfd-family-chart-loading').forEach(el => el.remove());
chartContainer.insertAdjacentHTML('beforeend', `${ICONS.spinner} ${isZh ? '正在加载绝版数据…' : 'Loading delisted data…'}
`);
}
await loadDelistedDB();
const loadingEl = document.getElementById('sfd-family-chart-loading');
if (loadingEl) loadingEl.remove();
} else {
// 切换回全部模式时清除可能的空状态
const emptyEl = document.getElementById('sfd-family-chart-empty');
if (emptyEl) emptyEl.remove();
}
buildStackedBarSvg(state.family.info, state.family.gameList);
});
return btn;
})()
]),
h('svg', { id: 'sfd-family-svg', style: { width: '100%', flex: '1', display: 'block' } })
])
]);
// rightPanel 独立为两个卡片,和贡献分布图并列
const outerWrap = h('div', { style: { display: 'flex', gap: '14px' } }, [
wrap,
h('div', { style: { width: '310px', flexShrink: '0', display: 'flex', flexDirection: 'column', gap: '10px' } }, [
(() => {
let pieMode = 'all'; // 'all' | 'stress'
const pieCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '10px', flex: '1', display: 'flex', flexDirection: 'column' } });
const pieTitleWrap = h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' } });
const pieTitle = h('div', { style: { fontSize: '13px', fontWeight: '600', color: '#c7d5e0' }, text: isZh ? '成员贡献占比' : 'Contribution Share' });
pieTitleWrap.appendChild(pieTitle);
const toggleBtn = h('button', {
class: 'sfd-btn sfd-btn-sm sfd-btn-ghost',
style: { fontSize: '10px', padding: '2px 8px', height: '22px', lineHeight: '1' },
text: isZh ? '精品' : 'Top',
onClick: () => {
pieMode = pieMode === 'all' ? 'stress' : 'all';
const svgContent = pieMode === 'all' ? pieSvgAll : pieSvgStress;
pieSvgWrap.innerHTML = `${svgContent} `;
pieLegendWrap.innerHTML = `${pieMode === 'all' ? pieLegendAll : pieLegendStress}
`;
toggleBtn.textContent = pieMode === 'all' ? (isZh ? '精品' : 'Top') : (isZh ? '全部' : 'All');
pieTitle.textContent = pieMode === 'all' ? (isZh ? '成员贡献占比' : 'Contribution Share') : (isZh ? '精品游戏贡献占比' : 'Top Games Share');
}
});
pieTitleWrap.appendChild(toggleBtn);
pieCard.appendChild(pieTitleWrap);
const pieSvgWrap = h('div', {});
pieSvgWrap.innerHTML = `${pieSvgAll} `;
pieCard.appendChild(pieSvgWrap);
const pieLegendWrap = h('div', {});
pieLegendWrap.innerHTML = `${pieLegendAll}
`;
pieCard.appendChild(pieLegendWrap);
return pieCard;
})(),
h('div', { class: 'sfd-family-stat-card', style: { padding: '10px', flex: '1', display: 'flex', flexDirection: 'column' } }, [
h('div', { style: { fontSize: '13px', fontWeight: '600', color: '#c7d5e0', marginBottom: '6px' }, text: isZh ? '近6月入库增量' : 'Recent 6 Months' }),
(() => { const div = h('div', {}); div.innerHTML = `${barGrid2}${bars2} `; div.addEventListener('click', (e) => { const item = e.target.closest('[data-month]'); if (item) { const month = item.getAttribute('data-month'); if (month) showMonthGames(month); } }); return div; })()
])
])
]);
return outerWrap;
}
function buildStackedBarSvg(fi, gl) {
if (!fi || !gl) return '';
const members = fi.family_member;
const memberCount = members.length;
const MAX_FAMILY = 6;
const isZh = locale === 'zh-CN';
// 绝版模式:仅统计绝版游戏
let gameInfo = gl.GameInfo;
if (state.family.chartShowDelisted) {
const delistedApps = getDelistedApps();
const delistedAppIds = new Set(delistedApps.map(app => Number(app.appid)));
const filtered = {};
for (let key in gl.GameInfo) {
if (delistedAppIds.has(Number(key))) {
filtered[key] = gl.GameInfo[key];
}
}
gameInfo = filtered;
// 家庭库中无绝版游戏时显示空状态
if (Object.keys(filtered).length === 0) {
const chartContainer = document.getElementById('sfd-family-chart-left');
if (chartContainer) {
chartContainer.querySelectorAll('#sfd-family-svg, .sfd-family-legend-wrap, #sfd-family-chart-empty').forEach(el => el.remove());
chartContainer.insertAdjacentHTML('beforeend', `${isZh ? '家庭库中暂无绝版游戏' : 'No delisted games in family library'} `);
}
return;
}
}
const colors = CHART_COLORS;
const legendLabels = [];
for (let i = 0; i < memberCount; i++) {
const ci = memberCount - i - 1;
legendLabels[i] = {
label: i === memberCount - 1 ? (isZh ? '单独贡献' : 'Exclusive') : `${memberCount - i}${isZh ? '人共同贡献' : '-person shared'}`,
color: colors[ci % colors.length]
};
}
const layerData = Array.from({ length: memberCount }, () => new Array(memberCount).fill(0));
let maxValue = 0;
for (let key in gameInfo) {
const game = gameInfo[key];
const ownerCount = game.owners.length;
const dsIdx = memberCount - ownerCount;
if (dsIdx >= 0 && dsIdx < memberCount) {
game.owners.forEach(owner => {
const mIdx = members.findIndex(m => m.steamid === owner);
if (mIdx !== undefined && mIdx >= 0) {
layerData[dsIdx][mIdx]++;
maxValue = Math.max(maxValue, layerData[dsIdx][mIdx]);
}
});
}
}
const svgW = 680, svgH = 420;
const pL = 50, pR = 20, pT = 20, pB = 85;
const cW = svgW - pL - pR;
const cH = svgH - pT - pB;
const gap = cW / MAX_FAMILY;
const barW = Math.min(Math.max(gap * 0.55, 24), 44);
const gridLines = [];
const gridCount = 5;
for (let i = 0; i <= gridCount; i++) {
const v = Math.round(maxValue / gridCount * i);
const y = pT + cH - (cH * i / gridCount);
gridLines.push(` `);
gridLines.push(`${v} `);
}
// 构建头像映射
const avatarMap = buildAvatarMap();
let bars = '';
let labelEls = '';
let avatarEls = '';
// 收集每个成员的完整数据,用于悬浮提示
const memberToolData = [];
for (let slot = 0; slot < MAX_FAMILY; slot++) {
const cx = pL + gap * slot + gap / 2;
if (slot < memberCount) {
const member = members[slot];
const sidStr = String(member.steamid);
let sh = 0;
const layerVals = [];
for (let lIdx = 0; lIdx < memberCount; lIdx++) {
const n = layerData[lIdx][slot];
layerVals.push(n);
if (n <= 0) continue;
const ht = maxValue ? (n / maxValue) * cH : 0;
const y = pT + cH - sh - ht;
const col = legendLabels[lIdx].color;
bars += ` `;
sh += ht;
}
const total = layerVals.reduce((s, v) => s + v, 0);
memberToolData.push({ slot, name: member.userName, steamid: sidStr, total, layerVals });
labelEls += `${member.userName} `;
// 名字下方头像(用 foreignObject 嵌入 HTML img,兼容性好)
const av = avatarMap[sidStr] || DEFAULT_AVATAR;
avatarEls += ` `;
} else {
bars += ` `;
labelEls += `${isZh ? '待加入' : 'Empty'} `;
}
}
const baseLine = ` `;
const legendHtml = ''
+ legendLabels.map(l => ` ${l.label} `).join('')
+ '
';
const svgStr = `${gridLines.join('')}${bars}${labelEls}${avatarEls}${baseLine} `;
const chartContainer = document.getElementById('sfd-family-chart-left') || document.getElementById('sfd-family-chart-wrap');
if (chartContainer) {
const oldSvg = chartContainer.querySelector('#sfd-family-svg');
const oldLegend = chartContainer.querySelector('.sfd-family-legend-wrap');
const oldEmpty = chartContainer.querySelector('#sfd-family-chart-empty');
if (oldSvg) oldSvg.remove();
if (oldLegend) oldLegend.remove();
if (oldEmpty) oldEmpty.remove();
// 创建高级悬浮提示框
let tipEl = chartContainer.querySelector('#sfd-family-chart-tip');
if (!tipEl) {
tipEl = document.createElement('div');
tipEl.id = 'sfd-family-chart-tip';
tipEl.style.cssText = 'position:fixed;z-index:9999;pointer-events:none;display:none;background:linear-gradient(135deg,rgba(15,23,42,0.97),rgba(30,41,59,0.97));border:1px solid rgba(102,192,244,0.3);border-radius:12px;padding:12px 16px;box-shadow:0 8px 32px rgba(0,0,0,0.6),0 0 0 1px rgba(102,192,244,0.1);backdrop-filter:blur(16px);font-size:12px;max-width:280px;transition:opacity 0.15s;opacity:0';
document.body.appendChild(tipEl);
}
chartContainer.insertAdjacentHTML('beforeend', svgStr);
chartContainer.insertAdjacentHTML('beforeend', legendHtml);
// 悬浮提示:同一柱子(成员)的多个色块视为整体
let curSlot = -1; // 当前显示的成员 slot
chartContainer.querySelectorAll('rect[data-slot]').forEach(rect => {
const slot = parseInt(rect.getAttribute('data-slot'));
const td = memberToolData[slot];
if (!td) return;
rect.addEventListener('mouseenter', (e) => {
if (slot === curSlot) return; // 同柱子色块切换,不更新
curSlot = slot;
let html = `${td.name} ${isZh ? '总贡献' : 'Total'}: ${td.total}
`;
html += '';
for (let lIdx = 0; lIdx < memberCount; lIdx++) {
const n = td.layerVals[lIdx];
const col = legendLabels[lIdx].color;
html += `
${legendLabels[lIdx].label} ${n}
`;
}
html += '
';
tipEl.innerHTML = html;
tipEl.style.display = 'block';
tipEl.style.opacity = '1';
tipEl.style.left = Math.min(e.clientX + 14, window.innerWidth - 300) + 'px';
tipEl.style.top = Math.max(8, e.clientY - 10) + 'px';
});
rect.addEventListener('mousemove', (e) => {
tipEl.style.left = Math.min(e.clientX + 14, window.innerWidth - 300) + 'px';
tipEl.style.top = Math.max(8, e.clientY - 10) + 'px';
});
rect.addEventListener('mouseleave', (e) => {
// 检查是否移到了同柱子的另一个色块
const rt = e.relatedTarget;
if (rt && rt.getAttribute && rt.getAttribute('data-slot') === String(slot)) return;
tipEl.style.opacity = '0';
setTimeout(() => { tipEl.style.display = 'none'; curSlot = -1; }, 150);
});
rect.addEventListener('click', () => {
const sid = rect.getAttribute('data-steamid');
const name = rect.getAttribute('data-membername');
showMemberGames(sid, name);
});
});
}
}
// ==================== 家庭组游戏卡片渲染辅助 ====================
function createFamilyGameCard(game, fi, ocColors) {
const isZh = locale === 'zh-CN';
const os = getOwnerStyle(game.owners.length);
const avatarMap = buildAvatarMap();
const ownerAvatars = game.owners.slice(0, 5).map(sid => {
const sidStr = String(sid);
const avatar = avatarMap[sidStr] || `https://avatars.steamstatic.com/${sidStr}.jpg`;
const name = (fi.steamIdtoName && (fi.steamIdtoName[sidStr] || fi.steamIdtoName[sid])) || 'ID:' + sidStr.slice(-4);
return h('img', { src: avatar, style: { width: '22px', height: '22px', borderRadius: '50%', objectFit: 'cover', flexShrink: '0', border: '2px solid rgba(15,23,42,0.9)' }, loading: 'lazy', title: name, onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" });
});
return h('div', { class: 'sfd-pl-recent-card', style: { background: os.bg, borderColor: os.border } }, [
(() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(),
h('div', { class: 'sfd-pl-recent-info' }, [
(() => { const el = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(),
h('div', { class: 'sfd-pl-recent-meta', style: { justifyContent: 'space-between' } }, [
h('span', { style: { color: '#94a3b8' }, text: DateUtils.formatTimestamp(game.time) }),
h('span', { style: { color: os.color, fontWeight: '700', background: os.color + '20', padding: '1px 8px', borderRadius: '10px', fontSize: '11px' }, text: `${game.owners.length}${isZh ? '人' : ''}` })
]),
h('div', { style: { display: 'flex', marginTop: '3px', gap: '3px' } }, ownerAvatars)
])
]);
}
// ==================== 好友库存对比 ====================
async function showGameCompare(steamid, friendName) {
const isZh = locale === 'zh-CN';
const friend = state.friends.data ? _friendsMap.get(String(steamid)) : null;
const friendAvatar = friend ? friend.avatar : '';
const friendHasGame = friend && friend.gameextrainfo;
// 构建自定义头部内容(头像 + 好友信息 + 徽章)
const headerContent = h('div', { style: { display: 'flex', alignItems: 'center', gap: '12px', flex: '1', minWidth: '0' } }, [
friendAvatar
? h('img', { src: friendAvatar, style: { width: '40px', height: '40px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(6,207,190,0.45)', flexShrink: '0', boxShadow: '0 4px 12px rgba(6,207,190,0.2)' }, loading: 'lazy', onerror: "this.onerror=null;this.src='https://avatars.steamstatic.com/fef49e7fa7e1997310dd48961da2e7d95a5c7a56_medium.jpg';" })
: h('div', { style: { width: '40px', height: '40px', borderRadius: '50%', background: 'rgba(6,207,190,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0' }, html: ICONS.user }),
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '0' } }, [
h('h3', { style: { margin: '0', fontSize: '14px', color: '#fff', fontWeight: '700', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } }, [
h('span', { text: friendName }),
friend && friend.level > 0 ? h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#fbbf24', background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.level} Lv.${friend.level}` }) : null,
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#34d399', background: 'rgba(16,185,129,0.12)', border: '1px solid rgba(16,185,129,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.package} - ` }),
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#c4b5fd', background: 'rgba(139,92,246,0.12)', border: '1px solid rgba(139,92,246,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.users} - ` })
]),
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' } }, [
h('span', { style: { fontSize: '11px', color: '#64748b', fontFamily: '"SF Mono","Fira Code",monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, text: steamid }),
friend ? h('span', { class: `sfd-badge sfd-badge-status ${friendHasGame ? 'in-game' : (friend.personastate > 0 ? 'online' : 'offline')}`, text: friendHasGame ? `🎮 ${friend.gameextrainfo}` : getPersonaStateText(friend.personastate) }) : null
])
])
]);
// 使用 createModal 创建弹窗
const { overlay, header, body: content } = createModal({
id: 'sfd-compare-popup',
headerCls: 'sfd-family-header sfd-compare-header',
headerContent,
noOutsideClick: true,
bodyStyle: { overflow: 'hidden', flex: '1', padding: '12px' }
});
content.appendChild(h('div', { class: 'sfd-family-empty', text: t('compareLoading').replace('{name}', friendName) }));
// Fetch friend's games via GM_xmlhttpRequest + API Key
try {
let friendGames = new Map();
const apiKey = storage.getApiKey();
if (!apiKey) {
content.innerHTML = '';
content.appendChild(h('div', { class: 'sfd-family-empty', style: { textAlign: 'center', padding: '20px' } }, [
h('div', { style: { fontSize: '32px', marginBottom: '8px' }, text: '🔑' }),
h('div', { text: t('compareNeedKey') }),
h('a', { href: 'https://steamcommunity.com/dev/apikey', target: '_blank', style: { color: '#66c0f4', fontSize: '11px' }, text: 'steamcommunity.com/dev/apikey' })
]));
return;
}
// 好友游戏库与游玩时长统计(用于 KPI 和 Top15 标签页)
let friendPlaytimeStats = { totalMinutes: 0, top15: [] };
// Method 1: 统一游戏库缓存(优先命中缓存,未命中则请求并缓存,供仪表盘/我的游戏库共用)
try {
const cached = await fetchOwnedGamesCached(steamid);
if (!overlay.isConnected) return; // 弹窗已关闭,中止异步流程
if (cached && !cached.private && !cached.error && cached.games && cached.games.length) {
const gamesWithTime = [];
cached.games.forEach(g => {
if (g.appid && g.name) {
friendGames.set(Number(g.appid), g.name);
gamesWithTime.push({ appid: g.appid, name: g.name, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '' });
}
});
const totalMinutes = gamesWithTime.reduce((s, g) => s + g.playtime_forever, 0);
const top15 = gamesWithTime.filter(g => g.playtime_forever > 0).sort((a, b) => b.playtime_forever - a.playtime_forever).slice(0, 15);
friendPlaytimeStats = { totalMinutes, top15 };
}
} catch (e) { logger.silent('好友游戏库 API 方式失败', e); }
// Method 2: Try HTML page scraping
if (friendGames.size === 0) {
try {
const html = await gmFetch(`https://steamcommunity.com/profiles/${steamid}/games/?tab=all`);
const match = html.match(/var\s+rgGames\s*=\s*(\[[\s\S]*?\])\s*;/);
if (match) {
const games = JSON.parse(match[1]);
games.forEach(g => {
if (g.appid && g.name) friendGames.set(Number(g.appid), g.name);
});
}
} catch (e) { logger.silent('好友游戏库 HTML 抓取失败', e); }
}
// Method 3: Try XML API
if (friendGames.size === 0) {
try {
const xmlText = await gmFetch(`https://steamcommunity.com/profiles/${steamid}/games/?tab=all&xml=1`);
const doc = new DOMParser().parseFromString(xmlText, 'text/xml');
doc.querySelectorAll('game').forEach(g => {
const appid = g.querySelector('appID')?.textContent;
const name = g.querySelector('name')?.textContent;
if (appid && name) friendGames.set(Number(appid), name);
});
} catch (e) { logger.silent('好友游戏库 XML 方式失败', e); }
}
if (friendGames.size === 0) {
if (!overlay.isConnected) return;
content.innerHTML = '';
content.appendChild(h('div', { class: 'sfd-family-empty', style: { textAlign: 'center', padding: '20px' } }, [
h('div', { style: { marginBottom: '8px' }, html: `${ICONS.lock} ` }),
h('div', { text: t('compareError') }),
h('div', { style: { fontSize: '11px', color: '#6b7280', marginTop: '4px' }, text: isZh ? '该好友可能将游戏详情设为私密' : 'This friend may have set game details to private' })
]));
return;
}
// 更新标题中的游戏数徽章
const gcValEl = header.querySelector('#sfd-compare-gc-val');
if (gcValEl) gcValEl.textContent = String(friendGames.size);
// ===== 修改:获取"自己"的游戏库(替代家庭组库) =====
let mySteamId = storage.getSteamId() || detectCurrentSteamId();
let myGames = new Map();
if (mySteamId) {
try {
const myCached = await fetchOwnedGamesCached(mySteamId);
if (!overlay.isConnected) return; // 弹窗已关闭,中止异步流程
if (myCached && !myCached.private && !myCached.error && myCached.games) {
myCached.games.forEach(g => {
if (g.appid && g.name) myGames.set(Number(g.appid), g.name);
});
}
} catch (e) {
logger.warn('获取比较用自身游戏库失败', e);
}
}
// Compare: shared = 双方都有的, friendOnly = 好友有但我没有的
const shared = [], friendOnly = [];
for (const [appid, name] of friendGames) {
if (myGames.has(appid)) shared.push({ appid, name });
else friendOnly.push({ appid, name });
}
// 我的独占 = 我有但好友没有的
const myOnly = [];
for (const [appid, name] of myGames) {
if (!friendGames.has(appid)) myOnly.push({ appid, name });
}
console.log(`[SFD] 游戏对比: 好友=${friendGames.size}, 自己=${myGames.size}, 共同=${shared.length}, 好友独有=${friendOnly.length}, 自己独有=${myOnly.length}`);
// Fetch mutual friends — 优先使用已缓存数据,避免重复API请求
async function fetchMutualFriends(sid) {
if (!apiKey) return [];
const myFriendIds = new Set(state.friends.data.map(f => f.steamid));
// 优先使用批量查询时缓存的好友列表
if (_mutualFriendLists[sid]) {
const mutual = _mutualFriendLists[sid].filter(id => myFriendIds.has(id));
return state.friends.data.filter(f => mutual.includes(f.steamid));
}
// 其次使用 _mutualCache 中的数量信息,如果 >= 0 说明之前查过但没缓存列表(私密等),直接用数量
if (_mutualCache[sid] === -1) return []; // 私密好友,无法获取
// 缓存中没有,需要重新请求
try {
const resp = await SteamAPI.getFriendsLegacy(sid);
const theirFriends = (resp.friendslist?.friends || []).map(f => f.steamid);
// 缓存好友列表供下次使用
_mutualFriendLists[sid] = theirFriends;
const mutual = theirFriends.filter(id => myFriendIds.has(id));
return state.friends.data.filter(f => mutual.includes(f.steamid));
} catch (e) {
logger.warn('获取共同好友详情失败', e);
return [];
}
}
let mutualFriendsCache = null;
// Tabs
let activeTab = 'play';
let comparePage = 1;
const PAGE_SIZE = 25;
// 8个标签
const tabData = [
{ key: 'play', label: isZh ? '最近游玩' : 'Recent Games' },
{ key: 'playtime', label: isZh ? '游玩时长' : 'Playtime' },
{ key: 'newgames', label: isZh ? '入库动态' : 'Library' },
{ key: 'mutual', label: isZh ? '共同好友' : 'Mutual' },
{ key: 'delisted', label: isZh ? '绝版游戏' : 'Delisted' },
{ key: 'shared', label: isZh ? `共同游戏 (${shared.length})` : `Shared (${shared.length})` },
{ key: 'friend', label: isZh ? `好友独有 (${friendOnly.length})` : `Friend Only (${friendOnly.length})` },
{ key: 'myonly', label: isZh ? `我的独有 (${myOnly.length})` : `My Only (${myOnly.length})` }
];
const tabBtns = [];
const tabsWrap = h('div', { class: 'sfd-family-tabs', style: { marginBottom: '8px' } });
tabData.forEach((td, i) => {
const btn = h('button', {
class: `sfd-family-tab${td.key === activeTab ? ' active' : ''}`,
onClick: () => {
activeTab = td.key;
comparePage = 1;
delistedPage = 1;
tabBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderCompareList();
}
}, [h('span', { text: td.label })]);
tabBtns.push(btn);
tabsWrap.appendChild(btn);
});
content.innerHTML = '';
content.appendChild(tabsWrap);
// Load mutual friends count
fetchMutualFriends(steamid).then(mf => {
if (!overlay.isConnected) return; // 弹窗已关闭,中止回调
mutualFriendsCache = mf;
// 更新共同好友标签文字
const mutualBtn = tabBtns[3]; // 第4个标签
if (mutualBtn) {
const span = mutualBtn.querySelector('span');
if (span) {
if (_mutualCache[steamid] === -1) {
span.textContent = isZh ? '共同好友 🔒' : 'Mutual 🔒';
} else {
span.textContent = isZh ? `共同好友 (${mf.length})` : `Mutual (${mf.length})`;
}
}
}
// 更新标题中的好友数徽章
const fcValEl = header.querySelector('#sfd-compare-fc-val');
if (fcValEl) {
if (_mutualCache[steamid] === -1) {
fcValEl.innerHTML = ICONS.lock;
} else {
const totalFriends = (_mutualFriendLists[steamid] && _mutualFriendLists[steamid].length) || mf.length;
fcValEl.textContent = String(totalFriends);
}
}
});
// Activity tab caches (lazy load)
let playActivityCache = null;
let ownedGamesCache = null;
// 绝版收藏筛选状态与缓存
let delistedDataCache = null;
let delistedTypeFilter = 'all';
let delistedScope = 'all';
let delistedPage = 1;
const DELISTED_PAGE_SIZE = 20;
async function fetchFriendPlayActivity(sid) {
if (playActivityCache) return playActivityCache;
if (!apiKey) return [];
try {
// 共用仪表盘的 recent cache,避免重复请求
const r = await fetchRecentCached(sid);
playActivityCache = r.games || [];
return playActivityCache;
} catch (e) {
logger.warn('获取好友游玩动态失败', e);
return [];
}
}
async function fetchFriendOwnedGames(sid) {
if (ownedGamesCache) return ownedGamesCache;
if (!apiKey) return { timeline: [], total: 0, isFirst: false };
try {
const cached = await fetchOwnedGamesCached(sid);
if (cached.private || cached.error) return { timeline: [], total: 0, isFirst: false };
// 复用共享的timeline对比逻辑
const result = recordTimeline(sid, cached);
ownedGamesCache = result || { timeline: [], gameInfo: {}, total: 0, isFirst: false, allGames: [] };
return ownedGamesCache;
} catch (e) {
logger.warn('获取好友游戏库失败', e);
return { timeline: [], gameInfo: {}, total: 0, isFirst: false, allGames: [] };
}
}
const listScroll = h('div', { class: 'sfd-family-scroll', style: { overflow: 'hidden', display: 'flex', flexDirection: 'column' } });
const fixedHeader = h('div', { style: { flexShrink: '0' } });
const gameScroll = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } });
const paginationWrap = h('div', { style: { flexShrink: '0', display: 'flex', justifyContent: 'flex-end' } });
listScroll.appendChild(fixedHeader);
listScroll.appendChild(gameScroll);
listScroll.appendChild(paginationWrap);
function renderCompareList() {
fixedHeader.innerHTML = '';
gameScroll.innerHTML = '';
paginationWrap.innerHTML = '';
if (activeTab === 'play') {
renderPlayTab();
return;
}
if (activeTab === 'playtime') {
renderPlaytimeTab();
return;
}
if (activeTab === 'newgames') {
renderNewGamesTab();
return;
}
if (activeTab === 'delisted') {
renderDelistedTab();
return;
}
if (activeTab === 'mutual') {
renderMutualTab();
return;
}
let games;
if (activeTab === 'shared') games = shared;
else if (activeTab === 'friend') games = friendOnly;
else if (activeTab === 'myonly') games = myOnly;
else games = shared;
const totalPages = Math.ceil(games.length / PAGE_SIZE);
if (comparePage > totalPages) comparePage = totalPages || 1;
const start = (comparePage - 1) * PAGE_SIZE;
const pageItems = games.slice(start, start + PAGE_SIZE);
const grid = h('div', { class: 'sfd-pl-recent-grid' });
if (games.length === 0) {
grid.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '无游戏' : 'No games' }));
} else {
pageItems.forEach((game) => {
const nameEl = h('a', {
class: 'sfd-pl-recent-name',
href: `https://store.steampowered.com/app/${game.appid}`,
target: '_blank',
text: game.name
});
loadGameZhName(nameEl, game.appid, game.name);
grid.appendChild(h('div', { class: 'sfd-pl-recent-card' }, [
(() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(),
h('div', { class: 'sfd-pl-recent-info' }, [
nameEl
])
]));
});
}
gameScroll.appendChild(grid);
// Pagination
if (totalPages > 1) {
const pagi = createPagination(comparePage, totalPages, (newPage) => { comparePage = newPage; renderCompareList(); });
pagi.classList.add('sfd-compare-pagi');
pagi.style.borderTop = '1px solid var(--sfd-border)';
pagi.style.flexShrink = '0';
paginationWrap.appendChild(pagi);
}
}
// 游玩动态渲染
async function renderPlayTab() {
let loadingEl = content.querySelector('.sfd-activity-loading');
if (!loadingEl) {
loadingEl = createLoadingEl(t('activityLoading') || (isZh ? '正在加载游玩动态…' : 'Loading play activity…'), '#a29bfe');
gameScroll.appendChild(loadingEl);
}
const games = await fetchFriendPlayActivity(steamid);
if (!overlay.isConnected) return; // 弹窗已关闭,中止渲染
loadingEl.remove();
const list = h('div', { class: 'sfd-pl-recent-grid' });
if (!games || games.length === 0) {
list.appendChild(h('div', { class: 'sfd-family-empty', text: t('activityEmptyPlay') || (isZh ? '暂无游玩记录' : 'No play records') }));
} else {
const total2w = games.reduce((s, g) => s + (g.playtime_2weeks || 0), 0);
games.forEach(game => list.appendChild(buildRecentGameCard(game, total2w, isZh)));
}
gameScroll.appendChild(list);
}
// 游玩时长标签页:Top15 游玩最多游戏
function renderPlaytimeTab() {
const list = h('div', { class: 'sfd-compare-playtime-list' });
const top15 = friendPlaytimeStats.top15 || [];
if (top15.length === 0) {
list.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '暂无游玩时长数据' : 'No playtime data' }));
} else {
const maxPlaytime = top15[0].playtime_forever || 1;
top15.forEach((game, i) => {
const rank = i + 1;
const rankClass = rank === 1 ? 'top1' : rank === 2 ? 'top2' : rank === 3 ? 'top3' : '';
const pct = maxPlaytime > 0 ? (game.playtime_forever / maxPlaytime * 100).toFixed(1) : '0';
const hours = (game.playtime_forever / 60).toFixed(1) + 'h';
const iconFallback = game.img_icon_url
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/${game.img_icon_url}.jpg`
: `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/capsule_sm_120.jpg`;
list.appendChild(h('div', { class: 'sfd-compare-playtime-item', onClick: () => openStorePage(game.appid) }, [
h('div', { class: `sfd-compare-game-rank ${rankClass}`, text: String(rank) }),
(() => { const { wrapper } = createImgWrapper(game.appid, 'sfd-img-sm'); wrapper.style.width = '32px'; wrapper.style.height = '32px'; wrapper.style.flexShrink = '0'; const img = wrapper.querySelector('img'); img.style.width = '32px'; img.style.height = '32px'; img.style.objectFit = 'cover'; img.style.borderRadius = '4px'; loadGameImage(wrapper, game.appid, `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${game.appid}/header.jpg`, iconFallback, '', true, 'header_schinese.jpg'); return wrapper; })(),
h('div', { class: 'sfd-family-game-info', style: { minWidth: '0' } }, [
h('a', { class: 'sfd-family-game-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', rel: 'noopener noreferrer', text: game.name, onClick: (e) => { e.preventDefault(); e.stopPropagation(); openStorePage(game.appid); } })
]),
h('div', { class: 'sfd-compare-playtime-bar-wrap' }, [
h('div', { class: 'sfd-compare-playtime-bar-fill', style: { width: pct + '%' } })
]),
h('div', { class: 'sfd-compare-playtime-hours', text: hours })
]));
});
}
gameScroll.appendChild(list);
}
// 入库动态渲染(喜加一)
async function renderNewGamesTab() {
let loadingEl = content.querySelector('.sfd-activity-loading');
if (!loadingEl) {
loadingEl = createLoadingEl(isZh ? '正在加载库…' : 'Loading library…', '#06cfbe');
gameScroll.appendChild(loadingEl);
}
// 每次点击都强制刷新:清除内存缓存和持久化缓存中该好友的数据
ownedGamesCache = null;
storage.delOwnedGameCache(steamid);
const summary = storage.getOwnedGamesSummary(); delete summary[steamid]; storage.setOwnedGamesSummary(summary);
const data = await fetchFriendOwnedGames(steamid);
if (!overlay.isConnected) return; // 弹窗已关闭,中止渲染
loadingEl.remove();
const list = h('div', { class: 'sfd-compare-list' });
const records = data.timeline || [];
// Summary cards
const summaryEl = h('div', { style: { display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap' } }, [
h('div', { style: { background: 'rgba(6,207,190,0.1)', border: '1px solid rgba(6,207,190,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [
h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#06cfbe' }, text: String(data.total) }),
h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: isZh ? '当前游戏总数' : 'Current Total' })
]),
h('div', { style: { background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.2)', borderRadius: '8px', padding: '8px 12px', textAlign: 'center', flex: '1' } }, [
h('div', { style: { fontSize: '18px', fontWeight: '700', color: '#a29bfe' }, text: String(records.length) }),
h('div', { style: { fontSize: '10px', color: '#8a9ba8' }, text: isZh ? '检查次数' : 'Checks' })
])
]);
list.appendChild(summaryEl);
if (records.length === 0) {
list.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '暂无数据' : 'No data' }));
} else {
// Render timeline from newest to oldest
const sorted = [...records].reverse();
// 预处理:合并连续的"无变化"记录(首次记录和有新增的不合并)
const groups = [];
sorted.forEach((record, idx) => {
const isFirstRecord = idx === sorted.length - 1;
const newCount = record.newAppIds ? record.newAppIds.length : 0;
const isNoChange = !isFirstRecord && newCount === 0;
const lastGroup = groups[groups.length - 1];
if (isNoChange && lastGroup && lastGroup.type === 'nochange') {
// 追加到当前无变化组
lastGroup.count++;
lastGroup.records.push(record);
lastGroup.endTs = record.ts;
} else {
// 新建组
groups.push({
type: isFirstRecord ? 'first' : (newCount > 0 ? 'new' : 'nochange'),
count: 1,
records: [record],
startTs: record.ts,
endTs: record.ts,
isFirstRecord,
newCount
});
}
});
// 渲染分组
groups.forEach(group => {
const record = group.records[0]; // 最新一条
const tsText = DateUtils.formatMs(group.startTs);
const tsEndText = group.count > 1 ? DateUtils.formatMs(group.endTs) : '';
// 分界线
list.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', margin: '12px 0 8px' } }, [
h('div', { style: { flex: '1', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(102,192,244,0.25), transparent)' } }),
h('span', { style: { fontSize: '10px', color: '#475569', whiteSpace: 'nowrap', letterSpacing: '0.5px' }, text: group.count > 1 ? `${tsText} → ${tsEndText}` : tsText }),
h('div', { style: { flex: '1', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(102,192,244,0.25), transparent)' } })
]));
// Record header
const headerColor = group.isFirstRecord ? '#06cfbe' : (group.newCount > 0 ? '#10b981' : '#8a9ba8');
const headerLabel = group.isFirstRecord
? (isZh ? '📌 首次记录' : '📌 First Record')
: (group.newCount > 0
? (isZh ? `🎉 喜加${group.newCount > 1 ? group.newCount : '一'}` : `🎉 ${group.newCount} New`)
: (group.count > 1
? (isZh ? `📦 无变化 ×${group.count}` : `📦 No Change ×${group.count}`)
: (isZh ? '📦 无变化' : '📦 No Change')));
list.appendChild(h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 10px', marginBottom: '6px', background: `${headerColor}10`, borderRadius: '6px', border: `1px solid ${headerColor}30` } }, [
h('span', { style: { fontSize: '13px', fontWeight: '600', color: headerColor }, text: headerLabel }),
h('span', { style: { fontSize: '11px', color: '#6b7280' }, text: `${group.isFirstRecord ? tsText + ' · ' : ''}${record.totalGames}${isZh ? '款' : ' games'}` })
]));
// Record content
if (group.isFirstRecord) {
list.appendChild(h('div', { style: { fontSize: '12px', color: '#8a9ba8', padding: '4px 10px 8px' }, text: isZh ? `已记录 ${record.totalGames} 款游戏,后续检查将对比显示新增` : `${record.totalGames} games recorded. Future checks will show additions.` }));
} else if (group.newCount === 0) {
list.appendChild(h('div', { style: { fontSize: '12px', color: '#8a9ba8', padding: '4px 10px 8px' }, text: isZh ? `该好友当前共 ${record.totalGames} 款游戏,连续 ${group.count} 次检查无变化` : `Friend has ${record.totalGames} games, no change for ${group.count} checks.` }));
} else {
// gameInfo 从 timeline 顶层全局缓存取
const gi = data.gameInfo || {};
const gameGrid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px', marginBottom: '8px' } });
record.newAppIds.forEach(appid => {
const info = gi[appid] || {};
gameGrid.appendChild(h('div', { class: 'sfd-pl-recent-card' }, [
(() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(appid) }); return dashLoadCapsule(capImg, appid); })(),
h('div', { class: 'sfd-pl-recent-info' }, [
h('a', {
class: 'sfd-pl-recent-name',
href: `https://store.steampowered.com/app/${appid}`,
target: '_blank',
rel: 'noopener noreferrer',
text: info.name || `App ${appid}`,
onClick: (e) => { e.preventDefault(); e.stopPropagation(); openStorePage(appid); }
}),
h('span', { style: { fontSize: '10px', color: '#34d399', fontWeight: '700' }, text: '🎉 新增' })
])
]));
});
list.appendChild(gameGrid);
}
});
}
gameScroll.appendChild(list);
}
async function renderMutualTab() {
const oldList = gameScroll.querySelector('.sfd-compare-list');
if (oldList) oldList.remove();
const list = h('div', { class: 'sfd-compare-list' });
if (!mutualFriendsCache) {
const loadingEl = createLoadingEl(isZh ? '正在加载共同好友…' : 'Loading mutual friends…', '#a29bfe');
list.appendChild(loadingEl);
gameScroll.appendChild(list);
mutualFriendsCache = await fetchMutualFriends(steamid);
if (!overlay.isConnected) return; // 弹窗已关闭,中止渲染
const countEl = summary.children[2].querySelector('div:first-child');
if (countEl) countEl.textContent = String(mutualFriendsCache.length);
renderMutualTab();
return;
}
if (mutualFriendsCache.length === 0) {
list.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '暂无共同好友' : 'No mutual friends' }));
} else {
list.style.display = 'grid';
list.style.gridTemplateColumns = 'repeat(3, 1fr)';
list.style.gap = '8px';
mutualFriendsCache.forEach(f => {
list.appendChild(h('a', { class: 'sfd-family-game-item', href: `https://steamcommunity.com/profiles/${f.steamid}`, target: '_blank', style: { borderLeft: '3px solid #a29bfe', background: '#a29bfe0d', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '8px', padding: '8px', borderRadius: '0 6px 6px 0' } }, [
h('img', { src: f.avatar, loading: 'lazy', style: { width: '36px', height: '36px', borderRadius: '50%', flexShrink: '0' } }),
h('div', { style: { minWidth: '0', flex: '1' } }, [
h('div', { style: { fontSize: '13px', fontWeight: '600', color: 'var(--sfd-text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, text: f.personaname }),
h('div', { style: { fontSize: '11px', color: '#8a9ba8', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '4px' } }, [
f.country_flag ? h('span', { style: { display: 'inline-flex', alignItems: 'center', flexShrink: '0' }, html: f.country_flag }) : null,
f.country_name || f.loccountrycode || '',
f.level > 0 ? h('span', { style: { color: '#fbbf24', fontWeight: '600', marginLeft: '6px' }, text: `Lv.${f.level}` }) : null
])
])
]));
});
}
gameScroll.appendChild(list);
}
// 绝版游戏标签页(参考个人游戏库:分类标签筛选 + 拥有率 KPI)
async function renderDelistedTab() {
fixedHeader.innerHTML = '';
gameScroll.innerHTML = '';
paginationWrap.innerHTML = '';
if (friendGames.size === 0) {
gameScroll.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '无法获取好友游戏列表' : 'Cannot fetch friend game list' }));
return;
}
if (!delistedDataCache) {
const loadingEl = createLoadingEl(isZh ? '正在检测绝版游戏…' : 'Detecting delisted games…', '#f44336');
gameScroll.appendChild(loadingEl);
await loadDelistedDB();
if (!overlay.isConnected) return; // 弹窗已关闭,中止渲染
const delistedApps = getDelistedApps();
const delistedMap = new Map();
delistedApps.forEach(app => { if (app.appid) delistedMap.set(Number(app.appid), app); });
const owned = [];
for (const [appid, name] of friendGames) {
const info = delistedMap.get(Number(appid));
if (info) {
owned.push({ appid, name: name || info.name || `App ${appid}`, delistedType: getDelistedTypeKey(info.type), delistedDate: info.changed || '' });
}
}
owned.sort((a, b) => (b.delistedDate || '').localeCompare(a.delistedDate || ''));
const allApps = delistedApps.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 || ''));
delistedDataCache = { owned, allApps, total: delistedApps.length };
loadingEl.remove();
}
const data = delistedDataCache;
const allTypes = [
{ key: 'delisted', color: '#f44336' },
{ key: 'purchase_disabled', color: '#ff9800' },
{ key: 'f2p_unavailable', color: '#ce93d8' },
{ key: 'retail_only', color: '#64b5f6' },
{ key: 'test_app', color: '#90a4ae' }
];
const ownedIds = new Set(data.owned.map(g => g.appid));
const allApps = data.allApps || [];
const missingApps = allApps.filter(g => !ownedIds.has(g.appid));
const scopeBase = delistedScope === 'all' ? allApps :
delistedScope === 'missing' ? missingApps : data.owned;
fixedHeader.appendChild(h('div', { class: 'sfd-delisted-cards', style: { display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap', alignItems: 'center' } }, [
createMetricCard({ value: String(data.total), label: isZh ? '绝版总数' : 'Total Delisted', accent: '#f44336', layout: 'compact', active: delistedScope === 'all', onClick: () => { delistedScope = 'all'; delistedTypeFilter = 'all'; delistedPage = 1; renderDelistedTab(); } }),
createMetricCard({ value: String(data.owned.length), label: isZh ? '好友拥有' : 'Friend Owns', accent: '#34d399', layout: 'compact', active: delistedScope === 'owned', onClick: () => { delistedScope = 'owned'; delistedTypeFilter = 'all'; delistedPage = 1; renderDelistedTab(); } }),
createMetricCard({ value: String(data.total - data.owned.length), label: isZh ? '未拥有' : 'Missing', accent: '#94a3b8', layout: 'compact', active: delistedScope === 'missing', onClick: () => { delistedScope = 'missing'; delistedTypeFilter = 'all'; delistedPage = 1; renderDelistedTab(); } }),
h('div', { style: { width: '1px', height: '32px', background: 'rgba(255,255,255,0.15)', flexShrink: '0' } }),
...allTypes.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: delistedTypeFilter === key, onClick: () => { delistedTypeFilter = delistedTypeFilter === key ? 'all' : key; delistedPage = 1; renderDelistedTab(); } });
})
]));
if (scopeBase.length === 0) {
gameScroll.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '暂无数据' : 'No data' }));
return;
}
let filtered = scopeBase;
if (delistedTypeFilter !== 'all') {
filtered = filtered.filter(g => g.delistedType === delistedTypeFilter);
}
const totalPages = Math.max(1, Math.ceil(filtered.length / DELISTED_PAGE_SIZE));
if (delistedPage > totalPages) delistedPage = totalPages;
const start = (delistedPage - 1) * DELISTED_PAGE_SIZE;
const pageItems = filtered.slice(start, start + DELISTED_PAGE_SIZE);
const gameList = h('div', { class: 'sfd-pl-recent-grid' });
pageItems.forEach(game => {
const typeLabel = getDelistedTypeLabel(game.delistedType);
const badgeClass = 'type-' + game.delistedType;
const isOwned = ownedIds.has(game.appid);
gameList.appendChild(h('div', { class: `sfd-pl-recent-card${isOwned ? '' : ' not-owned'}` }, [
(() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(),
h('div', { class: 'sfd-pl-recent-info' }, [
(() => { const el = h('a', { class: 'sfd-pl-recent-name', href: 'https://store.steampowered.com/app/' + game.appid, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(),
h('div', { class: 'sfd-pl-recent-meta' }, [
h('span', { class: 'sfd-pl-delisted-badge ' + badgeClass, text: typeLabel }),
game.delistedDate ? h('span', { text: game.delistedDate }) : null
])
])
]));
});
gameScroll.appendChild(gameList);
if (totalPages > 1) {
const pagi = createPagination(delistedPage, totalPages, (newPage) => { delistedPage = newPage; renderDelistedTab(); });
pagi.classList.add('sfd-compare-pagi');
pagi.style.borderTop = '1px solid var(--sfd-border)';
pagi.style.flexShrink = '0';
paginationWrap.appendChild(pagi);
}
}
content.appendChild(listScroll);
renderCompareList();
} catch (e) {
logger.error('游戏对比失败', e);
if (!overlay.isConnected) return;
content.innerHTML = '';
content.appendChild(h('div', { class: 'sfd-family-empty', text: t('compareError') }));
}
}
// ==================== 个人游戏库浮窗 (1.0.16) ====================
// 绝版游戏数据库 — 优先使用 GM_getResourceText,失败时回退到 gmFetch(与 steam-game-library-viewer 共享数据源)
let _delistedDB = null;
async function loadDelistedDB() {
if (_delistedDB) return _delistedDB;
try {
let jsonText = '';
if (typeof GM_getResourceText === 'function') {
try { jsonText = GM_getResourceText('delistedData'); }
catch (e) { jsonText = ''; }
}
if (!jsonText) {
jsonText = await gmFetch('https://leanisssharedstorage.blob.core.windows.net/copilot/asdm-files/steam_delisted_apps.json');
}
_delistedDB = JSON.parse(jsonText);
return _delistedDB;
} catch (e) {
logger.warn('加载绝版游戏库失败', e);
_delistedDB = {};
return _delistedDB;
}
}
// 兼容 library-viewer 新旧格式并标准化字段
function getDelistedApps() {
let rawApps = [];
if (Array.isArray(_delistedDB.data)) rawApps = _delistedDB.data;
else if (_delistedDB.delisted && Array.isArray(_delistedDB.delisted.apps)) rawApps = _delistedDB.delisted.apps;
return rawApps.map(app => ({
appid: app.appId || app.appid,
name: app.name,
type: app.delistType || app.type || 'Delisted',
changed: app.delistedAt || app.changed,
owners: app.ownerPercentage != null ? app.ownerPercentage + '%' : (app.owners || '-'),
achievements: app.achievementCount || app.achievements,
keyshopPrice: app.keyshopPrice,
source: app.source
}));
}
function getDelistedTypeKey(type) {
if (!type) return 'delisted';
if (typeof type === 'string' && type.includes('_')) return type;
const map = {
'Delisted': 'delisted',
'Purchase disabled': 'purchase_disabled',
'F2P (unavailable)': 'f2p_unavailable',
'Retail only': 'retail_only',
'Test app': 'test_app',
};
return map[type] || 'delisted';
}
function getDelistedTypeLabel(type) {
if (!type) return t('plDelistedTypeDelisted');
const map = {
'delisted': t('plDelistedTypeDelisted'),
'purchase_disabled': t('plDelistedTypePurchaseDisabled'),
'f2p_unavailable': t('plDelistedTypeF2p'),
'retail_only': t('plDelistedTypeRetail'),
'test_app': t('plDelistedTypeTest'),
};
return map[type] || type;
}
// 获取游戏图标 URL
function getGameIconUrl(appid, iconHash) {
if (iconHash) return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${iconHash}.jpg`;
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_sm_120.jpg`;
}
// 获取个人游戏库数据(带缓存)
const fetchMyOwnedGames = withCache({ memKey: 'personal.gamesCache' }, async () => {
const apiKey = storage.getApiKey();
if (!apiKey) return null;
const mySteamId = storage.getSteamId() || detectCurrentSteamId();
if (!mySteamId) return null;
try {
const r = await fetchOwnedGamesCached(mySteamId);
if (r.private || r.error) return null;
return { games: r.games, total: r.game_count, totalMinutes: r.totalMinutes };
} catch (e) {
logger.warn('获取自身游戏库失败', e);
return null;
}
});
// 获取最近游玩数据(共用 recent cache)
const fetchMyRecentGames = withCache({ memKey: 'personal.recentCache' }, async () => {
const apiKey = storage.getApiKey();
if (!apiKey) return null;
const mySteamId = storage.getSteamId() || detectCurrentSteamId();
if (!mySteamId) return null;
try {
const r = await fetchRecentCached(mySteamId);
return r.games || [];
} catch (e) {
logger.warn('获取最近游玩失败', e);
return null;
}
});
// ===== v1.2.1: 检测家庭共享对入库动态的影响 =====
// GetSharedLibraryApps 返回的 rt_time_acquired 是每个 app 的单个时间戳(秒),
// 当多名家庭成员拥有同一游戏时,该时间可能为最早入库者的时间,而非当前用户个人入库时间。
// 检测逻辑:标记 owners.length > 1 的共拥游戏,并通过个人许可页交叉验证日期。
// 解析许可页日期字符串为时间戳
function parseLicenseDate(dateStr) {
if (!dateStr) return 0;
// 中文格式: "2024年6月13日"
let m = dateStr.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime();
// 英文格式: "13 Jun 2024"
const months = { Jan:0, Feb:1, Mar:2, Apr:3, May:4, Jun:5, Jul:6, Aug:7, Sep:8, Oct:9, Nov:10, Dec:11 };
m = dateStr.match(/(\d{1,2})\s+(\w{3})\s+(\d{4})/);
if (m && months[m[2]] != null) return new Date(Number(m[3]), months[m[2]], Number(m[1])).getTime();
// 英文格式: "Jun 13, 2024"
m = dateStr.match(/(\w{3})\s+(\d{1,2}),?\s+(\d{4})/);
if (m && months[m[1]] != null) return new Date(Number(m[3]), months[m[1]], Number(m[2])).getTime();
return 0;
}
// 从许可页 HTML 中解析许可行
function parseLicenseRows(html, map) {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('.license_row').forEach(row => {
const link = row.querySelector('a[href*="/app/"]');
if (!link) return;
const appMatch = (link.getAttribute('href') || '').match(/\/app\/(\d+)/);
if (!appMatch) return;
const appid = Number(appMatch[1]);
const dateEl = row.querySelector('.license_date_col');
if (!dateEl) return;
const label = dateEl.querySelector('.license_date_col_label');
if (label) label.remove();
const dateText = (dateEl.textContent || '').trim();
const ts = parseLicenseDate(dateText);
if (appid && ts) map[appid] = { dateStr: dateText, ts };
});
} catch (e) { logger.silent('许可页 HTML 解析失败', e); }
}
// 获取个人许可页入库时间(通过 store.steampowered.com/account/licenses 抓取)
async function fetchPersonalLicenses() {
if (state.personal.licenseMap) return state.personal.licenseMap;
if (state.personal.licenseMapLoading) {
// 等待正在进行的请求
while (state.personal.licenseMapLoading) await new Promise(r => setTimeout(r, 200));
return state.personal.licenseMap || {};
}
state.personal.licenseMapLoading = true;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://store.steampowered.com/account/licenses/?l=schinese',
timeout: 15000,
onload(resp) {
try {
const html = resp.responseText || '';
const map = {};
parseLicenseRows(html, map);
// 提取 sessionid 用于 AJAX 分页
const sidMatch = html.match(/g_sessionID\s*=\s*"([^"]+)"/);
const sessionid = sidMatch ? sidMatch[1] : null;
// 检查是否有更多
const hasMore = html.includes('load_more_licenses') || html.includes('btn_load_more');
if (hasMore && sessionid) {
loadRemainingLicenses(sessionid, map, resolve);
} else {
state.personal.licenseMap = map;
state.personal.licenseMapLoading = false;
resolve(map);
}
} catch (e) {
logger.warn('解析许可页失败', e);
state.personal.licenseMapLoading = false;
resolve({});
}
},
onerror() { state.personal.licenseMapLoading = false; resolve({}); },
ontimeout() { state.personal.licenseMapLoading = false; resolve({}); }
});
});
}
// AJAX 分页加载剩余许可
function loadRemainingLicenses(sessionid, map, resolve, cursor) {
let pageCount = 0;
const loadNext = (cur) => {
if (pageCount >= MAX_LICENSE_PAGES) { state.personal.licenseMap = map; state.personal.licenseMapLoading = false; resolve(map); return; }
pageCount++;
GM_xmlhttpRequest({
method: 'POST',
url: 'https://store.steampowered.com/account/AjaxLoadMoreLicenses/',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: `sessionid=${encodeURIComponent(sessionid)}&cursor=${encodeURIComponent(cur || '')}`,
timeout: 15000,
onload(resp) {
try {
const data = JSON.parse(resp.responseText);
if (data.html) parseLicenseRows(data.html, map);
if (data.has_more && data.cursor) {
loadNext(data.cursor);
} else {
state.personal.licenseMap = map;
state.personal.licenseMapLoading = false;
resolve(map);
}
} catch (e) {
state.personal.licenseMap = map;
state.personal.licenseMapLoading = false;
resolve(map);
}
},
onerror() { state.personal.licenseMap = map; state.personal.licenseMapLoading = false; resolve(map); },
ontimeout() { state.personal.licenseMap = map; state.personal.licenseMapLoading = false; resolve(map); }
});
};
loadNext(cursor);
}
// 获取自己的完整入库历史:复用家庭组数据获取方法(从商店 webapi_token 鉴权),
// 调用 IFamilyGroupsService/GetSharedLibraryApps(include_own=true) 得到每个游戏的 rt_time_acquired 入库时间。
// v1.2.1: 新增家庭共享影响检测——标记共拥游戏(owners.length > 1),并通过个人许可页交叉验证日期。
const fetchMyLibraryHistory = withCache({ memKey: 'personal.licenseHistoryCache', dbKey: 'sfd_library_history_cache', ttl: LIBRARY_HISTORY_TTL }, async () => {
const mySteamId = storage.getSteamId() || detectCurrentSteamId();
if (!mySteamId) return null;
try {
const ok = await loadFamilyData(mySteamId);
const gl = state.family.gameList;
if (!ok || !gl || !gl.GameInfo) return null;
const items = [];
let coOwnedCount = 0;
for (const appid in gl.GameInfo) {
const info = gl.GameInfo[appid];
if (!info || !info.owners || !info.owners.includes(mySteamId)) continue;
const ts = (info.time || 0) * 1000; // rt_time_acquired(秒→毫秒)
const ownerCount = (info.owners || []).length;
const coOwned = ownerCount > 1;
if (coOwned) coOwnedCount++;
items.push({
appid: Number(appid),
name: info.name || `App ${appid}`,
ts,
dateStr: info.time ? (() => { const d = new Date(info.time * 1000); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; })() : '',
icon_hash: info.icon_hash || '',
ownerCount,
coOwned,
licenseTs: 0, // 个人许可页入库时间(0=未获取)
licenseDateStr: '', // 个人许可页日期字符串
dateMismatch: false, // 家庭组日期与个人许可日期不一致
familyTs: 0 // 原始家庭组API时间(被修正时保留)
});
}
items.sort((a, b) => (b.ts || 0) - (a.ts || 0));
// 记录共拥数量用于检测横幅显示
state.personal.coOwnedCount = coOwnedCount;
return items;
} catch (e) {
logger.warn('获取入库历史失败', e);
return null;
}
});
// v1.2.1: 用个人许可页数据交叉验证共拥游戏的入库日期
// 核心修复:GetSharedLibraryApps 的 rt_time_acquired 是所有拥有者中最新的入库时间,
// 当家庭成员新入库一个自己已拥有的游戏时,该时间会被刷新为家人的入库时间。
// 解决方案:用个人许可页(store.steampowered.com/account/licenses)的真实入库时间覆盖。
async function crossVerifyLicenses() {
const items = state.personal.licenseHistoryCache;
if (!items || items.length === 0) return;
const coOwnedItems = items.filter(it => it.coOwned);
if (coOwnedItems.length === 0) return; // 无共拥游戏,无需验证
try {
const licenseMap = await fetchPersonalLicenses();
if (!licenseMap || Object.keys(licenseMap).length === 0) return;
let verifiedCount = 0, mismatchCount = 0, correctedCount = 0;
for (const it of coOwnedItems) {
const lic = licenseMap[it.appid];
if (!lic) continue;
it.licenseTs = lic.ts;
it.licenseDateStr = lic.dateStr;
verifiedCount++;
if (it.ts && lic.ts) {
const d1 = new Date(it.ts); d1.setHours(0,0,0,0);
const d2 = new Date(lic.ts); d2.setHours(0,0,0,0);
if (d1.getTime() !== d2.getTime()) {
it.dateMismatch = true;
mismatchCount++;
}
}
// 核心修复:个人许可页时间才是自己真实入库时间,覆盖家庭组 API 时间
if (lic.ts && lic.ts !== it.ts) {
it.familyTs = it.ts; // 保留原始家庭组时间供参考
it.ts = lic.ts; // 用个人许可时间覆盖
it.dateStr = (() => { const d = new Date(lic.ts); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; })();
correctedCount++;
}
}
// 时间被修正后需要重新排序
if (correctedCount > 0) {
items.sort((a, b) => (b.ts || 0) - (a.ts || 0));
}
state.personal.licenseVerifiedCount = verifiedCount;
state.personal.licenseMismatchCount = mismatchCount;
state.personal.licenseCorrectedCount = correctedCount;
} catch (e) {
logger.warn('交叉验证许可失败', e);
}
}
// 检测个人绝版收藏(与 library-viewer 共享数据库并比对个人游戏库)
const fetchMyDelisted = withCache({ memKey: 'personal.delistedCache' }, async () => {
const ownedData = await fetchMyOwnedGames();
if (!ownedData || !ownedData.games.length) return null;
await loadDelistedDB();
const apps = getDelistedApps();
const myAppIds = new Set(ownedData.games.map(g => g.appid));
const owned = [];
const allApps = [];
for (const app of apps) {
if (!app.appid) continue;
const aid = Number(app.appid);
const entry = {
appid: aid,
name: app.name || `App ${aid}`,
delistedType: getDelistedTypeKey(app.type),
delistedDate: app.changed || '',
};
allApps.push(entry);
if (myAppIds.has(aid)) {
const gameInfo = ownedData.games.find(g => g.appid === aid) || {};
owned.push({
...entry,
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,
});
}
}
owned.sort((a, b) => (b.delistedDate || '').localeCompare(a.delistedDate || ''));
allApps.sort((a, b) => (b.delistedDate || '').localeCompare(a.delistedDate || ''));
return { owned, allApps, total: apps.length };
});
function renderPlaytimeBar(distData, total) {
if (total === 0) return h('div', { class: 'sfd-pl-empty', text: t('plNoData') });
const maxCount = Math.max(...distData.map(d => d.count), 1);
const container = h('div', { style: { padding: '4px 0' } });
for (const d of distData) {
const pct = total > 0 ? (d.count / total * 100).toFixed(1) : '0';
const barPct = maxCount > 0 ? (d.count / maxCount * 100).toFixed(1) : '0';
container.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '5px' } }, [
h('span', { style: { width: '70px', flexShrink: '0', fontSize: '11px', color: '#94a3b8', textAlign: 'right', whiteSpace: 'nowrap' }, text: d.label }),
h('div', { style: { flex: '1', height: '18px', background: 'rgba(255,255,255,0.04)', borderRadius: '4px', overflow: 'hidden', minWidth: '0' } }, [
h('div', { style: { height: '100%', width: barPct + '%', background: d.color, borderRadius: '4px', transition: 'width .3s ease' }, title: `${d.label}: ${d.count} (${pct}%)` })
]),
h('span', { style: { width: '64px', flexShrink: '0', fontSize: '11px', color: '#8a9ba8', textAlign: 'left', whiteSpace: 'nowrap' }, text: `${d.count} (${pct}%)` })
]));
}
return container;
}
function calcPlaytimeDist(games) {
const ranges = [
{ label: t('plRange1'), min: 0.01, max: 1, color: '#94a3b8' },
{ label: t('plRange2'), min: 1, max: 10, color: '#60a5fa' },
{ label: t('plRange3'), min: 10, max: 50, color: '#34d399' },
{ label: t('plRange4'), min: 50, max: 100, color: '#fbbf24' },
{ label: t('plRange5'), min: 100, max: 500, color: '#f97316' },
{ label: t('plRange6'), min: 500, max: Infinity, color: '#ef4444' }
];
const zeroCount = games.filter(g => g.playtime_forever === 0).length;
return [
{ label: t('plRange0'), count: zeroCount, color: '#64748b' },
...ranges.map(r => ({
label: r.label,
count: games.filter(g => {
const h = g.playtime_forever / 60;
return h >= r.min && h < r.max;
}).length,
color: r.color
}))
];
}
// ===== 主函数:展示个人游戏库浮窗 =====
function showPersonalLibrary() {
// 如果已存在浮窗,直接显示
if (state.personal.popupEl) {
state.personal.popupEl.classList.add('sfd-show');
return;
}
// 创建浮窗
const modal = createModal({
id: 'sfd-pl-popup',
cls: 'sfd-pl-popup',
headerCls: 'sfd-pl-header',
persistent: true,
titleHtml: `${ICONS.package} ${t('plTitle')}`,
headerActions: [
h('span', { class: 'sfd-dash-updated', id: 'sfd-pl-updated', text: '' }),
createRefreshBtn(() => { refreshPersonalLibraryWithProgress(); }, locale === 'zh-CN' ? '刷新数据' : 'Refresh', 'sfd-header-btn')
],
showProgress: true,
progressId: 'sfd-pl-progress',
progressBarId: 'sfd-pl-progress-bar',
contentId: 'sfd-pl-content',
bodyCls: 'sfd-pl-content',
exclusions: ['.sfd-trigger-library', '.sfd-panel', '.sfd-modal', '#sfd-compare-popup', '#sfd-member-games-popup', '#sfd-month-games-popup', '#sfd-my90-games-popup', '#sfd-family-popup'],
scrollSelector: '.sfd-pl-content, .sfd-pl-scroll'
});
state.personal.popupEl = modal.overlay;
state.personal.popupEl.classList.add('sfd-show');
// 初始渲染
state.personal.activeTab = 'all';
renderPersonalLibrary();
}
// 刷新个人游戏库数据(带进度条)
async function refreshPersonalLibraryWithProgress() {
const progEl = document.getElementById('sfd-pl-progress');
const barEl = document.getElementById('sfd-pl-progress-bar');
if (!progEl || !barEl) {
state.personal.gamesCache = null; state.personal.recentCache = null; state.personal.licenseHistoryCache = null; state.personal.delistedCache = null; state.personal.licenseMap = null; state.personal.coOwnedCount = 0; state.personal.licenseVerifiedCount = 0; state.personal.licenseMismatchCount = 0; state.personal.licenseCorrectedCount = 0; state.personal.activeTab = 'all'; _personalAppIdSet = null;
_dbDelete('sfd_library_history_cache');
renderPersonalLibrary(); return;
}
const setBar = (pct, cls) => { barEl.className = 'sfd-family-progress-bar' + (cls ? ' ' + cls : ''); barEl.style.width = pct + '%'; };
progEl.classList.add('active');
setBar(10, '');
state.personal.gamesCache = null; state.personal.recentCache = null; state.personal.licenseHistoryCache = null; state.personal.delistedCache = null; state.personal.licenseMap = null; state.personal.coOwnedCount = 0; state.personal.licenseVerifiedCount = 0; state.personal.licenseMismatchCount = 0; state.personal.licenseCorrectedCount = 0; state.personal.activeTab = 'all'; _personalAppIdSet = null;
_dbDelete('sfd_library_history_cache');
setBar(30, '');
try {
await fetchMyOwnedGames();
setBar(60, '');
if (state.personal.gamesCache) { try { await fetchMyRecentGames(); } catch (e) {} }
setBar(80, '');
try { await fetchMyLibraryHistory(); } catch (e) {}
setBar(95, '');
try { await fetchMyDelisted(); } catch (e) {}
setBar(100, 'done');
} catch (e) {
setBar(100, 'err');
}
setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 800);
renderPersonalLibrary();
}
// 渲染个人游戏库浮窗主体
function renderPersonalLibrary() {
if (!state.personal.popupEl) return;
// 更新数据时间显示
const updatedEl = document.getElementById('sfd-pl-updated');
if (updatedEl) {
const now = Date.now();
updatedEl.textContent = (locale === 'zh-CN' ? '更新于 ' : 'Updated ') + new Date(now).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
const content = state.personal.popupEl.querySelector('#sfd-pl-content');
content.innerHTML = '';
const apiKey = storage.getApiKey();
if (!apiKey) {
content.appendChild(h('div', { class: 'sfd-pl-empty', style: { textAlign: 'center', padding: '40px 20px' } }, [
h('div', { style: { fontSize: '32px', marginBottom: '8px' }, text: '🔑' }),
h('div', { text: t('plNeedKey') }),
h('a', { href: 'https://steamcommunity.com/dev/apikey', target: '_blank', style: { color: '#a78bfa', fontSize: '11px', marginTop: '6px', display: 'inline-block' }, text: 'steamcommunity.com/dev/apikey' })
]));
return;
}
content.replaceChildren(h('div', { class: 'sfd-pl-loading', html: `${ICONS.spinner}${t('plLoading')} ` }));
// 首次加载也显示上方进度条
const progEl = document.getElementById('sfd-pl-progress');
const barEl = document.getElementById('sfd-pl-progress-bar');
const setBar = (pct, cls) => { if (barEl) { barEl.className = 'sfd-family-progress-bar' + (cls ? ' ' + cls : ''); barEl.style.width = pct + '%'; } };
if (progEl) progEl.classList.add('active');
setBar(10, '');
fetchMyOwnedGames().then(async data => {
if (!content.isConnected) return; // 弹窗已关闭,中止回调
if (!data) {
if (progEl) { setBar(100, 'err'); setTimeout(() => { progEl.classList.remove('active'); setBar(0, ''); }, 1500); }
content.innerHTML = '';
content.appendChild(h('div', { class: 'sfd-pl-empty', style: { textAlign: 'center', padding: '40px 20px' } }, [
h('div', { style: { fontSize: '32px', marginBottom: '8px' }, text: '⚠️' }),
h('div', { text: t('plFetchError') })
]));
return;
}
setBar(40, '');
let recent2w = 0;
try { const rg = await fetchMyRecentGames(); recent2w = Array.isArray(rg) ? rg.length : 0; } catch (e) {}
setBar(70, '');
const unplayedCount = data.games.filter(g => g.playtime_forever === 0).length;
const avgHours = data.total > 0 ? (data.totalMinutes / 60 / data.total).toFixed(1) : '0';
const totalHours = (data.totalMinutes / 60).toFixed(1);
const unplayedRate = data.total > 0 ? (unplayedCount / data.total * 100).toFixed(1) : '0';
const playedCount = data.total - unplayedCount;
const zh = locale === 'zh-CN';
const allGames = (data.games || []).slice();
allGames.sort((a, b) => b.appid - a.appid);
const unplayed = allGames.filter(g => g.playtime_forever === 0);
const played = allGames.filter(g => g.playtime_forever > 0).sort((a, b) => b.playtime_forever - a.playtime_forever);
state.personal._allGames = allGames;
state.personal._played = played;
state.personal._unplayed = unplayed;
state.personal._totalHours = totalHours;
const tabs = h('div', { class: 'sfd-pl-tabs', id: 'sfd-pl-tabs' }, [
h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'all' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'all'; renderPersonalLibraryTab(); } }, [h('span', { text: `${zh ? '全部游戏' : 'All'} (${data.total})` })]),
h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'played' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'played'; renderPersonalLibraryTab(); } }, [h('span', { text: `${zh ? '已玩' : 'Played'} (${playedCount})` })]),
h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'unplayed' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'unplayed'; renderPersonalLibraryTab(); } }, [h('span', { text: `${zh ? '未玩' : 'Unplayed'} (${unplayedCount})` })]),
h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'recent' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'recent'; renderPersonalLibraryTab(); } }, [h('span', { text: t('plTabOverview') })]),
h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'playtime' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'playtime'; renderPersonalLibraryTab(); } }, [h('span', { text: t('plTabPlaytime') })]),
h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'activity' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'activity'; renderPersonalLibraryTab(); } }, [h('span', { text: t('plTabActivity') })]),
h('button', { class: `sfd-pl-tab ${state.personal.activeTab === 'delisted' ? 'active' : ''}`, onClick: () => { state.personal.activeTab = 'delisted'; renderPersonalLibraryTab(); } }, [h('span', { text: t('plTabDelisted') })]),
]);
content.innerHTML = '';
content.appendChild(tabs);
// 标签页内容容器
const tabContent = h('div', { class: 'sfd-pl-scroll', id: 'sfd-pl-tab-content' });
content.appendChild(tabContent);
setBar(100, 'done');
setTimeout(() => { if (progEl) { progEl.classList.remove('active'); setBar(0, ''); } }, 800);
renderPersonalLibraryTab();
});
}
function renderPersonalLibraryTab() {
const container = document.getElementById('sfd-pl-tab-content');
if (!container) return;
const tabOrder = ['all', 'played', 'unplayed', 'recent', 'playtime', 'activity', 'delisted'];
const tabs = document.querySelectorAll('#sfd-pl-tabs .sfd-pl-tab');
tabs.forEach((tab, index) => {
const key = tabOrder[index];
if (state.personal.activeTab === key) tab.classList.add('active');
else tab.classList.remove('active');
});
container.innerHTML = '';
container.style.cssText = '';
if (state.personal.activeTab === 'all') {
renderPLAllGamesTab(container);
} else if (state.personal.activeTab === 'played') {
renderPLPlayedTab(container);
} else if (state.personal.activeTab === 'unplayed') {
renderPLUnplayedTab(container);
} else if (state.personal.activeTab === 'recent') {
renderPLRecentTab(container);
} else if (state.personal.activeTab === 'playtime') {
renderPLPlaytimeTab(container);
} else if (state.personal.activeTab === 'activity') {
renderPLActivityTab(container);
} else if (state.personal.activeTab === 'delisted') {
renderPLDelistedTab(container);
}
}
// ===== 通用游戏网格渲染(复用好友对比窗口布局) =====
const PL_PAGE_SIZE = 25;
const _plPageVars = { all: 1, played: 1, unplayed: 1 };
function buildPLGameGrid(container, games, key) {
const pageVar = _plPageVars[key];
const zh = locale === 'zh-CN';
const totalPages = Math.ceil(games.length / PL_PAGE_SIZE);
if (pageVar > totalPages) _plPageVars[key] = totalPages || 1;
const start = (_plPageVars[key] - 1) * PL_PAGE_SIZE;
const pageItems = games.slice(start, start + PL_PAGE_SIZE);
const listScroll = h('div', { class: 'sfd-family-scroll', style: { overflow: 'hidden', display: 'flex', flexDirection: 'column' } });
const headerArea = h('div', { style: { flexShrink: '0' } });
const scrollArea = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } });
const pagiArea = h('div', { style: { flexShrink: '0', display: 'flex', justifyContent: 'flex-end' } });
listScroll.appendChild(headerArea);
listScroll.appendChild(scrollArea);
listScroll.appendChild(pagiArea);
// 游戏网格
const grid = h('div', { class: 'sfd-pl-recent-grid' });
if (games.length === 0) {
grid.appendChild(h('div', { class: 'sfd-pl-empty', text: zh ? '无游戏' : 'No games' }));
} else {
pageItems.forEach((game) => {
const isPlayed = game.playtime_forever > 0;
const grey = isPlayed ? '' : 'opacity:0.5;filter:grayscale(60%);';
const nameEl = h('a', {
class: 'sfd-pl-recent-name',
href: 'https://store.steampowered.com/app/' + game.appid,
target: '_blank',
text: game.name,
style: grey
});
loadGameZhName(nameEl, game.appid, game.name);
const cardWrapper = h('div', { class: 'sfd-pl-recent-card', style: 'position:relative' });
const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: function() { openStorePage(game.appid); }, style: grey });
const { wrapper } = wrapImgWithLoader(capImg, game.appid, 'sfd-img-cap');
wrapper.style.position = 'relative';
dashLoadCapsule(capImg, game.appid);
if (isPlayed && game.playtime_forever > 0) {
wrapper.appendChild(h('div', { style: { position: 'absolute', top: '4px', left: '4px', background: 'rgba(0,0,0,0.75)', color: '#34d399', fontSize: '10px', fontWeight: '700', padding: '1px 6px', borderRadius: '4px', pointerEvents: 'none', zIndex: '99' }, text: (game.playtime_forever / 60).toFixed(1) + 'h' }));
}
cardWrapper.appendChild(wrapper);
cardWrapper.appendChild(h('div', { class: 'sfd-pl-recent-info' }, [nameEl]));
grid.appendChild(cardWrapper);
});
}
scrollArea.appendChild(grid);
// 分页
if (totalPages > 1) {
const pagi = createPagination(_plPageVars[key], totalPages, function(newPage) {
_plPageVars[key] = newPage;
container.innerHTML = '';
buildPLGameGrid(container, games, key);
});
pagi.classList.add('sfd-compare-pagi');
pagi.style.borderTop = '1px solid var(--sfd-border)';
pagi.style.flexShrink = '0';
pagiArea.appendChild(pagi);
}
container.appendChild(listScroll);
}
// ===== 全部游戏标签页 =====
function renderPLAllGamesTab(container) {
_plPageVars.all = 1;
buildPLGameGrid(container, state.personal._allGames || [], 'all');
}
// ===== 已玩标签页 =====
function renderPLPlayedTab(container) {
_plPageVars.played = 1;
buildPLGameGrid(container, state.personal._played || [], 'played');
}
// ===== 未玩标签页 =====
function renderPLUnplayedTab(container) {
_plPageVars.unplayed = 1;
buildPLGameGrid(container, state.personal._unplayed || [], 'unplayed');
}
// ===== 最近游玩标签页 =====
function renderPLRecentTab(container) {
const data = state.personal.gamesCache;
if (!data) { container.replaceChildren(h('div', { class: 'sfd-pl-empty', text: t('plNoData') })); return; }
const recentSection = h('div', { class: 'sfd-pl-section' }, [
h('div', { class: 'sfd-pl-section-title', html: ICONS.game + ' ' + t('plRecentGames') })
]);
container.appendChild(recentSection);
const renderRecent = function(recentGames) {
recentSection.innerHTML = '';
recentSection.appendChild(h('div', { class: 'sfd-pl-section-title', html: ICONS.game + ' ' + t('plRecentGames') }));
if (!recentGames || recentGames.length === 0) {
recentSection.appendChild(h('div', { class: 'sfd-pl-empty', text: t('plNoRecent') }));
} else {
const grid = h('div', { class: 'sfd-pl-recent-grid' });
const zh = locale === 'zh-CN';
const total2w = recentGames.reduce(function(s, g) { return s + (g.playtime_2weeks || 0); }, 0);
recentGames.forEach(function(game) { grid.appendChild(buildRecentGameCard(game, total2w, zh)); });
recentSection.appendChild(grid);
}
};
if (state.personal.recentCache) {
renderRecent(state.personal.recentCache);
} else {
recentSection.appendChild(createLoadingEl(t('plLoading'), '#a78bfa', 'sfd-pl-loading', { padding: '20px' }));
fetchMyRecentGames().then(r => { if (container.isConnected) renderRecent(r); });
}
}
// ===== 游玩时长标签页 =====
function renderPLPlaytimeTab(container) {
const data = state.personal.gamesCache;
if (!data) { container.replaceChildren(h('div', { class: 'sfd-pl-empty', text: t('plNoData') })); return; }
const zh = locale === 'zh-CN';
const totalH = state.personal._totalHours || '0';
const games = data.games || [];
const maxGame = games.reduce((m, g) => g.playtime_forever > (m.playtime_forever || 0) ? g : m, {});
const maxH = maxGame.playtime_forever ? (maxGame.playtime_forever / 60).toFixed(1) : '0';
const recent2w = state.personal.recentCache ? state.personal.recentCache.reduce((s, g) => s + (g.playtime_2weeks || 0), 0) : 0;
const recent2wH = (recent2w / 60).toFixed(1);
const playedCount = games.filter(g => g.playtime_forever > 0).length;
const medianH = games.length > 0 ? (function() { const p = [...games].sort((a,b) => a.playtime_forever - b.playtime_forever); const mid = Math.floor(p.length / 2); return p.length % 2 ? (p[mid].playtime_forever / 60).toFixed(1) : ((p[mid-1].playtime_forever + p[mid].playtime_forever) / 120).toFixed(1); })() : '0';
const playRate = data.total > 0 ? (playedCount / data.total * 100).toFixed(1) : '0';
const kpiRow = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(6,1fr)', gap: '8px', marginBottom: '12px' } }, [
createMetricCard({ value: totalH, label: zh ? '总时长(h)' : 'Total(h)', accent: '#34d399' }),
createMetricCard({ value: recent2wH, label: zh ? '近2周(h)' : '2 Weeks(h)', accent: '#60a5fa' }),
createMetricCard({ value: maxH, label: `${zh ? '最长单游' : 'Longest'} ${maxGame.name ? '(' + maxGame.name.slice(0,6) + ')' : ''}`, accent: '#f59e0b' }),
createMetricCard({ value: (data.total > 0 ? (data.totalMinutes / 60 / data.total).toFixed(1) : '0'), label: zh ? '平均时长(h)' : 'Avg(h)', accent: '#a78bfa' }),
createMetricCard({ value: medianH, label: zh ? '中位数(h)' : 'Median(h)', accent: '#f87171' }),
createMetricCard({ value: playRate + '%', label: `${zh ? '游玩率' : 'Play Rate'} (${playedCount}/${data.total})`, accent: '#c084fc' }),
]);
container.appendChild(kpiRow);
const distData = calcPlaytimeDist(data.games);
const distSection = h('div', { class: 'sfd-pl-section' }, [
h('div', { class: 'sfd-pl-section-title', html: `${ICONS.barChart} ${t('plPlaytimeDist')}` })
]);
distSection.appendChild(renderPlaytimeBar(distData, data.total));
container.appendChild(distSection);
const sorted = [...data.games].sort((a, b) => b.playtime_forever - a.playtime_forever);
const top20 = sorted.slice(0, 20);
const maxPlaytime = top20.length > 0 ? top20[0].playtime_forever : 1;
const topSection = h('div', { class: 'sfd-pl-section' }, [
h('div', { class: 'sfd-pl-section-title', html: `${ICONS.trending} ${t('plTopGames')}` })
]);
if (top20.length === 0) {
topSection.appendChild(h('div', { class: 'sfd-pl-empty', text: t('plNoData') }));
} else {
const grid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: '5px' } });
top20.forEach((game, i) => {
const rankClass = i === 0 ? 'r1' : i === 1 ? 'r2' : i === 2 ? 'r3' : 'rN';
const pct = maxPlaytime > 0 ? (game.playtime_forever / maxPlaytime * 100).toFixed(1) : '0';
const playtimeH = game.playtime_forever > 0 ? (game.playtime_forever / 60).toFixed(1) + 'h' : t('plRange0');
grid.appendChild(h('div', { class: 'sfd-pl-game-item' }, [
h('div', { class: `sfd-pl-game-rank ${rankClass}`, text: String(i + 1) }),
(() => { const { wrapper } = createImgWrapper(game.appid, 'sfd-img-sm'); const img = wrapper.querySelector('img'); img.className = 'sfd-pl-game-icon'; const fallback = `https://cdn.cloudflare.steamstatic.com/steam/apps/${game.appid}/capsule_sm_120.jpg`; loadGameImage(wrapper, game.appid, getGameIconUrl(game.appid, game.img_icon_url), fallback, '', true); return wrapper; })(),
h('div', { class: 'sfd-pl-game-info' }, [
h('a', { class: 'sfd-pl-game-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }),
h('div', { class: 'sfd-pl-game-meta' }, [
h('span', { class: 'sfd-pl-game-playtime', text: playtimeH }),
game.rtime_last_played > 0 ? h('span', { class: 'sfd-pl-game-time', text: `· ${DateUtils.lastPlayed(game.rtime_last_played)}` }) : null
])
]),
h('div', { class: 'sfd-pl-game-bar-wrap' }, [
h('div', { class: 'sfd-pl-game-bar-fill', style: { width: pct + '%' } })
])
]));
});
topSection.appendChild(grid);
}
container.appendChild(topSection);
}
// ===== 入库动态标签页 =====
// v1.2.1: 新增家庭共享影响检测——标记共拥游戏,交叉验证个人许可页日期
function renderPLActivityTab(container) {
const isZh = locale === 'zh-CN';
const PAGE_SIZE = 30;
const timeOfDay = (ms) => {
if (!ms) return '';
const d = new Date(ms);
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
};
const fmtLicenseDate = (ts) => {
if (!ts) return '';
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
};
const renderContent = (allItems) => {
container.innerHTML = '';
container.style.overflow = 'hidden';
container.style.display = 'flex';
container.style.flexDirection = 'column';
const coOwnedCount = allItems.filter(it => it.coOwned).length;
// ===== 顶部工具栏:筛选卡片 + 搜索 =====
const soloCount = allItems.length - coOwnedCount;
const topBar = h('div', { style: { display: 'flex', gap: '8px', marginBottom: '10px', flexShrink: '0', alignItems: 'center' } });
container.appendChild(topBar);
const filterCardEls = [];
const makeFilterCard = (key, value, label, accent) => {
const card = createMetricCard({ value: String(value), label, accent, layout: 'compact', active: state.personal.activityFilter === key, onClick: () => { state.personal.activityFilter = key; state.personal.activityPage = 1; filterCardEls.forEach(c => c.classList.toggle('active', c._filterKey === key)); renderList(); } });
card._filterKey = key;
filterCardEls.push(card);
topBar.appendChild(card);
};
makeFilterCard('all', allItems.length, t('plHistoryTotal'), '#a78bfa');
if (coOwnedCount > 0) {
makeFilterCard('coOwned', coOwnedCount, t('plHistoryFilterCoOwned'), '#fbbf24');
makeFilterCard('solo', soloCount, t('plHistoryFilterSolo'), '#34d399');
}
const searchInput = h('input', { type: 'text', class: 'sfd-input', placeholder: isZh ? '搜索游戏名称...' : 'Search game name...', value: state.personal.activitySearch, style: { flex: '1', boxSizing: 'border-box', minWidth: '0' }, onInput: (e) => { state.personal.activitySearch = e.target.value; state.personal.activityPage = 1; renderList(); } });
topBar.appendChild(searchInput);
const scrollArea = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } });
container.appendChild(scrollArea);
const paginationWrap = h('div', { style: { flexShrink: '0', display: 'flex', justifyContent: 'flex-end' } });
container.appendChild(paginationWrap);
const renderList = () => {
scrollArea.innerHTML = '';
paginationWrap.innerHTML = '';
const q = (state.personal.activitySearch || '').trim().toLowerCase();
let items = (allItems || []).filter(it => !q || (it.name || '').toLowerCase().includes(q));
// 应用筛选
if (state.personal.activityFilter === 'coOwned') items = items.filter(it => it.coOwned);
else if (state.personal.activityFilter === 'solo') items = items.filter(it => !it.coOwned);
if (items.length === 0) {
scrollArea.appendChild(h('div', { class: 'sfd-pl-empty', text: t('plHistoryEmpty') }));
return;
}
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
if (state.personal.activityPage > totalPages) state.personal.activityPage = totalPages;
if (state.personal.activityPage < 1) state.personal.activityPage = 1;
const start = (state.personal.activityPage - 1) * PAGE_SIZE;
const pageItems = items.slice(start, start + PAGE_SIZE);
const list = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px' } });
let curDate = null;
pageItems.forEach(it => {
const dateKey = it.dateStr || (isZh ? '未知日期' : 'Unknown');
if (dateKey !== curDate) {
curDate = dateKey;
list.appendChild(h('div', { class: 'sfd-pl-timeline-divider', style: { gridColumn: '1 / -1' } }, [
h('div', { class: 'sfd-pl-timeline-line' }),
h('span', { class: 'sfd-pl-timeline-date', text: dateKey }),
h('div', { class: 'sfd-pl-timeline-line' })
]));
}
// 构建角标
const infoBadges = [];
let posterBadge = null;
const posterBadgeStyle = { position: 'absolute', top: '4px', left: '4px', zIndex: '5', textShadow: '0 1px 3px rgba(0,0,0,0.8)', pointerEvents: 'none' };
if (it.coOwned) {
posterBadge = h('span', { class: 'sfd-pl-coowned-badge', title: t('plHistoryCoOwnedTip'), style: posterBadgeStyle, text: `👥 ${it.ownerCount}${isZh ? '人共拥' : ''}` });
if (it.dateMismatch) {
infoBadges.push(h('span', { class: 'sfd-pl-mismatch-badge', text: `⚠ ${t('plHistoryDateMismatch')}` }));
}
} else {
posterBadge = h('span', { class: 'sfd-pl-solo-badge', style: posterBadgeStyle, text: `✓ ${t('plHistorySolo')}` });
}
// 构建元信息行:显示修正后的入库时间(个人许可页时间)
const metaChildren = [
h('span', { style: { color: '#94a3b8' }, text: timeOfDay(it.ts) })
];
// 共拥游戏:显示家庭组原始时间(被刷新的)作为参考
if (it.coOwned && it.familyTs) {
metaChildren.push(h('span', { style: { color: '#ef4444', fontSize: '9px', fontWeight: '600', textDecoration: 'line-through' }, title: isZh ? '家庭组API返回的时间(已被修正)' : 'Family API time (corrected)', text: `⊘ ${fmtLicenseDate(it.familyTs)}` }));
metaChildren.push(h('span', { style: { color: '#34d399', fontSize: '9px', fontWeight: '600' }, text: `→ ${t('plHistoryLicenseDate')}: ${fmtLicenseDate(it.ts)}` }));
} else if (it.coOwned && it.licenseTs) {
metaChildren.push(h('span', { style: { color: it.dateMismatch ? '#ef4444' : '#34d399', fontSize: '9px', fontWeight: '600' }, text: `${t('plHistoryLicenseDate')}: ${fmtLicenseDate(it.licenseTs)}` }));
} else {
metaChildren.push(h('span', { style: { color: '#34d399', fontWeight: '700' }, text: '✓' }));
}
list.appendChild(h('div', { class: 'sfd-pl-recent-card', style: { position: 'relative', ...(it.coOwned ? { borderColor: 'rgba(245,158,11,0.2)' } : {}) } }, [
(() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(it.appid) }); return dashLoadCapsule(capImg, it.appid); })(),
posterBadge,
h('div', { class: 'sfd-pl-recent-info' }, [
...(infoBadges.length > 0 ? [h('div', { style: { display: 'flex', alignItems: 'center', gap: '4px', flexWrap: 'wrap' } }, infoBadges)] : []),
(() => { const el = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${it.appid}`, target: '_blank', rel: 'noopener noreferrer', text: it.name, onClick: (e) => { e.preventDefault(); e.stopPropagation(); openStorePage(it.appid); } }); loadGameZhName(el, it.appid, it.name); return el; })(),
h('div', { class: 'sfd-pl-recent-meta', style: { justifyContent: 'space-between' } }, metaChildren)
])
]));
});
scrollArea.appendChild(list);
if (totalPages > 1) {
const pagi = createPagination(state.personal.activityPage, totalPages, (p) => { state.personal.activityPage = p; renderList(); });
paginationWrap.appendChild(pagi);
}
};
renderList();
// ===== 异步交叉验证(仅当有共拥游戏且尚未验证时) =====
if (coOwnedCount > 0 && !state.personal.licenseMap && !state.personal.licenseMapLoading) {
crossVerifyLicenses().then(() => {
if (container.isConnected) renderList(); // 容器仍存活才重渲染
});
}
};
if (state.personal.licenseHistoryCache) {
renderContent(state.personal.licenseHistoryCache);
} else {
container.appendChild(createLoadingEl(t('plHistoryLoading'), '#a78bfa', 'sfd-pl-loading', { padding: '30px' }));
fetchMyLibraryHistory().then(d => { if (container.isConnected) renderContent(d); });
}
}
// ===== 绝版收藏标签页 =====
function renderPLDelistedTab(container) {
const renderContent = (data) => {
container.innerHTML = '';
container.style.overflow = 'hidden';
container.style.display = 'flex';
container.style.flexDirection = 'column';
const allTypes = [
{ key: 'delisted', color: '#f44336' },
{ key: 'purchase_disabled', color: '#ff9800' },
{ key: 'f2p_unavailable', color: '#ce93d8' },
{ key: 'retail_only', color: '#64b5f6' },
{ key: 'test_app', color: '#90a4ae' }
];
const ownedIds = new Set(data.owned.map(g => g.appid));
const allApps = data.allApps || [];
const missingApps = allApps.filter(g => !ownedIds.has(g.appid));
const scopeBase = state.personal.delistedScope === 'all' ? allApps :
state.personal.delistedScope === 'missing' ? missingApps : data.owned;
const cardsRow = h('div', { class: 'sfd-delisted-cards', style: { display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap', flexShrink: '0', alignItems: 'center' } }, [
createMetricCard({ value: String(data.total), label: t('plDelistedTotal'), accent: '#f44336', layout: 'compact', active: state.personal.delistedScope === 'all', onClick: () => { state.personal.delistedScope = 'all'; state.personal.delistedTypeFilter = 'all'; state.personal.delistedPage = 1; renderPLDelistedTab(container); } }),
createMetricCard({ value: String(data.owned.length), label: t('plDelistedOwned'), accent: '#34d399', layout: 'compact', active: state.personal.delistedScope === 'owned', onClick: () => { state.personal.delistedScope = 'owned'; state.personal.delistedTypeFilter = 'all'; state.personal.delistedPage = 1; renderPLDelistedTab(container); } }),
createMetricCard({ value: String(data.total - data.owned.length), label: t('plDelistedMissing'), accent: '#94a3b8', layout: 'compact', active: state.personal.delistedScope === 'missing', onClick: () => { state.personal.delistedScope = 'missing'; state.personal.delistedTypeFilter = 'all'; state.personal.delistedPage = 1; renderPLDelistedTab(container); } }),
h('div', { style: { width: '1px', height: '32px', background: 'rgba(255,255,255,0.15)', flexShrink: '0' } }),
...allTypes.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: state.personal.delistedTypeFilter === key, onClick: () => { state.personal.delistedTypeFilter = state.personal.delistedTypeFilter === key ? 'all' : key; state.personal.delistedPage = 1; renderPLDelistedTab(container); } });
})
]);
container.appendChild(cardsRow);
if (scopeBase.length === 0) {
container.appendChild(h('div', { class: 'sfd-pl-empty', text: t('plNoData') }));
return;
}
let filtered = scopeBase;
if (state.personal.delistedTypeFilter !== 'all') {
filtered = filtered.filter(g => g.delistedType === state.personal.delistedTypeFilter);
}
const totalPages = Math.max(1, Math.ceil(filtered.length / state.personal.delistedPageSize));
if (state.personal.delistedPage > totalPages) state.personal.delistedPage = totalPages;
const start = (state.personal.delistedPage - 1) * state.personal.delistedPageSize;
const pageItems = filtered.slice(start, start + state.personal.delistedPageSize);
const scrollArea = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } });
const list = h('div', { class: 'sfd-pl-recent-grid' });
pageItems.forEach(game => {
const typeLabel = getDelistedTypeLabel(game.delistedType);
const isOwned = ownedIds.has(game.appid);
list.appendChild(h('div', { class: `sfd-pl-recent-card${isOwned ? '' : ' not-owned'}` }, [
(() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(),
h('div', { class: 'sfd-pl-recent-info' }, [
(() => { const el = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(),
h('div', { class: 'sfd-pl-recent-meta' }, [
h('span', { class: `sfd-pl-delisted-badge type-${game.delistedType}`, text: typeLabel }),
isOwned && game.playtime_forever > 0 ? h('span', { text: DateUtils.durationShort(game.playtime_forever) }) : null,
game.delistedDate ? h('span', { text: game.delistedDate }) : null
])
])
]));
});
scrollArea.appendChild(list);
container.appendChild(scrollArea);
const paginationWrap = h('div', { style: { flexShrink: '0', display: 'flex', justifyContent: 'flex-end' } });
if (totalPages > 1) {
const pagi = createPagination(state.personal.delistedPage, totalPages, (newPage) => { state.personal.delistedPage = newPage; renderPLDelistedTab(container); });
pagi.classList.add('sfd-pagination');
pagi.style.justifyContent = 'center';
pagi.style.padding = '8px 12px';
paginationWrap.appendChild(pagi);
}
container.appendChild(paginationWrap);
};
if (state.personal.delistedCache) {
renderContent(state.personal.delistedCache);
} else {
container.appendChild(createLoadingEl(t('plDelistedLoading'), '#a78bfa', 'sfd-pl-loading', { padding: '30px' }));
fetchMyDelisted().then(d => { if (container.isConnected) renderContent(d); });
}
}
// ==================== 通用游戏列表弹窗 ====================
function showGameListPopup(id, titleHtml, games, options = {}) {
const fi = state.family.info;
if (!fi) return;
const isZh = locale === 'zh-CN';
const { showOwnerTabs = false, emptyText = '' } = options;
// 构建拥有者筛选标签(在 createModal 之前,作为 headerActions 传入)
let headerActions = [];
let activeOwnerFilter = 0;
let tabBtns = [];
if (showOwnerTabs && games.length > 0) {
const countMap = {};
games.forEach(g => {
const n = Math.min(g.owners.length, 6);
countMap[n] = (countMap[n] || 0) + 1;
});
const tabsWrap = h('div', { class: 'sfd-family-tabs', style: { margin: '0', gap: '4px', flexWrap: 'nowrap' } });
const btnStyle = { flex: 'none', padding: '6px 14px', minWidth: '44px', textAlign: 'center' };
const allTabBtn = h('button', {
class: 'sfd-family-tab active',
style: btnStyle,
onClick: () => { activeOwnerFilter = 0; tabBtns.forEach(b => b.classList.remove('active')); allTabBtn.classList.add('active'); renderGamesList(); }
}, [h('span', { text: isZh ? '全部' : 'All' })]);
tabBtns.push(allTabBtn);
tabsWrap.appendChild(allTabBtn);
for (let i = 1; i <= 6; i++) {
const n = i;
const hasGames = countMap[n] > 0;
const btn = h('button', {
class: 'sfd-family-tab',
style: { ...btnStyle, opacity: hasGames ? '1' : '0.4', cursor: hasGames ? 'pointer' : 'default' },
disabled: !hasGames,
onClick: hasGames ? () => { activeOwnerFilter = n; tabBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderGamesList(); } : null
}, [h('span', { text: `${n}${isZh ? '人' : ''}` })]);
tabBtns.push(btn);
tabsWrap.appendChild(btn);
}
headerActions = [tabsWrap];
}
// 使用 createModal 创建弹窗(统一处理 outsideClick、ESC、关闭按钮)
const { body } = createModal({
id, titleHtml, headerActions,
bodyStyle: { padding: '0' }
});
const listWrap = h('div', { style: { overflow: 'auto', flex: '1', minHeight: '0', padding: '8px 12px' } });
const pagiEl = h('div', { style: { display: 'flex', justifyContent: 'center', padding: '4px 12px 8px', flexShrink: '0' } });
const GAME_PAGE_SIZE = 20;
let gamePage = 1;
function renderGamesList() {
listWrap.innerHTML = '';
const filtered = activeOwnerFilter === 0 ? games : games.filter(g => g.owners.length === activeOwnerFilter);
if (filtered.length === 0) {
listWrap.appendChild(h('div', { class: 'sfd-family-empty', text: emptyText || (isZh ? '暂无游戏数据' : 'No game data') }));
if (pagiEl) { pagiEl.innerHTML = ''; }
return;
}
const totalPages = Math.ceil(filtered.length / GAME_PAGE_SIZE);
if (gamePage > totalPages) gamePage = totalPages;
const start = (gamePage - 1) * GAME_PAGE_SIZE;
const pageItems = filtered.slice(start, start + GAME_PAGE_SIZE);
const grid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px' } });
pageItems.forEach(game => {
grid.appendChild(createFamilyGameCard(game, fi, CHART_COLORS));
});
listWrap.appendChild(grid);
pagiEl.innerHTML = '';
const pagi = createPagination(gamePage, totalPages, (newPage) => { gamePage = newPage; renderGamesList(); });
pagiEl.appendChild(pagi);
}
renderGamesList();
body.appendChild(listWrap);
body.appendChild(pagiEl);
}
// ==================== 成员游戏列表弹窗 ====================
function showMemberGames(steamid, memberName) {
const fi = state.family.info;
const gl = state.family.gameList;
if (!fi || !gl) return;
const isZh = locale === 'zh-CN';
// 绝版模式下仅筛选绝版游戏
let delistedAppIds = null;
if (state.family.chartShowDelisted) {
const delistedApps = getDelistedApps();
delistedAppIds = new Set(delistedApps.map(app => Number(app.appid)));
}
// 筛选该成员拥有的游戏
const memberGames = [];
for (let appid in gl.GameInfo) {
if (delistedAppIds && !delistedAppIds.has(Number(appid))) continue;
const info = gl.GameInfo[appid];
if (info.owners && info.owners.includes(steamid)) {
memberGames.push({ appid: Number(appid), name: info.name, time: info.time, owners: info.owners, icon_hash: info.icon_hash });
}
}
memberGames.sort((a, b) => (b.time || 0) - (a.time || 0));
const titleHtml = `${ICONS.family} ${memberName}${isZh ? '的' : "'s "}${state.family.chartShowDelisted ? (isZh ? '绝版游戏' : 'Delisted Games') : (isZh ? '游戏' : 'Games')} (${memberGames.length}) `;
showGameListPopup('sfd-member-games-popup', titleHtml, memberGames, { showOwnerTabs: true });
}
// ==================== 月度入库游戏弹窗 ====================
function showMonthGames(month) {
const fi = state.family.info;
const gl = state.family.gameList;
if (!fi || !gl || !gl.GameInfo) return;
const isZh = locale === 'zh-CN';
// 筛选该月入库的游戏
const [yearStr, monStr] = month.split('-');
const year = Number(yearStr);
const mon = Number(monStr);
const monthGames = [];
for (let appid in gl.GameInfo) {
const info = gl.GameInfo[appid];
if (!info.time || info.time <= 0) continue;
const d = new Date(info.time * 1000);
if (d.getFullYear() === year && d.getMonth() + 1 === mon) {
monthGames.push({ appid: Number(appid), name: info.name, time: info.time, owners: info.owners, icon_hash: info.icon_hash });
}
}
monthGames.sort((a, b) => (b.time || 0) - (a.time || 0));
const titleMonth = isZh ? `${year}年${mon}月` : `${month}`;
const titleHtml = `${ICONS.calendar} ${titleMonth}${isZh ? '入库游戏' : ' Added Games'} (${monthGames.length}) `;
showGameListPopup('sfd-month-games-popup', titleHtml, monthGames, { emptyText: isZh ? '该月无入库游戏' : 'No games added this month' });
}
// ==================== 90天我贡献的游戏弹窗 ====================
function showMy90Games() {
const fi = state.family.info;
const gl = state.family.gameList;
if (!fi || !gl || !gl.GameInfo) return;
const isZh = locale === 'zh-CN';
const mySteamId = (state.friends.ownProfile && state.friends.ownProfile.steamid) || state.family.popupSteamid;
const cutoff90 = Date.now() / 1000 - 90 * SECONDS_PER_DAY;
const myGames = [];
for (let appid in gl.GameInfo) {
const info = gl.GameInfo[appid];
if (info.time && info.time >= cutoff90 && (info.owners || [])[0] === String(mySteamId)) {
myGames.push({ appid: Number(appid), name: info.name, time: info.time, owners: info.owners, icon_hash: info.icon_hash });
}
}
myGames.sort((a, b) => (b.time || 0) - (a.time || 0));
const titleHtml = `${ICONS.trophy} ${isZh ? '90天我贡献的游戏' : 'My Games Added (90d)'} (${myGames.length}) `;
showGameListPopup('sfd-my90-games-popup', titleHtml, myGames, { emptyText: isZh ? '近90天无我贡献的游戏' : 'No games added by me in the last 90 days' });
}
function renderFamilyDynamicTab() {
const fi = state.family.info;
const gl = state.family.gameList;
if (!fi || !gl) return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无数据' : 'No data' });
const isZh = locale === 'zh-CN';
// 按日期分组(忽略无入库时间的天数),按时间倒序排列
const dateGroups = new Map();
const allGames = gl.GameList
.map(appid => {
const info = gl.GameInfo[appid];
if (!info || !info.time) return null;
return { appid: Number(appid), name: info.name, time: info.time, owners: info.owners, icon_hash: info.icon_hash };
})
.filter(g => g !== null)
.sort((a, b) => (b.time || 0) - (a.time || 0));
allGames.forEach(game => {
const dateKey = new Date(game.time * 1000).toLocaleDateString(isZh ? 'zh-CN' : 'en-US', { year: 'numeric', month: '2-digit', day: '2-digit' });
if (!dateGroups.has(dateKey)) dateGroups.set(dateKey, []);
dateGroups.get(dateKey).push(game);
});
const dates = [...dateGroups.keys()]; // 已按游戏时间倒序排列
const DAYS_PER_PAGE = state.family.dynamicPageSize; // 5天
const totalPages = Math.max(1, Math.ceil(dates.length / DAYS_PER_PAGE));
let page = state.family.dynamicPage;
if (page > totalPages) page = totalPages;
if (page < 1) page = 1;
state.family.dynamicPage = page;
const start = (page - 1) * DAYS_PER_PAGE;
const pageDates = dates.slice(start, start + DAYS_PER_PAGE);
const listWrap = h('div', { class: 'sfd-family-game-list', style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px', padding: '8px' } });
pageDates.forEach(dateKey => {
const dayGames = dateGroups.get(dateKey) || [];
listWrap.appendChild(h('div', { class: 'sfd-pl-timeline-divider', style: { gridColumn: '1 / -1' } }, [
h('div', { class: 'sfd-pl-timeline-line' }),
h('span', { class: 'sfd-pl-timeline-date', text: dateKey }),
h('div', { class: 'sfd-pl-timeline-line' })
]));
dayGames.forEach(game => {
listWrap.appendChild(createFamilyGameCard(game, fi, CHART_COLORS));
});
});
const nav = createPagination(page, totalPages, (newPage) => { state.family.dynamicPage = newPage; renderFamilyPopup(); });
nav.classList.add('sfd-family-page-nav');
// 滚动容器与 grid 分离:scrollWrap 负责滚动,listWrap 负责网格布局,避免 grid 被 flex 挤压
const scrollWrap = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } });
scrollWrap.appendChild(listWrap);
const container = h('div', { style: { display: 'flex', flexDirection: 'column', flex: '1', minHeight: '0', overflow: 'hidden' } });
container.appendChild(scrollWrap);
container.appendChild(nav);
return container;
}
// 公共:构建"最近游玩"卡片(含进度条)
function buildRecentGameCard(game, total2w, isZh) {
const recentH = game.playtime_2weeks > 0 ? DateUtils.durationShort(game.playtime_2weeks) : '-';
const totalH = game.playtime_forever > 0 ? DateUtils.durationShort(game.playtime_forever) : '-';
const pct = total2w > 0 ? Math.round((game.playtime_2weeks || 0) / total2w * 100) : 0;
const nameEl = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name });
loadGameZhName(nameEl, game.appid, game.name);
return h('div', { class: 'sfd-pl-recent-card' }, [
(() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => window.open(`https://store.steampowered.com/app/${game.appid}`, '_blank') }); return dashLoadCapsule(capImg, game.appid); })(),
h('div', { class: 'sfd-pl-recent-info' }, [
nameEl,
h('div', { class: 'sfd-pl-recent-meta' }, [
h('span', { text: `${isZh ? '近2周' : '2wks'}: ${recentH}` }),
h('span', { text: `${isZh ? '总计' : 'Total'}: ${totalH}` }),
h('span', { class: 'sfd-pl-recent-pct', text: `${isZh ? '本周占比' : 'Weekly Share'}: ${pct}%` })
]),
h('div', { class: 'sfd-pl-recent-bar' }, [h('div', { class: 'sfd-pl-recent-fill', style: { width: pct + '%' } })])
])
]);
}
// ==================== 家庭组许可增长曲线 ====================
function renderFamilyGrowthTab() {
const fi = state.family.info;
const gl = state.family.gameList;
if (!gl || !gl.GameInfo) return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无数据' : 'No data' });
const isZh = locale === 'zh-CN';
const members = fi ? fi.family_member : [];
const colors = CHART_COLORS;
const games = Object.entries(gl.GameInfo)
.map(([appid, info]) => ({ appid: Number(appid), time: info.time, owners: info.owners }))
.filter(g => g.time > 0)
.sort((a, b) => a.time - b.time);
if (games.length === 0) return h('div', { class: 'sfd-family-empty', text: isZh ? '无时间数据' : 'No time data' });
const nowSec = Date.now() / 1000;
// 按月聚合(仅计入首次入库成员,后续重复购买不计数)
const monthlyMap = new Map();
games.forEach(g => {
const d = new Date(g.time * 1000);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!monthlyMap.has(key)) monthlyMap.set(key, { all: 0, members: {} });
const entry = monthlyMap.get(key);
entry.all++;
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);
// 网格线(暗科技风)
let grid = '';
for (let i = 0; i <= 5; i++) {
const y = pT + cH - (cH * i / 5);
const v = Math.round(maxVal / 5 * i);
grid += `${v} `;
}
// 总计虚线
const totalPoints = cumAll.map((val, i) => `${pL + i * step},${pT + cH - (val / maxVal) * cH}`).join(' ');
// 总计:发光底层 + 虚线顶层
let lines = ` `;
// 每人曲线 + 渐变填充区域
let areas = '';
members.forEach((m, mi) => {
const c = colors[mi % colors.length];
const pts = cumMembers[m.steamid].map((val, i) => `${pL + i * step},${pT + cH - (val / maxVal) * cH}`).join(' ');
lines += ` `;
// 填充区域
const areaBottom = `${pL + (sorted.length - 1) * step},${pT + cH}`;
const areaPts = cumMembers[m.steamid].map((val, i) => `${pL + i * step},${pT + cH - (val / maxVal) * cH}`).join(' ');
areas += ` `;
});
// X 轴年份标签
let xLabels = '', lastYear = '';
sorted.forEach(([m], i) => {
const year = m.split('-')[0];
if (year !== lastYear) {
xLabels += `${year} `;
lastYear = year;
}
});
// 坐标轴线
const axisLines = ` `;
const svgStr = `${defs} ${grid}${areas}${lines}${axisLines}${xLabels} `;
// 左侧:曲线卡片
const chartCard = h('div', { class: 'sfd-family-stat-card', style: { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column', padding: '12px' } });
chartCard.insertAdjacentHTML('beforeend', `${isZh ? '许可累计增长曲线' : 'License Growth Curve'}
`);
chartCard.insertAdjacentHTML('beforeend', svgStr);
// 右侧:90天贡献成员对比 + 图例(仅计入首次入库成员)
const cutoff90 = nowSec - 90 * SECONDS_PER_DAY;
const memberRecent = {};
members.forEach(m => { memberRecent[m.steamid] = 0; });
for (let key in gl.GameInfo) {
const game = gl.GameInfo[key];
if (game.time && game.time >= cutoff90) {
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', `${isZh ? '90天贡献成员对比' : '90-Day Contribution'}
`);
// 横向条形图列表
const rowsWrap = h('div', { style: { flex: '1', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '10px', paddingRight: '4px', minHeight: '0' } });
allMembers.forEach((m, i) => {
const count = memberRecent[m.steamid] || 0;
const pct = m.pending ? 0 : Math.max(3, Math.round(count / maxRecent * 100));
const color = m.pending ? '#64748b' : colors[i % colors.length];
const sidStr = String(m.steamid);
const avatar = !m.pending && avatarMap[sidStr] ? avatarMap[sidStr] : null;
rowsWrap.appendChild(
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', opacity: m.pending ? '0.5' : '1' } }, [
h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px' } }, [
avatar
? h('img', { src: avatar, style: { width: '24px', height: '24px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${color}`, flexShrink: '0' }, loading: 'lazy' })
: h('div', { style: { width: '24px', height: '24px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: `2px dashed ${color}`, flexShrink: '0' }, html: ICONS.userPlaceholder }),
h('span', { style: { color: '#e2e8f0', fontWeight: '600', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: '1', minWidth: '0' }, text: m.pending ? (isZh ? '待加入' : 'Empty') : m.userName }),
h('span', { style: { color: color, fontWeight: '700', flexShrink: '0', fontVariantNumeric: 'tabular-nums' }, text: String(count) })
]),
h('div', { style: { height: '6px', borderRadius: '3px', background: 'rgba(255,255,255,0.04)', overflow: 'hidden' } }, [
h('div', { style: { height: '100%', borderRadius: '3px', background: `linear-gradient(90deg,${color},${color}88)`, width: pct + '%', boxShadow: `0 0 6px ${color}44` } })
])
])
);
});
rightCard.appendChild(rowsWrap);
// 底部图例(和贡献分布图例风格一致,横跨左侧卡片)
const legendItems = [` ${isZh ? '总计' : 'Total'} `];
members.forEach((m, mi) => {
const c = colors[mi % colors.length];
legendItems.push(` ${m.userName} `);
});
chartCard.insertAdjacentHTML('beforeend', `${legendItems.join('')}
`);
// 右侧列容器:最新入库 + 90天贡献
const rightColumn = h('div', { style: { display: 'flex', flexDirection: 'column', gap: '14px', width: '300px', flexShrink: '0' } });
// 最新入库卡片(复用 createFamilyGameCard 入库动态样式)
const latestGame = games.length > 0 ? games[games.length - 1] : null;
if (latestGame) {
rightColumn.appendChild(createFamilyGameCard(latestGame, fi, CHART_COLORS));
}
rightColumn.appendChild(rightCard);
const wrap = h('div', { style: { display: 'flex', gap: '14px', flex: '1', minHeight: '0' } });
wrap.appendChild(chartCard);
wrap.appendChild(rightColumn);
return wrap;
}
// ==================== 家庭组入库月度饼图 ====================
function renderFamilyHeatmapTab() {
const fi = state.family.info;
const gl = state.family.gameList;
if (!gl || !gl.GameInfo) return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无数据' : 'No data' });
const isZh = locale === 'zh-CN';
const members = fi ? fi.family_member : [];
const colors = CHART_COLORS;
if (members.length === 0) return h('div', { class: 'sfd-family-empty', text: isZh ? '无成员数据' : 'No member data' });
// 成员颜色映射
const memberColor = {};
members.forEach((m, i) => { memberColor[m.steamid] = colors[i % colors.length]; });
// 按月聚合每个成员的入库数(仅计入首次入库成员,后续重复购买不计数)
const monthlyMap = new Map();
Object.entries(gl.GameInfo).forEach(([appid, info]) => {
if (!info.time || info.time <= 0) return;
const d = new Date(info.time * 1000);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!monthlyMap.has(key)) monthlyMap.set(key, {});
const entry = monthlyMap.get(key);
const firstOwner = (info.owners || [])[0];
if (firstOwner) entry[firstOwner] = (entry[firstOwner] || 0) + 1;
});
const sortedMonths = [...monthlyMap.keys()].sort((a, b) => b.localeCompare(a));
if (sortedMonths.length === 0) return h('div', { class: 'sfd-family-empty', text: isZh ? '无时间数据' : 'No time data' });
// 计算峰值(单月单成员最高入库数)
let maxCount = 1;
sortedMonths.forEach(month => {
const entry = monthlyMap.get(month);
members.forEach(m => { const c = entry[m.steamid] || 0; if (c > maxCount) maxCount = c; });
});
// 图例(成员颜色)
const legendItems = members.map((m, i) => {
const c = colors[i % colors.length];
return ` ${esc(m.userName)} `;
}).join('');
// 单月饼图 SVG 生成
function buildPieSvg(month, entry) {
const slices = members
.map(m => ({ name: m.userName, count: entry ? (entry[m.steamid] || 0) : 0, color: memberColor[m.steamid] }))
.filter(s => s.count > 0);
const total = slices.reduce((s, sl) => s + sl.count, 0);
if (total === 0) return null;
const r = 38, cx = 50, cy = 50;
let paths = '';
if (slices.length === 1) {
const sl = slices[0];
paths += `${esc(sl.name)}: ${sl.count} (100%) `;
} else {
let startAngle = -Math.PI / 2;
slices.forEach(sl => {
const angle = (sl.count / total) * Math.PI * 2;
const endAngle = startAngle + angle;
const x1 = cx + r * Math.cos(startAngle);
const y1 = cy + r * Math.sin(startAngle);
const x2 = cx + r * Math.cos(endAngle);
const y2 = cy + r * Math.sin(endAngle);
const largeArc = angle > Math.PI ? 1 : 0;
const pct = Math.round(sl.count / total * 100);
paths += `${esc(sl.name)}: ${sl.count} (${pct}%) `;
startAngle = endAngle;
});
}
paths += `${total} `;
return `${paths} `;
}
// 按年分组(年份倒序)
const yearSet = [...new Set(sortedMonths.map(m => m.split('-')[0]))].sort((a, b) => b.localeCompare(a));
const yearGroupsHtml = yearSet.map(year => {
let items = '';
for (let mon = 1; mon <= 12; mon++) {
const monthStr = String(mon).padStart(2, '0');
const key = `${year}-${monthStr}`;
const entry = monthlyMap.get(key);
const svg = buildPieSvg(key, entry);
const monthLabel = isZh ? `${mon}月` : monthStr;
if (svg) {
items += ``;
} else {
items += ``;
}
}
const yearTotal = sortedMonths.filter(m => m.startsWith(year + '-')).reduce((s, m) => {
const e = monthlyMap.get(m);
return s + members.reduce((ss, mm) => ss + (e[mm.steamid] || 0), 0);
}, 0);
return `
${year}${isZh ? '入库' : 'added'} ${yearTotal}
${items}
`;
}).join('');
// 统计摘要
const totalGames = Object.keys(gl.GameInfo).length;
const wrap = h('div', { class: 'sfd-heat-pie-wrap' });
wrap.insertAdjacentHTML('beforeend', `${legendItems}
`);
const scroll = h('div', { class: 'sfd-heat-pie-scroll' });
scroll.insertAdjacentHTML('beforeend', yearGroupsHtml);
wrap.appendChild(scroll);
wrap.insertAdjacentHTML('beforeend', `${isZh ? '总计' : 'Total'}: ${totalGames} ${isZh ? '月份数' : 'Months'}: ${sortedMonths.length} ${isZh ? '峰值' : 'Peak'}: ${maxCount} ${isZh ? '款/月' : '/mo'}
`);
// 饼图点击:展示该月入库游戏
wrap.addEventListener('click', (e) => {
const item = e.target.closest('[data-month]');
if (item) {
const month = item.getAttribute('data-month');
if (month) showMonthGames(month);
}
});
return wrap;
}
// ==================== 家庭组游玩动态渲染 ====================
function renderFamilyPlayActivityTab() {
const fi = state.family.info;
if (!fi || !fi.family_member || fi.family_member.length === 0) {
return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无成员数据' : 'No member data' });
}
const isZh = locale === 'zh-CN';
const container = h('div', { style: { flex: '1', overflowY: 'auto', minHeight: '0' } });
if (state.family.playActivityLoading) {
container.appendChild(h('div', { class: 'sfd-family-loading', html: `${ICONS.spinner}${t('familyPlayLoading')} ` }));
return container;
}
// 无数据时直接触发加载(不再显示按钮)
if (!state.family.playActivity || Object.keys(state.family.playActivity).length === 0) {
if (!state.family.playActivityLoading) {
// 先尝试从缓存加载
const cached = storage.getFamilyPlayActivityCache();
if (cached && cached.steamid === state.family.popupSteamid && Date.now() - cached.timestamp < FAMILY_PLAY_TTL) {
state.family.playActivity = cached.data;
} else {
loadFamilyPlayActivity(state.family.popupSteamid).then(() => { if (familyPopupEl && familyPopupEl.classList.contains('sfd-show')) renderFamilyPopup(); });
}
}
// 显示加载动画(如果仍在加载中或刚触发)
if (state.family.playActivityLoading || !state.family.playActivity) {
container.appendChild(h('div', { class: 'sfd-family-loading', html: `${ICONS.spinner}${t('familyPlayLoading')} ` }));
return container;
}
}
// 遍历每个成员
const selfSteamId = String(state.family.popupSteamid);
const vacCacheData = storage.getVACCache();
const levelCacheData = storage.getLevelCache();
// 统一头像映射(复用 buildAvatarMap)
const memberAvatarMap = buildAvatarMap();
// 预计算各成员时长,用于进度条对比
// playtime_2weeks: 近2周时长(分钟), playtime_forever: 该游戏总时长(分钟)
// 总时长 = 最近玩过的所有游戏 playtime_2weeks 之和(避免 forever 之和过大无意义)
const playMembers = fillPendingSlots(fi.family_member);
let max2weeks = 0, maxGames = 0;
playMembers.forEach(member => {
if (member.pending) return;
const md = state.family.playActivity[member.steamid];
if (md && md.games) {
const s2 = md.games.reduce((s, g) => s + (g.playtime_2weeks || 0), 0);
if (s2 > max2weeks) max2weeks = s2;
if (md.games.length > maxGames) maxGames = md.games.length;
}
});
playMembers.forEach((member, idx) => {
const memberSid = String(member.steamid);
const isSelf = memberSid === selfSteamId;
const isPending = !!member.pending;
const memberData = !isPending ? state.family.playActivity[member.steamid] : null;
const hasGames = memberData && memberData.games && memberData.games.length > 0;
// 该成员近2周总时长 + 游戏数
const mem2weeks = hasGames ? memberData.games.reduce((s, g) => s + (g.playtime_2weeks || 0), 0) : 0;
const memGames = hasGames ? memberData.games.length : 0;
const pct2 = max2weeks > 0 ? Math.round(mem2weeks / max2weeks * 100) : 0;
const pctG = maxGames > 0 ? Math.round(memGames / maxGames * 100) : 0;
// 占位成员:灰色头像 + 待加入文字 + 无进度条
if (isPending) {
container.appendChild(h('div', { class: 'sfd-family-play-member collapsed', style: { opacity: '0.5' } }, [
h('div', { class: 'sfd-family-play-member-header', onClick: (e) => { e.currentTarget.parentElement.classList.toggle('collapsed'); } }, [
h('div', { style: { width: '32px', height: '32px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px dashed rgba(100,116,139,0.4)', flexShrink: '0' }, html: ICONS.userPlaceholder }),
h('div', { class: 'sfd-family-play-member-info' }, [
h('div', { class: 'sfd-family-play-member-name-row' }, [
h('span', { class: 'sfd-family-play-member-name', style: { color: '#64748b', fontStyle: 'italic' }, text: isZh ? '待加入' : 'Empty' })
])
])
]),
h('div', { class: 'sfd-family-play-member-games-wrap' }, [
h('div', { class: 'sfd-family-empty', style: { padding: '8px 12px', fontSize: '12px', color: '#475569' }, text: isZh ? '等待新成员加入家庭组' : 'Waiting for new member to join' })
])
]));
return;
}
// 头像与名称:复用 memberAvatarMap(与游戏拥有者头像同方法)
let avatarSrc = memberAvatarMap[memberSid] || `https://avatars.steamstatic.com/fef49e7fa7e1997310dd48961da2e7d95a5c7a56_medium.jpg`;
let memberName = (fi.steamIdtoName && fi.steamIdtoName[memberSid]) || member.userName || ('ID:' + memberSid.slice(-4));
let level = 0, vacBanned = false, gameBans = 0, friendDays = '', countryFlag = '', countryName = '';
// 补充 ownProfile / friendsData 的额外信息(等级、VAC、国家、好友天数)
if (isSelf && state.friends.ownProfile) {
const p = state.friends.ownProfile;
memberName = p.personaname || memberName;
level = p.level || 0;
countryFlag = p.country_flag || '';
countryName = p.country_name || '';
}
const fd = (!isSelf && state.friends.data) ? _friendsMap.get(memberSid) : null;
if (fd) {
memberName = fd.personaname || memberName;
level = fd.level || 0;
vacBanned = fd.vac_banned || false;
gameBans = fd.vac_game_bans || 0;
friendDays = fd.friend_days_text || '';
countryFlag = fd.country_flag || '';
countryName = fd.country_name || '';
}
// 数据源3:VAC/Level 缓存补充(自己和好友都补充)
const vacData = vacCacheData[memberSid] || vacCacheData[member.steamid];
if (vacData) {
vacBanned = vacBanned || vacData.VACBanned || false;
gameBans = gameBans || (vacData.NumberOfGameBans || 0);
}
// levelCacheData[sid] 存的是数字(等级值),不是对象
const lvlVal = levelCacheData[memberSid] || levelCacheData[member.steamid];
if (lvlVal && !level) level = lvlVal;
// 兜底:家庭组基础数据
if (isSelf) friendDays = '';
else friendDays = friendDays || (fd ? fd.friend_days_text : '');
// 家庭组游戏库拥有者映射
const gl = state.family.gameList;
const gameInfoMap = gl && gl.GameInfo ? gl.GameInfo : {};
const memberCard = h('div', { class: 'sfd-family-play-member collapsed' }, [
// 成员头部(增强版:头像+名称+标签+刷新)— 点击折叠/展开
h('div', { class: 'sfd-family-play-member-header', onClick: (e) => { e.currentTarget.parentElement.classList.toggle('collapsed'); } }, [
h('img', { class: 'sfd-family-play-member-avatar' + (vacBanned ? ' sfd-avatar-banned' : ''), src: avatarSrc, loading: 'lazy', onerror: `this.onerror=null;this.src='data:image/svg+xml, '` }),
h('div', { class: 'sfd-family-play-member-info' }, [
h('div', { class: 'sfd-family-play-member-name-row' }, [
h('span', { class: 'sfd-family-play-member-name', text: memberName }),
vacBanned ? h('span', { class: 'sfd-family-play-vac', title: 'VAC Banned', html: ICONS.shieldRed }) : gameBans > 0 ? h('span', { class: 'sfd-family-play-vac', title: `Dev Ban x${gameBans}`, html: ICONS.shieldDevBan }) : null,
level > 0 ? h('span', { class: 'sfd-family-play-level', title: `Level ${level}`, html: `${ICONS.level} ${level}` }) : null,
countryFlag ? h('span', { class: 'sfd-family-play-member-flag', title: countryName, html: countryFlag }) : null
]),
friendDays ? h('div', { class: 'sfd-family-play-member-days', text: '🤝' + friendDays }) : null
]),
// 进度条:最近2周 + 总时长对比
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', flex: '1', maxWidth: '420px', minWidth: '200px' } }, [
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
h('span', { style: { fontSize: '9px', color: '#64748b', whiteSpace: 'nowrap', width: '42px', flexShrink: '0' }, text: isZh ? '近2周' : '2w' }),
h('div', { style: { flex: '1', height: '6px', background: 'rgba(255,255,255,0.06)', borderRadius: '3px', overflow: 'hidden' } }, [
h('div', { style: { height: '100%', width: pct2 + '%', background: 'linear-gradient(90deg,#10b981,#34d399)', borderRadius: '3px', opacity: '0.7', transition: 'width 0.4s ease' } })
]),
h('span', { style: { fontSize: '10px', fontWeight: '600', color: '#34d399', whiteSpace: 'nowrap', width: '48px', textAlign: 'right', flexShrink: '0' }, text: DateUtils.duration(mem2weeks) })
]),
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
h('span', { style: { fontSize: '9px', color: '#64748b', whiteSpace: 'nowrap', width: '42px', flexShrink: '0' }, text: isZh ? '游戏数' : 'Games' }),
h('div', { style: { flex: '1', height: '6px', background: 'rgba(255,255,255,0.06)', borderRadius: '3px', overflow: 'hidden' } }, [
h('div', { style: { height: '100%', width: pctG + '%', background: 'linear-gradient(90deg,#f59e0b,#fbbf24)', borderRadius: '3px', opacity: '0.7', transition: 'width 0.4s ease' } })
]),
h('span', { style: { fontSize: '10px', fontWeight: '600', color: '#fbbf24', whiteSpace: 'nowrap', width: '48px', textAlign: 'right', flexShrink: '0' }, text: String(memGames) })
])
]),
createRefreshBtn((e) => { e.stopPropagation(); loadFamilyPlayActivity(state.family.popupSteamid, true).then(() => { if (familyPopupEl && familyPopupEl.classList.contains('sfd-show')) renderFamilyPopup(); }); }, t('familyPlayRefresh'), 'sfd-family-play-refresh')
]),
// 游戏列表(可折叠容器,gameInfoMap 在外部定义)
h('div', { class: 'sfd-family-play-member-games-wrap' }, [
hasGames ? h('div', { class: 'sfd-family-play-member-games' }, memberData.games.map(game => {
// 优先 header.jpg(大图),回退 img_icon_url(社区域名),再回退 capsule
const iconUrl = game.img_icon_url
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/${game.img_icon_url}.jpg`
: `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/capsule_sm_120.jpg`;
// 从家庭组游戏库查找拥有者
const gi = gameInfoMap[game.appid] || gameInfoMap[String(game.appid)];
const owners = gi && gi.owners ? gi.owners : [];
const ownerTitle = owners.map(sid => fi.steamIdtoName[sid] || fi.steamIdtoName[String(sid)] || 'ID:' + String(sid).slice(-4)).join(', ');
const ownerAvatars = owners.slice(0, 5).map((sid, idx) => {
const sidStr = String(sid);
const av = memberAvatarMap[sidStr] || `https://avatars.steamstatic.com/${sidStr}.jpg`;
const nm = fi.steamIdtoName[sid] || fi.steamIdtoName[sidStr] || 'ID:' + sidStr.slice(-4);
return h('img', { src: av, style: { width: '18px', height: '18px', borderRadius: '50%', objectFit: 'cover', flexShrink: '0', border: '1px solid rgba(15,23,42,0.9)' }, loading: 'lazy', title: nm, onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" });
});
return h('div', { class: 'sfd-family-play-game-item' }, [
(() => { const img = h('img', { class: 'sfd-family-play-game-icon', loading: 'lazy' }); dashLoadCapsule(img, game.appid); return img; })(),
h('div', { class: 'sfd-family-play-game-info' }, [
(() => { const el = h('a', { class: 'sfd-family-play-game-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(),
h('div', { class: 'sfd-family-play-game-meta' }, [
h('span', { class: 'sfd-family-play-game-playtime', text: `${DateUtils.duration(game.playtime_2weeks)}` }),
h('span', { class: 'sfd-family-play-game-playtime-total', text: `/ ${DateUtils.duration(game.playtime_forever)}h` })
]),
ownerAvatars.length > 0 ? h('div', { style: { display: 'flex', gap: '3px', marginTop: '2px' }, title: ownerTitle }, ownerAvatars) : null
])
]);
}))
: h('div', { class: 'sfd-family-empty', style: { padding: '8px 12px', fontSize: '12px' }, text: isZh ? '近2周无游玩记录' : 'No play in last 2 weeks' })
])
]);
container.appendChild(memberCard);
});
// 如果所有成员都没有游玩数据
if (container.children.length === 0) {
container.appendChild(h('div', { class: 'sfd-family-empty', text: t('familyPlayEmpty') }));
}
return container;
}
// ==================== 24小时入库热力图 ====================
function renderFamily24hHeatmap() {
const fi = state.family.info;
const gl = state.family.gameList;
const isZh = locale === 'zh-CN';
if (!gl || !gl.GameInfo || !fi || !fi.family_member) return null;
const members = fi.family_member;
const idMap = fi.steamIdtoName || {};
const avatarMap = buildAvatarMap();
// 统计全家每小时入库数
const familyCounts = new Array(24).fill(0);
// 统计每个成员每小时入库数(仅计入首次入库成员)
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);
});
function hexToRgba(hex, a) {
const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${a})`;
}
// count / rowMax,每行独立缩放
function memberHeatColor(count, hex, rowMax) {
if (count === 0) return 'rgba(255,255,255,0.03)';
const r = count / rowMax;
if (r < 0.2) return hexToRgba(hex, 0.18);
if (r < 0.4) return hexToRgba(hex, 0.35);
if (r < 0.6) return hexToRgba(hex, 0.55);
if (r < 0.8) return hexToRgba(hex, 0.78);
return hex;
}
// SVG 布局:左侧头像+昵称区 + 24 列方格
const cellSize = 26, cellGap = 4;
const cols = 24;
const labelW = 120; // 左侧头像+昵称区宽度
const hourLabelH = 18; // 顶部小时标签高度
const rowH = cellSize + cellGap;
const gridW = cols * (cellSize + cellGap) - cellGap;
const gridX = labelW + 8; // 网格起始 x(与左侧标签区留间隔)
const nameMaxW = labelW - (cellSize + 12) - 6; // 昵称可用宽度
const nameFontSize = 11;
// 按视觉宽度截断昵称(CJK 全宽,ASCII 半宽)
function truncateName(str) {
let w = 0;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
const isCJK = (ch >= 0x4E00 && ch <= 0x9FFF) || (ch >= 0x3000 && ch <= 0x30FF) || (ch >= 0xFF00 && ch <= 0xFFEF);
const cw = isCJK ? nameFontSize : nameFontSize * 0.55;
if (w + cw > nameMaxW - nameFontSize) return str.slice(0, i) + '…';
w += cw;
}
return str;
}
const allMembers = fillPendingSlots(members);
const totalRows = 1 + allMembers.length; // 全家 + 各成员 = 7 行
const svgW = gridX + gridW + 8;
const svgH = hourLabelH + totalRows * rowH + 4;
let svg = '';
// 顶部小时标签 0-23
for (let h = 0; h < 24; h++) {
const x = gridX + h * (cellSize + cellGap);
svg += `${h} `;
}
// 第 1 行:全家(不带名字,左侧留空)
const familyRowY = hourLabelH;
for (let h = 0; h < 24; h++) {
const x = gridX + h * (cellSize + cellGap);
const count = familyCounts[h];
const fill = heatColor(count);
svg += `${isZh ? '全家' : 'All'} ${h}:00 — ${count} ${isZh ? '款' : 'games'} `;
if (count > 0) {
const tc = count / maxCount >= 0.4 ? '#fff' : '#64748b';
svg += `${count} `;
}
}
// 各成员行(左侧头像 + 昵称)
allMembers.forEach((m, mi) => {
const rowIdx = mi + 1;
const rowY = hourLabelH + rowIdx * rowH;
const counts = m.pending ? new Array(24).fill(0) : (memberCounts[m.steamid] || new Array(24).fill(0));
const name = familyNameOf(m, idMap);
const av = m.pending ? null : (avatarMap[String(m.steamid)] || DEFAULT_AVATAR);
const mHex = m.pending ? '#64748b' : (memberColorMap[m.steamid] || CHART_COLORS[mi % CHART_COLORS.length]);
const rowMax = memberMaxMap[m.steamid] || 1; // 该成员自身峰值
// 左侧头像(用 foreignObject + img,兼容跨域头像;待加入用统一人形占位)
const avSize = cellSize - 4; // 头像略小于方格
const iconSize = Math.round(avSize * 0.6);
if (av) {
svg += ` `;
} else {
// 待加入:与加入时间/成员活跃度统一的人形占位头像
svg += `${ICONS.userPlaceholder}
`;
}
// 昵称(用成员固定色,强化与方格色的关联;过长截断省略号)
const nameFill = m.pending ? '#64748b' : mHex;
const displayName = m.pending ? name : truncateName(name);
svg += `${esc(displayName)} `;
// 24 方格(按该成员自身峰值缩放)
for (let h = 0; h < 24; h++) {
const x = gridX + h * (cellSize + cellGap);
const count = counts[h];
const fill = m.pending ? 'rgba(100,116,139,0.08)' : memberHeatColor(count, mHex, rowMax);
svg += `${esc(name)} ${h}:00 — ${count} ${isZh ? '款' : 'games'} `;
if (count > 0 && !m.pending) {
const tc = count / rowMax >= 0.4 ? '#fff' : '#64748b';
svg += `${count} `;
}
}
});
const card = h('div', { class: 'sfd-heatmap-card' });
card.insertAdjacentHTML('beforeend',
`${ICONS.clock}${isZh ? '24小时入库热力图' : '24h Acquisition Heatmap'} ${isZh ? '数字' : 'Nums'}
` +
`${isZh ? `第一行为全家合计(琥珀色),下方各成员使用专属固定色(峰值 ${peakHour}:00,${maxCount} 款)` : `Top row: family total (amber), below: per-member with own color (peak at ${peakHour}:00, ${maxCount} games)`}
`
);
const wrap = h('div', { style: { overflowX: 'auto', minWidth: '0' } });
wrap.insertAdjacentHTML('beforeend',
`${svg} `
);
card.appendChild(wrap);
// 数字显示开关(默认打开)
const toggle = card.querySelector('.sfd-hm-toggle');
const track = card.querySelector('.sfd-hm-track');
const dot = card.querySelector('.sfd-hm-dot');
if (toggle) {
let showNums = true;
// 初始化:默认显示数字
card.querySelectorAll('.sfd-hm-num').forEach(el => { el.style.display = ''; });
if (track) { track.style.background = '#3b82f6'; }
if (dot) { dot.style.left = '18px'; dot.style.background = '#fff'; }
toggle.addEventListener('click', () => {
showNums = !showNums;
card.querySelectorAll('.sfd-hm-num').forEach(el => { el.style.display = showNums ? '' : 'none'; });
if (track) { track.style.background = showNums ? '#3b82f6' : '#334155'; }
if (dot) { dot.style.left = showNums ? '18px' : '2px'; dot.style.background = showNums ? '#fff' : '#94a3b8'; }
});
}
// 热力图滚动:wrap 只有 overflowX:auto,垂直滚轮自然冒泡到父容器,无需拦截
card.insertAdjacentHTML('beforeend',
`` +
`${isZh ? '总计' : 'Total'}: ${totalGames} ` +
`${isZh ? '峰值时段' : 'Peak'}: ${peakHour}:00 ` +
`
`
);
return card;
}
// ==================== 热力图(成员洞察卡片) ====================
function renderFamilyHeatmapGrid() {
const fi = state.family.info;
const gl = state.family.gameList;
const isZh = locale === 'zh-CN';
const members = fi ? fi.family_member : [];
const colors = CHART_COLORS;
if (!gl || !gl.GameInfo || members.length === 0) return null;
const heatMembers = fillPendingSlots(members);
// 按月聚合每个成员的入库数(仅计入首次入库成员,后续重复购买不计数)
const monthlyMap = new Map();
Object.entries(gl.GameInfo).forEach(([appid, info]) => {
if (!info.time || info.time <= 0) return;
const d = new Date(info.time * 1000);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!monthlyMap.has(key)) monthlyMap.set(key, {});
const entry = monthlyMap.get(key);
const firstOwner = (info.owners || [])[0];
if (firstOwner) entry[firstOwner] = (entry[firstOwner] || 0) + 1;
});
const sortedMonths = [...monthlyMap.keys()].sort((a, b) => a.localeCompare(b));
if (sortedMonths.length === 0) return null;
// 全局峰值(用于卡片副标题统计展示)
let maxCount = 1;
sortedMonths.forEach(month => {
const entry = monthlyMap.get(month);
members.forEach(m => { const c = entry[m.steamid] || 0; if (c > maxCount) maxCount = c; });
});
// 各成员自身峰值(各行独立热力,互不影响)
const memberMaxMap = {};
members.forEach(m => {
let mm = 1;
sortedMonths.forEach(month => {
const entry = monthlyMap.get(month);
const c = entry ? (entry[m.steamid] || 0) : 0;
if (c > mm) mm = c;
});
memberMaxMap[m.steamid] = mm;
});
const years = [...new Set(sortedMonths.map(m => m.split('-')[0]))].sort();
const cellSize = 20, cellGap = 3, monthLabelW = 28, yearLabelH = 18, memberLabelH = 22;
const blockH = memberLabelH + yearLabelH + 12 * (cellSize + cellGap) + 6;
const heatW = monthLabelW + years.length * (cellSize + cellGap) + 10;
const memberSvgs = heatMembers.map((m, mi) => {
const isPending = !!m.pending;
const c = isPending ? '#64748b' : colors[mi % colors.length];
const rowMax = memberMaxMap[m.steamid] || 1; // 该成员自身峰值
let s = `${esc(m.userName)} `;
years.forEach((year, yi) => {
const x = monthLabelW + yi * (cellSize + cellGap);
s += `${year.slice(-2)} `;
});
for (let mon = 12; mon >= 1; mon--) {
const monthStr = String(mon).padStart(2, '0');
const rowIdx = 12 - mon;
const y = memberLabelH + yearLabelH + rowIdx * (cellSize + cellGap);
s += `${mon}月 `;
years.forEach((year, yi) => {
const key = `${year}-${monthStr}`;
const entry = monthlyMap.get(key);
const count = isPending ? 0 : (entry ? (entry[m.steamid] || 0) : 0);
const x = monthLabelW + yi * (cellSize + cellGap);
if (isPending) {
s += ` `;
} else {
const opacity = count > 0 ? Math.max(0.15, count / rowMax) : 0;
const fill = count > 0 ? c : 'rgba(255,255,255,0.03)';
s += `${count > 0 ? `${count} ` : ''}${esc(m.userName)}: ${count} ${isZh ? '款' : 'games'} (${key}) `;
}
});
}
return `${s} `;
});
const legendItems = members.map((m, mi) => {
const c = colors[mi % colors.length];
return ` ${esc(m.userName)} `;
}).join('');
const totalGames = Object.keys(gl.GameInfo).length;
const card = h('div', { class: 'sfd-heatmap-card' });
card.insertAdjacentHTML('beforeend',
`${ICONS.grid}${isZh ? '入库热力图' : 'Acquisition Heatmap'}
` +
`${isZh ? `按成员×月份展示入库数,各成员独立按自身峰值缩放(全局峰值 ${maxCount} 款/月)` : `Member×month grid, each scaled by own peak (global peak ${maxCount}/mo)`}
`
);
const grid = h('div', { class: 'sfd-heatmap-grid' });
memberSvgs.forEach(svg => {
const cell = h('div', { class: 'sfd-heatmap-cell' });
cell.insertAdjacentHTML('beforeend', svg);
grid.appendChild(cell);
});
card.appendChild(grid);
card.insertAdjacentHTML('beforeend', `${legendItems}
`);
card.insertAdjacentHTML('beforeend', `${isZh ? '总计' : 'Total'}: ${totalGames} ${isZh ? '月份数' : 'Months'}: ${sortedMonths.length} ${isZh ? '峰值' : 'Peak'}: ${maxCount} ${isZh ? '款/月' : '/mo'}
`);
return card;
}
// ==================== 成员洞察 ====================
// 公共:成员名称获取(避免重复定义)
function familyNameOf(m, idMap) {
return m.userName || (idMap && idMap[m.steamid]) || ('ID:' + String(m.steamid).slice(-4));
}
// 公共:补齐占位成员到 MAX_FAMILY(6人),不足的用"待加入"占位
const MAX_FAMILY_SLOTS = 6;
function fillPendingSlots(members) {
const result = [...members];
while (result.length < MAX_FAMILY_SLOTS) {
result.push({ steamid: 'pending_' + result.length, userName: locale === 'zh-CN' ? '待加入' : 'Empty', pending: true });
}
return result;
}
// 公共:构建 steamid -> avatar 映射(避免重复定义)
function buildAvatarMap() {
const map = {};
if (state.friends.ownProfile && state.friends.ownProfile.steamid) map[String(state.friends.ownProfile.steamid)] = state.friends.ownProfile.avatar || state.friends.ownProfile.avatarfull;
if (state.friends.data) state.friends.data.forEach(f => { if (f.steamid) map[String(f.steamid)] = f.avatar; });
// 家庭成员头像(可能不在好友列表中)
if (state.family.info && state.family.info.steamIdtoAvatar) {
for (const sid in state.family.info.steamIdtoAvatar) {
if (state.family.info.steamIdtoAvatar[sid] && !map[sid]) map[sid] = state.family.info.steamIdtoAvatar[sid];
}
}
return map;
}
// 计算成员活跃度
function computeMemberActivity(familyInfo, gameInfo) {
const members = (familyInfo && Array.isArray(familyInfo.family_member)) ? familyInfo.family_member : [];
const idMap = (familyInfo && familyInfo.steamIdtoName) ? familyInfo.steamIdtoName : {};
const nowSec = Date.now() / 1000;
const isZh = locale === 'zh-CN';
const result = { members: [], activeCount: 0, warmCount: 0, coldCount: 0, dormantCount: 0, healthScore: 0, statusLabels: { active: isZh ? '活跃' : 'Active', warm: isZh ? '温热' : 'Warm', cold: isZh ? '冷淡' : 'Cold', dormant: isZh ? '沉睡' : 'Dormant' } };
members.forEach(m => {
let sid = m.steamid, games = 0, times = [];
for (const appid in gameInfo) {
const g = gameInfo[appid];
if (g.owners && g.owners.indexOf(sid) !== -1) {
games++;
if (g.time > 0) times.push(g.time);
}
}
const latestTime = times.length > 0 ? Math.max(...times) : -1;
const firstTime = times.length > 0 ? Math.min(...times) : -1;
const daysSinceLatest = latestTime > 0 ? Math.floor((nowSec - latestTime) / SECONDS_PER_DAY) : -1;
let status, statusLabel;
if (daysSinceLatest < 0) { status = 'dormant'; statusLabel = isZh ? '从未入库' : 'Never'; result.dormantCount++; }
else if (daysSinceLatest < 14) { status = 'active'; statusLabel = result.statusLabels.active; result.activeCount++; }
else if (daysSinceLatest < 60) { status = 'warm'; statusLabel = result.statusLabels.warm; result.warmCount++; }
else if (daysSinceLatest < 180) { status = 'cold'; statusLabel = result.statusLabels.cold; result.coldCount++; }
else { status = 'dormant'; statusLabel = result.statusLabels.dormant; result.dormantCount++; }
const libMonths = firstTime > 0 ? Math.max(1, (nowSec - firstTime) / SECONDS_PER_MONTH) : 1;
result.members.push({ steamid: sid, name: familyNameOf(m, idMap), total: games, latestTime, daysSinceLatest, status, statusLabel, monthlyAvg: (games / libMonths).toFixed(1) });
});
const scoreMap = { active: 100, warm: 75, cold: 50, dormant: 25 };
let totalScore = 0, cnt = 0;
result.members.forEach(m => { totalScore += scoreMap[m.status]; cnt++; });
result.healthScore = cnt > 0 ? Math.round(totalScore / cnt) : 0;
const order = { active: 0, warm: 1, cold: 2, dormant: 3 };
result.members.sort((a, b) => order[a.status] - order[b.status] || b.total - a.total);
return result;
}
// 计算成员共同游戏矩阵
// 反向索引法:遍历每个游戏的 owners,对每对共拥成员递增计数
// 复杂度 O(m × avg_owners²),独占游戏自动跳过;原方法 O(n² × k)
function computeMemberOverlapMatrix(gameInfo, members, idMap) {
const n = members.length;
const matrix = [];
for (let i = 0; i < n; i++) matrix.push(new Array(n).fill(0));
// steamid → 成员索引
const memberIdx = {};
members.forEach((m, i) => { memberIdx[m.steamid] = i; });
// 遍历游戏,仅处理共拥游戏(owners.length >= 2)
for (const appid in gameInfo) {
const owners = gameInfo[appid].owners;
if (!owners || owners.length < 2) continue;
// 映射 owner steamid → 成员索引
const idxs = [];
for (const sid of owners) {
const idx = memberIdx[sid];
if (idx !== undefined) idxs.push(idx);
}
// 对每对共拥成员递增矩阵
for (let i = 0; i < idxs.length; i++) {
for (let j = i + 1; j < idxs.length; j++) {
matrix[idxs[i]][idxs[j]]++;
matrix[idxs[j]][idxs[i]]++;
}
}
}
const pairs = [];
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
pairs.push({ a: i, b: j, aName: familyNameOf(members[i], idMap), bName: familyNameOf(members[j], idMap), sharedGames: matrix[i][j] });
}
}
pairs.sort((a, b) => b.sharedGames - a.sharedGames);
return { matrix, pairs };
}
// 计算成员雷达数据(归一化 0-1)
function computeMemberRadar(gameInfo, members, idMap) {
const nowSec = Date.now() / 1000;
const isZh = locale === 'zh-CN';
const raw = members.map(m => {
let sid = m.steamid, total = 0, solo = 0, shared = 0, times = [];
for (const appid in gameInfo) {
const g = gameInfo[appid];
if (g.owners && g.owners.indexOf(sid) !== -1) {
total++;
if (g.owners.length === 1) solo++; else shared++;
if (g.time > 0) times.push(g.time);
}
}
const firstTime = times.length > 0 ? Math.min(...times) : nowSec;
const libMonths = Math.max(1, (nowSec - firstTime) / SECONDS_PER_MONTH);
return { name: familyNameOf(m, idMap), total, solo, shared, monthlyAvg: total / libMonths, shareRate: total > 0 ? shared / total : 0, libAge: (nowSec - firstTime) / SECONDS_PER_MONTH };
});
let maxT = 1, maxS = 1, maxSh = 1, maxM = 0.0001, maxA = 0.0001;
raw.forEach(r => { if (r.total > maxT) maxT = r.total; if (r.solo > maxS) maxS = r.solo; if (r.shared > maxSh) maxSh = r.shared; if (r.monthlyAvg > maxM) maxM = r.monthlyAvg; if (r.libAge > maxA) maxA = r.libAge; });
return raw.map(r => ({
name: r.name,
totalGames: r.total / maxT,
soloCount: r.solo / maxS,
sharedCount: r.shared / maxSh,
monthlyAvg: r.monthlyAvg / maxM,
shareRate: r.shareRate,
libraryAge: r.libAge / maxA
}));
}
function renderMemberInsightsTab() {
const fi = state.family.info;
const gl = state.family.gameList;
if (!fi || !fi.family_member || fi.family_member.length === 0) {
return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无成员数据' : 'No member data' });
}
if (!gl || !gl.GameInfo) {
return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无游戏数据' : 'No game data' });
}
const isZh = locale === 'zh-CN';
const members = fi.family_member;
const gameInfo = gl.GameInfo;
const idMap = fi.steamIdtoName || {};
const container = h('div', { style: { overflowY: 'auto', flex: '1', minHeight: '0', paddingRight: '4px' } });
// ===== 成员活跃度 + 共同游戏矩阵(左右结构) =====
const activity = computeMemberActivity(fi, gameInfo);
const actColors = { active: '#10b981', warm: '#3b82f6', cold: '#f59e0b', dormant: '#ef4444', never: '#6b7280' };
const healthPct = activity.healthScore;
const healthColor = healthPct >= 70 ? '#10b981' : (healthPct >= 40 ? '#f59e0b' : '#ef4444');
const statusCounts = { active: activity.activeCount, warm: activity.warmCount, cold: activity.coldCount, dormant: activity.dormantCount };
const avatarMap = buildAvatarMap();
const allMembers = fillPendingSlots(members);
const activityCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', flex: '1', minWidth: '0' } }, [
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' } }, [
h('div', { style: { fontSize: '14px', fontWeight: '600', color: '#c7d5e0' }, text: isZh ? '成员活跃度' : 'Member Activity' }),
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#94a3b8' } }, [
h('span', { text: isZh ? '健康分: ' : 'Health: ' }),
h('b', { style: { color: healthColor, fontSize: '16px' }, text: String(healthPct) })
])
]),
h('div', { style: { height: '8px', background: 'rgba(255,255,255,0.06)', borderRadius: '4px', overflow: 'hidden', marginBottom: '12px' } }, [
h('div', { style: { height: '100%', width: healthPct + '%', background: `linear-gradient(90deg,${healthColor},${healthColor}aa)`, borderRadius: '4px', transition: 'width 0.5s ease' } })
]),
h('div', { style: { display: 'flex', gap: '14px', flexWrap: 'wrap', marginBottom: '10px' } },
['active', 'warm', 'cold', 'dormant'].map(s =>
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '11px', color: '#94a3b8' } }, [
h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: actColors[s], display: 'inline-block' } }),
h('span', { text: `${activity.statusLabels[s]} ` }),
h('b', { style: { color: '#c6d4df' }, text: String(statusCounts[s]) })
])
)
),
...activity.members.map(m => {
const daysText = m.daysSinceLatest >= 0 ? (isZh ? `${m.daysSinceLatest} 天前` : `${m.daysSinceLatest}d ago`) : (isZh ? '从未' : 'Never');
const av = avatarMap[String(m.steamid)] || DEFAULT_AVATAR;
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', padding: '7px 8px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.04)', borderLeft: `3px solid ${actColors[m.status]}`, borderRadius: '8px', marginBottom: '5px' } }, [
h('img', { src: av, style: { width: '28px', height: '28px', borderRadius: '50%', objectFit: 'cover', flexShrink: '0', border: `2px solid ${actColors[m.status]}` }, loading: 'lazy', title: m.name, onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" }),
h('span', { style: { flexShrink: '0', width: '78px', fontSize: '12px', color: '#c6d4df', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: m.name, text: m.name }),
h('span', { style: { fontSize: '10px', fontWeight: '700', padding: '2px 8px', borderRadius: '4px', background: actColors[m.status] + '22', color: actColors[m.status] }, text: m.statusLabel }),
h('div', { style: { flex: '1', display: 'flex', gap: '14px', fontSize: '11px', color: '#8097a8', justifyContent: 'flex-end' } }, [
h('span', { html: `${isZh ? '库内' : 'Lib'}: ${m.total} ` }),
h('span', { html: `${isZh ? '月均' : 'Avg'}: ${m.monthlyAvg} ` }),
h('span', { html: `${isZh ? '最近入库' : 'Latest'}: ${daysText} ` })
])
]);
}),
// 待加入占位成员
...allMembers.slice(activity.members.length).map((m, i) => {
const ci = activity.members.length + i;
const c = CHART_COLORS[ci % CHART_COLORS.length];
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', padding: '7px 8px', background: 'rgba(100,116,139,0.04)', border: '1px solid rgba(100,116,139,0.1)', borderLeft: `3px dashed ${c}`, borderRadius: '8px', marginBottom: '5px', opacity: '0.5' } }, [
h('div', { style: { width: '24px', height: '24px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', border: `2px dashed ${c}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0' }, html: ICONS.userPlaceholder }),
h('span', { style: { flexShrink: '0', width: '90px', fontSize: '12px', color: '#64748b', fontStyle: 'italic' }, text: isZh ? '待加入' : 'Empty' }),
h('div', { style: { flex: '1', display: 'flex', gap: '14px', fontSize: '11px', color: '#475569', justifyContent: 'flex-end' } }, [
h('span', { text: `${isZh ? '库内' : 'Lib'}: 0` }),
h('span', { text: `${isZh ? '月均' : 'Avg'}: 0` }),
h('span', { text: `${isZh ? '最近入库' : 'Latest'}: -` })
])
]);
})
]);
// 共同游戏矩阵:6×6 头像网格,不足补"待加入"
const overlap = computeMemberOverlapMatrix(gameInfo, members, idMap);
let maxShared = 1;
overlap.pairs.forEach(p => { if (p.sharedGames > maxShared) maxShared = p.sharedGames; });
const N = allMembers.length;
const matrixCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column' } }, [
h('div', { style: { fontSize: '14px', fontWeight: '600', color: '#c7d5e0', marginBottom: '4px' }, text: isZh ? '共同游戏矩阵' : 'Shared Games Matrix' }),
h('div', { style: { fontSize: '11px', color: '#64748b', marginBottom: '10px' }, text: isZh ? '成员之间共同拥有的游戏数量(悬停查看详情)' : 'Shared games between members (hover for details)' }),
(() => {
const gridSize = N + 1; // 首行首列为表头
const grid = h('div', { style: { display: 'grid', gridTemplateColumns: `repeat(${gridSize}, 1fr)`, gap: '4px', flex: '1', alignItems: 'center' } });
// 左上角空
grid.appendChild(h('div'));
// 表头:头像
for (let j = 0; j < N; j++) {
const m = allMembers[j];
const av = m.pending ? null : (avatarMap[String(m.steamid)] || DEFAULT_AVATAR);
if (av) {
grid.appendChild(h('img', { src: av, style: { width: '32px', height: '32px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${CHART_COLORS[j % CHART_COLORS.length]}`, margin: '0 auto', display: 'block' }, loading: 'lazy', title: familyNameOf(m, idMap), onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" }));
} else {
grid.appendChild(h('div', { style: { width: '32px', height: '32px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', border: '2px dashed rgba(100,116,139,0.4)', margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: ICONS.userPlaceholder, title: isZh ? '待加入' : 'Empty' }));
}
}
// 数据行
for (let i = 0; i < N; i++) {
const mi = allMembers[i];
const avi = mi.pending ? null : (avatarMap[String(mi.steamid)] || DEFAULT_AVATAR);
// 行表头:头像
if (avi) {
grid.appendChild(h('img', { src: avi, style: { width: '32px', height: '32px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${CHART_COLORS[i % CHART_COLORS.length]}`, display: 'block' }, loading: 'lazy', title: familyNameOf(mi, idMap), onerror: "this.onerror=null;this.src=DEFAULT_AVATAR" }));
} else {
grid.appendChild(h('div', { style: { width: '32px', height: '32px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', border: '2px dashed rgba(100,116,139,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: ICONS.userPlaceholder, title: isZh ? '待加入' : 'Empty' }));
}
// 数据格
for (let j = 0; j < N; j++) {
const mj = allMembers[j];
const isPending = mi.pending || mj.pending;
if (i === j) {
grid.appendChild(h('div', { style: { textAlign: 'center', padding: '6px', background: 'rgba(6,207,190,0.1)', borderRadius: '4px', color: '#06cfbe', fontSize: '12px', fontWeight: '700' }, text: '-' }));
} else if (isPending) {
grid.appendChild(h('div', { style: { textAlign: 'center', padding: '6px', background: 'rgba(100,116,139,0.06)', borderRadius: '4px', color: '#475569', fontSize: '12px' }, text: '·' }));
} else {
const val = overlap.matrix[i] ? (overlap.matrix[i][j] || 0) : 0;
const intensity = val / maxShared;
const bg = val === 0 ? 'rgba(255,255,255,0.02)' : `rgba(84,160,255,${(0.15 + intensity * 0.5).toFixed(2)})`;
const nmI = familyNameOf(mi, idMap);
const nmJ = familyNameOf(mj, idMap);
grid.appendChild(h('div', { style: { textAlign: 'center', padding: '6px', background: bg, borderRadius: '4px', color: val > 0 ? '#c6d4df' : '#475569', fontWeight: val > 0 ? '600' : '400', fontSize: '12px' }, title: `${nmI} ↔ ${nmJ}: ${val} ${isZh ? '个共同游戏' : 'shared'}`, text: String(val) }));
}
}
}
return grid;
})()
]);
const topRow = h('div', { style: { display: 'flex', gap: '12px', alignItems: 'stretch', marginBottom: '12px' } });
topRow.appendChild(activityCard);
topRow.appendChild(matrixCard);
container.appendChild(topRow);
// ===== 成员雷达图 + 加入时间(左右结构) =====
const radar = computeMemberRadar(gameInfo, members, idMap);
if (radar.length > 0) {
const axes = isZh ? ['总游戏', '独占', '共享', '月均', '共享率', '库龄'] : ['Total', 'Solo', 'Shared', 'Avg', 'Share', 'Age'];
const cx = 150, cy = 140, R = 95, levels = 4;
let radarSvg = '';
for (let l = 1; l <= levels; l++) {
const r = R * l / levels;
const gpts = [];
for (let a = 0; a < axes.length; a++) {
const gang = -Math.PI / 2 + a * 2 * Math.PI / axes.length;
gpts.push(`${(cx + r * Math.cos(gang)).toFixed(1)},${(cy + r * Math.sin(gang)).toFixed(1)}`);
}
radarSvg += ` `;
}
for (let a = 0; a < axes.length; a++) {
const aang = -Math.PI / 2 + a * 2 * Math.PI / axes.length;
radarSvg += ` `;
radarSvg += `${axes[a]} `;
}
radar.forEach((m, mi) => {
const c = CHART_COLORS[mi % CHART_COLORS.length];
const vals = [m.totalGames, m.soloCount, m.sharedCount, m.monthlyAvg, m.shareRate, m.libraryAge];
const mpts = [];
for (let a = 0; a < axes.length; a++) {
const mang = -Math.PI / 2 + a * 2 * Math.PI / axes.length;
const mr = R * vals[a];
mpts.push(`${(cx + mr * Math.cos(mang)).toFixed(1)},${(cy + mr * Math.sin(mang)).toFixed(1)}`);
}
radarSvg += ` `;
});
const radarLegend = radar.map((m, mi) => {
const c = CHART_COLORS[mi % CHART_COLORS.length];
return ` ${m.name} `;
}).join('');
// 加入时间+冷却数据
const fmtJoinDate = (ts) => {
if (!ts) return isZh ? '未知' : 'Unknown';
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
};
const fmtCooldown = (secs) => {
if (!secs || secs <= 0) return isZh ? '无冷却' : 'None';
const days = Math.floor(secs / SECONDS_PER_DAY);
return isZh ? `${days} 天` : `${days}d`;
};
const joinListHtml = allMembers.map((m, i) => {
const c = CHART_COLORS[i % CHART_COLORS.length];
if (m.pending) {
return `
${ICONS.userPlaceholder}
${isZh ? '待加入' : 'Empty'}
-
${isZh ? '冷却' : 'CD'}: -
`;
}
const av = avatarMap[String(m.steamid)] || DEFAULT_AVATAR;
const joinDate = fmtJoinDate(m.time_joined);
const cd = fmtCooldown(m.cooldown_remaining);
const cdColor = m.cooldown_remaining > 0 ? '#ef4444' : '#34d399';
const nowSec = Date.now() / 1000;
const joinDays = m.time_joined > 0 ? Math.floor((nowSec - m.time_joined) / SECONDS_PER_DAY) : 0;
return `
${m.userName || familyNameOf(m, idMap)}
${joinDate}
${joinDays}${isZh ? '天' : 'd'}
${isZh ? '冷却' : 'CD'}: ${cd}
`;
}).join('');
const radarWrap = h('div', { style: { display: 'flex', gap: '12px', alignItems: 'stretch' } });
// 左侧卡片:雷达图
const radarCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', flex: '1', minWidth: '0' } });
radarCard.insertAdjacentHTML('beforeend',
`${isZh ? '成员多维度雷达对比' : 'Member Radar'}
` +
`${isZh ? '各维度按最大值归一化(0-1)' : 'Normalized by max (0-1)'}
` +
`${radarSvg} ` +
`${radarLegend}
`
);
radarWrap.appendChild(radarCard);
// 右侧卡片:加入时间
const joinCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', flex: '1', minWidth: '0' } });
joinCard.insertAdjacentHTML('beforeend',
`${isZh ? '加入时间' : 'Join Time'}
` +
`${isZh ? '成员加入家庭组的时间与冷却' : 'Member join time & cooldown'}
` +
joinListHtml
);
radarWrap.appendChild(joinCard);
container.appendChild(radarWrap);
}
// ===== 24小时入库热力图(全宽) =====
const heatmap24hCard = renderFamily24hHeatmap();
if (heatmap24hCard) {
container.appendChild(heatmap24hCard);
}
// 热力图大卡片
const heatmapCard = renderFamilyHeatmapGrid();
if (heatmapCard) container.appendChild(heatmapCard);
return container;
}
// ==================== 家庭愿望单 ====================
// 从愿望单页面 HTML 中提取 SSR queryData(参考 steam-family-game-analysis v1.39)
function faWlExtractQueryData(text) {
try {
let idx = 0;
while ((idx = text.indexOf('JSON.parse(', idx)) !== -1) {
const start = text.indexOf('"', idx + 11);
if (start === -1) { idx++; continue; }
let i = start + 1, inEsc = false;
while (i < text.length) {
const c = text[i];
if (inEsc) inEsc = false;
else if (c === '\\') inEsc = true;
else if (c === '"') break;
i++;
}
const escaped = text.substring(start + 1, i).replace(/[\n\r]/g, '');
let decoded = '';
try { decoded = JSON.parse('"' + escaped + '"'); } catch (_) { idx = i + 1; continue; }
if (typeof decoded === 'string' && decoded.indexOf('queryData') !== -1) {
try {
const outer = JSON.parse(decoded);
if (outer && typeof outer.queryData === 'string') {
const qd = JSON.parse(outer.queryData);
if (qd && Array.isArray(qd.queries)) return qd.queries;
}
} catch (_) {}
}
idx = i + 1;
}
} catch (e) { logger.warn('[WL] SSR 提取失败', e); }
return null;
}
// 从 SSR queries 数组提取愿望单条目(appid + name)
function faWlExtractEntries(queries) {
const entries = [];
const storeItemNames = {};
for (const query of queries) {
if (!query || !query.state || !query.state.data) continue;
const data = query.state.data;
const qKey = query.queryKey || [];
// 愿望单条目(含 appid/priority/date_added)
if (qKey[0] === 'WishlistSortedFiltered') {
const payload = Array.isArray(data) ? { items: data } : data;
if (payload && Array.isArray(payload.items)) {
for (const item of payload.items) {
if (item && item.appid) entries.push({ appid: Number(item.appid), name: '' });
}
}
}
// StoreItem 详情(含名称)
if (qKey.length >= 3 && qKey[0] === 'StoreItem') {
const appId = parseInt(String(qKey[1]).replace(/^app_/, ''), 10);
if (appId && data && data.default_info && data.default_info.name) {
storeItemNames[appId] = String(data.default_info.name);
}
}
}
// 去重 + 补名称
const seen = {};
const unique = [];
for (const e of entries) {
if (e.appid && !seen[e.appid]) {
seen[e.appid] = true;
e.name = storeItemNames[e.appid] || ('App ' + e.appid);
unique.push(e);
}
}
return unique;
}
// 来源1:从愿望单页面 HTML 抓取 SSR 数据(最可靠)
function faWlFetchFromPage(steamid) {
return new Promise(resolve => {
const isProfileId = /^\d{17}$/.test(String(steamid));
const url = isProfileId
? 'https://store.steampowered.com/wishlist/profiles/' + steamid + '/'
: 'https://store.steampowered.com/wishlist/id/' + steamid + '/';
GM_xmlhttpRequest({
method: 'GET', url, timeout: 30000,
onload(resp) {
if (resp.status < 200 || resp.status >= 300 || !resp.responseText) { resolve([]); return; }
const queries = faWlExtractQueryData(resp.responseText);
if (!queries) { resolve([]); return; }
const entries = faWlExtractEntries(queries);
console.log('[SFD-WL] ' + steamid + ' SSR: ' + entries.length + ' 条');
resolve(entries);
},
onerror() { resolve([]); },
ontimeout() { resolve([]); }
});
});
}
// 来源2:wishlistdata JSON API 分页(补充名称)
function faWlFetchFromApi(steamid) {
return new Promise(resolve => {
const entries = [];
const isProfileId = /^\d{17}$/.test(String(steamid));
const base = isProfileId
? 'https://store.steampowered.com/wishlist/profiles/' + steamid + '/wishlistdata/'
: 'https://store.steampowered.com/wishlist/id/' + steamid + '/wishlistdata/';
function fetchPage(p) {
if (p >= 60) { resolve(entries); return; }
GM_xmlhttpRequest({
method: 'GET', url: base + '?p=' + p, timeout: 20000,
onload(resp) {
if (resp.status < 200 || resp.status >= 300) { resolve(entries); return; }
try {
const data = JSON.parse(resp.responseText);
if (!data || (typeof data === 'object' && !Array.isArray(data) && Object.keys(data).length === 0)) { resolve(entries); return; }
const items = Array.isArray(data) ? data : Object.entries(data);
if (items.length === 0) { resolve(entries); return; }
items.forEach(item => {
let appid, info;
if (Array.isArray(item)) { appid = Number(item[0]); info = item[1]; }
else { appid = Number(item.appid); info = item; }
if (!appid || isNaN(appid) || !info) return;
entries.push({ appid, name: (info.name && String(info.name).trim()) || ('App ' + appid) });
});
setTimeout(() => fetchPage(p + 1), 300);
} catch (e) { resolve(entries); }
},
onerror() { resolve(entries); },
ontimeout() { resolve(entries); }
});
}
fetchPage(0);
});
}
// 获取单个成员的愿望单(SSR + API 并行抓取 + 合并)
async function fetchMemberWishlist(steamid) {
const [ssrEntries, apiEntries] = await Promise.all([faWlFetchFromPage(steamid), faWlFetchFromApi(steamid)]);
const apiMap = {};
apiEntries.forEach(e => { if (e && e.appid) apiMap[e.appid] = e; });
const result = [];
const seen = {};
// SSR 为基底(appid + 可能的名称)
ssrEntries.forEach(e => {
const api = apiMap[e.appid];
const name = (api && api.name && api.name !== ('App ' + e.appid)) ? api.name : (e.name || ('App ' + e.appid));
result.push({ appid: e.appid, name });
seen[e.appid] = true;
});
// API 有但 SSR 没有的
apiEntries.forEach(e => {
if (!seen[e.appid]) result.push({ appid: e.appid, name: e.name || ('App ' + e.appid) });
});
console.log('[SFD-WL] ' + steamid + ' 合并: ' + result.length + ' 条(SSR ' + ssrEntries.length + ' / API ' + apiEntries.length + ')');
return result;
}
// 获取所有成员的愿望单(合并去重,2并发)
async function fetchAllWishlists(members, onProgress) {
const merged = {}; // appid -> { appid, name, wishers: [steamid,...] }
const CONCURRENCY = 2;
let idx = 0, running = 0, completed = 0;
// 每个成员的完成状态:steamid -> 'pending' | 'loading' | 'done'
const statusMap = {};
members.forEach(m => { statusMap[m.steamid] = 'pending'; });
return new Promise(resolve => {
function launchNext() {
while (running < CONCURRENCY && idx < members.length) {
const m = members[idx++];
running++;
statusMap[m.steamid] = 'loading';
if (onProgress) onProgress(statusMap);
fetchMemberWishlist(m.steamid).then(entries => {
entries.forEach(e => {
if (!merged[e.appid]) {
merged[e.appid] = { appid: e.appid, name: e.name, wishers: [] };
} else {
// 已存在:用有效名称覆盖占位名(App xxx)
const isPlaceholder = n => !n || /^App \d+$/.test(n);
if (isPlaceholder(merged[e.appid].name) && !isPlaceholder(e.name)) {
merged[e.appid].name = e.name;
}
}
merged[e.appid].wishers.push(m.steamid);
});
running--;
completed++;
statusMap[m.steamid] = 'done';
if (onProgress) onProgress(statusMap);
if (completed >= members.length) { resolve(); return; }
launchNext();
});
}
}
launchNext();
}).then(() => {
// 标记家庭库已有的游戏
const libSet = new Set();
const gl = state.family.gameList;
if (gl && gl.GameList) gl.GameList.forEach(a => libSet.add(a));
Object.values(merged).forEach(g => { g.inLibrary = libSet.has(g.appid); });
// 排序:多人想要优先 → 家庭库未有 → 名称
return Object.values(merged).sort((a, b) => {
const wDiff = b.wishers.length - a.wishers.length;
if (wDiff !== 0) return wDiff;
if (a.inLibrary !== b.inLibrary) return a.inLibrary ? 1 : -1;
return (a.name || '').localeCompare(b.name || '', 'zh-CN');
});
});
}
// 愿望单状态
let wlState = { data: null, loading: false, filter: 'all', kpiFilter: 'all', page: 1, updatedAt: 0 };
function renderFamilyWishlistTab() {
const fi = state.family.info;
if (!fi || !fi.family_member || fi.family_member.length === 0) {
return h('div', { class: 'sfd-family-empty', text: locale === 'zh-CN' ? '无成员数据' : 'No member data' });
}
const isZh = locale === 'zh-CN';
const members = fi.family_member;
const container = h('div', { style: { flex: '1', overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: '0' } });
// 检查愿望单缓存(TTL 1周,手动刷新已提前清除缓存)
if (!wlState.data && !wlState.loading) {
const cached = storage.getWishlistCache();
if (cached && cached.steamid === state.family.popupSteamid && cached.data) {
// 重新计算 inLibrary 标记(家庭库可能已变化)
const libSet = new Set();
const gl = state.family.gameList;
if (gl && gl.GameList) gl.GameList.forEach(a => libSet.add(a));
cached.data.forEach(g => { g.inLibrary = libSet.has(g.appid); });
wlState.data = cached.data;
wlState.updatedAt = cached._ts || 0;
}
}
// 加载中 / 触发加载:显示科技感进度条
if (wlState.loading || !wlState.data) {
const avatarMap = buildAvatarMap();
const allMembers = fillPendingSlots(members);
const idMap = fi.steamIdtoName || {};
// 进度状态
if (!wlState.loading) {
wlState.loading = true;
// 首次打开立即将第一个成员设为 loading,让动效立即显示
const initStatusMap = {};
if (members.length > 0) initStatusMap[members[0].steamid] = 'loading';
wlState.progress = { statusMap: initStatusMap };
}
const prog = wlState.progress || { statusMap: {} };
// 构建进度条 UI
const loadWrap = h('div', { style: { flex: '1', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '20px' } });
loadWrap.appendChild(h('div', { style: { fontSize: '14px', fontWeight: '600', color: '#c7d5e0' }, text: isZh ? '正在获取家庭成员愿望单' : 'Fetching Family Wishlists' }));
// 进度条主体:6个头像节点 + 连线
const nodes = h('div', { style: { display: 'flex', alignItems: 'center', gap: '0', position: 'relative' } });
const lineW = 40; // 节点间连线宽度
allMembers.forEach((m, i) => {
const sid = String(m.steamid);
const status = m.pending ? 'pending' : (prog.statusMap[sid] || 'pending');
const av = m.pending ? null : (avatarMap[sid] || DEFAULT_AVATAR);
const nm = familyNameOf(m, idMap);
const color = m.pending ? '#475569' : CHART_COLORS[i % CHART_COLORS.length];
// 连线(第一个之前不画)
if (i > 0) {
const prevStatus = allMembers[i - 1].pending ? 'pending' : (prog.statusMap[String(allMembers[i - 1].steamid)] || 'pending');
const lineDone = (status === 'done' || status === 'loading') && (prevStatus === 'done' || prevStatus === 'loading');
const lineLoading = status === 'loading' && prevStatus === 'done';
nodes.appendChild(h('div', { style: { width: lineW + 'px', height: '3px', borderRadius: '2px', background: lineDone ? color : 'rgba(255,255,255,0.08)', transition: 'background 0.4s ease', position: 'relative', overflow: 'hidden' } }, lineLoading ? [h('div', { style: { position: 'absolute', top: '0', left: '0', height: '100%', width: '100%', background: `linear-gradient(90deg, transparent, ${color}, transparent)`, animation: 'sfd-wl-slide 1.2s linear infinite' } })] : []));
}
// 头像节点
const nodeWrap = h('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '6px' } });
const ringColor = status === 'done' ? '#34d399' : (status === 'loading' ? color : 'rgba(255,255,255,0.12)');
const nodeDiv = h('div', { style: { width: '48px', height: '48px', borderRadius: '50%', position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' } });
// 外环发光
if (status === 'loading') {
nodeDiv.appendChild(h('div', { style: { position: 'absolute', inset: '-4px', borderRadius: '50%', border: `2px solid ${color}`, opacity: '0.4', animation: 'sfd-wl-pulse 1.5s ease-in-out infinite' } }));
}
if (m.pending) {
nodeDiv.appendChild(h('div', { style: { width: '40px', height: '40px', borderRadius: '50%', background: 'rgba(100,116,139,0.1)', border: `2px dashed rgba(100,116,139,0.3)`, display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: ICONS.userPlaceholder }));
} else {
nodeDiv.appendChild(h('img', { src: av, style: { width: '40px', height: '40px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${ringColor}`, opacity: status === 'pending' ? '0.3' : '1', transition: 'all 0.4s ease' }, loading: 'lazy', onerror: function() { this.onerror = null; this.src = DEFAULT_AVATAR; } }));
}
// 完成勾
if (status === 'done') {
nodeDiv.appendChild(h('div', { style: { position: 'absolute', bottom: '-2px', right: '-2px', width: '18px', height: '18px', borderRadius: '50%', background: '#34d399', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px solid #0f172a' }, html: `${ICONS.check} ` }));
}
// loading 旋转环
if (status === 'loading') {
nodeDiv.appendChild(h('div', { style: { position: 'absolute', inset: '0', borderRadius: '50%', border: '2px solid transparent', borderTopColor: color, borderRightColor: color, animation: 'sfd-wl-spin 0.8s linear infinite' } }));
}
nodeWrap.appendChild(nodeDiv);
// 名称
nodeWrap.appendChild(h('span', { style: { fontSize: '10px', color: status === 'done' ? '#c6d4df' : (status === 'loading' ? color : '#475569'), maxWidth: '60px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center', transition: 'color 0.4s ease' }, title: nm, text: nm }));
nodes.appendChild(nodeWrap);
});
loadWrap.appendChild(nodes);
// 进度文字
const doneCount = allMembers.filter(m => !m.pending && prog.statusMap[String(m.steamid)] === 'done').length;
const realTotal = allMembers.filter(m => !m.pending).length;
loadWrap.appendChild(h('div', { style: { fontSize: '12px', color: '#8a9ba8' }, html: `${doneCount} / ${realTotal} ${isZh ? '成员已完成' : 'members done'}` }));
// CSS 动画(注入一次)
if (!document.getElementById('sfd-wl-progress-css')) {
const style = document.createElement('style');
style.id = 'sfd-wl-progress-css';
style.textContent = `
@keyframes sfd-wl-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes sfd-wl-pulse { 0%,100% { transform: scale(1); opacity: 0.4; } 50% { transform: scale(1.15); opacity: 0.1; } }
@keyframes sfd-wl-slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
`;
document.head.appendChild(style);
}
container.appendChild(loadWrap);
// 触发加载(仅首次)
if (!wlState._fetchStarted) {
wlState._fetchStarted = true;
fetchAllWishlists(members, (statusMap) => {
wlState.progress = { statusMap: { ...statusMap } };
renderFamilyPopup();
}).then(data => {
wlState.data = data;
wlState.loading = false;
wlState._fetchStarted = false;
wlState.updatedAt = Date.now();
storage.setWishlistCache({ steamid: state.family.popupSteamid, data, _ts: wlState.updatedAt });
renderFamilyPopup();
});
}
return container;
}
const data = wlState.data;
const avatarMap = buildAvatarMap();
const idMap = fi.steamIdtoName || {};
// 筛选
let filtered = data;
// 主筛选:KPI 卡片
if (wlState.kpiFilter === 'inLib') filtered = filtered.filter(g => g.inLibrary);
else if (wlState.kpiFilter === 'notInLib') filtered = filtered.filter(g => !g.inLibrary);
else if (wlState.kpiFilter === 'multiWant') filtered = filtered.filter(g => g.wishers.length === members.length);
// 次级筛选:1-6人
if (wlState.filter !== 'all') {
const wantN = Number(wlState.filter);
if (!isNaN(wantN)) filtered = filtered.filter(g => g.wishers.length === wantN);
}
// 统计
const total = data.length;
const inLib = data.filter(g => g.inLibrary).length;
const multiWant = data.filter(g => g.wishers.length === members.length).length;
const notInLib = total - inLib;
// 成员愿望单数量分布(含待加入占位)
const allMembers = fillPendingSlots(members);
const memberCounts = allMembers.map(m => {
const count = m.pending ? 0 : (data.filter(g => g.wishers.includes(m.steamid)).length);
return { name: familyNameOf(m, idMap), steamid: m.steamid, count, avatar: m.pending ? null : (avatarMap[String(m.steamid)] || DEFAULT_AVATAR), pending: !!m.pending };
});
const maxMc = Math.max(1, ...memberCounts.map(m => m.count));
// 分页(一行4个 × 3行 = 12个)
const PAGE_SIZE = 20;
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
if (wlState.page > totalPages) wlState.page = totalPages;
const start = (wlState.page - 1) * PAGE_SIZE;
const pageItems = filtered.slice(start, start + PAGE_SIZE);
// 主体左右布局
const body = h('div', { style: { display: 'flex', gap: '12px', flex: '1', minHeight: '0' } });
// 左侧:KPI田字格(可点击筛选) + 成员愿望单分布
const leftSide = h('div', { style: { width: '260px', flexShrink: '0', display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', paddingRight: '4px' } });
// KPI 田字格(2×2,可点击筛选)
const kpiCards = [
{ key: 'all', val: total, color: '#06cfbe', label: isZh ? '愿望单总数' : 'Total' },
{ key: 'inLib', val: inLib, color: '#f59e0b', label: isZh ? '家庭已有' : 'In Library' },
{ key: 'notInLib', val: notInLib, color: '#34d399', label: isZh ? '全家都无' : 'Missing' },
{ key: 'multiWant', val: multiWant, color: '#54a0ff', label: isZh ? '全家想要' : 'Multi Want' }
];
const kpiGrid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: '8px' } }, kpiCards.map(k => {
const active = wlState.kpiFilter === k.key;
return createMetricCard({
value: String(k.val), label: k.label, accent: k.color, layout: 'label',
active, onClick: () => { wlState.kpiFilter = active ? 'all' : k.key; wlState.page = 1; renderFamilyPopup(); }
});
}));
leftSide.appendChild(kpiGrid);
// 成员分布卡片
const distCard = h('div', { class: 'sfd-family-stat-card', style: { padding: '12px', display: 'flex', flexDirection: 'column' } }, [
h('div', { style: { fontSize: '13px', fontWeight: '600', color: '#c7d5e0', marginBottom: '8px' }, text: isZh ? '成员愿望单分布' : 'Member Distribution' }),
...memberCounts.map((m, i) => {
const c = CHART_COLORS[i % CHART_COLORS.length];
const pct = Math.max(3, Math.round(m.count / maxMc * 100));
if (m.pending) {
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px', opacity: '0.4' } }, [
h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px' } }, [
h('div', { style: { width: '24px', height: '24px', borderRadius: '50%', background: 'rgba(100,116,139,0.15)', border: `2px dashed ${c}`, flexShrink: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: ICONS.userPlaceholder }),
h('span', { style: { color: '#64748b', fontStyle: 'italic', flex: '1' }, text: isZh ? '待加入' : 'Empty' }),
h('span', { style: { color: '#475569', fontWeight: '700', flexShrink: '0' }, text: '0' })
]),
h('div', { style: { height: '6px', borderRadius: '3px', background: 'rgba(255,255,255,0.06)', overflow: 'hidden' } }, [
h('div', { style: { height: '100%', borderRadius: '3px', background: 'rgba(100,116,139,0.2)', width: '3%' } })
])
]);
}
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px', opacity: m.count === 0 ? '0.5' : '1' } }, [
h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px' } }, [
h('img', { src: m.avatar, style: { width: '24px', height: '24px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${c}`, flexShrink: '0' }, loading: 'lazy', onerror: function() { this.onerror = null; this.src = DEFAULT_AVATAR; } }),
h('span', { style: { color: '#e2e8f0', fontWeight: '600', flex: '1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, text: m.name }),
h('span', { style: { color: c, fontWeight: '700', flexShrink: '0' }, text: String(m.count) })
]),
h('div', { style: { height: '6px', borderRadius: '3px', background: 'rgba(255,255,255,0.06)', overflow: 'hidden' } }, [
h('div', { style: { height: '100%', borderRadius: '3px', background: c, width: pct + '%', transition: 'width 0.4s ease' } })
])
]);
})
]);
leftSide.appendChild(distCard);
leftSide.appendChild(h('button', { class: 'sfd-btn sfd-btn-sm sfd-btn-ghost', style: { width: '100%', flexShrink: '0' }, text: isZh ? '刷新愿望单' : 'Refresh', onClick: () => { storage.delWishlistCache(); wlState.data = null; wlState.page = 1; wlState.kpiFilter = 'all'; wlState.updatedAt = 0; renderFamilyPopup(); } }));
if (wlState.updatedAt > 0) {
leftSide.appendChild(h('div', { style: { textAlign: 'center', fontSize: '10px', color: '#64748b', marginTop: '4px', whiteSpace: 'nowrap' }, text: (isZh ? '更新于 ' : 'Updated ') + new Date(wlState.updatedAt).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }));
}
body.appendChild(leftSide);
// 右侧:次级筛选 + 游戏列表
const rightSide = h('div', { style: { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column' } });
// 工具栏
const toolbar = h('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px', flexShrink: '0', flexWrap: 'wrap', alignItems: 'center' } });
// 次级筛选按钮:全部、1人、2人、3人、4人、5人、6人(无数据则灰色禁用)
const wisherCounts = {};
for (let n = 1; n <= 6; n++) wisherCounts[n] = data.filter(g => g.wishers.length === n).length;
const filterKeys = ['all', 1, 2, 3, 4, 5, 6];
filterKeys.forEach(key => {
const isAll = key === 'all';
const label = isAll ? (isZh ? '全部' : 'All') : `${key}${isZh ? '人' : ''}`;
const active = wlState.filter === String(key);
const noData = !isAll && wisherCounts[key] === 0;
const btn = h('button', {
class: `sfd-btn sfd-btn-sm ${active ? 'sfd-btn-primary' : 'sfd-btn-ghost'}`,
text: label,
style: noData ? { opacity: '0.35', cursor: 'not-allowed', filter: 'grayscale(0.8)' } : {},
onClick: noData ? null : () => { wlState.filter = String(key); wlState.page = 1; renderFamilyPopup(); }
});
if (noData) btn.disabled = true;
toolbar.appendChild(btn);
});
rightSide.appendChild(toolbar);
// 游戏网格
const grid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '8px', overflowY: 'auto', flex: '1', minHeight: '0', paddingRight: '4px' } });
if (pageItems.length === 0) {
grid.appendChild(h('div', { class: 'sfd-family-empty', text: isZh ? '无匹配结果' : 'No results' }));
} else {
pageItems.forEach(game => {
const os = getOwnerStyle(game.wishers.length);
// 构建 wisher 头像元素(用 h() 创建 DOM,确保可靠渲染)
const wisherEls = game.wishers.slice(0, 6).map((sid, wi) => {
const c = CHART_COLORS[wi % CHART_COLORS.length];
const av = avatarMap[String(sid)] || DEFAULT_AVATAR;
const nm = (idMap && idMap[sid]) || familyNameOf({ steamid: sid }, idMap);
return h('img', { src: av, style: { width: '20px', height: '20px', borderRadius: '50%', objectFit: 'cover', border: `2px solid ${c}` }, loading: 'lazy', title: nm, onerror: function() { this.onerror = null; this.src = DEFAULT_AVATAR; } });
});
if (game.wishers.length > 6) {
wisherEls.push(h('span', { style: { fontSize: '10px', color: '#8a9ba8', alignSelf: 'center' }, text: `+${game.wishers.length - 6}` }));
}
grid.appendChild(h('div', { class: 'sfd-pl-recent-card', style: { background: os.bg, borderColor: os.border } }, [
(() => { const capImg = h('img', { class: 'sfd-pl-recent-cap', loading: 'lazy', onclick: () => openStorePage(game.appid) }); return dashLoadCapsule(capImg, game.appid); })(),
h('div', { class: 'sfd-pl-recent-info' }, [
h('div', { style: { display: 'flex', alignItems: 'center', gap: '4px' } }, [
(() => { const el = h('a', { class: 'sfd-pl-recent-name', href: `https://store.steampowered.com/app/${game.appid}`, target: '_blank', text: game.name }); loadGameZhName(el, game.appid, game.name); return el; })(),
game.inLibrary ? h('span', { style: { fontSize: '12px', flexShrink: '0' }, text: '✅️' }) : null
]),
h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '3px', marginTop: '4px' } }, wisherEls)
])
]));
});
}
rightSide.appendChild(grid);
// 分页
if (totalPages > 1) {
rightSide.appendChild(createPagination(wlState.page, totalPages, (p) => { wlState.page = p; renderFamilyPopup(); }));
}
body.appendChild(rightSide);
container.appendChild(body);
return container;
}
// ==================== 精品游戏 Tab ====================
let _stressGamesCache = null;
let _stressLoadFailed = false;
let _stressForceNetwork = false; // 刷新时强制跳过@resource走网络
const STRESS_JSON_URL = 'https://raw.githubusercontent.com/SmallFork/json/main/2026.json';
// 获取精品游戏列表(从缓存中提取所有年份的游戏)
function getStressAllGames() {
if (!_stressGamesCache) return [];
const raw = _stressGamesCache;
if (Array.isArray(raw)) return raw;
const all = [];
Object.values(raw).forEach(v => { if (Array.isArray(v)) all.push(...v); });
return all;
}
// 商店页面所有权检测缓存(appid → 'self'|'family'|false/null=未检测)
const _stressStoreOwnedCache = new Map();
const _stressStorePending = new Set();
let _stressDetectDBLoaded = false;
// 从 IndexedDB 加载持久化检测结果到内存缓存(按每条记录的 ts 判断是否过期)
function _loadStressDetectCache() {
if (_stressDetectDBLoaded) return;
_stressDetectDBLoaded = true;
const dbCache = storage.getStressDetectCache();
if (dbCache && typeof dbCache === 'object') {
const entries = dbCache.entries || dbCache;
if (entries && typeof entries === 'object') {
let loadedCount = 0, expiredCount = 0;
const now = Date.now();
Object.entries(entries).forEach(([appid, val]) => {
const result = val.result !== undefined ? val.result : val;
const entryTs = val.ts || 0;
// 按每条记录自己的时间戳判断是否在 TTL 内
if (now - entryTs > STRESS_DETECT_TTL) {
expiredCount++;
return; // 跳过过期记录
}
_stressStoreOwnedCache.set(Number(appid), result); // key 统一用 Number
loadedCount++;
});
if (loadedCount > 0) {
console.log(`%c[SFD] 预购检测缓存: 从本地加载 ${loadedCount} 条记录${expiredCount > 0 ? `(${expiredCount} 条已过期跳过)` : ''}`, 'color:#34d399');
}
}
}
}
// 持久化单条检测结果到 IndexedDB(同时清理过期记录)
function _persistStressDetectResult(appid, result) {
const dbCache = storage.getStressDetectCache();
let entries = {};
if (dbCache && dbCache.entries) entries = dbCache.entries;
else if (dbCache && typeof dbCache === 'object' && !dbCache._ts) entries = dbCache;
// 清理过期记录
const now = Date.now();
Object.keys(entries).forEach(key => {
const entryTs = entries[key].ts || 0;
if (now - entryTs > STRESS_DETECT_TTL) delete entries[key];
});
// 写入新记录
entries[appid] = { result, ts: now };
storage.setStressDetectCache({ entries, _ts: now });
}
// 个人游戏库 appid 集合缓存(用于已发售游戏快速对比,避免网页请求)
let _personalAppIdSet = null;
function getPersonalAppIdSet() {
if (_personalAppIdSet) return _personalAppIdSet;
const cache = state.personal.gamesCache;
if (!cache || !cache.games) { return null; }
_personalAppIdSet = new Set(cache.games.map(g => Number(g.appid)));
return _personalAppIdSet;
}
// 检测未发售游戏是否已被预购
function checkStressStoreFamilyOwnership(appid) {
_loadStressDetectCache(); // 首次调用时从 IndexedDB 加载
if (_stressStoreOwnedCache.has(appid)) {
return _stressStoreOwnedCache.get(appid);
}
if (_stressStorePending.has(appid)) return null;
_stressStorePending.add(appid);
const url = `https://store.steampowered.com/app/${appid}?l=schinese`;
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 15000,
onload(resp) {
let ownType = false;
try {
const html = resp.responseText || '';
// 1. 自己预购/已拥有:game_area_already_owned(优先检测,因为可能同时出现 family 和自己)
if (html.includes('game_area_already_owned') && html.includes('steamdb_already_in_library_link')) {
ownType = 'self';
} else if (html.includes('ds_owned_flag') && html.includes('already_in_library')) {
ownType = 'self';
}
// 2. 家庭组预购:family_info
else if (html.includes('family_info') && html.includes('steamdb_already_in_library_link')) {
ownType = 'family';
} else if (html.includes('game_purchase_area_owned_by_family')) {
ownType = 'family';
} else if (html.includes('IN STEAM FAMILY LIBRARY') || html.includes('已在 Steam 家庭组库中')) {
ownType = 'family';
}
console.log(`%c[SFD] 预购检测(网络): appid=${appid}, ownType=${ownType || 'none'}, htmlLen=${html.length}`, 'color:#f59e0b');
} catch (e) {
logger.warn(`2026精品 预购检测异常: appid=${appid}`, e);
}
_stressStoreOwnedCache.set(appid, ownType);
_persistStressDetectResult(appid, ownType);
_stressStorePending.delete(appid);
if (ownType) updateStressOwnershipUI(appid, ownType);
},
onerror(err) {
logger.warn(`2026精品 预购检测网络错误: appid=${appid}`, err);
_stressStoreOwnedCache.set(appid, false);
_stressStorePending.delete(appid);
},
ontimeout() {
logger.warn(`2026精品 预购检测超时: appid=${appid}`);
_stressStoreOwnedCache.set(appid, false);
_stressStorePending.delete(appid);
}
});
return null;
}
// 检测到预购拥有后,更新对应卡片 UI
function updateStressOwnershipUI(appid, ownType) {
const isZh = locale === 'zh-CN';
const dateStr = findGameDateByAppid(appid);
const labelText = stressOwnedLabel(ownType, isZh, dateStr);
const labelClass = ownType === 'self' ? 'self' : 'family';
const cards = document.querySelectorAll(`[data-stress-appid="${appid}"]`);
cards.forEach(card => {
card.classList.remove('not-owned');
card.classList.add('owned', `owned-${ownType}`);
// 如果已有主人头像区域或标签则跳过
if (card.querySelector('.sfd-stress-owners') || card.querySelector('.sfd-stress-featured-owners') || card.querySelector('.sfd-stress-tag')) return;
// 焦点卡片:更新徽章
const badge = card.querySelector('.sfd-stress-featured-badge');
if (badge) {
badge.textContent = labelText;
badge.className = `sfd-stress-featured-badge ${labelClass}`;
return;
}
// 网格卡片:添加标签
const tag = document.createElement('div');
tag.className = `sfd-stress-tag ${labelClass}`;
tag.textContent = labelText;
card.appendChild(tag);
});
// 更新未发售分组的头部计数
const unreleasedGroup = document.getElementById('sfd-stress-unreleased-group');
if (unreleasedGroup) {
const header = unreleasedGroup.querySelector('.sfd-stress-group-header');
const totalCards = unreleasedGroup.querySelectorAll('[data-stress-appid]');
const ownedCards = unreleasedGroup.querySelectorAll('[data-stress-appid].owned');
const ownedCount = ownedCards.length;
const total = totalCards.length;
const pct = total > 0 ? Math.round(ownedCount / total * 100) : 0;
if (header) {
const countEl = header.querySelector('.sfd-stress-group-count');
const pctEl = header.querySelector('.sfd-stress-group-pct');
if (countEl) countEl.textContent = `${ownedCount}/${total}`;
if (pctEl) pctEl.textContent = pct + '%';
// 重建分段进度条
const oldBar = header.querySelector('.sfd-stress-group-bar');
if (oldBar) {
const members = (state.family.info && state.family.info.family_member) || [];
const games = Array.from(totalCards).map(c => ({ appid: c.dataset.stressAppid }));
oldBar.replaceWith(buildSegmentedGroupBar(games, members));
}
}
}
}
// 获取游戏的综合拥有状态(家庭组API + 个人游戏库对比 + 未发售网页请求)
function getStressOwnedInfo(appid) {
_loadStressDetectCache(); // 确保从 IndexedDB 加载持久化缓存
const gl = state.family.gameList;
// 1. 先从 GetSharedLibraryApps 检查
if (gl && gl.GameInfo && gl.GameInfo[appid]) {
return { owned: true, owners: gl.GameInfo[appid].owners || [], ownType: 'family' };
}
// 2. 再查商店页面检测缓存(未发售游戏网页请求结果)
const storeType = _stressStoreOwnedCache.get(appid);
if (storeType && storeType !== false) {
return { owned: true, owners: [], ownType: storeType };
}
// 3. 判断是否已发售:已发售游戏用个人游戏库数据对比,不发起网页请求
const dateStr = findGameDateByAppid(appid);
if (isGameReleased(dateStr)) {
const personalSet = getPersonalAppIdSet();
if (personalSet && personalSet.has(Number(appid))) {
_stressStoreOwnedCache.set(appid, 'self');
return { owned: true, owners: [], ownType: 'self' };
}
// 个人游戏库已加载但未拥有时才缓存 false;未加载时不缓存,等待加载后重新检测
if (personalSet) _stressStoreOwnedCache.set(appid, false);
return { owned: false, owners: [], ownType: null };
}
// 4. 未发售游戏:触发异步网页请求检测(保留进度条)
const storeResult = checkStressStoreFamilyOwnership(appid);
if (storeResult && storeResult !== false) {
return { owned: true, owners: [], ownType: storeResult };
}
return { owned: false, owners: [], ownType: null };
}
/* 2026精品 封面加载:复用入库动态的 dashLoadCapsule 逻辑(已知成功 URL 缓存 + 多 CDN 回退 + API + 中文封面) */
function loadStressCapsule(img, appid) {
return dashLoadCapsule(img, appid);
}
function parseStressDate(dateStr) {
const m = dateStr.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日/);
if (m) return new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]));
return new Date(0);
}
// 拥有者头像渲染(网格卡片用)
function buildOwnerAvatars(owners, maxShow, avatarMap, idMap) {
const wrap = h('div', { class: 'sfd-stress-owners' });
owners.slice(0, maxShow).forEach(sid => {
const sidStr = String(sid);
const avatar = avatarMap[sidStr] || `https://avatars.steamstatic.com/${sidStr}.jpg`;
const name = idMap[sidStr] || idMap[sid] || 'ID:' + sidStr.slice(-4);
wrap.appendChild(h('img', {
src: avatar, loading: 'lazy', title: name,
onerror: function handler() { this.removeEventListener('error', handler); this.src = DEFAULT_AVATAR; }
}));
});
if (owners.length > maxShow) {
wrap.appendChild(h('span', { style: { fontSize: '10px', color: '#94a3b8' }, text: `+${owners.length - maxShow}` }));
}
return wrap;
}
// 构建分段进度条(按成员拥有数量着色,使用 CHART_COLORS)
function buildSegmentedGroupBar(games, members) {
const mem = members || [];
const counts = mem.map(() => 0);
let storeOwned = 0;
games.forEach(g => {
const appid = Number(g.appid) || 0;
if (appid <= 0) return;
const info = getStressOwnedInfo(appid);
if (!info.owned) return;
if (info.owners && info.owners.length > 0) {
// 仅计入首次入库成员(owners数组第一个),后续重复购买不计数
const firstOwner = info.owners[0];
const idx = mem.findIndex(m => String(firstOwner) === String(m.steamid));
if (idx >= 0) counts[idx]++;
else storeOwned++;
} else {
storeOwned++;
}
});
const bar = h('div', { class: 'sfd-stress-group-bar' });
mem.forEach((m, i) => {
if (counts[i] > 0) {
bar.appendChild(h('div', {
class: 'sfd-stress-group-bar-seg',
style: { flexGrow: counts[i], background: CHART_COLORS[i % CHART_COLORS.length] }
}));
}
});
if (storeOwned > 0) {
bar.appendChild(h('div', {
class: 'sfd-stress-group-bar-seg',
style: { flexGrow: storeOwned, background: '#64748b' }
}));
}
const totalOwned = counts.reduce((s, c) => s + c, 0) + storeOwned;
const unowned = games.length - totalOwned;
if (unowned > 0) {
bar.appendChild(h('div', { style: { flexGrow: unowned } }));
}
return bar;
}
// 判断游戏日期是否已过(已上市)
function isGameReleased(dateStr) {
if (!dateStr) return false;
// 提取年份和月份,忽略"日"
const m = dateStr.match(/(\d{4})年(\d{1,2})月/);
if (m) {
const releaseDate = new Date(parseInt(m[1]), parseInt(m[2]) - 1, 1);
const now = new Date();
// 将当前日期也取到月初比较,避免同月内显示问题
return releaseDate.getTime() <= now.getTime();
}
return false;
}
// 拥有状态标签文本
function stressOwnedLabel(ownType, isZh, dateStr) {
if (ownType === 'self') {
return (dateStr && isGameReleased(dateStr)) ? (isZh ? '自己拥有' : 'Owned') : (isZh ? '自己预购' : 'Self');
}
return (dateStr && isGameReleased(dateStr)) ? (isZh ? '家庭拥有' : 'Family Owned') : (isZh ? '家庭预购' : 'Family');
}
// 按 appid 从精品/系列缓存中查找发售日期
function findGameDateByAppid(appid) {
const target = String(appid);
const search = (cache) => {
if (!cache) return null;
if (Array.isArray(cache)) {
const g = cache.find(x => String(x.appid) === target);
return g ? g.date : null;
}
for (const games of Object.values(cache)) {
if (!Array.isArray(games)) continue;
const g = games.find(x => String(x.appid) === target);
if (g) return g.date;
}
return null;
};
return search(_stressGamesCache) || search(_seriesCache);
}
// ==================== 系列游戏 Tab ====================
let _seriesCache = null;
let _seriesLoadFailed = false;
const SERIES_JSON_URL = 'https://raw.githubusercontent.com/SmallFork/json/main/game_series.json';
function getSeriesAllGames() {
if (!_seriesCache) return [];
// 新格式为 { "系列名": [...] } 对象,需展开为数组并注入系列名
if (!Array.isArray(_seriesCache)) {
const all = [];
for (const [seriesName, games] of Object.entries(_seriesCache)) {
games.forEach(g => all.push({ ...g, '系列': seriesName }));
}
return all;
}
return _seriesCache;
}
function renderFamilySeriesTab() {
const fi = state.family.info;
const gl = state.family.gameList;
const isZh = locale === 'zh-CN';
const container = h('div', { style: { flex: '1', overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: '0' } });
// 数据加载(@resource 同步优先 → 网络异步)
if (!_seriesCache && !_seriesLoadFailed) {
try {
if (typeof GM_getResourceText === 'function') {
try {
const jsonText = GM_getResourceText('seriesData');
if (jsonText) {
_seriesCache = JSON.parse(jsonText);
}
} catch (e) { logger.silent('系列游戏 @resource JSON 解析失败', e); }
}
} catch (e) { logger.warn('系列游戏 @resource 读取失败', e); }
}
if (!_seriesCache && !_seriesLoadFailed) {
const loadingWrap = h('div', { class: 'sfd-stress-loading' }, [
h('div', { html: ICONS.spinner }),
h('div', { text: isZh ? '正在获取系列游戏…' : 'Fetching series…' })
]);
container.appendChild(loadingWrap);
GM_xmlhttpRequest({
method: 'GET', url: SERIES_JSON_URL, timeout: 20000,
onload(resp) {
try {
const data = JSON.parse(resp.responseText);
_seriesCache = data;
_seriesLoadFailed = false;
} catch (e) { _seriesLoadFailed = true; }
renderFamilyPopup();
},
onerror() { _seriesLoadFailed = true; renderFamilyPopup(); },
ontimeout() { _seriesLoadFailed = true; renderFamilyPopup(); }
});
return container;
}
if (_seriesLoadFailed && !_seriesCache) {
const errWrap = h('div', { class: 'sfd-stress-empty' }, [
h('div', { text: isZh ? '数据获取失败,请检查网络' : 'Failed to load data', style: { marginBottom: '12px' } }),
h('button', { class: 'sfd-btn sfd-btn-primary', text: isZh ? '重试' : 'Retry', onClick: () => { _seriesLoadFailed = false; renderFamilyPopup(); } })
]);
container.appendChild(errWrap);
return container;
}
// 预加载个人游戏库(用于已发售游戏快速对比,避免逐个网页请求)
if (!state.personal.gamesCache) {
fetchMyOwnedGames().then(r => { _personalAppIdSet = null; if (r && familyPopupEl && familyPopupEl.classList.contains('sfd-show')) renderFamilyPopup(); });
}
// 按系列分组
const allGames = getSeriesAllGames();
const seriesMap = {};
allGames.forEach(game => {
const s = game['系列'] || (isZh ? '未分类' : 'Other');
if (!seriesMap[s]) seriesMap[s] = [];
seriesMap[s].push(game);
});
// 每个系列内按序号排序
Object.values(seriesMap).forEach(games => {
games.sort((a, b) => (a['序号'] || 0) - (b['序号'] || 0));
});
if (!Object.keys(seriesMap).length) {
container.appendChild(h('div', { class: 'sfd-stress-empty', text: isZh ? '暂无数据' : 'No data' }));
return container;
}
const avatarMap = buildAvatarMap();
const idMap = fi && fi.steamIdtoName ? fi.steamIdtoName : {};
// 构建网格卡片(和精品游戏一样的样式,非家庭共享游戏触发商店页面检测+checking动效)
function buildSeriesCard(game) {
const appid = Number(game.appid) || 0;
const ownedInfo = appid > 0 ? getStressOwnedInfo(appid) : { owned: false, owners: [], ownType: null };
const isOwned = ownedInfo.owned;
const { owners, ownType } = ownedInfo;
const ownedClass = isOwned && ownType ? `owned owned-${ownType}` : 'owned';
// 非家庭共享且商店检测仍在进行中 → 添加 checking 动效(复用精品游戏的 sfd-stress-pulse)
const isChecking = appid > 0 && !isOwned && _stressStorePending.has(appid);
const card = h('div', {
class: `sfd-stress-card ${isOwned ? ownedClass : 'not-owned'}${isChecking ? ' checking' : ''}`,
'data-stress-appid': appid,
onClick: () => appid > 0 && window.open(`https://store.steampowered.com/app/${appid}`, '_blank')
});
const posterImg = h('img', { class: 'sfd-stress-poster', loading: 'lazy' });
if (appid > 0) {
card.appendChild(loadStressCapsule(posterImg, appid));
} else {
card.appendChild(posterImg);
}
const info = h('div', { class: 'sfd-stress-info' });
info.appendChild(h('div', { class: 'sfd-stress-name', text: game.name, title: game.name }));
info.appendChild(h('div', { class: 'sfd-stress-date', text: game.date || '' }));
card.appendChild(info);
if (owners.length > 0) {
card.appendChild(buildOwnerAvatars(owners, 5, avatarMap, idMap));
} else if (isOwned && ownType) {
card.appendChild(h('div', { class: `sfd-stress-tag ${ownType}`, text: stressOwnedLabel(ownType, isZh, game.date) }));
}
// 检测完成后移除 checking 动效(与精品游戏一致)
if (isChecking) {
const iv = setInterval(() => {
if (!_stressStorePending.has(appid)) {
clearInterval(iv);
card.classList.remove('checking');
}
}, 200);
}
return card;
}
// 渲染系列分组(和精品游戏一样样式,默认展开)
const scrollWrap = h('div', { class: 'sfd-stress-scroll' });
const seriesKeys = Object.keys(seriesMap).sort((a, b) => a.localeCompare(b, 'zh'));
seriesKeys.forEach(seriesName => {
const games = seriesMap[seriesName];
if (!games.length) return;
const ownedCount = games.filter(g => {
const appid = Number(g.appid) || 0;
return appid > 0 && getStressOwnedInfo(appid).owned;
}).length;
const bodyWrap = h('div', { class: 'sfd-stress-group-body' });
const grid = h('div', { class: 'sfd-stress-grid' });
games.forEach(game => grid.appendChild(buildSeriesCard(game)));
bodyWrap.appendChild(grid);
let collapsed = true;
bodyWrap.style.display = 'none';
const header = (() => {
const pct = games.length > 0 ? Math.round(ownedCount / games.length * 100) : 0;
const arrow = h('span', {
class: 'sfd-stress-group-arrow',
html: '▼',
style: { fontSize: '10px', cursor: 'pointer', transition: 'transform 0.25s', marginRight: '4px', transform: 'rotate(-90deg)' }
});
const hdr = h('div', { class: 'sfd-stress-group-header', style: { cursor: 'pointer' }, onClick: () => {
collapsed = !collapsed;
const a = hdr.querySelector('.sfd-stress-group-arrow');
if (collapsed) {
bodyWrap.style.display = 'none';
a.style.transform = 'rotate(-90deg)';
} else {
bodyWrap.style.display = '';
a.style.transform = '';
}
} }, [
arrow,
h('span', { class: 'sfd-stress-group-title', text: `${seriesName}(${games.length})` }),
h('span', { class: 'sfd-stress-group-count', text: `${ownedCount}/${games.length}` }),
buildSegmentedGroupBar(games, fi ? (fi.family_member || []) : []),
h('span', { class: 'sfd-stress-group-pct', text: pct + '%' })
]);
return hdr;
})();
scrollWrap.appendChild(h('div', { class: 'sfd-stress-group' }, [header, bodyWrap]));
});
container.appendChild(scrollWrap);
return container;
}
function renderStressFamilyTab() {
const fi = state.family.info;
const gl = state.family.gameList;
const isZh = locale === 'zh-CN';
const container = h('div', { style: { flex: '1', overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: '0' } });
// 数据加载(@resource 同步优先 → 网络异步,缓存全部数据)
if (!_stressGamesCache && !_stressLoadFailed) {
if (!_stressForceNetwork) {
try {
if (typeof GM_getResourceText === 'function') {
try {
const jsonText = GM_getResourceText('stressData');
if (jsonText) {
const data = JSON.parse(jsonText);
_stressGamesCache = data;
}
} catch (e) { logger.silent('精品游戏 @resource JSON 解析失败', e); }
}
} catch (e) { logger.warn('精品游戏 @resource 读取失败', e); }
}
}
if (!_stressGamesCache && !_stressLoadFailed) {
const loadingWrap = h('div', { class: 'sfd-stress-loading' }, [
h('div', { html: ICONS.spinner }),
h('div', { text: isZh ? '正在获取精品游戏…' : 'Fetching games…' })
]);
container.appendChild(loadingWrap);
GM_xmlhttpRequest({
method: 'GET', url: STRESS_JSON_URL, timeout: 20000,
onload(resp) {
try {
const data = JSON.parse(resp.responseText);
_stressGamesCache = data;
_stressLoadFailed = false;
_stressForceNetwork = false;
} catch (e) { _stressLoadFailed = true; }
renderFamilyPopup();
},
onerror() { _stressLoadFailed = true; renderFamilyPopup(); },
ontimeout() { _stressLoadFailed = true; renderFamilyPopup(); }
});
return container;
}
if (_stressLoadFailed && !_stressGamesCache) {
const errWrap = h('div', { class: 'sfd-stress-empty' }, [
h('div', { text: isZh ? '数据获取失败,请检查网络' : 'Failed to load data', style: { marginBottom: '12px' } }),
h('button', { class: 'sfd-btn sfd-btn-primary', text: isZh ? '重试' : 'Retry', onClick: () => { _stressLoadFailed = false; renderFamilyPopup(); } })
]);
container.appendChild(errWrap);
return container;
}
// 预加载个人游戏库(用于已发售游戏快速对比,避免逐个网页请求)
if (!state.personal.gamesCache) {
fetchMyOwnedGames().then(r => { _personalAppIdSet = null; if (r && familyPopupEl && familyPopupEl.classList.contains('sfd-show')) renderFamilyPopup(); });
}
// 从缓存中提取数据:如果是数组按date字段的年份自动分组,否则直接按key分组
const rawData = _stressGamesCache;
let yearMap;
if (Array.isArray(rawData)) {
yearMap = {};
rawData.forEach(game => {
const y = game.date ? String(game.date).match(/(\d{4})/) : null;
const key = y ? y[1] : 'unknown';
if (!yearMap[key]) yearMap[key] = [];
yearMap[key].push(game);
});
} else {
yearMap = rawData;
}
const yearKeys = Object.keys(yearMap).filter(k => Array.isArray(yearMap[k]) && yearMap[k].length).sort((a, b) => b - a);
if (!yearKeys.length) {
container.appendChild(h('div', { class: 'sfd-stress-empty', text: isZh ? '暂无数据' : 'No data' }));
return container;
}
const avatarMap = buildAvatarMap();
const idMap = fi && fi.steamIdtoName ? fi.steamIdtoName : {};
const now = new Date();
// 统计拥有数量
function countOwned(groupGames) {
let n = 0;
groupGames.forEach(g => { if (getStressOwnedInfo(Number(g.appid)).owned) n++; });
return n;
}
// 构建分组头部(含折叠箭头)
function buildGroupHeader(title, ownedCount, total, groupGames, collapsed, onToggle) {
const pct = total > 0 ? Math.round(ownedCount / total * 100) : 0;
const arrow = h('span', {
class: `sfd-stress-group-arrow ${collapsed ? 'collapsed' : ''}`,
html: '▼',
style: { fontSize: '10px', cursor: 'pointer', transition: 'transform 0.25s', marginRight: '4px' }
});
if (collapsed) arrow.style.transform = 'rotate(-90deg)';
const header = h('div', { class: 'sfd-stress-group-header', style: { cursor: 'pointer' }, onClick: onToggle }, [
arrow,
h('span', { class: 'sfd-stress-group-title', text: title }),
h('span', { class: 'sfd-stress-group-count', text: `${ownedCount}/${total}` }),
buildSegmentedGroupBar(groupGames, fi ? (fi.family_member || []) : []),
h('span', { class: 'sfd-stress-group-pct', text: pct + '%' })
]);
return header;
}
// 构建网格卡片
function buildStressCard(game, showPast, checking) {
const appid = Number(game.appid);
const { owned: isOwned, owners, ownType } = getStressOwnedInfo(appid);
const ownedClass = isOwned && ownType ? `owned owned-${ownType}` : 'owned';
const checkingClass = checking ? ' checking' : '';
const card = h('div', {
class: `sfd-stress-card ${isOwned ? ownedClass : 'not-owned'}${checkingClass}`,
'data-stress-appid': appid,
onClick: () => window.open(`https://store.steampowered.com/app/${appid}`, '_blank')
});
const posterImg = h('img', { class: 'sfd-stress-poster', loading: 'lazy' });
card.appendChild(loadStressCapsule(posterImg, appid));
const info = h('div', { class: 'sfd-stress-info' });
info.appendChild(h('div', { class: 'sfd-stress-name', text: game.name, title: game.name }));
const isPast = showPast && parseStressDate(game.date) < now;
info.appendChild(h('div', { class: `sfd-stress-date ${isPast ? 'past' : ''}`, text: game.date }));
card.appendChild(info);
if (owners.length > 0) {
card.appendChild(buildOwnerAvatars(owners, 5, avatarMap, idMap));
} else if (isOwned && ownType) {
card.appendChild(h('div', { class: `sfd-stress-tag ${ownType}`, text: stressOwnedLabel(ownType, isZh, game.date) }));
}
return card;
}
// 渲染单个分组(标题 + 内容),返回 groupEl 用于跟踪
function renderGroup(title, groupGames, defaultCollapsed) {
if (!groupGames.length) return null;
const ownedCount = countOwned(groupGames);
const bodyWrap = h('div', { class: 'sfd-stress-group-body' });
const grid = h('div', { class: 'sfd-stress-grid' });
groupGames.forEach(game => grid.appendChild(buildStressCard(game, true)));
bodyWrap.appendChild(grid);
let collapsed = defaultCollapsed;
if (collapsed) bodyWrap.style.display = 'none';
const header = buildGroupHeader(title, ownedCount, groupGames.length, groupGames, collapsed, () => {
collapsed = !collapsed;
const arrow = header.querySelector('.sfd-stress-group-arrow');
if (collapsed) {
bodyWrap.style.display = 'none';
arrow.style.transform = 'rotate(-90deg)';
} else {
bodyWrap.style.display = '';
arrow.style.transform = '';
}
});
const groupEl = h('div', { class: 'sfd-stress-group' }, [header, bodyWrap]);
return groupEl;
}
// 渲染未发售分组(带焦点卡片和检测进度条)
function renderUnreleasedGroup(yearLabel, groupGames) {
if (!groupGames.length) return null;
const ownedCount = countOwned(groupGames);
const totalCheck = groupGames.length;
const bodyWrap = h('div', { class: 'sfd-stress-group-body' });
bodyWrap.style.display = 'none'; // 默认折叠
const progWrap = h('div', { class: 'sfd-family-progress active', style: { marginBottom: '8px' } });
const progBar = h('div', { class: 'sfd-family-progress-bar', style: { width: '0%' } });
progWrap.appendChild(progBar);
const header = buildGroupHeader(yearLabel + (isZh ? ' 未发售' : ' Unreleased'), ownedCount, groupGames.length, groupGames, true, () => {
let c = bodyWrap.style.display === 'none';
bodyWrap.style.display = c ? '' : 'none';
const arrow = header.querySelector('.sfd-stress-group-arrow');
arrow.style.transform = c ? '' : 'rotate(-90deg)';
});
const groupEl = h('div', { class: 'sfd-stress-group' }, [header, progWrap, bodyWrap]);
// 追踪检测进度
let checkedCount = 0;
function onCheckDone() {
checkedCount++;
const pct = Math.round(checkedCount / totalCheck * 100);
progBar.style.width = pct + '%';
if (checkedCount >= totalCheck) {
progBar.classList.add('done');
setTimeout(() => { progWrap.style.opacity = '0'; progWrap.style.height = '0'; progWrap.style.marginBottom = '0'; progWrap.style.overflow = 'hidden'; progWrap.style.transition = 'opacity 0.5s, height 0.5s, margin-bottom 0.5s'; }, 300);
}
}
groupGames.forEach(g => {
const appid = Number(g.appid);
checkStressStoreFamilyOwnership(appid);
if (_stressStoreOwnedCache.has(appid)) {
onCheckDone();
} else {
const iv = setInterval(() => {
if (!_stressStorePending.has(appid)) {
clearInterval(iv);
const card = document.querySelector(`[data-stress-appid="${appid}"]`);
if (card) card.classList.remove('checking');
onCheckDone();
}
}, 200);
}
});
// 焦点大卡片(前2个)
if (groupGames.length >= 2) {
const featWrap = h('div', { class: 'sfd-stress-featured-wrap' });
groupGames.slice(0, 2).forEach(game => {
const appid = Number(game.appid);
const { owned: isOwned, owners, ownType } = getStressOwnedInfo(appid);
const releaseDate = parseStressDate(game.date);
const daysLeft = Math.max(0, Math.ceil((releaseDate - now) / 86400000));
const featClass = isOwned && ownType ? `sfd-stress-featured owned owned-${ownType}` : (isOwned ? 'sfd-stress-featured owned' : 'sfd-stress-featured');
const featCard = h('div', {
class: featClass, 'data-stress-appid': appid,
onClick: () => window.open(`https://store.steampowered.com/app/${appid}`, '_blank')
});
const posterImg = h('img', { class: 'sfd-stress-featured-poster' });
featCard.appendChild(loadStressCapsule(posterImg, appid));
const badgeClass = isOwned && ownType ? `sfd-stress-featured-badge ${ownType}` : 'sfd-stress-featured-badge';
const badgeText = isOwned && ownType ? stressOwnedLabel(ownType, isZh, game.date) : (isZh ? '即将发售' : 'Coming Soon');
const infoWrap = h('div', { class: 'sfd-stress-featured-info' });
infoWrap.appendChild(h('div', { class: badgeClass, text: badgeText }));
infoWrap.appendChild(h('div', { class: 'sfd-stress-featured-name', text: game.name, title: game.name }));
infoWrap.appendChild(h('div', { class: 'sfd-stress-featured-date', text: game.date }));
featCard.appendChild(infoWrap);
const countdownWrap = h('div', { class: 'sfd-stress-featured-countdown' });
countdownWrap.appendChild(h('div', { class: 'sfd-stress-featured-days', text: String(daysLeft) }));
countdownWrap.appendChild(h('div', { class: 'sfd-stress-featured-days-lbl', text: isZh ? '天后发售' : 'days left' }));
featCard.appendChild(countdownWrap);
if (owners.length > 0) {
const ownersWrap = h('div', { class: 'sfd-stress-featured-owners' });
owners.slice(0, 6).forEach(sid => {
const sidStr = String(sid);
const avatar = avatarMap[sidStr] || `https://avatars.steamstatic.com/${sidStr}.jpg`;
const name = idMap[sidStr] || idMap[sid] || 'ID:' + sidStr.slice(-4);
ownersWrap.appendChild(h('img', {
src: avatar, loading: 'lazy', title: name,
onerror: function handler() { this.removeEventListener('error', handler); this.src = DEFAULT_AVATAR; }
}));
});
if (owners.length > 6) {
ownersWrap.appendChild(h('span', { style: { fontSize: '11px', color: '#94a3b8' }, text: `+${owners.length - 6}` }));
}
featCard.appendChild(ownersWrap);
}
featWrap.appendChild(featCard);
});
bodyWrap.appendChild(featWrap);
}
// 剩余网格
if (groupGames.length > 2) {
const grid = h('div', { class: 'sfd-stress-grid' });
groupGames.slice(2).forEach(game => {
const appid = Number(game.appid);
const checking = _stressStorePending.has(appid) || (!_stressStoreOwnedCache.has(appid));
grid.appendChild(buildStressCard(game, false, checking));
});
bodyWrap.appendChild(grid);
}
return groupEl;
}
const scrollWrap = h('div', { class: 'sfd-stress-scroll' });
// 遍历每个年份,展示其已发售和未发售分组
yearKeys.forEach(yk => {
const yearGames = yearMap[yk];
if (!yearGames || !yearGames.length) return;
yearGames.sort((a, b) => parseStressDate(b.date) - parseStressDate(a.date));
const released = [], unreleased = [];
yearGames.forEach(game => { (parseStressDate(game.date) < now ? released : unreleased).push(game); });
released.sort((a, b) => parseStressDate(b.date) - parseStressDate(a.date));
unreleased.sort((a, b) => parseStressDate(a.date) - parseStressDate(b.date));
// 未发售(预购):放在前面
const unrelGroup = renderUnreleasedGroup(yk, unreleased);
if (unrelGroup) scrollWrap.appendChild(unrelGroup);
// 已发售:放在后面,默认折叠
const relGroup = renderGroup(yk + (isZh ? ' 已发售' : ' Released'), released, true);
if (relGroup) scrollWrap.appendChild(relGroup);
});
container.appendChild(scrollWrap);
return container;
}
// ==================== 过滤与排序算法 ====================
function applyPanelFiltersAndSort() {
// v1.2.5: 联动好友总览筛选
applyOverviewFilters();
renderOverviewTable();
updateOverviewKpis();
updateChipCounts();
}
function applyModalFiltersAndSort() {
let list = [...state.friends.data];
list = filterFriendsByQuery(list, state.modalSearch, true);
if (state.modalStatusFilter === 'online') list = list.filter(f => f.personastate > 0);
else if (state.modalStatusFilter === 'offline') list = list.filter(f => f.personastate === 0);
else if (state.modalStatusFilter === 'ingame') list = list.filter(f => !!f.gameextrainfo);
else if (state.modalStatusFilter === 'vac') list = list.filter(f => f.vac_banned);
else if (state.modalStatusFilter === 'devban') list = list.filter(f => f.vac_game_bans > 0);
sortHelper(list, state.modalSortBy);
state.friends.modalFiltered = list;
}
function downloadFile(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function exportModalCSV() {
const data = state.friends.modalFiltered || state.friends.data;
if (!data.length) return;
const headers = ['Name', 'SteamID', 'Status', 'Country', 'FriendDays', 'LastOnline', 'VACBanned', 'GameBans', 'Level'];
const rows = data.map(f => [
`"${(f.personaname || '').replace(/"/g, '""')}"`,
f.steamid,
f.gameextrainfo ? `In-Game: ${f.gameextrainfo}` : getPersonaStateText(f.personastate),
f.country_name || '',
f.friend_days || '',
f.lastlogoff ? new Date(f.lastlogoff * 1000).toISOString() : '',
f.vac_banned ? 'Yes' : 'No',
f.vac_game_bans || 0,
f.level || ''
]);
const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
downloadFile(csv, `steam_friends_${Date.now()}.csv`, 'text/csv;charset=utf-8');
}
function exportModalJSON() {
const data = state.friends.modalFiltered || state.friends.data;
if (!data.length) return;
const json = JSON.stringify(data.map(f => ({
name: f.personaname, steamid: f.steamid,
status: f.gameextrainfo ? `In-Game: ${f.gameextrainfo}` : getPersonaStateText(f.personastate),
country: f.country_name || '', friend_days: f.friend_days || 0,
last_online: f.lastlogoff ? new Date(f.lastlogoff * 1000).toISOString() : null,
vac_banned: !!f.vac_banned, game_bans: f.vac_game_bans || 0,
level: f.level || 0
})), null, 2);
downloadFile(json, `steam_friends_${Date.now()}.json`, 'application/json');
}
function exportFamilyCSV() {
const gl = state.family.gameList;
const fi = state.family.info;
if (!gl || !gl.GameInfo) return;
const headers = ['AppID', 'GameName', 'Owners', 'OwnerCount', 'Time'];
const rows = Object.entries(gl.GameInfo).map(([appid, info]) => {
const names = info.owners.map(sid => fi ? (fi.steamIdtoName[sid] || sid) : sid).join(' | ');
return [appid, `"${(info.name || '').replace(/"/g, '""')}"`, `"${names}"`, info.owners.length, info.time ? new Date(info.time * 1000).toISOString() : ''];
});
const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
downloadFile(csv, `family_games_${Date.now()}.csv`, 'text/csv;charset=utf-8');
}
function exportFamilyJSON() {
const gl = state.family.gameList;
const fi = state.family.info;
if (!gl || !gl.GameInfo) return;
const json = JSON.stringify(Object.entries(gl.GameInfo).map(([appid, info]) => ({
appid: Number(appid), name: info.name,
owners: info.owners.map(sid => fi ? (fi.steamIdtoName[sid] || sid) : sid),
owner_count: info.owners.length,
time: info.time ? new Date(info.time * 1000).toISOString() : null,
icon_hash: info.icon_hash
})), null, 2);
downloadFile(json, `family_games_${Date.now()}.json`, 'application/json');
}
function sortHelper(list, method) {
const getWeight = (x) => { if (x.gameextrainfo) return 100; if (x.personastate > 0) return 80; return 0; };
list.sort((a, b) => {
switch (method) {
case 'days-desc': return b.friend_days - a.friend_days;
case 'days-asc': return a.friend_days - b.friend_days;
case 'status-desc': {
const wA = getWeight(a), wB = getWeight(b);
if (wA !== wB) return wB - wA;
if (wA === 0) return b.lastlogoff - a.lastlogoff;
return b.friend_days - a.friend_days;
}
case 'name-asc': return a.personaname.localeCompare(b.personaname, locale === 'zh-CN' ? 'zh' : 'en');
case 'name-desc': return b.personaname.localeCompare(a.personaname, locale === 'zh-CN' ? 'zh' : 'en');
case 'country-asc': {
const na = a.country_name || a.loccountrycode || '\uFFFF';
const nb = b.country_name || b.loccountrycode || '\uFFFF';
return na.localeCompare(nb, locale === 'zh-CN' ? 'zh' : 'en');
}
case 'country-desc': {
const na = a.country_name || a.loccountrycode || '\u0000';
const nb = b.country_name || b.loccountrycode || '\u0000';
return nb.localeCompare(na, locale === 'zh-CN' ? 'zh' : 'en');
}
case 'level-desc': return (b.level || 0) - (a.level || 0);
case 'level-asc': return (a.level || 0) - (b.level || 0);
case 'mutual-desc': return (b.mutualFriendsCount || 0) - (a.mutualFriendsCount || 0);
case 'mutual-asc': return (a.mutualFriendsCount || 0) - (b.mutualFriendsCount || 0);
default: return 0;
}
});
}
// ==================== 更新 Panel Header 头像/信息 ====================
function updatePanelHeaderInfo() {
const el = dom.panelHeaderInfo;
if (!el) return;
const p = state.friends.ownProfile;
if (!p) return;
const hasGame = p.gameextrainfo;
el.innerHTML = '';
// 头像
if (p.avatar) {
const img = document.createElement('img');
Object.assign(img.style, { width: '36px', height: '36px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(59,130,246,0.45)', flexShrink: '0', boxShadow: '0 4px 12px rgba(59,130,246,0.2)' });
img.src = p.avatar;
img.loading = 'lazy';
img.onerror = () => { img.src = 'https://avatars.steamstatic.com/fef49e7fa7e1997310dd48961da2e7d95a5c7a56_medium.jpg'; };
el.appendChild(img);
}
// info
const infoWrap = document.createElement('div');
infoWrap.style.cssText = 'display:flex;flex-direction:column;gap:2px;min-width:0;';
// 昵称
const nameRow = document.createElement('h3');
nameRow.style.cssText = 'margin:0;font-size:14px;color:#fff;font-weight:700;display:flex;align-items:center;gap:8px;flex-wrap:wrap;';
nameRow.appendChild(h('span', { text: p.personaname }));
if (p.level > 0) nameRow.appendChild(h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '11px', color: '#fbbf24', background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.25)', borderRadius: '4px', padding: '1px 6px', fontWeight: '600' }, html: `${ICONS.level} Lv.${p.level}` }));
infoWrap.appendChild(nameRow);
// meta
const metaWrap = document.createElement('div');
metaWrap.style.cssText = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;';
if (p.country_name) metaWrap.appendChild(h('span', { style: { fontSize: '11px', color: '#94a3b8' }, html: `${p.country_flag || ''} ${p.country_name}` }));
metaWrap.appendChild(hasGame
? h('span', { class: 'sfd-badge sfd-badge-status in-game', text: `🎮 ${p.gameextrainfo}` })
: h('span', { class: `sfd-badge sfd-badge-status ${p.personastate > 0 ? 'online' : 'offline'}`, text: getPersonaStateText(p.personastate) })
);
infoWrap.appendChild(metaWrap);
el.appendChild(infoWrap);
// 好友进度条(长条形,在昵称/状态下方)
const cnt = getSteamFriendsCount();
const lim = getSteamFriendsLimit();
const pct = Math.min(100, Math.round(cnt / Math.max(1, lim) * 100));
const gaugeBar = document.createElement('div');
gaugeBar.style.cssText = 'flex:1;min-width:120px;max-width:400px;display:flex;align-items:center;gap:8px;';
gaugeBar.appendChild(h('div', { style: { flex: '1', height: '4px', borderRadius: '2px', background: 'rgba(255,255,255,0.08)', overflow: 'hidden' } }, [
h('div', { style: { height: '100%', width: pct + '%', borderRadius: '2px', background: 'linear-gradient(90deg,#3b82f6,#60a5fa)', transition: 'width 0.4s' } })
]));
gaugeBar.appendChild(h('span', { style: { fontSize: '11px', color: '#94a3b8', whiteSpace: 'nowrap', fontWeight: '600' }, text: `${cnt} / ${lim}` }));
gaugeBar.title = locale === 'zh-CN' ? `好友 ${cnt} / ${lim}(上限随等级增长)` : `Friends ${cnt} / ${lim}`;
el.appendChild(gaugeBar);
}
// ==================== 页脚状态管理 ====================
function setStatusText(txt) {
const el = document.getElementById('sfd-status-txt');
if (el) el.textContent = txt;
}
// ==================== 初始化与页面变动监听 ====================
// ==================== Title Bar 美化 ====================
function beautifyTitleBar() {
const titleBar = document.querySelector('.profile_friends.title_bar');
if (!titleBar || titleBar.dataset.sfdBeautified) return;
titleBar.dataset.sfdBeautified = '1';
// 1. 美化 title_bar 整体样式
titleBar.style.cssText = 'display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:8px 12px;background:linear-gradient(135deg,rgba(59,130,246,0.06),rgba(15,23,42,0.4));border:1px solid rgba(59,130,246,0.15);border-radius:10px;margin-bottom:12px;';
// 2. 好友计数 + 进度条
const titleDiv = titleBar.querySelector('.profile_friends.title');
let countVal = '', limitVal = '';
if (titleDiv) {
const countSpan = titleDiv.querySelector('.friends_count');
const limitSpan = titleDiv.querySelector('.friends_limit');
countVal = countSpan ? (countSpan.textContent || '').trim() : '';
limitVal = limitSpan ? (limitSpan.textContent || '').trim() : '';
titleDiv.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:0;';
titleDiv.innerHTML = `
${locale === 'zh-CN' ? '您的好友' : 'Your Friends'}
${esc(countVal)}
/ ${esc(limitVal)}
`;
}
// 2b. 好友进度条(放在 title_bar 上部,紧贴顶部)
const countNum = parseInt(countVal) || 0;
const limitNum = parseInt(limitVal) || 1;
const pct = Math.min(100, Math.round(countNum / limitNum * 100));
const barColor = pct >= 90 ? '#ef4444' : (pct >= 70 ? '#f59e0b' : '#3b82f6');
const progBar = document.createElement('div');
progBar.style.cssText = `width:100%;height:4px;margin-bottom:4px;border-radius:2px;background:rgba(255,255,255,0.06);overflow:hidden;flex-basis:100%;order:-1;`;
progBar.title = `${countNum} / ${limitNum} (${pct}%)`;
progBar.innerHTML = `
`;
titleBar.insertBefore(progBar, titleBar.firstChild);
// 3. 三个脚本按钮(紧跟在进度条后面,靠左排列)
const btnGroup = document.createElement('div');
btnGroup.style.cssText = 'display:flex;align-items:center;gap:4px;flex-shrink:0;';
// 好友管理按钮
const panelBtn = document.createElement('button');
panelBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;background:rgba(59,130,246,0.12);border:1px solid rgba(59,130,246,0.2);border-radius:6px;color:#66c0f4;cursor:pointer;transition:all 0.2s;padding:0;';
panelBtn.innerHTML = ICONS.users;
panelBtn.title = t('title');
panelBtn.onmouseenter = () => { panelBtn.style.background = 'rgba(59,130,246,0.25)'; panelBtn.style.borderColor = 'rgba(59,130,246,0.4)'; };
panelBtn.onmouseleave = () => { panelBtn.style.background = 'rgba(59,130,246,0.12)'; panelBtn.style.borderColor = 'rgba(59,130,246,0.2)'; };
panelBtn.onclick = () => {
panelEl.classList.toggle('sfd-show');
if (panelEl.classList.contains('sfd-show')) {
renderActiveTab();
autoRefreshFriends();
}
};
btnGroup.appendChild(panelBtn);
// 个人游戏库按钮
const libBtn = document.createElement('button');
libBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.2);border-radius:6px;color:#8b5cf6;cursor:pointer;transition:all 0.2s;padding:0;';
libBtn.innerHTML = ICONS.package;
libBtn.title = t('plTitle');
libBtn.onmouseenter = () => { libBtn.style.background = 'rgba(139,92,246,0.25)'; libBtn.style.borderColor = 'rgba(139,92,246,0.4)'; };
libBtn.onmouseleave = () => { libBtn.style.background = 'rgba(139,92,246,0.12)'; libBtn.style.borderColor = 'rgba(139,92,246,0.2)'; };
libBtn.onclick = () => {
if (!state.personal.popupEl) { showPersonalLibrary(); }
else { state.personal.popupEl.classList.toggle('sfd-show'); }
};
btnGroup.appendChild(libBtn);
// 家庭组按钮
const familyBtn = document.createElement('button');
familyBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;background:rgba(245,158,11,0.12);border:1px solid rgba(245,158,11,0.2);border-radius:6px;color:#f59e0b;cursor:pointer;transition:all 0.2s;padding:0;';
familyBtn.innerHTML = ICONS.family;
familyBtn.title = locale === 'zh-CN' ? '家庭组' : 'Family Group';
familyBtn.onmouseenter = () => { familyBtn.style.background = 'rgba(245,158,11,0.25)'; familyBtn.style.borderColor = 'rgba(245,158,11,0.4)'; };
familyBtn.onmouseleave = () => { familyBtn.style.background = 'rgba(245,158,11,0.12)'; familyBtn.style.borderColor = 'rgba(245,158,11,0.2)'; };
familyBtn.onclick = () => {
if (!familyPopupEl) {
if (!state.family.popupSteamid) {
const ownBlock = document.querySelector('.friend_block_v2[data-steamid]');
if (ownBlock) state.family.popupSteamid = ownBlock.dataset.steamid;
if (!state.family.popupSteamid) state.family.popupSteamid = storage.getSteamId() || detectCurrentSteamId();
}
if (state.family.popupSteamid) showFamilyPopup(state.family.popupSteamid);
return;
}
familyPopupEl.classList.toggle('sfd-show');
};
btnGroup.appendChild(familyBtn);
// 把按钮组插到进度条后面
progBar.insertAdjacentElement('afterend', btnGroup);
// 4. 排序按钮区域美化
const sortBox = titleBar.querySelector('.as-sortbox');
if (sortBox) {
sortBox.style.cssText = 'display:flex;align-items:center;gap:4px;';
const sortLabel = sortBox.querySelector('.as-sortbox__label');
if (sortLabel) sortLabel.style.cssText = 'font-size:11px;color:#94a3b8;';
const sortTrigger = sortBox.querySelector('.as-sortbox__trigger');
if (sortTrigger) sortTrigger.style.cssText = 'background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.15);border-radius:5px;color:#94a3b8;font-size:11px;padding:3px 8px;cursor:pointer;transition:all 0.2s;';
const sortReverse = sortBox.querySelector('.as-sortbox__reverse');
if (sortReverse) sortReverse.style.cssText = 'background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.15);border-radius:5px;color:#94a3b8;font-size:11px;padding:3px 6px;cursor:pointer;transition:all 0.2s;';
}
// 5. 管理好友列表 + 添加好友(带 SVG 图标 + tooltip)
const manageBtn = titleBar.querySelector('#manage_friends_control');
const addBtn = titleBar.querySelector('#add_friends_button');
if (manageBtn && addBtn) {
// 创建长胶囊容器
const pillWrap = document.createElement('span');
pillWrap.style.cssText = 'display:inline-flex;align-items:stretch;border-radius:14px;overflow:hidden;border:1px solid rgba(59,130,246,0.2);flex-shrink:0;margin-left:auto;order:98;';
// 管理按钮(左半边)
manageBtn.style.cssText = 'background:rgba(59,130,246,0.1)!important;background-image:none!important;color:#66c0f4!important;padding:3px 8px!important;cursor:pointer!important;border:none!important;display:inline-flex!important;align-items:center!important;gap:4px!important;transition:background 0.2s!important;white-space:nowrap!important;border-radius:0!important;box-shadow:none!important;text-shadow:none!important;line-height:normal!important;height:auto!important;font-size:11px!important;';
manageBtn.title = locale === 'zh-CN' ? '管理好友列表' : 'Manage Friends';
const manageBtnSpan = manageBtn.querySelector('span');
if (manageBtnSpan) {
manageBtnSpan.style.cssText = 'display:inline-flex!important;align-items:center!important;gap:4px!important;background:none!important;color:inherit!important;font-size:11px!important;';
manageBtnSpan.innerHTML = ICONS.manageList + '' + (locale === 'zh-CN' ? '管理好友' : 'Manage') + ' ';
}
manageBtn.onmouseenter = () => { manageBtn.style.background = 'rgba(59,130,246,0.2)'; };
manageBtn.onmouseleave = () => { manageBtn.style.background = 'rgba(59,130,246,0.1)'; };
// 添加按钮(右半边)
addBtn.style.cssText = 'background:rgba(16,185,129,0.1)!important;background-image:none!important;color:#10b981!important;padding:3px 8px!important;cursor:pointer!important;border:none!important;display:inline-flex!important;align-items:center!important;gap:4px!important;transition:background 0.2s!important;white-space:nowrap!important;border-radius:0!important;box-shadow:none!important;text-shadow:none!important;line-height:normal!important;height:auto!important;font-size:11px!important;';
addBtn.title = locale === 'zh-CN' ? '添加好友' : 'Add Friend';
const addBtnSpan = addBtn.querySelector('span');
if (addBtnSpan) {
addBtnSpan.style.cssText = 'display:inline-flex!important;align-items:center!important;gap:4px!important;background:none!important;color:inherit!important;font-size:11px!important;';
addBtnSpan.innerHTML = ICONS.userPlus + '' + (locale === 'zh-CN' ? '添加好友' : 'Add') + ' ';
}
addBtn.onmouseenter = () => { addBtn.style.background = 'rgba(16,185,129,0.2)'; };
addBtn.onmouseleave = () => { addBtn.style.background = 'rgba(16,185,129,0.1)'; };
// 包裹两个按钮
manageBtn.parentNode.insertBefore(pillWrap, manageBtn);
pillWrap.appendChild(manageBtn);
pillWrap.appendChild(addBtn);
} else {
if (manageBtn) {
manageBtn.style.cssText = 'background:rgba(59,130,246,0.12)!important;border:1px solid rgba(59,130,246,0.2)!important;border-radius:20px!important;color:#66c0f4!important;font-size:11px!important;padding:4px 10px!important;cursor:pointer!important;transition:all 0.2s!important;display:inline-flex!important;align-items:center!important;gap:3px!important;';
manageBtn.title = locale === 'zh-CN' ? '管理好友列表' : 'Manage Friends';
}
if (addBtn) {
addBtn.style.cssText = 'background:rgba(16,185,129,0.12)!important;border:1px solid rgba(16,185,129,0.2)!important;border-radius:20px!important;color:#10b981!important;font-size:11px!important;padding:4px 10px!important;cursor:pointer!important;transition:all 0.2s!important;display:inline-flex!important;align-items:center!important;gap:3px!important;';
addBtn.title = locale === 'zh-CN' ? '添加好友' : 'Add Friend';
}
}
// 6. 删除原有的右下角浮动触发按钮
if (panelTrigger) panelTrigger.remove();
if (familyTrigger) familyTrigger.remove();
if (libraryTrigger) libraryTrigger.remove();
}
function init() {
if (!window.location.pathname.includes('/friends')) return;
initUI();
// ===== Title Bar 美化 + 将三个触发按钮移入 =====
beautifyTitleBar();
// 首次使用无 API Key 时,自动弹出好友管理窗口并打开设置页
if (!storage.getApiKey()) {
panelEl.classList.add('sfd-show');
toggleSettings();
}
const onKey = (e) => {
if (e.target.matches('input, select, textarea')) { if (e.key === 'Escape') e.target.blur(); return; }
if (e.key === 'Escape') {
// 通过注册表调用 close(),确保 cleanup 执行(移除事件监听器)
const escOrder = ['sfd-month-games-popup', 'sfd-my90-games-popup', 'sfd-member-games-popup', 'sfd-contrib-overlay', 'sfd-compare-popup'];
for (const popupId of escOrder) {
const closeFn = _modalRegistry.get(popupId);
if (closeFn && document.getElementById(popupId)) { closeFn(); return; }
}
if (familyPopupEl && familyPopupEl.classList.contains('sfd-show')) familyPopupEl.classList.remove('sfd-show');
else if (state.personal.popupEl && state.personal.popupEl.classList.contains('sfd-show')) state.personal.popupEl.classList.remove('sfd-show');
else if (panelEl && panelEl.classList.contains('sfd-show')) panelEl.classList.remove('sfd-show');
}
};
document.addEventListener('keydown', onKey);
state.ui.disposers.push(() => document.removeEventListener('keydown', onKey));
if (state.friends.data.length) {
setTimeout(() => applyPageEnhancements(state.friends.data), 1000);
}
// VAC 检查:先从缓存加载
const apiKey = storage.getApiKey();
if (apiKey) {
loadVACFromCache();
loadLevelFromCache();
}
const targetNode = document.querySelector('.profile_friends_list') || document.body;
const observer = new MutationObserver((mutationsList) => {
let shouldUpdate = false;
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
const added = Array.from(mutation.addedNodes);
if (added.some(node => node.nodeType === 1 && (node.classList.contains('friend_block_v2') || node.querySelector('.friend_block_v2')))) {
shouldUpdate = true; break;
}
}
}
if (shouldUpdate && state.friends.data.length) applyPageEnhancements(state.friends.data);
});
observer.observe(targetNode, { childList: true, subtree: true });
state.ui.disposers.push(() => observer.disconnect());
window.addEventListener('beforeunload', () => {
state.ui.disposers.forEach(d => d());
state.ui.disposers = [];
});
}
// ==================== 社交仪表盘 (v1.0.17) ====================
let dashTipEl = null;
const DASH_HEAT_DAYS = 14;
const DASH_RECENT_TOP = 10;
const DASH_OWNED_TOP = 10;
const DASH_FRIENDS_TOP = 10;
const DASH_HEAT_ROWS = 12;
const DASH_COLORS = ['#38bdf8', '#a3e635', '#fbbf24', '#c084fc', '#f472b6', '#2dd4bf', '#fb7185', '#94a3b8'];
const DASH_HEAT_SCALE = ['rgba(255,255,255,0.07)', 'rgba(56,189,248,0.28)', 'rgba(56,189,248,0.5)', 'rgba(56,189,248,0.78)', '#38bdf8'];
const DASH_ZH = () => locale === 'zh-CN';
function dashEsc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
function dashDateKey(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
function dashFmtH(minutes) { if (!minutes || minutes <= 0) return locale === 'zh-CN' ? '0分' : '0m'; const zh = locale === 'zh-CN'; if (minutes < 60) return zh ? `${Math.round(minutes)}分钟` : `${Math.round(minutes)}m`; const hrs = minutes / 60; return zh ? `${hrs >= 100 ? Math.round(hrs) : hrs.toFixed(1)}小时` : `${hrs >= 100 ? Math.round(hrs) : hrs.toFixed(1)}h`; }
function dashCapsule(appid) { return `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/capsule_184x69.jpg`; }
function dashCapsuleFallback(appid) { return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_184x69.jpg`; }
const DASH_CAPSULE_CDN = 'https://cdn.cloudflare.steamstatic.com/steam/apps/';
const DASH_CAPSULE_PATHS = ['capsule_467x181.jpg', 'capsule_231x87.jpg', 'capsule_231x87.png', 'capsule_184x69.jpg', 'capsule_184x69.png', 'header.jpg'];
const _dashCapsuleGood = new Map();
const _dashCapsuleApiCache = new Map();
const _dashCapsuleApiPending = new Map();
const DB_CAPSULE_PREFIX = 'sfd_capsule_';
// 启动时从 IndexedDB 恢复封面缓存
function _restoreCapsuleCache() {
Object.keys(_dbCache).forEach(key => {
if (key.startsWith(DB_CAPSULE_PREFIX)) {
const appid = Number(key.slice(DB_CAPSULE_PREFIX.length));
if (appid && _dbCache[key]) _dashCapsuleGood.set(appid, _dbCache[key]);
}
});
if (_dashCapsuleGood.size) console.log(`%c[SFD] 封面缓存已恢复 ${_dashCapsuleGood.size} 条`, 'color:#66c0f4');
}
// 持久化封面 URL 到 IndexedDB
function _persistCapsuleUrl(appid, url) {
if (url && url.startsWith('http')) {
_dashCapsuleGood.set(appid, url);
_dbSet(DB_CAPSULE_PREFIX + appid, url);
}
}
function dashFetchCapsuleFromStore(appid) {
if (_dashCapsuleApiCache.has(appid)) return Promise.resolve(_dashCapsuleApiCache.get(appid));
if (_dashCapsuleApiPending.has(appid)) return _dashCapsuleApiPending.get(appid);
const p = SteamAPI.getAppDetails(appid)
.then(json => { let url = ''; const d = json && json[appid]; if (d && d.success && d.data && d.data.capsule_image) url = d.data.capsule_image; _dashCapsuleApiCache.set(appid, url); _dashCapsuleApiPending.delete(appid); if (url) _persistCapsuleUrl(appid, url); return url; })
.catch(() => { _dashCapsuleApiCache.set(appid, ''); _dashCapsuleApiPending.delete(appid); return ''; });
_dashCapsuleApiPending.set(appid, p);
return p;
}
// 创建胶囊图破碎占位图标
function _makeBrokenIcon(small) {
const icon = h('div', { class: 'sfd-img-broken-icon' + (small ? ' sfd-img-broken-sm' : ''), html: ICONS.steam });
return icon;
}
// 标记 wrapper 为加载完成
function _markLoaded(wrapper) { wrapper.classList.add('sfd-img-loaded'); }
// 标记 wrapper 为碎图
function _markBroken(wrapper, small) { wrapper.classList.add('sfd-img-broken'); wrapper.appendChild(_makeBrokenIcon(small)); }
// 创建带科技感加载骨架的图片 wrapper
function createImgWrapper(appid, className, small) {
const wrapper = h('div', { class: 'sfd-img-wrapper' + (className ? ' ' + className : '') });
const img = h('img', { alt: '', loading: 'lazy', dataset: { appid: String(appid) } });
wrapper.appendChild(img);
return { wrapper, img };
}
// 为已有的 img 元素包裹 wrapper(用于 dashLoadCapsule 等场景)
function wrapImgWithLoader(img, appid, wrapperClass) {
if (img.parentElement && img.parentElement.classList.contains('sfd-img-wrapper')) return { wrapper: img.parentElement, img };
const wrapper = h('div', { class: 'sfd-img-wrapper' + (wrapperClass ? ' ' + wrapperClass : '') });
wrapper.dataset.appid = String(appid);
if (img.parentElement) {
img.parentElement.replaceChild(wrapper, img);
}
wrapper.appendChild(img);
return { wrapper, img };
}
function dashLoadCapsule(img, appid) {
// 确保图片在 wrapper 中
const { wrapper } = wrapImgWithLoader(img, appid, 'sfd-img-cap');
const chain = []; const good = _dashCapsuleGood.get(appid);
if (good) chain.push(good);
DASH_CAPSULE_PATHS.forEach(p => chain.push(DASH_CAPSULE_CDN + appid + '/' + p));
chain.push(`https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/capsule_184x69.jpg`);
let idx = 0;
const onDone = () => { _markLoaded(wrapper); };
const onBroken = () => { img.onload = null; img.onerror = null; _markBroken(wrapper); };
img.onload = () => { if (img.src && img.src !== _dashCapsuleGood.get(appid)) _persistCapsuleUrl(appid, img.src); onDone(); };
const toPlaceholder = () => { img.onload = null; img.onerror = null; img.style.display = 'none'; _markBroken(wrapper); };
const tryNext = () => {
if (idx >= chain.length) { dashFetchCapsuleFromStore(appid).then(url => { if (url) { img.onerror = toPlaceholder; img.src = url; } else toPlaceholder(); }).catch(toPlaceholder); return; }
img.onerror = () => { idx++; tryNext(); };
img.src = chain[idx];
};
tryNext();
// 异步尝试中文封面,成功则替换
tryZhImage(img, appid, 'capsule_184x69_schinese.jpg');
return wrapper;
}
// 通用图片加载:自动包裹 wrapper,带加载态和碎图处理
function loadGameImage(wrapperOrImg, appid, primaryUrl, fallbackUrl, wrapperClass, small, zhFile) {
const isImg = wrapperOrImg.tagName === 'IMG';
const img = isImg ? wrapperOrImg : wrapperOrImg.querySelector('img') || h('img', { alt: '', loading: 'lazy' });
let wrapper;
if (isImg) {
const r = wrapImgWithLoader(wrapperOrImg, appid, wrapperClass);
wrapper = r.wrapper; // img already inside wrapper
} else {
wrapper = wrapperOrImg;
if (!wrapper.classList.contains('sfd-img-wrapper')) wrapper.classList.add('sfd-img-wrapper');
if (wrapperClass) wrapper.classList.add(wrapperClass);
if (!wrapper.contains(img)) wrapper.appendChild(img);
}
wrapper.dataset.appid = String(appid);
let triedPrimary = false;
const onDone = () => { _markLoaded(wrapper); };
const onBroken = () => { img.onload = null; img.onerror = null; _markBroken(wrapper, small); };
const tryPrimary = () => {
triedPrimary = true;
img.onerror = () => { if (fallbackUrl) { img.onerror = onBroken; img.src = fallbackUrl; } else onBroken(); };
img.onload = onDone;
img.src = primaryUrl;
};
tryPrimary();
if (zhFile && locale === 'zh-CN') {
tryZhImage(img, appid, zhFile);
}
}
// ===== 异步中文封面加载 =====
const _zhImgGood = new Map(); // appid -> 中文封面URL
const _zhImgFail = new Set(); // appid -> 无中文封面
function tryZhImage(img, appid, zhFileName) {
if (locale !== 'zh-CN' || !appid) return;
const cached = _zhImgGood.get(appid);
if (cached) { img.src = cached; return; }
if (_zhImgFail.has(appid)) return;
const zhUrl = `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/${zhFileName}`;
const tester = new Image();
tester.onload = () => { _zhImgGood.set(appid, zhUrl); img.src = zhUrl; };
tester.onerror = () => { _zhImgFail.add(appid); };
tester.src = zhUrl;
}
function dashAvatarFallback(el) { if (el.dataset.fb) { el.style.visibility = 'hidden'; return; } el.dataset.fb = '1'; el.src = DEFAULT_AVATAR; }
function dashSvg(tag, attrs = {}) { const el = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); return el; }
function dashTip(html, x, y) {
if (!dashTipEl) { dashTipEl = h('div', { class: 'sfd-dash-tip' }); document.body.appendChild(dashTipEl); }
if (dashTipEl.innerHTML !== html) { dashTipEl.innerHTML = html; dashTipEl.querySelectorAll('img').forEach(im => im.addEventListener('error', () => { const a = im.dataset.appid; if (a && !im.dataset.fb) { im.dataset.fb = '1'; im.src = dashCapsuleFallback(a); } else im.remove(); })); }
dashTipEl.style.display = 'block';
const pad = 14; const rect = dashTipEl.getBoundingClientRect();
let left = x + pad, top = y + pad;
if (left + rect.width > window.innerWidth - 10) left = x - rect.width - pad;
if (left < 4) left = 4;
if (top + rect.height > window.innerHeight - 10) top = y - rect.height - pad;
if (top < 4) top = 4;
dashTipEl.style.left = left + 'px'; dashTipEl.style.top = top + 'px';
}
function dashTipHide() { if (dashTipEl) dashTipEl.style.display = 'none'; }
function dashBindTip(el, htmlFn) { el.addEventListener('mouseenter', e => dashTip(htmlFn(), e.clientX, e.clientY)); el.addEventListener('mousemove', e => dashTip(htmlFn(), e.clientX, e.clientY)); el.addEventListener('mouseleave', dashTipHide); }
// —— 数据获取 ——
async function dashFetchRecent(sid) {
const data = await SteamAPI.getRecentGamesLegacy(sid);
const resp = data && data.response;
if (!resp || Object.keys(resp).length === 0) return { games: [], private: true };
return { private: false, games: (resp.games || []).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtime_2weeks: g.playtime_2weeks || 0, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '' })) };
}
async function fetchOwnedGamesCached(sid) {
const now = Date.now();
// 优先读取按好友存储的缓存
const c = storage.getOwnedGameCache(sid);
if (c && now - c.ts < OWNED_GAMES_TTL && !c.error) return c;
try {
const data = await SteamAPI.getOwnedGamesLegacy(sid);
const resp = data && data.response;
if (!resp || typeof resp.game_count !== 'number' || resp.game_count === 0) {
const r = { sid, games: [], game_count: 0, totalMinutes: 0, private: true, ts: now };
storage.setOwnedGameCache(sid, r);
const summary = storage.getOwnedGamesSummary(); summary[sid] = { ts: now, game_count: 0, totalMinutes: 0, private: true }; summary._ts = now; storage.setOwnedGamesSummary(summary);
return r;
}
let total = 0;
const games = (resp.games || []).map(g => { total += g.playtime_forever || 0; return { appid: g.appid, name: g.name || `App ${g.appid}`, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '', rtime_last_played: g.rtime_last_played || 0 }; });
const r = { sid, games, game_count: resp.game_count, totalMinutes: total, private: false, ts: now };
storage.setOwnedGameCache(sid, r);
const summary = storage.getOwnedGamesSummary(); summary[sid] = { ts: now, game_count: r.game_count, totalMinutes: r.totalMinutes, private: false }; summary._ts = now; storage.setOwnedGamesSummary(summary);
return r;
} catch (e) {
return { sid, games: [], game_count: 0, totalMinutes: 0, private: false, error: true, ts: now };
}
}
async function dashFetchOwned(sid) {
const r = await fetchOwnedGamesCached(sid);
// 同时记录timeline,检测新增游戏(与好友入库动态共用逻辑)
if (!r.private && !r.error) recordTimeline(sid, r);
return { game_count: r.game_count, totalMinutes: r.totalMinutes, private: r.private, error: r.error };
}
// 共享:入库动态timeline对比记录(仪表盘和好友窗口共用)
function recordTimeline(sid, cached) {
try {
const allGames = (cached.games || []).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtime_forever: g.playtime_forever || 0, img_icon_url: g.img_icon_url || '' }));
const currentAppIds = allGames.map(g => g.appid);
const checkTs = Date.now();
const totalGames = cached.game_count || allGames.length;
let timeline = storage.getTimeline(sid);
let isFirst = false; let newAppIds = [];
if (!timeline) {
isFirst = true;
timeline = { latestAppIds: currentAppIds, gameInfo: Object.fromEntries(allGames.map(g => [g.appid, { name: g.name, img_icon_url: g.img_icon_url }])), records: [{ ts: checkTs, type: 'first', totalGames }] };
} else {
const oldSet = new Set(timeline.latestAppIds);
newAppIds = currentAppIds.filter(id => !oldSet.has(id));
if (!timeline.gameInfo) timeline.gameInfo = {};
allGames.forEach(g => { if (newAppIds.includes(g.appid) && !timeline.gameInfo[g.appid]) timeline.gameInfo[g.appid] = { name: g.name, img_icon_url: g.img_icon_url }; });
timeline.records.push({ ts: checkTs, type: 'check', newAppIds, totalGames });
timeline.latestAppIds = currentAppIds;
}
storage.setTimeline(sid, timeline);
return { timeline: timeline.records, gameInfo: timeline.gameInfo, total: totalGames, isFirst, allGames, newAppIds };
} catch (e) { logger.warn('记录入库动态失败', e); return null; }
}
async function fetchRecentCached(sid) {
const cache = storage.getRecentCache() || {};
const c = cache[sid]; const now = Date.now();
if (c && now - c.ts < RECENT_TTL && !c.error) return c;
try {
const r = await dashFetchRecent(sid);
r.ts = now; cache[sid] = r; cache._ts = now; storage.setRecentCache(cache);
return r;
} catch (e) {
return { games: [], error: true, ts: now };
}
}
async function dashFetchFriendsCount(sid) {
try {
const data = await SteamAPI.getFriendsLegacy(sid);
const resp = data && data.friendslist;
if (!resp || !Array.isArray(resp.friends)) return { friendcount: 0, private: true };
return { friendcount: resp.friends.length, private: false };
} catch (e) {
return { friendcount: 0, private: true };
}
}
function dashSetProgress(text, pct) {
state.dashboard.progress = text; state.dashboard.progressPct = Math.max(0, Math.min(99, Math.round(pct)));
if (!dom.dashProgressText || !dom.dashProgressText.isConnected) dom.dashProgressText = document.getElementById('sfd-dash-progress-text');
if (!dom.dashProgressFill || !dom.dashProgressFill.isConnected) dom.dashProgressFill = document.getElementById('sfd-dash-progress-fill');
if (dom.dashProgressText) dom.dashProgressText.textContent = text || '';
if (dom.dashProgressFill) dom.dashProgressFill.style.width = state.dashboard.progressPct + '%';
}
function dashSetStage(stage, totalStages, text) {
state.dashboard.stage = stage;
const stages = document.querySelectorAll('.sfd-dash-load-stage');
stages.forEach((el, i) => { el.classList.remove('active', 'done'); if (i < stage - 1) el.classList.add('done'); else if (i === stage - 1) el.classList.add('active'); });
if (text) dashSetProgress(text, state.dashboard.progressPct);
}
function dashDimAllAvatars() {
const grid = document.getElementById('sfd-dash-load-grid');
if (grid) grid.querySelectorAll('.sfd-dash-load-cell.loaded').forEach(c => { c.classList.add('dimmed'); c.classList.remove('active'); });
}
function dashLightUpAll(sids, interval = 15) {
return new Promise(resolve => {
let done = 0;
sids.forEach((sid, idx) => {
setTimeout(() => {
const cell = document.querySelector(`.sfd-dash-load-cell[data-sid="${sid}"]`);
if (cell) { cell.classList.remove('dimmed'); cell.classList.add('active'); setTimeout(() => { cell.classList.add('loaded'); cell.classList.remove('active'); }, 150); }
done++; if (done === sids.length) resolve();
}, idx * interval);
});
});
}
function dashSetCurrentFriend(sid) {
const cell = document.querySelector(`.sfd-dash-load-cell[data-sid="${sid}"]`);
if (cell) { cell.classList.remove('dimmed'); cell.classList.add('active'); setTimeout(() => { cell.classList.add('loaded'); cell.classList.remove('active'); }, 150); }
}
function dashRecordSnapshot(ownedMap) {
try {
const snaps = storage.getDashSnaps(); const today = dashDateKey(new Date()); const dayData = {};
for (const sid in ownedMap) { const o = ownedMap[sid]; if (!o.private && !o.error && o.totalMinutes > 0) dayData[sid] = o.totalMinutes; }
if (Object.keys(dayData).length) snaps[today] = dayData;
const keys = Object.keys(snaps).sort(); while (keys.length > 45) delete snaps[keys.shift()];
storage.setDashSnaps(snaps);
} catch (e) { logger.warn('仪表盘快照记录失败', e); }
}
function dashAggregate(friends, summaries, recentMap, ownedMap, friendMap) {
const zh = DASH_ZH(); const fMap = {}; friends.forEach(f => { fMap[f.steamid] = f; });
const prof = (sid) => { const s = summaries[sid] || {}; const f = fMap[sid] || {}; return { steamid: sid, personaname: s.personaname || f.personaname || sid, avatar: s.avatarmedium || s.avatar || f.avatar || f.avatarmedium || '', personastate: s.personastate !== undefined ? s.personastate : (f.personastate || 0), gameextrainfo: s.gameextrainfo || f.gameextrainfo || '', profileurl: s.profileurl || f.profileurl || `https://steamcommunity.com/profiles/${sid}/` }; };
const recentRank = []; const gameAgg = {}; let total2wAll = 0, publicCount = 0, privateCount = 0;
for (const sid in recentMap) { const r = recentMap[sid]; if (r.error) continue; if (r.private) { privateCount++; continue; } publicCount++; let sum = 0; r.games.forEach(g => { sum += g.playtime_2weeks; if (g.playtime_2weeks > 0) { if (!gameAgg[g.appid]) gameAgg[g.appid] = { appid: g.appid, name: g.name, minutes: 0, players: 0 }; gameAgg[g.appid].minutes += g.playtime_2weeks; gameAgg[g.appid].players++; } }); total2wAll += sum; if (sum > 0) { const topGames = r.games.filter(g => g.playtime_2weeks > 0).sort((a, b) => b.playtime_2weeks - a.playtime_2weeks).slice(0, 3); recentRank.push({ ...prof(sid), total2w: sum, topGames }); } }
recentRank.sort((a, b) => b.total2w - a.total2w);
const hotAll = Object.values(gameAgg).sort((a, b) => b.minutes - a.minutes);
const hotGames = hotAll.slice(0, 10).map(g => ({ ...g }));
const rest = hotAll.slice(10); const restMin = rest.reduce((s, g) => s + g.minutes, 0);
if (restMin > 0) hotGames.push({ appid: 0, name: zh ? '其他' : 'Others', minutes: restMin, players: rest.reduce((s, g) => s + g.players, 0) });
const ownedRank = [];
for (const sid in ownedMap) { const o = ownedMap[sid]; if (o.private || o.error || !o.game_count) continue; ownedRank.push({ ...prof(sid), game_count: o.game_count, totalMinutes: o.totalMinutes }); }
ownedRank.sort((a, b) => b.game_count - a.game_count);
const friendsRank = [];
for (const sid in friendMap) { const fr = friendMap[sid]; if (fr.private || fr.error || !fr.friendcount) continue; friendsRank.push({ ...prof(sid), friendcount: fr.friendcount }); }
friendsRank.sort((a, b) => b.friendcount - a.friendcount);
const online = [];
if (Object.keys(summaries).length) { for (const sid in summaries) { if (summaries[sid].personastate > 0) online.push(prof(sid)); } } else { friends.forEach(f => { if (f.personastate > 0) online.push(prof(f.steamid)); }); }
online.sort((a, b) => ((b.gameextrainfo ? 1 : 0) - (a.gameextrainfo ? 1 : 0)) || String(a.personaname).localeCompare(String(b.personaname)));
const heatSids = recentRank.slice(0, DASH_HEAT_ROWS).map(r => r.steamid);
if (heatSids.length < 6) { const byTotal = ownedRank.slice().sort((a, b) => b.totalMinutes - a.totalMinutes); for (const o of byTotal) { if (heatSids.length >= 6) break; if (!heatSids.includes(o.steamid)) heatSids.push(o.steamid); } }
const heatmap = dashComputeHeatmap(storage.getDashSnaps(), heatSids, prof, recentMap);
return { kpis: { total: getSteamFriendsCount(), online: online.length, ingame: online.filter(o => o.gameextrainfo).length, public: publicCount, private: privateCount, total2w: total2wAll }, recentRank: recentRank.slice(0, DASH_RECENT_TOP), hotGames, hotTotal: hotAll.reduce((s, g) => s + g.minutes, 0), hotCount: hotAll.length, online, ownedRank: ownedRank.slice(0, DASH_OWNED_TOP), friendsRank: friendsRank.slice(0, DASH_FRIENDS_TOP), heatmap, generatedAt: 0 };
}
function dashComputeHeatmap(snaps, sids, prof, recentMap) {
const days = []; for (let i = DASH_HEAT_DAYS - 1; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(dashDateKey(d)); }
const snapDays = Object.keys(snaps).sort();
const rows = sids.map(sid => {
const p = prof(sid); const cells = days.map(day => { const snap = snaps[day]; if (!snap || snap[sid] == null) return null; let prevDay = null; for (let j = snapDays.length - 1; j >= 0; j--) { if (snapDays[j] < day) { prevDay = snapDays[j]; break; } } if (!prevDay || !snaps[prevDay] || snaps[prevDay][sid] == null) return null; const delta = snap[sid] - snaps[prevDay][sid]; return delta >= 0 ? delta : null; });
const hasInc = cells.some(c => c != null);
if (!hasInc && recentMap && recentMap[sid] && !recentMap[sid].error && !recentMap[sid].private) {
let sum = 0; (recentMap[sid].games || []).forEach(g => { sum += g.playtime_2weeks || 0; });
if (sum > 0) { const daily = sum / 14; for (let i = Math.max(0, days.length - 14); i < days.length; i++) cells[i] = daily; }
}
return { steamid: sid, name: p.personaname, avatar: p.avatar, cells };
});
let max = 0; rows.forEach(r => r.cells.forEach(c => { if (c && c > max) max = c; }));
return { days, rows, max, snapCount: snapDays.length };
}
function dashHeatColor(v, max) { if (v == null) return 'rgba(255,255,255,0.028)'; if (v <= 0 || max <= 0) return DASH_HEAT_SCALE[0]; const r = v / max; if (r < 0.25) return DASH_HEAT_SCALE[1]; if (r < 0.5) return DASH_HEAT_SCALE[2]; if (r < 0.75) return DASH_HEAT_SCALE[3]; return DASH_HEAT_SCALE[4]; }
async function loadDashboardData(forceRefresh = false) {
if (state.dashboard.loading) return;
const apiKey = storage.getApiKey();
const friends = state.friends.data.length ? state.friends.data : (storage.getCachedData() || []);
const friendsMap = new Map(friends.map(f => [String(f.steamid), f]));
if (!apiKey) { state.dashboard.error = 'noapikey'; state.dashboard.data = null; renderDashboard(); return; }
if (!friends.length) { state.dashboard.error = 'nofriends'; state.dashboard.data = null; renderDashboard(); return; }
if (!forceRefresh) { const cached = storage.getDashCache(); if (cached && cached.data) { state.dashboard.data = cached.data; state.dashboard.error = null; renderDashboard(); return; } }
state.dashboard.loading = true; state.dashboard.error = null; state.dashboard.progressPct = 0; renderDashboard();
const zh = DASH_ZH();
try {
const sids = friends.map(f => f.steamid);
const delay = ms => new Promise(r => setTimeout(r, ms));
const now = Date.now();
// 阶段1:刷新好友在线状态
dashSetStage(1, 4, zh ? '正在刷新好友在线状态…' : 'Refreshing friend status…');
const summaries = {};
// 优先读取 summaries 缓存(5分钟TTL),避免与好友列表重复请求
const summariesCache = storage.getSummariesCache();
if (summariesCache && !forceRefresh) {
Object.keys(summariesCache).forEach(sid => { if (sids.includes(sid)) summaries[sid] = summariesCache[sid]; });
} else {
for (let i = 0; i < sids.length; i += SUMMARY_BATCH_SIZE) {
const batch = sids.slice(i, i + SUMMARY_BATCH_SIZE);
try { const d = await SteamAPI.getSummary(batch); ((d && d.response && d.response.players) || []).forEach(p => { summaries[p.steamid] = p; }); } catch (e) { logger.warn('仪表盘摘要批量获取失败', e); }
}
// 保存到 summaries 缓存
const sumToCache = { _ts: Date.now() }; Object.assign(sumToCache, summaries); storage.setSummariesCache(sumToCache);
}
state.dashboard.summaries = summaries;
dashSetProgress(zh ? '正在加载头像…' : 'Loading avatars…', 3);
for (let i = 0; i < sids.length; i++) {
const sid = sids[i]; const cell = document.querySelector(`.sfd-dash-load-cell[data-sid="${sid}"]`);
if (cell && !cell.classList.contains('loaded')) { const s = summaries[sid] || {}; const f = friendsMap.get(String(sid)) || {}; const avatar = s.avatarmedium || s.avatar || f.avatarmedium || f.avatar || ''; cell.classList.add('active'); const img = cell.querySelector('img'); if (img && avatar) { img.src = avatar; img.onload = () => { cell.classList.add('loaded'); cell.classList.remove('active'); }; img.onerror = () => { cell.classList.add('loaded'); cell.classList.remove('active'); }; } else { cell.classList.add('loaded'); cell.classList.remove('active'); } }
if (i % 5 === 0) { dashSetProgress(state.dashboard.progress, 3 + (i / sids.length) * 2); await delay(0); }
}
await delay(500); dashDimAllAvatars(); await delay(300);
// 阶段2:获取最近游玩记录
dashSetStage(2, 4, zh ? '正在获取最近游玩记录…' : 'Fetching recent games…');
const recentCache = storage.getRecentCache() || {};
const recentMap = {}; const needFetchRecent = []; const cachedRecent = [];
sids.forEach(sid => { const c = recentCache[sid]; if (c && now - c.ts < RECENT_TTL && !c.error) { recentMap[sid] = c; cachedRecent.push(sid); } else needFetchRecent.push(sid); });
if (cachedRecent.length) { await dashLightUpAll(cachedRecent, 15); }
if (needFetchRecent.length) {
const recentTasks = needFetchRecent.map(sid => async () => { dashSetCurrentFriend(sid); try { recentMap[sid] = await fetchRecentCached(sid); } catch (e) { recentMap[sid] = { games: [], error: true }; } });
await _concurrentPool(recentTasks, 8, (done, total) => dashSetProgress(zh ? `正在获取最近游玩记录 (${done}/${total})…` : `Fetching recent games (${done}/${total})…`, 5 + 50 * done / total));
}
await delay(500); dashDimAllAvatars(); await delay(300);
// 阶段3:获取游戏库数据
dashSetStage(3, 4, zh ? '正在获取游戏库数据…' : 'Fetching game libraries…');
const ownedSummary = storage.getOwnedGamesSummary();
const ownedMap = {}; const needFetch = []; const cachedOwned = [];
sids.forEach(sid => { const c = ownedSummary[sid]; if (c && now - c.ts < OWNED_GAMES_TTL && !c.error) { ownedMap[sid] = { game_count: c.game_count, totalMinutes: c.totalMinutes, private: c.private }; cachedOwned.push(sid); } else needFetch.push(sid); });
if (cachedOwned.length) { await dashLightUpAll(cachedOwned, 15); }
if (needFetch.length) {
const ownedTasks = needFetch.map(sid => async () => { dashSetCurrentFriend(sid); try { ownedMap[sid] = await dashFetchOwned(sid); } catch (e) { ownedMap[sid] = { game_count: 0, totalMinutes: 0, error: true }; } });
await _concurrentPool(ownedTasks, 6, (done, total) => dashSetProgress(zh ? `正在获取游戏库数据 (${done}/${total})…` : `Fetching game libraries (${done}/${total})…`, 55 + 22 * done / total));
}
await delay(500); dashDimAllAvatars(); await delay(300);
// 阶段4:获取好友列表数据
dashSetStage(4, 4, zh ? '正在获取好友列表数据…' : 'Fetching friend lists…');
const friendsCache = storage.getDashFriendsCache() || {};
const friendMap = {}; const needFetchFr = []; const cachedFriends = [];
sids.forEach(sid => { const c = friendsCache[sid]; if (c && now - c.ts < DASH_FRIENDS_TTL) { friendMap[sid] = c; cachedFriends.push(sid); } else needFetchFr.push(sid); });
if (cachedFriends.length) { await dashLightUpAll(cachedFriends, 15); }
if (needFetchFr.length) {
const frTasks = needFetchFr.map(sid => async () => { dashSetCurrentFriend(sid); try { const r = await dashFetchFriendsCount(sid); r.ts = Date.now(); friendMap[sid] = r; friendsCache[sid] = r; } catch (e) { friendMap[sid] = { friendcount: 0, private: true, error: true }; } });
await _concurrentPool(frTasks, 6, (done, total) => dashSetProgress(zh ? `正在获取好友列表数据 (${done}/${total})…` : `Fetching friend lists (${done}/${total})…`, 77 + 20 * done / total));
friendsCache._ts = now; storage.setDashFriendsCache(friendsCache);
}
dashRecordSnapshot(ownedMap);
dashSetProgress(zh ? '正在聚合统计数据…' : 'Aggregating…', 99);
const data = dashAggregate(friends, summaries, recentMap, ownedMap, friendMap);
data.generatedAt = Date.now();
state.dashboard.data = data;
storage.setDashCache({ ts: data.generatedAt, data });
} catch (e) { logger.warn('仪表盘数据加载失败', e); state.dashboard.error = (e && e.message) || 'error'; }
state.dashboard.loading = false; state.dashboard.progress = ''; state.dashboard.stage = 0; state.dashboard.summaries = null; renderDashboard();
}
function dashCardTitle(icon, title, sub) { return h('div', { class: 'sfd-dash-card-title' }, [h('span', { html: icon, style: { display: 'inline-flex' } }), h('span', { text: title }), sub ? h('span', { class: 'sfd-dash-card-sub', text: sub }) : null]); }
function dashRenderKpis(d) {
const zh = DASH_ZH(); const k = d.kpis;
const items = [
{ iconSvg: ICONS.users, val: `${k.total} / ${getSteamFriendsLimit()}`, lbl: zh ? '好友总数' : 'Friends', accent: '#60a5fa' },
{ iconSvg: ICONS.activity, val: k.online, lbl: zh ? '当前在线' : 'Online', accent: '#34d399' },
{ iconSvg: ICONS.game, val: k.ingame, lbl: zh ? '游戏中' : 'In-Game', accent: '#a78bfa' },
{ iconSvg: ICONS.clock, val: dashFmtH(k.total2w), lbl: zh ? '近2周总时长' : '2-Week Playtime', accent: '#fbbf24' },
{ iconSvg: ICONS.trophy, val: k.public, lbl: zh ? '数据公开好友' : 'Public Profiles', accent: '#f472b6' }
];
return h('div', { class: 'sfd-dash-kpis' }, items.map(it => createMetricCard({ value: String(it.val), label: it.lbl, iconSvg: it.iconSvg, accent: it.accent, layout: 'row' })));
}
function dashRenderRecentRank(d) {
const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' });
card.appendChild(dashCardTitle(ICONS.trophy, zh ? '最近好友游戏时长排行' : 'Recent Playtime Ranking', zh ? '近2周 · Top 10' : '2 Weeks · Top 10'));
if (!d.recentRank.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '近2周暂无好友游玩记录(或好友游戏详情为私密)' : 'No recent records (friends may be private)' })); return card; }
const max = d.recentRank[0].total2w || 1;
d.recentRank.forEach((r, i) => {
const pct = Math.max(2, Math.round(r.total2w / max * 100));
const mid = h('div', { class: 'sfd-dash-rank-mid' }, [
h('div', { class: 'sfd-dash-rank-name' }, [h('span', { text: r.personaname }), h('span', { class: 'sfd-dash-rank-hours', text: dashFmtH(r.total2w) })]),
...r.topGames.map(g => h('div', { class: 'sfd-dash-rank-game' }, [h('span', { text: g.name }), h('b', { text: dashFmtH(g.playtime_2weeks) })])),
h('div', { class: 'sfd-dash-rank-bar' }, [h('div', { class: 'sfd-dash-rank-fill', style: { width: pct + '%' } })]),
h('div', { class: 'sfd-dash-rank-pct', text: zh ? `为第 1 名的 ${pct}%` : `${pct}% of #1` })
]);
const children = [h('div', { class: `sfd-dash-rank-no ${i === 0 ? 'r1' : i === 1 ? 'r2' : i === 2 ? 'r3' : ''}`, text: String(i + 1) }), h('img', { class: 'sfd-dash-rank-avatar', src: r.avatar, loading: 'lazy', onError: function () { dashAvatarFallback(this); } }), mid];
if (r.topGames[0] && r.topGames[0].appid) { const capImg = h('img', { class: 'sfd-dash-rank-cap', alt: '' }); children.push(dashLoadCapsule(capImg, r.topGames[0].appid)); }
const row = h('div', { class: 'sfd-dash-rank', onClick: () => window.open(r.profileurl, '_blank') }, children);
dashBindTip(row, () => { const games = r.topGames.map(g => `${dashEsc(g.name)} · ${dashFmtH(g.playtime_2weeks)}`).join(' '); return `${dashEsc(r.personaname)} ${zh ? '近2周总时长' : '2-week total'}: ${dashFmtH(r.total2w)} ${games} `; });
card.appendChild(row);
});
return card;
}
function dashGameTipHtml(g, total) { const zh = DASH_ZH(); const pct = total > 0 ? (g.minutes / total * 100).toFixed(1) : '0'; const img = g.appid ? ` ` : ''; return `${img}${dashEsc(g.name)} ${zh ? '近2周' : '2 weeks'}: ${dashFmtH(g.minutes)} · ${pct}%${g.players} ${zh ? '位好友在玩' : 'friends playing'} `; }
function dashRenderHotGames(d) {
const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' });
card.appendChild(dashCardTitle(ICONS.game, zh ? '热门游戏分布' : 'Popular Games', zh ? `近2周 · 共 ${d.hotCount} 款` : `2 weeks · ${d.hotCount} games`));
if (!d.hotGames.length || d.hotTotal <= 0) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '近2周暂无游戏记录' : 'No games played recently' })); return card; }
const R = 70, C = 2 * Math.PI * R; const svg = dashSvg('svg', { width: '170', height: '170', viewBox: '0 0 170 170' });
svg.appendChild(dashSvg('circle', { cx: '85', cy: '85', r: String(R), stroke: 'rgba(255,255,255,0.05)', 'stroke-width': '24' }));
let offset = 0; const segEls = [];
d.hotGames.forEach((g, i) => { const frac = g.minutes / d.hotTotal; const len = Math.max(0.5, frac * C - 2); const color = DASH_COLORS[i % DASH_COLORS.length]; const c = dashSvg('circle', { cx: '85', cy: '85', r: String(R), stroke: color, 'stroke-width': '24', 'stroke-dasharray': `${len} ${C - len}`, 'stroke-dashoffset': String(-offset) }); offset += frac * C; const tipFn = () => dashGameTipHtml(g, d.hotTotal); c.addEventListener('mouseenter', e => dashTip(tipFn(), e.clientX, e.clientY)); c.addEventListener('mousemove', e => dashTip(tipFn(), e.clientX, e.clientY)); c.addEventListener('mouseleave', dashTipHide); if (g.appid) c.addEventListener('click', () => openStorePage(g.appid)); svg.appendChild(c); segEls.push(c); });
const donutBox = h('div', { class: 'sfd-dash-donut' }); donutBox.appendChild(svg);
donutBox.appendChild(h('div', { class: 'sfd-dash-donut-center' }, [h('div', { class: 'sfd-dash-donut-total', text: dashFmtH(d.hotTotal) }), h('div', { class: 'sfd-dash-donut-total-lbl', text: zh ? '近2周总时长' : '2-Week Total' })]));
const legend = h('div', { class: 'sfd-dash-legend' });
d.hotGames.forEach((g, i) => { const color = DASH_COLORS[i % DASH_COLORS.length]; const pct = g.minutes / d.hotTotal * 100; let capEl; if (g.appid) { capEl = h('img', { class: 'sfd-dash-legend-cap', alt: '' }); capEl = dashLoadCapsule(capEl, g.appid); } else { capEl = h('div', { class: 'sfd-dash-legend-cap sfd-dash-legend-cap-ph' }); } const item = h('div', { class: 'sfd-dash-legend-item' }, [h('span', { class: 'sfd-dash-legend-dot', style: { background: color } }), capEl, h('div', { class: 'sfd-dash-legend-mid' }, [h('div', { class: 'sfd-dash-legend-name', text: g.name, title: g.name }), h('div', { class: 'sfd-dash-legend-val', text: `${dashFmtH(g.minutes)} · ${g.players}${zh ? '人' : 'p'}` })]), h('span', { class: 'sfd-dash-legend-pct', text: pct.toFixed(1) + '%' })]); item.addEventListener('mouseenter', e => { segEls.forEach((s, j) => { s.style.opacity = j === i ? '1' : '0.22'; }); dashTip(dashGameTipHtml(g, d.hotTotal), e.clientX, e.clientY); }); item.addEventListener('mousemove', e => dashTip(dashGameTipHtml(g, d.hotTotal), e.clientX, e.clientY)); item.addEventListener('mouseleave', () => { segEls.forEach(s => { s.style.opacity = '1'; }); dashTipHide(); }); if (g.appid) item.addEventListener('click', () => openStorePage(g.appid)); legend.appendChild(item); });
card.appendChild(h('div', { class: 'sfd-dash-donut-wrap' }, [donutBox, legend]));
return card;
}
function dashRenderHeatmap(d) {
const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' });
card.appendChild(dashCardTitle(ICONS.trending, zh ? '好友游戏时长热力图' : 'Playtime Heatmap', zh ? `近 ${DASH_HEAT_DAYS} 天` : `Last ${DASH_HEAT_DAYS} days`));
const hm = d.heatmap;
if (!hm || !hm.rows.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '暂无可展示的好友数据' : 'No data available' })); return card; }
const grid = h('div', { class: 'sfd-dash-heat', style: { gridTemplateColumns: `118px repeat(${DASH_HEAT_DAYS}, 13px)` } });
grid.appendChild(h('div', {}));
hm.days.forEach((day, i) => { const dt = new Date(day + 'T00:00:00'); const label = (i % 7 === 0 || i === DASH_HEAT_DAYS - 1) ? `${dt.getMonth() + 1}/${dt.getDate()}` : ''; grid.appendChild(h('div', { class: 'sfd-dash-heat-label', text: label })); });
hm.rows.forEach(row => {
grid.appendChild(h('div', { class: 'sfd-dash-heat-name', title: row.name }, [row.avatar ? h('img', { src: row.avatar, loading: 'lazy', onError: function () { this.style.display = 'none'; } }) : null, h('span', { text: row.name, style: { overflow: 'hidden', textOverflow: 'ellipsis' } })]));
row.cells.forEach((v, i) => { const cell = h('div', { class: 'sfd-dash-heat-cell', style: { background: dashHeatColor(v, hm.max) } }); const dt = new Date(hm.days[i] + 'T00:00:00'); const dateStr = zh ? `${dt.getMonth() + 1}月${dt.getDate()}日` : `${dt.getMonth() + 1}/${dt.getDate()}`; const valStr = v == null ? (zh ? '无数据' : 'No data') : v === 0 ? (zh ? '未游玩' : 'No play') : `${dashFmtH(v)} `; dashBindTip(cell, () => `${dashEsc(row.name)} ${dateStr} · ${valStr}`); grid.appendChild(cell); });
});
card.appendChild(h('div', { class: 'sfd-dash-heat-wrap' }, [grid]));
const legendRow = h('div', { class: 'sfd-dash-heat-legend' }, [zh ? '少 ' : 'Less ']);
DASH_HEAT_SCALE.forEach(c => legendRow.appendChild(h('i', { style: { background: c } })));
legendRow.appendChild(document.createTextNode(zh ? ' 多' : ' More'));
card.appendChild(legendRow);
if (hm.snapCount < 2) card.appendChild(h('div', { class: 'sfd-dash-hint', text: zh ? '当前显示近2周日均估算活跃度。持续使用后将基于每日快照精确计算每日游玩时长。' : 'Showing estimated daily averages for the last 2 weeks. Precise per-day data accumulates with daily use.' }));
return card;
}
function dashRenderOwnedRank(d) {
const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' });
card.appendChild(dashCardTitle(ICONS.package, zh ? '好友游戏总数排行' : 'Game Count Ranking', 'Top 10'));
if (!d.ownedRank.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '暂无公开游戏库的好友' : 'No public libraries' })); return card; }
const max = d.ownedRank[0].game_count || 1;
d.ownedRank.forEach((r, i) => { const pct = Math.max(3, Math.round(r.game_count / max * 100)); const row = h('div', { class: 'sfd-dash-owned-row', onClick: () => window.open(r.profileurl, '_blank') }, [h('span', { class: 'sfd-dash-owned-no', text: String(i + 1) }), h('img', { class: 'sfd-dash-owned-avatar', src: r.avatar, loading: 'lazy', onError: function () { this.style.display = 'none'; } }), h('span', { class: 'sfd-dash-owned-name', text: r.personaname, title: r.personaname }), h('div', { class: 'sfd-dash-owned-bar' }, [h('div', { class: 'sfd-dash-owned-fill', style: { width: pct + '%' } })]), h('span', { class: 'sfd-dash-owned-val', html: `${r.game_count}${zh ? '款' : ''} ${dashFmtH(r.totalMinutes)} ` })]); dashBindTip(row, () => `${dashEsc(r.personaname)} ${zh ? '游戏总数' : 'Games'}: ${r.game_count} ${zh ? '生涯总时长' : 'Total playtime'}: ${dashFmtH(r.totalMinutes)} `); card.appendChild(row); });
return card;
}
function dashRenderFriendsRank(d) {
const zh = DASH_ZH(); const card = h('div', { class: 'sfd-dash-card' });
card.appendChild(dashCardTitle(ICONS.users, zh ? '好友数排行' : 'Friends Count Ranking', 'Top 10'));
if (!d.friendsRank.length) { card.appendChild(h('div', { class: 'sfd-dash-empty', text: zh ? '暂无公开好友列表的好友' : 'No public friend lists' })); return card; }
const max = d.friendsRank[0].friendcount || 1;
d.friendsRank.forEach((r, i) => { const pct = Math.max(3, Math.round(r.friendcount / max * 100)); const row = h('div', { class: 'sfd-dash-owned-row', onClick: () => window.open(r.profileurl, '_blank') }, [h('span', { class: 'sfd-dash-owned-no', text: String(i + 1) }), h('img', { class: 'sfd-dash-owned-avatar', src: r.avatar, loading: 'lazy', onError: function () { this.style.display = 'none'; } }), h('span', { class: 'sfd-dash-owned-name', text: r.personaname, title: r.personaname }), h('div', { class: 'sfd-dash-owned-bar' }, [h('div', { class: 'sfd-dash-fr-fill', style: { width: pct + '%' } })]), h('span', { class: 'sfd-dash-owned-val sfd-dash-fr-val', html: `${r.friendcount}${zh ? '人' : ''}` })]); dashBindTip(row, () => `${dashEsc(r.personaname)} ${zh ? '好友总数' : 'Friends'}: ${r.friendcount} `); card.appendChild(row); });
return card;
}
function renderDashboard() {
const content = document.getElementById('sfd-dash-content'); const updatedEl = dom.dashUpdated;
if (!content) return; const zh = DASH_ZH();
if (state.dashboard.loading) {
// 加载中如果头像网格已存在(如关闭后重新打开),不重建,只更新进度
if (document.getElementById('sfd-dash-load-grid')) {
if (dom.dashProgressText) dom.dashProgressText.textContent = state.dashboard.progress || (zh ? '正在加载…' : 'Loading…');
if (dom.dashProgressFill) dom.dashProgressFill.style.width = (state.dashboard.progressPct || 0) + '%';
return;
}
if (updatedEl) updatedEl.textContent = ''; content.innerHTML = '';
const friends = state.friends.data.length ? state.friends.data : (storage.getCachedData() || []);
const loadChildren = [
h('div', { class: 'sfd-dash-load-stages' }, [1,2,3,4].map(() => h('div', { class: 'sfd-dash-load-stage' }))),
h('div', { class: 'sfd-dash-load-text', id: 'sfd-dash-progress-text', text: state.dashboard.progress || (zh ? '正在加载…' : 'Loading…') }),
h('div', { class: 'sfd-dash-load-mainbar' }, [h('div', { class: 'sfd-dash-load-mainbar-fill', id: 'sfd-dash-progress-fill', style: { width: (state.dashboard.progressPct || 0) + '%' } })]),
h('div', { style: { display: 'none' } }, [h('img', { id: 'sfd-dash-cur-avatar', src: '' }), h('div', { id: 'sfd-dash-cur-name', text: '' })]),
];
if (friends.length) { const grid = h('div', { class: 'sfd-dash-load-grid', id: 'sfd-dash-load-grid' }); const summaries = state.dashboard.summaries || storage.getSummariesCache() || {}; friends.forEach(f => { const cell = h('div', { class: 'sfd-dash-load-cell', dataset: { sid: f.steamid }, title: f.personaname || f.steamid }); const s = summaries[f.steamid] || {}; const avatar = s.avatarmedium || s.avatar || f.avatar || f.avatarmedium || ''; const img = h('img', { alt: '', loading: 'lazy' }); if (avatar) { img.src = avatar; cell.classList.add('loaded'); } cell.appendChild(img); grid.appendChild(cell); }); loadChildren.push(grid); }
content.appendChild(h('div', { style: { display: 'flex', flexDirection: 'column', height: '100%' } }, loadChildren));
// 恢复阶段指示器和已加载头像的 dimmed 状态
if (state.dashboard.stage) { dashSetStage(state.dashboard.stage, 4, state.dashboard.progress); }
if (state.dashboard.stage >= 2) { dashDimAllAvatars(); }
return; }
if (state.dashboard.error) { if (updatedEl) updatedEl.textContent = ''; content.innerHTML = ''; let tipText; if (state.dashboard.error === 'noapikey') tipText = zh ? '请先点击面板右上角设置按钮,配置 Steam Web API Key。' : 'Please configure your Steam Web API Key in the panel settings first.'; else if (state.dashboard.error === 'nofriends') tipText = zh ? '暂无好友数据,请先在「好友列表」Tab 点击「获取好友数据」。' : 'No friend data. Please fetch friends in the panel first.'; else tipText = (zh ? '加载失败:' : 'Failed: ') + state.dashboard.error; content.appendChild(h('div', { class: 'sfd-dash-empty' }, [h('div', { text: tipText }), h('div', { style: { marginTop: '12px' } }, [h('button', { class: 'sfd-btn sfd-btn-primary', text: zh ? '重试' : 'Retry', onClick: () => loadDashboardData(true) })])])); return; }
const d = state.dashboard.data;
if (!d) { content.innerHTML = ''; content.appendChild(h('div', { class: 'sfd-dash-empty' }, [h('div', { text: zh ? '点击上方刷新按钮,生成你的好友社交数据仪表盘' : 'Click refresh to generate your social dashboard' }), h('div', { style: { marginTop: '12px' } }, [h('button', { class: 'sfd-btn sfd-btn-primary', text: zh ? '加载数据' : 'Load Data', onClick: () => loadDashboardData(true) })])])); return; }
if (updatedEl) updatedEl.textContent = (zh ? '更新于 ' : 'Updated ') + new Date(d.generatedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
content.innerHTML = ''; content.appendChild(dashRenderKpis(d));
content.appendChild(h('div', { class: 'sfd-dash-grid' }, [h('div', { class: 'sfd-dash-col' }, [dashRenderRecentRank(d), dashRenderHeatmap(d)]), h('div', { class: 'sfd-dash-col' }, [dashRenderHotGames(d), dashRenderOwnedRank(d), dashRenderFriendsRank(d)])]));
}
// 先从 IndexedDB 加载缓存到内存镜像,再恢复封面缓存并初始化 UI
_loadIDBCache().then(() => {
_restoreCapsuleCache();
init();
logStorageSize();
});
})();