/* =============================================================================
* SGLV PTW · Tab: 家庭对比 (Family Compare)
* -----------------------------------------------------------------------------
* 迁移自主脚本 createFamilyCompareSection(v2.20.0 :14923-15088,166 行)
* + ptFetchMemberPlaytime(:15573-15590,18 行)
*
* 迁移纪律:纯搬运不改逻辑。依赖经 ctx.host 注入。
*
* 架构说明:家庭成员缓存读写(ptFamLoad/ptFamSave/ptFamIsStale)属**数据层**,
* 按三层架构留在主脚本,经 PTW_HOST.familyCache 暴露;
* 本库只做渲染与并发调度。
* ========================================================================== */
(function (root) {
'use strict';
function mount(container, ctx) {
const H = ctx.host;
const T = H.T;
const ICONS = H.ICONS;
const isZh = H.isZh;
const escHtml = H.escHtml;
const myGames = ctx.getGames();
const totalMinutes = myGames.reduce((s, g) => s + (g.playtime || 0), 0);
const familySection = document.createElement('div');
familySection.className = 'sglv-family-compare-section';
familySection.innerHTML = `
${ICONS.share} ${T.ptFamilyCompare}
`;
const listEl = familySection.querySelector('#sglv-fam-list');
const titleEl = familySection.querySelector('.sglv-fam-title');
const mySteamId = String(H.getActiveSteamId() || '');
const FAM_TOP_N = 20;
// ===== 我的数据:由本地库存派生(零网络)=====
const mySource = (myGames.length ? myGames : myGames.filter(g => H.isGameOwnedByMe(g)))
.filter(g => (g.playtime || 0) > 0)
.sort((a, b) => (b.playtime || 0) - (a.playtime || 0));
const myItem = {
steamId: mySteamId, isMe: true,
totalMinutes: totalMinutes,
top: mySource.slice(0, FAM_TOP_N).map(g => ({ appid: g.appid, name: g.name, playtime: g.playtime || 0, icon: g.icon })),
};
// 展开/收起(事件委托)
listEl.addEventListener('click', e => {
const head = e.target.closest('.sglv-fam-head');
if (head) head.parentElement.classList.toggle('open');
});
function renderItems(items, familyName) {
if (familyName) titleEl.innerHTML = `${ICONS.share} ${escHtml(familyName)} · ${T.ptFamilyCompare}`;
const maxMin = Math.max(1, ...items.map(i => i.totalMinutes || 0));
listEl.innerHTML = items.map((m, i) => {
const hours = m.totalMinutes >= 0 ? (m.totalMinutes / 60).toFixed(1) + 'h' : '—';
const pct = m.totalMinutes > 0 ? (m.totalMinutes / maxMin * 100).toFixed(1) : '0';
let gamesHtml;
if (m.failed || !m.top || m.top.length === 0) {
gamesHtml = `${escHtml(m.failed ? T.ptFamPrivate : T.ptFamNoGames)}
`;
} else {
const maxG = Math.max(1, m.top[0].playtime || 0);
// 行首图标:loading=lazy 在隐藏子树内不请求,展开成员才加载该成员 ≤20 张
gamesHtml = m.top.map((g, gi) => {
const gp = ((g.playtime || 0) / maxG * 100).toFixed(1);
const iconUrl = H.getGameIconUrl(g.appid, g.icon);
return `
${gi + 1}
${g.name}
${g.playtime > 0 ? (g.playtime / 60).toFixed(1) + 'h' : T.ptRange0}
`;
}).join('');
}
return `
${i + 1}
${escHtml(m.name)}
${hours}
▾
${gamesHtml}
`;
}).join('');
H.applyGameAliases(listEl);
}
// 单成员生涯时长 + Top20(apiKey 优先,access_token 兜底)
async function fetchMemberPlaytime(steamId, apiKey, token) {
const url = apiKey
? `https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${apiKey}&steamid=${steamId}&include_played_free_games=1&format=json`
: (token ? `https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?access_token=${token}&steamid=${steamId}&include_played_free_games=1` : '');
if (!url) return null;
const data = await H.requestSteamAPI(url);
const gList = (data && data.response && data.response.games) || [];
if (!gList.length) return null;
const total = gList.reduce((s, g) => s + (g.playtime_forever || 0), 0);
// 第 5 位透传 img_icon_url —— 社区小图标比 capsule_sm_120 兜底小 ~60%
const top = [...gList]
.sort((a, b) => (b.playtime_forever || 0) - (a.playtime_forever || 0))
.slice(0, FAM_TOP_N)
.map(g => [g.appid, g.name || `App ${g.appid}`, g.playtime_forever || 0, g.rtime_last_played || 0, g.img_icon_url || '']);
return { t: total, c: gList.length, ts: Date.now(), g: top };
}
function recordToItem(rec) {
return {
totalMinutes: rec.t,
// a[4] = img_icon_url;旧 4 元组缓存 → '' → capsule 兜底
top: (rec.g || []).map(a => ({ appid: a[0], name: a[1], playtime: a[2], icon: a[4] || '' })),
failed: !!rec.fail,
};
}
async function loadFamily(force = false) {
listEl.innerHTML = H.createSkeleton('family', 4);
const authToken = await H.getAccessToken().catch(() => null);
const apiKey = H.storage.getApiKey();
const cache = H.familyCache;
if (force) { cache.load(); cache.data.members = {}; cache.save(); }
let familyInfo = H.storage.getFamilyInfo() || {};
let nameMap = familyInfo.steamIdtoName || {};
let familyName = familyInfo.family_name || '';
if (Object.keys(nameMap).length === 0 && authToken) {
try {
const freshInfo = await H.fetchFamilyInfo(authToken);
if (freshInfo) {
H.storage.setFamilyInfo(freshInfo);
nameMap = freshInfo.steamIdtoName || {};
familyName = freshInfo.family_name || '';
}
} catch (e) { /* ignore */ }
}
if (familyName) titleEl.innerHTML = `${ICONS.share} ${escHtml(familyName)} · ${T.ptFamilyCompare}`;
if (!mySteamId || Object.keys(nameMap).length === 0) {
const reason = (!apiKey && !authToken) ? T.ptNoApiKey : (authToken ? T.ptNoFamily : T.ptNoToken);
listEl.innerHTML = `
${H.emptyHtml(reason, { style: 'margin-bottom:10px' })}
`;
const retry = listEl.querySelector('#sglv-family-retry');
if (retry) retry.addEventListener('click', () => {
H.storage.setFamilyInfo({});
loadFamily(true);
});
return;
}
const myName = nameMap[mySteamId] || (isZh ? '我' : 'Me');
myItem.name = myName;
const memberInfo = (H.storage.getFamilyInfo() || {}).family_member || [];
const others = Object.entries(nameMap)
.filter(([sid]) => sid !== mySteamId)
.map(([sid, name]) => {
const fm = memberInfo.find(x => x.steamid === sid);
return { steamId: sid, name: (fm && fm.userName) || name || ('ID:' + String(sid).slice(-4)) };
});
cache.load();
const withCache = m => {
const rec = cache.data.members[m.steamId];
return rec ? { ...m, ...recordToItem(rec) } : null;
};
// ① 先用缓存立刻出图(命中即秒开)
const cachedItems = [myItem, ...others.map(withCache).filter(Boolean)]
.sort((a, b) => (b.totalMinutes || 0) - (a.totalMinutes || 0));
if (cachedItems.length > 1) renderItems(cachedItems, familyName);
else listEl.innerHTML = H.createSkeleton('family', Math.max(2, others.length));
// ② 增量刷新:仅刷新缺失 / 过期(>1 周) 的成员,并发 3 + 429 自动降速
const stale = others.filter(m => force || cache.isStale(cache.data.members[m.steamId]));
if (stale.length === 0) {
renderItems(cachedItems.length > 1 ? cachedItems : [myItem], familyName);
return;
}
const results = await H.sglvSmartPool(
stale.map(m => () => fetchMemberPlaytime(m.steamId, apiKey, authToken).then(rec => ({ steamId: m.steamId, rec }))),
3
);
results.forEach((r, i) => {
const sid = stale[i].steamId;
if (r && r.rec) cache.data.members[sid] = r.rec;
else if (!cache.data.members[sid]) {
cache.data.members[sid] = { t: -1, c: 0, ts: Date.now(), g: [], fail: true };
}
});
cache.save();
const finalItems = [myItem, ...others.map(m => {
const rec = cache.data.members[m.steamId];
return rec ? { ...m, ...recordToItem(rec) } : { ...m, totalMinutes: -1, failed: true };
})].sort((a, b) => (b.totalMinutes || 0) - (a.totalMinutes || 0));
renderItems(finalItems, familyName);
}
const refreshBtn = familySection.querySelector('#sglv-fam-refresh');
if (refreshBtn) refreshBtn.addEventListener('click', e => {
e.stopPropagation();
loadFamily(true);
});
container.appendChild(familySection);
loadFamily(false);
}
// ---- 注册 ----
if (!root.SGLVPTW) {
console.warn('[PTW-TAB-FAMILY] 框架未就绪,无法注册');
} else {
root.SGLVPTW.registerTab({
key: 'family',
label: '家庭对比',
order: 30,
requires: [
'getOwnedGames', 'getActiveSteamId', 'isGameOwnedByMe', 'getGameIconUrl',
'applyGameAliases', 'createSkeleton', 'emptyHtml', 'sglvSmartPool',
'requestSteamAPI', 'getAccessToken', 'storage', 'familyCache',
// v2.21.1 审核修正:fetchFamilyInfo(authToken) 真实使用(L134)却未声明,
// host 缺失时会 TypeError 而非优雅降级 → 补声明;
// 顶层 'getFamilyInfo' 是旧契约残留(实际用的是 storage.getFamilyInfo)→ 移除。
'fetchFamilyInfo', 'ICONS', 'T', 'isZh', 'escHtml',
],
mount,
});
console.log('[PTW-TAB-FAMILY] 已注册');
}
})(typeof window !== 'undefined' ? window : globalThis);