// ==UserScript==
// @name Steam 许可分类器
// @namespace http://tampermonkey.net/
// @version 2.4.11
// @description 对Steam账户许可进行分类:商店、零售、礼物、免费,支持绝版游戏检查
// @author SmallFork
// @match https://store.steampowered.com/account/licenses*
// @grant GM_info
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant GM_getResourceText
// @grant unsafeWindow
// @resource delistedData https://leanisssharedstorage.blob.core.windows.net/copilot/asdm-files/steam_delisted_apps.json
// @connect api.steampowered.com
// @connect leanisssharedstorage.blob.core.windows.net
// @license MIT
// @tag Steam
// @tag games
// @homepageURL https://keylol.com/t1035308-1-1
// @icon data:image/svg+xml,
// ==/UserScript==
(function() {
'use strict';
// ==================== 配置 ====================
const CFG = {
PAGE_SIZE: 50, DEBOUNCE: 150, BLOB_CLEANUP: 500,
DATE_W: 160, DAY_MS: 864e5, OBS_DEBOUNCE: 150,
STICKY: 0,
REDEEM_MAX: 9, REDEEM_WAIT: 20,
MAX_PAGES: 3000, PAGE_DELAY: 150,
CACHE_TTL: 7 * 864e5, // 缓存有效期 7 天(毫秒)
INCREMENTAL_MAX_PAGES: 500, // 增量加载兜底:最多抓取页数,防止缓存指纹失配导致死循环
STALE_CACHE_THRESHOLD: 5 // 连续全新页达到此数时判定缓存失效
};
// ==================== I18N ====================
const ZH = {
store:'商店',retail:'零售',free:'免费',gift:'礼物',all:'全部',total:'总计',
pageTitle:'{nickname} 的许可',searchPlaceholder:'搜索游戏名称...',
showAll:'显示全部',pagedView:'分页显示',prev:'上一页',next:'下一页',
historyLink:'消费历史记录',
advanced:'高级选项',dateFrom:'从',dateTo:'到',
sortBy:'排序',nameAsc:'名称 A-Z',nameDesc:'名称 Z-A',dateLabel:'日期',clearFilter:'清除筛选',
donutTitle:'许可分布详情',
exportCSV:'CSV',exportJSON:'JSON',exportFailed:'导出失败',exportNoData:'导出失败: 没有数据',exportSuccess:'导出成功',
csvDate:'日期',csvName:'名称',csvCategory:'分类',csvDetails:'获取方式',
redeemBtn:'激活',redeemTitle:'CDKey 批量激活',
redeemPlaceholder:'输入CDKey,支持批量激活',
activateBtn:'激活Key',pasteBtn:'粘贴',clearBtn:'清空',copySub:'复制Sub',noSubData:'没有可复制的Sub',pasteNoKey:'没提取到Key',pasteKeyCount:'共提取{count}个Key',
indexLabel:'#',resultLabel:'结果',detailLabel:'详情',itemLabel:'项目',
success:'成功',fail:'失败',redeeming:'激活中',waiting:'等待中',
copyFailed:'复制失败Key',noFailedKeys:'没有失败的Key',copiedKeys:'复制成功',clearHistory:'清空历史',
redeemDone:'激活完成:共{total}个,成功{success}个,失败{fail}个',
totalLabel:'总计',successLabel:'成功',failLabel:'失败',
networkError:'网络错误或超时',othersError:'其他错误',
invalidKey:'无效激活码',duplicateKey:'重复激活',rateLimit:'次数上限',regionRestrict:'地区限制',alreadyOwned:'已拥有',missingDep:'缺少主游戏',walletCode:'这是充值码',successDetail:'激活成功',
withoutFree:'排除免费',freeDisabled:'已排除免费,无法查看免费分类',yearTitle:'年份',noData:'暂无数据',
calendarTitle:'日历热力图',calendarNoData:'没有数据',calendarLess:'少',calendarMore:'多',countUnit:'个',
filterCleared:'已清除筛选',clearCache:'清除缓存并刷新',clearCacheConfirm:'确定要清除所有缓存并刷新页面吗?',confirmOk:'确定',confirmCancel:'取消',noKeyInput:'没有输入Key',inputCleared:'已清空',
monthlyDistribution:'月度分布',
similarTitle:'相同许可猜测',similarPrefixLen:'前缀单词数',similarCount:'数量',
dataNote:'数据仅供参考,实际以系统记录为准',
trendTitle:'许可增加曲线',trendCumulative:'累加模式',
predictTitle:'添加许可频率分析',
predictDays:'天',
predictNextDate:'预计下次添加',predictLastDate:'最后添加日期',
predictTotalGames:'添加许可总天数',predictTotalItems:'非免费许可总数',
predictAvgPerDay:'平均每日许可数',predictOverallAvg:'平均间隔',
predictYearAvg:'年均间隔',predictYearCount:'添加天数',
predictYearLicenses:'许可数',predictDailyLicenses:'每日许可数',predictYearRatio:'一年占比',predictNote:'基于历史数据预测,仅供参考',
delistedTitle:'绝版游戏检查',delistedTotal:'绝版总数',delistedOwned:'已拥有',delistedMissing:'未拥有',
delistedOwnership:'绝版拥有率',delistedSearch:'搜索绝版游戏...',delistedFetching:'正在获取游戏库...',
delistedFetchFail:'获取游戏库失败',delistedNoData:'暂无绝版游戏数据',delistedLoaded:'已加载绝版数据',
typeDelisted:'已下架',typePurchaseDisabled:'已禁售',typeF2P:'F2P不可用',typeRetailOnly:'仅零售',typeTestApp:'测试应用',
delistedOwners:'拥有占比',delistedDate:'下架时间',delistedAchievements:'成就数',delistedCardName:'游戏名称',delistedCover:'封面',
autoLoadingLicenses:'正在加载入库记录...',autoLoadingProgress:'正在加载入库记录... ({loaded}/{total})',
autoLoadingDone:'入库记录加载完成,共 {count} 条',autoLoadingError:'加载入库记录失败: {msg}',
autoLoadingRedirect:'检测到非第一页,正在跳转以加载全部记录...',
loadStatusInit:'正在加载入库记录,请耐心等待...',
loadStatusCache:'正在恢复缓存...',
loadStatusCheck:'正在检查新增 (已对比 {count} 条)...',
loadStatusFetch:'正在加第{page}页... ({loaded}/{total})...',
loadStatusDone:'加载完成',
};
const ZH_TW = { ...ZH, free:'贈品',gift:'禮物',total:'總計',pageTitle:'{nickname} 的授權',
searchPlaceholder:'搜尋遊戲名稱...',showAll:'顯示全部',pagedView:'分頁顯示',
historyLink:'消費歷史記錄',advanced:'進階選項',dateFrom:'從',
nameAsc:'名稱 A-Z',nameDesc:'名稱 Z-A',clearFilter:'清除篩選',
donutTitle:'許可分佈詳情',
exportFailed:'匯出失敗',exportNoData:'匯出失敗: 沒有資料',exportSuccess:'匯出成功',
csvName:'名稱',csvCategory:'分類',csvDetails:'取得方式',
redeemPlaceholder:'輸入CDKey,支持批量激活',
pasteBtn:'貼上',clearBtn:'清空',copySub:'複製Sub',noSubData:'沒有可複製的Sub',pasteNoKey:'沒提取到Key',pasteKeyCount:'共提取{count}個Key',
indexLabel:'#',resultLabel:'結果',detailLabel:'詳情',itemLabel:'項目',
fail:'失敗',
copyFailed:'複製失敗Key',noFailedKeys:'沒有失敗的Key',copiedKeys:'複製成功',clearHistory:'清空歷史',
redeemDone:'激活完成:共{total}個,成功{success}個,失敗{fail}個',
totalLabel:'總計',successLabel:'成功',failLabel:'失敗',
networkError:'網路錯誤或超時',othersError:'其他錯誤',
invalidKey:'無效激活碼',duplicateKey:'重複激活',rateLimit:'次數上限',regionRestrict:'地區限制',alreadyOwned:'已擁有',missingDep:'缺少主遊戲',walletCode:'這是充值碼',successDetail:'激活成功',
withoutFree:'排除免費',freeDisabled:'已排除免費,無法查看免費分類',yearTitle:'年份',noData:'暫無數據',
calendarTitle:'日曆熱力圖',calendarNoData:'沒有數據',calendarLess:'少',calendarMore:'多',countUnit:'個',
filterCleared:'已清除篩選',clearCache:'清除快取並重新整理',clearCacheConfirm:'確定要清除所有快取並重新整理嗎?',confirmOk:'確定',confirmCancel:'取消',noKeyInput:'沒有輸入Key',inputCleared:'已清空',
monthlyDistribution:'月度分布',
similarTitle:'相同許可猜測',similarPrefixLen:'前綴單詞數',similarCount:'數量',
dataNote:'數據僅供參考,實際以系統記錄為準',
trendTitle:'許可增加曲線',trendCumulative:'累加模式',
predictTitle:'添加許可頻率分析',
predictDays:'天',
predictNextDate:'預計下次添加',predictLastDate:'最後添加日期',
predictTotalGames:'添加許可總天數',predictTotalItems:'非免費許可總數',
predictAvgPerDay:'平均每日許可數',predictOverallAvg:'平均間隔',
predictYearAvg:'年均間隔',predictYearCount:'添加天數',
predictYearLicenses:'許可數',predictDailyLicenses:'每日許可數',predictYearRatio:'一年占比',predictNote:'基於歷史數據預測,僅供參考',
delistedTitle:'絕版遊戲檢查',delistedTotal:'絕版總數',delistedOwned:'已擁有',delistedMissing:'未擁有',
delistedOwnership:'絕版擁有率',delistedSearch:'搜尋絕版遊戲...',delistedFetching:'正在獲取遊戲庫...',
delistedFetchFail:'獲取遊戲庫失敗',delistedNoData:'暫無絕版遊戲數據',delistedLoaded:'已載入絕版數據',
typeDelisted:'已下架',typePurchaseDisabled:'已禁售',typeF2P:'F2P不可用',typeRetailOnly:'僅零售',typeTestApp:'測試應用',
delistedOwners:'擁有佔比',delistedDate:'下架時間',delistedAchievements:'成就數',delistedCardName:'遊戲名稱',delistedCover:'封面',
autoLoadingLicenses:'正在載入入庫記錄...',autoLoadingProgress:'正在載入入庫記錄... ({loaded}/{total})',
autoLoadingDone:'入庫記錄載入完成,共 {count} 條',autoLoadingError:'載入入庫記錄失敗: {msg}',
autoLoadingRedirect:'檢測到非第一頁,正在跳轉以載入全部記錄...',
loadStatusInit:'正在載入入庫記錄,請耐心等待...',
loadStatusCache:'正在恢復快取...',
loadStatusCheck:'正在檢查新增 (已對比 {count} 條)...',
loadStatusFetch:'正在載入第{page}頁... ({loaded}/{total})...',
loadStatusDone:'載入完成',
};
const EN = { ...ZH, store:'Store',retail:'Retail',free:'Free',gift:'Gift',all:'All',total:'Total',
pageTitle:"{nickname}'s Licenses",searchPlaceholder:'Search game name...',
showAll:'Show All',pagedView:'Paged',prev:'Prev',next:'Next',
historyLink:'Purchase History',
advanced:'Advanced',dateFrom:'From',dateTo:'To',
sortBy:'Sort',nameAsc:'Name A-Z',nameDesc:'Name Z-A',dateLabel:'Date',clearFilter:'Clear Filters',
donutTitle:'License Distribution Detail',
exportFailed:'Export Failed',exportNoData:'Export failed: No data',exportSuccess:'Export successful',
csvDate:'Date',csvName:'Name',csvCategory:'Category',csvDetails:'Source',
redeemBtn:'Redeem',redeemTitle:'Batch CDKey Activation',
redeemPlaceholder:'Enter CDKeys, supports batch activation',
activateBtn:'Redeem Keys',pasteBtn:'Paste',clearBtn:'Clear',copySub:'Copy Sub',noSubData:'No Sub data to copy',pasteNoKey:'No keys extracted',pasteKeyCount:'Extracted {count} keys',
indexLabel:'#',resultLabel:'Result',detailLabel:'Detail',itemLabel:'Item',
success:'Success',fail:'Failed',redeeming:'Redeeming',waiting:'Waiting',
copyFailed:'Copy Failed Keys',noFailedKeys:'No failed keys',copiedKeys:'Copied successfully',clearHistory:'Clear History',
redeemDone:'Activation complete: {total} total, {success} succeeded, {fail} failed',
totalLabel:'Total',successLabel:'Success',failLabel:'Failed',
networkError:'Network error/timeout',othersError:'Other error',
invalidKey:'Invalid key',duplicateKey:'Duplicate key',rateLimit:'Rate limit',regionRestrict:'Region restricted',alreadyOwned:'Already owned',missingDep:'Missing dependency',walletCode:'Wallet code',successDetail:'Activated',
withoutFree:'Without Free',freeDisabled:'Free excluded, cannot view free category',yearTitle:'Year',noData:'No data',
calendarTitle:'Calendar Heatmap',calendarNoData:'No data',calendarLess:'Less',calendarMore:'More',countUnit:'',
filterCleared:'Filters cleared',clearCache:'Clear cache & reload',clearCacheConfirm:'Are you sure you want to clear all cache and reload?',confirmOk:'Confirm',confirmCancel:'Cancel',noKeyInput:'No keys entered',inputCleared:'Cleared',
monthlyDistribution:'Monthly Distribution',
similarTitle:'Similar License Guess',similarPrefixLen:'Prefix Words',similarCount:'Count',
dataNote:'Data is for reference only, actual records prevail',
trendTitle:'License Growth Trend',trendCumulative:'Cumulative',
predictTitle:'Add License Frequency',
predictDays:'days',
predictNextDate:'Predicted Next',predictLastDate:'Last Added',
predictTotalGames:'Total License Days',predictTotalItems:'Non-free Licenses',
predictAvgPerDay:'Avg Licenses/Day',predictOverallAvg:'Avg Interval',
predictYearAvg:'Year Avg',predictYearCount:'Add Days',
predictYearLicenses:'Licenses',predictDailyLicenses:'Daily Licenses',predictYearRatio:'Year Ratio',predictNote:'Prediction based on historical data, for reference only',
delistedTitle:'Delisted Games Check',delistedTotal:'Total Delisted',delistedOwned:'Owned',delistedMissing:'Missing',
delistedOwnership:'Delisted Ownership',delistedSearch:'Search delisted games...',delistedFetching:'Fetching game library...',
delistedFetchFail:'Failed to fetch game library',delistedNoData:'No delisted game data',delistedLoaded:'Delisted data loaded',
typeDelisted:'Delisted',typePurchaseDisabled:'Purchase Disabled',typeF2P:'F2P Unavailable',typeRetailOnly:'Retail Only',typeTestApp:'Test App',
delistedOwners:'Owner %',delistedDate:'Delisted Date',delistedAchievements:'Achievements',delistedCardName:'Name',delistedCover:'Cover',
autoLoadingLicenses:'Loading licenses...',autoLoadingProgress:'Loading licenses... ({loaded}/{total})',
autoLoadingDone:'Loaded {count} licenses',autoLoadingError:'Failed to load licenses: {msg}',
autoLoadingRedirect:'Not on first page, redirecting to load all records...',
loadStatusInit:'Loading licenses, please wait...',
loadStatusCache:'Restoring from cache...',
loadStatusCheck:'Checking for updates (compared {count})...',
loadStatusFetch:'Loading page {page}... ({loaded}/{total})...',
loadStatusDone:'Loading complete',
};
const I18N = { 'zh-CN': ZH, 'zh-TW': ZH_TW, 'zh-HK': ZH_TW, 'zh-MO': ZH_TW, 'en': EN };
let locale = 'zh-CN';
function detectLang() {
const l = (document.documentElement.lang || '').toLowerCase();
if (I18N[l]) return l;
if (l.startsWith('zh') && (l.includes('tw') || l.includes('hant'))) return 'zh-TW';
return l.startsWith('zh') ? 'zh-CN' : 'en';
}
const t = k => I18N[locale]?.[k] ?? ZH[k] ?? k;
const stopEv = (e, fn) => { e.stopPropagation(); e.preventDefault(); fn?.(); };
function toast(msg, type = 'info', ms = 3000) {
const el = document.createElement('div');
el.className = `slc-toast slc-toast--${type}`; el.textContent = msg;
document.body.appendChild(el);
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('slc-toast--visible')));
const hide = () => {
el.classList.remove('slc-toast--visible');
setTimeout(() => el.remove(), 300);
};
const timer = setTimeout(hide, ms);
el.addEventListener('click', () => { clearTimeout(timer); hide(); });
return { el, hide };
}
function showConfirm(msg) {
return new Promise(resolve => {
const modal = document.createElement('div');
modal.className = 'slc-confirm-modal';
modal.innerHTML = `
${escHtml(msg)}
`;
document.body.appendChild(modal);
requestAnimationFrame(() => requestAnimationFrame(() => modal.classList.add('visible')));
let done = false;
const close = (result) => {
if (done) return; done = true;
modal.classList.remove('visible');
setTimeout(() => modal.remove(), 250);
resolve(result);
};
modal.querySelector('.slc-confirm-ok').addEventListener('click', () => close(true));
modal.querySelector('.slc-confirm-cancel').addEventListener('click', () => close(false));
modal.addEventListener('click', e => { if (e.target === modal) close(false); });
document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') { close(false); document.removeEventListener('keydown', esc); } });
});
}
// ==================== SVG 公共属性 ====================
const _S = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
// ==================== 分类 ====================
const MATCH_CFG = [
{ id:'store', color:'#3b82f6', icon:``, kw:['Steam 商店','Steam Store'] },
{ id:'retail', color:'#eab308', icon:``, kw:['零售','Retail'] },
{ id:'free', color:'#10b981', icon:``, kw:['免费赠送','贈品','Complimentary'] },
{ id:'gift', color:'#ec4899', icon:``, kw:['礼物','玩家通行证','禮物','招待券','Gift','Guest Pass'] }
];
const CATS = ['store','retail','gift','free'].map(id => {
const c = MATCH_CFG.find(x => x.id === id);
return { id, color: c.color, icon: c.icon, get label() { return t(id); } };
});
const ALL_TYPE = Object.freeze({ id:'all', get label(){ return t('all'); }, color:'#4792c4', icon:`` });
const CAT_RE = new Map(MATCH_CFG.map(c => [
new RegExp(c.kw.map(s => s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('|'),'i'), c.id
]));
const isNoFree = () => state.noFree && state.filter !== 'free';
const chartCounts = () => {
if (!isNoFree()) return state.counts;
const m = new Map(CATS.map(c => [c.id, 0])); let tot = 0;
for (const c of CATS) { if (c.id==='free') continue; const v = state.counts.get(c.id)||0; m.set(c.id,v); tot+=v; }
m.set('total',tot); return m;
};
const chartCats = () => isNoFree() ? CATS.filter(c=>c.id!=='free') : CATS;
const SEL = { tbl:'table.account_table', tb:'table.account_table tbody', hdr:'h2', fc:'#steam-license-filter' };
const VER = GM_info.script.version;
const disposers = [];
const state = {
filter:'all', filterCls:'', search:'', page:1,
showAll:false, totalPages:1,
counts: new Map([...CATS.map(c=>[c.id,0]),['total',0]]),
dateFrom:'', dateTo:'', sortBy:'date-desc', noFree:false,
isProcessing: false, isLoading: false, cachedTb: null, cachedDataRows: null,
timers: { search: null, obs: null },
obs: null
};
const getSessionID = () => { try { return unsafeWindow.g_sessionID || window.g_sessionID || ''; } catch { return window.g_sessionID || ''; } };
// ==================== 样式 ====================
const _rgba = (h, a) => { const v = parseInt(h.slice(1), 16); return `rgba(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255},${a})`; };
const _catCSS = CATS.flatMap(c => [
`table.account_table tbody tr[data-category="${c.id}"]{background:${_rgba(c.color, .15)}}`,
`table.account_table tbody tr[data-category="${c.id}"]:hover{background:${_rgba(c.color, .25)}}`,
`table.account_table tbody.filter-${c.id} tr[data-category]:not([data-category="${c.id}"]){display:none}`
]);
const CSS_CRITICAL = [
':root{--bg-page:#0B1120;--bg-card:#1E293B;--border-card:#334155;--text-primary:#F1F5F9;--text-secondary:#94A3B8;--accent-blue:#3B82F6;--accent-amber:#F59E0B;--accent-pink:#EC4899;--accent-green:#10B981;--accent-purple:#8B5CF6;--radius-md:8px;--radius-lg:12px;--shadow-sm:0 2px 8px rgba(0,0,0,.3);--transition:all .2s ease}',
'table.account_table{margin-top:0;border-collapse:separate;border-spacing:0;contain:layout paint}',
'table.account_table tr{transition:background-color .2s ease;contain:layout}',
`table.account_table th{position:sticky;top:var(--filter-h,${CFG.STICKY}px);z-index:99;background:#0B1120;text-transform:none;font-size:13px;font-weight:bold;color:#F1F5F9;padding:12px 12px;border-bottom:1px solid #334155}`,
`table.account_table td.license_date_col{width:${CFG.DATE_W}px!important;min-width:${CFG.DATE_W}px;white-space:nowrap;text-align:left}`,
'table.account_table th.license_date_col{text-align:left}',
'table.account_table td{padding:12px 12px;border-bottom:1px solid rgba(51,65,85,.5);color:#F1F5F9;vertical-align:middle}',
'table.account_table tr:hover td{background:rgba(59,130,246,.08)}',
'#steam-license-filter{margin:0 0 4px;padding:16px 20px;background:var(--bg-card);border:1px solid var(--border-card);border-radius:var(--radius-lg);backdrop-filter:blur(4px);position:sticky;top:0;z-index:100;contain:layout;display:flex;flex-direction:column;gap:12px}',
`.stats-row{display:flex;gap:10px;flex-wrap:wrap}`,
`.stat-card{background:rgba(15,23,42,.6);border:1px solid var(--border-card);border-radius:var(--radius-md);padding:10px 16px;min-width:100px;text-align:center;cursor:pointer;position:relative;overflow:hidden}`,
`.stat-card:hover{transform:translateY(-2px);border-color:var(--card-color,var(--accent-blue));background:rgba(59,130,246,.1);box-shadow:var(--shadow-sm)}`,
`.stat-card.active{border-color:var(--card-color,var(--accent-blue));background:rgba(59,130,246,.15);box-shadow:var(--shadow-sm)}`,
`.stat-card.active .stat-number,.stat-card.active .stat-label{color:var(--card-color,var(--accent-blue))}`,
`.stat-card.disabled{opacity:.4;cursor:not-allowed}`,
`.stat-number{font-size:1.5rem;font-weight:700;line-height:1.2;color:var(--text-primary)}`,
`.stat-label{font-size:.7rem;letter-spacing:.5px;color:var(--text-secondary);margin-top:2px;display:flex;align-items:center;justify-content:center;gap:3px}`,
`.stat-label svg{width:12px;height:12px;flex-shrink:0}`,
`.stat-bar{height:3px;background:rgba(255,255,255,.08);border-radius:2px;margin-top:6px;overflow:hidden}`,
`.stat-bar-fill{height:100%;border-radius:2px;transition:width .3s ease}`,
`.stat-tools{display:flex;align-items:center;gap:4px;flex-wrap:wrap}`,
`.tool-btn{background:rgba(15,23,42,.6)!important;border:1px solid var(--border-card)!important;border-radius:var(--radius-md)!important;padding:0 10px!important;font-size:13px!important;height:34px!important;width:auto!important;color:var(--text-secondary)!important;cursor:pointer;transition:var(--transition);display:flex;align-items:center;justify-content:center;box-sizing:border-box}`,
`.tool-btn:hover{background:rgba(59,130,246,.2)!important;color:var(--text-primary)!important;border-color:var(--accent-blue)!important;transform:translateY(-1px)}`,
`.toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px}`,
`.toolbar .search-box{flex:2;min-width:180px;background:#0F172A;border:1px solid var(--border-card);border-radius:40px;padding:8px 16px;color:#F1F5F9;font-size:13px;outline:none;transition:var(--transition)}`,
`.toolbar .search-box::placeholder{color:var(--text-secondary)}`,
`.toolbar .search-box:focus{border-color:var(--accent-blue);box-shadow:0 0 0 2px rgba(59,130,246,.3)}`,
`.adv-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap}`,
`.adv-lbl{color:var(--text-secondary);font-size:12px;white-space:nowrap}`,
`.adv-date{position:relative;background:#0F172A!important;border:1px solid var(--border-card);border-radius:40px;color:#F1F5F9!important;padding:8px 16px;font-size:13px;outline:none;transition:var(--transition);color-scheme:dark;height:36px;width:140px;-webkit-appearance:none;appearance:none;box-sizing:border-box;line-height:normal;text-align:center}`,
`.adv-date::-webkit-calendar-picker-indicator{opacity:0;cursor:pointer;width:20px;height:20px}`,
`.adv-date::after{content:'';position:absolute;right:10px;top:50%;transform:translateY(-50%);width:16px;height:16px;pointer-events:none;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394A3B8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E") no-repeat center}`,
`.adv-date::-webkit-datetime-edit,.adv-date::-webkit-datetime-edit-fields-wrapper,.adv-date::-webkit-datetime-edit-text,.adv-date::-webkit-datetime-edit-month-field,.adv-date::-webkit-datetime-edit-day-field,.adv-date::-webkit-datetime-edit-year-field{background:transparent;color:#F1F5F9}`,
`.adv-date:focus{border-color:var(--accent-blue);box-shadow:0 0 0 2px rgba(59,130,246,.3)}`,
`.adv-sort{background:#0F172A;border:1px solid var(--border-card);border-radius:40px;color:#F1F5F9;padding:8px 16px;font-size:13px;outline:none;transition:var(--transition);color-scheme:dark;height:36px;-webkit-appearance:none;appearance:none;box-sizing:border-box;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394A3B8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px;cursor:pointer}`,
`.adv-sort option{background:#0B1120;color:var(--text-primary)}`,
`.adv-sort:focus{border-color:var(--accent-blue);box-shadow:0 0 0 2px rgba(59,130,246,.3)}`,
`.adv-clr{background:rgba(15,23,42,.6)!important;border:1px solid var(--border-card)!important;border-radius:40px!important;padding:0 10px!important;font-size:13px!important;height:36px!important;width:auto!important;color:var(--text-secondary)!important;cursor:pointer;transition:var(--transition);display:flex;align-items:center;justify-content:center;box-sizing:border-box}`,
`.adv-clr:hover{background:rgba(59,130,246,.2)!important;color:var(--text-primary)!important;border-color:var(--accent-blue)!important;transform:translateY(-1px)}`,
`.pager{display:flex;align-items:center;gap:6px;margin-left:auto}`,
`#steam-license-filter button.pager-btn{background:rgba(15,23,42,.6);border:1px solid var(--border-card);border-radius:var(--radius-md);padding:6px 14px;font-size:13px;height:auto;color:var(--text-primary);cursor:pointer;transition:var(--transition)}`,
`#steam-license-filter button.pager-btn:hover{background:rgba(59,130,246,.2);transform:translateY(-1px)}`,
`#steam-license-filter button.pager-btn:disabled{opacity:.4;cursor:not-allowed;transform:none}`,
`.page-info{display:inline-flex;align-items:center;gap:2px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-secondary)}`,
`.page-input{width:30px;text-align:center;background:rgba(0,0,0,.3);border:1px solid #4a6a7a;border-radius:4px;color:#c7d5e0;font-size:13px;padding:2px 4px;outline:none}`,
`.page-input:focus{border-color:var(--accent-blue)}`,
`#steam-license-filter button{border:2px solid transparent;border-radius:6px;background:transparent;color:var(--text-primary);cursor:pointer;font-weight:500;transition:var(--transition);outline:none;will-change:transform}`,
`#steam-license-filter button:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.4)}`,
..._catCSS,
'table.account_table tbody.paginated tr[data-category]:not(.page-visible){display:none}',
'table.account_table tbody tr[data-category="other"]{display:none}',
'h2 .search-box{display:none}',
'#slc-no-free{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;user-select:none;font-size:12px;color:var(--text-secondary);white-space:nowrap}',
'#slc-no-free input[type="checkbox"]{position:absolute;opacity:0;width:0;height:0}',
'#slc-no-free .tg-track{position:relative;width:44px;height:20px;background:#1E293B;border:1px solid var(--border-card);border-radius:10px;cursor:pointer;transition:var(--transition);flex-shrink:0}',
'#slc-no-free .tg-track::after{content:"";position:absolute;left:2px;top:2px;width:14px;height:14px;background:var(--text-secondary);border-radius:50%;transition:var(--transition)}',
'#slc-no-free input:checked+.tg-track{background:rgba(139,92,246,.3);border-color:var(--accent-purple)}',
'#slc-no-free input:checked+.tg-track::after{left:26px;background:var(--accent-purple)}',
'.stats[class*="svelte-"]{display:none!important}',
'#license-stats{display:none}',
'#steam-license-filter button[data-action]:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,.4);background:rgba(59,130,246,.2)}',
'#steam-license-filter button[data-action]:disabled{opacity:.4;cursor:not-allowed}',
'#steam-license-filter button[data-action].active-mode{background:var(--accent-purple);color:#fff}',
'table.account_table tbody.search-filtered tr[data-category]:not(.search-match){display:none}',
`@media(max-width:899px){.stat-card{min-width:80px;padding:8px 10px}.stat-number{font-size:1.2rem}.toolbar .search-box{min-width:140px}.stat-tools{gap:3px}.tool-btn{padding:0 6px!important;height:30px!important}.adv-date{width:115px;padding:6px 10px;font-size:12px;height:32px}}`,
`@media(max-width:599px){.stat-card{min-width:70px;padding:6px 8px}.stat-number{font-size:1rem}.pager-btn{padding:4px 8px!important;font-size:.75rem!important}.stat-tools{gap:2px}.tool-btn svg{width:14px;height:14px}.tool-btn{height:26px!important}#slc-no-free{font-size:11px}.adv-date{width:105px;height:30px;padding:6px 8px;font-size:12px}.adv-sort{font-size:12px;height:30px;padding:6px 10px}}`,
'.slc-toast{position:fixed;bottom:20px;right:20px;padding:12px 20px;border-radius:8px;font-size:14px;font-weight:500;z-index:10001;opacity:0;transform:translateY(10px);transition:all .3s ease;pointer-events:auto;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.4)}',
'.slc-toast--visible{opacity:1;transform:translateY(0)}',
'.slc-toast--info{background:#2a475e;color:#c7d5e0;border:1px solid #4a6a7a}',
'.slc-toast--success{background:rgba(16,185,129,.9);color:#fff}',
'.slc-toast--error{background:rgba(239,68,68,.9);color:#fff}',
'.slc-toast--warning{background:rgba(245,158,11,.9);color:#fff}',
'.slc-load-bar{width:100%;height:4px;background:rgba(255,255,255,.08);overflow:hidden;border-radius:2px;margin:0 0 4px}',
'.slc-load-bar.hidden{opacity:0;transition:opacity .3s ease}',
'.slc-load-bar-fill{height:100%;width:0;background:linear-gradient(90deg,var(--accent-blue),var(--accent-purple));border-radius:2px;transition:width .3s ease}',
'.slc-load-status{display:inline;margin-left:8px;font-size:13px;color:var(--text-secondary);font-weight:400;white-space:nowrap;animation:slc-pulse 1.5s ease-in-out infinite}',
'@keyframes slc-pulse{0%,100%{opacity:.6}50%{opacity:1}}'
].join('');
const CSS_DEFERRED = [
'.slc-modal{position:fixed;inset:0;background:rgba(11,17,32,.85);backdrop-filter:blur(4px);z-index:10000;display:flex;opacity:0;visibility:hidden}',
'#slc-donut-modal,#slc-year-modal,#slc-predict-modal,#slc-trend-modal,#slc-year-month-modal,#slc-similar-modal,#slc-cal-modal,#slc-redeem-modal,#slc-delisted-modal{align-items:center;justify-content:center}',
'.slc-modal.visible{opacity:1;visibility:visible}',
'.slc-mc{background:rgba(30,41,59,.95);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.1);border-radius:24px;padding:28px;box-shadow:0 8px 32px rgba(0,0,0,.5);width:90%;position:relative;transform:scale(.95);opacity:0;transition:transform .3s ease,opacity .3s ease}',
'.slc-modal.visible .slc-mc{transform:scale(1);opacity:1}',
'#slc-donut-c,#slc-predict-c,#slc-trend-c,#slc-similar-c,#slc-year-c,#slc-year-month-c{max-width:850px;contain:layout}',
'#slc-redeem-c{max-width:850px;display:flex;flex-direction:column}',
'#slc-cal-c{max-width:880px;height:650px;display:flex;flex-direction:column}',
'#slc-cal-body{flex:1;overflow-y:auto;min-height:0}',
'.slc-mx{position:absolute;top:12px;right:12px;background:none;border:none;color:#64748b;font-size:22px;cursor:pointer;padding:4px 8px;border-radius:8px;transition:all .2s}',
'.slc-mx:hover{background:rgba(255,255,255,.1);color:#94a3b8;transform:scale(1.1)}',
'.slc-mh{display:flex;align-items:center;gap:14px;margin-bottom:20px}',
'.slc-mh-i{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0}',
'.slc-mh-i svg{width:22px;height:22px;stroke:#fff;fill:none}',
'.slc-mh-t{font-size:20px;font-weight:700;color:#f8fafc;flex:1}',
'.slc-mf{margin-top:20px;padding-top:16px;border-top:1px solid #334155;display:flex;align-items:flex-start;gap:10px}',
'.slc-mf svg{width:16px;height:16px;stroke:#64748b;fill:none;flex-shrink:0;margin-top:1px}',
'.slc-mf-t{font-size:13px;color:#64748b;line-height:1.5}',
/* ===== 窗口导航栏 ===== */
'.slc-nav-sidebar{position:absolute;left:-52px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:6px;z-index:1}',
'.slc-nav-btn{width:42px;height:42px;border-radius:10px;border:1px solid rgba(255,255,255,.08);background:rgba(30,41,59,.9);color:#94a3b8;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s ease;backdrop-filter:blur(8px)}',
'.slc-nav-btn svg{width:20px;height:20px;stroke:currentColor;fill:none}',
'.slc-nav-btn:hover{background:rgba(51,65,85,.9);color:#f8fafc;border-color:rgba(255,255,255,.15);transform:scale(1.05)}',
'.slc-nav-btn.active{color:#fff}',
'#slc-donut-hi,#slc-redeem-hi,#slc-trend-hi{background:#3b82f6}',
'#slc-year-hi,#slc-year-month-hi{background:#8b5cf6}',
'#slc-predict-hi{background:#f59e0b}',
'#slc-similar-hi{background:#f97316}',
'#slc-cal-hi{background:#10b981}',
'#slc-delisted-hi{background:#f44336}',
'.slc-cal-container{display:flex;flex-direction:column;gap:24px}',
'.slc-cal-year{display:flex;flex-direction:column;gap:8px}',
'.slc-cal-year-label{font-size:16px;font-weight:600;color:#f8fafc}',
'.slc-cal-grid{display:flex;flex-direction:column;gap:4px}',
'.slc-cal-months{display:flex;gap:4px;padding-left:32px}',
'.slc-cal-months span{flex:1;text-align:center;font-size:11px;color:#64748b}',
'.slc-cal-days{display:flex;gap:4px}',
'.slc-cal-weekdays{display:flex;flex-direction:column;gap:4px;width:24px}',
'.slc-cal-weekdays span{font-size:10px;color:#64748b;text-align:center;height:14px;line-height:14px}',
'.slc-cal-cells{display:grid;grid-template-columns:repeat(53,1fr);grid-template-rows:repeat(7,1fr);grid-auto-flow:column;gap:2px;flex:1}',
'.slc-cal-cell{width:14px;height:14px;border-radius:2px;transition:all .15s}',
'.slc-cal-cell:hover{transform:scale(1.2);z-index:1}',
'.slc-cal-empty{background:transparent}',
'.slc-cal-legend{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:16px;padding:16px;background:var(--bg-card);border:1px solid var(--border-card);border-radius:12px}',
'.slc-cal-legend-label{font-size:12px;color:#64748b}',
'.slc-cal-legend-cells{display:flex;gap:3px}',
'.slc-cal-legend-cells div{width:14px;height:14px;border-radius:2px}',
'#slc-donut-legend .li{display:flex;align-items:center;gap:6px;padding:6px 10px;background:rgba(0,0,0,.2);border-radius:6px;transition:all .2s}',
'#slc-donut-legend .li:hover{background:rgba(0,0,0,.3);transform:translateY(-2px)}',
'#slc-donut-legend .lc{width:12px;height:12px;border-radius:3px;flex-shrink:0}',
'#slc-donut-legend .ll{color:#c7d5e0;font-size:12px;white-space:nowrap}',
'#slc-donut-legend .lv{color:#8a9ba8;font-size:11px;white-space:nowrap}',
'#slc-donut-layout{display:flex;gap:16px;align-items:stretch}',
'#slc-donut-left{flex:1;aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;background:var(--bg-card);border:1px solid var(--border-card);border-radius:12px}',
'#slc-donut-right{flex:1;aspect-ratio:1;display:flex;flex-direction:column;justify-content:center;padding:20px;background:var(--bg-card);border:1px solid var(--border-card);border-radius:12px}',
'#slc-donut-right .bar-chart{display:flex;align-items:flex-end;justify-content:space-around;height:100%;gap:12px;padding-top:20px}',
'#slc-donut-right .bar-col{display:flex;flex-direction:column;align-items:center;width:60px;height:100%;justify-content:flex-end}',
'#slc-donut-right .bar-track-v{width:100%;height:100%;background:rgba(255,255,255,.06);border-radius:6px 6px 0 0;position:relative;display:flex;flex-direction:column;justify-content:flex-end}',
'#slc-donut-right .bar-fill-v{width:100%;border-radius:6px 6px 0 0;transition:height .3s ease}',
'#slc-donut-right .bar-value{font-size:12px;color:#c7d5e0;margin-bottom:4px;font-weight:600}',
'#slc-donut-right .bar-label{font-size:11px;color:#94a3b8;margin-top:6px;text-align:center}',
'#slc-donut-legend{display:flex;flex-direction:row;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:16px;padding:16px;background:var(--bg-card);border:1px solid var(--border-card);border-radius:12px}',
'#slc-year-legend{display:flex;gap:16px;justify-content:center;margin-top:12px;flex-wrap:wrap;padding:16px;background:#1e293b;border:1px solid #334155;border-radius:12px}',
'#slc-year-legend .yl{display:flex;align-items:center;gap:6px;font-size:13px;color:#c7d5e0}',
'#slc-year-legend .yc{width:12px;height:12px;border-radius:2px;flex-shrink:0}',
'#slc-redeem-c .ra{display:flex;gap:8px;margin-top:16px;align-items:center;flex-wrap:wrap;justify-content:flex-end}',
'#slc-rcf{margin-left:auto}',
'#slc-rstats{display:flex;gap:8px;flex:1;min-width:0}',
'#slc-rstats .pc{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:8px 12px;display:flex;align-items:center;gap:8px;min-width:90px}',
'#slc-rstats .pc-i{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0}',
'#slc-rstats .pc-i svg{width:14px;height:14px}',
'#slc-rstats .pc-i.blue{background:rgba(59,130,246,.15)}',
'#slc-rstats .pc-i.green{background:rgba(34,197,94,.15)}',
'#slc-rstats .pc-i.red{background:rgba(239,68,68,.15)}',
'#slc-rstats .pc-t{display:flex;align-items:center;gap:6px;min-width:0;flex:1}',
'#slc-rstats .pc-l{font-size:10px;color:#64748b;letter-spacing:.3px}',
'#slc-rstats .pc-v{font-size:15px;font-weight:700;color:#f8fafc}',
'#slc-rt,#slc-pt{width:100%;border-collapse:collapse;font-size:13px;background:#1e293b;border:1px solid #334155;overflow:hidden}',
'#slc-rt{margin-top:12px;border-radius:12px;flex:1;overflow-y:auto;display:block}',
'#slc-pt{border-radius:10px}',
'#slc-rt th,#slc-pt th{padding:8px 10px;text-align:left;color:#8f98a0;font-size:12px;text-transform:uppercase;letter-spacing:.5px;background:rgba(0,0,0,.4);border-bottom:1px solid rgba(255,255,255,.08)}',
'#slc-rt td,#slc-pt td{padding:7px 10px;border-bottom:1px solid rgba(255,255,255,.05);color:#c7d5e0;vertical-align:middle}',
'#slc-rt tr:hover td,#slc-pt tr:hover td{background:rgba(255,255,255,.03)}',
'#slc-rt code{color:#66c0f4;font-size:13px;background:rgba(0,0,0,.2);padding:1px 5px;border-radius:3px}',
'#slc-rt a{color:#66c0f4;text-decoration:none;transition:color .15s}',
'#slc-rt a:hover{color:#fff}',
'#slc-ps{display:flex;flex-direction:column;gap:8px;min-width:160px}',
'#slc-ps .pc{background:#1e293b;border:1px solid #334155;border-radius:10px;padding:14px;display:flex;align-items:center;gap:12px}',
'#slc-ps .pc-i{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0}',
'#slc-ps .pc-i svg{width:20px;height:20px;stroke:#fff;fill:none}',
'#slc-ps .pc-i.blue{background:rgba(59,130,246,.15)}#slc-ps .pc-i.blue svg{stroke:#3b82f6}',
'#slc-ps .pc-i.purple{background:rgba(139,92,246,.15)}#slc-ps .pc-i.purple svg{stroke:#8b5cf6}',
'#slc-ps .pc-i.cyan{background:rgba(6,182,212,.15)}#slc-ps .pc-i.cyan svg{stroke:#06b6d4}',
'#slc-ps .pc-i.green{background:rgba(34,197,94,.15)}#slc-ps .pc-i.green svg{stroke:#22c55e}',
'#slc-ps .pc-i.orange{background:rgba(245,158,11,.15)}#slc-ps .pc-i.orange svg{stroke:#f59e0b}',
'#slc-ps .pc-i.rose{background:rgba(236,72,153,.15)}#slc-ps .pc-i.rose svg{stroke:#ec4899}',
'#slc-ps .pc-t{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}',
'#slc-ps .pc-l{font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.3px}',
'#slc-ps .pc-v{font-size:18px;font-weight:700;color:#f8fafc}',
'#slc-ps .pc-v.ac{color:#ec4899}',
'#slc-pb{display:flex;gap:20px;align-items:flex-start}',
'#slc-pr{flex:1;min-width:0}',
'#slc-pt .pb{height:5px;background:#334155;border-radius:2px;margin-top:3px;overflow:hidden}',
'#slc-pt .pb-f{height:100%;background:#f59e0b;border-radius:2px;transition:width .3s ease}',
'#slc-redeem-c textarea,#slc-redeem-c input[type="text"]{background:rgba(0,0,0,.3);border:1px solid #4a6a7a;border-radius:6px;padding:8px 14px;color:#c7d5e0;font-size:13px;outline:none;transition:all .2s ease;box-sizing:border-box}',
'#slc-redeem-c textarea:focus,#slc-redeem-c input[type="text"]:focus{border-color:#66c0f4;background:rgba(0,0,0,.4);box-shadow:0 0 0 2px rgba(102,192,244,.2)}',
'#slc-redeem-c button.rb{border:none;border-radius:2px;padding:8px 20px;font-size:13px;font-weight:400;cursor:pointer;transition:all .15s ease;outline:none;text-transform:uppercase;letter-spacing:.5px}',
'#slc-redeem-c button.rb:hover{filter:brightness(1.2)}',
'#slc-redeem-c button.rb:active{filter:brightness(.9)}',
'#slc-redeem-c button.rb:disabled{opacity:.4;cursor:not-allowed;filter:none}',
'#slc-redeem-c button.rb1{background:linear-gradient(to right,#47bfff 5%,#1a44c2 60%);color:#fff}',
'#slc-redeem-c button.rb1:hover:not(:disabled){background:linear-gradient(to right,#5ccfff 5%,#2555d5 60%)}',
'#slc-redeem-c button.rb2{background:rgba(0,0,0,.4);color:#8f98a0;border:1px solid rgba(255,255,255,.08)}',
'#slc-redeem-c button.rb2:hover:not(:disabled){background:rgba(0,0,0,.6);color:#c6d4df;border-color:rgba(255,255,255,.15)}',
'.slc-edd{position:relative;display:inline-flex;z-index:999999}',
'.slc-edd .em{display:none;position:absolute;top:100%;right:0;margin-top:4px;background:#0F172A;border:1px solid var(--border-card);border-radius:8px;overflow:hidden;z-index:99999;box-shadow:0 4px 12px rgba(0,0,0,.4);min-width:100px}',
'.slc-edd.open .em{display:block}',
'.slc-edd .em button{display:block;width:100%;padding:8px 16px;background:none;border:none;color:#F1F5F9;font-size:13px;cursor:pointer;text-align:left;white-space:nowrap;transition:background .15s}',
'.slc-edd .em button:hover{background:rgba(59,130,246,.2);color:var(--accent-blue)}',
/* ===== 绝版游戏检查窗口 ===== */
'#slc-delisted-c{max-width:900px;width:90vw;height:650px;display:flex;flex-direction:column}',
'#slc-delisted-body{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}',
'.slc-delisted-header{flex-shrink:0;padding-bottom:8px}',
'.slc-delisted-scroll{flex:1;overflow-y:auto;min-height:0;padding-right:4px;overscroll-behavior:auto}',
'.slc-delisted-kpi-row{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}',
'.slc-delisted-kpi-card{flex:1;min-width:100px;background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:10px 14px;text-align:center;transition:all .2s}',
'.slc-delisted-kpi-card:hover{background:rgba(255,255,255,.06)}',
'.slc-delisted-kpi-card.active{background:rgba(255,255,255,.1);box-shadow:0 0 0 2px rgba(255,255,255,.15) inset}',
'.slc-delisted-kpi-value{font-size:1.3rem;font-weight:700;color:#f8fafc}',
'.slc-delisted-kpi-label{font-size:11px;color:#64748b;margin-top:2px}',
'.slc-delisted-progress{height:5px;background:#334155;border-radius:2px;overflow:hidden;margin-bottom:12px}',
'.slc-delisted-progress-fill{height:100%;background:linear-gradient(90deg,#22c55e,#66c0f4);border-radius:2px;transition:width .3s ease}',
'.slc-delisted-type-row{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}',
'.slc-delisted-type-btn{font-size:11px;padding:4px 10px;border-radius:6px;cursor:pointer;transition:all .2s;font-weight:500;white-space:nowrap}',
'.slc-delisted-type-btn:hover{transform:translateY(-1px);filter:brightness(1.2)}',
'.slc-delisted-type-btn.active{font-weight:700;box-shadow:0 2px 6px rgba(0,0,0,.3)}',
'.slc-delisted-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px}',
'.slc-delisted-toolbar input[type=text]{flex:1;min-width:160px;background:rgba(0,0,0,.3);border:1px solid #4a6a7a;border-radius:20px;padding:6px 14px;color:#c7d5e0;font-size:12px;outline:none;transition:all .2s}',
'.slc-delisted-toolbar input[type=text]:focus{border-color:#66c0f4;box-shadow:0 0 0 2px rgba(102,192,244,.2)}',
'.slc-delisted-toolbar button,.slc-delisted-pagination button{background:rgba(15,23,42,.6)!important;border:1px solid #334155!important;border-radius:6px!important;padding:4px 10px!important;font-size:11px!important;height:28px!important;color:#94a3b8!important;cursor:pointer;transition:all .2s}',
'.slc-delisted-toolbar button:hover,.slc-delisted-pagination button:hover{background:rgba(59,130,246,.2)!important;color:#f1f5f9!important}',
'.slc-delisted-toolbar button.active{background:rgba(102,192,244,.2)!important;border-color:rgba(102,192,244,.4)!important;color:#66c0f4!important}',
'.slc-delisted-toolbar .slc-dlst-sort-group{display:flex;gap:0;border:1px solid #334155;border-radius:6px;overflow:hidden}',
'.slc-delisted-toolbar .slc-dlst-sort-btn{background:rgba(15,23,42,.6)!important;border:none!important;border-right:1px solid #334155!important;border-radius:0!important;padding:4px 10px!important;font-size:11px!important;height:28px!important;color:#94a3b8!important;cursor:pointer;transition:all .2s}',
'.slc-delisted-toolbar .slc-dlst-sort-btn:last-child{border-right:none!important}',
'.slc-delisted-toolbar .slc-dlst-sort-btn:hover{background:rgba(59,130,246,.2)!important;color:#f1f5f9!important}',
'.slc-delisted-toolbar .slc-dlst-sort-btn.active{background:rgba(102,192,244,.2)!important;color:#66c0f4!important}',
'.slc-delisted-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px}',
'.slc-delisted-card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);border-radius:8px;padding:10px 14px;transition:background .2s,border-color .2s;cursor:pointer;position:relative}',
'.slc-delisted-card:hover{background:rgba(255,255,255,.08);border-color:rgba(244,67,54,.3)}',
'.slc-delisted-card-name{font-size:13px;font-weight:600;color:#f1f5f9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}',
'.slc-delisted-card-meta{font-size:11px;color:#64748b;display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
'.slc-delisted-badge{display:inline-block;font-size:10px;font-weight:600;padding:1px 6px;border-radius:3px}',
'.slc-delisted-badge.type-delisted{background:rgba(244,67,54,.2);color:#f44336}',
'.slc-delisted-badge.type-purchase_disabled{background:rgba(255,152,0,.2);color:#ff9800}',
'.slc-delisted-badge.type-f2p_unavailable{background:rgba(156,39,176,.2);color:#ce93d8}',
'.slc-delisted-badge.type-retail_only{background:rgba(33,150,243,.2);color:#64b5f6}',
'.slc-delisted-badge.type-test_app{background:rgba(38,198,218,.2);color:#26c6da}',
'.slc-delisted-card .owned-badge{position:absolute;bottom:6px;right:6px;font-size:10px;padding:2px 6px;border-radius:3px;font-weight:600}',
'.slc-delisted-card .owned-badge.owned{background:rgba(34,197,94,.2);color:#22c55e}',
'.slc-delisted-card .owned-badge.missing{background:rgba(244,67,54,.15);color:#f44336}',
'.slc-delisted-card-achievements,.slc-delisted-card-owners{font-size:10px}',
'.slc-delisted-card-achievements{color:#66c0f4}',
'.slc-delisted-card-owners{color:#94a3b8}',
'.slc-delisted-list{display:flex;flex-direction:column;gap:4px}',
'.slc-delisted-list-item{display:flex;align-items:center;gap:10px;padding:8px 12px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.05);border-radius:6px;cursor:pointer;transition:background .2s,border-color .2s}',
'.slc-delisted-list-item:hover{background:rgba(255,255,255,.06);border-color:rgba(244,67,54,.2)}',
'.slc-delisted-list-item .name{flex:1;font-size:12px;color:#f1f5f9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.slc-delisted-list-item .meta{display:flex;align-items:center;gap:6px;flex-shrink:0}',
'.slc-delisted-list-item.has-cover{gap:12px}',
'.slc-delisted-cover{width:120px;height:45px;border-radius:4px;object-fit:cover;flex-shrink:0;background:rgba(255,255,255,.05)}',
'.slc-delisted-cover-btn{background:rgba(15,23,42,.6)!important;border:1px solid #334155!important;border-radius:6px!important;padding:4px 10px!important;font-size:11px!important;height:28px!important;color:#94a3b8!important;cursor:pointer;transition:all .2s}',
'.slc-delisted-cover-btn:hover{background:rgba(59,130,246,.2)!important;color:#f1f5f9!important}',
'.slc-delisted-cover-btn.active{background:rgba(102,192,244,.2)!important;border-color:rgba(102,192,244,.4)!important;color:#66c0f4!important}',
'.slc-delisted-pagination{display:flex;align-items:center;gap:6px;justify-content:center}',
'.slc-delisted-pagination button:disabled{opacity:.4;cursor:not-allowed}',
'.slc-delisted-pagination .page-info{font-size:12px;color:#64748b}',
'.slc-delisted-loading{text-align:center;padding:40px;color:#64748b;font-size:14px}',
'.slc-delisted-loading .spinner{display:inline-block;width:24px;height:24px;border:3px solid rgba(102,192,244,.2);border-top-color:#66c0f4;border-radius:50%;animation:slc-spin .8s linear infinite;margin-bottom:10px}',
'@keyframes slc-spin{to{transform:rotate(360deg)}}',
/* ===== 确认弹窗 ===== */
'.slc-confirm-modal{position:fixed;inset:0;background:rgba(11,17,32,.85);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:10001;display:flex;align-items:center;justify-content:center;opacity:0;visibility:hidden;transition:opacity .25s ease}',
'.slc-confirm-modal.visible{opacity:1;visibility:visible}',
'.slc-confirm-card{background:var(--bg-card);border:1px solid var(--border-card);border-radius:var(--radius-lg);padding:28px 32px 24px;width:90%;max-width:380px;box-shadow:0 8px 32px rgba(0,0,0,.5);transform:scale(.92);opacity:0;transition:transform .25s ease,opacity .25s ease;text-align:center}',
'.slc-confirm-modal.visible .slc-confirm-card{transform:scale(1);opacity:1}',
'.slc-confirm-icon{width:52px;height:52px;margin:0 auto 16px;border-radius:50%;background:rgba(239,68,68,.15);display:flex;align-items:center;justify-content:center}',
'.slc-confirm-icon svg{width:26px;height:26px;stroke:#ef4444;fill:none}',
'.slc-confirm-msg{font-size:14px;color:var(--text-primary);line-height:1.6;margin-bottom:24px}',
'.slc-confirm-btns{display:flex;gap:10px;justify-content:center}',
'.slc-confirm-cancel{flex:1;padding:9px 0;border-radius:var(--radius-md);font-size:13px;font-weight:500;cursor:pointer;transition:var(--transition);background:rgba(255,255,255,.06);border:1px solid var(--border-card);color:var(--text-secondary)}',
'.slc-confirm-cancel:hover{background:rgba(255,255,255,.12);color:var(--text-primary)}',
'.slc-confirm-ok{flex:1;padding:9px 0;border-radius:var(--radius-md);font-size:13px;font-weight:600;cursor:pointer;transition:var(--transition);background:rgba(239,68,68,.9);border:none;color:#fff}',
'.slc-confirm-ok:hover{background:rgba(220,38,38,.9);transform:translateY(-1px)}'
].join('');
function injectCSS(id, text) {
if (document.getElementById(id)) return;
const s = h('style', { id, text });
document.head.appendChild(s);
disposers.push(() => s.remove());
}
// ==================== 工具函数 ====================
const escHtml = s => String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/\n/g,' ');
const noDataHtml = () => `${escHtml(t('noData'))}
`;
const fmtD = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
function getTb() {
if (state.cachedTb && !state.cachedTb.isConnected) { state.cachedTb = null; startObs(); }
return state.cachedTb || (state.cachedTb = document.querySelector(SEL.tb));
}
function waitEl(sel, ms = 10000) {
return new Promise((res, rej) => {
const el = document.querySelector(sel);
if (el) return res(el);
let done = false;
const fin = e => { if (done) return; done = true; obs.disconnect(); clearTimeout(t); res(e); };
const root = document.querySelector('#page_body') || document.documentElement;
const obs = new MutationObserver(() => { const e = document.querySelector(sel); if (e) fin(e); });
obs.observe(root, { childList: true, subtree: true });
const t = setTimeout(() => { if (!done) { done = true; obs.disconnect(); rej(new Error(`waitEl timeout: ${sel}`)); } }, ms);
});
}
function classify(text) {
const s = text.trim();
for (const [re, id] of CAT_RE) { if (re.test(s)) return id; }
return 'other';
}
const isHdr = row => {
if (row.querySelector('th')) return true;
const d = row.querySelector('td.license_date_col')?.textContent?.trim() || '';
return d.length > 0 && !/\d/.test(d);
};
function dataRows() {
if (state.cachedDataRows) return state.cachedDataRows;
const tb = getTb();
const rows = tb ? Array.from(tb.rows).filter(r => !isHdr(r)) : [];
state.cachedDataRows = rows;
return rows;
}
const invalidateDataRows = () => { state.cachedDataRows = null; };
const MON = { jan:0,feb:1,mar:2,apr:3,may:4,jun:5,jul:6,aug:7,sep:8,oct:9,nov:10,dec:11 };
function parseDate(str) {
const s = str.trim();
const m1 = s.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日/);
if (m1) return new Date(+m1[1],+m1[2]-1,+m1[3]).getTime();
const m2 = s.match(/([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})/);
if (m2) { const mo = MON[m2[1].toLowerCase().slice(0,3)]; if (mo !== undefined) return new Date(+m2[3],mo,+m2[2]).getTime(); }
const m3 = s.match(/(\d{1,2})\s+([A-Za-z]+),?\s+(\d{4})/);
if (m3) { const mo = MON[m3[2].toLowerCase().slice(0,3)]; if (mo !== undefined) return new Date(+m3[3],mo,+m3[1]).getTime(); }
const d = new Date(s); return isNaN(d) ? null : d.getTime();
}
// ==================== DOM Helper ====================
const BOOL_A = new Set(['disabled','checked','selected','readonly','required','hidden','multiple','autofocus','open']);
function h(tag, p = {}, ch = []) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(p)) {
if (v == null) continue;
if (k === 'style' && typeof v === 'object') { for (const [sk, sv] of Object.entries(v)) { sk.startsWith('--') ? el.style.setProperty(sk, sv) : el.style[sk] = sv; } }
else if (k === 'dataset') Object.assign(el.dataset, v);
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'text') el.textContent = v;
else if (k === 'html') el.innerHTML = v;
else if (BOOL_A.has(k)) { v ? el.setAttribute(k, '') : el.removeAttribute(k); }
else el.setAttribute(k, v);
}
for (const c of ch) el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
return el;
}
// ==================== 昵称 ====================
const getNick = () => document.querySelector('#account_pulldown')?.textContent.trim()
|| document.querySelector('#global_header .user_persona')?.textContent.trim() || '';
function updateTitle() {
const nick = getNick();
if (!nick) return;
const hdr = document.querySelector(SEL.hdr);
if (!hdr) return;
const orig = hdr.dataset.origTitle || hdr.textContent.trim();
const title = t('pageTitle').replace('{nickname}', nick);
let span = hdr.querySelector('.page-title-text');
if (!span) {
span = h('span', { class:'page-title-text', text:title });
hdr.textContent = ''; hdr.appendChild(span);
hdr.style.cssText = 'display:flex;align-items:center;justify-content:space-between;width:100%;cursor:pointer';
span.style.cssText = 'max-width:500px;overflow-wrap:break-word';
}
if (!hdr.dataset.titleInit) {
hdr.dataset.titleInit = '1';
const handler = e => {
if (e.target.closest('.search-box')) return;
const show = hdr.dataset.showOrig === '1';
const newText = show ? hdr.dataset.newTitle : hdr.dataset.origTitle;
// 只更新文本节点,保留子元素(如 .slc-load-status)
const firstNode = span.firstChild;
if (firstNode && firstNode.nodeType === Node.TEXT_NODE) {
firstNode.nodeValue = newText;
} else {
span.insertBefore(document.createTextNode(newText), span.firstChild);
}
hdr.dataset.showOrig = show ? '0' : '1';
};
hdr.addEventListener('click', handler);
disposers.push(() => hdr.removeEventListener('click', handler));
} else { span.textContent = title; }
hdr.dataset.origTitle = orig; hdr.dataset.newTitle = title;
document.title = title;
}
function addBreadcrumb() {
const cur = document.querySelector('.blockbg .breadcrumb_current_page');
if (!cur || cur.dataset.done) return;
cur.dataset.done = '1';
const p = cur.parentElement;
p.appendChild(h('span', { class:'breadcrumb_separator', text:'>' }));
p.appendChild(h('a', { href:'https://store.steampowered.com/account/history', target:'_blank', text:t('historyLink'), 'data-panel':'{"noFocusRing":true}' }));
}
// ==================== UI Refs ====================
const ui = {
btns: new Map(), statTotal: null, statCounts: new Map(), statBars: new Map(), statCards: new Map(),
pageTotal: null, prevBtn: null, nextBtn: null, showAllBtn: null, pageInput: null,
searchInput: null,
donutRefs: null, yearRefs: null, yearMonthRefs: null, predictRefs: null,
trendRefs: null, similarRefs: null, resizeObs: null,
trendVisible: null, trendCumulative: true, similarPrefixWords: undefined
};
// ==================== Core Logic ====================
function markRows() {
const tb = getTb();
if (!tb) return;
try {
for (const row of tb.rows) {
if (isHdr(row)) { delete row.dataset.category; delete row.dataset.dateTimestamp; delete row.dataset.searchText; continue; }
// 删除 "未找到许可" 的无效行
if (row.textContent.includes('未找到许可')) { row.remove(); continue; }
if (row.dataset.category) continue;
const dc = row.querySelector('td.license_date_col');
const nc = row.querySelector('td:not(.license_date_col):not(.license_acquisition_col):not(.steamdb_license_id_col)');
const ac = row.querySelector('td.license_acquisition_col');
if (!dc || !nc || !ac) continue;
const raw = dc.textContent.trim();
row.dataset.dateTimestamp = parseDate(raw) ?? 0;
const norm = raw.replace(/\s+/g,' ').trim().replace(/ *([年月日]) */g,'$1');
if (dc.textContent !== norm) dc.textContent = norm;
row.dataset.category = classify(ac.textContent);
// 排除 "移除" 按钮文字后提取游戏名(修复 #7:避免 cloneNode 开销)
const removeLink = nc.querySelector('.free_license_remove_link');
const removeText = removeLink?.textContent?.trim() || '';
const fullText = nc.textContent?.trim() || '';
row.dataset.gameName = removeText ? fullText.replace(removeText, '').trim() : fullText;
row.dataset.searchText = row.dataset.gameName.toLowerCase();
}
} catch (err) { console.error('[SLC] markRows error:', err); }
}
function recalc() {
try {
for (const k of state.counts.keys()) state.counts.set(k, 0);
for (const row of dataRows()) {
const cat = row.dataset.category;
if (cat && state.counts.has(cat)) {
state.counts.set(cat, state.counts.get(cat) + 1);
state.counts.set('total', state.counts.get('total') + 1);
}
}
} catch (err) { console.error('[SLC] recalc error:', err); }
}
function refreshNums(vis, catCnts) {
const filtering = !!state.search || state.dateFrom || state.dateTo || state.noFree;
const total = filtering ? [...catCnts.values()].reduce((s,v)=>s+v,0) : state.counts.get('total');
ui.statTotal.textContent = total;
for (const c of CATS) {
const val = filtering ? (catCnts.get(c.id)||0) : state.counts.get(c.id);
const el = ui.statCounts.get(c.id);
if (el) el.textContent = val;
const bar = ui.statBars.get(c.id);
if (bar) bar.style.width = total ? (val/total*100)+'%' : '0%';
}
const totalBar = ui.statBars.get('all');
if (totalBar) totalBar.style.width = '100%';
}
function lockWidths() {
const d = String(state.counts.get('total')).length;
if (ui.statTotal) ui.statTotal.style.minWidth = `${d}ch`;
for (const [, el] of ui.statCounts) el.style.minWidth = `${Math.max(1,d-1)}ch`;
}
// 加载期间的轻量统计刷新:仅更新分类和数字,不做分页渲染
function refreshLoadingStats() {
if (!ui.statTotal) return; // 面板尚未创建
invalidateDataRows();
markRows();
recalc();
lockWidths();
// 从 state.counts 构建显示用计数,处理排除免费(搜索/日期筛选在最终applyPage时生效)
const catCnts = new Map(CATS.map(c => [c.id, state.noFree && c.id === 'free' ? 0 : state.counts.get(c.id)]));
refreshNums([], catCnts);
}
function applyFilter(ft) {
const tb = getTb(); if (!tb) return;
state.filter = ft;
if (state.filterCls) tb.classList.remove(state.filterCls);
state.filterCls = ft !== 'all' ? `filter-${ft}` : '';
if (state.filterCls) tb.classList.add(state.filterCls);
ui.btns.forEach((b, id) => b.classList.toggle('active', id === ft));
if (ui.statCards) ui.statCards.forEach((el, id) => { const card = el?.closest('.stat-card'); if (card) card.classList.toggle('active', id === ft); });
state.page = 1; applyPage();
}
function sortRows(rows) {
const [f, dir] = state.sortBy.split('-');
return rows.sort((a, b) => {
let c = 0;
if (f === 'date') c = (+a.dataset.dateTimestamp||0) - (+b.dataset.dateTimestamp||0);
else if (f === 'name') c = (a.dataset.gameName||'').localeCompare(b.dataset.gameName||'');
return dir === 'asc' ? c : -c;
});
}
function getVisible() {
const all = dataRows();
const fromT = state.dateFrom ? new Date(state.dateFrom).getTime() : 0;
const toT = state.dateTo ? new Date(state.dateTo).getTime() + CFG.DAY_MS : Infinity;
const sq = state.search;
const fcat = state.filter;
const vis = [], catCnts = new Map(CATS.map(c=>[c.id,0]));
for (const row of all) {
const cat = row.dataset.category;
if (!cat || cat === 'other') continue;
const rd = +row.dataset.dateTimestamp||0;
if (rd && (rd < fromT || rd > toT)) { row.classList.remove('search-match'); continue; }
const match = !sq || (row.dataset.searchText||'').includes(sq);
row.classList.toggle('search-match', match);
if (!match) continue;
if (state.noFree && cat === 'free') { row.classList.remove('search-match'); continue; }
catCnts.set(cat, (catCnts.get(cat)||0)+1);
if (fcat !== 'all' && cat !== fcat) { row.classList.remove('search-match'); continue; }
vis.push(row);
}
if (state.sortBy !== 'date-desc') sortRows(vis);
return { vis, catCnts };
}
function applyPage() {
try {
const tb = getTb(); if (!tb) return;
const { vis, catCnts } = getVisible();
const hasF = !!state.search || state.dateFrom || state.dateTo || state.noFree;
tb.classList.toggle('search-filtered', hasF);
refreshNums(vis, catCnts);
if (state.showAll) {
tb.classList.remove('paginated'); state.totalPages = 1;
for (const r of tb.rows) r.classList.remove('page-visible');
} else {
tb.classList.add('paginated');
const newTotalPages = Math.max(1, Math.ceil(vis.length / CFG.PAGE_SIZE));
state.totalPages = newTotalPages;
// 修复分页同步:当 totalPages 减少时校正页码
if (state.page > state.totalPages) {
state.page = state.totalPages;
}
state.page = Math.max(1, state.page);
const start = (state.page-1)*CFG.PAGE_SIZE, end = start+CFG.PAGE_SIZE;
const set = new Set(vis.slice(start, end));
for (const r of tb.rows) { if (!r.dataset.category) continue; r.classList.toggle('page-visible', set.has(r)); }
}
updatePager();
} catch (err) { console.error('[SLC] applyPage error:', err); }
}
// ==================== 分页 ====================
function createPager() {
const prev = h('button',{class:'pager-btn','data-action':'prev',text:t('prev')});
const inp = h('input',{type:'text',class:'page-input'});
const tot = h('span',{style:{color:'#c7d5e0',minWidth:'30px',display:'inline-block',textAlign:'center'}});
const sep = h('span',{style:{color:'#8a9ba8'},text:' / '});
const info = h('span',{class:'page-info'},[inp,sep,tot]);
const next = h('button',{class:'pager-btn','data-action':'next',text:t('next')});
const show = h('button',{class:'pager-btn','data-action':'showAll',text:t('showAll')});
inp.addEventListener('blur',()=>handlePageInp(inp));
inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();inp.blur();}});
ui.prevBtn=prev; ui.nextBtn=next; ui.showAllBtn=show; ui.pageTotal=tot; ui.pageInput=inp;
return {prev,info,next,show};
}
function handlePageInp(inp) {
const v = parseInt(inp.value,10);
if (!isNaN(v) && v>=1 && v<=state.totalPages && v!==state.page) { state.page=v; applyPage(); }
else inp.value=state.page;
}
function updatePager() {
if (!ui.prevBtn) return;
const ss = document.querySelector('#slc-sort');
if (state.showAll) {
ui.showAllBtn.textContent=t('pagedView'); ui.showAllBtn.classList.add('active-mode');
ui.prevBtn.disabled=ui.nextBtn.disabled=true; ui.pageInput.value=1; ui.pageInput.disabled=true;
if(ss){ss.disabled=true;ss.value='date-desc';state.sortBy='date-desc';}
} else {
ui.showAllBtn.textContent=t('showAll'); ui.showAllBtn.classList.remove('active-mode');
ui.prevBtn.disabled=state.page<=1; ui.nextBtn.disabled=state.page>=state.totalPages;
ui.pageInput.value=state.page; ui.pageInput.disabled=false; if(ss)ss.disabled=false;
}
ui.pageTotal.textContent=state.totalPages;
}
// ==================== Modal Factory ====================
const ICONS = {
donut: ``,
year: ``,
predict: ``,
trend: ``,
similar: ``,
key: ``,
info: ``,
calendarHeatmap: ``,
export: ``,
shield: ``,
clock: ``
};
ICONS['year-month'] = ICONS.year;
function createChartModal(prefix, title, iconBg, noteKey = 'dataNote') {
const modal = document.createElement('div');
modal.id = `slc-${prefix}-modal`; modal.className = 'slc-modal';
const contentId = `slc-${prefix}-c`;
const nav = MODAL_NAV.find(n => n.prefix === prefix);
const navHtml = nav ? buildNavSidebar(prefix) : '';
const modalIcon = nav?.icon || ICONS[prefix] || ICONS.info;
modal.innerHTML = `${navHtml}
${modalIcon}
${escHtml(title)}
${ICONS.info}${escHtml(t(noteKey))}
`;
document.body.appendChild(modal);
const hide = () => modal.classList.remove('visible');
modal.addEventListener('click', e => { if (e.target === modal) hide(); });
modal.querySelector('.slc-mx').addEventListener('click', hide);
// 防止滚轮事件穿透到页面
const mc = modal.querySelector('.slc-mc');
mc.addEventListener('wheel', e => { e.stopPropagation(); }, { passive: true });
// 绑定导航按钮点击事件
modal.querySelectorAll('.slc-nav-btn').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); switchToModal(btn.dataset.navKey); });
});
disposers.push(() => { modal.remove(); });
return { modal, hide, bodyEl: modal.querySelector(`#slc-${prefix}-body`) };
}
const _modalKeys = ['donutRefs','yearRefs','yearMonthRefs','predictRefs','trendRefs','similarRefs','calRefs','delistedRefs'];
// 窗口导航配置
const MODAL_NAV = [
{ key: 'donutRefs', prefix: 'donut', icon: ICONS.donut, titleKey: 'donutTitle', showFn: showDonut, color: '#3b82f6' },
{ key: 'yearRefs', prefix: 'year', icon: ICONS.year, titleKey: 'yearTitle', showFn: showYear, color: '#8b5cf6' },
{ key: 'predictRefs', prefix: 'predict', icon: ICONS.predict, titleKey: 'predictTitle', showFn: showPredict, color: '#f59e0b' },
{ key: 'trendRefs', prefix: 'trend', icon: ICONS.trend, titleKey: 'trendTitle', showFn: showTrend, color: '#3b82f6' },
{ key: 'similarRefs', prefix: 'similar', icon: ICONS.similar, titleKey: 'similarTitle', showFn: showSimilar, color: '#f97316' },
{ key: 'calRefs', prefix: 'cal', icon: ICONS.calendarHeatmap, titleKey: 'calendarTitle', showFn: showCalendarHeatmap, color: '#10b981' },
{ key: 'delistedRefs', prefix: 'delisted', icon: ICONS.shield, titleKey: 'delistedTitle', showFn: showDelisted, color: '#f44336' }
];
// 切换到指定窗口(无动画)
function switchToModal(targetKey) {
const targetNav = MODAL_NAV.find(n => n.key === targetKey);
if (!targetNav) return;
const ref = ui[targetNav.key];
hideAllModals();
if (ref) {
ref.modal.classList.add('visible');
} else {
targetNav.showFn();
// 首次创建:showFn 会立即添加 visible,需要移除后在下一帧重新添加以触发动画
const newRef = ui[targetNav.key];
if (newRef) {
newRef.modal.classList.remove('visible');
requestAnimationFrame(() => newRef.modal.classList.add('visible'));
}
}
}
// 生成导航栏 HTML
function buildNavSidebar(currentPrefix) {
const buttons = MODAL_NAV.map(nav => {
const isActive = nav.prefix === currentPrefix;
const style = isActive ? `background:${nav.color};border-color:transparent;box-shadow:0 2px 8px ${nav.color}40` : '';
return ``;
}).join('');
return ``;
}
function hideAllModals() {
_modalKeys.forEach(k => ui[k]?.modal.classList.remove('visible'));
rs.modal?.classList.remove('visible');
}
function buildGrid(max, pL, pR, pT, cH) {
let grid = '';
for (let i = 0; i <= 5; i++) {
const v = Math.round(max / 5 * i), y = pT + cH - (cH * i / 5);
grid += `${v}`;
}
return grid;
}
function buildStackedSvg({ labels, data, cats, max, svgW, svgH, pL, pR, pT, pB, bW, clickable }) {
const cW = svgW - pL - pR, cH = svgH - pT - pB;
const gap = cW / labels.length;
const grid = buildGrid(max, pL, svgW - pR, pT, cH);
let bars = '';
labels.forEach((label, i) => {
const item = data[i], cx = pL + gap * i + gap / 2;
let sh = 0;
const tot = cats.reduce((s, c) => s + (item.get(c.id) || 0), 0);
let barStack = '';
for (const c of cats) {
const n = item.get(c.id) || 0, ht = max ? (n / max) * cH : 0, y = pT + cH - sh - ht;
barStack += `${label} ${escHtml(c.label)}: ${n} (${tot ? (n / tot * 100).toFixed(1) : 0}%)`;
sh += ht;
}
if (clickable) {
bars += `${barStack}${label}`;
} else {
bars += `${barStack}${label}`;
}
});
const legend = cats.map(c => `${escHtml(c.label)}`).join('');
return `${legend}
`;
}
// ==================== Donut Chart ====================
function showDonut() {
if (!ui.donutRefs) ui.donutRefs = createChartModal('donut', t('donutTitle'), '#3b82f6');
const { modal, bodyEl } = ui.donutRefs;
const cnts=chartCounts(), total=cnts.get('total')||0;
const hash=CATS.map(c=>cnts.get(c.id)).join(',');
if(hash===modal.dataset.hash){modal.classList.add('visible');return;}
modal.dataset.hash=hash;
const cats=chartCats(), data=cats.map(c=>({...c,count:cnts.get(c.id)||0})).filter(d=>d.count>0);
const sz=240,sw=40,r=(sz-sw)/2,C=2*Math.PI*r;
let off=0;
const segs=data.map(d=>{
const dash=(d.count/total)*C;
const s=``;
off+=dash; return s;
}).join('');
const maxCount=Math.max(...data.map(d=>d.count),1);
const barCols=data.map(d=>{
const pct=(d.count/maxCount*100).toFixed(1);
return `${d.count}
${escHtml(d.label)}
`;
}).join('');
bodyEl.innerHTML=`${data.map(d=>`
${escHtml(d.label)}
${d.count} (${(d.count/total*100).toFixed(1)}%)
`).join('')}
`;
modal.classList.add('visible');
}
// ==================== Year Chart ====================
// 构建年度/月度图表数据
function buildYearChartData(timeUnit, filterYear = null) {
const cats = isNoFree() ? CATS.filter(c => c.id !== 'free') : CATS;
const timeMap = new Map();
for (const row of dataRows()) {
const cat = row.dataset.category;
if (!cat || cat === 'other' || (isNoFree() && cat === 'free')) continue;
const ts = +row.dataset.dateTimestamp || 0;
if (!ts) continue;
const d = new Date(ts);
if (filterYear && d.getFullYear() !== filterYear) continue;
const timeKey = timeUnit === 'year' ? d.getFullYear() : d.getMonth() + 1;
if (!timeMap.has(timeKey)) timeMap.set(timeKey, new Map(cats.map(c => [c.id, 0])));
const td = timeMap.get(timeKey);
if (td.has(cat)) td.set(cat, td.get(cat) + 1);
}
return { cats, timeMap };
}
function showYear() {
if (!ui.yearRefs) ui.yearRefs = createChartModal('year', t('yearTitle'), '#8b5cf6');
const { modal, bodyEl } = ui.yearRefs;
const { cats, timeMap } = buildYearChartData('year');
if (!timeMap.size) {
bodyEl.innerHTML = noDataHtml();
modal.classList.add('visible');
return;
}
const times = [...timeMap.keys()].sort((a, b) => a - b);
const maxVal = Math.max(...times.map(t => [...timeMap.get(t).values()].reduce((s, v) => s + v, 0)));
const svgW = 700, svgH = 380, pL = 50, pR = 20, pT = 20, pB = 40;
const bW = Math.min(40, ((svgW - pL - pR) / times.length) * .6);
bodyEl.innerHTML = buildStackedSvg({
labels: times,
data: times.map(t => timeMap.get(t)),
cats,
max: maxVal,
svgW, svgH, pL, pR, pT, pB,
bW,
clickable: true
});
bodyEl.querySelectorAll('.year-bar-group').forEach(g => {
g.addEventListener('click', () => showYearMonthDetail(+g.dataset.year, cats));
});
modal.classList.add('visible');
}
function showYearMonthDetail(year, cats = null) {
if (!ui.yearMonthRefs) ui.yearMonthRefs = createChartModal('year-month', '', '#8b5cf6');
const { modal, bodyEl } = ui.yearMonthRefs;
// 每次打开都更新标题(title 只在 createChartModal 首次调用时写入)
const titleEl = modal.querySelector('.slc-mh-t');
if (titleEl) titleEl.textContent = `${year} ${t('monthlyDistribution')}`;
const { cats: detailCats, timeMap } = buildYearChartData('month', year);
const finalCats = cats || detailCats;
// 确保所有月份都有数据
const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const monthData = months.map(m => {
if (!timeMap.has(m)) {
return new Map(finalCats.map(c => [c.id, 0]));
}
return timeMap.get(m);
});
const maxM = Math.max(...monthData.map(m => [...m.values()].reduce((s, v) => s + v, 0)));
const svgW = 700, svgH = 380, pL = 50, pR = 20, pT = 20, pB = 50;
const bW = Math.min(30, ((svgW - pL - pR) / 12) * .6);
bodyEl.innerHTML = buildStackedSvg({
labels: months.map(m => m + '月'),
data: monthData,
cats: finalCats,
max: maxM,
svgW, svgH, pL, pR, pT, pB,
bW,
clickable: false
});
modal.classList.add('visible');
}
// ==================== 预测 ====================
function buildPredictSum({ rows, udates, apd, avg, lastD, predD }) {
return `
${ICONS.similar}
${escHtml(t('predictTotalItems'))}${rows}
${ICONS.calendarHeatmap}
${escHtml(t('predictTotalGames'))}${udates}
${ICONS.predict}
${escHtml(t('predictAvgPerDay'))}${apd}
${ICONS.clock}
${escHtml(t('predictOverallAvg'))}${avg.toFixed(1)} ${escHtml(t('predictDays'))}
${ICONS.donut}
${escHtml(t('predictLastDate'))}${fmtD(lastD)}
${ICONS.trend}
${escHtml(t('predictNextDate'))}${fmtD(predD)}
`;
}
function buildPredictTable(ym, ycMap, ylMap) {
const yrs = [...ym.keys()].sort((a, b) => b - a);
const tRows = yrs.map(y => {
const info = ym.get(y), a = info.ints.length > 0 ? info.ints.reduce((s, v) => s + v, 0) / info.ints.length : 0;
const cnt = ycMap.get(y) || 0, lic = ylMap.get(y) || 0, daily = cnt > 0 ? (lic / cnt).toFixed(2) : '0.00', pct = (cnt / 365 * 100).toFixed(1);
return `| ${y} | ${lic} | ${cnt} | ${daily} | ${a > 0 ? a.toFixed(1) + ' ' + escHtml(t('predictDays')) : '-'} | |
`;
}).join('');
return `| ${escHtml(t('yearTitle'))} | ${escHtml(t('predictYearLicenses'))} | ${escHtml(t('predictYearCount'))} | ${escHtml(t('predictDailyLicenses'))} | ${escHtml(t('predictYearAvg'))} | ${escHtml(t('predictYearRatio'))} |
${tRows}
`;
}
function showPredict() {
if (!ui.predictRefs) ui.predictRefs = createChartModal('predict', t('predictTitle'), '#f59e0b', 'predictNote');
const { modal, bodyEl } = ui.predictRefs;
const rows = dataRows().filter(r => r.dataset.category && r.dataset.category !== 'free' && +r.dataset.dateTimestamp > 0);
if (rows.length < 2) { bodyEl.innerHTML = noDataHtml(); modal.classList.add('visible'); return; }
const dateSet = new Set(), udates = [];
for (const r of rows) { const ts = +r.dataset.dateTimestamp; if (ts > 0 && !dateSet.has(ts)) { dateSet.add(ts); udates.push(ts); } }
udates.sort((a, b) => a - b);
if (udates.length < 2) { bodyEl.innerHTML = noDataHtml(); modal.classList.add('visible'); return; }
const intervals = [];
for (let i = 1; i < udates.length; i++) intervals.push(Math.round((udates[i] - udates[i - 1]) / CFG.DAY_MS));
const avg = intervals.reduce((s, v) => s + v, 0) / intervals.length;
const ym = new Map();
for (let i = 1; i < udates.length; i++) {
const y = new Date(udates[i]).getFullYear();
if (!ym.has(y)) ym.set(y, { ints: [], cnt: 0 });
ym.get(y).ints.push(Math.round((udates[i] - udates[i - 1]) / CFG.DAY_MS));
ym.get(y).cnt++;
}
const ycMap = new Map(), ylMap = new Map();
for (const ts of udates) { const y = new Date(ts).getFullYear(); ycMap.set(y, (ycMap.get(y) || 0) + 1); }
for (const r of rows) { const ts = +r.dataset.dateTimestamp; if (ts > 0) { const y = new Date(ts).getFullYear(); ylMap.set(y, (ylMap.get(y) || 0) + 1); } }
const lastD = new Date(udates[udates.length - 1]);
const predD = new Date(lastD.getTime() + avg * CFG.DAY_MS);
const apd = udates.length > 0 ? (rows.length / udates.length).toFixed(2) : '0';
bodyEl.innerHTML = `${buildPredictSum({ rows: rows.length, udates: udates.length, apd, avg, lastD, predD })}${buildPredictTable(ym, ycMap, ylMap)}
`;
modal.classList.add('visible');
}
function showTrend() {
if (!ui.trendRefs) ui.trendRefs = createChartModal('trend', t('trendTitle'), '#3b82f6', 'dataNote');
const { modal, bodyEl } = ui.trendRefs;
// 分类颜色(与主分类统一)
const catColors = { all: '#64748b', ...Object.fromEntries(CATS.map(c => [c.id, c.color])) };
const catOrder = CATS.map(c => c.id);
// 初始化可见性(默认不显示总和,只显示4个分类)
if (!ui.trendVisible) ui.trendVisible = new Set(catOrder);
if (ui.trendCumulative === undefined) ui.trendCumulative = true;
const allRows = dataRows().filter(r => r.dataset.category && r.dataset.category !== 'other' && +r.dataset.dateTimestamp > 0);
// 创建图表容器
const chartContainerId = 'slc-trend-chart-container';
bodyEl.innerHTML = `${noDataHtml()}
`;
const chartContainer = bodyEl.querySelector(`#${chartContainerId}`);
// 清理上一次的事件处理器(修复 #2)
if (bodyEl._trendCleanup) { bodyEl._trendCleanup(); bodyEl._trendCleanup = null; }
let trendFilterHandler = null;
let trendCumHandler = null;
function renderTrend() {
const visibleCats = catOrder.filter(c => ui.trendVisible.has(c));
const showAll = ui.trendVisible.has('all');
if (!visibleCats.length && !showAll) { chartContainer.innerHTML = noDataHtml(); return; }
const rows = allRows.filter(r => ui.trendVisible.has(r.dataset.category) || showAll);
if (!rows.length) { chartContainer.innerHTML = noDataHtml(); return; }
// 按月统计每个分类和总和
const emptyMonth = () => ({ all: 0, ...Object.fromEntries(catOrder.map(c => [c, 0])) });
const monthly = new Map();
for (const row of rows) {
const ts = +row.dataset.dateTimestamp;
const d = new Date(ts);
const key = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
if (!monthly.has(key)) monthly.set(key, emptyMonth());
const cat = row.dataset.category;
if (monthly.get(key)[cat] !== undefined) {
monthly.get(key)[cat]++;
monthly.get(key).all++;
}
}
const sorted = [...monthly.entries()].sort((a, b) => a[0].localeCompare(b[0]));
// 累加模式下计算累计值
let data = sorted.map(x => ({ ...x[1] }));
if (ui.trendCumulative) {
const cum = emptyMonth();
data = data.map(d => {
for (const c of ['all', ...catOrder]) cum[c] += d[c];
return { ...cum };
});
}
const max = Math.max(...data.map(x => {
let m = 0;
if (showAll) m = Math.max(m, x.all);
catOrder.forEach(c => { if (ui.trendVisible.has(c)) m = Math.max(m, x[c]); });
return m;
})) || 1;
const svgW = 700, svgH = 320, pL = 50, pR = 20, pT = 20, pB = 60;
const cW = svgW - pL - pR, cH = svgH - pT - pB;
const step = cW / (sorted.length - 1 || 1);
// 生成每条可见曲线
const lineKeys = [...(showAll ? ['all'] : []), ...visibleCats];
const lines = lineKeys.map(cat => {
const points = data.map((item, i) => {
const x = pL + i * step;
const y = pT + cH - (item[cat] / max * cH);
return `${x},${y}`;
}).join(' ');
return ``;
}).join('');
// 网格
const grid = buildGrid(max, pL, svgW - pR, pT, cH);
// 年份标签
let labels = '';
let lastYear = '';
sorted.forEach((item, i) => {
const x = pL + i * step;
const year = item[0].split('-')[0];
if (year !== lastYear) {
labels += `${year}`;
lastYear = year;
}
});
const svg = ``;
chartContainer.innerHTML = `${svg}
`;
}
// 筛选按钮
const filterBtns = document.createElement('div');
filterBtns.setAttribute('data-trend-filter-btns', 'true');
filterBtns.style.cssText = 'display:flex;gap:12px;justify-content:center;margin-bottom:16px;padding:12px;background:rgba(0,0,0,.2);border-radius:8px';
// 累加模式切换开关(内联到筛选按钮行)
const cumToggle = document.createElement('div');
cumToggle.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:16px;padding-left:16px;border-left:1px solid rgba(255,255,255,.15)';
cumToggle.innerHTML = `${t('trendCumulative')}`;
const cumInput = cumToggle.querySelector('#slc-trend-cum');
const cumTrack = cumToggle.querySelector('.tg-track');
const cumThumb = cumTrack.querySelector('span');
cumInput.checked = ui.trendCumulative;
function updateCumStyle() {
const on = ui.trendCumulative;
cumTrack.style.background = on ? 'rgba(59,130,246,.3)' : '#2a475e';
cumTrack.style.borderColor = on ? '#3b82f6' : '#4a6a7a';
cumThumb.style.left = on ? '16px' : '2px';
cumThumb.style.background = on ? '#3b82f6' : '#8a9ba8';
}
updateCumStyle();
trendCumHandler = () => {
ui.trendCumulative = cumInput.checked;
updateCumStyle();
renderTrend();
};
cumInput.addEventListener('change', trendCumHandler);
// 注册清理函数,下次打开时移除旧监听器(修复 #2)
bodyEl._trendCleanup = () => {
cumInput.removeEventListener('change', trendCumHandler);
filterBtns.removeEventListener('click', trendFilterHandler);
};
function updateFilterBtns() {
const allVisible = ui.trendVisible.has('all');
filterBtns.innerHTML = `` + catOrder.map(cat => {
const visible = ui.trendVisible.has(cat);
return ``;
}).join('');
filterBtns.appendChild(cumToggle);
}
updateFilterBtns();
bodyEl.insertBefore(filterBtns, chartContainer);
renderTrend();
// 绑定筛选事件(使用引用以便清理)
trendFilterHandler = e => {
const btn = e.target.closest('button');
if (!btn) return;
const cat = btn.dataset.cat;
if (ui.trendVisible.has(cat)) {
ui.trendVisible.delete(cat);
} else {
ui.trendVisible.add(cat);
}
renderTrend();
updateFilterBtns();
};
filterBtns.addEventListener('click', trendFilterHandler);
modal.classList.add('visible');
}
// ==================== Similar License Guess ====================
function showSimilar() {
if (!ui.similarRefs) ui.similarRefs = createChartModal('similar', t('similarTitle'), '#f97316', 'dataNote');
const { modal, bodyEl } = ui.similarRefs;
// 获取所有非免费游戏名称
const catMap = new Map(CATS.map(c => [c.id, c]));
const rows = dataRows().filter(r => r.dataset.gameName && r.dataset.category && r.dataset.category !== 'other' && !(isNoFree() && r.dataset.category === 'free'));
const names = rows.map(r => {
const cat = catMap.get(r.dataset.category);
return {
name: r.dataset.gameName.trim(),
cat: r.dataset.category,
catLabel: cat?.label || r.dataset.category,
catColor: cat?.color || '#64748b'
};
});
if (names.length < 2) {
bodyEl.innerHTML = noDataHtml();
modal.classList.add('visible');
return;
}
// 前缀单词数选择
if (ui.similarPrefixWords === undefined) ui.similarPrefixWords = 5;
let prefixWords = ui.similarPrefixWords;
const prefixBtns = Array.from({length:10},(_,i)=>i+1).map(n=>``).join('');
const prefixBar = `${t('similarPrefixLen')}:${prefixBtns}
`;
function getWordPrefix(name, count) {
// 按空格/特殊字符分词,取前 count 个单词
const words = name.split(/[\s\-:_,]+/).filter(w => w.length > 0);
return { key: words.slice(0, count).join(' ').toLowerCase(), display: words.slice(0, count).join(' ') };
}
function groupByPrefix(wordCount) {
const groups = new Map();
for (const item of names) {
const { key, display } = getWordPrefix(item.name, wordCount);
const words = item.name.split(/[\s\-:_,]+/).filter(w => w.length > 0);
if (words.length < wordCount) continue;
if (!groups.has(key)) groups.set(key, { display, items: [] });
groups.get(key).items.push(item);
}
return [...groups.entries()].filter(g => g[1].items.length >= 2).sort((a, b) => b[1].items.length - a[1].items.length);
}
function renderSimilar(count) {
const groups = groupByPrefix(count);
if (!groups.length) {
return noDataHtml();
}
const list = groups.map((g, i) => {
const [key, { display, items }] = g;
const itemList = items.map(it => `${escHtml(it.name)}${escHtml(it.catLabel)}
`).join('');
return `${escHtml(display)}${t('similarCount')}: ${items.length} ${itemList}
`;
}).join('');
return `${list}
`;
}
bodyEl.innerHTML = prefixBar + `${renderSimilar(prefixWords)}
`;
// 折叠/展开事件:bodyEl 在多次打开间复用,只绑定一次
if (!bodyEl._similarFoldBound) {
bodyEl._similarFoldBound = true;
bodyEl.addEventListener('click', e => {
const hd = e.target.closest('.slc-similar-hd');
if (!hd) return;
const card = hd.closest('.slc-similar-group');
const bd = card.querySelector('.slc-similar-bd');
const arrow = hd.querySelector('.slc-similar-arrow');
const expanded = bd.style.maxHeight !== '0px';
if (expanded) {
bd.style.maxHeight = '0px';
bd.style.padding = '0 16px';
arrow.style.transform = 'rotate(-90deg)';
} else {
bd.style.maxHeight = bd.scrollHeight + 32 + 'px';
bd.style.padding = '12px 16px';
arrow.style.transform = 'rotate(0deg)';
}
});
}
// 前缀单词数选择事件:old firstElementChild 随 innerHTML 替换被销毁,无需手动解绑
bodyEl.firstElementChild.addEventListener('click', e => {
const btn = e.target.closest('.slc-similar-pbtn');
if (!btn) return;
prefixWords = ui.similarPrefixWords = +btn.dataset.n;
bodyEl.querySelectorAll('.slc-similar-pbtn').forEach(b => {
const active = +b.dataset.n === prefixWords;
b.style.background = active ? 'rgba(249,115,22,.2)' : 'transparent';
b.style.borderColor = active ? '#f97316' : 'rgba(255,255,255,.2)';
b.style.color = active ? '#f97316' : '#8a9ba8';
});
bodyEl.querySelector('#slc-similar-result').innerHTML = renderSimilar(prefixWords);
});
modal.classList.add('visible');
}
// ==================== CDKey Redeem ====================
const FAIL_MAP = {14:'invalidKey',15:'duplicateKey',53:'rateLimit',13:'regionRestrict',9:'alreadyOwned',24:'missingDep',50:'walletCode'};
const SVG_OK='';
const SVG_FAIL='';
const rs = { keyN:0, recvN:0, failSet:new Set(), modal:null, batchT:null, rowMap:new Map(), refs:{}, activeRequests: 0 };
const extractKeys = text => [...new Set((text||'').trim().toUpperCase().match(/([0-9A-Z]{5}-){2,4}[0-9A-Z]{5}/g)||[])];
function showRedeem(){if(!rs.modal)createRedeem();rs.modal.classList.add('visible');}
function redeemDone(){
const{refs:{act:$$a,inp:$$i,stTotal:$$t,stSuccess:$$s,stFail:$$f}}=rs;
const fc=rs.failSet.size,sc=rs.recvN-fc;
if($$t)$$t.textContent=rs.recvN;
if($$s)$$s.textContent=sc;
if($$f)$$f.textContent=fc;
if(rs.recvN>=rs.keyN){
if($$a)$$a.disabled=false;if($$i)$$i.disabled=false;
toast(t('redeemDone').replace('{total}',rs.keyN).replace('{success}',sc).replace('{fail}',fc),fc>0?'error':'success',5000);
}
}
const REDEEM_SVG = {
key: '',
total: '',
ok: '',
fail: ''
};
function createRedeem(){
const modal=document.createElement('div');
modal.id='slc-redeem-modal';modal.className='slc-modal';
modal.innerHTML=`
${REDEEM_SVG.key}
${t('redeemTitle')}
| ${t('indexLabel')} | Key | ${t('resultLabel')} | ${t('detailLabel')} | Sub | ${t('itemLabel')} |
|---|
${REDEEM_SVG.total}
${t('totalLabel')}0
${REDEEM_SVG.ok}
${t('successLabel')}0
${REDEEM_SVG.fail}
${t('failLabel')}0
${ICONS.info}${t('dataNote')}
`;
document.body.appendChild(modal);
const $=s=>modal.querySelector(s);
const $inp=$('#slc-rinp'),$tbody=$('#slc-rtb'),$table=$('#slc-rt'),$act=$('#slc-ra');
const $stTotal=$('#slc-rs-total'),$stSuccess=$('#slc-rs-success'),$stFail=$('#slc-rs-fail');
rs.refs={inp:$inp,tbody:$tbody,table:$table,act:$act,stTotal:$stTotal,stSuccess:$stSuccess,stFail:$stFail};
const hideModal=()=>rs.modal.classList.remove('visible');
modal.addEventListener('click',e=>{if(e.target===modal)hideModal();});
$('.slc-mx').addEventListener('click',hideModal);
$('#slc-rpaste').addEventListener('click',e=>{stopEv(e,()=>navigator.clipboard.readText().then(text=>{const keys=extractKeys(text);$inp.value=keys.reduce((s,k,i)=>s+(i%4===0&&i?'\n':' ')+k,'').trim();toast(keys.length?t('pasteKeyCount').replace('{count}',keys.length):t('pasteNoKey'),keys.length?'success':'error');}).catch(()=>{}));});
$('#slc-rclear').addEventListener('click',e=>{stopEv(e,()=>{$inp.value='';toast(t('inputCleared'),'info');});});
$('#slc-rch').addEventListener('click',e=>{stopEv(e,()=>{$tbody.innerHTML='';$table.style.display='none';$act.disabled=false;$inp.disabled=false;rs.failSet.clear();rs.rowMap.clear();$('#slc-redeem-c').style.removeProperty('max-height');const stats=$('#slc-rstats');if(stats)stats.style.display='none';['slc-rch','slc-rcf','slc-rcs'].forEach(id=>{const b=$('#'+id);if(b)b.style.display='none';});toast(t('clearBtn'),'info');});});
$('#slc-rcf').addEventListener('click',e=>{stopEv(e,()=>{if(rs.failSet.size){GM_setClipboard([...rs.failSet].join(','));toast(`${t('copiedKeys')} (${rs.failSet.size})`,'success');}else{toast(t('noFailedKeys'),'error');}});});
$('#slc-rcs').addEventListener('click',e=>{stopEv(e,()=>{const lines=[...modal.querySelectorAll('#slc-rtb .rs')].map(row=>{const subId=row.querySelector('.rsub code')?.textContent.trim()||'';const gn=row.querySelector('.rgame')?.textContent.trim()||'';return(subId&&subId!=='——')?`${subId}\t${gn}`:'';}).filter(Boolean);if(lines.length){GM_setClipboard(lines.join('\n'));toast(`${t('copiedKeys')} (${lines.length})`,'success');}else{toast(t('noSubData'),'error');}});});
$('#slc-ra').addEventListener('click',e=>{stopEv(e,()=>{const keys=extractKeys($inp.value);if(keys.length)doRedeem(keys);else toast(t('noKeyInput'),'error');});});
rs.modal=modal;
disposers.push(()=>{clearInterval(rs.batchT);modal?.remove();rs.modal=null;});
}
function doRedeem(keys){
const{inp,tbody,table,act}=rs.refs;
const $=s=>rs.modal.querySelector(s);
table.style.display='';act.disabled=true;inp.disabled=true;
$('#slc-redeem-c').style.maxHeight='650px';
['slc-rch','slc-rcf','slc-rcs'].forEach(id=>{const b=$('#'+id);if(b)b.style.display='';});
const stats=$('#slc-rstats');if(stats)stats.style.display='';
rs.keyN=keys.length;rs.recvN=0;rs.failSet.clear();rs.rowMap.clear();rs.activeRequests=0;
keys.forEach((key,i)=>{
const row=document.createElement('tr');row.dataset.key=key;
row.innerHTML=`${i+1} | ${escHtml(key)} | ${escHtml(t('redeeming'))}... | | | | `;
tbody.prepend(row);rs.rowMap.set(key,row);
if(iCFG.REDEEM_MAX)startBatch();
}
function redeemOne(key){
const fail=(detail,rk='networkError')=>updateRow(key,'fail',detail,rk,0,'');
const sessionID = getSessionID();
if (!sessionID) { fail('No session ID', 'othersError'); return; }
rs.activeRequests++;
GM_xmlhttpRequest({
url:'https://store.steampowered.com/account/ajaxregisterkey/',
headers:{'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8',Origin:'https://store.steampowered.com',Referer:'https://store.steampowered.com/account/registerkey'},
data:`product_key=${encodeURIComponent(key)}&sessionid=${encodeURIComponent(sessionID)}`,method:'POST',responseType:'json',timeout:15000,
onload(resp){
rs.activeRequests--;
if(resp.status!==200||!resp.response){fail(t('networkError'));return;}
const d=resp.response,it=d.purchase_receipt_info?.line_items?.[0];
if(d.success===1)updateRow(key,'success',t('successDetail'),'',Number(it?.packageid)||0,it?.line_item_description||'');
else{const rk=FAIL_MAP[d.purchase_result_details]||'othersError';updateRow(key,'fail',t(rk),rk,Number(it?.packageid)||0,it?.line_item_description||'');}
},ontimeout:()=>{rs.activeRequests--;fail(t('networkError'));},
onerror:()=>{rs.activeRequests--;fail(t('networkError'));}
});
}
function updateRow(key,status,detail,rk,subId,subName){
const ok=status==='success',isFail=status==='fail';
if(!ok)rs.failSet.add(key);
rs.recvN++;
const row=rs.rowMap.get(key);if(!row)return;
row.className=ok?'rs':(isFail?'rf':'');
const cells=row.querySelectorAll('td');
cells[2].innerHTML=ok?SVG_OK+t('success'):(isFail?SVG_FAIL+t('fail'):detail);
cells[2].removeAttribute('data-status');cells[2].removeAttribute('style');
cells[3].textContent=detail;
cells[4].innerHTML=subId?`${escHtml(String(subId))}`:'\u2014\u2014';
cells[5].textContent=subName||'\u2014\u2014';
redeemDone();
}
function startBatch(){
clearInterval(rs.batchT);
rs.batchT=setInterval(()=>{
const waiting=rs.modal.querySelectorAll('#slc-rtb tr [data-waiting="true"]');
if(!waiting.length){
// 检查是否还有活跃请求
if (rs.activeRequests === 0) {
clearInterval(rs.batchT);
rs.batchT = null;
}
return;
}
for(const cell of waiting){
if(rs.activeRequests>=CFG.REDEEM_MAX)return;
cell.textContent=`${t('redeeming')}...`;
cell.removeAttribute('data-waiting');
redeemOne(cell.closest('tr').dataset.key);
}
},CFG.REDEEM_WAIT*1000);
}
// ==================== 日历热力图 ====================
const CAL_MONTHS = { zh:['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'], en:['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] };
const CAL_WEEKS = { zh:['日','一','二','三','四','五','六'], en:['Su','Mo','Tu','We','Th','Fr','Sa'] };
function showCalendarHeatmap(){
const rows=dataRows().filter(r=>{
const cat=r.dataset.category;
if(!cat||cat==='other')return false;
if(state.noFree&&cat==='free')return false;
return +r.dataset.dateTimestamp>0;
});
if(!rows.length){toast(t('calendarNoData'),'error');return;}
const dateMap=new Map();
for(const r of rows){
const ts=+r.dataset.dateTimestamp;
const d=new Date(ts);
const key=fmtD(d);
dateMap.set(key,(dateMap.get(key)||0)+1);
}
const dates=[...dateMap.entries()].sort((a,b)=>a[0].localeCompare(b[0]));
const years=[...new Set(dates.map(([d])=>d.slice(0,4)))].sort((a,b)=>b.localeCompare(a));
const maxCount=Math.max(...dateMap.values());
const getColor=v=>{
if(v===0)return'#2a475e';
const p=v/maxCount;
if(p<0.25)return'#0e4429';
if(p<0.5)return'#006d32';
if(p<0.75)return'#26a641';
return'#39d353';
};
const isZh = locale.startsWith('zh');
const monthLabels = isZh ? CAL_MONTHS.zh : CAL_MONTHS.en;
const weekDayLabels = isZh ? CAL_WEEKS.zh : CAL_WEEKS.en;
const buildYear=y=>{
const startDate=new Date(`${y}-01-01`);
const endDate=new Date(`${y}-12-31`);
const startDay=startDate.getDay();
const totalDays=Math.round((endDate-startDate)/CFG.DAY_MS)+1;
let html=`${y}
`;
html+=`
${monthLabels.map(m=>`${m}`).join('')}
`;
html+=`
`;
html+=`
${weekDayLabels.map(d=>`${d}`).join('')}
`;
html+=`
`;
for(let i=0;i
`;
for(let i=0;i
`;
}
html+=`
`;
return html;
};
if (!ui.calRefs) ui.calRefs = createChartModal('cal', t('calendarTitle'), '#10b981');
const { modal, bodyEl } = ui.calRefs;
// hash 缓存:数据未变时跳过重建
const hash = rows.length + '_' + (rows[0]?.dataset.dateTimestamp || '');
if (hash === modal.dataset.hash) { modal.classList.add('visible'); return; }
modal.dataset.hash = hash;
bodyEl.innerHTML = `${years.map(buildYear).join('')}
`;
// 图例放在 bodyEl 外面,不跟随滚动
const legendCls = 'slc-cal-legend';
let legend = bodyEl.nextElementSibling;
if (!legend || !legend.classList.contains(legendCls)) {
bodyEl.insertAdjacentHTML('afterend', `${t('calendarLess')}${t('calendarMore')} `);
}
modal.classList.add('visible');
}
// ==================== 导出 ====================
const _RE_REMOVE = /\s*(移除|Remove|删除|Delete|卸载|Uninstall)\s*/gi;
const _CAT_LABEL_MAP = new Map(CATS.map(c => [c.id, c.label]));
function getExportData(){
const re=_RE_REMOVE;
return dataRows().filter(r=>r.dataset.category&&r.dataset.category!=='other'&&!(state.noFree&&r.dataset.category==='free')).map(r=>({
date:r.querySelector('td.license_date_col')?.textContent.trim()||'',
name:(r.dataset.gameName||'').replace(re,'').trim(),
category:_CAT_LABEL_MAP.get(r.dataset.category)||r.dataset.category,
details:r.querySelector('td.license_acquisition_col')?.textContent.trim()||''
}));
}
function dlBlob(blob,name){
const url=URL.createObjectURL(blob),a=h('a',{href:url,download:name,style:{display:'none'}});
document.body.appendChild(a);a.click();
setTimeout(()=>{a.remove();URL.revokeObjectURL(url);},CFG.BLOB_CLEANUP);
}
function exportData(fmt){
try{
const data=getExportData();
if(!data.length){toast(t('exportNoData'),'error');return;}
const now=new Date(),ts=fmtD(now)+'_'+[now.getHours(),now.getMinutes(),now.getSeconds()].map(v=>String(v).padStart(2,'0')).join('');
if(fmt==='csv'){
const hdr=[t('csvDate'),t('csvName'),t('csvCategory'),t('csvDetails')];
const esc=s=>`"${String(s).replace(/"/g,'""')}"`;
const csv=[hdr.join(','),...data.map(r=>[r.date,r.name,r.category,r.details].map(esc).join(','))].join('\n');
dlBlob(new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8;'}),`steam-licenses-${ts}.csv`);
}else{
dlBlob(new Blob([JSON.stringify(data,null,2)],{type:'application/json;charset=utf-8;'}),`steam-licenses-${ts}.json`);
}
toast(t('exportSuccess'),'success');
}catch(err){console.error(`Export ${fmt} failed:`,err);toast(t('exportFailed')+': '+err.message,'error');}
}
// ==================== Main UI ====================
function addFilterBtns(){
const table=document.querySelector(SEL.tbl);
if(!table||document.querySelector(SEL.fc))return;
const ctn=h('div',{id:'steam-license-filter'});
// ===== 统计卡片区 =====
const statsRow=h('div',{class:'stats-row'});
const allCats=[{id:'all',color:ALL_TYPE.color,label:ALL_TYPE.label,icon:ALL_TYPE.icon},...CATS.map(c=>({id:c.id,color:c.color,label:c.label,icon:c.icon}))];
const statCards=new Map();
const statBars=new Map();
for(const c of allCats){
const card=h('div',{class:'stat-card'+(c.id==='all'?' active':''),'data-filter':c.id,style:{'--card-color':c.color}});
const num=h('div',{class:'stat-number',text:'0'});
const lbl=h('div',{class:'stat-label',html:c.icon+' '+c.label});
const bar=h('div',{class:'stat-bar'});
const barFill=h('div',{class:'stat-bar-fill',style:{width:'0%',background:c.color}});
bar.appendChild(barFill);
card.append(lbl,num,bar);
statsRow.appendChild(card);
statCards.set(c.id,num);
statBars.set(c.id,barFill);
if(c.id==='all') ui.statTotal=num;
else ui.statCounts.set(c.id,num);
// 免费卡片右侧:排除免费开关 + 功能按钮
if(c.id==='free'){
// 排除免费开关(独立容器)
const nfWrap=h('div',{class:'stat-tools',style:{gap:'6px',padding:'0 12px'}});
const nfLabel=h('label',{id:'slc-no-free',html:`${t('withoutFree')}`});
nfWrap.appendChild(nfLabel);
statsRow.appendChild(nfWrap);
// 功能按钮 - 两排布局
const tools=h('div',{class:'stat-tools',style:{flexDirection:'column',alignItems:'flex-start',gap:'4px'}});
const toolRow1=h('div',{style:{display:'flex',alignItems:'center',gap:'4px'}});
const toolRow2=h('div',{style:{display:'flex',alignItems:'center',gap:'4px'}});
const toolBtns=[
{id:'slc-donut-btn',title:`${t('donutTitle')} (D)`,icon:ICONS.donut},
{id:'slc-year-btn',title:`${t('yearTitle')} (Y)`,icon:ICONS.year},
{id:'slc-pred-btn',title:`${t('predictTitle')} (F)`,icon:ICONS.predict},
{id:'slc-trend-btn',title:`${t('trendTitle')} (T)`,icon:ICONS.trend},
{id:'slc-cal-btn',title:`${t('calendarTitle')} (C)`,icon:ICONS.calendarHeatmap},
];
const svg16 = s => s.replace('')});
cacheClrBtn.addEventListener('click',async()=>{
if(!await showConfirm(t('clearCacheConfirm')))return;
try{
localStorage.removeItem('slc_license_cache');
localStorage.removeItem('slc_license_cache_ts');
localStorage.removeItem('slc_license_total');
localStorage.removeItem('slc_license_cache_ver');
await clearRowDB();
}catch(e){console.warn('[SLC] clear cache failed:',e);}
location.reload();
});
toolRow2.appendChild(cacheClrBtn);
tools.append(toolRow1, toolRow2);
statsRow.appendChild(tools);
}
}
ui.statBars=statBars;
ui.statCards=statCards;
ctn.appendChild(statsRow);
// 绑定排除免费和功能按钮事件
const nfCb=ctn.querySelector('#slc-nf');
if(nfCb)nfCb.addEventListener('change',e=>{
state.noFree=e.target.checked;state.page=1;e.target.blur();
// 切换免费卡片禁用状态
const freeCard=statCards.get('free')?.closest('.stat-card');
if(freeCard){
freeCard.classList.toggle('disabled',state.noFree);
if(state.noFree&&state.filter==='free'){state.filter='all';applyFilter('all');}
}
applyPage();
});
const expDdEl=ctn.querySelector('#slc-edd');
const toolActs={'slc-donut-btn':showDonut,'slc-year-btn':showYear,'slc-pred-btn':showPredict,'slc-trend-btn':showTrend,'slc-similar-btn':showSimilar,'slc-cal-btn':showCalendarHeatmap,'slc-redeem-btn':showRedeem,'slc-delisted-btn':showDelisted,'slc-ecsv':()=>{expDdEl.classList.remove('open');exportData('csv');},'slc-ejson':()=>{expDdEl.classList.remove('open');exportData('json');}};
for(const[id,fn]of Object.entries(toolActs)){const el=ctn.querySelector(`#${id}`);if(el)el.addEventListener('click',e=>stopEv(e,fn));}
const ebtnEl=ctn.querySelector('#slc-ebtn');
if(ebtnEl)ebtnEl.addEventListener('click',e=>stopEv(e,()=>expDdEl.classList.toggle('open')));
const closeExpDd=()=>expDdEl?.classList.remove('open');
document.addEventListener('click',closeExpDd);
disposers.push(()=>document.removeEventListener('click',closeExpDd));
// ===== 工具栏 =====
const toolbar=h('div',{class:'toolbar'});
// 搜索框
ui.searchInput=h('input',{type:'text',class:'search-box',placeholder:t('searchPlaceholder')});
ui.searchInput.addEventListener('input',e=>{state.search=e.target.value.trim().toLowerCase();clearTimeout(state.timers.search);state.timers.search=setTimeout(()=>{state.page=1;applyPage();},dataRows().length>1000?300:CFG.DEBOUNCE);});
ui.searchInput.addEventListener('keydown',e=>{if(e.key==='Escape'){ui.searchInput.value='';state.search='';applyPage();}});
ui.searchInput.addEventListener('click',e=>e.stopPropagation());
toolbar.appendChild(ui.searchInput);
// 日期与排序控件
const advGroup=h('div',{class:'adv-group'});
const dfInput=h('input',{type:'date',class:'adv-date',id:'slc-df',value:state.dateFrom,title:t('dateFrom')});
const dtInput=h('input',{type:'date',class:'adv-date',id:'slc-dt',value:state.dateTo,title:t('dateTo')});
const sortSelect=h('select',{class:'adv-sort',id:'slc-sort',title:t('sortBy'),html:``});
const clrBtn=h('button',{class:'adv-clr',id:'slc-clr',title:t('clearFilter'),html:''});
advGroup.append(h('span',{class:'adv-lbl',text:t('dateFrom')}),dfInput,h('span',{class:'adv-lbl',text:t('dateTo')}),dtInput,sortSelect,clrBtn);
toolbar.appendChild(advGroup);
const onDC=k=>e=>{state[k]=e.target.value;state.page=1;applyPage();};
dfInput.addEventListener('change',onDC('dateFrom'));
dtInput.addEventListener('change',onDC('dateTo'));
sortSelect.addEventListener('change',e=>{state.sortBy=e.target.value;applyPage();});
clrBtn.addEventListener('click',()=>{
Object.assign(state,{dateFrom:'',dateTo:'',sortBy:'date-desc',search:'',page:1,noFree:false});
dfInput.value='';dtInput.value='';sortSelect.value='date-desc';
const nfCb=document.querySelector('#slc-nf');if(nfCb)nfCb.checked=false;
const freeCard=statCards.get('free')?.closest('.stat-card');if(freeCard)freeCard.classList.remove('disabled');
if(ui.searchInput)ui.searchInput.value='';applyPage();toast(t('filterCleared'),'info');
});
// 分页控件
const pg=h('div',{class:'pager'});
const{prev,info,next,show}=createPager();
pg.append(prev,info,next,show);
toolbar.appendChild(pg);
ctn.appendChild(toolbar);
// 事件委托
ctn.addEventListener('click',e=>{
// 统计卡片点击筛选
const sc=e.target.closest('.stat-card[data-filter]');
if(sc){
if(sc.classList.contains('disabled')){toast(t('freeDisabled'),'warning');return;}
const fid=sc.dataset.filter;
ui.btns.forEach(b=>b.classList.remove('active'));
const fb=ui.btns.get(fid);
if(fb)fb.classList.add('active');
statCards.forEach((_,id)=>{const card=statCards.get(id)?.closest('.stat-card');if(card)card.classList.toggle('active',id===fid);});
applyFilter(fid);return;
}
const ab=e.target.closest('button[data-action]');
if(!ab||ab.disabled)return;
const act=ab.dataset.action;
if(act==='prev'){state.page=Math.max(1,state.page-1);applyPage();}
else if(act==='next'){state.page=Math.min(state.totalPages,state.page+1);applyPage();}
else if(act==='showAll'){state.showAll=!state.showAll;if(state.showAll)state.page=1;applyPage();}
});
table.parentNode.insertBefore(ctn,table);
// 隐藏原h2中的搜索框(如有)
const hdr=document.querySelector(SEL.hdr);
if(hdr){
const oldSearch=hdr.querySelector('.search-box');
if(oldSearch)oldSearch.style.display='none';
}
// 监听尺寸变化以调整吸顶偏移
const updH=()=>document.documentElement.style.setProperty('--filter-h',ctn.offsetHeight+'px');
updH();ui.resizeObs=new ResizeObserver(updH);ui.resizeObs.observe(ctn);
disposers.push(()=>{ctn.remove();document.documentElement.style.removeProperty('--filter-h');if(ui.searchInput?.parentNode)ui.searchInput.remove();ui.resizeObs?.disconnect();ui.resizeObs=null;});
invalidateDataRows();
requestAnimationFrame(() => {
state.isProcessing = true;
try { markRows(); recalc(); lockWidths(); applyPage(); } finally { state.isProcessing = false; }
startObs();
});
}
// ==================== 本地缓存存储 ====================
const CACHE_KEY = 'slc_license_cache'; // 存储指纹数组 JSON
const CACHE_TS_KEY = 'slc_license_cache_ts'; // 存储时间戳
const CACHE_TOTAL_KEY = 'slc_license_total'; // 缓存时的总记录数
const CACHE_VER_KEY = 'slc_license_cache_ver'; // 缓存版本号
const CACHE_VERSION = 2; // 缓存数据结构版本,升级时递增以自动失效旧缓存
function loadLicenseCache() {
try {
const fingerprints = JSON.parse(localStorage.getItem(CACHE_KEY) || '[]');
const ts = parseInt(localStorage.getItem(CACHE_TS_KEY) || '0');
const total = parseInt(localStorage.getItem(CACHE_TOTAL_KEY) || '0');
const ver = parseInt(localStorage.getItem(CACHE_VER_KEY) || '0');
// 缓存过期、格式非法、或版本不匹配时,视为无缓存
if (Date.now() - ts > CFG.CACHE_TTL || !Array.isArray(fingerprints) || ver !== CACHE_VERSION) {
if (ver && ver !== CACHE_VERSION) console.warn(`[SLC] cache version mismatch (${ver} != ${CACHE_VERSION}), ignoring stale cache`);
return { fps: new Set(), total: 0, ts: 0 };
}
return { fps: new Set(fingerprints), total, ts };
} catch (e) { return { fps: new Set(), total: 0, ts: 0 }; }
}
function saveLicenseCache(fpList, totalCount) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(fpList));
localStorage.setItem(CACHE_TS_KEY, String(Date.now()));
localStorage.setItem(CACHE_TOTAL_KEY, String(totalCount));
localStorage.setItem(CACHE_VER_KEY, String(CACHE_VERSION));
console.log(`[SLC] saved ${fpList.length} fingerprints (${(JSON.stringify(fpList).length / 1024).toFixed(0)}KB) to cache`);
} catch (e) { console.warn('[SLC] save cache failed:', e); }
}
function extractRowFingerprint(row) {
// 从行中提取唯一指纹:日期+名称+类型+包ID
const cells = row.querySelectorAll('td');
if (cells.length < 3) return null;
const date = cells[0]?.textContent?.trim() || '';
const product = cells[1]?.textContent?.trim() || '';
const type = cells[2]?.textContent?.trim() || '';
return `${date}||${product}||${type}`;
}
// ==================== IndexedDB 缓存(存 HTML 行,容量大) ====================
const DB_NAME = 'slc_cache_v1';
const DB_STORE = 'rows';
let _db = null;
async function openRowDB() {
if (_db) return _db;
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onerror = () => reject(req.error);
req.onsuccess = () => { _db = req.result; resolve(_db); };
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(DB_STORE)) {
db.createObjectStore(DB_STORE, { keyPath: 'fp' });
}
};
});
}
async function saveRowsToDB(rows) {
try {
const db = await openRowDB();
const tx = db.transaction(DB_STORE, 'readwrite');
const store = tx.objectStore(DB_STORE);
for (const row of rows) {
if (row.fp) store.put({ fp: row.fp, html: row.html });
}
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
console.log(`[SLC] saved ${rows.length} rows to IndexedDB`);
} catch (e) {
console.warn('[SLC] IndexedDB save failed:', e);
}
}
async function loadRowsFromDB() {
try {
const db = await openRowDB();
const tx = db.transaction(DB_STORE, 'readonly');
const store = tx.objectStore(DB_STORE);
return new Promise((resolve, reject) => {
const req = store.getAll();
req.onsuccess = () => {
const arr = req.result || [];
// 返回 Map,方便按指定顺序查找
resolve(new Map(arr.map(r => [r.fp, r.html])));
};
req.onerror = () => reject(req.error);
});
} catch (e) {
console.warn('[SLC] IndexedDB load failed:', e);
return new Map();
}
}
async function clearRowDB() {
try {
const db = await openRowDB();
const tx = db.transaction(DB_STORE, 'readwrite');
const store = tx.objectStore(DB_STORE);
store.clear();
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
} catch (e) {
console.warn('[SLC] IndexedDB clear failed:', e);
}
}
function createLoadBar() {
const hdr = document.querySelector(SEL.hdr);
if (!hdr) return;
// 进度条
const bar = document.createElement('div');
bar.className = 'slc-load-bar';
bar.id = 'slc-load-bar';
bar.innerHTML = '';
hdr.after(bar);
// 标题文字后方状态提示
const status = h('span', { class:'slc-load-status', id:'slc-load-status', text:t('loadStatusInit') });
const titleSpan = hdr.querySelector('.page-title-text');
if (titleSpan) titleSpan.appendChild(status);
else hdr.appendChild(status);
}
function updateLoadBar(percent, text) {
const fill = document.getElementById('slc-load-bar-fill');
if (fill) fill.style.width = Math.min(100, percent) + '%';
const status = document.getElementById('slc-load-status');
if (status && text != null) status.textContent = text;
}
function removeLoadBar() {
const bar = document.getElementById('slc-load-bar');
if (bar) {
bar.classList.add('hidden');
setTimeout(() => bar.remove(), 300);
}
const status = document.getElementById('slc-load-status');
if (status) status.remove();
}
// ==================== 自动加载全部入库记录 ====================
async function autoLoadAllPages() {
const nextLink = document.querySelector('a.license_paginator_next');
if (!nextLink) {
const oldLoadMore = document.querySelector('#load_more_buttons, .load_more_buttons');
if (oldLoadMore) { await autoLoadOldStyle(); }
return;
}
const url = new URL(location.href);
if (url.searchParams.has('continuationToken') || url.searchParams.has('offset')) {
toast(t('autoLoadingRedirect'), 'info', 3000);
url.searchParams.delete('continuationToken');
url.searchParams.delete('offset');
location.replace(url.toString());
return;
}
const tb = getTb();
if (!tb) return;
const paginatorCtn = document.querySelector('.license_paginator_ctn');
let totalCount = 0;
if (paginatorCtn) {
const spanText = paginatorCtn.querySelector('span')?.textContent || '';
const m = spanText.match(/共\s*(\d+)\s*个/);
if (m) totalCount = parseInt(m[1]);
}
const hasSteamDBCol = !!document.querySelector('th.steamdb_license_id_col');
// 加载本地缓存(只存指纹,不存HTML,避免超限)
const cache = loadLicenseCache();
const hasCache = cache.fps.size > 0;
console.log(`[SLC] cache: ${cache.fps.size} fingerprints, total: ${cache.total}, hasCache: ${hasCache}`);
// 显示加载进度条(header 下方)
createLoadBar();
state.isLoading = true; // 加载期间抑制MutationObserver的applyPage,改用轻量统计刷新
// 有缓存时:从 IndexedDB 恢复HTML到DOM,再增量抓取检查新增
const allFingerprints = new Set(); // 本次加载收集的所有指纹(用于快速查重)
const fpOrderList = []; // 有序指纹数组(新在前、旧在后,用于保存和恢复)
const dbNewRows = []; // 新增行,保存到 IndexedDB
if (hasCache) {
// 1. 从 IndexedDB 恢复全部缓存行到DOM(按指纹原始顺序)
updateLoadBar(5, t('loadStatusCache'));
const cachedMap = await loadRowsFromDB();
// CACHE_KEY 中的指纹数组保持了页面加载顺序(新在前、旧在后)
const fpOrder = JSON.parse(localStorage.getItem(CACHE_KEY) || '[]');
// 先收集有效的指纹和HTML,再批量解析+一次性插入DOM,避免逐行append触发N次重排
const validFps = [];
const htmlParts = [];
for (const fp of fpOrder) {
const html = cachedMap.get(fp);
if (!html) continue;
htmlParts.push(html);
validFps.push(fp);
}
let restored = 0;
let firstCachedRow = null;
if (htmlParts.length > 0) {
// 一次性解析全部行HTML,比逐行 innerHTML 快数倍
const tmp = document.createElement('tbody');
tmp.innerHTML = htmlParts.join('');
const rowsArr = Array.from(tmp.children);
const frag = document.createDocumentFragment();
for (let i = 0; i < validFps.length && i < rowsArr.length; i++) {
const row = rowsArr[i];
if (!row) continue;
frag.appendChild(row);
restored++;
allFingerprints.add(validFps[i]);
fpOrderList.push(validFps[i]);
if (!firstCachedRow) firstCachedRow = row;
}
tb.appendChild(frag); // 单次插入,仅触发一次重排
}
console.log(`[SLC] restored ${restored} rows from IndexedDB`);
refreshLoadingStats(); // 缓存恢复后立即更新统计数字
// 2. 从第一页开始抓取,对比指纹检查新增
let nextUrl2 = nextLink.href;
let pageCount2 = 0;
let fetched2 = 0;
let newCount = 0;
let consecutiveHits = 0;
let staleCachePages = 0; // 连续"整页全为新行"的页数,用于检测缓存失效
let cacheStale = false; // 缓存是否被判定为失效
while (nextUrl2 && pageCount2 < CFG.INCREMENTAL_MAX_PAGES) {
try {
const res = await fetch(nextUrl2);
if (!res.ok) break;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const fetchedTb = doc.querySelector('table.account_table tbody');
if (!fetchedTb) break;
const rows = Array.from(fetchedTb.rows).filter(r => !isHdr(r));
let pageNewCount = 0;
for (const row of rows) {
if (hasSteamDBCol && !row.querySelector('td.steamdb_license_id_col')) {
const methodCell = row.querySelector('td.license_acquisition_col');
if (methodCell) {
const steamdbCell = document.createElement('td');
steamdbCell.className = 'steamdb_license_id_col';
row.insertBefore(steamdbCell, methodCell);
}
}
const fp = extractRowFingerprint(row);
if (fp) allFingerprints.add(fp);
if (fp && !cache.fps.has(fp)) {
// 新增行:插入到缓存行之前(新许可在前,旧许可在后)
tb.insertBefore(row, firstCachedRow);
dbNewRows.push({ fp, html: row.outerHTML });
fpOrderList.unshift(fp); // 新许可插入到有序数组最前面
newCount++;
pageNewCount++;
consecutiveHits = 0;
} else {
consecutiveHits++;
}
}
fetched2 += rows.length;
pageCount2++;
// 连续一整页全部命中缓存且无新增,说明已到缓存末尾
if (pageNewCount === 0 && rows.length > 0 && consecutiveHits >= rows.length) {
console.log(`[SLC] incremental: page ${pageCount2} all cache hits, stopping`);
break;
}
// 兜底:连续多页全部为新行,说明缓存已失效(指纹全不匹配),提前终止避免卡死
if (rows.length > 0 && pageNewCount === rows.length) {
staleCachePages++;
if (staleCachePages >= CFG.STALE_CACHE_THRESHOLD) {
console.warn(`[SLC] incremental: ${staleCachePages} consecutive all-new pages, cache likely stale, stopping`);
cacheStale = true;
break;
}
} else {
staleCachePages = 0;
}
const pct = totalCount ? Math.min((restored + fetched2) / totalCount * 100, 100) : Math.min(pageCount2 * 5, 95);
updateLoadBar(pct, t('loadStatusCheck').replace('{count}', fetched2) + ` [${pageCount2}]`);
refreshLoadingStats(); // 每页加载后实时更新统计数字
const fetchedNext = doc.querySelector('a.license_paginator_next');
nextUrl2 = fetchedNext?.href || null;
await new Promise(r => setTimeout(r, CFG.PAGE_DELAY));
} catch (err) {
console.warn('[SLC] incremental load error:', err);
break;
}
}
// 保存新增的指纹和HTML
if (cacheStale) {
// 缓存已失效,清除旧缓存,下次加载将走全量路径
console.warn('[SLC] cache stale, clearing old cache');
try {
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(CACHE_TS_KEY);
localStorage.removeItem(CACHE_TOTAL_KEY);
localStorage.removeItem(CACHE_VER_KEY);
await clearRowDB();
} catch(e) { console.warn('[SLC] clear stale cache failed:', e); }
toast(t('loadStatusDone') + ' (cache cleared)', 'info', 3000);
} else {
if (dbNewRows.length > 0) {
await saveRowsToDB(dbNewRows);
}
if (allFingerprints.size > 0) {
saveLicenseCache(fpOrderList, restored + fetched2);
}
}
updateLoadBar(100, t('loadStatusDone'));
}
if (!hasCache) {
// 无缓存,正常加载全部分页,同时保存HTML到IndexedDB
let loaded = dataRows().length;
let nextUrl = nextLink.href;
let pageCount = 0;
const maxPages = CFG.MAX_PAGES;
const dbRows = []; // 收集所有行用于IndexedDB缓存
const totalPges = totalCount ? Math.ceil(totalCount / 100) : 0; // 根据许可总数估算总页数
while (nextUrl && pageCount < maxPages) {
try {
const res = await fetch(nextUrl);
if (!res.ok) { console.warn(`[SLC] fetch ${nextUrl} => ${res.status}`); break; }
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const fetchedTb = doc.querySelector('table.account_table tbody');
if (!fetchedTb) break;
const rows = Array.from(fetchedTb.rows).filter(r => !isHdr(r));
const frag = document.createDocumentFragment(); // 批量插入,减少重排
for (const row of rows) {
if (hasSteamDBCol && !row.querySelector('td.steamdb_license_id_col')) {
const methodCell = row.querySelector('td.license_acquisition_col');
if (methodCell) {
const steamdbCell = document.createElement('td');
steamdbCell.className = 'steamdb_license_id_col';
row.insertBefore(steamdbCell, methodCell);
}
}
frag.appendChild(row);
const fp = extractRowFingerprint(row);
if (fp) {
allFingerprints.add(fp);
fpOrderList.push(fp); // 页面顺序即新→旧
dbRows.push({ fp, html: row.outerHTML });
}
}
tb.appendChild(frag); // 单次插入,仅触发一次重排
loaded += rows.length;
pageCount++;
const pct = totalPges ? Math.min(pageCount / totalPges * 100, 100) : Math.min(pageCount * 5, 95);
updateLoadBar(pct, t('loadStatusFetch').replace('{loaded}', loaded).replace('{total}', totalCount || loaded).replace('{page}', pageCount));
refreshLoadingStats(); // 每页加载后实时更新统计数字
const fetchedNext = doc.querySelector('a.license_paginator_next');
nextUrl = fetchedNext?.href || null;
await new Promise(r => setTimeout(r, CFG.PAGE_DELAY));
} catch (err) {
console.error('[SLC] autoLoadAllPages error:', err);
toast(t('autoLoadingError').replace('{msg}', err.message), 'error', 5000);
break;
}
}
// 保存指纹到localStorage,HTML到IndexedDB
if (allFingerprints.size > 0) {
saveLicenseCache(fpOrderList, loaded);
}
if (dbRows.length > 0) {
await saveRowsToDB(dbRows);
}
}
state.isLoading = false; // 加载完成,恢复MutationObserver的正常响应
const paginator = document.querySelector('.license_paginator_ctn');
if (paginator) paginator.style.display = 'none';
// 隐藏进度条
setTimeout(() => {
updateLoadBar(100, t('loadStatusDone'));
setTimeout(() => removeLoadBar(), 300);
}, 300);
const finalCount = dataRows().length;
toast(t('autoLoadingDone').replace('{count}', finalCount), 'success', 3000);
console.log(`[SLC] autoLoadAllPages: loaded ${finalCount} licenses (cache fps: ${cache.fps.size}).`);
}
// 旧版页面自动加载(load_more_buttons 机制)
async function autoLoadOldStyle() {
const tb = getTb();
if (!tb) return;
let lastCount = 0;
let stableCount = 0;
const progressToast = toast(t('autoLoadingLicenses'), 'info', 999999);
while (stableCount < 3) {
const currentCount = tb.rows.length;
if (currentCount === lastCount) { stableCount++; }
else { stableCount = 0; }
lastCount = currentCount;
const btn = document.querySelector('#load_more_buttons') ||
document.querySelector('.load_more_buttons') ||
[...document.querySelectorAll('a, button')].find(el => /load\s*more|加载更多|顯示更多|显示更多/i.test(el.textContent));
if (btn) {
btn.click();
if (progressToast?.el) progressToast.el.textContent = `${t('autoLoadingLicenses')} (${currentCount})`;
await new Promise(r => setTimeout(r, 500));
} else {
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 300));
const newBtn = document.querySelector('#load_more_buttons') || document.querySelector('.load_more_buttons');
if (!newBtn) break;
}
}
if (progressToast?.hide) progressToast.hide();
const finalCount = dataRows().length;
toast(t('autoLoadingDone').replace('{count}', finalCount), 'success', 3000);
console.log(`[SLC] autoLoadOldStyle: loaded ${finalCount} licenses.`);
}
// ==================== 初始化 ====================
// 防重入标记
let _initialized = false;
async function init(){
if (_initialized) return;
_initialized = true;
locale=detectLang();
console.log(`Steam License Classifier v${VER} | lang: ${locale}`);
injectCSS('steam-license-styles', CSS_CRITICAL);
updateTitle();addBreadcrumb();
setTimeout(() => injectCSS('steam-license-styles-deferred', CSS_DEFERRED), 0);
try{
await waitEl(`${SEL.tb} tr td`);
// 先显示主面板(含筛选按钮、搜索框等),用户立即可见
addFilterBtns();
// 再异步加载剩余页面,加载期间统计数字实时更新
await autoLoadAllPages();
// 加载完成后做最终分页渲染
invalidateDataRows();
state.isProcessing = true;
try { markRows(); recalc(); lockWidths(); applyPage(); } finally { state.isProcessing = false; }
}
catch(err){console.warn('Steam License Classifier init failed:',err.message);}
finally{state.isLoading=false;} // 确保无论如何都重置加载状态
}
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function onReady(){document.removeEventListener('DOMContentLoaded',onReady);init();});}
else init();
// BFCache 支持:页面恢复时重新初始化
window.addEventListener('pageshow',e=>{if(e.persisted){_initialized=false;init();}});
window.addEventListener('pagehide',e=>{
if(!e.persisted){disposers.forEach(d=>d());disposers.length=0;state.obs?.disconnect();clearTimeout(state.timers.search);clearTimeout(state.timers.obs);}
});
// ==================== 绝版游戏检查 ====================
// 懒加载:仅在用户点击按钮时才解析,避免阻塞启动
let _delistedDB = null, _delistedApps = null;
function getDelistedApps() {
if (_delistedApps) return _delistedApps;
if (!_delistedDB) {
try { _delistedDB = JSON.parse(GM_getResourceText('delistedData') || '{}'); }
catch(e) { console.warn('[SLC] DELISTED_DB 加载失败:', e); _delistedDB = {}; }
}
let rawApps = [];
if (Array.isArray(_delistedDB.data)) rawApps = _delistedDB.data;
else if (_delistedDB.delisted && Array.isArray(_delistedDB.delisted.apps)) rawApps = _delistedDB.delisted.apps;
_delistedApps = rawApps.map(app => ({
appid: app.appId || app.appid,
name: app.name,
type: app.delistType || app.type || 'Delisted',
changed: app.delistedAt || app.changed,
owners: app.ownerPercentage != null ? app.ownerPercentage + '%' : (app.owners || '-'),
achievements: (app.achievementCount != null && app.achievementCount >= 0) ? app.achievementCount : (app.achievements > 0 ? app.achievements : null)
}));
return _delistedApps;
}
// 统一类型映射(消除 getDelistedTypeKey/getDelistedTypeLabel 中的重复映射)
const _DELISTED_TYPE_MAP = {
'Delisted': { key: 'delisted', labelKey: 'typeDelisted' },
'Purchase disabled': { key: 'purchase_disabled', labelKey: 'typePurchaseDisabled' },
'F2P (unavailable)': { key: 'f2p_unavailable', labelKey: 'typeF2P' },
'Retail only': { key: 'retail_only', labelKey: 'typeRetailOnly' },
'Test app': { key: 'test_app', labelKey: 'typeTestApp' }
};
// 反向映射:key → 原始名
const _KEY_TO_RAW = {};
for (const [raw, v] of Object.entries(_DELISTED_TYPE_MAP)) _KEY_TO_RAW[v.key] = raw;
function getDelistedTypeKey(type) {
if (!type) return 'delisted';
if (type.includes('_')) return type;
return _DELISTED_TYPE_MAP[type]?.key || 'delisted';
}
function getDelistedTypeLabel(type) {
if (!type) return t('typeDelisted');
if (type.includes('_')) return t(_DELISTED_TYPE_MAP[_KEY_TO_RAW[type]]?.labelKey || 'typeDelisted');
return t(_DELISTED_TYPE_MAP[type]?.labelKey || 'typeDelisted');
}
// 拥有状态辅助函数(消除 6 处重复表达式)
const isOwned = (ids, app) => ids.has(Number(app.appid)) || ids.has(String(app.appid));
// 类型颜色常量(模块级,避免每次渲染重建)
const _TYPE_COLORS = { delisted:'#f44336', purchase_disabled:'#ff9800', f2p_unavailable:'#ce93d8', retail_only:'#64b5f6', test_app:'#26c6da' };
const _dlst = { ownedIds: null, state: { search:'', typeFilter:'all', ownershipFilter:'all', page:1, pageSize:60, viewMode:'card', sortBy:'name', sortDir:'asc', showCover:false } };
// 共享 DOM 解析缓存(消除 detectSteamId/getAccessTokenSync 中的重复 getElementById)
let _cachedAppConfig = null;
function getAppConfig() {
if (_cachedAppConfig) return _cachedAppConfig;
try {
const el = document.getElementById('application_config');
if (el) {
_cachedAppConfig = {
userinfo: JSON.parse(el.getAttribute('data-userinfo') || '{}'),
storeConfig: JSON.parse(el.getAttribute('data-store_user_config') || '{}')
};
return _cachedAppConfig;
}
} catch {}
return null;
}
function detectSteamId() {
try { if (unsafeWindow.g_steamID) return unsafeWindow.g_steamID; } catch {}
const cfg = getAppConfig();
if (cfg?.userinfo?.steamid) return String(cfg.userinfo.steamid);
const m = document.documentElement.innerHTML.match(/g_steamID\s*=\s*"(\d{17})"/);
return m ? m[1] : '';
}
let _cachedWebApiToken = null;
function getAccessTokenSync() {
const cfg = getAppConfig();
if (cfg?.storeConfig?.webapi_token) return cfg.storeConfig.webapi_token;
const m = document.documentElement.innerHTML.match(/"webapi_token"\s*:\s*"([^"]+)"/);
return m ? m[1] : null;
}
function fetchWebApiTokenFromStore() {
return new Promise((resolve) => {
if (_cachedWebApiToken) { resolve(_cachedWebApiToken); return; }
GM_xmlhttpRequest({
method: 'GET',
url: 'https://store.steampowered.com/',
timeout: 10000,
onload(resp) {
try {
const html = resp.responseText;
const m1 = html.match(/id="application_config"[^>]*data-store_user_config="([^"]*)"/);
if (m1 && m1[1]) {
const decoded = m1[1].replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
const config = JSON.parse(decoded);
if (config.webapi_token) { _cachedWebApiToken = config.webapi_token; resolve(config.webapi_token); return; }
}
const m2 = html.match(/"webapi_token"\s*:\s*"([^"]+)"/);
if (m2 && m2[1]) { _cachedWebApiToken = m2[1]; resolve(m2[1]); return; }
} catch (e) { /* ignore */ }
resolve(null);
},
onerror() { resolve(null); },
ontimeout() { resolve(null); }
});
});
}
async function getAccessToken() {
if (_cachedWebApiToken) return _cachedWebApiToken;
const syncToken = getAccessTokenSync();
if (syncToken) { _cachedWebApiToken = syncToken; return syncToken; }
return await fetchWebApiTokenFromStore();
}
// 封装 GM_xmlhttpRequest 为 Promise
function gmFetch(url, timeout = 15000) {
return new Promise((res, rej) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout,
onload(r) {
try {
const text = r.responseText || '';
if (!text || text.trim().charAt(0) === '<') { rej(new Error('API 返回非 JSON')); return; }
res(JSON.parse(text));
} catch(e) { rej(e); }
},
onerror() { rej(new Error('网络错误')); },
ontimeout() { rej(new Error('超时')); }
});
});
}
async function fetchOwnedAppIds() {
const steamId = detectSteamId();
if (!steamId) throw new Error('无法检测 SteamID');
const token = await getAccessToken();
if (!token) throw new Error('未获取到 access_token,请确保已登录 Steam 商店');
// 通过 GetOwnedGames 获取个人游戏库
try {
const data = await gmFetch(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?access_token=${token}&steamid=${steamId}&include_appinfo=0&include_played_free_games=1&format=json`);
const apps = data?.response?.games || [];
if (apps.length > 0) return new Set(apps.map(a => a.appid));
} catch(e) {
console.warn('[SLC] GetOwnedGames 获取失败:', e);
}
throw new Error('无法获取游戏库,请确保已登录 Steam 且个人资料公开');
}
function getFilteredDelistedApps(allApps, ds, ownedIds) {
let apps = allApps;
if (ds.typeFilter !== 'all') apps = apps.filter(a => getDelistedTypeKey(a.type) === ds.typeFilter);
if (ds.ownershipFilter === 'owned') apps = apps.filter(a => isOwned(ownedIds, a));
else if (ds.ownershipFilter === 'missing') apps = apps.filter(a => !isOwned(ownedIds, a));
if (ds.search) {
const q = ds.search.toLowerCase();
apps = apps.filter(a => (a.name||'').toLowerCase().includes(q) || String(a.appid).includes(q));
}
// 排序
const dir = ds.sortDir === 'asc' ? 1 : -1;
apps = [...apps].sort((a, b) => {
if (ds.sortBy === 'name') {
return dir * (a.name || '').localeCompare(b.name || '', 'zh-CN');
} else if (ds.sortBy === 'date') {
const da = a.changed || '', db = b.changed || '';
return dir * da.localeCompare(db);
} else if (ds.sortBy === 'rarity') {
const pa = parseFloat(a.owners) || 0, pb = parseFloat(b.owners) || 0;
return dir * (pa - pb);
}
return 0;
});
return apps;
}
function renderDelistedContent(bodyEl, allApps, ownedIds) {
const ds = _dlst.state;
const apps = getFilteredDelistedApps(allApps, ds, ownedIds);
const totalDelisted = allApps.length;
// Type distribution + 拥有统计(单次遍历)
const typeCounts = {}, typeOwned = {};
allApps.forEach(app => {
const key = getDelistedTypeKey(app.type);
typeCounts[key] = (typeCounts[key] || 0) + 1;
if (isOwned(ownedIds, app)) typeOwned[key] = (typeOwned[key] || 0) + 1;
});
const ownedDelisted = Object.values(typeOwned).reduce((s, v) => s + v, 0);
const missingDelisted = totalDelisted - ownedDelisted;
const delistedPct = totalDelisted > 0 ? (ownedDelisted / totalDelisted * 100).toFixed(3) : '0.000';
let html = '';
// 固定头部区域:KPI + 进度条 + 类型筛选 + 搜索
html += ''; // end header
// 可滚动内容区域
html += '' + buildDelistedScroll(apps, ds, ownedIds) + '
';
bodyEl.innerHTML = html;
// Event bindings
const searchEl = bodyEl.querySelector('#slc-dlst-search');
if (searchEl) {
let searchTimer;
searchEl.addEventListener('input', e => {
ds.search = e.target.value;
ds.page = 1;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => updateDelistedScroll(bodyEl, allApps, ownedIds), 200);
});
}
// 事件委托:统一处理所有点击事件
bodyEl.addEventListener('click', e => {
const target = e.target.closest('[data-ownership], .slc-delisted-type-btn, #slc-dlst-card, #slc-dlst-list, #slc-dlst-cover, .slc-dlst-sort-btn');
if (!target || !bodyEl.contains(target)) return;
// KPI 卡片:拥有状态筛选
if (target.dataset.ownership) {
ds.ownershipFilter = target.dataset.ownership;
ds.page = 1;
updateDelistedScroll(bodyEl, allApps, ownedIds);
return;
}
// 类型筛选按钮
if (target.classList.contains('slc-delisted-type-btn')) {
ds.typeFilter = target.dataset.type;
ds.page = 1;
bodyEl.querySelectorAll('.slc-delisted-type-btn').forEach(b => {
const c = b.dataset.color;
const isActive = b.dataset.type === ds.typeFilter;
b.classList.toggle('active', isActive);
b.style.background = isActive ? c+'33' : 'rgba(255,255,255,.05)';
b.style.color = isActive ? c : '#94a3b8';
b.style.border = `1px solid ${isActive ? c+'66' : 'rgba(255,255,255,.1)'}`;
b.style.fontWeight = isActive ? '700' : '500';
b.style.boxShadow = isActive ? '0 2px 6px rgba(0,0,0,.3)' : 'none';
});
updateDelistedScroll(bodyEl, allApps, ownedIds);
return;
}
// 视图切换
if (target.id === 'slc-dlst-card') { ds.viewMode = 'card'; updateDelistedScroll(bodyEl, allApps, ownedIds); return; }
if (target.id === 'slc-dlst-list') { ds.viewMode = 'list'; updateDelistedScroll(bodyEl, allApps, ownedIds); return; }
// 封面按钮
if (target.id === 'slc-dlst-cover') { ds.showCover = !ds.showCover; updateDelistedScroll(bodyEl, allApps, ownedIds); return; }
// 排序按钮
if (target.classList.contains('slc-dlst-sort-btn')) {
const sortBy = target.dataset.sort;
if (ds.sortBy === sortBy) {
ds.sortDir = ds.sortDir === 'asc' ? 'desc' : 'asc';
} else {
ds.sortBy = sortBy;
ds.sortDir = 'asc';
}
ds.page = 1;
updateDelistedScroll(bodyEl, allApps, ownedIds);
}
});
// KPI 卡片鼠标样式
bodyEl.querySelectorAll('.slc-delisted-kpi-card[data-ownership]').forEach(card => { card.style.cursor = 'pointer'; });
}
// 构建游戏元数据 HTML(卡片/列表共用)
function buildDelistedMeta(app, typeKey) {
return `${getDelistedTypeLabel(app.type)}
${app.owners || '-'}
${app.changed ? `${app.changed}` : ''}
${app.achievements ? `🏆 ${app.achievements}` : ''}`;
}
function buildDelistedScroll(apps, ds, ownedIds) {
const totalPages = Math.max(1, Math.ceil(apps.length / ds.pageSize));
if (ds.page > totalPages) ds.page = totalPages;
const start = (ds.page - 1) * ds.pageSize;
const pageApps = apps.slice(start, start + ds.pageSize);
if (ds.viewMode === 'card') {
return `${pageApps.map(app => {
const owned = isOwned(ownedIds, app);
const typeKey = getDelistedTypeKey(app.type);
return `
${owned ? '✓ ' + t('delistedOwned') : t('delistedMissing')}
${app.name || 'AppID: '+app.appid}
${buildDelistedMeta(app, typeKey)}
`;
}).join('')}
`;
}
return `${pageApps.map(app => {
const owned = isOwned(ownedIds, app);
const typeKey = getDelistedTypeKey(app.type);
const coverUrl = `https://cdn.akamai.steamstatic.com/steam/apps/${app.appid}/header_292x136.jpg`;
return `
${ds.showCover ? `

` : ''}
${app.name || 'AppID: '+app.appid}
${buildDelistedMeta(app, typeKey)}
${owned ? '✓ ' + t('delistedOwned') : t('delistedMissing')}
`;
}).join('')}
`;
}
// 仅更新滚动区域(不重建头部,避免搜索框闪烁)
function updateDelistedScroll(bodyEl, allApps, ownedIds) {
const ds = _dlst.state;
const apps = getFilteredDelistedApps(allApps, ds, ownedIds);
const totalPages = Math.max(1, Math.ceil(apps.length / ds.pageSize));
if (ds.page > totalPages) ds.page = totalPages;
const scrollEl = bodyEl.querySelector('.slc-delisted-scroll');
if (scrollEl) scrollEl.innerHTML = buildDelistedScroll(apps, ds, ownedIds);
// 更新分页按钮状态
const prevBtn = bodyEl.querySelector('#slc-dlst-prev');
const nextBtn = bodyEl.querySelector('#slc-dlst-next');
const pageInfo = bodyEl.querySelector('.slc-delisted-pagination .page-info');
if (prevBtn) prevBtn.disabled = ds.page <= 1;
if (nextBtn) nextBtn.disabled = ds.page >= totalPages;
if (pageInfo) pageInfo.textContent = `${ds.page}/${totalPages} (${apps.length})`;
// 绑定分页按钮事件
if (prevBtn) prevBtn.onclick = () => { ds.page--; updateDelistedScroll(bodyEl, allApps, ownedIds); };
if (nextBtn) nextBtn.onclick = () => { ds.page++; updateDelistedScroll(bodyEl, allApps, ownedIds); };
// 更新视图模式按钮高亮
const cardBtn = bodyEl.querySelector('#slc-dlst-card');
const listBtn = bodyEl.querySelector('#slc-dlst-list');
const coverBtn = bodyEl.querySelector('#slc-dlst-cover');
if (cardBtn) cardBtn.classList.toggle('active', ds.viewMode === 'card');
if (listBtn) listBtn.classList.toggle('active', ds.viewMode === 'list');
if (coverBtn) coverBtn.classList.toggle('active', ds.showCover);
// 更新排序按钮高亮和箭头
const sortIcon = ds.sortDir === 'asc' ? '↑' : '↓';
const sortLabels = { name: t('delistedCardName') || '名称', date: t('delistedDate') || '日期', rarity: t('delistedOwners') || '稀有' };
bodyEl.querySelectorAll('.slc-dlst-sort-btn').forEach(btn => {
const sortBy = btn.dataset.sort;
const isActive = sortBy === ds.sortBy;
btn.classList.toggle('active', isActive);
btn.textContent = sortLabels[sortBy] + (isActive ? ' ' + sortIcon : '');
});
// 更新 KPI 卡片高亮
bodyEl.querySelectorAll('.slc-delisted-kpi-card[data-ownership]').forEach(card => {
card.classList.toggle('active', card.dataset.ownership === ds.ownershipFilter);
});
}
async function showDelisted() {
const allApps = getDelistedApps();
if (!allApps.length) { toast(t('delistedNoData'), 'error'); return; }
let refs = ui.delistedRefs;
if (!refs) {
refs = ui.delistedRefs = createChartModal('delisted', t('delistedTitle'), '#f44336', 'dataNote');
}
const { modal, bodyEl } = refs;
hideAllModals();
if (_dlst.ownedIds) {
renderDelistedContent(bodyEl, allApps, _dlst.ownedIds);
modal.classList.add('visible');
return;
}
// Loading state
bodyEl.innerHTML = ``;
modal.classList.add('visible');
try {
_dlst.ownedIds = await fetchOwnedAppIds();
toast(t('delistedLoaded') + ` (${_dlst.ownedIds.size})`, 'success');
renderDelistedContent(bodyEl, allApps, _dlst.ownedIds);
} catch(e) {
console.error('[SLC] 获取游戏库失败:', e);
bodyEl.innerHTML = `${t('delistedFetchFail')}: ${e.message}
`;
}
}
// ==================== Keyboard Shortcuts ====================
document.addEventListener('keydown',e=>{
if(e.target.matches('input,select,textarea')||e.ctrlKey||e.altKey||e.metaKey){
if(e.key==='Escape'&&e.target.matches('input,select,textarea'))e.target.blur();
return;
}
if(e.key==='Escape'){hideAllModals();return;}
switch(e.key){
case'/':e.preventDefault();ui.searchInput?.focus();break;
case'ArrowLeft':if(state.page>1){state.page--;applyPage();}break;
case'ArrowRight':if(state.page{
const hasNew=muts.some(m=>Array.from(m.addedNodes).some(n=>n.nodeType===1&&n.nodeName==='TR'));
if(hasNew){clearTimeout(state.timers.obs);state.timers.obs=setTimeout(()=>{
if(state.isProcessing||state.isLoading)return; // 加载期间由refreshLoadingStats接管
invalidateDataRows();
markRows();recalc();lockWidths();applyPage();
},CFG.OBS_DEBOUNCE);}
});
state.obs.observe(tb,{childList:true});
disposers.push(()=>{state.obs?.disconnect();state.obs=null;clearTimeout(state.timers.obs);state.timers.obs=null;});
}
})();