| null(模块级缓存)
async function fetchDynamicStoreOwnedAppIds() {
if (_dynamicStoreOwnedAppIds) return _dynamicStoreOwnedAppIds;
try {
const resp = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://store.steampowered.com/dynamicstore/userdata/',
timeout: 15000,
onload(r) {
if (r.status >= 200 && r.status < 300) resolve(r);
else reject(new Error('HTTP ' + r.status));
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时')),
});
});
const data = JSON.parse(safeResponseText(resp));
const ownedApps = data?.rgOwnedApps;
if (Array.isArray(ownedApps)) {
_dynamicStoreOwnedAppIds = new Set(ownedApps.map(Number).filter(n => Number.isFinite(n) && n > 0));
console.log(`[SGLV] dynamicstore/userdata 获取 ${_dynamicStoreOwnedAppIds.size} 个 owned appids`);
}
} catch (e) {
console.warn('[SGLV] dynamicstore/userdata 获取失败:', e);
}
return _dynamicStoreOwnedAppIds;
}
// --- getDynamicStoreAppIds ---
// 参考 SteamPeek标记库存状态-1.9.js:从页面 GDynamicStore 读取愿望单/库存 appid 集合(即时、零请求)
function getDynamicStoreAppIds(kind) {
try {
const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
let raw = null;
if (kind === 'wishlist') raw = win.GDynamicStore?.s_rgWishlist || win.g_rgWishlist;
else if (kind === 'owned') raw = win.GDynamicStore?.s_rgOwnedApps;
if (!raw) return new Set();
const arr = Array.isArray(raw) ? raw : Object.keys(raw);
return new Set(arr.map(Number).filter(n => Number.isFinite(n) && n > 0));
} catch { return new Set(); }
}
// ==================== SGLV 子模块(v2.4.1 子闭包封装,仅导出 initUI/autoScan) ====================
const SGLV_API = {};
(function () {
// ==================== SGLV 专属工具函数 ====================
// ==================== 工具函数 ====================
function requestSteamAPI(url) {
// v2.3.15: 增加 timeout(30s)和 ontimeout 处理,避免请求挂起导致 loading 卡住
// v2.7.9: 检测 HTML 响应(未登录/API Key 失效时 Steam 返回登录页),提前抛出清晰错误
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout: 30000,
onload(resp) {
try {
const text = safeResponseText(resp);
// 检测 HTML 响应(登录页/错误页),避免 JSON.parse 报模糊错误
if (text.startsWith('<') || text.startsWith(' {
GM_xmlhttpRequest({
method: 'GET', url, timeout: 20000,
onload(r) { resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, text: safeResponseText(r) }); },
onerror: reject, ontimeout: reject
});
});
if (!resp.ok) return { games: [], total: 0 };
const doc = new DOMParser().parseFromString(resp.text, 'text/html');
const rows = doc.querySelectorAll('.gameListRow');
const games = [];
rows.forEach(row => {
const appid = parseInt(row.id?.replace('game_', '') || row.dataset?.appid || '0', 10);
if (!appid) return;
const name = row.querySelector('.gameListRowItemName, .gameListRowItem h2, .gameListRowItem .gameListRowItemName')?.textContent?.trim() ||
row.querySelector('a[href*="/app/"]')?.textContent?.trim() || '';
const logoImg = row.querySelector('img.gameListLogo, img[src*="capsule"], img[src*="header"]');
const logoSrc = logoImg?.getAttribute('src') || '';
const playtimeEl = row.querySelector('.gameListRowItem .gameListRowItemHours, .hours_played, .gameListRowItem .gameListRowItemTime, .gameListRowItem .gameListRowItemStat');
const playtimeText = playtimeEl?.textContent?.trim() || '';
const playtime = parsePlaytimeTextToMinutes(playtimeText);
const lastPlayedText = row.querySelector('.gameListRowItem .gameListRowItemLastPlayed, .last_played, .gameListRowItem .gameListRowItemTime')?.textContent?.trim() || '';
const lastPlayed = parseLastPlayedToTs(lastPlayedText);
games.push({ appid, name: name || `App ${appid}`, playtime_forever: playtime, playtime_2weeks: 0, rtime_last_played: lastPlayed, img_icon_url: '', logoSrc });
});
const totalEl = doc.querySelector('.gameListRowItem .gameListRowItemCount, .gameListRow .gameListRowItem .gameListRowItemCount');
const totalText = totalEl?.textContent || '';
const totalMatch = totalText.match(/(\d+)/);
const total = totalMatch ? parseInt(totalMatch[1], 10) : games.length;
return { games, total };
} catch (e) {
console.warn('[SGLV] games 页抓取失败:', e);
return { games: [], total: 0 };
}
}
function getGameOwnerNames(g) {
if (!g.owners || g.owners.length === 0) return '';
const familyInfo = storage.getFamilyInfo();
const nameMap = familyInfo?.steamIdtoName || {};
return g.owners.map(sid => nameMap[sid] || 'ID:' + sid.slice(-4)).join(', ');
}
// v2.9.50: 通用计算缓存(持久化版)— 内存镜像 + IDB 持久化(通过 SGLVCore.PersistentComputeCache)
// 旧实现仅 session 内 Map,关闭浏览器/升级脚本即全部失效。
// 新增 PCC IDB 持久化层,关键计算结果(年度统计 / 家庭组累计 / 成就指标 / wishlist 统计)跨 session 复用。
//
// 工作流:
// 1) getCached(key, computeFn) — 同步读 _computedCache,未命中则 compute 后写内存 + 异步落 PCC
// 2) hydrateComputedCache() — 启动时从 PCC IDB 同步预填充到 _computedCache
// 3) clearComputedCache() — 清内存 + 清 PCC
const _computedCache = new Map();
const _PCC = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVCore) || window.SGLVCore;
const _PCC_CACHE = _PCC && _PCC.PersistentComputeCache ? _PCC.PersistentComputeCache : null;
// v2.9.50: 通用计算缓存 schema 版本(对应 _computedCache 这层,business 单独用自己 schema 升级)
const _COMPUTE_CACHE_SCHEMA_VER = 1;
let _pccHydrated = false;
function cacheKey(...parts) {
return parts.map(p => JSON.stringify(p)).join('|');
}
function getCached(key, computeFn) {
if (_computedCache.has(key)) return _computedCache.get(key);
const v = computeFn();
_computedCache.set(key, v);
// v2.9.50: 同步写内存 + 异步落 PCC(IDB 持久化,跨 session 复用)
if (_PCC_CACHE) {
try { _PCC_CACHE.set(key, _COMPUTE_CACHE_SCHEMA_VER, key, v); } catch (e) { /* quota */ }
}
return v;
}
// v2.9.50: 启动时从 PCC IDB 同步预填充到 _computedCache(必须在 sglvIDB.loadAll() 完成后调用)
// 返回:已 hydrate 的 key 数量
function hydrateComputedCache() {
if (_pccHydrated) return 0;
if (!_PCC_CACHE) { _pccHydrated = true; return 0; }
try {
const pccMem = _PCC_CACHE._mem || {};
let count = 0;
for (const k of Object.keys(pccMem)) {
if (!_computedCache.has(k)) {
const entry = pccMem[k];
if (entry && entry.value !== undefined && entry._ver === _COMPUTE_CACHE_SCHEMA_VER) {
_computedCache.set(k, entry.value);
count++;
}
}
}
_pccHydrated = true;
if (count > 0) console.log(`[SGLV-Compute] PCC IDB 预热 ${count} 条已缓存计算结果到 _computedCache`);
return count;
} catch (e) {
console.warn('[SGLV-Compute] PCC hydrate 失败:', e);
_pccHydrated = true;
return 0;
}
}
// v2.9.50: 按"游戏库版本"选择性地失效 PCC 缓存(避免整个 clear 导致其他无关数据丢失)
// 旧逻辑:_computedCache.clear() 把所有内存缓存清空,下次渲染全部重算
// 新逻辑:仅失效 sig 与 ownedGames 关联的 key,其他不依赖 ownedGames 的(搜索结果等)保留
// 当前简单实现:游戏库变化时整体清空(保持安全),但仍通过 PCC 落盘给下次复用
function clearComputedCache() {
console.log('[SGLV-Compute] clearComputedCache 被调用 — 失效所有计算缓存');
_computedCache.clear();
if (_PCC_CACHE) {
try { _PCC_CACHE.clear(); } catch (e) { /* ignore */ }
}
_pccHydrated = true; // 已清,无需再 hydrate
_cachedActiveOwnedAppIds = null;
_cachedActiveOwnedAppIdsKey = '';
_cachedDlcCount = null;
_cachedDlcCountKey = '';
_cachedStatFiltered = null; // v2.9.50: 同步失效 getStatFilteredGames 缓存
_cachedStatFilteredSig = '';
if (SGLV_API.invalidateAppTypeCache) SGLV_API.invalidateAppTypeCache();
const hadDlcTypeMap = !!dlcTypeMap;
dlcTypeMap = null;
_dlcsByParentCache = null;
console.log(`[SGLV-Compute] clearComputedCache 完成 — dlcTypeMap ${hadDlcTypeMap ? '已失效 (之前有数据)' : '本就为空'},appTypeCache 已失效,DLC 数量缓存已失效,statFiltered 已失效,dlcsByParent 已失效`);
}
// v2.9.50: 业务级持久化计算结果工具(给 computeYearlyStats / computeInsightData 等大数据用)
// 与 getCached 不同:不依赖 _computedCache 内存层,而是单独命名空间(biz_xxx)便于跨域管理
// key: 业务名,如 'biz_yearly_stats'
// schemaVer: schema 版本号,升级时强制重算
// inputSig: 输入签名(如 "123|456" = 游戏数 + 总acquiredTime)
// computeFn: 同步重算函数,返回新值
// 返回:同步值(命中缓存直接返回,未命中同步重算并落盘)
function getBizCached(key, schemaVer, inputSig, computeFn) {
if (!_PCC_CACHE) return computeFn();
// 1) 同步从 PCC 读 + 元数据
const meta = _PCC_CACHE.getSyncWithMeta(key);
if (meta && meta._ver === schemaVer && meta._sig === inputSig) {
return meta.value;
}
// 2) 同步重算 + 落盘
const v = computeFn();
try { _PCC_CACHE.set(key, schemaVer, inputSig, v); } catch (e) { /* ignore */ }
return v;
}
function getGameDBMeta(appid) {
for (const [pubName, pubData] of Object.entries(GAME_DB)) {
for (const [seriesName, games] of Object.entries(pubData)) {
if (games.some(g => g.id === appid)) {
return { publisher: pubName, series: seriesName };
}
}
}
return { publisher: '', series: '' };
}
function getGameIconUrl(appid, iconHash) {
if (iconHash) return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${iconHash}.jpg`;
return `https://cdn.akamai.steamstatic.com/steam/apps/${appid}/capsule_231x87.jpg`;
}
// v2.4.3: 多 CDN 封面回退链(参考 steam-family-game-analysis v1.58 faLoadCover 多 CDN fallback)
// 修复 cdn.cloudflare.steamstatic.com 不可达(部分网络环境被阻断/抽风)时封面大面积获取不到的问题:
// 依次尝试 cloudflare → cdn.akamai → shared.akamai → shared.fastly(store_item_assets),全部失败再走 appdetails API
// v2.9.106: 新增 shared.fastly.steamstatic.com 作为第四 CDN 源(部分新游戏仅 fastly 有图)
function coverChainUrls(appid, kind) {
const cf = `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}`;
const ak = `https://cdn.akamai.steamstatic.com/steam/apps/${appid}`;
const sh = `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}`;
const sf = `https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/${appid}`;
if (kind === 'poster') {
// v2.9.105: 补充 shared.akamai store_item_assets 路径的 library_600x900(部分新游戏仅在此路径有图)
// v2.9.106: 追加 shared.fastly store_item_assets 路径
return [
`${cf}/library_600x900_2x.jpg`, // HD 竖版
`${cf}/library_600x900.jpg`, // 标准竖版
`${ak}/library_600x900_2x.jpg`, // Akamai HD 竖版
`${ak}/library_600x900.jpg`, // Akamai 标准竖版
`${sh}/library_600x900_2x.jpg`, // shared.akamai HD 竖版
`${sh}/library_600x900.jpg`, // shared.akamai 标准竖版
`${sf}/library_600x900_2x.jpg`, // v2.9.106: shared.fastly HD 竖版
`${sf}/library_600x900.jpg`, // v2.9.106: shared.fastly 标准竖版
`${cf}/header.jpg`, // 宽版(几乎所有游戏都有)
`${ak}/header.jpg`,
`${sh}/header.jpg`,
`${sf}/header.jpg`,
];
}
if (kind === 'capsule') {
return [
`${cf}/capsule_467x181.jpg`, // 大横幅封面(最清晰)
`${cf}/capsule_231x87.jpg`, // 中等横幅
`${cf}/header.jpg`, // 头图兜底
`${ak}/capsule_231x87.jpg`,
`${ak}/header.jpg`,
`${sh}/capsule_184x69.jpg`, // shared akamai store_item_assets
`${sh}/header.jpg`,
`${sf}/capsule_184x69.jpg`, // v2.9.106: shared fastly store_item_assets
`${sf}/header.jpg`,
];
}
return [`${cf}/header.jpg`, `${ak}/header.jpg`, `${sh}/header.jpg`, `${sf}/header.jpg`]; // header
}
// API 图片回退:当所有 CDN 路径都失败时,通过 Steam Store API 获取真实图片 URL(含 hash)
// v2.4.3 重构:成功/进行中/失败三态分离——旧实现用同一 Map 的 '' 同时表示"请求中"与"失败",
// 导致同 appid 并发回退时后者直接占位、且瞬时失败后永不再重试(封面获取不到的另一根因)
// v2.9.105: 缓存键按 kind 隔离(poster/capsule/header),避免横版 URL 被竖版 poster 复用导致裁切变形
const _posterApiCache = new Map(); // appid:kind → API 获取成功的图片 URL
const _posterApiPending = new Map(); // appid:kind → 进行中的 API 请求 Promise(并发共享)
// v2.4.3: 占位替换仅替换
节点本身,不再覆盖父容器 innerHTML——
// 封面视图中父容器还含有徽章浮层(sglv-wl-cover-badges),需保留
// v2.8.2: 继承原 img 的 className(sglv-list-icon / sglv-card-img),
// 使占位 div 获得与原图相同的固定尺寸(92x43 / 100%×100%),避免布局塌陷
function _posterImgToPlaceholder(img) {
if (!img || !img.parentElement) return;
const holder = document.createElement('div');
holder.className = img.className;
holder.style.cssText = 'display:flex;align-items:center;justify-content:center;color:#5a6a7a;font-size:11px';
holder.textContent = isZh ? '无图片' : 'No Image';
img.parentElement.replaceChild(holder, img);
}
// v2.9.105: kind 感知——poster 优先 header_image(更宽,适合竖版裁切),
// capsule 优先 capsule_image(横幅原生),header 优先 header_image
function _fetchCoverApiUrl(appid, kind) {
const cacheKey = `${appid}:${kind || 'poster'}`;
if (_posterApiCache.has(cacheKey)) return Promise.resolve(_posterApiCache.get(cacheKey));
if (_posterApiPending.has(cacheKey)) return _posterApiPending.get(cacheKey);
const p = new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
// v2.8.1: filters=capsule_image,header_image 同为无效过滤值(返回 data:[]),
// CDN 全部失败时 API 兜底始终拿不到 URL → 直接占位。改用 filters=basic(含 header_image)。
url: `https://store.steampowered.com/api/appdetails?appids=${appid}&filters=basic`,
timeout: 10000,
onload(r) {
let url = '';
try {
const d = JSON.parse(safeResponseText(r))[appid];
if (d?.success && d?.data) {
// v2.9.105: 按 kind 选择最佳图片 URL
if (kind === 'capsule') {
url = d.data.capsule_image || d.data.header_image || '';
} else {
// poster / header 都优先 header_image(460x215,比 capsule 231x87 更适合竖版裁切)
url = d.data.header_image || d.data.capsule_image || '';
}
}
} catch { /* JSON parse or data shape mismatch; return empty url */ }
if (url) _posterApiCache.set(cacheKey, url); // 仅缓存成功结果;失败不记录,下次渲染可重试
_posterApiPending.delete(cacheKey);
resolve(url);
},
onerror() { _posterApiPending.delete(cacheKey); resolve(''); },
ontimeout() { _posterApiPending.delete(cacheKey); resolve(''); },
});
});
_posterApiPending.set(cacheKey, p);
return p;
}
// v2.9.105: 传递 kind 参数,使 API 兜底能按图片类型选择最佳 URL
unsafeWindow._sglvPosterFallback = function(img, appid, kind) {
_fetchCoverApiUrl(appid, kind).then(url => {
if (url) {
// API URL 加载仍失败 → 占位(先替换 onerror,防止链式 handler 死循环)
img.onerror = function() {
img.onerror = null;
_posterImgToPlaceholder(img);
};
img.src = url;
} else {
_posterImgToPlaceholder(img);
}
});
};
// 统一封面
标签生成:成功URL缓存 → 多 CDN 直链回退 → API 获取 → 占位
// v2.4.0: 增加成功URL内存缓存;v2.4.3: 多 CDN 回退链 + 按 kind 隔离缓存
// v2.9.105: ① 传递 kind 给 _sglvPosterFallback 使 API 兜底按类型选图
// ② onerror 链跳过 chain[0](与初始 src 相同,避免重复请求)
function coverImgTag(appid, name, kind, cls) {
const good = getPosterGood(appid, kind);
if (good) {
return `
`;
}
const chain = coverChainUrls(appid, kind);
// v2.9.105: onerror 链从 chain[1] 开始(chain[0] 已作为初始 src 尝试)
let handler = `_sglvPosterFallback(this, '${appid}', '${kind}');`;
for (let i = chain.length - 1; i >= 1; i--) {
handler = `this.src='${chain[i]}';this.onerror=function(){${handler}}`;
}
return `
`;
}
function posterImg(appid, name) { return coverImgTag(appid, name, 'poster', 'sglv-card-img'); }
// 横版封面图片(header.jpg),用于游戏橱窗列表
function headerImg(appid, name) { return coverImgTag(appid, name, 'header', 'sglv-card-img'); }
// v2.4.3: 横板封面图片(capsule 大横幅),用于愿望单封面视图
function capsuleImg(appid, name) { return coverImgTag(appid, name, 'capsule', 'sglv-wl-cover-img'); }
// v2.9.25: 分页导航——仅保留 首页|上页|下页|末页 + 跳页输入,无快捷页码按钮
function renderPagination(container, prefix, page, totalPages, totalItems, onPrev, onNext, onJump) {
if (!container) return;
container.innerHTML = `
${page}/${totalPages} (${totalItems})
`;
const goPage = (p) => {
const target = Math.min(Math.max(1, p), totalPages);
if (target === page) return;
if (onJump) onJump(target);
};
container.querySelector(`#${prefix}-first`)?.addEventListener('click', () => goPage(1));
container.querySelector(`#${prefix}-prev`)?.addEventListener('click', () => goPage(page - 1));
container.querySelector(`#${prefix}-next`)?.addEventListener('click', () => goPage(page + 1));
container.querySelector(`#${prefix}-last`)?.addEventListener('click', () => goPage(totalPages));
const jumpInput = container.querySelector(`#${prefix}-jump`);
jumpInput?.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
const p = parseInt(jumpInput.value);
if (!isNaN(p)) goPage(p);
}
});
jumpInput?.addEventListener('blur', () => {
const p = parseInt(jumpInput.value);
if (!isNaN(p) && p >= 1 && p <= totalPages && p !== page) goPage(p);
else jumpInput.value = page;
});
}
// ==================== 数据获取 ====================
// 方式1: 通过 access_token 获取家庭共享库(含自身游戏,include_own=true)
// v2.8.1: onProgress(stage, percent, text) 进度回调,UI 可选接入避免"界面不动"
async function fetchFamilyGameList(authToken, onProgress) {
const report = (stage, percent, text) => { if (typeof onProgress === 'function') onProgress(stage, percent, text); };
try {
// 阶段 1:获取家庭组基本信息
report(1, 25, '正在获取家庭组信息…');
const familyData = await requestSteamAPI(
`https://api.steampowered.com/IFamilyGroupsService/GetFamilyGroupForUser/v1/?access_token=${authToken}&include_family_group_response=true`
);
if (!familyData?.response?.family_groupid) { report(1, 100, '家庭组信息为空'); return null; }
const familyGroupId = familyData.response.family_groupid;
// 阶段 2:拉取共享库(主耗时 API,amount 不可知,给个递进动画)
report(2, 50, '正在获取家庭组共享游戏库…');
const gameData = await requestSteamAPI(
`https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/?access_token=${authToken}&family_groupid=${familyGroupId}&include_own=true&include_excluded=false&include_non_games=false`
);
report(3, 80, '正在整理共享游戏数据…');
if (gameData?.response?.apps) {
const apps = gameData.response.apps;
const games = apps
.filter(app => app.exclude_reason === 0)
.map(app => ({
appid: app.appid,
name: app.name || `App ${app.appid}`,
playtime: 0,
icon: app.img_icon_hash || '',
lastPlayed: app.rt_time_acquired || 0,
acquiredTime: app.rt_time_acquired || 0,
owners: app.owner_steamids || [],
}));
report(4, 100, `已获取 ${games.length} 款家庭组共享游戏`);
return games;
}
} catch (e) {
console.warn('[SGLV] fetchFamilyGameList error:', e);
}
return null;
}
// 方式2: 获取拥有游戏(优先 API Key,尝试 access_token,最后 fallback 页面抓取)
async function fetchOwnedGames(steamId, authToken) {
const apiKey = storage.getApiKey();
// 2.1 优先 API Key
if (apiKey) {
try {
const data = await requestSteamAPI(
`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${apiKey}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json`
);
if (data?.response?.games) {
console.log(`[SGLV] 通过 API Key 获取 ${data.response.games.length} 款游戏(含时长)`);
return {
games: data.response.games.map(g => ({
appid: g.appid,
name: g.name || `App ${g.appid}`,
playtime: g.playtime_forever || 0,
icon: g.img_icon_url || '',
lastPlayed: g.rtime_last_played || 0,
_source: 'api',
})),
source: 'api'
};
}
} catch (e) {
console.warn('[SGLV] API Key 获取失败:', e);
}
}
// 2.2 尝试用 access_token 调用 IPlayerService(部分账号可用)
if (authToken && steamId) {
try {
const data = await requestSteamAPI(
`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?access_token=${authToken}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json`
);
if (data?.response?.games) {
console.log(`[SGLV] 通过 access_token 获取 ${data.response.games.length} 款游戏(含时长)`);
return {
games: data.response.games.map(g => ({
appid: g.appid,
name: g.name || `App ${g.appid}`,
playtime: g.playtime_forever || 0,
icon: g.img_icon_url || '',
lastPlayed: g.rtime_last_played || 0,
_source: 'api',
})),
source: 'api'
};
}
} catch (e) {
console.warn('[SGLV] access_token 获取游戏时长失败:', e);
}
}
// 2.3 fallback:抓取社区游戏库页面(无需 Key,可获取时长)
if (steamId) {
try {
const scraped = await fetchGamesPage(steamId, 'all');
if (scraped.games.length > 0) {
console.log(`[SGLV] 通过页面抓取获取 ${scraped.games.length} 款游戏(含时长)`);
return {
games: scraped.games.map(g => ({
appid: g.appid,
name: g.name,
playtime: g.playtime_forever,
icon: g.img_icon_url || '',
lastPlayed: g.rtime_last_played,
_source: 'scrape',
})),
source: 'scrape'
};
}
} catch (e) {
console.warn('[SGLV] 页面抓取 fallback 失败:', e);
}
}
return { games: [], source: null };
}
async function fetchFamilyInfo(authToken) {
try {
const data = await requestSteamAPI(
`https://api.steampowered.com/IFamilyGroupsService/GetFamilyGroupForUser/v1/?access_token=${authToken}&include_family_group_response=true`
);
if (data?.response?.family_group) {
const fg = data.response.family_group;
const members = fg.members || [];
const nameMap = {};
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);
const params = batch.map((sid, idx) => `steamids[${idx}]=${sid}`).join('&');
try {
const pData = await requestSteamAPI(
`https://api.steampowered.com/IPlayerService/GetPlayerLinkDetails/v1/?access_token=${authToken}&${params}`
);
if (pData?.response?.accounts) {
pData.response.accounts.forEach(acc => {
if (acc.public_data) {
const sid = acc.public_data.steamid || acc.steamid;
if (sid && acc.public_data.persona_name) {
nameMap[sid] = acc.public_data.persona_name;
}
}
});
}
} catch (e) { /* ignore */ }
}
}
return {
family_groupid: data.response.family_groupid,
family_name: fg.name || 'Steam Family',
family_member: members.map(m => ({ steamid: m.steamid, userName: nameMap[m.steamid] || '' })),
steamIdtoName: nameMap
};
}
} catch (e) {
console.warn('[SGLV] fetchFamilyInfo error:', e);
}
return null;
}
// v2.3.29: 获取 dynamicstore/userdata——Steam 商店动态数据,返回完整 owned appids 列表
// 该端点比 GetOwnedGames 更可靠:包含 CD key 激活、促销许可、免费领取等所有类型的已入库游戏
// 参考 Steam-License-Classifier 的思路:license 数据是入库的 ground truth
// 合并两种数据源
async function fetchAllGames(onProgress) {
const gamesMap = new Map();
// v2.9.15: 进度回调——sub-step 1-3 映射到阶段 1 的 0-100%
const report = (sub, pct, text) => { if (typeof onProgress === 'function') onProgress(sub, pct, text); };
report(1, 10, isZh ? '正在准备访问令牌…' : 'Preparing access token…');
const authToken = await getAccessToken();
const steamId = getActiveSteamId();
// 步骤1:优先获取个人拥有游戏(含时长)
if (steamId) {
report(1, 20, isZh ? '正在拉取个人游戏…' : 'Fetching owned games…');
const owned = await fetchOwnedGames(steamId, authToken);
if (owned.games.length > 0) {
owned.games.forEach(g => {
gamesMap.set(g.appid, {
appid: g.appid,
name: g.name,
playtime: g.playtime || 0,
icon: g.icon || '',
lastPlayed: g.lastPlayed || 0,
owners: [],
acquiredTime: 0,
_source: g._source || owned.source,
});
});
console.log(`[SGLV] 个人游戏来源: ${owned.source || 'none'}, 共 ${owned.games.length} 款`);
}
}
// 步骤2:通过 access_token 获取家庭共享库(补充共享游戏和归属信息)
if (authToken) {
report(2, 50, isZh ? '正在拉取家庭组…' : 'Fetching family…');
const familyInfo = await fetchFamilyInfo(authToken);
if (familyInfo) storage.setFamilyInfo(familyInfo);
const familyGames = await fetchFamilyGameList(authToken);
if (familyGames && familyGames.length > 0) {
familyGames.forEach(g => {
const existing = gamesMap.get(g.appid);
if (!existing) {
gamesMap.set(g.appid, {
appid: g.appid,
name: g.name,
playtime: 0,
icon: g.icon || '',
lastPlayed: g.lastPlayed || 0,
owners: g.owners || [],
acquiredTime: g.acquiredTime || 0,
_source: 'family',
});
} else {
// 用家庭组数据补充 owners 和 acquiredTime
if (g.owners && g.owners.length) existing.owners = g.owners;
if (g.acquiredTime) existing.acquiredTime = g.acquiredTime;
}
});
console.log(`[SGLV] 通过 access_token 获取家庭共享 ${familyGames.length} 款游戏`);
}
}
// 步骤3:v2.3.29 通过 dynamicstore/userdata 补充 GetOwnedGames API 遗漏的游戏
// 某些通过 CD key 激活、促销许可或免费领取的游戏可能不在 GetOwnedGames 返回结果中
// dynamicstore/userdata 是 Steam 商店客户端使用的真实入库数据,包含所有许可类型
report(3, 80, isZh ? '正在补充遗漏游戏…' : 'Supplementing missing games…');
const dsOwnedAppIds = await fetchDynamicStoreOwnedAppIds();
if (dsOwnedAppIds && dsOwnedAppIds.size > 0) {
let supplemented = 0;
dsOwnedAppIds.forEach(appid => {
if (!gamesMap.has(appid)) {
gamesMap.set(appid, {
appid,
name: `App ${appid}`,
playtime: 0,
icon: '',
lastPlayed: 0,
owners: [],
acquiredTime: 0,
_source: 'dynamicstore',
});
supplemented++;
}
});
if (supplemented > 0) {
console.log(`[SGLV] dynamicstore/userdata 补充 ${supplemented} 款 GetOwnedGames 遗漏的游戏(CD key/促销许可等)`);
}
}
report(3, 100, isZh ? '库存拉取完成' : 'Done');
return Array.from(gamesMap.values());
}
// v2.3.24: 动态页专用数据源——始终拉取完整家庭组库(含个人 acquiredTime/owners,10 分钟会话级缓存)。
// 原因:acquiredTime 仅家庭组 API 提供;state.ownedGames 可能是缺少入库时间的缓存数据,
// 导致个人入库动态为空。此处与"显示家庭共享"开关无关,保证两个入库动态都能正确加载。
let _timelineFamilyGamesCache = null; // { games, ts, appidSet }
// v2.3.31: 入库热力图渲染缓存——避免每次切换游玩仪表标签页都重新计算 SVG
// v2.4.2: 缓存结构扩展为 { signature, html, ts, views }——views 预存各年份/全部视图 HTML,供年份切换直接复用
let _heatmapRenderCache = null;
// v2.8.1: onProgress(stage, percent, text) 进度回调,UI 可选接入避免"界面不动"
// v2.9.48: 增量更新——缓存有效时先返回缓存数据,后台静默拉取最新记录合并(不清空已有数据)
async function fetchTimelineFamilyGames(onProgress) {
const report = (stage, percent, text) => { if (typeof onProgress === 'function') onProgress(stage, percent, text); };
// v2.9.48: 缓存有效期内直接返回,不做增量更新(10 分钟 TTL)
if (_timelineFamilyGamesCache && Date.now() - _timelineFamilyGamesCache.ts < 10 * 60 * 1000) {
report(4, 100, `已从缓存载入 ${_timelineFamilyGamesCache.games.length} 款家庭组游戏`);
return _timelineFamilyGamesCache.games;
}
// v2.9.48: 缓存已过期但仍有数据——先返回旧数据(秒开),后台增量拉取最新记录
const hasStaleCache = _timelineFamilyGamesCache && _timelineFamilyGamesCache.games.length > 0;
if (hasStaleCache) {
// 立即返回旧数据,UI 秒开
report(4, 100, `已从缓存载入 ${_timelineFamilyGamesCache.games.length} 款家庭组游戏`);
// 后台静默增量更新(不阻塞 UI)
_refreshTimelineCacheInBackground();
return _timelineFamilyGamesCache.games;
}
// 无缓存——首次加载,走完整流程
try {
report(1, 8, '正在准备访问令牌…');
const authToken = await getAccessToken();
if (authToken) {
const games = await fetchFamilyGameList(authToken, (stage, percent, text) => {
const mapped = 10 + percent * 0.9;
report(Math.min(4, stage + 1), Math.min(99, mapped), text);
});
if (games && games.length > 0) {
const appidSet = new Set(games.map(g => g.appid));
_timelineFamilyGamesCache = { games, ts: Date.now(), appidSet };
report(4, 100, `已获取 ${games.length} 款家庭组共享游戏`);
return games;
}
}
} catch (e) { console.warn('[SGLV] 动态页家庭组数据获取失败:', e); }
const fb = (state.ownedGames && state.ownedGames.length > 0) ? state.ownedGames : null;
report(4, 100, fb ? `已使用本地游戏库(${fb.length} 款)作为回退` : '家庭组数据不可用');
return fb;
}
// v2.9.48: 后台增量刷新——拉取家庭组数据,与缓存合并(只新增不删除),更新时间戳
async function _refreshTimelineCacheInBackground() {
try {
const authToken = await getAccessToken();
if (!authToken) return;
const freshGames = await fetchFamilyGameList(authToken);
if (!freshGames || freshGames.length === 0) return;
const oldCache = _timelineFamilyGamesCache;
if (!oldCache) {
const appidSet = new Set(freshGames.map(g => g.appid));
_timelineFamilyGamesCache = { games: freshGames, ts: Date.now(), appidSet };
return;
}
// 增量合并:新数据中 appid 不在旧缓存中的才新增(保留已有数据不清空)
const oldSet = oldCache.appidSet || new Set(oldCache.games.map(g => g.appid));
const newGames = freshGames.filter(g => !oldSet.has(g.appid));
if (newGames.length > 0) {
// 更新旧缓存中已有游戏的数据(acquiredTime 可能更新)
const freshMap = new Map(freshGames.map(g => [g.appid, g]));
const mergedGames = oldCache.games.map(g => {
const fresh = freshMap.get(g.appid);
return fresh ? { ...g, ...fresh } : g;
});
// 追加新游戏
mergedGames.push(...newGames);
const mergedSet = new Set(mergedGames.map(g => g.appid));
_timelineFamilyGamesCache = { games: mergedGames, ts: Date.now(), appidSet: mergedSet };
console.log(`[SGLV] 时间线增量更新:新增 ${newGames.length} 款,总计 ${mergedGames.length} 款`);
} else {
// 无新增,仅更新时间戳
_timelineFamilyGamesCache.ts = Date.now();
}
} catch (e) {
console.warn('[SGLV] 时间线后台增量刷新失败:', e);
// 失败时仍更新时间戳,避免频繁重试
if (_timelineFamilyGamesCache) _timelineFamilyGamesCache.ts = Date.now();
}
}
// v2.3.15: 个人入库动态(优先使用已缓存的 state.ownedGames,不判断"显示家庭共享"开关,直接显示所有个人入库动态)
async function buildPersonalTimeline(steamId) {
// v2.3.24: 统一走动态页专用数据源(始终含完整家庭组 acquiredTime),与"显示家庭共享"开关无关
const familyGames = await fetchTimelineFamilyGames();
if (!familyGames || familyGames.length === 0) return null;
const mySteamId = String(steamId || getActiveSteamId() || '').trim();
const items = [];
for (const g of familyGames) {
const acquired = g.acquiredTime || 0;
if (!acquired) continue; // v2.3.15: 跳过无入库时间的游戏,避免显示无效条目
// 个人入库:owners 包含 mySteamId,或 owners 为空(视为个人拥有)
const owners = g.owners || [];
if (mySteamId && owners.length > 0 && !owners.some(sid => String(sid).trim() === mySteamId)) continue;
const ts = acquired * 1000;
const dateStr = new Date(ts).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
items.push({
appid: g.appid,
name: g.name || `App ${g.appid}`,
ts,
dateStr,
icon: g.icon || '',
playtime: g.playtime || 0,
});
}
items.sort((a, b) => (b.ts || 0) - (a.ts || 0));
return { items, total: items.length };
}
// v2.3.15: 家庭组入库动态(优先使用已缓存的 state.ownedGames,不判断"显示家庭共享"开关,直接显示所有入库动态)
// v2.8.1: onProgress(stage, percent, text) 进度回调,UI 可选接入避免"界面不动"
// 同时把循环拆成异步 yield,避免处理 1k+ 款游戏时阻塞主线程
async function buildFamilyTimeline(steamId, onProgress) {
const report = (stage, percent, text) => { if (typeof onProgress === 'function') onProgress(stage, percent, text); };
// v2.3.24: 统一走动态页专用数据源(始终含完整家庭组 acquiredTime),与"显示家庭共享"开关无关
// 阶段 1-4:token → 家庭组信息 → 共享库 → 整理(在 fetchTimelineFamilyGames 内部完成)
const familyGames = await fetchTimelineFamilyGames((stage, percent, text) => {
report(stage, percent, text);
});
if (!familyGames || familyGames.length === 0) return null;
// 阶段 4:分批整理游戏数据,避免长任务阻塞渲染
const items = [];
const total = familyGames.length;
const BATCH = 200; // 每批 200 条,配合 requestAnimationFrame 让出主线程
const yieldFrame = () => new Promise(r => requestAnimationFrame(() => r()));
for (let i = 0; i < total; i++) {
const g = familyGames[i];
const acquired = g.acquiredTime || 0;
if (acquired) {
const ts = acquired * 1000;
const dateStr = new Date(ts).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
items.push({
appid: g.appid,
name: g.name || `App ${g.appid}`,
ts,
dateStr,
icon: g.icon || '',
playtime: g.playtime || 0,
owners: g.owners || [],
});
}
// 每批结束让出一次主线程(数据量大时保持 UI 响应)
if ((i + 1) % BATCH === 0 && i + 1 < total) {
const pct = 50 + Math.round((i + 1) / total * 45);
report(4, pct, `已整理 ${i + 1} / ${total} 款游戏…`);
await yieldFrame();
}
}
items.sort((a, b) => (b.ts || 0) - (a.ts || 0));
report(4, 100, `整理完成:共 ${items.length} 条入库记录`);
return { items, total: items.length };
}
// ==================== 游戏收藏模块(v2.7.1:@require 外部库) ====================
// 供应商分类 / 绝版收藏 / PS会免 三个模块已拆分至独立 Greasyfork 库 sglv-game-collection。
// 主脚本经 CollectionHost 桥提供数据/工具/i18n,库经 init(ctx) 握手后独立运行收藏浮窗。
// 库未加载时(网络失败/TM缓存损坏),二级菜单项置灰提示,主浮窗功能不受影响。
// 桥接契约:宿主需 @grant GM_xmlhttpRequest/GM_getValue/GM_setValue,
// 并 @connect fastly.jsdelivr.net(PS会免数据)与 store.playstation.com(PS独占中文名)。
const CollectionHost = {
apiVersion: 1,
// ---- 库存数据访问(只读,尊重家庭组开关) ----
getOwnedGames: () => state.ownedGames,
getActiveOwnedAppIds,
isGameOwnedByMe,
getGameOwnerNames,
getShowFamilyShared: () => storage.getShowFamilyShared(),
// ---- 收藏数据源(@resource 声明保留在主脚本 metadata) ----
// v2.9.27: 供应商(GAME_DB)与系列(seriesDb)分离提供,不再合并
// v2.9.91: getGameDb 优先返回远程覆盖版本(如有)
getGameDb: () => supplierDbOverride || GAME_DB,
getSeriesDb: () => seriesDb,
// v2.9.90: 暴露 loadSeriesData 供收藏浮窗调用强制刷新
loadSeriesData,
// v2.9.91: 暴露 loadSupplierData + getSupplierFetchedAt 供收藏浮窗调用
loadSupplierData,
getSupplierFetchedAt: () => supplierFetchedAt,
getDelistedDb: () => DELISTED_DB,
getResourceText: (name) => { try { return GM_getResourceText(name); } catch (e) { return null; } },
// ---- 通用工具 ----
fetchGameZhName,
loadGameZhName,
coverChainUrls,
posterImg,
capsuleImg,
getGameIconUrl,
getPosterGood,
fetchCoverApiUrl: _fetchCoverApiUrl,
renderPagination,
paginate,
showToast,
wlEscape,
escHtml,
h,
formatLastPlayedShort,
cacheKey,
getCached,
// ---- TTL 缓存 ----
cacheGet,
cacheSet,
cacheDelete,
cacheTTL: CACHE_TTL,
// ---- i18n / 图标 ----
T,
ICONS,
isZh,
// ---- v2.9.12: 锁区游戏数据访问(供收藏模块调用) ----
loadBlockedApps,
isBlockedApp,
isBlockedLoaded: () => state.blockedLoaded,
getBlockedApps: () => state.blockedApps,
getBlockedSource: () => state.blockedSource,
// v2.9.90: 暴露 fetchedAt 供收藏浮窗显示数据更新时间(延迟查找,因 SGLV_API.getBlockedFetchedAt 在锁区IIFE中赋值)
getBlockedFetchedAt: () => (typeof SGLV_API.getBlockedFetchedAt === 'function') ? SGLV_API.getBlockedFetchedAt() : '',
parseManualBlockedJson,
// ---- v2.9.32: DLC 收集数据访问(供收藏模块调用,闭包直达无需 SGLV_API 中转) ----
// v1.9.2: loadDlcDatabaseFromCacheSync 暴露给收藏模块——冷启动时同步预热 dlcDbData / appTypeMap 闭包,
// 避免 collection 库每次 await 异步加载导致"假卡死"
isDlc,
isDlcDbReady: () => !!(dlcDbData || appTypeMap),
loadDlcDatabase,
loadDlcDatabaseFromCacheSync, // v1.9.2: 新增,同步读 GM 缓存填充闭包
enrichOwnedAppTypes,
enrichDlcTypes,
getDlcType,
getBundleCount,
getDlcParent: (dlcAppId) => {
const e = dlcDbData && dlcDbData[String(dlcAppId)];
return e ? e.base_appID : null;
},
// v2.9.32: 构建 父游戏 appID → 全部 DLC 数 反向映射(用于 DLC 收集完成度计算)
getParentDlcTotalMap: () => {
const map = {};
if (dlcDbData) {
for (const info of Object.values(dlcDbData)) {
if (info && info.base_appID) {
map[info.base_appID] = (map[info.base_appID] || 0) + 1;
}
}
}
console.log(`[SGLV-DLC] getParentDlcTotalMap — 构建 ${Object.keys(map).length} 个父游戏的 DLC 总数映射 (dlcDbData=${dlcDbData ? Object.keys(dlcDbData).length + '条' : 'null'})`);
return map;
},
// v2.9.46: 构建 父游戏 appID → [dlcAppId, ...] 反向映射(用于 DLC 收藏全量加载)
getDlcsByParentMap: () => {
if (_dlcsByParentCache) return _dlcsByParentCache;
const map = {};
if (dlcDbData) {
for (const [dlcAppId, info] of Object.entries(dlcDbData)) {
if (info && info.base_appID) {
const key = String(info.base_appID);
if (!map[key]) map[key] = [];
map[key].push(Number(dlcAppId));
}
}
}
_dlcsByParentCache = map;
console.log(`[SGLV-DLC] getDlcsByParentMap — 构建 ${Object.keys(map).length} 个父游戏的 DLC 列表映射`);
return map;
},
};
// v2.7.1: 收藏模块经 @require 外部库加载,握手初始化
let _collectionReady = false;
if (window.SGLVCollection && typeof window.SGLVCollection.init === 'function') {
_collectionReady = window.SGLVCollection.init(CollectionHost);
if (!_collectionReady) {
console.warn('[SGLV] 收藏模块握手失败 (apiVersion 不匹配)');
}
} else {
console.warn('[SGLV] 收藏模块库未加载 — 收藏功能不可用 (请检查 @require 或 Tampermonkey 外部资源设置)');
}
// v2.9.34: 云存档模块经 @require 外部库加载,握手初始化
let _cloudSaveReady = false;
if (window.SGLVCloudSave && typeof window.SGLVCloudSave.init === 'function') {
const CloudSaveHost = {
apiVersion: 1,
isZh,
showToast,
cacheGet,
cacheSet,
cacheDelete,
cacheTTL: CACHE_TTL,
storage: {
getCloudSaveData: storage.getCloudSaveData,
setCloudSaveData: storage.setCloudSaveData,
},
};
_cloudSaveReady = window.SGLVCloudSave.init(CloudSaveHost);
if (!_cloudSaveReady) {
console.warn('[SGLV] 云存档模块握手失败 (apiVersion 不匹配)');
}
} else {
console.warn('[SGLV] 云存档模块库未加载 — 云存档功能不可用 (请检查 @require 或 Tampermonkey 外部资源设置)');
}
// v2.9.79: ITAD 价格增强库初始化
// 硬编码 Key 作为兜底 (用户未配置时使用共享额度)
const ITAD_API_KEY_FALLBACK = '58eb2271a08c1cf60bd812d701d09d5c694f502e';
function getEffectiveItadKey() {
const userKey = storage.getItadApiKey();
return userKey || ITAD_API_KEY_FALLBACK;
}
let _itadReady = false;
if (window.SGLVITAD && typeof window.SGLVITAD.init === 'function') {
window.SGLVITAD.init({ apiKey: getEffectiveItadKey() });
_itadReady = true;
console.log('[SGLV] ITAD 价格增强库已加载' + (storage.getItadApiKey() ? ' (用户密钥)' : ' (兜底密钥)'));
} else {
console.warn('[SGLV] ITAD 库未加载 — 价格增强功能不可用 (请检查 @require)');
}
// ==================== UI 构建 ====================
let overlayEl, panelEl;
function initUI() {
// 库存展示按钮 — 插入到全局导航栏"客服"后方
// v2.7.0: 包装为二级菜单容器,新增"游戏收藏"子菜单项
const menuWrap = document.createElement('span');
menuWrap.className = 'sglv-menu-wrap';
const fab = document.createElement('a');
fab.className = 'menuitem sglv-fab';
fab.title = T.title;
fab.href = 'javascript:void(0)';
fab.textContent = T.title;
fab.addEventListener('click', (e) => { e.preventDefault(); togglePanel(); });
menuWrap.appendChild(fab);
// 二级菜单:游戏收藏
const submenu = document.createElement('div');
submenu.className = 'sglv-submenu';
const collectionLink = document.createElement('a');
collectionLink.className = 'menuitem sglv-submenu-item';
collectionLink.href = 'javascript:void(0)';
collectionLink.textContent = isZh ? '游戏收藏' : 'Game Collection';
collectionLink.title = isZh ? '供应商分类 / 绝版收藏 / PS会免 / Epic赠送 / 年度大作' : 'Publisher / Delisted / PS Plus / Epic Free / GotY';
collectionLink.addEventListener('click', (e) => {
e.preventDefault();
if (window.SGLVCollection) {
SGLVCollection.toggleCollection();
} else {
sglvToast.warning(isZh ? '收藏模块未加载' : 'Collection module not loaded');
}
});
submenu.appendChild(collectionLink);
menuWrap.appendChild(submenu);
const supernav = document.querySelector('.supernav_container');
if (supernav) {
// 定位"客服"链接(help.steampowered.com),在其后方插入
const helpLink = supernav.querySelector('a.menuitem[href*="help.steampowered.com"]')
|| Array.from(supernav.querySelectorAll('a.menuitem')).pop();
if (helpLink) helpLink.after(menuWrap);
else supernav.appendChild(menuWrap);
} else {
// v2.9.109: supernav_container 不存在时(React新布局/社区页)降级为浮动按钮
menuWrap.style.cssText = 'position:fixed;top:14px;right:160px;z-index:9999;display:flex;align-items:center;gap:4px;';
document.body.appendChild(menuWrap);
}
// 遮罩层
overlayEl = document.createElement('div');
overlayEl.className = 'sglv-overlay';
overlayEl.addEventListener('click', closePanel);
document.body.appendChild(overlayEl);
// 主面板
panelEl = document.createElement('div');
panelEl.className = 'sglv-panel';
// v2.9.22: 页头紧凑化——移除"游戏库"标题,保留最左侧 Steam 图标作为品牌标识,tabs 移入 header 内部(参考 steam-friend-manager 1.2.5 sfd-tab-bar 风格)
// v2.9.51: tab 菜单紧凑化 + 愿望单 tab 右侧加入全局搜索框(中文/拼音缩写/英文子串匹配)
panelEl.innerHTML = `
`;
document.body.appendChild(panelEl);
// v2.9.22: header 整行作为拖动手柄(参考 steam-friend-manager 1.2.5 makeDraggable)
makeDraggable(panelEl, panelEl.querySelector('.sglv-header'));
// 事件绑定
panelEl.querySelector('.sglv-close-btn').addEventListener('click', closePanel);
panelEl.querySelector('.sglv-refresh-btn').addEventListener('click', () => startFetch(true));
panelEl.querySelector('.sglv-settings-btn').addEventListener('click', () => {
// v2.9.28: 设置已集成进主面板——再点一次可退出设置视图
if (state.showSettings) closeGlobalSettings();
else openGlobalSettings();
});
// v2.9.27: 心型按钮 → 打开游戏收藏浮窗
panelEl.querySelector('.sglv-collection-btn').addEventListener('click', () => {
if (window.SGLVCollection) SGLVCollection.openCollection();
else showToast(isZh ? '收藏模块未加载' : 'Collection module not loaded');
});
panelEl.querySelector('.sglv-export-csv-btn').addEventListener('click', exportCSV);
panelEl.querySelector('.sglv-export-json-btn').addEventListener('click', exportJSON);
panelEl.querySelectorAll('.sglv-tab').forEach(tab => {
tab.addEventListener('click', () => {
// v2.9.56: 统一使用 switchToTab
switchToTab(tab.dataset.tab);
});
});
// v2.9.51: 绑定全局游戏搜索框事件(输入防抖/键盘导航/外部点击关闭/清除按钮)
bindGlobalSearchEvents();
// v2.9.51: 首次绑定时也构建一次搜索索引(已有缓存的情况下,UI 即可用)
if (!_searchIndexBuilt) {
try { buildSearchIndex(); } catch (e) { console.warn('[SGLV] 搜索索引预构建失败:', e); }
}
// v2.3.27: 供侧边栏 KPI 卡片跨模块打开游戏库浮窗
// v2.9.49: 注册到 addDisposer 确保卸载时清理,避免内存泄漏
const _onOpenLibrary = () => { openPanel(); };
document.addEventListener('sglv:open-library', _onOpenLibrary);
addDisposer(() => document.removeEventListener('sglv:open-library', _onOpenLibrary));
// v2.9.38: 监听 SGIS 侧边栏触发的事件, 支持跳转指定 tab
const _onOpenModal = (e) => {
openPanel();
const targetTab = e?.detail?.tab;
if (targetTab) {
// v2.9.56: 统一使用 switchToTab (内含 DOM 存在性检查)
const tabBtn = panelEl.querySelector(`.sglv-tab[data-tab="${targetTab}"]`);
if (tabBtn) switchToTab(targetTab);
}
};
document.addEventListener('sglv:open-modal', _onOpenModal);
addDisposer(() => document.removeEventListener('sglv:open-modal', _onOpenModal));
// 加载缓存(GM_getValue 同步读取,体积小,不阻塞绘制)
const cached = storage.getCachedGames();
if (cached.length) {
cached.sort((a, b) => a.name.localeCompare(b.name));
state.ownedGames = cached;
state.ownedAppIds = new Set(cached.map(g => g.appid));
markSearchIndexDirty(); // v2.9.51: 缓存恢复,索引需要重建
// v2.9.50: 同步从 PCC IDB 预热已计算的 KPI 结果到 _computedCache
// 旧逻辑 clearComputedCache 把所有缓存清空,下次开面板全量重算(每次)
// 新逻辑 hydrateComputedCache 把已持久化的计算结果(年度统计/家庭组/insight 等)从 IDB 同步填充
// 启动时 IDB.loadAll 已完成,内存镜像 _mem 可用
hydrateComputedCache();
} else {
// 首次使用/无缓存:确保缓存清空
clearComputedCache();
}
// v2.9.5: 首次使用检测——无 API Key 且无缓存游戏时给出引导
const _isFirstUse = !storage.getApiKey() && state.ownedGames.length === 0;
// v2.9.5: DLC 数据库同步预加载延迟到下一事件循环,确保菜单入口优先渲染
// DLC 缓存体积较大(数万条),同步 JSON.parse 会阻塞浏览器首次绘制
setTimeout(() => {
loadDlcDatabaseFromCacheSync();
loadDlcDatabase();
// v2.9.27: 异步加载游戏系列数据(game_series.json),合并到系列分类数据库
// v2.9.104: 同时拉取 steam_vediogame_selected.json,作为"互动影游"系列并入同一管线
loadSeriesData();
// v2.9.91: 异步加载供应商数据远程版本检查(覆盖 @resource GAME_DB)
loadSupplierData();
// v2.9.28: 首次未配置 API Key 不再自动弹出面板/设置,改为右下角引导提示卡片,
// 由用户主动点击"打开设置"进入设置视图,避免打断浏览体验
if (_isFirstUse) {
showApiKeyGuideToast();
}
}, 0);
}
function togglePanel() {
if (panelEl.classList.contains('sglv-show')) closePanel();
else openPanel();
}
function openPanel() {
panelEl.classList.add('sglv-show');
overlayEl.classList.add('sglv-show');
// v2.9.109: renderBody 包裹 try-catch,渲染失败不阻断面板打开
try { renderBody(); } catch (e) { console.error('[SGLV] 面板渲染失败:', e); }
// v2.9.99: 展柜预热 — 面板打开时后台预取展柜数据,用户点击展柜标签时即可秒开
try { _preheatShowcase(); } catch (e) { /* ignore */ }
}
// v2.9.99: 展柜预热 — 在后台静默预取展柜数据并写入持久化缓存
// 不阻塞当前标签页渲染,仅在后台完成数据获取
function _preheatShowcase() {
if (state.showcasePreheating || state.showcasePreheated) return;
if (state.ownedGames.length === 0) return; // 无游戏库数据时不预热
state.showcasePreheating = true;
// 异步预取,不阻塞 UI
(async () => {
try {
const configHash = _computeShowcaseCacheHash();
// 先检查持久化缓存是否仍然有效
const swr = _swrReadShowcaseCache(configHash);
if (swr.data && !swr.stale && !swr.configChanged) {
// 缓存有效,跳过预取,仅恢复内存缓存
if (!state.showcaseProfile && swr.data.profile) {
state.showcaseProfile = swr.data.profile;
state.showcaseProfileAt = swr.timestamp || 0;
}
state.showcasePreheating = false;
state.showcasePreheated = true;
return;
}
// v2.9.103: 与标签页渲染共享 in-flight 抓取,避免重复网络请求
const data = await _fetchShowcaseDataAll();
_writeShowcaseCache({ ...data, achievements: _showcaseUnlockedOnly(data.achievements) }, configHash);
state.showcasePreheating = false;
state.showcasePreheated = true;
} catch {
// v2.9.103: 失败仅复位 preheating,允许下次 openPanel 重试预热
// (旧实现失败后置 preheated=true,本 session 内永久跳过预热)
state.showcasePreheating = false;
}
})();
}
// v2.9.103: 展柜数据 in-flight Promise 共享 — 预热与标签页渲染复用同一次抓取
// (Profile/成就/最近游玩/徽章),并发进入时只发一轮网络请求;结算后清空槽位允许重试
let _showcaseFetchInflight = null;
function _fetchShowcaseDataAll() {
if (_showcaseFetchInflight) return _showcaseFetchInflight;
const p = (async () => {
const [profile, achievements, recentGames, allBadges] = await Promise.all([
_fetchShowcaseProfile(),
Promise.resolve(computeMyAchievementsCached()),
_fetchRecentPlayedGames(),
_getShowcaseBadges().catch(() => []),
]);
const allGames = getStatFilteredGames();
const gameCount = allGames.length;
const totalPlaytimeMin = allGames.reduce((s, g) => s + (g.playtime || 0), 0);
const totalHours = Math.floor(totalPlaytimeMin / 60);
const unlockedAchs = (achievements || []).filter(a => a.unlocked);
const stats = {
gameCount,
totalHours,
achCount: unlockedAchs.length,
totalAchPts: unlockedAchs.reduce((s, a) => s + a.pts, 0),
steamBadgeCount: (allBadges && allBadges.length) || profile.badgeCount || 0,
};
return { profile, recentGames, badges: allBadges, achievements, stats };
})();
_showcaseFetchInflight = p.then(
data => { _showcaseFetchInflight = null; return data; },
err => { _showcaseFetchInflight = null; throw err; }
);
return _showcaseFetchInflight;
}
function closePanel() {
panelEl.classList.remove('sglv-show');
overlayEl.classList.remove('sglv-show');
// v2.9.64: 清理搜索/详情浮窗状态,防止面板重开后浮窗残留 + 监听器泄漏 + 防抖 timer 泄漏
closeDetailPopup(); // 关闭详情浮窗 + 移除 _detailEscHandler + 递增 _detailToken 使在途请求失效
closeSearchPop(); // 关闭搜索下拉 + 重置聚焦索引
_gsDebouncedRender.cancel(); // 取消 pending 防抖 timer
if (_gsInputEl) _gsInputEl.value = ''; // 清空搜索框
_gsLastResults = [];
}
// v2.9.22: 允许拖动面板(参考 steam-friend-manager 1.2.5 makeDraggable)
// 用 !important 覆盖 shared CSS 中 sglv-panel 的 top/left 定位,
// 否则拖动后内联 top 会被样式表压制,导致窗口只能左右移动、无法上下移动。
function makeDraggable(el, handle) {
if (!el || !handle) return;
let isDragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0;
const onDown = (e) => {
// 忽略按钮 / 链接 / 输入控件 / label 上的 mousedown,避免与 tab/操作按钮冲突
const t = e.target;
if (t.closest('button, input, select, textarea, label, a')) return;
isDragging = true;
startX = e.clientX; startY = e.clientY;
const rect = el.getBoundingClientRect();
startLeft = rect.left; startTop = rect.top;
el.style.cursor = 'grabbing';
e.preventDefault();
};
const onMove = (e) => {
if (!isDragging) return;
el.style.setProperty('left', (startLeft + e.clientX - startX) + 'px', 'important');
el.style.setProperty('top', (startTop + e.clientY - startY) + 'px', 'important');
el.style.setProperty('right', 'auto', 'important');
el.style.setProperty('bottom', 'auto', 'important');
el.style.setProperty('transform', 'none', 'important');
};
const onUp = () => {
if (!isDragging) return;
isDragging = false;
el.style.cursor = '';
};
handle.addEventListener('mousedown', onDown);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
addDisposer(() => {
handle.removeEventListener('mousedown', onDown);
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
});
}
// ==================== v2.9.38: 我的成就 — 全新质感炫彩 SVG 图标库 ====================
// 设计规范:80x80 viewBox · 圆形徽章风格 · 四层结构 (背景环 + 主渐变填充 + 内层高光 + 中央图标)
// 共享滤镜 ID 命名约定: ach-{key}-shadow / ach-{key}-main / ach-{key}-shine / ach-{key}-rim
const _ACH_ICONS = {
// ===== 收藏家 — 紫色宝石盒 (紫蓝渐变) =====
collector: ``,
// ===== 游戏大王 — 黄金王冠 (金黄橙红渐变) =====
kingCollector: ``,
// ===== 时钟 — 青色钟表 (青蓝渐变) =====
clock: ``,
// ===== 沙漏 — 橙红沙漏 (橙红渐变) =====
hourglass: ``,
// ===== 完美 — 绿色圆环 (翠绿渐变) =====
perfection: ``,
// ===== 奖杯 — 黄金奖杯 (金黄渐变) =====
trophy: ``,
// ===== 火焰 — 红橙烈焰 (橙红渐变) =====
fire: ``,
// ===== 地球 — 青色地球 (青蓝渐变) =====
globe: ``,
// ===== 朝阳 — 金黄旭日 (橙黄渐变) =====
sunrise: ``,
// ===== 月亮 — 紫蓝夜空 (靛紫渐变) =====
moon: ``,
// ===== 钻石 — 青色钻石 (青蓝渐变) =====
diamond: ``,
// ===== 红心 — 粉红心形 (粉红渐变) =====
heart: ``,
// ===== 星星 — 金色星芒 (金黄渐变) =====
star: ``,
// ===== 盾牌 — 紫蓝盾牌 (靛紫渐变) =====
shield: ``,
// ===== 火箭 — 紫粉火箭 (紫粉渐变) =====
rocket: ``,
// ===== 消费 — 蓝色礼盒 (蓝紫渐变 · 新增) =====
spending: ``,
// ===== 社交 — 紫粉对话 (粉紫渐变 · 新增) =====
social: ``,
// ===== 探索 — 翠绿罗盘 (青绿渐变 · 新增) =====
explore: ``,
};
// v2.9.35: 成就稀有度元数据
const _ACH_RARITY = {
common: { label: T.achRarityCommon, pts: 10, color: '#94a3b8' },
rare: { label: T.achRarityRare, pts: 25, color: '#3b82f6' },
epic: { label: T.achRarityEpic, pts: 50, color: '#a855f7' },
legendary: { label: T.achRarityLegendary, pts: 100, color: '#f59e0b' },
};
// v2.9.35: 成就分类元数据 (6 大分类)
const _ACH_CATEGORIES = [
{ id: 'collector', icon: _ACH_ICONS.collector, label: T.achCategoryCollector, gradient: 'linear-gradient(135deg, #8b5cf6, #3b82f6)' },
{ id: 'playtime', icon: _ACH_ICONS.clock, label: T.achCategoryPlaytime, gradient: 'linear-gradient(135deg, #06b6d4, #0891b2)' },
{ id: 'mastery', icon: _ACH_ICONS.trophy, label: T.achCategoryMastery, gradient: 'linear-gradient(135deg, #10b981, #059669)' },
{ id: 'diversity', icon: _ACH_ICONS.globe, label: T.achCategoryDiversity, gradient: 'linear-gradient(135deg, #f97316, #ea580c)' },
{ id: 'loyalty', icon: _ACH_ICONS.heart, label: T.achCategoryLoyalty, gradient: 'linear-gradient(135deg, #f472b6, #db2777)' },
{ id: 'special', icon: _ACH_ICONS.star, label: T.achCategorySpecial, gradient: 'linear-gradient(135deg, #fbbf24, #f59e0b)' },
];
// v2.9.35: 成就定义 — 6 大分类 30+ 成就,含稀有度与成就点数
// metric 字段统一指向 _computeAchMetrics 返回的 key,简化判定逻辑
const _ACHIEVEMENT_DEFS = [
// ===== 收藏成就 (collector) — 游戏数量 =====
{ id: 'collector_10', icon: _ACH_ICONS.collector, name: isZh ? '初出茅庐' : 'First Steps', desc: isZh ? '拥有 10 款游戏' : 'Own 10 games', threshold: 10, metric: 'gameCount', cat: 'collector', rarity: 'common' },
{ id: 'collector_50', icon: _ACH_ICONS.collector, name: isZh ? '初级收藏家' : 'Novice Collector', desc: isZh ? '拥有 50 款游戏' : 'Own 50 games', threshold: 50, metric: 'gameCount', cat: 'collector', rarity: 'common' },
{ id: 'collector_100', icon: _ACH_ICONS.collector, name: isZh ? '中级收藏家' : 'Avid Collector', desc: isZh ? '拥有 100 款游戏' : 'Own 100 games', threshold: 100, metric: 'gameCount', cat: 'collector', rarity: 'rare' },
{ id: 'collector_300', icon: _ACH_ICONS.collector, name: isZh ? '高级收藏家' : 'Pro Collector', desc: isZh ? '拥有 300 款游戏' : 'Own 300 games', threshold: 300, metric: 'gameCount', cat: 'collector', rarity: 'rare' },
{ id: 'collector_500', icon: _ACH_ICONS.collector, name: isZh ? '超级收藏家' : 'Mega Collector', desc: isZh ? '拥有 500 款游戏' : 'Own 500 games', threshold: 500, metric: 'gameCount', cat: 'collector', rarity: 'epic' },
{ id: 'collector_1000', icon: _ACH_ICONS.kingCollector,name: isZh ? '游戏大王' : 'Game Master', desc: isZh ? '拥有 1000 款游戏' : 'Own 1000 games', threshold: 1000, metric: 'gameCount', cat: 'collector', rarity: 'legendary' },
// ===== 时长成就 (playtime) — 总游戏时长 =====
{ id: 'playtime_500', icon: _ACH_ICONS.clock, name: isZh ? '初涉江湖' : 'Getting Started', desc: isZh ? '总时长达到 500 小时' : 'Reach 500 hours', threshold: 500, metric: 'totalHours', cat: 'playtime', rarity: 'common' },
{ id: 'playtime_1k', icon: _ACH_ICONS.clock, name: isZh ? '入门玩家' : 'Beginner', desc: isZh ? '总时长达到 1000 小时' : 'Reach 1000 hours', threshold: 1000, metric: 'totalHours', cat: 'playtime', rarity: 'common' },
{ id: 'playtime_3k', icon: _ACH_ICONS.hourglass, name: isZh ? '熟练玩家' : 'Skilled', desc: isZh ? '总时长达到 3000 小时' : 'Reach 3000 hours', threshold: 3000, metric: 'totalHours', cat: 'playtime', rarity: 'rare' },
{ id: 'playtime_5k', icon: _ACH_ICONS.hourglass, name: isZh ? '资深玩家' : 'Veteran', desc: isZh ? '总时长达到 5000 小时' : 'Reach 5000 hours', threshold: 5000, metric: 'totalHours', cat: 'playtime', rarity: 'rare' },
{ id: 'playtime_10k', icon: _ACH_ICONS.hourglass, name: isZh ? '硬核玩家' : 'Hardcore', desc: isZh ? '总时长达到 10000 小时' : 'Reach 10000h', threshold: 10000,metric: 'totalHours', cat: 'playtime', rarity: 'epic' },
{ id: 'playtime_20k', icon: _ACH_ICONS.fire, name: isZh ? '传奇玩家' : 'Legend', desc: isZh ? '总时长达到 20000 小时' : 'Reach 20000h', threshold: 20000,metric: 'totalHours', cat: 'playtime', rarity: 'legendary' },
// ===== 专精成就 (mastery) — 单游戏深度游玩 =====
{ id: 'mastery_1x100', icon: _ACH_ICONS.trophy, name: isZh ? '专精入门' : 'Focused', desc: isZh ? '1 款游戏游玩超过 100 小时' : '1 game 100h+', threshold: 1, metric: 'games100h', cat: 'mastery', rarity: 'common' },
{ id: 'mastery_5x100', icon: _ACH_ICONS.trophy, name: isZh ? '多线专精' : 'Multi-Focus', desc: isZh ? '5 款游戏游玩超过 100 小时' : '5 games 100h+', threshold: 5, metric: 'games100h', cat: 'mastery', rarity: 'common' },
{ id: 'mastery_10x100', icon: _ACH_ICONS.trophy, name: isZh ? '深度专精' : 'Dedicated', desc: isZh ? '10 款游戏游玩超过 100 小时' : '10 games 100h+',threshold: 10,metric: 'games100h', cat: 'mastery', rarity: 'rare' },
{ id: 'mastery_1x500', icon: _ACH_ICONS.fire, name: isZh ? '挚爱之作' : 'Beloved', desc: isZh ? '1 款游戏游玩超过 500 小时' : '1 game 500h+', threshold: 1, metric: 'games500h', cat: 'mastery', rarity: 'rare' },
{ id: 'mastery_1x1k', icon: _ACH_ICONS.fire, name: isZh ? '一生挚爱' : 'Soulmate', desc: isZh ? '1 款游戏游玩超过 1000 小时' : '1 game 1000h+',threshold: 1, metric: 'games1kh', cat: 'mastery', rarity: 'epic' },
{ id: 'mastery_20x100', icon: _ACH_ICONS.perfection, name: isZh ? '完美主义' : 'Perfectionist', desc: isZh ? '20 款游戏游玩超过 100 小时' : '20 games 100h+',threshold: 20,metric: 'games100h', cat: 'mastery', rarity: 'legendary' },
// ===== 多元成就 (diversity) — 游戏覆盖面 =====
{ id: 'div_played50', icon: isZh ? _ACH_ICONS.globe : _ACH_ICONS.globe, name: isZh ? '广泛涉猎' : 'Explorer', desc: isZh ? '游玩库中 50% 的游戏' : 'Play 50% of library', threshold: 50, metric: 'playRate', cat: 'diversity', rarity: 'common' },
{ id: 'div_played80', icon: _ACH_ICONS.globe, name: isZh ? '博览群书' : 'Scholar', desc: isZh ? '游玩库中 80% 的游戏' : 'Play 80% of library', threshold: 80, metric: 'playRate', cat: 'diversity', rarity: 'rare' },
{ id: 'div_unplayed100',icon: _ACH_ICONS.shield, name: isZh ? '库存积压' : 'Backlog', desc: isZh ? '拥有 100 款从未游玩的游戏' : '100 unplayed games', threshold: 100, metric: 'unplayedCount', cat: 'diversity', rarity: 'common' },
{ id: 'div_recent7d', icon: _ACH_ICONS.sunrise, name: isZh ? '活跃玩家' : 'Active', desc: isZh ? '7 天内游玩过游戏' : 'Played within 7 days', threshold: 1, metric: 'recent7d', cat: 'diversity', rarity: 'common' },
{ id: 'div_recent24h', icon: _ACH_ICONS.sunrise, name: isZh ? '今日在线' : 'Today', desc: isZh ? '24 小时内游玩过游戏' : 'Played within 24h', threshold: 1, metric: 'recent24h', cat: 'diversity', rarity: 'rare' },
{ id: 'div_played100', icon: _ACH_ICONS.perfection, name: isZh ? '全勤玩家' : 'Completionist', desc: isZh ? '游玩库中 100% 的游戏' : 'Play 100% of library', threshold: 100,metric: 'playRate', cat: 'diversity', rarity: 'legendary' },
// ===== 忠诚成就 (loyalty) — 数据完整度与家庭组 =====
{ id: 'loy_data50', icon: _ACH_ICONS.heart, name: isZh ? '数据收集者' : 'Data Keeper', desc: isZh ? '50% 游戏有入库时间' : '50% games have acquire time', threshold: 50, metric: 'dataCompleteness', cat: 'loyalty', rarity: 'common' },
{ id: 'loy_data80', icon: _ACH_ICONS.heart, name: isZh ? '数据达人' : 'Data Master', desc: isZh ? '80% 游戏有入库时间' : '80% games have acquire time', threshold: 80, metric: 'dataCompleteness', cat: 'loyalty', rarity: 'rare' },
{ id: 'loy_family', icon: _ACH_ICONS.shield, name: isZh ? '家庭共享' : 'Family Sharing', desc: isZh ? '启用家庭组共享' : 'Family sharing enabled', threshold: 1, metric: 'hasFamily', cat: 'loyalty', rarity: 'common' },
{ id: 'loy_accurate', icon: _ACH_ICONS.diamond, name: isZh ? '精准记录' : 'Precise', desc: isZh ? '95% 游戏有入库时间' : '95% games have acquire time', threshold: 95, metric: 'dataCompleteness', cat: 'loyalty', rarity: 'epic' },
// ===== 特殊成就 (special) — 复合里程碑 =====
{ id: 'sp_night_owl', icon: _ACH_ICONS.moon, name: isZh ? '夜猫子' : 'Night Owl', desc: isZh ? '5000h+ 且拥有 200+ 游戏' : '5000h+ & 200+ games', threshold: 1, metric: 'nightOwl', cat: 'special', rarity: 'epic' },
{ id: 'sp_whale', icon: _ACH_ICONS.diamond, name: isZh ? '巨鲸玩家' : 'Whale', desc: isZh ? '10000h+ 且拥有 500+ 游戏' : '10000h+ & 500+ games', threshold: 1, metric: 'whale', cat: 'special', rarity: 'legendary' },
{ id: 'sp_pioneer', icon: _ACH_ICONS.rocket, name: isZh ? '先锋玩家' : 'Pioneer', desc: isZh ? '拥有游戏且总时长 1000h+' : 'Has games & 1000h+', threshold: 1, metric: 'pioneer', cat: 'special', rarity: 'common' },
{ id: 'sp_liberty', icon: _ACH_ICONS.star, name: isZh ? '自由之魂' : 'Free Spirit', desc: isZh ? '从未游玩的游戏超过 200 款' : '200+ unplayed games', threshold: 200,metric: 'unplayedCount', cat: 'special', rarity: 'rare' },
];
// v2.9.35: 成就解锁缓存 key (GM_setValue 持久化)
const _ACH_CACHE_KEY = 'sglv_ach_unlocks_v2';
// v2.9.35: 读取已解锁成就缓存 { achId: unlockTimestamp }
function _getAchUnlockCache() {
try {
const raw = GM_getValue(_ACH_CACHE_KEY, '{}');
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
return (data && typeof data === 'object') ? data : {};
} catch (e) {
return {};
}
}
// v2.9.35: 写入已解锁成就缓存 (新增解锁时追加时间戳)
function _saveAchUnlockCache(cache) {
try {
GM_setValue(_ACH_CACHE_KEY, JSON.stringify(cache));
} catch (e) { /* 静默失败 */ }
}
// v2.9.35: 统一计算成就指标 — 一次遍历,所有 metric 集中产出
function _computeAchMetrics() {
const allGames = getStatFilteredGames();
const gameCount = allGames.length;
const totalPlaytimeMin = allGames.reduce((s, g) => s + (g.playtime || 0), 0);
const totalHours = Math.floor(totalPlaytimeMin / 60);
const playedGames = allGames.filter(g => (g.playtime || 0) > 0).length;
const unplayedCount = gameCount - playedGames;
const playRate = gameCount > 0 ? Math.round((playedGames / gameCount) * 100) : 0;
const games100h = allGames.filter(g => (g.playtime || 0) >= 6000).length; // 100h = 6000min
const games500h = allGames.filter(g => (g.playtime || 0) >= 30000).length; // 500h = 30000min
const games1kh = allGames.filter(g => (g.playtime || 0) >= 60000).length; // 1000h = 60000min
const now = Date.now();
const recent7d = allGames.filter(g => g.lastPlayed && (now - g.lastPlayed * 1000) <= 7 * 86400000).length > 0 ? 1 : 0;
const recent24h = allGames.filter(g => g.lastPlayed && (now - g.lastPlayed * 1000) <= 86400000).length > 0 ? 1 : 0;
const withAcquired = allGames.filter(g => g.acquiredTime && g.acquiredTime > 0).length;
const dataCompleteness = gameCount > 0 ? Math.round((withAcquired / gameCount) * 100) : 0;
const hasFamily = (state.familyInfo && state.familyInfo.members && state.familyInfo.members.length > 0) ? 1 : 0;
const nightOwl = (totalHours >= 5000 && gameCount >= 200) ? 1 : 0;
const whale = (totalHours >= 10000 && gameCount >= 500) ? 1 : 0;
const pioneer = (gameCount > 0 && totalHours >= 1000) ? 1 : 0;
return {
gameCount, totalHours, playRate, unplayedCount, recent7d, recent24h,
games100h, games500h, games1kh, dataCompleteness, hasFamily,
nightOwl, whale, pioneer,
};
}
// v2.9.35: 计算用户成就数据 — 统一 metrics + 缓存解锁时间戳
function computeMyAchievements() {
const metrics = _computeAchMetrics();
const unlockCache = _getAchUnlockCache();
const now = Date.now();
let cacheDirty = false;
const results = _ACHIEVEMENT_DEFS.map(ach => {
const current = metrics[ach.metric] || 0;
const unlocked = current >= ach.threshold;
const progress = ach.threshold > 0 ? Math.min(100, (current / ach.threshold) * 100) : (unlocked ? 100 : 0);
const pts = _ACH_RARITY[ach.rarity]?.pts || 10;
// 解锁时间戳:新解锁则记录当前时间
let unlockedAt = null;
if (unlocked) {
if (unlockCache[ach.id]) {
unlockedAt = unlockCache[ach.id];
} else {
unlockCache[ach.id] = now;
unlockedAt = now;
cacheDirty = true;
}
}
return { ...ach, current, unlocked, progress, pts, unlockedAt };
});
// 清理已不再解锁的缓存条目(游戏被移除等边界情况)
Object.keys(unlockCache).forEach(id => {
const ach = results.find(a => a.id === id);
if (ach && !ach.unlocked) {
delete unlockCache[id];
cacheDirty = true;
}
});
if (cacheDirty) _saveAchUnlockCache(unlockCache);
return results;
}
// v2.9.35: 格式化解锁时间
function _formatAchTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
// v2.9.35: 格式化相对时间 (如 "3天前")
function _formatRelTime(ts) {
if (!ts) return '';
const diff = Date.now() - ts;
const days = Math.floor(diff / 86400000);
if (days === 0) return isZh ? '今天' : 'Today';
if (days === 1) return isZh ? '昨天' : 'Yesterday';
if (days < 7) return isZh ? `${days}天前` : `${days}d ago`;
if (days < 30) return isZh ? `${Math.floor(days / 7)}周前` : `${Math.floor(days / 7)}w ago`;
if (days < 365) return isZh ? `${Math.floor(days / 30)}月前` : `${Math.floor(days / 30)}mo ago`;
return isZh ? `${Math.floor(days / 365)}年前` : `${Math.floor(days / 365)}y ago`;
}
// ==================== v2.9.36: 我的成就标签页 — 左右布局重构 ====================
// v2.9.50: computeMyAchievementsCached — 走 PCC 持久化,跨 session 复用
function renderMyAchievementsTab(parent) {
const achievements = computeMyAchievementsCached();
const unlocked = achievements.filter(a => a.unlocked);
const totalPts = unlocked.reduce((s, a) => s + a.pts, 0);
const maxPts = achievements.reduce((s, a) => s + a.pts, 0);
const completionRate = achievements.length > 0 ? Math.round((unlocked.length / achievements.length) * 100) : 0;
const selectedCat = state.achSelectedCat || 'all';
// v2.9.98: 获取 Steam 平台徽章/成就数(区别于自定义成就)
let steamBadgeCount = 0;
try {
const sgisProfile = SGLV_API.getSGISProfile && SGLV_API.getSGISProfile();
if (sgisProfile && sgisProfile.userBadges && sgisProfile.userBadges.badges) {
steamBadgeCount = sgisProfile.userBadges.badges.length;
} else if (SGLV_API.getSGISUserBadges) {
const ub = SGLV_API.getSGISUserBadges();
if (ub && ub.badges) steamBadgeCount = ub.badges.length;
}
} catch { /* 静默 */ }
// v2.9.36: 获取用户名(从页面提取)
let userName = isZh ? 'Steam 玩家' : 'Steam Player';
try {
const pulldown = document.querySelector('#account_pulldown');
if (pulldown && pulldown.textContent.trim()) userName = pulldown.textContent.trim();
} catch { /* 静默 */ }
// v2.9.36: 计算成就等级(基于总点数)
const achLevel = Math.floor(totalPts / 100) + 1;
// 环形进度 SVG
const ringR = 38;
const ringCirc = 2 * Math.PI * ringR;
const ringOffset = ringCirc * (1 - completionRate / 100);
// 最近解锁 (按时间倒序,最多 5 个)
const recentUnlocks = unlocked
.filter(a => a.unlockedAt)
.sort((a, b) => b.unlockedAt - a.unlockedAt)
.slice(0, 5);
// 侧边栏最近解锁列表
const sidebarRecentHtml = recentUnlocks.length > 0
? recentUnlocks.map(a => ``).join('')
: ``;
// 分类导航(垂直列表)
const navItems = _ACH_CATEGORIES.map(cat => {
const catAchs = achievements.filter(a => a.cat === cat.id);
const catUnlocked = catAchs.filter(a => a.unlocked).length;
const catTotal = catAchs.length;
const badgeClass = catUnlocked === catTotal ? 'complete' : catUnlocked > 0 ? 'partial' : 'none';
return `
${cat.icon}
${cat.label}
${catUnlocked}/${catTotal}
`;
}).join('');
// "全部" 导航项
const allNavItem = `
${_ACH_ICONS.trophy}
${T.achAllCats}
${unlocked.length}/${achievements.length}
`;
// 成就卡片
const filteredAchs = selectedCat === 'all' ? achievements : achievements.filter(a => a.cat === selectedCat);
const catLabel = selectedCat === 'all' ? T.achAllCats : (_ACH_CATEGORIES.find(c => c.id === selectedCat)?.label || '');
const renderAchCard = (a) => {
const progressVal = a.unlocked ? '100%' : `${a.progress.toFixed(0)}%`;
const currentText = a.unlocked ? T.achUnlocked : `${a.current}/${a.threshold}`;
const unlockTimeHtml = a.unlocked && a.unlockedAt
? `${T.achUnlockedAt} ${_formatAchTime(a.unlockedAt)}
`
: '';
// v2.9.59: NEW 标签 — 24小时内解锁的成就显示 NEW
const isNew = a.unlocked && a.unlockedAt && (Date.now() - a.unlockedAt < 24 * 3600 * 1000);
const newTagHtml = isNew ? `NEW` : '';
// v2.9.59: 稀有度 class 添加到卡片本身(驱动左边框颜色)
return `
${a.name}${newTagHtml}
+${a.pts}pts
${a.desc}
${a.unlocked ? progressVal : currentText}
${unlockTimeHtml}
`;
};
const html = `
${filteredAchs.map(renderAchCard).join('')}
`;
parent.innerHTML = html;
// 分类切换事件
parent.querySelectorAll('.sglv-ach-nav-item').forEach(el => {
el.addEventListener('click', () => {
state.achSelectedCat = el.dataset.cat;
renderMyAchievementsTab(parent);
});
});
// 重置按钮事件
const resetBtn = parent.querySelector('#sglv-ach-reset');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
if (confirm(T.achResetConfirm)) {
GM_setValue(_ACH_CACHE_KEY, '{}');
renderMyAchievementsTab(parent);
}
});
}
}
// ==================== v2.9.90: 个人展柜标签页 ====================
// 设计目标:展示玩家游玩魅力与口味喜好,支持自定义 8 张竖版游戏封面、
// 最近游玩时长、最近解锁成就、玩家画像标签等。
// 布局:顶部英雄区(头像+等级+统计) → 双栏(左:封面网格+时长 / 右:成就+标签)
// 展柜配置存储 key
const _SC_CONFIG_KEY = nsKey('showcase_config');
function _loadShowcaseConfig() {
if (state.showcaseConfigLoaded) return state.showcaseConfig;
state.showcaseConfigLoaded = true;
try {
const raw = GM_getValue(_SC_CONFIG_KEY, '');
if (raw) {
const cfg = JSON.parse(raw);
if (cfg && Array.isArray(cfg.selectedAppIds)) {
// v2.9.94: 兼容 selectedBadgeIds 字段
if (!Array.isArray(cfg.selectedBadgeIds)) cfg.selectedBadgeIds = [];
state.showcaseConfig = cfg;
return cfg;
}
}
} catch (e) { /* 静默 */ }
state.showcaseConfig = { selectedAppIds: [], selectedBadgeIds: [] };
return state.showcaseConfig;
}
function _saveShowcaseConfig(cfg) {
state.showcaseConfig = cfg;
try { GM_setValue(_SC_CONFIG_KEY, JSON.stringify(cfg)); } catch (e) { /* 静默 */ }
// v2.9.99: 配置变更后展柜缓存失效无需显式重置 — _swrReadShowcaseCache 的
// configHash 比对(每次渲染现算 _computeShowcaseCacheHash)自然触发刷新
}
// ==================== v2.9.99: 展柜持久化缓存层 ====================
// 设计目标:跨 session 持久化展柜数据,页面刷新后立即可用,
// 未变化的用户配置(封面/徽章)不失效,配合 SWR 模式后台静默刷新。
//
// 缓存结构: { schemaVer, timestamp, steamId, configHash, data: { profile, recentGames, badges, achievements, stats } }
// configHash = 用户配置签名 + 游戏库规模签名,用于判断缓存是否仍然有效
// v2.9.103: schemaVer 版本化缓存结构,旧结构缓存直接失效重算;
// data.achievements 只存 unlocked 切片(锁定成就不参与展柜渲染,全量写入会撑爆存储配额)
const _SC_DATA_CACHE_KEY = nsKey('showcase_data_cache');
const _SC_DATA_SCHEMA = 1; // v2.9.103: 缓存结构版本(变更 data 结构时 +1)
const _SC_DATA_TTL = 30 * 60 * 1000; // 30 分钟正常 TTL
const _SC_DATA_STALE_TTL = 6 * 60 * 60 * 1000; // 6 小时过期兜底(超过则不使用)
let _scCacheWriteWarned = false; // v2.9.103: 写失败只告警一次,避免刷屏
// v2.9.103: 缓存只保留已解锁成就 — 全量成就(含锁定)大库用户可达数万条,
// 展柜渲染仅消费 unlocked 切片(计数/积分/画像标签/最近解锁),只存切片可显著减小 GM_setValue 体积
function _showcaseUnlockedOnly(achievements) {
return (achievements || []).filter(a => a && a.unlocked);
}
// 计算展柜缓存签名:用户配置 + 游戏库规模
function _computeShowcaseCacheHash() {
const cfg = _loadShowcaseConfig();
const games = getStatFilteredGames();
const coverPart = (cfg.selectedAppIds || []).join(',');
const badgePart = (cfg.selectedBadgeIds || []).map(b => b.appid + ':' + b.badgeid + ':' + (b.isFoil ? '1' : '0')).join(',');
const libPart = games.length + ':' + (games.length > 0 ? Math.floor(games.reduce((s, g) => s + (g.playtime || 0), 0) / 60) : 0);
return [coverPart, badgePart, libPart].join('|');
}
// 从持久化缓存读取展柜数据(不判断 TTL,由调用方决定是否使用)
function _readShowcaseCache() {
try {
const raw = GM_getValue(_SC_DATA_CACHE_KEY, '');
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || !parsed.data || !parsed.timestamp) return null;
if (parsed.schemaVer !== _SC_DATA_SCHEMA) return null; // v2.9.103: 结构版本不匹配直接丢弃
return parsed;
} catch { return null; }
}
// 写入持久化缓存
function _writeShowcaseCache(data, configHash) {
try {
const steamId = getActiveSteamId();
GM_setValue(_SC_DATA_CACHE_KEY, JSON.stringify({
schemaVer: _SC_DATA_SCHEMA,
timestamp: Date.now(),
steamId,
configHash,
data,
}));
} catch (e) {
// v2.9.103: 超配额/序列化失败不再静默 — 告警一次(缓存失败仅影响秒开体验,不影响功能)
if (!_scCacheWriteWarned) {
_scCacheWriteWarned = true;
console.warn('[SGLV] 展柜缓存写入失败(可能超 GM 存储配额),已跳过本次持久化', e);
}
}
}
// SWR 读取:返回 { data, stale } —— stale=true 表示数据已过 TTL 但仍在兜底窗口内
function _swrReadShowcaseCache(configHash) {
const cached = _readShowcaseCache();
if (!cached) return { data: null, stale: false, timestamp: 0 };
// steamId 不匹配则不使用(切换了账号)
const steamId = getActiveSteamId();
if (cached.steamId && steamId && cached.steamId !== steamId) return { data: null, stale: false, timestamp: 0 };
const age = Date.now() - cached.timestamp;
// 超过过期兜底窗口,直接丢弃
if (age > _SC_DATA_STALE_TTL) return { data: null, stale: false, timestamp: 0 };
// configHash 不匹配(用户改了封面/徽章/游戏库变化),数据仍可用但需要刷新
const configChanged = (cached.configHash !== configHash);
const stale = (age > _SC_DATA_TTL) || configChanged;
return { data: cached.data, stale, configChanged, timestamp: cached.timestamp };
}
// 生成数据签名用于比对是否需要重渲染
function _showcaseDataSignature(profile, recentGames, badges, stats) {
const p = profile ? [profile.name, profile.level, profile.badgeCount, profile.country].join(':') : '';
const r = (recentGames || []).map(g => g.appid + ':' + (g.playtime_forever || 0)).join(',');
const b = (badges || []).map(b => b.appid + ':' + b.badgeid + ':' + b.level).join(',');
const s = stats ? [stats.gameCount, stats.totalHours, stats.achCount, stats.totalAchPts].join(':') : '';
return [p, r, b, s].join('|');
}
// 获取默认 8 张封面(游玩时间最长的 8 个游戏)
function _getDefaultCoverAppIds(allGames) {
return allGames
.filter(g => (g.playtime || 0) > 0)
.sort((a, b) => (b.playtime || 0) - (a.playtime || 0))
.slice(0, 8)
.map(g => g.appid);
}
// 获取展柜封面 appid 列表(用户自定义优先,否则默认)
function _getShowcaseCoverAppIds(allGames) {
const cfg = _loadShowcaseConfig();
if (cfg.selectedAppIds && cfg.selectedAppIds.length > 0) {
// 过滤掉不在游戏库中的 appid(可能已移除)
const validSet = new Set(allGames.map(g => g.appid));
const valid = cfg.selectedAppIds.filter(id => validSet.has(id));
if (valid.length > 0) return valid.slice(0, 8);
}
return _getDefaultCoverAppIds(allGames);
}
// 应用封面三级回退链到
元素(参考游戏橱窗 coverImgTag 逻辑)
// Level 1: 成功URL缓存(getPosterGood)—— 避免重复试错
// Level 2: 多CDN直链回退(coverChainUrls)—— cloudflare → akamai → shared.akamai → shared.fastly
// Level 3: Steam Store API 兜底(_fetchCoverApiUrl)—— 获取含 hash 的真实图片URL
// 最终失败: 显示占位符
function _applyCoverChain(imgEl, appid) {
if (!imgEl || !appid) return;
const kind = 'poster';
// Level 1: 检查成功URL缓存
const goodUrl = getPosterGood(appid, kind);
if (goodUrl) {
imgEl.src = goodUrl;
imgEl.onerror = function () {
// 缓存的URL也失败(可能CDN临时不可达),降级到CDN链
_tryCdnChain(imgEl, appid, kind);
};
return;
}
_tryCdnChain(imgEl, appid, kind);
}
// Level 2: 多CDN直链回退
function _tryCdnChain(imgEl, appid, kind) {
const urls = coverChainUrls(appid, kind);
let idx = 0;
imgEl.src = urls[0];
imgEl.onload = function () {
// 记录成功URL,下次直接使用
recordPosterGood(appid, imgEl.src, kind);
imgEl.onload = null;
};
imgEl.onerror = function () {
idx++;
if (idx < urls.length) {
imgEl.src = urls[idx];
} else {
// 所有CDN直链失败,进入 Level 3: API 兜底
imgEl.onload = null;
_tryApiFallback(imgEl, appid, kind);
}
};
}
// Level 3: Steam Store API 获取真实图片URL
// v2.9.105: 传递 kind 参数,使 API 兜底按图片类型选择最佳 URL
function _tryApiFallback(imgEl, appid, kind) {
_fetchCoverApiUrl(appid, kind).then(url => {
if (url) {
// API返回的URL是相对路径或绝对路径,需要处理
const fullUrl = url.startsWith('http')
? url
: `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${url}`;
imgEl.onerror = function () {
// API URL也失败,显示占位
imgEl.onerror = null;
_showCoverPlaceholder(imgEl);
};
imgEl.onload = function () {
recordPosterGood(appid, fullUrl, kind);
imgEl.onload = null;
};
imgEl.src = fullUrl;
} else {
_showCoverPlaceholder(imgEl);
}
}).catch(() => _showCoverPlaceholder(imgEl));
}
// 最终占位符
function _showCoverPlaceholder(imgEl) {
if (!imgEl || !imgEl.parentElement) return;
imgEl.onerror = null;
imgEl.onload = null;
imgEl.style.display = 'none';
const ph = document.createElement('div');
ph.className = 'sglv-sc-cover-placeholder';
ph.textContent = isZh ? '无封面' : 'No Cover';
imgEl.parentElement.appendChild(ph);
}
// 应用游戏图标到
元素(带回退)
function _applyGameIcon(imgEl, appid, iconHash) {
const mainUrl = getGameIconUrl(appid, iconHash);
const fallbackUrl = `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_sm_120.jpg`;
imgEl.src = mainUrl;
imgEl.onerror = function () {
imgEl.onerror = null;
imgEl.src = fallbackUrl;
};
}
// 获取玩家档案(头像、昵称、等级、国家等)
// 参考个人信息面板(SGIS)的数据获取逻辑:
// 1. 优先复用 SGIS 已获取的 profile(含 GetPlayerSummaries + GetBadges 精确数据)
// 2. 回退到 Steam Web API(GetPlayerSummaries)+ 社区页面等级抓取
// 3. 最后回退到当前页面 DOM 提取
async function _fetchShowcaseProfile() {
const TTL = 30 * 60 * 1000; // 30 分钟缓存
if (state.showcaseProfile && (Date.now() - state.showcaseProfileAt) < TTL) {
return state.showcaseProfile;
}
if (state.showcaseProfileLoading) {
return new Promise(resolve => {
const check = setInterval(() => {
if (!state.showcaseProfileLoading) {
clearInterval(check);
resolve(state.showcaseProfile);
}
}, 100);
});
}
state.showcaseProfileLoading = true;
const steamId = getActiveSteamId();
let avatar = '';
let name = isZh ? 'Steam 玩家' : 'Steam Player';
let level = 0;
let country = '';
let profileUrl = '';
let badgeCount = 0;
// 1. 优先复用 SGIS 个人信息面板已获取的档案数据
// SGIS.profile 包含 summary (GetPlayerSummaries)、level、userBadges 等
const sgisProfile = SGLV_API.getSGISProfile && SGLV_API.getSGISProfile();
if (sgisProfile && sgisProfile.summary) {
const s = sgisProfile.summary;
avatar = s.avatarfull || s.avatarmedium || s.avatar || '';
name = s.personaname || name;
country = s.loccountrycode || '';
profileUrl = s.profileurl || '';
// 等级:优先用 GetBadges API 返回的精确值,降级用 fetchSteamLevel
if (sgisProfile.userBadges && sgisProfile.userBadges.playerLevel) {
level = sgisProfile.userBadges.playerLevel;
// 徽章数:从 GetBadges API 返回的 badges 数组获取
if (Array.isArray(sgisProfile.userBadges.badges)) {
badgeCount = sgisProfile.userBadges.badges.length;
}
} else if (sgisProfile.level && typeof sgisProfile.level === 'number') {
level = sgisProfile.level;
}
// 如果 SGIS 数据完整,直接使用
if (avatar && level > 0) {
const profile = { avatar, name, level, country, steamId, profileUrl, badgeCount };
state.showcaseProfile = profile;
state.showcaseProfileAt = Date.now();
state.showcaseProfileLoading = false;
return profile;
}
}
// 2. 回退到 Steam Web API(GetPlayerSummaries)+ 社区页面等级抓取
const apiKey = storage.getApiKey();
if (apiKey && steamId) {
// 并行获取:GetPlayerSummaries + 社区页面等级
const [summary, levelFromCommunity] = await Promise.all([
// GetPlayerSummaries(参考 SGIS fetchPlayerSummaries)
(async () => {
try {
const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${steamId}`;
const data = await requestSteamAPI(url);
const player = data?.response?.players?.[0];
return player || null;
} catch { return null; }
})(),
// 社区页面等级抓取(参考 SGIS fetchSteamLevel)
(async () => {
if (level > 0) return level; // 已有则跳过
try {
const html = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://steamcommunity.com/profiles/${steamId}/`,
timeout: 12000,
onload(r) { resolve(safeResponseText(r)); },
onerror: () => reject(new Error('network')),
ontimeout: () => reject(new Error('timeout')),
});
});
const m = html.match(/"player_level":(\d+)/) || html.match(/class="friendPlayerLevelNum"[^>]*>(\d+)/);
return m ? parseInt(m[1], 10) : 0;
} catch { return 0; }
})(),
]);
if (summary) {
if (!avatar) avatar = summary.avatarfull || summary.avatarmedium || summary.avatar || '';
if (name === (isZh ? 'Steam 玩家' : 'Steam Player')) name = summary.personaname || name;
if (!country) country = summary.loccountrycode || '';
if (!profileUrl) profileUrl = summary.profileurl || '';
}
if (levelFromCommunity > 0) level = levelFromCommunity;
}
// 3. 最后回退到当前页面 DOM 提取(快速、无网络开销)
if (!avatar || !level || !name || name === (isZh ? 'Steam 玩家' : 'Steam Player')) {
try {
const pulldown = document.querySelector('#account_pulldown');
if (pulldown && pulldown.textContent.trim() && (name === (isZh ? 'Steam 玩家' : 'Steam Player'))) {
name = pulldown.textContent.trim();
}
if (!avatar) {
const avatarImg = document.querySelector('#global_actions .user_avatar img, .playerAvatarAutoSizeInner img, .user_avatar img');
if (avatarImg && avatarImg.src) avatar = avatarImg.src;
}
if (!level) {
const levelEl = document.querySelector('.friendPlayerLevelNum');
if (levelEl) {
const lv = parseInt(levelEl.textContent.trim(), 10);
if (!isNaN(lv) && lv > 0) level = lv;
}
}
} catch { /* 静默 */ }
}
// 如果还没有获取到社区页面数据,尝试从社区页面提取昵称和头像
if ((!avatar || !level) && steamId && !apiKey) {
try {
const html = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://steamcommunity.com/profiles/${steamId}/`,
timeout: 10000,
onload(r) { resolve(safeResponseText(r)); },
onerror: () => reject(new Error('network')),
ontimeout: () => reject(new Error('timeout')),
});
});
if (!avatar) {
const avMatch = html.match(/"avatarfull":"([^"]+)"/) || html.match(/class="playerAvatarAutoSizeInner"[^>]*>[\s\S]*?
]+src="([^"]+)"/);
if (avMatch) avatar = avMatch[1];
}
if (!level) {
const lvMatch = html.match(/"player_level":(\d+)/) || html.match(/class="friendPlayerLevelNum"[^>]*>(\d+)/);
if (lvMatch) {
const lv = parseInt(lvMatch[1], 10);
if (!isNaN(lv) && lv > 0) level = lv;
}
}
if (name === (isZh ? 'Steam 玩家' : 'Steam Player')) {
const nmMatch = html.match(/"personaname":"([^"]+)"/);
if (nmMatch) name = nmMatch[1];
}
} catch { /* 静默,使用已有数据 */ }
}
const profile = { avatar, name, level, country, steamId, profileUrl, badgeCount };
state.showcaseProfile = profile;
state.showcaseProfileAt = Date.now();
state.showcaseProfileLoading = false;
return profile;
}
// 获取最近游玩游戏数据(参考个人信息面板 SGIS 的 fetchRecentlyPlayedGames)
// 数据源优先级:
// 1. 复用 SGIS.profile.recentGames(已通过 GetRecentlyPlayedGames API 获取)
// 2. 直接调用 GetRecentlyPlayedGames/v0001/ API
// 3. 回退到本地游戏库 lastPlayed 排序
// 返回统一格式: [{ appid, name, playtime_2weeks, playtime_forever, img_icon_url, iconUrl }]
async function _fetchRecentPlayedGames() {
const steamId = getActiveSteamId();
const gameMap = new Map(getStatFilteredGames().map(g => [g.appid, g]));
// 1. 优先复用 SGIS 个人信息面板已获取的最近游玩数据
const sgisProfile = SGLV_API.getSGISProfile && SGLV_API.getSGISProfile();
if (sgisProfile && Array.isArray(sgisProfile.recentGames) && sgisProfile.recentGames.length > 0) {
return sgisProfile.recentGames.map(g => {
const local = gameMap.get(g.appid);
return {
appid: g.appid,
name: g.name || (local ? local.name : `App ${g.appid}`),
playtime_2weeks: g.playtime_2weeks || 0,
playtime_forever: g.playtime_forever || (local ? local.playtime || 0 : 0),
img_icon_url: g.img_icon_url || '',
iconUrl: g.img_icon_url
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${g.appid}/${g.img_icon_url}.jpg`
: `https://cdn.cloudflare.steamstatic.com/steam/apps/${g.appid}/capsule_sm_120.jpg`,
};
});
}
// 2. 直接调用 GetRecentlyPlayedGames API
const apiKey = storage.getApiKey();
if (apiKey && steamId) {
try {
const url = `https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v0001/?key=${apiKey}&steamid=${steamId}&format=json`;
const data = await requestSteamAPI(url);
const games = data?.response?.games || [];
if (games.length > 0) {
return games.map(g => {
const local = gameMap.get(g.appid);
return {
appid: g.appid,
name: g.name || (local ? local.name : `App ${g.appid}`),
playtime_2weeks: g.playtime_2weeks || 0,
playtime_forever: g.playtime_forever || (local ? local.playtime || 0 : 0),
img_icon_url: g.img_icon_url || '',
iconUrl: g.img_icon_url
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${g.appid}/${g.img_icon_url}.jpg`
: `https://cdn.cloudflare.steamstatic.com/steam/apps/${g.appid}/capsule_sm_120.jpg`,
};
});
}
} catch { /* 静默,回退到本地数据 */ }
}
// 3. 回退到本地游戏库数据(按 lastPlayed 降序)
const allGames = getStatFilteredGames();
return allGames
.filter(g => g.lastPlayed && g.lastPlayed > 0)
.sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0))
.slice(0, 8)
.map(g => ({
appid: g.appid,
name: g.name,
playtime_2weeks: 0,
playtime_forever: g.playtime || 0,
img_icon_url: g.icon || '',
iconUrl: g.icon
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${g.appid}/${g.icon}.jpg`
: `https://cdn.cloudflare.steamstatic.com/steam/apps/${g.appid}/capsule_sm_120.jpg`,
}));
}
// ==================== v2.9.94: 徽章自定义展示 ====================
// 从 Steam 社区徽章页面抓取徽章图标 URL(GetBadges API 不返回图片)
// 参考 steam-badges-card-view-1.5.5 的 DOM 解析策略:
// .badge_info_image img[data-delayed-image] → 已解锁徽章图标
// .badge_empty_circle img[data-delayed-image] → 未解锁徽章图标
// .badge_progress_card.owned img → 降级: 已拥有的卡牌图
// 按 "appid|foil" 映射, 1 小时缓存
const _SC_BADGE_ICONS_KEY = nsKey('showcase_badge_icons');
const _SC_BADGE_ICONS_TTL = 60 * 60 * 1000; // 1 小时
async function _fetchBadgeIcons(steamId) {
if (!steamId) return null;
// 内存缓存
if (state.showcaseBadgeIcons && (Date.now() - state.showcaseBadgeIconsAt) < _SC_BADGE_ICONS_TTL) {
return state.showcaseBadgeIcons;
}
if (state.showcaseBadgeIconsLoading) {
return new Promise(resolve => {
const check = setInterval(() => {
if (!state.showcaseBadgeIconsLoading) {
clearInterval(check);
resolve(state.showcaseBadgeIcons);
}
}, 100);
});
}
state.showcaseBadgeIconsLoading = true;
// 持久化缓存
try {
const cached = GM_getValue(_SC_BADGE_ICONS_KEY, '');
if (cached) {
const parsed = JSON.parse(cached);
if (parsed && parsed.timestamp && (Date.now() - parsed.timestamp < _SC_BADGE_ICONS_TTL)) {
state.showcaseBadgeIcons = parsed.data || {};
state.showcaseBadgeIconsAt = parsed.timestamp;
state.showcaseBadgeIconsLoading = false;
return state.showcaseBadgeIcons;
}
}
} catch { /* 静默 */ }
const icons = {};
try {
const html = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://steamcommunity.com/profiles/${steamId}/badges`,
timeout: 15000,
onload(r) { resolve(safeResponseText(r)); },
onerror: () => reject(new Error('network')),
ontimeout: () => reject(new Error('timeout')),
});
});
if (html && html.length > 500) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const rows = doc.querySelectorAll('.badge_row');
rows.forEach(row => {
try {
// 提取 appid 和 foil 标记
const linkEl = row.querySelector('.badge_row_overlay');
const url = linkEl ? linkEl.href : '';
let appId = '', isFoil = false;
if (url) {
const foilMatch = url.match(/\/gamecards\/foil\/(\d+)/);
if (foilMatch) { appId = foilMatch[1]; isFoil = true; }
else { const norm = url.match(/\/gamecards\/(\d+)/); if (norm) appId = norm[1]; }
}
// 提取徽章图片 (多策略, 参考 steam-badges-card-view)
let iconUrl = '';
const badgeInfoImg = row.querySelector('.badge_info_image img');
if (badgeInfoImg) iconUrl = badgeInfoImg.getAttribute('data-delayed-image') || badgeInfoImg.src || '';
if (!iconUrl) {
const ownedCard = row.querySelector('.badge_progress_card.owned img');
if (ownedCard) iconUrl = ownedCard.getAttribute('data-delayed-image') || ownedCard.src || '';
}
if (!iconUrl) {
const lockedEl = row.querySelector('.badge_empty_circle img');
if (lockedEl) iconUrl = lockedEl.getAttribute('data-delayed-image') || lockedEl.src || '';
}
if (!iconUrl) {
const emptyLeftEl = row.querySelector('.badge_empty_left img');
if (emptyLeftEl) iconUrl = emptyLeftEl.getAttribute('data-delayed-image') || emptyLeftEl.src || '';
}
if (appId && iconUrl && /^https?:\/\//.test(iconUrl)) {
icons[appId + '|' + (isFoil ? '1' : '0')] = iconUrl;
}
} catch { /* 跳过单行解析错误 */ }
});
}
} catch { /* 网络失败, 静默 */ }
state.showcaseBadgeIcons = icons;
state.showcaseBadgeIconsAt = Date.now();
state.showcaseBadgeIconsLoading = false;
// 持久化
try {
GM_setValue(_SC_BADGE_ICONS_KEY, JSON.stringify({ timestamp: Date.now(), data: icons }));
} catch { /* 静默 */ }
return icons;
}
// 合并 GetBadges API 数据 + 社区页面图标数据
// 参考 SGIS enrichBadgesWithGameNames / detectEventBadge / getBadgeIcon 的逻辑
function _mergeBadgeData(badgeData, icons) {
if (!badgeData || !badgeData.badges) return [];
const owned = (state.ownedGames && state.ownedGames.length) ? state.ownedGames : storage.getCachedGames();
const ownedMap = {};
owned.forEach(g => { if (g && g.appid) ownedMap[String(g.appid)] = g.name; });
// 复用卡牌数据库 (SGLV_API.getCardDbMaxLevel / getCardDbGameName)
const getCardDbName = SGLV_API.getCardDbGameName || function() { return ''; };
const getCardDbMaxLv = SGLV_API.getCardDbMaxLevel || function() { return 0; };
const ACTIVITY_CDN = 'https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/items/';
const result = badgeData.badges.map(b => {
const appId = b.appid || 0;
// 游戏名
let gameName = '';
if (appId > 0) {
gameName = ownedMap[String(appId)] || getCardDbName(appId) || '';
}
// 活动徽章检测
let eventName = '';
// 节日活动 appid 映射 (复用 SGIS STEAM_EVENT_APPID_MAP)
const evtMap = SGLV_API.getSteamEventAppIdMap ? SGLV_API.getSteamEventAppIdMap() : null;
if (evtMap && evtMap[String(appId)]) {
eventName = evtMap[String(appId)];
} else if (appId > 3000000 && !gameName) {
eventName = 'Steam 活动';
}
// 徽章类型
let badgeType = 'game';
if (eventName) badgeType = 'event';
else if (b.communityitemid) badgeType = 'community';
// isFoil: badgeid 末位为奇数通常代表 foil (Steam 约定)
const isFoil = !!(b.border_color && b.border_color === 1);
// 图标 URL: 优先社区页面图标, 降级到 getBadgeIcon 三级回退
let iconUrl = '';
if (icons && icons[appId + '|' + (isFoil ? '1' : '0')]) {
iconUrl = icons[appId + '|' + (isFoil ? '1' : '0')];
} else if (icons && icons[appId + '|0']) {
iconUrl = icons[appId + '|0']; // 非foil降级
}
// 降级: 活动徽章用 CDN items 路径
if (!iconUrl && eventName && appId > 0) {
iconUrl = ACTIVITY_CDN + appId + '/icon_64x64.png';
}
// 降级: 游戏徽章用商店胶囊图
if (!iconUrl && appId > 0 && !eventName) {
iconUrl = `https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/${appId}/capsule_184x69.jpg`;
}
return {
appid: appId,
badgeid: b.badgeid,
level: b.level || 0,
xp: b.xp || 0,
scarcity: b.scarcity || 0,
completionTime: b.completion_time || 0,
iconUrl,
gameName,
eventName,
name: gameName || eventName || (b.communityitemid ? '社区徽章 #' + b.badgeid : '游戏 #' + appId),
badgeType,
isFoil,
maxLevel: getCardDbMaxLv(appId),
badgeUrl: appId > 0
? `https://steamcommunity.com/profiles/${getActiveSteamId()}/badges/${appId}`
: '',
};
});
// 按等级降序 + XP 降序排序, 便于选择器展示
result.sort((a, b) => (b.level || 0) - (a.level || 0) || (b.xp || 0) - (a.xp || 0));
return result;
}
// 获取展柜徽章数据 (合并 API + 图标)
async function _getShowcaseBadges() {
// 1. 优先复用 SGIS 已获取的徽章数据
let badgeData = null;
const sgisProfile = SGLV_API.getSGISProfile && SGLV_API.getSGISProfile();
if (sgisProfile && sgisProfile.userBadges) {
badgeData = sgisProfile.userBadges;
}
// 也检查 SGIS.userBadges (可能已由勋章标签页加载)
if (!badgeData && SGLV_API.getSGISUserBadges) {
badgeData = SGLV_API.getSGISUserBadges();
}
// 2. 若无缓存, 直接调用 GetBadges API
if (!badgeData) {
const steamId = getActiveSteamId();
const apiKey = storage.getApiKey();
if (apiKey && steamId) {
try {
const url = `https://api.steampowered.com/IPlayerService/GetBadges/v1/?key=${apiKey}&steamid=${steamId}`;
const data = await requestSteamAPI(url);
badgeData = {
badges: data?.response?.badges || [],
playerLevel: data?.response?.player_level || 0,
};
} catch { /* 静默 */ }
}
}
if (!badgeData || !badgeData.badges || badgeData.badges.length === 0) return [];
// 3. 获取图标
const steamId = getActiveSteamId();
const icons = await _fetchBadgeIcons(steamId);
// 4. 合并
return _mergeBadgeData(badgeData, icons);
}
// 获取用户选择的徽章 (最多 3 个)
function _getSelectedShowcaseBadges(allBadges) {
const cfg = _loadShowcaseConfig();
if (!cfg.selectedBadgeIds || cfg.selectedBadgeIds.length === 0) return [];
return cfg.selectedBadgeIds.map(sel => {
const badge = allBadges.find(b =>
b.appid === sel.appid && b.badgeid === sel.badgeid &&
!!b.isFoil === !!sel.isFoil
);
return badge || null;
}).filter(Boolean);
}
// 徽章选择器弹窗 (复用 _openCoverPicker 的模态框架)
function _openBadgePicker(allBadges, currentSelected, onSave) {
const existing = document.getElementById('sglv-sc-badge-picker-overlay');
if (existing) existing.remove();
const mountTarget = (SGLV_API.getPanelEl && SGLV_API.getPanelEl()) || document.body;
// 分类
const gameBadges = allBadges.filter(b => b.badgeType === 'game');
const eventBadges = allBadges.filter(b => b.badgeType === 'event');
const communityBadges = allBadges.filter(b => b.badgeType === 'community');
let selected = new Set(currentSelected.map(b => b.appid + '|' + b.badgeid + '|' + (b.isFoil ? '1' : '0')));
let currentFilter = 'all';
const overlay = document.createElement('div');
overlay.className = 'sglv-sc-picker-overlay';
overlay.id = 'sglv-sc-badge-picker-overlay';
overlay.innerHTML = `
`;
mountTarget.appendChild(overlay);
const grid = overlay.querySelector('#sglv-sc-badge-grid');
const searchInput = overlay.querySelector('#sglv-sc-badge-search');
const countEl = overlay.querySelector('#sglv-sc-badge-count');
const saveBtn = overlay.querySelector('#sglv-sc-badge-save');
function updateCount() {
countEl.innerHTML = `${selected.size} / 3`;
saveBtn.disabled = selected.size === 0 && currentSelected.length === 0;
}
function renderGrid(filter, search) {
const filterLower = (search || '').toLowerCase().trim();
let list = allBadges;
if (filter === 'game') list = gameBadges;
else if (filter === 'event') list = eventBadges;
else if (filter === 'community') list = communityBadges;
if (filterLower) {
list = list.filter(b =>
(b.name || '').toLowerCase().includes(filterLower) ||
String(b.appid).includes(filterLower)
);
}
// 限制最多 200 个, 避免过多 DOM
list = list.slice(0, 200);
grid.innerHTML = list.map(b => {
const key = b.appid + '|' + b.badgeid + '|' + (b.isFoil ? '1' : '0');
const isSelected = selected.has(key);
const foilTag = b.isFoil ? 'Foil' : '';
const lvlTag = b.level > 0 ? `Lv.${b.level}${b.maxLevel > 0 ? '/' + b.maxLevel : ''}` : '';
return `
${b.iconUrl ? `
})
` : '
' + (b.badgeType === 'event' ? '🎉' : b.badgeType === 'community' ? '★' : '🎮') + '
'}
${escHtml(b.name)}
${foilTag}${lvlTag}${b.xp || 0} XP
✓
`;
}).join('');
grid.querySelectorAll('.sglv-sc-badge-pick-item').forEach(item => {
item.addEventListener('click', () => {
const key = item.dataset.key;
if (selected.has(key)) {
selected.delete(key);
item.classList.remove('selected');
} else {
if (selected.size >= 3) {
sglvToast.warning(isZh ? '最多选择 3 个徽章' : 'Max 3 badges');
return;
}
selected.add(key);
item.classList.add('selected');
}
updateCount();
});
});
}
renderGrid('all', '');
updateCount();
// 搜索
const debouncedSearch = debounce((val) => renderGrid(currentFilter, val), 200);
searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value));
// 分类切换
overlay.querySelectorAll('.sglv-sc-badge-tab').forEach(tab => {
tab.addEventListener('click', () => {
overlay.querySelectorAll('.sglv-sc-badge-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentFilter = tab.dataset.filter;
renderGrid(currentFilter, searchInput.value);
});
});
// 关闭
overlay.querySelector('#sglv-sc-badge-close').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// 保存
saveBtn.addEventListener('click', () => {
// 从 allBadges 中找到选中的徽章
const selectedBadges = [];
selected.forEach(key => {
const [appid, badgeid, foil] = key.split('|');
const badge = allBadges.find(b =>
String(b.appid) === appid && String(b.badgeid) === badgeid &&
!!b.isFoil === (foil === '1')
);
if (badge) selectedBadges.push({
appid: badge.appid, badgeid: badge.badgeid, isFoil: badge.isFoil,
});
});
overlay.remove();
if (onSave) onSave(selectedBadges);
sglvToast.success(isZh ? '展柜徽章已更新' : 'Showcase badges updated');
});
// ESC 关闭
const escHandler = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
setTimeout(() => searchInput.focus(), 100);
}
// v2.9.99: 展柜骨架屏 — 立即展示结构布局,用户无需等待数据加载即可看到页面
function _createShowcaseSkeleton() {
const sk = (w, h, r) =>
``;
return `
`;
}
// v2.9.99: 渲染展柜标签页 — SWR 模式
// 流程: 骨架屏 → 持久化缓存快渲染 → 后台静默刷新 → 签名比对(未变化不重渲染)
async function renderShowcaseTab(parent) {
const myToken = ++state.showcaseRenderToken;
const allGames = getStatFilteredGames();
const configHash = _computeShowcaseCacheHash();
// Step 1: 立即渲染骨架屏(用户可以先看到页面结构)
parent.innerHTML = _createShowcaseSkeleton();
// Step 2: SWR 读取持久化缓存 — 跨 session 复用,页面刷新后立即可用
const swr = _swrReadShowcaseCache(configHash);
if (swr.data && swr.data.profile) {
// 有缓存,立即渲染完整内容(用户先看到上次数据,无白屏)
_renderShowcaseContent(parent, swr.data, allGames);
// 从持久化缓存恢复内存缓存,避免后台刷新时重复网络请求
if (!state.showcaseProfile && swr.data.profile) {
state.showcaseProfile = swr.data.profile;
state.showcaseProfileAt = swr.timestamp || 0;
}
// 缓存未过期且配置未变化 → 无需后台刷新,直接返回
if (!swr.stale && !swr.configChanged) {
state.showcasePreheated = true;
return;
}
}
// Step 3: 后台静默刷新(缓存过期/配置变更/无缓存时获取最新数据)
// v2.9.103: 与预热共享 in-flight 抓取(重复进入只发一轮请求)
const data = await _fetchShowcaseDataAll();
// 防止并发渲染竞态:刷新期间用户切换标签页则放弃本次渲染
if (myToken !== state.showcaseRenderToken) return;
// 统计数据
const { profile, recentGames, badges: allBadges, stats } = data;
// v2.9.103: 缓存只持久化 unlocked 成就切片(全量成就体积大且渲染不消费锁定条目)
const newData = { profile, recentGames, badges: allBadges, achievements: _showcaseUnlockedOnly(data.achievements), stats };
// Step 4: 签名比对 — 数据未变化则保留当前视图(避免闪烁)
const cachedSig = swr.data
? _showcaseDataSignature(swr.data.profile, swr.data.recentGames, swr.data.badges, swr.data.stats)
: '';
const newSig = _showcaseDataSignature(profile, recentGames, allBadges, stats);
if (cachedSig === newSig) {
_writeShowcaseCache(newData, configHash);
state.showcasePreheated = true;
return;
}
// Step 5: 数据有变化,重新渲染 + 持久化缓存
_renderShowcaseContent(parent, newData, allGames);
_writeShowcaseCache(newData, configHash);
state.showcasePreheated = true;
}
// v2.9.99: 展柜内容渲染(从预获取数据构建 HTML + 绑定事件)
function _renderShowcaseContent(parent, data, allGames) {
const { profile, recentGames, badges: allBadges, achievements, stats } = data;
const gameCount = stats ? stats.gameCount : allGames.length;
const totalHours = stats ? stats.totalHours : Math.floor(allGames.reduce((s, g) => s + (g.playtime || 0), 0) / 60);
const achs = achievements || computeMyAchievementsCached();
const unlockedAchs = achs.filter(a => a.unlocked);
const achCount = unlockedAchs.length;
const totalAchPts = unlockedAchs.reduce((s, a) => s + a.pts, 0);
const steamBadgeCount = (stats && stats.steamBadgeCount) || (allBadges && allBadges.length) || profile.badgeCount || 0;
// 封面 appid 列表
const coverAppIds = _getShowcaseCoverAppIds(allGames);
const gameMap = new Map(allGames.map(g => [g.appid, g]));
// 最近游玩游戏(API 已按最近游玩时间排序,取前 6 个)
const recentGamesDisplay = (recentGames || []).slice(0, 6);
const maxRecentPlaytime = recentGamesDisplay.length > 0
? Math.max(...recentGamesDisplay.map(g => g.playtime_forever || 0)) : 0;
// 最近解锁成就(按 unlockedAt 降序,最多 6 个)
const recentAchs = unlockedAchs
.filter(a => a.unlockedAt)
.sort((a, b) => b.unlockedAt - a.unlockedAt)
.slice(0, 6);
// 玩家画像标签(基于已解锁的成就分类)
const unlockedCats = new Set(unlockedAchs.map(a => a.cat));
const tagLabels = {
collector: isZh ? '收藏家' : 'Collector',
playtime: isZh ? '深度玩家' : 'Dedicated',
mastery: isZh ? '完美主义' : 'Perfectionist',
diversity: isZh ? '多元品味' : 'Diverse',
loyalty: isZh ? '忠实粉丝' : 'Loyal',
special: isZh ? '特殊成就' : 'Special',
};
// === 英雄区 HTML ===
const avatarHtml = profile.avatar
? `
`
: `${escHtml(profile.name.charAt(0).toUpperCase())}
`;
const levelBadgeHtml = profile.level > 0
? `Lv.${profile.level}
`
: '';
// 国家/地区标签(使用 Steam 社区国旗 CDN)
const countryHtml = profile.country
? `
${escHtml(profile.country)}
`
: '';
// v2.9.94: === 徽章展示区 HTML ===
// 获取用户选择的徽章(最多 3 个)
const selectedBadges = _getSelectedShowcaseBadges(allBadges);
const badgeSlotsHtml = [];
for (let i = 0; i < 3; i++) {
const badge = selectedBadges[i];
if (badge) {
const lvlText = badge.level > 0
? `Lv.${badge.level}${badge.maxLevel > 0 ? '/' + badge.maxLevel : ''}`
: '';
const foilTag = badge.isFoil ? 'Foil' : '';
badgeSlotsHtml.push(`
${badge.iconUrl
? `
${ICONS.medal}
`
: `
${ICONS.medal}
`
}
${lvlText}
${foilTag}
`);
} else {
badgeSlotsHtml.push(`+
`);
}
}
const badgeAreaHtml = allBadges.length > 0 ? `
${badgeSlotsHtml.join('')}
` : '';
// === 封面网格 HTML ===
const coverItemsHtml = [];
for (let i = 0; i < 8; i++) {
const appid = coverAppIds[i];
if (appid) {
const g = gameMap.get(appid);
const hours = g ? Math.floor((g.playtime || 0) / 60) : 0;
coverItemsHtml.push(`
${escHtml(g ? g.name : '')}
${hours > 0 ? `
${hours}h
` : ''}
`);
} else {
coverItemsHtml.push(``);
}
}
// === 最近游玩时长 HTML ===
const playtimeListHtml = recentGamesDisplay.length > 0
? recentGamesDisplay.map(g => {
const totalHours = Math.floor((g.playtime_forever || 0) / 60);
const recentHours = Math.floor((g.playtime_2weeks || 0) / 60);
const pct = maxRecentPlaytime > 0 ? Math.min(100, ((g.playtime_forever || 0) / maxRecentPlaytime) * 100) : 0;
const recentLabel = g.playtime_2weeks > 0
? `${isZh ? '近2周' : '2w'}: ${recentHours}h`
: '';
const totalLabel = `${isZh ? '总计' : 'Total'}: ${totalHours}h`;
return `
${escHtml(g.name)}
${recentLabel}${recentLabel && totalLabel ? '·' : ''}${totalLabel}
`;
}).join('')
: `${isZh ? '暂无最近游玩记录' : 'No recent playtime'}
`;
// === 最近解锁成就 HTML ===
const achListHtml = recentAchs.length > 0
? recentAchs.map(a => `
${a.icon}
${escHtml(a.name)}
${escHtml(_formatRelTime(a.unlockedAt))}
+${a.pts}
`).join('')
: `${isZh ? '暂无解锁的成就' : 'No unlocked achievements'}
`;
// === 玩家画像标签 HTML ===
const tagsHtml = unlockedCats.size > 0
? Array.from(unlockedCats).map(cat => `${tagLabels[cat] || cat}`).join('')
: `${isZh ? '继续游玩解锁更多标签' : 'Play more to unlock tags'}`;
// === 组装完整 HTML ===
const html = `
`;
parent.innerHTML = html;
// === 后处理:设置封面图片 CDN 回退链 ===
parent.querySelectorAll('img[data-cover-appid]').forEach(img => {
const appid = parseInt(img.dataset.coverAppid, 10);
if (appid) _applyCoverChain(img, appid);
});
// === 后处理:设置游戏图标 ===
parent.querySelectorAll('img[data-icon-appid]').forEach(img => {
const appid = parseInt(img.dataset.iconAppid, 10);
const hash = img.dataset.iconHash || '';
if (appid) _applyGameIcon(img, appid, hash);
});
// === 编辑封面按钮事件 ===
const editBtn = parent.querySelector('#sglv-sc-edit-covers');
if (editBtn) {
editBtn.addEventListener('click', () => {
_openCoverPicker(allGames, coverAppIds, (newAppIds) => {
const cfg = _loadShowcaseConfig();
_saveShowcaseConfig({ selectedAppIds: newAppIds, selectedBadgeIds: cfg.selectedBadgeIds || [] });
renderShowcaseTab(parent);
});
});
}
// v2.9.94: === 编辑徽章按钮事件 ===
const editBadgeBtn = parent.querySelector('#sglv-sc-edit-badges');
if (editBadgeBtn) {
editBadgeBtn.addEventListener('click', () => {
_openBadgePicker(allBadges, selectedBadges, (newBadgeIds) => {
const cfg = _loadShowcaseConfig();
_saveShowcaseConfig({ selectedAppIds: cfg.selectedAppIds || [], selectedBadgeIds: newBadgeIds });
renderShowcaseTab(parent);
});
});
}
// v2.9.94: === 徽章点击跳转 / 空位点击打开选择器 ===
parent.querySelectorAll('.sglv-sc-hero-badge[data-badge-url]').forEach(item => {
item.addEventListener('click', () => {
const url = item.dataset.badgeUrl;
if (url) window.open(url, '_blank');
});
});
parent.querySelectorAll('.sglv-sc-hero-badge-empty').forEach(item => {
item.addEventListener('click', () => {
_openBadgePicker(allBadges, selectedBadges, (newBadgeIds) => {
const cfg = _loadShowcaseConfig();
_saveShowcaseConfig({ selectedAppIds: cfg.selectedAppIds || [], selectedBadgeIds: newBadgeIds });
renderShowcaseTab(parent);
});
});
});
// === 封面点击跳转 ===
parent.querySelectorAll('.sglv-sc-cover-item[data-appid]').forEach(item => {
item.addEventListener('click', () => {
const appid = parseInt(item.dataset.appid, 10);
if (appid) window.open(`https://store.steampowered.com/app/${appid}`, '_blank');
});
});
}
// 封面选择器弹窗(内嵌于游戏库浮窗内,不隐藏浮窗本身)
function _openCoverPicker(allGames, currentSelected, onSave) {
// 移除已有弹窗
const existing = document.getElementById('sglv-sc-picker-overlay');
if (existing) existing.remove();
// 挂载目标:游戏库浮窗面板(position:fixed 作为 absolute 定位上下文)
const mountTarget = (SGLV_API.getPanelEl && SGLV_API.getPanelEl()) || document.body;
// 候选游戏列表(按游玩时间降序,最多展示前 120 个)
const candidates = allGames
.filter(g => (g.playtime || 0) > 0)
.sort((a, b) => (b.playtime || 0) - (a.playtime || 0))
.slice(0, 120);
let selected = new Set(currentSelected);
const overlay = document.createElement('div');
overlay.className = 'sglv-sc-picker-overlay';
overlay.id = 'sglv-sc-picker-overlay';
overlay.innerHTML = `
`;
mountTarget.appendChild(overlay);
const grid = overlay.querySelector('#sglv-sc-picker-grid');
const searchInput = overlay.querySelector('#sglv-sc-picker-search');
const countEl = overlay.querySelector('#sglv-sc-picker-count');
const saveBtn = overlay.querySelector('#sglv-sc-picker-save');
function updateCount() {
countEl.innerHTML = `${selected.size} / 8`;
saveBtn.disabled = selected.size === 0;
}
function renderGrid(filter) {
const filterLower = (filter || '').toLowerCase().trim();
const filtered = filterLower
? candidates.filter(g => (g.name || '').toLowerCase().includes(filterLower) || String(g.appid).includes(filterLower))
: candidates;
grid.innerHTML = filtered.map(g => {
const isSelected = selected.has(g.appid);
const hours = Math.floor((g.playtime || 0) / 60);
return `
✓
${escHtml(g.name)} (${hours}h)
`;
}).join('');
// 设置封面图片
grid.querySelectorAll('img[data-cover-appid]').forEach(img => {
const appid = parseInt(img.dataset.coverAppid, 10);
if (appid) _applyCoverChain(img, appid);
});
// 点击选择/取消
grid.querySelectorAll('.sglv-sc-picker-item').forEach(item => {
item.addEventListener('click', () => {
const appid = parseInt(item.dataset.appid, 10);
if (selected.has(appid)) {
selected.delete(appid);
item.classList.remove('selected');
} else {
if (selected.size >= 8) {
sglvToast.warning(isZh ? '最多选择 8 张封面' : 'Max 8 covers');
return;
}
selected.add(appid);
item.classList.add('selected');
}
updateCount();
});
});
}
renderGrid('');
updateCount();
// 搜索
const debouncedSearch = debounce((val) => renderGrid(val), 200);
searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value));
// 关闭
overlay.querySelector('#sglv-sc-picker-close').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
// 保存
saveBtn.addEventListener('click', () => {
const appIds = Array.from(selected);
overlay.remove();
if (onSave) onSave(appIds);
sglvToast.success(isZh ? '展柜封面已更新' : 'Showcase covers updated');
});
// ESC 关闭
const escHandler = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
// 搜索框聚焦
setTimeout(() => searchInput.focus(), 100);
}
// ==================== 渲染主体 ====================
function renderBody() {
const body = panelEl.querySelector('#sglv-body');
if (!body) { console.warn('[SGLV] #sglv-body 未找到,跳过渲染'); return; }
body.innerHTML = '';
try {
// v2.9.28: 同步设置按钮高亮态(设置视图覆盖展示时点亮 ⚙)
const _setBtn = panelEl.querySelector('.sglv-settings-btn');
if (_setBtn) _setBtn.classList.toggle('sglv-active', !!state.showSettings);
if (state.showSettings) {
// v2.9.28: 设置内容直接覆盖渲染在游戏库浮窗内(不再弹出独立浮层)
renderSettingsView(body);
return;
}
// v2.9.22: 依赖 ownedGames 的标签页(owned/playtime/trend/playdata)在无数据时
// 显示友好的占位骨架而非纯空白——
// - 若正在获取:spinner + 阶段进度提示
// - 若无缓存且未在获取:空状态 + "立即获取 / 打开设置" 引导按钮
// wishlist 标签页自带 loading 骨架,不在此拦截
const _ownedDepTabs = ['owned', 'showcase', 'playtime', 'trend', 'playdata', 'achievements'];
if (_ownedDepTabs.indexOf(state.activeTab) !== -1 && state.ownedGames.length === 0) {
body.appendChild(_buildOwnedEmptyPlaceholder());
return;
}
if (state.activeTab === 'owned') {
renderOwnedTab(body);
} else if (state.activeTab === 'showcase') {
renderShowcaseTab(body);
} else if (state.activeTab === 'playtime') {
renderPlaytimeTab(body);
} else if (state.activeTab === 'trend') {
renderTrendTab(body);
} else if (state.activeTab === 'playdata') {
renderPlaydataTab(body);
} else if (state.activeTab === 'achievements') {
renderMyAchievementsTab(body);
} else if (state.activeTab === 'cloudsave') {
renderCloudSaveTab(body);
} else if (state.activeTab === 'wishlist') {
renderWishlistTab(body);
}
} catch (e) {
// v2.9.109: 渲染失败时显示错误提示,不留空白面板
console.error('[SGLV] renderBody 渲染失败:', e);
body.innerHTML = ``
+ `
${isZh ? '渲染失败' : 'Render failed'}
`
+ `
${(e.message || String(e)).replace(/`
+ `
${isZh ? '请尝试切换其他标签页或刷新页面' : 'Try switching tabs or refresh the page'}
`
+ `
`;
}
}
// v2.9.22: ownedGames 空数据占位骨架
function _buildOwnedEmptyPlaceholder() {
const wrap = document.createElement('div');
wrap.className = 'sglv-body-loading';
const hasKey = !!storage.getApiKey();
const fetching = !!state.isLoading;
if (fetching) {
wrap.innerHTML = `
${isZh ? '正在获取你的游戏库…' : 'Fetching your library…'}
${isZh ? '首次访问需要从 Steam 拉取库存与时长,请稍候片刻。' : 'First-time load: fetching library and playtime from Steam. Please wait.'}
`;
} else {
wrap.innerHTML = `
🎮
${isZh ? '游戏库尚未获取' : 'Library not loaded yet'}
${isZh
? '点击下方按钮从 Steam 拉取你的库存与游戏时长(' + (hasKey ? '已配置 API Key' : '未配置 API Key,将自动尝试无 Key 抓取') + ')。'
: 'Click below to fetch your library & playtime from Steam (' + (hasKey ? 'API Key configured' : 'no API Key, fallback scraping') + ').'}
`;
wrap.querySelector('[data-act="fetch"]').addEventListener('click', () => {
wrap.innerHTML = `
${isZh ? '正在获取你的游戏库…' : 'Fetching your library…'}
${isZh ? '请稍候片刻。' : 'Please wait a moment.'}
`;
startFetch(true);
});
wrap.querySelector('[data-act="settings"]').addEventListener('click', () => {
openGlobalSettings();
});
}
return wrap;
}
// ==================== 趋势标签页 ====================
function renderTrendTab(parent) {
const wrap = document.createElement('div');
wrap.className = 'sglv-trend-wrap';
if (state.ownedGames.length === 0) {
wrap.innerHTML = `${T.noData}
`;
parent.appendChild(wrap);
return;
}
const hasAcquiredTime = state.ownedGames.some(g => g.acquiredTime > 0);
const isEstimated = !hasAcquiredTime;
// ====== 基础数据统计(始终计算)======
const allGames = state.ownedGames;
const totalGames = allGames.length; // 家庭组合并总数
// 获取当前用户 Steam ID
const steamId = getActiveSteamId();
// 筛选仅个人拥有的游戏(基于 owners 字段)
let personalGames = [];
if (steamId) {
personalGames = allGames.filter(g => {
const owners = g.owners || [];
return owners.length === 0 || owners.includes(steamId);
});
} else {
personalGames = allGames; // 无 steamId 时视为全部是自己的
}
const personalTotal = personalGames.length;
// 检测是否有家庭组 owners 数据(用于决定是否显示双曲线)
const hasOwnerData = steamId && allGames.some(g => g.owners && g.owners.length > 0);
// 家庭组其他成员(排除自己,避免主柱与"我的"成员柱视觉重叠)
const otherMembers = [];
if (storage.getShowFamilyShared() && hasOwnerData && steamId) {
const familyInfo = storage.getFamilyInfo();
const nameMap = familyInfo?.steamIdtoName || {};
otherMembers.push(...Object.entries(nameMap)
.filter(([sid]) => sid !== steamId)
.map(([sid, name]) => ({ steamid: sid, name })));
}
// v2.9.50: 一次性计算所有年度统计并走 PCC 持久化缓存(跨 session 复用,避免每次开面板都重算)
// 旧实现:分别调用 computeYearlyStats / computePersonalYearlyStats / computeFamilyMemberCumulativeStats / computeFamilyYearlyStats
// 共 4 次遍历 allGames,大库存(数千款)时 ~10-30ms × 4 ≈ 几十 ms,且每次开面板都重算
// 新实现:一次遍历 + 签名匹配直接走缓存
const _yearlyAll = computeYearlyStatsAllCached(allGames, steamId, otherMembers);
const yearlyStats = _yearlyAll.yearly;
const personalYearlyStats = _yearlyAll.personal;
const familyCumStats = _yearlyAll.familyCum;
const familyYearlyStats = _yearlyAll.familyYearly;
// ====== v2.9.48: 8 个 KPI 卡片分两行显示,增加入库频率维度 ======
const now = new Date();
const thisYear = now.getFullYear();
const yearCount = yearlyStats.years.length;
const thisYearIdx = yearlyStats.years.indexOf(thisYear);
const thisYearCount = thisYearIdx >= 0 ? yearlyStats.counts[thisYearIdx] : 0;
const avgPerYear = yearCount > 0 ? Math.round(yearlyStats.total / yearCount) : 0;
const peakYearIdx = yearlyStats.counts.indexOf(Math.max(...yearlyStats.counts));
const peakYear = yearlyStats.years[peakYearIdx] || '-';
const peakYearCount = yearlyStats.counts[peakYearIdx] || 0;
// 近30天/近90天入库数
const nowTs = Math.floor(now.getTime() / 1000);
const days30 = nowTs - 30 * 86400;
const days90 = nowTs - 90 * 86400;
let recent30 = 0, recent90 = 0;
allGames.forEach(g => {
const t = g.acquiredTime || 0;
if (t <= 0) return;
if (t >= days30) recent30++;
if (t >= days90) recent90++;
});
// 周均入库
// v2.9.48: 活跃天数(有入库记录的独立天数)
const activeDateSet = new Set();
allGames.forEach(g => {
const t = g.acquiredTime || 0;
if (t <= 0) return;
const d = new Date(t * 1000);
activeDateSet.add(d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate());
});
const activeDays = activeDateSet.size;
const kpiSection = document.createElement('div');
kpiSection.className = 'sglv-trend-kpi-wrap';
const firstYear = yearlyStats.years[0] || '-';
const lastYear = yearlyStats.years[yearlyStats.years.length - 1] || '-';
// v2.9.48: 第一行 4 卡 —— 基础统计
const kpiRow1 = `
${personalTotal.toLocaleString()}
${T.kpiMyCollection}
${totalGames.toLocaleString()}
${T.kpiDedupMerge}
${thisYearCount}${T.trendKpiGames}
${thisYear}
${firstYear} ~ ${lastYear}
${lastYear - firstYear + 1} ${T.kpiYearSpan}
`;
// v2.9.48: 第二行 4 卡 —— 频率维度
const kpiRow2 = `
${avgPerYear}${T.trendKpiGames}
${isZh ? '年均入库量' : 'per year'}
${peakYear}
${peakYearCount} ${T.trendKpiGames}
${recent30}${T.trendKpiGames}
${recent90} ${isZh ? '近90天' : '/ 90d'}
${activeDays}${isZh ? '天' : 'days'}
${isZh ? '日均 ' + (activeDays > 0 ? (totalGames / activeDays).toFixed(1) : '0') + ' 款' : (activeDays > 0 ? (totalGames / activeDays).toFixed(1) : '0') + ' / day'}
`;
kpiSection.innerHTML = `${kpiRow1}
${kpiRow2}
`;
// ====== v2.4.3: 入库趋势动态——左侧入库趋势图(不变)+ 右侧入库时间线 ======
// v2.9.22: KPI 紧凑放到左半侧顶部,时间线在右半侧从页面顶部开始撑满整个高度
const splitRow = document.createElement('div');
splitRow.className = 'sglv-trend-split';
// 左:KPI(紧凑 4 列)+ 入库趋势图(年度新增柱 + 家庭成员明细柱 + 累计折线)
const leftCol = document.createElement('div');
leftCol.className = 'sglv-trend-split-left';
leftCol.appendChild(kpiSection);
leftCol.insertAdjacentHTML('beforeend', `
${ICONS.trend} ${T.trendChartTitle}
`);
splitRow.appendChild(leftCol);
// 右:v2.9.48 标签切换(入库时间线 / 入库频率分析)
const rightCol = document.createElement('div');
rightCol.className = 'sglv-trend-split-right';
rightCol.innerHTML = `
${createLoadingHtml(T.trendFreqTitle, 28)}
`;
splitRow.appendChild(rightCol);
wrap.appendChild(splitRow);
// v2.9.49: 使用 requestAnimationFrame 替代 setTimeout,对齐浏览器渲染周期减少卡顿
requestAnimationFrame(() => renderTrendChart(yearlyStats, leftCol.querySelector('#sglv-trend-chart-main'), { isEstimated, familyCumStats, personalYearlyStats, familyYearlyStats, hasOwnerData }));
renderAcquiredTimeline(rightCol, allGames);
// v2.9.48: 右侧标签切换
const freqLoaded = { done: false };
rightCol.querySelectorAll('.sglv-trend-right-tab').forEach(tab => {
tab.addEventListener('click', () => {
rightCol.querySelectorAll('.sglv-trend-right-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const target = tab.dataset.tab;
rightCol.querySelector('#sglv-trend-right-timeline').style.display = target === 'timeline' ? '' : 'none';
rightCol.querySelector('#sglv-trend-right-frequency').style.display = target === 'frequency' ? '' : 'none';
// 懒加载频率分析
if (target === 'frequency' && !freqLoaded.done) {
freqLoaded.done = true;
renderFrequencyAnalysis(rightCol.querySelector('#sglv-trend-freq-content'), rightCol.querySelector('#sglv-trend-freq-loading'), allGames);
}
});
});
if (isEstimated) {
const note = document.createElement('div');
note.className = 'sglv-trend-note';
note.innerHTML = `${isZh ? '提示:当前数据缺少精确入库时间,趋势图基于“最近游玩时间”估算。登录 Steam 商店或获取家庭组数据后可获得更精确结果。' : 'Note: Precise acquisition time is unavailable; trend is estimated from last-played time.'}`;
wrap.appendChild(note);
}
parent.appendChild(wrap);
}
// ====== v2.9.48: 入库频率分析——带缓存的统计计算 + 图表渲染 ======
let _freqStatsCache = null; // { sig, stats, ts }
function computeFreqStats(games) {
const sig = games.length + '|' + (games[0]?.appid || 0);
if (_freqStatsCache && _freqStatsCache.sig === sig) return _freqStatsCache.stats;
const monthData = new Array(12).fill(0); // 0-11 月度
const weekdayData = new Array(7).fill(0); // 0-6 周日~周六
const hourData = new Array(24).fill(0); // 0-23 时段
const dateMap = {}; // 'YYYY-MM-DD' → count
let total = 0;
for (const g of games) {
const t = g.acquiredTime || 0;
if (t <= 0) continue;
const d = new Date(t * 1000);
monthData[d.getMonth()]++;
weekdayData[d.getDay()]++;
hourData[d.getHours()]++;
const dk = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
dateMap[dk] = (dateMap[dk] || 0) + 1;
total++;
}
// 最长连续入库天数
const sortedDates = Object.keys(dateMap).sort();
let maxStreak = 0, curStreak = 0;
let prevDate = null;
for (const ds of sortedDates) {
if (prevDate) {
const diff = (new Date(ds) - new Date(prevDate)) / 86400000;
curStreak = diff === 1 ? curStreak + 1 : 1;
} else {
curStreak = 1;
}
if (curStreak > maxStreak) maxStreak = curStreak;
prevDate = ds;
}
// 峰值月份/星期/时段
const monthNames = isZh
? ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月']
: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const weekdayNames = T.trendFreqWeekdayLong.split(',');
const peakMonth = monthNames[monthData.indexOf(Math.max(...monthData))] || '-';
const peakWeekday = weekdayNames[weekdayData.indexOf(Math.max(...weekdayData))] || '-';
const peakHour = hourData.indexOf(Math.max(...hourData));
const peakHourLabel = peakHour >= 0 ? String(peakHour).padStart(2, '0') + ':00 ~ ' + String((peakHour + 1) % 24).padStart(2, '0') + ':00' : '-';
const stats = {
monthData, weekdayData, hourData,
total, maxStreak,
peakMonth, peakWeekday, peakHourLabel,
peakMonthCount: Math.max(...monthData),
peakWeekdayCount: Math.max(...weekdayData),
peakHourCount: Math.max(...hourData),
monthNames, weekdayNames,
uniqueDays: sortedDates.length,
};
_freqStatsCache = { sig, stats, ts: Date.now() };
return stats;
}
function renderFrequencyAnalysis(contentEl, loadingEl, games) {
// 异步获取入库时间数据(增量更新,复用缓存)
(async () => {
try {
const familyGames = await fetchTimelineFamilyGames();
const acquiredMap = {};
if (familyGames) {
for (const g of familyGames) {
if (g.acquiredTime && g.acquiredTime > 0) acquiredMap[g.appid] = g.acquiredTime;
}
}
// 合并 acquiredTime 到 games
const enriched = games.map(g => ({
...g,
acquiredTime: acquiredMap[g.appid] || g.acquiredTime || 0,
}));
const stats = computeFreqStats(enriched);
const wdNames = stats.weekdayNames;
// 月度柱状图 SVG
const monthMax = Math.max(...stats.monthData, 1);
const monthBars = stats.monthData.map((v, i) => {
const h = v > 0 ? Math.max(2, (v / monthMax) * 100) : 0;
const x = i * (100 / 12) + 1;
const isPeak = v === stats.peakMonthCount && v > 0;
return ``;
}).join('');
// 星期柱状图
const wdMax = Math.max(...stats.weekdayData, 1);
const wdBars = stats.weekdayData.map((v, i) => {
const h = v > 0 ? Math.max(2, (v / wdMax) * 100) : 0;
const x = i * (100 / 7) + 1;
const isPeak = v === stats.peakWeekdayCount && v > 0;
return ``;
}).join('');
// 时段柱状图
const hrMax = Math.max(...stats.hourData, 1);
const hrBars = stats.hourData.map((v, i) => {
const h = v > 0 ? Math.max(2, (v / hrMax) * 100) : 0;
const x = i * (100 / 24) + 0.3;
const isPeak = v === stats.peakHourCount && v > 0;
return ``;
}).join('');
contentEl.innerHTML = `
`;
// 显示内容,隐藏 loading
loadingEl.style.display = 'none';
contentEl.style.display = '';
// v2.9.49: 优化 bar hover tooltip——复用单一 DOM 元素,避免每次 hover 创建/销毁
const freqTooltip = document.createElement('div');
freqTooltip.className = 'sglv-freq-tooltip';
freqTooltip.style.cssText = 'position:fixed;background:rgba(15,23,42,0.96);border:1px solid rgba(102,192,244,0.3);border-radius:6px;padding:4px 10px;font-size:11px;color:#fff;pointer-events:none;z-index:999;display:none;transform:translateX(-50%);';
document.body.appendChild(freqTooltip);
addDisposer(() => freqTooltip.remove());
contentEl.querySelectorAll('.sglv-freq-chart svg rect[data-v]').forEach(rect => {
rect.addEventListener('mouseenter', function() {
const v = this.getAttribute('data-v');
const l = this.getAttribute('data-l');
this.setAttribute('opacity', '0.7');
freqTooltip.textContent = `${l}: ${v} ${T.trendKpiGames}`;
const r = this.getBoundingClientRect();
freqTooltip.style.left = (r.left + r.width / 2) + 'px';
freqTooltip.style.top = (r.top - 28) + 'px';
freqTooltip.style.display = '';
});
rect.addEventListener('mouseleave', function() {
this.setAttribute('opacity', '1');
freqTooltip.style.display = 'none';
});
});
} catch (e) {
console.warn('[SGLV] 频率分析加载失败:', e);
loadingEl.innerHTML = `${T.ptHeatmapNoData}
`;
}
})();
}
function renderAcquiredTimeline(container, games) {
const scrollEl = container.querySelector('#sglv-tl-scroll');
const countEl = container.querySelector('#sglv-tl-count');
const searchEl = container.querySelector('#sglv-tl-search');
const pagiEl = container.querySelector('#sglv-tl-pagination');
if (!scrollEl || !countEl || !searchEl || !pagiEl) return;
const PAGE_SIZE = 20; // 两列 × 10 行
let allItems = null; // null = 加载中
let page = 1;
let query = '';
const fmtDateKey = (ts) => {
const d = new Date(ts * 1000);
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
};
const timeOfDay = (ts) => {
const d = new Date(ts * 1000);
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0');
};
function renderList() {
if (!allItems) { scrollEl.innerHTML = createLoadingHtml(T.ptHeatmapLoading, 28); return; }
const items = query ? allItems.filter(it => it.name.toLowerCase().includes(query)) : allItems;
countEl.textContent = items.length;
if (items.length === 0) {
scrollEl.innerHTML = `${T.ptHeatmapNoData}
`;
pagiEl.innerHTML = '';
return;
}
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
page = Math.min(Math.max(1, page), totalPages);
const pageItems = items.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
let html = '';
let curDate = null;
for (const it of pageItems) {
if (it.dateKey !== curDate) {
curDate = it.dateKey;
html += `
`;
}
html += `
${headerImg(it.appid, it.name)}
${it.name}
${timeOfDay(it.ts)}✓
`;
}
html += '
';
scrollEl.innerHTML = html;
// v2.3.33: 异步加载游戏中文名
scrollEl.querySelectorAll('[data-sglv-appid]').forEach(el => {
loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent);
});
renderPagination(pagiEl, 'sglv-tl', page, totalPages, items.length,
() => { page--; renderList(); },
() => { page++; renderList(); },
(p) => { page = p; renderList(); });
}
// v2.9.49: debounce 搜索输入,避免时间线列表每次按键触发完整重渲染
searchEl.addEventListener('input', debounce(() => {
query = searchEl.value.trim().toLowerCase();
page = 1;
renderList();
}, 200));
renderList(); // 加载占位
(async () => {
try {
const familyGames = await fetchTimelineFamilyGames();
if (!familyGames || familyGames.length === 0) { allItems = []; renderList(); return; }
const acquiredMap = {};
for (const g of familyGames) {
if (g.acquiredTime && g.acquiredTime > 0) acquiredMap[g.appid] = g.acquiredTime;
}
allItems = games
.filter(g => acquiredMap[g.appid])
.map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, ts: acquiredMap[g.appid], dateKey: fmtDateKey(acquiredMap[g.appid]) }))
.sort((a, b) => b.ts - a.ts);
} catch (e) {
console.warn('[SGLV] 入库时间线加载失败:', e);
allItems = [];
}
renderList();
})();
}
function computeYearlyStats(games) {
const map = new Map();
let total = 0;
let maxCount = 0;
games.forEach(g => {
const t = g.acquiredTime || g.lastPlayed || 0;
if (t <= 0) return;
const year = new Date(t * 1000).getFullYear();
const c = (map.get(year) || 0) + 1;
map.set(year, c);
total++;
if (c > maxCount) maxCount = c;
});
if (map.size === 0) return { years: [], counts: [], cumulative: [], total: 0, maxCount: 0, maxCumulative: 0 };
const minYear = Math.min(...map.keys());
const maxYear = Math.max(...map.keys());
const years = [];
const counts = [];
const cumulative = [];
let cum = 0;
for (let y = minYear; y <= maxYear; y++) {
const c = map.get(y) || 0;
cum += c;
years.push(y);
counts.push(c);
cumulative.push(cum);
}
return { years, counts, cumulative, total, maxCount, maxCumulative: cum };
}
// 计算仅个人拥有的游戏年度统计(用于区分"我的"和"家庭组合并")
// refYears: 参考年份范围,确保输出与此对齐
function computePersonalYearlyStats(games, steamId, refYears) {
const map = new Map();
let total = 0;
let maxCount = 0;
games.forEach(g => {
const owners = g.owners || [];
if (owners.length > 0 && !owners.includes(steamId)) return;
const t = g.acquiredTime || g.lastPlayed || 0;
if (t <= 0) return;
const year = new Date(t * 1000).getFullYear();
const c = (map.get(year) || 0) + 1;
map.set(year, c);
total++;
if (c > maxCount) maxCount = c;
});
if (map.size === 0) return { years: [], counts: [], cumulative: [], total: 0, maxCount: 0, maxCumulative: 0 };
// 使用 refYears 作为基准年份范围(与汇总统计对齐),缺失年份补 0
const years = refYears ? [...refYears] : Array.from(map.keys()).sort((a, b) => a - b);
const counts = [];
const cumulative = [];
let cum = 0;
for (const y of years) {
const c = map.get(y) || 0;
cum += c;
counts.push(c);
cumulative.push(cum);
}
return { years, counts, cumulative, total, maxCount, maxCumulative: cum };
}
function computeFamilyMemberCumulativeStats(games, members, years) {
if (!years || years.length === 0 || members.length === 0) return null;
const yearlyMemberCounts = new Map();
const memberIds = new Set(members.map(m => m.steamid));
years.forEach(y => { yearlyMemberCounts.set(y, {}); });
games.forEach(g => {
const t = g.acquiredTime || g.lastPlayed || 0;
if (t <= 0) return;
const owners = g.owners || [];
if (owners.length === 0) return;
const year = new Date(t * 1000).getFullYear();
const entry = yearlyMemberCounts.get(year);
if (!entry) return;
owners.forEach(sid => {
if (memberIds.has(sid)) {
entry[sid] = (entry[sid] || 0) + 1;
}
});
});
// 为每个成员构建年度累计数组
const resultMembers = members.map(m => {
let cum = 0;
const cumulative = years.map(y => {
const entry = yearlyMemberCounts.get(y);
cum += (entry ? (entry[m.steamid] || 0) : 0);
return cum;
});
return { steamid: m.steamid, name: m.name, cumulative };
});
const maxCumulative = Math.max(...resultMembers.map(m => m.cumulative[m.cumulative.length - 1]), 1);
return { years, members: resultMembers, maxCumulative };
}
function computeFamilyYearlyStats(games, members) {
const yearsSet = new Set();
const memberMap = new Map();
members.forEach(m => memberMap.set(m.steamid, { name: m.name, counts: new Map() }));
games.forEach(g => {
const t = g.acquiredTime || g.lastPlayed || 0;
if (t <= 0) return;
const year = new Date(t * 1000).getFullYear();
const owners = g.owners || [];
members.forEach(m => {
if (owners.includes(m.steamid)) {
yearsSet.add(year);
const counts = memberMap.get(m.steamid).counts;
counts.set(year, (counts.get(year) || 0) + 1);
}
});
});
const years = Array.from(yearsSet).sort((a, b) => a - b);
if (years.length === 0) return { years: [], members: [] };
const colors = ['#06cfbe', '#54a0ff', '#ff9f43', '#2ed573', '#ff6b6b', '#a29bfe', '#ffcd56'];
const result = [];
members.forEach((m, i) => {
const data = memberMap.get(m.steamid);
const counts = [];
years.forEach(y => counts.push(data.counts.get(y) || 0));
result.push({ steamid: m.steamid, name: m.name, counts, color: colors[i % colors.length] });
});
return { years, members: result };
}
// ==================== v2.9.50: 持久化版本(基于 PCC) ====================
// 给大数据计算函数加缓存包装,通过输入签名 + schema 版本自动判断是否重算
// 输入签名:游戏数 + acquiredTime 累计 + lastPlayed 累计(避免简单 length 不够,例如库存没变但补全了 acquiredTime)
// schema 版本号:业务逻辑变更时手动 +1 强制全量重算
const _BIZ_SCHEMA_YEARLY = 1;
const _BIZ_SCHEMA_INSIGHT = 1;
const _BIZ_SCHEMA_ACHIEVEMENTS = 1;
// v2.9.50: 生成大数据计算输入签名(基于 ownedGames + familyInfo 关键字段)
function _bizInputSig(games, extra) {
let accSum = 0, lpSum = 0, ptSum = 0;
const n = games ? games.length : 0;
for (let i = 0; i < n; i++) {
const g = games[i];
accSum += (g && g.acquiredTime) || 0;
lpSum += (g && g.lastPlayed) || 0;
ptSum += (g && g.playtime) || 0;
}
return `${n}|${accSum}|${lpSum}|${ptSum}|${extra || ''}`;
}
// v2.9.50: 持久化年度统计(整体 + 个人 + 家庭组)
// 返回 { yearly, personal, familyCum, familyYearly } 一次性计算所有
function computeYearlyStatsAllCached(games, steamId, otherMembers) {
const sig = _bizInputSig(games, `${steamId || ''}|${otherMembers ? otherMembers.length : 0}`);
return getBizCached('biz_yearly_stats_all', _BIZ_SCHEMA_YEARLY, sig, () => {
const yearly = computeYearlyStats(games);
const personal = steamId ? computePersonalYearlyStats(games, steamId, yearly.years) : null;
let familyCum = null;
let familyYearly = null;
if (otherMembers && otherMembers.length > 0 && yearly.years.length > 0) {
familyCum = computeFamilyMemberCumulativeStats(games, otherMembers, yearly.years);
familyYearly = computeFamilyYearlyStats(games, otherMembers);
}
return { yearly, personal, familyCum, familyYearly };
});
}
// v2.9.50: 持久化我的成就标签页(computeMyAchievements)
// 输入:state.ownedGames + familyInfo 关键字段 + DLC 状态(影响 gameCount)
// 注意:我的成就结果依赖 unlockCache(解锁时间),但缓存内只存当前快照
// 真正的解锁时间仍由 _getAchUnlockCache/_saveAchUnlockCache 管理(IDB 持久化)
// 业务结果(每个成就的 current/unlocked/progress/pts)可缓存
function computeMyAchievementsCached() {
const allGames = getStatFilteredGames();
const dlcReady = (SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()) ? '1' : '0';
const sig = _bizInputSig(allGames, `myAch|${dlcReady}`);
return getBizCached('biz_my_achievements', _BIZ_SCHEMA_ACHIEVEMENTS, sig, () => {
return computeMyAchievements();
});
}
// v2.9.50: 完整版洞察数据(从 SGIS 子闭包抽出,主闭包可走 PCC 持久化)
// 包含 5 维度评分 + 玩家画像,逻辑与原 SGIS.computeInsightData 同步
function _computeInsightDataFull() {
const allGames = getStatFilteredGames();
const gameCount = allGames.length;
const totalPlaytimeMin = allGames.reduce((s, g) => s + (g.playtime || 0), 0);
const totalHours = Math.floor(totalPlaytimeMin / 60);
const playedGames = allGames.filter(g => (g.playtime || 0) > 0);
const playedCount = playedGames.length;
const unplayedCount = Math.max(0, gameCount - playedCount);
const avgHours = playedCount > 0 ? Math.round(totalHours / playedCount) : 0;
const over100hCount = allGames.filter(g => (g.playtime || 0) >= 6000).length;
const over500hCount = allGames.filter(g => (g.playtime || 0) >= 30000).length;
const dustRate = gameCount > 0 ? unplayedCount / gameCount : 0;
const longGamesCount = allGames.filter(g => (g.playtime || 0) >= 600).length;
const completionRate = playedCount > 0 ? Math.round((longGamesCount / playedCount) * 100) : 0;
const topGames = allGames.filter(g => (g.playtime || 0) > 0)
.sort((a, b) => (b.playtime || 0) - (a.playtime || 0))
.slice(0, 10)
.map(g => ({ name: g.name, hours: Math.floor((g.playtime || 0) / 60) }));
const recentGames = allGames.filter(g => g.lastPlayed && g.lastPlayed > 0)
.sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0))
.slice(0, 5)
.map(g => ({ name: g.name, hours: Math.floor((g.playtime || 0) / 60), lastPlayed: g.lastPlayed }));
const dustCollectors = allGames.filter(g => !g.playtime || g.playtime === 0)
.slice(0, 5)
.map(g => g.name);
// ── 五维度评分 (0-100) ──
const breadthScore = Math.min(100, Math.round((gameCount / 300) * 100));
const depthScore = Math.min(100, Math.round((totalHours / 5000) * 100));
const completionScore = completionRate;
let diversityScore = 0;
if (playedCount > 0 && totalPlaytimeMin > 0) {
const shares = playedGames.map(g => (g.playtime || 0) / totalPlaytimeMin);
const hhi = shares.reduce((s, x) => s + x * x, 0);
diversityScore = Math.round((1 - hhi) * 100 * Math.min(1, playedCount / 10));
diversityScore = Math.max(0, Math.min(100, diversityScore));
}
const activityScore = Math.round((1 - dustRate) * 100);
const dimensions = [
{ label: '收藏广度', score: breadthScore, tag: breadthScore >= 80 ? '收藏大家' : breadthScore >= 60 ? '中量收藏' : breadthScore >= 40 ? '稳步积累' : '初入Steam', gradient: 'linear-gradient(135deg, #3b82f6, #06cfbe)', desc: `${gameCount} 款游戏 · ${over100hCount} 款超 100h · ${over500hCount} 款超 500h` },
{ label: '沉浸深度', score: depthScore, tag: depthScore >= 80 ? '硬核玩家' : depthScore >= 60 ? '资深玩家' : depthScore >= 40 ? '入门玩家' : '体验为主', gradient: 'linear-gradient(135deg, #06cfbe, #22d3ee)', desc: `总时长 ${totalHours}h · 平均 ${avgHours}h/款 · 最长 ${topGames[0]?.hours || 0}h (${topGames[0]?.name || '—'})` },
{ label: '完成度', score: completionScore, tag: completionScore >= 80 ? '深度游玩' : completionScore >= 60 ? '认真体验' : completionScore >= 40 ? '浅尝辄止' : '走马观花', gradient: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', desc: `${playedCount}/${gameCount} 已启动 · ${longGamesCount} 款深度游玩(≥10h) · 完成率 ${completionRate}%` },
{ label: '偏好多样性', score: diversityScore, tag: diversityScore >= 80 ? '涉猎广泛' : diversityScore >= 60 ? '多元尝试' : diversityScore >= 40 ? '有所偏好' : '专一玩家', gradient: 'linear-gradient(135deg, #f43f5e, #fb7185)', desc: `时长分布集中度 ${diversityScore}% 分散 · Top1 占比 ${topGames[0] && totalHours > 0 ? Math.round((topGames[0].hours / totalHours) * 100) : 0}%` },
{ label: '活跃度', score: activityScore, tag: activityScore >= 80 ? '活跃高玩' : activityScore >= 60 ? '稳定玩家' : activityScore >= 40 ? '间歇游玩' : '吃灰严重', gradient: 'linear-gradient(135deg, #f59e0b, #fbbf24)', desc: `吃灰率 ${(dustRate * 100).toFixed(1)}% · ${unplayedCount} 款未启动 · ${dustCollectors.length ? '如: ' + dustCollectors.slice(0, 2).join('、') : '无'}` },
];
// ── 玩家画像 (本地规则推断) ──
let personaType = '均衡型玩家';
let personaRarity = '常见';
let personaTagline = '游戏风格均衡, 各维度发展稳定';
let personaTraits = [];
const maxDim = dimensions.reduce((a, b) => a.score >= b.score ? a : b);
if (breadthScore >= 75 && depthScore < 50) {
personaType = '收藏型玩家'; personaRarity = '稀有';
personaTagline = '热衷扩充库藏, 拥有海量游戏但未必全部深入';
personaTraits = ['数字收藏家', '促销猎手', 'FOMO 型消费', '广度优先'];
} else if (depthScore >= 75 && breadthScore < 60) {
personaType = '硬核型玩家'; personaRarity = '史诗';
personaTagline = '少量游戏投入海量时长, 追求深度体验与精通';
personaTraits = ['深度沉浸', '反复游玩', '成就猎人', '精通导向'];
} else if (activityScore < 40 && breadthScore >= 60) {
personaType = '吃灰型玩家'; personaRarity = '常见';
personaTagline = '库藏丰富但启动率低, 大量游戏长期未触碰';
personaTraits = ['仓鼠症', '冲动消费', '收藏 > 游玩', '潜力待发掘'];
} else if (diversityScore >= 70 && completionScore >= 60) {
personaType = '探索型玩家'; personaRarity = '稀有';
personaTagline = '涉猎广泛且认真体验, 各类游戏均有深度游玩';
personaTraits = ['多元品味', '开放尝试', '均衡发展', '好奇心强'];
} else if (completionScore >= 75) {
personaType = '完成型玩家'; personaRarity = '史诗';
personaTagline = '注重游戏完成度, 倾向深度通关而非广度收集';
personaTraits = ['完美主义', '通关导向', '深度体验', '宁缺毋滥'];
} else if (breadthScore >= 60 && depthScore >= 60) {
personaType = '全能型玩家'; personaRarity = '传说';
personaTagline = '收藏与深度兼备, 游戏库既广且深';
personaTraits = ['收藏 + 深度', '资深 Steam 用户', '多元 + 专注', '高活跃'];
}
return {
kpi: { gameCount, totalHours, playedCount, unplayedCount, avgHours, over100hCount, over500hCount, dustRate, longGamesCount, completionRate },
topGames, recentGames, dustCollectors,
dimensions,
persona: { type: personaType, rarity: personaRarity, tagline: personaTagline, traits: personaTraits, dominantDim: maxDim.label },
};
}
// v2.9.50: SGIS 子闭包洞察数据走 PCC 持久化(跨 session 复用)
// SGIS 子闭包内的 computeInsightData 直接调用此函数
function computeInsightDataCachedSgis() {
const allGames = getStatFilteredGames();
const dlcReady = (SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady()) ? '1' : '0';
const sig = _bizInputSig(allGames, `sgis_insight|${dlcReady}`);
return getBizCached('biz_sgis_insight_data', _BIZ_SCHEMA_INSIGHT, sig, () => _computeInsightDataFull());
}
function createTrendTooltip(chartEl) {
let tooltip = chartEl.querySelector('.sglv-trend-tooltip');
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.className = 'sglv-trend-tooltip';
chartEl.appendChild(tooltip);
}
return tooltip;
}
// ==================== v2.9.15: SVG 图表基础库(共享命名空间) ====================
// 设计目标:提供最小可用 SVG 工具集,统一风格(深色背景 + 渐变 + 暖色),
// 现有图表(renderTrendChart/Daily/Donut 等)继续工作,新图表/外部模块可基于此扩展。
// 借鉴 steam-friend-manager 1.2.2 大量 SVG 图表代码经验
const SGLVCharts = {
// 通用颜色板(与现有 SGLV 视觉一致)
PALETTE: {
purple: '#a78bfa', blue: '#3b82f6', cyan: '#06b6d4', teal: '#14b8a6',
green: '#22c55e', amber: '#f59e0b', red: '#ef4444', pink: '#ec4899',
gray: '#94a3b8', text: '#cbd5e1', textMuted: '#64748b',
bgGrid: 'rgba(255,255,255,0.06)', bgCard: 'rgba(255,255,255,0.03)'
},
// 通用 SVG 容器生成
svg(w, h, attrs = '') {
return `';
},
// 堆叠条形图
stackedBar(categories, series, options = {}) {
const { width = 600, height = 240, pL = 40, pR = 16, pT = 20, pB = 40, colors = ['#a78bfa', '#38bdf8', '#22c55e', '#f59e0b', '#ec4899'] } = options;
if (!categories.length) return '';
const totals = categories.map((_, i) => series.reduce((s, ser) => s + (Number(ser.data[i]) || 0), 0));
const maxV = Math.max(1, ...totals);
const cW = width - pL - pR, cH = height - pT - pB;
const gap = cW / categories.length;
const barW = Math.min(Math.max(gap * 0.6, 16), 40);
let svg = SGLVCharts.svg(width, height) + SGLVCharts.gridLinesY(pL, width - pR, pT, cH, maxV);
categories.forEach((cat, i) => {
const cx = pL + gap * i + gap / 2;
let yAcc = pT + cH;
series.forEach((ser, sIdx) => {
const v = Number(ser.data[i]) || 0;
if (v <= 0) return;
const h = (v / maxV) * cH;
yAcc -= h;
const col = ser.color || colors[sIdx % colors.length];
svg += ``;
});
svg += `${SGLVCharts.esc(cat)}`;
});
return svg + '';
},
// 圆环/饼图
donutChart(slices, options = {}) {
const { size = 160, thickness = 22, centerText = '' } = options;
const total = slices.reduce((s, sl) => s + (sl.value || 0), 0);
if (total <= 0) return '';
const r = size / 2 - thickness / 2;
const cx = size / 2, cy = size / 2;
const C = 2 * Math.PI * r;
let acc = 0;
let svg = SGLVCharts.svg(size, size);
// 背景环
svg += ``;
slices.forEach(sl => {
if (!sl.value) return;
const len = (sl.value / total) * C;
const offset = -acc;
svg += ``;
acc += len;
});
if (centerText) {
svg += `${SGLVCharts.esc(centerText)}`;
}
return svg + '';
},
// 热力图(行 × 列)
heatmap(matrix, options = {}) {
const { cellSize = 18, gap = 3, xLabels = [], yLabels = [], color = '#38bdf8' } = options;
const rows = matrix.length, cols = matrix[0]?.length || 0;
if (!rows || !cols) return '';
const w = (xLabels.length ? 30 : 0) + cols * (cellSize + gap) - gap + 6;
const h = (yLabels.length ? 16 : 0) + rows * (cellSize + gap) - gap + 4;
let maxV = 0;
matrix.forEach(r => r.forEach(v => { if (v > maxV) maxV = v; }));
let svg = SGLVCharts.svg(w, h);
matrix.forEach((row, ri) => {
row.forEach((v, ci) => {
const x = (xLabels.length ? 30 : 0) + ci * (cellSize + gap);
const y = (yLabels.length ? 16 : 0) + ri * (cellSize + gap);
const r = maxV > 0 ? v / maxV : 0;
svg += `${SGLVCharts.esc(v)}`;
});
});
if (xLabels.length) {
const step = Math.max(1, Math.floor(xLabels.length / 12));
xLabels.forEach((lbl, i) => {
if (i % step !== 0) return;
const x = 30 + i * (cellSize + gap) + cellSize / 2;
svg += `${SGLVCharts.esc(lbl)}`;
});
}
if (yLabels.length) {
yLabels.forEach((lbl, ri) => {
const y = 16 + ri * (cellSize + gap) + cellSize / 2 + 3;
svg += `${SGLVCharts.esc(lbl)}`;
});
}
return svg + '';
}
};
// v2.9.15: 暴露给 SGIS / Collection 库复用
unsafeWindow.SGLVCharts = SGLVCharts;
function renderTrendChart(stats, container, options = {}) {
if (!stats.years.length) {
container.innerHTML = `${isZh ? '无时间数据' : 'No time data'}
`;
return;
}
const { isEstimated = false, familyCumStats = null, personalYearlyStats = null, familyYearlyStats = null, hasOwnerData = false } = options;
// 主柱数据:家庭组开启时显示"我的年度新增",否则显示总年度新增
const hasPersonalBar = hasOwnerData && personalYearlyStats && personalYearlyStats.years.length > 0
&& personalYearlyStats.cumulative.length === stats.years.length;
const mainCounts = hasPersonalBar ? personalYearlyStats.counts : stats.counts;
// SVG 布局参数:合并后留出顶部图例空间,整体更紧凑
const svgW = 800, svgH = 440;
const pL = 52, pR = 62, pT = 20, pB = 50;
const cW = svgW - pL - pR, cH = svgH - pT - pB;
const n = stats.years.length;
const gap = cW / n;
// 成员颜色(参考 steam-family-game-analysis)
const memberColors = ['#06cfbe', '#54a0ff', '#ff9f43', '#2ed573', '#ff6b6b', '#a29bfe'];
// 判断是否有家庭成员柱状图数据
const hasMemberBars = familyYearlyStats && familyYearlyStats.years.length > 0
&& familyYearlyStats.members.length > 0;
// 分组柱状图尺寸:每组最多 1 根主柱(年度总新增) + N 根成员细柱
const memberCount = hasMemberBars ? familyYearlyStats.members.length : 0;
const groupCount = hasMemberBars ? (1 + memberCount) : 1;
const groupGap = Math.max(2, gap * 0.12); // 组内柱间隙
// 组内总宽度占年份间距的 0.78;主柱稍宽,成员细柱窄
const groupTotalW = Math.min(gap * 0.78, groupCount * 14 + (groupCount - 1) * groupGap);
const mainBarW = hasMemberBars ? Math.max(10, groupTotalW * 0.32) : Math.min(gap * 0.55, 38);
const memberBarW = hasMemberBars ? Math.max(3, (groupTotalW - mainBarW - groupGap) / memberCount) : 0;
const allBarsTotalW = hasMemberBars ? (mainBarW + groupGap + memberBarW * memberCount) : mainBarW;
// Y 轴范围:需要考虑柱状最大值(主柱 + 成员最大柱)和折线最大值
// 主柱最大值
let mainMax = Math.max(...mainCounts, 1);
// 成员柱最大值(取所有成员各年新增的最大值)
let memberMax = 0;
if (hasMemberBars) {
familyYearlyStats.members.forEach(m => {
m.counts.forEach(c => { if (c > memberMax) memberMax = c; });
});
}
const maxCount = Math.max(mainMax, memberMax, 1);
const myMaxCumulative = stats.maxCumulative || 1;
const familyMaxCum = familyCumStats?.maxCumulative || 0;
const personalMaxCum = (personalYearlyStats && personalYearlyStats.years.length > 0) ? personalYearlyStats.maxCumulative : 0;
const maxCumulative = Math.max(myMaxCumulative, familyMaxCum, personalMaxCum) || 1;
const hasPersonalLine = personalYearlyStats && personalYearlyStats.years.length > 0
&& personalYearlyStats.cumulative.length === n;
const hasFamilyLines = familyCumStats && familyCumStats.members.length > 0
&& familyCumStats.members.every(m => m.cumulative.length === n);
// 网格 + 左右轴刻度
let grid = '';
const gridCount = 5;
for (let i = 0; i <= gridCount; i++) {
const v = Math.round(maxCount / gridCount * i);
const y = pT + cH - (cH * i / gridCount);
grid += ``;
grid += `${v}`;
const v2 = Math.round(maxCumulative / gridCount * i);
grid += `${v2}`;
}
// 柱状图:每年绘制主柱(我的年度新增 或 总年度新增) + 各家庭成员细柱
let bars = '';
stats.years.forEach((year, i) => {
const cx = pL + gap * i + gap / 2;
const groupStartX = cx - allBarsTotalW / 2;
const mainCount = mainCounts[i] || 0;
const mainH = maxCount ? (mainCount / maxCount) * cH : 0;
const mainY = pT + cH - mainH;
// 收集各成员当年累计值、累计、成员柱数据用于 tooltip
const memberData = hasFamilyLines ? familyCumStats.members.map(m => `${m.name}:${m.cumulative[i]}`).join('|') : '';
const personalCumVal = hasPersonalLine ? personalYearlyStats.cumulative[i] : '';
const familyTotalCumVal = stats.cumulative[i];
// 收集各成员当年新增(用于 tooltip)
const memberBarPairs = hasMemberBars
? familyYearlyStats.members.map(m => `${m.name}:${(familyYearlyStats.years.includes(year) ? (m.counts[familyYearlyStats.years.indexOf(year)] || 0) : 0)}`).join('|')
: '';
// 主柱(年度总新增)
bars += ``;
// 成员细柱(家庭组开启时)
if (hasMemberBars) {
const memberStartX = groupStartX + mainBarW + groupGap;
familyYearlyStats.members.forEach((m, mi) => {
if (!familyYearlyStats.years.includes(year)) return;
const yearIdx = familyYearlyStats.years.indexOf(year);
const c = m.counts[yearIdx] || 0;
const h = maxCount ? (c / maxCount) * cH : 0;
const x = memberStartX + mi * memberBarW;
const y = pT + cH - h;
bars += ``;
});
}
});
let linesHtml = '';
if (hasPersonalLine) {
// === 家庭组开启时:绘制双曲线 ===
// 1. 个人累计曲线(绿色实线 #22c55e)
const personalPoints = stats.years.map((_, i) => {
const x = pL + gap * i + gap / 2;
const y = pT + cH - (maxCumulative ? (personalYearlyStats.cumulative[i] / maxCumulative) * cH : 0);
return `${x},${y}`;
}).join(' ');
linesHtml += ``;
stats.years.forEach((_, i) => {
const x = pL + gap * i + gap / 2;
const y = pT + cH - (maxCumulative ? (personalYearlyStats.cumulative[i] / maxCumulative) * cH : 0);
linesHtml += ``;
});
// 2. 家庭组合并累计曲线(蓝紫色实线 #818cf8)
const totalPoints = stats.years.map((_, i) => {
const x = pL + gap * i + gap / 2;
const y = pT + cH - (maxCumulative ? (stats.cumulative[i] / maxCumulative) * cH : 0);
return `${x},${y}`;
}).join(' ');
linesHtml += ``;
stats.years.forEach((_, i) => {
const x = pL + gap * i + gap / 2;
const y = pT + cH - (maxCumulative ? (stats.cumulative[i] / maxCumulative) * cH : 0);
linesHtml += ``;
});
} else {
// === 非家庭组模式:保持原有单曲线逻辑 ===
// 我的累计曲线(绿色实线)
const myPoints = stats.years.map((_, i) => {
const x = pL + gap * i + gap / 2;
const y = pT + cH - (maxCumulative ? (stats.cumulative[i] / maxCumulative) * cH : 0);
return `${x},${y}`;
}).join(' ');
linesHtml += ``;
stats.years.forEach((_, i) => {
const x = pL + gap * i + gap / 2;
const y = pT + cH - (maxCumulative ? (stats.cumulative[i] / maxCumulative) * cH : 0);
linesHtml += ``;
});
}
// 家庭组成员累计曲线(彩色半透明,参考 steam-family-game-analysis)
if (hasFamilyLines) {
familyCumStats.members.forEach((m, mi) => {
const color = memberColors[mi % memberColors.length];
const pts = m.cumulative.map((val, i) => {
const x = pL + gap * i + gap / 2;
const y = pT + cH - (maxCumulative ? (val / maxCumulative) * cH : 0);
return `${x},${y}`;
}).join(' ');
linesHtml += ``;
m.cumulative.forEach((_, i) => {
const x = pL + gap * i + gap / 2;
const y = pT + cH - (maxCumulative ? (m.cumulative[i] / maxCumulative) * cH : 0);
linesHtml += ``;
});
});
}
// X 轴标签
let xLabels = '';
stats.years.forEach((year, i) => {
const x = pL + gap * i + gap / 2;
xLabels += `${year}`;
});
const baseLine = ``;
const svg = ``;
// 图例
let legendItems = `
${isZh ? (hasPersonalBar ? '我的年度新增' : '年度新增') : (hasPersonalBar ? 'My new this year' : 'New this year')}
`;
if (hasMemberBars) {
familyYearlyStats.members.forEach((m, mi) => {
// v2.8.3: 图例项变为可交互——hover 高亮对应成员柱,其余变暗
legendItems += `${m.name}
`;
});
}
if (hasPersonalLine) {
// 家庭组开启时:显示个人累计 + 家庭组合并累计
legendItems += `${isZh ? '我的累计' : 'My cumulative'}
`;
legendItems += `${isZh ? '家庭组合并' : 'Family total'}
`;
} else {
// 非家庭组模式
legendItems += `${isZh ? '我的累计' : 'My cumulative'}
`;
}
const legend = `${legendItems}
`;
const summary = `
${isZh ? '总计' : 'Total'}: ${stats.total}
${isZh ? '年份' : 'Years'}: ${stats.years.length}
${isZh ? '最早' : 'First'}: ${stats.years[0]}
${isZh ? '最新' : 'Latest'}: ${stats.years[stats.years.length - 1]}
`;
// v2.8.3: 成员对比摘要 — 贡献最多 / 最少 / 平均
let compareSummary = '';
if (hasMemberBars && familyYearlyStats.members.length > 0) {
const memberTotals = familyYearlyStats.members.map(m => ({
name: m.name, count: m.counts.reduce((s, c) => s + c, 0)
})).sort((a, b) => b.count - a.count);
const topM = memberTotals[0] || { name: '-', count: 0 };
const lowM = memberTotals[memberTotals.length - 1] || { name: '-', count: 0 };
const avgCnt = memberTotals.length > 0 ? Math.round(memberTotals.reduce((s, x) => s + x.count, 0) / memberTotals.length) : 0;
compareSummary = ``
+ `${isZh ? '贡献最多' : 'Top'}: ${topM.name} (${topM.count})`
+ `${isZh ? '平均' : 'Avg'}: ${avgCnt}`
+ `${isZh ? '贡献最少' : 'Low'}: ${lowM.name} (${lowM.count})`
+ `
`;
}
container.innerHTML = svg + legend + compareSummary + summary;
// v2.8.3: 图例交互 — hover 高亮对应成员柱,其余变暗
// v2.9.49: 缓存 memberBars 引用,避免每次 hover 都 querySelectorAll 扫描 DOM
if (hasMemberBars) {
const memberBars = container.querySelectorAll('.sglv-trend-bar-member');
container.querySelectorAll('.sglv-trend-legend-interactive').forEach(el => {
el.addEventListener('mouseenter', function() {
const mi = this.dataset.mi;
memberBars.forEach(bar => {
bar.style.opacity = bar.dataset.member === familyYearlyStats.members[mi].name ? '1' : '.1';
});
});
el.addEventListener('mouseleave', function() {
memberBars.forEach(bar => { bar.style.opacity = '0.85'; });
});
});
}
// Hover tooltip(主柱触发)
const tooltip = createTrendTooltip(container);
container.querySelectorAll('.sglv-trend-bar').forEach(bar => {
bar.addEventListener('mouseenter', () => {
const year = bar.dataset.year;
const count = bar.dataset.count;
const cum = bar.dataset.cum;
const membersStr = bar.dataset.members || '';
const memberBarsStr = bar.dataset.memberBars || '';
const rect = bar.getBoundingClientRect();
const chartRect = container.getBoundingClientRect();
// 成员柱明细(当年新增)
let memberBarRows = '';
if (memberBarsStr && hasMemberBars) {
memberBarsStr.split('|').forEach(pair => {
const [name, val] = pair.split(':');
if (!name) return;
const m = familyYearlyStats.members.find(mm => mm.name === name);
const color = m ? m.color : '#888';
const num = parseInt(val, 10) || 0;
memberBarRows += `${name}${num}
`;
});
}
// 成员累计行
let memberRows = '';
if (membersStr && hasFamilyLines) {
membersStr.split('|').forEach(pair => {
const [name, val] = pair.split(':');
if (!name || !val) return;
const idx = familyCumStats.members.findIndex(m => m.name === name);
const color = memberColors[idx % memberColors.length];
memberRows += `${name} (${isZh ? '累计' : 'cum.'})${val}
`;
});
}
// 构建累计行
let cumRows = '';
if (hasPersonalLine) {
const personalCumVal = bar.dataset.personalCum || '';
cumRows += `${isZh ? '我的累计' : 'My cum.'}${personalCumVal}
`;
cumRows += `${isZh ? '家庭组合并' : 'Family total'}${cum}
`;
} else {
cumRows += `${isZh ? '累计' : 'Cumulative'}${cum}
`;
}
tooltip.innerHTML = `
${year}
${isZh ? (hasPersonalBar ? '我的新增' : '年度新增') : (hasPersonalBar ? 'My new' : 'New')}${count}
${memberBarRows ? `${isZh ? '各成员新增' : 'Per member'}
${memberBarRows}` : ''}
${cumRows}
${memberRows ? `${isZh ? '各成员累计' : 'Member cumulative'}
${memberRows}` : ''}
`;
tooltip.style.opacity = '1';
const left = rect.left - chartRect.left + rect.width / 2 - tooltip.offsetWidth / 2;
const top = rect.top - chartRect.top - tooltip.offsetHeight - 8;
tooltip.style.left = `${Math.max(0, left)}px`;
tooltip.style.top = `${Math.max(0, top)}px`;
});
bar.addEventListener('mouseleave', () => { tooltip.style.opacity = '0'; });
});
}
// ==================== 设置面板 ====================
// ==================== v2.9.28: 设置集成进游戏库主面板(移除独立浮层) ====================
// 点击 header 的 ⚙ 设置按钮 → 设置视图在 #sglv-body 内覆盖展示(完整嵌入浮窗),
// 再次点击 ⚙、点击"返回游戏库"或切换任意标签页即可退出设置。
function openGlobalSettings() {
if (!panelEl) return;
// 保留原函数名兼容既有调用点:先确保主面板可见,再在面板内展示设置
openPanel();
state.showSettings = true;
renderBody();
}
function closeGlobalSettings() {
if (!state.showSettings) return;
state.showSettings = false;
renderBody();
}
// v2.9.28: 未配置 API Key 引导提示——右下角卡片。
// 形态参考 GM_notification 的右下角弹窗通知(见 GM_Docs/油猴脚本常见API权限.md),
// 但 GM_notification 需系统通知授权且点击后无法直接操作页面,故用页面内自绘卡片实现,
// 点击"打开设置"可直接唤起面板内设置视图;不自动弹出面板/设置,由用户主动进入。
function showApiKeyGuideToast() {
if (document.getElementById('sglv-guide-toast')) return;
const el = document.createElement('div');
el.id = 'sglv-guide-toast';
el.className = 'sglv-guide-toast';
el.innerHTML = `
${ICONS.shield} ${T.apiKeyGuideTitle}
${T.apiKeyGuideDesc}
`;
document.body.appendChild(el);
let _timer = setTimeout(dismiss, 12000);
function dismiss() {
clearTimeout(_timer);
el.style.transition = 'opacity 0.25s, transform 0.25s';
el.style.opacity = '0';
el.style.transform = 'translateX(24px)';
setTimeout(() => { try { el.remove(); } catch (e) {} }, 260);
}
el.querySelector('.sglv-guide-toast-close').addEventListener('click', dismiss);
el.querySelector('.sglv-guide-toast-btn.ghost').addEventListener('click', dismiss);
el.querySelector('.sglv-guide-toast-btn.primary').addEventListener('click', () => {
dismiss();
openGlobalSettings();
});
}
// v2.9.28: 设置视图渲染——卡片式分区布局,完整嵌入游戏库浮窗 #sglv-body
function renderSettingsView(body) {
const wrap = document.createElement('div');
wrap.className = 'sglv-set-wrap';
wrap.innerHTML = `
${ICONS.settings} ${T.globalSettings}
${ICONS.dollar}${T.aiPredictSection}
${T.aiPredictModesHelp}
${ICONS.dollar}${T.itadSection}
${storage.getItadApiKey() ? '● ' + (isZh ? '已配置' : 'Configured') : '○ ' + T.itadConnNoKey}
${T.itadDesc}
📖 ${T.itadHelpTitle}
${T.itadHelpSteps}
${ICONS.shield}${isZh ? '显示偏好' : 'Display Preferences'}
${isZh ? '控制游戏库浮窗中是否显示家庭组共享的游戏。' : 'Toggle whether family-shared games are shown in the library panel.'}
`;
wrap.querySelector('.sglv-set-back').addEventListener('click', closeGlobalSettings);
wrap.querySelector('#sglv-gs-save').addEventListener('click', () => {
storage.setApiKey(wrap.querySelector('#sglv-gs-apikey').value.trim());
storage.setSteamId(wrap.querySelector('#sglv-gs-steamid').value.trim());
storage.setAiApiUrl(wrap.querySelector('#sglv-gs-ai-url').value.trim() || 'https://api.deepseek.com/v1/chat/completions');
storage.setAiApiKey(wrap.querySelector('#sglv-gs-ai-key').value.trim());
storage.setAiModel(wrap.querySelector('#sglv-gs-ai-model').value.trim() || 'deepseek-v4-pro');
// 保存预测模式
const checkedModes = Array.from(wrap.querySelectorAll('#sglv-gs-predict-modes input[type="checkbox"]:checked')).map(cb => cb.value);
storage.setPredictModes(checkedModes.length ? checkedModes : ['seasonal', 'trend', 'lowest']);
// v2.9.46: 保存家庭组共享开关
storage.setShowFamilyShared(wrap.querySelector('#sglv-gs-family-toggle').checked);
// v2.9.79: 保存 ITAD API Key 并同步到库
const itadKey = wrap.querySelector('#sglv-gs-itad-key').value.trim();
storage.setItadApiKey(itadKey);
if (window.SGLVITAD) window.SGLVITAD.setApiKey(itadKey || ITAD_API_KEY_FALLBACK);
_activeSteamId = null; // Invalidate cache!
clearComputedCache();
const status = wrap.querySelector('#sglv-gs-status');
status.textContent = '✅ ' + T.saved;
setTimeout(() => { status.textContent = ''; closeGlobalSettings(); }, 800);
});
// v2.9.79: ITAD 测试连接按钮
const itadTestBtn = wrap.querySelector('#sglv-gs-itad-test');
if (itadTestBtn) {
itadTestBtn.addEventListener('click', async () => {
const keyInput = wrap.querySelector('#sglv-gs-itad-key');
const resultEl = wrap.querySelector('#sglv-gs-itad-test-result');
const testKey = keyInput.value.trim();
if (!testKey) {
resultEl.innerHTML = '⚠ ' + T.itadConnFail + ': Key 为空';
return;
}
itadTestBtn.disabled = true;
itadTestBtn.textContent = T.itadTesting;
resultEl.innerHTML = '' + T.itadTesting + '';
try {
const result = await window.SGLVITAD.testConnection(testKey);
if (result.success) {
resultEl.innerHTML = '✅ ' + T.itadConnSuccess + ' · ' + (isZh ? '商店数' : 'Shops') + ': ' + result.shopCount + '';
} else {
resultEl.innerHTML = '❌ ' + T.itadConnFail + ': ' + result.error + '';
}
} catch (e) {
resultEl.innerHTML = '❌ ' + T.itadConnFail + ': ' + e.message + '';
} finally {
itadTestBtn.disabled = false;
itadTestBtn.textContent = T.itadTestConn;
}
});
}
body.appendChild(wrap);
}
// ==================== v2.9.4: Barter.vg Bundle 数据库 (进包历史·离线程动态加载) ====================
// 数据源: https://bartervg.com/browse/bundles/json/
// 返回格式: {"18500":{"bundles":5,"bundles_packages":3},"232810":{"bundles":1},...}
// Key 是 appID 字符串, value.bundles = 进过的包数量
// v2.9.4: 原始 JSON 达数 MB,主线程 JSON.parse / GM_setValue 大对象会卡顿 UI。
// 改为:① 获取原始文本(不解析)→ ② Web Worker 离线程解析并按库存 appId 裁剪
// → ③ 仅缓存精简映射 {appId: 进包次数}(体积小,GM_setValue 毫秒级)。
// Worker 不可用(CSP 等)时回退主线程分片扫描(每片 ≤12ms,让出主线程)。
const BUNDLE_DB_API_URL = 'https://bartervg.com/browse/bundles/json/';
// v2.9.15: 使用缓存版本号系统自动管理 schema 升级
const BUNDLE_DB_CACHE_KEY = 'sglv_bundle_db_cache'; // 旧版大体积缓存(废弃,加载成功后清空)
const BUNDLE_DB_CACHE_KEY_V2 = nsKey('bundle_db_cache_v2'); // { data, appIds, all, timestamp }
const BUNDLE_DB_NEG_KEY = nsKey('bundle_db_neg'); // 负缓存 key
const BUNDLE_DB_NEG_TTL = 30 * 60 * 1000; // 负缓存 30 分钟
const BUNDLE_DB_CACHE_TTL = 48 * 60 * 60 * 1000; // 48小时
let bundleDbData = null; // { "appId": 进包次数 }(仅覆盖库存内 appId)
let bundleDbLoading = false;
function getBundleCount(appId) {
if (!bundleDbData || !appId) return 0;
const entry = bundleDbData[String(appId)];
if (!entry) return 0;
return typeof entry === 'number' ? entry : (entry.bundles || 0);
}
// Web Worker 源码(ES5):解析大 JSON 并按 wanted 裁剪,仅回传精简计数映射
const BUNDLE_PARSE_WORKER_SRC =
'self.onmessage = function(e) {\n' +
' var text = e.data.text;\n' +
' var wanted = (e.data.wanted && e.data.wanted.length) ? new Set(e.data.wanted) : null;\n' +
' try {\n' +
' var json = JSON.parse(text);\n' +
' var keys = Object.keys(json);\n' +
' var out = {};\n' +
' for (var i = 0; i < keys.length; i++) {\n' +
' var k = keys[i];\n' +
' if (wanted && !wanted.has(k)) continue;\n' +
' var entry = json[k];\n' +
' var n = (entry && entry.bundles) ? entry.bundles : 0;\n' +
' if (n > 0) out[k] = n;\n' +
' }\n' +
' self.postMessage({ ok: true, total: keys.length, data: out });\n' +
' } catch (err) {\n' +
' self.postMessage({ ok: false, error: String(err) });\n' +
' }\n' +
'};';
function parseBundleJsonInWorker(text, wantedAppIds, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
let worker = null;
try {
const blob = new Blob([BUNDLE_PARSE_WORKER_SRC], { type: 'application/javascript' });
worker = new Worker(URL.createObjectURL(blob));
} catch (e) { reject(e); return; }
const timer = setTimeout(() => {
try { worker.terminate(); } catch (e) {}
reject(new Error('worker timeout'));
}, timeoutMs);
worker.onmessage = (e) => {
clearTimeout(timer);
try { worker.terminate(); } catch (err) {}
const msg = e.data || {};
if (msg.ok) resolve(msg); else reject(new Error(msg.error || 'worker parse failed'));
};
worker.onerror = () => {
clearTimeout(timer);
try { worker.terminate(); } catch (err) {}
reject(new Error('worker error'));
};
worker.postMessage({ text: text, wanted: wantedAppIds });
});
}
// 兜底:主线程分片正则扫描(每片 ≤12ms 后让出主线程,UI 保持响应)
function parseBundleJsonChunked(text, wantedAppIds) {
return new Promise((resolve, reject) => {
const wanted = (wantedAppIds && wantedAppIds.length) ? new Set(wantedAppIds) : null;
const re = /"(\d+)":\s*\{\s*"bundles":(\d+)/g;
const out = {};
let total = 0;
function step() {
const sliceStart = performance.now();
let m;
while ((m = re.exec(text)) !== null) {
total++;
if (!wanted || wanted.has(m[1])) {
const n = parseInt(m[2], 10);
if (n > 0) out[m[1]] = n;
}
if ((total & 1023) === 0 && performance.now() - sliceStart > 12) {
setTimeout(step, 0);
return;
}
}
// v2.9.5: 0 匹配时警告 JSON 结构可能已变更
if (total === 0) console.warn('[SGLV] Bundle 分片解析 0 匹配——Barter.vg JSON 结构可能已变更');
resolve({ total: total, data: out });
}
try { step(); } catch (e) { reject(e); }
});
}
async function loadBundleDatabase() {
if (bundleDbData || bundleDbLoading) return bundleDbData;
bundleDbLoading = true;
try {
// v2.9.15: 负缓存——30 分钟内失败不重试
if (negCacheGet(BUNDLE_DB_NEG_KEY, BUNDLE_DB_NEG_TTL)) {
console.log('[SGLV] Bundle 数据库命中负缓存,跳过重试');
return null;
}
// 库存 appId 集合:按需裁剪,降低内存占用与缓存写入体积
const wantedAppIds = (state.ownedGames || []).map(g => String(g.appid));
// v2.9.4: v2 精简缓存命中条件——未过期且覆盖当前全部库存 appId
const cached = GM_getValue(BUNDLE_DB_CACHE_KEY_V2, null);
if (cached && cached.timestamp && (Date.now() - cached.timestamp < BUNDLE_DB_CACHE_TTL) && cached.data) {
const covered = new Set(cached.appIds || []);
const fullyCovered = wantedAppIds.length === 0 ? !!cached.all : wantedAppIds.every(id => covered.has(id));
if (fullyCovered) {
bundleDbData = cached.data;
console.log(`[SGLV] Bundle 数据库缓存命中: ${Object.keys(bundleDbData).length} 条记录(精简版)`);
return bundleDbData;
}
}
console.log('[SGLV] 正在从 Barter.vg 获取 Bundle 数据库(离线程解析)...');
const text = await sglvGmFetchTextRetry(BUNDLE_DB_API_URL, { timeout: 30000, retries: 2 });
let parsed = null;
try {
parsed = await parseBundleJsonInWorker(text, wantedAppIds);
} catch (we) {
console.warn('[SGLV] Worker 解析不可用,回退主线程分片解析:', we);
parsed = await parseBundleJsonChunked(text, wantedAppIds);
}
if (!parsed || parsed.total < 7000) {
throw new Error('Barter.vg bundles data sanity check failed');
}
bundleDbData = parsed.data;
GM_setValue(BUNDLE_DB_CACHE_KEY_V2, { data: parsed.data, appIds: wantedAppIds, all: wantedAppIds.length === 0, timestamp: Date.now() });
// 清理旧版大体积缓存,释放存储
try { GM_setValue(BUNDLE_DB_CACHE_KEY, null); } catch (e) {}
// 成功后清掉负缓存
negCacheClear(BUNDLE_DB_NEG_KEY);
console.log(`[SGLV] Bundle 数据库加载完成: 全库 ${parsed.total} 条,库存命中 ${Object.keys(parsed.data).length} 条(未阻塞 UI 主线程)`);
return bundleDbData;
} catch(e) {
console.warn('[SGLV] Bundle 数据库加载失败:', e);
// v2.9.15: 失败入负缓存
negCacheSet(BUNDLE_DB_NEG_KEY, e.message || String(e));
return null;
} finally {
bundleDbLoading = false;
}
}
// ==================== v2.9.1: Barter.vg DLC 数据库 (识别 DLC 及其父游戏) ====================
// 数据源: https://bartervg.com/browse/dlc/json/
// 返回格式: {"239550":{"base_appID":221380,"base_item_id":125},...}
// Key 是 DLC 的 appID, base_appID 是父游戏 appID
const DLC_DB_API_URL = 'https://bartervg.com/browse/dlc/json/';
const DLC_DB_CACHE_KEY = 'sglv_dlc_db_cache';
// v1.9.2: 48h → 72h(3 天)—DLC 数据只增不减,延长 TTL 减少网络请求
const DLC_DB_CACHE_TTL = 72 * 60 * 60 * 1000; // 72小时(3天)
let dlcDbData = null; // { "dlcAppId": { base_appID: 123, base_item_id: 45 } }
let dlcDbPromise = null; // v2.9.5: 存储 in-flight Promise,避免并发调用返回 null
let appTypeMap = null; // v2.9.6: 全库存应用的 Steam type 映射(补充 Barter.vg 未覆盖的 DLC)
let appTypePromise = null; // v2.9.6: in-flight Promise,避免并发调用
let _dlcsByParentCache = null; // v2.9.46: 父游戏→DLC列表 反向映射缓存
function isDlc(appId) {
if (!appId) return false;
const id = String(appId);
// v2.9.6: 双源检测——Barter.vg DLC 数据库 OR Steam Store API type=1(dlc)/type=6(music)
if (dlcDbData && dlcDbData[id]) return true;
if (appTypeMap) {
const t = appTypeMap[id];
if (t === 'dlc' || t === 'music') return true;
}
return false;
}
// v2.9.5: 同步从缓存加载 DLC 数据库(GM_getValue 同步读取)
// 在 renderGamesList() 首次渲染前调用,确保 isDlc() 可用
// 避免总游戏数在 DLC 数据库异步加载前错误包含 DLC 数量
// v2.9.6: 同时从缓存加载 appTypeMap(全库存应用类型),补充 Barter.vg 未覆盖的 DLC
function loadDlcDatabaseFromCacheSync() {
if (!dlcDbData) {
try {
const cached = GM_getValue(DLC_DB_CACHE_KEY, null);
if (cached && cached.timestamp && (Date.now() - cached.timestamp < DLC_DB_CACHE_TTL)) {
dlcDbData = cached.data;
console.log(`[SGLV] DLC 数据库缓存命中 (sync): ${Object.keys(dlcDbData).length} 条记录`);
}
} catch(e) {
console.warn('[SGLV] DLC 数据库缓存读取失败:', e);
}
}
// v2.9.6: 同步加载全库存应用类型缓存
if (!appTypeMap) {
try {
const cached = GM_getValue(APP_TYPES_CACHE_KEY, null);
if (cached && cached.timestamp && (Date.now() - cached.timestamp < APP_TYPES_CACHE_TTL)) {
appTypeMap = cached.data;
console.log(`[SGLV] 全库存应用类型缓存命中 (sync): ${Object.keys(appTypeMap).length} 条`);
}
} catch(e) {
console.warn('[SGLV] 全库存应用类型缓存读取失败:', e);
}
}
}
async function loadDlcDatabase() {
if (dlcDbData) return dlcDbData;
if (dlcDbPromise) return dlcDbPromise; // v2.9.5: 返回 in-flight Promise,避免并发调用返回 null
dlcDbPromise = (async () => {
try {
const cached = GM_getValue(DLC_DB_CACHE_KEY, null);
if (cached && cached.timestamp && (Date.now() - cached.timestamp < DLC_DB_CACHE_TTL)) {
dlcDbData = cached.data;
console.log(`[SGLV] DLC 数据库缓存命中: ${Object.keys(dlcDbData).length} 条记录`);
return dlcDbData;
}
console.log('[SGLV] 正在从 Barter.vg 获取 DLC 数据库...');
const json = await sglvGmFetchRetry(DLC_DB_API_URL, { timeout: 30000, retries: 2 });
if (!json || Object.keys(json).length < 7000) {
throw new Error('Barter.vg DLC data sanity check failed');
}
dlcDbData = json;
GM_setValue(DLC_DB_CACHE_KEY, { data: json, timestamp: Date.now() });
console.log(`[SGLV] DLC 数据库加载完成: ${Object.keys(json).length} 条记录`);
return dlcDbData;
} catch(e) {
console.warn('[SGLV] DLC 数据库加载失败:', e);
return null;
} finally {
dlcDbPromise = null;
}
})();
return dlcDbPromise;
}
// ==================== v2.9.1: DLC 类型子分类 (音乐/视频/常规DLC等) ====================
// 通过 Steam IStoreBrowseService/GetItems 批量获取 type 字段
// Steam GetItems type: 0=game, 1=dlc, 2=software, 3=video, 4=series, 6=music, 7=tool, 8=video_series
let dlcTypeMap = null; // { appId: 'music' | 'dlc' | 'video' | ... }
let dlcTypeLoading = false;
function getDlcType(appId) {
if (!dlcTypeMap || !appId) return null;
return dlcTypeMap[String(appId)] || null;
}
// v2.9.68: 增量式 DLC 类型获取 — 只获取 dlcTypeMap 中不存在的类型,合并而非替换
// 支持 onProgress 回调供渐进式渲染,支持 options 参数扩展
async function enrichDlcTypes(dlcAppIds, options = {}) {
if (!dlcAppIds || dlcAppIds.length === 0) return;
const { onProgress } = options;
// v2.9.68: 如果正在加载,等待完成后再检查增量(避免并发请求)
if (dlcTypeLoading) {
console.log('[SGLV] enrichDlcTypes — 等待已有加载完成...');
let waitCount = 0;
while (dlcTypeLoading && waitCount < 60) {
await new Promise(r => setTimeout(r, 500));
waitCount++;
}
}
// v2.9.68: 先尝试从 GM 缓存加载(如果 dlcTypeMap 尚未初始化)
// DLC 类型不变,缓存永久有效(移除 72h TTL 限制)
if (!dlcTypeMap) {
const cached = GM_getValue('sglv_dlc_types_cache', null);
if (cached && cached.data) {
dlcTypeMap = cached.data;
console.log(`[SGLV] DLC 类型 GM 缓存命中: ${Object.keys(dlcTypeMap).length} 条`);
}
}
// v2.9.68: 增量模式 — 只获取 dlcTypeMap 中不存在的 DLC 类型
const needEnrich = dlcAppIds.filter(id => !dlcTypeMap || !dlcTypeMap[String(id)]);
if (needEnrich.length === 0) {
console.log(`[SGLV] enrichDlcTypes — 全部 ${dlcAppIds.length} 个 DLC 类型已存在,跳过`);
return;
}
dlcTypeLoading = true;
try {
console.log(`[SGLV] 正在获取 ${needEnrich.length}/${dlcAppIds.length} 个 DLC 的类型信息...`);
const CHUNK = 100, CONCURRENCY = 3;
const chunks = [];
for (let i = 0; i < needEnrich.length; i += CHUNK) chunks.push(needEnrich.slice(i, i + CHUNK));
const typeMap = {};
let qIdx = 0;
let doneCount = 0;
const worker = async () => {
while (qIdx < chunks.length) {
const chunk = chunks[qIdx++];
try {
const input = {
ids: chunk.map(id => ({ appid: id })),
context: { language: 'schinese', country_code: 'CN', steam_realm: 1 },
data_request: { include_release: false }
};
const url = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input));
const resp = await requestSteamAPI(url);
const storeItems = resp?.response?.store_items || [];
storeItems.forEach(si => {
if (si && si.appid) {
let typeStr = 'dlc';
if (typeof si.type === 'number') {
typeStr = ({ 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' })[si.type] || 'other';
} else if (si.type) {
typeStr = String(si.type).toLowerCase();
}
typeMap[String(si.appid)] = typeStr;
// 同时补充 DLC 名称
if (si.name) {
const nc = sglvGameNameCacheLoad();
nc[String(si.appid)] = { name: si.name, ts: Date.now() };
}
}
});
} catch (e) { console.warn('[SGLV] DLC type batch failed:', e); }
doneCount += chunk.length;
if (onProgress) try { onProgress(doneCount, needEnrich.length); } catch (e) {}
await new Promise(r => setTimeout(r, 200));
}
};
const workers = [];
for (let i = 0; i < CONCURRENCY; i++) workers.push(worker());
await Promise.all(workers);
// v2.9.68: 合并新类型到 dlcTypeMap(而非替换),实现"只增不减"永久缓存
if (!dlcTypeMap) dlcTypeMap = {};
let newCount = 0;
for (const [k, v] of Object.entries(typeMap)) {
if (!dlcTypeMap[k]) { dlcTypeMap[k] = v; newCount++; }
}
GM_setValue('sglv_dlc_types_cache', { data: dlcTypeMap, timestamp: Date.now() });
if (isZh) { try { sglvGameNameCacheSave(); } catch (e) {} }
console.log(`[SGLV] DLC 类型获取完成: 新增 ${newCount} 条, 累计 ${Object.keys(dlcTypeMap).length} 条`);
} catch(e) {
console.warn('[SGLV] DLC 类型获取失败:', e);
} finally {
dlcTypeLoading = false;
}
}
// ==================== v2.9.6: 全库存应用类型获取(补充 Barter.vg DLC 数据库未覆盖的 DLC) ====================
// 通过 Steam IStoreBrowseService/GetItems 批量获取所有库存应用的 type 字段
// Barter.vg DLC 数据库覆盖不全(部分新 DLC 或冷门 DLC 未收录),
// 此函数用 Steam 官方 API 补充识别 type=1(dlc) 和 type=6(music) 的应用
const APP_TYPES_CACHE_KEY = 'sglv_app_types_cache';
// v1.9.2: 48h → 72h(3 天)—库存应用类型变化极慢
const APP_TYPES_CACHE_TTL = 72 * 60 * 60 * 1000; // 72小时(3天)
async function enrichOwnedAppTypes(onProgress) {
if (appTypeMap || appTypePromise) return appTypePromise;
const allItems = (storage.getShowFamilyShared() ? state.ownedGames : state.ownedGames.filter(g => isGameOwnedByMe(g)));
const appIds = allItems.map(g => g.appid);
if (appIds.length === 0) return null;
const report = (pct, text) => { if (typeof onProgress === 'function') onProgress(pct, text); };
appTypePromise = (async () => {
try {
// v2.9.15: 负缓存——全失败时短期不再重试,保护 Steam API
if (negCacheGet(nsKey('app_types_neg'), 30 * 60 * 1000)) {
console.log('[SGLV] app types 命中负缓存,跳过重试');
return null;
}
// 检查缓存
const cached = GM_getValue(APP_TYPES_CACHE_KEY, null);
if (cached && cached.timestamp && (Date.now() - cached.timestamp < APP_TYPES_CACHE_TTL)) {
appTypeMap = cached.data;
console.log(`[SGLV] 全库存应用类型缓存命中: ${Object.keys(appTypeMap).length} 条`);
report(100, isZh ? 'appdetails 缓存命中' : 'appdetails cached');
return appTypeMap;
}
report(0, isZh ? `正在补全 ${appIds.length} 款游戏详情…` : `Enriching ${appIds.length} games…`);
console.log(`[SGLV] 正在获取 ${appIds.length} 个库存应用的类型信息...`);
const CHUNK = 200, CONCURRENCY = 3;
const chunks = [];
for (let i = 0; i < appIds.length; i += CHUNK) chunks.push(appIds.slice(i, i + CHUNK));
const typeMap = {};
let doneCount = 0;
let qIdx = 0;
const total = chunks.length;
const worker = async () => {
while (qIdx < chunks.length) {
const chunk = chunks[qIdx++];
try {
const input = {
ids: chunk.map(id => ({ appid: id })),
context: { language: 'schinese', country_code: 'CN', steam_realm: 1 },
data_request: { include_release: false }
};
const url = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input));
const resp = await requestSteamAPI(url);
const storeItems = resp?.response?.store_items || [];
storeItems.forEach(si => {
if (si && si.appid) {
let typeStr = 'game';
if (typeof si.type === 'number') {
typeStr = ({ 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' })[si.type] || 'other';
} else if (si.type) {
typeStr = String(si.type).toLowerCase();
}
typeMap[String(si.appid)] = typeStr;
}
});
} catch (e) { console.warn('[SGLV] App type batch failed:', e); }
doneCount++;
// v2.9.15: 进度回调(throttle 用 setTimeout 简单实现,避免每批次都更新)
if (total > 0) report(Math.round(doneCount / total * 100), isZh ? `已补全 ${doneCount}/${total} 批…` : `${doneCount}/${total} batches…`);
await new Promise(r => setTimeout(r, 200));
}
};
const workers = [];
for (let i = 0; i < CONCURRENCY; i++) workers.push(worker());
await Promise.all(workers);
appTypeMap = typeMap;
GM_setValue(APP_TYPES_CACHE_KEY, { data: typeMap, timestamp: Date.now() });
// v2.9.6: appTypeMap 加载后失效 DLC 数量缓存,使下次统计使用双源检测
_cachedDlcCount = null;
_cachedDlcCountKey = '';
// 成功清负缓存
negCacheClear(nsKey('app_types_neg'));
report(100, isZh ? '详情补全完成' : 'Enrichment done');
console.log(`[SGLV] 全库存应用类型获取完成: ${Object.keys(typeMap).length} 条`);
return appTypeMap;
} catch(e) {
console.warn('[SGLV] 全库存应用类型获取失败:', e);
// v2.9.15: 失败入负缓存
negCacheSet(nsKey('app_types_neg'), e.message || String(e));
return null;
} finally {
appTypePromise = null;
}
})();
return appTypePromise;
}
// ==================== 游戏橱窗标签页 ====================
function renderOwnedTab(parent) {
// v2.4.5: 恢复持久化的排序偏好
state.ownedSort = storage.getOwnedSort();
// 顶部统计仪表板
const dashboard = document.createElement('div');
dashboard.className = 'sglv-stats-dashboard';
dashboard.id = 'sglv-owned-dashboard';
parent.appendChild(dashboard);
// 工具栏(获取按钮已合并到面板头部刷新按钮)
// v2.9.3: 紧凑布局——第1行搜索+排序+视图切换+分页;筛选改为点击上方 KPI 卡片(合并自 2.9.0 思路)
const toolbar = document.createElement('div');
toolbar.className = 'sglv-toolbar';
toolbar.innerHTML = `
`;
parent.appendChild(toolbar);
// v2.9.3: KPI 卡片点击筛选——点击统计卡片切换筛选状态(合并自 2.9.0 思路)
dashboard.addEventListener('click', e => {
const card = e.target.closest('[data-filter]');
if (!card) return;
const filter = card.dataset.filter;
state.ownedFilter = state.ownedFilter === filter ? 'all' : filter;
state.page = 1;
renderGamesList();
});
// v2.9.49: debounce 搜索输入,避免大库场景每次按键触发完整重渲染
toolbar.querySelector('#sglv-search').addEventListener('input', debounce(e => { state.searchQuery = e.target.value; state.page = 1; renderGamesList(); }, 200));
// v2.4.5: 排序下拉——切换后持久化并重置到第一页;按入库时间排序时后台补齐缺失的入库时间
const sortSelect = toolbar.querySelector('#sglv-sort');
sortSelect.value = state.ownedSort;
sortSelect.addEventListener('change', () => {
state.ownedSort = sortSelect.value;
storage.setOwnedSort(sortSelect.value);
state.page = 1;
renderGamesList();
if (sortSelect.value.startsWith('acquired')) enrichAcquiredTimesForSort();
});
toolbar.querySelectorAll('[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
state.viewMode = btn.dataset.view;
toolbar.querySelectorAll('[data-view]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderGamesList();
});
});
// 游戏列表容器
const content = document.createElement('div');
content.className = 'sglv-content';
content.id = 'sglv-games-content';
parent.appendChild(content);
// v2.9.5: 在首次渲染前同步从缓存加载 DLC 数据库(GM_getValue 同步读取)
// 确保 dlcDbData 已从缓存载入,使首次 renderGamesList() 的 isDlc() 即可正确工作
// 避免总游戏数在 DLC 数据库加载前错误包含 DLC 数量
loadDlcDatabaseFromCacheSync();
renderGamesList();
// v2.9.4: 异步加载 Bundle 数据库(Worker 离线程解析,不阻塞 UI)和卡牌数据库,加载完成后刷新仪表板
loadBundleDatabase().then(() => {
if (document.getElementById('sglv-owned-dashboard')) {
renderGamesList();
}
}).catch(e => console.warn('[SGLV] Bundle DB load failed:', e));
if (SGLV_API.loadCardDatabase) {
SGLV_API.loadCardDatabase().then(() => {
if (document.getElementById('sglv-owned-dashboard')) {
renderGamesList();
}
}).catch(e => console.warn('[SGLV] Card DB load failed:', e));
}
// v2.9.1: DLC 数据库已在前方调用 loadDlcDatabase() 启动加载;
// v2.9.6: DLC 数据库加载后,启动全库存应用类型获取(补充 Barter.vg 未覆盖的 DLC)
loadDlcDatabase().then(() => {
if (document.getElementById('sglv-owned-dashboard')) {
renderGamesList();
}
// v2.9.6: 用 Steam 官方 API 批量获取所有库存应用的 type,补充识别 Barter.vg 未收录的 DLC
enrichOwnedAppTypes().then(() => {
if (document.getElementById('sglv-owned-dashboard')) {
renderGamesList();
}
// 全库存 type 获取完成后,对已识别的 DLC 获取子分类(音乐/视频/常规DLC等)
const allItems = (storage.getShowFamilyShared() ? state.ownedGames : state.ownedGames.filter(g => isGameOwnedByMe(g)));
const dlcIds = allItems.filter(g => isDlc(g.appid)).map(g => g.appid);
if (dlcIds.length > 0) {
enrichDlcTypes(dlcIds).then(() => {
if (document.getElementById('sglv-owned-dashboard')) renderGamesList();
});
}
}).catch(e => console.warn('[SGLV] App types enrichment failed:', e));
}).catch(e => console.warn('[SGLV] DLC DB load failed:', e));
}
function renderGamesList() {
const content = document.getElementById('sglv-games-content');
const pagination = document.getElementById('sglv-pagination');
const dashboard = document.getElementById('sglv-owned-dashboard');
if (!content) return;
let games = [...state.ownedGames];
const showFamilyShared = storage.getShowFamilyShared();
if (!showFamilyShared) {
games = games.filter(g => isGameOwnedByMe(g));
}
// v2.9.1: 所有筛选排除 DLC(dlconly 除外)
if (state.ownedFilter !== 'dlconly') {
games = games.filter(g => !isDlc(g.appid));
}
// 分类筛选
if (state.ownedFilter === 'played') {
games = games.filter(g => (g.playtime || 0) > 0);
} else if (state.ownedFilter === 'unplayed') {
games = games.filter(g => (g.playtime || 0) === 0);
} else if (state.ownedFilter === 'shared') {
games = games.filter(g => g.owners && g.owners.length > 0 && !isGameOwnedByMe(g));
} else if (state.ownedFilter === 'owned') {
games = games.filter(g => isGameOwnedByMe(g));
} else if (state.ownedFilter === 'bundled') {
games = games.filter(g => getBundleCount(g.appid) > 0);
} else if (state.ownedFilter === 'cards') {
games = games.filter(g => SGLV_API.getCardDbMaxLevel && SGLV_API.getCardDbMaxLevel(g.appid) > 0);
} else if (state.ownedFilter === 'dlconly') {
games = games.filter(g => isDlc(g.appid));
}
// 搜索过滤
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
games = games.filter(g => g.name.toLowerCase().includes(q));
}
// v2.4.5: 排序——默认 AppID 升序,修复原列表顺序不明的问题;次级键 AppID 保证顺序稳定确定
// v2.9.49: 缓存 nameCollator 到模块级,避免每次 renderGamesList 都重新构造
const sortKey = state.ownedSort || 'appidAsc';
const acqTime = g => g.acquiredTime || 0;
games.sort((a, b) => {
let r = 0;
switch (sortKey) {
case 'appidDesc': r = b.appid - a.appid; break;
case 'nameAsc': r = _nameCollator.compare(a.name || '', b.name || ''); break;
case 'acquiredDesc': r = acqTime(b) - acqTime(a); break;
case 'acquiredAsc': {
// 无入库时间的条目始终沉底,不随方向浮动
const x = acqTime(a), y = acqTime(b);
r = (!x && !y) ? 0 : !x ? 1 : !y ? -1 : x - y;
break;
}
case 'playtimeDesc': r = (b.playtime || 0) - (a.playtime || 0); break;
case 'lastPlayedDesc': r = (b.lastPlayed || 0) - (a.lastPlayed || 0); break;
case 'bundleDesc': r = getBundleCount(b.appid) - getBundleCount(a.appid); break;
default: r = a.appid - b.appid; // appidAsc
}
return r || (a.appid - b.appid);
});
// 更新统计仪表板(依据“是否显示家庭共享”当前设置)
if (dashboard) {
// v2.9.1: 游戏与 DLC 分离统计
const allItems = showFamilyShared ? state.ownedGames : state.ownedGames.filter(g => isGameOwnedByMe(g));
const realGames = allItems.filter(g => !isDlc(g.appid));
const dlcGames = allItems.filter(g => isDlc(g.appid));
const totalGames = realGames.length;
// v2.9.22: 修复库存标签页与统计仪表总时长不一致——
// 历史实现仅累加 realGames(不含 DLC),但统计仪表 playtime 标签页累加全量(含 DLC),
// 导致两个 KPI 数字差异(如 4021.5h vs 4210.3h)。
// 统一取 max(含DLC, 不含DLC),确保两处显示一致,且以较大值为准。
const realGamesMinutes = realGames.reduce((s, g) => s + (g.playtime || 0), 0);
const allItemsMinutes = allItems.reduce((s, g) => s + (g.playtime || 0), 0);
const totalMinutes = Math.max(realGamesMinutes, allItemsMinutes);
const playedCount = realGames.filter(g => (g.playtime || 0) > 0).length;
const unplayedCount = totalGames - playedCount;
const sharedCount = state.ownedGames.filter(g => g.owners && g.owners.length > 0 && !isGameOwnedByMe(g) && !isDlc(g.appid)).length;
const avgHoursPerGame = totalGames > 0 ? (totalMinutes / 60 / totalGames).toFixed(1) : '0';
const playedPct = totalGames > 0 ? Math.round(playedCount / totalGames * 100) : 0;
const unplayedPct = totalGames > 0 ? Math.round(unplayedCount / totalGames * 100) : 0;
// v2.9.0: 进包/卡牌游戏统计(仅游戏,不含DLC)
const bundledCount = realGames.filter(g => getBundleCount(g.appid) > 0).length;
const cardCount = realGames.filter(g => SGLV_API.getCardDbMaxLevel && SGLV_API.getCardDbMaxLevel(g.appid) > 0).length;
// v2.9.1: DLC 独立统计
const dlcTotal = dlcGames.length;
const dlcBundled = dlcGames.filter(g => getBundleCount(g.appid) > 0).length;
const dlcMusic = dlcGames.filter(g => getDlcType(g.appid) === 'music').length;
const dlcSubText = dlcTotal > 0 ? `${T.kpiDlcMusic} ${dlcMusic} · ${T.kpiDlcBundled} ${dlcBundled}` : (isZh ? '加载中...' : 'Loading...');
dashboard.innerHTML = `
${totalGames.toLocaleString()}
${T.kpiAvgHours} ${avgHoursPerGame}${T.kpiPerGame}
${playedCount.toLocaleString()}
${T.kpiRate} ${playedPct}%
${unplayedCount.toLocaleString()}
${T.kpiRate} ${unplayedPct}%
${sharedCount.toLocaleString()}
${T.kpiFromFamily}
${bundledCount.toLocaleString()}
${T.kpiInBundles}
${cardCount.toLocaleString()}
${T.kpiHasCards}
${dlcTotal.toLocaleString()}
${dlcSubText}
`;
}
// 分页
// v2.4.0: 复用 paginate 纯函数统一分页计算
// v2.9.31: 封面视图每页 24 项(横版卡片占位更大),卡片/列表保持 48
const _effectivePageSize = state.viewMode === 'cover' ? 24 : state.pageSize;
const { pageItems: pageGames, totalPages, page: _clampedPage } = paginate(games, state.page, _effectivePageSize);
state.page = _clampedPage;
if (games.length === 0) {
content.innerHTML = `${state.ownedGames.length === 0 ? T.noData : (isZh ? '无匹配游戏' : 'No matching games')}
`;
if (pagination) pagination.innerHTML = '';
return;
}
// v2.9.31: 横版封面视图——3 列网格,胶囊横幅 + 徽章浮层 + 时长角标
if (state.viewMode === 'cover') {
const grid = document.createElement('div');
grid.className = 'sglv-cover-grid';
pageGames.forEach(g => {
const card = document.createElement('div');
card.className = 'sglv-cover-card';
const isOwned = isGameOwnedByMe(g);
const ownerNames = getGameOwnerNames(g);
const hours = Math.round(g.playtime / 60);
card.innerHTML = `
${coverImgTag(g.appid, g.name, 'capsule', 'sglv-cover-img')}
${!isOwned ? `${isZh ? '共享' : 'Shared'}` : ''}
${getBundleCount(g.appid) > 0 ? `${ICONS.package}${getBundleCount(g.appid)}` : ''}
${hours > 0 ? `
${ICONS.clock}${hours}h
` : ''}
${g.name}
AppID: ${g.appid}
`;
card.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${g.appid}`, '_blank'));
grid.appendChild(card);
});
content.innerHTML = '';
content.appendChild(grid);
} else if (state.viewMode === 'card') {
const grid = document.createElement('div');
grid.className = 'sglv-card-grid';
pageGames.forEach(g => {
const card = document.createElement('div');
card.className = 'sglv-game-card';
const isOwned = isGameOwnedByMe(g);
const ownerNames = getGameOwnerNames(g);
card.innerHTML = `
${posterImg(g.appid, g.name)}
${g.name}
AppID: ${g.appid}
${g.playtime > 0 ? `
${Math.round(g.playtime / 60)}h
` : ''}
${!isOwned ? `
共享
` : ''}
${getBundleCount(g.appid) > 0 ? `
${getBundleCount(g.appid)}` : ''}
`;
card.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${g.appid}`, '_blank'));
grid.appendChild(card);
});
content.innerHTML = '';
content.appendChild(grid);
} else {
const list = document.createElement('div');
list.className = 'sglv-list-view';
pageGames.forEach(g => {
const item = document.createElement('div');
item.className = 'sglv-list-item';
const hours = Math.round(g.playtime / 60);
const isOwned = isGameOwnedByMe(g);
const ownerNames = getGameOwnerNames(g);
item.innerHTML = `
${headerImg(g.appid, g.name).replace('sglv-card-img', 'sglv-list-icon')}
${g.name}
AppID: ${g.appid}
${!isOwned ? `👥 共享自: ${ownerNames}` : ''}
${hours > 0 ? `${hours}h` : ''}
${!isOwned ? `共享` : ''}
${getBundleCount(g.appid) > 0 ? `${getBundleCount(g.appid)}` : ''}
`;
item.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${g.appid}`, '_blank'));
list.appendChild(item);
});
content.innerHTML = '';
content.appendChild(list);
}
// 分页导航
renderPagination(pagination, 'sglv', state.page, totalPages, games.length,
() => { state.page--; renderGamesList(); },
() => { state.page++; renderGamesList(); },
(p) => { state.page = p; renderGamesList(); });
// v2.3.33:异步加载游戏中文名(参考 steam-friend-manager loadGameZhName)
// v2.8.2:列表视图改用 loadGameNameAlias 追加灰色别名(中英文互补),卡片视图保持原替换逻辑
content.querySelectorAll('[data-sglv-appid]').forEach(el => {
if (el.classList.contains('sglv-list-name')) {
loadGameNameAlias(el, el.getAttribute('data-sglv-appid'), el.textContent);
} else {
loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent);
}
});
}
// v2.4.5: 按入库时间排序时,若当前库数据缺少 acquiredTime(如缓存/抓取来源),
// 后台拉取家庭组数据补齐后重渲染,避免排序看似无效
let _acquiredEnrichPending = false;
function enrichAcquiredTimesForSort() {
if (_acquiredEnrichPending) return;
if (!state.ownedGames.length || state.ownedGames.some(g => g.acquiredTime > 0)) return;
_acquiredEnrichPending = true;
fetchTimelineFamilyGames().then(fg => {
if (fg && fg.length) {
const map = {};
fg.forEach(g => { if (g.acquiredTime > 0) map[g.appid] = g.acquiredTime; });
let changed = 0;
state.ownedGames.forEach(g => {
if (!g.acquiredTime && map[g.appid]) { g.acquiredTime = map[g.appid]; changed++; }
});
// 仅在仍按入库时间排序且列表仍展示时重渲染
if (changed > 0 && (state.ownedSort || '').startsWith('acquired') && document.getElementById('sglv-games-content')) {
renderGamesList();
}
}
}).catch(e => console.warn('[SGLV] 入库时间补齐失败:', e))
.finally(() => { _acquiredEnrichPending = false; });
}
// ==================== 游玩时长标签页 ====================
// v2.9.24: calcPlaytimeDist 支持 mode 参数——'count'(游戏数,默认) 或 'hours'(总时长)
function calcPlaytimeDist(games, mode = 'count') {
const cacheK = cacheKey('dist', mode, games.length, games.reduce((s, g) => s + (g.playtime || 0), 0));
return getCached(cacheK, () => {
const ranges = [
{ label: T.ptRange1, min: 0.01, max: 1, color: '#94a3b8' },
{ label: T.ptRange2, min: 1, max: 10, color: '#60a5fa' },
{ label: T.ptRange3, min: 10, max: 50, color: '#34d399' },
{ label: T.ptRange4, min: 50, max: 100, color: '#fbbf24' },
{ label: T.ptRange5, min: 100, max: 500, color: '#f97316' },
{ label: T.ptRange6, min: 500, max: Infinity, color: '#ef4444' }
];
if (mode === 'hours') {
// 时长维度:统计每个区间的总时长(小时),0 时长不计入
return ranges.map(r => ({
label: r.label,
count: Math.round(games.filter(g => {
const h = (g.playtime || 0) / 60;
return h >= r.min && h < r.max;
}).reduce((s, g) => s + (g.playtime || 0) / 60, 0)),
color: r.color
}));
}
// 游戏数维度(默认)
const zeroCount = games.filter(g => (g.playtime || 0) === 0).length;
return [
{ label: T.ptRange0, count: zeroCount, color: '#64748b' },
...ranges.map(r => ({
label: r.label,
count: games.filter(g => {
const h = (g.playtime || 0) / 60;
return h >= r.min && h < r.max;
}).length,
color: r.color
}))
];
});
}
function getPeriodKey(date, mode) {
const y = date.getFullYear();
const m = date.getMonth();
if (mode === 'year') return `${y}`;
if (mode === 'quarter') return `${y}-Q${Math.floor(m / 3) + 1}`;
return `${y}-${String(m + 1).padStart(2, '0')}`;
}
function renderPlaytimeTrend(games, mode) {
const cacheK = cacheKey('trend', mode, games.length, games.reduce((s, g) => s + (g.lastPlayed || 0), 0));
return getCached(cacheK, () => {
const played = games.filter(g => g.lastPlayed > 0);
if (!played.length) return `${isZh ? '暂无趋势数据' : 'No trend data'}
`;
const buckets = {};
played.forEach(g => {
const d = new Date(g.lastPlayed * 1000);
const key = getPeriodKey(d, mode);
buckets[key] = (buckets[key] || 0) + ((g.playtime || 0) / 60);
});
const dates = played.map(g => new Date(g.lastPlayed * 1000));
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
const periods = [];
let cursor = new Date(minDate);
while (cursor <= maxDate) {
periods.push(getPeriodKey(cursor, mode));
if (mode === 'year') cursor.setFullYear(cursor.getFullYear() + 1);
else if (mode === 'quarter') cursor.setMonth(cursor.getMonth() + 3);
else cursor.setMonth(cursor.getMonth() + 1);
}
const MAX_POINTS = mode === 'month' ? 12 : mode === 'quarter' ? 12 : 10;
const displayPeriods = periods.slice(-MAX_POINTS);
const displayValues = displayPeriods.map(p => buckets[p] || 0);
if (displayValues.every(v => v === 0)) return `${isZh ? '暂无趋势数据' : 'No trend data'}
`;
const W = 400, H = 200, PAD = 30;
const maxV = Math.max(...displayValues) || 1;
const n = displayValues.length;
const stepX = n > 1 ? (W - PAD * 2) / (n - 1) : 0;
const pts = displayValues.map((v, i) => {
const x = n > 1 ? PAD + i * stepX : W / 2;
const y = H - PAD - (v / maxV) * (H - PAD * 2);
return [x, y];
});
const areaPath = `M ${pts[0][0]} ${H - PAD} ` + pts.map(p => `L ${p[0]} ${p[1]}`).join(' ') + ` L ${pts[pts.length - 1][0]} ${H - PAD} Z`;
const linePath = `M ${pts.map(p => `${p[0]} ${p[1]}`).join(' L ')}`;
const labelStep = Math.max(1, Math.floor(n / 6));
const labels = displayPeriods.map((p, i) => {
if (i % labelStep !== 0 && i !== n - 1) return '';
const short = mode === 'year' ? p : p.replace(/^\d{4}-/, '');
return `${short}`;
}).join('');
const yLabels = [0, 0.25, 0.5, 0.75, 1].map(r => {
const v = maxV * r;
const y = H - PAD - r * (H - PAD * 2);
return `${v.toFixed(0)}h`;
}).join('');
// v2.9.23: 均值参考线(dashed)+ 峰值标注,参考设计图
const avgV = displayValues.reduce((s, v) => s + v, 0) / n;
const avgY = H - PAD - (avgV / maxV) * (H - PAD * 2);
const avgLine = `${isZh ? '均' : 'avg'} ${avgV.toFixed(0)}h`;
// 峰值标注(最高点的数值标签)
const maxIdx = displayValues.indexOf(maxV);
const peakLabel = `${isZh ? '峰' : 'peak'} ${maxV.toFixed(0)}h`;
return ``;
});
}
// v2.9.25: renderPlaytimeDonut 数值千分位格式化 + 中心文字自适应字号
function renderPlaytimeDonut(distData, total, unit = '') {
if (total === 0) return `${T.ptNoData}
`;
const sz = 240, sw = 44, r = (sz - sw) / 2;
const cx = sz / 2, cy = sz / 2;
const C = 2 * Math.PI * r;
// v2.9.25: 数值格式化——千分位分隔,避免大数字溢出甜甜圈中心
const fmt = (n) => n.toLocaleString();
let off = 0;
let segs = '';
for (const d of distData) {
if (d.count === 0) continue;
const dash = (d.count / total) * C;
segs += `${d.label}: ${fmt(d.count)}${unit} (${(d.count/total*100).toFixed(1)}%)`;
off += dash;
}
const legendHtml = distData.filter(d => d.count > 0).map(d =>
`${d.label}${fmt(d.count)}${unit} (${(d.count/total*100).toFixed(1)}%)
`
).join('');
// v2.9.25: 中心文字自适应字号——根据格式化后字符串长度动态缩放,避免溢出
const totalStr = fmt(total) + unit;
let centerFontSize;
if (totalStr.length <= 5) centerFontSize = 28;
else if (totalStr.length <= 7) centerFontSize = 24;
else if (totalStr.length <= 9) centerFontSize = 20;
else centerFontSize = 16;
return `
${legendHtml}
`;
}
// v2.3.30: 入库热力图——参考 steam-family-game-analysis v1.41 的 buildHeatmapBlock 设计
// 输入: games 数组(需含 acquiredTime 秒级时间戳);输出 GitHub 式贡献热力图 SVG
// v2.3.31: 新增缓存层——签名一致且 10 分钟内直接返回缓存的 HTML,跳过 SVG 重新计算
// v2.4.2: 按年分割(参考 v1.58)——新增年份选择器,默认展示最新年份;
// "全部"视图保留跨年连续网格,并增加年份分隔线与年份标签
// v2.9.22: 入库热力图骨架占位(GitHub 风格年份框架 + 灰格,无数据时也不会出现大片空白)
// 设计:显示当前年份的月份标签 + 7 行 × 53 周的灰色空格,结构与最终渲染保持一致,
// 加载完成时直接 innerHTML 替换为真实数据,无需 reflow。
// v2.9.22: 当年(currentYear)数据不够时(如 2026/8 才有少量数据),
// 早期月份(1-7月)位置用占位灰格填满 53×7 网格——GitHub 风格,
// 让"未到的时间"也以视觉可见的灰色块显示,底部不会出现"空白错觉"。
// 骨架色 #21262d(GitHub theme-1)比真实 0 级 #161b22 略亮,避免与背景融合。
function buildAcquiredHeatmapSkeleton() {
const cellSize = 12, cellGap = 3, cellStep = cellSize + cellGap;
const labelW = 26, monthH = 16;
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
const dayNames = ['', '一', '', '三', '', '五', ''];
const totalWeeks = 53; // 单年最多 53 周
const baseColor = '#21262d'; // 骨架占位色(GitHub theme-1),比 #161b22 略亮以区分背景
// v2.9.25: topH 去掉 yearH,与实际单年渲染一致(showYearRow=false),消除加载完成时 14px 垂直跳动
const topH = monthH;
const svgW = labelW + totalWeeks * cellStep + 8;
const svgH = topH + 7 * cellStep + 8;
// 月份标签:每 4 周一个(避免重叠)
const monthParts = monthNames.map((n, i) => {
const w = Math.floor(i * totalWeeks / 12);
return `${n}`;
}).join('');
// 星期标签
const dayParts = dayNames.map((n, i) => n ? `${n}` : '').join('');
// 灰格:7 行 × totalWeeks 列
const cellParts = [];
for (let w = 0; w < totalWeeks; w++) {
for (let d = 0; d < 7; d++) {
const x = labelW + w * cellStep, y = topH + d * cellStep;
cellParts.push(``);
}
}
// 顶部骨架图例(与真实渲染图例位置一致)
const legendSwatches = ['#161b22', '#0e4429', '#006832', '#26a641', '#39d353']
.map(c => ``).join('');
const skeleton = `
${T.ptHeatmapLess}${legendSwatches}${T.ptHeatmapMore}
${T.ptHeatmapTotal}: —
${T.ptHeatmapPeak}: —
${T.ptHeatmapAvg}: —
${isZh ? '加载中…' : 'Loading…'}
`;
return `${skeleton}
`;
}
// v2.9.26: 近6月入库增量骨架——尺寸与结构与实际 renderMonthlyAcquireChart 完全一致,消除加载跳动
function buildMonthlyAcquireSkeleton() {
const now = new Date();
const months = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push({ y: d.getFullYear(), m: d.getMonth(), key: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(-2)}` });
}
// v2.9.26: 与 renderMonthlyAcquireChart 完全一致的尺寸(加高 SVG 减少留白)
const W = 280, H = 200, lw = 32, topPad = 20, botPad = 24;
const plotW = W - lw - 2, plotH = H - topPad - botPad;
const step = plotW / 6, barW = Math.min(26, step * 0.6);
let parts = '';
// 网格线
const gridVals = [0, 1];
for (const v of gridVals) {
const y = topPad + plotH - (v / 1) * plotH;
parts += ``;
parts += `${v}`;
}
months.forEach((mo, i) => {
const cx = lw + step * i + step / 2;
const bx = cx - barW / 2, by = topPad + plotH - 2;
parts += ``;
const lbl = String(mo.y).slice(2) + '.' + String(mo.m + 1).padStart(2, '0');
parts += `${lbl}`;
});
const svg = ``;
// v2.9.26: 结构与实际渲染一致——inner + title + chart + summary
const summaryHtml = `
— ${isZh ? '总计' : 'total'}
${isZh ? '加载中…' : 'Loading…'}
`;
return `
${T.ptHeatmapMini6m}
${svg}
${summaryHtml}
`;
}
function renderAcquiredHeatmap(heatGames) {
const valid = heatGames.filter(g => g.acquiredTime && g.acquiredTime > 0);
if (valid.length === 0) return `${T.ptHeatmapNoData}
`;
// v2.3.31: 构建缓存签名——游戏数量 + 首尾 appid + 首尾入库时间,足够区分不同数据集
const sig = valid.length + '_' + valid[0].appid + '_' + valid[valid.length - 1].appid
+ '_' + valid[0].acquiredTime + '_' + valid[valid.length - 1].acquiredTime;
if (_heatmapRenderCache && _heatmapRenderCache.signature === sig
&& Date.now() - _heatmapRenderCache.ts < 10 * 60 * 1000) {
return _heatmapRenderCache.html;
}
// 单次遍历构建日入库计数:全局 dayMap + 按年 dayMap,同时收集有效时间戳
const dayMap = {};
const yearDayMaps = {};
const tsList = [];
for (const g of valid) {
const t = g.acquiredTime;
const d = new Date(t * 1000);
const y = d.getFullYear();
const key = y + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
dayMap[key] = (dayMap[key] || 0) + 1;
const ym = yearDayMaps[y] || (yearDayMaps[y] = {});
ym[key] = (ym[key] || 0) + 1;
tsList.push(t);
}
tsList.sort((a, b) => a - b);
const firstTs = tsList[0], lastTs = tsList[tsList.length - 1];
const years = Object.keys(yearDayMaps).map(Number).sort((a, b) => a - b);
const cellSize = 12, cellGap = 3, cellStep = cellSize + cellGap;
const labelW = 26, yearH = 14, monthH = 16;
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
const dayNames = ['', '一', '', '三', '', '五', ''];
// GitHub 风格绿色 5 级色阶(暗→亮)
const colors = ['#161b22', '#0e4429', '#006832', '#26a641', '#39d353'];
// v2.9.22: 未来月份占位色(today 之后)—— 比 0 级 #161b22 略亮,让"未到时间"格子清晰可见且不与 0 级混淆
const futureColor = '#30363d';
function heatColor(count) {
if (count === 0) return colors[0];
if (count <= 1) return colors[1];
if (count <= 2) return colors[2];
if (count <= 4) return colors[3];
return colors[4];
}
const fmtD = (d) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
// 构建单个视图(某一年 / 全部)的完整 HTML:SVG + 图例 + 统计
// showYearRow: 是否在顶部渲染年份标签与分隔线(仅"全部"跨年视图需要)
function buildView(viewDayMap, rangeStartDate, rangeEndDate, avgSpanDays, showYearRow) {
const globalStart = new Date(rangeStartDate);
globalStart.setHours(0, 0, 0, 0);
const endDate = new Date(rangeEndDate);
endDate.setHours(0, 0, 0, 0);
const gridStart = new Date(globalStart);
gridStart.setDate(gridStart.getDate() - globalStart.getDay());
const totalDays = Math.floor((endDate - gridStart) / 86400000) + 1;
const totalWeeks = Math.ceil(totalDays / 7);
const topH = (showYearRow ? yearH : 0) + monthH;
const svgW = labelW + totalWeeks * cellStep + 8;
const svgH = topH + 7 * cellStep + 8;
let maxDaily = 0, total = 0;
for (const k in viewDayMap) { if (viewDayMap[k] > maxDaily) maxDaily = viewDayMap[k]; total += viewDayMap[k]; }
const avgDaily = (total / Math.max(1, avgSpanDays)).toFixed(2);
const cellParts = [], monthLabelPositions = [], yearBoundaries = [];
let lastMonth = -1, lastYear = -1;
const startMs = globalStart.getTime(), endMs = endDate.getTime();
for (let w = 0; w < totalWeeks; w++) {
for (let d = 0; d < 7; d++) {
const cellTs = gridStart.getTime() + (w * 7 + d) * 86400000;
if (cellTs < startMs || cellTs > endMs) continue;
const cellDate = new Date(cellTs);
const key = cellDate.getFullYear() + '-' + String(cellDate.getMonth() + 1).padStart(2, '0') + '-' + String(cellDate.getDate()).padStart(2, '0');
const cnt = viewDayMap[key] || 0;
const x = labelW + w * cellStep, y = topH + d * cellStep;
if (cnt > 0) {
cellParts.push(`${key} · ${cnt} ${T.ptHeatmapAcquired}`);
} else {
// v2.9.22: 区分"已过但无数据"(colors[0]=#161b22) 与"未来"(futureColor=#30363d)——
// 当年视图 8-12 月位置用 futureColor 填充,一眼能看出"时间还没到"
const isFuture = cellDate.getTime() > today.getTime();
cellParts.push(``);
}
if (d === 0) {
if (cellDate.getMonth() !== lastMonth) {
lastMonth = cellDate.getMonth();
monthLabelPositions.push({ w, month: cellDate.getMonth() });
}
if (showYearRow && cellDate.getFullYear() !== lastYear) {
lastYear = cellDate.getFullYear();
yearBoundaries.push({ w, year: cellDate.getFullYear() });
}
}
}
}
// v2.4.2: 年份分隔线 + 年份标签(参考 v1.58),仅跨年视图渲染;首个边界不画线避免与坐标轴重叠
const yearParts = [];
for (const yb of yearBoundaries) {
if (yb.w > 0) {
const lineX = labelW + yb.w * cellStep - cellGap / 2;
yearParts.push(``);
}
yearParts.push(`${yb.year}`);
}
const monthParts = monthLabelPositions.map(ml =>
`${monthNames[ml.month]}`
).join('');
const dayParts = [];
for (let di = 0; di < 7; di++) {
if (dayNames[di]) dayParts.push(`${dayNames[di]}`);
}
const svgStr = ``;
// v2.4.9: 图例与统计摘要合并为同一行——图例在左,统计在右,避免图例溢出容器右下角
const summary = `
${T.ptHeatmapLess}${colors.map(c => ``).join('')}${T.ptHeatmapMore}
${T.ptHeatmapTotal}: ${total}
${T.ptHeatmapPeak}: ${maxDaily}
${T.ptHeatmapAvg}: ${avgDaily}
${fmtD(globalStart)} ~ ${fmtD(endDate)}
`;
return `${svgStr}
${summary}`;
}
// 预计算各视图:每年一个单年视图 + 跨年"全部"视图
const today = new Date();
today.setHours(0, 0, 0, 0);
const views = {};
for (const y of years) {
const yStart = new Date(y, 0, 1);
// v2.9.22: 当年也画完整 1-12 月(不再截断到 today)—— 8-12 月位置用 futureColor 灰格填满,
// 让"未到时间"以视觉可见的占位块显示,与真实数据区分清楚
const yEnd = new Date(y, 11, 31);
const avgSpan = Math.floor((yEnd - yStart) / 86400000) + 1;
views[String(y)] = buildView(yearDayMaps[y], yStart, yEnd, avgSpan, false);
}
const allAvgSpan = Math.max(1, Math.ceil((lastTs - firstTs) / 86400) + 1);
views.all = buildView(dayMap, new Date(firstTs * 1000), today, allAvgSpan, true);
// 年份选择器(最新在前,末尾追加"全部"),默认展示最新年份;仅一年数据时无需选择器
let filterHtml = '', defaultView = 'all';
if (years.length > 1) {
const latestYear = String(years[years.length - 1]);
const btns = [...years].reverse().map(y =>
``
).join('');
filterHtml = `${btns}
`;
defaultView = latestYear;
}
// v2.6.1: 入库热力图与近6月入库增量分成两个独立容器——主热力图占满容器宽度,增量柱图作为独立 section 在 wrap 下方展示(不在热力图节点内)
const html = `
${filterHtml}
${views[defaultView]}
`;
// v2.3.31: 写入渲染缓存(v2.4.2 起含各年份视图,供年份切换直接复用)
_heatmapRenderCache = { signature: sig, html, ts: Date.now(), views };
return html;
}
// v2.4.4: 近6月入库增量迷你柱状图——展示于入库热力图右下角
// 输入与热力图相同(含 acquiredTime 的游戏数组);输出带标题的紧凑 SVG(6 根彩色柱 + 数值标签 + 网格线)
function renderMonthlyAcquireChart(heatGames) {
const now = new Date();
const months = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push({ y: d.getFullYear(), m: d.getMonth(), count: 0 });
}
const startTs = new Date(months[0].y, months[0].m, 1).getTime() / 1000;
for (const g of heatGames) {
const t = g.acquiredTime;
if (!t || t < startTs) continue;
const d = new Date(t * 1000);
const idx = (d.getFullYear() - months[0].y) * 12 + (d.getMonth() - months[0].m);
if (idx >= 0 && idx < 6) months[idx].count++;
}
const max = Math.max(...months.map(o => o.count));
const BAR_COLORS = ['#14b8a6', '#3b82f6', '#f59e0b', '#22c55e', '#ef4444', '#8b5cf6'];
// v2.4.7: 侧栏紧凑尺寸(适配 260px 宽右栏,独立布局)
// v2.9.26: 加高 SVG (H 128→200) 让柱图更方正,在高瘦右栏里减少上下留白
const W = 280, H = 200, lw = 32, topPad = 20, botPad = 24;
const plotW = W - lw - 2, plotH = H - topPad - botPad;
const step = plotW / 6, barW = Math.min(26, step * 0.6);
const yMax = max > 0 ? max : 1;
const yOf = v => topPad + plotH - (v / yMax) * plotH;
let parts = '';
// 水平网格线 + Y 轴标签(0 / 半值 / 峰值,去重)
const gridVals = max > 0 ? [...new Set([0, Math.ceil(max / 2), max])] : [0, 1];
for (const v of gridVals) {
const y = yOf(v);
parts += ``;
parts += `${v}`;
}
months.forEach((o, i) => {
const cx = lw + step * i + step / 2;
const bh = max > 0 ? (o.count / yMax) * plotH : 0;
const bx = cx - barW / 2, by = topPad + plotH - bh;
const color = BAR_COLORS[i % BAR_COLORS.length];
// v2.9.24: count=0 的柱子显示极小高度 + 半透明,避免"标签无柱"困惑
const isZero = o.count === 0;
const renderBh = isZero ? 2 : Math.max(bh, 0);
const renderBy = isZero ? topPad + plotH - 2 : by;
const fillOpacity = isZero ? '0.25' : '1';
parts += `${o.y}-${String(o.m + 1).padStart(2, '0')} · ${o.count}${isZero ? (isZh ? ' (进行中)' : ' (in progress)') : ''}`;
if (o.count > 0) {
parts += `${o.count}`;
}
const lbl = String(o.y).slice(2) + '.' + String(o.m + 1).padStart(2, '0');
parts += `${lbl}`;
});
// v2.9.23: 增加标题栏统计(总数 + 峰值/低谷标注),参考设计图
const totalAcq = months.reduce((s, o) => s + o.count, 0);
const maxIdx = months.indexOf(months.reduce((mx, o) => o.count > mx.count ? o : mx, months[0]));
const minIdx = months.indexOf(months.reduce((mn, o) => o.count < mn.count ? o : mn, months[0]));
const maxMo = String(months[maxIdx].y).slice(2) + '.' + String(months[maxIdx].m + 1).padStart(2, '0');
const minMo = String(months[minIdx].y).slice(2) + '.' + String(months[minIdx].m + 1).padStart(2, '0');
const summaryHtml = `
${totalAcq} ${isZh ? '总计' : 'total'}
${isZh ? '峰值' : 'peak'} ${max} · ${maxMo}
${isZh ? '低谷' : 'low'} ${months[minIdx].count} · ${minMo}
`;
return `
${T.ptHeatmapMini6m} · ${totalAcq}
${summaryHtml}
`;
}
// v2.9.22: 家庭成员对比骨架——4 行灰色 bar row(与最终行高/列宽一致,避免 layout shift)
function buildFamilyCompareSkeleton(rows = 4) {
// 不同宽度的 bar 让骨架看起来更接近真实数据分布(最上面"我"略长,其他成员递减)
const widths = [72, 55, 38, 22];
const rowHtml = [];
for (let i = 0; i < rows; i++) {
const w = widths[i] || 18;
rowHtml.push(
``
);
}
return rowHtml.join('');
}
// v2.3.28: 提取家庭组对比为独立函数——供游玩仪表和游玩数据标签页共用
// v2.3.30: 新增 maxMembers 参数(默认6),不足时补齐"待加入"占位行
// v2.3.31: visibleRows=4 控制默认可见行数,超出部分纵向滚动;pending 只补齐到 visibleRows
// v2.9.22: 默认渲染 4 行灰色骨架行(与最终 bar row 样式一致),避免加载中底部大片空白
function createFamilyCompareSection(totalMinutes, maxMembers = 6, visibleRows = 4) {
const familySection = document.createElement('div');
familySection.className = 'sglv-family-compare-section';
familySection.innerHTML = `${ICONS.share} ${T.ptFamilyCompare}
`;
const familyContainer = document.createElement('div');
familyContainer.className = 'sglv-family-compare-container';
familyContainer.innerHTML = buildFamilyCompareSkeleton(visibleRows);
familySection.appendChild(familyContainer);
const apiKey = storage.getApiKey();
const mySteamId = getActiveSteamId();
async function renderFamilyCompare() {
const authToken = await getAccessToken().catch(() => null);
const hasApiKey = !!apiKey;
const hasToken = !!authToken;
let familyInfo = storage.getFamilyInfo() || {};
let nameMap = familyInfo.steamIdtoName || {};
let members = familyInfo.family_member || [];
let familyName = familyInfo.family_name || '';
if (Object.keys(nameMap).length === 0 || members.length === 0) {
// 保留骨架,仅在 familyContainer 顶部覆盖一行小提示
if (authToken) {
try {
const freshInfo = await fetchFamilyInfo(authToken);
if (freshInfo) {
storage.setFamilyInfo(freshInfo);
nameMap = freshInfo.steamIdtoName || {};
members = freshInfo.family_member || [];
familyName = freshInfo.family_name || '';
}
} catch { /* ignore */ }
}
}
const titleEl = familySection.querySelector('.sglv-playtime-section-title');
if (titleEl) {
const namePart = familyName ? `${familyName} · ` : '';
titleEl.innerHTML = `${ICONS.share} ${namePart}${T.ptFamilyCompare}`;
}
if (!mySteamId || Object.keys(nameMap).length === 0) {
let reason = '';
if (!hasApiKey && !hasToken) reason = T.ptNoApiKey;
else if (hasToken && Object.keys(nameMap).length === 0) reason = T.ptNoFamily;
else reason = T.ptNoToken;
familyContainer.innerHTML = `
${reason}
`;
familyContainer.querySelector('#sglv-family-retry')?.addEventListener('click', () => {
storage.setFamilyInfo({});
renderFamilyCompare();
});
return;
}
const myName = nameMap[mySteamId] || (isZh ? '我' : 'Me');
const list = [
{ steamId: String(mySteamId), name: myName, isMe: true },
...Object.entries(nameMap)
.filter(([sid]) => sid !== String(mySteamId))
.map(([sid, name]) => ({ steamId: sid, name: name || ('ID:' + String(sid).slice(-4)), isMe: false })),
];
if (members.length > 0) {
list.forEach(m => {
const fm = members.find(x => x.steamid === m.steamid);
if (fm?.userName) m.name = fm.userName;
});
}
// 保留骨架直到 API 数据返回(避免家庭信息已就绪但成员时长仍在请求时出现空白)
Promise.all(list.map(async m => {
if (m.isMe) {
return { ...m, totalMinutes: totalMinutes };
}
try {
let data;
if (hasApiKey) {
data = await requestSteamAPI(
`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${apiKey}&steamid=${m.steamId}&include_played_free_games=1&format=json`
);
} else if (authToken) {
data = await requestSteamAPI(
`https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?access_token=${authToken}&steamid=${m.steamId}&include_played_free_games=1`
);
} else {
return { ...m, totalMinutes: -1 };
}
const gList = data?.response?.games || [];
return { ...m, totalMinutes: gList.reduce((s, g) => s + (g.playtime_forever || 0), 0) };
} catch {
return { ...m, totalMinutes: -1 };
}
})).then(results => {
const valid = results.filter(r => r.totalMinutes >= 0);
if (valid.length === 0) {
familyContainer.innerHTML = `${isZh ? '获取失败,成员资料可能为私密' : 'Failed - profiles may be private'}
`;
return;
}
const maxMin = Math.max(1, ...valid.map(r => r.totalMinutes));
// v2.3.30: 最多展示 maxMembers 条,按游玩时长降序取前 N;不足则补齐"待加入"占位行
const sorted = valid.sort((a, b) => b.totalMinutes - a.totalMinutes);
const displayList = sorted.slice(0, maxMembers);
const pendingCount = Math.max(0, visibleRows - displayList.length);
const memberRows = displayList.map(m => {
const hours = (m.totalMinutes / 60).toFixed(1);
const pct = (m.totalMinutes / maxMin * 100).toFixed(1);
const isMeClass = m.isMe ? ' is-me' : '';
return ``;
}).join('');
const pendingRows = pendingCount > 0 ? Array.from({ length: pendingCount }, () =>
``
).join('') : '';
familyContainer.innerHTML = memberRows + pendingRows;
}).catch(() => {
familyContainer.innerHTML = `${isZh ? '获取失败' : 'Failed to fetch'}
`;
});
}
renderFamilyCompare();
return familySection;
}
function formatLastPlayedShort(ts) {
if (!ts || ts <= 0) return T.ptNever;
const diff = Date.now() / 1000 - ts;
const days = Math.floor(diff / 86400);
if (days <= 0) return isZh ? '今天' : 'Today';
if (days === 1) return isZh ? '昨天' : 'Yesterday';
if (days < 30) return `${days} ${T.ptDays}`;
if (days < 365) return isZh ? `${Math.floor(days / 30)} 个月前` : `${Math.floor(days / 30)} months ago`;
return isZh ? `${Math.floor(days / 365)} 年前` : `${Math.floor(days / 365)} years ago`;
}
// ==================== v2.9.95: 六维雷达图组件 ====================
// 参考体育竞技管理游戏 UI 风格,将玩家游戏数据可视化为六维能力雷达图
// 维度:库存规模、游玩时长、游玩率、深度完成、平均时长、铁杆指数
// v2.9.97: 每个维度采用不同炫彩颜色,避免全紫色单调
function renderPlaytimeRadar(games, stats) {
const cx = 110, cy = 110, R = 80;
// 六维炫彩配色 — 每个维度独立颜色,区分不同雷达趋势
const dimColors = ['#22d3ee', '#f59e0b', '#34d399', '#f472b6', '#38bdf8', '#fb7185'];
const dims = [
{ label: isZh ? '库存规模' : 'Library', value: Math.min(100, (stats.totalGames / 500) * 100), display: stats.totalGames.toString(), color: dimColors[0] },
{ label: isZh ? '游玩时长' : 'Hours', value: Math.min(100, (stats.totalHours / 5000) * 100), display: stats.totalHours.toFixed(0) + 'h', color: dimColors[1] },
{ label: isZh ? '游玩率' : 'Play Rate', value: stats.playRate, display: stats.playRate + '%', color: dimColors[2] },
{ label: isZh ? '深度完成' : 'Completion', value: stats.completionRate, display: stats.completionRate + '%', color: dimColors[3] },
{ label: isZh ? '平均时长' : 'Avg Hours', value: Math.min(100, (stats.avgHours / 50) * 100), display: stats.avgHours.toFixed(1) + 'h', color: dimColors[4] },
{ label: isZh ? '铁杆指数' : 'Hardcore', value: Math.min(100, (stats.hardcoreRate || 0)), display: (stats.hardcoreRate || 0) + '%', color: dimColors[5] },
];
const n = dims.length;
const angleStep = (Math.PI * 2) / n;
const startAngle = -Math.PI / 2;
// 渐变定义 — 多彩渐变用于雷达区域填充
let defsSvg = `
`;
// 网格圈(4层)
let gridSvg = '';
for (let layer = 1; layer <= 4; layer++) {
const r = (R / 4) * layer;
let pts = '';
for (let i = 0; i < n; i++) {
const a = startAngle + i * angleStep;
pts += `${cx + r * Math.cos(a)},${cy + r * Math.sin(a)} `;
}
gridSvg += ``;
}
// 轴线 + 标签(每个维度使用独立颜色)
let axisSvg = '';
let labelSvg = '';
for (let i = 0; i < n; i++) {
const a = startAngle + i * angleStep;
const x2 = cx + R * Math.cos(a);
const y2 = cy + R * Math.sin(a);
axisSvg += ``;
// 标签位置(轴外侧)
const lr = R + 18;
const lx = cx + lr * Math.cos(a);
const ly = cy + lr * Math.sin(a);
const dim = dims[i];
labelSvg += `${dim.label}`;
labelSvg += `${dim.display}`;
}
// 数据区域 + 数据点(每个点使用对应维度颜色)
let dataPts = '';
let pointSvg = '';
for (let i = 0; i < n; i++) {
const a = startAngle + i * angleStep;
const r = R * (dims[i].value / 100);
const x = cx + r * Math.cos(a);
const y = cy + r * Math.sin(a);
dataPts += `${x},${y} `;
pointSvg += ``;
}
return ``;
}
// v2.9.95: 雷达图右侧统计面板
// v2.9.97: 每个维度使用匹配的炫彩颜色
function renderRadarStatsPanel(games, stats) {
const statColors = ['#22d3ee', '#f59e0b', '#34d399', '#f472b6', '#38bdf8', '#fb7185'];
const items = [
{ icon: ICONS.library, label: isZh ? '游戏总数' : 'Total Games', val: stats.totalGames.toLocaleString(), pct: Math.min(100, (stats.totalGames / 500) * 100), color: statColors[0] },
{ icon: ICONS.clock, label: isZh ? '总时长' : 'Total Hours', val: stats.totalHours.toFixed(1) + 'h', pct: Math.min(100, (stats.totalHours / 5000) * 100), color: statColors[1] },
{ icon: ICONS.game, label: isZh ? '已启动' : 'Played', val: stats.playedCount.toLocaleString(), pct: stats.playRate, color: statColors[2] },
{ icon: ICONS.trophy, label: isZh ? '深度游玩' : 'Deep Play', val: stats.longGames + (isZh ? '款' : ' games'), pct: stats.completionRate, color: statColors[3] },
{ icon: ICONS.trend, label: isZh ? '平均时长' : 'Avg Hours', val: stats.avgHours.toFixed(1) + 'h', pct: Math.min(100, (stats.avgHours / 50) * 100), color: statColors[4] },
{ icon: ICONS.barChart, label: isZh ? '铁杆指数' : 'Hardcore', val: (stats.hardcoreRate || 0) + '%', pct: stats.hardcoreRate || 0, color: statColors[5] },
];
return items.map(it => `
`).join('');
}
function renderPlaytimeTab(parent) {
const wrap = document.createElement('div');
wrap.className = 'sglv-playtime-wrap';
if (state.ownedGames.length === 0) {
wrap.innerHTML = `${T.ptNoData}
`;
parent.appendChild(wrap);
return;
}
// 获取游戏列表(考虑家庭组共享开关)
const showFamilyShared = storage.getShowFamilyShared();
const cacheK = cacheKey('pt-games', showFamilyShared, state.ownedGames.length, state.ownedGames.reduce((s, g) => s + (g.playtime || 0), 0));
const games = getCached(cacheK, () => showFamilyShared ? [...state.ownedGames] : state.ownedGames.filter(g => isGameOwnedByMe(g)));
// 统计数据
const totalGames = games.length;
// v2.9.22: 与游戏橱窗标签页口径统一——取 max(含DLC, 不含DLC) 作为总时长,
// 避免库存 KPI 4021.5h / 统计仪表 KPI 4210.3h 这种不一致。
const realGamesForTotal = games.filter(g => !isDlc(g.appid));
const realGamesMinutes = realGamesForTotal.reduce((s, g) => s + (g.playtime || 0), 0);
const allGamesMinutes = games.reduce((s, g) => s + (g.playtime || 0), 0);
const totalMinutes = Math.max(realGamesMinutes, allGamesMinutes);
const totalHours = totalMinutes / 60;
const playedGames = games.filter(g => (g.playtime || 0) > 0);
const playedCount = playedGames.length;
const unplayedCount = totalGames - playedCount;
const avgHours = playedCount > 0 ? totalHours / playedCount : 0;
// v2.9.22: 游玩率(已启动/总数) + 完成度(深度游玩 ≥10h / 已启动)
const longGames = playedGames.filter(g => (g.playtime || 0) >= 600).length; // 600 min = 10h
const playRate = totalGames > 0 ? Math.round(playedCount / totalGames * 100) : 0;
const completionRate = playedCount > 0 ? Math.round(longGames / playedCount * 100) : 0;
// ====== KPI 数据卡片 ======
const kpiSection = document.createElement('div');
kpiSection.className = 'sglv-playtime-kpi-row';
const ptPlayedPct = playRate;
const ptUnplayedPct = totalGames > 0 ? Math.round(unplayedCount / totalGames * 100) : 0;
kpiSection.innerHTML = `
${totalHours.toFixed(1)}
${T.kpiHours} · ${T.kpiAvgHours} ${avgHours.toFixed(1)}${T.kpiPerGame}
${avgHours.toFixed(1)}
${T.kpiHours}${T.kpiPerGame}
${playedCount.toLocaleString()}
${T.kpiRate} ${ptPlayedPct}%
${unplayedCount.toLocaleString()}
${T.kpiRate} ${ptUnplayedPct}%
${playRate}%
${T.kpiPlayRateSub}
${completionRate}%
${longGames} ${T.kpiCompletionSub}
`;
wrap.appendChild(kpiSection);
// ====== v2.9.95: 六维雷达图 + 统计面板 ======
const hardcoreGames = playedGames.filter(g => (g.playtime || 0) >= 6000).length; // 6000 min = 100h
const hardcoreRate = playedCount > 0 ? Math.round(hardcoreGames / playedCount * 100) : 0;
const radarStats = { totalGames, totalHours, playedCount, longGames, avgHours, playRate, completionRate, hardcoreRate };
const radarRow = document.createElement('div');
radarRow.className = 'sglv-radar-row';
radarRow.innerHTML = `
${ICONS.barChart || ''} ${isZh ? '玩家画像雷达' : 'Player Radar'}
${renderPlaytimeRadar(games, radarStats)}
${renderRadarStatsPanel(games, radarStats)}
`;
wrap.appendChild(radarRow);
// ====== 1. 分布图 + 趋势图 左右两栏 ======
const chartRow = document.createElement('div');
chartRow.className = 'sglv-playtime-row';
// 左侧:时长分布甜甜圈(v2.9.25: 默认总时长维度,toggle 切换游戏数|总时长)
const distCol = document.createElement('div');
distCol.className = 'sglv-playtime-col';
distCol.innerHTML = `
${ICONS.barChart} ${T.ptDistTitle}
`;
chartRow.appendChild(distCol);
// v2.9.25: 默认渲染总时长维度(与"时长分布"标题语义一致)
let _distMode = 'hours';
const renderDonutByMode = (mode) => {
const dd = calcPlaytimeDist(games, mode);
if (mode === 'hours') {
const totalH = dd.reduce((s, d) => s + d.count, 0);
distCol.querySelector('#sglv-pt-donut').innerHTML = renderPlaytimeDonut(dd, totalH, 'h');
} else {
distCol.querySelector('#sglv-pt-donut').innerHTML = renderPlaytimeDonut(dd, totalGames, '');
}
};
renderDonutByMode(_distMode);
// toggle 绑定
distCol.querySelector('.sglv-trend-filter').addEventListener('click', e => {
const btn = e.target.closest('[data-dist]');
if (!btn) return;
distCol.querySelectorAll('[data-dist]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_distMode = btn.dataset.dist;
renderDonutByMode(_distMode);
});
// 右侧:时长趋势折线
const trendCol = document.createElement('div');
trendCol.className = 'sglv-playtime-col';
trendCol.innerHTML = `
${ICONS.trend} ${T.ptTrendTitle}
${renderPlaytimeTrend(games, 'month')}
`;
chartRow.appendChild(trendCol);
wrap.appendChild(chartRow);
// 绑定趋势筛选
trendCol.querySelector('.sglv-trend-filter').addEventListener('click', e => {
const btn = e.target.closest('.sglv-trend-btn');
if (!btn) return;
trendCol.querySelectorAll('.sglv-trend-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
trendCol.querySelector('#sglv-pt-trend').innerHTML = renderPlaytimeTrend(games, btn.dataset.mode);
});
// v2.3.30: 入库热力图(替换原家庭组对比——该对比已在"游玩数据"标签页展示,避免重复)
// v2.6.3: 左右分栏——热力图 + 近6月入库增量 并排展示
// v2.9.22: 默认渲染空白骨架占位(年份框架 + 灰格),避免加载中底部大片空白
const heatmapRow = document.createElement('div');
heatmapRow.className = 'sglv-heatmap-acquire-row';
wrap.appendChild(heatmapRow);
const heatmapSection = document.createElement('div');
heatmapSection.className = 'sglv-heatmap-section';
heatmapSection.innerHTML = `
${ICONS.grid} ${T.ptLibHeatmap}
${buildAcquiredHeatmapSkeleton()}
`;
heatmapRow.appendChild(heatmapSection);
// v2.6.3: 近6月入库增量作为右栏容器——与入库热力图左右并排
// v2.9.22: 默认渲染 6 根灰条骨架,异步数据回来后替换
const acquireSection = document.createElement('div');
acquireSection.className = 'sglv-heatmap-mini-section';
acquireSection.id = 'sglv-pt-acquire-mini';
acquireSection.innerHTML = buildMonthlyAcquireSkeleton();
heatmapRow.appendChild(acquireSection);
// 异步加载含 acquiredTime 的家庭组库数据,按当前 games 列表(已遵循"显示家庭共享"开关)匹配入库时间
(async () => {
const heatmapEl = heatmapSection.querySelector('#sglv-pt-heatmap');
try {
const familyGames = await fetchTimelineFamilyGames();
if (!familyGames || familyGames.length === 0) {
heatmapEl.innerHTML = `${T.ptHeatmapNoData}
`;
acquireSection.innerHTML = `${T.ptHeatmapNoData}
`;
return;
}
const acquiredMap = {};
for (const g of familyGames) {
if (g.acquiredTime && g.acquiredTime > 0) acquiredMap[g.appid] = g.acquiredTime;
}
const heatGames = games
.filter(g => acquiredMap[g.appid])
.map(g => ({ appid: g.appid, acquiredTime: acquiredMap[g.appid] }));
heatmapEl.innerHTML = renderAcquiredHeatmap(heatGames);
// v2.6.1: 近6月入库增量——渲染到独立的 acquireSection 容器,与热力图平级,避免布局错位
acquireSection.innerHTML = renderMonthlyAcquireChart(heatGames);
// v2.4.2: 渲染后默认滚动到最右侧,直接展示最新数据(参考 v1.58)
const scrollHmRight = () => requestAnimationFrame(() => {
const s = heatmapEl.querySelector('.sglv-heatmap-scroll');
if (s) s.scrollLeft = s.scrollWidth;
});
// v2.4.2: 绑定年份选择器——点击切换单年/全部视图(各视图 HTML 已在渲染缓存中预计算)
const hmFilter = heatmapEl.querySelector('.sglv-hm-year-filter');
const hmBody = heatmapEl.querySelector('.sglv-heatmap-body');
if (hmFilter && hmBody) {
hmFilter.addEventListener('click', e => {
const btn = e.target.closest('.sglv-hm-year-btn');
if (!btn || !_heatmapRenderCache || !_heatmapRenderCache.views) return;
const view = _heatmapRenderCache.views[btn.dataset.year];
if (!view) return;
hmFilter.querySelectorAll('.sglv-hm-year-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
hmBody.innerHTML = view;
scrollHmRight();
});
}
scrollHmRight();
} catch (e) {
console.warn('[SGLV] 入库热力图加载失败:', e);
heatmapEl.innerHTML = `${T.ptHeatmapNoData}
`;
acquireSection.innerHTML = `${T.ptHeatmapNoData}
`;
}
})();
parent.appendChild(wrap);
}
// ====== 游玩数据标签页 (Top 20 + 全部游戏) ======
function renderPlaydataTab(parent) {
const wrap = document.createElement('div');
wrap.className = 'sglv-playtime-wrap';
wrap.style.flexDirection = 'column';
if (state.ownedGames.length === 0) {
wrap.innerHTML = `${T.ptNoData}
`;
parent.appendChild(wrap);
return;
}
const showFamilyShared = storage.getShowFamilyShared();
const cacheK = cacheKey('pt-games', showFamilyShared, state.ownedGames.length, state.ownedGames.reduce((s, g) => s + (g.playtime || 0), 0));
const games = getCached(cacheK, () => showFamilyShared ? [...state.ownedGames] : state.ownedGames.filter(g => isGameOwnedByMe(g)));
// ====== Top 20 + 全部游戏 左右分栏 ======
const sortedGames = [...games].sort((a, b) => (b.playtime || 0) - (a.playtime || 0));
const top20 = sortedGames.slice(0, 20);
const maxPlaytime = top20.length > 0 ? (top20[0].playtime || 0) : 1;
const splitRow = document.createElement('div');
splitRow.className = 'sglv-playtime-split-row';
wrap.appendChild(splitRow);
// ====== 左侧:Top 20 ======
const topSection = document.createElement('div');
topSection.className = 'sglv-playtime-split-left';
topSection.innerHTML = `
${ICONS.trend} ${T.ptTopTitle}
`;
splitRow.appendChild(topSection);
const topListEl = topSection.querySelector('#sglv-pt-top-list');
if (top20.length === 0 || maxPlaytime === 0) {
topListEl.innerHTML = `${T.ptNoData}
`;
} else {
const frag = document.createDocumentFragment();
top20.forEach((game, i) => {
const playtimeMin = game.playtime || 0;
const pct = maxPlaytime > 0 ? (playtimeMin / maxPlaytime * 100).toFixed(1) : '0';
const playtimeH = playtimeMin > 0 ? (playtimeMin / 60).toFixed(1) + 'h' : T.ptRange0;
const rankClass = i === 0 ? 'rank-1' : i === 1 ? 'rank-2' : i === 2 ? 'rank-3' : '';
const rankBadgeClass = i === 0 ? 'r1' : i === 1 ? 'r2' : i === 2 ? 'r3' : '';
const iconUrl = getGameIconUrl(game.appid, game.icon);
const lastPlayedText = game.lastPlayed > 0 ? formatLastPlayedShort(game.lastPlayed) : T.ptNever;
const item = document.createElement('div');
item.className = `sglv-pt-top-item ${rankClass}`;
item.innerHTML = `
${i + 1}
${game.name}
${T.ptLastPlayed}: ${lastPlayedText}
${playtimeH}
`;
item.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${game.appid}`, '_blank'));
frag.appendChild(item);
});
topListEl.appendChild(frag);
// v2.3.33:异步加载游戏中文名
topListEl.querySelectorAll('[data-sglv-appid]').forEach(el => {
loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent);
});
}
// ====== 右侧:全部游戏(分页) ======
const PT_ALL_PAGE_SIZE = 50;
let ptAllPage = 1;
let ptAllQuery = '';
const allSection = document.createElement('div');
allSection.className = 'sglv-playtime-split-right';
allSection.innerHTML = `
${ICONS.list} ${T.ptAllTitle}
`;
splitRow.appendChild(allSection);
const allListEl = allSection.querySelector('#sglv-pt-all-list');
const searchInput = allSection.querySelector('#sglv-pt-search');
const countEl = allSection.querySelector('#sglv-pt-count');
const ptPagination = allSection.querySelector('#sglv-pt-pagination');
function renderAllPlaytimeList(filterQuery, page = 1) {
ptAllQuery = filterQuery;
ptAllPage = page;
let displayGames = sortedGames;
if (filterQuery) {
const q = filterQuery.toLowerCase();
displayGames = sortedGames.filter(g => g.name.toLowerCase().includes(q));
}
// v2.4.0: 复用 paginate 纯函数统一分页计算
const { pageItems: pageGames, totalPages, page: _clampedPage } = paginate(displayGames, ptAllPage, PT_ALL_PAGE_SIZE);
ptAllPage = _clampedPage;
const ptAllStart = (ptAllPage - 1) * PT_ALL_PAGE_SIZE; // v2.4.1: 修复 globalRank 计算缺失 start 变量
countEl.textContent = `${displayGames.length} ${isZh ? '款' : 'items'}`;
allListEl.innerHTML = '';
if (displayGames.length === 0) {
allListEl.innerHTML = `${isZh ? '无匹配游戏' : 'No matching games'}
`;
ptPagination.innerHTML = '';
return;
}
const allMaxPlaytime = pageGames.length > 0 ? Math.max(...pageGames.map(g => g.playtime || 0)) : 1;
const frag = document.createDocumentFragment();
pageGames.forEach((game, i) => {
const playtimeMin = game.playtime || 0;
const pct = allMaxPlaytime > 0 ? (playtimeMin / allMaxPlaytime * 100).toFixed(1) : '0';
const playtimeH = playtimeMin > 0 ? (playtimeMin / 60).toFixed(1) + 'h' : T.ptRange0;
const globalRank = ptAllStart + i + 1;
const item = document.createElement('div');
item.className = 'sglv-pt-all-item';
item.innerHTML = `
${globalRank}
${game.name}
${playtimeH}
`;
item.addEventListener('click', () => window.open(`https://store.steampowered.com/app/${game.appid}`, '_blank'));
frag.appendChild(item);
});
allListEl.appendChild(frag);
// v2.3.33:异步加载游戏中文名
allListEl.querySelectorAll('[data-sglv-appid]').forEach(el => {
loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent);
});
// v2.9.25: 统一使用 renderPagination,支持快捷翻页
const _ptTotal = displayGames.length;
renderPagination(ptPagination, 'sglv-pt', ptAllPage, totalPages, _ptTotal,
() => renderAllPlaytimeList(ptAllQuery, ptAllPage - 1),
() => renderAllPlaytimeList(ptAllQuery, ptAllPage + 1),
(p) => renderAllPlaytimeList(ptAllQuery, p));
}
renderAllPlaytimeList('');
// v2.9.49: debounce 搜索输入,避免游玩时长列表每次按键触发完整重渲染
searchInput.addEventListener('input', debounce((e) => { renderAllPlaytimeList(e.target.value, 1); }, 200));
// v2.4.4: 移除左下角重复的时长分布图("统计仪表"标签页已有),家庭组时长对比独占整行
// v2.7.8: 恢复整行设计,不再与左侧对齐;Top20 列表撑满 splitRow 高度
// v2.9.22: 家庭成员对比紧凑放到右半侧(allSection 底部),Top 20 撑到页面底部获得更大视野
const totalMinutes = games.reduce((s, g) => s + (g.playtime || 0), 0);
allSection.appendChild(createFamilyCompareSection(totalMinutes));
parent.appendChild(wrap);
}
// ==================== 愿望单标签页 (v2.3.19 新增) ====================
// v2.9.29: wlEscape 委托至全局 escHtml,统一 HTML 转义实现(DRY)
function wlEscape(s) {
return escHtml(s);
}
let _wlPriceSym = '';
// v2.9.101: 模块级币种代码记录(formatted 价格文本推断,供 wlCurrencyOf 兜底链使用;不改动 detectWlPriceSymbol 本身)
let _wlPriceCode = '';
function detectWlPriceSymbol(sampleText) {
if (_wlPriceSym) return _wlPriceSym;
const text = String(sampleText || document.querySelector('#header_wallet_balance')?.textContent || '');
const m = text.match(/(HK\$|NT\$|A\$|CDN\$|Mex\$|S\$|R\$|₩|€|£|¥|\$|₽|₹|฿|₫|RM)/);
_wlPriceSym = m ? m[1] : '¥';
return _wlPriceSym;
}
// 已在库判定集合:优先使用面板当前生效的库存集合(尊重"显示家庭共享"开关),
// 未加载库存时回退 GDynamicStore.s_rgOwnedApps(SteamPeek 方式)
function getWishlistOwnedIds() {
try {
const ids = getActiveOwnedAppIds();
if (ids && ids.size > 0) return ids;
} catch { /* ignore */ }
return getDynamicStoreAppIds('owned');
}
// v2.7.2: 即将发售多源判定(参考 Steam-Wishlist-Sidebar-1.0.9)
// 优先级:is_coming_soon 字段 > release.is_coming_soon > 未来时间戳 > release_string 措辞
// 支持 true→false 回退(游戏已发售时纠正旧缓存)
const WL_COMING_SOON_RE = /即将推出|即将宣布|coming soon|to be announced|\btba\b|\btbd\b|\bq[1-4]\b|summer|winter|spring|fall|autumn|^\s*\d{4}\s*$|年第[一二三四1-4]季度|[春夏秋冬]季/i;
function wlResolveComingSoon(opts) {
const rdTs = Number(opts.releaseTs) || 0;
let soon = opts.soonFlag === true || opts.releaseSoon === true;
if (!soon && rdTs > 0 && rdTs * 1000 > Date.now()) soon = true;
if (!soon && opts.releaseStr && WL_COMING_SOON_RE.test(opts.releaseStr)) soon = true;
const known = opts.soonFlag != null || opts.releaseSoon != null || !!opts.hasReleaseObj || rdTs > 0 || soon;
return { soon, known, date: rdTs > 0 ? new Date(rdTs * 1000).toISOString().slice(0, 10) : '' };
}
// 条目规整:兼容 wishlistdata 新旧两种结构,价格单位统一为元,防止 Infinity/NaN
function normalizeWishlistEntry(info) {
const appid = Number(info.appid) || 0;
let finalPrice = 0, originalPrice = 0, discountPct = 0;
let hasPriceInfo = false;
const sub = Array.isArray(info.subs) && info.subs.length > 0 ? info.subs[0] : null;
if (sub) {
hasPriceInfo = true;
finalPrice = (Number(sub.price) || 0) / 100;
discountPct = Number(sub.discount_pct) || 0;
originalPrice = (discountPct > 0 && discountPct < 100) ? finalPrice / (1 - discountPct / 100) : finalPrice;
}
if (info.best_purchase_option) {
hasPriceInfo = true;
const bpo = info.best_purchase_option;
finalPrice = (Number(bpo.final_price_in_cents) || 0) / 100;
originalPrice = (Number(bpo.original_price_in_cents) || 0) / 100;
if (originalPrice === 0) originalPrice = finalPrice;
discountPct = Number(bpo.discount_pct || 0);
if (bpo.formatted_final_price) detectWlPriceSymbol(bpo.formatted_final_price);
// v2.9.101: 旁路推断币种代码(软降级:lib 缺失/异常不影响原流程)
try { if (hasSglvCurrency && bpo.formatted_final_price) { const c = CUR.detectFromText(bpo.formatted_final_price); if (c) _wlPriceCode = c; } } catch {}
}
if (!Number.isFinite(finalPrice) || finalPrice < 0) finalPrice = 0;
if (!Number.isFinite(originalPrice) || originalPrice < 0) originalPrice = finalPrice;
if (!Number.isFinite(discountPct) || discountPct < 0 || discountPct > 100) discountPct = 0;
const name = (info.name && String(info.name).trim()) || '';
// v2.7.2: 即将发售多源判定(wishlistdata 的 is_coming_soon / release_date 时间戳 / release_string 措辞)
const cs = wlResolveComingSoon({
soonFlag: info.is_coming_soon,
releaseTs: info.release_date,
releaseStr: info.release_string,
});
return {
appid,
name: name || `App ${appid}`,
priority: Number(info.priority) || 0,
added: Number(info.added || info.date_added) || 0,
finalPrice, originalPrice, discountPct,
isFree: !!info.is_free || (hasPriceInfo && finalPrice === 0 && discountPct === 0),
isComingSoon: cs.soon,
releaseDate: cs.date,
_releaseKnown: cs.known,
type: String(info.type || 'game').toLowerCase(),
tags: normalizeWishlistTags(info.tags),
// v2.3.23: 用户自定义类别 ID 列表(新版愿望单"我的类别",统一为字符串避免 uint64 精度问题)
categoryIds: Array.isArray(info.category_ids) ? info.category_ids.map(id => String(id)).filter(s => s && s !== '0') : [],
_priced: hasPriceInfo,
};
}
// 标签规整:兼容字符串数组与 {name}/{tagid,name} 对象数组
function normalizeWishlistTags(raw) {
if (!Array.isArray(raw)) return [];
return raw.map(t => typeof t === 'string' ? t : (t && (t.name || t.description)) || '').filter(Boolean).slice(0, 20);
}
// 来源2:store wishlistdata 分页(含名称/价格/折扣,信息最全)
async function fetchWishlistFromStore(steamId) {
const out = [];
if (!steamId) return out;
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/`;
for (let p = 0; p < 60; p++) {
let data = null;
try {
data = await requestSteamAPI(`${base}?p=${p}`);
} catch { break; }
if (!data) break;
const entries = Array.isArray(data) ? data : Object.entries(data).map(([k, v]) => ({ appid: Number(k), ...(v || {}) }));
if (entries.length === 0) break;
entries.forEach(e => { if (e && e.appid) out.push(e); });
await new Promise(r => setTimeout(r, 200));
}
return out;
}
// 来源2:IPlayerService/GetWishlist API(仅 appid/排序/添加时间)
async function fetchWishlistFromApi(steamId) {
const out = [];
const apiKey = storage.getApiKey();
let authToken = null;
if (!apiKey) { try { authToken = await getAccessToken(); } catch { /* ignore */ } }
let url = null;
if (apiKey) url = `https://api.steampowered.com/IPlayerService/GetWishlist/v1/?key=${apiKey}&steamid=${steamId}&format=json`;
else if (authToken) url = `https://api.steampowered.com/IPlayerService/GetWishlist/v1/?access_token=${authToken}&steamid=${steamId}&format=json`;
if (!url) return out;
try {
const data = await requestSteamAPI(url);
const arr = data?.response?.items || [];
arr.forEach(it => { if (it && it.appid) out.push({ appid: Number(it.appid), priority: it.priority, added: it.date_added }); });
} catch (e) { console.warn('[SGLV] GetWishlist API 失败:', e); }
return out;
}
// v2.3.23: 解析 GetWishlistCategories 响应(返回结构未文档化,防御性兼容多种形态)
function parseWishlistCategoriesResponse(data) {
const out = {};
const resp = (data && typeof data === 'object') ? (data.response || data) : {};
const list = resp.categories || resp.wishlist_categories || (Array.isArray(resp) ? resp : null);
if (Array.isArray(list)) {
list.forEach(c => {
if (!c || typeof c !== 'object') return;
const id = c.categoryid ?? c.category_id ?? c.id;
const name = c.category_name ?? c.name ?? c.label;
if (id != null && name) out[String(id)] = String(name);
});
} else if (resp && typeof resp === 'object') {
// 兼容 { id: name } 或 { id: {category_name} } 映射
Object.entries(resp).forEach(([k, v]) => {
if (typeof v === 'string' && v) out[k] = v;
else if (v && typeof v === 'object') {
const name = v.category_name ?? v.name;
if (name) out[k] = String(name);
}
});
}
return out;
}
// v2.3.23: 获取用户自定义愿望单类别(我的类别)ID -> 名称映射,需 access_token(API Key 兜底)
async function fetchWishlistCategoryNames() {
const tryUrls = [];
try {
const token = await getAccessToken();
if (token) tryUrls.push(`https://api.steampowered.com/IWishlistService/GetWishlistCategories/v1/?access_token=${token}`);
} catch { /* ignore */ }
const apiKey = storage.getApiKey();
if (apiKey) tryUrls.push(`https://api.steampowered.com/IWishlistService/GetWishlistCategories/v1/?key=${apiKey}`);
for (const url of tryUrls) {
try {
const data = await requestSteamAPI(url);
const map = parseWishlistCategoriesResponse(data);
if (Object.keys(map).length > 0) return map;
} catch (e) {
// v2.7.9: 静默忽略 HTML 响应(未登录/API Key 失效时 Steam 返回登录页 HTML),不打印警告避免干扰
if (!String(e.message || e).includes('返回 HTML')) {
console.warn('[SGLV] GetWishlistCategories 失败:', e);
}
}
}
return {};
}
// v2.3.23: 后台静默刷新类别名映射,完成后仅局部刷新左侧仪表盘(不重建封面,避免闪烁)
function refreshWishlistCategoryNames() {
fetchWishlistCategoryNames().then(map => {
if (Object.keys(map).length === 0) return;
state.wishlistCategoryNames = map;
cacheSet('wishlistCatNames', map, 7 * 24 * 3600 * 1000); // 缓存 7 天
if (state.activeTab === 'wishlist' && state.wishlistLoaded) renderWishlistSide();
}).catch(() => {});
}
// v2.7.2: IStoreBrowseService/GetItems 批量补全(参考 Steam-Wishlist-Sidebar-1.0.9)
// 实测匿名可用,单批 100 个 appid、3 路并发,几秒完成数百条,替代绝大多数 appdetails 单条请求
async function enrichWishlistFromStoreBrowse(items, cc, lang, onBatchDone) {
const CHUNK = 100, CONCURRENCY = 3;
const chunks = [];
for (let i = 0; i < items.length; i += CHUNK) chunks.push(items.slice(i, i + CHUNK));
let qIdx = 0, processed = 0;
const applyStoreBrowse = (item, si) => {
if (!si || (si.success !== 1 && si.success !== true)) return;
if (si.name && (item.name.startsWith('App ') || isZh)) item.name = String(si.name);
if (typeof si.type === 'number') {
item.type = ({ 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' })[si.type] || 'other';
} else if (si.type) {
item.type = String(si.type).toLowerCase();
}
if (si.is_free === true) item.isFree = true;
const bpo = si.best_purchase_option;
if (bpo) {
const final = Number(bpo.final_price_in_cents) || 0;
const original = Number(bpo.original_price_in_cents) || 0;
item.finalPrice = final / 100;
item.originalPrice = (original > 0 ? original : final) / 100;
item.discountPct = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0);
if (item.originalPrice === item.finalPrice && item.discountPct > 0 && item.discountPct < 100) {
item.originalPrice = item.finalPrice / (1 - item.discountPct / 100);
}
if (bpo.formatted_final_price) detectWlPriceSymbol(bpo.formatted_final_price);
// v2.9.101: 旁路推断币种代码(软降级:lib 缺失/异常不影响原流程)
try { if (hasSglvCurrency && bpo.formatted_final_price) { const c = CUR.detectFromText(bpo.formatted_final_price); if (c) _wlPriceCode = c; } } catch {}
item._priced = true;
if (final === 0 && item.discountPct === 0) item.isFree = true;
}
// 即将发售判定:顶层 is_coming_soon / release.is_coming_soon / release.steam_release_date 未来时间戳
const rel = si.release || null;
const cs = wlResolveComingSoon({
soonFlag: si.is_coming_soon,
releaseSoon: rel && rel.is_coming_soon,
releaseTs: rel && rel.steam_release_date,
hasReleaseObj: !!rel,
});
if (cs.known) {
item.isComingSoon = cs.soon;
item._releaseKnown = true;
if (cs.date) item.releaseDate = cs.date;
}
item._metaDone = true;
if (si.name && isZh) {
const nc = sglvGameNameCacheLoad();
nc[String(item.appid)] = { name: si.name, ts: Date.now() };
}
};
const worker = async () => {
while (qIdx < chunks.length) {
const chunk = chunks[qIdx++];
try {
const input = {
ids: chunk.map(g => ({ appid: g.appid })),
context: { language: lang, country_code: cc, steam_realm: 1 },
data_request: { include_release: true, include_all_purchase_options: true, include_platforms: true }
};
const url = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input));
const resp = await requestSteamAPI(url);
const storeItems = resp?.response?.store_items || [];
const map = new Map();
storeItems.forEach(si => { if (si && si.appid) map.set(Number(si.appid), si); });
chunk.forEach(g => { const si = map.get(Number(g.appid)); if (si) applyStoreBrowse(g, si); });
} catch (e) { console.warn('[SGLV] GetItems batch failed:', e); }
processed += chunk.length;
if (onBatchDone) onBatchDone(processed, items.length);
await new Promise(r => setTimeout(r, 200));
}
};
const workers = [];
for (let i = 0; i < CONCURRENCY; i++) workers.push(worker());
await Promise.all(workers);
if (isZh) { try { sglvGameNameCacheSave(); } catch (e2) { console.warn('[SGLV] 中文名缓存写入失败:', e2); } }
}
// 资料补全:v2.7.2 三段式(参考 Steam-Wishlist-Sidebar-1.0.9)
// ① IStoreBrowseService/GetItems 批量补全(100 个/批、3 路并发)—— 名称/类型/价格/折扣/发售状态
// ② appdetails 兜底残余(缺名称/价格/发售状态的条目),filters 增加 release_date 判定 coming_soon
async function enrichWishlistMeta(items, onBatchDone) {
const need = items.filter(i => !i._metaDone);
if (need.length === 0) { if (onBatchDone) onBatchDone(0, 0); return; }
let cc = 'CN';
try {
const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
cc = win.g_strCountryCode || 'CN';
} catch { /* ignore */ }
const lang = isZh ? 'schinese' : 'english';
// ① GetItems 批量补全(核心速度优化,替代绝大多数 appdetails 单条请求)
await enrichWishlistFromStoreBrowse(need, cc, lang, onBatchDone);
// ② appdetails 兜底残余:仍缺名称/价格/发售状态的条目
const leftovers = items.filter(i => !i._metaDone && (i.name.startsWith('App ') || !i._priced || !i._releaseKnown));
if (leftovers.length > 0) {
// v2.4.3: filters 限定返回字段,v2.7.2: 增加 release_date 以判定 coming_soon
const filters = 'name,type,is_free,genres,price_overview,release_date';
const enrichBatch = async (batch) => {
const data = await requestSteamAPI(`https://store.steampowered.com/api/appdetails?appids=${batch.map(g => g.appid).join(',')}&filters=${filters}&cc=${cc}&l=${lang}`);
batch.forEach(g => {
const d = data?.[g.appid];
if (!d || !d.success || !d.data) return;
const dd = d.data;
// v2.3.33: 中文环境下 appdetails 使用 l=schinese 请求,返回的为中文名,
// 应始终覆盖 SSR/API 返回的英文名,而非仅覆盖占位符。
if (dd.name && (g.name.startsWith('App ') || isZh)) g.name = dd.name;
if (dd.type) g.type = String(dd.type).toLowerCase();
if (dd.is_free === true) g.isFree = true;
if (Array.isArray(dd.genres) && dd.genres.length > 0) {
g.tags = dd.genres.map(x => x.description).filter(Boolean);
}
const po = dd.price_overview;
if (po && typeof po.final === 'number') {
g.finalPrice = po.final / 100;
g.originalPrice = (po.initial || po.final) / 100;
g.discountPct = po.discount_percent || 0;
// v2.9.101: entry 补 currency 字段(appdetails price_overview 为权威币种码,保证 wlCurrencyOf 不空)
g.currency = po.currency || g.currency;
g.isFree = false;
g._priced = true;
if (po.final_formatted) detectWlPriceSymbol(po.final_formatted);
// v2.9.101: 旁路推断币种代码(软降级:lib 缺失/异常不影响原流程)
try { if (hasSglvCurrency && po.final_formatted) { const c = CUR.detectFromText(po.final_formatted); if (c) _wlPriceCode = c; } } catch {}
}
// v2.7.2: release_date.coming_soon 为权威判定(可回退 true→false,纠正旧缓存)
if (dd.release_date && typeof dd.release_date.coming_soon !== 'undefined') {
g.isComingSoon = !!dd.release_date.coming_soon;
g._releaseKnown = true;
if (dd.release_date.date) g.releaseDate = dd.release_date.date;
}
g._metaDone = true;
// v2.3.33:同步写入中文名缓存,避免 loadGameZhName 对已补全的游戏重复请求 appdetails
if (dd.name && isZh) {
const nc = sglvGameNameCacheLoad();
nc[String(g.appid)] = { name: dd.name, ts: Date.now() };
}
});
// v2.3.33:批量写入后统一落盘一次,避免 forEach 内每条都调 GM_setValue
if (isZh) { try { sglvGameNameCacheSave(); } catch (e2) { console.warn('[SGLV] 中文名缓存写入失败:', e2); } }
};
let done = 0;
for (let i = 0; i < leftovers.length; i += 25) {
const batch = leftovers.slice(i, i + 25);
try { await enrichBatch(batch); } catch { /* 忽略单批失败 */ }
done += batch.length;
if (onBatchDone) onBatchDone(done, leftovers.length);
await new Promise(r => setTimeout(r, 250));
}
// v2.4.3: 对补全失败的条目以更小批次重试一轮——旧实现单批失败后条目永久缺失中文名/价格
const retry = leftovers.filter(i => !i._metaDone);
for (let i = 0; i < retry.length; i += 10) {
const batch = retry.slice(i, i + 10);
try { await enrichBatch(batch); } catch { /* 重试失败保持原样 */ }
await new Promise(r => setTimeout(r, 300));
}
}
if (onBatchDone) onBatchDone(need.length, need.length);
}
// v2.7.2: 后台静默刷新愿望单动态字段(价格/折扣/即将发售)
// 仅用 IStoreBrowseService/GetItems 批量请求(快速),比较新旧值后仅更新有变化的条目并局部刷新 UI
// v2.9.30: 增加 6 小时节流,避免每次进入愿望单标签页都触发 API 请求(缓存已延长至 3 天)
let _wlDynamicRefreshing = false;
let _wlDynamicLastRefresh = 0;
const WL_DYNAMIC_REFRESH_INTERVAL = 6 * 3600 * 1000; // 6 小时节流
async function refreshWishlistDynamicFields(items) {
if (_wlDynamicRefreshing || !items || items.length === 0) return;
// v2.9.30: 6 小时内已刷新过则跳过,避免每次进入标签页都发起 API 请求
if (Date.now() - _wlDynamicLastRefresh < WL_DYNAMIC_REFRESH_INTERVAL) return;
_wlDynamicRefreshing = true;
try {
let cc = 'CN';
try {
const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
cc = win.g_strCountryCode || 'CN';
} catch { /* ignore */ }
const lang = isZh ? 'schinese' : 'english';
// 标记动态字段待刷新:重置 _priced/_releaseKnown 让 enrichWishlistFromStoreBrowse 重新处理
// 但保留 _metaDone 以跳过 appdetails 兜底(名称/类型/标签等静态数据不变)
const refreshItems = items.map(g => {
const copy = Object.assign({}, g);
copy._priced = false;
copy._releaseKnown = false;
copy._metaDone = true; // 跳过 appdetails 兜底
return copy;
});
// 记录刷新前的快照,用于比较变化
const before = new Map(items.map(g => [g.appid, {
finalPrice: g.finalPrice, discountPct: g.discountPct, isComingSoon: g.isComingSoon, isFree: g.isFree,
}]));
await enrichWishlistFromStoreBrowse(refreshItems, cc, lang);
// 将刷新结果合并回原数组,仅更新有变化的动态字段
let changed = false;
const origMap = new Map(items.map(g => [g.appid, g]));
refreshItems.forEach(refreshed => {
const orig = origMap.get(refreshed.appid);
if (!orig) return;
const oldSnap = before.get(orig.appid);
// 仅在字段实际变化时更新并标记 changed
if (refreshed._priced && refreshed.finalPrice !== oldSnap.finalPrice) {
orig.finalPrice = refreshed.finalPrice;
orig.originalPrice = refreshed.originalPrice;
orig.discountPct = refreshed.discountPct;
orig.isFree = refreshed.isFree;
orig._priced = true;
changed = true;
}
if (refreshed._releaseKnown && !!refreshed.isComingSoon !== !!oldSnap.isComingSoon) {
orig.isComingSoon = refreshed.isComingSoon;
orig._releaseKnown = true;
if (refreshed.releaseDate) orig.releaseDate = refreshed.releaseDate;
changed = true;
}
});
if (changed) {
// 更新缓存并局部刷新 UI
cacheSet('wishlistGames', items, CACHE_TTL.wishlist);
renderWishlistSections();
}
} catch (e) {
console.warn('[SGLV] 后台刷新动态字段失败:', e);
} finally {
_wlDynamicRefreshing = false;
_wlDynamicLastRefresh = Date.now(); // v2.9.30: 记录本次刷新时间用于节流
}
}
// ==================== 愿望单页面 SSR 数据提取 (v2.3.20 新增) ====================
// 参考 steam-wishlist-exporter-2.1.3:从愿望单页面 HTML 中解析 React Query SSR 数据,
// 获取 date_added/priority(全部条目)+ StoreItem 详情(名称/价格/标签,首批条目)+ 标签名映射
// 这解决了 wishlistdata API 不返回 added/tags 导致时间线和标签为空、名称需逐批 appdetails 补全的问题
// 从 HTML 文本中提取 React Query 的 queries 数组(扫描 JSON.parse("...") 包含 queryData 的片段)
function wlExtractQueryDataFromText(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.includes('queryData')) {
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 (_) { console.warn('[SGLV] SSR queryData 解析失败'); }
}
idx = i + 1;
}
} catch (e) { console.warn('[SGLV] wlExtractQueryDataFromText failed:', e); }
return null;
}
// 从 queries 数组提取:愿望单条目 / StoreItem 缓存 / 标签名映射
function wlExtractFromQueries(queries) {
const entries = [];
const tagNameMap = {};
const storeItemCache = new Map();
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/category_ids)
if (qKey[0] === 'WishlistSortedFiltered') {
const payload = Array.isArray(data) ? { items: data } : data;
if (payload && Array.isArray(payload.items)) entries.push(...payload.items);
}
// 标签名映射(tagid -> name)
if (qKey[0] === 'LocalizedTagNames' && typeof data === 'object' && !Array.isArray(data)) {
Object.assign(tagNameMap, data);
}
// StoreItem 详情(按 appid 聚合各子查询:default_info/top_tags/include_platforms 等)
if (qKey.length >= 3 && qKey[0] === 'StoreItem') {
const appId = parseInt(String(qKey[1]).replace(/^app_/, ''), 10);
if (!appId || isNaN(appId)) continue;
if (!storeItemCache.has(appId)) storeItemCache.set(appId, { _appId: appId });
storeItemCache.get(appId)[qKey[2]] = data;
}
}
// 去重(按 appid)
const seen = new Set();
const unique = entries.filter(e => e && e.appid && !seen.has(e.appid) && seen.add(e.appid));
return { entries: unique, storeItemCache, tagNameMap };
}
// 将 StoreItem 缓存数据应用到已规整的愿望单条目(补全名称/价格/标签/类型)
function applyStoreItemToWlEntry(item, storeData, tagNameMap) {
if (!item || !storeData) return;
const di = storeData.default_info;
if (di) {
// v2.3.33:中文环境下 SSR 名称可能为英文,仍作为兜底应用(appdetails 会后续覆盖为中文名)
if (di.name && (item.name.startsWith('App ') || isZh)) item.name = String(di.name);
// Steam StoreItem.type: 0=game, 1=DLC, 2=software, 3=video, 4=series, 6=music, 7=tool, 8=video_series
if (typeof di.type === 'number') {
item.type = ({ 0: 'game', 1: 'dlc', 2: 'software', 3: 'video', 4: 'series', 6: 'music', 7: 'tool', 8: 'video_series' })[di.type] || 'other';
} else if (di.type) {
item.type = String(di.type).toLowerCase();
}
if (di.is_free === true) item.isFree = true;
const bpo = di.best_purchase_option;
if (bpo) {
const final = Number(bpo.final_price_in_cents) || 0;
const original = Number(bpo.original_price_in_cents) || 0;
item.finalPrice = final / 100;
item.originalPrice = (original > 0 ? original : final) / 100;
item.discountPct = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0);
if (bpo.formatted_final_price) detectWlPriceSymbol(bpo.formatted_final_price);
// v2.9.101: 旁路推断币种代码(软降级:lib 缺失/异常不影响原流程)
try { if (hasSglvCurrency && bpo.formatted_final_price) { const c = CUR.detectFromText(bpo.formatted_final_price); if (c) _wlPriceCode = c; } } catch {}
item._priced = true;
if (final === 0 && item.discountPct === 0) item.isFree = true;
}
// v2.7.2: 即将发售判定(SSR default_info.is_coming_soon / release.steam_release_date / release.is_coming_soon)
const rel = di.release || di.release_date;
const cs = wlResolveComingSoon({
soonFlag: di.is_coming_soon,
releaseSoon: rel && rel.is_coming_soon,
releaseTs: rel && (rel.steam_release_date || rel.date),
hasReleaseObj: !!rel,
});
if (cs.known) {
item.isComingSoon = cs.soon;
item._releaseKnown = true;
if (cs.date) item.releaseDate = cs.date;
}
}
// top_tags -> 标签名列表(通过 tagNameMap 映射 tagid)
const tt = storeData.top_tags;
if (Array.isArray(tt) && tt.length > 0) {
item.tags = tt.map(t => {
if (!t) return '';
if (t.name) return t.name;
if (t.tagid != null) return tagNameMap[t.tagid] || '';
return '';
}).filter(Boolean).slice(0, 20);
}
// v2.3.33:中文环境下 SSR StoreItem 的名称随用户账号语言(可能为英文),
// 不标记 _metaDone,让 enrichWishlistMeta 通过 l=schinese 的 appdetails 覆盖为中文名;
// 非中文环境保持原有行为(SSR 数据完整即跳过 appdetails)。
item._metaDone = isZh ? false : true; // StoreItem 数据完整,跳过 appdetails 补全
}
// 从愿望单页面 HTML 抓取 SSR 数据(参考 steam-wishlist-exporter-2.1.3 的 SSR 提取)
async function fetchWishlistFromPage(steamId) {
if (!steamId) return null;
const isProfileId = /^\d{17}$/.test(String(steamId));
const url = isProfileId
? `https://store.steampowered.com/wishlist/profiles/${steamId}/`
: `https://store.steampowered.com/wishlist/id/${steamId}/`;
try {
const resp = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout: 30000,
onload(r) { resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, text: safeResponseText(r) }); },
onerror: () => reject(new Error('network error')),
ontimeout: () => reject(new Error('wishlist page fetch timeout'))
});
});
if (!resp.ok || !resp.text) { console.warn('[SGLV] 愿望单页面抓取失败: HTTP', resp.status); return null; }
const queries = wlExtractQueryDataFromText(resp.text);
if (!queries) { console.warn('[SGLV] 愿望单页面未找到 SSR queryData'); return null; }
const data = wlExtractFromQueries(queries);
console.log(`[SGLV] 愿望单页面 SSR: ${data.entries.length} 条目, ${data.storeItemCache.size} 详情, ${Object.keys(data.tagNameMap).length} 标签`);
if (data.entries.length === 0) return null;
return data;
} catch (e) {
console.warn('[SGLV] fetchWishlistFromPage 失败:', e);
return null;
}
}
// 愿望单类型分布:将 type 字段归一化为 game/dlc/software/other
function wlGetTypeKey(type) {
const t = String(type || 'game').toLowerCase();
if (t === 'game' || t === '0') return 'game';
if (t === 'dlc' || t === '1') return 'dlc';
if (t === 'software' || t === '2') return 'software';
return 'other';
}
// 主入口:四级来源兜底(愿望单页面SSR → wishlistdata → GetWishlist API → GDynamicStore)
// v2.7.2: 缓存命中时后台静默刷新动态字段(价格/折扣/即将发售),不阻塞 UI
// v2.9.30: 缓存 TTL 延长至 3 天,动态字段刷新增加 6h 节流,避免每次进入标签页都触发 API 请求
async function loadWishlistGames(force = false) {
if (state.wishlistLoading) return;
if (!force) {
const cached = cacheGet('wishlistGames');
if (cached && Array.isArray(cached) && cached.length > 0) {
state.wishlistGames = cached;
state.wishlistLoaded = true;
markSearchIndexDirty(); // v2.9.51: 缓存恢复,索引需要重建
state.wishlistCategoryNames = cacheGet('wishlistCatNames') || {}; // v2.3.23: 恢复类别名缓存
renderWishlistSections();
refreshWishlistCategoryNames(); // v2.3.23: 后台静默更新类别名
// v2.7.2: 后台静默刷新动态字段——价格/折扣/即将发售状态变化频繁,缓存内可能已过时
// v2.9.30: 6h 节流,同一会话内不重复刷新;复用 enrichWishlistFromStoreBrowse 批量补全
refreshWishlistDynamicFields(cached);
return;
}
}
state.wishlistLoading = true;
state.wishlistLoadStage = 1; // v2.9.47: 阶段1 — 页面抓取
state.wishlistLoadProgress = 0;
renderWishlistGamesList();
try {
const steamId = getActiveSteamId();
// 来源1(新增):愿望单页面 SSR —— 含 date_added/priority(全部条目)+ StoreItem 详情(首批条目)
// 解决 wishlistdata API 不返回 added 字段导致时间线为空、名称需逐批 appdetails 补全的问题
let ssrData = null;
try { ssrData = await fetchWishlistFromPage(steamId); }
catch (e) { console.warn('[SGLV] 愿望单页面 SSR 抓取失败:', e); }
let raw = (ssrData && ssrData.entries.length > 0) ? ssrData.entries : [];
const storeItemCache = ssrData ? ssrData.storeItemCache : new Map();
const tagNameMap = ssrData ? ssrData.tagNameMap : {};
// v2.9.47: 阶段2 — API 数据补全
state.wishlistLoadStage = 2;
renderWishlistGamesList();
// 来源2:store wishlistdata 分页(补充名称/价格/标签/类型)
let apiEntries = [];
if (steamId) {
try { apiEntries = await fetchWishlistFromStore(steamId); }
catch (e) { console.warn('[SGLV] wishlistdata API 失败:', e); }
}
if (raw.length === 0) {
// SSR 失败,直接用 wishlistdata API 数据
raw = apiEntries;
} else if (apiEntries.length > 0) {
// 合并:SSR 提供 date_added/priority(API 通常缺失),API 提供名称/价格/标签(SSR 仅首批有)
const apiMap = new Map(apiEntries.map(e => [Number(e.appid), e]));
raw = raw.map(ssrEntry => {
const apiEntry = apiMap.get(Number(ssrEntry.appid));
if (!apiEntry) return ssrEntry;
// API 字段在前,SSR 的 date_added/priority/category_ids 覆盖(SSR 数据更可靠)
return Object.assign({}, apiEntry, {
date_added: ssrEntry.date_added,
priority: ssrEntry.priority,
category_ids: ssrEntry.category_ids,
});
});
}
// 来源3:IPlayerService/GetWishlist API
if (raw.length === 0) raw = await fetchWishlistFromApi(steamId);
// 来源4:GDynamicStore 兜底(仅 appid)
if (raw.length === 0) {
raw = [...getDynamicStoreAppIds('wishlist')].map(appid => ({ appid }));
}
const items = raw.map(entry => {
const item = normalizeWishlistEntry(entry);
// 应用 SSR StoreItem 详情(首批约 60 个条目有完整名称/价格/标签/类型)
if (storeItemCache.has(item.appid)) {
applyStoreItemToWlEntry(item, storeItemCache.get(item.appid), tagNameMap);
}
return item;
}).filter(i => i.appid > 0);
items.sort((a, b) => {
const pa = a.priority > 0 ? a.priority : 999999;
const pb = b.priority > 0 ? b.priority : 999999;
if (pa !== pb) return pa - pb;
return a.name.localeCompare(b.name, 'zh-CN');
});
state.wishlistGames = items;
state.wishlistLoaded = true;
state.wishlistLoading = false;
state.wishlistLoadStage = 0; // v2.9.47: 重置加载阶段
state.wishlistLoadProgress = 0;
markSearchIndexDirty(); // v2.9.51: 搜索索引依赖 wishlistGames,标记为脏
state.wishlistCategoryNames = cacheGet('wishlistCatNames') || {}; // v2.3.23: 先用缓存类别名出图
renderWishlistSections(); // 先出列表,再后台逐批补全名称/标签/价格
refreshWishlistCategoryNames(); // v2.3.23: 后台获取最新类别名并局部刷新
// v2.9.47: 阶段3 — 资料富集(后台补全,不阻塞列表展示)
state.wishlistLoadStage = 3;
await enrichWishlistMeta(items, (done, total) => {
updateWishlistProgress(done, total);
renderWishlistSections();
});
cacheSet('wishlistGames', items, CACHE_TTL.wishlist); // v2.9.30: 缓存延长至 3 天(动态字段后台静默刷新,6h 节流)
updateWishlistProgress(0, 0);
state.wishlistLoadStage = 0; // v2.9.47: 富集完成,重置
state.wishlistLoadProgress = 0;
renderWishlistSections();
sglvToast.success(T.wlFetchSuccess.replace('{n}', items.length));
} catch (e) {
console.error('[SGLV] 愿望单获取失败:', e);
sglvToast.error(T.wlFetchFail);
state.wishlistLoading = false;
state.wishlistLoadStage = 0; // v2.9.47: 重置加载阶段
state.wishlistLoadProgress = 0;
renderWishlistSections();
}
}
// 后台资料补全进度提示(左侧仪表盘顶部)
function updateWishlistProgress(done, total) {
const el = document.getElementById('sglv-wl-progress');
if (!el) return;
if (!done || !total || done >= total) { el.textContent = ''; return; }
el.textContent = (isZh ? '正在补全资料… ' : 'Enriching… ') + done + '/' + total;
}
// v2.9.101: 愿望单条目币种解析 helper(本任务只建 helper 与字段,computeWishlistStats 接入留待下一任务)
// 兜底链:entry.currency(appdetails 权威值)→ 用户国家代码对应币种(REGIONS 表,g_strCountryCode 权威)
// → _wlPriceCode(formatted 价格文本推断,时序波动/跨区域残留风险,垫底)→ 'CNY'
// v2.9.103: _wlPriceCode 优先级下移 — 推断值存在"统计先于渲染执行为空"与"跨区域浏览残留陈旧值"
// 两类风险,权威值(entry.currency/国家码)优先;非 REGIONS 国家(如 HK)国家码查不到时自然落到 _wlPriceCode。
function regionCurrencyOfCountry(cc) {
try {
if (SGLV_API.getRegionCurrency) return SGLV_API.getRegionCurrency(cc) || '';
} catch { /* ignore */ }
return '';
}
function wlCurrencyOf(g) {
try {
const cc = (typeof unsafeWindow !== 'undefined' && unsafeWindow.g_strCountryCode) || '';
return (g && g.currency) || regionCurrencyOfCountry(cc) || _wlPriceCode || 'CNY';
} catch { return _wlPriceCode || 'CNY'; }
}
// 统计计算:参考 steam-portal analyzeWishlist,计算 KPI/价格区间/折扣区间/按年趋势/类型分布
function computeWishlistStats() {
const games = state.wishlistGames;
const ownedIds = getWishlistOwnedIds();
const total = games.length;
const inLibrary = games.filter(g => ownedIds.has(Number(g.appid)) || ownedIds.has(String(g.appid))).length;
const isOwned = g => ownedIds.has(Number(g.appid)) || ownedIds.has(String(g.appid));
// 价格统计(参考 steam-portal:仅付费游戏,价格单位为元)
const paidItems = games.filter(g => !g.isFree && g.finalPrice > 0);
const prices = paidItems.map(g => g.finalPrice).sort((a, b) => a - b);
// v2.9.102: 多币种汇总软降级开关 —— lib 缺失或 USD 枢纽表派生失败(getUsdRates 返回 null)时,
// 三处金额汇总全部走原裸求和路径(else 分支保留原实现,仅新增分支不覆写)。
// aggregate 对空 code 静默跳过(致命陷阱),构造项集时用 wlCurrencyOf 兜底保证 code 永不为空。
const _wlTargetCode = wlCurrencyOf(null); // 本区币种(统计目标币)
const _wlUsdRates = (hasSglvCurrency && SGLV_API.getUsdRates) ? SGLV_API.getUsdRates() : null;
const _wlMultiCur = hasSglvCurrency && !!_wlUsdRates;
const _rawPriceSum = prices.reduce((s, p) => s + p, 0);
let totalValue = _rawPriceSum;
let _wlAgg = null;
// 单条折算(供 priceBuckets/类别/未分类使用;降级路径下恒为 null 不会被调用):
// 全同币种(code===target)恒等直返 amount,与裸求和逐项相等;仅混合币种才走 USD 枢纽折算。
let _convPrice = null;
if (_wlMultiCur) {
_convPrice = g => {
const code = g.currency || wlCurrencyOf(g);
if (code === _wlTargetCode) return g.finalPrice;
const v = CUR.convert(g.finalPrice, code, _wlTargetCode, _wlUsdRates);
return Number.isFinite(v) ? v : null; // 缺汇率 → null,分桶时跳过
};
const _aggItems = paidItems.map(g => ({ finalPrice: g.finalPrice, currency: g.currency || wlCurrencyOf(g) }));
_wlAgg = CUR.aggregate(_aggItems, _wlTargetCode, _wlUsdRates, { amountKey: 'finalPrice', codeKey: 'currency' });
// 全同币种时 aggregate 走 target 直累分支(Math.round(x*100) 整数分),total === 原裸求和
// (Steam 价格两位小数,*100 精确无损)——验收红线;混合币种时为折算后总额。
totalValue = _wlAgg.total;
}
// avgPrice/medianPrice 维持基于原 finalPrice 计算,不引入折算:中位数跨币种无数学意义,保持现状。
const avgPrice = paidItems.length > 0 ? _rawPriceSum / paidItems.length : 0;
const medianPrice = prices.length > 0
? (prices.length % 2 === 0
? (prices[prices.length / 2 - 1] + prices[prices.length / 2]) / 2
: prices[Math.floor(prices.length / 2)])
: 0;
// 折扣统计
const discountedItems = games.filter(g => g.discountPct > 0);
const discounts = discountedItems.map(g => g.discountPct);
const avgDiscount = discounts.length > 0 ? discounts.reduce((s, d) => s + d, 0) / discounts.length : 0;
const maxDiscount = discounts.length > 0 ? Math.max(...discounts) : 0;
const discountedRate = total > 0 ? discountedItems.length / total : 0;
// 免费游戏
const freeCount = games.filter(g => g.isFree).length;
const freeRate = total > 0 ? freeCount / total : 0;
// v2.7.2: 即将发售
const comingSoonItems = games.filter(g => g.isComingSoon);
const comingSoonCount = comingSoonItems.length;
const comingSoonRate = total > 0 ? comingSoonCount / total : 0;
// 购入率
const purchaseRate = total > 0 ? inLibrary / total : 0;
// 最早添加日期
const addedDates = games.filter(g => g.added > 0).map(g => g.added);
const earliestAdded = addedDates.length > 0 ? Math.min(...addedDates) : 0;
// 热门标签 TOP 15
const tagMap = new Map();
games.forEach(g => (g.tags || []).forEach(t => tagMap.set(t, (tagMap.get(t) || 0) + 1)));
const topTags = [...tagMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
// 添加时间线(按月,取最近 12 个月)
const monthMap = new Map();
games.forEach(g => {
if (g.added > 0) {
const d = new Date(g.added * 1000);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
monthMap.set(key, (monthMap.get(key) || 0) + 1);
}
});
const timeline = [...monthMap.entries()].sort((a, b) => a[0].localeCompare(b[0])).slice(-12);
// 按年添加趋势(参考 steam-portal addedByYear)
const yearMap = new Map();
games.forEach(g => {
if (g.added > 0) {
const year = String(new Date(g.added * 1000).getFullYear());
yearMap.set(year, (yearMap.get(year) || 0) + 1);
}
});
const addedByYear = [...yearMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
// 价格区间分布(参考 steam-portal priceBuckets,单位为元)
// v2.9.102: 聚合路径下改按折算后金额分桶(全同币种时 _convPrice 恒等直返,分桶数与原版一致);降级路径维持原 finalPrice 分桶
const _bucketPrices = _wlMultiCur ? paidItems.map(g => _convPrice(g)) : null;
const priceBuckets = _wlMultiCur ? [
{ label: T.wlPriceFree, count: games.filter(g => g.isFree).length, color: '#6366f1' },
{ label: T.wlPriceLt50, count: _bucketPrices.filter(p => p != null && p < 50).length, color: '#10b981' },
{ label: T.wlPrice50_100, count: _bucketPrices.filter(p => p != null && p >= 50 && p < 100).length, color: '#22c55e' },
{ label: T.wlPrice100_200, count: _bucketPrices.filter(p => p != null && p >= 100 && p < 200).length, color: '#84cc16' },
{ label: T.wlPrice200_500, count: _bucketPrices.filter(p => p != null && p >= 200 && p < 500).length, color: '#f59e0b' },
{ label: T.wlPriceGte500, count: _bucketPrices.filter(p => p != null && p >= 500).length, color: '#ef4444' },
] : [
{ label: T.wlPriceFree, count: games.filter(g => g.isFree).length, color: '#6366f1' },
{ label: T.wlPriceLt50, count: paidItems.filter(g => g.finalPrice < 50).length, color: '#10b981' },
{ label: T.wlPrice50_100, count: paidItems.filter(g => g.finalPrice >= 50 && g.finalPrice < 100).length, color: '#22c55e' },
{ label: T.wlPrice100_200, count: paidItems.filter(g => g.finalPrice >= 100 && g.finalPrice < 200).length, color: '#84cc16' },
{ label: T.wlPrice200_500, count: paidItems.filter(g => g.finalPrice >= 200 && g.finalPrice < 500).length, color: '#f59e0b' },
{ label: T.wlPriceGte500, count: paidItems.filter(g => g.finalPrice >= 500).length, color: '#ef4444' },
];
// 折扣分布(参考 steam-portal discountBuckets)
const discountBuckets = [
{ label: T.wlDisc0, count: games.filter(g => g.discountPct === 0).length, color: '#64748b' },
{ label: T.wlDisc1_25, count: games.filter(g => g.discountPct > 0 && g.discountPct <= 25).length, color: '#84cc16' },
{ label: T.wlDisc26_50, count: games.filter(g => g.discountPct > 25 && g.discountPct <= 50).length, color: '#22c55e' },
{ label: T.wlDisc51_75, count: games.filter(g => g.discountPct > 50 && g.discountPct <= 75).length, color: '#f59e0b' },
{ label: T.wlDisc76plus, count: games.filter(g => g.discountPct > 75).length, color: '#ef4444' },
];
// 类型分布(愿望单游戏分类汇总:游戏/DLC/软件/其他)
const typeMap = new Map();
games.forEach(g => {
const key = wlGetTypeKey(g.type);
typeMap.set(key, (typeMap.get(key) || 0) + 1);
});
const typeDist = [...typeMap.entries()].sort((a, b) => b[1] - a[1]);
// v2.8.0: 我的类别详细统计(每类别:数量/总值/均价/打折/待上市/已入库/免费 + 占比)
// v2.9.102: 聚合路径下 totalValue 按条目折算到目标币种后累加(_convPrice 全同币种恒等直返);降级路径保持原 finalPrice 累加
const catNames = state.wishlistCategoryNames || {};
const catStatsMap = new Map();
games.forEach(g => {
const ids = Array.isArray(g.categoryIds) ? g.categoryIds : [];
if (ids.length === 0) return;
ids.forEach(id => {
if (!catStatsMap.has(id)) catStatsMap.set(id, {
count: 0, totalValue: 0, paidCount: 0,
discountCount: 0, comingSoonCount: 0, inLibraryCount: 0, freeCount: 0,
});
const cs = catStatsMap.get(id);
cs.count++;
if (!g.isFree && g.finalPrice > 0) {
if (_wlMultiCur) {
const cv = _convPrice(g);
if (cv != null) cs.totalValue += cv;
} else {
cs.totalValue += g.finalPrice;
}
cs.paidCount++;
}
if (g.discountPct > 0) cs.discountCount++;
if (g.isComingSoon) cs.comingSoonCount++;
if (isOwned(g)) cs.inLibraryCount++;
if (g.isFree) cs.freeCount++;
});
});
const categoryStats = [...catStatsMap.entries()]
.map(([id, cs]) => ({
id, name: catNames[id] || `#${id}`,
...cs,
avgPrice: cs.paidCount > 0 ? cs.totalValue / cs.paidCount : 0,
pct: total > 0 ? cs.count / total : 0,
}))
.sort((a, b) => b.count - a.count);
// v2.8.0: 未分类游戏统计(v2.9.102: totalValue 聚合路径下按折算后金额汇总,降级路径维持原裸求和)
const uncatGames = games.filter(g => {
const ids = Array.isArray(g.categoryIds) ? g.categoryIds : [];
return ids.length === 0;
});
const uncatPaid = uncatGames.filter(g => !g.isFree && g.finalPrice > 0);
const uncategorizedStats = {
count: uncatGames.length,
totalValue: _wlMultiCur
? uncatPaid.reduce((s, g) => { const cv = _convPrice(g); return cv != null ? s + cv : s; }, 0)
: uncatPaid.reduce((s, g) => s + g.finalPrice, 0),
discountCount: uncatGames.filter(g => g.discountPct > 0).length,
comingSoonCount: uncatGames.filter(g => g.isComingSoon).length,
inLibraryCount: uncatGames.filter(g => isOwned(g)).length,
freeCount: uncatGames.filter(g => g.isFree).length,
pct: total > 0 ? uncatGames.length / total : 0,
};
return {
total, inLibrary, purchaseRate,
totalValue, avgPrice, medianPrice,
discountedCount: discountedItems.length, discountedRate, avgDiscount, maxDiscount,
freeCount, freeRate,
comingSoonCount, comingSoonRate,
earliestAdded,
topTags, timeline, addedByYear,
priceBuckets, discountBuckets, typeDist,
categoryStats, uncategorizedStats,
// v2.9.102: aggregate 附加产物透传(无汇率降级分桶/未支持币种列表;降级路径下为空值,新增字段不影响既有消费方)
byCurrency: _wlAgg ? _wlAgg.byCurrency : {},
unsupported: _wlAgg ? _wlAgg.unsupported : [],
targetCode: _wlTargetCode,
};
}
// v2.9.50: 持久化 wishlist 统计(跨 session 复用)
// 输入签名:wishlist 数量 + 价格字段总和 + 类别字段总和
// 动态字段(价格/折扣)会由 refreshWishlistDynamicFields 后台刷新(已有)
// v2.9.102: PCC 防脏缓存 —— schemaVer 1→2 双保险(旧脏缓存版本不匹配直接重算);
// 签名在既有单次循环中顺带收集货币指纹(curSet,零额外遍历):币种集合 + 小时级汇率快照 + 目标币种,
// 任一变化 → sig 变化 → PCC 自动重算,避免沿用汇率/币种变更前的脏统计。
function computeWishlistStatsCached() {
const games = state.wishlistGames;
let finalPriceSum = 0, originalPriceSum = 0, discountSum = 0, addedSum = 0;
const curSet = new Set(); // v2.9.102: 货币指纹(与汇总同口径: g.currency || wlCurrencyOf 兜底)
const n = games ? games.length : 0;
for (let i = 0; i < n; i++) {
const g = games[i];
finalPriceSum += (g && g.finalPrice) || 0;
originalPriceSum += (g && g.originalPrice) || 0;
discountSum += (g && g.discountPct) || 0;
addedSum += (g && g.added) || 0;
curSet.add((g && g.currency) || wlCurrencyOf(g));
}
const catNames = state.wishlistCategoryNames || {};
const catKey = Object.keys(catNames).sort().map(k => `${k}:${catNames[k]}`).join(',');
// 小时级汇率快照(getRatesTs 经 SGLV_API 桥接,拿不到时用 0);targetCode 与 computeWishlistStats 同口径
let ratesHour = 0;
try { if (SGLV_API.getRatesTs) ratesHour = Math.floor((SGLV_API.getRatesTs() || 0) / 3600000); } catch { ratesHour = 0; }
const curFp = [...curSet].sort().join(',') + '|' + ratesHour + '|' + wlCurrencyOf(null);
const sig = `${n}|${finalPriceSum}|${originalPriceSum}|${discountSum}|${addedSum}|${catKey}|${curFp}`;
return getBizCached('biz_wishlist_stats', 2, sig, () => computeWishlistStats());
}
function getFilteredWishlist() {
const q = state.wishlistSearch.toLowerCase().trim();
const ownedIds = getWishlistOwnedIds();
return state.wishlistGames.filter(g => {
if (q && !g.name.toLowerCase().includes(q) && !String(g.appid).includes(q)) return false;
if (state.wishlistFilter === 'discount' && !(g.discountPct > 0)) return false;
if (state.wishlistFilter === 'inlibrary' && !(ownedIds.has(Number(g.appid)) || ownedIds.has(String(g.appid)))) return false;
if (state.wishlistFilter === 'coming' && !g.isComingSoon) return false;
// v2.8.0: 类别筛选(点击"我的类别统计"卡片触发)
if (state.wishlistFilter === 'category' && state.wishlistCategoryFilter) {
const ids = Array.isArray(g.categoryIds) ? g.categoryIds : [];
if (state.wishlistCategoryFilter === '__uncat__') {
if (ids.length > 0) return false;
} else {
if (!ids.includes(state.wishlistCategoryFilter)) return false;
}
}
return true;
});
}
// ----- 渲染 -----
// v2.9.15: 愿望单首次加载骨架屏——KPI 数字 + 8 section + 游戏网格,避免页面紧缩
function renderWishlistSkeleton(wrap) {
// KPI 区:4 个数字骨架
const kpiEl = wrap.querySelector('#sglv-wishlist-kpi');
if (kpiEl && !kpiEl.innerHTML) {
kpiEl.className = 'sglv-wl-kpi';
kpiEl.innerHTML = Array.from({ length: 4 }, () =>
``
).join('');
}
// 8 个 section:每 section 一行骨架
const sectionIds = ['sglv-wl-upcoming', 'sglv-wl-catstats', 'sglv-wl-tags', 'sglv-wl-year-trend', 'sglv-wl-price-dist', 'sglv-wl-disc-dist', 'sglv-wl-timeline'];
sectionIds.forEach(id => {
const el = wrap.querySelector('#' + id);
if (!el || el.innerHTML) return;
el.innerHTML = `
`;
});
// 右侧 content:12 个卡片网格骨架
const content = wrap.querySelector('#sglv-wishlist-content');
if (content && !content.innerHTML) {
content.innerHTML = `${Array.from({ length: 12 }, () =>
`
`
).join('')}
${renderWishlistLoadingHtml()}`;
}
}
// ==================== 云存档标签页 (v2.9.34) ====================
function renderCloudSaveTab(parent) {
const wrap = document.createElement('div');
wrap.className = 'sglv-cs-wrap';
// 模块未加载
if (!window.SGLVCloudSave || !_cloudSaveReady) {
wrap.innerHTML = `
⚠️
${isZh ? '云存档模块未加载' : 'Cloud save module not loaded'}
${isZh ? '请检查 @require 外部库配置' : 'Please check @require external library config'}
`;
parent.innerHTML = '';
parent.appendChild(wrap);
return;
}
// 加载中
if (state.cloudSaveLoading) {
wrap.innerHTML = `
`;
parent.innerHTML = '';
parent.appendChild(wrap);
return;
}
// 错误态
if (state.cloudSaveError) {
const isLogin = state.cloudSaveError === 'LOGIN_REQUIRED';
wrap.innerHTML = `
${isLogin ? '🔐' : '⚠️'}
${isLogin ? T.csLoginRequired : T.csFetchFail}
`;
parent.innerHTML = '';
parent.appendChild(wrap);
wrap.querySelector('#sglv-cs-retry').addEventListener('click', () => loadCloudSaveData(true));
return;
}
// 空数据——v2.9.39: 切换到云存档标签页时,先尝试从 IDB 加载缓存(立即显示)
if (!state.cloudSaveLoaded || !state.cloudSaveGames || state.cloudSaveGames.length === 0) {
// 渲染空态占位(含获取按钮)
wrap.innerHTML = `
☁️
${T.csNoData}
`;
parent.innerHTML = '';
parent.appendChild(wrap);
// v2.9.39: 启动缓存加载流程(可能瞬间命中 IDB 缓存并替换此占位)
_initCloudSaveTab();
wrap.querySelector('#sglv-cs-fetch').addEventListener('click', () => loadCloudSaveData(true));
return;
}
// 仪表盘
renderCloudSaveDashboard(parent, wrap);
// v2.9.39: 渲染完成后,若距上次后台刷新超过 6h 节流窗口,触发静默增量更新
// 仅在切到该标签页时触发(不打扰用户)
if (Date.now() - (state.cloudSaveBgRefreshAt || 0) >= CLOUDSAVE_BG_REFRESH_INTERVAL) {
_backgroundRefreshCloudSave();
}
// v2.9.67: 后台渐进式填充文件数量(12h 节流,逐个请求单App页面解析文件数)
_enrichCloudSaveFileCounts();
}
async function loadCloudSaveData(force) {
state.cloudSaveLoading = true;
state.cloudSaveError = null;
renderBody();
try {
const result = await window.SGLVCloudSave.fetchGlobalCloudSaves(force);
state.cloudSaveGames = result.games || [];
state.cloudSaveSource = result.source;
state.cloudSaveLastUpdate = result.lastUpdate;
state.cloudSaveDiff = result.diff;
state.cloudSaveLoaded = true;
state.cloudSaveError = null;
showToast(T.csFetchSuccess.replace('{n}', state.cloudSaveGames.length));
// v2.9.67: 数据加载完成后,后台填充文件数量
_enrichCloudSaveFileCounts();
} catch (err) {
state.cloudSaveError = err.message || 'UNKNOWN_ERROR';
console.error('[SGLV] 云存档数据获取失败:', err);
} finally {
state.cloudSaveLoading = false;
renderBody();
}
}
// v2.9.39: 启动时尝试从 IDB 加载缓存(非阻塞)—— 即使后端 fetchGlobalCloudSaves 因 _memCache 未初始化而抓取网络
// 也能保证先看到本地缓存数据
async function _tryLoadCloudSaveFromIDBCache() {
if (state.cloudSaveLoaded && state.cloudSaveGames.length > 0) return; // 已有内存数据
if (!window.SGLVCloudSave?.getCachedCloudSavesAsync) return;
try {
const cached = await window.SGLVCloudSave.getCachedCloudSavesAsync();
if (cached && cached.games && cached.games.length > 0) {
// v2.9.39: 清除旧 _status 标记,避免误显示"新增/变化"徽章
cached.games.forEach(g => { if (g._status) g._status = null; });
state.cloudSaveGames = cached.games;
state.cloudSaveSource = 'cache';
state.cloudSaveLastUpdate = cached.lastUpdate || 0;
// v2.9.74: 从 IDB 缓存恢复 fileCountAt,避免页面刷新后丢失导致每次都重新获取文件数
state.cloudSaveFileCountAt = cached.fileCountAt || 0;
state.cloudSaveDiff = null;
state.cloudSaveLoaded = true;
state.cloudSaveError = null;
console.log(`[SGLV] 云存档 IDB 缓存命中: ${cached.games.length} 款游戏, 更新于 ${new Date(cached.lastUpdate).toLocaleString()}`);
renderBody();
return true;
}
} catch (e) {
console.warn('[SGLV] 云存档 IDB 缓存加载失败:', e);
}
return false;
}
// v2.9.39: 后台静默增量更新(只增不减 + 6h 节流 + 2天 TTL)
// - 已有缓存时立即显示缓存数据,后台增量抓取并合并
// - 节流:6h 内不重复后台刷新(避免频繁抓取被 Steam 限流)
// - TTL:2 天未更新才触发后台刷新(避免无意义抓取)
// - additive: 旧游戏中未被新抓取覆盖的项保留(不会显示"删除")
async function _backgroundRefreshCloudSave() {
if (state.cloudSaveBgRefresh) return; // 已有后台刷新进行中
if (!window.SGLVCloudSave?.fetchGlobalCloudSaves) return;
if (Date.now() - (state.cloudSaveBgRefreshAt || 0) < CLOUDSAVE_BG_REFRESH_INTERVAL) {
return; // 6h 节流
}
state.cloudSaveBgRefresh = true;
state.cloudSaveBgRefreshAt = Date.now();
const prevCount = state.cloudSaveGames.length;
try {
// additive=true 保留旧数据;silent=true 不修改 lastUpdate(避免影响节流)
// 这里不传 force,因为 lib 内部 TTL 已处理(仅当 lastUpdate 超 2天才抓取)
const result = await window.SGLVCloudSave.fetchGlobalCloudSaves(false, { additive: true, silent: true });
if (result && result.games) {
state.cloudSaveGames = result.games;
state.cloudSaveSource = result.source;
state.cloudSaveLastUpdate = result.lastUpdate;
state.cloudSaveDiff = result.diff;
state.cloudSaveLoaded = true;
state.cloudSaveError = null;
const newCount = result.games.length - prevCount;
if (newCount > 0) {
console.log(`[SGLV] 云存档后台增量更新: 新增 ${newCount} 款, 总计 ${result.games.length} 款`);
showToast(T.csBgRefreshDone.replace('{n}', newCount));
}
renderBody();
}
} catch (err) {
console.warn('[SGLV] 云存档后台刷新失败:', err);
} finally {
state.cloudSaveBgRefresh = false;
}
}
// v2.9.67: 后台渐进式填充云存档文件数量
// 全局云存档页面不直接展示文件数,需逐一请求单App页面解析 accountTable tbody tr 行数
// 参考 Steam 云.html 页面结构: appid=7 (Steam Client) 有 2 个文件 = tbody 内 2 个 tr
// - v2.9.74: 5天 TTL(云存档相对固定),fileCountAt 从 IDB 缓存恢复,避免每次页面刷新都重新获取
// - 并发 3,每请求间隔 500ms
// - 每获取到一个游戏的文件数就刷新 UI(渐进式更新)
let _csFileCountAbort = null;
async function _enrichCloudSaveFileCounts() {
if (state.cloudSaveFileCountEnriching) return;
if (!window.SGLVCloudSave?.enrichFileCounts) return;
// v2.9.74: 5天 TTL——fileCountAt 从 IDB 缓存恢复,避免每次页面刷新都重新获取
if (Date.now() - (state.cloudSaveFileCountAt || 0) < CLOUDSAVE_FILECOUNT_INTERVAL) return;
// 检查是否有需要填充的游戏(fileCount=0)
const needEnrich = state.cloudSaveGames.some(g => g.appid && (!g.fileCount || g.fileCount === 0));
if (!needEnrich) return;
state.cloudSaveFileCountEnriching = true;
state.cloudSaveFileCountAt = Date.now();
_csFileCountAbort = new AbortController();
let renderTimer = null;
try {
console.log(`[SGLV] 云存档文件数填充开始 (${state.cloudSaveGames.filter(g => !g.fileCount || g.fileCount === 0).length} 款待获取)`);
await window.SGLVCloudSave.enrichFileCounts(state.cloudSaveGames, {
concurrency: 3,
delay: 500,
signal: _csFileCountAbort.signal,
onProgress: (updatedGame, allGames) => {
// 节流渲染:每 800ms 最多刷新一次 UI
if (!renderTimer) {
renderTimer = setTimeout(() => {
renderTimer = null;
if (state.activeTab === 'cloudsave') renderBody();
}, 800);
}
},
});
console.log('[SGLV] 云存档文件数填充完成');
} catch (err) {
console.warn('[SGLV] 云存档文件数填充失败:', err);
} finally {
state.cloudSaveFileCountEnriching = false;
_csFileCountAbort = null;
// 最终刷新一次 UI
if (state.activeTab === 'cloudsave') renderBody();
}
}
// v2.9.39: 首次进入云存档标签页时调用——先显示缓存,再后台增量更新
async function _initCloudSaveTab() {
// 1) 立即从 IDB 加载缓存(同步显示,无需等待)
const loaded = await _tryLoadCloudSaveFromIDBCache();
// 2) 缓存为空时走原 fetch 流程(用户首次使用 / 清缓存)
if (!loaded) {
await loadCloudSaveData(false);
return;
}
// 3) 已有缓存 → 后台静默增量更新(不阻塞 UI)
_backgroundRefreshCloudSave();
// v2.9.67: 后台渐进式填充文件数量(v2.9.74: 5天 TTL)
_enrichCloudSaveFileCounts();
}
function renderCloudSaveDashboard(parent, wrap) {
const games = state.cloudSaveGames;
const fmt = window.SGLVCloudSave.formatSize;
// v2.9.36: 构建 appid → 游戏名称 映射(从已拥有游戏库中查找)
const _ownedGameMap = {};
if (state.ownedGames && state.ownedGames.length > 0) {
state.ownedGames.forEach(g => {
if (g.appid) _ownedGameMap[String(g.appid)] = g.name || '';
});
}
// 云存档游戏名称解析:优先用游戏库名称,回退到原始 name 字段
function resolveGameName(csGame) {
const appid = String(csGame.appid || '');
if (_ownedGameMap[appid]) return _ownedGameMap[appid];
// 如果原始 name 不是 "显示文件" 等无意义值,直接使用
const raw = csGame.name || '';
if (raw && raw !== '显示文件' && raw !== 'Show Files' && !/^App\s+\d+$/.test(raw)) return raw;
return `App ${appid}`;
}
// v2.9.44: 封面降级逻辑已抽取到 sglv-cover-fallback.lib.js(@require 引用)
// 这里只做轻量适配:lib 不可用时安全降级(返回空字符串 + onerror 自然触发 placeholder)
// v2.9.105: 传递 kind 参数给 lib v1.1.0,使 CDN 链和 API 兜底按图片类型选择最佳 URL
function getCsCoverUrl(appId, kind) {
if (window.SGLVCoverFallback && window.SGLVCoverFallback.getCoverUrl) {
return window.SGLVCoverFallback.getCoverUrl(appId, kind || 'capsule');
}
// 兜底:直接用 cloudflare capsule(兼容 lib 未加载场景)
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_231x87.jpg`;
}
// ---- KPI 计算 ----
const totalGames = games.length;
const totalSize = games.reduce((s, g) => s + (g.sizeBytes || 0), 0);
const totalFiles = games.reduce((s, g) => s + (g.fileCount || 0), 0);
const avgSize = totalGames > 0 ? Math.round(totalSize / totalGames) : 0;
// ---- 大小分布 ----
const sizeBuckets = [0, 0, 0, 0, 0]; // <1MB, 1-10, 10-50, 50-100, 100+
const sizeLabels = [T.csSizeLt1, T.csSize1_10, T.csSize10_50, T.csSize50_100, T.csSize100plus];
games.forEach(g => {
const mb = (g.sizeBytes || 0) / (1024 * 1024);
if (mb < 1) sizeBuckets[0]++;
else if (mb < 10) sizeBuckets[1]++;
else if (mb < 50) sizeBuckets[2]++;
else if (mb < 100) sizeBuckets[3]++;
else sizeBuckets[4]++;
});
// ---- 文件数分布 ----
// v2.9.73: 优化分桶——大多数游戏存档数 <10,旧 <5/5-20/20-50/50+ 颗粒度太粗
const fileBuckets = [0, 0, 0, 0]; // <3, 3-5, 5-10, 10+
const fileLabels = [T.csFilesLt3, T.csFiles3_5, T.csFiles5_10, T.csFiles10plus];
games.forEach(g => {
const f = g.fileCount || 0;
if (f < 3) fileBuckets[0]++;
else if (f <= 5) fileBuckets[1]++;
else if (f <= 10) fileBuckets[2]++;
else fileBuckets[3]++;
});
// ---- TOP 10 按大小 ----
const top10 = [...games].sort((a, b) => (b.sizeBytes || 0) - (a.sizeBytes || 0)).slice(0, 10);
const maxTopSize = top10[0]?.sizeBytes || 1;
// ---- Diff 汇总 ----
const diff = state.cloudSaveDiff;
const hasDiff = diff && (diff.new.length || diff.changed.length || diff.removed.length);
// ---- 来源/时间 ----
const sourceLabel = state.cloudSaveSource === 'web' ? T.csSourceWeb : T.csSourceCache;
const updatedTime = state.cloudSaveLastUpdate ? new Date(state.cloudSaveLastUpdate).toLocaleString() : '';
// v2.9.74: 文件数更新时间独立显示,避免与存档数据更新时间混淆
const fileCountTime = state.cloudSaveFileCountAt ? new Date(state.cloudSaveFileCountAt).toLocaleString() : '';
// ============ 左侧仪表盘 ============
const side = document.createElement('div');
side.className = 'sglv-cs-side';
side.innerHTML = `
${totalGames.toLocaleString()}
${isZh ? '云存档游戏' : 'cloud games'}
${fmt(totalSize)}
${isZh ? '全部存档' : 'all saves'}
${totalFiles.toLocaleString()}${state.cloudSaveFileCountEnriching ? ' ' : ''}
${state.cloudSaveFileCountEnriching ? (isZh ? '正在获取文件数…' : 'Fetching file counts…') : (isZh ? '存档文件' : 'save files')}
${fmt(avgSize)}
${isZh ? '平均每款' : 'avg per game'}
${T.csTopSizes}
${top10.map((g, i) => {
const gName = resolveGameName(g);
return `
${i + 1}
${gName}
${fmt(g.sizeBytes || 0)}
`;
}).join('')}
${T.csSizeDist}
${sizeBuckets.map((v, i) => {
const mx = Math.max(...sizeBuckets, 1);
return `
`;
}).join('')}
${T.csFileDist}
${fileBuckets.map((v, i) => {
const mx = Math.max(...fileBuckets, 1);
return `
`;
}).join('')}
${hasDiff ? `
${diff.new.length ? `${T.csBadgeNew} ${diff.new.length}` : ''}
${diff.changed.length ? `${T.csBadgeChanged} ${diff.changed.length}` : ''}
${diff.removed.length ? `${T.csBadgeRemoved} ${diff.removed.length}` : ''}
` : ''}
`;
// ============ 右侧游戏列表 ============
const listWrap = document.createElement('div');
listWrap.className = 'sglv-cs-list-wrap';
const toolbar = document.createElement('div');
toolbar.className = 'sglv-cs-toolbar';
toolbar.innerHTML = `
`;
const listHeader = document.createElement('div');
listHeader.className = 'sglv-cs-list-header';
listHeader.innerHTML = `${T.csGameList}${games.length}`;
const listEl = document.createElement('div');
listEl.className = 'sglv-cs-game-list';
listWrap.appendChild(toolbar);
listWrap.appendChild(listHeader);
listWrap.appendChild(listEl);
// ---- 渲染游戏列表(带搜索+排序) ----
function renderGameList() {
let list = [...state.cloudSaveGames];
if (state.cloudSaveSearch) {
const q = state.cloudSaveSearch.toLowerCase();
// v2.9.36: 搜索同时匹配解析后的游戏名称
list = list.filter(g => {
const resolved = resolveGameName(g).toLowerCase();
return resolved.includes(q) || String(g.appid).includes(q) || (g.name || '').toLowerCase().includes(q);
});
}
switch (state.cloudSaveSort) {
case 'sizeAsc': list.sort((a, b) => (a.sizeBytes || 0) - (b.sizeBytes || 0)); break;
case 'filesDesc': list.sort((a, b) => (b.fileCount || 0) - (a.fileCount || 0)); break;
case 'nameAsc': list.sort((a, b) => resolveGameName(a).localeCompare(resolveGameName(b))); break;
default: list.sort((a, b) => (b.sizeBytes || 0) - (a.sizeBytes || 0));
}
if (list.length === 0) {
listEl.innerHTML = `${isZh ? '无匹配结果' : 'No matching results'}
`;
return;
}
listEl.innerHTML = list.map(g => {
const gName = resolveGameName(g);
const badge = g._status === 'new'
? `${T.csBadgeNew}`
: g._status === 'changed'
? `${T.csBadgeChanged}`
: '';
// v2.9.44: 游戏封面图(三级降级,data 属性触发全局事件委托)
// marker 从 data-sglv-cs-cover 改为通用的 data-sglv-cover(lib 通用 marker)
// v2.9.105: 添加 data-kind="capsule" 使 lib v1.1.0 按 kind 选择 CDN 链和 API 兜底
const coverHtml = g.appid
? `
N/A`
: '';
return `
${coverHtml}
${gName}
App ${g.appid}
`;
}).join('');
}
// 组装
wrap.appendChild(side);
wrap.appendChild(listWrap);
parent.innerHTML = '';
parent.appendChild(wrap);
renderGameList();
// ---- 事件绑定 ----
const searchInput = wrap.querySelector('.sglv-cs-search');
if (searchInput) {
let timer;
searchInput.addEventListener('input', (e) => {
clearTimeout(timer);
timer = setTimeout(() => {
state.cloudSaveSearch = e.target.value;
renderGameList();
}, 200);
});
}
const sortSelect = wrap.querySelector('.sglv-cs-sort');
if (sortSelect) {
sortSelect.addEventListener('change', (e) => {
state.cloudSaveSort = e.target.value;
renderGameList();
});
}
const refreshBtn = wrap.querySelector('#sglv-cs-refresh');
if (refreshBtn) {
// v2.9.39: 手动刷新按钮也使用 additive 模式(只增不减),保护已有游戏列表不被误删
// v2.9.74: 手动刷新同时重置文件数 TTL,让新增游戏的文件数能立即获取
refreshBtn.addEventListener('click', () => {
state.cloudSaveFileCountAt = 0;
loadCloudSaveDataAdditive(true);
});
}
// v2.9.44: 绑定封面降级事件委托(@require sglv-cover-fallback.lib.js)
// onFailed 回调:所有降级链失败后,显示右侧 N/A fallback 文本
if (window.SGLVCoverFallback) {
window.SGLVCoverFallback.bindErrorHandler(wrap, (img) => {
if (img.nextElementSibling) img.nextElementSibling.style.display = 'flex';
});
} else {
console.warn('[SGLV] SGLVCoverFallback 未加载 — 封面降级不可用 (请检查 @require sglv-cover-fallback.lib.js)');
}
}
// v2.9.39: additive 模式加载(保留旧数据中未在新抓取中出现的项)
async function loadCloudSaveDataAdditive(force) {
state.cloudSaveLoading = true;
state.cloudSaveError = null;
renderBody();
try {
const result = await window.SGLVCloudSave.fetchGlobalCloudSaves(force, { additive: true, silent: !force });
state.cloudSaveGames = result.games || [];
state.cloudSaveSource = result.source;
state.cloudSaveLastUpdate = result.lastUpdate;
state.cloudSaveDiff = result.diff;
state.cloudSaveLoaded = true;
state.cloudSaveError = null;
showToast(T.csFetchSuccess.replace('{n}', state.cloudSaveGames.length));
// v2.9.67: 数据加载完成后,后台填充文件数量
_enrichCloudSaveFileCounts();
} catch (err) {
state.cloudSaveError = err.message || 'UNKNOWN_ERROR';
console.error('[SGLV] 云存档数据获取失败:', err);
} finally {
state.cloudSaveLoading = false;
renderBody();
}
}
function renderWishlistTab(parent) {
const wrap = document.createElement('div');
wrap.className = 'sglv-wishlist-wrap';
// 左侧仪表盘面板顺序(v2.8.0 调整):
// KPI → 待上市游戏 → 我的类别统计 → 热门标签TOP15 → 按年趋势 → 价格区间 → 折扣分布 → 添加时间线
const side = document.createElement('div');
side.className = 'sglv-wl-side';
side.innerHTML = `
`;
wrap.appendChild(side);
// 右侧:游戏视图(工具栏 + 列表)
const main = document.createElement('div');
main.className = 'sglv-wl-main';
const filterBtns = [
{ key: 'all', label: T.all },
{ key: 'discount', label: T.wlFilterDiscount },
{ key: 'coming', label: T.wlFilterComingSoon },
{ key: 'inlibrary', label: T.wlFilterInLibrary },
];
const toolbar = document.createElement('div');
toolbar.className = 'sglv-toolbar';
toolbar.innerHTML = `
`;
main.appendChild(toolbar);
// v2.6.1: 筛选标签栏 + 分页放同一行,紧凑操作栏
// v2.7.3: 更新提示信息移至筛选按钮右侧(红框标注位置)
const filterBar = document.createElement('div');
filterBar.className = 'sglv-filter-bar';
filterBar.innerHTML = `
${filterBtns.map(fb =>
``
).join('')}
`;
main.appendChild(filterBar);
// 游戏列表
const content = document.createElement('div');
content.className = 'sglv-content';
content.id = 'sglv-wishlist-content';
main.appendChild(content);
wrap.appendChild(main);
parent.appendChild(wrap);
// v2.9.15: 首次加载时显示完整骨架屏(KPI + 8 section + 游戏网格),避免页面紧缩
// v2.9.47: 修复首次加载骨架屏不显示——改为 !wishlistLoaded 即显示(不要求 wishlistLoading)
if (!state.wishlistLoaded) {
renderWishlistSkeleton(wrap);
}
// 事件绑定
// v2.9.49: debounce 搜索输入,避免愿望单列表每次按键触发完整重渲染
toolbar.querySelector('#sglv-wishlist-search').addEventListener('input', debounce((e) => {
state.wishlistSearch = e.target.value;
state.wishlistPage = 1;
renderWishlistGamesList();
}, 200));
// v2.5.0: 筛选按钮事件——从 filterBar 查找
filterBar.querySelectorAll('.sglv-wl-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
state.wishlistFilter = btn.dataset.filter;
state.wishlistCategoryFilter = null; // v2.8.0: 清除类别筛选
state.wishlistPage = 1;
filterBar.querySelectorAll('.sglv-wl-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderWishlistSide(); // v2.8.0: 刷新类别卡片高亮状态
renderWishlistGamesList();
});
});
// v2.4.3: 视图切换统一处理(card/cover/list 三模式)
const setWlViewMode = (mode) => {
state.wishlistViewMode = mode;
['card', 'cover', 'list'].forEach(m => {
toolbar.querySelector(`#sglv-wl-${m}`)?.classList.toggle('active', m === mode);
});
renderWishlistGamesList();
};
toolbar.querySelector('#sglv-wl-card').addEventListener('click', () => setWlViewMode('card'));
toolbar.querySelector('#sglv-wl-cover').addEventListener('click', () => setWlViewMode('cover'));
toolbar.querySelector('#sglv-wl-list').addEventListener('click', () => setWlViewMode('list'));
toolbar.querySelector('#sglv-wl-refresh').addEventListener('click', () => {
sglvToast.info(isZh ? '正在刷新愿望单…' : 'Refreshing wishlist…');
loadWishlistGames(true);
});
if (!state.wishlistLoaded && !state.wishlistLoading) {
loadWishlistGames();
} else {
renderWishlistSections();
}
}
function renderWishlistSections() {
if (state.activeTab !== 'wishlist' || !document.getElementById('sglv-wishlist-content')) return;
renderWishlistKpi();
renderWishlistSide();
renderWishlistGamesList();
}
// KPI:6 卡片仪表盘(参考 steam-portal WishlistKpiRow,含图标+数值+标签+子文本)
function renderWishlistKpi() {
const el = document.getElementById('sglv-wishlist-kpi');
if (!el) return;
if (!state.wishlistLoaded) { el.innerHTML = ''; return; }
const s = computeWishlistStatsCached();
const sym = detectWlPriceSymbol();
// v2.9.103: 混合币种聚合提示 — aggregate 对缺汇率币种剔除出 totalValue,存在被排除条目时提示用户
const multiCurHint = (s.unsupported && s.unsupported.length > 0)
? ` · ${escHtml(T.wlMultiCurHint)}`
: '';
// 价格格式化:大数用 k,小数保留整数
const fmtPrice = (v) => {
if (v >= 10000) return `${sym}${(v / 1000).toFixed(1)}k`;
if (v >= 1000) return `${sym}${Math.round(v).toLocaleString()}`;
return `${sym}${v.toFixed(0)}`;
};
// 最早添加日期
const earliestStr = s.earliestAdded > 0
? new Date(s.earliestAdded * 1000).toLocaleDateString(isZh ? 'zh-CN' : 'en-US')
: '-';
el.className = 'sglv-wl-kpi-row';
el.innerHTML = `
${ICONS.heart}
${s.total.toLocaleString()}
${T.wlTotal}
${T.wlEarliest} ${earliestStr}
${ICONS.dollar}
${fmtPrice(s.totalValue)}
${T.wlKpiTotalValue}
${T.wlAvgPrice} ${fmtPrice(s.avgPrice)} · ${T.wlMedian} ${fmtPrice(s.medianPrice)}${multiCurHint}
${ICONS.tag}
${s.discountedCount.toLocaleString()}
${T.wlKpiOnSale}
${T.wlRate} ${(s.discountedRate * 100).toFixed(1)}% · ${T.wlAvgDisc} ${s.avgDiscount.toFixed(0)}% · ${T.wlMaxDisc} ${s.maxDiscount}%
${ICONS.rocket}
${s.comingSoonCount.toLocaleString()}
${T.wlKpiComingSoon}
${T.wlRate} ${(s.comingSoonRate * 100).toFixed(1)}%
${ICONS.library}
${s.inLibrary.toLocaleString()}
${T.wlKpiInLib}
${T.wlPurchaseRate} ${(s.purchaseRate * 100).toFixed(1)}%
${ICONS.calendar}
${s.addedByYear.length}
${isZh ? '添加年份' : 'Years'}
${s.addedByYear.length > 0 ? `${s.addedByYear[0][0]} - ${s.addedByYear[s.addedByYear.length - 1][0]}` : '-'}
`;
}
// 左侧仪表盘:热门标签 TOP 15 + 添加时间线(参考愿望单导出器统计面板样式)
const WL_TAG_COLORS = ['#66c0f4', '#a78bfa', '#34d399', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#f97316', '#84cc16', '#6366f1', '#14b8a6', '#e11d48', '#8b5cf6', '#0ea5e9', '#10b981'];
function renderWishlistSide() {
const tagsEl = document.getElementById('sglv-wl-tags');
const tlEl = document.getElementById('sglv-wl-timeline');
const upEl = document.getElementById('sglv-wl-upcoming');
const ytEl = document.getElementById('sglv-wl-year-trend');
const pdEl = document.getElementById('sglv-wl-price-dist');
const ddEl = document.getElementById('sglv-wl-disc-dist');
const csEl = document.getElementById('sglv-wl-catstats');
if (!tagsEl || !tlEl) return;
if (!state.wishlistLoaded) {
[tagsEl, tlEl, upEl, ytEl, pdEl, ddEl, csEl].forEach(e => { if (e) e.innerHTML = ''; });
return;
}
const s = computeWishlistStatsCached();
// 按年添加趋势(参考 steam-portal AddedTimelineChart,迷你竖向柱状图)
// v2.3.23: 柱子外套轨道容器确保百分比高度生效;峰值年份高亮突出
if (ytEl) {
const maxY = s.addedByYear.length > 0 ? Math.max(...s.addedByYear.map(x => x[1])) : 1;
ytEl.innerHTML = `
${ICONS.calendar} ${T.wlAddedByYear}
${s.addedByYear.length === 0 ? `${T.wlNoData}
` :
`${s.addedByYear.map(([year, count]) => `
`).join('')}
`}
`;
}
// 价格区间分布(参考 steam-portal PriceDistChart,竖向柱状图)
if (pdEl) {
const maxP = s.priceBuckets.length > 0 ? Math.max(...s.priceBuckets.map(b => b.count)) : 1;
const hasP = s.priceBuckets.some(b => b.count > 0);
pdEl.innerHTML = `
${ICONS.dollar} ${T.wlPriceDist}
${!hasP ? `${T.wlNoData}
` :
`${s.priceBuckets.map(b => `
`).join('')}
`}
`;
}
// 折扣分布(参考 steam-portal DiscountDistChart,竖向柱状图)
if (ddEl) {
const maxD = s.discountBuckets.length > 0 ? Math.max(...s.discountBuckets.map(b => b.count)) : 1;
const hasD = s.discountBuckets.some(b => b.count > 0);
ddEl.innerHTML = `
${ICONS.tag} ${T.wlDiscountDist}
${!hasD ? `${T.wlNoData}
` :
`${s.discountBuckets.map(b => `
`).join('')}
`}
`;
}
// 热门标签 TOP 15
const maxTag = s.topTags.length > 0 ? s.topTags[0][1] : 1;
tagsEl.innerHTML = `
${ICONS.barChart} ${T.wlTopTags}
${s.topTags.length === 0 ? `${T.wlNoData}
` :
s.topTags.map(([name, count], i) => `
${wlEscape(name)}
${count}
`).join('')}
`;
// 添加时间线(按月,取最近 12 个月)
const maxTl = s.timeline.length > 0 ? Math.max(...s.timeline.map(x => x[1])) : 1;
tlEl.innerHTML = `
${ICONS.clock} ${T.wlTimeline}
${s.timeline.length === 0 ? `${T.wlNoData}
` :
s.timeline.map(([month, count]) => `
`).join('')}
`;
// v2.7.3: 待上市游戏图表(参考愿望单插件,替换原类型分布)
// 按发售时间分组:30天内 / 90天内 / 更远 / 未定日期,并高亮最近即将发售的游戏
if (upEl) {
const now = Date.now();
const day30 = 30 * 864e5, day90 = 90 * 864e5;
const upcoming = state.wishlistGames
.filter(g => g.isComingSoon)
.map(g => {
let ts = 0;
if (g.releaseDate) {
const d = new Date(g.releaseDate);
if (!isNaN(d.getTime())) ts = d.getTime();
}
return { appid: g.appid, name: g.name, ts, releaseDate: g.releaseDate || '' };
});
const within30 = upcoming.filter(g => g.ts > 0 && g.ts - now <= day30 && g.ts - now >= 0).length;
const within90 = upcoming.filter(g => g.ts > 0 && g.ts - now > day30 && g.ts - now <= day90).length;
const far = upcoming.filter(g => g.ts > 0 && g.ts - now > day90).length;
const noDate = upcoming.filter(g => g.ts === 0).length;
const maxUp = Math.max(within30, within90, far, noDate, 1);
// 最近即将发售的游戏(取第一个有日期且未过期的)
const nextRelease = upcoming.filter(g => g.ts > 0 && g.ts >= now).sort((a, b) => a.ts - b.ts)[0];
const upBuckets = [
{ label: T.wlUpcoming30d, count: within30, color: '#10b981' },
{ label: T.wlUpcoming90d, count: within90, color: '#06b6d4' },
{ label: T.wlUpcomingFar, count: far, color: '#a78bfa' },
{ label: T.wlUpcomingNoDate, count: noDate, color: '#94a3b8' },
];
upEl.innerHTML = `
${ICONS.rocket} ${T.wlUpcoming}
${upcoming.length}
${upcoming.length === 0 ? `${T.wlNoData}
` :
`${upBuckets.map(b => `
`).join('')}
`}
${nextRelease ? `
${ICONS.clock}
${wlEscape(nextRelease.name)}
${nextRelease.releaseDate}
` : ''}
`;
}
// v2.8.0: 我的类别统计(每类别详细统计 + 点击筛选游戏列表)
if (csEl) {
const catStats = s.categoryStats || [];
const uncat = s.uncategorizedStats || { count: 0, totalValue: 0, discountCount: 0, comingSoonCount: 0, inLibraryCount: 0, pct: 0 };
const sym = detectWlPriceSymbol();
const activeCat = state.wishlistCategoryFilter;
const catCount = catStats.length;
const titleHtml = `${ICONS.barChart} ${T.wlCatStats}
${catCount}
`;
if (s.total === 0) {
csEl.innerHTML = `${titleHtml}${T.wlNoData}
`;
} else if (catCount === 0) {
csEl.innerHTML = `${titleHtml}${T.wlUncatEmpty.replace('{n}', uncat.count.toLocaleString())}
`;
} else {
const maxCount = Math.max(...catStats.map(c => c.count), uncat.count, 1);
const renderCatCard = (c, color, isUncat) => {
const catId = isUncat ? '__uncat__' : c.id;
const isActive = activeCat === catId;
const nameStyle = isUncat ? 'font-style:italic;color:var(--sglv-text-secondary)' : '';
return `
${ICONS.dollar}${sym}${c.totalValue.toFixed(0)}
${ICONS.tag}${c.discountCount}
${ICONS.rocket}${c.comingSoonCount}
${ICONS.library}${c.inLibraryCount}
`;
};
csEl.innerHTML = `${titleHtml}
${catStats.map((c, i) => renderCatCard(c, WL_TAG_COLORS[i % WL_TAG_COLORS.length], false)).join('')}
${uncat.count > 0 ? renderCatCard({ ...uncat, name: T.wlCatStatsNoCat }, '#64748b', true) : ''}
`;
// v2.8.0: 点击类别卡片筛选/取消筛选游戏列表
csEl.querySelectorAll('.sglv-wl-cat-card').forEach(card => {
card.addEventListener('click', () => {
const catId = card.dataset.catId;
if (state.wishlistCategoryFilter === catId) {
state.wishlistCategoryFilter = null;
state.wishlistFilter = 'all';
} else {
state.wishlistCategoryFilter = catId;
state.wishlistFilter = 'category';
}
state.wishlistPage = 1;
document.querySelectorAll('.sglv-wl-filter-btn').forEach(b =>
b.classList.toggle('active', b.dataset.filter === state.wishlistFilter));
renderWishlistSide();
renderWishlistGamesList();
});
});
}
}
}
// v2.9.47: 愿望单加载等待动画(参考 DLC spinner 风格)
function renderWishlistLoadingHtml() {
const stages = [
{ pct: 15, text: T.wlLoadStage1 },
{ pct: 45, text: T.wlLoadStage2 },
{ pct: 75, text: T.wlLoadStage3 },
];
const stage = stages[state.wishlistLoadStage - 1] || { pct: 10, text: T.wlFetching };
const pct = state.wishlistLoadProgress > 0 ? state.wishlistLoadProgress : stage.pct;
const spinSvg = '';
return `
${spinSvg}
${stage.text}
${T.wlLoadSub}
`;
}
function renderWishlistGamesList() {
const content = document.getElementById('sglv-wishlist-content');
if (!content) return;
// v2.5.0: 辅助函数——清空工具栏分页
const clearPagination = () => { const p = document.getElementById('sglv-wishlist-pagination'); if (p) p.innerHTML = ''; };
if (state.wishlistLoading) {
// v2.9.49: 优化加载状态更新——已有 loading 容器时仅更新文本和进度条,避免 spinner 动画重启闪烁
const existing = content.querySelector('.sglv-wl-loading');
if (existing) {
const stages = [
{ pct: 15, text: T.wlLoadStage1 },
{ pct: 45, text: T.wlLoadStage2 },
{ pct: 75, text: T.wlLoadStage3 },
];
const stage = stages[state.wishlistLoadStage - 1] || { pct: 10, text: T.wlFetching };
const pct = state.wishlistLoadProgress > 0 ? state.wishlistLoadProgress : stage.pct;
const textEl = existing.querySelector('.sglv-wl-loading-text');
const barEl = existing.querySelector('.sglv-wl-loading-bar-fill');
if (textEl) textEl.textContent = stage.text;
if (barEl) barEl.style.width = pct + '%';
} else {
content.innerHTML = renderWishlistLoadingHtml();
}
clearPagination();
return;
}
if (!state.wishlistLoaded || state.wishlistGames.length === 0) {
content.innerHTML = `${state.wishlistLoaded ? T.wlNoData : T.wlFetching}
`;
clearPagination();
return;
}
const games = getFilteredWishlist();
if (games.length === 0) {
content.innerHTML = `${T.wlNoData}
`;
clearPagination();
return;
}
// v2.4.0: 复用 paginate 纯函数统一分页计算
const { pageItems: pageGames, totalPages, page: _clampedPage } = paginate(games, state.wishlistPage, state.wishlistPageSize);
state.wishlistPage = _clampedPage;
const ownedIds = getWishlistOwnedIds();
const sym = detectWlPriceSymbol();
const isOwned = g => ownedIds.has(Number(g.appid)) || ownedIds.has(String(g.appid));
// v2.3.23: 在库状态判定集合——个人在库优先,其次家庭共享(ownedGames 未加载时退化为旧集合)
const myOwnedIds = new Set();
const familySharedIds = new Set();
state.ownedGames.forEach(og => {
if (isGameOwnedByMe(og)) myOwnedIds.add(Number(og.appid));
else familySharedIds.add(Number(og.appid));
});
const showFamily = storage.getShowFamilyShared();
const ownershipBadgeHtml = g => {
const id = Number(g.appid);
const ownedByMe = state.ownedGames.length > 0 ? myOwnedIds.has(id) : isOwned(g);
if (ownedByMe) return `✓ ${T.wlInLibrary}`;
// v2.9.67: 家庭共享标签改为图标+“共享”汉字,固定在价格列最右侧,避免换行
if (showFamily && familySharedIds.has(id)) {
const _fsIcon = ICONS.familyShare.replace('